From d271670906788def7c8e0ed60a0c6a21e451fdcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 10 Jul 2026 16:08:13 -0400 Subject: [PATCH] feat(settings): persist scalar settings across launches --- crates/ely_browser_core/src/local_state.rs | 53 +++++++++++++++++-- crates/ely_browser_core/src/state.rs | 2 +- crates/ely_browser_core/src/state/settings.rs | 4 ++ crates/ely_browser_core/src/state/sync.rs | 15 +++++- crates/ely_browser_core/tests/local_state.rs | 33 +++++++++++- crates/ely_domain/src/favorite.rs | 5 +- crates/ely_domain/src/new_tab.rs | 5 +- crates/ely_domain/src/privacy.rs | 5 +- crates/ely_domain/src/search.rs | 4 +- crates/ely_domain/src/sync.rs | 5 +- 10 files changed, 119 insertions(+), 12 deletions(-) diff --git a/crates/ely_browser_core/src/local_state.rs b/crates/ely_browser_core/src/local_state.rs index f2ea4d6..2967c97 100644 --- a/crates/ely_browser_core/src/local_state.rs +++ b/crates/ely_browser_core/src/local_state.rs @@ -4,11 +4,14 @@ //! reduce what survives a restart, and Private-profile data never //! reaches disk. +use ely_domain::{ + AppearanceSettings, FavoriteLimit, HistoryRecordingPolicy, NewTabDestination, SearchEngine, +}; use serde::{Deserialize, Serialize}; use crate::{ CoreError, - state::BrowserCore, + state::{BrowserCore, SyncObjectPolicies}, sync_records::{ BookmarkSyncRecord, HistorySyncRecord, NoteSyncRecord, PluginSettingsSyncRecord, ProfileSyncRecord, ReadingListSyncRecord, SNAPSHOT_SCHEMA_REV, SitePermissionSyncRecord, @@ -22,12 +25,35 @@ pub(crate) const LOCAL_STATE_REV: u32 = 1; struct LocalStateDocument { local_rev: u32, body: SyncSnapshotBody, + // Scalar settings persist locally; cloud sync of them is a separate, + // still-unbuilt concern, so they stay out of the sync wire schema. + // `default` keeps older rev-1 files (written before settings existed) + // loadable — their settings fall back to defaults. + #[serde(default)] + settings: LocalSettings, +} + +/// Scalar user settings that must survive a restart. Unlike the sync +/// snapshot body, these never leave the device yet. `default` on the +/// container fills any field a future revision has not written. +#[derive(Default, Serialize, Deserialize)] +#[serde(default)] +struct LocalSettings { + search_engine: SearchEngine, + new_tab_destination: NewTabDestination, + history_recording_policy: HistoryRecordingPolicy, + favorite_limit: FavoriteLimit, + appearance: AppearanceSettings, + sync_object_policies: SyncObjectPolicies, } impl BrowserCore { pub fn build_local_state_bytes(&self) -> Result, CoreError> { - let document = - LocalStateDocument { local_rev: LOCAL_STATE_REV, body: local_body_from_core(self) }; + let document = LocalStateDocument { + local_rev: LOCAL_STATE_REV, + body: local_body_from_core(self), + settings: self.local_settings(), + }; serde_json::to_vec(&document) .map_err(|error| CoreError::LocalState { reason: error.to_string() }) } @@ -47,8 +73,29 @@ impl BrowserCore { } self.apply_sync_snapshot_body(document.body) .map_err(|error| CoreError::LocalState { reason: error.to_string() })?; + self.apply_local_settings(document.settings); Ok(()) } + + fn local_settings(&self) -> LocalSettings { + LocalSettings { + search_engine: self.search_engine(), + new_tab_destination: self.new_tab_destination(), + history_recording_policy: self.history_recording_policy(), + favorite_limit: self.favorite_limit(), + appearance: self.appearance(), + sync_object_policies: self.sync_object_policies(), + } + } + + fn apply_local_settings(&mut self, settings: LocalSettings) { + self.set_search_engine(settings.search_engine); + self.set_new_tab_destination(settings.new_tab_destination); + self.set_history_recording_policy(settings.history_recording_policy); + self.set_favorite_limit(settings.favorite_limit); + self.set_appearance(settings.appearance); + self.set_sync_object_policies(settings.sync_object_policies); + } } fn local_body_from_core(core: &BrowserCore) -> SyncSnapshotBody { diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 8ab086a..91193b4 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -9,7 +9,7 @@ use ely_domain::{ }; use crate::{CoreError, navigation::tab_title}; -use sync::SyncObjectPolicies; +pub(crate) use sync::SyncObjectPolicies; mod bookmarks; mod commands; diff --git a/crates/ely_browser_core/src/state/settings.rs b/crates/ely_browser_core/src/state/settings.rs index 894bb01..8309635 100644 --- a/crates/ely_browser_core/src/state/settings.rs +++ b/crates/ely_browser_core/src/state/settings.rs @@ -75,6 +75,10 @@ impl BrowserCore { self.appearance = AppearanceSettings::default(); } + pub(crate) fn set_appearance(&mut self, appearance: AppearanceSettings) { + self.appearance = appearance; + } + pub fn set_command_query(&mut self, query: impl Into) { self.command_query = query.into(); } diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index 4586320..dfafba3 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -1,5 +1,7 @@ use std::time::{Duration, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; + use ely_domain::{ ArchivePolicy, BookmarkEntry, BookmarkId, BrowserTab, ProfileId, Space, SpaceId, SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus, @@ -11,8 +13,9 @@ use super::{BrowserCore, sync_context::SyncSnapshotApplyContext}; use crate::sync_engine::SyncSnapshotApplySummary; use crate::sync_records::{BookmarkSyncRecord, SpaceSyncRecord, TabSyncRecord}; -#[derive(Clone, Debug)] -pub(super) struct SyncObjectPolicies { +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(default)] +pub(crate) struct SyncObjectPolicies { spaces: SyncObjectPolicy, tabs: SyncObjectPolicy, bookmarks: SyncObjectPolicy, @@ -84,6 +87,14 @@ impl BrowserCore { self.sync_object_policies.get(kind) } + pub(crate) fn sync_object_policies(&self) -> SyncObjectPolicies { + self.sync_object_policies + } + + pub(crate) fn set_sync_object_policies(&mut self, policies: SyncObjectPolicies) { + self.sync_object_policies = policies; + } + pub fn set_sync_connection_state(&mut self, state: SyncConnectionState) { self.sync_connection_state = state; } diff --git a/crates/ely_browser_core/tests/local_state.rs b/crates/ely_browser_core/tests/local_state.rs index de4abf4..308485a 100644 --- a/crates/ely_browser_core/tests/local_state.rs +++ b/crates/ely_browser_core/tests/local_state.rs @@ -1,12 +1,43 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{SyncObjectKind, SyncObjectPolicy, UrlText}; +use ely_domain::{ + FavoriteLimit, HistoryRecordingPolicy, NewTabDestination, SearchEngine, SyncObjectKind, + SyncObjectPolicy, ThemeMode, UrlText, +}; fn standard_core() -> Result> { Ok(BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?) } +#[test] +fn local_state_persists_scalar_settings() -> Result<(), Box> { + let mut before = standard_core()?; + before.set_search_engine(SearchEngine::Google); + before.set_new_tab_destination(NewTabDestination::Bookmarks); + before.set_favorite_limit(FavoriteLimit::TwentyFour); + before.set_history_recording_policy(HistoryRecordingPolicy::Pause); + before.set_theme_mode(ThemeMode::Dark); + // Privacy-critical: a paused sync toggle must not silently re-enable. + before.set_sync_object_policy(SyncObjectKind::History, SyncObjectPolicy::Paused); + let bytes = before.build_local_state_bytes()?; + + let mut after = standard_core()?; + after.apply_local_state_bytes(&bytes)?; + + assert_eq!(after.search_engine(), SearchEngine::Google); + assert_eq!(after.new_tab_destination(), NewTabDestination::Bookmarks); + assert_eq!(after.favorite_limit(), FavoriteLimit::TwentyFour); + assert_eq!(after.history_recording_policy(), HistoryRecordingPolicy::Pause); + assert_eq!(after.appearance().theme_mode(), ThemeMode::Dark); + assert_eq!( + after.sync_object_policy(SyncObjectKind::History), + SyncObjectPolicy::Paused, + "a paused sync toggle must survive a restart", + ); + Ok(()) +} + #[test] fn local_state_round_trips_across_a_restart() -> Result<(), Box> { let mut before = standard_core()?; diff --git a/crates/ely_domain/src/favorite.rs b/crates/ely_domain/src/favorite.rs index aa9036e..836a621 100644 --- a/crates/ely_domain/src/favorite.rs +++ b/crates/ely_domain/src/favorite.rs @@ -1,4 +1,7 @@ -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] pub enum FavoriteLimit { Six, #[default] diff --git a/crates/ely_domain/src/new_tab.rs b/crates/ely_domain/src/new_tab.rs index 416c57e..c189f67 100644 --- a/crates/ely_domain/src/new_tab.rs +++ b/crates/ely_domain/src/new_tab.rs @@ -1,6 +1,9 @@ +use serde::{Deserialize, Serialize}; + use crate::{DomainError, UrlText}; -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] pub enum NewTabDestination { #[default] ElyNewTab, diff --git a/crates/ely_domain/src/privacy.rs b/crates/ely_domain/src/privacy.rs index 9b84ee6..f448cdd 100644 --- a/crates/ely_domain/src/privacy.rs +++ b/crates/ely_domain/src/privacy.rs @@ -1,4 +1,7 @@ -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] pub enum HistoryRecordingPolicy { #[default] Record, diff --git a/crates/ely_domain/src/search.rs b/crates/ely_domain/src/search.rs index c765913..a96d85f 100644 --- a/crates/ely_domain/src/search.rs +++ b/crates/ely_domain/src/search.rs @@ -1,8 +1,10 @@ +use serde::{Deserialize, Serialize}; use url::Url; use crate::{DomainError, UrlText}; -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] pub enum SearchEngine { #[default] DuckDuckGo, diff --git a/crates/ely_domain/src/sync.rs b/crates/ely_domain/src/sync.rs index 3908569..a020b09 100644 --- a/crates/ely_domain/src/sync.rs +++ b/crates/ely_domain/src/sync.rs @@ -1,3 +1,5 @@ +use serde::{Deserialize, Serialize}; + /// Connection lifecycle of the cloud sync client. /// /// The previous variant set was a single `SignedOut`, which made the @@ -35,7 +37,8 @@ pub enum SyncObjectKind { PluginSettings, } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] pub enum SyncObjectPolicy { #[default] Enabled,