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

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

View file

@ -1135,6 +1135,35 @@ server deployment.
## Updates & Feature Flags
## 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.

View file

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

View file

@ -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 包内预检确认不再包含该静态库。

View file

@ -0,0 +1,72 @@
# ADR 0173: Mobile Git knowledge and device-secret boundary
## Status
Accepted for the next HoloLake Era iOS internal build.
## Context
The mobile client cannot reuse a desktop folder picker, a desktop Git process,
or a plaintext provider-secret file. Treating the app-owned knowledge lake as a
renamed Tolaria vault also leaves first launch, repository synchronization, and
the knowledge Agent without a truthful mobile execution path.
HoloLake needs three different stores whose boundaries remain visible:
1. a bundled foundation world that can open offline;
2. normal Markdown knowledge that can synchronize with the registered Guanghu
code-channel repository;
3. device-only credentials that must never enter Markdown, Git, model context,
logs, receipts, archives, or server synchronization.
## Decision
- iOS creates and opens managed knowledge directories inside the application
sandbox. It never invokes the unsupported mobile folder picker.
- The foundation world is seeded locally and is not evidence of a server
connection, authenticated identity, persona residency, or repository
synchronization.
- A synchronized personal lake contains a typed
`.hololake-sync-policy.json`. Normal Markdown pages are eligible for
synchronization. `本地密钥/**`, `.hololake-*`, and `.git/**` are excluded.
- Email OTP creates a short, device-bound HoloLake session. The production
token is stored in iOS Keychain with
`AccessibleWhenUnlockedThisDeviceOnly` and synchronization disabled. The
metadata file contains only a stable handle, device id, and expiry.
- User-supplied model keys use the same device-only Keychain protection.
Server-supplied model keys never leave the server.
- The mobile model target uses the existing bounded knowledge-Agent tool
contract. It may search and read the active lake and create or replace a
normal Markdown page.
- A synchronized create or edit is complete only after the server atomically
advances the registered bare repository from the exact previous `main`
commit and returns the new 40-character commit plus content SHA-256. A
conflict or missing receipt rolls back the local mutation.
- The server accepts only bounded Markdown paths and content. Traversal,
non-Markdown paths, `本地密钥`, oversized content, and stale base commits
fail closed. Receipts never echo page content.
- Deletion from a synchronized lake remains disabled until it has the same
atomic server receipt and rollback semantics.
- The model is not an authority. The execution sequence remains:
```text
human event and real state
→ constrained model request
→ native allowlisted knowledge executor
→ local filesystem result
→ atomic repository update
→ verifiable commit receipt
```
## Consequences
- Mobile and desktop share Markdown semantics without pretending their storage
and credential environments are identical.
- The knowledge Agent can perform useful read/write work on iOS while every
external mutation still has a native receipt.
- A server outage leaves local knowledge readable but makes synchronized
writes fail closed.
- A TestFlight archive or an App Store Connect upload is not proof of this
capability. Acceptance requires iOS compilation, automated non-leak tests,
deployed server health, repository readback, and a real-device TestFlight
run.

View file

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

View file

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

View file

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

View file

@ -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 */,

View file

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

View file

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

View file

@ -1060,8 +1060,24 @@ fn edit_note_from_tool_args(
let raw_path = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "path")?;
let 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 {

View file

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

View file

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

View file

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

View file

@ -82,6 +82,26 @@ pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|path| path.to_string_lossy().to_string())
}
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"));
}
}

View file

@ -0,0 +1,129 @@
use std::borrow::Cow;
const SERVICE: &str = "com.guanghu.hololake.device-secrets";
pub(crate) const ACCOUNT_SESSION_KIND: &str = "account-session";
#[cfg(any(mobile, test))]
pub(crate) const MODEL_API_KEY_KIND: &str = "model-api-key";
fn normalized_segment(value: &str) -> Result<Cow<'_, str>, String> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.len() > 128
|| !trimmed
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err("Device secret identifier is invalid.".to_string());
}
Ok(if trimmed.bytes().any(|byte| byte.is_ascii_uppercase()) {
Cow::Owned(trimmed.to_ascii_lowercase())
} else {
Cow::Borrowed(trimmed)
})
}
fn account_name(kind: &str, identifier: &str) -> Result<String, String> {
Ok(format!(
"{}:{}",
normalized_segment(kind)?,
normalized_segment(identifier)?
))
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn lookup_options(account: &str) -> security_framework::passwords::PasswordOptions {
let mut options =
security_framework::passwords::PasswordOptions::new_generic_password(SERVICE, account);
options.set_access_synchronized(Some(false));
options
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
pub(crate) fn save(kind: &str, identifier: &str, secret: &str) -> Result<String, String> {
use security_framework::access_control::{ProtectionMode, SecAccessControl};
use security_framework::passwords::set_generic_password_options;
let secret = secret.trim();
if secret.is_empty() {
return Err("Device secret cannot be empty.".to_string());
}
let account = account_name(kind, identifier)?;
let mut options = lookup_options(&account);
let access_control = SecAccessControl::create_with_protection(
Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly),
0,
)
.map_err(|error| {
format!(
"Device Keychain protection failed (OSStatus {}).",
error.code()
)
})?;
options.set_access_control(access_control);
#[cfg(target_os = "ios")]
options.use_protected_keychain();
set_generic_password_options(secret.as_bytes(), options)
.map_err(|error| format!("Device Keychain save failed (OSStatus {}).", error.code()))?;
Ok(account)
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
pub(crate) fn load(kind: &str, identifier: &str) -> Result<String, String> {
use security_framework::passwords::generic_password;
let account = account_name(kind, identifier)?;
let bytes = generic_password(lookup_options(&account))
.map_err(|error| format!("Device Keychain read failed (OSStatus {}).", error.code()))?;
String::from_utf8(bytes).map_err(|_| "Device Keychain value is not valid UTF-8.".to_string())
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
pub(crate) fn delete(kind: &str, identifier: &str) -> Result<(), String> {
use security_framework::passwords::delete_generic_password_options;
use security_framework_sys::base::errSecItemNotFound;
let account = account_name(kind, identifier)?;
match delete_generic_password_options(lookup_options(&account)) {
Ok(()) => Ok(()),
Err(error) if error.code() == errSecItemNotFound => Ok(()),
Err(error) => Err(format!(
"Device Keychain delete failed (OSStatus {}).",
error.code()
)),
}
}
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
pub(crate) fn save(_kind: &str, _identifier: &str, _secret: &str) -> Result<String, String> {
Err("Device-only Keychain storage is unavailable on this platform.".to_string())
}
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
pub(crate) fn load(_kind: &str, _identifier: &str) -> Result<String, String> {
Err("Device-only Keychain storage is unavailable on this platform.".to_string())
}
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
pub(crate) fn delete(_kind: &str, _identifier: &str) -> Result<(), String> {
Err("Device-only Keychain storage is unavailable on this platform.".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn device_secret_handles_are_stable_and_lowercase() {
assert_eq!(
account_name(MODEL_API_KEY_KIND, " OpenAI ").unwrap(),
"model-api-key:openai"
);
}
#[test]
fn device_secret_handles_reject_paths_and_blank_values() {
assert!(account_name(MODEL_API_KEY_KIND, "../secret").is_err());
assert!(account_name(MODEL_API_KEY_KIND, " ").is_err());
}
}

View file

@ -851,6 +851,10 @@ fn load_or_create_identity() -> Result<RouterDeviceIdentity, String> {
load_or_create_identity_at(&path)
}
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)

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,364 @@
use std::fs;
use std::path::Path;
const MOBILE_VAULT_MARKER: &str = ".hololake-mobile-vault";
const WORLD_VAULT_DIRECTORY: &str = "HoloLake Foundation World";
const PERSONAL_VAULT_DIRECTORY: &str = "My HoloLake Knowledge";
const WORLD_FILES: [(&str, &str); 6] = [
(
"welcome.md",
r#"---
type: Note
_pinned: true
---
#
HoloLake
- [[]]
- [[]]
- [[]]
线
"#,
),
(
"光湖世界.md",
r#"---
type: Note
---
#
"#,
),
(
"五域结构.md",
r#"---
type: Note
---
#
- 广
-
-
-
-
使
"#,
),
(
"人与人格体.md",
r#"---
type: Note
---
#
1.
2.
3.
4.
"#,
),
(
"AI-PROMPT.md",
r#"---
type: Note
_organized: true
---
#
-
-
-
-
-
"#,
),
(
"AI-MEMORY.md",
r#"---
type: Note
_organized: true
---
# AI
"#,
),
];
const PERSONAL_FILES: [(&str, &str); 7] = [
(
"welcome.md",
r#"---
type: Note
_pinned: true
---
# HoloLake
[[]] [[]]
"#,
),
(
"光湖世界基础.md",
r#"---
type: Note
---
#
"#,
),
(
"我的湖心.md",
r#"---
type: Note
_pinned: true
---
#
-
-
-
-
"#,
),
(
"AI-PROMPT.md",
r#"---
type: Note
_organized: true
---
#
-
-
-
-
- `AI-MEMORY.md`
"#,
),
(
"AI-MEMORY.md",
r#"---
type: Note
_organized: true
---
# AI
"#,
),
(
".hololake-sync-policy.json",
r#"{
"schema": "guanghu.hololake-knowledge-sync-policy/v1",
"include": ["**/*.md", "views/**/*.yml", "attachments/**"],
"exclude": ["本地密钥/**", ".hololake-*", ".git/**"],
"secret_policy": "keychain-handle-only"
}
"#,
),
(
"本地密钥/README.md",
r#"---
type:
local_only: true
sync: never
---
#
iOS Keychain MarkdownGit
"#,
),
];
#[derive(Clone, Copy)]
enum MobileVaultKind {
World,
Personal,
}
impl MobileVaultKind {
fn directory_name(self) -> &'static str {
match self {
Self::World => WORLD_VAULT_DIRECTORY,
Self::Personal => PERSONAL_VAULT_DIRECTORY,
}
}
fn marker_value(self) -> &'static str {
match self {
Self::World => "hololake-mobile-world-v1\n",
Self::Personal => "hololake-mobile-personal-v1\n",
}
}
fn files(self) -> &'static [(&'static str, &'static str)] {
match self {
Self::World => &WORLD_FILES,
Self::Personal => &PERSONAL_FILES,
}
}
}
pub fn open_or_create_mobile_world_vault(root: &Path) -> Result<String, String> {
open_or_create_mobile_vault(root, MobileVaultKind::World)
}
pub fn open_or_create_mobile_personal_vault(root: &Path) -> Result<String, String> {
open_or_create_mobile_vault(root, MobileVaultKind::Personal)
}
fn open_or_create_mobile_vault(root: &Path, kind: MobileVaultKind) -> Result<String, String> {
open_or_create_mobile_vault_in_documents(root, kind)
}
fn open_or_create_mobile_vault_in_documents(
documents_dir: &Path,
kind: MobileVaultKind,
) -> Result<String, String> {
let path = documents_dir.join(kind.directory_name());
open_or_create_mobile_vault_at(&path, kind)
}
#[cfg(test)]
pub(crate) fn open_or_create_mobile_world_vault_at(path: &Path) -> Result<String, String> {
open_or_create_mobile_vault_at(path, MobileVaultKind::World)
}
#[cfg(test)]
pub(crate) fn open_or_create_mobile_personal_vault_at(path: &Path) -> Result<String, String> {
open_or_create_mobile_vault_at(path, MobileVaultKind::Personal)
}
#[cfg(test)]
pub(crate) fn open_or_create_mobile_vaults_in_documents(
documents_dir: &Path,
) -> Result<(String, String), String> {
let world = open_or_create_mobile_vault_in_documents(documents_dir, MobileVaultKind::World)?;
let personal =
open_or_create_mobile_vault_in_documents(documents_dir, MobileVaultKind::Personal)?;
Ok((world, personal))
}
fn open_or_create_mobile_vault_at(path: &Path, kind: MobileVaultKind) -> Result<String, String> {
if path.exists() {
validate_existing_mobile_vault(path, kind)?;
return canonical_path_string(path);
}
create_mobile_vault(path, kind)?;
canonical_path_string(path)
}
fn validate_existing_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> {
if !path.is_dir() {
return Err(format!(
"The mobile vault path already exists and is not a folder: {}",
path.display()
));
}
let marker = fs::read_to_string(path.join(MOBILE_VAULT_MARKER)).map_err(|_| {
format!(
"The mobile vault folder already exists but is not managed by HoloLake: {}",
path.display()
)
})?;
if marker != kind.marker_value() {
return Err(format!(
"The mobile vault folder already exists with a different HoloLake vault type: {}",
path.display()
));
}
let required_files_present = kind
.files()
.iter()
.all(|(relative_path, _)| path.join(relative_path).is_file());
if !required_files_present {
return Err(format!(
"The mobile vault folder already exists but its foundation is incomplete: {}",
path.display()
));
}
Ok(())
}
fn create_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> {
fs::create_dir_all(path)
.map_err(|error| format!("Failed to create the mobile vault folder: {error}"))?;
let result = seed_mobile_vault(path, kind);
if result.is_err() {
let _ = fs::remove_dir_all(path);
}
result
}
fn seed_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> {
for (relative_path, content) in kind.files() {
let output = path.join(relative_path);
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {relative_path}: {error}"))?;
}
fs::write(output, content)
.map_err(|error| format!("Failed to write {relative_path}: {error}"))?;
}
fs::write(path.join(MOBILE_VAULT_MARKER), kind.marker_value())
.map_err(|error| format!("Failed to write the mobile vault marker: {error}"))?;
crate::vault::seed_config_files(path.to_string_lossy());
for required in ["AGENTS.md", "type.md", "note.md"] {
if !path.join(required).is_file() {
return Err(format!(
"Failed to seed required mobile vault file: {required}"
));
}
}
Ok(())
}
fn canonical_path_string(path: &Path) -> Result<String, String> {
path.canonicalize()
.map(|resolved| resolved.to_string_lossy().to_string())
.map_err(|error| format!("Failed to resolve mobile vault path: {error}"))
}

View file

@ -9,6 +9,7 @@ mod getting_started;
mod ignored;
mod 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,

View file

@ -0,0 +1,91 @@
import { useState } from 'react'
import { useHoloLakeAccount } from '../hooks/useHoloLakeAccount'
import type { AppLocale, TranslationKey } from '../lib/i18n'
import { translate } from '../lib/i18n'
import { Button } from './ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from './ui/card'
import { Input } from './ui/input'
export function HoloLakeAccountPanel({ locale }: { locale: AppLocale }) {
const account = useHoloLakeAccount()
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const t = (key: TranslationKey) => translate(locale, key)
const busy = account.state.phase === 'busy' || account.state.phase === 'checking'
const authenticated = account.state.phase === 'authenticated'
const awaitingCode = account.state.phase === 'code-sent'
return (
<Card className="hololake-account-panel">
<CardHeader>
<CardTitle>{t('hololake.account.title')}</CardTitle>
<CardDescription>{t('hololake.account.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{authenticated ? (
<div className="space-y-2" aria-live="polite">
<p className="text-sm font-medium">{t('hololake.account.signedIn')}</p>
<p className="text-muted-foreground text-sm">{t('hololake.account.serverKeys')}</p>
{account.state.lastCommit
? <p className="text-muted-foreground text-xs">{t('hololake.account.syncComplete')} · <code>{account.state.lastCommit.slice(0, 12)}</code></p>
: null}
</div>
) : awaitingCode ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">{t('hololake.account.codeSent')}</p>
<Input
aria-label={t('hololake.account.code')}
autoComplete="one-time-code"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder={t('hololake.account.code')}
value={code}
/>
</div>
) : (
<Input
aria-label={t('hololake.account.email')}
autoCapitalize="none"
autoComplete="email"
inputMode="email"
onChange={(event) => setEmail(event.target.value)}
placeholder={t('hololake.account.email')}
type="email"
value={email}
/>
)}
{account.state.error
? <p className="text-destructive text-sm" role="alert">{t('hololake.account.error')} <code>{account.state.error}</code></p>
: null}
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
{authenticated ? (
<>
<Button disabled={busy} onClick={() => void account.syncKnowledge()}>
{busy ? t('hololake.account.syncing') : t('hololake.account.sync')}
</Button>
<Button disabled={busy} onClick={() => void account.logout()} variant="outline">
{t('hololake.account.signOut')}
</Button>
</>
) : awaitingCode ? (
<Button disabled={busy || code.length !== 6} onClick={() => void account.verifyCode(code)}>
{t('hololake.account.verify')}
</Button>
) : (
<Button disabled={busy || !email.includes('@')} onClick={() => void account.requestCode(email)}>
{busy ? t('hololake.account.sending') : t('hololake.account.sendCode')}
</Button>
)}
</CardFooter>
</Card>
)
}

View file

@ -26,6 +26,7 @@ import { GuanghuWorldMap } from './GuanghuWorldMap'
import { GuanghuWorldLoginGate } from './GuanghuWorldLoginGate'
import { 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} />

View file

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

View file

@ -0,0 +1,125 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useHoloLakeAccount } from './useHoloLakeAccount'
const { invokeMock, isTauriMock, trackEventMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
isTauriMock: vi.fn(),
trackEventMock: vi.fn(),
}))
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }))
vi.mock('../mock-tauri', () => ({ isTauri: isTauriMock }))
vi.mock('../lib/telemetry', () => ({ trackEvent: trackEventMock }))
describe('useHoloLakeAccount', () => {
beforeEach(() => {
vi.clearAllMocks()
isTauriMock.mockReturnValue(true)
invokeMock.mockImplementation((command: string) => {
if (command === 'hololake_account_status') {
return Promise.resolve({ state: 'signed_out' })
}
return Promise.resolve({})
})
})
it('requests and verifies an email code without exposing a session token', async () => {
invokeMock.mockImplementation((command: string) => {
if (command === 'hololake_account_status') return Promise.resolve({ state: 'signed_out' })
if (command === 'hololake_account_request_email_code') {
return Promise.resolve({ request_id: 'request-001' })
}
if (command === 'hololake_account_verify_email_code') {
return Promise.resolve({ state: 'authenticated', expires_at: 1_800_086_400 })
}
return Promise.resolve({})
})
const { result } = renderHook(() => useHoloLakeAccount())
await waitFor(() => expect(result.current.state.phase).toBe('signed-out'))
await act(() => result.current.requestCode('owner@example.invalid'))
expect(result.current.state.phase).toBe('code-sent')
expect(invokeMock).toHaveBeenCalledWith(
'hololake_account_request_email_code',
{ email: 'owner@example.invalid' },
)
await act(() => result.current.verifyCode('123456'))
expect(result.current.state.phase).toBe('authenticated')
expect(result.current.state).not.toHaveProperty('sessionToken')
expect(trackEventMock).toHaveBeenCalledWith(
'hololake_account_login_completed',
{ result: 'authenticated' },
)
})
it('records only the knowledge commit after a successful sync', async () => {
invokeMock.mockImplementation((command: string) => {
if (command === 'hololake_account_status') {
return Promise.resolve({ state: 'authenticated', expires_at: 1_800_086_400 })
}
if (command === 'hololake_sync_knowledge') {
return Promise.resolve({ commit: 'a'.repeat(40) })
}
return Promise.resolve({})
})
const { result } = renderHook(() => useHoloLakeAccount())
await waitFor(() => expect(result.current.state.phase).toBe('authenticated'))
await act(() => result.current.syncKnowledge())
expect(result.current.state.lastCommit).toBe('a'.repeat(40))
expect(trackEventMock).toHaveBeenCalledWith(
'hololake_knowledge_sync_completed',
{ result: 'success' },
)
})
it('keeps the current interaction available after recoverable failures', async () => {
invokeMock.mockImplementation((command: string) => {
if (command === 'hololake_account_status') return Promise.resolve({ state: 'signed_out' })
if (command === 'hololake_account_request_email_code') {
return Promise.resolve({ request_id: 'request-001' })
}
if (command === 'hololake_account_verify_email_code') {
return Promise.reject(new Error('hololake_login_verification_failed'))
}
return Promise.resolve({})
})
const { result } = renderHook(() => useHoloLakeAccount())
await waitFor(() => expect(result.current.state.phase).toBe('signed-out'))
await act(() => result.current.requestCode('owner@example.invalid'))
await act(() => result.current.verifyCode('000000'))
expect(result.current.state.phase).toBe('code-sent')
expect(result.current.state.requestId).toBe('request-001')
expect(result.current.state.error).toBe('hololake_login_verification_failed')
})
it('keeps an authenticated session usable when knowledge sync fails', async () => {
invokeMock.mockImplementation((command: string) => {
if (command === 'hololake_account_status') {
return Promise.resolve({ state: 'authenticated', expires_at: 1_800_086_400 })
}
if (command === 'hololake_sync_knowledge') {
return Promise.reject(new Error('hololake_knowledge_archive_failed'))
}
return Promise.resolve({})
})
const { result } = renderHook(() => useHoloLakeAccount())
await waitFor(() => expect(result.current.state.phase).toBe('authenticated'))
await act(() => result.current.syncKnowledge())
expect(result.current.state.phase).toBe('authenticated')
expect(result.current.state.error).toBe('hololake_knowledge_archive_failed')
})
it('stays signed out in the browser preview without calling native commands', async () => {
isTauriMock.mockReturnValue(false)
const { result } = renderHook(() => useHoloLakeAccount())
await waitFor(() => expect(result.current.state.phase).toBe('signed-out'))
expect(invokeMock).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,140 @@
import { invoke } from '@tauri-apps/api/core'
import { useCallback, useEffect, useState } from 'react'
import { trackEvent } from '../lib/telemetry'
import { isTauri } from '../mock-tauri'
type AccountPhase = 'checking' | 'signed-out' | 'code-sent' | 'authenticated' | 'busy' | 'error'
type AccountState = {
error: string | null
expiresAt: number | null
lastCommit: string | null
phase: AccountPhase
requestId: string | null
}
type NativeAccountStatus = {
expires_at?: number
state?: 'authenticated' | 'signed_out'
}
type NativeCodeRequest = {
request_id?: string
}
type NativeKnowledgeReceipt = {
commit?: string
}
const initialState: AccountState = {
error: null,
expiresAt: null,
lastCommit: null,
phase: 'checking',
requestId: null,
}
function errorText(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
export function useHoloLakeAccount() {
const [state, setState] = useState<AccountState>(initialState)
const refresh = useCallback(async () => {
if (!isTauri()) {
setState((current) => ({ ...current, phase: 'signed-out' }))
return
}
try {
const status = await invoke<NativeAccountStatus>('hololake_account_status')
setState((current) => ({
...current,
error: null,
expiresAt: status.expires_at ?? null,
phase: status.state === 'authenticated' ? 'authenticated' : 'signed-out',
}))
} catch (error) {
setState((current) => ({ ...current, error: errorText(error), phase: 'signed-out' }))
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const requestCode = useCallback(async (email: string) => {
setState((current) => ({ ...current, error: null, phase: 'busy' }))
trackEvent('hololake_account_code_requested', { surface: 'account-panel' })
try {
const result = await invoke<NativeCodeRequest>('hololake_account_request_email_code', { email })
if (!result.request_id) throw new Error('hololake_login_request_id_missing')
setState((current) => ({
...current,
phase: 'code-sent',
requestId: result.request_id ?? null,
}))
} catch (error) {
setState((current) => ({ ...current, error: errorText(error), phase: 'error' }))
}
}, [])
const verifyCode = useCallback(async (code: string) => {
if (!state.requestId) return
setState((current) => ({ ...current, error: null, phase: 'busy' }))
try {
const result = await invoke<NativeAccountStatus>('hololake_account_verify_email_code', {
requestId: state.requestId,
code,
})
trackEvent('hololake_account_login_completed', { result: 'authenticated' })
setState((current) => ({
...current,
expiresAt: result.expires_at ?? null,
phase: 'authenticated',
requestId: null,
}))
} catch (error) {
trackEvent('hololake_account_login_completed', { result: 'error' })
setState((current) => ({ ...current, error: errorText(error), phase: 'code-sent' }))
}
}, [state.requestId])
const syncKnowledge = useCallback(async () => {
setState((current) => ({ ...current, error: null, phase: 'busy' }))
trackEvent('hololake_knowledge_sync_requested', { repository: 'hololake-knowledge-base' })
try {
const result = await invoke<NativeKnowledgeReceipt>('hololake_sync_knowledge')
trackEvent('hololake_knowledge_sync_completed', { result: 'success' })
setState((current) => ({
...current,
lastCommit: result.commit ?? null,
phase: 'authenticated',
}))
} catch (error) {
trackEvent('hololake_knowledge_sync_completed', { result: 'error' })
setState((current) => ({ ...current, error: errorText(error), phase: 'authenticated' }))
}
}, [])
const logout = useCallback(async () => {
setState((current) => ({ ...current, error: null, phase: 'busy' }))
try {
await invoke('hololake_account_logout')
trackEvent('hololake_account_logout_completed')
setState(initialState)
await refresh()
} catch (error) {
setState((current) => ({ ...current, error: errorText(error), phase: 'authenticated' }))
}
}, [refresh])
return {
logout,
requestCode,
state,
syncKnowledge,
verifyCode,
}
}

View file

@ -8,6 +8,8 @@ import {
labelFromPath,
} 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 })

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 设置为可选项",

View file

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

View file

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

View file

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