diff --git a/docs/contracts/Sync-v1契约.md b/docs/contracts/Sync-v1契约.md index eef36f5..91683a4 100644 --- a/docs/contracts/Sync-v1契约.md +++ b/docs/contracts/Sync-v1契约.md @@ -63,4 +63,13 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字 桌面 Task CRUD 经 Host records broker,操作重放返回原始记录且同 ID 不同字段拒绝;Core 不以全局 SQLite 作为任务来源。已明确归属于当前 Vault 的旧 Task 表在首次访问时逐条迁移,完成后设置所有权标记,来源表保留;未分配 Vault 的全局旧数据不猜测归属。文件身份采纳时,任务链接通过 Host 别名解析,并在上传队列物化前将规范化引用写成新的逻辑记录。 -本节仅交付任务逻辑记录。用户 Skill/配置、主题、可选对话等仍须逐类定义白名单与适配器;不得用复制任意 JSON/SQLite 代替。 +用户 Skill/配置、可选对话等仍须逐类定义白名单与适配器;不得用复制任意 JSON/SQLite 代替。 + + +## 主题与编辑器偏好记录 v1 + +主题设置使用 `opennexus-records/v1/theme-settings/appearance.json`,kind 为 theme_settings、id 为 appearance;编辑器偏好使用 `opennexus-records/v1/preferences/editor.json`,kind 为 preferences、id 为 editor。两者沿用 schema/id/kind/data 信封、文件 CAS、幂等操作和冲突保留规则。 + +主题白名单为已安装主题标识、编辑字体与字号、行高、代码配色、六级标题字体大小与字重;偏好白名单为恢复上次库的布尔偏好、自动保存间隔、语言、编辑模式、行宽、拼写检查、Markdown 格式与最多 20 项格式预设。Rust 拒绝未知嵌套字段并限制字符串、枚举、数量和数值范围。不传输实际 Vault 路径、凭据、权限、环境变量或自定义主题程序包。目标设备缺少主题时使用内置浅色主题并显示提示。 + +前端按 Vault 和记录类别持久保存草稿、基线摘要与操作 ID;重启恢复原草稿,CAS 冲突必须选择保留本地或采用 Workspace 版本。选择期间出现新编辑时保留新编辑。远端应用不产生回流。轮询空库不自动创建设置;初始上传和合并预览显式写入当前偏好,空库下载不预写设置。 diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index bfc7f62..05f6ef2 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -100,3 +100,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 本地文件 ID 采纳后的 task note_id 通过别名解析,上传前生成持久规范化引用,避免把本机旧 ID 留给另一设备。其余逻辑记录类别继续实施。 - Task 增量后端全量 900 项、Rust desktop 全目标 50 项通过(另 1 个父测试驱动的进程辅助入口),Clippy `-D warnings` 通过。 + + +## 增量:主题与编辑器偏好逻辑记录 + +- Host 新增两个仅限主窗口、校验当前 Vault 的偏好命令;Rust 逐字段校验主题和编辑器设置,普通 JSON、权限、密钥和本机路径不进入记录。 +- 设置编辑先保存按 Vault 隔离的持久草稿,再通过 CAS 写入 Workspace。覆盖选择串行等待在途写入,选择远端期间的新编辑不被丢弃;存储删除失败时保留内存及磁盘草稿并报告失败。远端主题应用不产生同步回流。 +- 初始上传和合并预览包含当前偏好;空库下载不提前创建记录。真实双客户端 HTTP 测试新增主题记录传播,嵌套未知字段和非法值由 Rust 测试验证。 +- 本增量全量前端 97 文件 / 515 项通过;其后补充存储故障修复,两文件 7 项针对性测试通过。TypeScript 两项目检查通过,Rust desktop 全目标通过(数量见测试日志),Clippy `-D warnings` 通过。测试日志为 `.build/preferences-frontend-tests.log` 和 `.build/preferences-rust-tests.log`。生产签名、OS 沙箱、Skill/配置等未完成项仍未通过整体验收。 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 798a948..9c5ff10 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -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", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 2b7cc1c..a80de52 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -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" ] } diff --git a/frontend/src-tauri/permissions/autogenerated/record_get.toml b/frontend/src-tauri/permissions/autogenerated/record_get.toml new file mode 100644 index 0000000..381a8c9 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/record_get.toml @@ -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"] diff --git a/frontend/src-tauri/permissions/autogenerated/record_write.toml b/frontend/src-tauri/permissions/autogenerated/record_write.toml new file mode 100644 index 0000000..aba89fa --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/record_write.toml @@ -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"] diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index c3a4698..348cf1c 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod core; pub mod credentials; mod payloads; +mod preference_records; pub mod recent; pub mod records; #[cfg(feature = "desktop")] diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index b2a4e6b..feb8111 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -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, diff --git a/frontend/src-tauri/src/preference_records.rs b/frontend/src-tauri/src/preference_records.rs new file mode 100644 index 0000000..ce862dd --- /dev/null +++ b/frontend/src-tauri/src/preference_records.rs @@ -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, +} +#[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, +} +fn decode(value: &Value) -> Result { + 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" + ); + } +} diff --git a/frontend/src-tauri/src/record_commands.rs b/frontend/src-tauri/src/record_commands.rs new file mode 100644 index 0000000..53e49df --- /dev/null +++ b/frontend/src-tauri/src/record_commands.rs @@ -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, 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 { + 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})) + }) +} diff --git a/frontend/src-tauri/src/records.rs b/frontend/src-tauri/src/records.rs index 39c2e2a..dad7fd1 100644 --- a/frontend/src-tauri/src/records.rs +++ b/frontend/src-tauri/src/records.rs @@ -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 { if !id.starts_with("task_") @@ -32,10 +32,27 @@ pub fn path(id: &str) -> Result { } Ok(format!("opennexus-records/v1/tasks/{id}.json")) } +pub fn path_for(kind: &str, id: &str) -> Result { + 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 { 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 { } 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(¬e_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> { - let path = path(id)?; + self.record_get_kind("task", id) + } + pub fn record_get_kind(&mut self, kind: &str, id: &str) -> Result> { + 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::>(); let total = paths.len(); let mut items = Vec::new(); diff --git a/frontend/src-tauri/src/sync_initial.rs b/frontend/src-tauri/src/sync_initial.rs index ea73026..4e61eb2 100644 --- a/frontend/src-tauri/src/sync_initial.rs +++ b/frontend/src-tauri/src/sync_initial.rs @@ -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), diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index fe1ef67..d80ff95 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -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}; diff --git a/frontend/src/features/settings/SyncSettings.spec.ts b/frontend/src/features/settings/SyncSettings.spec.ts index 8097fe2..8a9b6aa 100644 --- a/frontend/src/features/settings/SyncSettings.spec.ts +++ b/frontend/src/features/settings/SyncSettings.spec.ts @@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { afterEach, expect, it, vi } from 'vitest' import { hostInvoke } from '@/services/platform/desktop' import SyncSettings from './SyncSettings.vue' +vi.mock('@/services/platform/preferenceSync', () => ({ preferenceSyncIssues: [], resolvePreferenceDraft: vi.fn(), seedCurrentPreferences: vi.fn() })) vi.mock('@/services/platform/desktop', () => ({ hostInvoke: vi.fn() })) const confirm = vi.hoisted(() => vi.fn()) vi.mock('@/composables/useActionDialog', () => ({ useActionDialog: () => ({ actionDialog: null, resolveAction: vi.fn(), askConfirm: confirm }) })) diff --git a/frontend/src/features/settings/SyncSettings.vue b/frontend/src/features/settings/SyncSettings.vue index 9fa5874..30fc5b6 100644 --- a/frontend/src/features/settings/SyncSettings.vue +++ b/frontend/src/features/settings/SyncSettings.vue @@ -1,5 +1,6 @@