feat(ios): add native Git knowledge and device-only secrets

This commit is contained in:
冰朔 2026-08-03 22:27:09 +08:00
commit b30f814320
51 changed files with 3019 additions and 97 deletions

View file

@ -1135,6 +1135,35 @@ server deployment.
## Updates & Feature Flags ## 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 ### 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. - **`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_<name>`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union. - **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union.

View file

@ -1,6 +1,10 @@
# Architecture # 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 ## 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. 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 ### Three representations, one authority
Vault data exists in three forms simultaneously: Vault data exists in three forms simultaneously:

View file

@ -1,7 +1,7 @@
# HoloLake Era iPhone / TestFlight 承接记录 # HoloLake Era iPhone / TestFlight 承接记录
状态日期:2026-07-19 状态日期:2026-08-03
当前版本:0.1.7 当前版本:0.4.6
Bundle ID:`com.guanghulab.hololake` 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 对齐。私人设备标识不得写入仓库。 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 包内预检确认不再包含该静态库。 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 包内预检确认不再包含该静态库。

View file

@ -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.

View file

@ -220,3 +220,6 @@ proposed → active → superseded
| [0167](0167-gestational-history-continuity-ingestion.md) | Gestational history enters Guanghu through a native continuity protocol | active | | [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 | | [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 | | [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 |

View file

@ -1885,6 +1885,8 @@ dependencies = [
"regex", "regex",
"reqwest 0.12.28", "reqwest 0.12.28",
"ring", "ring",
"security-framework",
"security-framework-sys",
"sentry", "sentry",
"serde", "serde",
"serde_json", "serde_json",
@ -1903,6 +1905,7 @@ dependencies = [
"tokio", "tokio",
"uuid", "uuid",
"walkdir", "walkdir",
"zip 0.6.6",
] ]
[[package]] [[package]]
@ -5349,7 +5352,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"zip", "zip 4.6.1",
] ]
[[package]] [[package]]
@ -7268,6 +7271,18 @@ dependencies = [
"syn 2.0.115", "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]] [[package]]
name = "zip" name = "zip"
version = "4.6.1" version = "4.6.1"

View file

@ -52,11 +52,18 @@ quick-xml = { version = "0.38", features = ["serialize"] }
tauri-plugin-deep-link = "2.4.9" tauri-plugin-deep-link = "2.4.9"
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
ring = "0.17" ring = "0.17"
zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.3" objc2 = "0.6.3"
objc2-app-kit = "0.3.2" objc2-app-kit = "0.3.2"
objc2-foundation = "0.3.2" objc2-foundation = "0.3.2"
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] } 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] [dev-dependencies]

View file

@ -66,6 +66,7 @@
47E641E8D831982238FCF5D4 /* hermes_cli.rs */ = {isa = PBXFileReference; path = hermes_cli.rs; sourceTree = "<group>"; }; 47E641E8D831982238FCF5D4 /* hermes_cli.rs */ = {isa = PBXFileReference; path = hermes_cli.rs; sourceTree = "<group>"; };
47EDAF686EACF168E90E00C9 /* paths.rs */ = {isa = PBXFileReference; path = paths.rs; sourceTree = "<group>"; }; 47EDAF686EACF168E90E00C9 /* paths.rs */ = {isa = PBXFileReference; path = paths.rs; sourceTree = "<group>"; };
497C22F10C21FCB4CF1978A0 /* line_stream.rs */ = {isa = PBXFileReference; path = line_stream.rs; sourceTree = "<group>"; }; 497C22F10C21FCB4CF1978A0 /* line_stream.rs */ = {isa = PBXFileReference; path = line_stream.rs; sourceTree = "<group>"; };
4CC25FAC04523FC1407E890B /* guanghu_world_login.rs */ = {isa = PBXFileReference; path = guanghu_world_login.rs; sourceTree = "<group>"; };
4E4BBC1CC65B04620FE5ACF7 /* memory.rs */ = {isa = PBXFileReference; path = memory.rs; sourceTree = "<group>"; }; 4E4BBC1CC65B04620FE5ACF7 /* memory.rs */ = {isa = PBXFileReference; path = memory.rs; sourceTree = "<group>"; };
515B73736D1F5EA458FF4CBF /* claude_invocation.rs */ = {isa = PBXFileReference; path = claude_invocation.rs; sourceTree = "<group>"; }; 515B73736D1F5EA458FF4CBF /* claude_invocation.rs */ = {isa = PBXFileReference; path = claude_invocation.rs; sourceTree = "<group>"; };
53E80AB4F48A6E53B2338B4B /* ai_model_tools.rs */ = {isa = PBXFileReference; path = ai_model_tools.rs; sourceTree = "<group>"; }; 53E80AB4F48A6E53B2338B4B /* ai_model_tools.rs */ = {isa = PBXFileReference; path = ai_model_tools.rs; sourceTree = "<group>"; };
@ -89,6 +90,7 @@
7599B204D83DCC8DC75458B1 /* frontmatter_regression_tests.rs */ = {isa = PBXFileReference; path = frontmatter_regression_tests.rs; sourceTree = "<group>"; }; 7599B204D83DCC8DC75458B1 /* frontmatter_regression_tests.rs */ = {isa = PBXFileReference; path = frontmatter_regression_tests.rs; sourceTree = "<group>"; };
7876AB1696C6A473C0CA60DD /* dates.rs */ = {isa = PBXFileReference; path = dates.rs; sourceTree = "<group>"; }; 7876AB1696C6A473C0CA60DD /* dates.rs */ = {isa = PBXFileReference; path = dates.rs; sourceTree = "<group>"; };
79E99FECA35482A58E9929E8 /* upstream.rs */ = {isa = PBXFileReference; path = upstream.rs; sourceTree = "<group>"; }; 79E99FECA35482A58E9929E8 /* upstream.rs */ = {isa = PBXFileReference; path = upstream.rs; sourceTree = "<group>"; };
7DE25EB5FAB7CE0ACBD80ABB /* guanghu_router.rs */ = {isa = PBXFileReference; path = guanghu_router.rs; sourceTree = "<group>"; };
7FEB8ACB3EFE38A9C2273981 /* remote.rs */ = {isa = PBXFileReference; path = remote.rs; sourceTree = "<group>"; }; 7FEB8ACB3EFE38A9C2273981 /* remote.rs */ = {isa = PBXFileReference; path = remote.rs; sourceTree = "<group>"; };
814BEE45F3957BA070B8A6FE /* keys.rs */ = {isa = PBXFileReference; path = keys.rs; sourceTree = "<group>"; }; 814BEE45F3957BA070B8A6FE /* keys.rs */ = {isa = PBXFileReference; path = keys.rs; sourceTree = "<group>"; };
83CCD3A86C61E070EB9C029B /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = "<group>"; }; 83CCD3A86C61E070EB9C029B /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = "<group>"; };
@ -106,6 +108,7 @@
960E88B1AC470035ADEC0DD8 /* mcp_config.rs */ = {isa = PBXFileReference; path = mcp_config.rs; sourceTree = "<group>"; }; 960E88B1AC470035ADEC0DD8 /* mcp_config.rs */ = {isa = PBXFileReference; path = mcp_config.rs; sourceTree = "<group>"; };
975BD8B6821A8FA0356DFE31 /* hololake_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = hololake_iOS.entitlements; sourceTree = "<group>"; }; 975BD8B6821A8FA0356DFE31 /* hololake_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = hololake_iOS.entitlements; sourceTree = "<group>"; };
98555F139A6093466C0DD80E /* extraction.rs */ = {isa = PBXFileReference; path = extraction.rs; sourceTree = "<group>"; }; 98555F139A6093466C0DD80E /* extraction.rs */ = {isa = PBXFileReference; path = extraction.rs; sourceTree = "<group>"; };
986A5C5A5D4E1DE0DB1DB3D9 /* hololake_account.rs */ = {isa = PBXFileReference; path = hololake_account.rs; sourceTree = "<group>"; };
9935075CB2F77C7FF5DEC9CD /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; 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 = "<group>"; }; 99B3DA704DB835E530007E84 /* antigravity_config.rs */ = {isa = PBXFileReference; path = antigravity_config.rs; sourceTree = "<group>"; };
9C5AB398816652B7DA146489 /* windows_cmd_shim.rs */ = {isa = PBXFileReference; path = windows_cmd_shim.rs; sourceTree = "<group>"; }; 9C5AB398816652B7DA146489 /* windows_cmd_shim.rs */ = {isa = PBXFileReference; path = windows_cmd_shim.rs; sourceTree = "<group>"; };
@ -123,9 +126,11 @@
B0B4AE2937945F1896E1B205 /* mod_tests.rs */ = {isa = PBXFileReference; path = mod_tests.rs; sourceTree = "<group>"; }; B0B4AE2937945F1896E1B205 /* mod_tests.rs */ = {isa = PBXFileReference; path = mod_tests.rs; sourceTree = "<group>"; };
B37489EAD8111AF691B4E56D /* opencode_config.rs */ = {isa = PBXFileReference; path = opencode_config.rs; sourceTree = "<group>"; }; B37489EAD8111AF691B4E56D /* opencode_config.rs */ = {isa = PBXFileReference; path = opencode_config.rs; sourceTree = "<group>"; };
B377452F2570C8D916B24737 /* rename.rs */ = {isa = PBXFileReference; path = rename.rs; sourceTree = "<group>"; }; B377452F2570C8D916B24737 /* rename.rs */ = {isa = PBXFileReference; path = rename.rs; sourceTree = "<group>"; };
B594CFA2AE6E07F7EEBDCA5F /* guanghu_living_system.rs */ = {isa = PBXFileReference; path = guanghu_living_system.rs; sourceTree = "<group>"; };
B7223A7822564745FD4B7CDD /* app_icon.rs */ = {isa = PBXFileReference; path = app_icon.rs; sourceTree = "<group>"; }; B7223A7822564745FD4B7CDD /* app_icon.rs */ = {isa = PBXFileReference; path = app_icon.rs; sourceTree = "<group>"; };
B7436FF982457F75FD04F370 /* ai.rs */ = {isa = PBXFileReference; path = ai.rs; sourceTree = "<group>"; }; B7436FF982457F75FD04F370 /* ai.rs */ = {isa = PBXFileReference; path = ai.rs; sourceTree = "<group>"; };
B7EEF25B97EA865DC2F2B16F /* app_icon.rs */ = {isa = PBXFileReference; path = app_icon.rs; sourceTree = "<group>"; }; B7EEF25B97EA865DC2F2B16F /* app_icon.rs */ = {isa = PBXFileReference; path = app_icon.rs; sourceTree = "<group>"; };
B873D19C73F8DB13E8418612 /* guanghu_shanghai_node.rs */ = {isa = PBXFileReference; path = guanghu_shanghai_node.rs; sourceTree = "<group>"; };
BA9780D7C82253E700210AD1 /* pi_cli.rs */ = {isa = PBXFileReference; path = pi_cli.rs; sourceTree = "<group>"; }; BA9780D7C82253E700210AD1 /* pi_cli.rs */ = {isa = PBXFileReference; path = pi_cli.rs; sourceTree = "<group>"; };
BB2C976547D1CF4F9CEDE8E1 /* antigravity_discovery.rs */ = {isa = PBXFileReference; path = antigravity_discovery.rs; sourceTree = "<group>"; }; BB2C976547D1CF4F9CEDE8E1 /* antigravity_discovery.rs */ = {isa = PBXFileReference; path = antigravity_discovery.rs; sourceTree = "<group>"; };
BB68BFCC620A099FBDFE5234 /* folders.rs */ = {isa = PBXFileReference; path = folders.rs; sourceTree = "<group>"; }; BB68BFCC620A099FBDFE5234 /* folders.rs */ = {isa = PBXFileReference; path = folders.rs; sourceTree = "<group>"; };
@ -162,11 +167,14 @@
E502EAEA6030504F91B1B6B8 /* command.rs */ = {isa = PBXFileReference; path = command.rs; sourceTree = "<group>"; }; E502EAEA6030504F91B1B6B8 /* command.rs */ = {isa = PBXFileReference; path = command.rs; sourceTree = "<group>"; };
E7E43D90B97E284B2F6A717D /* frontmatter.rs */ = {isa = PBXFileReference; path = frontmatter.rs; sourceTree = "<group>"; }; E7E43D90B97E284B2F6A717D /* frontmatter.rs */ = {isa = PBXFileReference; path = frontmatter.rs; sourceTree = "<group>"; };
E8A91F6EDFE1DF22A75BAC5B /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 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 = "<group>"; };
EE36F56C627BC172C7CCA065 /* pi_events.rs */ = {isa = PBXFileReference; path = pi_events.rs; sourceTree = "<group>"; }; EE36F56C627BC172C7CCA065 /* pi_events.rs */ = {isa = PBXFileReference; path = pi_events.rs; sourceTree = "<group>"; };
EEA7BC2EB818A793D68CECA1 /* frontmatter_cmds.rs */ = {isa = PBXFileReference; path = frontmatter_cmds.rs; sourceTree = "<group>"; }; EEA7BC2EB818A793D68CECA1 /* frontmatter_cmds.rs */ = {isa = PBXFileReference; path = frontmatter_cmds.rs; sourceTree = "<group>"; };
EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */ = {isa = PBXFileReference; path = modified_dates_tests.rs; sourceTree = "<group>"; }; EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */ = {isa = PBXFileReference; path = modified_dates_tests.rs; sourceTree = "<group>"; };
F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */ = {isa = PBXFileReference; path = kiro_discovery.rs; sourceTree = "<group>"; }; F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */ = {isa = PBXFileReference; path = kiro_discovery.rs; sourceTree = "<group>"; };
F1238B11A81DABC7B28747B9 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = "<group>"; }; F1238B11A81DABC7B28747B9 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = "<group>"; };
F2D9E48A1998AD5837EBE8C5 /* guanghu_enterprise.rs */ = {isa = PBXFileReference; path = guanghu_enterprise.rs; sourceTree = "<group>"; };
F5DC0A0680C3D852550B4558 /* hldp_runtime.rs */ = {isa = PBXFileReference; path = hldp_runtime.rs; sourceTree = "<group>"; };
F7D0E6E67B410BBCCF64F197 /* file_url.rs */ = {isa = PBXFileReference; path = file_url.rs; sourceTree = "<group>"; }; F7D0E6E67B410BBCCF64F197 /* file_url.rs */ = {isa = PBXFileReference; path = file_url.rs; sourceTree = "<group>"; };
F86E7BC210B3629F404B302E /* basics.rs */ = {isa = PBXFileReference; path = basics.rs; sourceTree = "<group>"; }; F86E7BC210B3629F404B302E /* basics.rs */ = {isa = PBXFileReference; path = basics.rs; sourceTree = "<group>"; };
F920B9501682227EB4C5580B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; F920B9501682227EB4C5580B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -176,6 +184,7 @@
FBB7A51D7CCC05D3348D1190 /* vault_list.rs */ = {isa = PBXFileReference; path = vault_list.rs; sourceTree = "<group>"; }; FBB7A51D7CCC05D3348D1190 /* vault_list.rs */ = {isa = PBXFileReference; path = vault_list.rs; sourceTree = "<group>"; };
FBD91C23BBFFCA204E30DF3C /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; }; FBD91C23BBFFCA204E30DF3C /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
FE4F7CE296F76FBE49B288EE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; FE4F7CE296F76FBE49B288EE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
FEE79A2B08D489C84E5C130A /* device_secret_store.rs */ = {isa = PBXFileReference; path = device_secret_store.rs; sourceTree = "<group>"; };
FF3044079CC906291942C508 /* codex_cli.rs */ = {isa = PBXFileReference; path = codex_cli.rs; sourceTree = "<group>"; }; FF3044079CC906291942C508 /* codex_cli.rs */ = {isa = PBXFileReference; path = codex_cli.rs; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -221,6 +230,7 @@
2B938E7363F949A622DD8397 /* ignored.rs */, 2B938E7363F949A622DD8397 /* ignored.rs */,
E19D7FEEBC114189FCF0F84A /* image.rs */, E19D7FEEBC114189FCF0F84A /* image.rs */,
65A73B64D88239A170CF299A /* migration.rs */, 65A73B64D88239A170CF299A /* migration.rs */,
EBFA0B7CAB8661AB3605599A /* mobile_vault.rs */,
B0B4AE2937945F1896E1B205 /* mod_tests.rs */, B0B4AE2937945F1896E1B205 /* mod_tests.rs */,
83CCD3A86C61E070EB9C029B /* mod.rs */, 83CCD3A86C61E070EB9C029B /* mod.rs */,
EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */, EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */,
@ -370,8 +380,16 @@
FF3044079CC906291942C508 /* codex_cli.rs */, FF3044079CC906291942C508 /* codex_cli.rs */,
BEE7A0C1F8EE18EDAB31905A /* copilot_cli.rs */, BEE7A0C1F8EE18EDAB31905A /* copilot_cli.rs */,
282A3A834C97BA5453CC9355 /* copilot_discovery.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 */, 47E641E8D831982238FCF5D4 /* hermes_cli.rs */,
A0B9AEB056A7B2713B0EF52A /* hermes_discovery.rs */, A0B9AEB056A7B2713B0EF52A /* hermes_discovery.rs */,
F5DC0A0680C3D852550B4558 /* hldp_runtime.rs */,
986A5C5A5D4E1DE0DB1DB3D9 /* hololake_account.rs */,
1CEF6C06C1287DD4AE2F9B51 /* kiro_cli.rs */, 1CEF6C06C1287DD4AE2F9B51 /* kiro_cli.rs */,
F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */, F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */,
8969FA0D7729542C8FE8ED93 /* lib.rs */, 8969FA0D7729542C8FE8ED93 /* lib.rs */,

View file

@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.1.7</string> <string>0.4.6</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20</string> <string>26</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

View file

@ -51,8 +51,8 @@ targets:
- UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationLandscapeRight
CFBundleDisplayName: HoloLake CFBundleDisplayName: HoloLake
CFBundleShortVersionString: 0.1.7 CFBundleShortVersionString: 0.4.6
CFBundleVersion: "20" CFBundleVersion: "26"
entitlements: entitlements:
path: hololake_iOS/hololake_iOS.entitlements path: hololake_iOS/hololake_iOS.entitlements
scheme: scheme:

View file

@ -1060,8 +1060,24 @@ fn edit_note_from_tool_args(
let raw_path = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "path")?; let raw_path = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "path")?;
let content = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "content")?; let content = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "content")?;
let path = resolve_existing_vault_note(request, raw_path)?; 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)?; 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( fn delete_note_from_tool_args(
@ -1070,6 +1086,15 @@ fn delete_note_from_tool_args(
) -> Result<String, String> { ) -> Result<String, String> {
let raw_path = required_tool_string(args, DELETE_NOTE_TOOL_NAME, "path")?; let raw_path = required_tool_string(args, DELETE_NOTE_TOOL_NAME, "path")?;
let path = resolve_existing_vault_note(request, raw_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())?; crate::vault::delete_note(path.to_string_lossy().as_ref())?;
Ok(format!("已删除笔记:{}", path.display())) Ok(format!("已删除笔记:{}", path.display()))
} }
@ -1286,12 +1311,29 @@ fn create_note_from_tool_args(
let vault_path = tool_vault_path(request, args)?; let vault_path = tool_vault_path(request, args)?;
crate::commands::create_note_content( crate::commands::create_note_content(
PathBuf::from(note_path), PathBuf::from(note_path),
content, content.clone(),
Some(PathBuf::from(vault_path)), 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!({ let output = serde_json::json!({
"path": note_path, "path": note_path,
"vaultPath": vault_path, "vaultPath": vault_path,
"synchronizedCommit": synchronized_commit,
}) })
.to_string(); .to_string();
Ok(OpenAiToolResult { Ok(OpenAiToolResult {

View file

@ -255,7 +255,7 @@ fn tool_choice_compatibility_error(error: &str) -> bool {
|| normalized.contains("invalid parameter")) || normalized.contains("invalid parameter"))
} }
fn run_openai_agent_loop<F, S>( pub(crate) fn run_openai_agent_loop<F, S>(
request: &AiModelStreamRequest, request: &AiModelStreamRequest,
mut payload: serde_json::Value, mut payload: serde_json::Value,
emit: &mut F, 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() { if api_key.is_empty() {
return Err("API key cannot be empty.".into()); return Err("API key cannot be empty.".into());
} }
let path = secrets_path()?; #[cfg(mobile)]
let mut secrets = read_secrets_at(&path)?; {
secrets.provider_api_keys.insert(provider_id, api_key); crate::device_secret_store::save(
write_secrets_at(&path, &secrets) 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> { pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> {
let provider_id = normalize_secret_provider_id(&provider_id)?; let provider_id = normalize_secret_provider_id(&provider_id)?;
let path = secrets_path()?; #[cfg(mobile)]
let mut secrets = read_secrets_at(&path)?; {
secrets.provider_api_keys.remove(&provider_id); return crate::device_secret_store::delete(
write_secrets_at(&path, &secrets) 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<String, String> { fn normalize_secret_provider_id(provider_id: &str) -> Result<String, String> {
@ -729,19 +751,30 @@ fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
} }
fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<String>, String> { fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
let secrets = read_secrets_at(&secrets_path()?)?; #[cfg(mobile)]
let api_key = secrets {
.provider_api_keys let api_key = crate::device_secret_store::load(
.get(&request.provider.id) crate::device_secret_store::MODEL_API_KEY_KIND,
.map(|value| value.trim().to_string()) &request.provider.id,
.filter(|value| !value.is_empty()); )?;
if api_key.is_none() { return Ok(Some(api_key));
return Err(format!( }
"No local API key is saved for {}.", #[cfg(desktop)]
request.provider.name {
)); 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<Option<String>, String> { fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {

View file

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
const APP_CONFIG_POLICY_JSON: &str = include_str!("../../mcp-server/app-config-policy.json"); const APP_CONFIG_POLICY_JSON: &str = include_str!("../../mcp-server/app-config-policy.json");
static MOBILE_APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct AppConfigPolicy { struct AppConfigPolicy {
@ -48,6 +49,9 @@ fn app_config_dir() -> Result<PathBuf, String> {
} }
fn primary_config_dir() -> Option<PathBuf> { fn primary_config_dir() -> Option<PathBuf> {
if let Some(path) = MOBILE_APP_CONFIG_DIR.get() {
return Some(path.clone());
}
primary_config_dir_from_sources( primary_config_dir_from_sources(
explicit_xdg_config_home(), explicit_xdg_config_home(),
dirs::home_dir(), dirs::home_dir(),
@ -55,6 +59,16 @@ fn primary_config_dir() -> Option<PathBuf> {
) )
} }
#[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( fn primary_config_dir_from_sources(
explicit_xdg: Option<PathBuf>, explicit_xdg: Option<PathBuf>,
home: Option<PathBuf>, home: Option<PathBuf>,

View file

@ -339,33 +339,56 @@ pub fn abort_ai_agent_stream(_event_name: String) -> Result<bool, String> {
#[cfg(mobile)] #[cfg(mobile)]
#[tauri::command] #[tauri::command]
pub async fn stream_ai_model( pub async fn stream_ai_model(
_app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
_request: crate::ai_models::AiModelStreamRequest, request: crate::ai_models::AiModelStreamRequest,
) -> Result<String, String> { ) -> Result<String, String> {
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)] #[cfg(mobile)]
#[tauri::command] #[tauri::command]
pub fn save_ai_model_provider_api_key( pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
_provider_id: String, crate::ai_models::save_provider_api_key(provider_id, api_key)
_api_key: String,
) -> Result<(), String> {
Err("Local AI provider secret storage is only available in the desktop app.".into())
} }
#[cfg(mobile)] #[cfg(mobile)]
#[tauri::command] #[tauri::command]
pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> { 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()) crate::ai_models::delete_provider_api_key(provider_id)
} }
#[cfg(mobile)] #[cfg(mobile)]
#[tauri::command] #[tauri::command]
pub fn test_ai_model_provider( pub async fn test_ai_model_provider(
_request: crate::ai_models::AiModelProviderTestRequest, request: crate::ai_models::AiModelProviderTestRequest,
) -> Result<String, String> { ) -> Result<String, String> {
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)] #[cfg(test)]

View file

@ -82,6 +82,26 @@ pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|path| path.to_string_lossy().to_string()) vault::default_vault_path().map(|path| path.to_string_lossy().to_string())
} }
fn mobile_knowledge_root(app_handle: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
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<String, String> {
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<String, String> {
vault::open_or_create_mobile_personal_vault(&mobile_knowledge_root(&app_handle)?)
}
#[tauri::command] #[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> { pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path); let vault_path = expand_tilde(&vault_path);
@ -153,4 +173,22 @@ mod tests {
Ok(explicit.to_string_lossy().to_string()) 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"));
}
} }

View file

@ -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<Cow<'_, str>, 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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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());
}
}

View file

@ -851,6 +851,10 @@ fn load_or_create_identity() -> Result<RouterDeviceIdentity, String> {
load_or_create_identity_at(&path) load_or_create_identity_at(&path)
} }
pub(crate) fn hololake_device_id() -> Result<String, String> {
load_or_create_identity().map(|identity| identity.device_id)
}
fn load_or_create_identity_at(path: &Path) -> Result<RouterDeviceIdentity, String> { fn load_or_create_identity_at(path: &Path) -> Result<RouterDeviceIdentity, String> {
if path.exists() { if path.exists() {
let bytes = fs::read(path) let bytes = fs::read(path)

View file

@ -1,7 +1,9 @@
use serde::Serialize; use serde::Serialize;
const SHANGHAI_NODE_ID: &str = "BS-SH-005"; 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"; const SHANGHAI_NODE_ADDRESS: &str = "124.223.10.33";
#[cfg(any(target_os = "macos", target_os = "linux"))]
const GHOS_LOGIN_PATTERN: &str = "484c44502d47484f532d4c4f47494e21"; const GHOS_LOGIN_PATTERN: &str = "484c44502d47484f532d4c4f47494e21";
#[derive(Debug, Serialize, PartialEq, Eq)] #[derive(Debug, Serialize, PartialEq, Eq)]
@ -76,13 +78,21 @@ fn ping_arguments() -> [&'static str; 4] {
#[tauri::command] #[tauri::command]
pub fn guanghu_shanghai_node_status() -> Result<GuanghuShanghaiNodeStatus, String> { pub fn guanghu_shanghai_node_status() -> Result<GuanghuShanghaiNodeStatus, String> {
let output = crate::hidden_command("ping") #[cfg(any(target_os = "macos", target_os = "linux", windows))]
.args(ping_arguments()) {
.output() let output = crate::hidden_command("ping")
.map_err(|error| format!("guanghu_shanghai_probe_unavailable: {error}"))?; .args(ping_arguments())
let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); .output()
text.push_str(&String::from_utf8_lossy(&output.stderr)); .map_err(|error| format!("guanghu_shanghai_probe_unavailable: {error}"))?;
Ok(status_from_ping(output.status.success(), &text)) 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)] #[cfg(test)]

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ pub mod codex_cli;
mod commands; mod commands;
pub mod copilot_cli; pub mod copilot_cli;
mod copilot_discovery; mod copilot_discovery;
mod device_secret_store;
pub mod frontmatter; pub mod frontmatter;
pub mod git; pub mod git;
mod guanghu_enterprise; mod guanghu_enterprise;
@ -25,6 +26,7 @@ mod guanghu_world_login;
pub mod hermes_cli; pub mod hermes_cli;
mod hermes_discovery; mod hermes_discovery;
mod hldp_runtime; mod hldp_runtime;
mod hololake_account;
pub mod kiro_cli; pub mod kiro_cli;
mod kiro_discovery; mod kiro_discovery;
#[cfg(any(test, all(desktop, target_os = "linux")))] #[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<dyn std::error::Error>> { fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
#[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)?; setup_common_plugins(app)?;
#[cfg(desktop)] #[cfg(desktop)]
@ -533,6 +542,12 @@ macro_rules! app_invoke_handler {
guanghu_world_login::guanghu_world_login_claim, guanghu_world_login::guanghu_world_login_claim,
guanghu_world_login::guanghu_world_login_status, guanghu_world_login::guanghu_world_login_status,
guanghu_world_login::guanghu_world_logout, 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_files,
commands::get_conflict_mode, commands::get_conflict_mode,
commands::git_resolve_conflict, commands::git_resolve_conflict,
@ -590,6 +605,8 @@ macro_rules! app_invoke_handler {
commands::create_getting_started_vault, commands::create_getting_started_vault,
commands::check_vault_exists, commands::check_vault_exists,
commands::get_default_vault_path, commands::get_default_vault_path,
commands::open_mobile_foundation_knowledge,
commands::open_mobile_personal_knowledge,
commands::register_mcp_tools, commands::register_mcp_tools,
commands::remove_mcp_tools, commands::remove_mcp_tools,
commands::check_mcp_status, commands::check_mcp_status,

View file

@ -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<String, String> {
open_or_create_mobile_vault(root, MobileVaultKind::World)
}
pub fn open_or_create_mobile_personal_vault(root: &Path) -> Result<String, String> {
open_or_create_mobile_vault(root, MobileVaultKind::Personal)
}
fn open_or_create_mobile_vault(root: &Path, kind: MobileVaultKind) -> Result<String, String> {
open_or_create_mobile_vault_in_documents(root, kind)
}
fn open_or_create_mobile_vault_in_documents(
documents_dir: &Path,
kind: MobileVaultKind,
) -> Result<String, String> {
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<String, String> {
open_or_create_mobile_vault_at(path, MobileVaultKind::World)
}
#[cfg(test)]
pub(crate) fn open_or_create_mobile_personal_vault_at(path: &Path) -> Result<String, String> {
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<String, String> {
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<String, String> {
path.canonicalize()
.map(|resolved| resolved.to_string_lossy().to_string())
.map_err(|error| format!("Failed to resolve mobile vault path: {error}"))
}

View file

@ -9,6 +9,7 @@ mod getting_started;
mod ignored; mod ignored;
mod image; mod image;
mod migration; mod migration;
mod mobile_vault;
mod parsing; mod parsing;
pub(crate) mod path_identity; pub(crate) mod path_identity;
mod rename; 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 ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths};
pub use image::{copy_image_to_vault, save_image}; pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type; 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::{ pub use rename::{
auto_rename_untitled, detect_renames, move_note_to_folder, move_note_to_workspace, rename_note, auto_rename_untitled, detect_renames, move_note_to_folder, move_note_to_workspace, rename_note,
rename_note_filename, update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename, rename_note_filename, update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename,

View file

@ -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 (
<Card className="hololake-account-panel">
<CardHeader>
<CardTitle>{t('hololake.account.title')}</CardTitle>
<CardDescription>{t('hololake.account.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{authenticated ? (
<div className="space-y-2" aria-live="polite">
<p className="text-sm font-medium">{t('hololake.account.signedIn')}</p>
<p className="text-muted-foreground text-sm">{t('hololake.account.serverKeys')}</p>
{account.state.lastCommit
? <p className="text-muted-foreground text-xs">{t('hololake.account.syncComplete')} · <code>{account.state.lastCommit.slice(0, 12)}</code></p>
: null}
</div>
) : awaitingCode ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">{t('hololake.account.codeSent')}</p>
<Input
aria-label={t('hololake.account.code')}
autoComplete="one-time-code"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder={t('hololake.account.code')}
value={code}
/>
</div>
) : (
<Input
aria-label={t('hololake.account.email')}
autoCapitalize="none"
autoComplete="email"
inputMode="email"
onChange={(event) => setEmail(event.target.value)}
placeholder={t('hololake.account.email')}
type="email"
value={email}
/>
)}
{account.state.error
? <p className="text-destructive text-sm" role="alert">{t('hololake.account.error')} <code>{account.state.error}</code></p>
: null}
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
{authenticated ? (
<>
<Button disabled={busy} onClick={() => void account.syncKnowledge()}>
{busy ? t('hololake.account.syncing') : t('hololake.account.sync')}
</Button>
<Button disabled={busy} onClick={() => void account.logout()} variant="outline">
{t('hololake.account.signOut')}
</Button>
</>
) : awaitingCode ? (
<Button disabled={busy || code.length !== 6} onClick={() => void account.verifyCode(code)}>
{t('hololake.account.verify')}
</Button>
) : (
<Button disabled={busy || !email.includes('@')} onClick={() => void account.requestCode(email)}>
{busy ? t('hololake.account.sending') : t('hololake.account.sendCode')}
</Button>
)}
</CardFooter>
</Card>
)
}

View file

@ -26,6 +26,7 @@ import { GuanghuWorldMap } from './GuanghuWorldMap'
import { GuanghuWorldLoginGate } from './GuanghuWorldLoginGate' import { GuanghuWorldLoginGate } from './GuanghuWorldLoginGate'
import { FifthDomainSystems } from './FifthDomainSystems' import { FifthDomainSystems } from './FifthDomainSystems'
import { EternalLakeHeartPage } from './EternalLakeHeartPage' import { EternalLakeHeartPage } from './EternalLakeHeartPage'
import { HoloLakeAccountPanel } from './HoloLakeAccountPanel'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -407,6 +408,7 @@ export function HoloLakeHome({
}}>离开光湖世界</Button> }}>离开光湖世界</Button>
</article> </article>
<GuanghuRouterConsole locale={locale} router={router} /> <GuanghuRouterConsole locale={locale} router={router} />
<HoloLakeAccountPanel locale={locale} />
</div> </div>
</section> </section>
) )
@ -416,6 +418,7 @@ export function HoloLakeHome({
<section className="channel-stage"> <section className="channel-stage">
<h1>{t('hololake.channel.heartbeatTitle')}</h1> <h1>{t('hololake.channel.heartbeatTitle')}</h1>
<p className="channel-stage__lead">{t('hololake.channel.heartbeatDescription')}</p> <p className="channel-stage__lead">{t('hololake.channel.heartbeatDescription')}</p>
<HoloLakeAccountPanel locale={locale} />
<div className="channel-grid channel-grid--three"> <div className="channel-grid channel-grid--three">
<RouteCard eyebrow="PERSONA · AI AGENT" title="人格体工作舱" description="与当前频道的人格体持续协作;保留多 Agent、模型选择、权限模式、工具调用、执行回执与历史会话。" action="打开人格体工作舱" onOpen={onOpenAiWorkspace ? openAiWorkspace : undefined} /> <RouteCard eyebrow="PERSONA · AI AGENT" title="人格体工作舱" description="与当前频道的人格体持续协作;保留多 Agent、模型选择、权限模式、工具调用、执行回执与历史会话。" action="打开人格体工作舱" onOpen={onOpenAiWorkspace ? openAiWorkspace : undefined} />
<RouteCard eyebrow="MODULE · KNOWLEDGE" title={t('hololake.home.moduleKnowledgeTitle')} description={t('hololake.home.moduleKnowledgeDescription')} action={t('hololake.channel.openKnowledge')} onOpen={openKnowledgeBase} /> <RouteCard eyebrow="MODULE · KNOWLEDGE" title={t('hololake.home.moduleKnowledgeTitle')} description={t('hololake.home.moduleKnowledgeDescription')} action={t('hololake.channel.openKnowledge')} onOpen={openKnowledgeBase} />

View file

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import guanghuGalaxyLogo from '@/assets/guanghu-logo-light.png' import guanghuGalaxyLogo from '@/assets/guanghu-logo-light.png'
import { TOLARIA_FIRST_LAUNCH_DOCS_URL } from '@/constants/feedback' import { TOLARIA_FIRST_LAUNCH_DOCS_URL } from '@/constants/feedback'
import { translate, type AppLocale } from '@/lib/i18n' import { translate, type AppLocale } from '@/lib/i18n'
import { isMobilePlatform } from '@/utils/platform'
import { openExternalUrl } from '@/utils/url' import { openExternalUrl } from '@/utils/url'
interface WelcomeScreenProps { interface WelcomeScreenProps {
@ -317,6 +318,7 @@ function useWelcomeActionButtons({
mode, mode,
busy, busy,
isOffline, isOffline,
mobile,
onCreateEmptyVault, onCreateEmptyVault,
onOpenFolder, onOpenFolder,
onCreateVault, onCreateVault,
@ -325,21 +327,24 @@ function useWelcomeActionButtons({
'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault' 'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault'
> & { > & {
busy: boolean busy: boolean
mobile: boolean
}) { }) {
const templateActionRef = useRef<HTMLButtonElement>(null) const templateActionRef = useRef<HTMLButtonElement>(null)
const createEmptyActionRef = useRef<HTMLButtonElement>(null) const createEmptyActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null) const openFolderActionRef = useRef<HTMLButtonElement>(null)
const actionButtonRefs = useMemo( const actionButtonRefs = useMemo(
() => [templateActionRef, createEmptyActionRef, openFolderActionRef], () => mobile
[], ? [templateActionRef, createEmptyActionRef]
: [templateActionRef, createEmptyActionRef, openFolderActionRef],
[mobile],
) )
const actions = useMemo<WelcomeAction[]>( const actions = useMemo<WelcomeAction[]>(
() => ([ () => ([
{ disabled: isOffline, run: onCreateVault }, { disabled: !mobile && isOffline, run: onCreateVault },
{ disabled: false, run: onCreateEmptyVault }, { disabled: false, run: onCreateEmptyVault },
{ disabled: false, run: onOpenFolder }, ...(!mobile ? [{ disabled: false, run: onOpenFolder }] : []),
]), ]),
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder], [isOffline, mobile, onCreateEmptyVault, onCreateVault, onOpenFolder],
) )
useEffect(() => { useEffect(() => {
@ -416,6 +421,7 @@ function WelcomeActions({
createEmptyActionRef, createEmptyActionRef,
creatingAction, creatingAction,
isOffline, isOffline,
mobile,
locale, locale,
onCreateEmptyVault, onCreateEmptyVault,
onCreateVault, onCreateVault,
@ -430,6 +436,7 @@ function WelcomeActions({
busy: boolean busy: boolean
createEmptyActionRef: WelcomeActionButtonRef createEmptyActionRef: WelcomeActionButtonRef
locale: AppLocale locale: AppLocale
mobile: boolean
openFolderActionRef: WelcomeActionButtonRef openFolderActionRef: WelcomeActionButtonRef
presentation: WelcomeScreenPresentation presentation: WelcomeScreenPresentation
templateActionRef: WelcomeActionButtonRef templateActionRef: WelcomeActionButtonRef
@ -439,12 +446,20 @@ function WelcomeActions({
<OptionButton <OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />} icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light)" iconBg="var(--accent-purple-light)"
label={translate(locale, 'onboarding.welcome.templateTitle')} label={translate(locale, mobile
description={presentation.templateDescription} ? 'onboarding.welcome.mobileWorldTitle'
loadingLabel={translate(locale, 'onboarding.welcome.templateLoading')} : 'onboarding.welcome.templateTitle')}
loadingDescription={translate(locale, 'onboarding.welcome.templateLoadingDescription')} 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} onClick={onCreateVault}
disabled={busy || isOffline} disabled={busy || (!mobile && isOffline)}
loading={creatingAction === 'template'} loading={creatingAction === 'template'}
testId="welcome-create-vault" testId="welcome-create-vault"
autoFocus autoFocus
@ -454,10 +469,18 @@ function WelcomeActions({
<OptionButton <OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />} icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light)" iconBg="var(--accent-blue-light)"
label={translate(locale, 'onboarding.welcome.createEmpty')} label={translate(locale, mobile
description={translate(locale, 'onboarding.welcome.createEmptyDescription')} ? 'onboarding.welcome.mobilePersonalTitle'
loadingLabel={translate(locale, 'onboarding.welcome.createEmptyLoading')} : 'onboarding.welcome.createEmpty')}
loadingDescription={translate(locale, 'onboarding.welcome.createEmptyLoadingDescription')} 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} onClick={onCreateEmptyVault}
disabled={busy} disabled={busy}
loading={creatingAction === 'empty'} loading={creatingAction === 'empty'}
@ -465,16 +488,18 @@ function WelcomeActions({
buttonRef={createEmptyActionRef} buttonRef={createEmptyActionRef}
/> />
<OptionButton {!mobile && (
icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />} <OptionButton
iconBg="var(--accent-green-light)" icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />}
label={presentation.openFolderLabel} iconBg="var(--accent-green-light)"
description={translate(locale, 'onboarding.welcome.openExistingDescription')} label={presentation.openFolderLabel}
onClick={onOpenFolder} description={translate(locale, 'onboarding.welcome.openExistingDescription')}
disabled={busy} onClick={onOpenFolder}
testId="welcome-open-folder" disabled={busy}
buttonRef={openFolderActionRef} testId="welcome-open-folder"
/> buttonRef={openFolderActionRef}
/>
)}
</div> </div>
) )
} }
@ -556,11 +581,13 @@ export function WelcomeScreen({
canRetryTemplate, canRetryTemplate,
}: WelcomeScreenProps) { }: WelcomeScreenProps) {
const busy = creatingAction !== null const busy = creatingAction !== null
const mobile = isMobilePlatform()
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline, locale) const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline, locale)
const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({ const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({
mode, mode,
busy, busy,
isOffline, isOffline,
mobile,
onCreateEmptyVault, onCreateEmptyVault,
onOpenFolder, onOpenFolder,
onCreateVault, onCreateVault,
@ -581,6 +608,7 @@ export function WelcomeScreen({
creatingAction={creatingAction} creatingAction={creatingAction}
isOffline={isOffline} isOffline={isOffline}
locale={locale} locale={locale}
mobile={mobile}
onCreateEmptyVault={onCreateEmptyVault} onCreateEmptyVault={onCreateEmptyVault}
onCreateVault={onCreateVault} onCreateVault={onCreateVault}
onOpenFolder={onOpenFolder} onOpenFolder={onOpenFolder}

View file

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

View file

@ -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<AccountState>(initialState)
const refresh = useCallback(async () => {
if (!isTauri()) {
setState((current) => ({ ...current, phase: 'signed-out' }))
return
}
try {
const status = await invoke<NativeAccountStatus>('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<NativeCodeRequest>('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<NativeAccountStatus>('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<NativeKnowledgeReceipt>('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,
}
}

View file

@ -8,6 +8,8 @@ import {
labelFromPath, labelFromPath,
} from '../utils/gettingStartedVault' } from '../utils/gettingStartedVault'
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog' import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
import { isMobilePlatform } from '../utils/platform'
import { trackEvent } from '../lib/telemetry'
type OnboardingState = type OnboardingState =
| { status: 'loading' } | { status: 'loading' }
@ -16,7 +18,7 @@ type OnboardingState =
| { status: 'ready'; vaultPath: string } | { status: 'ready'; vaultPath: string }
type CreatingAction = 'template' | 'empty' | null 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 OnVaultReady = (vaultPath: string, source: ReadyVaultSource) => void
type RegisterVault = ( type RegisterVault = (
vaultPath: string, vaultPath: string,
@ -51,6 +53,11 @@ interface CreateEmptyVaultHandlerOptions extends ReadyVaultHandlerOptions {
setCreatingAction: SetCreatingAction setCreatingAction: SetCreatingAction
} }
interface MobileKnowledgeHandlerOptions extends CreateEmptyVaultHandlerOptions {
command: 'open_mobile_foundation_knowledge' | 'open_mobile_personal_knowledge'
source: Extract<ReadyVaultSource, 'world' | 'personal'>
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> { function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args) return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
} }
@ -263,6 +270,35 @@ function useOpenFolderHandler(
}, [options]) }, [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<string>('open_mobile_foundation_knowledge', {})
await registerVaultSelection(options.registerVault, foundationPath, { verifyAvailability: false })
}
const knowledgePath = await tauriCall<string>(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( export function useOnboarding(
initialVaultPath: string, initialVaultPath: string,
options: OnboardingOptions = {}, options: OnboardingOptions = {},
@ -321,14 +357,14 @@ export function useOnboarding(
onVaultReady: options.onVaultReady, onVaultReady: options.onVaultReady,
}) })
const handleCreateVault = useCreateVaultHandler(createTemplateVault, setError) const handleCreateDesktopVault = useCreateVaultHandler(createTemplateVault, setError)
const retryCreateVault = useCallback(async () => { const retryCreateVault = useCallback(async () => {
if (!lastTemplatePath) return if (!lastTemplatePath) return
await createTemplateVault(lastTemplatePath) await createTemplateVault(lastTemplatePath)
}, [createTemplateVault, lastTemplatePath]) }, [createTemplateVault, lastTemplatePath])
const handleCreateEmptyVault = useCreateEmptyVaultHandler({ const handleCreateDesktopEmptyVault = useCreateEmptyVaultHandler({
onVaultReady: options.onVaultReady, onVaultReady: options.onVaultReady,
registerVault: options.registerVault, registerVault: options.registerVault,
setCreatingAction, setCreatingAction,
@ -343,6 +379,42 @@ export function useOnboarding(
setState, 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(() => { const handleDismiss = useCallback(() => {
markDismissed() markDismissed()
setState({ status: 'ready', vaultPath: initialVaultPath }) setState({ status: 'ready', vaultPath: initialVaultPath })

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -92,11 +92,30 @@
"onboarding.welcome.createEmptyDescription": "Start fresh in an empty folder with HoloLake Era defaults", "onboarding.welcome.createEmptyDescription": "Start fresh in an empty folder with HoloLake Era defaults",
"onboarding.welcome.createEmptyLoading": "Creating vault...", "onboarding.welcome.createEmptyLoading": "Creating vault...",
"onboarding.welcome.createEmptyLoadingDescription": "Preparing HoloLake Era defaults in the selected folder", "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.openExisting": "Open existing vault",
"onboarding.welcome.openExistingDescription": "Point to a folder you already have", "onboarding.welcome.openExistingDescription": "Point to a folder you already have",
"onboarding.welcome.retryDownload": "Retry download", "onboarding.welcome.retryDownload": "Retry download",
"onboarding.welcome.docsPrompt": "New to HoloLake Era?", "onboarding.welcome.docsPrompt": "New to HoloLake Era?",
"onboarding.welcome.docsLink": "Read the first-launch guide", "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.checkingTitle": "Checking AI setup",
"onboarding.ai.checkingDescription": "Looking for local AI agents on this machine.", "onboarding.ai.checkingDescription": "Looking for local AI agents on this machine.",
"onboarding.ai.missingTitle": "AI setup is optional", "onboarding.ai.missingTitle": "AI setup is optional",

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -92,11 +92,30 @@
"onboarding.welcome.createEmptyDescription": "使用 HoloLake Era 默认设置,在空文件夹中重新开始", "onboarding.welcome.createEmptyDescription": "使用 HoloLake Era 默认设置,在空文件夹中重新开始",
"onboarding.welcome.createEmptyLoading": "正在创建 Vault...", "onboarding.welcome.createEmptyLoading": "正在创建 Vault...",
"onboarding.welcome.createEmptyLoadingDescription": "正在所选文件夹中准备 HoloLake Era 默认设置", "onboarding.welcome.createEmptyLoadingDescription": "正在所选文件夹中准备 HoloLake Era 默认设置",
"onboarding.welcome.mobileWorldTitle": "进入光湖基础世界",
"onboarding.welcome.mobileWorldDescription": "打开内置的共同语言世界基础",
"onboarding.welcome.mobilePersonalTitle": "建立我的本地知识湖",
"onboarding.welcome.mobilePersonalDescription": "在当前手机内建立私人知识湖",
"onboarding.welcome.openExisting": "打开现有 Vault", "onboarding.welcome.openExisting": "打开现有 Vault",
"onboarding.welcome.openExistingDescription": "指向您已有的文件夹", "onboarding.welcome.openExistingDescription": "指向您已有的文件夹",
"onboarding.welcome.retryDownload": "重试下载", "onboarding.welcome.retryDownload": "重试下载",
"onboarding.welcome.docsPrompt": "HoloLake Era 新手?", "onboarding.welcome.docsPrompt": "HoloLake Era 新手?",
"onboarding.welcome.docsLink": "阅读首次启动指南", "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.checkingTitle": "正在检查 AI 设置",
"onboarding.ai.checkingDescription": "正在本机上查找本地 AI 代理。", "onboarding.ai.checkingDescription": "正在本机上查找本地 AI 代理。",
"onboarding.ai.missingTitle": "AI 设置为可选项", "onboarding.ai.missingTitle": "AI 设置为可选项",

View file

@ -1121,5 +1121,24 @@
"hololake.login.errorTitle": "The world entrance did not open", "hololake.login.errorTitle": "The world entrance did not open",
"hololake.login.retry": "Try again", "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.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"
} }

View file

@ -21,6 +21,10 @@ export function isWindows(): boolean {
return getUserAgent().includes('Windows') return getUserAgent().includes('Windows')
} }
export function isMobilePlatform(): boolean {
return /Android|iPad|iPhone|iPod/u.test(getUserAgent())
}
export function shouldUseCustomWindowChrome(): boolean { export function shouldUseCustomWindowChrome(): boolean {
return isTauri() && (isLinux() || isWindows()) return isTauri() && (isLinux() || isWindows())
} }

View file

@ -983,6 +983,14 @@ export default defineConfig({
port: 5202, port: 5202,
strictPort: true, strictPort: true,
allowedHosts: true, allowedHosts: true,
fs: process.env.HOLOLAKE_TEST_DEPENDENCY_ROOT
? {
allow: [
__dirname,
path.resolve(process.env.HOLOLAKE_TEST_DEPENDENCY_ROOT),
],
}
: undefined,
watch: { watch: {
ignored: devServerWatchIgnored, ignored: devServerWatchIgnored,
}, },