diff --git a/docs/contracts/Host-v1契约.md b/docs/contracts/Host-v1契约.md index 0fe80f6..c21de31 100644 --- a/docs/contracts/Host-v1契约.md +++ b/docs/contracts/Host-v1契约.md @@ -10,7 +10,8 @@ | --- | --- | | `host_capabilities` | protocol=1,workspace=true;core/sync/credentials/extensions=false | | `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name | -| `workspace_recent` | 当前进程已打开的 Vault;尚未持久化最近列表 | +| `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 | +| `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 | | `workspace_revoke` | 释放当前 Vault;不删除用户内容 | | `workspace_tree` | Markdown 与目录树;file_id/path/hash/revision/deleted/is_folder | | `workspace_read` | path,返回条目与 UTF-8 content | @@ -27,7 +28,7 @@ OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vau 保存前对比实际磁盘 SHA-256,先持久化包含内容的 journal,再同目录临时文件刷盘和替换,最后提交元数据及 outbox。文件已替换但数据库未提交时,启动恢复补齐同一 operation_id。摘要不匹配则保留 journal 冲突及外部正文。remote origin 不再次入 outbox。outbox 目前只持久化,**尚无上传/拉取循环**。 -移动和删除另有 file_ops 日志;目标或回收正文刷盘后才删除来源,数据库失败可重放。Windows reparse point(包括 junction)、越界路径、保留目录和设备名被拒绝。大小写重命名暂报 PATH_CONFLICT,UNC 不支持;附件树、多窗口共享、目录移动/删除、稳定外部重命名识别、监听去重及完整恢复 UI 尚未实现。 +移动和删除另有 file_ops 日志;目标或回收正文刷盘后才删除来源,数据库失败可重放。Windows reparse point(包括 junction)、越界路径、保留目录和设备名被拒绝。最近 Vault 授权保存在应用数据目录的独立 SQLite 中,使用事务更新且不包含正文;任意路径不能绕过目录选择直接加入。大小写重命名暂报 PATH_CONFLICT,UNC 不支持;附件树、多窗口共享、目录移动/删除、稳定外部重命名识别、监听去重及完整恢复 UI 尚未实现。 本地恶意进程仍可能在路径校验与系统调用间改变文件系统;该预览不是 OS 沙箱。发布前须补句柄级路径固定及突破测试、磁盘满/掉电故障注入、真实 WebView 交互、性能门禁。 diff --git a/docs/development/第三阶段实施与验收记录.md b/docs/development/第三阶段实施与验收记录.md index 956564a..bf123cf 100644 --- a/docs/development/第三阶段实施与验收记录.md +++ b/docs/development/第三阶段实施与验收记录.md @@ -17,10 +17,10 @@ ## 已执行验证 - 后端:887 项 pytest 通过;新增 Web/桌面单写入者门禁测试。 -- 前端:88 个文件、473 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。桌面标题栏窗口操作使用 Tauri capability,Web 模式不显示伪窗口按钮。 +- 前端:88 个文件、474 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。桌面标题栏窗口操作使用 Tauri capability,Web 模式不显示伪窗口按钮。 - Sync v1:18 项隔离测试通过,Windows / Python 3.13.9 / SQLite / 磁盘对象 Fixture。包括双线程并发 CAS;不是 PostgreSQL 多 Worker 压测。 - Community v1:13 项隔离测试通过;前端使用 Python 生成的真实 Ed25519 向量互验。 -- Rust:系统 `rustc/cargo 1.98.1`(`x86_64-pc-windows-gnu`)下,5 项文件恢复测试、rustfmt、clippy `-D warnings` 和 Tauri desktop 开发构建通过。没有安装包或实机 UI 证据。 +- Rust:系统 `rustc/cargo 1.98.1`(`x86_64-pc-windows-gnu`)下,7 项文件恢复及最近 Vault 授权测试、rustfmt、clippy `-D warnings` 和 Tauri desktop 开发构建通过。没有安装包或新标题栏实机 UI 证据。 - 文档相对链接、`git diff --check` 及 AI Core / Sync / Community 隔离冒烟通过。Gitea 工作流已加入,但未推送,因此无远端 Runner 证据。 - 真实模型、个人 Vault、真实音频和凭据未作为测试输入。 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index f393099..d822f38 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -5,6 +5,7 @@ fn main() { "host_capabilities", "editor_capabilities", "workspace_choose", + "workspace_open", "workspace_tree", "workspace_read", "workspace_write", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 280df55..ba003a3 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -9,6 +9,7 @@ "core:default", "allow-host-capabilities", "allow-workspace-choose", + "allow-workspace-open", "allow-workspace-tree", "allow-workspace-read", "allow-workspace-write", diff --git a/frontend/src-tauri/permissions/autogenerated/workspace_open.toml b/frontend/src-tauri/permissions/autogenerated/workspace_open.toml new file mode 100644 index 0000000..9f2f8d9 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/workspace_open.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-workspace-open" +description = "Enables the workspace_open command without any pre-configured scope." +commands.allow = ["workspace_open"] + +[[permission]] +identifier = "deny-workspace-open" +description = "Denies the workspace_open command without any pre-configured scope." +commands.deny = ["workspace_open"] diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index b88862e..54aebb2 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ //! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。 +pub mod recent; pub mod workspace; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 2a372d1..1b72065 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -2,23 +2,20 @@ //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 +use notesagent_host::recent::{RecentVault, RecentVaultStore}; use notesagent_host::workspace::{Document, Entry, Workspace}; -use serde::Serialize; +use std::path::Path; use std::sync::Mutex; use tauri::{Emitter, Manager, State}; #[derive(Default)] -struct Host(Mutex>); - -#[derive(Serialize)] -struct VaultInfo { - vault_id: String, - path: String, - name: String, +struct Host { + workspace: Mutex>, + recent: Mutex>, } -fn info(ws: &Workspace) -> VaultInfo { - VaultInfo { +fn info(ws: &Workspace) -> RecentVault { + RecentVault { vault_id: ws.vault_id.clone(), path: ws.root.to_string_lossy().into(), name: ws @@ -34,7 +31,7 @@ fn with_workspace( host: &Host, f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result, ) -> Result { - let mut guard = host.0.lock().map_err(|_| "HOST_BUSY")?; + let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?; let ws = guard.as_mut().ok_or("VAULT_NOT_OPEN")?; f(ws).map_err(|e| e.code) } @@ -52,14 +49,14 @@ fn editor_capabilities(app: tauri::AppHandle, import_enabled: bool) -> Result<() } #[tauri::command] -fn workspace_choose(host: State<'_, Host>) -> Result, String> { +fn workspace_choose(host: State<'_, Host>) -> Result, String> { let Some(path) = rfd::FileDialog::new() .set_title("选择本地 Vault") .pick_folder() else { return Ok(None); }; - let mut guard = host.0.lock().map_err(|_| "HOST_BUSY")?; + let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?; if guard .as_ref() .is_some_and(|ws| ws.root == path.canonicalize().unwrap_or_default()) @@ -68,19 +65,61 @@ fn workspace_choose(host: State<'_, Host>) -> Result, String> } let workspace = Workspace::open(&path).map_err(|e| e.code)?; let result = info(&workspace); + host.recent + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .remember(&result)?; *guard = Some(workspace); Ok(Some(result)) } #[tauri::command] -fn workspace_recent(host: State<'_, Host>) -> Result, String> { - let guard = host.0.lock().map_err(|_| "HOST_BUSY")?; - Ok(guard.as_ref().map(info).into_iter().collect()) +fn workspace_open(host: State<'_, Host>, path: String) -> Result { + let authorized = host + .recent + .lock() + .map_err(|_| "HOST_BUSY")? + .as_ref() + .ok_or("HOST_NOT_READY")? + .authorized(Path::new(&path))? + .ok_or("VAULT_NOT_AUTHORIZED")?; + let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?; + if guard + .as_ref() + .is_some_and(|ws| ws.root == Path::new(&authorized.path)) + { + 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 result = info(&workspace); + *guard = Some(workspace); + Ok(result) +} + +#[tauri::command] +fn workspace_recent(host: State<'_, Host>) -> Result, String> { + host.recent + .lock() + .map_err(|_| "HOST_BUSY")? + .as_ref() + .ok_or("HOST_NOT_READY")? + .list() } #[tauri::command] fn workspace_revoke(host: State<'_, Host>) -> Result<(), String> { - *host.0.lock().map_err(|_| "HOST_BUSY")? = None; + let mut workspace = host.workspace.lock().map_err(|_| "HOST_BUSY")?; + if let Some(active) = workspace.as_ref() { + host.recent + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .revoke(&active.root)?; + } + *workspace = None; Ok(()) } @@ -136,6 +175,12 @@ fn main() { let paragraph = Submenu::with_items(app, "段落", true, &[&import])?; app.manage(import); app.set_menu(Menu::with_items(app, &[¶graph])?)?; + let state_path = app.path().app_data_dir()?.join("host-state.sqlite3"); + *app.state::() + .recent + .lock() + .map_err(|_| std::io::Error::other("HOST_BUSY"))? = + Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?); Ok(()) }) .on_menu_event(|app, event| { @@ -154,6 +199,7 @@ fn main() { host_capabilities, editor_capabilities, workspace_choose, + workspace_open, workspace_recent, workspace_revoke, workspace_tree, diff --git a/frontend/src-tauri/src/recent.rs b/frontend/src-tauri/src/recent.rs new file mode 100644 index 0000000..ae7a5f6 --- /dev/null +++ b/frontend/src-tauri/src/recent.rs @@ -0,0 +1,168 @@ +//! 用户明确选择过的 Vault 授权记录;只保存身份和路径,不读取或复制笔记正文。 + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +const MAX_RECENT_VAULTS: i64 = 20; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecentVault { + pub vault_id: String, + pub path: String, + pub name: String, +} + +pub struct RecentVaultStore { + db: Connection, +} + +impl RecentVaultStore { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + } + let db = Connection::open(path).map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + db.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS recent_vaults ( + path TEXT PRIMARY KEY NOT NULL, + vault_id TEXT NOT NULL, + name TEXT NOT NULL, + ordering INTEGER NOT NULL + );", + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + Ok(Self { db }) + } + + pub fn list(&self) -> Result, String> { + let mut query = self + .db + .prepare("SELECT vault_id,path,name FROM recent_vaults ORDER BY ordering DESC LIMIT ?") + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + let rows = query + .query_map([MAX_RECENT_VAULTS], |row| { + Ok(RecentVault { + vault_id: row.get(0)?, + path: row.get(1)?, + name: row.get(2)?, + }) + }) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + rows.collect::>>() + .map_err(|_| "RECENT_VAULT_STORE_ERROR".into()) + } + + pub fn remember(&mut self, vault: &RecentVault) -> Result<(), String> { + let transaction = self + .db + .transaction() + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + let ordering: i64 = transaction + .query_row( + "SELECT COALESCE(MAX(ordering),0)+1 FROM recent_vaults", + [], + |row| row.get(0), + ) + .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], + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + transaction + .execute( + "DELETE FROM recent_vaults WHERE path IN ( + SELECT path FROM recent_vaults ORDER BY ordering DESC LIMIT -1 OFFSET ? + )", + [MAX_RECENT_VAULTS], + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + transaction + .commit() + .map_err(|_| "RECENT_VAULT_STORE_ERROR".into()) + } + + pub fn authorized(&self, path: &Path) -> Result, String> { + let canonical = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?; + self.db + .query_row( + "SELECT vault_id,path,name FROM recent_vaults WHERE path=?", + [canonical.to_string_lossy().as_ref()], + |row| { + Ok(RecentVault { + vault_id: row.get(0)?, + path: row.get(1)?, + name: row.get(2)?, + }) + }, + ) + .optional() + .map_err(|_| "RECENT_VAULT_STORE_ERROR".into()) + } + + pub fn revoke(&mut self, path: &Path) -> Result<(), String> { + let canonical: PathBuf = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?; + self.db + .execute( + "DELETE FROM recent_vaults WHERE path=?", + [canonical.to_string_lossy().as_ref()], + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vault(root: &Path, id: usize) -> RecentVault { + let path = root.join(format!("vault-{id}")); + fs::create_dir(&path).unwrap(); + RecentVault { + vault_id: format!("id-{id}"), + path: path.canonicalize().unwrap().to_string_lossy().into(), + name: format!("Vault {id}"), + } + } + + #[test] + fn persists_order_authorization_and_revocation() { + let temporary = tempfile::tempdir().unwrap(); + let database = temporary.path().join("state/host.sqlite3"); + let first = vault(temporary.path(), 1); + let second = vault(temporary.path(), 2); + let mut store = RecentVaultStore::open(&database).unwrap(); + store.remember(&first).unwrap(); + store.remember(&second).unwrap(); + store.remember(&first).unwrap(); + assert_eq!(store.list().unwrap(), vec![first.clone(), second]); + drop(store); + + let mut reopened = RecentVaultStore::open(&database).unwrap(); + assert_eq!( + reopened.authorized(Path::new(&first.path)).unwrap(), + Some(first.clone()) + ); + reopened.revoke(Path::new(&first.path)).unwrap(); + assert_eq!(reopened.authorized(Path::new(&first.path)).unwrap(), None); + } + + #[test] + fn keeps_only_the_most_recent_authorizations() { + let temporary = tempfile::tempdir().unwrap(); + let mut store = RecentVaultStore::open(&temporary.path().join("host.sqlite3")).unwrap(); + for id in 0..25 { + store.remember(&vault(temporary.path(), id)).unwrap(); + } + let items = store.list().unwrap(); + assert_eq!(items.len(), MAX_RECENT_VAULTS as usize); + assert_eq!(items[0].vault_id, "id-24"); + assert_eq!(items[19].vault_id, "id-5"); + } +} diff --git a/frontend/src/services/platform/desktop.spec.ts b/frontend/src/services/platform/desktop.spec.ts index bf097f4..f8fa2a3 100644 --- a/frontend/src/services/platform/desktop.spec.ts +++ b/frontend/src/services/platform/desktop.spec.ts @@ -28,6 +28,13 @@ describe('原生 Workspace 适配', () => { }) it('取消原生目录选择不进入空 Vault', async () => { native.enabled = true; native.invoke.mockResolvedValue(null) - await expect(workspace.openVault('ignored')).rejects.toThrow() + await expect(workspace.openVault('')).rejects.toThrow() + expect(native.invoke).toHaveBeenCalledWith('workspace_choose', undefined) + }) + it('最近 Vault 只能通过已授权路径命令重开', async () => { + native.enabled = true + native.invoke.mockResolvedValue({ vault_id: 'vault', path: 'C:\\notes', name: 'notes' }) + await expect(workspace.openVault('C:\\notes')).resolves.toMatchObject({ vault_id: 'vault' }) + expect(native.invoke).toHaveBeenCalledWith('workspace_open', { path: 'C:\\notes' }) }) }) diff --git a/frontend/src/services/workspaceService.ts b/frontend/src/services/workspaceService.ts index 698b256..b1d412f 100644 --- a/frontend/src/services/workspaceService.ts +++ b/frontend/src/services/workspaceService.ts @@ -98,7 +98,10 @@ export async function getRecentVaults(): Promise { export async function openVault(path: string): Promise { if (isDesktop()) { - const vault = await hostInvoke('workspace_choose') + // 空路径只表示用户点击“选择目录”;最近列表只能重开 Host 已持久化授权的路径。 + const vault = path + ? await hostInvoke('workspace_open', { path }) + : await hostInvoke('workspace_choose') if (!vault) throw new Error(t('已取消选择', 'Selection cancelled')) cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++ return vault