feat(desktop): 增加原生 Vault 写入与属性导入
This commit is contained in:
Generated
+5292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "notesagent-desktop"
|
||||
version = "0.3.0-alpha.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
|
||||
[lib]
|
||||
name = "notesagent_host"
|
||||
|
||||
[[bin]]
|
||||
name = "notesagent-desktop"
|
||||
path = "src/main.rs"
|
||||
required-features = ["desktop"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
tempfile = "3"
|
||||
fs2 = "0.4"
|
||||
tauri = { version = "2", optional = true, features = ["tray-icon"] }
|
||||
rfd = { version = "0.15", optional = true }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", optional = true }
|
||||
@@ -0,0 +1,19 @@
|
||||
fn main() {
|
||||
#[cfg(feature = "desktop")]
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
|
||||
tauri_build::AppManifest::new().commands(&[
|
||||
"host_capabilities",
|
||||
"editor_capabilities",
|
||||
"workspace_choose",
|
||||
"workspace_tree",
|
||||
"workspace_read",
|
||||
"workspace_write",
|
||||
"workspace_rename",
|
||||
"workspace_delete",
|
||||
"workspace_mkdir",
|
||||
"workspace_recent",
|
||||
"workspace_revoke",
|
||||
]),
|
||||
))
|
||||
.expect("Tauri 配置无效");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "仅本地应用主窗口可请求受限 Vault 命令;远程来源没有授权。",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"allow-host-capabilities",
|
||||
"allow-workspace-choose",
|
||||
"allow-workspace-tree",
|
||||
"allow-workspace-read",
|
||||
"allow-workspace-write",
|
||||
"allow-workspace-rename",
|
||||
"allow-workspace-delete",
|
||||
"allow-workspace-mkdir",
|
||||
"allow-workspace-recent",
|
||||
"allow-workspace-revoke",
|
||||
"core:window:allow-destroy",
|
||||
"allow-editor-capabilities"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-editor-capabilities"
|
||||
description = "Enables the editor_capabilities command without any pre-configured scope."
|
||||
commands.allow = ["editor_capabilities"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-editor-capabilities"
|
||||
description = "Denies the editor_capabilities command without any pre-configured scope."
|
||||
commands.deny = ["editor_capabilities"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-host-capabilities"
|
||||
description = "Enables the host_capabilities command without any pre-configured scope."
|
||||
commands.allow = ["host_capabilities"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-host-capabilities"
|
||||
description = "Denies the host_capabilities command without any pre-configured scope."
|
||||
commands.deny = ["host_capabilities"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-choose"
|
||||
description = "Enables the workspace_choose command without any pre-configured scope."
|
||||
commands.allow = ["workspace_choose"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-choose"
|
||||
description = "Denies the workspace_choose command without any pre-configured scope."
|
||||
commands.deny = ["workspace_choose"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-delete"
|
||||
description = "Enables the workspace_delete command without any pre-configured scope."
|
||||
commands.allow = ["workspace_delete"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-delete"
|
||||
description = "Denies the workspace_delete command without any pre-configured scope."
|
||||
commands.deny = ["workspace_delete"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-mkdir"
|
||||
description = "Enables the workspace_mkdir command without any pre-configured scope."
|
||||
commands.allow = ["workspace_mkdir"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-mkdir"
|
||||
description = "Denies the workspace_mkdir command without any pre-configured scope."
|
||||
commands.deny = ["workspace_mkdir"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-read"
|
||||
description = "Enables the workspace_read command without any pre-configured scope."
|
||||
commands.allow = ["workspace_read"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-read"
|
||||
description = "Denies the workspace_read command without any pre-configured scope."
|
||||
commands.deny = ["workspace_read"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-recent"
|
||||
description = "Enables the workspace_recent command without any pre-configured scope."
|
||||
commands.allow = ["workspace_recent"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-recent"
|
||||
description = "Denies the workspace_recent command without any pre-configured scope."
|
||||
commands.deny = ["workspace_recent"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-rename"
|
||||
description = "Enables the workspace_rename command without any pre-configured scope."
|
||||
commands.allow = ["workspace_rename"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-rename"
|
||||
description = "Denies the workspace_rename command without any pre-configured scope."
|
||||
commands.deny = ["workspace_rename"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-revoke"
|
||||
description = "Enables the workspace_revoke command without any pre-configured scope."
|
||||
commands.allow = ["workspace_revoke"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-revoke"
|
||||
description = "Denies the workspace_revoke command without any pre-configured scope."
|
||||
commands.deny = ["workspace_revoke"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-tree"
|
||||
description = "Enables the workspace_tree command without any pre-configured scope."
|
||||
commands.allow = ["workspace_tree"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-tree"
|
||||
description = "Denies the workspace_tree command without any pre-configured scope."
|
||||
commands.deny = ["workspace_tree"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-workspace-write"
|
||||
description = "Enables the workspace_write command without any pre-configured scope."
|
||||
commands.allow = ["workspace_write"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-workspace-write"
|
||||
description = "Denies the workspace_write command without any pre-configured scope."
|
||||
commands.deny = ["workspace_write"]
|
||||
@@ -0,0 +1,3 @@
|
||||
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
|
||||
|
||||
pub mod workspace;
|
||||
@@ -0,0 +1,168 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
||||
|
||||
use notesagent_host::workspace::{Document, Entry, Workspace};
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager, State};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Host(Mutex<Option<Workspace>>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VaultInfo {
|
||||
vault_id: String,
|
||||
path: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn info(ws: &Workspace) -> VaultInfo {
|
||||
VaultInfo {
|
||||
vault_id: ws.vault_id.clone(),
|
||||
path: ws.root.to_string_lossy().into(),
|
||||
name: ws
|
||||
.root
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_workspace<T>(
|
||||
host: &Host,
|
||||
f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result<T>,
|
||||
) -> Result<T, String> {
|
||||
let mut guard = host.0.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":false,"sync":false,"credentials":false,"extensions":false,"release":"preview"})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn editor_capabilities(app: tauri::AppHandle, import_enabled: bool) -> Result<(), String> {
|
||||
app.state::<tauri::menu::MenuItem<tauri::Wry>>()
|
||||
.set_enabled(import_enabled)
|
||||
.map_err(|_| "MENU_UNAVAILABLE".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn workspace_choose(host: State<'_, Host>) -> Result<Option<VaultInfo>, 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")?;
|
||||
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);
|
||||
*guard = Some(workspace);
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn workspace_recent(host: State<'_, Host>) -> Result<Vec<VaultInfo>, String> {
|
||||
let guard = host.0.lock().map_err(|_| "HOST_BUSY")?;
|
||||
Ok(guard.as_ref().map(info).into_iter().collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn workspace_revoke(host: State<'_, Host>) -> Result<(), String> {
|
||||
*host.0.lock().map_err(|_| "HOST_BUSY")? = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn workspace_tree(host: State<'_, Host>) -> Result<Vec<Entry>, String> {
|
||||
with_workspace(&host, |ws| ws.scan())
|
||||
}
|
||||
#[tauri::command]
|
||||
fn workspace_read(host: State<'_, Host>, path: String) -> Result<Document, String> {
|
||||
with_workspace(&host, |ws| ws.read(&path))
|
||||
}
|
||||
#[tauri::command]
|
||||
fn workspace_write(
|
||||
host: State<'_, Host>,
|
||||
path: String,
|
||||
expected: String,
|
||||
content: String,
|
||||
) -> Result<Entry, String> {
|
||||
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<Entry, String> {
|
||||
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,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let paragraph = Submenu::with_items(app, "段落", true, &[&import])?;
|
||||
app.manage(import);
|
||||
app.set_menu(Menu::with_items(app, &[¶graph])?)?;
|
||||
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,
|
||||
editor_capabilities,
|
||||
workspace_choose,
|
||||
workspace_recent,
|
||||
workspace_revoke,
|
||||
workspace_tree,
|
||||
workspace_read,
|
||||
workspace_write,
|
||||
workspace_rename,
|
||||
workspace_delete,
|
||||
workspace_mkdir
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("桌面 Host 启动失败");
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
//! 每个 Vault 一个 OS 锁和 SQLite 日志;恢复只重放摘要仍匹配的写入,绝不覆盖外部修改。
|
||||
|
||||
use fs2::FileExt;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HostError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, HostError>;
|
||||
|
||||
impl HostError {
|
||||
fn new(code: &str) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: code.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for HostError {
|
||||
fn from(_: std::io::Error) -> Self {
|
||||
Self::new("FILESYSTEM_ERROR")
|
||||
}
|
||||
}
|
||||
impl From<rusqlite::Error> for HostError {
|
||||
fn from(_: rusqlite::Error) -> Self {
|
||||
Self::new("DATABASE_ERROR")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub file_id: String,
|
||||
pub path: String,
|
||||
pub hash: String,
|
||||
pub revision: i64,
|
||||
pub deleted: bool,
|
||||
#[serde(default)]
|
||||
pub is_folder: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Document {
|
||||
#[serde(flatten)]
|
||||
pub entry: Entry,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn linked(path: &Path) -> std::io::Result<bool> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
// junction 也是 reparse point,不能仅检查 symlink。
|
||||
Ok(metadata.file_attributes() & 0x400 != 0)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok(metadata.file_type().is_symlink())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
pub root: PathBuf,
|
||||
pub vault_id: String,
|
||||
db: Connection,
|
||||
_lock: File,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn open(root: &Path) -> Result<Self> {
|
||||
if !root.is_dir() || linked(root)? || root.to_string_lossy().starts_with("\\\\") {
|
||||
return Err(HostError::new("VAULT_PATH_UNSUPPORTED"));
|
||||
}
|
||||
let root = root.canonicalize()?;
|
||||
let managed = root.join(".ainote");
|
||||
if managed.exists() && linked(&managed)? {
|
||||
return Err(HostError::new("UNSAFE_PATH"));
|
||||
}
|
||||
fs::create_dir_all(&managed)?;
|
||||
let lock_path = managed.join("host.lock");
|
||||
if lock_path.exists() && linked(&lock_path)? {
|
||||
return Err(HostError::new("UNSAFE_PATH"));
|
||||
}
|
||||
let lock = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(lock_path)?;
|
||||
lock.try_lock_exclusive()
|
||||
.map_err(|_| HostError::new("VAULT_ALREADY_OPEN"))?;
|
||||
let db_path = managed.join("host.sqlite3");
|
||||
if db_path.exists() && linked(&db_path)? {
|
||||
return Err(HostError::new("UNSAFE_PATH"));
|
||||
}
|
||||
let db = Connection::open(db_path)?;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
if version > 1 {
|
||||
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
|
||||
}
|
||||
db.execute_batch("BEGIN IMMEDIATE;
|
||||
CREATE TABLE IF NOT EXISTS identity (id TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY,path TEXT UNIQUE NOT NULL,hash TEXT NOT NULL,revision INTEGER NOT NULL,deleted INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS journal (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,path TEXT NOT NULL,expected TEXT NOT NULL,content BLOB NOT NULL,origin TEXT NOT NULL,state TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS file_ops (id TEXT PRIMARY KEY,kind TEXT NOT NULL,path TEXT NOT NULL,destination TEXT NOT NULL,hash TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending');
|
||||
CREATE TABLE IF NOT EXISTS outbox (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,revision INTEGER NOT NULL,path TEXT NOT NULL,hash TEXT NOT NULL,operation TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending');
|
||||
PRAGMA user_version=1; COMMIT;")?;
|
||||
let vault_id: String = db
|
||||
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
||||
.optional()?
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
db.execute(
|
||||
"INSERT INTO identity SELECT ?1 WHERE NOT EXISTS (SELECT 1 FROM identity)",
|
||||
[&vault_id],
|
||||
)?;
|
||||
let mut workspace = Self {
|
||||
root,
|
||||
vault_id,
|
||||
db,
|
||||
_lock: lock,
|
||||
};
|
||||
workspace.recover()?;
|
||||
workspace.scan()?;
|
||||
Ok(workspace)
|
||||
}
|
||||
|
||||
pub fn resolve(&self, relative: &str) -> Result<PathBuf> {
|
||||
if relative.is_empty() || relative.contains('\\') || relative.starts_with('/') {
|
||||
return Err(HostError::new("UNSAFE_PATH"));
|
||||
}
|
||||
let mut path = self.root.clone();
|
||||
for component in Path::new(relative).components() {
|
||||
let Component::Normal(value) = component else {
|
||||
return Err(HostError::new("UNSAFE_PATH"));
|
||||
};
|
||||
let name = value.to_string_lossy();
|
||||
let stem = name.split('.').next().unwrap_or("").to_ascii_uppercase();
|
||||
if name.eq_ignore_ascii_case(".ainote")
|
||||
|| name.eq_ignore_ascii_case(".git")
|
||||
|| name.ends_with(['.', ' '])
|
||||
|| name
|
||||
.chars()
|
||||
.any(|c| c.is_control() || "<>:\"|?*".contains(c))
|
||||
|| ["CON", "PRN", "AUX", "NUL"].contains(&stem.as_str())
|
||||
|| (stem.len() == 4
|
||||
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.as_bytes()[3].is_ascii_digit())
|
||||
{
|
||||
return Err(HostError::new("UNSAFE_PATH"));
|
||||
}
|
||||
path.push(value);
|
||||
if path.exists() && linked(&path)? {
|
||||
return Err(HostError::new("UNSAFE_PATH"));
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn entry(&self, path: &str) -> Result<Option<Entry>> {
|
||||
Ok(self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT id,path,hash,revision,deleted FROM files WHERE path=?1",
|
||||
[path],
|
||||
|r| {
|
||||
Ok(Entry {
|
||||
file_id: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
hash: r.get(2)?,
|
||||
revision: r.get(3)?,
|
||||
deleted: r.get(4)?,
|
||||
is_folder: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn scan_dir(&self, dir: &Path, paths: &mut Vec<String>) -> Result<()> {
|
||||
for item in fs::read_dir(dir)? {
|
||||
let path = item?.path();
|
||||
let relative = path
|
||||
.strip_prefix(&self.root)
|
||||
.map_err(|_| HostError::new("UNSAFE_PATH"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if relative
|
||||
.split('/')
|
||||
.any(|s| s.eq_ignore_ascii_case(".ainote") || s.eq_ignore_ascii_case(".git"))
|
||||
|| linked(&path)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
paths.push(relative);
|
||||
self.scan_dir(&path, paths)?;
|
||||
} else if path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
|
||||
{
|
||||
paths.push(relative);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scan(&mut self) -> Result<Vec<Entry>> {
|
||||
let mut paths = Vec::new();
|
||||
self.scan_dir(&self.root, &mut paths)?;
|
||||
let mut entries = Vec::new();
|
||||
for path in paths {
|
||||
if self.resolve(&path)?.is_dir() {
|
||||
entries.push(Entry {
|
||||
file_id: format!("folder:{path}"),
|
||||
path,
|
||||
hash: String::new(),
|
||||
revision: 0,
|
||||
deleted: false,
|
||||
is_folder: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let content = fs::read(self.resolve(&path)?)?;
|
||||
let digest = hash(&content);
|
||||
let previous = self.entry(&path)?;
|
||||
if previous
|
||||
.as_ref()
|
||||
.is_none_or(|e| e.hash != digest || e.deleted)
|
||||
{
|
||||
let id = previous
|
||||
.as_ref()
|
||||
.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id.clone());
|
||||
self.db.execute("INSERT INTO files VALUES (?1,?2,?3,1,0) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,revision=files.revision+1,deleted=0", params![id,path,digest])?;
|
||||
}
|
||||
entries.push(
|
||||
self.entry(&path)?
|
||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?,
|
||||
);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn read(&mut self, path: &str) -> Result<Document> {
|
||||
self.scan()?;
|
||||
let entry = self
|
||||
.entry(path)?
|
||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
|
||||
let content = fs::read_to_string(self.resolve(path)?)?;
|
||||
if hash(content.as_bytes()) != entry.hash {
|
||||
return Err(HostError::new("REVISION_CONFLICT"));
|
||||
}
|
||||
Ok(Document { entry, content })
|
||||
}
|
||||
|
||||
pub fn write(
|
||||
&mut self,
|
||||
path: &str,
|
||||
expected: &str,
|
||||
content: &[u8],
|
||||
origin: &str,
|
||||
) -> Result<Entry> {
|
||||
if content.len() > 100 * 1024 * 1024 {
|
||||
return Err(HostError::new("FILE_TOO_LARGE"));
|
||||
}
|
||||
if origin != "local" && origin != "remote" {
|
||||
return Err(HostError::new("INVALID_ORIGIN"));
|
||||
}
|
||||
let target = self.resolve(path)?;
|
||||
let current = if target.exists() {
|
||||
hash(&fs::read(&target)?)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if current != expected {
|
||||
return Err(HostError::new("REVISION_CONFLICT"));
|
||||
}
|
||||
let file_id = self
|
||||
.entry(path)?
|
||||
.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id);
|
||||
let operation_id = Uuid::new_v4().to_string();
|
||||
self.db.execute(
|
||||
"INSERT INTO journal VALUES (?1,?2,?3,?4,?5,?6,'pending')",
|
||||
params![operation_id, file_id, path, expected, content, origin],
|
||||
)?;
|
||||
self.apply_journal(&operation_id, &file_id, path, expected, content, origin)?;
|
||||
self.entry(path)?
|
||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
fn apply_journal(
|
||||
&mut self,
|
||||
operation_id: &str,
|
||||
file_id: &str,
|
||||
path: &str,
|
||||
expected: &str,
|
||||
content: &[u8],
|
||||
origin: &str,
|
||||
) -> Result<()> {
|
||||
let target = self.resolve(path)?;
|
||||
let digest = hash(content);
|
||||
let current = if target.exists() {
|
||||
hash(&fs::read(&target)?)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if current != expected && current != digest {
|
||||
self.db.execute(
|
||||
"UPDATE journal SET state='conflict' WHERE operation_id=?1",
|
||||
[operation_id],
|
||||
)?;
|
||||
return Err(HostError::new("RECOVERY_CONFLICT"));
|
||||
}
|
||||
if current != digest {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| HostError::new("UNSAFE_PATH"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
temp.write_all(content)?;
|
||||
temp.as_file().sync_all()?;
|
||||
temp.persist(&target)
|
||||
.map_err(|_| HostError::new("ATOMIC_REPLACE_FAILED"))?;
|
||||
#[cfg(unix)]
|
||||
File::open(parent)?.sync_all()?;
|
||||
}
|
||||
// 文件成功但 DB 未提交时,重启凭 journal 补齐同一 operation_id,避免丢 outbox。
|
||||
let tx = self.db.transaction()?;
|
||||
tx.execute("INSERT INTO files VALUES (?1,?2,?3,1,0) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,revision=files.revision+1,deleted=0", params![file_id,path,digest])?;
|
||||
if origin == "local" {
|
||||
tx.execute("INSERT OR IGNORE INTO outbox SELECT ?1,id,revision,path,hash,'put',?2,'pending' FROM files WHERE path=?3", params![operation_id,content,path])?;
|
||||
}
|
||||
tx.execute("DELETE FROM journal WHERE operation_id=?1", [operation_id])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn recover(&mut self) -> Result<()> {
|
||||
let operations = {
|
||||
let mut statement = self
|
||||
.db
|
||||
.prepare("SELECT id FROM file_ops WHERE state='pending'")?;
|
||||
let values = statement
|
||||
.query_map([], |r| r.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
values
|
||||
};
|
||||
for operation in operations {
|
||||
match self.apply_file_op(&operation) {
|
||||
Err(error) if error.code == "RECOVERY_CONFLICT" => {}
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
let pending = {
|
||||
let mut statement = self.db.prepare("SELECT operation_id,file_id,path,expected,content,origin FROM journal WHERE state='pending'")?;
|
||||
let result = statement
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, Vec<u8>>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
result
|
||||
};
|
||||
for (op, id, path, expected, content, origin) in pending {
|
||||
match self.apply_journal(&op, &id, &path, &expected, &content, &origin) {
|
||||
Err(e) if e.code == "RECOVERY_CONFLICT" => {}
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pending_count(&self) -> Result<i64> {
|
||||
Ok(self.db.query_row(
|
||||
"SELECT COUNT(*) FROM outbox WHERE state='pending'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn mkdir(&self, path: &str) -> Result<()> {
|
||||
fs::create_dir_all(self.resolve(path)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename(&mut self, path: &str, destination: &str, expected: &str) -> Result<Entry> {
|
||||
let target = self.resolve(destination)?;
|
||||
if target.exists() {
|
||||
return Err(HostError::new("PATH_CONFLICT"));
|
||||
}
|
||||
let operation = self.prepare_file_op("rename", path, destination, expected)?;
|
||||
self.apply_file_op(&operation)?;
|
||||
self.entry(destination)?
|
||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
pub fn delete(&mut self, path: &str, expected: &str) -> Result<()> {
|
||||
let operation = self.prepare_file_op("delete", path, "", expected)?;
|
||||
self.apply_file_op(&operation)
|
||||
}
|
||||
|
||||
fn prepare_file_op(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
path: &str,
|
||||
destination: &str,
|
||||
expected: &str,
|
||||
) -> Result<String> {
|
||||
let source = self.resolve(path)?;
|
||||
if !source.is_file() {
|
||||
return Err(HostError::new("FILE_NOT_FOUND"));
|
||||
}
|
||||
let content = fs::read(source)?;
|
||||
if hash(&content) != expected {
|
||||
return Err(HostError::new("REVISION_CONFLICT"));
|
||||
}
|
||||
self.entry(path)?
|
||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
self.db.execute(
|
||||
"INSERT INTO file_ops VALUES (?1,?2,?3,?4,?5,?6,'pending')",
|
||||
params![id, kind, path, destination, expected, content],
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn apply_file_op(&mut self, id: &str) -> Result<()> {
|
||||
let (kind, path, destination, expected, content): (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Vec<u8>,
|
||||
) = self.db.query_row(
|
||||
"SELECT kind,path,destination,hash,content FROM file_ops WHERE id=?1",
|
||||
[id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
||||
)?;
|
||||
let source = self.resolve(&path)?;
|
||||
let previous = self
|
||||
.entry(&path)?
|
||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
|
||||
let source_conflict = source.exists() && hash(&fs::read(&source)?) != expected;
|
||||
let target = if kind == "rename" {
|
||||
self.resolve(&destination)?
|
||||
} else {
|
||||
let trash = self.root.join(".ainote").join("trash");
|
||||
if trash.exists() && linked(&trash)? {
|
||||
return Err(HostError::new("UNSAFE_PATH"));
|
||||
}
|
||||
fs::create_dir_all(&trash)?;
|
||||
trash.join(id)
|
||||
};
|
||||
let target_conflict =
|
||||
target.exists() && (linked(&target)? || hash(&fs::read(&target)?) != expected);
|
||||
if source_conflict || target_conflict {
|
||||
self.db
|
||||
.execute("UPDATE file_ops SET state='conflict' WHERE id=?1", [id])?;
|
||||
return Err(HostError::new("RECOVERY_CONFLICT"));
|
||||
}
|
||||
// journal 保留完整内容,目标刷盘后才删除来源;两处崩溃均可幂等重放。
|
||||
if !target.exists() {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| HostError::new("UNSAFE_PATH"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
temp.write_all(&content)?;
|
||||
temp.as_file().sync_all()?;
|
||||
temp.persist_noclobber(&target)
|
||||
.map_err(|_| HostError::new("PATH_CONFLICT"))?;
|
||||
}
|
||||
if source.exists() {
|
||||
fs::remove_file(source)?;
|
||||
}
|
||||
let tx = self.db.transaction()?;
|
||||
if kind == "rename" {
|
||||
tx.execute(
|
||||
"UPDATE files SET path=?1,revision=revision+1 WHERE id=?2",
|
||||
params![destination, previous.file_id],
|
||||
)?;
|
||||
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,hash,'put',?2,'pending' FROM files WHERE id=?3", params![id,content,previous.file_id])?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"UPDATE files SET deleted=1,revision=revision+1 WHERE id=?1",
|
||||
[&previous.file_id],
|
||||
)?;
|
||||
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,'','delete',X'','pending' FROM files WHERE id=?2", params![id,previous.file_id])?;
|
||||
}
|
||||
tx.execute("DELETE FROM file_ops WHERE id=?1", [id])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rename_and_delete_recover_after_source_removed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(dir.path()).unwrap();
|
||||
let original = ws.write("a.md", "", b"safe", "local").unwrap();
|
||||
let operation = ws
|
||||
.prepare_file_op("rename", "a.md", "b.md", &original.hash)
|
||||
.unwrap();
|
||||
fs::rename(dir.path().join("a.md"), dir.path().join("b.md")).unwrap();
|
||||
ws.apply_file_op(&operation).unwrap();
|
||||
assert_eq!(ws.read("b.md").unwrap().entry.file_id, original.file_id);
|
||||
let operation = ws
|
||||
.prepare_file_op("delete", "b.md", "", &original.hash)
|
||||
.unwrap();
|
||||
fs::remove_file(dir.path().join("b.md")).unwrap();
|
||||
ws.apply_file_op(&operation).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join(".ainote/trash").join(operation)).unwrap(),
|
||||
b"safe"
|
||||
);
|
||||
assert_eq!(ws.pending_count().unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_conflict_and_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(dir.path()).unwrap();
|
||||
let first = ws.write("中文/笔记.md", "", b"first", "local").unwrap();
|
||||
assert!(Workspace::open(dir.path()).is_err());
|
||||
assert_eq!(
|
||||
ws.write("中文/笔记.md", "", b"lost", "local")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"REVISION_CONFLICT"
|
||||
);
|
||||
let second = ws
|
||||
.write("中文/笔记.md", &first.hash, b"second", "local")
|
||||
.unwrap();
|
||||
assert_eq!(first.file_id, second.file_id);
|
||||
assert_eq!(ws.pending_count().unwrap(), 2);
|
||||
drop(ws);
|
||||
let mut ws = Workspace::open(dir.path()).unwrap();
|
||||
assert_eq!(ws.read("中文/笔记.md").unwrap().content, "second");
|
||||
assert_eq!(ws.pending_count().unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_paths_external_change_and_remote_origin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(dir.path()).unwrap();
|
||||
for path in ["../x", "/x", "x\\y", "CON.md", ".ainote/file", "a:b", "x."] {
|
||||
assert!(ws.resolve(path).is_err(), "{path}");
|
||||
}
|
||||
let first = ws.write("a.md", "", b"a", "remote").unwrap();
|
||||
assert_eq!(ws.pending_count().unwrap(), 0);
|
||||
fs::write(dir.path().join("a.md"), b"external").unwrap();
|
||||
assert_eq!(
|
||||
ws.write("a.md", &first.hash, b"lost", "local")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"REVISION_CONFLICT"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_after_file_replace_recovers_outbox_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(dir.path()).unwrap();
|
||||
let id = Uuid::new_v4().to_string();
|
||||
ws.db
|
||||
.execute(
|
||||
"INSERT INTO journal VALUES ('operation',?1,'a.md','',?2,'local','pending')",
|
||||
params![id, b"recovered".as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.path().join("a.md"), b"recovered").unwrap();
|
||||
ws.recover().unwrap();
|
||||
ws.recover().unwrap();
|
||||
assert_eq!(ws.pending_count().unwrap(), 1);
|
||||
assert_eq!(ws.read("a.md").unwrap().content, "recovered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_keeps_both_sides_on_external_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(dir.path()).unwrap();
|
||||
ws.db
|
||||
.execute(
|
||||
"INSERT INTO journal VALUES ('operation','file','a.md','',?1,'local','pending')",
|
||||
[b"pending".as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.path().join("a.md"), b"external").unwrap();
|
||||
ws.recover().unwrap();
|
||||
assert_eq!(fs::read(dir.path().join("a.md")).unwrap(), b"external");
|
||||
let state: String = ws
|
||||
.db
|
||||
.query_row("SELECT state FROM journal", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(state, "conflict");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "NotesAgent Preview",
|
||||
"version": "0.3.0-alpha.1",
|
||||
"identifier": "cc.kronecker.notesagent",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1200, "height": 800, "minWidth": 640, "minHeight": 480}],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"capabilities": ["main"]
|
||||
}
|
||||
},
|
||||
"bundle": {"active": false}
|
||||
}
|
||||
Reference in New Issue
Block a user