From 292889f934ff16af85a1a80741d560feb3a4a17b 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 04:14:58 +0800 Subject: [PATCH] feat: admit signed mobile sync bridge module --- .../contracts/mobile-sync-v1.json | 90 ++ .../module-donor-admission-registry.json | 10 +- .../contracts/numbered-ipc-registry.json | 81 +- ...-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod | 34 + ...-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod.sig | 1 + .../unified-number-coordinate-tree.json | 88 +- .../scripts/mobile-sync-admission.test.mjs | 43 + .../unified-number-coordinate-tree.test.mjs | 4 +- .../src-tauri/Cargo.lock | 7 + .../src-tauri/Cargo.toml | 1 + .../src-tauri/src/lib.rs | 2 + .../src-tauri/src/mobile_sync.rs | 1227 +++++++++++++++++ .../src-tauri/src/module_package_runtime.rs | 39 +- .../src-tauri/src/number_coordinate_tree.rs | 2 +- .../src-tauri/src/numbered_ipc.rs | 2 +- .../src-tauri/src/numbered_ipc_dispatch.rs | 14 + .../hololake-native-desktop/src/main.tsx | 46 +- .../src/modules/mobile-sync/index.tsx | 164 +++ .../src/modules/mobile-sync/styles.css | 2 + .../src/modules/numbered-ipc.ts | 48 + .../hololake-native-desktop/src/styles.css | 1 + 21 files changed, 1891 insertions(+), 15 deletions(-) create mode 100644 product-source/hololake-native-desktop/contracts/mobile-sync-v1.json create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod.sig create mode 100644 product-source/hololake-native-desktop/scripts/mobile-sync-admission.test.mjs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/mobile_sync.rs create mode 100644 product-source/hololake-native-desktop/src/modules/mobile-sync/index.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/mobile-sync/styles.css diff --git a/product-source/hololake-native-desktop/contracts/mobile-sync-v1.json b/product-source/hololake-native-desktop/contracts/mobile-sync-v1.json new file mode 100644 index 000000000..af29f9e4d --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/mobile-sync-v1.json @@ -0,0 +1,90 @@ +{ + "schema": "hololake.mobile-sync.contract/v1", + "record_id": "HLP-MOBILE-SYNC-001", + "state": "HOLOLAKE_0_5_NUMBERED_DESKTOP_BRIDGE_ACCEPTED", + "package": { + "official_module_number": "HLP-MOD-OFFICIAL-MOBILE-SYNC-0001", + "adapter": "mobile-sync-v1", + "registration_class": "OFFICIAL_LIGHTHOUSE", + "activation": "SIGNED_PACKAGE_PLUS_EXPLICIT_HUMAN_PERMISSION_CONFIRMATION", + "listener_after_activation": "EXPLICIT_HUMAN_ACTION_ONLY" + }, + "numbered_ipc": { + "module": "HLP-NIPC-MOD-0030", + "target": "HLP-NIPC-TGT-0030", + "operations": "HLP-NIPC-OP-0141..HLP-NIPC-OP-0146", + "public_tauri_commands": ["numbered_ipc"], + "mismatched_coordinate": "FAIL_CLOSED" + }, + "desktop_role": "USER_LOCAL_COMPUTER_TERMINAL_ROOT_NODE", + "mobile_role": "REMOTE_BODY_ENTRY_OF_THE_SAME_PERSONA_SYSTEM", + "transport": { + "implemented": "SAME_LAN_DIRECT_HTTP_WITH_APPLICATION_LAYER_ENCRYPTION", + "port_preference": 37421, + "remote_internet_direct": "NOT_IMPLEMENTED", + "encrypted_relay": "NOT_IMPLEMENTED", + "maximum_request_bytes": 262144, + "maximum_concurrent_connections": 16, + "duplicate_http_header": "REJECT" + }, + "pairing": { + "uri_scheme": "hololake://pair", + "secret_bits": 256, + "ttl_seconds": 600, + "single_use": true, + "payload_aead": "CHACHA20_POLY1305", + "aad": "hololake.mobile.pair/v1" + }, + "session": { + "key_bits": 256, + "aead": "CHACHA20_POLY1305", + "replay_guard": "STRICTLY_INCREASING_PER_DEVICE_COUNTER", + "device_revocation": true, + "desktop_key_storage": "ACCOUNT_SCOPED_SQLITE", + "ios_key_storage": "KEYCHAIN_THIS_DEVICE_ONLY" + }, + "routes": { + "GET /v1/status": "NO_PRIVATE_PAYLOAD", + "POST /v1/pair": "ONE_TIME_PAIRING_SECRET_REQUIRED", + "POST /v1/sync": "PAIRED_DEVICE_AEAD_AND_COUNTER_REQUIRED" + }, + "projection": { + "personal_channel": "MINIMUM_COUNTS_AND_INTEGRITY", + "web_novel": "WORK_LIST_AND_COUNTS", + "education": "COUNTS_ONLY_NO_SENSITIVE_CELL_VALUES", + "mobile_capture": "BOUNDED_INBOX_WRITE_ONLY_NO_AUTOMATIC_DOMAIN_MUTATION" + }, + "hard_boundaries": { + "remote_desktop_clone": false, + "second_persona_system": false, + "platform_private_payload_custody": false, + "desktop_offline_execution": false, + "mobile_capture_mutates_persona_or_industry_data": false, + "module_unmount_stops_listener_before_lifecycle_transition": true, + "background_frontend_polling": false + }, + "client_scope": { + "desktop_bridge": "IN_THIS_ADMISSION", + "ios_application_source": "NOT_IN_DECLARED_DONOR_CANDIDATE", + "ios_installable_package": "PENDING_SEPARATE_ADMISSION", + "claim_real_iphone_end_to_end_accepted": false + }, + "current_acceptance": { + "state": "PASS_DESKTOP_BRIDGE_ONLY", + "module_package_sha256": "61a9d402c936d7477609bcfa6eb4ad6e83cb6c089ba51b3db09a2d6259185924", + "module_package_signature": "PASS_EMBEDDED_PRODUCT_TRUST", + "signed_app_binary_sha256": "9be06d6c5dbd4f46f6f925142d633f12b42ee553873c2342f9804bcf39dde7b4", + "signed_app_cdhash": "c595cb2a7729d49638faa78f76e145ee9b1da05a", + "apple_team_identifier": "825A9L3G7Q", + "module_receipts": { + "install": "a09496ec6113254f77ea2425d0bd30571e9e0e3df62bfa18e500f860c2f65d4d", + "mount": "5b162ee807c8b4d9c8aa8d9cb590c64587f51067dc0804030bf57ed0753ac25a", + "self_test_pass": "e66ee868e28a25fcd70cc888845661b5044d18a6e18b70cee6e4839b02fe1c53" + }, + "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", + "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 1e138cd8c..c0bb08290 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": "FIVE_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED", + "state": "SIX_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED", "root_rule": { "official_base": "HOLOLAKE_0.5.0_NUMBERED_IPC_ROOT", "repair_old_application_in_place": false, @@ -151,12 +151,16 @@ "admission_order": 6, "candidate_number": "HLP-DONOR-CAND-0006", "name": "mobile_sync", - "state": "QUARANTINED_PENDING_ADMISSION", + "state": "ADMITTED_INSTALLED_RUNTIME_ACCEPTED", "paths": [ "contracts/mobile-sync-v1.json", "src/MobileSyncPanel.tsx", "src-tauri/src/mobile_sync.rs" - ] + ], + "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" }, { "admission_order": 7, diff --git a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json index e6f0232b6..1d384179a 100644 --- a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json +++ b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json @@ -71,7 +71,12 @@ "import_education_tables_from_dialog", "get_education_recognition_capability", "get_web_novel_workspace_snapshot", - "inspect_web_novel_document_from_dialog" + "inspect_web_novel_document_from_dialog", + "start_mobile_sync", + "get_mobile_sync_status", + "rotate_mobile_pairing", + "stop_mobile_sync", + "get_mobile_sync_snapshot" ], "input_wrapper_aliases": [ "confirm_hololake_update_install", @@ -169,7 +174,8 @@ "upsert_web_novel_timeline_event", "link_web_novel_scene_entity", "restore_web_novel_chapter_version", - "export_web_novel_author_delivery" + "export_web_novel_author_delivery", + "revoke_mobile_sync_device" ], "direct_field_aliases": { "perform_code_repo_login": [ @@ -361,6 +367,11 @@ "module_number": "HLP-NIPC-MOD-0029", "target_number": "HLP-NIPC-TGT-0029", "internal_name": "web_novel_delivery" + }, + { + "module_number": "HLP-NIPC-MOD-0030", + "target_number": "HLP-NIPC-TGT-0030", + "internal_name": "mobile_sync" } ], "operations": [ @@ -1903,6 +1914,72 @@ "admission": "VERIFIED_HUMAN_ROUTE", "effect": "STATE_CHANGE", "payload_schema": "hololake.numbered-ipc.payload/export_web_novel_author_delivery/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0141", + "alias": "start_mobile_sync", + "handler": "mobile_sync::start_mobile_sync", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0030", + "target_number": "HLP-NIPC-TGT-0030", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/start_mobile_sync/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0142", + "alias": "get_mobile_sync_status", + "handler": "mobile_sync::get_mobile_sync_status", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0030", + "target_number": "HLP-NIPC-TGT-0030", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_mobile_sync_status/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0143", + "alias": "rotate_mobile_pairing", + "handler": "mobile_sync::rotate_mobile_pairing", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0030", + "target_number": "HLP-NIPC-TGT-0030", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/rotate_mobile_pairing/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0144", + "alias": "stop_mobile_sync", + "handler": "mobile_sync::stop_mobile_sync", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0030", + "target_number": "HLP-NIPC-TGT-0030", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/stop_mobile_sync/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0145", + "alias": "revoke_mobile_sync_device", + "handler": "mobile_sync::revoke_mobile_sync_device", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0030", + "target_number": "HLP-NIPC-TGT-0030", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/revoke_mobile_sync_device/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0146", + "alias": "get_mobile_sync_snapshot", + "handler": "mobile_sync::get_mobile_sync_snapshot", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0030", + "target_number": "HLP-NIPC-TGT-0030", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_mobile_sync_snapshot/v1" } ] } diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod new file mode 100644 index 000000000..dc973c2b7 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod @@ -0,0 +1,34 @@ +{ + "schema": "hololake.module-package/v1", + "manifest": { + "moduleNumber": "HLP-MOD-OFFICIAL-MOBILE-SYNC-0001", + "registrationClass": "OFFICIAL_LIGHTHOUSE", + "displayName": "移动同步桥", + "version": "0.1.0", + "minimumHostVersion": "0.5.0", + "adapter": "mobile-sync-v1", + "contentDigest": "3e3ff8c7beba3d70ee01c918630a40ea901313b424f264b41e2d376550534e6e", + "permissions": ["MOBILE_SYNC_LISTEN_SAME_LAN", "MOBILE_SYNC_PAIR_DEVICE", "MOBILE_SYNC_READ_MINIMUM_PROJECTION", "MOBILE_SYNC_CAPTURE_INBOX_WRITE", "MOBILE_SYNC_REVOKE_DEVICE"], + "userDataSchema": "hololake.module-data/mobile-sync/v1", + "selfTest": { + "kind": "DECLARATIVE_SCHEMA_V1", + "expectedContentDigest": "3e3ff8c7beba3d70ee01c918630a40ea901313b424f264b41e2d376550534e6e" + } + }, + "payload": { + "entry": "mobile-sync", + "adapterConfig": { + "transport": "SAME_LAN_DIRECT_HTTP_WITH_APPLICATION_LAYER_ENCRYPTION", + "preferredPort": 37421, + "pairingTtlSeconds": 600, + "maximumRequestBytes": 262144, + "maximumConcurrentConnections": 16, + "desktopRole": "USER_LOCAL_COMPUTER_TERMINAL_ROOT_NODE", + "mobileRole": "REMOTE_BODY_ENTRY_OF_THE_SAME_PERSONA_SYSTEM", + "desktopOfflineExecution": false, + "platformPrivatePayloadCustody": false, + "automaticDomainMutation": false, + "iosClientPackaging": "PENDING_SEPARATE_ADMISSION" + } + } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod.sig b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod.sig new file mode 100644 index 000000000..fabf73a24 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVReHJHQ2hTVDYvRTV5bDhHMkZjMzNkN0lpaDBLaDRxcnhHajJ3b3NDS21zZWljYWJORmtrNit5MHJvZC9yN2YrSUhiSE5sT0dMd25Jc2pXMkQyeWttODhKSklsRmVKNWd3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MDgzMTYwCWZpbGU6SExQLU1PRC1PRkZJQ0lBTC1NT0JJTEUtU1lOQy0wMDAxLTAuMS4wLmdobW9kCjN6R1JZZitSbzI1cVRESkNUMnJrVXFWUlUyZ3B3L2lIQmhrOXZaTGc3T2x1REdNUUZrK2M4WWs4bE9Zb3NuSTNoNmRBRmlCekh0dkNNN1pKR2k4ZkFRPT0K \ No newline at end of file diff --git a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json index 4adab0446..3680f30c5 100644 --- a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json +++ b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json @@ -54,7 +54,7 @@ }, { "recordId": "HLP-NUMBERED-IPC-ROOT-001", - "sha256": "06516d366a37a6e2f680c225b43920919e04db3603c2c1f11bd256b320631465" + "sha256": "40a9d411dc891c8d3333dfea3f9d24c1047c8ae8568c04a83263bb4be5c6a466" }, { "recordId": "HLP-NBROKER-ROOT-001", @@ -69,7 +69,7 @@ "everyAcceptedCallHasEvidenceClass": true, "mismatchedCoordinate": "FAIL_CLOSED" }, - "routeCount": 162, + "routeCount": 168, "routes": [ { "transport": "DIRECT_LOCAL_NUMBERED_BROKER", @@ -1947,6 +1947,90 @@ "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0029/HLP-NIPC-OP-0140/HLP-NIPC-TGT-0029" }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0141", + "targetNumber": "HLP-NIPC-TGT-0030", + "alias": "start_mobile_sync", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0030/HLP-NIPC-OP-0141/HLP-NIPC-TGT-0030" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0142", + "targetNumber": "HLP-NIPC-TGT-0030", + "alias": "get_mobile_sync_status", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0030/HLP-NIPC-OP-0142/HLP-NIPC-TGT-0030" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0143", + "targetNumber": "HLP-NIPC-TGT-0030", + "alias": "rotate_mobile_pairing", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0030/HLP-NIPC-OP-0143/HLP-NIPC-TGT-0030" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0144", + "targetNumber": "HLP-NIPC-TGT-0030", + "alias": "stop_mobile_sync", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0030/HLP-NIPC-OP-0144/HLP-NIPC-TGT-0030" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0145", + "targetNumber": "HLP-NIPC-TGT-0030", + "alias": "revoke_mobile_sync_device", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0030/HLP-NIPC-OP-0145/HLP-NIPC-TGT-0030" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0146", + "targetNumber": "HLP-NIPC-TGT-0030", + "alias": "get_mobile_sync_snapshot", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0030/HLP-NIPC-OP-0146/HLP-NIPC-TGT-0030" + }, { "transport": "TAURI_WEBVIEW_NUMBERED_IPC", "protocolVersion": "HLP-NIPC-v1", diff --git a/product-source/hololake-native-desktop/scripts/mobile-sync-admission.test.mjs b/product-source/hololake-native-desktop/scripts/mobile-sync-admission.test.mjs new file mode 100644 index 000000000..d999da796 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/mobile-sync-admission.test.mjs @@ -0,0 +1,43 @@ +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/mobile-sync-v1.json')) +const registry = JSON.parse(read('contracts/numbered-ipc-registry.json')) +const modulePackage = JSON.parse(read('fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod')) +const rust = read('src-tauri/src/mobile_sync.rs') +const frontend = read('src/modules/mobile-sync/index.tsx') + +test('mobile sync is a signed opt-in bridge, not a second persona system', () => { + assert.equal(modulePackage.manifest.moduleNumber, 'HLP-MOD-OFFICIAL-MOBILE-SYNC-0001') + assert.equal(modulePackage.manifest.registrationClass, 'OFFICIAL_LIGHTHOUSE') + assert.equal(modulePackage.manifest.adapter, 'mobile-sync-v1') + assert.equal(modulePackage.manifest.permissions.length, 5) + assert.equal(modulePackage.payload.adapterConfig.desktopRole, 'USER_LOCAL_COMPUTER_TERMINAL_ROOT_NODE') + assert.equal(modulePackage.payload.adapterConfig.iosClientPackaging, 'PENDING_SEPARATE_ADMISSION') + assert.equal(contract.hard_boundaries.second_persona_system, false) + assert.equal(contract.hard_boundaries.desktop_offline_execution, false) +}) + +test('all desktop controls cross one numbered route family', () => { + const routes = registry.operations.filter((route) => route.module_number === 'HLP-NIPC-MOD-0030') + assert.deepEqual(routes.map((route) => route.operation_number), Array.from({ length: 6 }, (_, index) => `HLP-NIPC-OP-${String(index + 141).padStart(4, '0')}`)) + assert.ok(routes.every((route) => route.target_number === 'HLP-NIPC-TGT-0030')) + assert.ok(routes.every((route) => route.admission === 'VERIFIED_HUMAN_ROUTE')) + assert.doesNotMatch(frontend, /from ['"]@tauri-apps\/api\/core['"]/) + assert.doesNotMatch(rust, /#\[tauri::command\]/) + assert.match(rust, /require_active_module_adapter/) +}) + +test('transport is bounded, encrypted, replay guarded and explicitly controlled', () => { + assert.match(rust, /MAX_REQUEST_BYTES: usize = 256 \* 1024/) + assert.match(rust, /MAX_ACTIVE_CONNECTIONS: usize = 16/) + assert.match(rust, /CHACHA20_POLY1305/) + assert.match(rust, /input\.counter <= last_counter/) + assert.match(rust, /DUPLICATE_HEADER_REJECTED/) + assert.match(rust, /pub\(crate\) fn stop_for_unmount/) + assert.doesNotMatch(frontend, /setInterval/) + assert.match(frontend, /开启 iPhone 同步/) + assert.match(frontend, /关闭本机同步入口/) +}) diff --git a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs index dd110c9f7..835a93147 100644 --- a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs +++ b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs @@ -7,8 +7,8 @@ test('identity, webview and direct broker numbers compile into one unique eviden const generated = JSON.parse(readFileSync(new URL('../generated/unified-number-coordinate-tree.json', import.meta.url), 'utf8')) assert.deepEqual(generated, compileUnifiedNumberTree()) assert.equal(generated.recordId, 'HLP-UNIFIED-NUMBER-TREE-001') - assert.equal(generated.routeCount, 162) - assert.equal(new Set(generated.routes.map((route) => route.path)).size, 162) + assert.equal(generated.routeCount, 168) + assert.equal(new Set(generated.routes.map((route) => route.path)).size, 168) assert.equal(generated.invariants.everyPhysicalCallHasNumberedRoute, true) assert.equal(generated.invariants.everyAcceptedCallHasEvidenceClass, true) assert.ok(generated.routes.every((route) => route.admission && route.evidence)) diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index 2725b1833..6776d71d0 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.lock +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.lock @@ -1558,6 +1558,7 @@ dependencies = [ "futures-util", "interprocess", "minisign-verify", + "qrcode", "quick-xml 0.31.0", "regex", "reqwest", @@ -3043,6 +3044,12 @@ dependencies = [ "psl-types", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-xml" version = "0.31.0" diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.toml b/product-source/hololake-native-desktop/src-tauri/Cargo.toml index e2f07020b..92fa937ef 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.toml +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.toml @@ -41,6 +41,7 @@ zip = { version = "=0.6.6", default-features = false, features = ["deflate"] } tokio = { version = "1", features = ["time"] } futures-util = "0.3" minisign-verify = "0.2.5" +qrcode = { version = "0.14", default-features = false, features = ["svg"] } [target.'cfg(windows)'.dependencies] widestring = "1" diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index 032077fd1..391e49510 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ mod home_status; mod knowledge_base; mod local_development_bridge; mod metacognitive_zero_layer; +mod mobile_sync; mod module_package_runtime; mod native_composition; mod number_coordinate_tree; @@ -53,6 +54,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .manage(numbered_ipc::NumberedIpcState::default()) + .manage(mobile_sync::MobileSyncState::default()) .invoke_handler(tauri::generate_handler![numbered_ipc::numbered_ipc,]) .setup(|app| { // GLS 是 HoloLake 产品内核,不是开发机旁路服务。合同、依赖闭包、 diff --git a/product-source/hololake-native-desktop/src-tauri/src/mobile_sync.rs b/product-source/hololake-native-desktop/src-tauri/src/mobile_sync.rs new file mode 100644 index 000000000..0aa429de0 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/mobile_sync.rs @@ -0,0 +1,1227 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use qrcode::{render::svg, QrCode}; +use ring::{ + aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305}, + rand::{SecureRandom, SystemRandom}, +}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs, + io::{Read, Write}, + net::{Shutdown, TcpListener, TcpStream, UdpSocket}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +const MOBILE_SYNC_SCHEMA: &str = "hololake.mobile-sync/v1"; +const MOBILE_PAIR_AAD: &[u8] = b"hololake.mobile.pair/v1"; +const MOBILE_SYNC_AAD_PREFIX: &str = "hololake.mobile.sync/v1:"; +const MAX_REQUEST_BYTES: usize = 256 * 1024; +const PAIRING_TTL_MS: u64 = 10 * 60 * 1000; +const DEFAULT_PORT: u16 = 37_421; +const MAX_ACTIVE_CONNECTIONS: usize = 16; +const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-MOBILE-SYNC-0001"; +const ADAPTER: &str = "mobile-sync-v1"; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +#[derive(Default)] +pub struct MobileSyncState { + handle: Mutex>, +} + +struct MobileSyncHandle { + runtime: Arc, + worker: Option>, +} + +impl Drop for MobileSyncHandle { + fn drop(&mut self) { + self.runtime.shutdown.store(true, Ordering::Release); + let _ = TcpStream::connect(("127.0.0.1", self.runtime.port)); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +struct MobileSyncRuntime { + shutdown: AtomicBool, + active_connections: AtomicUsize, + port: u16, + lan_address: String, + desktop_name: String, + account_root: PathBuf, + database_path: PathBuf, + pairing: Mutex, +} + +#[derive(Clone)] +struct PairingMaterial { + pairing_id: String, + secret: [u8; 32], + expires_at_unix_ms: u64, + used: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MobileSyncStatus { + schema: &'static str, + state: &'static str, + running: bool, + lan_address: Option, + port: Option, + pairing_uri: Option, + pairing_qr_svg: Option, + pairing_expires_at_unix_ms: Option, + paired_devices: Vec, + transport: &'static str, + encryption: &'static str, + desktop_is_root_node: bool, + platform_private_data_custody: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PairedDeviceSummary { + device_id: String, + display_name: String, + platform: String, + created_at_unix_ms: u64, + last_seen_at_unix_ms: Option, + state: &'static str, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PairRequest { + device_name: String, + platform: String, + request_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PairResponse { + schema: &'static str, + state: &'static str, + device_id: String, + session_key: String, + desktop_name: String, + root_node_role: &'static str, + request_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SyncRequest { + counter: u64, + after_cursor: Option, + capture: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MobileCaptureInput { + title: String, + body: String, + request_id: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MobileSyncSnapshot { + schema: &'static str, + state: &'static str, + cursor: u64, + generated_at_unix_ms: u64, + desktop_name: String, + root_node_online: bool, + personal_channel: MobileChannelProjection, + web_novel: MobileWebNovelProjection, + education: MobileEducationProjection, + recent_captures: Vec, + boundary: MobileBoundaryProjection, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MobileChannelProjection { + home: &'static str, + growth_event_count: u64, + integrity: &'static str, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct MobileWebNovelProjection { + work_count: u64, + volume_count: u64, + chapter_count: u64, + works: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MobileWebNovelWork { + work_id: String, + title: String, + status: String, + updated_at_unix_ms: u64, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct MobileEducationProjection { + active_table_count: u64, + archived_table_count: u64, + unassigned_table_count: u64, + sensitive_values_included: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MobileCaptureProjection { + cursor: u64, + capture_id: String, + title: String, + body: String, + source_device_id: String, + created_at_unix_ms: u64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MobileBoundaryProjection { + mobile_role: &'static str, + remote_desktop_clone: bool, + desktop_offline_execution: bool, + sensitive_education_values: &'static str, + model_api: &'static str, +} + +struct HttpRequest { + method: String, + path: String, + headers: HashMap, + body: Vec, +} + +#[derive(Debug)] +struct HttpResponse { + status: u16, + reason: &'static str, + headers: Vec<(String, String)>, + body: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RevokeMobileSyncDeviceInput { + device_id: String, +} + +pub fn start_mobile_sync(app: AppHandle) -> Result { + require_active(&app)?; + let state = app.state::(); + let mut guard = state + .handle + .lock() + .map_err(|_| "HOLOLAKE_MOBILE_SYNC_STATE_LOCK_FAILED".to_string())?; + if guard.is_none() { + let sync_root = crate::authenticated_storage::account_storage_root(&app, "mobile-sync-v1")?; + let account_root = sync_root + .parent() + .ok_or_else(|| "HOLOLAKE_MOBILE_SYNC_ACCOUNT_ROOT_INVALID".to_string())? + .to_path_buf(); + let database_path = sync_root.join("mobile-sync.sqlite3"); + initialize_database(&database_path)?; + let listener = TcpListener::bind(("0.0.0.0", DEFAULT_PORT)) + .or_else(|_| TcpListener::bind(("0.0.0.0", 0))) + .map_err(|error| format!("HOLOLAKE_MOBILE_SYNC_LISTEN_FAILED: {error}"))?; + listener + .set_nonblocking(true) + .map_err(|error| format!("HOLOLAKE_MOBILE_SYNC_NONBLOCKING_FAILED: {error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("HOLOLAKE_MOBILE_SYNC_ADDRESS_FAILED: {error}"))? + .port(); + let runtime = Arc::new(MobileSyncRuntime { + shutdown: AtomicBool::new(false), + active_connections: AtomicUsize::new(0), + port, + lan_address: resolve_lan_address(), + desktop_name: desktop_name(), + account_root, + database_path, + pairing: Mutex::new(new_pairing_material()?), + }); + let worker_runtime = runtime.clone(); + let worker = thread::Builder::new() + .name("hololake-mobile-sync".into()) + .spawn(move || serve(listener, worker_runtime)) + .map_err(|error| format!("HOLOLAKE_MOBILE_SYNC_THREAD_FAILED: {error}"))?; + *guard = Some(MobileSyncHandle { + runtime, + worker: Some(worker), + }); + } + status_from_handle(guard.as_ref()) +} + +pub fn get_mobile_sync_status(app: AppHandle) -> Result { + require_active(&app)?; + let state = app.state::(); + let guard = state + .handle + .lock() + .map_err(|_| "HOLOLAKE_MOBILE_SYNC_STATE_LOCK_FAILED".to_string())?; + status_from_handle(guard.as_ref()) +} + +pub fn rotate_mobile_pairing(app: AppHandle) -> Result { + require_active(&app)?; + let state = app.state::(); + let guard = state + .handle + .lock() + .map_err(|_| "HOLOLAKE_MOBILE_SYNC_STATE_LOCK_FAILED".to_string())?; + let handle = guard + .as_ref() + .ok_or_else(|| "HOLOLAKE_MOBILE_SYNC_NOT_RUNNING".to_string())?; + *handle + .runtime + .pairing + .lock() + .map_err(|_| "HOLOLAKE_MOBILE_SYNC_PAIRING_LOCK_FAILED".to_string())? = + new_pairing_material()?; + status_from_handle(Some(handle)) +} + +pub fn stop_mobile_sync(app: AppHandle) -> Result { + require_active(&app)?; + stop_for_unmount(&app)?; + status_from_handle(None) +} + +pub(crate) fn stop_for_unmount(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let handle = state + .handle + .lock() + .map_err(|_| "HOLOLAKE_MOBILE_SYNC_STATE_LOCK_FAILED".to_string())? + .take(); + drop(handle); + Ok(()) +} + +pub fn revoke_mobile_sync_device( + app: AppHandle, + input: RevokeMobileSyncDeviceInput, +) -> Result { + require_active(&app)?; + validate_id(&input.device_id)?; + let state = app.state::(); + let guard = state + .handle + .lock() + .map_err(|_| "HOLOLAKE_MOBILE_SYNC_STATE_LOCK_FAILED".to_string())?; + let handle = guard + .as_ref() + .ok_or_else(|| "HOLOLAKE_MOBILE_SYNC_NOT_RUNNING".to_string())?; + let connection = open_database(&handle.runtime.database_path)?; + let changed = connection + .execute( + "UPDATE mobile_devices SET revoked = 1 WHERE device_id = ?1 AND revoked = 0", + params![input.device_id], + ) + .map_err(database_error)?; + if changed != 1 { + return Err("HOLOLAKE_MOBILE_SYNC_DEVICE_NOT_FOUND".into()); + } + status_from_handle(Some(handle)) +} + +pub fn get_mobile_sync_snapshot(app: AppHandle) -> Result { + require_active(&app)?; + let state = app.state::(); + let guard = state + .handle + .lock() + .map_err(|_| "HOLOLAKE_MOBILE_SYNC_STATE_LOCK_FAILED".to_string())?; + let handle = guard + .as_ref() + .ok_or_else(|| "HOLOLAKE_MOBILE_SYNC_NOT_RUNNING".to_string())?; + build_snapshot(&handle.runtime) +} + +fn status_from_handle(handle: Option<&MobileSyncHandle>) -> Result { + let Some(handle) = handle else { + return Ok(MobileSyncStatus { + schema: MOBILE_SYNC_SCHEMA, + state: "OFFLINE", + running: false, + lan_address: None, + port: None, + pairing_uri: None, + pairing_qr_svg: None, + pairing_expires_at_unix_ms: None, + paired_devices: Vec::new(), + transport: "SAME_LAN_DIRECT", + encryption: "CHACHA20_POLY1305_APPLICATION_LAYER", + desktop_is_root_node: true, + platform_private_data_custody: false, + }); + }; + let now = now_unix_ms(); + let pairing = handle + .runtime + .pairing + .lock() + .map_err(|_| "HOLOLAKE_MOBILE_SYNC_PAIRING_LOCK_FAILED".to_string())? + .clone(); + let active = !pairing.used && pairing.expires_at_unix_ms > now; + let pairing_uri = active.then(|| pairing_uri(&handle.runtime, &pairing)); + let pairing_qr_svg = pairing_uri.as_ref().map(|uri| qr_svg(uri)).transpose()?; + Ok(MobileSyncStatus { + schema: MOBILE_SYNC_SCHEMA, + state: if active { "PAIRING_READY" } else { "RUNNING" }, + running: true, + lan_address: Some(handle.runtime.lan_address.clone()), + port: Some(handle.runtime.port), + pairing_uri, + pairing_qr_svg, + pairing_expires_at_unix_ms: active.then_some(pairing.expires_at_unix_ms), + paired_devices: list_devices(&handle.runtime.database_path)?, + transport: "SAME_LAN_DIRECT", + encryption: "CHACHA20_POLY1305_APPLICATION_LAYER", + desktop_is_root_node: true, + platform_private_data_custody: false, + }) +} + +fn serve(listener: TcpListener, runtime: Arc) { + while !runtime.shutdown.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, _)) => { + if runtime.active_connections.fetch_add(1, Ordering::AcqRel) + >= MAX_ACTIVE_CONNECTIONS + { + runtime.active_connections.fetch_sub(1, Ordering::AcqRel); + drop(stream); + continue; + } + let connection_runtime = runtime.clone(); + if thread::Builder::new() + .name("hololake-mobile-sync-connection".into()) + .spawn(move || { + handle_connection(stream, &connection_runtime); + connection_runtime + .active_connections + .fetch_sub(1, Ordering::AcqRel); + }) + .is_err() + { + runtime.active_connections.fetch_sub(1, Ordering::AcqRel); + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(40)); + } + Err(_) => thread::sleep(Duration::from_millis(100)), + } + } +} + +fn handle_connection(mut stream: TcpStream, runtime: &MobileSyncRuntime) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); + let response = match read_http_request(&mut stream) { + Ok(request) => route_request(runtime, request), + Err(_) => error_response(400, "Bad Request", "INVALID_REQUEST"), + }; + let _ = write_http_response(&mut stream, response); + let _ = stream.shutdown(Shutdown::Both); +} + +fn route_request(runtime: &MobileSyncRuntime, request: HttpRequest) -> HttpResponse { + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/v1/status") => json_response( + 200, + "OK", + &serde_json::json!({ + "schema": MOBILE_SYNC_SCHEMA, + "state": "PAIRING_ENDPOINT_REACHABLE", + "privatePayloadReadable": false, + }), + ), + ("POST", "/v1/pair") => match pair_device(runtime, &request) { + Ok(response) => response, + Err(code) => error_response(401, "Unauthorized", &code), + }, + ("POST", "/v1/sync") => match sync_device(runtime, &request) { + Ok(response) => response, + Err(code) => error_response(401, "Unauthorized", &code), + }, + _ => error_response(404, "Not Found", "ROUTE_NOT_FOUND"), + } +} + +fn pair_device(runtime: &MobileSyncRuntime, request: &HttpRequest) -> Result { + let pairing_id = required_header(request, "x-hololake-pairing-id")?; + let nonce = decode_nonce(required_header(request, "x-hololake-nonce")?)?; + let mut pairing = runtime + .pairing + .lock() + .map_err(|_| "PAIRING_LOCK_FAILED".to_string())?; + if pairing.used + || pairing.expires_at_unix_ms <= now_unix_ms() + || pairing.pairing_id != pairing_id + { + return Err("PAIRING_EXPIRED_OR_INVALID".into()); + } + let clear = decrypt(&pairing.secret, nonce, MOBILE_PAIR_AAD, &request.body)?; + let input: PairRequest = + serde_json::from_slice(&clear).map_err(|_| "PAIRING_PAYLOAD_INVALID")?; + validate_device_name(&input.device_name)?; + if input.platform != "IOS" || Uuid::parse_str(&input.request_id).is_err() { + return Err("PAIRING_DEVICE_INVALID".into()); + } + let device_id = format!("ios-{}", Uuid::new_v4()); + let session_key = random_32()?; + let now = now_unix_ms(); + let connection = open_database(&runtime.database_path)?; + connection + .execute( + "INSERT INTO mobile_devices (device_id, display_name, platform, session_key, last_counter, created_at_unix_ms, last_seen_at_unix_ms, revoked) VALUES (?1, ?2, ?3, ?4, 0, ?5, NULL, 0)", + params![device_id, input.device_name, input.platform, session_key.as_slice(), now], + ) + .map_err(database_error)?; + pairing.used = true; + let response = PairResponse { + schema: MOBILE_SYNC_SCHEMA, + state: "PAIRED", + device_id, + session_key: URL_SAFE_NO_PAD.encode(session_key), + desktop_name: runtime.desktop_name.clone(), + root_node_role: "USER_LOCAL_COMPUTER_TERMINAL", + request_id: input.request_id, + }; + encrypted_response(&pairing.secret, MOBILE_PAIR_AAD, &response) +} + +fn sync_device(runtime: &MobileSyncRuntime, request: &HttpRequest) -> Result { + let device_id = required_header(request, "x-hololake-device-id")?; + validate_id(device_id)?; + let nonce = decode_nonce(required_header(request, "x-hololake-nonce")?)?; + let mut connection = open_database(&runtime.database_path)?; + let (session_key, revoked): (Vec, i64) = connection + .query_row( + "SELECT session_key, revoked FROM mobile_devices WHERE device_id = ?1", + params![device_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(database_error)? + .ok_or_else(|| "DEVICE_NOT_PAIRED".to_string())?; + if revoked != 0 || session_key.len() != 32 { + return Err("DEVICE_REVOKED".into()); + } + let key: [u8; 32] = session_key + .try_into() + .map_err(|_| "DEVICE_SESSION_KEY_INVALID".to_string())?; + let aad = sync_aad(device_id); + let clear = decrypt(&key, nonce, aad.as_bytes(), &request.body)?; + let input: SyncRequest = serde_json::from_slice(&clear).map_err(|_| "SYNC_PAYLOAD_INVALID")?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_error)?; + let last_counter: u64 = transaction + .query_row( + "SELECT last_counter FROM mobile_devices WHERE device_id = ?1 AND revoked = 0", + params![device_id], + |row| row.get(0), + ) + .map_err(database_error)?; + if input.counter <= last_counter { + return Err("SYNC_REPLAY_REJECTED".into()); + } + if let Some(capture) = input.capture { + validate_capture(&capture)?; + transaction + .execute( + "INSERT OR IGNORE INTO mobile_captures (capture_id, title, body, source_device_id, request_id, created_at_unix_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![Uuid::new_v4().to_string(), capture.title.trim(), capture.body.trim(), device_id, capture.request_id, now_unix_ms()], + ) + .map_err(database_error)?; + } + transaction + .execute( + "UPDATE mobile_devices SET last_counter = ?2, last_seen_at_unix_ms = ?3 WHERE device_id = ?1 AND revoked = 0", + params![device_id, input.counter, now_unix_ms()], + ) + .map_err(database_error)?; + transaction.commit().map_err(database_error)?; + let mut snapshot = build_snapshot(runtime)?; + if let Some(after) = input.after_cursor { + snapshot.recent_captures.retain(|item| item.cursor > after); + } + encrypted_response(&key, aad.as_bytes(), &snapshot) +} + +fn build_snapshot(runtime: &MobileSyncRuntime) -> Result { + let captures = list_captures(&runtime.database_path)?; + let cursor = captures.first().map(|item| item.cursor).unwrap_or(0); + let growth_event_count = count_rows( + &runtime + .account_root + .join("channel-growth-v1/channel-growth.sqlite3"), + "growth_events", + None, + ); + Ok(MobileSyncSnapshot { + schema: MOBILE_SYNC_SCHEMA, + state: "SYNCED_WITH_ROOT_NODE", + cursor, + generated_at_unix_ms: now_unix_ms(), + desktop_name: runtime.desktop_name.clone(), + root_node_online: true, + personal_channel: MobileChannelProjection { + home: "ONE_PERSON_ONE_PERSONAL_CHANNEL", + growth_event_count, + integrity: "LOCAL_APPEND_ONLY_PROJECTION", + }, + web_novel: web_novel_projection(&runtime.account_root), + education: education_projection(&runtime.account_root), + recent_captures: captures, + boundary: MobileBoundaryProjection { + mobile_role: "REMOTE_BODY_ENTRY_OF_THE_SAME_PERSONA_SYSTEM", + remote_desktop_clone: false, + desktop_offline_execution: false, + sensitive_education_values: "NEVER_INCLUDED_IN_MOBILE_SUMMARY", + model_api: "NOT_CONFIGURED_LOCAL_PROJECTION_ONLY", + }, + }) +} + +fn web_novel_projection(account_root: &Path) -> MobileWebNovelProjection { + let path = account_root.join("web-novel-workspace-v1/web-novel-workspace.sqlite3"); + let Ok(connection) = + Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + return MobileWebNovelProjection::default(); + }; + let work_count = query_count(&connection, "SELECT COUNT(*) FROM web_novel_works"); + let volume_count = query_count(&connection, "SELECT COUNT(*) FROM web_novel_volumes"); + let chapter_count = query_count(&connection, "SELECT COUNT(*) FROM web_novel_chapters"); + let works = connection + .prepare("SELECT work_id, title, status, updated_at_unix_ms FROM web_novel_works ORDER BY updated_at_unix_ms DESC LIMIT 20") + .and_then(|mut statement| { + statement + .query_map([], |row| { + Ok(MobileWebNovelWork { + work_id: row.get(0)?, + title: row.get(1)?, + status: row.get(2)?, + updated_at_unix_ms: row.get(3)?, + }) + }) + .and_then(|rows| rows.collect()) + }) + .unwrap_or_default(); + MobileWebNovelProjection { + work_count, + volume_count, + chapter_count, + works, + } +} + +fn education_projection(account_root: &Path) -> MobileEducationProjection { + let path = account_root.join("education-workspace-v1/education-workspace.sqlite3"); + let Ok(connection) = + Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + return MobileEducationProjection::default(); + }; + MobileEducationProjection { + active_table_count: query_count( + &connection, + "SELECT COUNT(*) FROM education_tables WHERE assignment_scope = 'EDUCATION' AND archived = 0", + ), + archived_table_count: query_count( + &connection, + "SELECT COUNT(*) FROM education_tables WHERE assignment_scope = 'EDUCATION' AND archived = 1", + ), + unassigned_table_count: query_count( + &connection, + "SELECT COUNT(*) FROM education_tables WHERE assignment_scope = 'UNASSIGNED' AND archived = 0", + ), + sensitive_values_included: false, + } +} + +fn query_count(connection: &Connection, sql: &str) -> u64 { + connection.query_row(sql, [], |row| row.get(0)).unwrap_or(0) +} + +fn count_rows(path: &Path, table: &str, condition: Option<&str>) -> u64 { + let Ok(connection) = + Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + return 0; + }; + let sql = format!( + "SELECT COUNT(*) FROM {table}{}", + condition + .map(|value| format!(" WHERE {value}")) + .unwrap_or_default() + ); + query_count(&connection, &sql) +} + +fn list_devices(path: &Path) -> Result, String> { + let connection = open_database(path)?; + let mut statement = connection + .prepare("SELECT device_id, display_name, platform, created_at_unix_ms, last_seen_at_unix_ms, revoked FROM mobile_devices ORDER BY created_at_unix_ms DESC") + .map_err(database_error)?; + let devices = statement + .query_map([], |row| { + let revoked: i64 = row.get(5)?; + Ok(PairedDeviceSummary { + device_id: row.get(0)?, + display_name: row.get(1)?, + platform: row.get(2)?, + created_at_unix_ms: row.get(3)?, + last_seen_at_unix_ms: row.get(4)?, + state: if revoked == 0 { "ACTIVE" } else { "REVOKED" }, + }) + }) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + Ok(devices) +} + +fn list_captures(path: &Path) -> Result, String> { + let connection = open_database(path)?; + let mut statement = connection + .prepare("SELECT sequence, capture_id, title, body, source_device_id, created_at_unix_ms FROM mobile_captures ORDER BY sequence DESC LIMIT 50") + .map_err(database_error)?; + let captures = statement + .query_map([], |row| { + Ok(MobileCaptureProjection { + cursor: row.get(0)?, + capture_id: row.get(1)?, + title: row.get(2)?, + body: row.get(3)?, + source_device_id: row.get(4)?, + created_at_unix_ms: row.get(5)?, + }) + }) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + Ok(captures) +} + +fn initialize_database(path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("HOLOLAKE_MOBILE_SYNC_ROOT_FAILED: {error}"))?; + } + let connection = open_database(path)?; + connection + .execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS mobile_devices ( + device_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + platform TEXT NOT NULL, + session_key BLOB NOT NULL, + last_counter INTEGER NOT NULL DEFAULT 0, + created_at_unix_ms INTEGER NOT NULL, + last_seen_at_unix_ms INTEGER, + revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0,1)) + ); + CREATE TABLE IF NOT EXISTS mobile_captures ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + capture_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + body TEXT NOT NULL, + source_device_id TEXT NOT NULL, + request_id TEXT NOT NULL UNIQUE, + created_at_unix_ms INTEGER NOT NULL, + FOREIGN KEY(source_device_id) REFERENCES mobile_devices(device_id) + );", + ) + .map_err(database_error) +} + +fn open_database(path: &Path) -> Result { + let connection = Connection::open(path).map_err(database_error)?; + connection + .busy_timeout(Duration::from_secs(3)) + .map_err(database_error)?; + Ok(connection) +} + +fn read_http_request(stream: &mut TcpStream) -> Result { + let mut buffer = Vec::with_capacity(4096); + let mut chunk = [0u8; 4096]; + let header_end = loop { + let read = stream.read(&mut chunk).map_err(|_| "REQUEST_READ_FAILED")?; + if read == 0 { + return Err("REQUEST_TRUNCATED".into()); + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.len() > MAX_REQUEST_BYTES { + return Err("REQUEST_TOO_LARGE".into()); + } + if let Some(index) = find_bytes(&buffer, b"\r\n\r\n") { + break index + 4; + } + }; + let header_text = std::str::from_utf8(&buffer[..header_end]).map_err(|_| "HEADERS_INVALID")?; + let mut lines = header_text.split("\r\n"); + let mut request_line = lines + .next() + .ok_or("REQUEST_LINE_MISSING")? + .split_whitespace(); + let method = request_line.next().ok_or("METHOD_MISSING")?.to_string(); + let path = request_line.next().ok_or("PATH_MISSING")?.to_string(); + if request_line.next() != Some("HTTP/1.1") || request_line.next().is_some() { + return Err("HTTP_VERSION_INVALID".into()); + } + let mut headers = HashMap::new(); + for line in lines.filter(|line| !line.is_empty()) { + let Some((name, value)) = line.split_once(':') else { + return Err("HEADER_INVALID".into()); + }; + if headers + .insert(name.trim().to_ascii_lowercase(), value.trim().to_string()) + .is_some() + { + return Err("DUPLICATE_HEADER_REJECTED".into()); + } + } + let content_length = headers + .get("content-length") + .map(|value| value.parse::().map_err(|_| "CONTENT_LENGTH_INVALID")) + .transpose()? + .unwrap_or(0); + if content_length > MAX_REQUEST_BYTES { + return Err("REQUEST_TOO_LARGE".into()); + } + while buffer.len() < header_end + content_length { + let read = stream.read(&mut chunk).map_err(|_| "BODY_READ_FAILED")?; + if read == 0 { + return Err("BODY_TRUNCATED".into()); + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.len() > MAX_REQUEST_BYTES { + return Err("REQUEST_TOO_LARGE".into()); + } + } + Ok(HttpRequest { + method, + path, + headers, + body: buffer[header_end..header_end + content_length].to_vec(), + }) +} + +fn write_http_response(stream: &mut TcpStream, response: HttpResponse) -> Result<(), String> { + let mut head = format!( + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n", + response.status, + response.reason, + response.body.len() + ); + for (name, value) in response.headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str("\r\n"); + stream + .write_all(head.as_bytes()) + .map_err(|_| "RESPONSE_WRITE_FAILED")?; + stream + .write_all(&response.body) + .map_err(|_| "RESPONSE_WRITE_FAILED".to_string()) +} + +fn encrypted_response( + key: &[u8; 32], + aad: &[u8], + value: &T, +) -> Result { + let clear = serde_json::to_vec(value).map_err(|_| "RESPONSE_SERIALIZATION_FAILED")?; + let nonce = random_nonce()?; + let body = encrypt(key, nonce, aad, &clear)?; + Ok(HttpResponse { + status: 200, + reason: "OK", + headers: vec![ + ("Content-Type".into(), "application/octet-stream".into()), + ("X-HoloLake-Nonce".into(), URL_SAFE_NO_PAD.encode(nonce)), + ], + body, + }) +} + +fn json_response(status: u16, reason: &'static str, value: &serde_json::Value) -> HttpResponse { + HttpResponse { + status, + reason, + headers: vec![("Content-Type".into(), "application/json".into())], + body: serde_json::to_vec(value).unwrap_or_default(), + } +} + +fn error_response(status: u16, reason: &'static str, code: &str) -> HttpResponse { + json_response( + status, + reason, + &serde_json::json!({"schema": MOBILE_SYNC_SCHEMA, "state": "REJECTED", "code": code}), + ) +} + +fn encrypt(key: &[u8; 32], nonce: [u8; 12], aad: &[u8], clear: &[u8]) -> Result, String> { + let unbound = UnboundKey::new(&CHACHA20_POLY1305, key).map_err(|_| "ENCRYPTION_KEY_INVALID")?; + let key = LessSafeKey::new(unbound); + let mut output = clear.to_vec(); + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad), + &mut output, + ) + .map_err(|_| "ENCRYPTION_FAILED".to_string())?; + Ok(output) +} + +fn decrypt( + key: &[u8; 32], + nonce: [u8; 12], + aad: &[u8], + encrypted: &[u8], +) -> Result, String> { + let unbound = UnboundKey::new(&CHACHA20_POLY1305, key).map_err(|_| "ENCRYPTION_KEY_INVALID")?; + let key = LessSafeKey::new(unbound); + let mut buffer = encrypted.to_vec(); + let clear = key + .open_in_place( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad), + &mut buffer, + ) + .map_err(|_| "AUTHENTICATION_FAILED".to_string())?; + Ok(clear.to_vec()) +} + +fn pairing_uri(runtime: &MobileSyncRuntime, pairing: &PairingMaterial) -> String { + format!( + "hololake://pair?host={}&port={}&id={}&secret={}", + runtime.lan_address, + runtime.port, + pairing.pairing_id, + URL_SAFE_NO_PAD.encode(pairing.secret) + ) +} + +fn qr_svg(value: &str) -> Result { + let code = QrCode::new(value.as_bytes()).map_err(|_| "HOLOLAKE_MOBILE_SYNC_QR_FAILED")?; + Ok(code + .render::() + .min_dimensions(320, 320) + .dark_color(svg::Color("#07111f")) + .light_color(svg::Color("#f5f7fc")) + .build()) +} + +fn new_pairing_material() -> Result { + Ok(PairingMaterial { + pairing_id: Uuid::new_v4().to_string(), + secret: random_32()?, + expires_at_unix_ms: now_unix_ms().saturating_add(PAIRING_TTL_MS), + used: false, + }) +} + +fn random_32() -> Result<[u8; 32], String> { + let mut bytes = [0u8; 32]; + SystemRandom::new() + .fill(&mut bytes) + .map_err(|_| "SECURE_RANDOM_UNAVAILABLE".to_string())?; + Ok(bytes) +} + +fn random_nonce() -> Result<[u8; 12], String> { + let mut bytes = [0u8; 12]; + SystemRandom::new() + .fill(&mut bytes) + .map_err(|_| "SECURE_RANDOM_UNAVAILABLE".to_string())?; + Ok(bytes) +} + +fn decode_nonce(value: &str) -> Result<[u8; 12], String> { + URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| "NONCE_INVALID".to_string())? + .try_into() + .map_err(|_| "NONCE_INVALID".to_string()) +} + +fn required_header<'a>(request: &'a HttpRequest, name: &str) -> Result<&'a str, String> { + request + .headers + .get(name) + .map(String::as_str) + .ok_or_else(|| "AUTHENTICATION_HEADER_MISSING".to_string()) +} + +fn sync_aad(device_id: &str) -> String { + format!("{MOBILE_SYNC_AAD_PREFIX}{device_id}") +} + +fn validate_device_name(value: &str) -> Result<(), String> { + let length = value.trim().chars().count(); + if (1..=80).contains(&length) { + Ok(()) + } else { + Err("DEVICE_NAME_INVALID".into()) + } +} + +fn validate_capture(value: &MobileCaptureInput) -> Result<(), String> { + if value.title.trim().chars().count() > 120 + || value.body.trim().is_empty() + || value.body.trim().chars().count() > 10_000 + || Uuid::parse_str(&value.request_id).is_err() + { + Err("CAPTURE_INVALID".into()) + } else { + Ok(()) + } +} + +fn validate_id(value: &str) -> Result<(), String> { + if !value.is_empty() + && value.len() <= 80 + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + Ok(()) + } else { + Err("IDENTIFIER_INVALID".into()) + } +} + +fn resolve_lan_address() -> String { + let candidate = UdpSocket::bind("0.0.0.0:0") + .and_then(|socket| { + socket.connect("192.0.2.1:9")?; + socket.local_addr() + }) + .map(|address| address.ip().to_string()); + match candidate { + Ok(value) if value != "0.0.0.0" => value, + _ => "127.0.0.1".into(), + } +} + +fn desktop_name() -> String { + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "HoloLake Mac".into()) +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn database_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_MOBILE_SYNC_DATABASE_FAILED: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn encrypted_payload_requires_the_exact_key_nonce_and_aad() { + let key = [7u8; 32]; + let nonce = [9u8; 12]; + let encrypted = encrypt(&key, nonce, b"scope", b"hello").unwrap(); + assert_eq!( + decrypt(&key, nonce, b"scope", &encrypted).unwrap(), + b"hello" + ); + assert!(decrypt(&key, nonce, b"other", &encrypted).is_err()); + } + + #[test] + fn sync_database_keeps_device_keys_and_capture_requests_unique() { + let temporary = tempdir().unwrap(); + let database = temporary.path().join("mobile-sync.sqlite3"); + initialize_database(&database).unwrap(); + let connection = open_database(&database).unwrap(); + connection + .execute( + "INSERT INTO mobile_devices (device_id, display_name, platform, session_key, created_at_unix_ms) VALUES ('ios-one', 'iPhone', 'IOS', ?1, 1)", + params![[1u8; 32].as_slice()], + ) + .unwrap(); + connection + .execute( + "INSERT INTO mobile_captures (capture_id, title, body, source_device_id, request_id, created_at_unix_ms) VALUES ('capture-one', '想法', '正文', 'ios-one', 'request-one', 2)", + [], + ) + .unwrap(); + assert_eq!(list_devices(&database).unwrap().len(), 1); + assert_eq!(list_captures(&database).unwrap().len(), 1); + assert!(connection + .execute( + "INSERT INTO mobile_captures (capture_id, title, body, source_device_id, request_id, created_at_unix_ms) VALUES ('capture-two', '重复', '正文', 'ios-one', 'request-one', 3)", + [], + ) + .is_err()); + } + + #[test] + fn pairing_uri_contains_a_full_random_secret_without_persisting_it() { + let material = new_pairing_material().unwrap(); + let runtime = MobileSyncRuntime { + shutdown: AtomicBool::new(false), + active_connections: AtomicUsize::new(0), + port: 37421, + lan_address: "192.168.1.8".into(), + desktop_name: "Mac".into(), + account_root: PathBuf::new(), + database_path: PathBuf::new(), + pairing: Mutex::new(material.clone()), + }; + let uri = pairing_uri(&runtime, &material); + assert!(uri.starts_with("hololake://pair?host=192.168.1.8&port=37421&id=")); + assert!(uri.contains("&secret=")); + } + + #[test] + fn pairing_is_single_use_and_sync_replay_is_rejected() { + let temporary = tempdir().unwrap(); + let database = temporary.path().join("mobile-sync.sqlite3"); + initialize_database(&database).unwrap(); + let pairing = new_pairing_material().unwrap(); + let runtime = MobileSyncRuntime { + shutdown: AtomicBool::new(false), + active_connections: AtomicUsize::new(0), + port: 37421, + lan_address: "192.168.1.8".into(), + desktop_name: "Mac".into(), + account_root: temporary.path().to_path_buf(), + database_path: database, + pairing: Mutex::new(pairing.clone()), + }; + let pair_nonce = [3u8; 12]; + let pair_clear = serde_json::to_vec(&serde_json::json!({ + "deviceName": "冰朔的 iPhone", + "platform": "IOS", + "requestId": Uuid::new_v4().to_string() + })) + .unwrap(); + let pair_request = HttpRequest { + method: "POST".into(), + path: "/v1/pair".into(), + headers: HashMap::from([ + ("x-hololake-pairing-id".into(), pairing.pairing_id.clone()), + ( + "x-hololake-nonce".into(), + URL_SAFE_NO_PAD.encode(pair_nonce), + ), + ]), + body: encrypt(&pairing.secret, pair_nonce, MOBILE_PAIR_AAD, &pair_clear).unwrap(), + }; + let pair_response = pair_device(&runtime, &pair_request).unwrap(); + assert_eq!( + pair_device(&runtime, &pair_request).unwrap_err(), + "PAIRING_EXPIRED_OR_INVALID" + ); + let response_nonce = decode_nonce( + pair_response + .headers + .iter() + .find(|(name, _)| name == "X-HoloLake-Nonce") + .map(|(_, value)| value.as_str()) + .unwrap(), + ) + .unwrap(); + let response: serde_json::Value = serde_json::from_slice( + &decrypt( + &pairing.secret, + response_nonce, + MOBILE_PAIR_AAD, + &pair_response.body, + ) + .unwrap(), + ) + .unwrap(); + let device_id = response["deviceId"].as_str().unwrap(); + let key: [u8; 32] = URL_SAFE_NO_PAD + .decode(response["sessionKey"].as_str().unwrap()) + .unwrap() + .try_into() + .unwrap(); + let sync_nonce = [4u8; 12]; + let sync_clear = serde_json::to_vec(&serde_json::json!({ + "counter": 1, + "afterCursor": null, + "capture": null + })) + .unwrap(); + let aad = sync_aad(device_id); + let sync_request = HttpRequest { + method: "POST".into(), + path: "/v1/sync".into(), + headers: HashMap::from([ + ("x-hololake-device-id".into(), device_id.into()), + ( + "x-hololake-nonce".into(), + URL_SAFE_NO_PAD.encode(sync_nonce), + ), + ]), + body: encrypt(&key, sync_nonce, aad.as_bytes(), &sync_clear).unwrap(), + }; + assert!(sync_device(&runtime, &sync_request).is_ok()); + assert_eq!( + sync_device(&runtime, &sync_request).unwrap_err(), + "SYNC_REPLAY_REJECTED" + ); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs b/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs index bc544f10c..0c3b6faf7 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs @@ -87,6 +87,12 @@ const WEB_NOVEL_DELIVERY_PACKAGE: &[u8] = include_bytes!( const WEB_NOVEL_DELIVERY_SIGNATURE: &str = include_str!( "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod.sig" ); +const MOBILE_SYNC_NUMBER: &str = "HLP-MOD-OFFICIAL-MOBILE-SYNC-0001"; +const MOBILE_SYNC_PACKAGE: &[u8] = + include_bytes!("../../fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod"); +const MOBILE_SYNC_SIGNATURE: &str = include_str!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod.sig" +); struct BundledModuleSource { module_number: &'static str, @@ -140,6 +146,11 @@ const BUNDLED_MODULES: &[BundledModuleSource] = &[ package: WEB_NOVEL_DELIVERY_PACKAGE, signature: WEB_NOVEL_DELIVERY_SIGNATURE, }, + BundledModuleSource { + module_number: MOBILE_SYNC_NUMBER, + package: MOBILE_SYNC_PACKAGE, + signature: MOBILE_SYNC_SIGNATURE, + }, ]; #[derive(Debug, Deserialize)] @@ -1209,6 +1220,9 @@ pub async fn unmount_module( app: AppHandle, input: ModuleNumberInput, ) -> Result { + if input.module_number == MOBILE_SYNC_NUMBER { + crate::mobile_sync::stop_for_unmount(&app)?; + } transition( &runtime_root(&app)?, &input.module_number, @@ -1585,7 +1599,7 @@ mod tests { assert_eq!(package.manifest.adapter, "channel-workbench-v1"); assert_eq!(package.manifest.permissions.len(), 4); assert!(is_sha256(&digest)); - assert_eq!(BUNDLED_MODULES.len(), 9); + assert_eq!(BUNDLED_MODULES.len(), 10); } #[test] @@ -1670,6 +1684,29 @@ mod tests { } } + #[test] + fn bundled_mobile_sync_is_signed_and_keeps_the_desktop_as_root_node() { + let (_, package, digest) = read_verified_package_bytes( + MOBILE_SYNC_PACKAGE, + MOBILE_SYNC_SIGNATURE, + RELEASE_TRUST_RAW, + ) + .unwrap(); + assert_eq!(package.manifest.module_number, MOBILE_SYNC_NUMBER); + assert_eq!(package.manifest.registration_class, "OFFICIAL_LIGHTHOUSE"); + assert_eq!(package.manifest.adapter, "mobile-sync-v1"); + assert_eq!(package.manifest.permissions.len(), 5); + assert_eq!( + package.payload["adapterConfig"]["desktopRole"], + "USER_LOCAL_COMPUTER_TERMINAL_ROOT_NODE" + ); + assert_eq!( + package.payload["adapterConfig"]["iosClientPackaging"], + "PENDING_SEPARATE_ADMISSION" + ); + assert!(is_sha256(&digest)); + } + #[cfg(unix)] #[test] fn symlinked_package_input_is_rejected_before_signature_processing() { diff --git a/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs b/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs index ddd421573..c9b92d506 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs @@ -53,7 +53,7 @@ fn validate_tree() -> Result<(), String> { || tree.record_id != "HLP-UNIFIED-NUMBER-TREE-001" || tree.state != "MACHINE_COMPILED_STARTUP_ENFORCED" || tree.root_number != "HLP-NUMBER-WORLD-ROOT-001" - || tree.route_count != 162 + || tree.route_count != 168 || tree.routes.len() != tree.route_count || !tree.invariants.number_is_stable_coordinate_not_authority || !tree.invariants.path_is_unique_navigation diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs index 1f9bafc13..5e5973ed5 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs @@ -934,7 +934,7 @@ mod tests { #[test] fn registry_is_closed_and_contains_every_migrated_command() { let registry = load_registry().unwrap(); - assert_eq!(registry.operations.len(), 140); + assert_eq!(registry.operations.len(), 146); assert!(!registry.runtime.legacy_direct_commands_allowed); } diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs index cb9961788..ceba2fa0f 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs @@ -126,6 +126,20 @@ pub(crate) async fn dispatch( "module_package_runtime::activate_bundled_module" => json( crate::module_package_runtime::activate_bundled_module(app, input(&payload)?).await?, ), + "mobile_sync::start_mobile_sync" => json(crate::mobile_sync::start_mobile_sync(app)?), + "mobile_sync::get_mobile_sync_status" => { + json(crate::mobile_sync::get_mobile_sync_status(app)?) + } + "mobile_sync::rotate_mobile_pairing" => { + json(crate::mobile_sync::rotate_mobile_pairing(app)?) + } + "mobile_sync::stop_mobile_sync" => json(crate::mobile_sync::stop_mobile_sync(app)?), + "mobile_sync::revoke_mobile_sync_device" => json( + crate::mobile_sync::revoke_mobile_sync_device(app, input(&payload)?)?, + ), + "mobile_sync::get_mobile_sync_snapshot" => { + json(crate::mobile_sync::get_mobile_sync_snapshot(app)?) + } "native_composition::get_native_composition_module_registry" => { json(crate::native_composition::get_native_composition_module_registry()) } diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 4c5201fff..bb1675dad 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -9,6 +9,7 @@ const ChannelWorkbenchStudio = lazy(() => import('./modules/channel-workbench'). const PersonaChannelBody = lazy(() => import('./modules/persona-channel-body').then((module) => ({ default: module.PersonaChannelBody }))) const EducationWorkspace = lazy(() => import('./modules/education-workspace').then((module) => ({ default: module.EducationWorkspace }))) const WebNovelWorkspace = lazy(() => import('./modules/web-novel/WebNovelWorkspace').then((module) => ({ default: module.WebNovelWorkspace }))) +const MobileSyncPanel = lazy(() => import('./modules/mobile-sync').then((module) => ({ default: module.MobileSyncPanel }))) const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate'] @@ -55,7 +56,7 @@ import './design-tokens.css' import './styles.css' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' -type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'education' | 'webNovel' | 'persona' | 'code' | 'receipts' | 'system' +type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'education' | 'webNovel' | 'mobileSync' | 'persona' | 'code' | 'receipts' | 'system' type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'personalNodeGuide' | 'tool' type KnowledgeSource = 'native' | 'legacy' @@ -369,7 +370,7 @@ const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], a const themes: Array<{ id: ThemeId; name: string }> = [ { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, ] -const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', education: '教育工作台', webNovel: '网文作者工作台', persona: '人格频道本体', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } +const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', education: '教育工作台', webNovel: '网文作者工作台', mobileSync: '移动同步桥', persona: '人格频道本体', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } const domainGates = [ { domain: 'BRANCH_DOMAIN', className: 'd-sub', title: '光湖分域', gate: 'GATE 02 · ONLINE', facts: [ ['域标识', 'BRANCH_DOMAIN'], ['责任主体', '花尔 · TCS-GL-0005∞'], ['人格体主体', '爆米花 · PER-BMH001 · AGE'], ['关系支持', '糖星云 · PER-TXY001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], @@ -615,6 +616,9 @@ function HoloLakeApp() { const [webNovelModule, setWebNovelModule] = useState(null) const [webNovelBusy, setWebNovelBusy] = useState(false) const [webNovelMessage, setWebNovelMessage] = useState('') + const [mobileSyncModule, setMobileSyncModule] = useState(null) + const [mobileSyncBusy, setMobileSyncBusy] = useState(false) + const [mobileSyncMessage, setMobileSyncMessage] = useState('') const [expanded, setExpanded] = useState>(() => new Set(['导入'])) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') @@ -1207,6 +1211,27 @@ function HoloLakeApp() { finally { setWebNovelBusy(false) } } + const refreshMobileSyncModule = async () => { + try { + const catalog = await invoke('get_bundled_module_catalog') + const module = catalog.find((item) => item.moduleNumber === 'HLP-MOD-OFFICIAL-MOBILE-SYNC-0001') || null + setMobileSyncModule(module) + return module + } catch (error) { setMobileSyncMessage(humanError(error, 'system')); return null } + } + const openMobileSync = () => { openWorldTool('mobileSync'); void refreshMobileSyncModule() } + const activateMobileSyncModule = async () => { + setMobileSyncBusy(true) + setMobileSyncMessage('正在验证官方签名、登记通信编号并确认五项本机网络边界……') + try { + await invoke('activate_bundled_module', { input: { moduleNumber: 'HLP-MOD-OFFICIAL-MOBILE-SYNC-0001', humanConfirmedPermissionExpansion: true } }) + const module = await refreshMobileSyncModule() + if (!module || module.installedState !== 'ACTIVE') throw new Error('HOLOLAKE_MODULE_NOT_ACTIVE') + setMobileSyncMessage('移动同步桥已通过签名、编号、权限和自检验收;监听器仍保持关闭,等待本人显式开启。') + } catch (error) { setMobileSyncMessage(humanError(error, 'system')) } + finally { setMobileSyncBusy(false) } + } + const cloneCodeChannel = async (event: React.FormEvent) => { event.preventDefault() if (!cloneUrl.trim()) return @@ -1635,6 +1660,20 @@ function HoloLakeApp() { ) + const renderMobileSync = () => ( +
+ {mobileSyncModule?.installedState === 'ACTIVE' + ?

正在装载移动同步桥……

}> setWorldStage(toolReturnStage)}/>
+ :
+

启用移动同步桥

+

模块只在本人确认后开放同一局域网入口;电脑仍是唯一根节点,手机只读取最小投影并把快速记录写入隔离收件箱。

+ HLP-MOD-OFFICIAL-MOBILE-SYNC-0001 · 5 项本机网络权限 + + {mobileSyncMessage &&

{mobileSyncMessage}

} +
} +
+ ) + const renderCode = () => (
} @@ -1919,7 +1959,7 @@ function HoloLakeApp() { } {worldStage === 'tool' &&
{viewLabels[view]}{domainDisplayName(repoLogin.domain)}
-
{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'composition' ? renderComposition() : view === 'workbench' ? renderWorkbench() : view === 'education' ? renderEducation() : view === 'webNovel' ? renderWebNovel() : view === 'persona' ? renderPersonaBody() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}
+
{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'composition' ? renderComposition() : view === 'workbench' ? renderWorkbench() : view === 'education' ? renderEducation() : view === 'webNovel' ? renderWebNovel() : view === 'mobileSync' ? renderMobileSync() : view === 'persona' ? renderPersonaBody() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}
}
光湖语言系统 · 通用人工智能操作平台GH-AIOS
diff --git a/product-source/hololake-native-desktop/src/modules/mobile-sync/index.tsx b/product-source/hololake-native-desktop/src/modules/mobile-sync/index.tsx new file mode 100644 index 000000000..eda688bd9 --- /dev/null +++ b/product-source/hololake-native-desktop/src/modules/mobile-sync/index.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { numberedInvoke as invoke } from '../numbered-ipc' +import './styles.css' + +interface PairedDevice { + deviceId: string + displayName: string + platform: string + createdAtUnixMs: number + lastSeenAtUnixMs?: number + state: 'ACTIVE' | 'REVOKED' +} + +interface MobileSyncStatus { + schema: string + state: 'OFFLINE' | 'PAIRING_READY' | 'RUNNING' + running: boolean + lanAddress?: string + port?: number + pairingUri?: string + pairingQrSvg?: string + pairingExpiresAtUnixMs?: number + pairedDevices: PairedDevice[] + transport: string + encryption: string + desktopIsRootNode: boolean + platformPrivateDataCustody: boolean +} + +interface MobileCapture { + cursor: number + captureId: string + title: string + body: string + sourceDeviceId: string + createdAtUnixMs: number +} + +interface MobileSyncSnapshot { + state: string + cursor: number + generatedAtUnixMs: number + desktopName: string + rootNodeOnline: boolean + personalChannel: { home: string; growthEventCount: number; integrity: string } + webNovel: { + workCount: number + volumeCount: number + chapterCount: number + works: Array<{ workId: string; title: string; status: string; updatedAtUnixMs: number }> + } + education: { + activeTableCount: number + archivedTableCount: number + unassignedTableCount: number + sensitiveValuesIncluded: boolean + } + recentCaptures: MobileCapture[] + boundary: { + mobileRole: string + remoteDesktopClone: boolean + desktopOfflineExecution: boolean + sensitiveEducationValues: string + modelApi: string + } +} + +function formatTime(value?: number) { + return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未连接' +} + +export function MobileSyncPanel({ onBack }: { onBack: () => void }) { + const [status, setStatus] = useState(null) + const [snapshot, setSnapshot] = useState(null) + const [message, setMessage] = useState('手机是当前个人节点的远程入口,不是第二套人格系统。') + const [pending, setPending] = useState(false) + + const refresh = useCallback(async () => { + const next = await invoke('get_mobile_sync_status') + setStatus(next) + if (next.running) setSnapshot(await invoke('get_mobile_sync_snapshot')) + else setSnapshot(null) + }, []) + + useEffect(() => { + let cancelled = false + refresh().catch((error) => !cancelled && setMessage(String(error))) + return () => { cancelled = true } + }, [refresh]) + + const run = async (action: () => Promise, success: string) => { + if (pending) return + setPending(true) + try { + await action() + setMessage(success) + await refresh() + } catch (error) { + setMessage(String(error)) + } finally { + setPending(false) + } + } + + const qrSource = useMemo( + () => status?.pairingQrSvg ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(status.pairingQrSvg)}` : '', + [status?.pairingQrSvg], + ) + + return
+
+ +
HOLOLAKE / SAME PERSONA SYSTEMiPhone 多端同步
+ {status?.running ? '个人主节点在线' : '同步未开启'} +
+ +
+ +
手机远程身体入口

{status?.running ? '同一频道,正在等待 iPhone' : '由这台电脑守住唯一真相'}

桌面保留身份、人格、数据与执行主权;手机只同步经过授权的投影与快速记录。

+
+ + {!status?.running ?
+ 同一局域网 · 应用层端到端加密 +

开启后只在当前登录账号的数据根建立同步库。平台服务器不托管你的作品、教育数据、人格记忆或设备密钥。

+ +
:
+
+
PAIRING

配对这台 iPhone

+ {status.pairingUri ? <> + {qrSource && HoloLake iPhone 配对二维码} +

用 iPhone 版 HoloLake 扫描。二维码内含一次性高强度配对密钥,十分钟后或使用一次后失效。

+ + 到期:{formatTime(status.pairingExpiresAtUnixMs)} + :
当前配对凭据已使用或到期

需要添加另一台手机时,再生成一枚新的凭据。

} +
+ +
+
ROOT NODE PROJECTION

当前同步投影

+
+
{snapshot?.webNovel.workCount ?? 0}网文作品
+
{snapshot?.webNovel.chapterCount ?? 0}章节
+
{snapshot?.education.activeTableCount ?? 0}教育表格
+
{snapshot?.personalChannel.growthEventCount ?? 0}成长事件
+
+

教育敏感值:{snapshot?.education.sensitiveValuesIncluded ? '已包含' : '不会进入手机摘要'} · 电脑离线时手机不会冒充执行。

+
+ +
+
DEVICES

已配对设备

+ {status.pairedDevices.length ? status.pairedDevices.map((device) =>
+
{device.displayName}{device.platform} · {device.state === 'ACTIVE' ? `最近连接 ${formatTime(device.lastSeenAtUnixMs)}` : '已撤销'}
+ {device.state === 'ACTIVE' && } +
) :

还没有完成配对的 iPhone。

} +
+ +
+
MOBILE CAPTURES

手机快速记录

+ {snapshot?.recentCaptures.length ? snapshot.recentCaptures.map((capture) =>
{capture.title || '未命名记录'}

{capture.body}

) :

手机写下的想法会加密回到这台电脑,并出现在这里。

} +
+
} + +
{message}
{status?.running && }
+
+} diff --git a/product-source/hololake-native-desktop/src/modules/mobile-sync/styles.css b/product-source/hololake-native-desktop/src/modules/mobile-sync/styles.css new file mode 100644 index 000000000..bd917ab2d --- /dev/null +++ b/product-source/hololake-native-desktop/src/modules/mobile-sync/styles.css @@ -0,0 +1,2 @@ +.mobile-sync-world{min-height:100%;padding:28px 34px 34px;color:var(--text-primary);background:radial-gradient(circle at 50% 30%,color-mix(in srgb,var(--shine-cool) 34%,transparent),transparent 36%),linear-gradient(180deg,transparent,color-mix(in srgb,var(--bg-d) 70%,transparent));overflow:auto}.mobile-sync-worldbar{display:flex;align-items:center;justify-content:space-between;gap:24px}.mobile-sync-worldbar>button,.mobile-sync-footer button,.mobile-sync-world button{border:1px solid var(--glass-edge);background:var(--glass);color:var(--text-primary);border-radius:999px;padding:10px 16px;font:inherit;font-weight:650;cursor:pointer}.mobile-sync-world button:hover{border-color:var(--glass-edge-hover);background:var(--glass-hover)}.mobile-sync-world button:disabled{opacity:.55;cursor:wait}.mobile-sync-worldbar div{display:grid;gap:3px;text-align:center}.mobile-sync-worldbar span,.mobile-sync-projection header span,.mobile-sync-devices header span,.mobile-sync-captures header span,.mobile-sync-pairing header span{font-size:11px;letter-spacing:.16em;color:var(--text-aux);font-weight:700}.mobile-sync-worldbar b{font-size:18px}.mobile-sync-worldbar em{font-style:normal;color:var(--text-aux);font-size:13px}.mobile-sync-focus{min-height:260px;display:grid;place-items:center;position:relative;text-align:center}.mobile-sync-focus>div:last-child{position:relative;z-index:2;max-width:680px}.mobile-sync-focus h1{margin:10px 0 8px;font-size:clamp(30px,4vw,52px);line-height:1.08;font-weight:650;letter-spacing:-.035em}.mobile-sync-focus p{margin:0 auto;max-width:590px;color:var(--text-body);line-height:1.7}.mobile-sync-focus>div>span{color:var(--star-bright);font-size:12px;letter-spacing:.22em;font-weight:700}.mobile-sync-orbit{position:absolute;width:190px;height:190px;border-radius:50%;background:radial-gradient(circle,color-mix(in srgb,var(--pool-cool-core) 45%,transparent),transparent 64%);filter:blur(.2px);opacity:.54}.mobile-sync-orbit:before,.mobile-sync-orbit:after{content:"";position:absolute;inset:18%;border-radius:50%;border:1px solid color-mix(in srgb,var(--star-dim) 34%,transparent)}.mobile-sync-orbit:after{inset:2%;border-color:color-mix(in srgb,var(--star-faint) 18%,transparent)}.mobile-sync-orbit i{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--star-bright);box-shadow:0 0 18px var(--star-bright)}.mobile-sync-orbit i:nth-child(1){left:18%;top:18%}.mobile-sync-orbit i:nth-child(2){right:8%;top:48%}.mobile-sync-orbit i:nth-child(3){left:40%;bottom:2%}.mobile-sync-orbit.is-online{animation:mobile-sync-breathe 5s ease-in-out infinite}.mobile-sync-start{max-width:720px;margin:0 auto;padding:28px;border:1px solid var(--glass-edge);background:var(--glass);border-radius:28px;text-align:center;box-shadow:0 24px 70px color-mix(in srgb,var(--bg-d) 44%,transparent)}.mobile-sync-start b{font-size:18px}.mobile-sync-start p{color:var(--text-body);line-height:1.7;margin:10px auto 20px;max-width:600px}.mobile-sync-start button{background:color-mix(in srgb,var(--pool-warm-core) 30%,var(--glass));padding:12px 22px}.mobile-sync-grid{display:grid;grid-template-columns:minmax(310px,.85fr) minmax(420px,1.15fr);gap:18px;max-width:1180px;margin:0 auto}.mobile-sync-grid>section{border:1px solid var(--glass-edge);background:var(--glass);border-radius:24px;padding:22px;box-shadow:0 18px 48px color-mix(in srgb,var(--bg-d) 30%,transparent)}.mobile-sync-grid section>header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px}.mobile-sync-grid h2{font-size:20px;margin:3px 0 0}.mobile-sync-pairing{grid-row:span 2;text-align:center}.mobile-sync-pairing header{text-align:left}.mobile-sync-pairing img{display:block;width:min(260px,88%);margin:8px auto 14px;border-radius:18px;background:#f5f7fc;padding:12px}.mobile-sync-pairing p,.mobile-sync-projection>p,.mobile-sync-devices>p,.mobile-sync-captures>p{color:var(--text-body);line-height:1.65}.mobile-sync-pairing small{display:block;margin-top:12px;color:var(--text-aux)}.mobile-sync-pairing-used{padding:42px 20px}.mobile-sync-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.mobile-sync-metrics article{display:grid;gap:4px;padding:16px 10px;border-radius:16px;background:color-mix(in srgb,var(--glass-hover) 76%,transparent);text-align:center}.mobile-sync-metrics b{font-size:28px;font-weight:650}.mobile-sync-metrics span{font-size:12px;color:var(--text-aux)}.mobile-sync-devices article{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:13px 0;border-top:1px solid color-mix(in srgb,var(--glass-edge) 60%,transparent)}.mobile-sync-devices article div{display:grid;gap:4px}.mobile-sync-devices article span{font-size:12px;color:var(--text-aux)}.mobile-sync-captures{grid-column:2}.mobile-sync-captures article{padding:14px 0;border-top:1px solid color-mix(in srgb,var(--glass-edge) 60%,transparent)}.mobile-sync-captures article div{display:flex;justify-content:space-between;gap:18px}.mobile-sync-captures time{font-size:12px;color:var(--text-aux)}.mobile-sync-captures article p{color:var(--text-body);line-height:1.6;white-space:pre-wrap;margin:7px 0 0}.mobile-sync-footer{display:flex;align-items:center;justify-content:space-between;gap:18px;max-width:1180px;margin:18px auto 0;color:var(--text-aux);font-size:13px}.mobile-sync-footer button{color:var(--text-aux)} +@media (max-width:900px){.mobile-sync-world{padding:20px}.mobile-sync-worldbar{align-items:flex-start}.mobile-sync-worldbar em{display:none}.mobile-sync-grid{grid-template-columns:1fr}.mobile-sync-pairing{grid-row:auto}.mobile-sync-captures{grid-column:auto}.mobile-sync-metrics{grid-template-columns:repeat(2,1fr)}.mobile-sync-footer{align-items:flex-start;flex-direction:column}} diff --git a/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts b/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts index d962a1f5d..78e82c4f6 100644 --- a/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts +++ b/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts @@ -1120,6 +1120,54 @@ const ROUTES = { "moduleNumber": "HLP-NIPC-MOD-0029", "operationNumber": "HLP-NIPC-OP-0140", "targetNumber": "HLP-NIPC-TGT-0029" + }, + "start_mobile_sync": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0141", + "targetNumber": "HLP-NIPC-TGT-0030" + }, + "get_mobile_sync_status": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0142", + "targetNumber": "HLP-NIPC-TGT-0030" + }, + "rotate_mobile_pairing": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0143", + "targetNumber": "HLP-NIPC-TGT-0030" + }, + "stop_mobile_sync": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0144", + "targetNumber": "HLP-NIPC-TGT-0030" + }, + "revoke_mobile_sync_device": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0145", + "targetNumber": "HLP-NIPC-TGT-0030" + }, + "get_mobile_sync_snapshot": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0030", + "operationNumber": "HLP-NIPC-OP-0146", + "targetNumber": "HLP-NIPC-TGT-0030" } } as const diff --git a/product-source/hololake-native-desktop/src/styles.css b/product-source/hololake-native-desktop/src/styles.css index b8f13d2f8..5ae777dc7 100644 --- a/product-source/hololake-native-desktop/src/styles.css +++ b/product-source/hololake-native-desktop/src/styles.css @@ -681,6 +681,7 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca .channel-main .pool-bay { width: 260px; height: 84px; } .channel-main .pool-label { top: 92px; } .channel-knowledge { left: 14%; top: 48%; } .channel-code { left: 31%; top: 61%; } .channel-light { right: 31%; top: 61%; } .channel-status { right: 14%; top: 48%; } .channel-time { left: 50%; bottom: 4%; transform: translateX(-50%); } +.channel-mobile { right: 7%; top: 70%; } .private-route-note { position: absolute; z-index: 10; left: 50%; bottom: 11%; width: min(720px, calc(100% - 64px)); margin: 0; transform: translateX(-50%); color: var(--content-muted); text-align: center; font-size: 12.5px; font-weight: 650; letter-spacing: .08em; } .personal-node-guide { position: absolute; z-index: 12; left: 50%; top: 55%; width: min(760px, calc(100% - 72px)); max-height: calc(100% - 190px); overflow: auto; padding: 26px 30px; transform: translate(-50%, -50%); border: 1px solid var(--panel-edge); border-radius: 20px; color: var(--content-secondary); background: color-mix(in srgb, var(--panel-bg) 88%, transparent); box-shadow: 0 30px 90px rgba(0, 0, 0, .42); backdrop-filter: blur(22px); } .personal-node-guide header span { color: var(--accent-light); font-size: 11px; font-weight: 750; letter-spacing: .2em; }