diff --git a/Examples/IOSUploaderDemo/README.md b/Examples/IOSUploaderDemo/README.md deleted file mode 100644 index 69c0e1b..0000000 --- a/Examples/IOSUploaderDemo/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# IOSUploaderDemo - -此目录用于承载后续 iOS Simulator smoke 与宿主接入示例。 - -当前首版未提交完整独立 Demo App 工程,但已补齐可执行的 simulator smoke 脚本入口,以下目录仍承担这些用途: - -1. 作为 `DataGatewayClient` 的最小接入样例。 -2. 作为 iOS Simulator smoke 的承载目录。 -3. 用于验证前后台切换、强杀恢复、断网恢复等端侧专项场景。 - -建议后续示例工程至少包含: - -1. 使用 `DataGatewayClientConfig.recommended(...)` 初始化 SDK。 -2. 使用 `ArchebaseDeviceInitializer.initDevice(deviceID:)` 写入 `archebase-config.json`。 -3. 使用 `ArchebaseDeviceInitializer.reinitDevice(deviceID:)` 显式轮换配置。 -4. 使用 `DataGatewayClient.fromArchebaseConfig(...)` 从配置文件构造上传 client。 -5. 支持选择文件并发起 `upload(_:)`。 -6. 支持订阅 `uploadEvents(_:)` 并显示阶段变化。 -7. 支持列出 `listPendingUploads()` 返回的本地待恢复任务。 -8. 支持调用 `resumeUpload(logicalUploadID:)`、`abortUpload(logicalUploadID:)`、`deleteLocalSnapshot(logicalUploadID:)`。 - -建议从 package 根目录执行模拟器 smoke 命令: - -```bash -cd data-sdk -export DGW_LOCAL_AUTH_ENDPOINT='http://127.0.0.1:15055' -export DGW_LOCAL_GATEWAY_ENDPOINT='http://127.0.0.1:15053' -export DGW_LOCAL_INIT_ENDPOINT='http://127.0.0.1:15057' -export DGW_LOCAL_CREDENTIAL_BASE64='' -export DGW_LOCAL_DEVICE_ID='' -./Scripts/simulator_smoke.sh -``` - -该脚本默认执行 `SwiftDataGatewayClient-Package` scheme 上的本地联调 smoke,并显式启用 `xcodebuild` 测试超时: - -1. `DGW_IOS_SMOKE_DESTINATION_TIMEOUT_SECONDS`,默认 `30` -2. `DGW_IOS_SMOKE_DEFAULT_TEST_TIMEOUT_SECONDS`,默认 `120` -3. `DGW_IOS_SMOKE_MAX_TEST_TIMEOUT_SECONDS`,默认 `300` -4. `DGW_IOS_SMOKE_TEST_ONE` / `DGW_IOS_SMOKE_TEST_TWO` 默认指向 `LocalStackHarnessTests` 两个 smoke case,`DGW_IOS_SMOKE_TEST_THREE` 默认指向 device initializer 配置写入 smoke,且测试标识必须带 `()` -5. `DGW_IOS_SMOKE_DERIVED_DATA_PATH` 可覆盖 `build-for-testing` 产物目录;脚本会自动 patch `.xctestrun`,确保 simulator 宿主进程拿到 `DGW_LOCAL_*` 环境变量 - -当前环境如缺少可用 iOS Simulator runtime / CoreSimulator 组件,脚本会在前置检查阶段直接失败,而不是无限等待。 diff --git a/Examples/dp-simulator/dp-simulator.xcodeproj/project.pbxproj b/Examples/dp-simulator/dp-simulator.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6a316e5 --- /dev/null +++ b/Examples/dp-simulator/dp-simulator.xcodeproj/project.pbxproj @@ -0,0 +1,375 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + AE7DB8802FAD8A4B00B4CF49 /* DataGatewayClient in Frameworks */ = {isa = PBXBuildFile; productRef = AE7DB8832FAD8A4B00B4CF49 /* DataGatewayClient */; }; + AE7DB8812FAD8A4B00B4CF49 /* DGWControlPlane in Frameworks */ = {isa = PBXBuildFile; productRef = AE7DB8842FAD8A4B00B4CF49 /* DGWControlPlane */; }; + AE7DB8822FAD8A4B00B4CF49 /* DGWStore in Frameworks */ = {isa = PBXBuildFile; productRef = AE7DB8852FAD8A4B00B4CF49 /* DGWStore */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AE7DB8702FAD713E00B4CF49 /* dp-simulator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "dp-simulator.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AE7DB8722FAD713E00B4CF49 /* dp-simulator */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "dp-simulator"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + AE7DB86D2FAD713E00B4CF49 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AE7DB8802FAD8A4B00B4CF49 /* DataGatewayClient in Frameworks */, + AE7DB8812FAD8A4B00B4CF49 /* DGWControlPlane in Frameworks */, + AE7DB8822FAD8A4B00B4CF49 /* DGWStore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AE7DB8672FAD713E00B4CF49 = { + isa = PBXGroup; + children = ( + AE7DB8722FAD713E00B4CF49 /* dp-simulator */, + AE7DB8712FAD713E00B4CF49 /* Products */, + ); + sourceTree = ""; + }; + AE7DB8712FAD713E00B4CF49 /* Products */ = { + isa = PBXGroup; + children = ( + AE7DB8702FAD713E00B4CF49 /* dp-simulator.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AE7DB86F2FAD713E00B4CF49 /* dp-simulator */ = { + isa = PBXNativeTarget; + buildConfigurationList = AE7DB87D2FAD713F00B4CF49 /* Build configuration list for PBXNativeTarget "dp-simulator" */; + buildPhases = ( + AE7DB86C2FAD713E00B4CF49 /* Sources */, + AE7DB86D2FAD713E00B4CF49 /* Frameworks */, + AE7DB86E2FAD713E00B4CF49 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AE7DB8722FAD713E00B4CF49 /* dp-simulator */, + ); + name = "dp-simulator"; + packageProductDependencies = ( + AE7DB8832FAD8A4B00B4CF49 /* DataGatewayClient */, + AE7DB8842FAD8A4B00B4CF49 /* DGWControlPlane */, + AE7DB8852FAD8A4B00B4CF49 /* DGWStore */, + ); + productName = "dp-simulator"; + productReference = AE7DB8702FAD713E00B4CF49 /* dp-simulator.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AE7DB8682FAD713E00B4CF49 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + AE7DB86F2FAD713E00B4CF49 = { + CreatedOnToolsVersion = 26.4.1; + }; + }; + }; + buildConfigurationList = AE7DB86B2FAD713E00B4CF49 /* Build configuration list for PBXProject "dp-simulator" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AE7DB8672FAD713E00B4CF49; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../data-sdk" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = AE7DB8712FAD713E00B4CF49 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AE7DB86F2FAD713E00B4CF49 /* dp-simulator */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AE7DB86E2FAD713E00B4CF49 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AE7DB86C2FAD713E00B4CF49 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AE7DB87B2FAD713F00B4CF49 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AE7DB87C2FAD713F00B4CF49 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AE7DB87E2FAD713F00B4CF49 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 67DZ4DM6P7; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.archebase.dp-simulator"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AE7DB87F2FAD713F00B4CF49 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 67DZ4DM6P7; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.archebase.dp-simulator"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AE7DB86B2FAD713E00B4CF49 /* Build configuration list for PBXProject "dp-simulator" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE7DB87B2FAD713F00B4CF49 /* Debug */, + AE7DB87C2FAD713F00B4CF49 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AE7DB87D2FAD713F00B4CF49 /* Build configuration list for PBXNativeTarget "dp-simulator" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE7DB87E2FAD713F00B4CF49 /* Debug */, + AE7DB87F2FAD713F00B4CF49 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../data-sdk" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../data-sdk"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AE7DB8832FAD8A4B00B4CF49 /* DataGatewayClient */ = { + isa = XCSwiftPackageProductDependency; + package = AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../data-sdk" */; + productName = DataGatewayClient; + }; + AE7DB8842FAD8A4B00B4CF49 /* DGWControlPlane */ = { + isa = XCSwiftPackageProductDependency; + package = AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../data-sdk" */; + productName = DGWControlPlane; + }; + AE7DB8852FAD8A4B00B4CF49 /* DGWStore */ = { + isa = XCSwiftPackageProductDependency; + package = AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../data-sdk" */; + productName = DGWStore; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = AE7DB8682FAD713E00B4CF49 /* Project object */; +} diff --git a/Examples/dp-simulator/dp-simulator.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/dp-simulator/dp-simulator.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/dp-simulator/dp-simulator.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/dp-simulator/dp-simulator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/dp-simulator/dp-simulator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..06f03bb --- /dev/null +++ b/Examples/dp-simulator/dp-simulator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,222 @@ +{ + "originHash" : "dcceab5a796629740a4c2e6abe30c4a3c6d01bc09516730ba0881239f9c7caa3", + "pins" : [ + { + "identity" : "alibabacloud-oss-swift-sdk-v2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aliyun/alibabacloud-oss-swift-sdk-v2.git", + "state" : { + "revision" : "f9583dda9ea06e7e592739446e983507497a8094", + "version" : "0.2.0" + } + }, + { + "identity" : "grpc-swift-2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-2.git", + "state" : { + "revision" : "d19a94824cc6fa182211e8197ce391ff712b69f1", + "version" : "2.4.0" + } + }, + { + "identity" : "grpc-swift-nio-transport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", + "state" : { + "revision" : "f62a09000685b5b86ee383b63e042f286b1a5422", + "version" : "2.7.0" + } + }, + { + "identity" : "grpc-swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-protobuf.git", + "state" : { + "revision" : "8723cf856dc23d9c2fad4d874e7b9ed3254acf03", + "version" : "2.3.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "81558271e243f8f47dfe8e9fdd55f3c2b5413f68", + "version" : "1.37.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "xmlcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CoreOffice/XMLCoder.git", + "state" : { + "revision" : "b2b5d72345bab9e1938a483cf862b498aeed3796", + "version" : "0.18.1" + } + } + ], + "version" : 3 +} diff --git a/Examples/dp-simulator/dp-simulator.xcodeproj/xcuserdata/pfwang80s.xcuserdatad/xcschemes/xcschememanagement.plist b/Examples/dp-simulator/dp-simulator.xcodeproj/xcuserdata/pfwang80s.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..1ce7656 --- /dev/null +++ b/Examples/dp-simulator/dp-simulator.xcodeproj/xcuserdata/pfwang80s.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + dp-simulator.xcscheme_^#shared#^_ + + orderHint + 1 + + + + diff --git a/Examples/dp-simulator/dp-simulator/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/dp-simulator/dp-simulator/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/dp-simulator/dp-simulator/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/dp-simulator/dp-simulator/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/dp-simulator/dp-simulator/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/dp-simulator/dp-simulator/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/dp-simulator/dp-simulator/Assets.xcassets/Contents.json b/Examples/dp-simulator/dp-simulator/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/dp-simulator/dp-simulator/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/dp-simulator/dp-simulator/ContentView.swift b/Examples/dp-simulator/dp-simulator/ContentView.swift new file mode 100644 index 0000000..dfe469a --- /dev/null +++ b/Examples/dp-simulator/dp-simulator/ContentView.swift @@ -0,0 +1,513 @@ +import DataGatewayClient +import DGWStore +import SwiftUI +import UniformTypeIdentifiers + +struct ContentView: View { + @StateObject private var viewModel = UploadDemoViewModel() + @State private var isFileImporterPresented = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 18) { + StatusPanel(viewModel: viewModel) + EndpointsSection(viewModel: viewModel) + DeviceSection(viewModel: viewModel) + UploadSection( + viewModel: viewModel, + presentFileImporter: { isFileImporterPresented = true } + ) + PendingUploadsSection(viewModel: viewModel) + ResultSection(result: viewModel.lastResult) + PathsSection(paths: viewModel.paths) + } + .padding() + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("DP Simulator") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await viewModel.refreshLocalState() } + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + .disabled(viewModel.isBusy) + } + } + .task { + await viewModel.bootstrap() + #if DEBUG + await viewModel.runLaunchSelfTestIfRequested() + #endif + } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [.data, .content, .text, .json], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + viewModel.selectFile(urls.first) + case .failure(let error): + viewModel.errorMessage = UploadDemoViewModel.describe(error) + } + } + .alert( + "操作失败", + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { isPresented in + if !isPresented { + viewModel.errorMessage = nil + } + } + ) + ) { + Button("好", role: .cancel) { + viewModel.errorMessage = nil + } + } message: { + Text(viewModel.errorMessage ?? "") + } + } + } +} + +private struct StatusPanel: View { + @ObservedObject var viewModel: UploadDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(viewModel.statusMessage) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + if viewModel.isBusy { + ProgressView() + } + } + + HStack(spacing: 10) { + StatusBadge( + title: viewModel.endpointsAvailable ? "Endpoints" : "No Endpoints", + systemImage: viewModel.endpointsAvailable ? "checkmark.seal.fill" : "exclamationmark.triangle.fill", + tint: viewModel.endpointsAvailable ? .green : .orange + ) + StatusBadge( + title: viewModel.deviceReady ? "Device Ready" : "Device Pending", + systemImage: viewModel.deviceReady ? "iphone.gen3.circle.fill" : "iphone.gen3.slash", + tint: viewModel.deviceReady ? .blue : .secondary + ) + StatusBadge( + title: "\(viewModel.pendingUploads.count) Pending", + systemImage: "tray.full.fill", + tint: viewModel.pendingUploads.isEmpty ? .secondary : .purple + ) + } + } + .sectionBox() + } +} + +private struct EndpointsSection: View { + @ObservedObject var viewModel: UploadDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Endpoints", systemImage: "globe") + + TextEditor(text: $viewModel.endpointsJSON) + .font(.system(.footnote, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .frame(minHeight: 180) + .padding(8) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + HStack { + Button { + Task { await viewModel.saveEndpoints() } + } label: { + Label("保存 Endpoints", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderedProminent) + + Button { + viewModel.fillSampleEndpoints() + } label: { + Label("填入样例", systemImage: "curlybraces") + } + .buttonStyle(.bordered) + + Spacer(minLength: 0) + } + .disabled(viewModel.isBusy) + } + .sectionBox() + } +} + +private struct DeviceSection: View { + @ObservedObject var viewModel: UploadDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Device", systemImage: "iphone") + + TextField("Device ID", text: $viewModel.deviceID) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + + HStack { + Button { + Task { await viewModel.initializeDevice() } + } label: { + Label("Init Device", systemImage: "checkmark.circle") + } + .buttonStyle(.borderedProminent) + + Button(role: .destructive) { + Task { await viewModel.reinitializeDevice() } + } label: { + Label("Reinit Device", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.bordered) + + Spacer(minLength: 0) + } + .disabled(viewModel.isBusy) + + if !viewModel.deviceTags.isEmpty { + KeyValueBlock(title: "Device Tags", values: viewModel.deviceTags) + } + } + .sectionBox() + } +} + +private struct UploadSection: View { + @ObservedObject var viewModel: UploadDemoViewModel + let presentFileImporter: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Upload", systemImage: "square.and.arrow.up") + + HStack { + Button { + presentFileImporter() + } label: { + Label("选择文件", systemImage: "folder") + } + .buttonStyle(.bordered) + + Button { + Task { await viewModel.createSampleFile() } + } label: { + Label("生成样例文件", systemImage: "doc.badge.plus") + } + .buttonStyle(.bordered) + + Spacer(minLength: 0) + } + .disabled(viewModel.isBusy) + + Text(viewModel.selectedFileURL?.lastPathComponent ?? "尚未选择文件") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + + MetadataEditors(viewModel: viewModel) + + Button { + Task { await viewModel.uploadSelectedFile() } + } label: { + Label("上传选中文件", systemImage: "paperplane.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isBusy || !viewModel.deviceReady) + + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.uploadStatus) + .font(.subheadline) + .foregroundStyle(.secondary) + if let uploadProgress = viewModel.uploadProgress { + ProgressView(value: uploadProgress) + } + } + } + .sectionBox() + } +} + +private struct MetadataEditors: View { + @ObservedObject var viewModel: UploadDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Client Hints") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextEditor(text: $viewModel.clientHintsJSON) + .metadataEditor(height: 88) + + Text("Raw Tags") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextEditor(text: $viewModel.rawTagsJSON) + .metadataEditor(height: 88) + } + } +} + +private struct PendingUploadsSection: View { + @ObservedObject var viewModel: UploadDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Pending Uploads", systemImage: "tray.full") + + HStack { + Button { + Task { await viewModel.refreshPendingUploads() } + } label: { + Label("List", systemImage: "list.bullet") + } + .buttonStyle(.bordered) + + Button { + Task { await viewModel.resumeAllPending() } + } label: { + Label("Resume All", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + + Button(role: .destructive) { + Task { await viewModel.abortAllPending() } + } label: { + Label("Abort All", systemImage: "xmark.octagon") + } + .buttonStyle(.bordered) + + Spacer(minLength: 0) + } + .disabled(viewModel.isBusy || !viewModel.deviceReady) + + if viewModel.pendingUploads.isEmpty { + Text("当前没有 SDK 本地 active 快照。") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + VStack(spacing: 10) { + ForEach(viewModel.pendingUploads, id: \.logicalUploadID) { upload in + PendingUploadRow( + upload: upload, + isBusy: viewModel.isBusy, + resume: { Task { await viewModel.resume(upload) } }, + abort: { Task { await viewModel.abort(upload) } } + ) + } + } + } + } + .sectionBox() + } +} + +private struct PendingUploadRow: View { + let upload: PendingUploadInfo + let isBusy: Bool + let resume: () -> Void + let abort: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(upload.logicalUploadID) + .font(.system(.subheadline, design: .monospaced).weight(.semibold)) + .lineLimit(2) + .textSelection(.enabled) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { + Text("Upload ID") + .foregroundStyle(.secondary) + Text(upload.uploadID) + .lineLimit(1) + .truncationMode(.middle) + } + GridRow { + Text("Phase") + .foregroundStyle(.secondary) + Text(upload.phase.rawValue) + } + GridRow { + Text("Size") + .foregroundStyle(.secondary) + Text(UploadDemoViewModel.formatBytes(upload.fileSize)) + } + GridRow { + Text("Updated") + .foregroundStyle(.secondary) + Text(upload.updatedAt.formatted(date: .abbreviated, time: .shortened)) + } + } + .font(.caption) + + HStack { + Button(action: resume) { + Label("Resume", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + + Button(role: .destructive, action: abort) { + Label("Abort", systemImage: "trash") + } + .buttonStyle(.bordered) + + Spacer(minLength: 0) + } + .disabled(isBusy) + } + .padding(12) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private struct ResultSection: View { + let result: UploadResult? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Last Result", systemImage: "checkmark.seal") + + if let result { + KeyValueBlock(values: [ + "logicalUploadID": result.logicalUploadID, + "uploadID": result.uploadID, + "bucket": result.bucket, + "objectKey": result.objectKey, + "fileSize": UploadDemoViewModel.formatBytes(result.fileSize), + "ossObjectETag": result.ossObjectETag, + ]) + } else { + Text("尚未完成上传或恢复。") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .sectionBox() + } +} + +private struct PathsSection: View { + let paths: GatewayPaths + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Local Paths", systemImage: "externaldrive") + KeyValueBlock(values: [ + "endpoints": paths.endpointsURL.path, + "config": paths.configURL.path, + "uploads": paths.persistRootURL.path, + ]) + } + .sectionBox() + } +} + +private struct SectionHeader: View { + let title: String + let systemImage: String + + var body: some View { + Label(title, systemImage: systemImage) + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct StatusBadge: View { + let title: String + let systemImage: String + let tint: Color + + var body: some View { + Label(title, systemImage: systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(tint.opacity(0.12)) + .clipShape(Capsule()) + } +} + +private struct KeyValueBlock: View { + var title: String? + let values: [String: String] + + init(title: String? = nil, values: [String: String]) { + self.title = title + self.values = values + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let title { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + ForEach(values.keys.sorted(), id: \.self) { key in + VStack(alignment: .leading, spacing: 2) { + Text(key) + .font(.caption) + .foregroundStyle(.secondary) + Text(values[key] ?? "") + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(10) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private extension View { + func sectionBox() -> some View { + self + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } +} + +private extension TextEditor { + func metadataEditor(height: CGFloat) -> some View { + self + .font(.system(.footnote, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .frame(minHeight: height) + .padding(8) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +#Preview { + ContentView() +} diff --git a/Examples/dp-simulator/dp-simulator/GatewayUploadService.swift b/Examples/dp-simulator/dp-simulator/GatewayUploadService.swift new file mode 100644 index 0000000..e60a27a --- /dev/null +++ b/Examples/dp-simulator/dp-simulator/GatewayUploadService.swift @@ -0,0 +1,221 @@ +import DataGatewayClient +import DGWControlPlane +import DGWStore +import Foundation + +struct GatewayPaths: Sendable { + let archebaseRootURL: URL + let endpointsURL: URL + let configURL: URL + let persistRootURL: URL + let demoFilesURL: URL + + nonisolated static var appDefault: GatewayPaths { + let supportRoot = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + let archebaseRoot = supportRoot + .appendingPathComponent("Archebase", isDirectory: true) + .standardizedFileURL + + return GatewayPaths( + archebaseRootURL: archebaseRoot, + endpointsURL: archebaseRoot.appendingPathComponent("archebase-endpoints.json").standardizedFileURL, + configURL: archebaseRoot.appendingPathComponent("archebase-config.json").standardizedFileURL, + persistRootURL: archebaseRoot.appendingPathComponent("Uploads", isDirectory: true).standardizedFileURL, + demoFilesURL: archebaseRoot.appendingPathComponent("Demo Files", isDirectory: true).standardizedFileURL + ) + } +} + +struct GatewayLocalState: Sendable { + let paths: GatewayPaths + let endpointsExists: Bool + let configExists: Bool + let endpointsJSON: String? +} + +actor GatewayUploadService { + private let paths: GatewayPaths + private var client: DataGatewayClient? + + init(paths: GatewayPaths = .appDefault) { + self.paths = paths + } + + func localState() -> GatewayLocalState { + let fileManager = FileManager.default + let endpointsURL = self.paths.endpointsURL.standardizedFileURL + let configURL = self.paths.configURL.standardizedFileURL + let endpointsExists = fileManager.fileExists(atPath: endpointsURL.path) + let configExists = fileManager.fileExists(atPath: configURL.path) + let endpointsJSON = endpointsExists + ? try? String(contentsOf: endpointsURL, encoding: .utf8) + : nil + + return GatewayLocalState( + paths: self.paths, + endpointsExists: endpointsExists, + configExists: configExists, + endpointsJSON: endpointsJSON + ) + } + + func initializeEndpoints(json: String) throws { + do { + try DataGatewayClient.initialize( + endpointsJSON: json, + endpointsURL: self.paths.endpointsURL + ) + } catch let error as DataGatewayClientError { + guard self.shouldFallbackPersistEndpoints(after: error) else { + throw error + } + try self.writeEndpointsJSONFallback(json) + try DataGatewayClient.initialize( + endpointsJSON: json, + endpointsURL: self.paths.endpointsURL + ) + } + self.client = nil + } + + func initializeDevice(deviceID: String) async throws -> [String: String] { + let initializer = try self.makeDeviceInitializer() + let config: ArchebaseConfig + do { + config = try await initializer.initDevice(deviceID: deviceID) + } catch let error as DataGatewayClientError { + switch error { + case .alreadyInitialized: + config = try await self.loadExistingDeviceConfig() + case .persistenceFailed where self.localState().configExists: + config = try await self.loadExistingDeviceConfig() + default: + throw error + } + } + self.client = try await self.makeClient() + return config.tags + } + + func reinitializeDevice(deviceID: String) async throws -> [String: String] { + let initializer = try self.makeDeviceInitializer() + let config = try await initializer.reinitDevice(deviceID: deviceID) + self.client = try await self.makeClient() + return config.tags + } + + func listPendingUploads() async throws -> [PendingUploadInfo] { + let client = try await self.loadClient() + return try await client.listPendingUploads() + } + + func uploadEventStream( + fileURL: URL, + clientHints: [String: String], + rawTags: [String: String] + ) async throws -> AsyncThrowingStream { + let client = try await self.loadClient() + let request = UploadRequest( + fileURL: fileURL, + clientHints: clientHints, + rawTags: rawTags, + displayName: fileURL.lastPathComponent + ) + return await client.uploadEvents(request) + } + + func resumeUpload(logicalUploadID: String) async throws -> UploadResult { + let client = try await self.loadClient() + return try await client.resumeUpload(logicalUploadID: logicalUploadID) + } + + func abortUpload(logicalUploadID: String) async throws { + let client = try await self.loadClient() + try await client.abortUpload(logicalUploadID: logicalUploadID) + } + + func makeSampleFile() throws -> URL { + try FileManager.default.createDirectory( + at: self.paths.demoFilesURL, + withIntermediateDirectories: true + ) + + let timestamp = ISO8601DateFormatter().string(from: Date()) + let payload: [String: Any] = [ + "source": "dp-simulator", + "created_at": timestamp, + "sequence": Int(Date().timeIntervalSince1970), + "values": [ + "temperature": 21.6, + "pressure": 101.3, + "status": "demo", + ], + ] + let data = try JSONSerialization.data( + withJSONObject: payload, + options: [.prettyPrinted, .sortedKeys] + ) + let fileName = "dp-simulator-\(Int(Date().timeIntervalSince1970)).json" + let fileURL = self.paths.demoFilesURL.appendingPathComponent(fileName) + try data.write(to: fileURL, options: [.atomic]) + return fileURL + } + + private func loadClient() async throws -> DataGatewayClient { + if let client { + return client + } + let client = try await self.makeClient() + self.client = client + return client + } + + private func makeClient() async throws -> DataGatewayClient { + try await DataGatewayClient.fromArchebaseConfig( + configURL: self.paths.configURL, + persistRootURL: self.paths.persistRootURL, + endpointsURL: self.paths.endpointsURL, + observability: .disabled + ) + } + + private func makeDeviceInitializer() throws -> ArchebaseDeviceInitializer { + try ArchebaseDeviceInitializer( + config: DeviceInitClientConfig( + configURL: self.paths.configURL, + endpointsURL: self.paths.endpointsURL + ) + ) + } + + private func loadExistingDeviceConfig() async throws -> ArchebaseConfig { + let store = ArchebaseConfigStore(configURL: self.paths.configURL) + return try await store.load() + } + + private func shouldFallbackPersistEndpoints(after error: DataGatewayClientError) -> Bool { + switch error { + case .endpointsNotInitialized: + return true + case .persistenceFailed: + return true + default: + return false + } + } + + private func writeEndpointsJSONFallback(_ json: String) throws { + let endpointsURL = self.paths.endpointsURL.standardizedFileURL + let parent = endpointsURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + #if os(iOS) + let options: Data.WritingOptions = [.atomic, .completeFileProtectionUnlessOpen] + #else + let options: Data.WritingOptions = [.atomic] + #endif + try Data(json.utf8).write(to: endpointsURL, options: options) + } +} diff --git a/Examples/dp-simulator/dp-simulator/UploadDemoViewModel.swift b/Examples/dp-simulator/dp-simulator/UploadDemoViewModel.swift new file mode 100644 index 0000000..2599aa6 --- /dev/null +++ b/Examples/dp-simulator/dp-simulator/UploadDemoViewModel.swift @@ -0,0 +1,605 @@ +import Combine +import DataGatewayClient +import DGWControlPlane +import DGWStore +import Foundation +import UIKit + +@MainActor +final class UploadDemoViewModel: ObservableObject { + @Published var endpointsJSON: String = UploadDemoViewModel.sampleEndpointsJSON + @Published var deviceID: String + @Published var clientHintsJSON: String = """ + { + "source": "dp-simulator" + } + """ + @Published var rawTagsJSON: String = """ + { + "scene": "demo" + } + """ + + @Published private(set) var paths: GatewayPaths = .appDefault + @Published private(set) var endpointsAvailable = false + @Published private(set) var configAvailable = false + @Published private(set) var deviceReady = false + @Published private(set) var pendingUploads: [PendingUploadInfo] = [] + @Published private(set) var selectedFileURL: URL? + @Published private(set) var lastResult: UploadResult? + @Published private(set) var deviceTags: [String: String] = [:] + @Published private(set) var statusMessage = "正在检查本地配置..." + @Published private(set) var uploadStatus = "空闲" + @Published private(set) var uploadProgress: Double? + @Published private(set) var isBusy = false + @Published var errorMessage: String? + + private let service: GatewayUploadService + private var didBootstrap = false + #if DEBUG + private var didRunLaunchSelfTest = false + #endif + private var uploadedBytesThisRun: UInt64 = 0 + + init(service: GatewayUploadService = GatewayUploadService()) { + self.service = service + self.deviceID = Self.defaultDeviceID() + } + + func bootstrap() async { + guard !self.didBootstrap else { + return + } + self.didBootstrap = true + await self.refreshLocalState(showErrors: false) + } + + func refreshLocalState(showErrors: Bool = true) async { + let localState = await self.refreshStoredFileState() + + guard localState.endpointsExists else { + self.deviceReady = false + self.pendingUploads = [] + self.statusMessage = localState.configExists + ? "检测到设备配置,但 endpoints 缺失。请先保存 endpoints JSON。" + : "请先保存 endpoints JSON,再初始化设备。" + return + } + + guard localState.configExists else { + self.deviceReady = false + self.pendingUploads = [] + self.statusMessage = "已找到 endpoints,请输入 device ID 后初始化设备。" + return + } + + do { + self.pendingUploads = try await self.service.listPendingUploads() + self.deviceReady = true + self.statusMessage = self.pendingUploads.isEmpty + ? "已加载本地设备配置,可以直接上传。" + : "已加载本地设备配置,发现 \(self.pendingUploads.count) 个待处理上传。" + } catch { + self.deviceReady = false + self.pendingUploads = [] + self.statusMessage = "本地设备配置存在,但 SDK client 加载失败。" + if showErrors { + self.errorMessage = Self.describe(error) + } + } + } + + func saveEndpoints() async { + await self.withBusy { + let json = self.endpointsJSON.trimmingCharacters(in: .whitespacesAndNewlines) + guard !json.isEmpty else { + throw DemoInputError("请输入 endpoints JSON。") + } + + try await self.service.initializeEndpoints(json: json) + let localState = await self.refreshStoredFileState() + self.statusMessage = localState.configExists + ? "Endpoints 已保存,已检测到本地设备配置。" + : "Endpoints 已保存,可以初始化设备。" + } + } + + func fillSampleEndpoints() { + self.endpointsJSON = Self.sampleEndpointsJSON + } + + #if DEBUG + func runLaunchSelfTestIfRequested() async { + guard !self.didRunLaunchSelfTest else { + return + } + guard ProcessInfo.processInfo.arguments.contains("--dp-simulator-self-test-init-device") else { + return + } + + self.didRunLaunchSelfTest = true + self.deviceID = Self.launchArgumentValue(named: "--dp-simulator-device-id") ?? "260508-000001" + await self.writeLaunchSelfTestResult(stage: "started", passed: false, error: nil) + + await self.saveEndpoints() + if let errorMessage { + await self.writeLaunchSelfTestResult(stage: "saveEndpoints", passed: false, error: errorMessage) + return + } + + await self.initializeDevice() + if let errorMessage { + await self.writeLaunchSelfTestResult(stage: "initializeDevice", passed: false, error: errorMessage) + return + } + + await self.refreshLocalState(showErrors: false) + await self.writeLaunchSelfTestResult(stage: "completed", passed: self.deviceReady, error: nil) + } + #endif + + func initializeDevice() async { + await self.withBusy { + try await self.ensureEndpointsReady() + let trimmedDeviceID = try self.validDeviceID() + let tags = try await self.service.initializeDevice(deviceID: trimmedDeviceID) + UserDefaults.standard.set(trimmedDeviceID, forKey: Self.deviceIDDefaultsKey) + self.deviceTags = tags + await self.refreshLocalState(showErrors: false) + self.statusMessage = "设备初始化完成,后续启动会直接使用本地配置。" + } + } + + func reinitializeDevice() async { + await self.withBusy { + try await self.ensureEndpointsReady() + let trimmedDeviceID = try self.validDeviceID() + let tags = try await self.service.reinitializeDevice(deviceID: trimmedDeviceID) + UserDefaults.standard.set(trimmedDeviceID, forKey: Self.deviceIDDefaultsKey) + self.deviceTags = tags + await self.refreshLocalState(showErrors: false) + self.statusMessage = "设备已重新初始化,新上传会使用更新后的配置。" + } + } + + func selectFile(_ fileURL: URL?) { + guard let fileURL else { + return + } + self.selectedFileURL = fileURL + self.uploadStatus = "已选择 \(fileURL.lastPathComponent)" + } + + func createSampleFile() async { + await self.withBusy { + let fileURL = try await self.service.makeSampleFile() + self.selectedFileURL = fileURL + self.uploadStatus = "已生成样例文件 \(fileURL.lastPathComponent)" + } + } + + func uploadSelectedFile() async { + await self.withBusy { + guard let selectedFileURL else { + throw DemoInputError("请先选择或生成一个要上传的文件。") + } + guard self.deviceReady else { + throw DemoInputError("请先完成设备初始化,或等待应用加载已有设备配置。") + } + + let clientHints = try Self.parseStringDictionary( + self.clientHintsJSON, + fieldName: "clientHints" + ) + let rawTags = try Self.parseStringDictionary( + self.rawTagsJSON, + fieldName: "rawTags" + ) + + self.uploadedBytesThisRun = 0 + self.uploadProgress = 0 + self.lastResult = nil + let stream = try await self.service.uploadEventStream( + fileURL: selectedFileURL, + clientHints: clientHints, + rawTags: rawTags + ) + + var completedResult: UploadResult? + for try await event in stream { + self.apply(event) + if case .completed(let result) = event { + completedResult = result + } + } + + if let completedResult { + self.lastResult = completedResult + } + await self.refreshPendingUploads() + if let completedResult { + self.statusMessage = "上传完成:\(completedResult.logicalUploadID)" + } + } + } + + func refreshPendingUploads() async { + do { + let localState = await self.refreshStoredFileState() + guard localState.endpointsExists else { + self.pendingUploads = [] + self.deviceReady = false + self.statusMessage = "请先保存 endpoints JSON。" + return + } + guard localState.configExists else { + self.pendingUploads = [] + self.deviceReady = false + self.statusMessage = "请先初始化设备。" + return + } + self.pendingUploads = try await self.service.listPendingUploads() + self.deviceReady = true + self.statusMessage = self.pendingUploads.isEmpty + ? "没有待恢复上传。" + : "已刷新待恢复上传:\(self.pendingUploads.count) 个。" + } catch { + self.errorMessage = Self.describe(error) + } + } + + func resume(_ upload: PendingUploadInfo) async { + await self.withBusy { + self.uploadStatus = "正在恢复 \(upload.logicalUploadID)" + let result = try await self.service.resumeUpload(logicalUploadID: upload.logicalUploadID) + self.lastResult = result + self.uploadProgress = 1 + self.statusMessage = "恢复完成:\(result.logicalUploadID)" + await self.refreshPendingUploads() + } + } + + func resumeAllPending() async { + await self.withBusy { + let uploads = self.pendingUploads + guard !uploads.isEmpty else { + self.statusMessage = "没有待恢复上传。" + return + } + + var successCount = 0 + var failures: [String] = [] + for upload in uploads { + do { + self.uploadStatus = "正在恢复 \(upload.logicalUploadID)" + self.lastResult = try await self.service.resumeUpload(logicalUploadID: upload.logicalUploadID) + successCount += 1 + } catch { + failures.append("\(upload.logicalUploadID): \(Self.describe(error))") + } + } + + await self.refreshPendingUploads() + self.statusMessage = "批量恢复完成:成功 \(successCount) 个,失败 \(failures.count) 个。" + if !failures.isEmpty { + self.errorMessage = failures.joined(separator: "\n\n") + } + } + } + + func abort(_ upload: PendingUploadInfo) async { + await self.withBusy { + try await self.service.abortUpload(logicalUploadID: upload.logicalUploadID) + await self.refreshPendingUploads() + self.statusMessage = "已取消上传:\(upload.logicalUploadID)" + } + } + + func abortAllPending() async { + await self.withBusy { + let uploads = self.pendingUploads + guard !uploads.isEmpty else { + self.statusMessage = "没有待取消上传。" + return + } + + var successCount = 0 + var failures: [String] = [] + for upload in uploads { + do { + try await self.service.abortUpload(logicalUploadID: upload.logicalUploadID) + successCount += 1 + } catch { + failures.append("\(upload.logicalUploadID): \(Self.describe(error))") + } + } + + await self.refreshPendingUploads() + self.statusMessage = "批量取消完成:成功 \(successCount) 个,失败 \(failures.count) 个。" + if !failures.isEmpty { + self.errorMessage = failures.joined(separator: "\n\n") + } + } + } + + static func formatBytes(_ bytes: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } + + static func describe(_ error: Error) -> String { + if let inputError = error as? DemoInputError { + return inputError.message + } + + guard let sdkError = error as? DataGatewayClientError else { + return error.localizedDescription + } + + switch sdkError { + case .authenticationFailed(let code, let message): + return "认证失败 \(code ?? "UNKNOWN"):\(message)" + case .gatewayFailed(let statusCode, let detailCode, let message): + return "Gateway 请求失败 \(statusCode) \(detailCode ?? "UNKNOWN"):\(message)" + case .invalidConfiguration(let message): + return "配置无效:\(message)" + case .alreadyInitialized(let configURL): + return "设备已经初始化:\(configURL.path)。如需换绑请使用 Reinit Device。" + case .notInitialized(let configURL): + return "设备尚未初始化:\(configURL.path)。" + case .endpointsAlreadyInitialized(let endpointsURL): + return "Endpoints 已存在且内容不同:\(endpointsURL.path)。当前 demo 不会静默切换服务端。" + case .endpointsNotInitialized(let endpointsURL): + return "Endpoints 尚未初始化:\(endpointsURL.path)。" + case .invalidLocalFile(let message): + return "本地文件不可用:\(message)" + case .zeroByteFile: + return "不能上传 0 字节文件。" + case .ossFailed(let httpStatus, let ossCode, let message): + return "OSS 上传失败 \(httpStatus.map(String.init) ?? "-") \(ossCode ?? "UNKNOWN"):\(message)" + case .persistenceFailed(let message): + return "本地持久化失败:\(message)" + case .rawTagConflict(let key): + return "rawTags 与设备 tags 冲突:\(key)" + case .uploadRestartExceeded: + return "恢复上传时超过自动重建次数。" + case .resumeNotPossible(let reason): + return "无法恢复上传:\(reason)" + case .integrityCheckFailed(let message): + return "完整性校验失败:\(message)" + case .retryExhausted(let lastError): + return "重试耗尽:\(lastError)" + case .cancelled: + return "操作已取消。" + } + } + + private func ensureEndpointsReady() async throws { + let localState = await self.refreshStoredFileState() + if localState.endpointsExists { + return + } + + let json = self.endpointsJSON.trimmingCharacters(in: .whitespacesAndNewlines) + guard !json.isEmpty else { + throw DemoInputError("请先输入 endpoints JSON。") + } + try await self.service.initializeEndpoints(json: json) + _ = await self.refreshStoredFileState() + } + + @discardableResult + private func refreshStoredFileState() async -> GatewayLocalState { + let localState = await self.service.localState() + self.paths = localState.paths + self.endpointsAvailable = localState.endpointsExists + self.configAvailable = localState.configExists + + if let endpointsJSON = localState.endpointsJSON, !endpointsJSON.isEmpty { + self.endpointsJSON = endpointsJSON + } + + if !localState.endpointsExists || !localState.configExists { + self.deviceReady = false + self.pendingUploads = [] + } else { + self.deviceReady = true + } + + return localState + } + + private func validDeviceID() throws -> String { + let trimmed = self.deviceID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw DemoInputError("请输入 device ID。") + } + return trimmed + } + + private func apply(_ event: UploadEvent) { + switch event { + case .preparing: + self.uploadStatus = "准备本地文件" + self.uploadProgress = 0 + case .authenticating: + self.uploadStatus = "认证中" + case .creatingLogicalUpload: + self.uploadStatus = "创建逻辑上传" + case .resuming(let logicalUploadID): + self.uploadStatus = "恢复 \(logicalUploadID)" + case .initiatingMultipart(let uploadID): + self.uploadStatus = "初始化分片上传 \(uploadID)" + case .uploadingPart(let partNumber, let sentBytes, let totalBytes): + self.uploadedBytesThisRun += sentBytes + if totalBytes > 0 { + self.uploadProgress = min(Double(self.uploadedBytesThisRun) / Double(totalBytes), 0.99) + } + self.uploadStatus = "上传分片 \(partNumber):\(Self.formatBytes(self.uploadedBytesThisRun)) / \(Self.formatBytes(totalBytes))" + case .refreshingCredentials(let uploadID): + self.uploadStatus = "刷新凭证 \(uploadID)" + case .reconcilingRemoteParts(let uploadID): + self.uploadStatus = "校验远端分片 \(uploadID)" + case .completingMultipart(let uploadID): + self.uploadStatus = "完成分片上传 \(uploadID)" + case .completingBusinessUpload(let uploadID): + self.uploadStatus = "完成业务上传 \(uploadID)" + case .completed(let result): + self.uploadProgress = 1 + self.lastResult = result + self.uploadStatus = "上传完成 \(result.logicalUploadID)" + } + } + + private func withBusy(_ operation: () async throws -> Void) async { + guard !self.isBusy else { + return + } + self.isBusy = true + self.errorMessage = nil + defer { + self.isBusy = false + } + + do { + try await operation() + } catch let sdkError as DataGatewayClientError { + if case .alreadyInitialized = sdkError { + await self.refreshLocalState(showErrors: false) + } + self.errorMessage = Self.describe(sdkError) + } catch { + self.errorMessage = Self.describe(error) + } + } + + private static func parseStringDictionary(_ source: String, fieldName: String) throws -> [String: String] { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return [:] + } + + do { + let data = Data(trimmed.utf8) + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: Any] else { + throw DemoInputError("\(fieldName) 必须是 JSON object。") + } + + var result: [String: String] = [:] + for (key, value) in dictionary { + guard let stringValue = value as? String else { + throw DemoInputError("\(fieldName).\(key) 的值必须是字符串。") + } + result[key] = stringValue + } + return result + } catch let inputError as DemoInputError { + throw inputError + } catch { + throw DemoInputError("\(fieldName) 不是合法 JSON:\(error.localizedDescription)") + } + } + + private static func defaultDeviceID() -> String { + if let storedValue = UserDefaults.standard.string(forKey: Self.deviceIDDefaultsKey), + !storedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return storedValue + } + + let deviceSuffix = UIDevice.current.identifierForVendor?.uuidString.prefix(8).lowercased() + ?? UUID().uuidString.prefix(8).lowercased() + return "ios-\(deviceSuffix)" + } + + #if DEBUG + private func writeLaunchSelfTestResult(stage: String, passed: Bool, error: String?) async { + let localState = await self.service.localState() + let result = LaunchSelfTestResult( + stage: stage, + passed: passed, + error: error, + statusMessage: self.statusMessage, + deviceID: self.deviceID, + endpointsAvailable: localState.endpointsExists, + configAvailable: localState.configExists, + deviceReady: self.deviceReady, + endpointsURL: localState.paths.endpointsURL.path, + configURL: localState.paths.configURL.path, + timestamp: ISO8601DateFormatter().string(from: Date()) + ) + + do { + try FileManager.default.createDirectory( + at: localState.paths.archebaseRootURL, + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(result) + let resultURL = localState.paths.archebaseRootURL.appendingPathComponent("launch-self-test-result.json") + try data.write(to: resultURL, options: [.atomic]) + } catch { + self.errorMessage = Self.describe(error) + } + } + + private static func launchArgumentValue(named name: String) -> String? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: name) else { + return nil + } + let valueIndex = arguments.index(after: index) + guard valueIndex < arguments.endIndex else { + return nil + } + return arguments[valueIndex] + } + #endif + + private static let deviceIDDefaultsKey = "dp-simulator.last-device-id" + + private static let sampleEndpointsJSON = """ + { + "auth": { + "scheme": "http", + "host": "nlb-isnnehtfmxqv70lvm9.cn-shanghai.nlb.aliyuncsslb.com", + "port": 50051 + }, + "gateway": { + "scheme": "http", + "host": "nlb-isnnehtfmxqv70lvm9.cn-shanghai.nlb.aliyuncsslb.com", + "port": 50053 + }, + "deviceInit": { + "scheme": "http", + "host": "nlb-isnnehtfmxqv70lvm9.cn-shanghai.nlb.aliyuncsslb.com", + "port": 50057 + } + } + """ +} + +#if DEBUG +private struct LaunchSelfTestResult: Encodable { + let stage: String + let passed: Bool + let error: String? + let statusMessage: String + let deviceID: String + let endpointsAvailable: Bool + let configAvailable: Bool + let deviceReady: Bool + let endpointsURL: String + let configURL: String + let timestamp: String +} +#endif + +struct DemoInputError: Error { + let message: String + + init(_ message: String) { + self.message = message + } +} diff --git a/Examples/dp-simulator/dp-simulator/dp_simulatorApp.swift b/Examples/dp-simulator/dp-simulator/dp_simulatorApp.swift new file mode 100644 index 0000000..9dd39f5 --- /dev/null +++ b/Examples/dp-simulator/dp-simulator/dp_simulatorApp.swift @@ -0,0 +1,17 @@ +// +// dp_simulatorApp.swift +// dp-simulator +// +// Created by Pengfei Wang on 2026/5/8. +// + +import SwiftUI + +@main +struct dp_simulatorApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Sources/DGWStore/ArchebaseConfigStore.swift b/Sources/DGWStore/ArchebaseConfigStore.swift index 9fcae9d..2d969ac 100644 --- a/Sources/DGWStore/ArchebaseConfigStore.swift +++ b/Sources/DGWStore/ArchebaseConfigStore.swift @@ -14,7 +14,7 @@ public actor ArchebaseConfigStore { /// Returns whether the configuration file currently exists. public func exists() -> Bool { - self.fileManager.fileExists(atPath: self.configURL.path()) + self.fileManager.fileExists(atPath: self.configURL.path) } /// Returns the standardized configuration file URL used by this store. @@ -61,9 +61,16 @@ public actor ArchebaseConfigStore { do { try Self.writeProtected(data, to: tempURL) if replacingExisting { - _ = try self.fileManager.replaceItemAt(self.configURL, withItemAt: tempURL) + try self.replaceOrMoveTemporaryItem(tempURL, to: self.configURL) } else { - try self.fileManager.moveItem(at: tempURL, to: self.configURL) + do { + try self.fileManager.moveItem(at: tempURL, to: self.configURL) + } catch { + if self.fileManager.fileExists(atPath: self.configURL.path) { + throw DataGatewayClientError.alreadyInitialized(configURL: self.configURL) + } + throw error + } } let loaded = try self.load() guard loaded == config else { @@ -78,6 +85,23 @@ public actor ArchebaseConfigStore { } } + private func replaceOrMoveTemporaryItem(_ temporaryURL: URL, to destination: URL) throws { + if self.fileManager.fileExists(atPath: destination.path) { + _ = try self.fileManager.replaceItemAt(destination, withItemAt: temporaryURL) + return + } + + do { + try self.fileManager.moveItem(at: temporaryURL, to: destination) + } catch { + if self.fileManager.fileExists(atPath: destination.path) { + _ = try self.fileManager.replaceItemAt(destination, withItemAt: temporaryURL) + return + } + throw error + } + } + private static func writeProtected(_ data: Data, to url: URL) throws { #if os(iOS) try data.write(to: url, options: [.completeFileProtectionUnlessOpen]) diff --git a/Sources/DGWStore/UploadStateStore.swift b/Sources/DGWStore/UploadStateStore.swift index 64e06cc..8e1fb47 100644 --- a/Sources/DGWStore/UploadStateStore.swift +++ b/Sources/DGWStore/UploadStateStore.swift @@ -220,7 +220,7 @@ public actor UploadStateStore { for directory in self.stateDirectories() { try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } - if !self.fileManager.fileExists(atPath: self.indexFileURL().path()) { + if !self.fileManager.fileExists(atPath: self.indexFileURL().path) { try self.atomicWrite(LocalFileIndex.empty, to: self.indexFileURL()) } } @@ -234,7 +234,7 @@ public actor UploadStateStore { namespace: SnapshotNamespace ) throws -> PersistedUploadState? { let url = self.snapshotURL(logicalUploadID: logicalUploadID, namespace: namespace) - guard self.fileManager.fileExists(atPath: url.path()) else { + guard self.fileManager.fileExists(atPath: url.path) else { return nil } return try self.jsonCodec.decode(PersistedUploadState.self, from: Data(contentsOf: url)) @@ -254,7 +254,7 @@ public actor UploadStateStore { private func readIndex() throws -> LocalFileIndex { let url = self.indexFileURL() - guard self.fileManager.fileExists(atPath: url.path()) else { + guard self.fileManager.fileExists(atPath: url.path) else { return .empty } return try self.jsonCodec.decode(LocalFileIndex.self, from: Data(contentsOf: url)) @@ -262,7 +262,7 @@ public actor UploadStateStore { private func removeSnapshotIfPresent(logicalUploadID: String, in namespace: SnapshotNamespace) throws { let url = self.snapshotURL(logicalUploadID: logicalUploadID, namespace: namespace) - if self.fileManager.fileExists(atPath: url.path()) { + if self.fileManager.fileExists(atPath: url.path) { try self.fileManager.removeItem(at: url) } } @@ -302,10 +302,23 @@ public actor UploadStateStore { #endif try data.write(to: temporaryURL, options: writeOptions) - if self.fileManager.fileExists(atPath: destination.path()) { + try self.replaceOrMoveTemporaryItem(temporaryURL, to: destination) + } + + private func replaceOrMoveTemporaryItem(_ temporaryURL: URL, to destination: URL) throws { + if self.fileManager.fileExists(atPath: destination.path) { _ = try self.fileManager.replaceItemAt(destination, withItemAt: temporaryURL) - } else { + return + } + + do { try self.fileManager.moveItem(at: temporaryURL, to: destination) + } catch { + if self.fileManager.fileExists(atPath: destination.path) { + _ = try self.fileManager.replaceItemAt(destination, withItemAt: temporaryURL) + return + } + throw error } } @@ -330,7 +343,7 @@ public actor UploadStateStore { } private func indexKey(for fileURL: URL) -> String { - fileURL.standardizedFileURL.path() + fileURL.standardizedFileURL.path } } diff --git a/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift b/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift index 7e221e3..4dcb247 100644 --- a/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift +++ b/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift @@ -53,7 +53,7 @@ public enum ArchebasePublicEndpoints { package static func load(endpointsURL: URL) throws -> Resolved { let resolvedURL = endpointsURL.standardizedFileURL - guard FileManager.default.fileExists(atPath: resolvedURL.path()) else { + guard FileManager.default.fileExists(atPath: resolvedURL.path) else { throw DataGatewayClientError.endpointsNotInitialized(endpointsURL: resolvedURL) } @@ -79,7 +79,7 @@ public enum ArchebasePublicEndpoints { let resolvedURL = endpointsURL.standardizedFileURL let fileManager = FileManager.default - if fileManager.fileExists(atPath: resolvedURL.path()) { + if fileManager.fileExists(atPath: resolvedURL.path) { let existing = try Self.load(endpointsURL: resolvedURL) guard existing == expected else { throw DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL: resolvedURL) @@ -105,8 +105,8 @@ public enum ArchebasePublicEndpoints { do { try fileManager.moveItem(at: tempURL, to: endpointsURL) } catch { - if fileManager.fileExists(atPath: endpointsURL.path()) { - let existing = try Self.load(endpointsURL: endpointsURL) + if fileManager.fileExists(atPath: endpointsURL.path) { + let existing = try Self.loadPersistedEndpoints(endpointsURL: endpointsURL) guard existing == expected else { throw DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL: endpointsURL) } @@ -115,7 +115,7 @@ public enum ArchebasePublicEndpoints { throw error } - let loaded = try Self.load(endpointsURL: endpointsURL) + let loaded = try Self.loadPersistedEndpoints(endpointsURL: endpointsURL) guard loaded == expected else { throw DataGatewayClientError.persistenceFailed("archebase endpoints verification failed after write") } @@ -130,6 +130,19 @@ public enum ArchebasePublicEndpoints { } } + private static func loadPersistedEndpoints(endpointsURL: URL) throws -> Resolved { + do { + let data = try Data(contentsOf: endpointsURL) + return try Self.decodeEndpoints(data) + } catch let error as DataGatewayClientError { + throw error + } catch { + throw DataGatewayClientError.persistenceFailed( + "failed to verify persisted archebase endpoints at \(endpointsURL.path): \(error.localizedDescription)" + ) + } + } + private static func writeProtected(_ data: Data, to url: URL) throws { #if os(iOS) try data.write(to: url, options: [.completeFileProtectionUnlessOpen]) diff --git a/Sources/DataGatewayClient/FilePreparation.swift b/Sources/DataGatewayClient/FilePreparation.swift index c410b28..b024836 100644 --- a/Sources/DataGatewayClient/FilePreparation.swift +++ b/Sources/DataGatewayClient/FilePreparation.swift @@ -571,11 +571,11 @@ package struct LocalFileSystem: FileSystemProviding { package init() {} package func fileExists(at url: URL) -> Bool { - FileManager.default.fileExists(atPath: url.path()) + FileManager.default.fileExists(atPath: url.path) } package func attributes(at url: URL) throws -> [FileAttributeKey: Any] { - try FileManager.default.attributesOfItem(atPath: url.path()) + try FileManager.default.attributesOfItem(atPath: url.path) } package func read(prefixFrom url: URL, maxLength: Int) throws -> Data { @@ -593,7 +593,7 @@ package struct LocalFileSystem: FileSystemProviding { } package func copyItem(at sourceURL: URL, to destinationURL: URL) throws { - if FileManager.default.fileExists(atPath: destinationURL.path()) { + if FileManager.default.fileExists(atPath: destinationURL.path) { try FileManager.default.removeItem(at: destinationURL) } try FileManager.default.copyItem(at: sourceURL, to: destinationURL) diff --git a/Tests/DGWStoreTests/UploadStateStoreTests.swift b/Tests/DGWStoreTests/UploadStateStoreTests.swift index 8a0788c..bb98dc7 100644 --- a/Tests/DGWStoreTests/UploadStateStoreTests.swift +++ b/Tests/DGWStoreTests/UploadStateStoreTests.swift @@ -140,8 +140,8 @@ import Testing ) ) - #expect(!FileManager.default.fileExists(atPath: terminalURL(root: sandbox.root, logicalUploadID: "logical-7").path())) - #expect(!FileManager.default.fileExists(atPath: completedURL(root: sandbox.root, logicalUploadID: "logical-8").path())) + #expect(!FileManager.default.fileExists(atPath: terminalURL(root: sandbox.root, logicalUploadID: "logical-7").path)) + #expect(!FileManager.default.fileExists(atPath: completedURL(root: sandbox.root, logicalUploadID: "logical-8").path)) } @Test func keepFlagsPreventRemovalBeforeTTLButDisableRetentionWhenFalse() async throws { @@ -166,8 +166,8 @@ import Testing ) ) - #expect(FileManager.default.fileExists(atPath: terminalURL(root: sandbox.root, logicalUploadID: "logical-9").path())) - #expect(!FileManager.default.fileExists(atPath: completedURL(root: sandbox.root, logicalUploadID: "logical-10").path())) + #expect(FileManager.default.fileExists(atPath: terminalURL(root: sandbox.root, logicalUploadID: "logical-9").path)) + #expect(!FileManager.default.fileExists(atPath: completedURL(root: sandbox.root, logicalUploadID: "logical-10").path)) } } diff --git a/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift b/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift index da4552e..7f0b705 100644 --- a/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift @@ -12,7 +12,7 @@ final class PassthroughSecurityScopedAccessor: SecurityScopedFileAccessing, @unc } func bookmarkData(for fileURL: URL) throws -> Data { - Data("bookmark:\(fileURL.path())".utf8) + Data("bookmark:\(fileURL.path)".utf8) } } @@ -194,11 +194,24 @@ func makePersistencePolicy(copyExternalFileIntoManagedStaging: Bool) -> LocalPer try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) - #expect(FileManager.default.fileExists(atPath: endpointsURL.path())) + #expect(FileManager.default.fileExists(atPath: endpointsURL.path)) let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: endpointsURL) #expect(endpoints.gateway == URL(string: "http://gateway.example.com:50053")!) } +@Test func endpointInitializeLoadsFromDirectoryWithSpaces() throws { + let root = try filePreparationTemporaryRoot() + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Archebase", isDirectory: true) + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) + + #expect(FileManager.default.fileExists(atPath: endpointsURL.path)) + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: endpointsURL) + #expect(endpoints.deviceInit == URL(string: "https://init.example.com:443")!) +} + @Test func endpointInitializeRejectsInvalidJSONWithoutCreatingFile() throws { let root = try filePreparationTemporaryRoot() let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) @@ -207,7 +220,7 @@ func makePersistencePolicy(copyExternalFileIntoManagedStaging: Bool) -> LocalPer try DataGatewayClient.initialize(endpointsJSON: "{", endpointsURL: endpointsURL) } - #expect(!FileManager.default.fileExists(atPath: endpointsURL.path())) + #expect(!FileManager.default.fileExists(atPath: endpointsURL.path)) } @Test func endpointInitializeIsIdempotentForEquivalentEndpoints() throws { @@ -382,7 +395,7 @@ private extension Dictionary { #expect(prepared.sourceFileURL == sourceURL) #expect(prepared.managedFileURL != sourceURL) - #expect(prepared.managedFileURL.path().hasPrefix(stagingRoot.path())) + #expect(prepared.managedFileURL.path.hasPrefix(stagingRoot.path)) #expect(filesystem.copiedItems().count == 1) #expect(prepared.fileSize == UInt64(data.count)) #expect(prepared.fingerprint == LocalFileFingerprint( diff --git a/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift b/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift index c9b15de..d479751 100644 --- a/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift @@ -817,7 +817,7 @@ private func uniqueRealClientConfig(from config: DataGatewayClientConfig, label: .appendingPathComponent("aliyun-real-\(label)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: copy.persistRootURL, withIntermediateDirectories: true) let originalEndpointsURL = originalPersistRoot.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) - if FileManager.default.fileExists(atPath: originalEndpointsURL.path()) { + if FileManager.default.fileExists(atPath: originalEndpointsURL.path) { try FileManager.default.copyItem( at: originalEndpointsURL, to: copy.persistRootURL.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) diff --git a/Tests/DataGatewayClientIntegrationTests/ManualAliyunDeviceInitTests.swift b/Tests/DataGatewayClientIntegrationTests/ManualAliyunDeviceInitTests.swift index e51cadd..e668974 100644 --- a/Tests/DataGatewayClientIntegrationTests/ManualAliyunDeviceInitTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/ManualAliyunDeviceInitTests.swift @@ -21,7 +21,7 @@ struct ManualAliyunDeviceInitTests { let configPath = try requiredEnvironment("DGW_MANUAL_CONFIG_URL", environment: environment) let configURL = URL(fileURLWithPath: configPath) - if FileManager.default.fileExists(atPath: configURL.path()) { + if FileManager.default.fileExists(atPath: configURL.path) { try FileManager.default.removeItem(at: configURL) } @@ -35,7 +35,7 @@ struct ManualAliyunDeviceInitTests { let config = try await initializer.initDevice(deviceID: deviceID) #expect(!config.apiKey.isEmpty) - print("MANUAL_DEVICE_INIT_CONFIG_URL=\(configURL.standardizedFileURL.path())") + print("MANUAL_DEVICE_INIT_CONFIG_URL=\(configURL.standardizedFileURL.path)") print("MANUAL_DEVICE_INIT_TAG_KEYS=\(config.tags.keys.sorted().joined(separator: ","))") } }