diff --git a/product-source/hololake-native-desktop/contracts/ios-mobile-client-v1.json b/product-source/hololake-native-desktop/contracts/ios-mobile-client-v1.json new file mode 100644 index 000000000..976e1bf43 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/ios-mobile-client-v1.json @@ -0,0 +1,49 @@ +{ + "schema": "hololake.ios-mobile-client.contract/v1", + "record_id": "HLP-IOS-CLIENT-001", + "state": "NUMBERED_SOURCE_SIMULATOR_AND_DEVELOPMENT_SIGNED_IPA_ACCEPTED", + "role": "REMOTE_BODY_ENTRY_OF_THE_SAME_PERSONA_SYSTEM", + "root_node": "USER_LOCAL_COMPUTER_TERMINAL", + "numbered_transport": { + "desktop_module": "HLP-MOD-OFFICIAL-MOBILE-SYNC-0001", + "desktop_route_family": "HLP-NIPC-MOD-0030/HLP-NIPC-OP-0141..0146/HLP-NIPC-TGT-0030", + "wire_schema": "hololake.mobile-sync/v1", + "client_number": "HLP-IOS-CLIENT-001", + "unregistered_route_access": "FAIL_CLOSED" + }, + "security": { + "pairing": "CHACHA20_POLY1305_ONE_TIME_SECRET", + "session": "CHACHA20_POLY1305_STRICT_COUNTER", + "key_storage": "KEYCHAIN_WHEN_UNLOCKED_THIS_DEVICE_ONLY", + "local_network_only": true, + "background_polling": false, + "remote_desktop_clone": false, + "desktop_offline_execution": false + }, + "interface": { + "framework": "SWIFTUI", + "visual_family": "HOLOLAKE_TRADITIONAL_SURFACE_MOBILE_ADAPTATION", + "idle_state": "STATIC", + "manual_sync_only": true, + "pairing_entry": ["CUSTOM_URL_SCHEME", "PASTEBOARD"], + "projections": ["CHANNEL_INTEGRITY", "WEB_NOVEL_COUNTS_AND_WORKS", "EDUCATION_COUNTS", "BOUNDED_CAPTURE"] + }, + "acceptance": { + "simulator_build": "PASS_SIGNED_LOCAL_SIMULATOR_IPHONE_17_PRO_IOS_26_5", + "simulator_visual_inspection": "PASS_PAIRING_AND_NUMBER_BOUNDARY_SURFACE_IDLE_STATIC", + "unit_tests": "PASS_4", + "development_signed_device_archive": "PASS_APPLE_DEVELOPMENT_TEAM_825A9L3G7Q", + "debugging_ipa_export": "PASS", + "version": "0.5.0", + "build": "1", + "bundle_identifier": "com.guanghulab.hololake", + "xcode": "26.6_17F113", + "archive_application_binary_sha256": "7021c9cde2f7bdf01cda6200669be528a58d27ef41adbd3d58d7135ff7245702", + "archive_application_cdhash": "11ae3a8ac0d761e4d4b4de229dbb19291de29e03", + "debugging_ipa_sha256": "a6b505e1a7dc10febd831a540396bb9530f78bb2429d258962f0250e5597b1f0", + "provisioning_profile_uuid": "fe5fe72c-4826-4e19-9672-d9868cc4491e", + "provisioning_profile_expires": "2027-07-19", + "desktop_delivery_path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake-iPhone-0.5.0-Development.ipa", + "real_iphone_pair_and_sync": "NOT_CLAIMED" + } +} diff --git a/product-source/hololake-native-desktop/contracts/mobile-sync-v1.json b/product-source/hololake-native-desktop/contracts/mobile-sync-v1.json index af29f9e4d..0336ffff1 100644 --- a/product-source/hololake-native-desktop/contracts/mobile-sync-v1.json +++ b/product-source/hololake-native-desktop/contracts/mobile-sync-v1.json @@ -65,12 +65,12 @@ }, "client_scope": { "desktop_bridge": "IN_THIS_ADMISSION", - "ios_application_source": "NOT_IN_DECLARED_DONOR_CANDIDATE", - "ios_installable_package": "PENDING_SEPARATE_ADMISSION", + "ios_application_source": "SEPARATELY_ADMITTED_AT_MOBILE_IOS_WITH_HLP_IOS_CLIENT_001", + "ios_installable_package": "DEVELOPMENT_SIGNED_DEBUGGING_IPA_EXPORTED", "claim_real_iphone_end_to_end_accepted": false }, "current_acceptance": { - "state": "PASS_DESKTOP_BRIDGE_ONLY", + "state": "PASS_DESKTOP_BRIDGE_AND_SEPARATELY_ADMITTED_IOS_CLIENT_ARTIFACT", "module_package_sha256": "61a9d402c936d7477609bcfa6eb4ad6e83cb6c089ba51b3db09a2d6259185924", "module_package_signature": "PASS_EMBEDDED_PRODUCT_TRUST", "signed_app_binary_sha256": "9be06d6c5dbd4f46f6f925142d633f12b42ee553873c2342f9804bcf39dde7b4", @@ -84,7 +84,10 @@ "desktop_listener": "PASS_EXPLICIT_START_REACHABLE_STATUS_AND_EXPLICIT_STOP", "restart_restore": "PASS_ACTIVE_MODULE_RESTORED_LISTENER_OFFLINE", "legacy_account_readback": "PASS_ONE_PAIRED_DEVICE_ONE_ISOLATED_CAPTURE_RETAINED", - "live_iphone_pairing_this_cycle": "NOT_CLAIMED", + "ios_client_contract": "contracts/ios-mobile-client-v1.json", + "ios_client_source_build_and_simulator": "PASS", + "ios_development_signed_ipa": "PASS_SHA256_a6b505e1a7dc10febd831a540396bb9530f78bb2429d258962f0250e5597b1f0", + "live_iphone_pairing_this_cycle": "NOT_CLAIMED_NO_PHYSICAL_DEVICE_USED", "notarization": "FINAL_RELEASE_CANDIDATE_PENDING" } } diff --git a/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json b/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json index 477bf8033..0db17c74e 100644 --- a/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json +++ b/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json @@ -1,7 +1,7 @@ { "schema": "hololake.module-donor-admission-registry/v1", "record_id": "HLP-MODULE-DONOR-ADMISSION-001", - "state": "SEVEN_CANDIDATES_ADMITTED_DONOR_SOURCES_REMAIN_READ_ONLY", + "state": "EIGHT_CANDIDATES_ADMITTED_DONOR_SOURCES_REMAIN_READ_ONLY", "root_rule": { "official_base": "HOLOLAKE_0.5.0_NUMBERED_IPC_ROOT", "repair_old_application_in_place": false, @@ -160,7 +160,7 @@ "runtime_module_number": "HLP-MOD-OFFICIAL-MOBILE-SYNC-0001", "numbered_ipc_module": "HLP-NIPC-MOD-0030", "acceptance_evidence": "contracts/mobile-sync-v1.json#current_acceptance", - "boundary": "DESKTOP_ROOT_NODE_SAME_LAN_BRIDGE_ONLY; EXPLICIT_LISTENER; IOS_INSTALLABLE_PACKAGE_PENDING_SEPARATE_ADMISSION; NO_SECOND_PERSONA_SYSTEM" + "boundary": "DESKTOP_ROOT_NODE_SAME_LAN_BRIDGE_ONLY; EXPLICIT_LISTENER; IOS CLIENT REQUIRES SEPARATE NUMBERED ADMISSION; NO_SECOND_PERSONA_SYSTEM" }, { "admission_order": 7, @@ -180,6 +180,23 @@ "numbered_ipc_module": "HLP-NIPC-MOD-0031", "acceptance_evidence": "contracts/dynamic-language-world-visual-system.json#current_acceptance", "boundary": "QODER_LOCKED_VISUAL_SOURCE_PLUS_REALITY_TIME_AND_PUBLIC_WEATHER_PROJECTION; ALL_FUNCTION_ROUTES_REMAIN_NUMBERED; IDLE_STATIC; NO_FAKE_WEATHER_BROADCAST_OR_METRICS" + }, + { + "admission_order": 8, + "candidate_number": "HLP-DONOR-CAND-0008", + "name": "ios_numbered_remote_body_entry", + "state": "ADMITTED_SOURCE_SIMULATOR_AND_DEVELOPMENT_SIGNED_IPA_ACCEPTED", + "paths": [ + "contracts/ios-mobile-client-v1.json", + "mobile/ios/project.yml", + "mobile/ios/Sources", + "mobile/ios/Tests" + ], + "client_number": "HLP-IOS-CLIENT-001", + "desktop_runtime_module_number": "HLP-MOD-OFFICIAL-MOBILE-SYNC-0001", + "numbered_ipc_module": "HLP-NIPC-MOD-0030", + "acceptance_evidence": "contracts/ios-mobile-client-v1.json#acceptance", + "boundary": "IPHONE IS A THIN REMOTE BODY ENTRY; DESKTOP REMAINS ROOT NODE; SAME LAN EXPLICIT PAIR AND MANUAL SYNC ONLY; NO SECOND PERSONA SYSTEM; REAL IPHONE END TO END NOT CLAIMED" } ], "admission_gate": [ diff --git a/product-source/hololake-native-desktop/mobile/ios/.gitignore b/product-source/hololake-native-desktop/mobile/ios/.gitignore new file mode 100644 index 000000000..45706c522 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/.gitignore @@ -0,0 +1,4 @@ +build/ +*.xcuserstate +xcuserdata/ + diff --git a/product-source/hololake-native-desktop/mobile/ios/ExportOptions.Debug.plist b/product-source/hololake-native-desktop/mobile/ios/ExportOptions.Debug.plist new file mode 100644 index 000000000..01dcb8305 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/ExportOptions.Debug.plist @@ -0,0 +1,16 @@ + + + + + method + debugging + signingStyle + automatic + teamID + 825A9L3G7Q + stripSwiftSymbols + + uploadSymbols + + + diff --git a/product-source/hololake-native-desktop/mobile/ios/Generated/HoloLakeMobile-Info.plist b/product-source/hololake-native-desktop/mobile/ios/Generated/HoloLakeMobile-Info.plist new file mode 100644 index 000000000..d03325242 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Generated/HoloLakeMobile-Info.plist @@ -0,0 +1,56 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + HoloLake + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.guanghulab.hololake.pair + CFBundleURLSchemes + + hololake + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.productivity + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + HoloLake 仅在你明确配对后,通过同一局域网连接你的电脑端频道。 + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/product-source/hololake-native-desktop/mobile/ios/Generated/HoloLakeMobileTests-Info.plist b/product-source/hololake-native-desktop/mobile/ios/Generated/HoloLakeMobileTests-Info.plist new file mode 100644 index 000000000..6c40a6cd0 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Generated/HoloLakeMobileTests-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/project.pbxproj b/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/project.pbxproj new file mode 100644 index 000000000..b4fc2292f --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/project.pbxproj @@ -0,0 +1,425 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 147588FBBE65B668142D5DBE /* HoloLakeMobileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBF45AEC03B7B5DE76DE7DA /* HoloLakeMobileTests.swift */; }; + 149652D6C6336752E39CEF9D /* HoloLakeKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A889326BDEACBDB756B15A /* HoloLakeKeychain.swift */; }; + 4C8E5CF9FBFC541E3724C695 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DF66BC64D4DCD3BF0583C4 /* ContentView.swift */; }; + 4FD7821997D8E90E6594FC23 /* HoloLakeMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCE2A5796B15DAC37985182 /* HoloLakeMobileApp.swift */; }; + 9BC7CC6D8BC355D731943912 /* HoloLakeDesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A48AB789379757C66C6884 /* HoloLakeDesign.swift */; }; + C0E93C7456C0A3BE8BA595FC /* HoloLakeSyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B91D61B90D609EF17397D193 /* HoloLakeSyncClient.swift */; }; + FFEEE7FC9ED4ECCD91A96AF1 /* HoloLakeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F5647617271E7611365B1 /* HoloLakeModels.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CD999C930B8B85FE1C3D884D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7BB68CAE65448CB9319F9FE1 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 306917F839BF1A325E2C4CF4; + remoteInfo = HoloLakeMobile; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 001F5647617271E7611365B1 /* HoloLakeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeModels.swift; sourceTree = ""; }; + 39A48AB789379757C66C6884 /* HoloLakeDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeDesign.swift; sourceTree = ""; }; + 6DCE2A5796B15DAC37985182 /* HoloLakeMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeMobileApp.swift; sourceTree = ""; }; + 75A889326BDEACBDB756B15A /* HoloLakeKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeKeychain.swift; sourceTree = ""; }; + 7BBF45AEC03B7B5DE76DE7DA /* HoloLakeMobileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeMobileTests.swift; sourceTree = ""; }; + AAB8B15F447567F18DF8DA22 /* HoloLakeMobileTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = HoloLakeMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B91D61B90D609EF17397D193 /* HoloLakeSyncClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeSyncClient.swift; sourceTree = ""; }; + D5DF66BC64D4DCD3BF0583C4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + F674C2EA817D94D8089E46A6 /* HoloLakeMobile.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = HoloLakeMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 14AB215583F1F3DFF867AD25 /* Sources */ = { + isa = PBXGroup; + children = ( + D5DF66BC64D4DCD3BF0583C4 /* ContentView.swift */, + 39A48AB789379757C66C6884 /* HoloLakeDesign.swift */, + 75A889326BDEACBDB756B15A /* HoloLakeKeychain.swift */, + 6DCE2A5796B15DAC37985182 /* HoloLakeMobileApp.swift */, + 001F5647617271E7611365B1 /* HoloLakeModels.swift */, + B91D61B90D609EF17397D193 /* HoloLakeSyncClient.swift */, + ); + path = Sources; + sourceTree = ""; + }; + 57FD470E035A4BE1AC23C789 = { + isa = PBXGroup; + children = ( + 14AB215583F1F3DFF867AD25 /* Sources */, + 65C6F102E3F59F8DEC7CF977 /* Tests */, + C1AA7BA4A57964238429B9FC /* Products */, + ); + sourceTree = ""; + }; + 65C6F102E3F59F8DEC7CF977 /* Tests */ = { + isa = PBXGroup; + children = ( + 7BBF45AEC03B7B5DE76DE7DA /* HoloLakeMobileTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; + C1AA7BA4A57964238429B9FC /* Products */ = { + isa = PBXGroup; + children = ( + F674C2EA817D94D8089E46A6 /* HoloLakeMobile.app */, + AAB8B15F447567F18DF8DA22 /* HoloLakeMobileTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 306917F839BF1A325E2C4CF4 /* HoloLakeMobile */ = { + isa = PBXNativeTarget; + buildConfigurationList = 821BFCD605046D5BD179550C /* Build configuration list for PBXNativeTarget "HoloLakeMobile" */; + buildPhases = ( + EF1823B21F5FD988A9F1C68B /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = HoloLakeMobile; + packageProductDependencies = ( + ); + productName = HoloLakeMobile; + productReference = F674C2EA817D94D8089E46A6 /* HoloLakeMobile.app */; + productType = "com.apple.product-type.application"; + }; + 9F8DF7CBFC2D6315E6807301 /* HoloLakeMobileTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 94843BB695F4BFC5D7BD814C /* Build configuration list for PBXNativeTarget "HoloLakeMobileTests" */; + buildPhases = ( + C9F5955F3342913B35B01034 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 43C074DF3E4135062FD9F2D5 /* PBXTargetDependency */, + ); + name = HoloLakeMobileTests; + packageProductDependencies = ( + ); + productName = HoloLakeMobileTests; + productReference = AAB8B15F447567F18DF8DA22 /* HoloLakeMobileTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7BB68CAE65448CB9319F9FE1 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 306917F839BF1A325E2C4CF4 = { + DevelopmentTeam = 825A9L3G7Q; + ProvisioningStyle = Automatic; + }; + 9F8DF7CBFC2D6315E6807301 = { + DevelopmentTeam = 825A9L3G7Q; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 9D0C9B96A22462DAABC1E8F8 /* Build configuration list for PBXProject "HoloLakeMobile" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 57FD470E035A4BE1AC23C789; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = C1AA7BA4A57964238429B9FC /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 306917F839BF1A325E2C4CF4 /* HoloLakeMobile */, + 9F8DF7CBFC2D6315E6807301 /* HoloLakeMobileTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + C9F5955F3342913B35B01034 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 147588FBBE65B668142D5DBE /* HoloLakeMobileTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EF1823B21F5FD988A9F1C68B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4C8E5CF9FBFC541E3724C695 /* ContentView.swift in Sources */, + 9BC7CC6D8BC355D731943912 /* HoloLakeDesign.swift in Sources */, + 149652D6C6336752E39CEF9D /* HoloLakeKeychain.swift in Sources */, + 4FD7821997D8E90E6594FC23 /* HoloLakeMobileApp.swift in Sources */, + FFEEE7FC9ED4ECCD91A96AF1 /* HoloLakeModels.swift in Sources */, + C0E93C7456C0A3BE8BA595FC /* HoloLakeSyncClient.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 43C074DF3E4135062FD9F2D5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 306917F839BF1A325E2C4CF4 /* HoloLakeMobile */; + targetProxy = CD999C930B8B85FE1C3D884D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 41EDE5121626CC1CCE382706 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 825A9L3G7Q; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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 = 17.0; + MARKETING_VERSION = 0.5.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 4F5C6B6543A0A3082363F109 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 825A9L3G7Q; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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 = 17.0; + MARKETING_VERSION = 0.5.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 512CB348BCA8C1688C5D794E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + INFOPLIST_FILE = "Generated/HoloLakeMobileTests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.guanghulab.hololake.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HoloLake.app/HoloLake"; + }; + name = Release; + }; + A44510E6B199DFF6BD5BC1EF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + INFOPLIST_FILE = "Generated/HoloLakeMobileTests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.guanghulab.hololake.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HoloLake.app/HoloLake"; + }; + name = Debug; + }; + B82D688E4449B0C6DC27D25E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = "Generated/HoloLakeMobile-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.guanghulab.hololake; + PRODUCT_MODULE_NAME = HoloLakeMobile; + PRODUCT_NAME = HoloLake; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + BECD79B877979A2CD86E7D9C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = "Generated/HoloLakeMobile-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.guanghulab.hololake; + PRODUCT_MODULE_NAME = HoloLakeMobile; + PRODUCT_NAME = HoloLake; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 821BFCD605046D5BD179550C /* Build configuration list for PBXNativeTarget "HoloLakeMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BECD79B877979A2CD86E7D9C /* Debug */, + B82D688E4449B0C6DC27D25E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 94843BB695F4BFC5D7BD814C /* Build configuration list for PBXNativeTarget "HoloLakeMobileTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A44510E6B199DFF6BD5BC1EF /* Debug */, + 512CB348BCA8C1688C5D794E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 9D0C9B96A22462DAABC1E8F8 /* Build configuration list for PBXProject "HoloLakeMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4F5C6B6543A0A3082363F109 /* Debug */, + 41EDE5121626CC1CCE382706 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7BB68CAE65448CB9319F9FE1 /* Project object */; +} diff --git a/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/xcshareddata/xcschemes/HoloLakeMobile.xcscheme b/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/xcshareddata/xcschemes/HoloLakeMobile.xcscheme new file mode 100644 index 000000000..3a7b98f9c --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/xcshareddata/xcschemes/HoloLakeMobile.xcscheme @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/product-source/hololake-native-desktop/mobile/ios/Sources/ContentView.swift b/product-source/hololake-native-desktop/mobile/ios/Sources/ContentView.swift new file mode 100644 index 000000000..008ee0913 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Sources/ContentView.swift @@ -0,0 +1,203 @@ +import SwiftUI +import UIKit + +struct ContentView: View { + @EnvironmentObject private var client: HoloLakeSyncClient + @State private var pairingText = "" + @State private var captureTitle = "" + @State private var captureBody = "" + + var body: some View { + NavigationStack { + ZStack { + LinearGradient( + colors: [HoloLakeDesign.deep, HoloLakeDesign.lake, HoloLakeDesign.violet.opacity(0.78)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 16) { + header + if client.session == nil { pairingCard } else { dashboard } + boundaryCard + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + } + } + .toolbar(.hidden, for: .navigationBar) + .alert("HoloLake", isPresented: Binding( + get: { client.errorText != nil }, + set: { if !$0 { client.errorText = nil } } + )) { + Button("知道了", role: .cancel) { client.errorText = nil } + } message: { + Text(client.errorText ?? "") + } + } + .preferredColorScheme(.dark) + } + + private var header: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 5) { + Text("HoloLake").font(.title.weight(.semibold)).tracking(2) + Text("同一频道 · iPhone 轻入口") + .font(.subheadline) + .foregroundStyle(HoloLakeDesign.muted) + } + Spacer() + Circle() + .fill(client.session == nil ? HoloLakeDesign.gold : HoloLakeDesign.mint) + .frame(width: 11, height: 11) + .shadow(color: client.session == nil ? HoloLakeDesign.gold : HoloLakeDesign.mint, radius: 8) + } + .foregroundStyle(HoloLakeDesign.pearl) + .padding(.top, 4) + } + + private var pairingCard: some View { + LakeCard { + VStack(alignment: .leading, spacing: 14) { + Text("连接电脑端").font(.title3.weight(.semibold)) + Text("先在电脑端打开“多端同步”,再扫描配对码或粘贴配对链接。配对密钥只保存在这台 iPhone 的钥匙串。") + .font(.subheadline) + .foregroundStyle(HoloLakeDesign.muted) + + TextField("hololake://pair?…", text: $pairingText, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(14) + .background(.black.opacity(0.22), in: RoundedRectangle(cornerRadius: 16)) + + HStack { + Button("从剪贴板读取") { + pairingText = UIPasteboard.general.string ?? "" + } + .buttonStyle(.bordered) + + Button("校验并配对") { + Task { await client.pair(from: pairingText) } + } + .buttonStyle(.borderedProminent) + .tint(HoloLakeDesign.gold) + .foregroundStyle(HoloLakeDesign.deep) + .disabled(pairingText.isEmpty || client.isWorking) + } + } + } + .foregroundStyle(HoloLakeDesign.pearl) + } + + private var dashboard: some View { + VStack(spacing: 16) { + LakeCard { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(client.session?.desktopName ?? "电脑端") + .font(.title3.weight(.semibold)) + Text(client.stateText) + .font(.caption) + .foregroundStyle(HoloLakeDesign.muted) + } + Spacer() + Button { + Task { await client.sync() } + } label: { + if client.isWorking { + ProgressView() + } else { + Label("同步", systemImage: "arrow.triangle.2.circlepath") + } + } + .buttonStyle(.borderedProminent) + .tint(HoloLakeDesign.mint) + .foregroundStyle(HoloLakeDesign.deep) + .disabled(client.isWorking) + } + + if let snapshot = client.snapshot { + HStack(spacing: 10) { + MetricTile(label: "作品", value: "\(snapshot.webNovel.workCount)") + MetricTile(label: "章节", value: "\(snapshot.webNovel.chapterCount)") + MetricTile(label: "教育表", value: "\(snapshot.education.activeTableCount)") + } + } else { + Text("保持静止;只有你点击同步时才会连接电脑。") + .font(.subheadline) + .foregroundStyle(HoloLakeDesign.muted) + } + } + } + + LakeCard { + VStack(alignment: .leading, spacing: 12) { + Text("随手记回频道").font(.headline) + TextField("标题(可选)", text: $captureTitle) + .padding(12) + .background(.black.opacity(0.2), in: RoundedRectangle(cornerRadius: 14)) + TextField("正文", text: $captureBody, axis: .vertical) + .lineLimit(4...10) + .padding(12) + .background(.black.opacity(0.2), in: RoundedRectangle(cornerRadius: 14)) + Button("写入电脑端收件箱") { + let title = captureTitle + let body = captureBody + Task { + await client.sync(captureTitle: title, captureBody: body) + if client.errorText == nil { + captureTitle = "" + captureBody = "" + } + } + } + .buttonStyle(.bordered) + .disabled(captureBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || client.isWorking) + } + } + + if let works = client.snapshot?.webNovel.works, !works.isEmpty { + LakeCard { + VStack(alignment: .leading, spacing: 12) { + Text("最近作品").font(.headline) + ForEach(works.prefix(5)) { work in + HStack { + Text(work.title).lineLimit(1) + Spacer() + Text(work.status) + .font(.caption) + .foregroundStyle(HoloLakeDesign.muted) + } + if work.id != works.prefix(5).last?.id { + Divider().overlay(.white.opacity(0.12)) + } + } + } + } + } + + Button("解除本机配对", role: .destructive) { + client.disconnect() + } + .font(.footnote) + } + .foregroundStyle(HoloLakeDesign.pearl) + } + + private var boundaryCard: some View { + VStack(alignment: .leading, spacing: 6) { + Text("HLP-IOS-CLIENT-001") + .font(.caption2.monospaced()) + .foregroundStyle(HoloLakeDesign.gold) + Text("手机是同一频道的远程身体入口,不复制电脑桌面,不在电脑离线时执行人格或行业任务。") + .font(.caption) + .foregroundStyle(HoloLakeDesign.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } +} + diff --git a/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeDesign.swift b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeDesign.swift new file mode 100644 index 000000000..797b0283a --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeDesign.swift @@ -0,0 +1,42 @@ +import SwiftUI + +enum HoloLakeDesign { + static let deep = Color(red: 0.025, green: 0.045, blue: 0.11) + static let lake = Color(red: 0.08, green: 0.14, blue: 0.32) + static let violet = Color(red: 0.34, green: 0.19, blue: 0.52) + static let pearl = Color(red: 0.95, green: 0.96, blue: 1) + static let muted = Color(red: 0.62, green: 0.66, blue: 0.78) + static let gold = Color(red: 0.96, green: 0.87, blue: 0.62) + static let mint = Color(red: 0.43, green: 0.93, blue: 0.78) +} + +struct LakeCard: View { + @ViewBuilder let content: Content + + var body: some View { + content + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.white.opacity(0.075), in: RoundedRectangle(cornerRadius: 24)) + .overlay { + RoundedRectangle(cornerRadius: 24) + .stroke(.white.opacity(0.14), lineWidth: 1) + } + } +} + +struct MetricTile: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).font(.caption).foregroundStyle(HoloLakeDesign.muted) + Text(value).font(.title2.weight(.semibold)).foregroundStyle(HoloLakeDesign.pearl) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 18)) + } +} + diff --git a/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeKeychain.swift b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeKeychain.swift new file mode 100644 index 000000000..345974f2c --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeKeychain.swift @@ -0,0 +1,59 @@ +import Foundation +import Security + +enum HoloLakeKeychain { + private static let service = "com.guanghulab.hololake.mobile-sync" + private static let account = "HLP-IOS-CLIENT-001" + + static func load() throws -> StoredSession? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw HoloLakeMobileError.keychain(status) + } + return try JSONDecoder().decode(StoredSession.self, from: data) + } + + static func save(_ session: StoredSession) throws { + let data = try JSONEncoder().encode(session) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + let update = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if update == errSecItemNotFound { + var insertion = query + insertion.merge(attributes) { _, new in new } + let status = SecItemAdd(insertion as CFDictionary, nil) + guard status == errSecSuccess else { throw HoloLakeMobileError.keychain(status) } + } else if update != errSecSuccess { + throw HoloLakeMobileError.keychain(update) + } + } + + static func remove() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw HoloLakeMobileError.keychain(status) + } + } +} + diff --git a/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeMobileApp.swift b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeMobileApp.swift new file mode 100644 index 000000000..181084959 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeMobileApp.swift @@ -0,0 +1,17 @@ +import SwiftUI + +@main +struct HoloLakeMobileApp: App { + @StateObject private var client = HoloLakeSyncClient() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(client) + .onOpenURL { url in + Task { await client.pair(with: url) } + } + } + } +} + diff --git a/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeModels.swift b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeModels.swift new file mode 100644 index 000000000..ab00154e1 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeModels.swift @@ -0,0 +1,177 @@ +import Foundation +import Security + +struct PairingDescriptor: Equatable { + let host: String + let port: Int + let pairingID: String + let secret: Data + + init(url: URL) throws { + guard url.scheme == "hololake", url.host == "pair", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw HoloLakeMobileError.invalidPairingLink + } + var values: [String: String] = [:] + for item in components.queryItems ?? [] { + guard let value = item.value, values[item.name] == nil else { + throw HoloLakeMobileError.invalidPairingLink + } + values[item.name] = value + } + guard let host = values["host"], !host.isEmpty, + let portText = values["port"], let port = Int(portText), (1...65535).contains(port), + let pairingID = values["id"], UUID(uuidString: pairingID) != nil, + let secretText = values["secret"], + let secret = Data(base64URL: secretText), secret.count == 32 else { + throw HoloLakeMobileError.invalidPairingLink + } + self.host = host + self.port = port + self.pairingID = pairingID + self.secret = secret + } + + var baseURL: URL { + URL(string: "http://\(host):\(port)")! + } +} + +struct StoredSession: Codable, Equatable { + let host: String + let port: Int + let deviceID: String + let sessionKey: Data + let desktopName: String + var counter: UInt64 + var cursor: UInt64 + + var baseURL: URL { + URL(string: "http://\(host):\(port)")! + } +} + +struct PairRequest: Codable { + let deviceName: String + let platform: String + let requestID: String +} + +struct PairResponse: Codable { + let schema: String + let state: String + let deviceID: String + let sessionKey: String + let desktopName: String + let rootNodeRole: String + let requestID: String +} + +struct SyncRequest: Codable { + let counter: UInt64 + let afterCursor: UInt64? + let capture: MobileCaptureInput? +} + +struct MobileCaptureInput: Codable { + let title: String + let body: String + let requestID: String +} + +struct MobileSyncSnapshot: Codable { + let schema: String + let state: String + let cursor: UInt64 + let generatedAtUnixMs: UInt64 + let desktopName: String + let rootNodeOnline: Bool + let personalChannel: MobileChannelProjection + let webNovel: MobileWebNovelProjection + let education: MobileEducationProjection + let recentCaptures: [MobileCaptureProjection] + let boundary: MobileBoundaryProjection +} + +struct MobileChannelProjection: Codable { + let home: String + let growthEventCount: UInt64 + let integrity: String +} + +struct MobileWebNovelProjection: Codable { + let workCount: UInt64 + let volumeCount: UInt64 + let chapterCount: UInt64 + let works: [MobileWebNovelWork] +} + +struct MobileWebNovelWork: Codable, Identifiable { + let workID: String + let title: String + let status: String + let updatedAtUnixMs: UInt64 + var id: String { workID } +} + +struct MobileEducationProjection: Codable { + let activeTableCount: UInt64 + let archivedTableCount: UInt64 + let unassignedTableCount: UInt64 + let sensitiveValuesIncluded: Bool +} + +struct MobileCaptureProjection: Codable, Identifiable { + let cursor: UInt64 + let captureID: String + let title: String + let body: String + let sourceDeviceID: String + let createdAtUnixMs: UInt64 + var id: String { captureID } +} + +struct MobileBoundaryProjection: Codable { + let mobileRole: String + let remoteDesktopClone: Bool + let desktopOfflineExecution: Bool + let sensitiveEducationValues: String + let modelAPI: String +} + +enum HoloLakeMobileError: LocalizedError { + case invalidPairingLink + case invalidResponse + case rejected(String) + case missingNonce + case missingSession + case keychain(OSStatus) + + var errorDescription: String? { + switch self { + case .invalidPairingLink: return "配对链接无效或已损坏。" + case .invalidResponse: return "电脑端返回了无法校验的响应。" + case .rejected(let code): return "电脑端拒绝了本次请求:\(code)" + case .missingNonce: return "加密响应缺少一次性随机数。" + case .missingSession: return "请先与电脑端 HoloLake 配对。" + case .keychain(let status): return "本机钥匙串写入失败(\(status))。" + } + } +} + +extension Data { + init?(base64URL value: String) { + var text = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + text += String(repeating: "=", count: (4 - text.count % 4) % 4) + guard let data = Data(base64Encoded: text) else { return nil } + self = data + } + + var base64URL: String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeSyncClient.swift b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeSyncClient.swift new file mode 100644 index 000000000..ffc99f63e --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeSyncClient.swift @@ -0,0 +1,190 @@ +import CryptoKit +import Foundation +import UIKit + +@MainActor +final class HoloLakeSyncClient: ObservableObject { + static let clientNumber = "HLP-IOS-CLIENT-001" + static let pairAAD = Data("hololake.mobile.pair/v1".utf8) + + @Published private(set) var session: StoredSession? + @Published private(set) var snapshot: MobileSyncSnapshot? + @Published private(set) var stateText = "尚未配对" + @Published private(set) var isWorking = false + @Published var errorText: String? + + init() { + do { + session = try HoloLakeKeychain.load() + stateText = session == nil ? "尚未配对" : "已配对,等待手动同步" + } catch { + errorText = error.localizedDescription + } + } + + func pair(with url: URL) async { + await perform("正在校验配对…") { + let descriptor = try PairingDescriptor(url: url) + let requestID = UUID().uuidString.lowercased() + let payload = PairRequest( + deviceName: UIDevice.current.name, + platform: "IOS", + requestID: requestID + ) + let responseData = try await Self.exchange( + url: descriptor.baseURL.appending(path: "v1/pair"), + key: descriptor.secret, + aad: Self.pairAAD, + headers: ["X-HoloLake-Pairing-ID": descriptor.pairingID], + payload: payload + ) + let response = try JSONDecoder().decode(PairResponse.self, from: responseData) + guard response.schema == "hololake.mobile-sync/v1", + response.state == "PAIRED", + response.rootNodeRole == "USER_LOCAL_COMPUTER_TERMINAL", + response.requestID == requestID, + let sessionKey = Data(base64URL: response.sessionKey), sessionKey.count == 32 else { + throw HoloLakeMobileError.invalidResponse + } + let next = StoredSession( + host: descriptor.host, + port: descriptor.port, + deviceID: response.deviceID, + sessionKey: sessionKey, + desktopName: response.desktopName, + counter: 0, + cursor: 0 + ) + try HoloLakeKeychain.save(next) + session = next + stateText = "已与 \(response.desktopName) 配对" + } + } + + func pair(from text: String) async { + guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else { + errorText = HoloLakeMobileError.invalidPairingLink.localizedDescription + return + } + await pair(with: url) + } + + func sync(captureTitle: String? = nil, captureBody: String? = nil) async { + await perform("正在与电脑同步…") { + guard var current = session else { throw HoloLakeMobileError.missingSession } + current.counter += 1 + // Consume and persist the counter before transport. If the root node accepts a + // request but its response is lost, the next manual retry still moves forward. + try HoloLakeKeychain.save(current) + session = current + let capture: MobileCaptureInput? + if let body = captureBody?.trimmingCharacters(in: .whitespacesAndNewlines), !body.isEmpty { + capture = MobileCaptureInput( + title: (captureTitle ?? "").trimmingCharacters(in: .whitespacesAndNewlines), + body: body, + requestID: UUID().uuidString.lowercased() + ) + } else { + capture = nil + } + let aad = Data("hololake.mobile.sync/v1:\(current.deviceID)".utf8) + let responseData = try await Self.exchange( + url: current.baseURL.appending(path: "v1/sync"), + key: current.sessionKey, + aad: aad, + headers: ["X-HoloLake-Device-ID": current.deviceID], + payload: SyncRequest(counter: current.counter, afterCursor: nil, capture: capture) + ) + let nextSnapshot = try JSONDecoder().decode(MobileSyncSnapshot.self, from: responseData) + guard nextSnapshot.schema == "hololake.mobile-sync/v1", + nextSnapshot.state == "SYNCED_WITH_ROOT_NODE", + nextSnapshot.rootNodeOnline, + nextSnapshot.boundary.mobileRole == "REMOTE_BODY_ENTRY_OF_THE_SAME_PERSONA_SYSTEM", + !nextSnapshot.boundary.remoteDesktopClone, + !nextSnapshot.boundary.desktopOfflineExecution else { + throw HoloLakeMobileError.invalidResponse + } + current.cursor = nextSnapshot.cursor + try HoloLakeKeychain.save(current) + session = current + snapshot = nextSnapshot + stateText = "已同步 · \(nextSnapshot.desktopName)" + } + } + + func disconnect() { + do { + try HoloLakeKeychain.remove() + session = nil + snapshot = nil + stateText = "尚未配对" + } catch { + errorText = error.localizedDescription + } + } + + private func perform(_ activeText: String, operation: () async throws -> Void) async { + guard !isWorking else { return } + isWorking = true + errorText = nil + stateText = activeText + do { + try await operation() + } catch { + errorText = error.localizedDescription + stateText = session == nil ? "尚未配对" : "同步未完成" + } + isWorking = false + } + + static func exchange( + url: URL, + key: Data, + aad: Data, + headers: [String: String], + payload: Payload, + session: URLSession = .shared + ) async throws -> Data { + let clear = try JSONEncoder().encode(payload) + let nonceData = randomNonce() + let nonce = try ChaChaPoly.Nonce(data: nonceData) + let sealed = try ChaChaPoly.seal( + clear, + using: SymmetricKey(data: key), + nonce: nonce, + authenticating: aad + ) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 8 + request.cachePolicy = .reloadIgnoringLocalCacheData + request.httpBody = sealed.ciphertext + sealed.tag + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + request.setValue(nonceData.base64URL, forHTTPHeaderField: "X-HoloLake-Nonce") + headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } + let (body, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw HoloLakeMobileError.invalidResponse + } + guard http.statusCode == 200 else { + let code = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["code"] as? String + throw HoloLakeMobileError.rejected(code ?? "HTTP_\(http.statusCode)") + } + guard let nonceHeader = http.value(forHTTPHeaderField: "X-HoloLake-Nonce"), + let responseNonceData = Data(base64URL: nonceHeader), responseNonceData.count == 12 else { + throw HoloLakeMobileError.missingNonce + } + let responseNonce = try ChaChaPoly.Nonce(data: responseNonceData) + guard body.count >= 16 else { throw HoloLakeMobileError.invalidResponse } + let responseBox = try ChaChaPoly.SealedBox( + nonce: responseNonce, + ciphertext: body.dropLast(16), + tag: body.suffix(16) + ) + return try ChaChaPoly.open(responseBox, using: SymmetricKey(data: key), authenticating: aad) + } + + static func randomNonce() -> Data { + Data((0..<12).map { _ in UInt8.random(in: .min ... .max) }) + } +} diff --git a/product-source/hololake-native-desktop/mobile/ios/Tests/HoloLakeMobileTests.swift b/product-source/hololake-native-desktop/mobile/ios/Tests/HoloLakeMobileTests.swift new file mode 100644 index 000000000..eae55ba7c --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/Tests/HoloLakeMobileTests.swift @@ -0,0 +1,44 @@ +import CryptoKit +import XCTest +@testable import HoloLakeMobile + +final class HoloLakeMobileTests: XCTestCase { + func testPairingCoordinateParsesOnlyCompleteLink() throws { + let secret = Data(repeating: 7, count: 32).base64URL + let id = UUID().uuidString.lowercased() + let url = try XCTUnwrap(URL(string: "hololake://pair?host=192.168.1.8&port=37421&id=\(id)&secret=\(secret)")) + let value = try PairingDescriptor(url: url) + XCTAssertEqual(value.host, "192.168.1.8") + XCTAssertEqual(value.port, 37421) + XCTAssertEqual(value.pairingID, id) + XCTAssertEqual(value.secret, Data(repeating: 7, count: 32)) + } + + func testPairingCoordinateRejectsMissingSecret() throws { + let url = try XCTUnwrap(URL(string: "hololake://pair?host=127.0.0.1&port=37421&id=\(UUID().uuidString)")) + XCTAssertThrowsError(try PairingDescriptor(url: url)) + } + + func testPairingCoordinateRejectsDuplicateKeys() throws { + let secret = Data(repeating: 7, count: 32).base64URL + let id = UUID().uuidString.lowercased() + let url = try XCTUnwrap(URL(string: "hololake://pair?host=192.168.1.8&host=127.0.0.1&port=37421&id=\(id)&secret=\(secret)")) + XCTAssertThrowsError(try PairingDescriptor(url: url)) + } + + func testCryptoKitPayloadMatchesServerWireShape() throws { + let key = SymmetricKey(data: Data(repeating: 3, count: 32)) + let nonce = try ChaChaPoly.Nonce(data: Data(repeating: 9, count: 12)) + let aad = Data("hololake.mobile.pair/v1".utf8) + let clear = Data("hello".utf8) + let sealed = try ChaChaPoly.seal(clear, using: key, nonce: nonce, authenticating: aad) + let wire = sealed.ciphertext + sealed.tag + XCTAssertEqual(wire.count, clear.count + 16) + let restored = try ChaChaPoly.open( + ChaChaPoly.SealedBox(nonce: nonce, ciphertext: wire.dropLast(16), tag: wire.suffix(16)), + using: key, + authenticating: aad + ) + XCTAssertEqual(restored, clear) + } +} diff --git a/product-source/hololake-native-desktop/mobile/ios/project.yml b/product-source/hololake-native-desktop/mobile/ios/project.yml new file mode 100644 index 000000000..b733e4c91 --- /dev/null +++ b/product-source/hololake-native-desktop/mobile/ios/project.yml @@ -0,0 +1,63 @@ +name: HoloLakeMobile +options: + bundleIdPrefix: com.guanghulab + deploymentTarget: + iOS: "17.0" +settings: + base: + DEVELOPMENT_TEAM: 825A9L3G7Q + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: "5.0" + CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: 0.5.0 + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: false +targets: + HoloLakeMobile: + type: application + platform: iOS + sources: + - path: Sources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.guanghulab.hololake + PRODUCT_NAME: HoloLake + PRODUCT_MODULE_NAME: HoloLakeMobile + info: + path: Generated/HoloLakeMobile-Info.plist + properties: + CFBundleDisplayName: HoloLake + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + LSApplicationCategoryType: public.app-category.productivity + NSLocalNetworkUsageDescription: HoloLake 仅在你明确配对后,通过同一局域网连接你的电脑端频道。 + UIApplicationSupportsIndirectInputEvents: true + UIRequiresFullScreen: true + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + CFBundleURLTypes: + - CFBundleTypeRole: Editor + CFBundleURLName: com.guanghulab.hololake.pair + CFBundleURLSchemes: + - hololake + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + scheme: + testTargets: + - HoloLakeMobileTests + HoloLakeMobileTests: + type: bundle.unit-test + platform: iOS + sources: + - path: Tests + dependencies: + - target: HoloLakeMobile + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.guanghulab.hololake.tests + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/HoloLake.app/HoloLake" + BUNDLE_LOADER: "$(TEST_HOST)" + info: + path: Generated/HoloLakeMobileTests-Info.plist + properties: {} diff --git a/product-source/hololake-native-desktop/scripts/ios-mobile-client-admission.test.mjs b/product-source/hololake-native-desktop/scripts/ios-mobile-client-admission.test.mjs new file mode 100644 index 000000000..1e7b8be68 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/ios-mobile-client-admission.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8') +const contract = JSON.parse(read('contracts/ios-mobile-client-v1.json')) +const project = read('mobile/ios/project.yml') +const client = read('mobile/ios/Sources/HoloLakeSyncClient.swift') +const keychain = read('mobile/ios/Sources/HoloLakeKeychain.swift') +const models = read('mobile/ios/Sources/HoloLakeModels.swift') +const interfaceSource = read('mobile/ios/Sources/ContentView.swift') + +test('iPhone client is the numbered remote body entry of the desktop root node', () => { + assert.equal(contract.record_id, 'HLP-IOS-CLIENT-001') + assert.equal(contract.role, 'REMOTE_BODY_ENTRY_OF_THE_SAME_PERSONA_SYSTEM') + assert.equal(contract.root_node, 'USER_LOCAL_COMPUTER_TERMINAL') + assert.equal(contract.numbered_transport.desktop_module, 'HLP-MOD-OFFICIAL-MOBILE-SYNC-0001') + assert.match(project, /PRODUCT_BUNDLE_IDENTIFIER: com\.guanghulab\.hololake/) + assert.match(client, /static let clientNumber = "HLP-IOS-CLIENT-001"/) + assert.match(interfaceSource, /HLP-IOS-CLIENT-001/) + assert.equal(contract.state, 'NUMBERED_SOURCE_SIMULATOR_AND_DEVELOPMENT_SIGNED_IPA_ACCEPTED') + assert.equal(contract.acceptance.unit_tests, 'PASS_4') + assert.equal(contract.acceptance.real_iphone_pair_and_sync, 'NOT_CLAIMED') +}) + +test('iPhone secrets stay device local and the transport matches the desktop protocol', () => { + assert.equal(contract.security.key_storage, 'KEYCHAIN_WHEN_UNLOCKED_THIS_DEVICE_ONLY') + assert.equal(contract.security.background_polling, false) + assert.equal(contract.security.remote_desktop_clone, false) + assert.match(keychain, /kSecAttrAccessibleWhenUnlockedThisDeviceOnly/) + assert.match(client, /ChaChaPoly\.seal/) + assert.match(client, /ChaChaPoly\.open/) + assert.match(client, /hololake\.mobile\.pair\/v1/) + assert.match(client, /hololake\.mobile\.sync\/v1:/) + assert.match(client, /response\.rootNodeRole == "USER_LOCAL_COMPUTER_TERMINAL"/) + assert.match(client, /nextSnapshot\.rootNodeOnline/) + assert.ok(client.indexOf('try HoloLakeKeychain.save(current)') < client.indexOf('url: current.baseURL.appending(path: "v1/sync")')) + assert.match(models, /hololake/) + assert.doesNotMatch(client, /Timer|scheduledTimer|setInterval|backgroundTask/) +}) + +test('mobile interface stays manual and exposes only bounded projections', () => { + assert.equal(contract.interface.idle_state, 'STATIC') + assert.equal(contract.interface.manual_sync_only, true) + assert.match(interfaceSource, /只有你点击同步时才会连接电脑/) + assert.match(interfaceSource, /写入电脑端收件箱/) + assert.doesNotMatch(interfaceSource, /人格执行|模型调用|远程桌面/) +}) diff --git a/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs b/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs index c265c9808..c06e8d696 100644 --- a/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs +++ b/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs @@ -6,13 +6,21 @@ const catalog = JSON.parse(readFileSync(new URL('../contracts/module-donor-admis const numbered = JSON.parse(readFileSync(new URL('../contracts/numbered-ipc-registry.json', import.meta.url), 'utf8')) test('chaotic donors are read-only candidates and never a bulk merge source', () => { - assert.equal(catalog.state, 'SEVEN_CANDIDATES_ADMITTED_DONOR_SOURCES_REMAIN_READ_ONLY') + assert.equal(catalog.state, 'EIGHT_CANDIDATES_ADMITTED_DONOR_SOURCES_REMAIN_READ_ONLY') assert.equal(catalog.root_rule.repair_old_application_in_place, false) assert.equal(catalog.root_rule.bulk_merge_or_wholesale_copy_allowed, false) assert.equal(catalog.root_rule.one_candidate_per_admission_cycle, true) assert.ok(catalog.donors.every((donor) => donor.state.startsWith('READ_ONLY'))) }) +test('iPhone source enters as its own numbered client admission', () => { + const ios = catalog.candidates.find((candidate) => candidate.client_number === 'HLP-IOS-CLIENT-001') + assert.equal(ios.candidate_number, 'HLP-DONOR-CAND-0008') + assert.equal(ios.desktop_runtime_module_number, 'HLP-MOD-OFFICIAL-MOBILE-SYNC-0001') + assert.equal(ios.numbered_ipc_module, 'HLP-NIPC-MOD-0030') + assert.match(ios.boundary, /NO SECOND PERSONA SYSTEM/) +}) + test('candidate coordinates are unique but are not permanent runtime module numbers', () => { const coordinates = catalog.candidates.map((candidate) => candidate.candidate_number) const admitted = catalog.candidates.filter((candidate) => candidate.state.startsWith('ADMITTED'))