feat(desktop): 持久化最近 Vault 授权

This commit is contained in:
2026-09-07 17:55:42 +08:00
parent 8ae5006ea7
commit 34a36bb328
8 changed files with 257 additions and 19 deletions
+1
View File
@@ -5,6 +5,7 @@ fn main() {
"host_capabilities", "host_capabilities",
"editor_capabilities", "editor_capabilities",
"workspace_choose", "workspace_choose",
"workspace_open",
"workspace_tree", "workspace_tree",
"workspace_read", "workspace_read",
"workspace_write", "workspace_write",
@@ -9,6 +9,7 @@
"core:default", "core:default",
"allow-host-capabilities", "allow-host-capabilities",
"allow-workspace-choose", "allow-workspace-choose",
"allow-workspace-open",
"allow-workspace-tree", "allow-workspace-tree",
"allow-workspace-read", "allow-workspace-read",
"allow-workspace-write", "allow-workspace-write",
@@ -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"]
+1
View File
@@ -1,3 +1,4 @@
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。 //! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
pub mod recent;
pub mod workspace; pub mod workspace;
+63 -17
View File
@@ -2,23 +2,20 @@
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
use notesagent_host::recent::{RecentVault, RecentVaultStore};
use notesagent_host::workspace::{Document, Entry, Workspace}; use notesagent_host::workspace::{Document, Entry, Workspace};
use serde::Serialize; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Emitter, Manager, State}; use tauri::{Emitter, Manager, State};
#[derive(Default)] #[derive(Default)]
struct Host(Mutex<Option<Workspace>>); struct Host {
workspace: Mutex<Option<Workspace>>,
#[derive(Serialize)] recent: Mutex<Option<RecentVaultStore>>,
struct VaultInfo {
vault_id: String,
path: String,
name: String,
} }
fn info(ws: &Workspace) -> VaultInfo { fn info(ws: &Workspace) -> RecentVault {
VaultInfo { RecentVault {
vault_id: ws.vault_id.clone(), vault_id: ws.vault_id.clone(),
path: ws.root.to_string_lossy().into(), path: ws.root.to_string_lossy().into(),
name: ws name: ws
@@ -34,7 +31,7 @@ fn with_workspace<T>(
host: &Host, host: &Host,
f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result<T>, f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result<T>,
) -> Result<T, String> { ) -> Result<T, String> {
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")?; let ws = guard.as_mut().ok_or("VAULT_NOT_OPEN")?;
f(ws).map_err(|e| e.code) f(ws).map_err(|e| e.code)
} }
@@ -52,14 +49,14 @@ fn editor_capabilities(app: tauri::AppHandle, import_enabled: bool) -> Result<()
} }
#[tauri::command] #[tauri::command]
fn workspace_choose(host: State<'_, Host>) -> Result<Option<VaultInfo>, String> { fn workspace_choose(host: State<'_, Host>) -> Result<Option<RecentVault>, String> {
let Some(path) = rfd::FileDialog::new() let Some(path) = rfd::FileDialog::new()
.set_title("选择本地 Vault") .set_title("选择本地 Vault")
.pick_folder() .pick_folder()
else { else {
return Ok(None); 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 if guard
.as_ref() .as_ref()
.is_some_and(|ws| ws.root == path.canonicalize().unwrap_or_default()) .is_some_and(|ws| ws.root == path.canonicalize().unwrap_or_default())
@@ -68,19 +65,61 @@ fn workspace_choose(host: State<'_, Host>) -> Result<Option<VaultInfo>, String>
} }
let workspace = Workspace::open(&path).map_err(|e| e.code)?; let workspace = Workspace::open(&path).map_err(|e| e.code)?;
let result = info(&workspace); let result = info(&workspace);
host.recent
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.remember(&result)?;
*guard = Some(workspace); *guard = Some(workspace);
Ok(Some(result)) Ok(Some(result))
} }
#[tauri::command] #[tauri::command]
fn workspace_recent(host: State<'_, Host>) -> Result<Vec<VaultInfo>, String> { fn workspace_open(host: State<'_, Host>, path: String) -> Result<RecentVault, String> {
let guard = host.0.lock().map_err(|_| "HOST_BUSY")?; let authorized = host
Ok(guard.as_ref().map(info).into_iter().collect()) .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<Vec<RecentVault>, String> {
host.recent
.lock()
.map_err(|_| "HOST_BUSY")?
.as_ref()
.ok_or("HOST_NOT_READY")?
.list()
} }
#[tauri::command] #[tauri::command]
fn workspace_revoke(host: State<'_, Host>) -> Result<(), String> { 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(()) Ok(())
} }
@@ -136,6 +175,12 @@ fn main() {
let paragraph = Submenu::with_items(app, "段落", true, &[&import])?; let paragraph = Submenu::with_items(app, "段落", true, &[&import])?;
app.manage(import); app.manage(import);
app.set_menu(Menu::with_items(app, &[&paragraph])?)?; app.set_menu(Menu::with_items(app, &[&paragraph])?)?;
let state_path = app.path().app_data_dir()?.join("host-state.sqlite3");
*app.state::<Host>()
.recent
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? =
Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?);
Ok(()) Ok(())
}) })
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
@@ -154,6 +199,7 @@ fn main() {
host_capabilities, host_capabilities,
editor_capabilities, editor_capabilities,
workspace_choose, workspace_choose,
workspace_open,
workspace_recent, workspace_recent,
workspace_revoke, workspace_revoke,
workspace_tree, workspace_tree,
+168
View File
@@ -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<Self, String> {
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<Vec<RecentVault>, 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::<rusqlite::Result<Vec<_>>>()
.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<Option<RecentVault>, 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");
}
}
@@ -28,6 +28,13 @@ describe('原生 Workspace 适配', () => {
}) })
it('取消原生目录选择不进入空 Vault', async () => { it('取消原生目录选择不进入空 Vault', async () => {
native.enabled = true; native.invoke.mockResolvedValue(null) 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' })
}) })
}) })
+4 -1
View File
@@ -98,7 +98,10 @@ export async function getRecentVaults(): Promise<VaultInfo[]> {
export async function openVault(path: string): Promise<VaultInfo> { export async function openVault(path: string): Promise<VaultInfo> {
if (isDesktop()) { if (isDesktop()) {
const vault = await hostInvoke<HostVault | null>('workspace_choose') // 空路径只表示用户点击“选择目录”;最近列表只能重开 Host 已持久化授权的路径。
const vault = path
? await hostInvoke<HostVault>('workspace_open', { path })
: await hostInvoke<HostVault | null>('workspace_choose')
if (!vault) throw new Error(t('已取消选择', 'Selection cancelled')) if (!vault) throw new Error(t('已取消选择', 'Selection cancelled'))
cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++ cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++
return vault return vault