feat: admit numbered iPhone companion

This commit is contained in:
冰朔 2026-08-19 07:28:26 +08:00
commit fe1aaade0e
20 changed files with 1563 additions and 7 deletions

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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": [

View file

@ -0,0 +1,4 @@
build/
*.xcuserstate
xcuserdata/

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>debugging</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>825A9L3G7Q</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadSymbols</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>HoloLake</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.guanghulab.hololake.pair</string>
<key>CFBundleURLSchemes</key>
<array>
<string>hololake</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>HoloLake 仅在你明确配对后,通过同一局域网连接你的电脑端频道。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View file

@ -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 = "<group>"; };
39A48AB789379757C66C6884 /* HoloLakeDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeDesign.swift; sourceTree = "<group>"; };
6DCE2A5796B15DAC37985182 /* HoloLakeMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeMobileApp.swift; sourceTree = "<group>"; };
75A889326BDEACBDB756B15A /* HoloLakeKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeKeychain.swift; sourceTree = "<group>"; };
7BBF45AEC03B7B5DE76DE7DA /* HoloLakeMobileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoloLakeMobileTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D5DF66BC64D4DCD3BF0583C4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
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 = "<group>";
};
57FD470E035A4BE1AC23C789 = {
isa = PBXGroup;
children = (
14AB215583F1F3DFF867AD25 /* Sources */,
65C6F102E3F59F8DEC7CF977 /* Tests */,
C1AA7BA4A57964238429B9FC /* Products */,
);
sourceTree = "<group>";
};
65C6F102E3F59F8DEC7CF977 /* Tests */ = {
isa = PBXGroup;
children = (
7BBF45AEC03B7B5DE76DE7DA /* HoloLakeMobileTests.swift */,
);
path = Tests;
sourceTree = "<group>";
};
C1AA7BA4A57964238429B9FC /* Products */ = {
isa = PBXGroup;
children = (
F674C2EA817D94D8089E46A6 /* HoloLakeMobile.app */,
AAB8B15F447567F18DF8DA22 /* HoloLakeMobileTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "306917F839BF1A325E2C4CF4"
BuildableName = "HoloLakeMobile.app"
BlueprintName = "HoloLakeMobile"
ReferencedContainer = "container:HoloLakeMobile.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "306917F839BF1A325E2C4CF4"
BuildableName = "HoloLakeMobile.app"
BlueprintName = "HoloLakeMobile"
ReferencedContainer = "container:HoloLakeMobile.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9F8DF7CBFC2D6315E6807301"
BuildableName = "HoloLakeMobileTests.xctest"
BlueprintName = "HoloLakeMobileTests"
ReferencedContainer = "container:HoloLakeMobile.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "306917F839BF1A325E2C4CF4"
BuildableName = "HoloLakeMobile.app"
BlueprintName = "HoloLakeMobile"
ReferencedContainer = "container:HoloLakeMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "306917F839BF1A325E2C4CF4"
BuildableName = "HoloLakeMobile.app"
BlueprintName = "HoloLakeMobile"
ReferencedContainer = "container:HoloLakeMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -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)
}
}

View file

@ -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<Content: View>: 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))
}
}

View file

@ -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)
}
}
}

View file

@ -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) }
}
}
}
}

View file

@ -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: "")
}
}

View file

@ -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<Payload: Encodable>(
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) })
}
}

View file

@ -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)
}
}

View file

@ -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: {}

View file

@ -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, /人格执行|模型调用|远程桌面/)
})

View file

@ -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'))