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

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