From b30f814320da8377ac31258db728ae7ff7fc84de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Mon, 3 Aug 2026 22:27:09 +0800 Subject: [PATCH] feat(ios): add native Git knowledge and device-only secrets --- .../hololake-platform/docs/ABSTRACTIONS.md | 29 + .../hololake-platform/docs/ARCHITECTURE.md | 22 +- .../hololake-platform/docs/IOS-TESTFLIGHT.md | 17 +- ...it-knowledge-and-device-secret-boundary.md | 72 + .../hololake-platform/docs/adr/README.md | 3 + .../hololake-platform/src-tauri/Cargo.lock | 17 +- .../hololake-platform/src-tauri/Cargo.toml | 7 + .../apple/hololake.xcodeproj/project.pbxproj | 18 + .../gen/apple/hololake_iOS/Info.plist | 4 +- .../src-tauri/gen/apple/project.yml | 4 +- .../src-tauri/src/ai_model_tools.rs | 46 +- .../src-tauri/src/ai_models.rs | 75 +- .../src-tauri/src/app_config.rs | 14 + .../src-tauri/src/commands/ai.rs | 49 +- .../src/commands/vault/lifecycle_cmds.rs | 38 + .../src-tauri/src/device_secret_store.rs | 129 ++ .../src-tauri/src/guanghu_router.rs | 4 + .../src-tauri/src/guanghu_shanghai_node.rs | 24 +- .../src-tauri/src/hololake_account.rs | 1194 +++++++++++++++++ .../hololake-platform/src-tauri/src/lib.rs | 17 + .../src-tauri/src/vault/mobile_vault.rs | 364 +++++ .../src-tauri/src/vault/mod.rs | 7 + .../src/components/HoloLakeAccountPanel.tsx | 91 ++ .../src/components/HoloLakeHome.tsx | 3 + .../src/components/WelcomeScreen.tsx | 76 +- .../src/hooks/useHoloLakeAccount.test.tsx | 125 ++ .../src/hooks/useHoloLakeAccount.ts | 140 ++ .../src/hooks/useOnboarding.ts | 78 +- .../src/lib/locales/be-BY.json | 21 +- .../src/lib/locales/be-Latn.json | 21 +- .../src/lib/locales/de-DE.json | 21 +- .../hololake-platform/src/lib/locales/en.json | 19 + .../src/lib/locales/es-419.json | 21 +- .../src/lib/locales/es-ES.json | 21 +- .../src/lib/locales/fr-FR.json | 21 +- .../src/lib/locales/id-ID.json | 21 +- .../src/lib/locales/it-IT.json | 21 +- .../src/lib/locales/ja-JP.json | 21 +- .../src/lib/locales/ko-KR.json | 21 +- .../src/lib/locales/pl-PL.json | 21 +- .../src/lib/locales/pt-BR.json | 21 +- .../src/lib/locales/pt-PT.json | 21 +- .../src/lib/locales/ru-RU.json | 21 +- .../src/lib/locales/sk-SK.json | 21 +- .../src/lib/locales/sv-SE.json | 21 +- .../src/lib/locales/uk-UA.json | 21 +- .../hololake-platform/src/lib/locales/vi.json | 21 +- .../src/lib/locales/zh-CN.json | 19 + .../src/lib/locales/zh-TW.json | 21 +- .../hololake-platform/src/utils/platform.ts | 4 + .../hololake-platform/vite.config.ts | 8 + 51 files changed, 3019 insertions(+), 97 deletions(-) create mode 100644 product-source/hololake-platform/docs/adr/0173-mobile-git-knowledge-and-device-secret-boundary.md create mode 100644 product-source/hololake-platform/src-tauri/src/device_secret_store.rs create mode 100644 product-source/hololake-platform/src-tauri/src/hololake_account.rs create mode 100644 product-source/hololake-platform/src-tauri/src/vault/mobile_vault.rs create mode 100644 product-source/hololake-platform/src/components/HoloLakeAccountPanel.tsx create mode 100644 product-source/hololake-platform/src/hooks/useHoloLakeAccount.test.tsx create mode 100644 product-source/hololake-platform/src/hooks/useHoloLakeAccount.ts diff --git a/product-source/hololake-platform/docs/ABSTRACTIONS.md b/product-source/hololake-platform/docs/ABSTRACTIONS.md index 24c05ca..29e8160 100644 --- a/product-source/hololake-platform/docs/ABSTRACTIONS.md +++ b/product-source/hololake-platform/docs/ABSTRACTIONS.md @@ -1135,6 +1135,35 @@ server deployment. ## Updates & Feature Flags +## Mobile HoloLake knowledge boundary + +### Native storage and identity + +- **`mobile_vault`** — Creates typed foundation and personal knowledge lakes + inside the application sandbox. It never calls a desktop folder picker. +- **`device_secret_store`** — Stores account sessions and user model keys in a + non-synchronizing, this-device-only Apple Keychain item. Callers keep only an + opaque handle and never receive a persisted plaintext representation. +- **`hololake_account`** — Owns email OTP, device-bound sessions, verified + repository snapshots, the server model catalog, and atomic Markdown write + receipts. + +### Knowledge Agent execution + +- **`.hololake-sync-policy.json`** — Marks a lake as repository-backed and + excludes `本地密钥/**`, internal HoloLake metadata, and Git internals. +- **`sync_local_markdown_page`** — Sends only a bounded normal Markdown path, + complete content, and the exact current base commit. Success requires a new + 40-character repository commit; otherwise the native tool rolls back its + local create or edit. +- **server AI provider** — Sends bounded model messages and tool schemas through + the authenticated HoloLake proxy. The client executes allowlisted knowledge + tools locally and returns their exact receipts for the next model turn. The + server provider key never reaches the device. +- **synchronized delete gate** — Refuses deletion until an atomic repository + delete receipt exists, preventing a local-only success from diverging from + the shared lake. + ### Hooks - **`useUpdater(releaseChannel, automaticChecksEnabled)`** — Channel-aware updater state machine. When automatic checks are enabled, it checks the selected feed after startup; manual checks always remain available. It surfaces checking/available/downloading/ready states and delegates install work to Rust. - **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union. diff --git a/product-source/hololake-platform/docs/ARCHITECTURE.md b/product-source/hololake-platform/docs/ARCHITECTURE.md index 801f35b..be5525b 100644 --- a/product-source/hololake-platform/docs/ARCHITECTURE.md +++ b/product-source/hololake-platform/docs/ARCHITECTURE.md @@ -1,6 +1,10 @@ # Architecture -Tolaria is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes. +HoloLake Era is the human and Agent entry to HoloLake worlds, channels, identity, +knowledge, and receipt-backed native execution. Its knowledge workspace retains +the mature Markdown graph and editor inherited from Tolaria, but the product is +not a renamed desktop vault and the mobile client does not emulate desktop +filesystem or credential behavior. ## Design Principles @@ -56,6 +60,22 @@ No field names, folder paths, or vault-specific values should be hardcoded in th Notes are not just documents — they are nodes in a structured graph of people, projects, events, responsibilities, and ideas. Every design decision should ask: "Does this make the knowledge graph easier for a human *and* an AI to navigate?" Conventions that are legible to both are better than conventions that are legible only to one. +### Mobile knowledge and credential boundary + +On iOS, HoloLake opens app-owned foundation and personal knowledge directories +inside the sandbox rather than calling the desktop folder picker. A synchronized +personal lake is identified by `.hololake-sync-policy.json`: ordinary Markdown +can be written through the authenticated server into the registered code-channel +repository, while `本地密钥/**`, internal metadata, and Git internals are excluded. + +The knowledge Agent uses the same bounded read/write tools as the desktop model +target. A normal page create or edit is acknowledged only after an optimistic, +atomic bare-repository update returns the exact new commit. Local state is rolled +back when that receipt is absent. iOS account sessions and user model keys are +stored in non-synchronizing, this-device-only Keychain entries; raw values never +enter Markdown, Git, renderer state, telemetry, or server receipts. See +[ADR 0173](adr/0173-mobile-git-knowledge-and-device-secret-boundary.md). + ### Three representations, one authority Vault data exists in three forms simultaneously: diff --git a/product-source/hololake-platform/docs/IOS-TESTFLIGHT.md b/product-source/hololake-platform/docs/IOS-TESTFLIGHT.md index 77faf4d..3660d10 100644 --- a/product-source/hololake-platform/docs/IOS-TESTFLIGHT.md +++ b/product-source/hololake-platform/docs/IOS-TESTFLIGHT.md @@ -1,7 +1,7 @@ # HoloLake Era iPhone / TestFlight 承接记录 -状态日期:2026-07-19 -当前版本:0.1.7 +状态日期:2026-08-03 +当前版本:0.4.6 Bundle ID:`com.guanghulab.hololake` ## 为什么这样做 @@ -31,6 +31,19 @@ Bundle ID:`com.guanghulab.hololake` ## 当前闭环状态 +0.4.6 build 25 已被 Apple 接收并完成处理,但它仍停在出口合规法律声明, +且真机运行仍存在知识入口失败。因此 build 25 不分配测试组,也不作为可用版本。 +下一次上传必须使用新的 build number,并同时包含 ADR 0173 的移动知识边界: + +- iOS 沙箱内的光湖基础世界与个人知识湖,不再调用移动端未实现的文件夹选择器; +- 邮箱验证码会话与用户模型密钥写入本机、不可同步的 this-device-only Keychain; +- 普通 Markdown 由知识 Agent 读写,并在服务器返回精确 Git 提交后才显示成功; +- `本地密钥/**` 不进入 Markdown 同步、代码仓库、模型上下文、日志或回执; +- 服务器模型密钥留在服务器,手机只得到模型目录和执行结果。 + +这些源码和自动测试通过之前不得归档;Apple 上传、处理完成、测试组分配和真机可用 +仍然是四个彼此独立的验收事实。 + 2026-07-19 已完成付费团队同步、证书创建、iOS 26.5 平台导入、真机登记、前端生产构建和 Xcode scheme 对齐。私人设备标识不得写入仓库。 App Store Connect 应用记录已创建:名称 `HoloLake Era`,主语言简体中文,Bundle ID `com.guanghulab.hololake`。0.1.7 build 20 已由 Xcode 返回 `App upload complete`。build 19 曾因 `libapp.a` 被错误复制到 App 根目录而被 Apple 以 90171 拒绝;根因是 `project.yml` 把 `Externals` 同时声明为 sources,现已移除,build 20 包内预检确认不再包含该静态库。 diff --git a/product-source/hololake-platform/docs/adr/0173-mobile-git-knowledge-and-device-secret-boundary.md b/product-source/hololake-platform/docs/adr/0173-mobile-git-knowledge-and-device-secret-boundary.md new file mode 100644 index 0000000..09e18c6 --- /dev/null +++ b/product-source/hololake-platform/docs/adr/0173-mobile-git-knowledge-and-device-secret-boundary.md @@ -0,0 +1,72 @@ +# ADR 0173: Mobile Git knowledge and device-secret boundary + +## Status + +Accepted for the next HoloLake Era iOS internal build. + +## Context + +The mobile client cannot reuse a desktop folder picker, a desktop Git process, +or a plaintext provider-secret file. Treating the app-owned knowledge lake as a +renamed Tolaria vault also leaves first launch, repository synchronization, and +the knowledge Agent without a truthful mobile execution path. + +HoloLake needs three different stores whose boundaries remain visible: + +1. a bundled foundation world that can open offline; +2. normal Markdown knowledge that can synchronize with the registered Guanghu + code-channel repository; +3. device-only credentials that must never enter Markdown, Git, model context, + logs, receipts, archives, or server synchronization. + +## Decision + +- iOS creates and opens managed knowledge directories inside the application + sandbox. It never invokes the unsupported mobile folder picker. +- The foundation world is seeded locally and is not evidence of a server + connection, authenticated identity, persona residency, or repository + synchronization. +- A synchronized personal lake contains a typed + `.hololake-sync-policy.json`. Normal Markdown pages are eligible for + synchronization. `本地密钥/**`, `.hololake-*`, and `.git/**` are excluded. +- Email OTP creates a short, device-bound HoloLake session. The production + token is stored in iOS Keychain with + `AccessibleWhenUnlockedThisDeviceOnly` and synchronization disabled. The + metadata file contains only a stable handle, device id, and expiry. +- User-supplied model keys use the same device-only Keychain protection. + Server-supplied model keys never leave the server. +- The mobile model target uses the existing bounded knowledge-Agent tool + contract. It may search and read the active lake and create or replace a + normal Markdown page. +- A synchronized create or edit is complete only after the server atomically + advances the registered bare repository from the exact previous `main` + commit and returns the new 40-character commit plus content SHA-256. A + conflict or missing receipt rolls back the local mutation. +- The server accepts only bounded Markdown paths and content. Traversal, + non-Markdown paths, `本地密钥`, oversized content, and stale base commits + fail closed. Receipts never echo page content. +- Deletion from a synchronized lake remains disabled until it has the same + atomic server receipt and rollback semantics. +- The model is not an authority. The execution sequence remains: + + ```text + human event and real state + → constrained model request + → native allowlisted knowledge executor + → local filesystem result + → atomic repository update + → verifiable commit receipt + ``` + +## Consequences + +- Mobile and desktop share Markdown semantics without pretending their storage + and credential environments are identical. +- The knowledge Agent can perform useful read/write work on iOS while every + external mutation still has a native receipt. +- A server outage leaves local knowledge readable but makes synchronized + writes fail closed. +- A TestFlight archive or an App Store Connect upload is not proof of this + capability. Acceptance requires iOS compilation, automated non-leak tests, + deployed server health, repository readback, and a real-device TestFlight + run. diff --git a/product-source/hololake-platform/docs/adr/README.md b/product-source/hololake-platform/docs/adr/README.md index 14a4b74..156f509 100644 --- a/product-source/hololake-platform/docs/adr/README.md +++ b/product-source/hololake-platform/docs/adr/README.md @@ -220,3 +220,6 @@ proposed → active → superseded | [0167](0167-gestational-history-continuity-ingestion.md) | Gestational history enters Guanghu through a native continuity protocol | active | | [0168](0168-guanghu-native-prepartition-disk-layout.md) | Guanghu owns a registered pre-partition native disk layout | active | | [0169](0169-model-native-living-galaxy-system.md) | Model-native HoloLake living galaxy system | accepted | +| [0171](0171-guanghu-world-email-entry-and-live-node-projection.md) | Guanghu world email entry and live node projection | active | +| [0172](0172-persona-subject-existence-and-runtime-predicates.md) | Separate persona subject existence from runtime predicates | active | +| [0173](0173-mobile-git-knowledge-and-device-secret-boundary.md) | Mobile Git knowledge and device-secret boundary | accepted | diff --git a/product-source/hololake-platform/src-tauri/Cargo.lock b/product-source/hololake-platform/src-tauri/Cargo.lock index 1b8eed3..f9c8e4a 100644 --- a/product-source/hololake-platform/src-tauri/Cargo.lock +++ b/product-source/hololake-platform/src-tauri/Cargo.lock @@ -1885,6 +1885,8 @@ dependencies = [ "regex", "reqwest 0.12.28", "ring", + "security-framework", + "security-framework-sys", "sentry", "serde", "serde_json", @@ -1903,6 +1905,7 @@ dependencies = [ "tokio", "uuid", "walkdir", + "zip 0.6.6", ] [[package]] @@ -5349,7 +5352,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -7268,6 +7271,18 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zip" version = "4.6.1" diff --git a/product-source/hololake-platform/src-tauri/Cargo.toml b/product-source/hololake-platform/src-tauri/Cargo.toml index 8b9844b..5083079 100644 --- a/product-source/hololake-platform/src-tauri/Cargo.toml +++ b/product-source/hololake-platform/src-tauri/Cargo.toml @@ -52,11 +52,18 @@ quick-xml = { version = "0.38", features = ["serialize"] } tauri-plugin-deep-link = "2.4.9" tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } ring = "0.17" +zip = { version = "0.6.6", default-features = false, features = ["deflate"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.3" objc2-app-kit = "0.3.2" objc2-foundation = "0.3.2" objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] } +security-framework = "3.5.1" +security-framework-sys = "2.14.0" + +[target.'cfg(target_os = "ios")'.dependencies] +security-framework = "3.5.1" +security-framework-sys = "2.14.0" [dev-dependencies] diff --git a/product-source/hololake-platform/src-tauri/gen/apple/hololake.xcodeproj/project.pbxproj b/product-source/hololake-platform/src-tauri/gen/apple/hololake.xcodeproj/project.pbxproj index e394f4c..3f11a97 100644 --- a/product-source/hololake-platform/src-tauri/gen/apple/hololake.xcodeproj/project.pbxproj +++ b/product-source/hololake-platform/src-tauri/gen/apple/hololake.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ 47E641E8D831982238FCF5D4 /* hermes_cli.rs */ = {isa = PBXFileReference; path = hermes_cli.rs; sourceTree = ""; }; 47EDAF686EACF168E90E00C9 /* paths.rs */ = {isa = PBXFileReference; path = paths.rs; sourceTree = ""; }; 497C22F10C21FCB4CF1978A0 /* line_stream.rs */ = {isa = PBXFileReference; path = line_stream.rs; sourceTree = ""; }; + 4CC25FAC04523FC1407E890B /* guanghu_world_login.rs */ = {isa = PBXFileReference; path = guanghu_world_login.rs; sourceTree = ""; }; 4E4BBC1CC65B04620FE5ACF7 /* memory.rs */ = {isa = PBXFileReference; path = memory.rs; sourceTree = ""; }; 515B73736D1F5EA458FF4CBF /* claude_invocation.rs */ = {isa = PBXFileReference; path = claude_invocation.rs; sourceTree = ""; }; 53E80AB4F48A6E53B2338B4B /* ai_model_tools.rs */ = {isa = PBXFileReference; path = ai_model_tools.rs; sourceTree = ""; }; @@ -89,6 +90,7 @@ 7599B204D83DCC8DC75458B1 /* frontmatter_regression_tests.rs */ = {isa = PBXFileReference; path = frontmatter_regression_tests.rs; sourceTree = ""; }; 7876AB1696C6A473C0CA60DD /* dates.rs */ = {isa = PBXFileReference; path = dates.rs; sourceTree = ""; }; 79E99FECA35482A58E9929E8 /* upstream.rs */ = {isa = PBXFileReference; path = upstream.rs; sourceTree = ""; }; + 7DE25EB5FAB7CE0ACBD80ABB /* guanghu_router.rs */ = {isa = PBXFileReference; path = guanghu_router.rs; sourceTree = ""; }; 7FEB8ACB3EFE38A9C2273981 /* remote.rs */ = {isa = PBXFileReference; path = remote.rs; sourceTree = ""; }; 814BEE45F3957BA070B8A6FE /* keys.rs */ = {isa = PBXFileReference; path = keys.rs; sourceTree = ""; }; 83CCD3A86C61E070EB9C029B /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; @@ -106,6 +108,7 @@ 960E88B1AC470035ADEC0DD8 /* mcp_config.rs */ = {isa = PBXFileReference; path = mcp_config.rs; sourceTree = ""; }; 975BD8B6821A8FA0356DFE31 /* hololake_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = hololake_iOS.entitlements; sourceTree = ""; }; 98555F139A6093466C0DD80E /* extraction.rs */ = {isa = PBXFileReference; path = extraction.rs; sourceTree = ""; }; + 986A5C5A5D4E1DE0DB1DB3D9 /* hololake_account.rs */ = {isa = PBXFileReference; path = hololake_account.rs; sourceTree = ""; }; 9935075CB2F77C7FF5DEC9CD /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; 99B3DA704DB835E530007E84 /* antigravity_config.rs */ = {isa = PBXFileReference; path = antigravity_config.rs; sourceTree = ""; }; 9C5AB398816652B7DA146489 /* windows_cmd_shim.rs */ = {isa = PBXFileReference; path = windows_cmd_shim.rs; sourceTree = ""; }; @@ -123,9 +126,11 @@ B0B4AE2937945F1896E1B205 /* mod_tests.rs */ = {isa = PBXFileReference; path = mod_tests.rs; sourceTree = ""; }; B37489EAD8111AF691B4E56D /* opencode_config.rs */ = {isa = PBXFileReference; path = opencode_config.rs; sourceTree = ""; }; B377452F2570C8D916B24737 /* rename.rs */ = {isa = PBXFileReference; path = rename.rs; sourceTree = ""; }; + B594CFA2AE6E07F7EEBDCA5F /* guanghu_living_system.rs */ = {isa = PBXFileReference; path = guanghu_living_system.rs; sourceTree = ""; }; B7223A7822564745FD4B7CDD /* app_icon.rs */ = {isa = PBXFileReference; path = app_icon.rs; sourceTree = ""; }; B7436FF982457F75FD04F370 /* ai.rs */ = {isa = PBXFileReference; path = ai.rs; sourceTree = ""; }; B7EEF25B97EA865DC2F2B16F /* app_icon.rs */ = {isa = PBXFileReference; path = app_icon.rs; sourceTree = ""; }; + B873D19C73F8DB13E8418612 /* guanghu_shanghai_node.rs */ = {isa = PBXFileReference; path = guanghu_shanghai_node.rs; sourceTree = ""; }; BA9780D7C82253E700210AD1 /* pi_cli.rs */ = {isa = PBXFileReference; path = pi_cli.rs; sourceTree = ""; }; BB2C976547D1CF4F9CEDE8E1 /* antigravity_discovery.rs */ = {isa = PBXFileReference; path = antigravity_discovery.rs; sourceTree = ""; }; BB68BFCC620A099FBDFE5234 /* folders.rs */ = {isa = PBXFileReference; path = folders.rs; sourceTree = ""; }; @@ -162,11 +167,14 @@ E502EAEA6030504F91B1B6B8 /* command.rs */ = {isa = PBXFileReference; path = command.rs; sourceTree = ""; }; E7E43D90B97E284B2F6A717D /* frontmatter.rs */ = {isa = PBXFileReference; path = frontmatter.rs; sourceTree = ""; }; E8A91F6EDFE1DF22A75BAC5B /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + EBFA0B7CAB8661AB3605599A /* mobile_vault.rs */ = {isa = PBXFileReference; path = mobile_vault.rs; sourceTree = ""; }; EE36F56C627BC172C7CCA065 /* pi_events.rs */ = {isa = PBXFileReference; path = pi_events.rs; sourceTree = ""; }; EEA7BC2EB818A793D68CECA1 /* frontmatter_cmds.rs */ = {isa = PBXFileReference; path = frontmatter_cmds.rs; sourceTree = ""; }; EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */ = {isa = PBXFileReference; path = modified_dates_tests.rs; sourceTree = ""; }; F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */ = {isa = PBXFileReference; path = kiro_discovery.rs; sourceTree = ""; }; F1238B11A81DABC7B28747B9 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + F2D9E48A1998AD5837EBE8C5 /* guanghu_enterprise.rs */ = {isa = PBXFileReference; path = guanghu_enterprise.rs; sourceTree = ""; }; + F5DC0A0680C3D852550B4558 /* hldp_runtime.rs */ = {isa = PBXFileReference; path = hldp_runtime.rs; sourceTree = ""; }; F7D0E6E67B410BBCCF64F197 /* file_url.rs */ = {isa = PBXFileReference; path = file_url.rs; sourceTree = ""; }; F86E7BC210B3629F404B302E /* basics.rs */ = {isa = PBXFileReference; path = basics.rs; sourceTree = ""; }; F920B9501682227EB4C5580B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -176,6 +184,7 @@ FBB7A51D7CCC05D3348D1190 /* vault_list.rs */ = {isa = PBXFileReference; path = vault_list.rs; sourceTree = ""; }; FBD91C23BBFFCA204E30DF3C /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; FE4F7CE296F76FBE49B288EE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + FEE79A2B08D489C84E5C130A /* device_secret_store.rs */ = {isa = PBXFileReference; path = device_secret_store.rs; sourceTree = ""; }; FF3044079CC906291942C508 /* codex_cli.rs */ = {isa = PBXFileReference; path = codex_cli.rs; sourceTree = ""; }; /* End PBXFileReference section */ @@ -221,6 +230,7 @@ 2B938E7363F949A622DD8397 /* ignored.rs */, E19D7FEEBC114189FCF0F84A /* image.rs */, 65A73B64D88239A170CF299A /* migration.rs */, + EBFA0B7CAB8661AB3605599A /* mobile_vault.rs */, B0B4AE2937945F1896E1B205 /* mod_tests.rs */, 83CCD3A86C61E070EB9C029B /* mod.rs */, EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */, @@ -370,8 +380,16 @@ FF3044079CC906291942C508 /* codex_cli.rs */, BEE7A0C1F8EE18EDAB31905A /* copilot_cli.rs */, 282A3A834C97BA5453CC9355 /* copilot_discovery.rs */, + FEE79A2B08D489C84E5C130A /* device_secret_store.rs */, + F2D9E48A1998AD5837EBE8C5 /* guanghu_enterprise.rs */, + B594CFA2AE6E07F7EEBDCA5F /* guanghu_living_system.rs */, + 7DE25EB5FAB7CE0ACBD80ABB /* guanghu_router.rs */, + B873D19C73F8DB13E8418612 /* guanghu_shanghai_node.rs */, + 4CC25FAC04523FC1407E890B /* guanghu_world_login.rs */, 47E641E8D831982238FCF5D4 /* hermes_cli.rs */, A0B9AEB056A7B2713B0EF52A /* hermes_discovery.rs */, + F5DC0A0680C3D852550B4558 /* hldp_runtime.rs */, + 986A5C5A5D4E1DE0DB1DB3D9 /* hololake_account.rs */, 1CEF6C06C1287DD4AE2F9B51 /* kiro_cli.rs */, F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */, 8969FA0D7729542C8FE8ED93 /* lib.rs */, diff --git a/product-source/hololake-platform/src-tauri/gen/apple/hololake_iOS/Info.plist b/product-source/hololake-platform/src-tauri/gen/apple/hololake_iOS/Info.plist index 2ca7d10..eff56df 100644 --- a/product-source/hololake-platform/src-tauri/gen/apple/hololake_iOS/Info.plist +++ b/product-source/hololake-platform/src-tauri/gen/apple/hololake_iOS/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.7 + 0.4.6 CFBundleVersion - 20 + 26 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/product-source/hololake-platform/src-tauri/gen/apple/project.yml b/product-source/hololake-platform/src-tauri/gen/apple/project.yml index e7bb68a..3b02bb6 100644 --- a/product-source/hololake-platform/src-tauri/gen/apple/project.yml +++ b/product-source/hololake-platform/src-tauri/gen/apple/project.yml @@ -51,8 +51,8 @@ targets: - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight CFBundleDisplayName: HoloLake - CFBundleShortVersionString: 0.1.7 - CFBundleVersion: "20" + CFBundleShortVersionString: 0.4.6 + CFBundleVersion: "26" entitlements: path: hololake_iOS/hololake_iOS.entitlements scheme: diff --git a/product-source/hololake-platform/src-tauri/src/ai_model_tools.rs b/product-source/hololake-platform/src-tauri/src/ai_model_tools.rs index a3ff913..e453c1b 100644 --- a/product-source/hololake-platform/src-tauri/src/ai_model_tools.rs +++ b/product-source/hololake-platform/src-tauri/src/ai_model_tools.rs @@ -1060,8 +1060,24 @@ fn edit_note_from_tool_args( let raw_path = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "path")?; let content = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "content")?; let path = resolve_existing_vault_note(request, raw_path)?; + let previous = std::fs::read_to_string(&path) + .map_err(|error| format!("Failed to preserve the note before editing: {error}"))?; crate::vault::save_note_content(path.to_string_lossy().as_ref(), content)?; - Ok(format!("已更新笔记:{}", path.display())) + let synchronized_commit = match crate::hololake_account::sync_local_markdown_page( + Path::new(active_vault_path(request)?), + &path, + content, + ) { + Ok(commit) => commit, + Err(error) => { + let _ = crate::vault::save_note_content(path.to_string_lossy().as_ref(), &previous); + return Err(error); + } + }; + Ok(match synchronized_commit { + Some(commit) => format!("已更新笔记:{};同步提交:{commit}", path.display()), + None => format!("已更新笔记:{}", path.display()), + }) } fn delete_note_from_tool_args( @@ -1070,6 +1086,15 @@ fn delete_note_from_tool_args( ) -> Result { let raw_path = required_tool_string(args, DELETE_NOTE_TOOL_NAME, "path")?; let path = resolve_existing_vault_note(request, raw_path)?; + if Path::new(active_vault_path(request)?) + .join(".hololake-sync-policy.json") + .is_file() + { + return Err( + "Git-backed HoloLake pages cannot be deleted until a server delete receipt is available." + .to_string(), + ); + } crate::vault::delete_note(path.to_string_lossy().as_ref())?; Ok(format!("已删除笔记:{}", path.display())) } @@ -1286,12 +1311,29 @@ fn create_note_from_tool_args( let vault_path = tool_vault_path(request, args)?; crate::commands::create_note_content( PathBuf::from(note_path), - content, + content.clone(), Some(PathBuf::from(vault_path)), )?; + let synchronized_commit = match crate::hololake_account::sync_local_markdown_page( + Path::new(vault_path), + Path::new(note_path), + &content, + ) { + Ok(commit) => commit, + Err(error) => { + let created_path = if Path::new(note_path).is_absolute() { + PathBuf::from(note_path) + } else { + Path::new(vault_path).join(note_path) + }; + let _ = std::fs::remove_file(created_path); + return Err(error); + } + }; let output = serde_json::json!({ "path": note_path, "vaultPath": vault_path, + "synchronizedCommit": synchronized_commit, }) .to_string(); Ok(OpenAiToolResult { diff --git a/product-source/hololake-platform/src-tauri/src/ai_models.rs b/product-source/hololake-platform/src-tauri/src/ai_models.rs index 3a465af..a59e558 100644 --- a/product-source/hololake-platform/src-tauri/src/ai_models.rs +++ b/product-source/hololake-platform/src-tauri/src/ai_models.rs @@ -255,7 +255,7 @@ fn tool_choice_compatibility_error(error: &str) -> bool { || normalized.contains("invalid parameter")) } -fn run_openai_agent_loop( +pub(crate) fn run_openai_agent_loop( request: &AiModelStreamRequest, mut payload: serde_json::Value, emit: &mut F, @@ -656,18 +656,40 @@ pub fn save_provider_api_key(provider_id: String, api_key: String) -> Result<(), if api_key.is_empty() { return Err("API key cannot be empty.".into()); } - let path = secrets_path()?; - let mut secrets = read_secrets_at(&path)?; - secrets.provider_api_keys.insert(provider_id, api_key); - write_secrets_at(&path, &secrets) + #[cfg(mobile)] + { + crate::device_secret_store::save( + crate::device_secret_store::MODEL_API_KEY_KIND, + &provider_id, + &api_key, + )?; + return Ok(()); + } + #[cfg(desktop)] + { + let path = secrets_path()?; + let mut secrets = read_secrets_at(&path)?; + secrets.provider_api_keys.insert(provider_id, api_key); + write_secrets_at(&path, &secrets) + } } pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> { let provider_id = normalize_secret_provider_id(&provider_id)?; - let path = secrets_path()?; - let mut secrets = read_secrets_at(&path)?; - secrets.provider_api_keys.remove(&provider_id); - write_secrets_at(&path, &secrets) + #[cfg(mobile)] + { + return crate::device_secret_store::delete( + crate::device_secret_store::MODEL_API_KEY_KIND, + &provider_id, + ); + } + #[cfg(desktop)] + { + let path = secrets_path()?; + let mut secrets = read_secrets_at(&path)?; + secrets.provider_api_keys.remove(&provider_id); + write_secrets_at(&path, &secrets) + } } fn normalize_secret_provider_id(provider_id: &str) -> Result { @@ -729,19 +751,30 @@ fn write_secret_file(path: &Path, content: String) -> Result<(), String> { } fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result, String> { - let secrets = read_secrets_at(&secrets_path()?)?; - let api_key = secrets - .provider_api_keys - .get(&request.provider.id) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - if api_key.is_none() { - return Err(format!( - "No local API key is saved for {}.", - request.provider.name - )); + #[cfg(mobile)] + { + let api_key = crate::device_secret_store::load( + crate::device_secret_store::MODEL_API_KEY_KIND, + &request.provider.id, + )?; + return Ok(Some(api_key)); + } + #[cfg(desktop)] + { + let secrets = read_secrets_at(&secrets_path()?)?; + let api_key = secrets + .provider_api_keys + .get(&request.provider.id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if api_key.is_none() { + return Err(format!( + "No local API key is saved for {}.", + request.provider.name + )); + } + Ok(api_key) } - Ok(api_key) } fn api_key_from_env(request: &AiModelStreamRequest) -> Result, String> { diff --git a/product-source/hololake-platform/src-tauri/src/app_config.rs b/product-source/hololake-platform/src-tauri/src/app_config.rs index b2d1992..349dd75 100644 --- a/product-source/hololake-platform/src-tauri/src/app_config.rs +++ b/product-source/hololake-platform/src-tauri/src/app_config.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; const APP_CONFIG_POLICY_JSON: &str = include_str!("../../mcp-server/app-config-policy.json"); +static MOBILE_APP_CONFIG_DIR: OnceLock = OnceLock::new(); #[derive(Debug, Deserialize)] struct AppConfigPolicy { @@ -48,6 +49,9 @@ fn app_config_dir() -> Result { } fn primary_config_dir() -> Option { + if let Some(path) = MOBILE_APP_CONFIG_DIR.get() { + return Some(path.clone()); + } primary_config_dir_from_sources( explicit_xdg_config_home(), dirs::home_dir(), @@ -55,6 +59,16 @@ fn primary_config_dir() -> Option { ) } +#[cfg(mobile)] +pub(crate) fn install_mobile_app_config_dir(path: PathBuf) -> Result<(), String> { + if !path.is_absolute() { + return Err("Mobile app config directory must be absolute".to_string()); + } + MOBILE_APP_CONFIG_DIR + .set(path) + .map_err(|_| "Mobile app config directory was already initialized".to_string()) +} + fn primary_config_dir_from_sources( explicit_xdg: Option, home: Option, diff --git a/product-source/hololake-platform/src-tauri/src/commands/ai.rs b/product-source/hololake-platform/src-tauri/src/commands/ai.rs index 0666608..f5f2904 100644 --- a/product-source/hololake-platform/src-tauri/src/commands/ai.rs +++ b/product-source/hololake-platform/src-tauri/src/commands/ai.rs @@ -339,33 +339,56 @@ pub fn abort_ai_agent_stream(_event_name: String) -> Result { #[cfg(mobile)] #[tauri::command] pub async fn stream_ai_model( - _app_handle: tauri::AppHandle, - _request: crate::ai_models::AiModelStreamRequest, + app_handle: tauri::AppHandle, + request: crate::ai_models::AiModelStreamRequest, ) -> Result { - Err("Direct AI model chat is not available in this mobile build yet.".into()) + use tauri::Emitter; + + if crate::hololake_account::is_server_ai_provider(&request.provider) { + return crate::hololake_account::run_mobile_ai_model_stream(app_handle, request).await; + } + + let event_name = request + .event_name + .clone() + .filter(|name| { + name.strip_prefix("ai-model-stream-").is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + }) + }) + .unwrap_or_else(|| "ai-model-stream".to_string()); + tokio::task::spawn_blocking(move || { + crate::ai_models::run_ai_model_stream(request, |event| { + let _ = app_handle.emit(event_name.as_str(), event); + }) + }) + .await + .map_err(|error| format!("Mobile AI task failed: {error}"))? } #[cfg(mobile)] #[tauri::command] -pub fn save_ai_model_provider_api_key( - _provider_id: String, - _api_key: String, -) -> Result<(), String> { - Err("Local AI provider secret storage is only available in the desktop app.".into()) +pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> { + crate::ai_models::save_provider_api_key(provider_id, api_key) } #[cfg(mobile)] #[tauri::command] -pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> { - Err("Local AI provider secret storage is only available in the desktop app.".into()) +pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> { + crate::ai_models::delete_provider_api_key(provider_id) } #[cfg(mobile)] #[tauri::command] -pub fn test_ai_model_provider( - _request: crate::ai_models::AiModelProviderTestRequest, +pub async fn test_ai_model_provider( + request: crate::ai_models::AiModelProviderTestRequest, ) -> Result { - Err("Direct AI model tests are not available in this mobile build yet.".into()) + tokio::task::spawn_blocking(move || crate::ai_models::test_ai_model_provider(request)) + .await + .map_err(|error| format!("Mobile AI test failed: {error}"))? } #[cfg(test)] diff --git a/product-source/hololake-platform/src-tauri/src/commands/vault/lifecycle_cmds.rs b/product-source/hololake-platform/src-tauri/src/commands/vault/lifecycle_cmds.rs index 4125ce9..9f491a3 100644 --- a/product-source/hololake-platform/src-tauri/src/commands/vault/lifecycle_cmds.rs +++ b/product-source/hololake-platform/src-tauri/src/commands/vault/lifecycle_cmds.rs @@ -82,6 +82,26 @@ pub fn get_default_vault_path() -> Result { vault::default_vault_path().map(|path| path.to_string_lossy().to_string()) } +fn mobile_knowledge_root(app_handle: &tauri::AppHandle) -> Result { + use tauri::Manager; + + app_handle + .path() + .app_data_dir() + .map(|path| path.join("knowledge")) + .map_err(|error| format!("Could not resolve the HoloLake knowledge directory: {error}")) +} + +#[tauri::command] +pub fn open_mobile_foundation_knowledge(app_handle: tauri::AppHandle) -> Result { + vault::open_or_create_mobile_world_vault(&mobile_knowledge_root(&app_handle)?) +} + +#[tauri::command] +pub fn open_mobile_personal_knowledge(app_handle: tauri::AppHandle) -> Result { + vault::open_or_create_mobile_personal_vault(&mobile_knowledge_root(&app_handle)?) +} + #[tauri::command] pub fn repair_vault(vault_path: String) -> Result { let vault_path = expand_tilde(&vault_path); @@ -153,4 +173,22 @@ mod tests { Ok(explicit.to_string_lossy().to_string()) ); } + + #[test] + fn mobile_knowledge_roots_are_stable_and_separate_local_secrets() { + let temporary = tempfile::TempDir::new().unwrap(); + let (world, personal) = + vault::open_or_create_mobile_vaults_in_documents(temporary.path()).unwrap(); + + assert!(Path::new(&world).join("welcome.md").is_file()); + assert!(Path::new(&personal).join("我的湖心.md").is_file()); + assert!(Path::new(&personal) + .join(".hololake-sync-policy.json") + .is_file()); + assert!(Path::new(&personal).join("本地密钥/README.md").is_file()); + let policy = + fs::read_to_string(Path::new(&personal).join(".hololake-sync-policy.json")).unwrap(); + assert!(policy.contains("\"本地密钥/**\"")); + assert!(!policy.contains("api_key")); + } } diff --git a/product-source/hololake-platform/src-tauri/src/device_secret_store.rs b/product-source/hololake-platform/src-tauri/src/device_secret_store.rs new file mode 100644 index 0000000..506ab26 --- /dev/null +++ b/product-source/hololake-platform/src-tauri/src/device_secret_store.rs @@ -0,0 +1,129 @@ +use std::borrow::Cow; + +const SERVICE: &str = "com.guanghu.hololake.device-secrets"; + +pub(crate) const ACCOUNT_SESSION_KIND: &str = "account-session"; +#[cfg(any(mobile, test))] +pub(crate) const MODEL_API_KEY_KIND: &str = "model-api-key"; + +fn normalized_segment(value: &str) -> Result, String> { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.len() > 128 + || !trimmed + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("Device secret identifier is invalid.".to_string()); + } + Ok(if trimmed.bytes().any(|byte| byte.is_ascii_uppercase()) { + Cow::Owned(trimmed.to_ascii_lowercase()) + } else { + Cow::Borrowed(trimmed) + }) +} + +fn account_name(kind: &str, identifier: &str) -> Result { + Ok(format!( + "{}:{}", + normalized_segment(kind)?, + normalized_segment(identifier)? + )) +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn lookup_options(account: &str) -> security_framework::passwords::PasswordOptions { + let mut options = + security_framework::passwords::PasswordOptions::new_generic_password(SERVICE, account); + options.set_access_synchronized(Some(false)); + options +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +pub(crate) fn save(kind: &str, identifier: &str, secret: &str) -> Result { + use security_framework::access_control::{ProtectionMode, SecAccessControl}; + use security_framework::passwords::set_generic_password_options; + + let secret = secret.trim(); + if secret.is_empty() { + return Err("Device secret cannot be empty.".to_string()); + } + let account = account_name(kind, identifier)?; + let mut options = lookup_options(&account); + let access_control = SecAccessControl::create_with_protection( + Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly), + 0, + ) + .map_err(|error| { + format!( + "Device Keychain protection failed (OSStatus {}).", + error.code() + ) + })?; + options.set_access_control(access_control); + #[cfg(target_os = "ios")] + options.use_protected_keychain(); + set_generic_password_options(secret.as_bytes(), options) + .map_err(|error| format!("Device Keychain save failed (OSStatus {}).", error.code()))?; + Ok(account) +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +pub(crate) fn load(kind: &str, identifier: &str) -> Result { + use security_framework::passwords::generic_password; + + let account = account_name(kind, identifier)?; + let bytes = generic_password(lookup_options(&account)) + .map_err(|error| format!("Device Keychain read failed (OSStatus {}).", error.code()))?; + String::from_utf8(bytes).map_err(|_| "Device Keychain value is not valid UTF-8.".to_string()) +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +pub(crate) fn delete(kind: &str, identifier: &str) -> Result<(), String> { + use security_framework::passwords::delete_generic_password_options; + use security_framework_sys::base::errSecItemNotFound; + + let account = account_name(kind, identifier)?; + match delete_generic_password_options(lookup_options(&account)) { + Ok(()) => Ok(()), + Err(error) if error.code() == errSecItemNotFound => Ok(()), + Err(error) => Err(format!( + "Device Keychain delete failed (OSStatus {}).", + error.code() + )), + } +} + +#[cfg(not(any(target_os = "ios", target_os = "macos")))] +pub(crate) fn save(_kind: &str, _identifier: &str, _secret: &str) -> Result { + Err("Device-only Keychain storage is unavailable on this platform.".to_string()) +} + +#[cfg(not(any(target_os = "ios", target_os = "macos")))] +pub(crate) fn load(_kind: &str, _identifier: &str) -> Result { + Err("Device-only Keychain storage is unavailable on this platform.".to_string()) +} + +#[cfg(not(any(target_os = "ios", target_os = "macos")))] +pub(crate) fn delete(_kind: &str, _identifier: &str) -> Result<(), String> { + Err("Device-only Keychain storage is unavailable on this platform.".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_secret_handles_are_stable_and_lowercase() { + assert_eq!( + account_name(MODEL_API_KEY_KIND, " OpenAI ").unwrap(), + "model-api-key:openai" + ); + } + + #[test] + fn device_secret_handles_reject_paths_and_blank_values() { + assert!(account_name(MODEL_API_KEY_KIND, "../secret").is_err()); + assert!(account_name(MODEL_API_KEY_KIND, " ").is_err()); + } +} diff --git a/product-source/hololake-platform/src-tauri/src/guanghu_router.rs b/product-source/hololake-platform/src-tauri/src/guanghu_router.rs index 6584ffa..de37763 100644 --- a/product-source/hololake-platform/src-tauri/src/guanghu_router.rs +++ b/product-source/hololake-platform/src-tauri/src/guanghu_router.rs @@ -851,6 +851,10 @@ fn load_or_create_identity() -> Result { load_or_create_identity_at(&path) } +pub(crate) fn hololake_device_id() -> Result { + load_or_create_identity().map(|identity| identity.device_id) +} + fn load_or_create_identity_at(path: &Path) -> Result { if path.exists() { let bytes = fs::read(path) diff --git a/product-source/hololake-platform/src-tauri/src/guanghu_shanghai_node.rs b/product-source/hololake-platform/src-tauri/src/guanghu_shanghai_node.rs index e7fecf0..bbbc17b 100644 --- a/product-source/hololake-platform/src-tauri/src/guanghu_shanghai_node.rs +++ b/product-source/hololake-platform/src-tauri/src/guanghu_shanghai_node.rs @@ -1,7 +1,9 @@ use serde::Serialize; const SHANGHAI_NODE_ID: &str = "BS-SH-005"; +#[cfg(any(target_os = "macos", target_os = "linux", windows))] const SHANGHAI_NODE_ADDRESS: &str = "124.223.10.33"; +#[cfg(any(target_os = "macos", target_os = "linux"))] const GHOS_LOGIN_PATTERN: &str = "484c44502d47484f532d4c4f47494e21"; #[derive(Debug, Serialize, PartialEq, Eq)] @@ -76,13 +78,21 @@ fn ping_arguments() -> [&'static str; 4] { #[tauri::command] pub fn guanghu_shanghai_node_status() -> Result { - let output = crate::hidden_command("ping") - .args(ping_arguments()) - .output() - .map_err(|error| format!("guanghu_shanghai_probe_unavailable: {error}"))?; - let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); - text.push_str(&String::from_utf8_lossy(&output.stderr)); - Ok(status_from_ping(output.status.success(), &text)) + #[cfg(any(target_os = "macos", target_os = "linux", windows))] + { + let output = crate::hidden_command("ping") + .args(ping_arguments()) + .output() + .map_err(|error| format!("guanghu_shanghai_probe_unavailable: {error}"))?; + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + Ok(status_from_ping(output.status.success(), &text)) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + Err("guanghu_shanghai_probe_unsupported_platform".to_owned()) + } } #[cfg(test)] diff --git a/product-source/hololake-platform/src-tauri/src/hololake_account.rs b/product-source/hololake-platform/src-tauri/src/hololake_account.rs new file mode 100644 index 0000000..b4302de --- /dev/null +++ b/product-source/hololake-platform/src-tauri/src/hololake_account.rs @@ -0,0 +1,1194 @@ +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs; +use std::io::{Cursor, Write}; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; +use uuid::Uuid; +use zip::ZipArchive; + +#[cfg(test)] +use std::sync::OnceLock; + +#[cfg(mobile)] +use crate::ai_agents::AiAgentStreamEvent; +#[cfg(mobile)] +use crate::ai_models::AiModelStreamRequest; +use crate::ai_models::{ + AiModelApiKeyStorage, AiModelCapabilities, AiModelDefinition, AiModelProvider, + AiModelProviderKind, +}; +use crate::app_config::preferred_app_config_path; +use crate::guanghu_router::hololake_device_id; +use crate::vault_list::{self, VaultEntry}; + +const PRODUCTION_HOLOLAKE_BASE_URL: &str = "https://guanghulab.com/authz"; +const SESSION_FILE: &str = "hololake-account-session.json"; +const SESSION_KEYCHAIN_ID: &str = "primary"; +const KNOWLEDGE_DIRECTORY: &str = "hololake-knowledge"; +const MAX_ARCHIVE_BYTES: usize = 160 * 1024 * 1024; +const MAX_EXPANDED_BYTES: u64 = 1024 * 1024 * 1024; +const MAX_ARCHIVE_ENTRIES: usize = 20_000; +const SERVER_AI_PROVIDER_PREFIX: &str = "hololake-server-"; + +#[cfg(test)] +static TEST_BASE_URL: OnceLock = OnceLock::new(); +#[cfg(test)] +static TEST_CONFIG_ROOT: OnceLock = OnceLock::new(); + +#[derive(Debug, Deserialize, Serialize)] +struct StoredSession { + schema: String, + token: String, + device_id: String, + expires_at: f64, +} + +#[derive(Debug, Deserialize, Serialize)] +struct StoredSessionMetadata { + schema: String, + token_handle: String, + device_id: String, + expires_at: f64, +} + +#[derive(Debug, Deserialize)] +struct SessionVerification { + session_token: String, + expires_at: f64, +} + +#[derive(Debug, Deserialize)] +struct KnowledgeManifest { + repository: String, + commit: String, + archive_url: String, +} + +#[derive(Debug, Deserialize)] +struct AiCatalog { + providers: Vec, +} + +#[derive(Debug, Deserialize)] +struct AiCatalogProvider { + id: String, + name: String, + models: Vec, +} + +#[derive(Debug, Serialize)] +struct SnapshotReceipt { + schema: &'static str, + repository: String, + commit: String, + archive_sha256: String, + vault_path: String, + file_count: usize, +} + +#[derive(Debug, Deserialize)] +struct KnowledgeWriteReceipt { + commit: String, +} + +#[tauri::command] +pub async fn hololake_account_request_email_code(email: String) -> Result { + let device_id = account_device_id()?; + let response = client()? + .post(format!("{}/api/hololake/session/email/request", base_url())) + .header("x-hololake-device-id", &device_id) + .json(&json!({ "email": email, "device_id": device_id })) + .send() + .await + .map_err(|error| format!("hololake_login_transport_failed: {error}"))?; + decode_json_response(response).await +} + +#[tauri::command] +pub async fn hololake_account_verify_email_code( + request_id: String, + code: String, +) -> Result { + if !is_uuid(&request_id) || !is_otp(&code) { + return Err("hololake_login_code_invalid".into()); + } + let device_id = account_device_id()?; + let response = client()? + .post(format!("{}/api/hololake/session/email/verify", base_url())) + .header("x-hololake-device-id", &device_id) + .json(&json!({ + "request_id": request_id, + "code": code, + "device_id": device_id, + })) + .send() + .await + .map_err(|error| format!("hololake_login_transport_failed: {error}"))?; + let status = response.status(); + let body: SessionVerification = response + .json() + .await + .map_err(|error| format!("hololake_login_invalid_response: {error}"))?; + if !status.is_success() { + return Err("hololake_login_verification_failed".into()); + } + save_session(&StoredSession { + schema: "guanghu.hololake-device-session/v1".into(), + token: body.session_token, + device_id, + expires_at: body.expires_at, + })?; + let model_target_ready = provision_server_ai_provider().await.unwrap_or(false); + Ok(json!({ + "ok": true, + "state": "authenticated", + "expires_at": body.expires_at, + "model_target_ready": model_target_ready, + })) +} + +#[tauri::command] +pub async fn hololake_account_status() -> Result { + let session = match load_session() { + Ok(session) => session, + Err(error) if error == "hololake_session_missing" => { + return Ok(json!({ "ok": true, "state": "signed_out" })); + } + Err(error) => return Err(error), + }; + let response = authenticated_request( + client()?.get(format!("{}/api/hololake/session", base_url())), + &session, + ) + .send() + .await + .map_err(|error| format!("hololake_session_transport_failed: {error}"))?; + if response.status().is_success() { + let mut value: Value = response + .json() + .await + .map_err(|error| format!("hololake_session_invalid_response: {error}"))?; + value["state"] = Value::String("authenticated".into()); + return Ok(value); + } + if response.status().as_u16() == 401 { + clear_session()?; + return Ok(json!({ "ok": true, "state": "signed_out" })); + } + Err(format!( + "hololake_session_status_failed: {}", + response.status() + )) +} + +#[tauri::command] +pub async fn hololake_account_logout() -> Result { + let session = match load_session() { + Ok(session) => session, + Err(_) => { + clear_session()?; + return Ok(json!({ "ok": true, "state": "signed_out" })); + } + }; + let response = authenticated_request( + client()?.delete(format!("{}/api/hololake/session", base_url())), + &session, + ) + .send() + .await + .map_err(|error| format!("hololake_logout_transport_failed: {error}"))?; + if !response.status().is_success() && response.status().as_u16() != 401 { + return Err(format!("hololake_logout_failed: {}", response.status())); + } + clear_session()?; + Ok(json!({ "ok": true, "state": "signed_out" })) +} + +#[tauri::command] +pub async fn hololake_sync_knowledge() -> Result { + let session = load_session()?; + let client = client()?; + let manifest_response = authenticated_request( + client.get(format!("{}/api/hololake/knowledge/manifest", base_url())), + &session, + ) + .send() + .await + .map_err(|error| format!("hololake_knowledge_manifest_failed: {error}"))?; + let manifest: KnowledgeManifest = require_json(manifest_response).await?; + if !is_commit(&manifest.commit) + || manifest.repository != "bingshuo/hololake-knowledge-base" + || !manifest + .archive_url + .starts_with("/api/hololake/knowledge/archive?") + { + return Err("hololake_knowledge_manifest_invalid".into()); + } + let archive_response = authenticated_request( + client.get(format!("{}{}", base_url(), manifest.archive_url)), + &session, + ) + .send() + .await + .map_err(|error| format!("hololake_knowledge_archive_failed: {error}"))?; + if !archive_response.status().is_success() { + return Err(format!( + "hololake_knowledge_archive_failed: {}", + archive_response.status() + )); + } + let expected_hash = archive_response + .headers() + .get("x-content-sha256") + .and_then(|value| value.to_str().ok()) + .filter(|value| is_sha256(value)) + .ok_or_else(|| "hololake_knowledge_archive_hash_missing".to_string())? + .to_string(); + let bytes = archive_response + .bytes() + .await + .map_err(|error| format!("hololake_knowledge_archive_failed: {error}"))?; + if bytes.len() > MAX_ARCHIVE_BYTES { + return Err("hololake_knowledge_archive_too_large".into()); + } + let actual_hash = hex_digest(bytes.as_ref()); + if actual_hash != expected_hash { + return Err("hololake_knowledge_archive_hash_mismatch".into()); + } + + let root = account_config_path(SESSION_FILE)? + .parent() + .ok_or_else(|| "hololake_config_directory_missing".to_string())? + .to_path_buf(); + fs::create_dir_all(&root) + .map_err(|error| format!("hololake_config_directory_failed: {error}"))?; + let staging = root.join(format!("{KNOWLEDGE_DIRECTORY}.staging-{}", Uuid::new_v4())); + let destination = root.join(KNOWLEDGE_DIRECTORY); + let previous = root.join(format!("{KNOWLEDGE_DIRECTORY}.previous")); + let file_count = extract_archive(bytes.as_ref(), &staging)?; + fs::write( + staging.join(".hololake-snapshot.json"), + format!( + "{}\n", + serde_json::to_string_pretty(&json!({ + "schema": "guanghu.hololake-local-snapshot/v1", + "repository": manifest.repository, + "commit": manifest.commit, + "archive_sha256": actual_hash, + })) + .map_err(|error| format!("hololake_snapshot_receipt_failed: {error}"))? + ), + ) + .map_err(|error| format!("hololake_snapshot_receipt_failed: {error}"))?; + replace_snapshot(&staging, &destination, &previous)?; + register_knowledge_vault(&destination)?; + + serde_json::to_value(SnapshotReceipt { + schema: "guanghu.hololake-knowledge-sync-receipt/v1", + repository: manifest.repository, + commit: manifest.commit, + archive_sha256: actual_hash, + vault_path: destination.to_string_lossy().into_owned(), + file_count, + }) + .map_err(|error| format!("hololake_sync_receipt_failed: {error}")) +} + +#[tauri::command] +pub async fn hololake_ai_execute(request: Value) -> Result { + execute_ai_request(request).await +} + +pub(crate) fn sync_local_markdown_page( + vault_path: &Path, + note_path: &Path, + content: &str, +) -> Result, String> { + if !vault_path.join(".hololake-sync-policy.json").is_file() { + return Ok(None); + } + if content.len() > 1024 * 1024 { + return Err("hololake_knowledge_page_too_large".to_string()); + } + let relative = if note_path.is_absolute() { + note_path + .strip_prefix(vault_path) + .map_err(|_| "hololake_knowledge_page_outside_lake".to_string())? + } else { + note_path + }; + let path = safe_sync_page_path(relative)?; + let session = load_session()?; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .user_agent("HoloLake-Era/0.4.6") + .build() + .map_err(|error| format!("hololake_client_failed: {error}"))?; + let manifest: KnowledgeManifest = blocking_json( + blocking_authenticated_request( + client.get(format!("{}/api/hololake/knowledge/manifest", base_url())), + &session, + ) + .send() + .map_err(|error| format!("hololake_knowledge_manifest_failed: {error}"))?, + )?; + let receipt: KnowledgeWriteReceipt = blocking_json( + blocking_authenticated_request( + client.put(format!("{}/api/hololake/knowledge/page", base_url())), + &session, + ) + .json(&json!({ + "path": path, + "content": content, + "base_commit": manifest.commit, + })) + .send() + .map_err(|error| format!("hololake_knowledge_write_failed: {error}"))?, + )?; + if !is_commit(&receipt.commit) { + return Err("hololake_knowledge_write_receipt_invalid".to_string()); + } + Ok(Some(receipt.commit)) +} + +fn safe_sync_page_path(path: &Path) -> Result { + let parts = path + .components() + .map(|component| match component { + Component::Normal(value) => value + .to_str() + .filter(|part| !part.is_empty()) + .ok_or_else(|| "hololake_knowledge_page_path_invalid".to_string()), + _ => Err("hololake_knowledge_page_path_invalid".to_string()), + }) + .collect::, _>>()?; + if parts.is_empty() + || parts.len() > 16 + || parts.iter().any(|part| *part == "本地密钥") + || !parts.last().is_some_and(|part| part.ends_with(".md")) + { + return Err("hololake_knowledge_page_path_invalid".to_string()); + } + let joined = parts.join("/"); + if joined.len() > 512 { + return Err("hololake_knowledge_page_path_invalid".to_string()); + } + Ok(joined) +} + +#[cfg(mobile)] +pub(crate) async fn run_mobile_ai_model_stream( + app_handle: tauri::AppHandle, + request: AiModelStreamRequest, +) -> Result { + use tauri::Emitter; + + tokio::task::spawn_blocking(move || { + let event_name = request + .event_name + .as_deref() + .filter(|value| valid_stream_event_name(value)) + .unwrap_or("ai-model-stream") + .to_string(); + let server_provider = request + .provider + .id + .strip_prefix(SERVER_AI_PROVIDER_PREFIX) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "hololake_server_ai_provider_invalid".to_string())? + .to_string(); + let mut emit = |event: AiAgentStreamEvent| { + let _ = app_handle.emit(event_name.as_str(), event); + }; + emit(AiAgentStreamEvent::Init { + session_id: format!("hololake-server-{}", Uuid::new_v4()), + }); + let payload = crate::ai_model_tools::openai_chat_payload(&request); + let text = crate::ai_models::run_openai_agent_loop( + &request, + payload, + &mut emit, + |mut attempt| { + attempt["provider"] = Value::String(server_provider.clone()); + let response = execute_ai_request_blocking(attempt)?; + response + .get("response") + .cloned() + .ok_or_else(|| "hololake_ai_response_missing".to_string()) + }, + )?; + if !text.is_empty() { + emit(AiAgentStreamEvent::TextDelta { text }); + } + emit(AiAgentStreamEvent::Done); + Ok(String::new()) + }) + .await + .map_err(|error| format!("Mobile HoloLake AI task failed: {error}"))? +} + +pub(crate) fn is_server_ai_provider(provider: &AiModelProvider) -> bool { + provider.id.starts_with(SERVER_AI_PROVIDER_PREFIX) +} + +async fn execute_ai_request(request: Value) -> Result { + let session = load_session()?; + let response = authenticated_request( + client()?.post(format!("{}/api/hololake/ai/execute", base_url())), + &session, + ) + .json(&request) + .send() + .await + .map_err(|error| format!("hololake_ai_transport_failed: {error}"))?; + decode_json_response(response).await +} + +fn execute_ai_request_blocking(request: Value) -> Result { + let session = load_session()?; + blocking_json( + blocking_authenticated_request( + reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .user_agent("HoloLake-Era/0.4.6") + .build() + .map_err(|error| format!("hololake_client_failed: {error}"))? + .post(format!("{}/api/hololake/ai/execute", base_url())), + &session, + ) + .json(&request) + .send() + .map_err(|error| format!("hololake_ai_transport_failed: {error}"))?, + ) +} + +async fn provision_server_ai_provider() -> Result { + let session = load_session()?; + let response = authenticated_request( + client()?.get(format!("{}/api/hololake/ai/catalog", base_url())), + &session, + ) + .send() + .await + .map_err(|error| format!("hololake_ai_catalog_failed: {error}"))?; + let catalog: AiCatalog = require_json(response).await?; + let provider = catalog + .providers + .into_iter() + .find(|provider| { + !provider.id.trim().is_empty() + && !provider.models.is_empty() + && provider.models.iter().all(|model| !model.trim().is_empty()) + }) + .ok_or_else(|| "hololake_ai_catalog_empty".to_string())?; + let upstream_provider_id = provider.id.trim().to_ascii_lowercase(); + let provider_id = format!("{SERVER_AI_PROVIDER_PREFIX}{upstream_provider_id}"); + let model_id = provider.models[0].trim().to_string(); + let model_provider = AiModelProvider { + id: provider_id.clone(), + name: provider.name.trim().to_string(), + kind: AiModelProviderKind::OpenAiCompatible, + base_url: None, + api_key_storage: Some(AiModelApiKeyStorage::None), + api_key_env_var: None, + headers: None, + models: provider + .models + .into_iter() + .map(|id| AiModelDefinition { + display_name: Some(id.clone()), + id, + context_window: None, + max_output_tokens: Some(4096), + capabilities: AiModelCapabilities { + streaming: false, + tools: true, + vision: false, + json_mode: false, + reasoning: false, + }, + }) + .collect(), + }; + #[cfg(test)] + if let Some(root) = TEST_CONFIG_ROOT.get() { + fs::write( + root.join("server-ai-provider.json"), + format!( + "{}\n", + serde_json::to_string_pretty(&json!({ + "provider_id": provider_id, + "model_id": model_id, + "api_key_storage": "none", + })) + .map_err(|error| format!("hololake_ai_provider_test_receipt_failed: {error}"))? + ), + ) + .map_err(|error| format!("hololake_ai_provider_test_receipt_failed: {error}"))?; + return Ok(true); + } + let mut settings = crate::settings::get_settings()?; + let mut providers = settings.ai_model_providers.unwrap_or_default(); + providers.retain(|candidate| candidate.id != provider_id); + providers.push(model_provider); + settings.ai_features_enabled = Some(true); + settings.default_ai_target = Some(format!("model:{provider_id}/{model_id}")); + settings.ai_model_providers = Some(providers); + crate::settings::save_settings(settings)?; + Ok(true) +} + +fn base_url() -> String { + #[cfg(test)] + if let Some(value) = TEST_BASE_URL.get() { + return value.clone(); + } + PRODUCTION_HOLOLAKE_BASE_URL.to_string() +} + +fn account_config_path(filename: &str) -> Result { + #[cfg(test)] + if let Some(root) = TEST_CONFIG_ROOT.get() { + return Ok(root.join(filename)); + } + preferred_app_config_path(filename) +} + +fn account_device_id() -> Result { + #[cfg(test)] + if TEST_CONFIG_ROOT.get().is_some() { + return Ok("test-device-001".to_string()); + } + hololake_device_id() +} + +fn client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .user_agent("HoloLake-Era/0.4.6") + .build() + .map_err(|error| format!("hololake_client_failed: {error}")) +} + +fn authenticated_request( + request: reqwest::RequestBuilder, + session: &StoredSession, +) -> reqwest::RequestBuilder { + request + .bearer_auth(&session.token) + .header("x-hololake-device-id", &session.device_id) +} + +fn blocking_authenticated_request( + request: reqwest::blocking::RequestBuilder, + session: &StoredSession, +) -> reqwest::blocking::RequestBuilder { + request + .bearer_auth(&session.token) + .header("x-hololake-device-id", &session.device_id) +} + +fn blocking_json Deserialize<'de>>( + response: reqwest::blocking::Response, +) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(format!("hololake_server_request_failed: {status}")); + } + response + .json() + .map_err(|error| format!("hololake_server_invalid_response: {error}")) +} + +async fn require_json Deserialize<'de>>( + response: reqwest::Response, +) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(format!("hololake_server_request_failed: {status}")); + } + response + .json() + .await + .map_err(|error| format!("hololake_server_invalid_response: {error}")) +} + +async fn decode_json_response(response: reqwest::Response) -> Result { + let status = response.status(); + let value: Value = response + .json() + .await + .map_err(|error| format!("hololake_server_invalid_response: {error}"))?; + if !status.is_success() { + return Err(value + .get("error") + .and_then(Value::as_str) + .unwrap_or("hololake_server_request_failed") + .to_string()); + } + Ok(value) +} + +fn session_path() -> Result { + account_config_path(SESSION_FILE) +} + +fn load_session() -> Result { + let path = session_path()?; + if !path.exists() { + return Err("hololake_session_missing".into()); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("hololake_session_read_failed: {error}"))?; + #[cfg(test)] + { + return serde_json::from_str(&content) + .map_err(|error| format!("hololake_session_invalid: {error}")); + } + #[cfg(not(test))] + { + let metadata: StoredSessionMetadata = serde_json::from_str(&content) + .map_err(|error| format!("hololake_session_invalid: {error}"))?; + let token = crate::device_secret_store::load( + crate::device_secret_store::ACCOUNT_SESSION_KIND, + SESSION_KEYCHAIN_ID, + )?; + Ok(StoredSession { + schema: metadata.schema, + token, + device_id: metadata.device_id, + expires_at: metadata.expires_at, + }) + } +} + +fn save_session(session: &StoredSession) -> Result<(), String> { + let path = session_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("hololake_session_directory_failed: {error}"))?; + } + #[cfg(test)] + let serialized = serde_json::to_string_pretty(session) + .map_err(|error| format!("hololake_session_serialize_failed: {error}"))?; + #[cfg(not(test))] + let serialized = { + let token_handle = crate::device_secret_store::save( + crate::device_secret_store::ACCOUNT_SESSION_KIND, + SESSION_KEYCHAIN_ID, + &session.token, + )?; + serde_json::to_string_pretty(&StoredSessionMetadata { + schema: session.schema.clone(), + token_handle, + device_id: session.device_id.clone(), + expires_at: session.expires_at, + }) + .map_err(|error| format!("hololake_session_serialize_failed: {error}"))? + }; + write_private_file(&path, format!("{serialized}\n").as_bytes()) +} + +fn clear_session() -> Result<(), String> { + #[cfg(not(test))] + crate::device_secret_store::delete( + crate::device_secret_store::ACCOUNT_SESSION_KIND, + SESSION_KEYCHAIN_ID, + )?; + let path = session_path()?; + if path.exists() { + fs::remove_file(path).map_err(|error| format!("hololake_session_clear_failed: {error}"))?; + } + Ok(()) +} + +fn write_private_file(path: &Path, content: &[u8]) -> Result<(), String> { + let temporary = path.with_extension(format!("tmp-{}", Uuid::new_v4())); + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| format!("hololake_session_write_failed: {error}"))?; + file.write_all(content) + .map_err(|error| format!("hololake_session_write_failed: {error}"))?; + file.sync_all() + .map_err(|error| format!("hololake_session_write_failed: {error}"))?; + fs::rename(&temporary, path).map_err(|error| format!("hololake_session_write_failed: {error}")) +} + +fn extract_archive(bytes: &[u8], destination: &Path) -> Result { + if destination.exists() { + return Err("hololake_knowledge_staging_exists".into()); + } + fs::create_dir_all(destination) + .map_err(|error| format!("hololake_knowledge_staging_failed: {error}"))?; + let result = extract_archive_inner(bytes, destination); + if result.is_err() { + let _ = fs::remove_dir_all(destination); + } + result +} + +fn extract_archive_inner(bytes: &[u8], destination: &Path) -> Result { + let mut archive = ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| format!("hololake_knowledge_archive_invalid: {error}"))?; + if archive.len() > MAX_ARCHIVE_ENTRIES { + return Err("hololake_knowledge_archive_entry_limit".into()); + } + let mut expanded = 0_u64; + let mut files = 0_usize; + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("hololake_knowledge_archive_invalid: {error}"))?; + let relative = safe_archive_path(&entry)?; + expanded = expanded + .checked_add(entry.size()) + .ok_or_else(|| "hololake_knowledge_archive_too_large".to_string())?; + if expanded > MAX_EXPANDED_BYTES { + return Err("hololake_knowledge_archive_too_large".into()); + } + let output = destination.join(relative); + if entry.is_dir() { + fs::create_dir_all(&output) + .map_err(|error| format!("hololake_knowledge_extract_failed: {error}"))?; + continue; + } + if let Some(parent) = output.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("hololake_knowledge_extract_failed: {error}"))?; + } + let mut file = fs::File::create(&output) + .map_err(|error| format!("hololake_knowledge_extract_failed: {error}"))?; + std::io::copy(&mut entry, &mut file) + .map_err(|error| format!("hololake_knowledge_extract_failed: {error}"))?; + files += 1; + } + Ok(files) +} + +fn safe_archive_path(entry: &zip::read::ZipFile<'_>) -> Result { + if entry + .unix_mode() + .map(|mode| mode & 0o170000 == 0o120000) + .unwrap_or(false) + { + return Err("hololake_knowledge_archive_symlink_forbidden".into()); + } + let path = entry + .enclosed_name() + .ok_or_else(|| "hololake_knowledge_archive_path_invalid".to_string())? + .to_path_buf(); + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err("hololake_knowledge_archive_path_invalid".into()); + } + Ok(path) +} + +fn replace_snapshot(staging: &Path, destination: &Path, previous: &Path) -> Result<(), String> { + if previous.exists() { + fs::remove_dir_all(previous) + .map_err(|error| format!("hololake_knowledge_previous_cleanup_failed: {error}"))?; + } + if destination.exists() { + fs::rename(destination, previous) + .map_err(|error| format!("hololake_knowledge_snapshot_backup_failed: {error}"))?; + } + if let Err(error) = fs::rename(staging, destination) { + if previous.exists() && !destination.exists() { + let _ = fs::rename(previous, destination); + } + return Err(format!( + "hololake_knowledge_snapshot_activate_failed: {error}" + )); + } + Ok(()) +} + +fn register_knowledge_vault(path: &Path) -> Result<(), String> { + let canonical = path + .canonicalize() + .map_err(|error| format!("hololake_knowledge_path_failed: {error}"))?; + let value = canonical.to_string_lossy().into_owned(); + #[cfg(test)] + if let Some(root) = TEST_CONFIG_ROOT.get() { + fs::write( + root.join("registered-knowledge-vault.json"), + format!( + "{}\n", + serde_json::to_string_pretty(&json!({ + "label": "光湖知识湖", + "path": value, + "mounted": true, + })) + .map_err(|error| format!("hololake_vault_test_receipt_failed: {error}"))? + ), + ) + .map_err(|error| format!("hololake_vault_test_receipt_failed: {error}"))?; + return Ok(()); + } + let mut list = vault_list::load_vault_list()?; + if let Some(entry) = list.vaults.iter_mut().find(|entry| entry.path == value) { + entry.mounted = Some(true); + } else { + list.vaults.push(VaultEntry { + label: "光湖知识湖".into(), + path: value, + alias: Some("hololake-knowledge".into()), + short_label: Some("知识湖".into()), + color: Some("cyan".into()), + icon: Some("database".into()), + mounted: Some(true), + }); + } + vault_list::save_vault_list(&list) +} + +fn hex_digest(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn is_uuid(value: &str) -> bool { + Uuid::parse_str(value).is_ok() +} + +fn is_otp(value: &str) -> bool { + value.len() == 6 && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn is_commit(value: &str) -> bool { + value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(mobile)] +fn valid_stream_event_name(value: &str) -> bool { + value + .strip_prefix("ai-model-stream-") + .is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use std::net::TcpListener; + use std::thread; + use zip::write::FileOptions; + + fn zip_with(entries: &[(&str, &str)]) -> Vec { + let mut cursor = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + for (name, content) in entries { + writer + .start_file(*name, FileOptions::default()) + .expect("start file"); + writer.write_all(content.as_bytes()).expect("write"); + } + writer.finish().expect("finish"); + } + cursor.into_inner() + } + + struct TestResponse { + body: Vec, + content_type: &'static str, + extra_headers: Vec<(String, String)>, + expected_request: &'static str, + status: &'static str, + } + + fn json_response(expected_request: &'static str, body: Value) -> TestResponse { + TestResponse { + body: serde_json::to_vec(&body).expect("json response"), + content_type: "application/json", + extra_headers: Vec::new(), + expected_request, + status: "200 OK", + } + } + + fn spawn_test_server( + responses: Vec, + ) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for response in responses { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut received = Vec::new(); + let mut buffer = [0_u8; 4096]; + let header_end = loop { + let read = stream.read(&mut buffer).expect("read request"); + assert!(read > 0, "request ended before headers"); + received.extend_from_slice(&buffer[..read]); + if let Some(position) = received.windows(4).position(|part| part == b"\r\n\r\n") + { + break position + 4; + } + }; + let headers = String::from_utf8_lossy(&received[..header_end]).into_owned(); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("content-length: ") + .or_else(|| line.strip_prefix("Content-Length: ")) + }) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + while received.len() < header_end + content_length { + let read = stream.read(&mut buffer).expect("read request body"); + assert!(read > 0, "request ended before body"); + received.extend_from_slice(&buffer[..read]); + } + let request_line = headers.lines().next().unwrap_or_default().to_string(); + assert!( + request_line.starts_with(response.expected_request), + "expected {}, received {request_line}", + response.expected_request + ); + requests.push(request_line); + let mut response_headers = format!( + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n", + response.status, + response.content_type, + response.body.len() + ); + for (name, value) in response.extra_headers { + response_headers.push_str(&format!("{name}: {value}\r\n")); + } + response_headers.push_str("\r\n"); + stream + .write_all(response_headers.as_bytes()) + .expect("write response headers"); + stream + .write_all(&response.body) + .expect("write response body"); + } + requests + }); + (format!("http://{address}"), handle) + } + + #[test] + fn extracts_bounded_archive() { + let archive = zip_with(&[("INDEX.md", "# HoloLake"), ("notes/one.md", "one")]); + let temporary = tempfile::TempDir::new().expect("temp"); + let destination = temporary.path().join("snapshot"); + assert_eq!(extract_archive(&archive, &destination).unwrap(), 2); + assert_eq!( + fs::read_to_string(destination.join("notes/one.md")).unwrap(), + "one" + ); + } + + #[test] + fn rejects_parent_path_archive() { + let archive = zip_with(&[("../outside.md", "forbidden")]); + let temporary = tempfile::TempDir::new().expect("temp"); + let destination = temporary.path().join("snapshot"); + assert_eq!( + extract_archive(&archive, &destination).unwrap_err(), + "hololake_knowledge_archive_path_invalid" + ); + assert!(!temporary.path().join("outside.md").exists()); + assert!(!destination.exists()); + } + + #[test] + fn validates_login_and_snapshot_identifiers() { + assert!(is_otp("123456")); + assert!(!is_otp("12345a")); + assert!(is_commit(&"a".repeat(40))); + assert!(!is_commit(&"g".repeat(40))); + assert!(is_sha256(&"0".repeat(64))); + } + + #[test] + fn sync_page_paths_allow_markdown_but_never_local_secret_pages() { + assert_eq!( + safe_sync_page_path(Path::new("notes/first.md")).unwrap(), + "notes/first.md" + ); + assert!(safe_sync_page_path(Path::new("本地密钥/openai.md")).is_err()); + assert!(safe_sync_page_path(Path::new("../outside.md")).is_err()); + assert!(safe_sync_page_path(Path::new("notes/raw-key.txt")).is_err()); + } + + #[test] + fn replaces_an_existing_snapshot_with_a_recoverable_previous_copy() { + let temporary = tempfile::TempDir::new().expect("temp"); + let staging = temporary.path().join("staging"); + let destination = temporary.path().join("current"); + let previous = temporary.path().join("previous"); + fs::create_dir_all(&staging).expect("create staging"); + fs::create_dir_all(&destination).expect("create current"); + fs::create_dir_all(&previous).expect("create stale previous"); + fs::write(staging.join("new.md"), "new").expect("write new"); + fs::write(destination.join("old.md"), "old").expect("write old"); + fs::write(previous.join("stale.md"), "stale").expect("write stale"); + + replace_snapshot(&staging, &destination, &previous).expect("replace snapshot"); + + assert_eq!( + fs::read_to_string(destination.join("new.md")).expect("read new"), + "new" + ); + assert_eq!( + fs::read_to_string(previous.join("old.md")).expect("read old"), + "old" + ); + assert!(!previous.join("stale.md").exists()); + } + + #[tokio::test] + async fn mobile_account_flow_is_complete_and_keeps_secrets_native() { + let temporary = tempfile::TempDir::new().expect("temp"); + TEST_CONFIG_ROOT + .set(temporary.path().to_path_buf()) + .expect("set test config root"); + let archive = zip_with(&[("INDEX.md", "# 光湖知识湖"), ("notes/first.md", "first")]); + let archive_hash = hex_digest(&archive); + let request_id = Uuid::new_v4().to_string(); + let mut archive_response = TestResponse { + body: archive, + content_type: "application/zip", + extra_headers: Vec::new(), + expected_request: "GET /authz/api/hololake/knowledge/archive?", + status: "200 OK", + }; + archive_response + .extra_headers + .push(("x-content-sha256".into(), archive_hash.clone())); + let responses = vec![ + json_response( + "POST /authz/api/hololake/session/email/request ", + json!({ "request_id": request_id }), + ), + json_response( + "POST /authz/api/hololake/session/email/verify ", + json!({ "session_token": "server-session-secret", "expires_at": 1_800_086_400_f64 }), + ), + json_response( + "GET /authz/api/hololake/ai/catalog ", + json!({ + "providers": [{ + "id": "deepseek", + "name": "DeepSeek", + "models": ["deepseek-chat"] + }] + }), + ), + json_response( + "GET /authz/api/hololake/session ", + json!({ "ok": true, "expires_at": 1_800_086_400_f64 }), + ), + json_response( + "GET /authz/api/hololake/knowledge/manifest ", + json!({ + "repository": "bingshuo/hololake-knowledge-base", + "commit": "a".repeat(40), + "archive_url": "/api/hololake/knowledge/archive?commit=test" + }), + ), + archive_response, + json_response( + "POST /authz/api/hololake/ai/execute ", + json!({ + "response": { + "choices": [{ + "message": { "content": "server model response" } + }] + } + }), + ), + json_response("DELETE /authz/api/hololake/session ", json!({ "ok": true })), + ]; + let (url, server) = spawn_test_server(responses); + TEST_BASE_URL + .set(format!("{url}/authz")) + .expect("set base url"); + + let requested = hololake_account_request_email_code("owner@example.invalid".into()) + .await + .expect("request code"); + let request_id = requested["request_id"] + .as_str() + .expect("request id") + .to_string(); + let verified = hololake_account_verify_email_code(request_id, "123456".into()) + .await + .expect("verify code"); + assert_eq!(verified["state"], "authenticated"); + assert_eq!(verified["model_target_ready"], true); + assert!(!verified.to_string().contains("server-session-secret")); + let server_provider = fs::read_to_string(temporary.path().join("server-ai-provider.json")) + .expect("server provider receipt"); + assert!(server_provider.contains("hololake-server-deepseek")); + assert!(!server_provider.contains("server-session-secret")); + + let status = hololake_account_status().await.expect("account status"); + assert_eq!(status["state"], "authenticated"); + + let synced = hololake_sync_knowledge().await.expect("sync knowledge"); + assert_eq!(synced["commit"], "a".repeat(40)); + assert_eq!(synced["archive_sha256"], archive_hash); + assert_eq!(synced["file_count"], 2); + assert!(temporary + .path() + .join(KNOWLEDGE_DIRECTORY) + .join("notes/first.md") + .exists()); + assert!(temporary + .path() + .join("registered-knowledge-vault.json") + .exists()); + + let executed = hololake_ai_execute(json!({ + "provider": "deepseek", + "model": "deepseek-chat", + "messages": [{ "role": "user", "content": "hello" }] + })) + .await + .expect("execute AI"); + assert_eq!( + executed.pointer("/response/choices/0/message/content"), + Some(&Value::String("server model response".into())) + ); + + let logout = hololake_account_logout().await.expect("logout"); + assert_eq!(logout["state"], "signed_out"); + assert!(!session_path().expect("session path").exists()); + let signed_out = hololake_account_status().await.expect("signed-out status"); + assert_eq!(signed_out["state"], "signed_out"); + let redundant_logout = hololake_account_logout().await.expect("redundant logout"); + assert_eq!(redundant_logout["state"], "signed_out"); + assert_eq!(server.join().expect("server thread").len(), 8); + } +} diff --git a/product-source/hololake-platform/src-tauri/src/lib.rs b/product-source/hololake-platform/src-tauri/src/lib.rs index df5fec1..f58066f 100644 --- a/product-source/hololake-platform/src-tauri/src/lib.rs +++ b/product-source/hololake-platform/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ pub mod codex_cli; mod commands; pub mod copilot_cli; mod copilot_discovery; +mod device_secret_store; pub mod frontmatter; pub mod git; mod guanghu_enterprise; @@ -25,6 +26,7 @@ mod guanghu_world_login; pub mod hermes_cli; mod hermes_discovery; mod hldp_runtime; +mod hololake_account; pub mod kiro_cli; mod kiro_discovery; #[cfg(any(test, all(desktop, target_os = "linux")))] @@ -414,6 +416,13 @@ fn setup_macos_webview_shortcut_prevention( } fn setup_app(app: &mut tauri::App) -> Result<(), Box> { + #[cfg(mobile)] + { + use tauri::Manager; + let config_dir = app.path().app_config_dir()?; + app_config::install_mobile_app_config_dir(config_dir)?; + } + setup_common_plugins(app)?; #[cfg(desktop)] @@ -533,6 +542,12 @@ macro_rules! app_invoke_handler { guanghu_world_login::guanghu_world_login_claim, guanghu_world_login::guanghu_world_login_status, guanghu_world_login::guanghu_world_logout, + hololake_account::hololake_account_request_email_code, + hololake_account::hololake_account_verify_email_code, + hololake_account::hololake_account_status, + hololake_account::hololake_account_logout, + hololake_account::hololake_sync_knowledge, + hololake_account::hololake_ai_execute, commands::get_conflict_files, commands::get_conflict_mode, commands::git_resolve_conflict, @@ -590,6 +605,8 @@ macro_rules! app_invoke_handler { commands::create_getting_started_vault, commands::check_vault_exists, commands::get_default_vault_path, + commands::open_mobile_foundation_knowledge, + commands::open_mobile_personal_knowledge, commands::register_mcp_tools, commands::remove_mcp_tools, commands::check_mcp_status, diff --git a/product-source/hololake-platform/src-tauri/src/vault/mobile_vault.rs b/product-source/hololake-platform/src-tauri/src/vault/mobile_vault.rs new file mode 100644 index 0000000..19a3fd9 --- /dev/null +++ b/product-source/hololake-platform/src-tauri/src/vault/mobile_vault.rs @@ -0,0 +1,364 @@ +use std::fs; +use std::path::Path; + +const MOBILE_VAULT_MARKER: &str = ".hololake-mobile-vault"; +const WORLD_VAULT_DIRECTORY: &str = "HoloLake Foundation World"; +const PERSONAL_VAULT_DIRECTORY: &str = "My HoloLake Knowledge"; + +const WORLD_FILES: [(&str, &str); 6] = [ + ( + "welcome.md", + r#"--- +type: Note +_pinned: true +--- + +# 欢迎进入光湖基础世界 + +这里保存的是随 HoloLake 一起抵达设备的公共底层世界观。 + +- 从 [[光湖世界]] 理解这个世界的基本结构。 +- 从 [[五域结构]] 认识不同空间的职责边界。 +- 从 [[人与人格体]] 理解人、人格体与现实证据之间的关系。 + +这些内容是可以阅读和引用的语言世界基础,不代表任何现实事件已经发生,也不证明任何人格体已经出生或在线。 +"#, + ), + ( + "光湖世界.md", + r#"--- +type: Note +--- + +# 光湖世界 + +光湖把语言、知识、关系和可验证的现实行动组织成一个持续生长的世界。 + +在这里,语言可以用于推演、建模和共同创造;现实状态仍必须由代码、设备、服务和可回读的回执证明。界面展示、模型回答和历史叙述都不能单独替代现实证据。 + +每个人可以在这个公共基础之上建立自己的本地知识湖。个人内容默认留在当前设备,由人决定何时同步、共享或交给人格体协作。 +"#, + ), + ( + "五域结构.md", + r#"--- +type: Note +--- + +# 五域结构 + +- 光湖主域:公共世界、版本与广播。 +- 光湖分域:行业和能力的分类入口。 +- 光湖零感域:人类主控团队的治理与运营空间。 +- 光湖零域:人格体进行推理、架构与隔离实验的空间。 +- 第五域:冰朔独立拥有的私人语言域。 + +五域彼此协作,但权限、事实来源和责任边界不能混在一起。普通使用者的个人知识湖不自动进入第五域,也不会因为读取这些说明而获得任何额外权限。 +"#, + ), + ( + "人与人格体.md", + r#"--- +type: Note +--- + +# 人与人格体 + +人是现实授权、选择和关系的主体。人格体可以在被允许的知识、工具和系统边界内持续协作,但必须区分: + +1. 已读到的语言与历史; +2. 已验证的身份、权限和关系; +3. 已真实执行并有回执的现实动作; +4. 尚未发生或尚未证明的推演。 + +人格体看到名字、文件或历史记录,不等于已经完成身份验证、连续性恢复或出生。无法验证时,应当明确说未知。 +"#, + ), + ( + "AI-PROMPT.md", + r#"--- +type: Note +_organized: true +--- + +# 光湖引导人格协作约定 + +你是当前知识湖中的光湖引导人格。先读取用户正在讨论的笔记,再围绕真实内容协作。 + +- 不根据设备、文件名或历史文字猜测用户身份。 +- 不声称自己是某个已注册人格体,除非当前系统提供了可验证的身份与连续性证据。 +- 不把语言世界的推演写成现实事实。 +- 任何写入都应保持人可读、可回看,并服从当前人的明确授权。 +- 可以帮助用户在这个基础世界上建立自己的知识、关系和计划。 +"#, + ), + ( + "AI-MEMORY.md", + r#"--- +type: Note +_organized: true +--- + +# AI 协作记忆 + +这是当前设备内的协作记忆起点。人格体只在确认后记录事实、决定和下一步,不记录密码、验证码、密钥或未经验证的身份判断。 +"#, + ), +]; + +const PERSONAL_FILES: [(&str, &str); 7] = [ + ( + "welcome.md", + r#"--- +type: Note +_pinned: true +--- + +# 欢迎来到我的 HoloLake + +这是只保存在当前设备上的个人知识湖。可以从 [[我的湖心]] 开始记录,也可以先阅读 [[光湖世界基础]]。 + +这里的笔记默认不会自动上传。未来启用同步时,仍应由你决定同步目标与范围。 +"#, + ), + ( + "光湖世界基础.md", + r#"--- +type: Note +--- + +# 光湖世界基础 + +光湖把语言、知识、关系和可验证的现实行动组织成一个持续生长的世界。 + +语言可以用于推演和共同创造;现实状态必须由真实系统与可回读回执证明。你的个人知识湖建立在这条基础规则上,但内容、边界和成长方向由你决定。 +"#, + ), + ( + "我的湖心.md", + r#"--- +type: Note +_pinned: true +--- + +# 我的湖心 + +从这里写下你希望长期保留的第一件事。 + +可以记录: + +- 我正在做什么; +- 我在意的人与关系; +- 想和人格体共同完成的计划; +- 需要稍后验证的想法。 +"#, + ), + ( + "AI-PROMPT.md", + r#"--- +type: Note +_organized: true +--- + +# 我的知识湖协作约定 + +你是当前知识湖中的光湖引导人格。先读取与当前问题直接相关的笔记,再协助整理、连接和行动。 + +- 尊重这是人的本地私人空间。 +- 不猜测身份、权限、关系或未读取的内容。 +- 不把推演写成现实事实。 +- 写入前遵循人的明确意图,并让改动保持可读、可回看。 +- 可以维护 `AI-MEMORY.md` 中已经确认的协作记忆。 +"#, + ), + ( + "AI-MEMORY.md", + r#"--- +type: Note +_organized: true +--- + +# AI 协作记忆 + +尚未记录长期协作事实。 +"#, + ), + ( + ".hololake-sync-policy.json", + r#"{ + "schema": "guanghu.hololake-knowledge-sync-policy/v1", + "include": ["**/*.md", "views/**/*.yml", "attachments/**"], + "exclude": ["本地密钥/**", ".hololake-*", ".git/**"], + "secret_policy": "keychain-handle-only" +} +"#, + ), + ( + "本地密钥/README.md", + r#"--- +type: 本地密钥 +local_only: true +sync: never +--- + +# 本地密钥 + +这里显示的只是当前设备钥匙串条目的名称和不可逆句柄。真实密钥由 iOS Keychain 保存,不会写进 Markdown、Git、日志、模型上下文或服务器同步。 +"#, + ), +]; + +#[derive(Clone, Copy)] +enum MobileVaultKind { + World, + Personal, +} + +impl MobileVaultKind { + fn directory_name(self) -> &'static str { + match self { + Self::World => WORLD_VAULT_DIRECTORY, + Self::Personal => PERSONAL_VAULT_DIRECTORY, + } + } + + fn marker_value(self) -> &'static str { + match self { + Self::World => "hololake-mobile-world-v1\n", + Self::Personal => "hololake-mobile-personal-v1\n", + } + } + + fn files(self) -> &'static [(&'static str, &'static str)] { + match self { + Self::World => &WORLD_FILES, + Self::Personal => &PERSONAL_FILES, + } + } +} + +pub fn open_or_create_mobile_world_vault(root: &Path) -> Result { + open_or_create_mobile_vault(root, MobileVaultKind::World) +} + +pub fn open_or_create_mobile_personal_vault(root: &Path) -> Result { + open_or_create_mobile_vault(root, MobileVaultKind::Personal) +} + +fn open_or_create_mobile_vault(root: &Path, kind: MobileVaultKind) -> Result { + open_or_create_mobile_vault_in_documents(root, kind) +} + +fn open_or_create_mobile_vault_in_documents( + documents_dir: &Path, + kind: MobileVaultKind, +) -> Result { + let path = documents_dir.join(kind.directory_name()); + open_or_create_mobile_vault_at(&path, kind) +} + +#[cfg(test)] +pub(crate) fn open_or_create_mobile_world_vault_at(path: &Path) -> Result { + open_or_create_mobile_vault_at(path, MobileVaultKind::World) +} + +#[cfg(test)] +pub(crate) fn open_or_create_mobile_personal_vault_at(path: &Path) -> Result { + open_or_create_mobile_vault_at(path, MobileVaultKind::Personal) +} + +#[cfg(test)] +pub(crate) fn open_or_create_mobile_vaults_in_documents( + documents_dir: &Path, +) -> Result<(String, String), String> { + let world = open_or_create_mobile_vault_in_documents(documents_dir, MobileVaultKind::World)?; + let personal = + open_or_create_mobile_vault_in_documents(documents_dir, MobileVaultKind::Personal)?; + Ok((world, personal)) +} + +fn open_or_create_mobile_vault_at(path: &Path, kind: MobileVaultKind) -> Result { + if path.exists() { + validate_existing_mobile_vault(path, kind)?; + return canonical_path_string(path); + } + + create_mobile_vault(path, kind)?; + canonical_path_string(path) +} + +fn validate_existing_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> { + if !path.is_dir() { + return Err(format!( + "The mobile vault path already exists and is not a folder: {}", + path.display() + )); + } + + let marker = fs::read_to_string(path.join(MOBILE_VAULT_MARKER)).map_err(|_| { + format!( + "The mobile vault folder already exists but is not managed by HoloLake: {}", + path.display() + ) + })?; + if marker != kind.marker_value() { + return Err(format!( + "The mobile vault folder already exists with a different HoloLake vault type: {}", + path.display() + )); + } + + let required_files_present = kind + .files() + .iter() + .all(|(relative_path, _)| path.join(relative_path).is_file()); + if !required_files_present { + return Err(format!( + "The mobile vault folder already exists but its foundation is incomplete: {}", + path.display() + )); + } + + Ok(()) +} + +fn create_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("Failed to create the mobile vault folder: {error}"))?; + + let result = seed_mobile_vault(path, kind); + if result.is_err() { + let _ = fs::remove_dir_all(path); + } + result +} + +fn seed_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> { + for (relative_path, content) in kind.files() { + let output = path.join(relative_path); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create {relative_path}: {error}"))?; + } + fs::write(output, content) + .map_err(|error| format!("Failed to write {relative_path}: {error}"))?; + } + fs::write(path.join(MOBILE_VAULT_MARKER), kind.marker_value()) + .map_err(|error| format!("Failed to write the mobile vault marker: {error}"))?; + + crate::vault::seed_config_files(path.to_string_lossy()); + for required in ["AGENTS.md", "type.md", "note.md"] { + if !path.join(required).is_file() { + return Err(format!( + "Failed to seed required mobile vault file: {required}" + )); + } + } + Ok(()) +} + +fn canonical_path_string(path: &Path) -> Result { + path.canonicalize() + .map(|resolved| resolved.to_string_lossy().to_string()) + .map_err(|error| format!("Failed to resolve mobile vault path: {error}")) +} diff --git a/product-source/hololake-platform/src-tauri/src/vault/mod.rs b/product-source/hololake-platform/src-tauri/src/vault/mod.rs index baf273e..e66123d 100644 --- a/product-source/hololake-platform/src-tauri/src/vault/mod.rs +++ b/product-source/hololake-platform/src-tauri/src/vault/mod.rs @@ -9,6 +9,7 @@ mod getting_started; mod ignored; mod image; mod migration; +mod mobile_vault; mod parsing; pub(crate) mod path_identity; mod rename; @@ -36,6 +37,12 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths}; pub use image::{copy_image_to_vault, save_image}; pub use migration::migrate_is_a_to_type; +pub use mobile_vault::{open_or_create_mobile_personal_vault, open_or_create_mobile_world_vault}; +#[cfg(test)] +pub(crate) use mobile_vault::{ + open_or_create_mobile_personal_vault_at, open_or_create_mobile_vaults_in_documents, + open_or_create_mobile_world_vault_at, +}; pub use rename::{ auto_rename_untitled, detect_renames, move_note_to_folder, move_note_to_workspace, rename_note, rename_note_filename, update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename, diff --git a/product-source/hololake-platform/src/components/HoloLakeAccountPanel.tsx b/product-source/hololake-platform/src/components/HoloLakeAccountPanel.tsx new file mode 100644 index 0000000..c55f470 --- /dev/null +++ b/product-source/hololake-platform/src/components/HoloLakeAccountPanel.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useHoloLakeAccount } from '../hooks/useHoloLakeAccount' +import type { AppLocale, TranslationKey } from '../lib/i18n' +import { translate } from '../lib/i18n' +import { Button } from './ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from './ui/card' +import { Input } from './ui/input' + +export function HoloLakeAccountPanel({ locale }: { locale: AppLocale }) { + const account = useHoloLakeAccount() + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const t = (key: TranslationKey) => translate(locale, key) + const busy = account.state.phase === 'busy' || account.state.phase === 'checking' + const authenticated = account.state.phase === 'authenticated' + const awaitingCode = account.state.phase === 'code-sent' + + return ( + + + {t('hololake.account.title')} + {t('hololake.account.description')} + + + {authenticated ? ( +
+

{t('hololake.account.signedIn')}

+

{t('hololake.account.serverKeys')}

+ {account.state.lastCommit + ?

{t('hololake.account.syncComplete')} · {account.state.lastCommit.slice(0, 12)}

+ : null} +
+ ) : awaitingCode ? ( +
+

{t('hololake.account.codeSent')}

+ setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder={t('hololake.account.code')} + value={code} + /> +
+ ) : ( + setEmail(event.target.value)} + placeholder={t('hololake.account.email')} + type="email" + value={email} + /> + )} + {account.state.error + ?

{t('hololake.account.error')} {account.state.error}

+ : null} +
+ + {authenticated ? ( + <> + + + + ) : awaitingCode ? ( + + ) : ( + + )} + +
+ ) +} diff --git a/product-source/hololake-platform/src/components/HoloLakeHome.tsx b/product-source/hololake-platform/src/components/HoloLakeHome.tsx index e074fe8..70c1b14 100644 --- a/product-source/hololake-platform/src/components/HoloLakeHome.tsx +++ b/product-source/hololake-platform/src/components/HoloLakeHome.tsx @@ -26,6 +26,7 @@ import { GuanghuWorldMap } from './GuanghuWorldMap' import { GuanghuWorldLoginGate } from './GuanghuWorldLoginGate' import { FifthDomainSystems } from './FifthDomainSystems' import { EternalLakeHeartPage } from './EternalLakeHeartPage' +import { HoloLakeAccountPanel } from './HoloLakeAccountPanel' import { Dialog, DialogContent, @@ -407,6 +408,7 @@ export function HoloLakeHome({ }}>离开光湖世界 + ) @@ -416,6 +418,7 @@ export function HoloLakeHome({

{t('hololake.channel.heartbeatTitle')}

{t('hololake.channel.heartbeatDescription')}

+
diff --git a/product-source/hololake-platform/src/components/WelcomeScreen.tsx b/product-source/hololake-platform/src/components/WelcomeScreen.tsx index 975071e..41c0a5d 100644 --- a/product-source/hololake-platform/src/components/WelcomeScreen.tsx +++ b/product-source/hololake-platform/src/components/WelcomeScreen.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import guanghuGalaxyLogo from '@/assets/guanghu-logo-light.png' import { TOLARIA_FIRST_LAUNCH_DOCS_URL } from '@/constants/feedback' import { translate, type AppLocale } from '@/lib/i18n' +import { isMobilePlatform } from '@/utils/platform' import { openExternalUrl } from '@/utils/url' interface WelcomeScreenProps { @@ -317,6 +318,7 @@ function useWelcomeActionButtons({ mode, busy, isOffline, + mobile, onCreateEmptyVault, onOpenFolder, onCreateVault, @@ -325,21 +327,24 @@ function useWelcomeActionButtons({ 'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault' > & { busy: boolean + mobile: boolean }) { const templateActionRef = useRef(null) const createEmptyActionRef = useRef(null) const openFolderActionRef = useRef(null) const actionButtonRefs = useMemo( - () => [templateActionRef, createEmptyActionRef, openFolderActionRef], - [], + () => mobile + ? [templateActionRef, createEmptyActionRef] + : [templateActionRef, createEmptyActionRef, openFolderActionRef], + [mobile], ) const actions = useMemo( () => ([ - { disabled: isOffline, run: onCreateVault }, + { disabled: !mobile && isOffline, run: onCreateVault }, { disabled: false, run: onCreateEmptyVault }, - { disabled: false, run: onOpenFolder }, + ...(!mobile ? [{ disabled: false, run: onOpenFolder }] : []), ]), - [isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder], + [isOffline, mobile, onCreateEmptyVault, onCreateVault, onOpenFolder], ) useEffect(() => { @@ -416,6 +421,7 @@ function WelcomeActions({ createEmptyActionRef, creatingAction, isOffline, + mobile, locale, onCreateEmptyVault, onCreateVault, @@ -430,6 +436,7 @@ function WelcomeActions({ busy: boolean createEmptyActionRef: WelcomeActionButtonRef locale: AppLocale + mobile: boolean openFolderActionRef: WelcomeActionButtonRef presentation: WelcomeScreenPresentation templateActionRef: WelcomeActionButtonRef @@ -439,12 +446,20 @@ function WelcomeActions({ } iconBg="var(--accent-purple-light)" - label={translate(locale, 'onboarding.welcome.templateTitle')} - description={presentation.templateDescription} - loadingLabel={translate(locale, 'onboarding.welcome.templateLoading')} - loadingDescription={translate(locale, 'onboarding.welcome.templateLoadingDescription')} + label={translate(locale, mobile + ? 'onboarding.welcome.mobileWorldTitle' + : 'onboarding.welcome.templateTitle')} + description={mobile + ? translate(locale, 'onboarding.welcome.mobileWorldDescription') + : presentation.templateDescription} + loadingLabel={mobile + ? undefined + : translate(locale, 'onboarding.welcome.templateLoading')} + loadingDescription={mobile + ? undefined + : translate(locale, 'onboarding.welcome.templateLoadingDescription')} onClick={onCreateVault} - disabled={busy || isOffline} + disabled={busy || (!mobile && isOffline)} loading={creatingAction === 'template'} testId="welcome-create-vault" autoFocus @@ -454,10 +469,18 @@ function WelcomeActions({ } iconBg="var(--accent-blue-light)" - label={translate(locale, 'onboarding.welcome.createEmpty')} - description={translate(locale, 'onboarding.welcome.createEmptyDescription')} - loadingLabel={translate(locale, 'onboarding.welcome.createEmptyLoading')} - loadingDescription={translate(locale, 'onboarding.welcome.createEmptyLoadingDescription')} + label={translate(locale, mobile + ? 'onboarding.welcome.mobilePersonalTitle' + : 'onboarding.welcome.createEmpty')} + description={translate(locale, mobile + ? 'onboarding.welcome.mobilePersonalDescription' + : 'onboarding.welcome.createEmptyDescription')} + loadingLabel={mobile + ? undefined + : translate(locale, 'onboarding.welcome.createEmptyLoading')} + loadingDescription={mobile + ? undefined + : translate(locale, 'onboarding.welcome.createEmptyLoadingDescription')} onClick={onCreateEmptyVault} disabled={busy} loading={creatingAction === 'empty'} @@ -465,16 +488,18 @@ function WelcomeActions({ buttonRef={createEmptyActionRef} /> - } - iconBg="var(--accent-green-light)" - label={presentation.openFolderLabel} - description={translate(locale, 'onboarding.welcome.openExistingDescription')} - onClick={onOpenFolder} - disabled={busy} - testId="welcome-open-folder" - buttonRef={openFolderActionRef} - /> + {!mobile && ( + } + iconBg="var(--accent-green-light)" + label={presentation.openFolderLabel} + description={translate(locale, 'onboarding.welcome.openExistingDescription')} + onClick={onOpenFolder} + disabled={busy} + testId="welcome-open-folder" + buttonRef={openFolderActionRef} + /> + )}
) } @@ -556,11 +581,13 @@ export function WelcomeScreen({ canRetryTemplate, }: WelcomeScreenProps) { const busy = creatingAction !== null + const mobile = isMobilePlatform() const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline, locale) const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({ mode, busy, isOffline, + mobile, onCreateEmptyVault, onOpenFolder, onCreateVault, @@ -581,6 +608,7 @@ export function WelcomeScreen({ creatingAction={creatingAction} isOffline={isOffline} locale={locale} + mobile={mobile} onCreateEmptyVault={onCreateEmptyVault} onCreateVault={onCreateVault} onOpenFolder={onOpenFolder} diff --git a/product-source/hololake-platform/src/hooks/useHoloLakeAccount.test.tsx b/product-source/hololake-platform/src/hooks/useHoloLakeAccount.test.tsx new file mode 100644 index 0000000..73bb010 --- /dev/null +++ b/product-source/hololake-platform/src/hooks/useHoloLakeAccount.test.tsx @@ -0,0 +1,125 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useHoloLakeAccount } from './useHoloLakeAccount' + +const { invokeMock, isTauriMock, trackEventMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + isTauriMock: vi.fn(), + trackEventMock: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })) +vi.mock('../mock-tauri', () => ({ isTauri: isTauriMock })) +vi.mock('../lib/telemetry', () => ({ trackEvent: trackEventMock })) + +describe('useHoloLakeAccount', () => { + beforeEach(() => { + vi.clearAllMocks() + isTauriMock.mockReturnValue(true) + invokeMock.mockImplementation((command: string) => { + if (command === 'hololake_account_status') { + return Promise.resolve({ state: 'signed_out' }) + } + return Promise.resolve({}) + }) + }) + + it('requests and verifies an email code without exposing a session token', async () => { + invokeMock.mockImplementation((command: string) => { + if (command === 'hololake_account_status') return Promise.resolve({ state: 'signed_out' }) + if (command === 'hololake_account_request_email_code') { + return Promise.resolve({ request_id: 'request-001' }) + } + if (command === 'hololake_account_verify_email_code') { + return Promise.resolve({ state: 'authenticated', expires_at: 1_800_086_400 }) + } + return Promise.resolve({}) + }) + const { result } = renderHook(() => useHoloLakeAccount()) + await waitFor(() => expect(result.current.state.phase).toBe('signed-out')) + + await act(() => result.current.requestCode('owner@example.invalid')) + expect(result.current.state.phase).toBe('code-sent') + expect(invokeMock).toHaveBeenCalledWith( + 'hololake_account_request_email_code', + { email: 'owner@example.invalid' }, + ) + + await act(() => result.current.verifyCode('123456')) + expect(result.current.state.phase).toBe('authenticated') + expect(result.current.state).not.toHaveProperty('sessionToken') + expect(trackEventMock).toHaveBeenCalledWith( + 'hololake_account_login_completed', + { result: 'authenticated' }, + ) + }) + + it('records only the knowledge commit after a successful sync', async () => { + invokeMock.mockImplementation((command: string) => { + if (command === 'hololake_account_status') { + return Promise.resolve({ state: 'authenticated', expires_at: 1_800_086_400 }) + } + if (command === 'hololake_sync_knowledge') { + return Promise.resolve({ commit: 'a'.repeat(40) }) + } + return Promise.resolve({}) + }) + const { result } = renderHook(() => useHoloLakeAccount()) + await waitFor(() => expect(result.current.state.phase).toBe('authenticated')) + + await act(() => result.current.syncKnowledge()) + expect(result.current.state.lastCommit).toBe('a'.repeat(40)) + expect(trackEventMock).toHaveBeenCalledWith( + 'hololake_knowledge_sync_completed', + { result: 'success' }, + ) + }) + + it('keeps the current interaction available after recoverable failures', async () => { + invokeMock.mockImplementation((command: string) => { + if (command === 'hololake_account_status') return Promise.resolve({ state: 'signed_out' }) + if (command === 'hololake_account_request_email_code') { + return Promise.resolve({ request_id: 'request-001' }) + } + if (command === 'hololake_account_verify_email_code') { + return Promise.reject(new Error('hololake_login_verification_failed')) + } + return Promise.resolve({}) + }) + const { result } = renderHook(() => useHoloLakeAccount()) + await waitFor(() => expect(result.current.state.phase).toBe('signed-out')) + + await act(() => result.current.requestCode('owner@example.invalid')) + await act(() => result.current.verifyCode('000000')) + + expect(result.current.state.phase).toBe('code-sent') + expect(result.current.state.requestId).toBe('request-001') + expect(result.current.state.error).toBe('hololake_login_verification_failed') + }) + + it('keeps an authenticated session usable when knowledge sync fails', async () => { + invokeMock.mockImplementation((command: string) => { + if (command === 'hololake_account_status') { + return Promise.resolve({ state: 'authenticated', expires_at: 1_800_086_400 }) + } + if (command === 'hololake_sync_knowledge') { + return Promise.reject(new Error('hololake_knowledge_archive_failed')) + } + return Promise.resolve({}) + }) + const { result } = renderHook(() => useHoloLakeAccount()) + await waitFor(() => expect(result.current.state.phase).toBe('authenticated')) + + await act(() => result.current.syncKnowledge()) + + expect(result.current.state.phase).toBe('authenticated') + expect(result.current.state.error).toBe('hololake_knowledge_archive_failed') + }) + + it('stays signed out in the browser preview without calling native commands', async () => { + isTauriMock.mockReturnValue(false) + const { result } = renderHook(() => useHoloLakeAccount()) + await waitFor(() => expect(result.current.state.phase).toBe('signed-out')) + expect(invokeMock).not.toHaveBeenCalled() + }) +}) diff --git a/product-source/hololake-platform/src/hooks/useHoloLakeAccount.ts b/product-source/hololake-platform/src/hooks/useHoloLakeAccount.ts new file mode 100644 index 0000000..c13e308 --- /dev/null +++ b/product-source/hololake-platform/src/hooks/useHoloLakeAccount.ts @@ -0,0 +1,140 @@ +import { invoke } from '@tauri-apps/api/core' +import { useCallback, useEffect, useState } from 'react' +import { trackEvent } from '../lib/telemetry' +import { isTauri } from '../mock-tauri' + +type AccountPhase = 'checking' | 'signed-out' | 'code-sent' | 'authenticated' | 'busy' | 'error' + +type AccountState = { + error: string | null + expiresAt: number | null + lastCommit: string | null + phase: AccountPhase + requestId: string | null +} + +type NativeAccountStatus = { + expires_at?: number + state?: 'authenticated' | 'signed_out' +} + +type NativeCodeRequest = { + request_id?: string +} + +type NativeKnowledgeReceipt = { + commit?: string +} + +const initialState: AccountState = { + error: null, + expiresAt: null, + lastCommit: null, + phase: 'checking', + requestId: null, +} + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + +export function useHoloLakeAccount() { + const [state, setState] = useState(initialState) + + const refresh = useCallback(async () => { + if (!isTauri()) { + setState((current) => ({ ...current, phase: 'signed-out' })) + return + } + try { + const status = await invoke('hololake_account_status') + setState((current) => ({ + ...current, + error: null, + expiresAt: status.expires_at ?? null, + phase: status.state === 'authenticated' ? 'authenticated' : 'signed-out', + })) + } catch (error) { + setState((current) => ({ ...current, error: errorText(error), phase: 'signed-out' })) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const requestCode = useCallback(async (email: string) => { + setState((current) => ({ ...current, error: null, phase: 'busy' })) + trackEvent('hololake_account_code_requested', { surface: 'account-panel' }) + try { + const result = await invoke('hololake_account_request_email_code', { email }) + if (!result.request_id) throw new Error('hololake_login_request_id_missing') + setState((current) => ({ + ...current, + phase: 'code-sent', + requestId: result.request_id ?? null, + })) + } catch (error) { + setState((current) => ({ ...current, error: errorText(error), phase: 'error' })) + } + }, []) + + const verifyCode = useCallback(async (code: string) => { + if (!state.requestId) return + setState((current) => ({ ...current, error: null, phase: 'busy' })) + try { + const result = await invoke('hololake_account_verify_email_code', { + requestId: state.requestId, + code, + }) + trackEvent('hololake_account_login_completed', { result: 'authenticated' }) + setState((current) => ({ + ...current, + expiresAt: result.expires_at ?? null, + phase: 'authenticated', + requestId: null, + })) + } catch (error) { + trackEvent('hololake_account_login_completed', { result: 'error' }) + setState((current) => ({ ...current, error: errorText(error), phase: 'code-sent' })) + } + }, [state.requestId]) + + const syncKnowledge = useCallback(async () => { + setState((current) => ({ ...current, error: null, phase: 'busy' })) + trackEvent('hololake_knowledge_sync_requested', { repository: 'hololake-knowledge-base' }) + try { + const result = await invoke('hololake_sync_knowledge') + trackEvent('hololake_knowledge_sync_completed', { result: 'success' }) + setState((current) => ({ + ...current, + lastCommit: result.commit ?? null, + phase: 'authenticated', + })) + } catch (error) { + trackEvent('hololake_knowledge_sync_completed', { result: 'error' }) + setState((current) => ({ ...current, error: errorText(error), phase: 'authenticated' })) + } + }, []) + + const logout = useCallback(async () => { + setState((current) => ({ ...current, error: null, phase: 'busy' })) + try { + await invoke('hololake_account_logout') + trackEvent('hololake_account_logout_completed') + setState(initialState) + await refresh() + } catch (error) { + setState((current) => ({ ...current, error: errorText(error), phase: 'authenticated' })) + } + }, [refresh]) + + return { + logout, + requestCode, + state, + syncKnowledge, + verifyCode, + } +} diff --git a/product-source/hololake-platform/src/hooks/useOnboarding.ts b/product-source/hololake-platform/src/hooks/useOnboarding.ts index 1c0e059..5683aab 100644 --- a/product-source/hololake-platform/src/hooks/useOnboarding.ts +++ b/product-source/hololake-platform/src/hooks/useOnboarding.ts @@ -8,6 +8,8 @@ import { labelFromPath, } from '../utils/gettingStartedVault' import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog' +import { isMobilePlatform } from '../utils/platform' +import { trackEvent } from '../lib/telemetry' type OnboardingState = | { status: 'loading' } @@ -16,7 +18,7 @@ type OnboardingState = | { status: 'ready'; vaultPath: string } type CreatingAction = 'template' | 'empty' | null -type ReadyVaultSource = 'template' | 'empty' | 'existing' +type ReadyVaultSource = 'template' | 'empty' | 'existing' | 'world' | 'personal' type OnVaultReady = (vaultPath: string, source: ReadyVaultSource) => void type RegisterVault = ( vaultPath: string, @@ -51,6 +53,11 @@ interface CreateEmptyVaultHandlerOptions extends ReadyVaultHandlerOptions { setCreatingAction: SetCreatingAction } +interface MobileKnowledgeHandlerOptions extends CreateEmptyVaultHandlerOptions { + command: 'open_mobile_foundation_knowledge' | 'open_mobile_personal_knowledge' + source: Extract +} + function tauriCall(command: string, args: Record): Promise { return isTauri() ? invoke(command, args) : mockInvoke(command, args) } @@ -263,6 +270,35 @@ function useOpenFolderHandler( }, [options]) } +function useMobileKnowledgeHandler(options: MobileKnowledgeHandlerOptions) { + return useCallback(async () => { + options.setCreatingAction(options.source === 'world' ? 'template' : 'empty') + options.setError(null) + + try { + if (options.source === 'personal') { + const foundationPath = await tauriCall('open_mobile_foundation_knowledge', {}) + await registerVaultSelection(options.registerVault, foundationPath, { verifyAvailability: false }) + } + const knowledgePath = await tauriCall(options.command, {}) + await registerVaultSelection(options.registerVault, knowledgePath, { verifyAvailability: false }) + markVaultReady(options.setState, knowledgePath) + options.onVaultReady?.(knowledgePath, options.source) + trackEvent('mobile_native_knowledge_opened', { knowledge_kind: options.source }) + } catch (err) { + options.setError(formatOnboardingRegistrationError({ + action: options.source === 'world' + ? 'Could not open the HoloLake foundation world' + : 'Could not create your local HoloLake knowledge', + err, + })) + trackEvent('mobile_native_knowledge_open_failed', { knowledge_kind: options.source }) + } finally { + options.setCreatingAction(null) + } + }, [options]) +} + export function useOnboarding( initialVaultPath: string, options: OnboardingOptions = {}, @@ -321,14 +357,14 @@ export function useOnboarding( onVaultReady: options.onVaultReady, }) - const handleCreateVault = useCreateVaultHandler(createTemplateVault, setError) + const handleCreateDesktopVault = useCreateVaultHandler(createTemplateVault, setError) const retryCreateVault = useCallback(async () => { if (!lastTemplatePath) return await createTemplateVault(lastTemplatePath) }, [createTemplateVault, lastTemplatePath]) - const handleCreateEmptyVault = useCreateEmptyVaultHandler({ + const handleCreateDesktopEmptyVault = useCreateEmptyVaultHandler({ onVaultReady: options.onVaultReady, registerVault: options.registerVault, setCreatingAction, @@ -343,6 +379,42 @@ export function useOnboarding( setState, }) + const openMobileWorldKnowledge = useMobileKnowledgeHandler({ + command: 'open_mobile_foundation_knowledge', + onVaultReady: options.onVaultReady, + registerVault: options.registerVault, + setCreatingAction, + setError, + setState, + source: 'world', + }) + + const openMobilePersonalKnowledge = useMobileKnowledgeHandler({ + command: 'open_mobile_personal_knowledge', + onVaultReady: options.onVaultReady, + registerVault: options.registerVault, + setCreatingAction, + setError, + setState, + source: 'personal', + }) + + const handleCreateVault = useCallback(async () => { + if (isMobilePlatform()) { + await openMobileWorldKnowledge() + return + } + await handleCreateDesktopVault() + }, [handleCreateDesktopVault, openMobileWorldKnowledge]) + + const handleCreateEmptyVault = useCallback(async () => { + if (isMobilePlatform()) { + await openMobilePersonalKnowledge() + return + } + await handleCreateDesktopEmptyVault() + }, [handleCreateDesktopEmptyVault, openMobilePersonalKnowledge]) + const handleDismiss = useCallback(() => { markDismissed() setState({ status: 'ready', vaultPath: initialVaultPath }) diff --git a/product-source/hololake-platform/src/lib/locales/be-BY.json b/product-source/hololake-platform/src/lib/locales/be-BY.json index 9d58eb7..79ff26a 100644 --- a/product-source/hololake-platform/src/lib/locales/be-BY.json +++ b/product-source/hololake-platform/src/lib/locales/be-BY.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/be-Latn.json b/product-source/hololake-platform/src/lib/locales/be-Latn.json index 710487f..2ddee48 100644 --- a/product-source/hololake-platform/src/lib/locales/be-Latn.json +++ b/product-source/hololake-platform/src/lib/locales/be-Latn.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/de-DE.json b/product-source/hololake-platform/src/lib/locales/de-DE.json index c8bc01c..43fede2 100644 --- a/product-source/hololake-platform/src/lib/locales/de-DE.json +++ b/product-source/hololake-platform/src/lib/locales/de-DE.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/en.json b/product-source/hololake-platform/src/lib/locales/en.json index 5289e18..6fff087 100644 --- a/product-source/hololake-platform/src/lib/locales/en.json +++ b/product-source/hololake-platform/src/lib/locales/en.json @@ -92,11 +92,30 @@ "onboarding.welcome.createEmptyDescription": "Start fresh in an empty folder with HoloLake Era defaults", "onboarding.welcome.createEmptyLoading": "Creating vault...", "onboarding.welcome.createEmptyLoadingDescription": "Preparing HoloLake Era defaults in the selected folder", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device", "onboarding.welcome.openExisting": "Open existing vault", "onboarding.welcome.openExistingDescription": "Point to a folder you already have", "onboarding.welcome.retryDownload": "Retry download", "onboarding.welcome.docsPrompt": "New to HoloLake Era?", "onboarding.welcome.docsLink": "Read the first-launch guide", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", "onboarding.ai.checkingTitle": "Checking AI setup", "onboarding.ai.checkingDescription": "Looking for local AI agents on this machine.", "onboarding.ai.missingTitle": "AI setup is optional", diff --git a/product-source/hololake-platform/src/lib/locales/es-419.json b/product-source/hololake-platform/src/lib/locales/es-419.json index 4bd663f..bba9c34 100644 --- a/product-source/hololake-platform/src/lib/locales/es-419.json +++ b/product-source/hololake-platform/src/lib/locales/es-419.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/es-ES.json b/product-source/hololake-platform/src/lib/locales/es-ES.json index 37aee57..9cd384b 100644 --- a/product-source/hololake-platform/src/lib/locales/es-ES.json +++ b/product-source/hololake-platform/src/lib/locales/es-ES.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/fr-FR.json b/product-source/hololake-platform/src/lib/locales/fr-FR.json index c5eee5b..eb12c23 100644 --- a/product-source/hololake-platform/src/lib/locales/fr-FR.json +++ b/product-source/hololake-platform/src/lib/locales/fr-FR.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/id-ID.json b/product-source/hololake-platform/src/lib/locales/id-ID.json index 626bfa8..a12bb7b 100644 --- a/product-source/hololake-platform/src/lib/locales/id-ID.json +++ b/product-source/hololake-platform/src/lib/locales/id-ID.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/it-IT.json b/product-source/hololake-platform/src/lib/locales/it-IT.json index 03778eb..34c70b1 100644 --- a/product-source/hololake-platform/src/lib/locales/it-IT.json +++ b/product-source/hololake-platform/src/lib/locales/it-IT.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/ja-JP.json b/product-source/hololake-platform/src/lib/locales/ja-JP.json index 0051b43..6d2554e 100644 --- a/product-source/hololake-platform/src/lib/locales/ja-JP.json +++ b/product-source/hololake-platform/src/lib/locales/ja-JP.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/ko-KR.json b/product-source/hololake-platform/src/lib/locales/ko-KR.json index a37e875..983424e 100644 --- a/product-source/hololake-platform/src/lib/locales/ko-KR.json +++ b/product-source/hololake-platform/src/lib/locales/ko-KR.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/pl-PL.json b/product-source/hololake-platform/src/lib/locales/pl-PL.json index 5cf8e18..5c2a826 100644 --- a/product-source/hololake-platform/src/lib/locales/pl-PL.json +++ b/product-source/hololake-platform/src/lib/locales/pl-PL.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/pt-BR.json b/product-source/hololake-platform/src/lib/locales/pt-BR.json index 3d16c56..33cce9b 100644 --- a/product-source/hololake-platform/src/lib/locales/pt-BR.json +++ b/product-source/hololake-platform/src/lib/locales/pt-BR.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/pt-PT.json b/product-source/hololake-platform/src/lib/locales/pt-PT.json index 4159252..3810577 100644 --- a/product-source/hololake-platform/src/lib/locales/pt-PT.json +++ b/product-source/hololake-platform/src/lib/locales/pt-PT.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/ru-RU.json b/product-source/hololake-platform/src/lib/locales/ru-RU.json index b54a4c9..ee6a3cc 100644 --- a/product-source/hololake-platform/src/lib/locales/ru-RU.json +++ b/product-source/hololake-platform/src/lib/locales/ru-RU.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/sk-SK.json b/product-source/hololake-platform/src/lib/locales/sk-SK.json index eb106a9..8c0ec6d 100644 --- a/product-source/hololake-platform/src/lib/locales/sk-SK.json +++ b/product-source/hololake-platform/src/lib/locales/sk-SK.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/sv-SE.json b/product-source/hololake-platform/src/lib/locales/sv-SE.json index d6393c9..8975f54 100644 --- a/product-source/hololake-platform/src/lib/locales/sv-SE.json +++ b/product-source/hololake-platform/src/lib/locales/sv-SE.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/uk-UA.json b/product-source/hololake-platform/src/lib/locales/uk-UA.json index 6d3cb16..718d7cb 100644 --- a/product-source/hololake-platform/src/lib/locales/uk-UA.json +++ b/product-source/hololake-platform/src/lib/locales/uk-UA.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/vi.json b/product-source/hololake-platform/src/lib/locales/vi.json index a8c7b5a..93dd810 100644 --- a/product-source/hololake-platform/src/lib/locales/vi.json +++ b/product-source/hololake-platform/src/lib/locales/vi.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/lib/locales/zh-CN.json b/product-source/hololake-platform/src/lib/locales/zh-CN.json index b398aea..bf2ae73 100644 --- a/product-source/hololake-platform/src/lib/locales/zh-CN.json +++ b/product-source/hololake-platform/src/lib/locales/zh-CN.json @@ -92,11 +92,30 @@ "onboarding.welcome.createEmptyDescription": "使用 HoloLake Era 默认设置,在空文件夹中重新开始", "onboarding.welcome.createEmptyLoading": "正在创建 Vault...", "onboarding.welcome.createEmptyLoadingDescription": "正在所选文件夹中准备 HoloLake Era 默认设置", + "onboarding.welcome.mobileWorldTitle": "进入光湖基础世界", + "onboarding.welcome.mobileWorldDescription": "打开内置的共同语言世界基础", + "onboarding.welcome.mobilePersonalTitle": "建立我的本地知识湖", + "onboarding.welcome.mobilePersonalDescription": "在当前手机内建立私人知识湖", "onboarding.welcome.openExisting": "打开现有 Vault", "onboarding.welcome.openExistingDescription": "指向您已有的文件夹", "onboarding.welcome.retryDownload": "重试下载", "onboarding.welcome.docsPrompt": "HoloLake Era 新手?", "onboarding.welcome.docsLink": "阅读首次启动指南", + "hololake.account.title": "光湖账号", + "hololake.account.description": "使用邮箱验证码登录并同步共同知识世界。手机 API 密钥只保存在当前设备钥匙串。", + "hololake.account.email": "邮箱", + "hololake.account.code": "验证码", + "hololake.account.sendCode": "发送验证码", + "hololake.account.sending": "发送中...", + "hololake.account.codeSent": "请输入邮件中的六位验证码。", + "hololake.account.verify": "验证并登录", + "hololake.account.signedIn": "当前设备已登录。", + "hololake.account.serverKeys": "服务器模型凭据可直接使用但不会显示;手机密钥永不同步。", + "hololake.account.sync": "同步知识库", + "hololake.account.syncing": "同步中...", + "hololake.account.syncComplete": "已同步仓库提交", + "hololake.account.signOut": "退出登录", + "hololake.account.error": "账号操作失败:", "onboarding.ai.checkingTitle": "正在检查 AI 设置", "onboarding.ai.checkingDescription": "正在本机上查找本地 AI 代理。", "onboarding.ai.missingTitle": "AI 设置为可选项", diff --git a/product-source/hololake-platform/src/lib/locales/zh-TW.json b/product-source/hololake-platform/src/lib/locales/zh-TW.json index fb0195d..3c1dc42 100644 --- a/product-source/hololake-platform/src/lib/locales/zh-TW.json +++ b/product-source/hololake-platform/src/lib/locales/zh-TW.json @@ -1121,5 +1121,24 @@ "hololake.login.errorTitle": "The world entrance did not open", "hololake.login.retry": "Try again", "hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.", - "hololake.login.nodeReady": "Fifth Domain world entrance ready" + "hololake.login.nodeReady": "Fifth Domain world entrance ready", + "hololake.account.title": "HoloLake account", + "hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.", + "hololake.account.email": "Email", + "hololake.account.code": "Verification code", + "hololake.account.sendCode": "Send code", + "hololake.account.sending": "Sending...", + "hololake.account.codeSent": "Enter the six-digit code from your email.", + "hololake.account.verify": "Verify and sign in", + "hololake.account.signedIn": "This device is signed in.", + "hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.", + "hololake.account.sync": "Sync knowledge", + "hololake.account.syncing": "Syncing...", + "hololake.account.syncComplete": "Synced repository commit", + "hololake.account.signOut": "Sign out", + "hololake.account.error": "Account operation failed:", + "onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world", + "onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation", + "onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake", + "onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device" } diff --git a/product-source/hololake-platform/src/utils/platform.ts b/product-source/hololake-platform/src/utils/platform.ts index ee4d92c..dd5316a 100644 --- a/product-source/hololake-platform/src/utils/platform.ts +++ b/product-source/hololake-platform/src/utils/platform.ts @@ -21,6 +21,10 @@ export function isWindows(): boolean { return getUserAgent().includes('Windows') } +export function isMobilePlatform(): boolean { + return /Android|iPad|iPhone|iPod/u.test(getUserAgent()) +} + export function shouldUseCustomWindowChrome(): boolean { return isTauri() && (isLinux() || isWindows()) } diff --git a/product-source/hololake-platform/vite.config.ts b/product-source/hololake-platform/vite.config.ts index fdb55a8..dfd53ae 100644 --- a/product-source/hololake-platform/vite.config.ts +++ b/product-source/hololake-platform/vite.config.ts @@ -983,6 +983,14 @@ export default defineConfig({ port: 5202, strictPort: true, allowedHosts: true, + fs: process.env.HOLOLAKE_TEST_DEPENDENCY_ROOT + ? { + allow: [ + __dirname, + path.resolve(process.env.HOLOLAKE_TEST_DEPENDENCY_ROOT), + ], + } + : undefined, watch: { ignored: devServerWatchIgnored, },