feat(sync): 持久化类型化主题与编辑器偏好并支持草稿恢复

This commit is contained in:
2026-09-08 19:54:24 +08:00
parent cf39b1a905
commit 5c995e66be
20 changed files with 681 additions and 16 deletions
+2
View File
@@ -16,6 +16,8 @@ fn main() {
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
tauri_build::AppManifest::new().commands(&[
"host_capabilities",
"record_get",
"record_write",
"sync_login",
"sync_vaults",
"sync_create_vault",
+3 -1
View File
@@ -47,6 +47,8 @@
"allow-sync-resolve",
"allow-sync-logout",
"allow-sync-run",
"allow-sync-preview"
"allow-sync-preview",
"allow-record-get",
"allow-record-write"
]
}
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-record-get"
description = "Enables the record_get command without any pre-configured scope."
commands.allow = ["record_get"]
[[permission]]
identifier = "deny-record-get"
description = "Denies the record_get command without any pre-configured scope."
commands.deny = ["record_get"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-record-write"
description = "Enables the record_write command without any pre-configured scope."
commands.allow = ["record_write"]
[[permission]]
identifier = "deny-record-write"
description = "Denies the record_write command without any pre-configured scope."
commands.deny = ["record_write"]
+1
View File
@@ -3,6 +3,7 @@
pub mod core;
pub mod credentials;
mod payloads;
mod preference_records;
pub mod recent;
pub mod records;
#[cfg(feature = "desktop")]
+4
View File
@@ -2,6 +2,8 @@
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
mod record_commands;
use record_commands::*;
mod sync_commands;
use sync_commands::*;
@@ -831,6 +833,8 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
host_capabilities,
record_get,
record_write,
sync_login,
sync_vaults,
sync_create_vault,
@@ -0,0 +1,180 @@
//! Preference schemas contain portable values only; no paths, permissions, providers or secrets.
use crate::workspace::{HostError, Result};
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Level {
size: f64,
weight: u16,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Headings {
custom: bool,
family: String,
levels: Vec<Level>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Theme {
theme_id: String,
font_editor_size: f64,
font_editor_family: String,
line_height: f64,
code_block_theme: String,
headings: Headings,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Markdown {
heading: String,
bullet: String,
increment_list: bool,
fence: String,
math: bool,
callouts: bool,
diagrams: bool,
auto_links: bool,
line_numbers: bool,
wrap_code: bool,
indent: u8,
default_language: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Preset {
name: String,
preferences: Markdown,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Preferences {
restore_last_vault: bool,
auto_save_interval: u32,
language: String,
default_editor_mode: String,
editor_line_width: u16,
spell_check: bool,
markdown: Markdown,
presets: Vec<Preset>,
}
fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
serde_json::from_value(value.clone()).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))
}
fn markdown(value: &Markdown) -> bool {
let _ = (
value.increment_list,
value.math,
value.callouts,
value.diagrams,
value.auto_links,
value.line_numbers,
value.wrap_code,
);
matches!(value.heading.as_str(), "atx" | "setext")
&& matches!(value.bullet.as_str(), "-" | "*" | "+")
&& matches!(value.fence.as_str(), "`" | "~")
&& [2, 4, 8].contains(&value.indent)
&& value.default_language.len() <= 40
&& value
.default_language
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b))
}
pub fn validate(kind: &str, value: &Value) -> Result<()> {
let valid = match kind {
"theme_settings" => {
let value: Theme = decode(value)?;
let _ = value.headings.custom;
!value.theme_id.is_empty()
&& value.theme_id.len() <= 128
&& value
.theme_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
&& (6.0..=72.0).contains(&value.font_editor_size)
&& (1.0..=3.0).contains(&value.line_height)
&& !value.font_editor_family.is_empty()
&& value.font_editor_family.len() <= 256
&& !value
.font_editor_family
.chars()
.any(|v| v.is_control() || ";{}\\".contains(v))
&& matches!(
value.code_block_theme.as_str(),
"auto" | "github-light" | "github-dark"
)
&& matches!(
value.headings.family.as_str(),
"inherit" | "serif" | "sans-serif" | "monospace"
)
&& value.headings.levels.len() == 6
&& value.headings.levels.iter().all(|level| {
(12.0..=72.0).contains(&level.size)
&& [400, 500, 600, 700, 800].contains(&level.weight)
})
}
"preferences" => {
let value: Preferences = decode(value)?;
let _ = (value.restore_last_vault, value.spell_check);
(50..=60000).contains(&value.auto_save_interval)
&& matches!(value.language.as_str(), "zh-CN" | "en")
&& matches!(value.default_editor_mode.as_str(), "source" | "wysiwyg")
&& (40..=200).contains(&value.editor_line_width)
&& markdown(&value.markdown)
&& value.presets.len() <= 20
&& value.presets.iter().all(|p| {
!p.name.trim().is_empty()
&& p.name.chars().count() <= 40
&& markdown(&p.preferences)
})
}
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
};
if valid {
Ok(())
} else {
Err(HostError::new("RECORD_DATA_INVALID"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn portable_preferences_reject_unowned_nested_fields_and_invalid_values() {
let theme = json!({"themeId":"dark","fontEditorSize":18,"fontEditorFamily":"system-ui","lineHeight":1.7,"codeBlockTheme":"auto","headings":{"custom":false,"family":"inherit","levels":([32,28,24,21,18,16].map(|size|json!({"size":size,"weight":700})))}});
validate("theme_settings", &theme).unwrap();
let markdown = json!({"heading":"atx","bullet":"-","incrementList":true,"fence":"`","math":true,"callouts":true,"diagrams":true,"autoLinks":true,"lineNumbers":true,"wrapCode":false,"indent":4,"defaultLanguage":""});
let preferences = json!({"restoreLastVault":true,"autoSaveInterval":1500,"language":"zh-CN","defaultEditorMode":"wysiwyg","editorLineWidth":80,"spellCheck":false,"markdown":markdown,"presets":[]});
validate("preferences", &preferences).unwrap();
for field in [
"apiKey",
"permissions",
"environment",
"vaultPath",
"provider",
] {
let mut bad = preferences.clone();
bad[field] = json!("planted-secret");
assert_eq!(
validate("preferences", &bad).unwrap_err().code,
"RECORD_SCHEMA_INVALID"
);
}
let mut bad = preferences.clone();
bad["markdown"]["apiKey"] = json!("planted-secret");
assert_eq!(
validate("preferences", &bad).unwrap_err().code,
"RECORD_SCHEMA_INVALID"
);
let mut bad = theme;
bad["fontEditorFamily"] = json!("x;url(secret)");
assert_eq!(
validate("theme_settings", &bad).unwrap_err().code,
"RECORD_DATA_INVALID"
);
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Main-window preference records; no generic credential or application-state accessor.
use super::{with_workspace, Host};
use notesagent_host::{records, workspace::HostError};
use serde::Deserialize;
use serde_json::{json, Value};
use tauri::State;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Get {
vault_id: String,
kind: String,
id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Write {
vault_id: String,
record: Value,
expected: String,
operation_id: String,
}
fn preference(kind: &str) -> Result<(), String> {
if matches!(kind, "theme_settings" | "preferences") {
Ok(())
} else {
Err("RECORD_SCOPE_DENIED".into())
}
}
#[tauri::command]
pub fn record_get(host: State<'_, Host>, request: Get) -> Result<Option<Value>, String> {
preference(&request.kind)?;
with_workspace(&host, |ws| {
if ws.vault_id != request.vault_id {
return Err(HostError::new("VAULT_CHANGED"));
}
ws.record_get_kind(&request.kind, &request.id)
})
}
#[tauri::command]
pub fn record_write(host: State<'_, Host>, request: Write) -> Result<Value, String> {
let kind = request.record["kind"]
.as_str()
.ok_or("RECORD_SCHEMA_INVALID")?;
preference(kind)?;
let path =
records::path_for(kind, request.record["id"].as_str().unwrap_or("")).map_err(|e| e.code)?;
let bytes = serde_json::to_vec(&request.record).map_err(|_| "RECORD_SCHEMA_INVALID")?;
with_workspace(&host, |ws| {
if ws.vault_id != request.vault_id {
return Err(HostError::new("VAULT_CHANGED"));
}
let entry = ws.write_operation(
&path,
&request.expected,
&bytes,
"local",
&request.operation_id,
)?;
Ok(json!({"record":request.record,"hash":entry.hash,"file_id":entry.file_id}))
})
}
+40 -11
View File
@@ -19,7 +19,7 @@ pub struct Record {
pub schema: u32,
pub kind: String,
pub id: String,
pub data: TaskData,
pub data: Value,
}
pub fn path(id: &str) -> Result<String> {
if !id.starts_with("task_")
@@ -32,10 +32,27 @@ pub fn path(id: &str) -> Result<String> {
}
Ok(format!("opennexus-records/v1/tasks/{id}.json"))
}
pub fn path_for(kind: &str, id: &str) -> Result<String> {
match (kind, id) {
("task", id) => path(id),
("theme_settings", "appearance") => {
Ok("opennexus-records/v1/theme-settings/appearance.json".into())
}
("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()),
_ => Err(HostError::new("RECORD_ID_INVALID")),
}
}
pub fn is_record(path: &str) -> bool {
path.starts_with("opennexus-records/")
}
pub fn allowed(path_value: &str) -> bool {
if matches!(
path_value,
"opennexus-records/v1/theme-settings/appearance.json"
| "opennexus-records/v1/preferences/editor.json"
) {
return true;
}
path_value
.strip_prefix("opennexus-records/v1/tasks/")
.and_then(|v| v.strip_suffix(".json"))
@@ -48,10 +65,15 @@ pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
let record: Record =
serde_json::from_slice(content).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?;
let time = |value: i64| (0..=253402300799999).contains(&value);
if record.schema != 1 || record.kind != "task" || path(&record.id)? != path_value {
if record.schema != 1 || path_for(&record.kind, &record.id)? != path_value {
return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED"));
}
let data = &record.data;
if record.kind != "task" {
crate::preference_records::validate(&record.kind, &record.data)?;
return Ok(record);
}
let data: TaskData = serde_json::from_value(record.data.clone())
.map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?;
if data.title.trim().is_empty()
|| data.title.len() > 4096
|| data.description.len() > 262144
@@ -76,15 +98,19 @@ pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
}
impl Workspace {
pub(crate) fn normalize_record_links(&mut self) -> Result<()> {
for path in self.sync_paths()?.into_iter().filter(|v| allowed(v)) {
for path in self
.sync_paths()?
.into_iter()
.filter(|v| v.starts_with("opennexus-records/v1/tasks/") && allowed(v))
{
let bytes = std::fs::read(self.resolve(&path)?)?;
let original = validate(&path, &bytes)?;
if let Some(note_id) = original.data.note_id.as_ref() {
if let Some(note_id) = original.data["note_id"].as_str() {
if let Ok(note_path) = self.path_for_id(note_id) {
if let Some(entry) = self.entry(&note_path)? {
if &entry.file_id != note_id {
if entry.file_id != note_id {
let mut record = original;
record.data.note_id = Some(entry.file_id);
record.data["note_id"] = json!(entry.file_id);
self.write(
&path,
&crate::workspace::hash(&bytes),
@@ -100,7 +126,10 @@ impl Workspace {
Ok(())
}
pub fn record_get(&mut self, id: &str) -> Result<Option<Value>> {
let path = path(id)?;
self.record_get_kind("task", id)
}
pub fn record_get_kind(&mut self, kind: &str, id: &str) -> Result<Option<Value>> {
let path = path_for(kind, id)?;
if !self.resolve(&path)?.is_file() {
return Ok(None);
}
@@ -109,10 +138,10 @@ impl Workspace {
}
let document = self.read(&path)?;
let mut record = validate(&path, document.content.as_bytes())?;
if let Some(note_id) = record.data.note_id.as_ref() {
if let Some(note_id) = record.data["note_id"].as_str() {
if let Ok(path) = self.path_for_id(note_id) {
if let Some(entry) = self.entry(&path)? {
record.data.note_id = Some(entry.file_id);
record.data["note_id"] = json!(entry.file_id);
}
}
}
@@ -127,7 +156,7 @@ impl Workspace {
let paths = self
.sync_paths()?
.into_iter()
.filter(|path| allowed(path))
.filter(|path| path.starts_with("opennexus-records/v1/tasks/") && allowed(path))
.collect::<Vec<_>>();
let total = paths.len();
let mut items = Vec::new();
+3
View File
@@ -40,6 +40,9 @@ impl Workspace {
return Err(HostError::new("FILE_TOO_LARGE"));
}
let bytes = fs::read(source)?;
if crate::records::is_record(&path) {
crate::records::validate(&path, &bytes)?;
}
Ok(Local {
path,
hash: hash(&bytes),
+51
View File
@@ -484,6 +484,57 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
.record_get(task_id)
.unwrap()
.is_none());
let preference_path = "opennexus-records/v1/theme-settings/appearance.json";
let preference_record = json!({"schema":1,"kind":"theme_settings","id":"appearance","data":{"themeId":"dark","fontEditorSize":18,"fontEditorFamily":"system-ui","lineHeight":1.7,"codeBlockTheme":"auto","headings":{"custom":false,"family":"inherit","levels":([32,28,24,21,18,16].map(|size|json!({"size":size,"weight":700})))}}});
workspace
.lock()
.unwrap()
.write(
preference_path,
"",
&serde_json::to_vec(&preference_record).unwrap(),
"local",
)
.unwrap();
client.push_one(&workspace, &binding).await.unwrap();
client_b.pull_page(&workspace_b, &binding_b).await.unwrap();
assert_eq!(
workspace_b
.lock()
.unwrap()
.record_get_kind("theme_settings", "appearance")
.unwrap()
.unwrap()["record"],
preference_record
);
{
let mut ws = workspace_b.lock().unwrap();
let stored = ws
.record_get_kind("theme_settings", "appearance")
.unwrap()
.unwrap();
let mut record = stored["record"].clone();
record["data"]["fontEditorSize"] = json!(24);
ws.write(
preference_path,
stored["hash"].as_str().unwrap(),
&serde_json::to_vec(&record).unwrap(),
"local",
)
.unwrap();
}
client_b.push_one(&workspace_b, &binding_b).await.unwrap();
client.pull_page(&workspace, &binding).await.unwrap();
assert_eq!(
workspace
.lock()
.unwrap()
.record_get_kind("theme_settings", "appearance")
.unwrap()
.unwrap()["record"]["data"]["fontEditorSize"],
24
);
// Kill the actual client process after each durable 10 MiB server offset,
// before its response reaches the client. The next process must query offset.
use sha2::{Digest, Sha256};