fix(desktop): 兼容 Windows Vault 扩展路径

This commit is contained in:
2026-09-07 23:06:28 +08:00
parent 51105f6e53
commit a4be2d98f4
3 changed files with 123 additions and 18 deletions
+7 -6
View File
@@ -3,7 +3,7 @@
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
use notesagent_host::recent::{RecentVault, RecentVaultStore}; 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::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
@@ -18,7 +18,7 @@ struct Host {
fn info(ws: &Workspace) -> RecentVault { fn info(ws: &Workspace) -> RecentVault {
RecentVault { RecentVault {
vault_id: ws.vault_id.clone(), vault_id: ws.vault_id.clone(),
path: ws.root.to_string_lossy().into(), path: portable_path_string(&ws.root),
name: ws name: ws
.root .root
.file_name() .file_name()
@@ -164,10 +164,11 @@ fn workspace_open(host: State<'_, Host>, path: String) -> Result<RecentVault, St
.authorized(Path::new(&path))? .authorized(Path::new(&path))?
.ok_or("VAULT_NOT_AUTHORIZED")?; .ok_or("VAULT_NOT_AUTHORIZED")?;
let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?; let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?;
if guard if guard.as_ref().is_some_and(|ws| {
.as_ref() Path::new(&authorized.path)
.is_some_and(|ws| ws.root == Path::new(&authorized.path)) .canonicalize()
{ .is_ok_and(|path| ws.root == path)
}) {
return Ok(guard.as_ref().map(info).ok_or("VAULT_NOT_OPEN")?); return Ok(guard.as_ref().map(info).ok_or("VAULT_NOT_OPEN")?);
} }
let workspace = Workspace::open(Path::new(&authorized.path)).map_err(|e| e.code)?; let workspace = Workspace::open(Path::new(&authorized.path)).map_err(|e| e.code)?;
+79 -11
View File
@@ -3,7 +3,9 @@
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::Path;
use crate::workspace::{portable_path, portable_path_string};
const MAX_RECENT_VAULTS: i64 = 20; const MAX_RECENT_VAULTS: i64 = 20;
@@ -52,10 +54,20 @@ impl RecentVaultStore {
}) })
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?; .map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
rows.collect::<rusqlite::Result<Vec<_>>>() rows.collect::<rusqlite::Result<Vec<_>>>()
.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()) .map_err(|_| "RECENT_VAULT_STORE_ERROR".into())
} }
pub fn remember(&mut self, vault: &RecentVault) -> Result<(), String> { pub fn remember(&mut self, vault: &RecentVault) -> Result<(), String> {
let (canonical, portable) = path_forms(Path::new(&vault.path))?;
let transaction = self let transaction = self
.db .db
.transaction() .transaction()
@@ -67,11 +79,17 @@ impl RecentVaultStore {
|row| row.get(0), |row| row.get(0),
) )
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?; .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 transaction
.execute( .execute(
"INSERT INTO recent_vaults(path,vault_id,name,ordering) VALUES(?,?,?,?) "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", 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")?; .map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
transaction transaction
@@ -88,11 +106,11 @@ impl RecentVaultStore {
} }
pub fn authorized(&self, path: &Path) -> Result<Option<RecentVault>, String> { pub fn authorized(&self, path: &Path) -> Result<Option<RecentVault>, String> {
let canonical = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?; let (canonical, portable) = path_forms(path)?;
self.db let result = self.db
.query_row( .query_row(
"SELECT vault_id,path,name FROM recent_vaults WHERE path=?", "SELECT vault_id,path,name FROM recent_vaults WHERE path=? OR path=? ORDER BY ordering DESC LIMIT 1",
[canonical.to_string_lossy().as_ref()], params![canonical, portable],
|row| { |row| {
Ok(RecentVault { Ok(RecentVault {
vault_id: row.get(0)?, vault_id: row.get(0)?,
@@ -102,21 +120,36 @@ impl RecentVaultStore {
}, },
) )
.optional() .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> { 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 self.db
.execute( .execute(
"DELETE FROM recent_vaults WHERE path=?", "DELETE FROM recent_vaults WHERE path=? OR path=?",
[canonical.to_string_lossy().as_ref()], params![canonical, portable],
) )
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?; .map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -126,7 +159,7 @@ mod tests {
fs::create_dir(&path).unwrap(); fs::create_dir(&path).unwrap();
RecentVault { RecentVault {
vault_id: format!("id-{id}"), vault_id: format!("id-{id}"),
path: path.canonicalize().unwrap().to_string_lossy().into(), path: portable_path_string(&path.canonicalize().unwrap()),
name: format!("Vault {id}"), name: format!("Vault {id}"),
} }
} }
@@ -165,4 +198,39 @@ mod tests {
assert_eq!(items[0].vault_id, "id-24"); assert_eq!(items[0].vault_id, "id-24");
assert_eq!(items[19].vault_id, "id-5"); 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(&current.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(&current.path)).unwrap(),
Some(current.clone())
);
store.remember(&current).unwrap();
let stored: String = store
.db
.query_row("SELECT path FROM recent_vaults", [], |row| row.get(0))
.unwrap();
assert_eq!(stored, current.path);
}
} }
+37 -1
View File
@@ -73,6 +73,30 @@ fn linked(path: &Path) -> std::io::Result<bool> {
} }
} }
/// 将 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 struct Workspace {
pub root: PathBuf, pub root: PathBuf,
pub vault_id: String, pub vault_id: String,
@@ -82,7 +106,8 @@ pub struct Workspace {
impl Workspace { impl Workspace {
pub fn open(root: &Path) -> Result<Self> { pub fn open(root: &Path) -> Result<Self> {
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")); return Err(HostError::new("VAULT_PATH_UNSUPPORTED"));
} }
let root = root.canonicalize()?; let root = root.canonicalize()?;
@@ -517,6 +542,17 @@ impl Workspace {
mod tests { mod tests {
use super::*; 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] #[test]
fn rename_and_delete_recover_after_source_removed() { fn rename_and_delete_recover_after_source_removed() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();