diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index 9baf66393..f8332138f 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -5,6 +5,7 @@ use chacha20poly1305::{ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; +use std::path::Path; use crate::storage::config::config_dir; use crate::storage::secrets::SecretsStore; @@ -186,6 +187,38 @@ fn strip_excluded( clocks.retain(|k, _| !is_excluded_secret(k)); } +/// Collect every `*.json` in `dir` into `files`, keyed by `prefix` + filename. +/// Keys present in `skip` are omitted — that is how a device withholds a config +/// file (e.g. `theme.json`) from the uploaded blob. A missing directory is not +/// an error: not every install has plugins. +fn collect_json_dir( + files: &mut HashMap, + dir: &Path, + prefix: &str, + skip: &HashSet, +) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let (Some(name), Ok(content)) = ( + path.file_name().and_then(|n| n.to_str()), + fs::read_to_string(&path), + ) else { + continue; + }; + let key = format!("{prefix}{name}"); + if skip.contains(&key) { + continue; + } + files.insert(key, content); + } +} + #[tauri::command] pub fn backup_export( state: tauri::State, @@ -193,6 +226,7 @@ pub fn backup_export( account_id: String, device_id: String, excluded_ids: Option>, + skip_files: Option>, ) -> Result, String> { if enc_key.len() != 32 { return Err("enc_key must be 32 bytes".to_string()); @@ -209,54 +243,20 @@ pub fn backup_export( let mut files = HashMap::new(); let dir = config_dir(); - // Root JSON files (connections.json, identities.json, plugin-registry.json, …) - if let Ok(entries) = std::fs::read_dir(&dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") { - if let (Some(name), Ok(content)) = ( - path.file_name().and_then(|n| n.to_str()).map(String::from), - std::fs::read_to_string(&path), - ) { - files.insert(name, content); - } - } - } - } - - // plugin-data/.json — each plugin's api.storage - let plugin_data_dir = dir.join("plugin-data"); - if let Ok(entries) = std::fs::read_dir(&plugin_data_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") { - if let (Some(name), Ok(content)) = ( - path.file_name().and_then(|n| n.to_str()).map(String::from), - std::fs::read_to_string(&path), - ) { - files.insert(format!("plugin-data/{name}"), content); - } - } - } - } - - // plugins/__meta__/*.json — the installed-plugin list and marketplace sources. - // Carrying these is what lets a fresh device restore the user's plugin set; - // the reinstall itself happens client-side and stays hash-verified. - let plugin_meta_dir = dir.join("plugins").join(PLUGIN_META_ID); - if let Ok(entries) = std::fs::read_dir(&plugin_meta_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") { - if let (Some(name), Ok(content)) = ( - path.file_name().and_then(|n| n.to_str()).map(String::from), - std::fs::read_to_string(&path), - ) { - files.insert(format!("{PLUGIN_META_PREFIX}{name}"), content); - } - } - } - } + let skip: HashSet = skip_files.unwrap_or_default().into_iter().collect(); + + // Root JSON files (connections.json, identities.json, plugin-registry.json, …), + // each plugin's api.storage, and the installed-plugin list that lets a fresh + // device restore the user's plugin set — the reinstall itself happens + // client-side and stays hash-verified. + collect_json_dir(&mut files, &dir, "", &skip); + collect_json_dir(&mut files, &dir.join("plugin-data"), "plugin-data/", &skip); + collect_json_dir( + &mut files, + &dir.join("plugins").join(PLUGIN_META_ID), + PLUGIN_META_PREFIX, + &skip, + ); let data = state.export_all()?; let mut secrets = data.secrets; let mut clocks = data.clocks; @@ -517,6 +517,63 @@ pub fn updater_set_auto(enabled: bool) -> Result<(), String> { mod tests { use super::*; + #[test] + fn collect_json_dir_skips_named_files_and_prefixes_the_rest() { + let dir = std::env::temp_dir().join(format!("voltius-skip-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("theme.json"), r#"{"activeThemeId":"voltius"}"#).unwrap(); + fs::write(dir.join("connections.json"), "[]").unwrap(); + fs::write(dir.join("notes.txt"), "ignored").unwrap(); + + let mut files = HashMap::new(); + let skip: HashSet = ["theme.json".to_string()].into_iter().collect(); + collect_json_dir(&mut files, &dir, "", &skip); + + assert!( + !files.contains_key("theme.json"), + "skipped file must not be collected" + ); + assert_eq!( + files.get("connections.json").map(String::as_str), + Some("[]") + ); + assert!(!files.contains_key("notes.txt"), "non-json must be ignored"); + + let mut prefixed = HashMap::new(); + collect_json_dir(&mut prefixed, &dir, "plugin-data/", &HashSet::new()); + assert!( + prefixed.contains_key("plugin-data/theme.json"), + "prefix applies to the key" + ); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn collect_json_dir_skip_matches_the_prefixed_key_not_the_bare_filename() { + let dir = std::env::temp_dir().join(format!("voltius-skip-key-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("theme.json"), r#"{"activeThemeId":"voltius"}"#).unwrap(); + + let mut files = HashMap::new(); + let skip: HashSet = ["plugin-data/theme.json".to_string()].into_iter().collect(); + collect_json_dir(&mut files, &dir, "plugin-data/", &skip); + assert!( + !files.contains_key("plugin-data/theme.json"), + "a skip entry for the prefixed key must skip the file" + ); + + let mut files = HashMap::new(); + let skip: HashSet = ["theme.json".to_string()].into_iter().collect(); + collect_json_dir(&mut files, &dir, "plugin-data/", &skip); + assert!( + files.contains_key("plugin-data/theme.json"), + "a skip entry for the bare filename must not match a differently-prefixed key" + ); + + fs::remove_dir_all(&dir).ok(); + } + #[test] fn strip_excluded_removes_entity_and_its_secrets_from_both_maps() { let mut files = HashMap::new(); diff --git a/src/components/import-export/UserDataImportTab.tsx b/src/components/import-export/UserDataImportTab.tsx index 40852e55d..aeed009e0 100644 --- a/src/components/import-export/UserDataImportTab.tsx +++ b/src/components/import-export/UserDataImportTab.tsx @@ -96,6 +96,9 @@ export function UserDataImportTab({ onClose }: { onClose: () => void }) { {t(`importExport.userData.handlers.${h.key}.label`)} + {h.key === "themes" && ( + {t("importExport.userData.import.themesReplaceWarning")} + )} ))} diff --git a/src/components/settings/sections/SyncSection.test.tsx b/src/components/settings/sections/SyncSection.test.tsx new file mode 100644 index 000000000..ea873f988 --- /dev/null +++ b/src/components/settings/sections/SyncSection.test.tsx @@ -0,0 +1,66 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, cleanup, fireEvent } from "@testing-library/react"; +import { useSyncPrefsStore } from "@/stores/syncPrefsStore"; +import { USER_DATA_HANDLERS } from "@/services/user-data/registry"; + +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => null) })); +vi.mock("@/utils/billing", () => ({ openPortal: vi.fn() })); +vi.mock("@/services/sync", () => ({ + getSyncState: () => ({ status: "idle", lastSync: null, error: null, cloudActive: false, blobSizeBytes: null }), + onSyncStateChange: () => () => {}, + syncNow: vi.fn(), + scheduleSync: vi.fn(), +})); + +import { scheduleSync } from "@/services/sync"; +import SyncSection from "./SyncSection"; + +const toggleFor = (c: HTMLElement, domain: string) => + c.querySelector(`[data-sync-domain="${domain}"] button[role="switch"]`) as HTMLButtonElement | null; + +describe("SyncSection settings domains", () => { + beforeEach(() => useSyncPrefsStore.setState({ syncSettingDomains: {}, syncTypes: {}, excludedIds: [] })); + afterEach(cleanup); + + test("renders a toggle for each settings domain and none for vaults", () => { + const { container } = render(); + expect(toggleFor(container, "themes")).toBeTruthy(); + expect(toggleFor(container, "appSettings")).toBeTruthy(); + expect(toggleFor(container, "recentPeople")).toBeTruthy(); + expect(toggleFor(container, "vaults")).toBeNull(); + }); + + test("switching a domain off records it", () => { + const { container } = render(); + fireEvent.click(toggleFor(container, "themes")!); + expect(useSyncPrefsStore.getState().isDomainSynced("themes")).toBe(false); + }); + + test("switching a domain back on publishes this device's values", () => { + const themes = USER_DATA_HANDLERS.find((h) => h.key === "themes")!; + const touch = vi.spyOn(themes, "touch").mockImplementation(() => {}); + useSyncPrefsStore.getState().setSyncSettingDomain("themes", false); + const { container } = render(); + fireEvent.click(toggleFor(container, "themes")!); + expect(touch).toHaveBeenCalled(); + touch.mockRestore(); + }); + + test("switching a domain off does not touch it", () => { + const themes = USER_DATA_HANDLERS.find((h) => h.key === "themes")!; + const touch = vi.spyOn(themes, "touch").mockImplementation(() => {}); + const { container } = render(); + fireEvent.click(toggleFor(container, "themes")!); + expect(touch).not.toHaveBeenCalled(); + touch.mockRestore(); + }); + + test("switching a domain off schedules a push to withdraw the server copy", () => { + const { container } = render(); + fireEvent.click(toggleFor(container, "themes")!); + expect(scheduleSync).toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings/sections/SyncSection.tsx b/src/components/settings/sections/SyncSection.tsx index bbadc9228..eb0a5b56c 100644 --- a/src/components/settings/sections/SyncSection.tsx +++ b/src/components/settings/sections/SyncSection.tsx @@ -2,11 +2,33 @@ import { useEffect, useState } from "react"; import { Icon } from "@iconify/react"; import { useTranslation } from "react-i18next"; import { Toggle } from "@/components/shared/Toggle"; -import { getSyncState, onSyncStateChange, syncNow } from "@/services/sync"; -import { useSyncPrefsStore, SYNC_OBJECT_TYPES } from "@/stores/syncPrefsStore"; +import { getSyncState, onSyncStateChange, scheduleSync, syncNow } from "@/services/sync"; +import { useSyncPrefsStore, SYNC_OBJECT_TYPES, SYNC_SETTING_DOMAINS } from "@/stores/syncPrefsStore"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; import { useUIStore } from "@/stores/uiStore"; import { openPortal } from "@/utils/billing"; +import { USER_DATA_HANDLERS } from "@/services/user-data/registry"; +import { SettingsGroup } from "./shared"; + +function SyncToggleRow({ domain, label, sub, checked, onChange }: { + /** Stable hook for tests and UI automation; also the handler key for settings rows. */ + domain: string; + label: string; + sub: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{label}

+

{sub}

+
+ +
+ ); +} export default function SyncSection() { const { t } = useTranslation(); @@ -17,127 +39,129 @@ export default function SyncSection() { const isPro = useSubscriptionStore((s) => s.isPro); const openSettings = useUIStore((s) => s.openSettings); const openCloudAuth = useUIStore((s) => s.openCloudAuth); - const { syncTypes, setSyncType } = useSyncPrefsStore(); + const { syncTypes, setSyncType, isDomainSynced, setSyncSettingDomain } = useSyncPrefsStore(); const isLoggedIn = accountMode === "server"; return (
- {/* Voltius cloud sync */} -
-

- {t("settings.sync.voltiusCloud")} -

-
- {isLoggedIn && isPro ? ( -
-
-

{t("settings.sync.active.title")}

-

- {syncState.status === "syncing" && t("settings.sync.active.syncing")} - {syncState.status === "error" && t("settings.sync.active.error", { error: syncState.error ?? "unknown" })} - {syncState.status === "success" && syncState.lastSync && t("settings.sync.active.lastSync", { time: syncState.lastSync.toLocaleTimeString() })} - {syncState.status === "offline" && t("settings.sync.active.offline")} - {syncState.status === "idle" && t("settings.sync.active.idle")} -

-
- -
- ) : isLoggedIn && !isPro ? ( -
-
-

{t("settings.sync.requiresPro.title")}

-

{t("settings.sync.requiresPro.sub")}

-
- + + {isLoggedIn && isPro ? ( +
+
+

{t("settings.sync.active.title")}

+

+ {syncState.status === "syncing" && t("settings.sync.active.syncing")} + {syncState.status === "error" && t("settings.sync.active.error", { error: syncState.error ?? "unknown" })} + {syncState.status === "success" && syncState.lastSync && t("settings.sync.active.lastSync", { time: syncState.lastSync.toLocaleTimeString() })} + {syncState.status === "offline" && t("settings.sync.active.offline")} + {syncState.status === "idle" && t("settings.sync.active.idle")} +

- ) : ( -
-
-

{t("settings.sync.notConnected.title")}

-

- {t("settings.sync.notConnected.sub")} -

-
- + +
+ ) : isLoggedIn && !isPro ? ( +
+
+

{t("settings.sync.requiresPro.title")}

+

{t("settings.sync.requiresPro.sub")}

- )} -
-
- - {/* Gist sync — pointer to plugins */} -
-

- {t("settings.sync.gistTitle")} -

-
-
+ +
+ ) : ( +
-

{t("settings.sync.gist.title")}

-

{t("settings.sync.gist.sub")}

+

{t("settings.sync.notConnected.title")}

+

+ {t("settings.sync.notConnected.sub")} +

+ )} + + + +
+
+

{t("settings.sync.gist.title")}

+

{t("settings.sync.gist.sub")}

+
+
-
+ - {/* Sync preferences */}
-

- {t("settings.sync.prefsTitle")} -

-
- {SYNC_OBJECT_TYPES.map(({ id }, i) => { - const value = syncTypes[id] ?? true; - return ( -
0 ? { borderTop: "1px solid var(--t-border)" } : undefined} - > -
-

{t(`settings.sync.objectType.${id}.label`)}

-

{t(`settings.sync.objectType.${id}.sub`)}

-
- setSyncType(id, v)} /> -
- ); - })} -
+ + {SYNC_OBJECT_TYPES.map(({ id }) => ( + setSyncType(id, v)} + /> + ))} +

{t("settings.sync.prefsFooter")}

+ +
+ + {SYNC_SETTING_DOMAINS.map(({ id }) => ( + { + setSyncSettingDomain(id, v); + // Publishing on re-enable, so a value curated here while sync was + // off is not silently lost to the other device's newer timestamp. + if (v) USER_DATA_HANDLERS.find((h) => h.key === id)?.touch(); + // On disable, the section already on the server must be withdrawn + // from that blob now, not merely left out of future ones. + else scheduleSync(); + }} + /> + ))} + +

{t("settings.sync.settingsFooter")}

+
); } diff --git a/src/i18n/locales/en/importExport.json b/src/i18n/locales/en/importExport.json index cebe4b9fd..d85aff74f 100644 --- a/src/i18n/locales/en/importExport.json +++ b/src/i18n/locales/en/importExport.json @@ -101,7 +101,8 @@ "applyDefault": "Apply", "resultApplied_one": "Applied {{count}} setting: {{list}}.", "resultApplied_other": "Applied {{count}} settings: {{list}}.", - "resultError": "Error: {{error}}" + "resultError": "Error: {{error}}", + "themesReplaceWarning": "Replaces this device's custom themes." }, "handlers": { "themes": { "label": "Themes" }, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index f31e9d511..a3738c0e8 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -509,7 +509,7 @@ "configure": "Configure →" }, "prefsTitle": "Sync Preferences", - "prefsFooter": "Disabled types won't trigger automatic syncs when changed. Individual objects can also be excluded via their edit panel.", + "prefsFooter": "Disabled types are never uploaded. Individual objects can also be excluded from their edit panel.", "quickToggleLabel": "Sync {{label}}", "objectType": { "connection": { @@ -532,6 +532,15 @@ "label": "Port Forwarding", "sub": "Saved tunnel rules" } + }, + "settingsTitle": "Settings", + "settingsFooter": "Anything switched off stays on this device. It is never uploaded, even encrypted. Turning sync back on publishes this device's values.", + "settingDomain": { + "themes": { "label": "Themes", "sub": "Active theme and custom themes" }, + "uiPreferences": { "label": "Interface", "sub": "Scale, layouts and sort order" }, + "shortcuts": { "label": "Shortcuts", "sub": "Keyboard overrides" }, + "appSettings": { "label": "App settings", "sub": "Terminal, SFTP, plugins, language" }, + "recentPeople": { "label": "Recent people", "sub": "Who you have invited lately" } } }, "vaults": { diff --git a/src/i18n/locales/fr/importExport.json b/src/i18n/locales/fr/importExport.json index e1c94effc..8785a6b37 100644 --- a/src/i18n/locales/fr/importExport.json +++ b/src/i18n/locales/fr/importExport.json @@ -101,7 +101,8 @@ "applyDefault": "Appliquer", "resultApplied_one": "{{count}} paramètre appliqué : {{list}}.", "resultApplied_other": "{{count}} paramètres appliqués : {{list}}.", - "resultError": "Erreur : {{error}}" + "resultError": "Erreur : {{error}}", + "themesReplaceWarning": "Remplace les thèmes personnalisés de cet appareil." }, "handlers": { "themes": { "label": "Thèmes" }, diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index ec1a66fbc..195f06837 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -509,7 +509,7 @@ "configure": "Configurer →" }, "prefsTitle": "Préférences de synchronisation", - "prefsFooter": "Les types désactivés ne déclenchent pas de synchronisation automatique lors de modifications. Les objets individuels peuvent également être exclus via leur panneau d'édition.", + "prefsFooter": "Les types désactivés ne sont jamais envoyés. Les objets peuvent aussi être exclus depuis leur panneau d'édition.", "quickToggleLabel": "Synchroniser {{label}}", "objectType": { "connection": { @@ -532,6 +532,15 @@ "label": "Transfert de ports", "sub": "Règles de tunnel enregistrées" } + }, + "settingsTitle": "Paramètres", + "settingsFooter": "Tout ce qui est désactivé reste sur cet appareil. Rien n'est envoyé, même chiffré. Réactiver la synchronisation publie les valeurs de cet appareil.", + "settingDomain": { + "themes": { "label": "Thèmes", "sub": "Thème actif et thèmes personnalisés" }, + "uiPreferences": { "label": "Interface", "sub": "Échelle, dispositions et tri" }, + "shortcuts": { "label": "Raccourcis", "sub": "Raccourcis clavier personnalisés" }, + "appSettings": { "label": "Paramètres de l'application", "sub": "Terminal, SFTP, extensions, langue" }, + "recentPeople": { "label": "Personnes récentes", "sub": "Vos invitations récentes" } } }, "vaults": { diff --git a/src/i18n/locales/ru/importExport.json b/src/i18n/locales/ru/importExport.json index fb0036457..d58250bc4 100644 --- a/src/i18n/locales/ru/importExport.json +++ b/src/i18n/locales/ru/importExport.json @@ -129,7 +129,8 @@ "resultApplied_few": "Применено {{count}} настройки: {{list}}.", "resultApplied_many": "Применено {{count}} настроек: {{list}}.", "resultApplied_other": "Применено {{count}} настроек: {{list}}.", - "resultError": "Ошибка: {{error}}" + "resultError": "Ошибка: {{error}}", + "themesReplaceWarning": "Заменит пользовательские темы этого устройства." }, "handlers": { "themes": { "label": "Темы" }, diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 22acb5808..3d7b67429 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -515,7 +515,7 @@ "configure": "Настроить →" }, "prefsTitle": "Настройки синхронизации", - "prefsFooter": "Отключённые типы не будут запускать автоматическую синхронизацию при изменении. Отдельные объекты также можно исключить в панели редактирования.", + "prefsFooter": "Отключённые типы никогда не отправляются. Отдельные объекты также можно исключить в панели редактирования.", "quickToggleLabel": "Синхронизировать {{label}}", "objectType": { "connection": { @@ -538,6 +538,15 @@ "label": "Проброс портов", "sub": "Сохранённые правила туннелей" } + }, + "settingsTitle": "Настройки", + "settingsFooter": "Всё отключённое остаётся на этом устройстве. Оно никогда не отправляется, даже в зашифрованном виде. Повторное включение синхронизации публикует значения с этого устройства.", + "settingDomain": { + "themes": { "label": "Темы", "sub": "Активная тема и пользовательские темы" }, + "uiPreferences": { "label": "Интерфейс", "sub": "Масштаб, раскладки и порядок сортировки" }, + "shortcuts": { "label": "Сочетания клавиш", "sub": "Переопределения клавиатуры" }, + "appSettings": { "label": "Настройки приложения", "sub": "Терминал, SFTP, плагины, язык" }, + "recentPeople": { "label": "Недавние люди", "sub": "Кого вы недавно приглашали" } } }, "vaults": { diff --git a/src/i18n/locales/zh/importExport.json b/src/i18n/locales/zh/importExport.json index 2c6396da8..3d64842c1 100644 --- a/src/i18n/locales/zh/importExport.json +++ b/src/i18n/locales/zh/importExport.json @@ -101,7 +101,8 @@ "applyDefault": "应用", "resultApplied_one": "已应用 {{count}} 个设置:{{list}}。", "resultApplied_other": "已应用 {{count}} 个设置:{{list}}。", - "resultError": "错误:{{error}}" + "resultError": "错误:{{error}}", + "themesReplaceWarning": "将替换此设备上的自定义主题。" }, "handlers": { "themes": { "label": "主题" }, diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 4e5217cec..37a93cd8a 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -395,7 +395,7 @@ "configure": "配置 →" }, "prefsTitle": "同步偏好", - "prefsFooter": "禁用的类型在更改时不会触发自动同步。单个对象也可以通过其编辑面板排除。", + "prefsFooter": "禁用的类型永远不会上传。单个对象也可以通过其编辑面板排除。", "quickToggleLabel": "同步 {{label}}", "objectType": { "connection": { "label": "主机", "sub": "SSH 连接" }, @@ -403,6 +403,15 @@ "key": { "label": "SSH 密钥", "sub": "存储在密钥链中的密钥对" }, "folder": { "label": "文件夹", "sub": "用于组织对象的文件夹结构" }, "port-forwarding-rule": { "label": "端口转发", "sub": "保存的隧道规则" } + }, + "settingsTitle": "设置", + "settingsFooter": "关闭的项目只保留在此设备上,永远不会上传,即使加密后也不会。重新开启同步会发布此设备的值。", + "settingDomain": { + "themes": { "label": "主题", "sub": "当前主题和自定义主题" }, + "uiPreferences": { "label": "界面", "sub": "缩放、布局和排序方式" }, + "shortcuts": { "label": "快捷键", "sub": "键盘快捷键覆盖" }, + "appSettings": { "label": "应用设置", "sub": "终端、SFTP、插件、语言" }, + "recentPeople": { "label": "最近联系人", "sub": "您最近邀请过的人" } } }, "vaults": { diff --git a/src/plugins/runtime.exportState.test.ts b/src/plugins/runtime.exportState.test.ts index fc7721265..f176911f7 100644 --- a/src/plugins/runtime.exportState.test.ts +++ b/src/plugins/runtime.exportState.test.ts @@ -8,11 +8,16 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invokeMock(...args), })); -// Stub the sync service: `getExcludedObjectIds()` returns a known set so the test -// can assert it is threaded through to `backup_export` (issue #47). The other -// three named imports are the surface runtime.ts pulls from this module. +// Stub the sync service: `getExcludedObjectIds()` and `getPluginSkippedSyncFiles()` +// return known/controllable values so the test can assert they're threaded through +// to `backup_export` (issues #47, #42). The rest are the remaining surface +// runtime.ts pulls from this module. +const pluginSkippedFilesMock = vi.fn<() => string[]>(() => ["theme.json"]); +const writeFilteredSettingsMock = vi.fn(async () => {}); vi.mock("@/services/sync", () => ({ getExcludedObjectIds: () => ["excluded-host", "excluded-key"], + getPluginSkippedSyncFiles: () => pluginSkippedFilesMock(), + writeFilteredSettings: () => writeFilteredSettingsMock(), getSyncState: () => ({ status: "idle" }), onSyncStateChange: () => () => {}, ENTITY_FILES: [], @@ -29,29 +34,64 @@ function captureApi(manifest: PluginManifest): PluginAPI { return captured; } +async function exportOnce(): Promise { + const manifest: PluginManifest = { + id: "gist-sync-test", + name: "Gist Sync", + version: "1.0.0", + permissions: ["sync:write"], + }; + const api = captureApi(manifest); + try { + await api.sync.exportState("aabb", "device-1"); + } finally { + unloadPlugin("gist-sync-test"); + } +} + describe("plugin sync.exportState honours sync exclusions", () => { beforeEach(() => { invokeMock.mockReset(); invokeMock.mockResolvedValue([1, 2, 3]); + pluginSkippedFilesMock.mockReset(); + writeFilteredSettingsMock.mockReset(); }); test("forwards getExcludedObjectIds() into backup_export", async () => { - const manifest: PluginManifest = { - id: "gist-sync-test", - name: "Gist Sync", - version: "1.0.0", - permissions: ["sync:write"], - }; - const api = captureApi(manifest); - try { - await api.sync.exportState("aabb", "device-1"); - } finally { - unloadPlugin("gist-sync-test"); - } + pluginSkippedFilesMock.mockReturnValue(["theme.json"]); + await exportOnce(); + + expect(invokeMock).toHaveBeenCalledWith( + "backup_export", + expect.objectContaining({ + excludedIds: ["excluded-host", "excluded-key"], + skipFiles: ["theme.json"], + }), + ); + }); + + test("writes the filtered settings bundle before calling backup_export", async () => { + await exportOnce(); + expect(writeFilteredSettingsMock).toHaveBeenCalled(); + }); + + test("themes ON: theme.json is not withheld on the plugin path", async () => { + pluginSkippedFilesMock.mockReturnValue([]); + await exportOnce(); + + expect(invokeMock).toHaveBeenCalledWith( + "backup_export", + expect.objectContaining({ skipFiles: [] }), + ); + }); + + test("themes OFF: theme.json is withheld on the plugin path", async () => { + pluginSkippedFilesMock.mockReturnValue(["theme.json"]); + await exportOnce(); expect(invokeMock).toHaveBeenCalledWith( "backup_export", - expect.objectContaining({ excludedIds: ["excluded-host", "excluded-key"] }), + expect.objectContaining({ skipFiles: ["theme.json"] }), ); }); }); diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 627ed733d..f3d09ffb8 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -30,8 +30,9 @@ import { useVaultStore } from "@/stores/vaultStore"; import { usePortForwardingStore } from "@/stores/portForwardingStore"; import { useTransferQueueStore } from "@/stores/transferQueueStore"; import { useHostPingStore } from "@/stores/hostPingStore"; -import { getSyncState, onSyncStateChange, ENTITY_FILES, getExcludedObjectIds, type BlobPayload } from "@/services/sync"; +import { getSyncState, onSyncStateChange, ENTITY_FILES, getExcludedObjectIds, getPluginSkippedSyncFiles, writeFilteredSettings, type BlobPayload } from "@/services/sync"; import { useThemeStore } from "@/stores/themeStore"; +import { useSyncPrefsStore } from "@/stores/syncPrefsStore"; import { mergeEntities, mergeSecrets } from "@/services/crdt"; import type { UISlot, @@ -2354,14 +2355,19 @@ function createPluginAPI(manifest: PluginManifest): PluginAPI { async exportState(encKey, deviceId) { requirePerm(manifest, "sync:write"); + await writeFilteredSettings(); const encKeyBytes = Array.from(new Uint8Array(encKey.match(/.{2}/g)!.map((b) => parseInt(b, 16)))); const blob: number[] = await invoke("backup_export", { encKey: encKeyBytes, accountId: "gist-sync", deviceId, // Strip cloud-off objects (and their secrets) from third-party sync - // destinations too, mirroring the built-in server push (issue #47). + // destinations too, mirroring the built-in server push (issue #47), + // and withhold the same config files (issue #42) — the plugin path's + // own theme.json rule, since this destination has no settings-bundle + // theme route (see importStates below). excludedIds: getExcludedObjectIds(), + skipFiles: getPluginSkippedSyncFiles(), }); const CHUNK = 8192; let binary = ""; @@ -2423,7 +2429,10 @@ function createPluginAPI(manifest: PluginManifest): PluginAPI { } } - if (bestThemeRaw) { + // Inbound half of what getPluginSkippedSyncFiles enforces outbound: a + // device that opted themes out of sync must not have them overwritten + // by an incoming blob either. + if (bestThemeRaw && useSyncPrefsStore.getState().isDomainSynced("themes")) { try { const localRaw = await invoke("theme_load"); let apply = true; diff --git a/src/services/sync.skipFiles.test.ts b/src/services/sync.skipFiles.test.ts new file mode 100644 index 000000000..4c0825727 --- /dev/null +++ b/src/services/sync.skipFiles.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => null) })); + +import { getSkippedSyncFiles, getPluginSkippedSyncFiles } from "./sync"; +import { useSyncPrefsStore } from "@/stores/syncPrefsStore"; + +describe("getSkippedSyncFiles", () => { + beforeEach(() => useSyncPrefsStore.setState({ syncSettingDomains: {} })); + + test("always withholds theme.json — themes travel in the bundle now", () => { + expect(getSkippedSyncFiles()).toContain("theme.json"); + }); + + test("still withholds theme.json even if the themes domain is switched off", () => { + useSyncPrefsStore.getState().setSyncSettingDomain("themes", false); + expect(getSkippedSyncFiles()).toContain("theme.json"); + }); + + test("withholds plugin-registry.json when app settings are not synced", () => { + expect(getSkippedSyncFiles()).not.toContain("plugin-registry.json"); + useSyncPrefsStore.getState().setSyncSettingDomain("appSettings", false); + expect(getSkippedSyncFiles()).toContain("plugin-registry.json"); + }); +}); + +describe("getPluginSkippedSyncFiles", () => { + beforeEach(() => useSyncPrefsStore.setState({ syncSettingDomains: {} })); + + test("does not withhold theme.json while the themes domain is synced — it's the plugin path's only theme route", () => { + expect(getPluginSkippedSyncFiles()).not.toContain("theme.json"); + }); + + test("withholds theme.json once the themes domain is switched off", () => { + useSyncPrefsStore.getState().setSyncSettingDomain("themes", false); + expect(getPluginSkippedSyncFiles()).toContain("theme.json"); + }); + + test("withholds plugin-registry.json when app settings are not synced, same as the server variant", () => { + expect(getPluginSkippedSyncFiles()).not.toContain("plugin-registry.json"); + useSyncPrefsStore.getState().setSyncSettingDomain("appSettings", false); + expect(getPluginSkippedSyncFiles()).toContain("plugin-registry.json"); + }); +}); diff --git a/src/services/sync.ts b/src/services/sync.ts index bb9a4e899..90930c643 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -3,7 +3,6 @@ import i18n from "@/i18n"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; import { getJwt, getServerUrl, isJwtExpiredOrExpiring } from "@/services/authTokens"; import { getVaultKey, unlockVaultIfNeeded } from "@/services/vault"; -import { useThemeStore } from "@/stores/themeStore"; import { buildUserDataBundle, mergeUserDataBundle, applyUserDataBundle } from "@/services/user-data/registry"; import type { UserDataBundle } from "@/services/user-data/formats"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -17,6 +16,7 @@ import { useSnippetFolderStore } from "@/stores/snippetFolderStore"; import { usePortForwardingStore } from "@/stores/portForwardingStore"; import { mergeEntities, mergeSecrets, secretsDiffer, type TimestampedEntity } from "@/services/crdt"; import { filterRemoteExcluded, collectExcludedIds } from "./syncExclusion"; +import { filterIncoming, filterOutgoing } from "@/services/user-data/syncFilter"; import { useSyncPrefsStore } from "@/stores/syncPrefsStore"; import { useVaultKeysStore } from "@/stores/vaultKeysStore"; import { buildDecryptKeyCandidates } from "@/services/vaultKeyCandidates"; @@ -83,22 +83,6 @@ function setState(status: SyncStatus, error?: string) { _listeners.forEach((fn) => fn()); } -async function applyRemoteTheme(remotePayload: BlobPayload): Promise { - try { - const remoteRaw = remotePayload.files["theme.json"]; - if (!remoteRaw) return; - const remote = JSON.parse(remoteRaw) as { updatedAt?: string }; - if (!remote.updatedAt) return; - const localRaw = await invoke("theme_load"); - if (localRaw) { - const local = JSON.parse(localRaw) as { updatedAt?: string }; - if (local.updatedAt && local.updatedAt >= remote.updatedAt) return; - } - await invoke("theme_save", { state: remoteRaw }); - await useThemeStore.getState().loadFromDisk(); - } catch {} -} - async function applyRemoteSettings(remotePayload: BlobPayload): Promise { try { const remoteRaw = remotePayload.files["settings.json"]; @@ -107,7 +91,7 @@ async function applyRemoteSettings(remotePayload: BlobPayload): Promise { if (remote.type !== "voltius-user-data") return; const localRaw = await invoke("settings_load"); const local = localRaw ? (JSON.parse(localRaw) as UserDataBundle) : null; - const { merged, updatedKeys } = mergeUserDataBundle(local, remote); + const { merged, updatedKeys } = mergeUserDataBundle(local, filterIncoming(remote)); if (updatedKeys.length === 0) return; await invoke("settings_save", { state: JSON.stringify(merged) }); await applyUserDataBundle(merged, updatedKeys, { remote: true }); @@ -322,6 +306,65 @@ export function getExcludedObjectIds(): string[] { ); } +/** `plugin-registry.json` duplicates `appSettings.plugins.overrides`, so every + * destination withholds it under the same condition — that part isn't + * destination-specific, unlike theme.json below. */ +function skippedConfigFilesCore(): string[] { + const skipped: string[] = []; + if (!useSyncPrefsStore.getState().isDomainSynced("appSettings")) { + skipped.push("plugin-registry.json"); + } + return skipped; +} + +/** + * Config files that must not enter the SERVER blob this round. + * + * `theme.json` is always withheld here: themes travel in the settings bundle, + * and `applyRemoteSettings` merges that bundle on pull, so a second wire would + * be redundant and could fight the domain toggle. + * + * Exported for its test; plugin destinations use `getPluginSkippedSyncFiles`. + */ +export function getSkippedSyncFiles(): string[] { + return ["theme.json", ...skippedConfigFilesCore()]; +} + +/** + * Config files that must not enter a PLUGIN (third-party) blob this round. + * + * Unlike the server destination, plugin destinations never merge the settings + * bundle — `theme.json` is their *only* theme route (see `importStates` in + * runtime.ts). So it must still travel there whenever the themes domain is + * synced, and is withheld only when the user has switched that domain off. + * This asymmetry with `getSkippedSyncFiles` is deliberate, not a bug: it + * exists because the two destinations don't apply the same wire for themes. + */ +export function getPluginSkippedSyncFiles(): string[] { + const skipped = skippedConfigFilesCore(); + if (!useSyncPrefsStore.getState().isDomainSynced("themes")) { + skipped.push("theme.json"); + } + return skipped; +} + +/** + * Ensure settings.json is current before ANY `backup_export` caller reads it. + * Filtered: this file is both the local merge base and part of the uploaded + * blob, so a switched-off domain has to be absent from it, not merely ignored + * on arrival. Every `backup_export` caller (server push, plugin export) must + * call this first — issue #47 was exactly a second caller skipping a step + * like this one. + */ +export async function writeFilteredSettings(): Promise { + // Not swallowed: backup_export reads settings.json from disk regardless of + // this call's outcome, so a hidden failure here would upload the + // pre-toggle, unfiltered file. A failed sync round is strictly better than + // uploading held-back data — let this throw and abort the round. + const bundle = filterOutgoing(buildUserDataBundle()); + await invoke("settings_save", { state: JSON.stringify(bundle) }); +} + /** Export local data and upload to server. */ export async function push(): Promise { const encKey = await getEncKey(); @@ -335,17 +378,14 @@ export async function push(): Promise { await unlockVaultIfNeeded(); - // Ensure settings.json is current before backup_export reads it. - try { - const bundle = buildUserDataBundle(); - await invoke("settings_save", { state: JSON.stringify(bundle) }); - } catch {} + await writeFilteredSettings(); const blob: number[] = await invoke("backup_export", { encKey, accountId, deviceId, excludedIds: getExcludedObjectIds(), + skipFiles: getSkippedSyncFiles(), }); const res = await fetchWithAuth(`${serverUrl}/v1/sync/blob`, { @@ -455,7 +495,6 @@ async function pullAndMerge(remoteDeviceId: string): Promise { ENTITY_FILES, ); - await applyRemoteTheme(remotePayload); await applyRemoteSettings(remotePayload); applyRemoteLiveSessions(remoteDeviceId, remotePayload); @@ -633,7 +672,6 @@ export async function syncOnLoginReplace(): Promise { ENTITY_FILES, ); - await applyRemoteTheme(remotePayload); await applyRemoteSettings(remotePayload); applyRemoteLiveSessions(device.device_id, remotePayload); diff --git a/src/services/user-data/handler.test.ts b/src/services/user-data/handler.test.ts index 18ba6626d..004ab2896 100644 --- a/src/services/user-data/handler.test.ts +++ b/src/services/user-data/handler.test.ts @@ -31,4 +31,20 @@ describe("registered handlers", () => { else expect(h.merge, h.key).toBe(lastWriteWins); } }); + + test("every handler exposes touch()", () => { + for (const h of USER_DATA_HANDLERS) { + expect(typeof h.touch, h.key).toBe("function"); + } + }); + + test("touch advances the timestamp of every toggleable domain", () => { + // `vaults` derives its timestamp from row clocks and is never toggleable, + // so it has nothing to advance. + for (const h of USER_DATA_HANDLERS.filter((x) => x.key !== "vaults")) { + const before = h.getTimestamp(); + h.touch(); + expect(h.getTimestamp() > before, h.key).toBe(true); + } + }); }); diff --git a/src/services/user-data/handler.ts b/src/services/user-data/handler.ts index f9aaddc98..40e333d3a 100644 --- a/src/services/user-data/handler.ts +++ b/src/services/user-data/handler.ts @@ -6,7 +6,8 @@ export interface UserDataHandler { readonly label: string; readonly icon: string; - // Read current state from stores. + // Read current state from stores. Must be side-effect free: mergeUserDataBundle + // calls this on the absent-section path, outside of any explicit export flow. export(): unknown; // Write exported state to stores. @@ -23,6 +24,10 @@ export interface UserDataHandler { // ISO timestamp of the most recent local change to this domain. getTimestamp(): string; + // Stamp this domain as changed now, so the next merge publishes local values. + // Called when the user switches sync back on for the domain. + touch(): void; + // Short human-readable summary of current state, e.g. "3 custom themes". describe(): string; } diff --git a/src/services/user-data/handlers/appSettings.ts b/src/services/user-data/handlers/appSettings.ts index c02e231ca..423f6d9dd 100644 --- a/src/services/user-data/handlers/appSettings.ts +++ b/src/services/user-data/handlers/appSettings.ts @@ -76,6 +76,10 @@ export const appSettingsHandler: UserDataHandler = { return useAppSettingsTimestampStore.getState().updatedAt; }, + touch(): void { + useAppSettingsTimestampStore.getState().touch(); + }, + describe(): string { const { preferredShell } = useTerminalSettingsStore.getState(); return preferredShell diff --git a/src/services/user-data/handlers/recentPeople.ts b/src/services/user-data/handlers/recentPeople.ts index 2055c123c..166d43c68 100644 --- a/src/services/user-data/handlers/recentPeople.ts +++ b/src/services/user-data/handlers/recentPeople.ts @@ -1,5 +1,6 @@ import i18n from "@/i18n"; import { useRecentPeopleStore, type RecentPerson } from "@/stores/recentPeopleStore"; +import { pushSettingsChange, settingsStamp } from "@/stores/remoteApplyGuard"; import { lastWriteWins, type UserDataHandler } from "../handler"; export const recentPeopleHandler: UserDataHandler = { @@ -24,6 +25,11 @@ export const recentPeopleHandler: UserDataHandler = { return useRecentPeopleStore.getState().recentUpdatedAt; }, + touch(): void { + useRecentPeopleStore.setState({ recentUpdatedAt: settingsStamp() }); + pushSettingsChange(); + }, + describe(): string { return i18n.t("importExport.userData.describe.recentPeople", { count: useRecentPeopleStore.getState().recent.length, diff --git a/src/services/user-data/handlers/shortcuts.ts b/src/services/user-data/handlers/shortcuts.ts index 540419562..fa8b745a3 100644 --- a/src/services/user-data/handlers/shortcuts.ts +++ b/src/services/user-data/handlers/shortcuts.ts @@ -1,5 +1,6 @@ import i18n from "@/i18n"; import { useShortcutStore } from "@/stores/shortcutStore"; +import { pushSettingsChange, settingsStamp } from "@/stores/remoteApplyGuard"; import { lastWriteWins, type UserDataHandler } from "../handler"; interface ShortcutOverride { @@ -33,6 +34,11 @@ export const shortcutsHandler: UserDataHandler = { return useShortcutStore.getState().shortcutsUpdatedAt; }, + touch(): void { + useShortcutStore.setState({ shortcutsUpdatedAt: settingsStamp() }); + pushSettingsChange(); + }, + describe(): string { const overrides = useShortcutStore.getState().shortcuts.filter( (sc) => sc.key !== sc.defaultKey, diff --git a/src/services/user-data/handlers/themes.test.ts b/src/services/user-data/handlers/themes.test.ts new file mode 100644 index 000000000..0584a4674 --- /dev/null +++ b/src/services/user-data/handlers/themes.test.ts @@ -0,0 +1,63 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { useThemeStore } from "@/stores/themeStore"; +import { themesHandler } from "./themes"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => null) })); + +describe("themesHandler", () => { + beforeEach(() => { + useThemeStore.setState({ + activeThemeId: "voltius", + customThemes: [], + mode: "manual", + lightThemeId: "voltius-light", + darkThemeId: "voltius", + scheduleLightStart: "07:00", + scheduleDarkStart: "19:00", + location: null, + }); + }); + + test("exports every field theme.json persists", () => { + useThemeStore.setState({ mode: "schedule", scheduleLightStart: "06:30", darkThemeId: "dracula" }); + expect(themesHandler.export()).toMatchObject({ + activeThemeId: "voltius", + mode: "schedule", + lightThemeId: "voltius-light", + darkThemeId: "dracula", + scheduleLightStart: "06:30", + scheduleDarkStart: "19:00", + location: null, + }); + }); + + test("imports the automation fields, not just the active theme", async () => { + await themesHandler.import({ + activeThemeId: "voltius-light", + customThemes: [], + mode: "schedule", + lightThemeId: "solarized", + darkThemeId: "dracula", + scheduleLightStart: "05:00", + scheduleDarkStart: "21:00", + location: { lat: 48.85, lng: 2.35, label: "Paris", source: "manual" }, + }); + const s = useThemeStore.getState(); + expect(s.activeThemeId).toBe("voltius-light"); + expect(s.mode).toBe("schedule"); + expect(s.lightThemeId).toBe("solarized"); + expect(s.darkThemeId).toBe("dracula"); + expect(s.scheduleLightStart).toBe("05:00"); + expect(s.scheduleDarkStart).toBe("21:00"); + expect(s.location).toEqual({ lat: 48.85, lng: 2.35, label: "Paris", source: "manual" }); + }); + + test("a section missing the new fields leaves the local values alone", async () => { + useThemeStore.setState({ mode: "schedule", darkThemeId: "dracula" }); + await themesHandler.import({ activeThemeId: "voltius-light", customThemes: [] }); + const s = useThemeStore.getState(); + expect(s.activeThemeId).toBe("voltius-light"); + expect(s.mode).toBe("schedule"); + expect(s.darkThemeId).toBe("dracula"); + }); +}); diff --git a/src/services/user-data/handlers/themes.ts b/src/services/user-data/handlers/themes.ts index 35a02a59e..1ec41ab45 100644 --- a/src/services/user-data/handlers/themes.ts +++ b/src/services/user-data/handlers/themes.ts @@ -1,11 +1,18 @@ import i18n from "@/i18n"; import { useThemeStore } from "@/stores/themeStore"; import type { AppTheme } from "@/themes/types"; +import type { ThemeMode, GeoLocation } from "@/services/themeAutomation"; import { lastWriteWins, type UserDataHandler } from "../handler"; interface ThemesData { activeThemeId: string; customThemes: AppTheme[]; + mode?: ThemeMode; + lightThemeId?: string; + darkThemeId?: string; + scheduleLightStart?: string; + scheduleDarkStart?: string; + location?: GeoLocation | null; } export const themesHandler: UserDataHandler = { @@ -14,17 +21,41 @@ export const themesHandler: UserDataHandler = { icon: "lucide:palette", export(): ThemesData { - const { activeThemeId, customThemes } = useThemeStore.getState(); - return { activeThemeId, customThemes }; + const s = useThemeStore.getState(); + return { + activeThemeId: s.activeThemeId, + customThemes: s.customThemes, + mode: s.mode, + lightThemeId: s.lightThemeId, + darkThemeId: s.darkThemeId, + scheduleLightStart: s.scheduleLightStart, + scheduleDarkStart: s.scheduleDarkStart, + location: s.location, + }; }, async import(data: unknown): Promise { - const { activeThemeId, customThemes } = data as ThemesData; - const store = useThemeStore.getState(); - for (const theme of (customThemes ?? [])) { - store.saveCustomTheme({ ...theme, builtIn: false }); + const d = (data ?? {}) as Partial; + + // One setState + one persist: each individual setter writes theme.json and + // schedules a push, so calling seven of them would do that seven times. + const patch: Partial = {}; + if (d.activeThemeId) patch.activeThemeId = d.activeThemeId; + if (d.mode) patch.mode = d.mode; + if (d.lightThemeId) patch.lightThemeId = d.lightThemeId; + if (d.darkThemeId) patch.darkThemeId = d.darkThemeId; + if (d.scheduleLightStart) patch.scheduleLightStart = d.scheduleLightStart; + if (d.scheduleDarkStart) patch.scheduleDarkStart = d.scheduleDarkStart; + if (d.location !== undefined) patch.location = d.location; + // REPLACE, not upsert: an upsert can only add themes, never remove one a + // remote deletion took out, so a deleted theme would survive on this device + // and get republished back to the deleter on the next export. + if (Array.isArray(d.customThemes)) { + patch.customThemes = d.customThemes.map((theme) => ({ ...theme, builtIn: false })); } - if (activeThemeId) store.setTheme(activeThemeId); + if (Object.keys(patch).length === 0) return; + useThemeStore.setState(patch); + useThemeStore.getState().persist(); }, merge: lastWriteWins, @@ -33,6 +64,10 @@ export const themesHandler: UserDataHandler = { return useThemeStore.getState().updatedAt; }, + touch(): void { + useThemeStore.getState().persist(); + }, + describe(): string { const { customThemes } = useThemeStore.getState(); return i18n.t("importExport.userData.describe.themes", { count: customThemes.length }); diff --git a/src/services/user-data/handlers/uiPreferences.ts b/src/services/user-data/handlers/uiPreferences.ts index 88b162477..bb02bc8ea 100644 --- a/src/services/user-data/handlers/uiPreferences.ts +++ b/src/services/user-data/handlers/uiPreferences.ts @@ -1,6 +1,7 @@ import i18n from "@/i18n"; import { useUIStore } from "@/stores/uiStore"; import type { LayoutMode, SortMode } from "@/stores/uiStore"; +import { pushSettingsChange, settingsStamp } from "@/stores/remoteApplyGuard"; import { lastWriteWins, type UserDataHandler } from "../handler"; interface UIPrefsData { @@ -49,6 +50,11 @@ export const uiPreferencesHandler: UserDataHandler = { return useUIStore.getState().prefsUpdatedAt; }, + touch(): void { + useUIStore.setState({ prefsUpdatedAt: settingsStamp() }); + pushSettingsChange(); + }, + describe(): string { const s = useUIStore.getState(); return i18n.t("importExport.userData.describe.uiPreferences", { scale: s.uiScale, layout: s.homeLayoutMode }); diff --git a/src/services/user-data/handlers/vaults.ts b/src/services/user-data/handlers/vaults.ts index edb1aee20..9fdaddfa5 100644 --- a/src/services/user-data/handlers/vaults.ts +++ b/src/services/user-data/handlers/vaults.ts @@ -35,6 +35,10 @@ export const vaultsHandler: UserDataHandler = { return newestVaultTimestamp(section()); }, + // No-op by design: the vaults timestamp is the newest row clock, and vaults + // is never user-toggleable, so there is no re-enable to publish. + touch(): void {}, + describe(): string { return i18n.t("importExport.userData.describe.vaults", { count: Object.values(section()).filter(isAliveVaultRow).length, diff --git a/src/services/user-data/registry.mergeAbsentSection.test.ts b/src/services/user-data/registry.mergeAbsentSection.test.ts new file mode 100644 index 000000000..7bff2a9f5 --- /dev/null +++ b/src/services/user-data/registry.mergeAbsentSection.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { mergeUserDataBundle } from "./registry"; +import type { UserDataBundle } from "./formats"; +import { useUIStore } from "@/stores/uiStore"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => null) })); + +const OLD_TS = new Date(0).toISOString(); +const REMOTE_TS = "2020-01-01T00:00:00.000Z"; +const LIVE_TS = "2030-01-01T00:00:00.000Z"; + +function remoteBundle(uiScale: number, updated_at: string): UserDataBundle { + return { + type: "voltius-user-data", + version: 2, + exported_at: updated_at, + sections: { + uiPreferences: { data: { uiScale, homeLayoutMode: "list" }, updated_at }, + }, + } as UserDataBundle; +} + +/** A local bundle with NO uiPreferences section — as happens once filterOutgoing + * strips a switched-off domain out of settings.json before it's written. */ +function localBundleMissingSection(): UserDataBundle { + return { + type: "voltius-user-data", + version: 2, + exported_at: OLD_TS, + sections: {}, + }; +} + +describe("mergeUserDataBundle — section absent from the local bundle", () => { + beforeEach(() => { + useUIStore.setState({ prefsUpdatedAt: LIVE_TS, uiScale: 1.5 }); + }); + + test("falls back to the handler's live state, so a newer local edit beats a stale remote", () => { + const { merged, updatedKeys } = mergeUserDataBundle(localBundleMissingSection(), remoteBundle(2, REMOTE_TS)); + + expect(merged.sections.uiPreferences.data).toMatchObject({ uiScale: 1.5 }); + expect(merged.sections.uiPreferences.updated_at).toBe(LIVE_TS); + expect(updatedKeys).not.toContain("uiPreferences"); + }); + + test("a genuinely newer remote still wins over the live local state", () => { + const FUTURE_TS = "2031-01-01T00:00:00.000Z"; + const { merged, updatedKeys } = mergeUserDataBundle(localBundleMissingSection(), remoteBundle(3, FUTURE_TS)); + + expect(merged.sections.uiPreferences.data).toMatchObject({ uiScale: 3 }); + expect(merged.sections.uiPreferences.updated_at).toBe(FUTURE_TS); + expect(updatedKeys).toContain("uiPreferences"); + }); +}); diff --git a/src/services/user-data/registry.ts b/src/services/user-data/registry.ts index eaeb12d65..a6ef9e6cd 100644 --- a/src/services/user-data/registry.ts +++ b/src/services/user-data/registry.ts @@ -12,6 +12,11 @@ import { vaultsHandler } from "./handlers/vaults"; // Order matters for UI rendering. Adding a new settings domain: // 1. Create handlers/.ts implementing UserDataHandler // 2. Add it here +// 3. Add an entry to SYNC_SETTING_DOMAINS in stores/syncPrefsStore.ts, or the +// new domain silently becomes permanently-synced (isDomainSynced defaults +// an unknown key to true) — the drift test in syncPrefsStore.test.ts +// fails until you do +// 4. Add the four settings.sync.settingDomain..{label,sub} locale strings export const USER_DATA_HANDLERS: UserDataHandler[] = [ themesHandler, @@ -24,6 +29,13 @@ export const USER_DATA_HANDLERS: UserDataHandler[] = [ // ─── Build ──────────────────────────────────────────────────────────────────── +/** + * Unfiltered by design: the manual export UI produces a backup the user asked + * for by name, and that backup must be complete regardless of sync domain + * toggles. Only the sync path wraps this in `filterOutgoing`. A future sync + * route reaching for a "build the bundle" function should filter its own + * output rather than change this one. + */ export function buildUserDataBundle(keys?: string[]): UserDataBundle { const handlers = keys ? USER_DATA_HANDLERS.filter((h) => keys.includes(h.key)) @@ -82,10 +94,20 @@ export function mergeUserDataBundle( const remoteSection = remote.sections[h.key]; if (!remoteSection) continue; - const localTs = localSection?.updated_at ?? new Date(0).toISOString(); + // A section missing from the bundle isn't necessarily missing data: a + // switched-off domain is filtered OUT of settings.json before it's ever + // written (filterOutgoing), so the merge base can lack an entry for a + // domain whose store still holds current, possibly newer, local state. + // The stores are local truth — settings.json is only a cache of them — + // so fall back to the handler's live export/timestamp rather than + // treating an absent section as "no local value", which would let + // lastWriteWins hand a stale remote an unconditional win the moment the + // domain is re-enabled. + const localData = localSection ? localSection.data : h.export(); + const localTs = localSection ? localSection.updated_at : h.getTimestamp(); const remoteTs = remoteSection.updated_at; const { value, updated } = h.merge( - localSection?.data, + localData, remoteSection.data, localTs, remoteTs, diff --git a/src/services/user-data/syncFilter.test.ts b/src/services/user-data/syncFilter.test.ts new file mode 100644 index 000000000..d04f6ab23 --- /dev/null +++ b/src/services/user-data/syncFilter.test.ts @@ -0,0 +1,48 @@ +import { describe, test, expect, beforeEach } from "vitest"; +import { useSyncPrefsStore } from "@/stores/syncPrefsStore"; +import { filterOutgoing, filterIncoming } from "./syncFilter"; +import type { UserDataBundle } from "./formats"; + +const bundle = (): UserDataBundle => ({ + type: "voltius-user-data", + version: 2, + exported_at: "2026-08-20T00:00:00.000Z", + sections: { + themes: { data: { activeThemeId: "dracula" }, updated_at: "2026-08-20T00:00:00.000Z" }, + appSettings: { data: { locale: "fr" }, updated_at: "2026-08-20T00:00:00.000Z" }, + vaults: { data: {}, updated_at: "2026-08-20T00:00:00.000Z" }, + }, +}); + +describe("syncFilter", () => { + beforeEach(() => useSyncPrefsStore.setState({ syncSettingDomains: {} })); + + test("passes everything through when nothing is switched off", () => { + expect(Object.keys(filterOutgoing(bundle()).sections).sort()) + .toEqual(["appSettings", "themes", "vaults"]); + }); + + test("drops a switched-off section on the way out", () => { + useSyncPrefsStore.getState().setSyncSettingDomain("themes", false); + const out = filterOutgoing(bundle()); + expect(out.sections.themes).toBeUndefined(); + expect(out.sections.appSettings).toBeDefined(); + }); + + test("drops a switched-off section on the way in", () => { + useSyncPrefsStore.getState().setSyncSettingDomain("themes", false); + expect(filterIncoming(bundle()).sections.themes).toBeUndefined(); + }); + + test("never drops vaults", () => { + useSyncPrefsStore.setState({ syncSettingDomains: { vaults: false } }); + expect(filterOutgoing(bundle()).sections.vaults).toBeDefined(); + }); + + test("does not mutate the input", () => { + useSyncPrefsStore.getState().setSyncSettingDomain("themes", false); + const input = bundle(); + filterOutgoing(input); + expect(input.sections.themes).toBeDefined(); + }); +}); diff --git a/src/services/user-data/syncFilter.ts b/src/services/user-data/syncFilter.ts new file mode 100644 index 000000000..f06b26197 --- /dev/null +++ b/src/services/user-data/syncFilter.ts @@ -0,0 +1,32 @@ +import { useSyncPrefsStore } from "@/stores/syncPrefsStore"; +import type { UserDataBundle, UserDataSection } from "./formats"; + +// filterOutgoing and filterIncoming both delegate here and are identical +// today. Kept as two functions because a follow-up PR gives them different +// per-key behaviour: outgoing must delete held-back paths from the server +// blob, incoming must not. +function keepSyncedSections(bundle: UserDataBundle): UserDataBundle { + const { isDomainSynced } = useSyncPrefsStore.getState(); + const sections: Record = {}; + for (const [key, section] of Object.entries(bundle.sections)) { + if (isDomainSynced(key)) sections[key] = section; + } + return { ...bundle, sections }; +} + +/** + * The bundle as it may leave this device. Written to settings.json before + * `backup_export` reads it, so a switched-off domain never enters the blob — + * the guarantee is the filter, not the push trigger. + */ +export function filterOutgoing(bundle: UserDataBundle): UserDataBundle { + return keepSyncedSections(bundle); +} + +/** + * A remote bundle as this device may consider it. Switched-off sections are + * dropped before the merge, so remote values neither win nor reach the stores. + */ +export function filterIncoming(bundle: UserDataBundle): UserDataBundle { + return keepSyncedSections(bundle); +} diff --git a/src/stores/syncPrefsStore.test.ts b/src/stores/syncPrefsStore.test.ts new file mode 100644 index 000000000..fdde2848e --- /dev/null +++ b/src/stores/syncPrefsStore.test.ts @@ -0,0 +1,34 @@ +import { describe, test, expect, beforeEach } from "vitest"; +import { useSyncPrefsStore, SYNC_SETTING_DOMAINS } from "./syncPrefsStore"; +import { USER_DATA_HANDLERS } from "@/services/user-data/registry"; + +describe("settings domain toggles", () => { + beforeEach(() => useSyncPrefsStore.setState({ syncSettingDomains: {} })); + + test("lists the five toggleable domains and not vaults", () => { + expect(SYNC_SETTING_DOMAINS.map((d) => d.id)).toEqual([ + "themes", "uiPreferences", "shortcuts", "appSettings", "recentPeople", + ]); + }); + + test("domains sync by default", () => { + expect(useSyncPrefsStore.getState().isDomainSynced("themes")).toBe(true); + }); + + test("switching a domain off is remembered", () => { + useSyncPrefsStore.getState().setSyncSettingDomain("themes", false); + expect(useSyncPrefsStore.getState().isDomainSynced("themes")).toBe(false); + expect(useSyncPrefsStore.getState().isDomainSynced("appSettings")).toBe(true); + }); + + test("vaults always syncs, even if a stale value says otherwise", () => { + useSyncPrefsStore.setState({ syncSettingDomains: { vaults: false } }); + expect(useSyncPrefsStore.getState().isDomainSynced("vaults")).toBe(true); + }); + + test("every handler has a sync toggle, or is the vaults exception", () => { + const handlerKeys = new Set(USER_DATA_HANDLERS.map((h) => h.key)); + const togglableKeys = new Set([...SYNC_SETTING_DOMAINS.map((d) => d.id), "vaults"]); + expect(togglableKeys).toEqual(handlerKeys); + }); +}); diff --git a/src/stores/syncPrefsStore.ts b/src/stores/syncPrefsStore.ts index d7042c646..963e79800 100644 --- a/src/stores/syncPrefsStore.ts +++ b/src/stores/syncPrefsStore.ts @@ -18,6 +18,23 @@ export const SYNC_OBJECT_TYPES: SyncObjectTypeDef[] = [ { id: "port-forwarding-rule", label: "Port Forwarding", sub: "Saved tunnel rules" }, ]; +export interface SyncSettingDomainDef { + /** Handler key from USER_DATA_HANDLERS. */ + id: string; +} + +// `vaults` is deliberately absent: it is tombstone-merged data, not a +// preference, and switching it off would strand deletes on this device. +export const SYNC_SETTING_DOMAINS: SyncSettingDomainDef[] = [ + { id: "themes" }, + { id: "uiPreferences" }, + { id: "shortcuts" }, + { id: "appSettings" }, + { id: "recentPeople" }, +]; + +const TOGGLEABLE_DOMAINS = new Set(SYNC_SETTING_DOMAINS.map((d) => d.id)); + // ─── Store ─────────────────────────────────────────────────────────────────── interface SyncPrefsStore { @@ -25,12 +42,16 @@ interface SyncPrefsStore { syncTypes: Record; // Per-object exclusions by ID excludedIds: string[]; + // Per-domain settings toggles: key = handler key, value = synced (default true when absent) + syncSettingDomains: Record; setSyncType: (typeId: string, v: boolean) => void; toggleExcluded: (id: string) => void; isExcluded: (id: string) => boolean; isTypeSynced: (typeId: string) => boolean; isObjectSynced: (id: string, typeId: string) => boolean; + setSyncSettingDomain: (id: string, v: boolean) => void; + isDomainSynced: (id: string) => boolean; } export const useSyncPrefsStore = create()( @@ -38,6 +59,7 @@ export const useSyncPrefsStore = create()( (set, get) => ({ syncTypes: {}, excludedIds: [], + syncSettingDomains: {}, setSyncType: (typeId, v) => set((s) => ({ syncTypes: { ...s.syncTypes, [typeId]: v } })), @@ -58,6 +80,14 @@ export const useSyncPrefsStore = create()( if ((s.syncTypes[typeId] ?? true) === false) return false; return !s.excludedIds.includes(id); }, + + setSyncSettingDomain: (id, v) => + set((s) => ({ syncSettingDomains: { ...s.syncSettingDomains, [id]: v } })), + + isDomainSynced: (id) => { + if (!TOGGLEABLE_DOMAINS.has(id)) return true; + return get().syncSettingDomains[id] ?? true; + }, }), { name: "sync-prefs" }, ),