From 957983d988dc41c687125d3b9e47df136c906369 Mon Sep 17 00:00:00 2001 From: Pengfei Wang Date: Sat, 16 May 2026 11:08:00 +0800 Subject: [PATCH] feat: qiongche sdk --- .../xcshareddata/swiftpm/Package.resolved | 222 ++++++ .../xcschemes/xcschememanagement.plist | 2 +- .../project.pbxproj | 373 ++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 222 ++++++ .../xcschemes/xcschememanagement.plist | 14 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../Assets.xcassets/Contents.json | 6 + .../qiongche-sdk-demo/ContentView.swift | 308 +++++++++ .../qiongche-sdk-demo/QiongcheDemoPaths.swift | 82 +++ .../QiongcheDemoService.swift | 82 +++ .../QiongcheDemoViewModel.swift | 192 ++++++ .../qiongche_sdk_demoApp.swift | 17 + README.md | 111 +++ Scripts/local_integration_bootstrap.sh | 156 +++-- Sources/DGWStore/ArchebaseConfigStore.swift | 56 +- Sources/DGWStore/AtomicFileWriter.swift | 62 ++ .../ArchebasePublicEndpoints.swift | 113 +++- .../QiongcheConfigParser.swift | 118 ++++ .../QiongcheDataGatewaySDK.swift | 278 ++++++++ .../QiongcheDeviceProvisioner.swift | 54 ++ .../QiongcheEndpointProbe.swift | 136 ++++ .../ArchebaseConfigStoreTests.swift | 37 + .../FilePreparationTests.swift | 51 ++ .../LocalStackHarnessTests.swift | 18 +- .../QiongcheConfigParserTests.swift | 233 +++++++ .../QiongcheDataGatewaySDKTests.swift | 638 ++++++++++++++++++ 28 files changed, 3492 insertions(+), 142 deletions(-) create mode 100644 Examples/dp-simulator/dp-simulator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.pbxproj create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/xcuserdata/pfwang80s.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/Contents.json create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo/ContentView.swift create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoPaths.swift create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoService.swift create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoViewModel.swift create mode 100644 Examples/qiongche-sdk-demo/qiongche-sdk-demo/qiongche_sdk_demoApp.swift create mode 100644 Sources/DGWStore/AtomicFileWriter.swift create mode 100644 Sources/DataGatewayClient/QiongcheConfigParser.swift create mode 100644 Sources/DataGatewayClient/QiongcheDataGatewaySDK.swift create mode 100644 Sources/DataGatewayClient/QiongcheDeviceProvisioner.swift create mode 100644 Sources/DataGatewayClient/QiongcheEndpointProbe.swift create mode 100644 Tests/DataGatewayClientIntegrationTests/QiongcheConfigParserTests.swift create mode 100644 Tests/DataGatewayClientIntegrationTests/QiongcheDataGatewaySDKTests.swift 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 index 2f173e7..1ce7656 100644 --- 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 @@ -7,7 +7,7 @@ dp-simulator.xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.pbxproj b/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a2a6f31 --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.pbxproj @@ -0,0 +1,373 @@ +// !$*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 /* qiongche-sdk-demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "qiongche-sdk-demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AE7DB8722FAD713E00B4CF49 /* qiongche-sdk-demo */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "qiongche-sdk-demo"; + 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 /* qiongche-sdk-demo */, + AE7DB8712FAD713E00B4CF49 /* Products */, + ); + sourceTree = ""; + }; + AE7DB8712FAD713E00B4CF49 /* Products */ = { + isa = PBXGroup; + children = ( + AE7DB8702FAD713E00B4CF49 /* qiongche-sdk-demo.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AE7DB86F2FAD713E00B4CF49 /* qiongche-sdk-demo */ = { + isa = PBXNativeTarget; + buildConfigurationList = AE7DB87D2FAD713F00B4CF49 /* Build configuration list for PBXNativeTarget "qiongche-sdk-demo" */; + buildPhases = ( + AE7DB86C2FAD713E00B4CF49 /* Sources */, + AE7DB86D2FAD713E00B4CF49 /* Frameworks */, + AE7DB86E2FAD713E00B4CF49 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AE7DB8722FAD713E00B4CF49 /* qiongche-sdk-demo */, + ); + name = "qiongche-sdk-demo"; + packageProductDependencies = ( + AE7DB8832FAD8A4B00B4CF49 /* DataGatewayClient */, + AE7DB8842FAD8A4B00B4CF49 /* DGWControlPlane */, + AE7DB8852FAD8A4B00B4CF49 /* DGWStore */, + ); + productName = "qiongche-sdk-demo"; + productReference = AE7DB8702FAD713E00B4CF49 /* qiongche-sdk-demo.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 "qiongche-sdk-demo" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AE7DB8672FAD713E00B4CF49; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../.." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = AE7DB8712FAD713E00B4CF49 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AE7DB86F2FAD713E00B4CF49 /* qiongche-sdk-demo */, + ); + }; +/* 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 = 18.0; + 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 = 18.0; + 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; + 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.qiongche.sdk.demo; + 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; + 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.qiongche.sdk.demo; + 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 "qiongche-sdk-demo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE7DB87B2FAD713F00B4CF49 /* Debug */, + AE7DB87C2FAD713F00B4CF49 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AE7DB87D2FAD713F00B4CF49 /* Build configuration list for PBXNativeTarget "qiongche-sdk-demo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE7DB87E2FAD713F00B4CF49 /* Debug */, + AE7DB87F2FAD713F00B4CF49 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../.."; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AE7DB8832FAD8A4B00B4CF49 /* DataGatewayClient */ = { + isa = XCSwiftPackageProductDependency; + package = AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../.." */; + productName = DataGatewayClient; + }; + AE7DB8842FAD8A4B00B4CF49 /* DGWControlPlane */ = { + isa = XCSwiftPackageProductDependency; + package = AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../.." */; + productName = DGWControlPlane; + }; + AE7DB8852FAD8A4B00B4CF49 /* DGWStore */ = { + isa = XCSwiftPackageProductDependency; + package = AE7DB8862FAD8A4B00B4CF49 /* XCLocalSwiftPackageReference "../.." */; + productName = DGWStore; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = AE7DB8682FAD713E00B4CF49 /* Project object */; +} diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..e0d49df --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo.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" : "21fe69ab7ce0e87ac089534733c52f037e74a3eb", + "version" : "2.4.1" + } + }, + { + "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" : "03cc312c2c933ed87abace34044a5dff7a3117c1", + "version" : "1.5.0" + } + }, + { + "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" : "42f62383dbcd074440cb1f6a750b9c02df9e7325", + "version" : "0.18.2" + } + } + ], + "version" : 3 +} diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/xcuserdata/pfwang80s.xcuserdatad/xcschemes/xcschememanagement.plist b/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/xcuserdata/pfwang80s.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..0ad082b --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj/xcuserdata/pfwang80s.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + qiongche-sdk-demo.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/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/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/Contents.json b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo/ContentView.swift b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/ContentView.swift new file mode 100644 index 0000000..7751a21 --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/ContentView.swift @@ -0,0 +1,308 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var viewModel = QiongcheDemoViewModel() + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 16) { + StatusStrip(viewModel: viewModel) + ConfigPanel(viewModel: viewModel) + LocalStatePanel(state: viewModel.localState) + ReadyPanel(viewModel: viewModel) + QiongcheUploadPanel(viewModel: viewModel) + PathsPanel(paths: viewModel.localState.paths) + } + .padding() + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Qiongche SDK") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await viewModel.refreshLocalState() } + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + .disabled(viewModel.isBusy) + } + } + .task { + await viewModel.bootstrap() + } + .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 StatusStrip: View { + @ObservedObject var viewModel: QiongcheDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(viewModel.statusMessage) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + if viewModel.isBusy { + ProgressView() + } + } + + HStack(spacing: 8) { + StateBadge(title: viewModel.localState.endpointsExists ? "Endpoint" : "No Endpoint", systemImage: "globe", tint: viewModel.localState.endpointsExists ? .green : .orange) + StateBadge(title: viewModel.localState.configExists ? "Config" : "No Config", systemImage: "key", tint: viewModel.localState.configExists ? .green : .orange) + StateBadge(title: viewModel.localState.stateExists ? "State" : "No State", systemImage: "doc.text", tint: viewModel.localState.stateExists ? .blue : .secondary) + } + } + .panelBox() + } +} + +private struct ConfigPanel: View { + @ObservedObject var viewModel: QiongcheDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + PanelHeader(title: "配置", systemImage: "curlybraces") + TextEditor(text: $viewModel.configString) + .font(.system(.footnote, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .frame(minHeight: 190) + .padding(8) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + HStack { + Button { + viewModel.fillSampleConfig() + } label: { + Label("填入示例配置", systemImage: "doc.badge.plus") + } + .buttonStyle(.bordered) + + Button { + Task { await viewModel.saveConfigAndInit() } + } label: { + Label("保存并初始化", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderedProminent) + } + .disabled(viewModel.isBusy) + } + .panelBox() + } +} + +private struct LocalStatePanel: View { + let state: QiongcheDemoLocalState + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + PanelHeader(title: "本地状态", systemImage: "externaldrive") + KeyValueLine(title: "endpoint", value: state.endpointsExists ? "存在" : "缺失") + KeyValueLine(title: "config", value: state.configExists ? "存在" : "缺失") + KeyValueLine(title: "state", value: state.stateExists ? "存在" : "缺失") + if let deviceID = state.stateDeviceID { + KeyValueLine(title: "device_id", value: deviceID) + } + if let initializedAt = state.initializedAt { + KeyValueLine(title: "initialized", value: initializedAt.formatted(date: .abbreviated, time: .standard)) + } + if let stateReadError = state.stateReadError { + Text(stateReadError) + .font(.footnote) + .foregroundStyle(.orange) + } + } + .panelBox() + } +} + +private struct ReadyPanel: View { + @ObservedObject var viewModel: QiongcheDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + PanelHeader(title: "Ready", systemImage: "checkmark.seal") + HStack { + StateBadge(title: readyTitle, systemImage: readyIcon, tint: readyTint) + Spacer(minLength: 0) + Button { + Task { await viewModel.checkReady() } + } label: { + Label("检查", systemImage: "dot.radiowaves.left.and.right") + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isBusy) + } + if let lastReadyCheck = viewModel.lastReadyCheck { + Text(lastReadyCheck.formatted(date: .abbreviated, time: .standard)) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .panelBox() + } + + private var readyTitle: String { + switch viewModel.ready { + case .some(true): return "Ready" + case .some(false): return "Not Ready" + case .none: return "Unchecked" + } + } + + private var readyIcon: String { + switch viewModel.ready { + case .some(true): return "checkmark.circle.fill" + case .some(false): return "xmark.circle.fill" + case .none: return "minus.circle" + } + } + + private var readyTint: Color { + switch viewModel.ready { + case .some(true): return .green + case .some(false): return .orange + case .none: return .secondary + } + } +} + +struct QiongcheUploadPanel: View { + @ObservedObject var viewModel: QiongcheDemoViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + PanelHeader(title: "上传验证", systemImage: "paperplane") + HStack { + Button { + Task { await viewModel.makeSampleFile() } + } label: { + Label("生成文件", systemImage: "doc.badge.plus") + } + .buttonStyle(.bordered) + + Button { + Task { await viewModel.uploadSampleFile() } + } label: { + Label("上传文件", systemImage: "square.and.arrow.up") + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isBusy || viewModel.sampleFileURL == nil) + } + + Text(viewModel.sampleFileURL?.lastPathComponent ?? "未生成文件") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + + if let resultSummary = viewModel.resultSummary { + Text(resultSummary) + .font(.footnote.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if !viewModel.uploadEvents.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(viewModel.uploadEvents.suffix(8).enumerated()), id: \.offset) { _, event in + Text(event) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .panelBox() + } +} + +private struct PathsPanel: View { + let paths: QiongcheDemoPaths + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelHeader(title: "路径", systemImage: "folder") + KeyValueLine(title: "root", value: paths.rootURL.path) + KeyValueLine(title: "uploads", value: paths.persistRootURL.path) + } + .panelBox() + } +} + +private struct PanelHeader: View { + var title: String + var systemImage: String + + var body: some View { + Label(title, systemImage: systemImage) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct StateBadge: View { + var title: String + var systemImage: String + var tint: Color + + var body: some View { + Label(title, systemImage: systemImage) + .font(.caption.weight(.semibold)) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(tint.opacity(0.14)) + .foregroundStyle(tint) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private struct KeyValueLine: View { + var title: String + var value: String + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.footnote.monospaced()) + .textSelection(.enabled) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private extension View { + func panelBox() -> some View { + self + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoPaths.swift b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoPaths.swift new file mode 100644 index 0000000..021da3e --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoPaths.swift @@ -0,0 +1,82 @@ +import DataGatewayClient +import Foundation + +struct QiongcheDemoPaths: Sendable { + let rootURL: URL + let endpointsURL: URL + let configURL: URL + let stateURL: URL + let persistRootURL: URL + let demoFilesURL: URL + + nonisolated static var appDefault: QiongcheDemoPaths { + let supportRoot = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + let root = supportRoot + .appendingPathComponent("Archebase", isDirectory: true) + .standardizedFileURL + + return QiongcheDemoPaths( + rootURL: root, + endpointsURL: root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName).standardizedFileURL, + configURL: root.appendingPathComponent("archebase-config.json").standardizedFileURL, + stateURL: root.appendingPathComponent("qiongche-sdk-state.json").standardizedFileURL, + persistRootURL: root.appendingPathComponent("Uploads", isDirectory: true).standardizedFileURL, + demoFilesURL: root.appendingPathComponent("Demo Files", isDirectory: true).standardizedFileURL + ) + } +} + +struct QiongcheDemoLocalState: Sendable { + let paths: QiongcheDemoPaths + let endpointsExists: Bool + let configExists: Bool + let stateExists: Bool + let stateDeviceID: String? + let initializedAt: Date? + let stateReadError: String? +} + +struct QiongcheDemoStateReader { + private struct PersistedState: Decodable { + var deviceID: String + var initializedAtUnix: Int64 + + enum CodingKeys: String, CodingKey { + case deviceID = "device_id" + case initializedAtUnix = "initialized_at_unix" + } + } + + static func read(paths: QiongcheDemoPaths, fileManager: FileManager = .default) -> QiongcheDemoLocalState { + let endpointsExists = fileManager.fileExists(atPath: paths.endpointsURL.path) + let configExists = fileManager.fileExists(atPath: paths.configURL.path) + let stateExists = fileManager.fileExists(atPath: paths.stateURL.path) + + var stateDeviceID: String? + var initializedAt: Date? + var stateReadError: String? + + if stateExists { + do { + let state = try JSONDecoder().decode(PersistedState.self, from: Data(contentsOf: paths.stateURL)) + stateDeviceID = state.deviceID + initializedAt = Date(timeIntervalSince1970: TimeInterval(state.initializedAtUnix)) + } catch { + stateReadError = "state 文件无法解析" + } + } + + return QiongcheDemoLocalState( + paths: paths, + endpointsExists: endpointsExists, + configExists: configExists, + stateExists: stateExists, + stateDeviceID: stateDeviceID, + initializedAt: initializedAt, + stateReadError: stateReadError + ) + } +} diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoService.swift b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoService.swift new file mode 100644 index 0000000..c098ef6 --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoService.swift @@ -0,0 +1,82 @@ +import DataGatewayClient +import Foundation + +actor QiongcheDemoService { + private let paths: QiongcheDemoPaths + private var client: DataGatewayClient? + + init(paths: QiongcheDemoPaths = .appDefault) { + self.paths = paths + } + + func localState() -> QiongcheDemoLocalState { + QiongcheDemoStateReader.read(paths: self.paths) + } + + func saveConfigAndInit(_ configString: String) async throws { + let sdk = try QiongcheDataGatewaySDK(rootURL: self.paths.rootURL) + try await sdk.saveConfigAndInit(configString: configString) + self.client = nil + } + + func checkReady() async -> Bool { + do { + let sdk = try QiongcheDataGatewaySDK(rootURL: self.paths.rootURL) + return await sdk.isReadyToUpload() + } catch { + return false + } + } + + 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": "qiongche-sdk-demo", + "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 = "qiongche-sdk-demo-\(Int(Date().timeIntervalSince1970)).json" + let fileURL = self.paths.demoFilesURL.appendingPathComponent(fileName) + try data.write(to: fileURL, options: [.atomic]) + return fileURL + } + + func uploadSampleFile(_ fileURL: URL) async throws -> AsyncThrowingStream { + let client = try await self.loadClient() + let request = UploadRequest( + fileURL: fileURL, + clientHints: ["source": "qiongche-sdk-demo"], + rawTags: ["scene": "qiongche-demo"], + displayName: fileURL.lastPathComponent + ) + return await client.uploadEvents(request) + } + + private func loadClient() async throws -> DataGatewayClient { + if let client { + return client + } + let client = try await DataGatewayClient.fromArchebaseConfig( + configURL: self.paths.configURL, + persistRootURL: self.paths.persistRootURL, + endpointsURL: self.paths.endpointsURL, + observability: .disabled + ) + self.client = client + return client + } +} diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoViewModel.swift b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoViewModel.swift new file mode 100644 index 0000000..ff6061e --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/QiongcheDemoViewModel.swift @@ -0,0 +1,192 @@ +import Combine +import DataGatewayClient +import DGWControlPlane +import Foundation + +@MainActor +final class QiongcheDemoViewModel: ObservableObject { + @Published var configString: String + @Published private(set) var localState: QiongcheDemoLocalState + @Published private(set) var ready: Bool? + @Published private(set) var lastReadyCheck: Date? + @Published private(set) var sampleFileURL: URL? + @Published private(set) var uploadEvents: [String] = [] + @Published private(set) var resultSummary: String? + @Published private(set) var statusMessage = "正在检查本地状态..." + @Published private(set) var isBusy = false + @Published var errorMessage: String? + + private let service: QiongcheDemoService + private var didBootstrap = false + + init(service: QiongcheDemoService = QiongcheDemoService()) { + self.service = service + self.configString = QiongcheDemoViewModel.sampleConfigString + self.localState = QiongcheDemoStateReader.read(paths: .appDefault) + } + + func bootstrap() async { + guard !self.didBootstrap else { + return + } + self.didBootstrap = true + await self.refreshLocalState() + } + + func refreshLocalState() async { + self.localState = await self.service.localState() + self.statusMessage = self.stateSummary(self.localState) + } + + func fillSampleConfig() { + self.configString = Self.sampleConfigString + } + + func saveConfigAndInit() async { + await self.withBusy { + let trimmed = self.configString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw QiongcheDemoInputError("请输入配置 JSON。") + } + try await self.service.saveConfigAndInit(trimmed) + self.ready = nil + await self.refreshLocalState() + self.statusMessage = "配置已保存,设备初始化完成。" + } + } + + func checkReady() async { + await self.withBusy { + let value = await self.service.checkReady() + self.ready = value + self.lastReadyCheck = Date() + self.statusMessage = value ? "Ready" : "Not Ready" + } + } + + func makeSampleFile() async { + await self.withBusy { + let fileURL = try await self.service.makeSampleFile() + self.sampleFileURL = fileURL + self.uploadEvents = ["已生成 \(fileURL.lastPathComponent)"] + self.resultSummary = nil + } + } + + func uploadSampleFile() async { + await self.withBusy { + guard let sampleFileURL else { + throw QiongcheDemoInputError("请先生成样例文件。") + } + self.uploadEvents = ["开始上传 \(sampleFileURL.lastPathComponent)"] + self.resultSummary = nil + let stream = try await self.service.uploadSampleFile(sampleFileURL) + for try await event in stream { + self.uploadEvents.append(Self.describe(event)) + if case .completed(let result) = event { + self.resultSummary = "logicalUploadID: \(result.logicalUploadID)" + } + } + await self.refreshLocalState() + } + } + + private func withBusy(_ operation: () async throws -> Void) async { + guard !self.isBusy else { + return + } + self.isBusy = true + defer { + self.isBusy = false + } + + do { + try await operation() + } catch { + self.errorMessage = Self.describe(error) + } + } + + private func stateSummary(_ state: QiongcheDemoLocalState) -> String { + if let stateReadError = state.stateReadError { + return stateReadError + } + if state.endpointsExists, state.configExists { + return "本地配置存在。" + } + if state.endpointsExists { + return "endpoint 已存在,设备配置缺失。" + } + if state.configExists { + return "设备配置存在,endpoint 缺失。" + } + return "尚未保存穹彻配置。" + } + + static func describe(_ error: any Error) -> String { + if let error = error as? QiongcheSDKError { + switch error { + case .invalidConfigString(let message): + return message + } + } + if let error = error as? DataGatewayClientError { + switch error { + case .persistenceFailed(let message), .invalidConfiguration(let message): + return message + case .authenticationFailed(_, let message), .gatewayFailed(_, _, let message): + return message + default: + return String(describing: error) + } + } + if let error = error as? QiongcheDemoInputError { + return error.message + } + return error.localizedDescription + } + + static func describe(_ event: UploadEvent) -> String { + switch event { + case .preparing: + return "preparing" + case .authenticating: + return "authenticating" + case .creatingLogicalUpload: + return "creating logical upload" + case .resuming(let logicalUploadID): + return "resuming \(logicalUploadID)" + case .initiatingMultipart(let uploadID): + return "multipart \(uploadID)" + case .uploadingPart(let partNumber, let sentBytes, let totalBytes): + return "part \(partNumber): \(sentBytes)/\(totalBytes)" + case .refreshingCredentials(let uploadID): + return "refreshing \(uploadID)" + case .reconcilingRemoteParts(let uploadID): + return "reconciling \(uploadID)" + case .completingMultipart(let uploadID): + return "completing multipart \(uploadID)" + case .completingBusinessUpload(let uploadID): + return "completing upload \(uploadID)" + case .completed: + return "completed" + } + } + + static let sampleConfigString = """ + { + "device_id": "robot-001", + "auth": { "scheme": "http", "host": "localhost", "port": 50051 }, + "gateway": { "scheme": "http", "host": "localhost", "port": 50053 }, + "deviceInit": { "scheme": "http", "host": "localhost", "port": 50057 } + } + """ +} + +struct QiongcheDemoInputError: Error { + let message: String + + init(_ message: String) { + self.message = message + } +} diff --git a/Examples/qiongche-sdk-demo/qiongche-sdk-demo/qiongche_sdk_demoApp.swift b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/qiongche_sdk_demoApp.swift new file mode 100644 index 0000000..029f230 --- /dev/null +++ b/Examples/qiongche-sdk-demo/qiongche-sdk-demo/qiongche_sdk_demoApp.swift @@ -0,0 +1,17 @@ +// +// qiongche_sdk_demoApp.swift +// qiongche-sdk-demo +// +// Created by Pengfei Wang on 2026/5/8. +// + +import SwiftUI + +@main +struct QiongcheSDKDemoApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/README.md b/README.md index e4f3142..4705b04 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,117 @@ iOS App 推荐使用配置文件驱动方式接入。 如果接入方已经通过其他安全渠道直接向 App 下发 `API Key`,也可以跳过设备初始化,直接使用 `DataGatewayClientConfig.recommended(...)` 创建客户端。生产 App 通常优先使用设备初始化方式。 +### 5.1 穹彻专供接入 + +穹彻 App 优先使用 `QiongcheDataGatewaySDK`,不需要自行编排 endpoint 初始化、设备初始化和本地配置覆盖。穹彻 facade 只提供两个入口: + +```swift +let qiongcheSDK = try QiongcheDataGatewaySDK() + +try await qiongcheSDK.saveConfigAndInit(configString: configStringFromTrustedChannel) + +let ready = await qiongcheSDK.isReadyToUpload() +``` + +`saveConfigAndInit(configString:)` 接收穹彻可信渠道下发的完整配置字符串,完成设备初始化并写入本地文件。`isReadyToUpload()` 用于上传前判断本地配置和服务连通性是否满足创建上传客户端的条件。 + +穹彻封装不提供 `reinit`、`reset` 或 `replaceConfig` 入口。需要更新配置时,继续调用 `saveConfigAndInit(configString:)`;该方法会在远端初始化成功后覆盖本地 endpoint、设备配置和穹彻 state。 + +完成 ready 检查后,上传仍使用通用上传客户端: + +```swift +let supportRoot = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask +)[0] +let archebaseRoot = supportRoot.appendingPathComponent("Archebase", isDirectory: true) + +let client = try await DataGatewayClient.fromArchebaseConfig( + configURL: archebaseRoot.appendingPathComponent("archebase-config.json"), + persistRootURL: archebaseRoot.appendingPathComponent("Uploads", isDirectory: true), + endpointsURL: archebaseRoot.appendingPathComponent("archebase-endpoints.json") +) +``` + +穹彻配置字符串是 UTF-8 JSON,顶层只接受 `auth`、`gateway`、`deviceInit` 和 `device_id`: + +```json +{ + "device_id": "robot-001", + "auth": { "scheme": "http", "host": "localhost", "port": 50051 }, + "gateway": { "scheme": "http", "host": "localhost", "port": 50053 }, + "deviceInit": { "scheme": "http", "host": "localhost", "port": 50057 } +} +``` + +字段约束: + +| 字段 | 说明 | +|---|---| +| `device_id` | 必填,trim 后不能为空,不能包含控制字符;只写入穹彻 state,不写入 endpoint 文件。 | +| `auth` | 认证服务 endpoint,只接受 `scheme`、`host`、`port`。 | +| `gateway` | 上传网关 endpoint,只接受 `scheme`、`host`、`port`。 | +| `deviceInit` | 设备初始化 endpoint,只接受 `scheme`、`host`、`port`。 | + +`saveConfigAndInit(configString:)` 的覆盖规则: + +1. 先解析配置并使用本次 `deviceInit` endpoint 调远端初始化。 +2. 远端初始化成功后,覆盖写入 `archebase-endpoints.json`、`archebase-config.json` 和 `qiongche-sdk-state.json`。 +3. 本地已有 endpoint、设备配置或 state 时,不因为内容不同报错。 +4. 远端初始化失败时,不覆盖本地已有 endpoint、设备配置或 state。 +5. endpoint 文件只保存 `auth`、`gateway`、`deviceInit`,不会保存 `device_id`。 + +`isReadyToUpload()` 的判断范围: + +1. 本地 `archebase-config.json` 存在且可解析。 +2. 本地 `archebase-endpoints.json` 存在且可解析。 +3. `auth` 和 `gateway` endpoint 可发起轻量 gRPC 请求。 + +`isReadyToUpload()` 不创建上传任务,不申请对象存储临时凭证,不探测对象存储,也不探测 `deviceInit` endpoint。返回 `false` 的常见原因包括本地文件缺失、JSON 损坏、endpoint 不可达、TLS 配置不匹配、DNS/TCP 连接失败或请求超时。 + +穹彻接入时建议单独处理配置错误: + +```swift +do { + try await qiongcheSDK.saveConfigAndInit(configString: configStringFromTrustedChannel) +} catch let error as QiongcheSDKError { + switch error { + case .invalidConfigString(let message): + print(message) + } +} catch let error as DataGatewayClientError { + switch error { + case .persistenceFailed(let message): + print(message) + default: + print(error) + } +} catch { + print(error.localizedDescription) +} +``` + +安全注意事项: + +1. 不要把完整 `configString`、`api_key`、access token、STS secret 或带签名 query 的 URL 写入日志、埋点、剪贴板或可导出的诊断文件。 +2. App UI 不应展示 `archebase-config.json` 中的 `api_key`。 +3. `configString` 只能来自可信渠道;再次提交会切换本机 endpoint、设备配置和穹彻 state。 +4. 排查问题时优先记录短错误摘要、文件是否存在、ready 结果和时间,不记录敏感字段值。 + +穹彻示例 App 位于: + +```text +Examples/qiongche-sdk-demo/ +``` + +运行方式: + +```bash +open Examples/qiongche-sdk-demo/qiongche-sdk-demo.xcodeproj +``` + +示例 App 面向穹彻 SDK 接入者,首屏提供配置输入、保存并初始化、本地状态、ready 检查、生成样例文件和上传样例文件。示例 App 不提供 reinit 按钮;再次提交配置会继续调用 `saveConfigAndInit(configString:)` 覆盖本地 endpoint、设备配置和穹彻 state。 + ## 6. 文件目录建议 建议将 SDK 配置和上传持久化状态放在 `Application Support` 下,并保持在 App 私有容器内: diff --git a/Scripts/local_integration_bootstrap.sh b/Scripts/local_integration_bootstrap.sh index 96070d5..2c0dd2d 100755 --- a/Scripts/local_integration_bootstrap.sh +++ b/Scripts/local_integration_bootstrap.sh @@ -31,10 +31,12 @@ BOOTSTRAP_API_KEY_SUFFIX="${BOOTSTRAP_RUN_SUFFIX//[^[:alnum:]-]/-}" BOOTSTRAP_API_KEY_SUFFIX="${BOOTSTRAP_API_KEY_SUFFIX:0:26}" BOOTSTRAP_API_KEY_ID="${DGW_LOCAL_BOOTSTRAP_API_KEY_ID:-swift-key-${BOOTSTRAP_API_KEY_SUFFIX}}" BOOTSTRAP_API_KEY_PREFIX="${DGW_LOCAL_BOOTSTRAP_API_KEY_PREFIX:-swift-local-${BOOTSTRAP_API_KEY_SUFFIX}}" +BOOTSTRAP_API_KEY_NAME="${DGW_LOCAL_BOOTSTRAP_API_KEY_NAME:-$BOOTSTRAP_API_KEY_PREFIX}" BOOTSTRAP_API_KEY_STATUS="${DGW_LOCAL_BOOTSTRAP_API_KEY_STATUS:-1}" BOOTSTRAP_CSRF_ORIGIN="${DGW_LOCAL_BOOTSTRAP_CSRF_ORIGIN:-$GATEWAY_HTTP_BASE}" CURL_CONNECT_TIMEOUT_SECONDS="${DGW_LOCAL_BOOTSTRAP_CONNECT_TIMEOUT_SECONDS:-3}" CURL_MAX_TIME_SECONDS="${DGW_LOCAL_BOOTSTRAP_MAX_TIME_SECONDS:-10}" +OPERATION_API_BASE="${DGW_LOCAL_OPERATION_API_BASE:-/api/operation/v1}" usage() { cat <<'EOF' @@ -68,10 +70,12 @@ Environment overrides: DGW_LOCAL_BOOTSTRAP_SUITE_DESCRIPTION DGW_LOCAL_BOOTSTRAP_API_KEY_ID DGW_LOCAL_BOOTSTRAP_API_KEY_PREFIX + DGW_LOCAL_BOOTSTRAP_API_KEY_NAME DGW_LOCAL_BOOTSTRAP_API_KEY_STATUS DGW_LOCAL_BOOTSTRAP_CSRF_ORIGIN DGW_LOCAL_BOOTSTRAP_CONNECT_TIMEOUT_SECONDS DGW_LOCAL_BOOTSTRAP_MAX_TIME_SECONDS + DGW_LOCAL_OPERATION_API_BASE DATA_PLATFORM_ROOT (required for --start-stack and grpcurl bootstrap; defaults to ../data-platform) DATA_PLATFORM_PROTO_ROOT (defaults to DATA_PLATFORM_ROOT/common/proto) @@ -226,13 +230,25 @@ bootstrap_devices_via_grpc() { local credential_base64="$1" local site_id="$2" - local admin_token device_body device_response device_name device_id + local admin_token suite_body suite_response suite_name + local device_body device_response device_name device_id local unbound_body unbound_response unbound_name unbound_device_id - local suite_body suite_response suite_name add_device_body add_device_response + local remove_device_body remove_device_response admin_token=$(admin_bearer_token) + suite_body=$(cat <&2 + exit 1 + fi + device_body=$(cat <&2 - exit 1 - fi - - add_device_body=$(cat <&2 + remove_device_response=$(grpc_call "$META_ENDPOINT" archebase.meta.v1.DeviceManagementService/RemoveDeviceFromSuite "$remove_device_body" -H "Authorization: Bearer ${admin_token}") + if [[ -n "$remove_device_response" && "$remove_device_response" != "{}" ]]; then + echo "failed to unbind device from suite through grpc: $remove_device_response" >&2 exit 1 fi @@ -286,8 +291,9 @@ bootstrap_via_grpc() { fi local admin_token site_body site_response site_id api_key_body api_key_response credential_base64 + local suite_body suite_response suite_name local device_body device_response device_name device_id unbound_body unbound_response unbound_name unbound_device_id - local suite_body suite_response suite_name add_device_body add_device_response + local remove_device_body remove_device_response admin_token=$(admin_bearer_token) site_body=$(cat <&2 + echo "failed to create site api key through grpc: $api_key_response" >&2 + exit 1 + fi + + suite_body=$(cat <&2 exit 1 fi device_body=$(cat <&2 - exit 1 - fi - - add_device_body=$(cat <&2 + remove_device_response=$(grpc_call "$META_ENDPOINT" archebase.meta.v1.DeviceManagementService/RemoveDeviceFromSuite "$remove_device_body" -H "Authorization: Bearer ${admin_token}") + if [[ -n "$remove_device_response" && "$remove_device_response" != "{}" ]]; then + echo "failed to unbind device from suite through grpc: $remove_device_response" >&2 exit 1 fi @@ -399,6 +408,7 @@ export DGW_LOCAL_BOOTSTRAP_SUITE_DISPLAY_NAME='${BOOTSTRAP_SUITE_DISPLAY_NAME}' export DGW_LOCAL_BOOTSTRAP_SUITE_DESCRIPTION='${BOOTSTRAP_SUITE_DESCRIPTION}' export DGW_LOCAL_BOOTSTRAP_API_KEY_ID='${BOOTSTRAP_API_KEY_ID}' export DGW_LOCAL_BOOTSTRAP_API_KEY_PREFIX='${BOOTSTRAP_API_KEY_PREFIX}' +export DGW_LOCAL_BOOTSTRAP_API_KEY_NAME='${BOOTSTRAP_API_KEY_NAME}' export DGW_LOCAL_BOOTSTRAP_API_KEY_STATUS='${BOOTSTRAP_API_KEY_STATUS}' export DGW_LOCAL_BOOTSTRAP_CSRF_ORIGIN='${BOOTSTRAP_CSRF_ORIGIN}' @@ -443,6 +453,7 @@ EOF export DGW_LOCAL_BOOTSTRAP_SUITE_DESCRIPTION="$BOOTSTRAP_SUITE_DESCRIPTION" export DGW_LOCAL_BOOTSTRAP_API_KEY_ID="$BOOTSTRAP_API_KEY_ID" export DGW_LOCAL_BOOTSTRAP_API_KEY_PREFIX="$BOOTSTRAP_API_KEY_PREFIX" + export DGW_LOCAL_BOOTSTRAP_API_KEY_NAME="$BOOTSTRAP_API_KEY_NAME" export DGW_LOCAL_BOOTSTRAP_API_KEY_STATUS="$BOOTSTRAP_API_KEY_STATUS" export DGW_LOCAL_BOOTSTRAP_CSRF_ORIGIN="$BOOTSTRAP_CSRF_ORIGIN" DATA_GATEWAY_CLIENT_USE_MOCK_OSS=1 DGW_LOCAL_RUNTIME_INTEGRATION=1 swift test --filter LocalStackHarnessTests --package-path "${PACKAGE_DIR}" @@ -483,7 +494,7 @@ curl -fsS \ LOGIN_ROUTE_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \ --connect-timeout "$CURL_CONNECT_TIMEOUT_SECONDS" \ --max-time "$CURL_MAX_TIME_SECONDS" \ - "${GATEWAY_HTTP_BASE%/}/api/dataplatform/v1/auth/login" || true) + "${GATEWAY_HTTP_BASE%/}${OPERATION_API_BASE}/auth/login" || true) case "$LOGIN_ROUTE_STATUS" in 200|204|400|401|403|405) ;; @@ -502,7 +513,7 @@ EOF COOKIE_JAR="$(mktemp /tmp/swift-dgw-cookie.XXXXXX)" trap 'rm -f "$COOKIE_JAR"' EXIT -LOGIN_RESPONSE=$(curl -sS -c "$COOKIE_JAR" -X POST "${GATEWAY_HTTP_BASE%/}/api/dataplatform/v1/auth/login" \ +LOGIN_RESPONSE=$(curl -sS -c "$COOKIE_JAR" -X POST "${GATEWAY_HTTP_BASE%/}${OPERATION_API_BASE}/auth/login" \ --connect-timeout "$CURL_CONNECT_TIMEOUT_SECONDS" \ --max-time "$CURL_MAX_TIME_SECONDS" \ -H 'Content-Type: application/json' \ @@ -521,7 +532,7 @@ SITE_BODY=$(cat <&2 exit 1 fi +SUITE_BODY=$(cat <&2 + exit 1 +fi + DEVICE_BODY=$(cat <&2 - exit 1 -fi - -ADD_DEVICE_BODY=$(cat <&2 +if [[ -n "$REMOVE_DEVICE_RESPONSE" && "$REMOVE_DEVICE_RESPONSE" != "{}" ]]; then + echo "failed to unbind device from suite: $REMOVE_DEVICE_RESPONSE" >&2 exit 1 fi diff --git a/Sources/DGWStore/ArchebaseConfigStore.swift b/Sources/DGWStore/ArchebaseConfigStore.swift index 2d969ac..96f700c 100644 --- a/Sources/DGWStore/ArchebaseConfigStore.swift +++ b/Sources/DGWStore/ArchebaseConfigStore.swift @@ -53,23 +53,26 @@ public actor ArchebaseConfigStore { try self.write(config, replacingExisting: true) } + /// Writes or replaces the device configuration without exposing reinit semantics. + package func replaceOrInitialize(_ config: ArchebaseConfig) throws { + try self.write(config, replacingExisting: true) + } + private func write(_ config: ArchebaseConfig, replacingExisting: Bool) throws { let data = try config.prettyJSONData() - let parent = self.configURL.deletingLastPathComponent() - try self.fileManager.createDirectory(at: parent, withIntermediateDirectories: true) - let tempURL = parent.appendingPathComponent(".\(self.configURL.lastPathComponent).\(UUID().uuidString).tmp") do { - try Self.writeProtected(data, to: tempURL) - if replacingExisting { - try self.replaceOrMoveTemporaryItem(tempURL, to: self.configURL) - } else { - 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) + try AtomicFileWriter.write(data, to: self.configURL, fileManager: self.fileManager) { temporaryURL, destination, fileManager in + if replacingExisting { + try AtomicFileWriter.replaceOrMoveTemporaryItem(temporaryURL, to: destination, fileManager: fileManager) + } else { + do { + try AtomicFileWriter.moveTemporaryItem(temporaryURL, to: destination, fileManager: fileManager) + } catch { + if fileManager.fileExists(atPath: destination.path) { + throw DataGatewayClientError.alreadyInitialized(configURL: destination) + } + throw error } - throw error } } let loaded = try self.load() @@ -77,36 +80,9 @@ public actor ArchebaseConfigStore { throw DataGatewayClientError.persistenceFailed("archebase config verification failed after write") } } catch let error as DataGatewayClientError { - try? self.fileManager.removeItem(at: tempURL) throw error } catch { - try? self.fileManager.removeItem(at: tempURL) throw DataGatewayClientError.persistenceFailed("failed to write archebase config: \(error.localizedDescription)") } } - - 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]) - #else - try data.write(to: url, options: []) - #endif - } } diff --git a/Sources/DGWStore/AtomicFileWriter.swift b/Sources/DGWStore/AtomicFileWriter.swift new file mode 100644 index 0000000..43f595e --- /dev/null +++ b/Sources/DGWStore/AtomicFileWriter.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Shared helper for protected temporary writes followed by an atomic move or replace. +package enum AtomicFileWriter { + package static func write( + _ data: Data, + to destination: URL, + fileManager: FileManager = .default, + operation: (_ temporaryURL: URL, _ destination: URL, _ fileManager: FileManager) throws -> Void + ) throws { + let resolvedDestination = destination.standardizedFileURL + let parent = resolvedDestination.deletingLastPathComponent() + let temporaryURL = parent.appendingPathComponent(".\(resolvedDestination.lastPathComponent).\(UUID().uuidString).tmp") + + do { + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + try Self.writeProtected(data, to: temporaryURL) + try operation(temporaryURL, resolvedDestination, fileManager) + try? fileManager.removeItem(at: temporaryURL) + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + } + + package static func moveTemporaryItem( + _ temporaryURL: URL, + to destination: URL, + fileManager: FileManager + ) throws { + try fileManager.moveItem(at: temporaryURL, to: destination) + } + + package static func replaceOrMoveTemporaryItem( + _ temporaryURL: URL, + to destination: URL, + fileManager: FileManager + ) throws { + if fileManager.fileExists(atPath: destination.path) { + _ = try fileManager.replaceItemAt(destination, withItemAt: temporaryURL) + return + } + + do { + try fileManager.moveItem(at: temporaryURL, to: destination) + } catch { + if fileManager.fileExists(atPath: destination.path) { + _ = try 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]) + #else + try data.write(to: url, options: []) + #endif + } +} diff --git a/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift b/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift index 4dcb247..e3edbf8 100644 --- a/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift +++ b/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift @@ -1,4 +1,5 @@ import DGWControlPlane +import DGWStore import Foundation /// Runtime store for Archebase public service endpoints. @@ -51,6 +52,27 @@ public enum ArchebasePublicEndpoints { } } + package static func normalizedJSONData(endpointsJSON: String) throws -> Data { + guard !endpointsJSON.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DataGatewayClientError.invalidConfiguration("archebase endpoints json must not be empty") + } + + return try self.normalizedJSONData(from: Data(endpointsJSON.utf8)) + } + + package static func normalizedJSONData(from data: Data) throws -> Data { + let resolved = try self.decodeEndpoints(data) + return try self.normalizedJSONData(from: resolved) + } + + package static func normalizedJSONString(endpointsJSON: String) throws -> String { + let data = try self.normalizedJSONData(endpointsJSON: endpointsJSON) + guard let string = String(data: data, encoding: .utf8) else { + throw DataGatewayClientError.persistenceFailed("failed to encode normalized archebase endpoints") + } + return string + } + package static func load(endpointsURL: URL) throws -> Resolved { let resolvedURL = endpointsURL.standardizedFileURL guard FileManager.default.fileExists(atPath: resolvedURL.path) else { @@ -90,29 +112,52 @@ public enum ArchebasePublicEndpoints { try Self.atomicWrite(data, expected: expected, to: resolvedURL, fileManager: fileManager) } + package static func replace(endpointsJSON: String, endpointsURL: URL) throws { + let data = try Self.normalizedJSONData(endpointsJSON: endpointsJSON) + let expected = try Self.decodeEndpoints(data) + let resolvedURL = endpointsURL.standardizedFileURL + try Self.atomicWrite(data, expected: expected, to: resolvedURL, fileManager: .default, replacingExisting: true) + } + + private static func normalizedJSONData(from resolved: Resolved) throws -> Data { + try Self.normalizedEncoder.encode(NormalizedEndpointsPayload( + auth: NormalizedEndpointPayload(url: resolved.auth), + gateway: NormalizedEndpointPayload(url: resolved.gateway), + deviceInit: NormalizedEndpointPayload(url: resolved.deviceInit) + )) + } + private static func atomicWrite( _ data: Data, expected: Resolved, to endpointsURL: URL, - fileManager: FileManager + fileManager: FileManager, + replacingExisting: Bool = false ) throws { - let parent = endpointsURL.deletingLastPathComponent() - let tempURL = parent.appendingPathComponent(".\(endpointsURL.lastPathComponent).\(UUID().uuidString).tmp") - + var equivalentExistingFileWonRace = false do { - try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) - try Self.writeProtected(data, to: tempURL) - do { - try fileManager.moveItem(at: tempURL, to: endpointsURL) - } catch { - if fileManager.fileExists(atPath: endpointsURL.path) { - let existing = try Self.loadPersistedEndpoints(endpointsURL: endpointsURL) - guard existing == expected else { - throw DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL: endpointsURL) + try AtomicFileWriter.write(data, to: endpointsURL, fileManager: fileManager) { temporaryURL, destination, fileManager in + if replacingExisting { + try AtomicFileWriter.replaceOrMoveTemporaryItem(temporaryURL, to: destination, fileManager: fileManager) + } else { + do { + try AtomicFileWriter.moveTemporaryItem(temporaryURL, to: destination, fileManager: fileManager) + } catch { + if fileManager.fileExists(atPath: destination.path) { + let existing = try Self.loadPersistedEndpoints(endpointsURL: destination) + guard existing == expected else { + throw DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL: destination) + } + equivalentExistingFileWonRace = true + return + } + throw error } - return } - throw error + } + + if equivalentExistingFileWonRace { + return } let loaded = try Self.loadPersistedEndpoints(endpointsURL: endpointsURL) @@ -120,10 +165,8 @@ public enum ArchebasePublicEndpoints { throw DataGatewayClientError.persistenceFailed("archebase endpoints verification failed after write") } } catch let error as DataGatewayClientError { - try? fileManager.removeItem(at: tempURL) throw error } catch { - try? fileManager.removeItem(at: tempURL) throw DataGatewayClientError.persistenceFailed( "failed to write archebase endpoints: \(error.localizedDescription)" ) @@ -143,13 +186,11 @@ public enum ArchebasePublicEndpoints { } } - private static func writeProtected(_ data: Data, to url: URL) throws { - #if os(iOS) - try data.write(to: url, options: [.completeFileProtectionUnlessOpen]) - #else - try data.write(to: url, options: []) - #endif - } + private static let normalizedEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + }() } private struct EndpointsPayload: Decodable { @@ -158,6 +199,30 @@ private struct EndpointsPayload: Decodable { var deviceInit: EndpointPayload } +private struct NormalizedEndpointsPayload: Encodable { + var auth: NormalizedEndpointPayload + var gateway: NormalizedEndpointPayload + var deviceInit: NormalizedEndpointPayload +} + +private struct NormalizedEndpointPayload: Encodable { + var scheme: String + var host: String + var port: Int + + init(url: URL) throws { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme, + let host = components.host, + let port = components.port else { + throw DataGatewayClientError.invalidConfiguration("normalized endpoint is not a valid URL") + } + self.scheme = scheme + self.host = host + self.port = port + } +} + private struct EndpointPayload: Decodable { private static let allowedFields: Set = ["scheme", "host", "port"] diff --git a/Sources/DataGatewayClient/QiongcheConfigParser.swift b/Sources/DataGatewayClient/QiongcheConfigParser.swift new file mode 100644 index 0000000..6f7ea79 --- /dev/null +++ b/Sources/DataGatewayClient/QiongcheConfigParser.swift @@ -0,0 +1,118 @@ +import Crypto +import DGWControlPlane +import Foundation + +public enum QiongcheSDKError: Error, Sendable, Equatable { + case invalidConfigString(String) +} + +package struct QiongcheBootstrapConfig: Sendable, Equatable { + package let deviceID: String + package let normalizedEndpointsJSONData: Data + package let resolvedEndpoints: ArchebasePublicEndpoints.Resolved + + package var normalizedEndpointsJSONString: String { + String(decoding: self.normalizedEndpointsJSONData, as: UTF8.self) + } + + package var endpointsSHA256Hex: String { + QiongcheConfigParser.sha256Hex(self.normalizedEndpointsJSONData) + } +} + +package enum QiongcheConfigParser { + private static let allowedTopLevelFields: Set = ["auth", "gateway", "deviceInit", "device_id"] + + package static func parse(_ configString: String) throws -> QiongcheBootstrapConfig { + guard !configString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw QiongcheSDKError.invalidConfigString("configString must not be empty") + } + + let data = Data(configString.utf8) + let topLevel: [String: Any] + do { + guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw QiongcheSDKError.invalidConfigString("configString must be a JSON object") + } + topLevel = parsed + } catch let error as QiongcheSDKError { + throw error + } catch { + throw QiongcheSDKError.invalidConfigString("configString is not valid JSON") + } + + let unsupportedFields = Set(topLevel.keys).subtracting(Self.allowedTopLevelFields).sorted() + if let unsupportedField = unsupportedFields.first { + throw QiongcheSDKError.invalidConfigString("unsupported top-level field '\(unsupportedField)'") + } + + let deviceID = try Self.parseDeviceID(topLevel["device_id"]) + let endpointsData = try Self.endpointsData(from: topLevel) + + do { + let normalizedData = try ArchebasePublicEndpoints.normalizedJSONData(from: endpointsData) + let resolved = try ArchebasePublicEndpoints.decodeEndpoints(normalizedData) + return QiongcheBootstrapConfig( + deviceID: deviceID, + normalizedEndpointsJSONData: normalizedData, + resolvedEndpoints: resolved + ) + } catch let error as DataGatewayClientError { + throw QiongcheSDKError.invalidConfigString(Self.endpointMessage(from: error)) + } catch { + throw QiongcheSDKError.invalidConfigString("endpoint configuration is invalid") + } + } + + private static func parseDeviceID(_ value: Any?) throws -> String { + guard let rawDeviceID = value as? String else { + throw QiongcheSDKError.invalidConfigString("device_id is required") + } + + let deviceID = rawDeviceID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !deviceID.isEmpty else { + throw QiongcheSDKError.invalidConfigString("device_id must not be empty") + } + + guard !deviceID.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { + throw QiongcheSDKError.invalidConfigString("device_id contains unsupported control characters") + } + + return deviceID + } + + private static func endpointsData(from topLevel: [String: Any]) throws -> Data { + for field in ["auth", "gateway", "deviceInit"] where topLevel[field] == nil { + throw QiongcheSDKError.invalidConfigString("\(field) endpoint is required") + } + + let endpointsObject: [String: Any] = [ + "auth": topLevel["auth"] as Any, + "gateway": topLevel["gateway"] as Any, + "deviceInit": topLevel["deviceInit"] as Any, + ] + + do { + return try JSONSerialization.data(withJSONObject: endpointsObject, options: [.sortedKeys]) + } catch { + throw QiongcheSDKError.invalidConfigString("endpoint configuration is invalid") + } + } + + private static func endpointMessage(from error: DataGatewayClientError) -> String { + switch error { + case .invalidConfiguration(let message), .persistenceFailed(let message): + return message + case .endpointsAlreadyInitialized, .endpointsNotInitialized: + return "endpoint configuration is invalid" + default: + return "endpoint configuration is invalid" + } + } + + package static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/Sources/DataGatewayClient/QiongcheDataGatewaySDK.swift b/Sources/DataGatewayClient/QiongcheDataGatewaySDK.swift new file mode 100644 index 0000000..30c0023 --- /dev/null +++ b/Sources/DataGatewayClient/QiongcheDataGatewaySDK.swift @@ -0,0 +1,278 @@ +import DGWControlPlane +import DGWStore +import Foundation + +package protocol QiongcheSDKClock: Sendable { + func now() async -> Date +} + +package struct SystemQiongcheSDKClock: QiongcheSDKClock { + package init() {} + + package func now() async -> Date { + Date() + } +} + +package protocol QiongcheLocalPersisting: Sendable { + func replaceEndpoints(endpointsJSON: String, endpointsURL: URL) async throws + func replaceConfig(_ config: ArchebaseConfig, configURL: URL) async throws + func replaceState(_ state: QiongcheSDKState, stateURL: URL) async throws +} + +package struct DefaultQiongcheLocalPersister: QiongcheLocalPersisting { + package init() {} + + package func replaceEndpoints(endpointsJSON: String, endpointsURL: URL) async throws { + try ArchebasePublicEndpoints.replace(endpointsJSON: endpointsJSON, endpointsURL: endpointsURL) + } + + package func replaceConfig(_ config: ArchebaseConfig, configURL: URL) async throws { + try await ArchebaseConfigStore(configURL: configURL) + .replaceOrInitialize(config) + } + + package func replaceState(_ state: QiongcheSDKState, stateURL: URL) async throws { + try QiongcheSDKStateStore(stateURL: stateURL).replace(state) + } +} + +public actor QiongcheDataGatewaySDK { + private let paths: QiongcheSDKPaths + private let stateStore: QiongcheSDKStateStore + private let deviceProvisioner: any QiongcheDeviceProvisioning + private let readinessProbe: (any QiongcheEndpointProbing)? + private let localPersister: any QiongcheLocalPersisting + private let clock: any QiongcheSDKClock + private let deviceInitTimeout: Duration + private let readinessTimeout: Duration + + public init( + rootURL: URL? = nil, + deviceInitTimeout: Duration = .seconds(10), + readinessTimeout: Duration = .seconds(3) + ) throws { + try self.init( + rootURL: rootURL, + deviceInitTimeout: deviceInitTimeout, + readinessTimeout: readinessTimeout, + deviceProvisioner: DefaultQiongcheDeviceProvisioner(), + readinessProbe: DefaultQiongcheEndpointProbe(), + localPersister: DefaultQiongcheLocalPersister(), + clock: SystemQiongcheSDKClock() + ) + } + + package init( + rootURL: URL? = nil, + deviceInitTimeout: Duration = .seconds(10), + readinessTimeout: Duration = .seconds(3), + deviceProvisioner: any QiongcheDeviceProvisioning, + readinessProbe: (any QiongcheEndpointProbing)? = nil, + localPersister: any QiongcheLocalPersisting = DefaultQiongcheLocalPersister(), + clock: any QiongcheSDKClock = SystemQiongcheSDKClock() + ) throws { + let paths = try QiongcheSDKPaths(rootURL: rootURL) + self.paths = paths + self.stateStore = QiongcheSDKStateStore(stateURL: paths.stateURL) + self.deviceProvisioner = deviceProvisioner + self.readinessProbe = readinessProbe + self.localPersister = localPersister + self.clock = clock + self.deviceInitTimeout = deviceInitTimeout + self.readinessTimeout = readinessTimeout + } + + public func saveConfigAndInit(configString: String) async throws { + let parsed = try QiongcheConfigParser.parse(configString) + let remoteConfig = try await self.deviceProvisioner.initDevice( + deviceID: parsed.deviceID, + deviceInitEndpoint: parsed.resolvedEndpoints.deviceInit, + tls: parsed.resolvedEndpoints.deviceInitTLS, + timeout: self.deviceInitTimeout + ) + + try await self.localPersister.replaceEndpoints( + endpointsJSON: parsed.normalizedEndpointsJSONString, + endpointsURL: self.paths.endpointsURL + ) + + try await self.localPersister.replaceConfig(remoteConfig, configURL: self.paths.configURL) + + let now = await self.clock.now() + let state = try QiongcheSDKState( + deviceID: parsed.deviceID, + endpointsSHA256: parsed.endpointsSHA256Hex, + initializedAtUnix: Int64(now.timeIntervalSince1970) + ) + try await self.localPersister.replaceState(state, stateURL: self.paths.stateURL) + } + + public func isReadyToUpload() async -> Bool { + guard let readinessProbe = self.readinessProbe else { + return false + } + + do { + _ = try await ArchebaseConfigStore(configURL: self.paths.configURL).load() + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: self.paths.endpointsURL) + + async let authReachable = readinessProbe.authEndpointReachable( + endpoint: endpoints.auth, + tls: endpoints.authTLS, + timeout: self.readinessTimeout + ) + async let gatewayReachable = readinessProbe.gatewayEndpointReachable( + endpoint: endpoints.gateway, + tls: endpoints.gatewayTLS, + timeout: self.readinessTimeout + ) + + let auth = await authReachable + let gateway = await gatewayReachable + return auth && gateway + } catch { + return false + } + } +} + +package struct QiongcheSDKPaths: Sendable, Equatable { + package let rootURL: URL + package let endpointsURL: URL + package let configURL: URL + package let stateURL: URL + package let persistRootURL: URL + + package init(rootURL: URL? = nil) throws { + let root = try rootURL ?? Self.defaultRootURL() + self.rootURL = root.standardizedFileURL + self.endpointsURL = self.rootURL + .appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName, isDirectory: false) + .standardizedFileURL + self.configURL = self.rootURL + .appendingPathComponent("archebase-config.json", isDirectory: false) + .standardizedFileURL + self.stateURL = self.rootURL + .appendingPathComponent("qiongche-sdk-state.json", isDirectory: false) + .standardizedFileURL + self.persistRootURL = self.rootURL + .appendingPathComponent("Uploads", isDirectory: true) + .standardizedFileURL + } + + private static func defaultRootURL() throws -> URL { + guard let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw DataGatewayClientError.invalidConfiguration("application support directory is unavailable") + } + + return applicationSupport + .appendingPathComponent("Archebase", isDirectory: true) + .standardizedFileURL + } +} + +package struct QiongcheSDKState: Codable, Sendable, Equatable { + package static let currentVersion = 1 + + package var version: Int + package var deviceID: String + package var endpointsSHA256: String + package var initializedAtUnix: Int64 + + enum CodingKeys: String, CodingKey { + case version + case deviceID = "device_id" + case endpointsSHA256 = "endpoints_sha256" + case initializedAtUnix = "initialized_at_unix" + } + + package init( + version: Int = Self.currentVersion, + deviceID: String, + endpointsSHA256: String, + initializedAtUnix: Int64 + ) throws { + self.version = version + self.deviceID = deviceID + self.endpointsSHA256 = endpointsSHA256 + self.initializedAtUnix = initializedAtUnix + try self.validate() + } + + package func validate() throws { + guard self.version == Self.currentVersion else { + throw DataGatewayClientError.invalidConfiguration("qiongche sdk state version is unsupported") + } + guard !self.deviceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DataGatewayClientError.invalidConfiguration("qiongche sdk state device_id must not be empty") + } + guard !self.deviceID.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { + throw DataGatewayClientError.invalidConfiguration("qiongche sdk state device_id contains unsupported control characters") + } + guard self.endpointsSHA256.count == 64, + self.endpointsSHA256.allSatisfy({ $0.isHexDigit }) else { + throw DataGatewayClientError.invalidConfiguration("qiongche sdk state endpoints_sha256 is invalid") + } + guard self.initializedAtUnix > 0 else { + throw DataGatewayClientError.invalidConfiguration("qiongche sdk state initialized_at_unix is invalid") + } + } +} + +package struct QiongcheSDKStateStore { + private let stateURL: URL + private let fileManager: FileManager + + package init(stateURL: URL, fileManager: FileManager = .default) { + self.stateURL = stateURL.standardizedFileURL + self.fileManager = fileManager + } + + package func load() throws -> QiongcheSDKState { + guard self.fileManager.fileExists(atPath: self.stateURL.path) else { + throw DataGatewayClientError.notInitialized(configURL: self.stateURL) + } + + do { + let state = try Self.decoder.decode(QiongcheSDKState.self, from: Data(contentsOf: self.stateURL)) + try state.validate() + return state + } catch let error as DataGatewayClientError { + throw error + } catch { + throw DataGatewayClientError.invalidConfiguration( + "failed to load qiongche sdk state: \(error.localizedDescription)" + ) + } + } + + package func replace(_ state: QiongcheSDKState) throws { + try state.validate() + let data = try Self.encoder.encode(state) + do { + try AtomicFileWriter.write(data, to: self.stateURL, fileManager: self.fileManager) { temporaryURL, destination, fileManager in + try AtomicFileWriter.replaceOrMoveTemporaryItem(temporaryURL, to: destination, fileManager: fileManager) + } + let loaded = try self.load() + guard loaded == state else { + throw DataGatewayClientError.persistenceFailed("qiongche sdk state verification failed after write") + } + } catch let error as DataGatewayClientError { + throw error + } catch { + throw DataGatewayClientError.persistenceFailed("failed to write qiongche sdk state: \(error.localizedDescription)") + } + } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() + + private static let decoder = JSONDecoder() +} diff --git a/Sources/DataGatewayClient/QiongcheDeviceProvisioner.swift b/Sources/DataGatewayClient/QiongcheDeviceProvisioner.swift new file mode 100644 index 0000000..9caf666 --- /dev/null +++ b/Sources/DataGatewayClient/QiongcheDeviceProvisioner.swift @@ -0,0 +1,54 @@ +import DGWControlPlane +import DGWStore +import Foundation + +package protocol QiongcheDeviceProvisioning: Sendable { + func initDevice( + deviceID: String, + deviceInitEndpoint: URL, + tls: TLSMode, + timeout: Duration + ) async throws -> ArchebaseConfig +} + +package struct DefaultQiongcheDeviceProvisioner: QiongcheDeviceProvisioning { + package init() {} + + package func initDevice( + deviceID: String, + deviceInitEndpoint: URL, + tls: TLSMode, + timeout: Duration + ) async throws -> ArchebaseConfig { + try DataGatewayClientConfig.validate(endpoint: deviceInitEndpoint, tls: tls, fieldName: "deviceInitEndpoint") + + let security: ControlPlaneTransportSecurity = switch tls { + case .plaintext: .plaintext + case .tls: .tls + } + let factory = ControlPlaneClientFactory( + configuration: ControlPlaneTransportConfiguration( + endpoint: deviceInitEndpoint, + security: security, + requestTimeout: timeout + ) + ) + let managedTransport = try factory.makeDeviceInitTransport() + defer { + managedTransport.shutdown() + } + + do { + let response = try await managedTransport.serviceClient.initDevice( + deviceID: deviceID, + sdkVersion: DataGatewayClientModule.version, + platform: "ios" + ) + return try ArchebaseConfig(apiKey: response.apiKey, tags: response.tags) + } catch let error as DataGatewayClientError { + throw error + } catch { + throw ControlPlaneErrorMapper.map(error) + } + } +} diff --git a/Sources/DataGatewayClient/QiongcheEndpointProbe.swift b/Sources/DataGatewayClient/QiongcheEndpointProbe.swift new file mode 100644 index 0000000..dbad291 --- /dev/null +++ b/Sources/DataGatewayClient/QiongcheEndpointProbe.swift @@ -0,0 +1,136 @@ +import DGWControlPlane +import DGWProto +import Foundation +import GRPCCore + +package protocol QiongcheEndpointProbing: Sendable { + func authEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool + func gatewayEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool +} + +package enum QiongcheEndpointReachability { + package static func isReachable(error: any Error) -> Bool { + if error is QiongcheProbeTimeoutError { + return false + } + + if let rpcError = error as? RPCError { + return self.isReachable(rpcCode: rpcError.code) + } + + if error is CancellationError { + return false + } + + return false + } + + package static func isReachable(rpcCode: RPCError.Code) -> Bool { + switch rpcCode { + case .unauthenticated, .permissionDenied, .invalidArgument, .notFound, .failedPrecondition: + return true + case .unavailable, .deadlineExceeded, .cancelled: + return false + default: + return false + } + } +} + +package struct QiongcheProbeTimeoutError: Error, Sendable, Equatable {} + +package enum QiongcheProbeTimeout { + package static func run( + timeout: Duration, + operation: @Sendable @escaping () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(for: timeout) + throw QiongcheProbeTimeoutError() + } + + defer { + group.cancelAll() + } + guard let result = try await group.next() else { + throw QiongcheProbeTimeoutError() + } + return result + } + } +} + +package struct DefaultQiongcheEndpointProbe: QiongcheEndpointProbing { + private static let probeCredentialBase64 = "qiongche-readiness-probe" + private static let probeLogicalUploadID = "qiongche-readiness-probe" + + package init() {} + + package func authEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool { + do { + try DataGatewayClientConfig.validate(endpoint: endpoint, tls: tls, fieldName: "authEndpoint") + let managedTransport = try self.makeFactory(endpoint: endpoint, tls: tls, timeout: timeout) + .makeAuthTransport() + defer { + managedTransport.shutdown() + } + + _ = try await QiongcheProbeTimeout.run(timeout: timeout) { + try await managedTransport.serviceClient.exchangeCredential( + credentialBase64: Self.probeCredentialBase64, + timeout: timeout + ) + } + return true + } catch { + return QiongcheEndpointReachability.isReachable(error: error) + } + } + + package func gatewayEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool { + do { + try DataGatewayClientConfig.validate(endpoint: endpoint, tls: tls, fieldName: "gatewayEndpoint") + let managedTransport = try self.makeFactory(endpoint: endpoint, tls: tls, timeout: timeout) + .makeGatewayClient() + defer { + managedTransport.shutdown() + } + + var request = Archebase_DataGateway_V1_GetUploadRecoveryRequest() + request.logicalUploadID = Self.probeLogicalUploadID + var options = CallOptions.defaults + options.timeout = timeout + let probeRequest = request + let callOptions = options + + _ = try await QiongcheProbeTimeout.run(timeout: timeout) { + try await managedTransport.serviceClient.getUploadRecovery( + probeRequest, + metadata: Metadata(), + options: callOptions + ) + } + return true + } catch { + return QiongcheEndpointReachability.isReachable(error: error) + } + } + + private func makeFactory(endpoint: URL, tls: TLSMode, timeout: Duration) -> ControlPlaneClientFactory { + let security: ControlPlaneTransportSecurity = switch tls { + case .plaintext: .plaintext + case .tls: .tls + } + return ControlPlaneClientFactory( + configuration: ControlPlaneTransportConfiguration( + endpoint: endpoint, + security: security, + requestTimeout: timeout + ) + ) + } +} diff --git a/Tests/DGWStoreTests/ArchebaseConfigStoreTests.swift b/Tests/DGWStoreTests/ArchebaseConfigStoreTests.swift index d7c0860..25a3591 100644 --- a/Tests/DGWStoreTests/ArchebaseConfigStoreTests.swift +++ b/Tests/DGWStoreTests/ArchebaseConfigStoreTests.swift @@ -78,6 +78,43 @@ import Testing #expect(try await store.load() == old) } +@Test func replaceOrInitializeWritesConfigWhenMissing() async throws { + let configURL = try temporaryConfigURL() + let store = ArchebaseConfigStore(configURL: configURL) + let config = try ArchebaseConfig(apiKey: "credential-v1", tags: ["device": "robot"]) + + try await store.replaceOrInitialize(config) + + #expect(try await store.load() == config) +} + +@Test func replaceOrInitializeOverwritesExistingConfig() async throws { + let configURL = try temporaryConfigURL() + let store = ArchebaseConfigStore(configURL: configURL) + let old = try ArchebaseConfig(apiKey: "credential-v1", tags: ["device": "old"]) + let new = try ArchebaseConfig(apiKey: "credential-v2", tags: ["device": "new"]) + try await store.initialize(old) + + try await store.replaceOrInitialize(new) + + #expect(try await store.load() == new) +} + +@Test func replaceOrInitializeKeepsOldFileOnInvalidNewConfig() async throws { + let configURL = try temporaryConfigURL() + let store = ArchebaseConfigStore(configURL: configURL) + let old = try ArchebaseConfig(apiKey: "credential-v1", tags: ["device": "old"]) + try await store.initialize(old) + + var invalid = old + invalid.apiKey = " " + _ = await #expect(throws: DataGatewayClientError.self) { + try await store.replaceOrInitialize(invalid) + } + + #expect(try await store.load() == old) +} + @Test func loadRejectsCorruptedJSON() async throws { let configURL = try temporaryConfigURL() try FileManager.default.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) diff --git a/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift b/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift index 7f0b705..67f530b 100644 --- a/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift @@ -107,6 +107,27 @@ func makePersistencePolicy(copyExternalFileIntoManagedStaging: Bool) -> LocalPer #expect(endpoints.deviceInitTLS == .tls) } +@Test func endpointNormalizationProducesStableJSONForEquivalentInput() throws { + let reorderedEndpointsJSON = """ + { + "deviceInit": { "port": 443, "host": "init.example.com", "scheme": "https" }, + "gateway": { "host": "gateway.example.com", "port": 50053, "scheme": "http" }, + "auth": { "scheme": "http", "port": 50051, "host": "auth.example.com" } + } + """ + + let normalized = try ArchebasePublicEndpoints.normalizedJSONString(endpointsJSON: validEndpointsJSON()) + let reorderedNormalized = try ArchebasePublicEndpoints.normalizedJSONString(endpointsJSON: reorderedEndpointsJSON) + + #expect(normalized == reorderedNormalized) + #expect(!normalized.contains("device_id")) + + let decoded = try ArchebasePublicEndpoints.decodeEndpoints(Data(normalized.utf8)) + #expect(decoded.auth == URL(string: "http://auth.example.com:50051")!) + #expect(decoded.gateway == URL(string: "http://gateway.example.com:50053")!) + #expect(decoded.deviceInit == URL(string: "https://init.example.com:443")!) +} + @Test func endpointDecodeRejectsLegacySchemaField() { let legacySchema = Data(""" { @@ -246,6 +267,36 @@ func makePersistencePolicy(copyExternalFileIntoManagedStaging: Bool) -> LocalPer #expect(error == .endpointsAlreadyInitialized(endpointsURL: endpointsURL.standardizedFileURL)) } +@Test func endpointReplaceOverwritesDifferentExistingEndpoints() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) + try ArchebasePublicEndpoints.replace( + endpointsJSON: validEndpointsJSON(authHost: "replacement-auth.example.com"), + endpointsURL: endpointsURL + ) + + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: endpointsURL) + #expect(endpoints.auth == URL(string: "http://replacement-auth.example.com:50051")!) +} + +@Test func endpointReplaceRejectsInvalidJSONWithoutChangingExistingFile() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) + let originalData = try Data(contentsOf: endpointsURL) + + #expect(throws: DataGatewayClientError.self) { + try ArchebasePublicEndpoints.replace(endpointsJSON: "{", endpointsURL: endpointsURL) + } + + #expect(try Data(contentsOf: endpointsURL) == originalData) + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: endpointsURL) + #expect(endpoints.auth == URL(string: "http://auth.example.com:50051")!) +} + @Test func endpointInitializeRejectsCorruptExistingFile() throws { let root = try filePreparationTemporaryRoot() let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) diff --git a/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift b/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift index d479751..db61e33 100644 --- a/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift @@ -141,19 +141,25 @@ struct LocalStackHarnessTests { #expect(script.contains("DGW_LOCAL_BOOTSTRAP_ADMIN_PASSWORD")) #expect(script.contains("DGW_LOCAL_BOOTSTRAP_MAX_TIME_SECONDS")) #expect(script.contains("DGW_LOCAL_BOOTSTRAP_CONNECT_TIMEOUT_SECONDS")) + #expect(script.contains("DGW_LOCAL_OPERATION_API_BASE")) #expect(script.contains(#"BOOTSTRAP_API_KEY_SUFFIX="${BOOTSTRAP_API_KEY_SUFFIX:0:26}""#)) #expect(script.contains("swift-key-${BOOTSTRAP_API_KEY_SUFFIX}")) + #expect(script.contains("DGW_LOCAL_BOOTSTRAP_API_KEY_NAME")) #expect(script.contains("DGW_LOCAL_CREDENTIAL_BASE64")) #expect(script.contains("DGW_LOCAL_DEVICE_ID")) #expect(script.contains("DGW_LOCAL_UNBOUND_DEVICE_ID")) #expect(script.contains("DGW_LOCAL_INIT_ENDPOINT")) #expect(script.contains("curl -sS -X POST")) - #expect(script.contains("/api/dataplatform/v1/auth/login")) - #expect(script.contains("/api/dataplatform/v1/sites")) - #expect(script.contains("/api/dataplatform/v1/sites/${SITE_ID}/api-keys")) - #expect(script.contains("/api/dataplatform/v1/devices:register")) - #expect(script.contains("/api/dataplatform/v1/deviceSuites")) - #expect(script.contains(":addDevice")) + #expect(script.contains("${OPERATION_API_BASE}/auth/login")) + #expect(script.contains("${OPERATION_API_BASE}/sites")) + #expect(script.contains("${OPERATION_API_BASE}/sites/${SITE_ID}/api-keys")) + #expect(script.contains("${OPERATION_API_BASE}/devices:register")) + #expect(script.contains("${OPERATION_API_BASE}/deviceSuites")) + #expect(script.contains(#""keyName":$(json_string "$BOOTSTRAP_API_KEY_NAME")"#)) + #expect(script.contains(#""suite":$(json_string "$SUITE_NAME")"#)) + #expect(script.contains("CreateSiteApiKey")) + #expect(script.contains("RemoveDeviceFromSuite")) + #expect(script.contains(":removeDevice")) } @Test func simulatorSmokeScriptSkipsPackageUpdatesForCachedDependencies() throws { diff --git a/Tests/DataGatewayClientIntegrationTests/QiongcheConfigParserTests.swift b/Tests/DataGatewayClientIntegrationTests/QiongcheConfigParserTests.swift new file mode 100644 index 0000000..44e1010 --- /dev/null +++ b/Tests/DataGatewayClientIntegrationTests/QiongcheConfigParserTests.swift @@ -0,0 +1,233 @@ +import Foundation +import Testing + +@testable import DataGatewayClient + +@Test func qiongcheConfigParserParsesValidConfig() throws { + let parsed = try QiongcheConfigParser.parse(validQiongcheConfig(deviceID: " robot-001 ")) + + #expect(parsed.deviceID == "robot-001") + #expect(parsed.resolvedEndpoints.auth == URL(string: "http://auth.example.com:50051")!) + #expect(parsed.resolvedEndpoints.gateway == URL(string: "http://gateway.example.com:50053")!) + #expect(parsed.resolvedEndpoints.deviceInit == URL(string: "https://init.example.com:443")!) + #expect(!parsed.normalizedEndpointsJSONString.contains("device_id")) + #expect(throws: Never.self) { + try ArchebasePublicEndpoints.decodeEndpoints(parsed.normalizedEndpointsJSONData) + } +} + +@Test func qiongcheEndpointFingerprintIgnoresJSONFieldOrder() throws { + let reordered = """ + { + "deviceInit": { "port": 443, "host": "init.example.com", "scheme": "https" }, + "gateway": { "host": "gateway.example.com", "scheme": "http", "port": 50053 }, + "auth": { "port": 50051, "scheme": "http", "host": "auth.example.com" }, + "device_id": "robot-001" + } + """ + + let parsed = try QiongcheConfigParser.parse(validQiongcheConfig()) + let reorderedParsed = try QiongcheConfigParser.parse(reordered) + + #expect(parsed.normalizedEndpointsJSONData == reorderedParsed.normalizedEndpointsJSONData) + #expect(parsed.endpointsSHA256Hex == reorderedParsed.endpointsSHA256Hex) + #expect(parsed.endpointsSHA256Hex.count == 64) +} + +@Test func qiongcheEndpointFingerprintChangesWhenEndpointChanges() throws { + let parsed = try QiongcheConfigParser.parse(validQiongcheConfig()) + let changed = try QiongcheConfigParser.parse(validQiongcheConfig(authHost: "auth-alt.example.com")) + + #expect(parsed.endpointsSHA256Hex != changed.endpointsSHA256Hex) + #expect(throws: Never.self) { + try ArchebasePublicEndpoints.decodeEndpoints(changed.normalizedEndpointsJSONData) + } +} + +@Test func qiongcheConfigParserRejectsMissingDeviceID() { + let error = #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "auth": { "scheme": "http", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } + + #expect(error == .invalidConfigString("device_id is required")) +} + +@Test func qiongcheConfigParserInvalidConfigUsesQiongcheSDKErrorWithoutEchoingConfig() { + let configString = """ + { + "device_id": "robot-001", + "auth": { "scheme": "grpc", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """ + + let error = #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(configString) + } + + if case .invalidConfigString(let message) = error { + #expect(message == "auth.scheme must be http or https") + #expect(!message.contains("robot-001")) + #expect(!message.contains("auth.example.com")) + } else { + Issue.record("expected invalidConfigString") + } +} + +@Test func qiongcheConfigParserRejectsEmptyDeviceID() { + let error = #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(validQiongcheConfig(deviceID: " ")) + } + + #expect(error == .invalidConfigString("device_id must not be empty")) +} + +@Test func qiongcheConfigParserRejectsControlCharacterDeviceID() { + let error = #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot\\u0007001", + "auth": { "scheme": "http", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } + + #expect(error == .invalidConfigString("device_id contains unsupported control characters")) +} + +@Test func qiongcheConfigParserRejectsMissingEndpoint() { + let error = #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot-001", + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } + + #expect(error == .invalidConfigString("auth endpoint is required")) +} + +@Test func qiongcheConfigParserRejectsMissingGatewayAndDeviceInit() { + #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot-001", + "auth": { "scheme": "http", "host": "auth.example.com", "port": 50051 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } + + #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot-001", + "auth": { "scheme": "http", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 } + } + """) + } +} + +@Test func qiongcheConfigParserRejectsWrongDeviceIDType() { + let error = #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": 1001, + "auth": { "scheme": "http", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } + + #expect(error == .invalidConfigString("device_id is required")) +} + +@Test func qiongcheConfigParserRejectsInvalidEndpointSchemeHostAndPort() { + #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot-001", + "auth": { "scheme": "grpc", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } + + #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot-001", + "auth": { "scheme": "http", "host": " ", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } + + #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot-001", + "auth": { "scheme": "http", "host": "auth.example.com", "port": 65536 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } +} + +@Test func qiongcheConfigParserRejectsUnsupportedTopLevelField() { + let error = #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot-001", + "auth": { "scheme": "http", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 }, + "extra": true + } + """) + } + + #expect(error == .invalidConfigString("unsupported top-level field 'extra'")) +} + +@Test func qiongcheConfigParserReusesEndpointValidation() { + let error = #expect(throws: QiongcheSDKError.self) { + try QiongcheConfigParser.parse(""" + { + "device_id": "robot-001", + "auth": { "schema": "http", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """) + } + + #expect(error == .invalidConfigString("auth.schema is not supported; use scheme")) +} + +func validQiongcheConfig(deviceID: String = "robot-001", authHost: String = "auth.example.com") -> String { + """ + { + "device_id": "\(deviceID)", + "auth": { "scheme": "http", "host": "\(authHost)", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """ +} diff --git a/Tests/DataGatewayClientIntegrationTests/QiongcheDataGatewaySDKTests.swift b/Tests/DataGatewayClientIntegrationTests/QiongcheDataGatewaySDKTests.swift new file mode 100644 index 0000000..410d062 --- /dev/null +++ b/Tests/DataGatewayClientIntegrationTests/QiongcheDataGatewaySDKTests.swift @@ -0,0 +1,638 @@ +import DGWControlPlane +import DGWStore +import Foundation +import GRPCCore +import Testing + +@testable import DataGatewayClient + +@Test func qiongcheSDKPathsUseTemporaryRootWhenProvided() throws { + let root = try qiongcheTemporaryRoot() + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Archebase", isDirectory: true) + + let paths = try QiongcheSDKPaths(rootURL: root) + + #expect(paths.rootURL == root.standardizedFileURL) + #expect(paths.endpointsURL == root.appendingPathComponent("archebase-endpoints.json").standardizedFileURL) + #expect(paths.configURL == root.appendingPathComponent("archebase-config.json").standardizedFileURL) + #expect(paths.stateURL == root.appendingPathComponent("qiongche-sdk-state.json").standardizedFileURL) + #expect(paths.persistRootURL == root.appendingPathComponent("Uploads", isDirectory: true).standardizedFileURL) +} + +@Test func qiongcheSDKPathsDefaultToApplicationSupportArchebase() throws { + let paths = try QiongcheSDKPaths() + + #expect(paths.rootURL.lastPathComponent == "Archebase") + #expect(paths.rootURL.path.contains("Application Support")) + #expect(paths.endpointsURL.lastPathComponent == "archebase-endpoints.json") + #expect(paths.configURL.lastPathComponent == "archebase-config.json") + #expect(paths.stateURL.lastPathComponent == "qiongche-sdk-state.json") + #expect(paths.persistRootURL.lastPathComponent == "Uploads") +} + +@Test func qiongcheSDKStateStoreWritesAndLoadsState() throws { + let paths = try QiongcheSDKPaths(rootURL: qiongcheTemporaryRoot()) + let store = QiongcheSDKStateStore(stateURL: paths.stateURL) + let state = try QiongcheSDKState( + deviceID: "robot-001", + endpointsSHA256: String(repeating: "a", count: 64), + initializedAtUnix: 1_778_840_000 + ) + + try store.replace(state) + + #expect(try store.load() == state) + let raw = try String(contentsOf: paths.stateURL, encoding: .utf8) + #expect(raw.contains("\"device_id\"")) + #expect(raw.contains("\"endpoints_sha256\"")) + #expect(!raw.contains("api_key")) +} + +@Test func qiongcheSDKStateStoreOverwritesExistingState() throws { + let paths = try QiongcheSDKPaths(rootURL: qiongcheTemporaryRoot()) + let store = QiongcheSDKStateStore(stateURL: paths.stateURL) + let old = try QiongcheSDKState( + deviceID: "robot-old", + endpointsSHA256: String(repeating: "a", count: 64), + initializedAtUnix: 1 + ) + let new = try QiongcheSDKState( + deviceID: "robot-new", + endpointsSHA256: String(repeating: "b", count: 64), + initializedAtUnix: 2 + ) + + try store.replace(old) + try store.replace(new) + + #expect(try store.load() == new) +} + +@Test func qiongcheSDKStateStoreCanReplaceCorruptedState() throws { + let paths = try QiongcheSDKPaths(rootURL: qiongcheTemporaryRoot()) + try FileManager.default.createDirectory(at: paths.rootURL, withIntermediateDirectories: true) + try Data("not-json".utf8).write(to: paths.stateURL) + let store = QiongcheSDKStateStore(stateURL: paths.stateURL) + + #expect(throws: DataGatewayClientError.self) { + _ = try store.load() + } + + let replacement = try QiongcheSDKState( + deviceID: "robot-001", + endpointsSHA256: String(repeating: "c", count: 64), + initializedAtUnix: 3 + ) + try store.replace(replacement) + + #expect(try store.load() == replacement) +} + +@Test func qiongcheDeviceProvisioningFakeRecordsInputs() async throws { + let provisioner = RecordingQiongcheDeviceProvisioner( + result: try ArchebaseConfig(apiKey: "credential-v1", tags: ["device": "robot"]) + ) + let endpoint = URL(string: "https://init.example.com:443")! + + let config = try await provisioner.initDevice( + deviceID: "robot-001", + deviceInitEndpoint: endpoint, + tls: .tls, + timeout: .seconds(7) + ) + + #expect(config == (try ArchebaseConfig(apiKey: "credential-v1", tags: ["device": "robot"]))) + let records = await provisioner.records() + #expect(records == [ + .init(deviceID: "robot-001", endpoint: endpoint, tls: .tls, timeout: .seconds(7)), + ]) +} + +@Test func qiongcheDefaultDeviceProvisionerCanBeConstructedWithoutLocalFiles() { + _ = DefaultQiongcheDeviceProvisioner() +} + +@Test func qiongcheSDKActorDefaultInitSucceedsWithTemporaryRoot() throws { + _ = try QiongcheDataGatewaySDK(rootURL: qiongcheTemporaryRoot()) +} + +@Test func qiongcheSDKActorTestInitAcceptsFakeDependencies() throws { + _ = try QiongcheDataGatewaySDK( + rootURL: qiongcheTemporaryRoot(), + deviceInitTimeout: .seconds(5), + readinessTimeout: .seconds(2), + deviceProvisioner: RecordingQiongcheDeviceProvisioner( + result: try ArchebaseConfig(apiKey: "credential-v1", tags: [:]) + ), + readinessProbe: AlwaysReachableQiongcheProbe(), + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 1_778_840_000)) + ) +} + +@Test func qiongcheSaveConfigAndInitFirstCallWritesEndpointsConfigAndState() async throws { + let root = try qiongcheTemporaryRoot() + let paths = try QiongcheSDKPaths(rootURL: root) + let remoteConfig = try ArchebaseConfig(apiKey: "credential-v1", tags: ["device": "robot"]) + let provisioner = RecordingQiongcheDeviceProvisioner(result: remoteConfig) + let sdk = try QiongcheDataGatewaySDK( + rootURL: root, + deviceProvisioner: provisioner, + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 1_778_840_000)) + ) + + try await sdk.saveConfigAndInit(configString: validQiongcheConfig(deviceID: "robot-001")) + + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: paths.endpointsURL) + #expect(endpoints.auth == URL(string: "http://auth.example.com:50051")!) + #expect(try await ArchebaseConfigStore(configURL: paths.configURL).load() == remoteConfig) + let state = try QiongcheSDKStateStore(stateURL: paths.stateURL).load() + let parsed = try QiongcheConfigParser.parse(validQiongcheConfig(deviceID: "robot-001")) + #expect(state.deviceID == "robot-001") + #expect(state.endpointsSHA256 == parsed.endpointsSHA256Hex) + #expect(state.initializedAtUnix == 1_778_840_000) + + let records = await provisioner.records() + #expect(records == [ + .init( + deviceID: "robot-001", + endpoint: URL(string: "https://init.example.com:443")!, + tls: .tls, + timeout: .seconds(10) + ), + ]) +} + +@Test func qiongcheSaveConfigAndInitOverwritesExistingLocalFiles() async throws { + let root = try qiongcheTemporaryRoot() + let paths = try QiongcheSDKPaths(rootURL: root) + try ArchebasePublicEndpoints.replace( + endpointsJSON: validQiongcheConfig(deviceID: "robot-old", authHost: "old-auth.example.com"), + endpointsURL: paths.endpointsURL + ) + try await ArchebaseConfigStore(configURL: paths.configURL) + .replaceOrInitialize(try ArchebaseConfig(apiKey: "credential-old", tags: ["device": "old"])) + try QiongcheSDKStateStore(stateURL: paths.stateURL).replace(try QiongcheSDKState( + deviceID: "robot-old", + endpointsSHA256: String(repeating: "a", count: 64), + initializedAtUnix: 1 + )) + + let remoteConfig = try ArchebaseConfig(apiKey: "credential-new", tags: ["device": "new"]) + let sdk = try QiongcheDataGatewaySDK( + rootURL: root, + deviceProvisioner: RecordingQiongcheDeviceProvisioner(result: remoteConfig), + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 2)) + ) + + try await sdk.saveConfigAndInit( + configString: validQiongcheConfig(deviceID: "robot-new", authHost: "new-auth.example.com") + ) + + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: paths.endpointsURL) + #expect(endpoints.auth == URL(string: "http://new-auth.example.com:50051")!) + #expect(try await ArchebaseConfigStore(configURL: paths.configURL).load() == remoteConfig) + let state = try QiongcheSDKStateStore(stateURL: paths.stateURL).load() + #expect(state.deviceID == "robot-new") + #expect(state.initializedAtUnix == 2) +} + +@Test func qiongcheSaveConfigAndInitRepeatedSameConfigCallsRemoteEachTime() async throws { + let root = try qiongcheTemporaryRoot() + let provisioner = RecordingQiongcheDeviceProvisioner( + result: try ArchebaseConfig(apiKey: "credential-v1", tags: [:]) + ) + let sdk = try QiongcheDataGatewaySDK( + rootURL: root, + deviceProvisioner: provisioner, + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 1)) + ) + + try await sdk.saveConfigAndInit(configString: validQiongcheConfig(deviceID: "robot-001")) + try await sdk.saveConfigAndInit(configString: validQiongcheConfig(deviceID: "robot-001")) + + #expect(await provisioner.records().count == 2) +} + +@Test func qiongcheSaveConfigAndInitRebuildsConfigWhenStateExistsButConfigMissing() async throws { + let root = try qiongcheTemporaryRoot() + let paths = try QiongcheSDKPaths(rootURL: root) + try ArchebasePublicEndpoints.replace(endpointsJSON: validQiongcheConfig(), endpointsURL: paths.endpointsURL) + try QiongcheSDKStateStore(stateURL: paths.stateURL).replace(try QiongcheSDKState( + deviceID: "robot-001", + endpointsSHA256: String(repeating: "d", count: 64), + initializedAtUnix: 1 + )) + let remoteConfig = try ArchebaseConfig(apiKey: "credential-v1", tags: ["device": "rebuilt"]) + let sdk = try QiongcheDataGatewaySDK( + rootURL: root, + deviceProvisioner: RecordingQiongcheDeviceProvisioner(result: remoteConfig), + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 2)) + ) + + try await sdk.saveConfigAndInit(configString: validQiongcheConfig(deviceID: "robot-001")) + + #expect(try await ArchebaseConfigStore(configURL: paths.configURL).load() == remoteConfig) + #expect(try QiongcheSDKStateStore(stateURL: paths.stateURL).load().initializedAtUnix == 2) +} + +@Test func qiongcheSaveConfigAndInitKeepsOldFilesWhenRemoteInitFails() async throws { + let root = try qiongcheTemporaryRoot() + let paths = try QiongcheSDKPaths(rootURL: root) + let oldConfigString = validQiongcheConfig(deviceID: "robot-old", authHost: "old-auth.example.com") + let oldRemoteConfig = try ArchebaseConfig(apiKey: "credential-old", tags: ["device": "old"]) + let oldState = try QiongcheSDKState( + deviceID: "robot-old", + endpointsSHA256: String(repeating: "a", count: 64), + initializedAtUnix: 1 + ) + try ArchebasePublicEndpoints.replace(endpointsJSON: oldConfigString, endpointsURL: paths.endpointsURL) + try await ArchebaseConfigStore(configURL: paths.configURL).replaceOrInitialize(oldRemoteConfig) + try QiongcheSDKStateStore(stateURL: paths.stateURL).replace(oldState) + + let sdk = try QiongcheDataGatewaySDK( + rootURL: root, + deviceProvisioner: RecordingQiongcheDeviceProvisioner( + error: .gatewayFailed(statusCode: 14, detailCode: nil, message: "unavailable") + ), + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 2)) + ) + + await #expect(throws: DataGatewayClientError.self) { + try await sdk.saveConfigAndInit( + configString: validQiongcheConfig(deviceID: "robot-new", authHost: "new-auth.example.com") + ) + } + + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: paths.endpointsURL) + #expect(endpoints.auth == URL(string: "http://old-auth.example.com:50051")!) + #expect(try await ArchebaseConfigStore(configURL: paths.configURL).load() == oldRemoteConfig) + #expect(try QiongcheSDKStateStore(stateURL: paths.stateURL).load() == oldState) +} + +@Test func qiongcheSaveConfigAndInitPropagatesEndpointPersistenceFailure() async throws { + let provisioner = RecordingQiongcheDeviceProvisioner( + result: try ArchebaseConfig(apiKey: "credential-v1", tags: [:]) + ) + let persister = RecordingQiongcheLocalPersister(failures: [.endpoints]) + let sdk = try QiongcheDataGatewaySDK( + rootURL: qiongcheTemporaryRoot(), + deviceProvisioner: provisioner, + localPersister: persister, + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 1)) + ) + + await #expect(throws: DataGatewayClientError.self) { + try await sdk.saveConfigAndInit(configString: validQiongcheConfig()) + } + + #expect(await persister.operations() == [.endpoints]) + #expect(await provisioner.records().count == 1) +} + +@Test func qiongcheSaveConfigAndInitPropagatesConfigPersistenceFailure() async throws { + let persister = RecordingQiongcheLocalPersister(failures: [.config]) + let sdk = try QiongcheDataGatewaySDK( + rootURL: qiongcheTemporaryRoot(), + deviceProvisioner: RecordingQiongcheDeviceProvisioner( + result: try ArchebaseConfig(apiKey: "credential-v1", tags: [:]) + ), + localPersister: persister, + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 1)) + ) + + await #expect(throws: DataGatewayClientError.self) { + try await sdk.saveConfigAndInit(configString: validQiongcheConfig()) + } + + #expect(await persister.operations() == [.endpoints, .config]) +} + +@Test func qiongcheSaveConfigAndInitCanRecoverAfterStatePersistenceFailure() async throws { + let provisioner = RecordingQiongcheDeviceProvisioner( + result: try ArchebaseConfig(apiKey: "credential-v1", tags: [:]) + ) + let persister = RecordingQiongcheLocalPersister(failures: [.state]) + let sdk = try QiongcheDataGatewaySDK( + rootURL: qiongcheTemporaryRoot(), + deviceProvisioner: provisioner, + localPersister: persister, + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 1)) + ) + + await #expect(throws: DataGatewayClientError.self) { + try await sdk.saveConfigAndInit(configString: validQiongcheConfig()) + } + + try await sdk.saveConfigAndInit(configString: validQiongcheConfig()) + + #expect(await persister.operations() == [.endpoints, .config, .state, .endpoints, .config, .state]) + #expect(await provisioner.records().count == 2) +} + +@Test func qiongcheEndpointReachabilityClassifiesReachableRPCFailures() { + for code in [ + RPCError.Code.unauthenticated, + .permissionDenied, + .invalidArgument, + .notFound, + .failedPrecondition, + ] { + #expect(QiongcheEndpointReachability.isReachable(error: RPCError(code: code, message: "reachable"))) + } +} + +@Test func qiongcheEndpointReachabilityClassifiesUnreachableRPCFailures() { + for code in [ + RPCError.Code.unavailable, + .deadlineExceeded, + .cancelled, + ] { + #expect(!QiongcheEndpointReachability.isReachable(error: RPCError(code: code, message: "unreachable"))) + } +} + +@Test func qiongcheEndpointReachabilityClassifiesCancellationAsUnreachable() { + #expect(!QiongcheEndpointReachability.isReachable(error: CancellationError())) +} + +@Test func qiongcheProbeTimeoutReturnsFastTaskResult() async throws { + let result = try await QiongcheProbeTimeout.run(timeout: .seconds(1)) { + true + } + + #expect(result) +} + +@Test func qiongcheProbeTimeoutThrowsAndClassifiesAsUnreachable() async { + let error = await #expect(throws: QiongcheProbeTimeoutError.self) { + try await QiongcheProbeTimeout.run( + timeout: Duration(secondsComponent: 0, attosecondsComponent: 10_000_000_000_000_000) + ) { + try await Task.sleep(for: .seconds(1)) + return true + } + } + + if let error { + #expect(!QiongcheEndpointReachability.isReachable(error: error)) + } +} + +@Test func qiongcheDefaultEndpointProbeReturnsFalseForInvalidTLSConfiguration() async { + let probe = DefaultQiongcheEndpointProbe() + let endpoint = URL(string: "https://auth.example.com:443")! + + let authReachable = await probe.authEndpointReachable( + endpoint: endpoint, + tls: .plaintext, + timeout: .seconds(1) + ) + let gatewayReachable = await probe.gatewayEndpointReachable( + endpoint: endpoint, + tls: .plaintext, + timeout: .seconds(1) + ) + + #expect(!authReachable) + #expect(!gatewayReachable) +} + +@Test func qiongcheIsReadyToUploadReturnsFalseWhenConfigMissing() async throws { + let root = try qiongcheTemporaryRoot() + let paths = try QiongcheSDKPaths(rootURL: root) + try ArchebasePublicEndpoints.replace(endpointsJSON: validQiongcheConfig(), endpointsURL: paths.endpointsURL) + let sdk = try qiongcheReadySDK(rootURL: root, authReachable: true, gatewayReachable: true) + + #expect(await sdk.isReadyToUpload() == false) +} + +@Test func qiongcheIsReadyToUploadReturnsFalseWhenEndpointsMissing() async throws { + let root = try qiongcheTemporaryRoot() + let paths = try QiongcheSDKPaths(rootURL: root) + try await ArchebaseConfigStore(configURL: paths.configURL) + .replaceOrInitialize(try ArchebaseConfig(apiKey: "credential-v1", tags: [:])) + let sdk = try qiongcheReadySDK(rootURL: root, authReachable: true, gatewayReachable: true) + + #expect(await sdk.isReadyToUpload() == false) +} + +@Test func qiongcheIsReadyToUploadReturnsFalseWhenEndpointsAreCorrupt() async throws { + let root = try qiongcheTemporaryRoot() + let paths = try QiongcheSDKPaths(rootURL: root) + try FileManager.default.createDirectory(at: paths.rootURL, withIntermediateDirectories: true) + try Data("not-json".utf8).write(to: paths.endpointsURL) + try await ArchebaseConfigStore(configURL: paths.configURL) + .replaceOrInitialize(try ArchebaseConfig(apiKey: "credential-v1", tags: [:])) + let sdk = try qiongcheReadySDK(rootURL: root, authReachable: true, gatewayReachable: true) + + #expect(await sdk.isReadyToUpload() == false) +} + +@Test func qiongcheIsReadyToUploadReturnsTrueWhenAuthAndGatewayReachable() async throws { + let root = try qiongcheTemporaryRoot() + try await writeQiongcheReadyFiles(rootURL: root) + let sdk = try qiongcheReadySDK(rootURL: root, authReachable: true, gatewayReachable: true) + + #expect(await sdk.isReadyToUpload()) +} + +@Test func qiongcheIsReadyToUploadReturnsFalseWhenEitherEndpointIsUnreachable() async throws { + let root = try qiongcheTemporaryRoot() + try await writeQiongcheReadyFiles(rootURL: root) + let authDownSDK = try qiongcheReadySDK(rootURL: root, authReachable: false, gatewayReachable: true) + let gatewayDownSDK = try qiongcheReadySDK(rootURL: root, authReachable: true, gatewayReachable: false) + + #expect(await authDownSDK.isReadyToUpload() == false) + #expect(await gatewayDownSDK.isReadyToUpload() == false) +} + +@Test func qiongcheIsReadyToUploadReturnsFalseWhenProbeTimesOut() async throws { + let root = try qiongcheTemporaryRoot() + try await writeQiongcheReadyFiles(rootURL: root) + let sdk = try QiongcheDataGatewaySDK( + rootURL: root, + readinessTimeout: Duration(secondsComponent: 0, attosecondsComponent: 10_000_000_000_000_000), + deviceProvisioner: RecordingQiongcheDeviceProvisioner( + result: try ArchebaseConfig(apiKey: "credential-v1", tags: [:]) + ), + readinessProbe: TimeoutQiongcheProbe(), + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 1)) + ) + + #expect(await sdk.isReadyToUpload() == false) +} + +func qiongcheTemporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("qiongche-sdk-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root +} + +private func writeQiongcheReadyFiles(rootURL: URL) async throws { + let paths = try QiongcheSDKPaths(rootURL: rootURL) + try ArchebasePublicEndpoints.replace(endpointsJSON: validQiongcheConfig(), endpointsURL: paths.endpointsURL) + try await ArchebaseConfigStore(configURL: paths.configURL) + .replaceOrInitialize(try ArchebaseConfig(apiKey: "credential-v1", tags: [:])) +} + +private func qiongcheReadySDK( + rootURL: URL, + authReachable: Bool, + gatewayReachable: Bool +) throws -> QiongcheDataGatewaySDK { + try QiongcheDataGatewaySDK( + rootURL: rootURL, + deviceProvisioner: RecordingQiongcheDeviceProvisioner( + result: try ArchebaseConfig(apiKey: "credential-v1", tags: [:]) + ), + readinessProbe: ConfiguredQiongcheProbe(authReachable: authReachable, gatewayReachable: gatewayReachable), + clock: FixedQiongcheSDKClock(date: Date(timeIntervalSince1970: 1)) + ) +} + +private actor RecordingQiongcheDeviceProvisioner: QiongcheDeviceProvisioning { + struct Record: Equatable { + var deviceID: String + var endpoint: URL + var tls: TLSMode + var timeout: Duration + } + + private enum Outcome { + case success(ArchebaseConfig) + case failure(DataGatewayClientError) + } + + private let outcome: Outcome + private var recorded: [Record] = [] + + init(result: ArchebaseConfig) { + self.outcome = .success(result) + } + + init(error: DataGatewayClientError) { + self.outcome = .failure(error) + } + + func initDevice( + deviceID: String, + deviceInitEndpoint: URL, + tls: TLSMode, + timeout: Duration + ) async throws -> ArchebaseConfig { + self.recorded.append(.init(deviceID: deviceID, endpoint: deviceInitEndpoint, tls: tls, timeout: timeout)) + switch self.outcome { + case .success(let config): + return config + case .failure(let error): + throw error + } + } + + func records() -> [Record] { + self.recorded + } +} + +private struct AlwaysReachableQiongcheProbe: QiongcheEndpointProbing { + func authEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool { + _ = (endpoint, tls, timeout) + return true + } + + func gatewayEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool { + _ = (endpoint, tls, timeout) + return true + } +} + +private struct ConfiguredQiongcheProbe: QiongcheEndpointProbing { + var authReachable: Bool + var gatewayReachable: Bool + + func authEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool { + _ = (endpoint, tls, timeout) + return self.authReachable + } + + func gatewayEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool { + _ = (endpoint, tls, timeout) + return self.gatewayReachable + } +} + +private struct TimeoutQiongcheProbe: QiongcheEndpointProbing { + func authEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool { + _ = (endpoint, tls) + return await self.timedOutResult(timeout: timeout) + } + + func gatewayEndpointReachable(endpoint: URL, tls: TLSMode, timeout: Duration) async -> Bool { + _ = (endpoint, tls) + return await self.timedOutResult(timeout: timeout) + } + + private func timedOutResult(timeout: Duration) async -> Bool { + do { + return try await QiongcheProbeTimeout.run(timeout: timeout) { + try await Task.sleep(for: .seconds(1)) + return true + } + } catch { + return false + } + } +} + +private struct FixedQiongcheSDKClock: QiongcheSDKClock { + var date: Date + + func now() async -> Date { + self.date + } +} + +private actor RecordingQiongcheLocalPersister: QiongcheLocalPersisting { + enum Stage: Equatable, Sendable { + case endpoints + case config + case state + } + + private var failures: [Stage] + private var recordedOperations: [Stage] = [] + + init(failures: [Stage]) { + self.failures = failures + } + + func replaceEndpoints(endpointsJSON: String, endpointsURL: URL) async throws { + _ = (endpointsJSON, endpointsURL) + try self.record(.endpoints) + } + + func replaceConfig(_ config: ArchebaseConfig, configURL: URL) async throws { + _ = (config, configURL) + try self.record(.config) + } + + func replaceState(_ state: QiongcheSDKState, stateURL: URL) async throws { + _ = (state, stateURL) + try self.record(.state) + } + + func operations() -> [Stage] { + self.recordedOperations + } + + private func record(_ stage: Stage) throws { + self.recordedOperations.append(stage) + if self.failures.first == stage { + self.failures.removeFirst() + throw DataGatewayClientError.persistenceFailed("injected qiongche \(stage) failure") + } + } +}