feat(core): persist and restore browser state across launches

This commit is contained in:
2026-07-10 12:01:43 -04:00
parent cb1ff2e66d
commit bb16ed6fb3
12 changed files with 536 additions and 10 deletions
+3
View File
@@ -27,6 +27,9 @@ pub enum CoreError {
#[error("invalid .elydata package: {reason}")]
InvalidLocalDataPackage { reason: String },
#[error("local state persistence failed: {reason}")]
LocalState { reason: String },
#[error("trashed space not found: {id}")]
TrashedSpaceNotFound { id: SpaceId },
+1
View File
@@ -1,4 +1,5 @@
mod error;
mod local_state;
mod navigation;
mod state;
mod sync_engine;
+113
View File
@@ -0,0 +1,113 @@
//! Versioned on-disk browser state. The wire shape reuses the sync
//! snapshot records and appliers — one canonical serialization with two
//! consumers — but visibility is local: cloud sync policies never
//! reduce what survives a restart, and Private-profile data never
//! reaches disk.
use serde::{Deserialize, Serialize};
use crate::{
CoreError,
state::BrowserCore,
sync_records::{
BookmarkSyncRecord, HistorySyncRecord, NoteSyncRecord, PluginSettingsSyncRecord,
ProfileSyncRecord, ReadingListSyncRecord, SNAPSHOT_SCHEMA_REV, SitePermissionSyncRecord,
SpaceSyncRecord, SyncSnapshotBody, TabSyncRecord,
},
};
pub(crate) const LOCAL_STATE_REV: u32 = 1;
#[derive(Serialize, Deserialize)]
struct LocalStateDocument {
local_rev: u32,
body: SyncSnapshotBody,
}
impl BrowserCore {
pub fn build_local_state_bytes(&self) -> Result<Vec<u8>, CoreError> {
let document =
LocalStateDocument { local_rev: LOCAL_STATE_REV, body: local_body_from_core(self) };
serde_json::to_vec(&document)
.map_err(|error| CoreError::LocalState { reason: error.to_string() })
}
pub fn apply_local_state_bytes(&mut self, bytes: &[u8]) -> Result<(), CoreError> {
let document: LocalStateDocument = serde_json::from_slice(bytes)
.map_err(|error| CoreError::LocalState { reason: error.to_string() })?;
if document.local_rev != LOCAL_STATE_REV {
return Err(CoreError::LocalState {
reason: format!("unsupported local_rev {}", document.local_rev),
});
}
if document.body.schema_rev != SNAPSHOT_SCHEMA_REV {
return Err(CoreError::LocalState {
reason: format!("unsupported schema_rev {}", document.body.schema_rev),
});
}
self.apply_sync_snapshot_body(document.body)
.map_err(|error| CoreError::LocalState { reason: error.to_string() })?;
Ok(())
}
}
fn local_body_from_core(core: &BrowserCore) -> SyncSnapshotBody {
SyncSnapshotBody {
schema_rev: SNAPSHOT_SCHEMA_REV,
profiles: core
.visible_profiles_for_local()
.into_iter()
.map(ProfileSyncRecord::from_profile)
.collect(),
spaces: core
.visible_spaces_for_local()
.into_iter()
.map(SpaceSyncRecord::from_space)
.collect(),
bookmarks: core
.visible_bookmarks_for_local()
.into_iter()
.map(|entry| {
BookmarkSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id()))
})
.collect(),
tabs: core
.visible_tabs_for_local()
.into_iter()
.map(|entry| {
TabSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id()))
})
.collect(),
notes: core
.visible_notes_for_local()
.into_iter()
.map(|entry| {
NoteSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id()))
})
.collect(),
reading_list: core
.visible_reading_list_for_local()
.into_iter()
.map(|entry| {
ReadingListSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id()))
})
.collect(),
site_permissions: core
.visible_site_permissions_for_local()
.into_iter()
.map(SitePermissionSyncRecord::from_entry)
.collect(),
history: core
.visible_history_for_local()
.into_iter()
.map(|entry| {
HistorySyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id()))
})
.collect(),
plugin_settings: core
.visible_plugin_settings_for_local()
.into_iter()
.map(PluginSettingsSyncRecord::from_plugin)
.collect(),
}
}
+1
View File
@@ -18,6 +18,7 @@ mod downloads;
mod history;
mod local_data_export_records;
mod local_data_exports;
mod local_visibility;
mod notes;
mod plugins;
mod privacy;
@@ -0,0 +1,70 @@
//! Local persistence sees more than cloud sync: pausing cloud sync or
//! disabling a tab's sync must never reduce what survives a restart.
//! Only Private-profile data stays out of the on-disk state.
use ely_domain::{
BookmarkEntry, BrowserTab, HistoryEntry, NoteEntry, Profile, ProfileId, ProfileKind,
ReadingListEntry, SitePermissionEntry, Space,
};
use super::BrowserCore;
use crate::state::InstalledPlugin;
impl BrowserCore {
fn profile_persists_locally(&self, profile_id: &ProfileId) -> bool {
self.profiles
.iter()
.any(|profile| profile.id() == profile_id && profile.kind() != &ProfileKind::Private)
}
pub(crate) fn visible_profiles_for_local(&self) -> Vec<&Profile> {
self.profiles.iter().filter(|profile| profile.kind() != &ProfileKind::Private).collect()
}
pub(crate) fn visible_spaces_for_local(&self) -> Vec<&Space> {
self.spaces.iter().collect()
}
pub(crate) fn visible_tabs_for_local(&self) -> Vec<&BrowserTab> {
self.tabs.iter().filter(|tab| self.profile_persists_locally(tab.profile_id())).collect()
}
pub(crate) fn visible_bookmarks_for_local(&self) -> Vec<&BookmarkEntry> {
self.bookmarks
.iter()
.filter(|entry| self.profile_persists_locally(entry.profile_id()))
.collect()
}
pub(crate) fn visible_notes_for_local(&self) -> Vec<&NoteEntry> {
self.notes
.iter()
.filter(|entry| self.profile_persists_locally(entry.profile_id()))
.collect()
}
pub(crate) fn visible_reading_list_for_local(&self) -> Vec<&ReadingListEntry> {
self.reading_list
.iter()
.filter(|entry| self.profile_persists_locally(entry.profile_id()))
.collect()
}
pub(crate) fn visible_site_permissions_for_local(&self) -> Vec<&SitePermissionEntry> {
self.site_permissions
.iter()
.filter(|entry| self.profile_persists_locally(entry.profile_id()))
.collect()
}
pub(crate) fn visible_history_for_local(&self) -> Vec<&HistoryEntry> {
self.history_entries
.iter()
.filter(|entry| self.profile_persists_locally(entry.profile_id()))
.collect()
}
pub(crate) fn visible_plugin_settings_for_local(&self) -> Vec<&InstalledPlugin> {
self.installed_plugins.iter().collect()
}
}
+9 -9
View File
@@ -113,7 +113,7 @@ pub(crate) struct ProfileSyncRecord {
}
impl ProfileSyncRecord {
fn from_profile(profile: &Profile) -> Self {
pub(crate) fn from_profile(profile: &Profile) -> Self {
Self {
id: profile.id().as_str().to_string(),
name: profile.name().to_string(),
@@ -189,7 +189,7 @@ pub(crate) struct SpaceSyncRecord {
}
impl SpaceSyncRecord {
fn from_space(space: &Space) -> Self {
pub(crate) fn from_space(space: &Space) -> Self {
Self {
id: space.id().as_str().to_string(),
name: space.name().to_string(),
@@ -246,7 +246,7 @@ pub(crate) struct BookmarkSyncRecord {
}
impl BookmarkSyncRecord {
fn from_entry(entry: &BookmarkEntry, space_name: Option<String>) -> Self {
pub(crate) fn from_entry(entry: &BookmarkEntry, space_name: Option<String>) -> Self {
Self {
id: entry.id().as_str().to_string(),
title: entry.title().to_string(),
@@ -285,7 +285,7 @@ pub(crate) struct TabSyncRecord {
}
impl TabSyncRecord {
fn from_entry(entry: &BrowserTab, space_name: Option<String>) -> Self {
pub(crate) fn from_entry(entry: &BrowserTab, space_name: Option<String>) -> Self {
Self {
id: entry.id().as_str().to_string(),
title: entry.title().to_string(),
@@ -320,7 +320,7 @@ pub(crate) struct NoteSyncRecord {
}
impl NoteSyncRecord {
fn from_entry(entry: &NoteEntry, space_name: Option<String>) -> Self {
pub(crate) fn from_entry(entry: &NoteEntry, space_name: Option<String>) -> Self {
Self {
id: entry.id().as_str().to_string(),
profile_id: entry.profile_id().as_str().to_string(),
@@ -366,7 +366,7 @@ pub(crate) struct ReadingListSyncRecord {
}
impl ReadingListSyncRecord {
fn from_entry(entry: &ReadingListEntry, space_name: Option<String>) -> Self {
pub(crate) fn from_entry(entry: &ReadingListEntry, space_name: Option<String>) -> Self {
Self {
id: entry.id().as_str().to_string(),
profile_id: entry.profile_id().as_str().to_string(),
@@ -407,7 +407,7 @@ pub(crate) struct SitePermissionSyncRecord {
}
impl SitePermissionSyncRecord {
fn from_entry(entry: &SitePermissionEntry) -> Self {
pub(crate) fn from_entry(entry: &SitePermissionEntry) -> Self {
Self {
profile_id: entry.profile_id().as_str().to_string(),
origin: entry.origin().as_str().to_string(),
@@ -433,7 +433,7 @@ pub(crate) struct HistorySyncRecord {
}
impl HistorySyncRecord {
fn from_entry(entry: &HistoryEntry, space_name: Option<String>) -> Self {
pub(crate) fn from_entry(entry: &HistoryEntry, space_name: Option<String>) -> Self {
Self {
profile_id: entry.profile_id().as_str().to_string(),
space_id: entry.space_id().as_str().to_string(),
@@ -457,7 +457,7 @@ pub(crate) struct PluginSettingsSyncRecord {
}
impl PluginSettingsSyncRecord {
fn from_plugin(plugin: &InstalledPlugin) -> Self {
pub(crate) fn from_plugin(plugin: &InstalledPlugin) -> Self {
Self {
plugin_id: plugin.id().as_str().to_string(),
checksum: plugin.manifest().checksum().to_string(),
@@ -0,0 +1,76 @@
use std::error::Error;
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::{SyncObjectKind, SyncObjectPolicy, UrlText};
fn standard_core() -> Result<BrowserCore, Box<dyn Error>> {
Ok(BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?)
}
#[test]
fn local_state_round_trips_across_a_restart() -> Result<(), Box<dyn Error>> {
let mut before = standard_core()?;
before.open_tab(UrlText::parse("https://servo.org/")?);
before.bookmark_active_tab()?;
before.set_command_query(">new-space Research");
before.submit_command()?;
let bytes = before.build_local_state_bytes()?;
let mut after = standard_core()?;
after.apply_local_state_bytes(&bytes)?;
let snapshot = after.snapshot()?;
assert!(snapshot.tabs.iter().any(|tab| tab.url().as_str() == "https://servo.org/"));
assert!(snapshot.bookmarks.iter().any(|entry| entry.url().as_str() == "https://servo.org/"));
assert!(snapshot.spaces.iter().any(|space| space.name() == "Research"));
Ok(())
}
#[test]
fn paused_cloud_sync_does_not_reduce_local_state() -> Result<(), Box<dyn Error>> {
let mut core = standard_core()?;
core.open_tab(UrlText::parse("https://example.com/paused")?);
core.set_sync_object_policy(SyncObjectKind::Tabs, SyncObjectPolicy::Paused);
let bytes = core.build_local_state_bytes()?;
let mut restored = standard_core()?;
restored.apply_local_state_bytes(&bytes)?;
assert!(
restored
.snapshot()?
.tabs
.iter()
.any(|tab| tab.url().as_str() == "https://example.com/paused"),
"tabs must persist locally even when cloud sync is paused"
);
Ok(())
}
#[test]
fn private_profile_data_never_persists() -> Result<(), Box<dyn Error>> {
let mut core = standard_core()?;
core.set_command_query(">new-private-profile Vault");
core.submit_command()?;
core.set_command_query(">switch-profile Vault");
core.submit_command()?;
core.open_tab(UrlText::parse("https://example.com/secret")?);
let bytes = core.build_local_state_bytes()?;
let document = String::from_utf8(bytes.clone())?;
assert!(!document.contains("Vault"), "private profile must not persist");
assert!(!document.contains("example.com/secret"), "private tab must not persist");
let mut restored = standard_core()?;
restored.apply_local_state_bytes(&bytes)?;
Ok(())
}
#[test]
fn unknown_local_state_revisions_fail_closed() -> Result<(), Box<dyn Error>> {
let mut core = standard_core()?;
let result =
core.apply_local_state_bytes(br#"{"local_rev":99,"body":{"schema_rev":1,"bookmarks":[]}}"#);
assert!(result.is_err(), "unknown local_rev must be rejected");
Ok(())
}