feat(settings): persist scalar settings across launches
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()?;
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user