diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index ea6ef21..24ba4d7 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -3,7 +3,7 @@ //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 use notesagent_host::recent::{RecentVault, RecentVaultStore}; -use notesagent_host::workspace::{Document, Entry, Workspace}; +use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace}; use std::path::Path; use std::sync::Mutex; use std::time::Duration; @@ -18,7 +18,7 @@ struct Host { fn info(ws: &Workspace) -> RecentVault { RecentVault { vault_id: ws.vault_id.clone(), - path: ws.root.to_string_lossy().into(), + path: portable_path_string(&ws.root), name: ws .root .file_name() @@ -164,10 +164,11 @@ fn workspace_open(host: State<'_, Host>, path: String) -> Result>>() + .map(|vaults| { + vaults + .into_iter() + .map(|mut vault| { + vault.path = portable_path_string(Path::new(&vault.path)); + vault + }) + .collect() + }) .map_err(|_| "RECENT_VAULT_STORE_ERROR".into()) } pub fn remember(&mut self, vault: &RecentVault) -> Result<(), String> { + let (canonical, portable) = path_forms(Path::new(&vault.path))?; let transaction = self .db .transaction() @@ -67,11 +79,17 @@ impl RecentVaultStore { |row| row.get(0), ) .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + transaction + .execute( + "DELETE FROM recent_vaults WHERE path=? OR path=?", + params![canonical, portable], + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; transaction .execute( "INSERT INTO recent_vaults(path,vault_id,name,ordering) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET vault_id=excluded.vault_id,name=excluded.name,ordering=excluded.ordering", - params![vault.path, vault.vault_id, vault.name, ordering], + params![portable, vault.vault_id, vault.name, ordering], ) .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; transaction @@ -88,11 +106,11 @@ impl RecentVaultStore { } pub fn authorized(&self, path: &Path) -> Result, String> { - let canonical = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?; - self.db + let (canonical, portable) = path_forms(path)?; + let result = self.db .query_row( - "SELECT vault_id,path,name FROM recent_vaults WHERE path=?", - [canonical.to_string_lossy().as_ref()], + "SELECT vault_id,path,name FROM recent_vaults WHERE path=? OR path=? ORDER BY ordering DESC LIMIT 1", + params![canonical, portable], |row| { Ok(RecentVault { vault_id: row.get(0)?, @@ -102,21 +120,36 @@ impl RecentVaultStore { }, ) .optional() - .map_err(|_| "RECENT_VAULT_STORE_ERROR".into()) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + Ok(result.map(|mut vault| { + vault.path = portable_path_string(Path::new(&vault.path)); + vault + })) } pub fn revoke(&mut self, path: &Path) -> Result<(), String> { - let canonical: PathBuf = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?; + let (canonical, portable) = path_forms(path)?; self.db .execute( - "DELETE FROM recent_vaults WHERE path=?", - [canonical.to_string_lossy().as_ref()], + "DELETE FROM recent_vaults WHERE path=? OR path=?", + params![canonical, portable], ) .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; Ok(()) } } +fn path_forms(path: &Path) -> Result<(String, String), String> { + let accessible = portable_path(path); + let canonical = accessible + .canonicalize() + .map_err(|_| "VAULT_PATH_UNSUPPORTED")?; + Ok(( + canonical.to_string_lossy().into_owned(), + portable_path_string(&canonical), + )) +} + #[cfg(test)] mod tests { use super::*; @@ -126,7 +159,7 @@ mod tests { fs::create_dir(&path).unwrap(); RecentVault { vault_id: format!("id-{id}"), - path: path.canonicalize().unwrap().to_string_lossy().into(), + path: portable_path_string(&path.canonicalize().unwrap()), name: format!("Vault {id}"), } } @@ -165,4 +198,39 @@ mod tests { assert_eq!(items[0].vault_id, "id-24"); assert_eq!(items[19].vault_id, "id-5"); } + + #[cfg(windows)] + #[test] + fn reads_and_replaces_legacy_verbatim_paths() { + let temporary = tempfile::tempdir().unwrap(); + let database = temporary.path().join("host.sqlite3"); + let current = vault(temporary.path(), 1); + let legacy = Path::new(¤t.path) + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + assert!(legacy.starts_with(r"\\?\")); + + let mut store = RecentVaultStore::open(&database).unwrap(); + store + .db + .execute( + "INSERT INTO recent_vaults VALUES (?,?,?,1)", + params![legacy, current.vault_id, current.name], + ) + .unwrap(); + + assert_eq!(store.list().unwrap(), vec![current.clone()]); + assert_eq!( + store.authorized(Path::new(¤t.path)).unwrap(), + Some(current.clone()) + ); + store.remember(¤t).unwrap(); + let stored: String = store + .db + .query_row("SELECT path FROM recent_vaults", [], |row| row.get(0)) + .unwrap(); + assert_eq!(stored, current.path); + } } diff --git a/frontend/src-tauri/src/workspace.rs b/frontend/src-tauri/src/workspace.rs index a2e1cf9..b0a5555 100644 --- a/frontend/src-tauri/src/workspace.rs +++ b/frontend/src-tauri/src/workspace.rs @@ -73,6 +73,30 @@ fn linked(path: &Path) -> std::io::Result { } } +/// 将 Windows 本地磁盘的 verbatim 路径转换为适合界面和持久化的普通路径。 +/// UNC 与其他设备路径保持原样,后续仍会被 Vault 安全检查拒绝。 +pub fn portable_path(path: &Path) -> PathBuf { + #[cfg(windows)] + { + let raw = path.to_string_lossy(); + if let Some(rest) = raw.strip_prefix(r"\\?\") { + let bytes = rest.as_bytes(); + if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/') + { + return PathBuf::from(rest); + } + } + } + path.to_path_buf() +} + +pub fn portable_path_string(path: &Path) -> String { + portable_path(path).to_string_lossy().into_owned() +} + pub struct Workspace { pub root: PathBuf, pub vault_id: String, @@ -82,7 +106,8 @@ pub struct Workspace { impl Workspace { pub fn open(root: &Path) -> Result { - if !root.is_dir() || linked(root)? || root.to_string_lossy().starts_with("\\\\") { + let root = portable_path(root); + if root.to_string_lossy().starts_with("\\\\") || !root.is_dir() || linked(&root)? { return Err(HostError::new("VAULT_PATH_UNSUPPORTED")); } let root = root.canonicalize()?; @@ -517,6 +542,17 @@ impl Workspace { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn opens_windows_verbatim_disk_path() { + let dir = tempfile::tempdir().unwrap(); + let verbatim = dir.path().canonicalize().unwrap(); + assert!(verbatim.to_string_lossy().starts_with(r"\\?\")); + + let ws = Workspace::open(&verbatim).unwrap(); + assert_eq!(portable_path(&ws.root), portable_path(&verbatim)); + } + #[test] fn rename_and_delete_recover_after_source_removed() { let dir = tempfile::tempdir().unwrap();