diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index c4974b0e91..475f365136 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -171,6 +171,32 @@ applying or uploading settings, a host fans out `account://settings-applied` to attached controllers; the controller re-emits it locally so the frontend config cache and model selectors refresh without reconnecting. +The account settings payload is the complete `ConfigExport.config` document, +not a whitelist assembled by the login UI. Its scope is: + +| Persisted configuration | Account sync coverage | +|---|---| +| `app` | Language, startup/window preferences, logging, notifications, layout, FlowChat, AI experience/quick actions, voice input/call settings, keybindings, tool/Skill groups, hook enablement gates, worktree defaults | +| `ai` | Persisted models and credentials, default/task/subagent model selectors, Agent profile overrides, Skill availability, Review Teams, concurrency/timeouts, proxy, browser/tool preferences, non-secret WebSearch settings | +| `editor`, `terminal`, `workspace` | Preferences in the global document; workspace files and machine connection records are separate | +| `tool_permissions`, `memories` | User permission policy and memory preferences; project permission files and generated memory content are separate | +| `mcp_servers`, `acp_clients`, `plugin`, `project` | Declarations present in the global document; external executables, installed packages and separately stored project overlays are not copied | +| `appearance`, `font` | Appearance selection and UI font preferences; imported skin assets are stored separately | + +The frontend refreshes the config cache and the appearance, font and language +runtimes after a settings-applied event. Keybindings register a path watcher +even when their initial value came from the bootstrap hint, and an empty or +removed override restores the registered default. Applying these preferences +does not save them again. An unavailable imported skin keeps the persisted +selection and exposes the existing degraded/unavailable state. + +This is settings synchronization, not a user-home backup: custom Agent and Skill +source files, `hooks.json` declarations/scripts, plugin packages, skin/pet +assets, local credential-vault entries, SSH profiles and browser storage are +outside this payload. A synchronized declaration or asset path does not imply +that its dependency is installed or usable on another host. Runtime-only model +credentials are also excluded. Session backup upload has a separate lifecycle. + The sync engine subscribes to successful local mutations at `ConfigService`, in addition to legacy host notifications. This covers model, Skill, Agent profile, and individual preference mutations through Desktop and CLI. Failed @@ -179,21 +205,21 @@ local-change signal. Pending local edits take priority over the periodic pull; a fetched blob is applied only if the local document still matches its pre-fetch snapshot. The comparison and import share the config write lock. -Older settings snapshots may omit fixed fields introduced by a newer build. -Imports preserve those local fields instead of replacing them with defaults. -Supplied arrays and dynamic maps remain authoritative, so deleted models, -profiles and list entries are not resurrected. Optional/default-elided fields -retain their existing reset semantics; an explicit raw backup restore also -honors omitted default memory and AI preferences. Legacy renamed fields still -pass through their migrations before values at the new names are preserved. +Imports validate the OpenBitFun product identity, export format and config +schema, then replace the document. Within the supported schema, omitted fields +with serde defaults acquire those defaults; they do not retain the receiving +host's prior value. Arrays and dynamic maps remain authoritative, so deleted +models, profiles and list entries are not resurrected. Pre-OpenBitFun formats +and retired fields require the explicit migration tool. Configuration write +timestamps and informational build versions are excluded from the sync content +hash so a reload or unchanged save does not cause a redundant upload. Realtime voice credentials live in `app.voice_call` in the same persisted configuration and export/backup format as model settings. Account settings -apply preserves the controller's existing voice fields when an older payload -omits them, and an empty voice API key from an unconfigured host does not erase -a configured local key. Non-empty synced keys still replace the local key. -Explicit file imports can restore or clear a supplied key; local voice saves -and resets can also clear it. A valid whole-config import creates a raw +apply is authoritative here too: a supplied empty voice key clears the local +key, and absent voice fields receive defaults. Explicit file imports can +restore or clear a supplied key; local voice saves and resets can also clear +it. A valid whole-config import creates a raw `app_pre-import_*.json` backup before replacement, under the existing backup retention policy. Config reload and model-reference reconciliation serialize their reads and writes with local saves so stale snapshots cannot undo a diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 760a39b523..16e54edc10 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -1044,8 +1044,7 @@ mod tests { for account_sync in [false, true] { let (service, _dir) = test_service("import-explicit-deletions").await; // A raw backup intentionally omits these default values. Restoring - // it must still reset them, even though legacy missing fixed fields - // now retain their local values. + // it must still reset them to the declared defaults. let backup = service.create_backup().await.unwrap(); let raw_backup: serde_json::Value = serde_json::from_slice(&tokio::fs::read(backup).await.unwrap()).unwrap(); @@ -1186,6 +1185,141 @@ mod tests { assert!(changes.has_changed().unwrap()); } + #[tokio::test] + async fn account_settings_round_trip_covers_persisted_preference_groups() { + use serde_json::json; + + let (source, _source_dir) = test_service("sync-coverage-source").await; + let (target, target_dir) = test_service("sync-coverage-target").await; + let mut changes = source.subscribe_local_changes(); + let fixtures = vec![ + ("app.language", json!("en-US")), + ("app.prevent_sleep", json!(true)), + ("app.notifications.enabled", json!(false)), + ("app.logging.include_sensitive_diagnostics", json!(true)), + ("app.sidebar.width", json!(280)), + ("app.right_panel.width", json!(420)), + ("app.flow_chat.show_permission_mode_control", json!(false)), + ("app.hooks.project_hooks_enabled", json!(true)), + ( + "app.keybindings", + json!({"version": 1, "overrides": {"session.new": {"key": "n", "alt": true}}}), + ), + ( + "app.user_tool_groups", + json!({"version": 1, "groups": [{"id": "tools", "name": "Tools", "toolNames": ["Read"]}]}), + ), + ( + "app.user_skill_groups", + json!({"version": 1, "groups": [{"id": "skills", "name": "Skills", "skillKeys": ["user::fixture"]}]}), + ), + ( + "app.ai_experience.quick_actions", + json!([{"id": "fixture", "label": "Fixture", "prompt": "Check changes", "enabled": false}]), + ), + ( + "app.voice_call", + serde_json::to_value(realtime_voice_fixture()).unwrap(), + ), + ("editor.font_size", json!(18)), + ("editor.format_on_save", json!(true)), + ("terminal.font_size", json!(17)), + ("terminal.terminal_panel_position", json!("bottom")), + ("workspace.exclude_patterns", json!(["**/fixture-cache/**"])), + ( + "ai.models", + serde_json::to_value(vec![runtime_model("fixture-model", "fixture-model-key")]) + .unwrap(), + ), + ("ai.default_models.primary", json!("fixture-model")), + ( + "ai.agent_profiles", + json!({"fixture-profile": {"profile_id": "fixture-profile", "added_tools": ["Read"], "disabled_user_skills": ["user::fixture"]}}), + ), + ( + "ai.skill_settings.globally_disabled_user_skills", + json!(["user::fixture"]), + ), + ("ai.subagent_max_concurrency", json!(3)), + ("ai.stream_idle_timeout_secs", json!(123)), + ("ai.web_search.provider", json!("tavily")), + ("ai.allow_tool_json_repair", json!(false)), + ("tool_permissions.interaction.auto_approve_ask", json!(true)), + ("memories.use_memories", json!(true)), + ( + "mcp_servers", + json!({"mcpServers": {"fixture": {"command": "fixture", "env": {"TOKEN": "fixture-mcp-key"}}}}), + ), + ( + "acp_clients", + json!({"acpClients": {"fixture": {"command": "fixture"}}}), + ), + ( + "plugin", + json!([{"spec": "fixture-package@1.0.0", "options": {"enabled": true}}]), + ), + ("appearance.selection", json!("fixture-appearance")), + ( + "font", + json!({"uiSize": {"level": "custom", "customPx": 17}}), + ), + ]; + for (path, value) in &fixtures { + source.set_config(path, value).await.unwrap(); + assert!( + changes.has_changed().unwrap(), + "Missing upload signal: {path}" + ); + changes.borrow_and_update(); + } + // Use the serialized wire payload, not a typed in-memory shortcut. + let payload = serde_json::to_string(&source.export_config().await.unwrap()).unwrap(); + let target_changes = target.subscribe_local_changes(); + let result = target + .import_account_settings(serde_json::from_str(&payload).unwrap()) + .await + .unwrap(); + assert!(result.success, "{:?}", result.errors); + assert!( + !target_changes.has_changed().unwrap(), + "Cloud apply echoed an upload" + ); + drop(target); + let restarted = restart_test_service(&target_dir, "sync-coverage-target").await; + for (path, expected) in fixtures { + let actual: serde_json::Value = restarted.get_config(Some(path)).await.unwrap(); + // Agent profile defaults are materialized by serde; compare their + // complete source representation just like all other sections. + let source_value: serde_json::Value = source.get_config(Some(path)).await.unwrap(); + assert_eq!( + actual, source_value, + "Settings lost in sync/restart: {path}" + ); + if path != "ai.agent_profiles" && path != "ai.models" { + assert_eq!( + actual, expected, + "Setting was dropped before export: {path}" + ); + } + } + let mut source_config = + serde_json::to_value(source.export_config().await.unwrap().config).unwrap(); + let mut target_config = + serde_json::to_value(restarted.export_config().await.unwrap().config).unwrap(); + source_config + .as_object_mut() + .unwrap() + .remove("last_modified"); + target_config + .as_object_mut() + .unwrap() + .remove("last_modified"); + assert_eq!( + source_config, target_config, + "Full settings document must round-trip" + ); + } + #[tokio::test] async fn stale_cloud_pull_cannot_overwrite_a_save_made_during_the_fetch() { let name = "config-stale-cloud-pull"; diff --git a/src/crates/assembly/core/src/service/remote_connect/settings_sync.rs b/src/crates/assembly/core/src/service/remote_connect/settings_sync.rs index d3f0d5d26f..2bdc1eb8fb 100644 --- a/src/crates/assembly/core/src/service/remote_connect/settings_sync.rs +++ b/src/crates/assembly/core/src/service/remote_connect/settings_sync.rs @@ -191,20 +191,24 @@ fn canonicalize_json(value: serde_json::Value) -> serde_json::Value { .collect(), ) } - serde_json::Value::Array(values) => serde_json::Value::Array( - values.into_iter().map(canonicalize_json).collect(), - ), + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.into_iter().map(canonicalize_json).collect()) + } value => value, } } -/// Hash the canonical config content of a settings payload, ignoring volatile -/// wrapper fields such as `export_timestamp`. +/// Hash settings content, excluding export and document write metadata. A +/// cloud import updates the local document timestamp/build; those changes +/// must not turn the next unchanged save into another upload. fn settings_content_hash(payload: &str) -> Result { let export = config_export_value(payload)?; - let canonical = serde_json::to_string(&canonicalize_json(serde_json::to_value( - export.config, - )?)) + let mut config = serde_json::to_value(export.config)?; + if let Some(root) = config.as_object_mut() { + root.remove("last_modified"); + root.remove("version"); + } + let canonical = serde_json::to_string(&canonicalize_json(config)) .map_err(|e| anyhow!("serialize settings for hashing: {e}"))?; Ok(sync_state::content_hash(&canonical)) } @@ -564,6 +568,22 @@ mod tests { ); } + #[test] + fn content_hash_ignores_host_write_metadata_but_keeps_settings() { + let mut first = crate::service::config::GlobalConfig::default(); + first.last_modified = chrono::DateTime::from_timestamp_millis(1_000).unwrap(); + first.version = "older-build".to_string(); + let mut second = first.clone(); + second.last_modified = chrono::DateTime::from_timestamp_millis(2_000).unwrap(); + second.version = "newer-build".to_string(); + let hash = |config| { + settings_content_hash(&settings_payload(config, "fixture", "fixture")).unwrap() + }; + assert_eq!(hash(first.clone()), hash(second.clone())); + second.app.notifications.enabled = !first.app.notifications.enabled; + assert_ne!(hash(first), hash(second)); + } + #[test] fn content_hash_changes_with_config_content() { let a = crate::service::config::GlobalConfig::default(); @@ -594,4 +614,43 @@ mod tests { invalid.as_object_mut().unwrap().remove("format_version"); assert!(config_export_value(&invalid.to_string()).is_err()); } + + #[test] + fn older_supported_payload_defaults_missing_preferences_and_round_trips() { + let config = crate::service::config::GlobalConfig::default(); + let mut payload: serde_json::Value = serde_json::from_str(&settings_payload( + config, + "2026-01-01T00:00:00Z", + "older-build", + )) + .unwrap(); + let app = payload["config"]["app"].as_object_mut().unwrap(); + for field in [ + "voice_call", + "user_tool_groups", + "user_skill_groups", + "prevent_sleep", + ] { + app.remove(field); + } + payload["config"]["app"]["ai_experience"]["quick_actions"] = serde_json::json!([]); + payload["config"].as_object_mut().unwrap().remove("font"); + let export = config_export_value(&payload.to_string()).unwrap(); + assert!(export.config.app.voice_call.api_key.is_empty()); + assert!(export.config.app.user_tool_groups.groups.is_empty()); + assert!(export.config.app.user_skill_groups.groups.is_empty()); + assert!(!export.config.app.prevent_sleep); + assert!(export.config.font.is_none()); + assert!(export.config.app.ai_experience.quick_actions.is_empty()); + let reexported = serde_json::to_string(&export).unwrap(); + let reparsed = config_export_value(&reexported).unwrap(); + assert_eq!( + serde_json::to_value(export.config).unwrap(), + serde_json::to_value(reparsed.config).unwrap() + ); + assert_eq!( + settings_content_hash(&payload.to_string()).unwrap(), + settings_content_hash(&reexported).unwrap() + ); + } } diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index df8b5e9f3c..79b0d9fae8 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -157,9 +157,7 @@ const AppLayout: React.FC = ({ className = '' }) => { try { const raw = await configManager.getOptionalConfig('app.keybindings'); const overrides = parseStoredKeybindings(raw); - if (Object.keys(overrides).length > 0) { - shortcutManager.loadUserOverrides(overrides); - } + shortcutManager.loadUserOverrides(overrides); } catch { // No overrides stored yet — that's fine } @@ -167,9 +165,7 @@ const AppLayout: React.FC = ({ className = '' }) => { void load(); - const unsubscribe = configManager.onConfigChange((path) => { - if (path === 'app.keybindings') void load(); - }); + const unsubscribe = configManager.watch('app.keybindings', () => { void load(); }); return () => unsubscribe(); }, []); diff --git a/src/web-ui/src/infrastructure/account/settingsAppliedListener.test.ts b/src/web-ui/src/infrastructure/account/settingsAppliedListener.test.ts index 3897906bfa..afe17360a4 100644 --- a/src/web-ui/src/infrastructure/account/settingsAppliedListener.test.ts +++ b/src/web-ui/src/infrastructure/account/settingsAppliedListener.test.ts @@ -4,6 +4,11 @@ const mocks = vi.hoisted(() => ({ listen: vi.fn(), reloadConfig: vi.fn(), applyExternalReload: vi.fn(), + getConfig: vi.fn(), + reconcilePersistedState: vi.fn(), + reloadFromConfig: vi.fn(), + applyPersistedLanguage: vi.fn(), + warn: vi.fn(), })); vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ @@ -11,18 +16,28 @@ vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ })); vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ - configAPI: { reloadConfig: mocks.reloadConfig }, + configAPI: { reloadConfig: mocks.reloadConfig, getConfig: mocks.getConfig }, })); vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ configManager: { applyExternalReload: mocks.applyExternalReload }, })); +vi.mock('@/infrastructure/appearance', () => ({ + appearanceService: { reconcilePersistedState: mocks.reconcilePersistedState }, +})); +vi.mock('@/infrastructure/font-preference/core/FontPreferenceService', () => ({ + fontPreferenceService: { reloadFromConfig: mocks.reloadFromConfig }, +})); +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { applyPersistedLanguage: mocks.applyPersistedLanguage }, +})); + vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), - warn: vi.fn(), + warn: mocks.warn, error: vi.fn(), }), })); @@ -34,6 +49,10 @@ describe('settingsAppliedListener', () => { mocks.listen.mockReturnValue(() => undefined); mocks.reloadConfig.mockResolvedValue(undefined); mocks.applyExternalReload.mockResolvedValue(undefined); + mocks.getConfig.mockResolvedValue('en-US'); + mocks.reconcilePersistedState.mockResolvedValue(undefined); + mocks.reloadFromConfig.mockResolvedValue(undefined); + mocks.applyPersistedLanguage.mockResolvedValue(undefined); }); it('refreshes the config cache when the backend applies cloud settings', async () => { @@ -49,7 +68,25 @@ describe('settingsAppliedListener', () => { await vi.waitFor(() => { expect(mocks.reloadConfig).toHaveBeenCalledTimes(1); expect(mocks.applyExternalReload).toHaveBeenCalledTimes(1); + expect(mocks.reconcilePersistedState).toHaveBeenCalledTimes(1); + expect(mocks.reloadFromConfig).toHaveBeenCalledTimes(1); + expect(mocks.applyPersistedLanguage).toHaveBeenCalledWith('en-US'); }); + expect(mocks.getConfig).toHaveBeenCalledWith('app.language'); + }); + + it('refreshes the remaining preferences when one runtime fails', async () => { + mocks.reconcilePersistedState.mockRejectedValueOnce(new Error('skin unavailable')); + const { ensureSettingsAppliedListener } = await import('./settingsAppliedListener'); + ensureSettingsAppliedListener(); + mocks.listen.mock.calls[0][1]({ applied: true }); + await vi.waitFor(() => expect(mocks.warn).toHaveBeenCalledWith( + 'Failed to refresh cloud-synced preference', + expect.objectContaining({ preference: 'appearance' }), + )); + expect(mocks.applyExternalReload).toHaveBeenCalledTimes(1); + expect(mocks.reloadFromConfig).toHaveBeenCalledTimes(1); + expect(mocks.applyPersistedLanguage).toHaveBeenCalledWith('en-US'); }); it('registers the listener only once', async () => { @@ -59,4 +96,22 @@ describe('settingsAppliedListener', () => { expect(mocks.listen).toHaveBeenCalledTimes(1); }); + + it('finishes the current language apply before refreshing a newer settings event', async () => { + let finishLanguage!: () => void; + mocks.applyPersistedLanguage.mockReturnValueOnce(new Promise(resolve => { finishLanguage = resolve; })); + const { ensureSettingsAppliedListener } = await import('./settingsAppliedListener'); + ensureSettingsAppliedListener(); + const handler = mocks.listen.mock.calls[0][1]; + handler({ applied: true }); + await vi.waitFor(() => expect(mocks.applyPersistedLanguage).toHaveBeenCalledTimes(1)); + + mocks.getConfig.mockResolvedValue('zh-CN'); + handler({ applied: true }); + handler({ applied: true }); + expect(mocks.reloadConfig).toHaveBeenCalledTimes(1); + finishLanguage(); + await vi.waitFor(() => expect(mocks.applyPersistedLanguage).toHaveBeenLastCalledWith('zh-CN')); + expect(mocks.reloadConfig).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/web-ui/src/infrastructure/account/settingsAppliedListener.ts b/src/web-ui/src/infrastructure/account/settingsAppliedListener.ts index 05deb4229a..4883b512f7 100644 --- a/src/web-ui/src/infrastructure/account/settingsAppliedListener.ts +++ b/src/web-ui/src/infrastructure/account/settingsAppliedListener.ts @@ -9,16 +9,54 @@ import { api } from '@/infrastructure/api/service-api/ApiClient'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { appearanceService } from '@/infrastructure/appearance'; +import { fontPreferenceService } from '@/infrastructure/font-preference/core/FontPreferenceService'; +import { i18nService, type LocaleId } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('SettingsAppliedListener'); let settingsAppliedUnlisten: (() => void) | null = null; +let refreshInFlight = false; +let refreshRequested = false; + +async function requestSettingsRefresh(): Promise { + refreshRequested = true; + if (refreshInFlight) return; + refreshInFlight = true; + try { + // Reconcile another snapshot if events arrived while refreshing. Serial + // application prevents an older asynchronous locale change finishing last. + while (refreshRequested) { + refreshRequested = false; + await applyCloudSyncedSettings(); + } + } finally { + refreshInFlight = false; + } +} async function applyCloudSyncedSettings(): Promise { try { await configAPI.reloadConfig(); - await configManager.applyExternalReload(); + // These runtimes read through ConfigAPI directly, so cache invalidation + // alone cannot update their already-rendered state. Keep failures isolated: + // an unavailable skin must not prevent language, fonts or shortcuts loading. + const refreshes = [ + { name: 'config', run: () => configManager.applyExternalReload() }, + { name: 'appearance', run: () => appearanceService.reconcilePersistedState() }, + { name: 'font', run: () => fontPreferenceService.reloadFromConfig() }, + { name: 'language', run: async () => { + const locale = await configAPI.getConfig('app.language') as LocaleId; + await i18nService.applyPersistedLanguage(locale); + } }, + ]; + const results = await Promise.allSettled(refreshes.map(refresh => refresh.run())); + results.forEach((result, index) => { + if (result.status === 'rejected') { + log.warn('Failed to refresh cloud-synced preference', { preference: refreshes[index].name, error: result.reason }); + } + }); } catch (error) { log.warn('Failed to apply cloud-synced settings', error); } @@ -31,7 +69,7 @@ export function ensureSettingsAppliedListener(): void { } try { settingsAppliedUnlisten = api.listen('account://settings-applied', () => { - void applyCloudSyncedSettings(); + void requestSettingsRefresh(); }); } catch (error) { log.warn('Failed to register settings-applied listener', error); diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts index 96fa901af1..f2c64bdbfd 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts @@ -516,6 +516,7 @@ describe('ConfigManager', () => { }); await configManager.getConfig('editor'); await configManager.getConfig('app.window.mode'); + await configManager.getConfig('app.keybindings'); const changes: Array<{ path: string; oldValue: unknown; newValue: unknown }> = []; const unsubscribe = configManager.onConfigChange((path, oldValue, newValue) => { @@ -572,4 +573,19 @@ describe('ConfigManager', () => { unsubscribe(); }); + + it('notifies an uncached keybinding watcher when cloud sync removes bootstrap overrides', async () => { + const stored = { + version: 1, overrides: { 'session.new': { key: 'n', alt: true } }, + }; + globalThis.__OPENBITFUN_BOOTSTRAP_KEYBINDINGS__ = stored; + await expect(configManager.getOptionalConfig('app.keybindings')).resolves.toEqual(stored); + const watcher = vi.fn(); + const unwatch = configManager.watch('app.keybindings', watcher); + configApiMocks.getConfigs.mockResolvedValueOnce({ 'app.keybindings': undefined }); + await configManager.applyExternalReload(); + expect(watcher).toHaveBeenCalledTimes(1); + await expect(configManager.getOptionalConfig('app.keybindings')).resolves.toBeUndefined(); + unwatch(); + }); }); diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts index e0567d52a9..9ad186c9cf 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts @@ -804,7 +804,8 @@ class ConfigManagerImpl implements IConfigManager { /** * Re-read every cached/watched path after the backend applied an external * config change (e.g. account cloud sync), then notify listeners only for - * paths whose value actually changed so config-driven UI refreshes. + * paths whose value changed or had no cached baseline (including bootstrap + * and optional reads) so config-driven UI refreshes. */ async applyExternalReload(): Promise { const trackedPaths = new Set([ @@ -835,7 +836,7 @@ class ConfigManagerImpl implements IConfigManager { for (const path of trackedPaths) { const oldValue = previousValues.get(path); const newValue = this.configCache.get(path); - if (!configValuesEqual(oldValue, newValue)) { + if (!previousValues.has(path) || !configValuesEqual(oldValue, newValue)) { this.notifyConfigChange(path, oldValue, newValue); } } diff --git a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts index 2452649de8..9cecf607b2 100644 --- a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts +++ b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts @@ -62,6 +62,35 @@ describe('FontPreferenceService', () => { expect(service.getPreference().uiSize.customPx).toBe(16); }); + it('applies synced changes and deletions to the runtime and subscribers without uploading again', async () => { + const service = new FontPreferenceService(); + const changed = vi.fn(); + service.on('font:after-change', changed); + configMocks.getConfig.mockResolvedValue({ uiSize: { level: 'large' } }); + await service.reloadFromConfig(); + expect(service.getPreference()).toEqual({ uiSize: { level: 'large' } }); + expect(changed).toHaveBeenCalledTimes(1); + + await service.reloadFromConfig(); + expect(changed).toHaveBeenCalledTimes(1); + configMocks.getConfig.mockResolvedValue(undefined); + await service.reloadFromConfig(); + expect(service.getPreference()).toEqual(service.getDefaultPreference()); + expect(changed).toHaveBeenCalledTimes(2); + expect(configMocks.setConfig).not.toHaveBeenCalled(); + }); + + it('does not apply a stale synced font read over a newer local edit', async () => { + let finishRead!: (value: unknown) => void; + configMocks.getConfig.mockReturnValue(new Promise(resolve => { finishRead = resolve; })); + const service = new FontPreferenceService(); + const reload = service.reloadFromConfig(); + await service.setUiSize('custom', 18); + finishRead({ uiSize: { level: 'large' } }); + await reload; + expect(service.getPreference().uiSize).toEqual({ level: 'custom', customPx: 18 }); + }); + it.each([[12, -1], [20, 1]] as const)('keeps the %ipx boundary without redundant writes', async (customPx, delta) => { const service = new FontPreferenceService(); await service.setUiSize('custom', customPx); diff --git a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts index 425b95eed6..20dd5524e8 100644 --- a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts +++ b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts @@ -19,17 +19,15 @@ const CONFIG_KEY = 'font'; export class FontPreferenceService { private preference: FontPreference = { ...DEFAULT_FONT_PREFERENCE }; private listeners: Map> = new Map(); + private changeVersion = 0; // ---- Lifecycle ---- async initialize(): Promise { try { - const saved = await configAPI.getConfig(CONFIG_KEY, { skipRetryOnNotFound: true }) as FontPreference | undefined; - if (saved) { - this.preference = this.mergeWithDefaults(saved); - } - } catch { - // Config not found — use defaults + await this.reloadFromConfig(); + } catch (error) { + log.warn('Failed to load font preference', error); } this.applyPreference(this.preference); @@ -38,6 +36,21 @@ export class FontPreferenceService { }); } + /** Apply externally persisted preferences without writing them back to the host. */ + async reloadFromConfig(): Promise { + const version = ++this.changeVersion; + const saved = await configAPI.getConfig(CONFIG_KEY, { skipRetryOnNotFound: true }) as FontPreference | undefined; + if (version !== this.changeVersion) return; + const previous = this.preference; + const preference = this.mergeWithDefaults(saved ?? DEFAULT_FONT_PREFERENCE); + this.preference = preference; + this.applyPreference(preference); + if (previous.uiSize.level !== preference.uiSize.level + || previous.uiSize.customPx !== preference.uiSize.customPx) { + this.emit({ type: 'font:after-change', preference, previousPreference: previous, timestamp: Date.now() }); + } + } + // ---- Read ---- getPreference(): FontPreference { @@ -51,6 +64,7 @@ export class FontPreferenceService { // ---- Write ---- async setPreference(partial: Partial): Promise { + this.changeVersion += 1; const previous = { ...this.preference }; const merged = this.mergeWithDefaults({ ...this.preference, ...partial }); diff --git a/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts b/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts index 950d721ba2..7e50436f15 100644 --- a/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts +++ b/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts @@ -44,6 +44,21 @@ describe('ShortcutManager platform primary modifier', () => { vi.restoreAllMocks(); }); + it('restores the registered default when synced overrides are removed', () => { + setPlatform('Win32'); + const callback = vi.fn(); + shortcutManager.loadUserOverrides({ 'fixture.sync': { key: 'q', alt: true } }); + shortcutManager.register('fixture.sync', { key: 'n', ctrl: true, scope: 'app' }, callback); + dispatchScopedKey('app', { key: 'q', altKey: true }); + expect(callback).toHaveBeenCalledTimes(1); + + shortcutManager.loadUserOverrides({}); + dispatchScopedKey('app', { key: 'q', altKey: true }); + expect(callback).toHaveBeenCalledTimes(1); + dispatchScopedKey('app', { key: 'n', ctrlKey: true }); + expect(callback).toHaveBeenCalledTimes(2); + }); + it('maps logical Ctrl shortcuts to Command on macOS', () => { setPlatform('MacIntel'); const callback = vi.fn(); diff --git a/src/web-ui/src/infrastructure/services/ShortcutManager.ts b/src/web-ui/src/infrastructure/services/ShortcutManager.ts index b09a219ac1..b8ee127b31 100644 --- a/src/web-ui/src/infrastructure/services/ShortcutManager.ts +++ b/src/web-ui/src/infrastructure/services/ShortcutManager.ts @@ -138,7 +138,7 @@ export class ShortcutManager { /** * All registrations, keyed by shortcut id. */ - private registrations: Map = new Map(); + private registrations: Map = new Map(); /** * O(1) lookup index: mapKey → sorted registrations (descending priority). @@ -199,8 +199,9 @@ export class ShortcutManager { this.removeFromLookupMap(existing); } - const registration: ShortcutRegistration = { + const registration: ShortcutRegistration & { defaultConfig: ShortcutConfig } = { id, + defaultConfig: { ...config }, config: effectiveConfig, callback, description: options?.description, @@ -394,7 +395,7 @@ export class ShortcutManager { // Re-apply overrides to all existing registrations for (const [id, registration] of this.registrations.entries()) { - const newConfig = this.applyOverride(id, registration.config); + const newConfig = this.applyOverride(id, registration.defaultConfig); this.removeFromLookupMap(registration); registration.config = newConfig; this.addToLookupMap(registration);