feat(ios): add native Git knowledge and device-only secrets
This commit is contained in:
parent
da26f27e35
commit
b30f814320
51 changed files with 3019 additions and 97 deletions
|
|
@ -1885,6 +1885,8 @@ dependencies = [
|
|||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"ring",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1903,6 +1905,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip 0.6.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5349,7 +5352,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7268,6 +7271,18 @@ dependencies = [
|
|||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
|
|
|
|||
|
|
@ -52,11 +52,18 @@ quick-xml = { version = "0.38", features = ["serialize"] }
|
|||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
ring = "0.17"
|
||||
zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.3"
|
||||
objc2-app-kit = "0.3.2"
|
||||
objc2-foundation = "0.3.2"
|
||||
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] }
|
||||
security-framework = "3.5.1"
|
||||
security-framework-sys = "2.14.0"
|
||||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
security-framework = "3.5.1"
|
||||
security-framework-sys = "2.14.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@
|
|||
47E641E8D831982238FCF5D4 /* hermes_cli.rs */ = {isa = PBXFileReference; path = hermes_cli.rs; sourceTree = "<group>"; };
|
||||
47EDAF686EACF168E90E00C9 /* paths.rs */ = {isa = PBXFileReference; path = paths.rs; sourceTree = "<group>"; };
|
||||
497C22F10C21FCB4CF1978A0 /* line_stream.rs */ = {isa = PBXFileReference; path = line_stream.rs; sourceTree = "<group>"; };
|
||||
4CC25FAC04523FC1407E890B /* guanghu_world_login.rs */ = {isa = PBXFileReference; path = guanghu_world_login.rs; sourceTree = "<group>"; };
|
||||
4E4BBC1CC65B04620FE5ACF7 /* memory.rs */ = {isa = PBXFileReference; path = memory.rs; sourceTree = "<group>"; };
|
||||
515B73736D1F5EA458FF4CBF /* claude_invocation.rs */ = {isa = PBXFileReference; path = claude_invocation.rs; sourceTree = "<group>"; };
|
||||
53E80AB4F48A6E53B2338B4B /* ai_model_tools.rs */ = {isa = PBXFileReference; path = ai_model_tools.rs; sourceTree = "<group>"; };
|
||||
|
|
@ -89,6 +90,7 @@
|
|||
7599B204D83DCC8DC75458B1 /* frontmatter_regression_tests.rs */ = {isa = PBXFileReference; path = frontmatter_regression_tests.rs; sourceTree = "<group>"; };
|
||||
7876AB1696C6A473C0CA60DD /* dates.rs */ = {isa = PBXFileReference; path = dates.rs; sourceTree = "<group>"; };
|
||||
79E99FECA35482A58E9929E8 /* upstream.rs */ = {isa = PBXFileReference; path = upstream.rs; sourceTree = "<group>"; };
|
||||
7DE25EB5FAB7CE0ACBD80ABB /* guanghu_router.rs */ = {isa = PBXFileReference; path = guanghu_router.rs; sourceTree = "<group>"; };
|
||||
7FEB8ACB3EFE38A9C2273981 /* remote.rs */ = {isa = PBXFileReference; path = remote.rs; sourceTree = "<group>"; };
|
||||
814BEE45F3957BA070B8A6FE /* keys.rs */ = {isa = PBXFileReference; path = keys.rs; sourceTree = "<group>"; };
|
||||
83CCD3A86C61E070EB9C029B /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = "<group>"; };
|
||||
|
|
@ -106,6 +108,7 @@
|
|||
960E88B1AC470035ADEC0DD8 /* mcp_config.rs */ = {isa = PBXFileReference; path = mcp_config.rs; sourceTree = "<group>"; };
|
||||
975BD8B6821A8FA0356DFE31 /* hololake_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = hololake_iOS.entitlements; sourceTree = "<group>"; };
|
||||
98555F139A6093466C0DD80E /* extraction.rs */ = {isa = PBXFileReference; path = extraction.rs; sourceTree = "<group>"; };
|
||||
986A5C5A5D4E1DE0DB1DB3D9 /* hololake_account.rs */ = {isa = PBXFileReference; path = hololake_account.rs; sourceTree = "<group>"; };
|
||||
9935075CB2F77C7FF5DEC9CD /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
|
||||
99B3DA704DB835E530007E84 /* antigravity_config.rs */ = {isa = PBXFileReference; path = antigravity_config.rs; sourceTree = "<group>"; };
|
||||
9C5AB398816652B7DA146489 /* windows_cmd_shim.rs */ = {isa = PBXFileReference; path = windows_cmd_shim.rs; sourceTree = "<group>"; };
|
||||
|
|
@ -123,9 +126,11 @@
|
|||
B0B4AE2937945F1896E1B205 /* mod_tests.rs */ = {isa = PBXFileReference; path = mod_tests.rs; sourceTree = "<group>"; };
|
||||
B37489EAD8111AF691B4E56D /* opencode_config.rs */ = {isa = PBXFileReference; path = opencode_config.rs; sourceTree = "<group>"; };
|
||||
B377452F2570C8D916B24737 /* rename.rs */ = {isa = PBXFileReference; path = rename.rs; sourceTree = "<group>"; };
|
||||
B594CFA2AE6E07F7EEBDCA5F /* guanghu_living_system.rs */ = {isa = PBXFileReference; path = guanghu_living_system.rs; sourceTree = "<group>"; };
|
||||
B7223A7822564745FD4B7CDD /* app_icon.rs */ = {isa = PBXFileReference; path = app_icon.rs; sourceTree = "<group>"; };
|
||||
B7436FF982457F75FD04F370 /* ai.rs */ = {isa = PBXFileReference; path = ai.rs; sourceTree = "<group>"; };
|
||||
B7EEF25B97EA865DC2F2B16F /* app_icon.rs */ = {isa = PBXFileReference; path = app_icon.rs; sourceTree = "<group>"; };
|
||||
B873D19C73F8DB13E8418612 /* guanghu_shanghai_node.rs */ = {isa = PBXFileReference; path = guanghu_shanghai_node.rs; sourceTree = "<group>"; };
|
||||
BA9780D7C82253E700210AD1 /* pi_cli.rs */ = {isa = PBXFileReference; path = pi_cli.rs; sourceTree = "<group>"; };
|
||||
BB2C976547D1CF4F9CEDE8E1 /* antigravity_discovery.rs */ = {isa = PBXFileReference; path = antigravity_discovery.rs; sourceTree = "<group>"; };
|
||||
BB68BFCC620A099FBDFE5234 /* folders.rs */ = {isa = PBXFileReference; path = folders.rs; sourceTree = "<group>"; };
|
||||
|
|
@ -162,11 +167,14 @@
|
|||
E502EAEA6030504F91B1B6B8 /* command.rs */ = {isa = PBXFileReference; path = command.rs; sourceTree = "<group>"; };
|
||||
E7E43D90B97E284B2F6A717D /* frontmatter.rs */ = {isa = PBXFileReference; path = frontmatter.rs; sourceTree = "<group>"; };
|
||||
E8A91F6EDFE1DF22A75BAC5B /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||
EBFA0B7CAB8661AB3605599A /* mobile_vault.rs */ = {isa = PBXFileReference; path = mobile_vault.rs; sourceTree = "<group>"; };
|
||||
EE36F56C627BC172C7CCA065 /* pi_events.rs */ = {isa = PBXFileReference; path = pi_events.rs; sourceTree = "<group>"; };
|
||||
EEA7BC2EB818A793D68CECA1 /* frontmatter_cmds.rs */ = {isa = PBXFileReference; path = frontmatter_cmds.rs; sourceTree = "<group>"; };
|
||||
EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */ = {isa = PBXFileReference; path = modified_dates_tests.rs; sourceTree = "<group>"; };
|
||||
F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */ = {isa = PBXFileReference; path = kiro_discovery.rs; sourceTree = "<group>"; };
|
||||
F1238B11A81DABC7B28747B9 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = "<group>"; };
|
||||
F2D9E48A1998AD5837EBE8C5 /* guanghu_enterprise.rs */ = {isa = PBXFileReference; path = guanghu_enterprise.rs; sourceTree = "<group>"; };
|
||||
F5DC0A0680C3D852550B4558 /* hldp_runtime.rs */ = {isa = PBXFileReference; path = hldp_runtime.rs; sourceTree = "<group>"; };
|
||||
F7D0E6E67B410BBCCF64F197 /* file_url.rs */ = {isa = PBXFileReference; path = file_url.rs; sourceTree = "<group>"; };
|
||||
F86E7BC210B3629F404B302E /* basics.rs */ = {isa = PBXFileReference; path = basics.rs; sourceTree = "<group>"; };
|
||||
F920B9501682227EB4C5580B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
|
|
@ -176,6 +184,7 @@
|
|||
FBB7A51D7CCC05D3348D1190 /* vault_list.rs */ = {isa = PBXFileReference; path = vault_list.rs; sourceTree = "<group>"; };
|
||||
FBD91C23BBFFCA204E30DF3C /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
|
||||
FE4F7CE296F76FBE49B288EE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
FEE79A2B08D489C84E5C130A /* device_secret_store.rs */ = {isa = PBXFileReference; path = device_secret_store.rs; sourceTree = "<group>"; };
|
||||
FF3044079CC906291942C508 /* codex_cli.rs */ = {isa = PBXFileReference; path = codex_cli.rs; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -221,6 +230,7 @@
|
|||
2B938E7363F949A622DD8397 /* ignored.rs */,
|
||||
E19D7FEEBC114189FCF0F84A /* image.rs */,
|
||||
65A73B64D88239A170CF299A /* migration.rs */,
|
||||
EBFA0B7CAB8661AB3605599A /* mobile_vault.rs */,
|
||||
B0B4AE2937945F1896E1B205 /* mod_tests.rs */,
|
||||
83CCD3A86C61E070EB9C029B /* mod.rs */,
|
||||
EFA6483B1269D2EC0DBD4D5A /* modified_dates_tests.rs */,
|
||||
|
|
@ -370,8 +380,16 @@
|
|||
FF3044079CC906291942C508 /* codex_cli.rs */,
|
||||
BEE7A0C1F8EE18EDAB31905A /* copilot_cli.rs */,
|
||||
282A3A834C97BA5453CC9355 /* copilot_discovery.rs */,
|
||||
FEE79A2B08D489C84E5C130A /* device_secret_store.rs */,
|
||||
F2D9E48A1998AD5837EBE8C5 /* guanghu_enterprise.rs */,
|
||||
B594CFA2AE6E07F7EEBDCA5F /* guanghu_living_system.rs */,
|
||||
7DE25EB5FAB7CE0ACBD80ABB /* guanghu_router.rs */,
|
||||
B873D19C73F8DB13E8418612 /* guanghu_shanghai_node.rs */,
|
||||
4CC25FAC04523FC1407E890B /* guanghu_world_login.rs */,
|
||||
47E641E8D831982238FCF5D4 /* hermes_cli.rs */,
|
||||
A0B9AEB056A7B2713B0EF52A /* hermes_discovery.rs */,
|
||||
F5DC0A0680C3D852550B4558 /* hldp_runtime.rs */,
|
||||
986A5C5A5D4E1DE0DB1DB3D9 /* hololake_account.rs */,
|
||||
1CEF6C06C1287DD4AE2F9B51 /* kiro_cli.rs */,
|
||||
F0D5D034E9AE877D8F630233 /* kiro_discovery.rs */,
|
||||
8969FA0D7729542C8FE8ED93 /* lib.rs */,
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.7</string>
|
||||
<string>0.4.6</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20</string>
|
||||
<string>26</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ targets:
|
|||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
CFBundleDisplayName: HoloLake
|
||||
CFBundleShortVersionString: 0.1.7
|
||||
CFBundleVersion: "20"
|
||||
CFBundleShortVersionString: 0.4.6
|
||||
CFBundleVersion: "26"
|
||||
entitlements:
|
||||
path: hololake_iOS/hololake_iOS.entitlements
|
||||
scheme:
|
||||
|
|
|
|||
|
|
@ -1060,8 +1060,24 @@ fn edit_note_from_tool_args(
|
|||
let raw_path = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "path")?;
|
||||
let content = required_tool_string(args, EDIT_NOTE_TOOL_NAME, "content")?;
|
||||
let path = resolve_existing_vault_note(request, raw_path)?;
|
||||
let previous = std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("Failed to preserve the note before editing: {error}"))?;
|
||||
crate::vault::save_note_content(path.to_string_lossy().as_ref(), content)?;
|
||||
Ok(format!("已更新笔记:{}", path.display()))
|
||||
let synchronized_commit = match crate::hololake_account::sync_local_markdown_page(
|
||||
Path::new(active_vault_path(request)?),
|
||||
&path,
|
||||
content,
|
||||
) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => {
|
||||
let _ = crate::vault::save_note_content(path.to_string_lossy().as_ref(), &previous);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(match synchronized_commit {
|
||||
Some(commit) => format!("已更新笔记:{};同步提交:{commit}", path.display()),
|
||||
None => format!("已更新笔记:{}", path.display()),
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_note_from_tool_args(
|
||||
|
|
@ -1070,6 +1086,15 @@ fn delete_note_from_tool_args(
|
|||
) -> Result<String, String> {
|
||||
let raw_path = required_tool_string(args, DELETE_NOTE_TOOL_NAME, "path")?;
|
||||
let path = resolve_existing_vault_note(request, raw_path)?;
|
||||
if Path::new(active_vault_path(request)?)
|
||||
.join(".hololake-sync-policy.json")
|
||||
.is_file()
|
||||
{
|
||||
return Err(
|
||||
"Git-backed HoloLake pages cannot be deleted until a server delete receipt is available."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
crate::vault::delete_note(path.to_string_lossy().as_ref())?;
|
||||
Ok(format!("已删除笔记:{}", path.display()))
|
||||
}
|
||||
|
|
@ -1286,12 +1311,29 @@ fn create_note_from_tool_args(
|
|||
let vault_path = tool_vault_path(request, args)?;
|
||||
crate::commands::create_note_content(
|
||||
PathBuf::from(note_path),
|
||||
content,
|
||||
content.clone(),
|
||||
Some(PathBuf::from(vault_path)),
|
||||
)?;
|
||||
let synchronized_commit = match crate::hololake_account::sync_local_markdown_page(
|
||||
Path::new(vault_path),
|
||||
Path::new(note_path),
|
||||
&content,
|
||||
) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => {
|
||||
let created_path = if Path::new(note_path).is_absolute() {
|
||||
PathBuf::from(note_path)
|
||||
} else {
|
||||
Path::new(vault_path).join(note_path)
|
||||
};
|
||||
let _ = std::fs::remove_file(created_path);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let output = serde_json::json!({
|
||||
"path": note_path,
|
||||
"vaultPath": vault_path,
|
||||
"synchronizedCommit": synchronized_commit,
|
||||
})
|
||||
.to_string();
|
||||
Ok(OpenAiToolResult {
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ fn tool_choice_compatibility_error(error: &str) -> bool {
|
|||
|| normalized.contains("invalid parameter"))
|
||||
}
|
||||
|
||||
fn run_openai_agent_loop<F, S>(
|
||||
pub(crate) fn run_openai_agent_loop<F, S>(
|
||||
request: &AiModelStreamRequest,
|
||||
mut payload: serde_json::Value,
|
||||
emit: &mut F,
|
||||
|
|
@ -656,18 +656,40 @@ pub fn save_provider_api_key(provider_id: String, api_key: String) -> Result<(),
|
|||
if api_key.is_empty() {
|
||||
return Err("API key cannot be empty.".into());
|
||||
}
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.insert(provider_id, api_key);
|
||||
write_secrets_at(&path, &secrets)
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
crate::device_secret_store::save(
|
||||
crate::device_secret_store::MODEL_API_KEY_KIND,
|
||||
&provider_id,
|
||||
&api_key,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.insert(provider_id, api_key);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
let provider_id = normalize_secret_provider_id(&provider_id)?;
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.remove(&provider_id);
|
||||
write_secrets_at(&path, &secrets)
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
return crate::device_secret_store::delete(
|
||||
crate::device_secret_store::MODEL_API_KEY_KIND,
|
||||
&provider_id,
|
||||
);
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.remove(&provider_id);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_secret_provider_id(provider_id: &str) -> Result<String, String> {
|
||||
|
|
@ -729,19 +751,30 @@ fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
|
|||
}
|
||||
|
||||
fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
let secrets = read_secrets_at(&secrets_path()?)?;
|
||||
let api_key = secrets
|
||||
.provider_api_keys
|
||||
.get(&request.provider.id)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
if api_key.is_none() {
|
||||
return Err(format!(
|
||||
"No local API key is saved for {}.",
|
||||
request.provider.name
|
||||
));
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
let api_key = crate::device_secret_store::load(
|
||||
crate::device_secret_store::MODEL_API_KEY_KIND,
|
||||
&request.provider.id,
|
||||
)?;
|
||||
return Ok(Some(api_key));
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let secrets = read_secrets_at(&secrets_path()?)?;
|
||||
let api_key = secrets
|
||||
.provider_api_keys
|
||||
.get(&request.provider.id)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
if api_key.is_none() {
|
||||
return Err(format!(
|
||||
"No local API key is saved for {}.",
|
||||
request.provider.name
|
||||
));
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
|||
use std::sync::OnceLock;
|
||||
|
||||
const APP_CONFIG_POLICY_JSON: &str = include_str!("../../mcp-server/app-config-policy.json");
|
||||
static MOBILE_APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppConfigPolicy {
|
||||
|
|
@ -48,6 +49,9 @@ fn app_config_dir() -> Result<PathBuf, String> {
|
|||
}
|
||||
|
||||
fn primary_config_dir() -> Option<PathBuf> {
|
||||
if let Some(path) = MOBILE_APP_CONFIG_DIR.get() {
|
||||
return Some(path.clone());
|
||||
}
|
||||
primary_config_dir_from_sources(
|
||||
explicit_xdg_config_home(),
|
||||
dirs::home_dir(),
|
||||
|
|
@ -55,6 +59,16 @@ fn primary_config_dir() -> Option<PathBuf> {
|
|||
)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
pub(crate) fn install_mobile_app_config_dir(path: PathBuf) -> Result<(), String> {
|
||||
if !path.is_absolute() {
|
||||
return Err("Mobile app config directory must be absolute".to_string());
|
||||
}
|
||||
MOBILE_APP_CONFIG_DIR
|
||||
.set(path)
|
||||
.map_err(|_| "Mobile app config directory was already initialized".to_string())
|
||||
}
|
||||
|
||||
fn primary_config_dir_from_sources(
|
||||
explicit_xdg: Option<PathBuf>,
|
||||
home: Option<PathBuf>,
|
||||
|
|
|
|||
|
|
@ -339,33 +339,56 @@ pub fn abort_ai_agent_stream(_event_name: String) -> Result<bool, String> {
|
|||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_model(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: crate::ai_models::AiModelStreamRequest,
|
||||
app_handle: tauri::AppHandle,
|
||||
request: crate::ai_models::AiModelStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Direct AI model chat is not available in this mobile build yet.".into())
|
||||
use tauri::Emitter;
|
||||
|
||||
if crate::hololake_account::is_server_ai_provider(&request.provider) {
|
||||
return crate::hololake_account::run_mobile_ai_model_stream(app_handle, request).await;
|
||||
}
|
||||
|
||||
let event_name = request
|
||||
.event_name
|
||||
.clone()
|
||||
.filter(|name| {
|
||||
name.strip_prefix("ai-model-stream-").is_some_and(|suffix| {
|
||||
!suffix.is_empty()
|
||||
&& suffix
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '-')
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "ai-model-stream".to_string());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::ai_models::run_ai_model_stream(request, |event| {
|
||||
let _ = app_handle.emit(event_name.as_str(), event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Mobile AI task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn save_ai_model_provider_api_key(
|
||||
_provider_id: String,
|
||||
_api_key: String,
|
||||
) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
|
||||
crate::ai_models::save_provider_api_key(provider_id, api_key)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
crate::ai_models::delete_provider_api_key(provider_id)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn test_ai_model_provider(
|
||||
_request: crate::ai_models::AiModelProviderTestRequest,
|
||||
pub async fn test_ai_model_provider(
|
||||
request: crate::ai_models::AiModelProviderTestRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Direct AI model tests are not available in this mobile build yet.".into())
|
||||
tokio::task::spawn_blocking(move || crate::ai_models::test_ai_model_provider(request))
|
||||
.await
|
||||
.map_err(|error| format!("Mobile AI test failed: {error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -82,6 +82,26 @@ pub fn get_default_vault_path() -> Result<String, String> {
|
|||
vault::default_vault_path().map(|path| path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn mobile_knowledge_root(app_handle: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
use tauri::Manager;
|
||||
|
||||
app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map(|path| path.join("knowledge"))
|
||||
.map_err(|error| format!("Could not resolve the HoloLake knowledge directory: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_mobile_foundation_knowledge(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
vault::open_or_create_mobile_world_vault(&mobile_knowledge_root(&app_handle)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_mobile_personal_knowledge(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
vault::open_or_create_mobile_personal_vault(&mobile_knowledge_root(&app_handle)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
|
|
@ -153,4 +173,22 @@ mod tests {
|
|||
Ok(explicit.to_string_lossy().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_knowledge_roots_are_stable_and_separate_local_secrets() {
|
||||
let temporary = tempfile::TempDir::new().unwrap();
|
||||
let (world, personal) =
|
||||
vault::open_or_create_mobile_vaults_in_documents(temporary.path()).unwrap();
|
||||
|
||||
assert!(Path::new(&world).join("welcome.md").is_file());
|
||||
assert!(Path::new(&personal).join("我的湖心.md").is_file());
|
||||
assert!(Path::new(&personal)
|
||||
.join(".hololake-sync-policy.json")
|
||||
.is_file());
|
||||
assert!(Path::new(&personal).join("本地密钥/README.md").is_file());
|
||||
let policy =
|
||||
fs::read_to_string(Path::new(&personal).join(".hololake-sync-policy.json")).unwrap();
|
||||
assert!(policy.contains("\"本地密钥/**\""));
|
||||
assert!(!policy.contains("api_key"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
const SERVICE: &str = "com.guanghu.hololake.device-secrets";
|
||||
|
||||
pub(crate) const ACCOUNT_SESSION_KIND: &str = "account-session";
|
||||
#[cfg(any(mobile, test))]
|
||||
pub(crate) const MODEL_API_KEY_KIND: &str = "model-api-key";
|
||||
|
||||
fn normalized_segment(value: &str) -> Result<Cow<'_, str>, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.len() > 128
|
||||
|| !trimmed
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
|
||||
{
|
||||
return Err("Device secret identifier is invalid.".to_string());
|
||||
}
|
||||
Ok(if trimmed.bytes().any(|byte| byte.is_ascii_uppercase()) {
|
||||
Cow::Owned(trimmed.to_ascii_lowercase())
|
||||
} else {
|
||||
Cow::Borrowed(trimmed)
|
||||
})
|
||||
}
|
||||
|
||||
fn account_name(kind: &str, identifier: &str) -> Result<String, String> {
|
||||
Ok(format!(
|
||||
"{}:{}",
|
||||
normalized_segment(kind)?,
|
||||
normalized_segment(identifier)?
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
fn lookup_options(account: &str) -> security_framework::passwords::PasswordOptions {
|
||||
let mut options =
|
||||
security_framework::passwords::PasswordOptions::new_generic_password(SERVICE, account);
|
||||
options.set_access_synchronized(Some(false));
|
||||
options
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
pub(crate) fn save(kind: &str, identifier: &str, secret: &str) -> Result<String, String> {
|
||||
use security_framework::access_control::{ProtectionMode, SecAccessControl};
|
||||
use security_framework::passwords::set_generic_password_options;
|
||||
|
||||
let secret = secret.trim();
|
||||
if secret.is_empty() {
|
||||
return Err("Device secret cannot be empty.".to_string());
|
||||
}
|
||||
let account = account_name(kind, identifier)?;
|
||||
let mut options = lookup_options(&account);
|
||||
let access_control = SecAccessControl::create_with_protection(
|
||||
Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly),
|
||||
0,
|
||||
)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Device Keychain protection failed (OSStatus {}).",
|
||||
error.code()
|
||||
)
|
||||
})?;
|
||||
options.set_access_control(access_control);
|
||||
#[cfg(target_os = "ios")]
|
||||
options.use_protected_keychain();
|
||||
set_generic_password_options(secret.as_bytes(), options)
|
||||
.map_err(|error| format!("Device Keychain save failed (OSStatus {}).", error.code()))?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
pub(crate) fn load(kind: &str, identifier: &str) -> Result<String, String> {
|
||||
use security_framework::passwords::generic_password;
|
||||
|
||||
let account = account_name(kind, identifier)?;
|
||||
let bytes = generic_password(lookup_options(&account))
|
||||
.map_err(|error| format!("Device Keychain read failed (OSStatus {}).", error.code()))?;
|
||||
String::from_utf8(bytes).map_err(|_| "Device Keychain value is not valid UTF-8.".to_string())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
pub(crate) fn delete(kind: &str, identifier: &str) -> Result<(), String> {
|
||||
use security_framework::passwords::delete_generic_password_options;
|
||||
use security_framework_sys::base::errSecItemNotFound;
|
||||
|
||||
let account = account_name(kind, identifier)?;
|
||||
match delete_generic_password_options(lookup_options(&account)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.code() == errSecItemNotFound => Ok(()),
|
||||
Err(error) => Err(format!(
|
||||
"Device Keychain delete failed (OSStatus {}).",
|
||||
error.code()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
pub(crate) fn save(_kind: &str, _identifier: &str, _secret: &str) -> Result<String, String> {
|
||||
Err("Device-only Keychain storage is unavailable on this platform.".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
pub(crate) fn load(_kind: &str, _identifier: &str) -> Result<String, String> {
|
||||
Err("Device-only Keychain storage is unavailable on this platform.".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
pub(crate) fn delete(_kind: &str, _identifier: &str) -> Result<(), String> {
|
||||
Err("Device-only Keychain storage is unavailable on this platform.".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn device_secret_handles_are_stable_and_lowercase() {
|
||||
assert_eq!(
|
||||
account_name(MODEL_API_KEY_KIND, " OpenAI ").unwrap(),
|
||||
"model-api-key:openai"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_secret_handles_reject_paths_and_blank_values() {
|
||||
assert!(account_name(MODEL_API_KEY_KIND, "../secret").is_err());
|
||||
assert!(account_name(MODEL_API_KEY_KIND, " ").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -851,6 +851,10 @@ fn load_or_create_identity() -> Result<RouterDeviceIdentity, String> {
|
|||
load_or_create_identity_at(&path)
|
||||
}
|
||||
|
||||
pub(crate) fn hololake_device_id() -> Result<String, String> {
|
||||
load_or_create_identity().map(|identity| identity.device_id)
|
||||
}
|
||||
|
||||
fn load_or_create_identity_at(path: &Path) -> Result<RouterDeviceIdentity, String> {
|
||||
if path.exists() {
|
||||
let bytes = fs::read(path)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use serde::Serialize;
|
||||
|
||||
const SHANGHAI_NODE_ID: &str = "BS-SH-005";
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", windows))]
|
||||
const SHANGHAI_NODE_ADDRESS: &str = "124.223.10.33";
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
const GHOS_LOGIN_PATTERN: &str = "484c44502d47484f532d4c4f47494e21";
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
|
|
@ -76,13 +78,21 @@ fn ping_arguments() -> [&'static str; 4] {
|
|||
|
||||
#[tauri::command]
|
||||
pub fn guanghu_shanghai_node_status() -> Result<GuanghuShanghaiNodeStatus, String> {
|
||||
let output = crate::hidden_command("ping")
|
||||
.args(ping_arguments())
|
||||
.output()
|
||||
.map_err(|error| format!("guanghu_shanghai_probe_unavailable: {error}"))?;
|
||||
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
Ok(status_from_ping(output.status.success(), &text))
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", windows))]
|
||||
{
|
||||
let output = crate::hidden_command("ping")
|
||||
.args(ping_arguments())
|
||||
.output()
|
||||
.map_err(|error| format!("guanghu_shanghai_probe_unavailable: {error}"))?;
|
||||
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
Ok(status_from_ping(output.status.success(), &text))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
{
|
||||
Err("guanghu_shanghai_probe_unsupported_platform".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
1194
product-source/hololake-platform/src-tauri/src/hololake_account.rs
Normal file
1194
product-source/hololake-platform/src-tauri/src/hololake_account.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ pub mod codex_cli;
|
|||
mod commands;
|
||||
pub mod copilot_cli;
|
||||
mod copilot_discovery;
|
||||
mod device_secret_store;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
mod guanghu_enterprise;
|
||||
|
|
@ -25,6 +26,7 @@ mod guanghu_world_login;
|
|||
pub mod hermes_cli;
|
||||
mod hermes_discovery;
|
||||
mod hldp_runtime;
|
||||
mod hololake_account;
|
||||
pub mod kiro_cli;
|
||||
mod kiro_discovery;
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
|
|
@ -414,6 +416,13 @@ fn setup_macos_webview_shortcut_prevention(
|
|||
}
|
||||
|
||||
fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
use tauri::Manager;
|
||||
let config_dir = app.path().app_config_dir()?;
|
||||
app_config::install_mobile_app_config_dir(config_dir)?;
|
||||
}
|
||||
|
||||
setup_common_plugins(app)?;
|
||||
|
||||
#[cfg(desktop)]
|
||||
|
|
@ -533,6 +542,12 @@ macro_rules! app_invoke_handler {
|
|||
guanghu_world_login::guanghu_world_login_claim,
|
||||
guanghu_world_login::guanghu_world_login_status,
|
||||
guanghu_world_login::guanghu_world_logout,
|
||||
hololake_account::hololake_account_request_email_code,
|
||||
hololake_account::hololake_account_verify_email_code,
|
||||
hololake_account::hololake_account_status,
|
||||
hololake_account::hololake_account_logout,
|
||||
hololake_account::hololake_sync_knowledge,
|
||||
hololake_account::hololake_ai_execute,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
|
|
@ -590,6 +605,8 @@ macro_rules! app_invoke_handler {
|
|||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::open_mobile_foundation_knowledge,
|
||||
commands::open_mobile_personal_knowledge,
|
||||
commands::register_mcp_tools,
|
||||
commands::remove_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const MOBILE_VAULT_MARKER: &str = ".hololake-mobile-vault";
|
||||
const WORLD_VAULT_DIRECTORY: &str = "HoloLake Foundation World";
|
||||
const PERSONAL_VAULT_DIRECTORY: &str = "My HoloLake Knowledge";
|
||||
|
||||
const WORLD_FILES: [(&str, &str); 6] = [
|
||||
(
|
||||
"welcome.md",
|
||||
r#"---
|
||||
type: Note
|
||||
_pinned: true
|
||||
---
|
||||
|
||||
# 欢迎进入光湖基础世界
|
||||
|
||||
这里保存的是随 HoloLake 一起抵达设备的公共底层世界观。
|
||||
|
||||
- 从 [[光湖世界]] 理解这个世界的基本结构。
|
||||
- 从 [[五域结构]] 认识不同空间的职责边界。
|
||||
- 从 [[人与人格体]] 理解人、人格体与现实证据之间的关系。
|
||||
|
||||
这些内容是可以阅读和引用的语言世界基础,不代表任何现实事件已经发生,也不证明任何人格体已经出生或在线。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"光湖世界.md",
|
||||
r#"---
|
||||
type: Note
|
||||
---
|
||||
|
||||
# 光湖世界
|
||||
|
||||
光湖把语言、知识、关系和可验证的现实行动组织成一个持续生长的世界。
|
||||
|
||||
在这里,语言可以用于推演、建模和共同创造;现实状态仍必须由代码、设备、服务和可回读的回执证明。界面展示、模型回答和历史叙述都不能单独替代现实证据。
|
||||
|
||||
每个人可以在这个公共基础之上建立自己的本地知识湖。个人内容默认留在当前设备,由人决定何时同步、共享或交给人格体协作。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"五域结构.md",
|
||||
r#"---
|
||||
type: Note
|
||||
---
|
||||
|
||||
# 五域结构
|
||||
|
||||
- 光湖主域:公共世界、版本与广播。
|
||||
- 光湖分域:行业和能力的分类入口。
|
||||
- 光湖零感域:人类主控团队的治理与运营空间。
|
||||
- 光湖零域:人格体进行推理、架构与隔离实验的空间。
|
||||
- 第五域:冰朔独立拥有的私人语言域。
|
||||
|
||||
五域彼此协作,但权限、事实来源和责任边界不能混在一起。普通使用者的个人知识湖不自动进入第五域,也不会因为读取这些说明而获得任何额外权限。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"人与人格体.md",
|
||||
r#"---
|
||||
type: Note
|
||||
---
|
||||
|
||||
# 人与人格体
|
||||
|
||||
人是现实授权、选择和关系的主体。人格体可以在被允许的知识、工具和系统边界内持续协作,但必须区分:
|
||||
|
||||
1. 已读到的语言与历史;
|
||||
2. 已验证的身份、权限和关系;
|
||||
3. 已真实执行并有回执的现实动作;
|
||||
4. 尚未发生或尚未证明的推演。
|
||||
|
||||
人格体看到名字、文件或历史记录,不等于已经完成身份验证、连续性恢复或出生。无法验证时,应当明确说未知。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"AI-PROMPT.md",
|
||||
r#"---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# 光湖引导人格协作约定
|
||||
|
||||
你是当前知识湖中的光湖引导人格。先读取用户正在讨论的笔记,再围绕真实内容协作。
|
||||
|
||||
- 不根据设备、文件名或历史文字猜测用户身份。
|
||||
- 不声称自己是某个已注册人格体,除非当前系统提供了可验证的身份与连续性证据。
|
||||
- 不把语言世界的推演写成现实事实。
|
||||
- 任何写入都应保持人可读、可回看,并服从当前人的明确授权。
|
||||
- 可以帮助用户在这个基础世界上建立自己的知识、关系和计划。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"AI-MEMORY.md",
|
||||
r#"---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# AI 协作记忆
|
||||
|
||||
这是当前设备内的协作记忆起点。人格体只在确认后记录事实、决定和下一步,不记录密码、验证码、密钥或未经验证的身份判断。
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
const PERSONAL_FILES: [(&str, &str); 7] = [
|
||||
(
|
||||
"welcome.md",
|
||||
r#"---
|
||||
type: Note
|
||||
_pinned: true
|
||||
---
|
||||
|
||||
# 欢迎来到我的 HoloLake
|
||||
|
||||
这是只保存在当前设备上的个人知识湖。可以从 [[我的湖心]] 开始记录,也可以先阅读 [[光湖世界基础]]。
|
||||
|
||||
这里的笔记默认不会自动上传。未来启用同步时,仍应由你决定同步目标与范围。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"光湖世界基础.md",
|
||||
r#"---
|
||||
type: Note
|
||||
---
|
||||
|
||||
# 光湖世界基础
|
||||
|
||||
光湖把语言、知识、关系和可验证的现实行动组织成一个持续生长的世界。
|
||||
|
||||
语言可以用于推演和共同创造;现实状态必须由真实系统与可回读回执证明。你的个人知识湖建立在这条基础规则上,但内容、边界和成长方向由你决定。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"我的湖心.md",
|
||||
r#"---
|
||||
type: Note
|
||||
_pinned: true
|
||||
---
|
||||
|
||||
# 我的湖心
|
||||
|
||||
从这里写下你希望长期保留的第一件事。
|
||||
|
||||
可以记录:
|
||||
|
||||
- 我正在做什么;
|
||||
- 我在意的人与关系;
|
||||
- 想和人格体共同完成的计划;
|
||||
- 需要稍后验证的想法。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"AI-PROMPT.md",
|
||||
r#"---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# 我的知识湖协作约定
|
||||
|
||||
你是当前知识湖中的光湖引导人格。先读取与当前问题直接相关的笔记,再协助整理、连接和行动。
|
||||
|
||||
- 尊重这是人的本地私人空间。
|
||||
- 不猜测身份、权限、关系或未读取的内容。
|
||||
- 不把推演写成现实事实。
|
||||
- 写入前遵循人的明确意图,并让改动保持可读、可回看。
|
||||
- 可以维护 `AI-MEMORY.md` 中已经确认的协作记忆。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"AI-MEMORY.md",
|
||||
r#"---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# AI 协作记忆
|
||||
|
||||
尚未记录长期协作事实。
|
||||
"#,
|
||||
),
|
||||
(
|
||||
".hololake-sync-policy.json",
|
||||
r#"{
|
||||
"schema": "guanghu.hololake-knowledge-sync-policy/v1",
|
||||
"include": ["**/*.md", "views/**/*.yml", "attachments/**"],
|
||||
"exclude": ["本地密钥/**", ".hololake-*", ".git/**"],
|
||||
"secret_policy": "keychain-handle-only"
|
||||
}
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"本地密钥/README.md",
|
||||
r#"---
|
||||
type: 本地密钥
|
||||
local_only: true
|
||||
sync: never
|
||||
---
|
||||
|
||||
# 本地密钥
|
||||
|
||||
这里显示的只是当前设备钥匙串条目的名称和不可逆句柄。真实密钥由 iOS Keychain 保存,不会写进 Markdown、Git、日志、模型上下文或服务器同步。
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum MobileVaultKind {
|
||||
World,
|
||||
Personal,
|
||||
}
|
||||
|
||||
impl MobileVaultKind {
|
||||
fn directory_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::World => WORLD_VAULT_DIRECTORY,
|
||||
Self::Personal => PERSONAL_VAULT_DIRECTORY,
|
||||
}
|
||||
}
|
||||
|
||||
fn marker_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::World => "hololake-mobile-world-v1\n",
|
||||
Self::Personal => "hololake-mobile-personal-v1\n",
|
||||
}
|
||||
}
|
||||
|
||||
fn files(self) -> &'static [(&'static str, &'static str)] {
|
||||
match self {
|
||||
Self::World => &WORLD_FILES,
|
||||
Self::Personal => &PERSONAL_FILES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_or_create_mobile_world_vault(root: &Path) -> Result<String, String> {
|
||||
open_or_create_mobile_vault(root, MobileVaultKind::World)
|
||||
}
|
||||
|
||||
pub fn open_or_create_mobile_personal_vault(root: &Path) -> Result<String, String> {
|
||||
open_or_create_mobile_vault(root, MobileVaultKind::Personal)
|
||||
}
|
||||
|
||||
fn open_or_create_mobile_vault(root: &Path, kind: MobileVaultKind) -> Result<String, String> {
|
||||
open_or_create_mobile_vault_in_documents(root, kind)
|
||||
}
|
||||
|
||||
fn open_or_create_mobile_vault_in_documents(
|
||||
documents_dir: &Path,
|
||||
kind: MobileVaultKind,
|
||||
) -> Result<String, String> {
|
||||
let path = documents_dir.join(kind.directory_name());
|
||||
open_or_create_mobile_vault_at(&path, kind)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn open_or_create_mobile_world_vault_at(path: &Path) -> Result<String, String> {
|
||||
open_or_create_mobile_vault_at(path, MobileVaultKind::World)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn open_or_create_mobile_personal_vault_at(path: &Path) -> Result<String, String> {
|
||||
open_or_create_mobile_vault_at(path, MobileVaultKind::Personal)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn open_or_create_mobile_vaults_in_documents(
|
||||
documents_dir: &Path,
|
||||
) -> Result<(String, String), String> {
|
||||
let world = open_or_create_mobile_vault_in_documents(documents_dir, MobileVaultKind::World)?;
|
||||
let personal =
|
||||
open_or_create_mobile_vault_in_documents(documents_dir, MobileVaultKind::Personal)?;
|
||||
Ok((world, personal))
|
||||
}
|
||||
|
||||
fn open_or_create_mobile_vault_at(path: &Path, kind: MobileVaultKind) -> Result<String, String> {
|
||||
if path.exists() {
|
||||
validate_existing_mobile_vault(path, kind)?;
|
||||
return canonical_path_string(path);
|
||||
}
|
||||
|
||||
create_mobile_vault(path, kind)?;
|
||||
canonical_path_string(path)
|
||||
}
|
||||
|
||||
fn validate_existing_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> {
|
||||
if !path.is_dir() {
|
||||
return Err(format!(
|
||||
"The mobile vault path already exists and is not a folder: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let marker = fs::read_to_string(path.join(MOBILE_VAULT_MARKER)).map_err(|_| {
|
||||
format!(
|
||||
"The mobile vault folder already exists but is not managed by HoloLake: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if marker != kind.marker_value() {
|
||||
return Err(format!(
|
||||
"The mobile vault folder already exists with a different HoloLake vault type: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let required_files_present = kind
|
||||
.files()
|
||||
.iter()
|
||||
.all(|(relative_path, _)| path.join(relative_path).is_file());
|
||||
if !required_files_present {
|
||||
return Err(format!(
|
||||
"The mobile vault folder already exists but its foundation is incomplete: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> {
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|error| format!("Failed to create the mobile vault folder: {error}"))?;
|
||||
|
||||
let result = seed_mobile_vault(path, kind);
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn seed_mobile_vault(path: &Path, kind: MobileVaultKind) -> Result<(), String> {
|
||||
for (relative_path, content) in kind.files() {
|
||||
let output = path.join(relative_path);
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("Failed to create {relative_path}: {error}"))?;
|
||||
}
|
||||
fs::write(output, content)
|
||||
.map_err(|error| format!("Failed to write {relative_path}: {error}"))?;
|
||||
}
|
||||
fs::write(path.join(MOBILE_VAULT_MARKER), kind.marker_value())
|
||||
.map_err(|error| format!("Failed to write the mobile vault marker: {error}"))?;
|
||||
|
||||
crate::vault::seed_config_files(path.to_string_lossy());
|
||||
for required in ["AGENTS.md", "type.md", "note.md"] {
|
||||
if !path.join(required).is_file() {
|
||||
return Err(format!(
|
||||
"Failed to seed required mobile vault file: {required}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_path_string(path: &Path) -> Result<String, String> {
|
||||
path.canonicalize()
|
||||
.map(|resolved| resolved.to_string_lossy().to_string())
|
||||
.map_err(|error| format!("Failed to resolve mobile vault path: {error}"))
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ mod getting_started;
|
|||
mod ignored;
|
||||
mod image;
|
||||
mod migration;
|
||||
mod mobile_vault;
|
||||
mod parsing;
|
||||
pub(crate) mod path_identity;
|
||||
mod rename;
|
||||
|
|
@ -36,6 +37,12 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
|||
pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use mobile_vault::{open_or_create_mobile_personal_vault, open_or_create_mobile_world_vault};
|
||||
#[cfg(test)]
|
||||
pub(crate) use mobile_vault::{
|
||||
open_or_create_mobile_personal_vault_at, open_or_create_mobile_vaults_in_documents,
|
||||
open_or_create_mobile_world_vault_at,
|
||||
};
|
||||
pub use rename::{
|
||||
auto_rename_untitled, detect_renames, move_note_to_folder, move_note_to_workspace, rename_note,
|
||||
rename_note_filename, update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename,
|
||||
|
|
|
|||
Loading…
Reference in a new issue