#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 use notesagent_host::recent::{RecentVault, RecentVaultStore}; use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace}; use std::path::Path; use std::sync::Mutex; use std::time::Duration; use tauri::{Emitter, Manager, State}; #[derive(Default)] struct Host { workspace: Mutex>, recent: Mutex>, } fn info(ws: &Workspace) -> RecentVault { RecentVault { vault_id: ws.vault_id.clone(), path: portable_path_string(&ws.root), name: ws .root .file_name() .unwrap_or_default() .to_string_lossy() .into(), } } fn with_workspace( host: &Host, f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result, ) -> Result { 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) } #[tauri::command] fn host_capabilities() -> serde_json::Value { serde_json::json!({"protocol":1,"workspace":true,"core":true,"sync":false,"credentials":false,"extensions":false,"release":"preview"}) } #[derive(serde::Serialize)] struct CoreResponse { status: u16, content_type: String, body: String, } fn core_url(path: &str) -> Result { if (!path.starts_with("/api/") && path != "/api" && path != "/health") || path.contains("..") || path.contains(['\r', '\n']) { return Err("CORE_PATH_DENIED".into()); } Ok(format!("http://127.0.0.1:8000{path}")) } #[cfg(test)] mod core_proxy_tests { use super::core_url; #[test] fn only_allows_expected_loopback_paths() { assert_eq!(core_url("/health").unwrap(), "http://127.0.0.1:8000/health"); assert!(core_url("/api/status?verbose=true").is_ok()); assert!(core_url("https://example.com/api/status").is_err()); assert!(core_url("/api/../secret").is_err()); } } /// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。 #[tauri::command] async fn core_request( method: String, path: String, body: Option, authorization: Option, ) -> Result { let method = reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?; if !matches!( method, reqwest::Method::GET | reqwest::Method::POST | reqwest::Method::PUT | reqwest::Method::PATCH | reqwest::Method::DELETE ) { return Err("CORE_METHOD_DENIED".into()); } let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .map_err(|_| "CORE_CLIENT_ERROR")?; let mut request = client.request(method, core_url(&path)?); if let Some(value) = body { request = request.json(&value); } if let Some(value) = authorization { request = request.header(reqwest::header::AUTHORIZATION, value); } let response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?; let status = response.status().as_u16(); let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("") .to_owned(); let body = response.text().await.map_err(|_| "CORE_RESPONSE_ERROR")?; Ok(CoreResponse { status, content_type, body, }) } #[tauri::command] fn editor_capabilities(app: tauri::AppHandle, import_enabled: bool) -> Result<(), String> { app.state::>() .set_enabled(import_enabled) .map_err(|_| "MENU_UNAVAILABLE".into()) } #[tauri::command] 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.workspace.lock().map_err(|_| "HOST_BUSY")?; if guard .as_ref() .is_some_and(|ws| ws.root == path.canonicalize().unwrap_or_default()) { return Ok(guard.as_ref().map(info)); } 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_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| { Path::new(&authorized.path) .canonicalize() .is_ok_and(|path| ws.root == 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> { 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(()) } #[tauri::command] fn workspace_tree(host: State<'_, Host>) -> Result, String> { with_workspace(&host, |ws| ws.scan()) } #[tauri::command] fn workspace_read(host: State<'_, Host>, path: String) -> Result { with_workspace(&host, |ws| ws.read(&path)) } #[tauri::command] fn workspace_write( host: State<'_, Host>, path: String, expected: String, content: String, ) -> Result { with_workspace(&host, |ws| { ws.write(&path, &expected, content.as_bytes(), "local") }) } #[tauri::command] fn workspace_rename( host: State<'_, Host>, path: String, destination: String, expected: String, ) -> Result { with_workspace(&host, |ws| ws.rename(&path, &destination, &expected)) } #[tauri::command] fn workspace_delete(host: State<'_, Host>, path: String, expected: String) -> Result<(), String> { with_workspace(&host, |ws| ws.delete(&path, &expected)) } #[tauri::command] fn workspace_mkdir(host: State<'_, Host>, path: String) -> Result<(), String> { with_workspace(&host, |ws| ws.mkdir(&path)) } fn main() { tauri::Builder::default() .manage(Host::default()) .setup(|app| { use tauri::menu::{Menu, MenuItem, Submenu}; let import = MenuItem::with_id( app, "editor.import-note-properties", "导入为笔记属性…", false, Some("CmdOrCtrl+Alt+P"), )?; 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| { // 主窗口独占预览 Vault;扩展窗口没有命令 capability。 if let Some(window) = app.get_webview_window("main") { let _ = window.emit("editor-command", event.id().as_ref()); } }) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window.emit("host-close-requested", ()); } }) .invoke_handler(tauri::generate_handler![ host_capabilities, core_request, editor_capabilities, workspace_choose, workspace_open, workspace_recent, workspace_revoke, workspace_tree, workspace_read, workspace_write, workspace_rename, workspace_delete, workspace_mkdir ]) .run(tauri::generate_context!()) .expect("桌面 Host 启动失败"); }