feat(sync): 持久化类型化主题与编辑器偏好并支持草稿恢复
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod core;
|
||||
pub mod credentials;
|
||||
mod payloads;
|
||||
mod preference_records;
|
||||
pub mod recent;
|
||||
pub mod records;
|
||||
#[cfg(feature = "desktop")]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}))
|
||||
})
|
||||
}
|
||||
@@ -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(¬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<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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 }) }))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch, computed } from 'vue'
|
||||
import { preferenceSyncIssues, resolvePreferenceDraft, seedCurrentPreferences } from '@/services/platform/preferenceSync'
|
||||
import { hostInvoke } from '@/services/platform/desktop'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
@@ -18,6 +19,7 @@ const preview = ref<Preview | null>(null), previewPage = ref(0)
|
||||
const previewItems = computed(() => preview.value?.items.slice(previewPage.value * 100, (previewPage.value + 1) * 100) ?? [])
|
||||
watch([endpoint, account, selected, () => status.value?.vault_id], () => { preview.value = null; previewPage.value = 0 })
|
||||
function previewMerge() { return act(async () => {
|
||||
await seedCurrentPreferences(status.value!.vault_id)
|
||||
preview.value = await hostInvoke<Preview>('sync_preview', { request: { vault_id: status.value!.vault_id, endpoint: endpoint.value, account: account.value, remote_vault: selected.value, mode: 'merge' } })
|
||||
}) }
|
||||
async function merge() {
|
||||
@@ -61,7 +63,10 @@ async function bind(mode: 'upload' | 'download') {
|
||||
const remote = selected.value
|
||||
if (!vaultId || !remote) return
|
||||
if (!(await askConfirm(mode === 'upload' ? t('将当前本地笔记上传到所选空远端库?', 'Upload current notes to the selected empty remote vault?') : t('将所选远端库下载到当前空本地库?', 'Download the selected remote vault into this empty local vault?')))) return
|
||||
await act(async () => { await hostInvoke('sync_bind', { request: { vault_id: vaultId, endpoint: endpoint.value, account: account.value, remote_vault: remote, mode } }) })
|
||||
await act(async () => {
|
||||
if (mode === 'upload') await seedCurrentPreferences(vaultId)
|
||||
await hostInvoke('sync_bind', { request: { vault_id: vaultId, endpoint: endpoint.value, account: account.value, remote_vault: remote, mode } })
|
||||
})
|
||||
}
|
||||
async function unbind() {
|
||||
const binding = status.value?.binding
|
||||
@@ -86,8 +91,13 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<h2 id="sync-title">OpenNexus Sync</h2>
|
||||
<p>{{ t('同步当前 Vault 的 Markdown 与常用附件。登录前请先解锁设备凭据保险库。', 'Sync Markdown and supported attachments in the current vault. Unlock the device credential vault before signing in.') }}</p>
|
||||
<p class="subtle">{{ t('两边都有文件时先预览合并。内容冲突会保留两端版本;任务和配置记录同步仍在开发中。', 'Preview a merge when both vaults contain files. Conflicts retain both versions; task and configuration record sync is still under development.') }}</p>
|
||||
<p class="subtle">{{ t('默认同步笔记、附件、任务、主题设置和编辑器偏好。两边都有数据时先预览合并;密钥、权限和本机路径不随设置同步。', 'Notes, attachments, tasks, theme settings and editor preferences sync by default. Preview a merge when both vaults contain data. Secrets, permissions and device paths stay local.') }}</p>
|
||||
<p v-if="message" class="error-banner" role="alert">{{ message }}</p>
|
||||
<article v-for="issue in preferenceSyncIssues" :key="issue.kind" class="sync-conflict" role="status">
|
||||
<h3>{{ issue.label }}</h3><p>{{ issue.error }}</p>
|
||||
<p>{{ t('本机待提交设置已保留,请选择使用哪一份。', 'The local preference draft is retained. Choose which version to use.') }}</p>
|
||||
<div v-if="issue.hasDraft" class="inline-actions"><button :disabled="busy" @click="act(() => resolvePreferenceDraft(issue.kind, 'local'))">{{ t('保留本机设置', 'Keep local settings') }}</button><button :disabled="busy" @click="act(() => resolvePreferenceDraft(issue.kind, 'remote'))">{{ t('采用工作区设置', 'Use workspace settings') }}</button></div>
|
||||
</article>
|
||||
<form class="sync-form" @submit.prevent="login">
|
||||
<label>{{ t('服务器地址', 'Server URL') }}<input v-model="endpoint" required :disabled="!!status?.binding || busy" type="url" autocomplete="url" /></label>
|
||||
<label>{{ t('账户', 'Account') }}<input v-model="account" required :disabled="!!status?.binding || busy" autocomplete="username" /></label>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from './stores/settings'
|
||||
import { watch } from 'vue'
|
||||
import { appLocale } from './i18n'
|
||||
import { updateDocumentTitle } from './router'
|
||||
import { installPreferenceSync } from './services/platform/preferenceSync'
|
||||
import { installDesktopLifecycle } from './services/platform/lifecycle'
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -20,7 +21,7 @@ app.use(router)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
void themeStore.initTheme()
|
||||
void themeStore.initTheme().then(installPreferenceSync, installPreferenceSync)
|
||||
watch(appLocale, () => updateDocumentTitle())
|
||||
watch(() => settingsStore.spellCheck, (enabled) => {
|
||||
document.body.spellcheck = enabled
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { hostInvoke } from './desktop'
|
||||
import { installPreferenceSync, seedCurrentPreferences } from './preferenceSync'
|
||||
vi.mock('./desktop', () => ({ isDesktop: () => true, hostInvoke: vi.fn(), nativePath: (value: string) => value, nativeTree: () => [] }))
|
||||
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); localStorage.clear() })
|
||||
it('applies remote appearance without echoing it and only exports approved local fields', async () => {
|
||||
vi.useFakeTimers(); setActivePinia(createPinia())
|
||||
const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore()
|
||||
settings.permissionPolicy = { plantedSecretPermission: 'allow' }
|
||||
const 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 => ({ size, weight: 700 })) } }
|
||||
let remote: Record<string, unknown> = { record: { schema: 1, kind: 'theme_settings', id: 'appearance', data }, hash: '1'.repeat(64), file_id: 'theme-file' }
|
||||
vi.mocked(hostInvoke).mockImplementation(async (command, args) => {
|
||||
const request = args!.request as { kind: string; record?: Record<string, unknown> }
|
||||
if (command === 'record_get') return request.kind === 'theme_settings' ? remote : null
|
||||
const result = { record: request.record, hash: '2'.repeat(64), file_id: 'theme-file' }
|
||||
if (request.record?.kind === 'theme_settings') remote = result
|
||||
return result
|
||||
})
|
||||
workspace.vaultId = 'one'; installPreferenceSync(); await flushPromises()
|
||||
expect(theme.fontEditorSize).toBe(18); expect(theme.currentThemeId).toBe('dark')
|
||||
expect(vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')).toHaveLength(0)
|
||||
theme.fontEditorSize = 24; await vi.advanceTimersByTimeAsync(1500); await flushPromises()
|
||||
let writes = vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(JSON.stringify(writes)).not.toContain('plantedSecretPermission')
|
||||
await seedCurrentPreferences('one')
|
||||
writes = vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')
|
||||
expect(writes.some(([, args]) => (args!.request as { record: { kind: string } }).record.kind === 'preferences')).toBe(true)
|
||||
expect(JSON.stringify(writes)).not.toContain('permissionPolicy')
|
||||
await expect(seedCurrentPreferences('other')).rejects.toThrow('PREFERENCE_BINDING_NOT_READY')
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Portable preference records are bound to the active Vault; local drafts retain their own Vault key. */
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useHeadingAppearanceStore, normalizeHeadingAppearance } from '@/stores/headingAppearance'
|
||||
import { useMarkdownPreferencesStore, normalizeMarkdownPreferences } from '@/stores/markdownPreferences'
|
||||
import { hostInvoke, isDesktop } from './desktop'
|
||||
import { RecordBinding } from './recordBinding'
|
||||
interface Controller { label: string; binding: Pick<RecordBinding<never>, 'capture' | 'poll' | 'seed' | 'stop' | 'keepLocal' | 'useRemote' | 'error' | 'hasDraft'> }
|
||||
export const preferenceSyncIssues = ref<Array<{ kind: string; label: string; error: string; hasDraft: boolean }>>([])
|
||||
let controllers = new Map<string, Controller>(), activeVault = '', installed = false
|
||||
export async function seedCurrentPreferences(vaultId: string) {
|
||||
if (!isDesktop()) return
|
||||
if (vaultId !== activeVault || controllers.size !== 2) throw new Error('PREFERENCE_BINDING_NOT_READY')
|
||||
for (const { binding } of controllers.values()) {
|
||||
await binding.seed()
|
||||
if (binding.error) throw new Error(binding.error)
|
||||
}
|
||||
}
|
||||
export async function resolvePreferenceDraft(kind: string, choice: 'local' | 'remote') {
|
||||
const controller = controllers.get(kind)
|
||||
if (!controller) return
|
||||
if (choice === 'local') { controller.binding.capture(); await controller.binding.keepLocal() }
|
||||
else await controller.binding.useRemote()
|
||||
}
|
||||
export function installPreferenceSync() {
|
||||
if (!isDesktop() || installed) return
|
||||
installed = true
|
||||
const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore()
|
||||
const headings = useHeadingAppearanceStore(), markdown = useMarkdownPreferencesStore()
|
||||
let applying = 0
|
||||
const readTheme = () => ({ themeId: theme.currentThemeId, fontEditorSize: theme.fontEditorSize, fontEditorFamily: theme.fontEditorFamily, lineHeight: theme.lineHeight, codeBlockTheme: theme.codeBlockTheme, headings: normalizeHeadingAppearance(headings.preferences) })
|
||||
const readPreferences = () => ({ restoreLastVault: settings.restoreLastVault, autoSaveInterval: settings.autoSaveInterval, language: settings.language, defaultEditorMode: settings.defaultEditorMode, editorLineWidth: settings.editorLineWidth, spellCheck: settings.spellCheck, markdown: normalizeMarkdownPreferences(markdown.preferences), presets: markdown.customPresets.map(preset => ({ name: preset.name, preferences: normalizeMarkdownPreferences(preset.preferences) })) })
|
||||
const changed = () => { preferenceSyncIssues.value = [...controllers].filter(([, value]) => value.binding.error).map(([kind, value]) => ({ kind, label: value.label, error: value.binding.error, hasDraft: value.binding.hasDraft })) }
|
||||
async function apply(action: () => void) { applying++; try { action(); await nextTick() } finally { applying-- } }
|
||||
watch(() => workspace.vaultId, vaultId => {
|
||||
for (const value of controllers.values()) value.binding.stop()
|
||||
controllers = new Map(); activeVault = vaultId; changed()
|
||||
if (!vaultId) return
|
||||
const common = { vaultId, invoke: hostInvoke, storage: localStorage, changed }
|
||||
controllers.set('theme_settings', { label: '主题与外观', binding: new RecordBinding({ ...common, kind: 'theme_settings', id: 'appearance', read: readTheme, apply: data => apply(() => {
|
||||
if (!theme.applyTheme(data.themeId)) { theme.applyTheme('light'); theme.themeLoadWarning = `同步主题 ${data.themeId} 尚未安装,请在本机安装并确认后使用。` }
|
||||
theme.fontEditorSize = data.fontEditorSize; theme.fontEditorFamily = data.fontEditorFamily; theme.lineHeight = data.lineHeight; theme.codeBlockTheme = data.codeBlockTheme
|
||||
headings.preferences = normalizeHeadingAppearance(data.headings)
|
||||
}) }) })
|
||||
controllers.set('preferences', { label: '编辑器偏好', binding: new RecordBinding({ ...common, kind: 'preferences', id: 'editor', read: readPreferences, apply: data => apply(() => {
|
||||
settings.restoreLastVault = data.restoreLastVault; settings.autoSaveInterval = data.autoSaveInterval; settings.language = data.language
|
||||
settings.defaultEditorMode = data.defaultEditorMode; settings.editorLineWidth = data.editorLineWidth; settings.spellCheck = data.spellCheck
|
||||
markdown.apply(data.markdown); markdown.customPresets = data.presets.map(preset => ({ name: preset.name, preferences: normalizeMarkdownPreferences(preset.preferences) }))
|
||||
}) }) })
|
||||
changed()
|
||||
for (const value of controllers.values()) void value.binding.poll()
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
watch(readTheme, () => { if (!applying) controllers.get('theme_settings')?.binding.capture() }, { deep: true, flush: 'sync' })
|
||||
watch(readPreferences, () => { if (!applying) controllers.get('preferences')?.binding.capture() }, { deep: true, flush: 'sync' })
|
||||
setInterval(() => { for (const value of controllers.values()) void value.binding.poll() }, 1500)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { RecordBinding, type RecordDocument } from './recordBinding'
|
||||
const h1 = '1'.repeat(64), h2 = '2'.repeat(64), h3 = '3'.repeat(64)
|
||||
function document(value: number, hash = h1): RecordDocument<{ value: number }> { return { record: { schema: 1, kind: 'preferences', id: 'editor', data: { value } }, hash, file_id: 'file' } }
|
||||
function setup(invoke: (command: string, args: Record<string, unknown>) => Promise<unknown>, vaultId = 'vault') {
|
||||
let local = { value: 1 }
|
||||
const apply = vi.fn((data: { value: number }) => { local = data })
|
||||
const binding = new RecordBinding({ vaultId, kind: 'preferences', id: 'editor', read: () => local, apply, invoke: async <R>(command: string, args: Record<string, unknown>) => await invoke(command, args) as R, storage: localStorage })
|
||||
return { binding, apply, edit: (value: number) => { local = { value }; binding.capture() }, read: () => local }
|
||||
}
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
it('reopens a conflicting draft without overwriting it and only rebases after a decision', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(document(1))
|
||||
const original = setup(invoke); await original.binding.poll(); original.edit(2)
|
||||
const firstDraft = JSON.parse(localStorage.getItem('opennexus-record-draft:vault:preferences:editor')!)
|
||||
original.binding.stop()
|
||||
invoke.mockImplementation(async command => { if (command === 'record_get') return document(3, h3); throw new Error('REVISION_CONFLICT') })
|
||||
const reopened = setup(invoke); await reopened.binding.poll()
|
||||
expect(reopened.read()).toEqual({ value: 2 }); expect(reopened.binding.conflicted).toBe(true)
|
||||
expect(JSON.parse(localStorage.getItem('opennexus-record-draft:vault:preferences:editor')!)).toEqual(firstDraft)
|
||||
invoke.mockImplementation(async (command, args) => command === 'record_get' ? document(3, h3) : { ...document(2, h2), record: args.request.record })
|
||||
await reopened.binding.keepLocal()
|
||||
const write = invoke.mock.calls.filter(([command]) => command === 'record_write').at(-1)![1].request
|
||||
expect(write.expected).toBe(h3); expect(write.operation_id).not.toBe(firstDraft.operation_id)
|
||||
expect(reopened.binding.hasDraft).toBe(false)
|
||||
})
|
||||
it('keeps drafts separated by Vault and does not seed an empty Vault during polling', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(null), first = setup(invoke)
|
||||
await first.binding.poll(); expect(invoke.mock.calls.some(([command]) => command === 'record_write')).toBe(false)
|
||||
first.edit(2); first.binding.stop()
|
||||
const second = setup(invoke, 'second'); await second.binding.poll()
|
||||
expect(second.binding.hasDraft).toBe(false); expect(second.read()).toEqual({ value: 1 })
|
||||
})
|
||||
it('orders edits made during an in-flight commit against its confirmed hash', async () => {
|
||||
let release!: (value: RecordDocument<{ value: number }>) => void
|
||||
const invoke = vi.fn().mockResolvedValue(document(1)), state = setup(invoke)
|
||||
await state.binding.poll(); state.edit(2)
|
||||
invoke.mockImplementation(async command => command === 'record_get' ? document(1) : new Promise(resolve => { release = resolve }))
|
||||
const pending = state.binding.poll(); await vi.waitFor(() => expect(release).toBeTypeOf('function'))
|
||||
state.edit(3); release(document(2, h2)); await pending
|
||||
const draft = JSON.parse(localStorage.getItem('opennexus-record-draft:vault:preferences:editor')!)
|
||||
expect(draft.expected).toBe(h2); expect(draft.record.data).toEqual({ value: 3 })
|
||||
})
|
||||
it('rejects a remote-choice response that would discard a newer local edit', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(document(1)), state = setup(invoke)
|
||||
await state.binding.poll(); state.edit(2)
|
||||
let release!: (value: RecordDocument<{ value: number }>) => void
|
||||
invoke.mockImplementation(() => new Promise(resolve => { release = resolve }))
|
||||
const choice = state.binding.useRemote(); await Promise.resolve(); state.edit(4); release(document(3, h3)); await choice
|
||||
expect(state.binding.error).toBe('PREFERENCE_CHANGED'); expect(state.read()).toEqual({ value: 4 }); expect(state.binding.hasDraft).toBe(true)
|
||||
})
|
||||
it('waits for a live poll before seeding and reuses its result', async () => {
|
||||
let release!: (value: null) => void
|
||||
const invoke = vi.fn().mockImplementationOnce(() => new Promise(resolve => { release = resolve })).mockImplementation(async command => command === 'record_get' ? null : document(1))
|
||||
const state = setup(invoke), poll = state.binding.poll(), seed = state.binding.seed()
|
||||
release(null); await poll; await seed
|
||||
expect(invoke.mock.calls.filter(([command]) => command === 'record_write')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('retains the committed draft when removing its durable receipt fails', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(document(1)), state = setup(invoke)
|
||||
await state.binding.poll(); state.edit(2)
|
||||
invoke.mockImplementation(async command => command === 'record_get' ? document(1) : document(2, h2))
|
||||
const remove = vi.spyOn(localStorage, 'removeItem').mockImplementation(() => { throw new Error('disk unavailable') })
|
||||
await state.binding.poll()
|
||||
expect(state.binding.hasDraft).toBe(true)
|
||||
expect(state.binding.error).toBe('PREFERENCE_DRAFT_STORE_FAILED')
|
||||
expect(JSON.parse(localStorage.getItem('opennexus-record-draft:vault:preferences:editor')!).record.data).toEqual({ value: 2 })
|
||||
remove.mockRestore()
|
||||
state.edit(3); await state.binding.poll()
|
||||
expect(state.binding.hasDraft).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
/** A durable preference draft keeps its original CAS base until the user resolves a conflict. */
|
||||
export interface LogicalRecord<T> { schema: 1; kind: string; id: string; data: T }
|
||||
export interface RecordDocument<T> { record: LogicalRecord<T>; hash: string; file_id: string }
|
||||
interface Draft<T> { record: LogicalRecord<T>; expected: string; operation_id: string }
|
||||
interface Options<T> {
|
||||
vaultId: string; kind: string; id: string; read(): T; apply(data: T): void | Promise<void>
|
||||
invoke<R>(command: string, args: Record<string, unknown>): Promise<R>
|
||||
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>; changed?(): void
|
||||
}
|
||||
export class RecordBinding<T> {
|
||||
private draft: Draft<T> | null = null
|
||||
private remote: RecordDocument<T> | null = null
|
||||
private running = false
|
||||
private active: Promise<void> | null = null
|
||||
private restored = false
|
||||
private invalidDraft = false
|
||||
private stopped = false
|
||||
private initialized = false
|
||||
private appliedHash: string | null = null
|
||||
error = ''
|
||||
constructor(private options: Options<T>) {
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(this.key) ?? 'null') as Draft<T> | null
|
||||
if (value && (value.record?.schema !== 1 || value.record.kind !== options.kind || value.record.id !== options.id || !value.record.data || typeof value.record.data !== 'object' || typeof value.expected !== 'string' || !/^(?:[0-9a-f]{64})?$/.test(value.expected) || typeof value.operation_id !== 'string' || !/^[0-9a-f-]{36}$/.test(value.operation_id))) throw new Error('invalid draft')
|
||||
this.draft = value; this.restored = value !== null
|
||||
} catch { this.error = 'PREFERENCE_DRAFT_INVALID'; this.invalidDraft = true }
|
||||
}
|
||||
private get key() { return `opennexus-record-draft:${this.options.vaultId}:${this.options.kind}:${this.options.id}` }
|
||||
private persist(draft = this.draft) {
|
||||
try {
|
||||
if (draft) this.options.storage.setItem(this.key, JSON.stringify(draft))
|
||||
else this.options.storage.removeItem(this.key)
|
||||
} catch { this.error = 'PREFERENCE_DRAFT_STORE_FAILED'; this.options.changed?.(); throw new Error(this.error) }
|
||||
this.options.changed?.()
|
||||
}
|
||||
get conflicted() { return this.error === 'REVISION_CONFLICT' }
|
||||
get hasDraft() { return this.draft !== null || this.invalidDraft }
|
||||
stop() { this.stopped = true }
|
||||
capture() {
|
||||
if (this.stopped) return
|
||||
const data = JSON.parse(JSON.stringify(this.options.read())) as T
|
||||
if (!this.draft && this.remote && JSON.stringify(data) === JSON.stringify(this.remote.record.data)) return
|
||||
// New edits while a request runs get a new operation, but preserve the unresolved base.
|
||||
this.draft = { record: { schema: 1, kind: this.options.kind, id: this.options.id, data }, expected: this.draft?.expected ?? this.remote?.hash ?? '', operation_id: crypto.randomUUID() }
|
||||
this.restored = false; this.invalidDraft = false
|
||||
try { this.persist(); if (this.error === 'PREFERENCE_DRAFT_STORE_FAILED') this.error = '' } catch { this.error = 'PREFERENCE_DRAFT_STORE_FAILED'; this.options.changed?.() }
|
||||
}
|
||||
async seed() {
|
||||
await this.poll()
|
||||
if (!this.stopped && this.initialized && !this.remote && !this.draft) { this.capture(); await this.poll() }
|
||||
}
|
||||
poll(): Promise<void> {
|
||||
if (this.stopped) return Promise.resolve()
|
||||
if (this.active) return this.active
|
||||
this.active = this.run().finally(() => { this.active = null })
|
||||
return this.active
|
||||
}
|
||||
private async run() {
|
||||
if (this.running || this.stopped || this.invalidDraft || this.error === 'PREFERENCE_DRAFT_STORE_FAILED') return
|
||||
this.running = true
|
||||
try {
|
||||
this.remote = await this.options.invoke<RecordDocument<T> | null>('record_get', { request: { vault_id: this.options.vaultId, kind: this.options.kind, id: this.options.id } })
|
||||
if (this.stopped) return
|
||||
this.initialized = true
|
||||
if (this.draft) {
|
||||
if (this.restored) { this.restored = false; await this.options.apply(this.draft.record.data) }
|
||||
if (this.stopped || this.conflicted) return
|
||||
const draft = this.draft
|
||||
const committed = await this.options.invoke<RecordDocument<T>>('record_write', { request: { vault_id: this.options.vaultId, ...draft } })
|
||||
this.remote = committed
|
||||
if (this.draft.operation_id === draft.operation_id) {
|
||||
this.persist(null); this.draft = null; this.appliedHash = committed.hash
|
||||
} else {
|
||||
// The next local edit follows the just-confirmed predecessor, not its older CAS base.
|
||||
this.draft.expected = committed.hash; this.persist()
|
||||
}
|
||||
} else if (this.remote && this.remote.hash !== this.appliedHash) {
|
||||
await this.options.apply(this.remote.record.data)
|
||||
this.appliedHash = this.remote.hash
|
||||
}
|
||||
this.error = ''
|
||||
} catch (error) { this.error = error instanceof Error ? error.message : 'PREFERENCE_SYNC_FAILED' }
|
||||
finally { this.running = false; this.options.changed?.() }
|
||||
}
|
||||
async keepLocal() {
|
||||
await this.active
|
||||
if (this.running || this.stopped) return
|
||||
this.running = true
|
||||
try {
|
||||
if (this.invalidDraft) this.capture()
|
||||
if (!this.draft) return
|
||||
const remote = await this.options.invoke<RecordDocument<T> | null>('record_get', { request: { vault_id: this.options.vaultId, kind: this.options.kind, id: this.options.id } })
|
||||
if (this.stopped) return
|
||||
this.remote = remote; this.draft.expected = remote?.hash ?? ''; this.draft.operation_id = crypto.randomUUID(); this.error = ''; this.persist()
|
||||
} finally { this.running = false }
|
||||
await this.poll()
|
||||
}
|
||||
async useRemote() {
|
||||
await this.active
|
||||
if (this.running || this.stopped) return
|
||||
this.running = true
|
||||
try {
|
||||
const decision = this.draft?.operation_id
|
||||
const remote = await this.options.invoke<RecordDocument<T> | null>('record_get', { request: { vault_id: this.options.vaultId, kind: this.options.kind, id: this.options.id } })
|
||||
if (this.stopped) return
|
||||
if (this.draft?.operation_id !== decision) { this.error = 'PREFERENCE_CHANGED'; this.options.changed?.(); return }
|
||||
if (!remote) { this.error = 'PREFERENCE_REMOTE_MISSING'; this.options.changed?.(); return }
|
||||
this.options.storage.removeItem(this.key)
|
||||
this.draft = null; this.invalidDraft = false; this.error = ''; this.remote = remote; this.options.changed?.()
|
||||
await this.options.apply(remote.record.data); this.appliedHash = remote.hash
|
||||
} finally { this.running = false }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user