From fe1aaade0e8e2c6553b105938708fe125948a746 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com>
Date: Wed, 19 Aug 2026 07:28:26 +0800
Subject: [PATCH] feat: admit numbered iPhone companion
---
.../contracts/ios-mobile-client-v1.json | 49 ++
.../contracts/mobile-sync-v1.json | 11 +-
.../module-donor-admission-registry.json | 21 +-
.../mobile/ios/.gitignore | 4 +
.../mobile/ios/ExportOptions.Debug.plist | 16 +
.../ios/Generated/HoloLakeMobile-Info.plist | 56 +++
.../Generated/HoloLakeMobileTests-Info.plist | 22 +
.../HoloLakeMobile.xcodeproj/project.pbxproj | 425 ++++++++++++++++++
.../contents.xcworkspacedata | 7 +
.../xcschemes/HoloLakeMobile.xcscheme | 106 +++++
.../mobile/ios/Sources/ContentView.swift | 203 +++++++++
.../mobile/ios/Sources/HoloLakeDesign.swift | 42 ++
.../mobile/ios/Sources/HoloLakeKeychain.swift | 59 +++
.../ios/Sources/HoloLakeMobileApp.swift | 17 +
.../mobile/ios/Sources/HoloLakeModels.swift | 177 ++++++++
.../ios/Sources/HoloLakeSyncClient.swift | 190 ++++++++
.../ios/Tests/HoloLakeMobileTests.swift | 44 ++
.../mobile/ios/project.yml | 63 +++
.../ios-mobile-client-admission.test.mjs | 48 ++
.../scripts/module-donor-admission.test.mjs | 10 +-
20 files changed, 1563 insertions(+), 7 deletions(-)
create mode 100644 product-source/hololake-native-desktop/contracts/ios-mobile-client-v1.json
create mode 100644 product-source/hololake-native-desktop/mobile/ios/.gitignore
create mode 100644 product-source/hololake-native-desktop/mobile/ios/ExportOptions.Debug.plist
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Generated/HoloLakeMobile-Info.plist
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Generated/HoloLakeMobileTests-Info.plist
create mode 100644 product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/project.pbxproj
create mode 100644 product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata
create mode 100644 product-source/hololake-native-desktop/mobile/ios/HoloLakeMobile.xcodeproj/xcshareddata/xcschemes/HoloLakeMobile.xcscheme
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Sources/ContentView.swift
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeDesign.swift
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeKeychain.swift
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeMobileApp.swift
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeModels.swift
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeSyncClient.swift
create mode 100644 product-source/hololake-native-desktop/mobile/ios/Tests/HoloLakeMobileTests.swift
create mode 100644 product-source/hololake-native-desktop/mobile/ios/project.yml
create mode 100644 product-source/hololake-native-desktop/scripts/ios-mobile-client-admission.test.mjs
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'))