feat(ios): add native Git knowledge and device-only secrets
This commit is contained in:
parent
da26f27e35
commit
b30f814320
51 changed files with 3019 additions and 97 deletions
|
|
@ -1135,6 +1135,35 @@ server deployment.
|
|||
|
||||
## Updates & Feature Flags
|
||||
|
||||
## Mobile HoloLake knowledge boundary
|
||||
|
||||
### Native storage and identity
|
||||
|
||||
- **`mobile_vault`** — Creates typed foundation and personal knowledge lakes
|
||||
inside the application sandbox. It never calls a desktop folder picker.
|
||||
- **`device_secret_store`** — Stores account sessions and user model keys in a
|
||||
non-synchronizing, this-device-only Apple Keychain item. Callers keep only an
|
||||
opaque handle and never receive a persisted plaintext representation.
|
||||
- **`hololake_account`** — Owns email OTP, device-bound sessions, verified
|
||||
repository snapshots, the server model catalog, and atomic Markdown write
|
||||
receipts.
|
||||
|
||||
### Knowledge Agent execution
|
||||
|
||||
- **`.hololake-sync-policy.json`** — Marks a lake as repository-backed and
|
||||
excludes `本地密钥/**`, internal HoloLake metadata, and Git internals.
|
||||
- **`sync_local_markdown_page`** — Sends only a bounded normal Markdown path,
|
||||
complete content, and the exact current base commit. Success requires a new
|
||||
40-character repository commit; otherwise the native tool rolls back its
|
||||
local create or edit.
|
||||
- **server AI provider** — Sends bounded model messages and tool schemas through
|
||||
the authenticated HoloLake proxy. The client executes allowlisted knowledge
|
||||
tools locally and returns their exact receipts for the next model turn. The
|
||||
server provider key never reaches the device.
|
||||
- **synchronized delete gate** — Refuses deletion until an atomic repository
|
||||
delete receipt exists, preventing a local-only success from diverging from
|
||||
the shared lake.
|
||||
|
||||
### Hooks
|
||||
- **`useUpdater(releaseChannel, automaticChecksEnabled)`** — Channel-aware updater state machine. When automatic checks are enabled, it checks the selected feed after startup; manual checks always remain available. It surfaces checking/available/downloading/ready states and delegates install work to Rust.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
# Architecture
|
||||
|
||||
Tolaria is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
|
||||
HoloLake Era is the human and Agent entry to HoloLake worlds, channels, identity,
|
||||
knowledge, and receipt-backed native execution. Its knowledge workspace retains
|
||||
the mature Markdown graph and editor inherited from Tolaria, but the product is
|
||||
not a renamed desktop vault and the mobile client does not emulate desktop
|
||||
filesystem or credential behavior.
|
||||
|
||||
## Design Principles
|
||||
|
||||
|
|
@ -56,6 +60,22 @@ No field names, folder paths, or vault-specific values should be hardcoded in th
|
|||
|
||||
Notes are not just documents — they are nodes in a structured graph of people, projects, events, responsibilities, and ideas. Every design decision should ask: "Does this make the knowledge graph easier for a human *and* an AI to navigate?" Conventions that are legible to both are better than conventions that are legible only to one.
|
||||
|
||||
### Mobile knowledge and credential boundary
|
||||
|
||||
On iOS, HoloLake opens app-owned foundation and personal knowledge directories
|
||||
inside the sandbox rather than calling the desktop folder picker. A synchronized
|
||||
personal lake is identified by `.hololake-sync-policy.json`: ordinary Markdown
|
||||
can be written through the authenticated server into the registered code-channel
|
||||
repository, while `本地密钥/**`, internal metadata, and Git internals are excluded.
|
||||
|
||||
The knowledge Agent uses the same bounded read/write tools as the desktop model
|
||||
target. A normal page create or edit is acknowledged only after an optimistic,
|
||||
atomic bare-repository update returns the exact new commit. Local state is rolled
|
||||
back when that receipt is absent. iOS account sessions and user model keys are
|
||||
stored in non-synchronizing, this-device-only Keychain entries; raw values never
|
||||
enter Markdown, Git, renderer state, telemetry, or server receipts. See
|
||||
[ADR 0173](adr/0173-mobile-git-knowledge-and-device-secret-boundary.md).
|
||||
|
||||
### Three representations, one authority
|
||||
|
||||
Vault data exists in three forms simultaneously:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# HoloLake Era iPhone / TestFlight 承接记录
|
||||
|
||||
状态日期:2026-07-19
|
||||
当前版本:0.1.7
|
||||
状态日期:2026-08-03
|
||||
当前版本:0.4.6
|
||||
Bundle ID:`com.guanghulab.hololake`
|
||||
|
||||
## 为什么这样做
|
||||
|
|
@ -31,6 +31,19 @@ Bundle ID:`com.guanghulab.hololake`
|
|||
|
||||
## 当前闭环状态
|
||||
|
||||
0.4.6 build 25 已被 Apple 接收并完成处理,但它仍停在出口合规法律声明,
|
||||
且真机运行仍存在知识入口失败。因此 build 25 不分配测试组,也不作为可用版本。
|
||||
下一次上传必须使用新的 build number,并同时包含 ADR 0173 的移动知识边界:
|
||||
|
||||
- iOS 沙箱内的光湖基础世界与个人知识湖,不再调用移动端未实现的文件夹选择器;
|
||||
- 邮箱验证码会话与用户模型密钥写入本机、不可同步的 this-device-only Keychain;
|
||||
- 普通 Markdown 由知识 Agent 读写,并在服务器返回精确 Git 提交后才显示成功;
|
||||
- `本地密钥/**` 不进入 Markdown 同步、代码仓库、模型上下文、日志或回执;
|
||||
- 服务器模型密钥留在服务器,手机只得到模型目录和执行结果。
|
||||
|
||||
这些源码和自动测试通过之前不得归档;Apple 上传、处理完成、测试组分配和真机可用
|
||||
仍然是四个彼此独立的验收事实。
|
||||
|
||||
2026-07-19 已完成付费团队同步、证书创建、iOS 26.5 平台导入、真机登记、前端生产构建和 Xcode scheme 对齐。私人设备标识不得写入仓库。
|
||||
|
||||
App Store Connect 应用记录已创建:名称 `HoloLake Era`,主语言简体中文,Bundle ID `com.guanghulab.hololake`。0.1.7 build 20 已由 Xcode 返回 `App upload complete`。build 19 曾因 `libapp.a` 被错误复制到 App 根目录而被 Apple 以 90171 拒绝;根因是 `project.yml` 把 `Externals` 同时声明为 sources,现已移除,build 20 包内预检确认不再包含该静态库。
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -220,3 +220,6 @@ proposed → active → superseded
|
|||
| [0167](0167-gestational-history-continuity-ingestion.md) | Gestational history enters Guanghu through a native continuity protocol | active |
|
||||
| [0168](0168-guanghu-native-prepartition-disk-layout.md) | Guanghu owns a registered pre-partition native disk layout | active |
|
||||
| [0169](0169-model-native-living-galaxy-system.md) | Model-native HoloLake living galaxy system | accepted |
|
||||
| [0171](0171-guanghu-world-email-entry-and-live-node-projection.md) | Guanghu world email entry and live node projection | active |
|
||||
| [0172](0172-persona-subject-existence-and-runtime-predicates.md) | Separate persona subject existence from runtime predicates | active |
|
||||
| [0173](0173-mobile-git-knowledge-and-device-secret-boundary.md) | Mobile Git knowledge and device-secret boundary | accepted |
|
||||
|
|
|
|||
|
|
@ -1885,6 +1885,8 @@ dependencies = [
|
|||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"ring",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1903,6 +1905,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip 0.6.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5349,7 +5352,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7268,6 +7271,18 @@ dependencies = [
|
|||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
|
|
|
|||
|
|
@ -52,11 +52,18 @@ quick-xml = { version = "0.38", features = ["serialize"] }
|
|||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
ring = "0.17"
|
||||
zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.3"
|
||||
objc2-app-kit = "0.3.2"
|
||||
objc2-foundation = "0.3.2"
|
||||
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] }
|
||||
security-framework = "3.5.1"
|
||||
security-framework-sys = "2.14.0"
|
||||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
security-framework = "3.5.1"
|
||||
security-framework-sys = "2.14.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@
|
|||
47E641E8D831982238FCF5D4 /* hermes_cli.rs */ = {isa = PBXFileReference; path = hermes_cli.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>"; };
|
||||
4CC25FAC04523FC1407E890B /* guanghu_world_login.rs */ = {isa = PBXFileReference; path = guanghu_world_login.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>"; };
|
||||
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>"; };
|
||||
7876AB1696C6A473C0CA60DD /* dates.rs */ = {isa = PBXFileReference; path = dates.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>"; };
|
||||
814BEE45F3957BA070B8A6FE /* keys.rs */ = {isa = PBXFileReference; path = keys.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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
|
|
@ -123,9 +126,11 @@
|
|||
B0B4AE2937945F1896E1B205 /* mod_tests.rs */ = {isa = PBXFileReference; path = mod_tests.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>"; };
|
||||
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>"; };
|
||||
B7436FF982457F75FD04F370 /* ai.rs */ = {isa = PBXFileReference; path = ai.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>"; };
|
||||
BB2C976547D1CF4F9CEDE8E1 /* antigravity_discovery.rs */ = {isa = PBXFileReference; path = antigravity_discovery.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>"; };
|
||||
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; };
|
||||
EBFA0B7CAB8661AB3605599A /* mobile_vault.rs */ = {isa = PBXFileReference; path = mobile_vault.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
F86E7BC210B3629F404B302E /* basics.rs */ = {isa = PBXFileReference; path = basics.rs; 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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -221,6 +230,7 @@
|
|||
2B938E7363F949A622DD8397 /* ignored.rs */,
|
||||
E19D7FEEBC114189FCF0F84A /* image.rs */,
|
||||
65A73B64D88239A170CF299A /* migration.rs */,
|
||||
EBFA0B7CAB8661AB3605599A /* mobile_vault.rs */,
|
||||
B0B4AE2937945F1896E1B205 /* mod_tests.rs */,
|
||||
83CCD3A86C61E070EB9C029B /* mod.rs */,
|
||||
EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */,
|
||||
|
|
@ -370,8 +380,16 @@
|
|||
FF3044079CC906291942C508 /* codex_cli.rs */,
|
||||
BEE7A0C1F8EE18EDAB31905A /* copilot_cli.rs */,
|
||||
282A3A834C97BA5453CC9355 /* copilot_discovery.rs */,
|
||||
FEE79A2B08D489C84E5C130A /* device_secret_store.rs */,
|
||||
F2D9E48A1998AD5837EBE8C5 /* guanghu_enterprise.rs */,
|
||||
B594CFA2AE6E07F7EEBDCA5F /* guanghu_living_system.rs */,
|
||||
7DE25EB5FAB7CE0ACBD80ABB /* guanghu_router.rs */,
|
||||
B873D19C73F8DB13E8418612 /* guanghu_shanghai_node.rs */,
|
||||
4CC25FAC04523FC1407E890B /* guanghu_world_login.rs */,
|
||||
47E641E8D831982238FCF5D4 /* hermes_cli.rs */,
|
||||
A0B9AEB056A7B2713B0EF52A /* hermes_discovery.rs */,
|
||||
F5DC0A0680C3D852550B4558 /* hldp_runtime.rs */,
|
||||
986A5C5A5D4E1DE0DB1DB3D9 /* hololake_account.rs */,
|
||||
1CEF6C06C1287DD4AE2F9B51 /* kiro_cli.rs */,
|
||||
F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */,
|
||||
8969FA0D7729542C8FE8ED93 /* lib.rs */,
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.7</string>
|
||||
<string>0.4.6</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20</string>
|
||||
<string>26</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ targets:
|
|||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
CFBundleDisplayName: HoloLake
|
||||
CFBundleShortVersionString: 0.1.7
|
||||
CFBundleVersion: "20"
|
||||
CFBundleShortVersionString: 0.4.6
|
||||
CFBundleVersion: "26"
|
||||
entitlements:
|
||||
path: hololake_iOS/hololake_iOS.entitlements
|
||||
scheme:
|
||||
|
|
|
|||
|
|
@ -1060,8 +1060,24 @@ fn edit_note_from_tool_args(
|
|||
let raw_path = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "path")?;
|
||||
let content = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "content")?;
|
||||
let path = resolve_existing_vault_note(request, raw_path)?;
|
||||
let previous = std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("Failed to preserve the note before editing: {error}"))?;
|
||||
crate::vault::save_note_content(path.to_string_lossy().as_ref(), content)?;
|
||||
Ok(format!("已更新笔记:{}", path.display()))
|
||||
let synchronized_commit = match crate::hololake_account::sync_local_markdown_page(
|
||||
Path::new(active_vault_path(request)?),
|
||||
&path,
|
||||
content,
|
||||
) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => {
|
||||
let _ = crate::vault::save_note_content(path.to_string_lossy().as_ref(), &previous);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(match synchronized_commit {
|
||||
Some(commit) => format!("已更新笔记:{};同步提交:{commit}", path.display()),
|
||||
None => format!("已更新笔记:{}", path.display()),
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_note_from_tool_args(
|
||||
|
|
@ -1070,6 +1086,15 @@ fn delete_note_from_tool_args(
|
|||
) -> Result<String, String> {
|
||||
let raw_path = required_tool_string(args, DELETE_NOTE_TOOL_NAME, "path")?;
|
||||
let path = resolve_existing_vault_note(request, raw_path)?;
|
||||
if Path::new(active_vault_path(request)?)
|
||||
.join(".hololake-sync-policy.json")
|
||||
.is_file()
|
||||
{
|
||||
return Err(
|
||||
"Git-backed HoloLake pages cannot be deleted until a server delete receipt is available."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
crate::vault::delete_note(path.to_string_lossy().as_ref())?;
|
||||
Ok(format!("已删除笔记:{}", path.display()))
|
||||
}
|
||||
|
|
@ -1286,12 +1311,29 @@ fn create_note_from_tool_args(
|
|||
let vault_path = tool_vault_path(request, args)?;
|
||||
crate::commands::create_note_content(
|
||||
PathBuf::from(note_path),
|
||||
content,
|
||||
content.clone(),
|
||||
Some(PathBuf::from(vault_path)),
|
||||
)?;
|
||||
let synchronized_commit = match crate::hololake_account::sync_local_markdown_page(
|
||||
Path::new(vault_path),
|
||||
Path::new(note_path),
|
||||
&content,
|
||||
) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => {
|
||||
let created_path = if Path::new(note_path).is_absolute() {
|
||||
PathBuf::from(note_path)
|
||||
} else {
|
||||
Path::new(vault_path).join(note_path)
|
||||
};
|
||||
let _ = std::fs::remove_file(created_path);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let output = serde_json::json!({
|
||||
"path": note_path,
|
||||
"vaultPath": vault_path,
|
||||
"synchronizedCommit": synchronized_commit,
|
||||
})
|
||||
.to_string();
|
||||
Ok(OpenAiToolResult {
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ fn tool_choice_compatibility_error(error: &str) -> bool {
|
|||
|| normalized.contains("invalid parameter"))
|
||||
}
|
||||
|
||||
fn run_openai_agent_loop<F, S>(
|
||||
pub(crate) fn run_openai_agent_loop<F, S>(
|
||||
request: &AiModelStreamRequest,
|
||||
mut payload: serde_json::Value,
|
||||
emit: &mut F,
|
||||
|
|
@ -656,18 +656,40 @@ pub fn save_provider_api_key(provider_id: String, api_key: String) -> Result<(),
|
|||
if api_key.is_empty() {
|
||||
return Err("API key cannot be empty.".into());
|
||||
}
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.insert(provider_id, api_key);
|
||||
write_secrets_at(&path, &secrets)
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
crate::device_secret_store::save(
|
||||
crate::device_secret_store::MODEL_API_KEY_KIND,
|
||||
&provider_id,
|
||||
&api_key,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.insert(provider_id, api_key);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
let provider_id = normalize_secret_provider_id(&provider_id)?;
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.remove(&provider_id);
|
||||
write_secrets_at(&path, &secrets)
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
return crate::device_secret_store::delete(
|
||||
crate::device_secret_store::MODEL_API_KEY_KIND,
|
||||
&provider_id,
|
||||
);
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.remove(&provider_id);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_secret_provider_id(provider_id: &str) -> Result<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> {
|
||||
let secrets = read_secrets_at(&secrets_path()?)?;
|
||||
let api_key = secrets
|
||||
.provider_api_keys
|
||||
.get(&request.provider.id)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
if api_key.is_none() {
|
||||
return Err(format!(
|
||||
"No local API key is saved for {}.",
|
||||
request.provider.name
|
||||
));
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
let api_key = crate::device_secret_store::load(
|
||||
crate::device_secret_store::MODEL_API_KEY_KIND,
|
||||
&request.provider.id,
|
||||
)?;
|
||||
return Ok(Some(api_key));
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let secrets = read_secrets_at(&secrets_path()?)?;
|
||||
let api_key = secrets
|
||||
.provider_api_keys
|
||||
.get(&request.provider.id)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
if api_key.is_none() {
|
||||
return Err(format!(
|
||||
"No local API key is saved for {}.",
|
||||
request.provider.name
|
||||
));
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
|||
use std::sync::OnceLock;
|
||||
|
||||
const APP_CONFIG_POLICY_JSON: &str = include_str!("../../mcp-server/app-config-policy.json");
|
||||
static MOBILE_APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppConfigPolicy {
|
||||
|
|
@ -48,6 +49,9 @@ fn app_config_dir() -> Result<PathBuf, String> {
|
|||
}
|
||||
|
||||
fn primary_config_dir() -> Option<PathBuf> {
|
||||
if let Some(path) = MOBILE_APP_CONFIG_DIR.get() {
|
||||
return Some(path.clone());
|
||||
}
|
||||
primary_config_dir_from_sources(
|
||||
explicit_xdg_config_home(),
|
||||
dirs::home_dir(),
|
||||
|
|
@ -55,6 +59,16 @@ fn primary_config_dir() -> Option<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(
|
||||
explicit_xdg: Option<PathBuf>,
|
||||
home: Option<PathBuf>,
|
||||
|
|
|
|||
|
|
@ -339,33 +339,56 @@ pub fn abort_ai_agent_stream(_event_name: String) -> Result<bool, String> {
|
|||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_model(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: crate::ai_models::AiModelStreamRequest,
|
||||
app_handle: tauri::AppHandle,
|
||||
request: crate::ai_models::AiModelStreamRequest,
|
||||
) -> Result<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)]
|
||||
#[tauri::command]
|
||||
pub fn save_ai_model_provider_api_key(
|
||||
_provider_id: String,
|
||||
_api_key: String,
|
||||
) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
|
||||
crate::ai_models::save_provider_api_key(provider_id, api_key)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
crate::ai_models::delete_provider_api_key(provider_id)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn test_ai_model_provider(
|
||||
_request: crate::ai_models::AiModelProviderTestRequest,
|
||||
pub async fn test_ai_model_provider(
|
||||
request: crate::ai_models::AiModelProviderTestRequest,
|
||||
) -> Result<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)]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
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]
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
|
|
@ -153,4 +173,22 @@ mod tests {
|
|||
Ok(explicit.to_string_lossy().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_knowledge_roots_are_stable_and_separate_local_secrets() {
|
||||
let temporary = tempfile::TempDir::new().unwrap();
|
||||
let (world, personal) =
|
||||
vault::open_or_create_mobile_vaults_in_documents(temporary.path()).unwrap();
|
||||
|
||||
assert!(Path::new(&world).join("welcome.md").is_file());
|
||||
assert!(Path::new(&personal).join("我的湖心.md").is_file());
|
||||
assert!(Path::new(&personal)
|
||||
.join(".hololake-sync-policy.json")
|
||||
.is_file());
|
||||
assert!(Path::new(&personal).join("本地密钥/README.md").is_file());
|
||||
let policy =
|
||||
fs::read_to_string(Path::new(&personal).join(".hololake-sync-policy.json")).unwrap();
|
||||
assert!(policy.contains("\"本地密钥/**\""));
|
||||
assert!(!policy.contains("api_key"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -851,6 +851,10 @@ fn load_or_create_identity() -> Result<RouterDeviceIdentity, String> {
|
|||
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> {
|
||||
if path.exists() {
|
||||
let bytes = fs::read(path)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use serde::Serialize;
|
||||
|
||||
const SHANGHAI_NODE_ID: &str = "BS-SH-005";
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", windows))]
|
||||
const SHANGHAI_NODE_ADDRESS: &str = "124.223.10.33";
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
const GHOS_LOGIN_PATTERN: &str = "484c44502d47484f532d4c4f47494e21";
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
|
|
@ -76,13 +78,21 @@ fn ping_arguments() -> [&'static str; 4] {
|
|||
|
||||
#[tauri::command]
|
||||
pub fn guanghu_shanghai_node_status() -> Result<GuanghuShanghaiNodeStatus, String> {
|
||||
let output = crate::hidden_command("ping")
|
||||
.args(ping_arguments())
|
||||
.output()
|
||||
.map_err(|error| format!("guanghu_shanghai_probe_unavailable: {error}"))?;
|
||||
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
Ok(status_from_ping(output.status.success(), &text))
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", windows))]
|
||||
{
|
||||
let output = crate::hidden_command("ping")
|
||||
.args(ping_arguments())
|
||||
.output()
|
||||
.map_err(|error| format!("guanghu_shanghai_probe_unavailable: {error}"))?;
|
||||
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
Ok(status_from_ping(output.status.success(), &text))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
{
|
||||
Err("guanghu_shanghai_probe_unsupported_platform".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
1194
product-source/hololake-platform/src-tauri/src/hololake_account.rs
Normal file
1194
product-source/hololake-platform/src-tauri/src/hololake_account.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ pub mod codex_cli;
|
|||
mod commands;
|
||||
pub mod copilot_cli;
|
||||
mod copilot_discovery;
|
||||
mod device_secret_store;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
mod guanghu_enterprise;
|
||||
|
|
@ -25,6 +26,7 @@ mod guanghu_world_login;
|
|||
pub mod hermes_cli;
|
||||
mod hermes_discovery;
|
||||
mod hldp_runtime;
|
||||
mod hololake_account;
|
||||
pub mod kiro_cli;
|
||||
mod kiro_discovery;
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
|
|
@ -414,6 +416,13 @@ fn setup_macos_webview_shortcut_prevention(
|
|||
}
|
||||
|
||||
fn setup_app(app: &mut tauri::App) -> Result<(), Box<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)?;
|
||||
|
||||
#[cfg(desktop)]
|
||||
|
|
@ -533,6 +542,12 @@ macro_rules! app_invoke_handler {
|
|||
guanghu_world_login::guanghu_world_login_claim,
|
||||
guanghu_world_login::guanghu_world_login_status,
|
||||
guanghu_world_login::guanghu_world_logout,
|
||||
hololake_account::hololake_account_request_email_code,
|
||||
hololake_account::hololake_account_verify_email_code,
|
||||
hololake_account::hololake_account_status,
|
||||
hololake_account::hololake_account_logout,
|
||||
hololake_account::hololake_sync_knowledge,
|
||||
hololake_account::hololake_ai_execute,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
|
|
@ -590,6 +605,8 @@ macro_rules! app_invoke_handler {
|
|||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::open_mobile_foundation_knowledge,
|
||||
commands::open_mobile_personal_knowledge,
|
||||
commands::register_mcp_tools,
|
||||
commands::remove_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ mod getting_started;
|
|||
mod ignored;
|
||||
mod image;
|
||||
mod migration;
|
||||
mod mobile_vault;
|
||||
mod parsing;
|
||||
pub(crate) mod path_identity;
|
||||
mod rename;
|
||||
|
|
@ -36,6 +37,12 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
|||
pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use mobile_vault::{open_or_create_mobile_personal_vault, open_or_create_mobile_world_vault};
|
||||
#[cfg(test)]
|
||||
pub(crate) use mobile_vault::{
|
||||
open_or_create_mobile_personal_vault_at, open_or_create_mobile_vaults_in_documents,
|
||||
open_or_create_mobile_world_vault_at,
|
||||
};
|
||||
pub use rename::{
|
||||
auto_rename_untitled, detect_renames, move_note_to_folder, move_note_to_workspace, rename_note,
|
||||
rename_note_filename, update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import { GuanghuWorldMap } from './GuanghuWorldMap'
|
|||
import { GuanghuWorldLoginGate } from './GuanghuWorldLoginGate'
|
||||
import { FifthDomainSystems } from './FifthDomainSystems'
|
||||
import { EternalLakeHeartPage } from './EternalLakeHeartPage'
|
||||
import { HoloLakeAccountPanel } from './HoloLakeAccountPanel'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -407,6 +408,7 @@ export function HoloLakeHome({
|
|||
}}>离开光湖世界</Button>
|
||||
</article>
|
||||
<GuanghuRouterConsole locale={locale} router={router} />
|
||||
<HoloLakeAccountPanel locale={locale} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -416,6 +418,7 @@ export function HoloLakeHome({
|
|||
<section className="channel-stage">
|
||||
<h1>{t('hololake.channel.heartbeatTitle')}</h1>
|
||||
<p className="channel-stage__lead">{t('hololake.channel.heartbeatDescription')}</p>
|
||||
<HoloLakeAccountPanel locale={locale} />
|
||||
<div className="channel-grid channel-grid--three">
|
||||
<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} />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||
import guanghuGalaxyLogo from '@/assets/guanghu-logo-light.png'
|
||||
import { TOLARIA_FIRST_LAUNCH_DOCS_URL } from '@/constants/feedback'
|
||||
import { translate, type AppLocale } from '@/lib/i18n'
|
||||
import { isMobilePlatform } from '@/utils/platform'
|
||||
import { openExternalUrl } from '@/utils/url'
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
|
|
@ -317,6 +318,7 @@ function useWelcomeActionButtons({
|
|||
mode,
|
||||
busy,
|
||||
isOffline,
|
||||
mobile,
|
||||
onCreateEmptyVault,
|
||||
onOpenFolder,
|
||||
onCreateVault,
|
||||
|
|
@ -325,21 +327,24 @@ function useWelcomeActionButtons({
|
|||
'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault'
|
||||
> & {
|
||||
busy: boolean
|
||||
mobile: boolean
|
||||
}) {
|
||||
const templateActionRef = useRef<HTMLButtonElement>(null)
|
||||
const createEmptyActionRef = useRef<HTMLButtonElement>(null)
|
||||
const openFolderActionRef = useRef<HTMLButtonElement>(null)
|
||||
const actionButtonRefs = useMemo(
|
||||
() => [templateActionRef, createEmptyActionRef, openFolderActionRef],
|
||||
[],
|
||||
() => mobile
|
||||
? [templateActionRef, createEmptyActionRef]
|
||||
: [templateActionRef, createEmptyActionRef, openFolderActionRef],
|
||||
[mobile],
|
||||
)
|
||||
const actions = useMemo<WelcomeAction[]>(
|
||||
() => ([
|
||||
{ disabled: isOffline, run: onCreateVault },
|
||||
{ disabled: !mobile && isOffline, run: onCreateVault },
|
||||
{ disabled: false, run: onCreateEmptyVault },
|
||||
{ disabled: false, run: onOpenFolder },
|
||||
...(!mobile ? [{ disabled: false, run: onOpenFolder }] : []),
|
||||
]),
|
||||
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
|
||||
[isOffline, mobile, onCreateEmptyVault, onCreateVault, onOpenFolder],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -416,6 +421,7 @@ function WelcomeActions({
|
|||
createEmptyActionRef,
|
||||
creatingAction,
|
||||
isOffline,
|
||||
mobile,
|
||||
locale,
|
||||
onCreateEmptyVault,
|
||||
onCreateVault,
|
||||
|
|
@ -430,6 +436,7 @@ function WelcomeActions({
|
|||
busy: boolean
|
||||
createEmptyActionRef: WelcomeActionButtonRef
|
||||
locale: AppLocale
|
||||
mobile: boolean
|
||||
openFolderActionRef: WelcomeActionButtonRef
|
||||
presentation: WelcomeScreenPresentation
|
||||
templateActionRef: WelcomeActionButtonRef
|
||||
|
|
@ -439,12 +446,20 @@ function WelcomeActions({
|
|||
<OptionButton
|
||||
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
|
||||
iconBg="var(--accent-purple-light)"
|
||||
label={translate(locale, 'onboarding.welcome.templateTitle')}
|
||||
description={presentation.templateDescription}
|
||||
loadingLabel={translate(locale, 'onboarding.welcome.templateLoading')}
|
||||
loadingDescription={translate(locale, 'onboarding.welcome.templateLoadingDescription')}
|
||||
label={translate(locale, mobile
|
||||
? 'onboarding.welcome.mobileWorldTitle'
|
||||
: 'onboarding.welcome.templateTitle')}
|
||||
description={mobile
|
||||
? translate(locale, 'onboarding.welcome.mobileWorldDescription')
|
||||
: presentation.templateDescription}
|
||||
loadingLabel={mobile
|
||||
? undefined
|
||||
: translate(locale, 'onboarding.welcome.templateLoading')}
|
||||
loadingDescription={mobile
|
||||
? undefined
|
||||
: translate(locale, 'onboarding.welcome.templateLoadingDescription')}
|
||||
onClick={onCreateVault}
|
||||
disabled={busy || isOffline}
|
||||
disabled={busy || (!mobile && isOffline)}
|
||||
loading={creatingAction === 'template'}
|
||||
testId="welcome-create-vault"
|
||||
autoFocus
|
||||
|
|
@ -454,10 +469,18 @@ function WelcomeActions({
|
|||
<OptionButton
|
||||
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
|
||||
iconBg="var(--accent-blue-light)"
|
||||
label={translate(locale, 'onboarding.welcome.createEmpty')}
|
||||
description={translate(locale, 'onboarding.welcome.createEmptyDescription')}
|
||||
loadingLabel={translate(locale, 'onboarding.welcome.createEmptyLoading')}
|
||||
loadingDescription={translate(locale, 'onboarding.welcome.createEmptyLoadingDescription')}
|
||||
label={translate(locale, mobile
|
||||
? 'onboarding.welcome.mobilePersonalTitle'
|
||||
: 'onboarding.welcome.createEmpty')}
|
||||
description={translate(locale, mobile
|
||||
? 'onboarding.welcome.mobilePersonalDescription'
|
||||
: 'onboarding.welcome.createEmptyDescription')}
|
||||
loadingLabel={mobile
|
||||
? undefined
|
||||
: translate(locale, 'onboarding.welcome.createEmptyLoading')}
|
||||
loadingDescription={mobile
|
||||
? undefined
|
||||
: translate(locale, 'onboarding.welcome.createEmptyLoadingDescription')}
|
||||
onClick={onCreateEmptyVault}
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'empty'}
|
||||
|
|
@ -465,16 +488,18 @@ function WelcomeActions({
|
|||
buttonRef={createEmptyActionRef}
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />}
|
||||
iconBg="var(--accent-green-light)"
|
||||
label={presentation.openFolderLabel}
|
||||
description={translate(locale, 'onboarding.welcome.openExistingDescription')}
|
||||
onClick={onOpenFolder}
|
||||
disabled={busy}
|
||||
testId="welcome-open-folder"
|
||||
buttonRef={openFolderActionRef}
|
||||
/>
|
||||
{!mobile && (
|
||||
<OptionButton
|
||||
icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />}
|
||||
iconBg="var(--accent-green-light)"
|
||||
label={presentation.openFolderLabel}
|
||||
description={translate(locale, 'onboarding.welcome.openExistingDescription')}
|
||||
onClick={onOpenFolder}
|
||||
disabled={busy}
|
||||
testId="welcome-open-folder"
|
||||
buttonRef={openFolderActionRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -556,11 +581,13 @@ export function WelcomeScreen({
|
|||
canRetryTemplate,
|
||||
}: WelcomeScreenProps) {
|
||||
const busy = creatingAction !== null
|
||||
const mobile = isMobilePlatform()
|
||||
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline, locale)
|
||||
const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({
|
||||
mode,
|
||||
busy,
|
||||
isOffline,
|
||||
mobile,
|
||||
onCreateEmptyVault,
|
||||
onOpenFolder,
|
||||
onCreateVault,
|
||||
|
|
@ -581,6 +608,7 @@ export function WelcomeScreen({
|
|||
creatingAction={creatingAction}
|
||||
isOffline={isOffline}
|
||||
locale={locale}
|
||||
mobile={mobile}
|
||||
onCreateEmptyVault={onCreateEmptyVault}
|
||||
onCreateVault={onCreateVault}
|
||||
onOpenFolder={onOpenFolder}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
140
product-source/hololake-platform/src/hooks/useHoloLakeAccount.ts
Normal file
140
product-source/hololake-platform/src/hooks/useHoloLakeAccount.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
labelFromPath,
|
||||
} from '../utils/gettingStartedVault'
|
||||
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
|
||||
import { isMobilePlatform } from '../utils/platform'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
type OnboardingState =
|
||||
| { status: 'loading' }
|
||||
|
|
@ -16,7 +18,7 @@ type OnboardingState =
|
|||
| { status: 'ready'; vaultPath: string }
|
||||
|
||||
type CreatingAction = 'template' | 'empty' | null
|
||||
type ReadyVaultSource = 'template' | 'empty' | 'existing'
|
||||
type ReadyVaultSource = 'template' | 'empty' | 'existing' | 'world' | 'personal'
|
||||
type OnVaultReady = (vaultPath: string, source: ReadyVaultSource) => void
|
||||
type RegisterVault = (
|
||||
vaultPath: string,
|
||||
|
|
@ -51,6 +53,11 @@ interface CreateEmptyVaultHandlerOptions extends ReadyVaultHandlerOptions {
|
|||
setCreatingAction: SetCreatingAction
|
||||
}
|
||||
|
||||
interface MobileKnowledgeHandlerOptions extends CreateEmptyVaultHandlerOptions {
|
||||
command: 'open_mobile_foundation_knowledge' | 'open_mobile_personal_knowledge'
|
||||
source: Extract<ReadyVaultSource, 'world' | 'personal'>
|
||||
}
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
|
@ -263,6 +270,35 @@ function useOpenFolderHandler(
|
|||
}, [options])
|
||||
}
|
||||
|
||||
function useMobileKnowledgeHandler(options: MobileKnowledgeHandlerOptions) {
|
||||
return useCallback(async () => {
|
||||
options.setCreatingAction(options.source === 'world' ? 'template' : 'empty')
|
||||
options.setError(null)
|
||||
|
||||
try {
|
||||
if (options.source === 'personal') {
|
||||
const foundationPath = await tauriCall<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(
|
||||
initialVaultPath: string,
|
||||
options: OnboardingOptions = {},
|
||||
|
|
@ -321,14 +357,14 @@ export function useOnboarding(
|
|||
onVaultReady: options.onVaultReady,
|
||||
})
|
||||
|
||||
const handleCreateVault = useCreateVaultHandler(createTemplateVault, setError)
|
||||
const handleCreateDesktopVault = useCreateVaultHandler(createTemplateVault, setError)
|
||||
|
||||
const retryCreateVault = useCallback(async () => {
|
||||
if (!lastTemplatePath) return
|
||||
await createTemplateVault(lastTemplatePath)
|
||||
}, [createTemplateVault, lastTemplatePath])
|
||||
|
||||
const handleCreateEmptyVault = useCreateEmptyVaultHandler({
|
||||
const handleCreateDesktopEmptyVault = useCreateEmptyVaultHandler({
|
||||
onVaultReady: options.onVaultReady,
|
||||
registerVault: options.registerVault,
|
||||
setCreatingAction,
|
||||
|
|
@ -343,6 +379,42 @@ export function useOnboarding(
|
|||
setState,
|
||||
})
|
||||
|
||||
const openMobileWorldKnowledge = useMobileKnowledgeHandler({
|
||||
command: 'open_mobile_foundation_knowledge',
|
||||
onVaultReady: options.onVaultReady,
|
||||
registerVault: options.registerVault,
|
||||
setCreatingAction,
|
||||
setError,
|
||||
setState,
|
||||
source: 'world',
|
||||
})
|
||||
|
||||
const openMobilePersonalKnowledge = useMobileKnowledgeHandler({
|
||||
command: 'open_mobile_personal_knowledge',
|
||||
onVaultReady: options.onVaultReady,
|
||||
registerVault: options.registerVault,
|
||||
setCreatingAction,
|
||||
setError,
|
||||
setState,
|
||||
source: 'personal',
|
||||
})
|
||||
|
||||
const handleCreateVault = useCallback(async () => {
|
||||
if (isMobilePlatform()) {
|
||||
await openMobileWorldKnowledge()
|
||||
return
|
||||
}
|
||||
await handleCreateDesktopVault()
|
||||
}, [handleCreateDesktopVault, openMobileWorldKnowledge])
|
||||
|
||||
const handleCreateEmptyVault = useCallback(async () => {
|
||||
if (isMobilePlatform()) {
|
||||
await openMobilePersonalKnowledge()
|
||||
return
|
||||
}
|
||||
await handleCreateDesktopEmptyVault()
|
||||
}, [handleCreateDesktopEmptyVault, openMobilePersonalKnowledge])
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath: initialVaultPath })
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,11 +92,30 @@
|
|||
"onboarding.welcome.createEmptyDescription": "Start fresh in an empty folder with HoloLake Era defaults",
|
||||
"onboarding.welcome.createEmptyLoading": "Creating vault...",
|
||||
"onboarding.welcome.createEmptyLoadingDescription": "Preparing HoloLake Era defaults in the selected folder",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device",
|
||||
"onboarding.welcome.openExisting": "Open existing vault",
|
||||
"onboarding.welcome.openExistingDescription": "Point to a folder you already have",
|
||||
"onboarding.welcome.retryDownload": "Retry download",
|
||||
"onboarding.welcome.docsPrompt": "New to HoloLake Era?",
|
||||
"onboarding.welcome.docsLink": "Read the first-launch guide",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.ai.checkingTitle": "Checking AI setup",
|
||||
"onboarding.ai.checkingDescription": "Looking for local AI agents on this machine.",
|
||||
"onboarding.ai.missingTitle": "AI setup is optional",
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,11 +92,30 @@
|
|||
"onboarding.welcome.createEmptyDescription": "使用 HoloLake Era 默认设置,在空文件夹中重新开始",
|
||||
"onboarding.welcome.createEmptyLoading": "正在创建 Vault...",
|
||||
"onboarding.welcome.createEmptyLoadingDescription": "正在所选文件夹中准备 HoloLake Era 默认设置",
|
||||
"onboarding.welcome.mobileWorldTitle": "进入光湖基础世界",
|
||||
"onboarding.welcome.mobileWorldDescription": "打开内置的共同语言世界基础",
|
||||
"onboarding.welcome.mobilePersonalTitle": "建立我的本地知识湖",
|
||||
"onboarding.welcome.mobilePersonalDescription": "在当前手机内建立私人知识湖",
|
||||
"onboarding.welcome.openExisting": "打开现有 Vault",
|
||||
"onboarding.welcome.openExistingDescription": "指向您已有的文件夹",
|
||||
"onboarding.welcome.retryDownload": "重试下载",
|
||||
"onboarding.welcome.docsPrompt": "HoloLake Era 新手?",
|
||||
"onboarding.welcome.docsLink": "阅读首次启动指南",
|
||||
"hololake.account.title": "光湖账号",
|
||||
"hololake.account.description": "使用邮箱验证码登录并同步共同知识世界。手机 API 密钥只保存在当前设备钥匙串。",
|
||||
"hololake.account.email": "邮箱",
|
||||
"hololake.account.code": "验证码",
|
||||
"hololake.account.sendCode": "发送验证码",
|
||||
"hololake.account.sending": "发送中...",
|
||||
"hololake.account.codeSent": "请输入邮件中的六位验证码。",
|
||||
"hololake.account.verify": "验证并登录",
|
||||
"hololake.account.signedIn": "当前设备已登录。",
|
||||
"hololake.account.serverKeys": "服务器模型凭据可直接使用但不会显示;手机密钥永不同步。",
|
||||
"hololake.account.sync": "同步知识库",
|
||||
"hololake.account.syncing": "同步中...",
|
||||
"hololake.account.syncComplete": "已同步仓库提交",
|
||||
"hololake.account.signOut": "退出登录",
|
||||
"hololake.account.error": "账号操作失败:",
|
||||
"onboarding.ai.checkingTitle": "正在检查 AI 设置",
|
||||
"onboarding.ai.checkingDescription": "正在本机上查找本地 AI 代理。",
|
||||
"onboarding.ai.missingTitle": "AI 设置为可选项",
|
||||
|
|
|
|||
|
|
@ -1121,5 +1121,24 @@
|
|||
"hololake.login.errorTitle": "The world entrance did not open",
|
||||
"hololake.login.retry": "Try again",
|
||||
"hololake.login.privacy": "HoloLake never receives or stores your mailbox address. The Fifth Domain controller resolves the bound Guanghu number.",
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready"
|
||||
"hololake.login.nodeReady": "Fifth Domain world entrance ready",
|
||||
"hololake.account.title": "HoloLake account",
|
||||
"hololake.account.description": "Sign in by email to synchronize the shared knowledge world. Device API keys remain in this device's Keychain.",
|
||||
"hololake.account.email": "Email",
|
||||
"hololake.account.code": "Verification code",
|
||||
"hololake.account.sendCode": "Send code",
|
||||
"hololake.account.sending": "Sending...",
|
||||
"hololake.account.codeSent": "Enter the six-digit code from your email.",
|
||||
"hololake.account.verify": "Verify and sign in",
|
||||
"hololake.account.signedIn": "This device is signed in.",
|
||||
"hololake.account.serverKeys": "Server model credentials are usable without being shown. Device keys never sync.",
|
||||
"hololake.account.sync": "Sync knowledge",
|
||||
"hololake.account.syncing": "Syncing...",
|
||||
"hololake.account.syncComplete": "Synced repository commit",
|
||||
"hololake.account.signOut": "Sign out",
|
||||
"hololake.account.error": "Account operation failed:",
|
||||
"onboarding.welcome.mobileWorldTitle": "Enter the HoloLake foundation world",
|
||||
"onboarding.welcome.mobileWorldDescription": "Open the built-in shared language-world foundation",
|
||||
"onboarding.welcome.mobilePersonalTitle": "Create my local knowledge lake",
|
||||
"onboarding.welcome.mobilePersonalDescription": "Create a private knowledge lake inside this device"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export function isWindows(): boolean {
|
|||
return getUserAgent().includes('Windows')
|
||||
}
|
||||
|
||||
export function isMobilePlatform(): boolean {
|
||||
return /Android|iPad|iPhone|iPod/u.test(getUserAgent())
|
||||
}
|
||||
|
||||
export function shouldUseCustomWindowChrome(): boolean {
|
||||
return isTauri() && (isLinux() || isWindows())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -983,6 +983,14 @@ export default defineConfig({
|
|||
port: 5202,
|
||||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
fs: process.env.HOLOLAKE_TEST_DEPENDENCY_ROOT
|
||||
? {
|
||||
allow: [
|
||||
__dirname,
|
||||
path.resolve(process.env.HOLOLAKE_TEST_DEPENDENCY_ROOT),
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: devServerWatchIgnored,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue