feat(settings): persist scalar settings across launches

This commit is contained in:
2026-07-10 16:08:13 -04:00
parent 324b0f1a6e
commit d271670906
10 changed files with 119 additions and 12 deletions
+50 -3
View File
@@ -4,11 +4,14 @@
//! reduce what survives a restart, and Private-profile data never //! reduce what survives a restart, and Private-profile data never
//! reaches disk. //! reaches disk.
use ely_domain::{
AppearanceSettings, FavoriteLimit, HistoryRecordingPolicy, NewTabDestination, SearchEngine,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
CoreError, CoreError,
state::BrowserCore, state::{BrowserCore, SyncObjectPolicies},
sync_records::{ sync_records::{
BookmarkSyncRecord, HistorySyncRecord, NoteSyncRecord, PluginSettingsSyncRecord, BookmarkSyncRecord, HistorySyncRecord, NoteSyncRecord, PluginSettingsSyncRecord,
ProfileSyncRecord, ReadingListSyncRecord, SNAPSHOT_SCHEMA_REV, SitePermissionSyncRecord, ProfileSyncRecord, ReadingListSyncRecord, SNAPSHOT_SCHEMA_REV, SitePermissionSyncRecord,
@@ -22,12 +25,35 @@ pub(crate) const LOCAL_STATE_REV: u32 = 1;
struct LocalStateDocument { struct LocalStateDocument {
local_rev: u32, local_rev: u32,
body: SyncSnapshotBody, 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 { impl BrowserCore {
pub fn build_local_state_bytes(&self) -> Result<Vec<u8>, CoreError> { pub fn build_local_state_bytes(&self) -> Result<Vec<u8>, CoreError> {
let document = let document = LocalStateDocument {
LocalStateDocument { local_rev: LOCAL_STATE_REV, body: local_body_from_core(self) }; local_rev: LOCAL_STATE_REV,
body: local_body_from_core(self),
settings: self.local_settings(),
};
serde_json::to_vec(&document) serde_json::to_vec(&document)
.map_err(|error| CoreError::LocalState { reason: error.to_string() }) .map_err(|error| CoreError::LocalState { reason: error.to_string() })
} }
@@ -47,8 +73,29 @@ impl BrowserCore {
} }
self.apply_sync_snapshot_body(document.body) self.apply_sync_snapshot_body(document.body)
.map_err(|error| CoreError::LocalState { reason: error.to_string() })?; .map_err(|error| CoreError::LocalState { reason: error.to_string() })?;
self.apply_local_settings(document.settings);
Ok(()) 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 { fn local_body_from_core(core: &BrowserCore) -> SyncSnapshotBody {
+1 -1
View File
@@ -9,7 +9,7 @@ use ely_domain::{
}; };
use crate::{CoreError, navigation::tab_title}; use crate::{CoreError, navigation::tab_title};
use sync::SyncObjectPolicies; pub(crate) use sync::SyncObjectPolicies;
mod bookmarks; mod bookmarks;
mod commands; mod commands;
@@ -75,6 +75,10 @@ impl BrowserCore {
self.appearance = AppearanceSettings::default(); 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<String>) { pub fn set_command_query(&mut self, query: impl Into<String>) {
self.command_query = query.into(); self.command_query = query.into();
} }
+13 -2
View File
@@ -1,5 +1,7 @@
use std::time::{Duration, UNIX_EPOCH}; use std::time::{Duration, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use ely_domain::{ use ely_domain::{
ArchivePolicy, BookmarkEntry, BookmarkId, BrowserTab, ProfileId, Space, SpaceId, ArchivePolicy, BookmarkEntry, BookmarkId, BrowserTab, ProfileId, Space, SpaceId,
SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus, SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus,
@@ -11,8 +13,9 @@ use super::{BrowserCore, sync_context::SyncSnapshotApplyContext};
use crate::sync_engine::SyncSnapshotApplySummary; use crate::sync_engine::SyncSnapshotApplySummary;
use crate::sync_records::{BookmarkSyncRecord, SpaceSyncRecord, TabSyncRecord}; use crate::sync_records::{BookmarkSyncRecord, SpaceSyncRecord, TabSyncRecord};
#[derive(Clone, Debug)] #[derive(Clone, Copy, Debug, Deserialize, Serialize)]
pub(super) struct SyncObjectPolicies { #[serde(default)]
pub(crate) struct SyncObjectPolicies {
spaces: SyncObjectPolicy, spaces: SyncObjectPolicy,
tabs: SyncObjectPolicy, tabs: SyncObjectPolicy,
bookmarks: SyncObjectPolicy, bookmarks: SyncObjectPolicy,
@@ -84,6 +87,14 @@ impl BrowserCore {
self.sync_object_policies.get(kind) 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) { pub fn set_sync_connection_state(&mut self, state: SyncConnectionState) {
self.sync_connection_state = state; self.sync_connection_state = state;
} }
+32 -1
View File
@@ -1,12 +1,43 @@
use std::error::Error; use std::error::Error;
use ely_browser_core::{BrowserCore, InitialBrowserConfig}; 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<BrowserCore, Box<dyn Error>> { fn standard_core() -> Result<BrowserCore, Box<dyn Error>> {
Ok(BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?) Ok(BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?)
} }
#[test]
fn local_state_persists_scalar_settings() -> Result<(), Box<dyn Error>> {
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] #[test]
fn local_state_round_trips_across_a_restart() -> Result<(), Box<dyn Error>> { fn local_state_round_trips_across_a_restart() -> Result<(), Box<dyn Error>> {
let mut before = standard_core()?; let mut before = standard_core()?;
+4 -1
View File
@@ -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 { pub enum FavoriteLimit {
Six, Six,
#[default] #[default]
+4 -1
View File
@@ -1,6 +1,9 @@
use serde::{Deserialize, Serialize};
use crate::{DomainError, UrlText}; 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 { pub enum NewTabDestination {
#[default] #[default]
ElyNewTab, ElyNewTab,
+4 -1
View File
@@ -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 { pub enum HistoryRecordingPolicy {
#[default] #[default]
Record, Record,
+3 -1
View File
@@ -1,8 +1,10 @@
use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use crate::{DomainError, UrlText}; 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 { pub enum SearchEngine {
#[default] #[default]
DuckDuckGo, DuckDuckGo,
+4 -1
View File
@@ -1,3 +1,5 @@
use serde::{Deserialize, Serialize};
/// Connection lifecycle of the cloud sync client. /// Connection lifecycle of the cloud sync client.
/// ///
/// The previous variant set was a single `SignedOut`, which made the /// The previous variant set was a single `SignedOut`, which made the
@@ -35,7 +37,8 @@ pub enum SyncObjectKind {
PluginSettings, 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 { pub enum SyncObjectPolicy {
#[default] #[default]
Enabled, Enabled,