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
@@ -0,0 +1,81 @@
//! On-disk browser state for one standard profile:
//! `<profile-data-root>/<profile-id>/local-state.json`, published
//! atomically so a crash mid-write can never truncate the previous state.
use std::{
fs, io,
io::Write,
path::{Path, PathBuf},
};
use ely_domain::ProfileId;
const LOCAL_STATE_FILE: &str = "local-state.json";
pub(crate) fn local_state_path(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
profile_data_root.join(profile_id.as_str()).join(LOCAL_STATE_FILE)
}
pub(crate) fn save_local_state(path: &Path, bytes: &[u8]) -> io::Result<()> {
let directory = path
.parent()
.ok_or_else(|| io::Error::other("local state path has no parent directory"))?;
fs::create_dir_all(directory)?;
let temporary = path.with_extension("json.tmp");
{
let mut file = fs::File::create(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
}
fs::rename(&temporary, path)
}
/// Missing file is a normal first launch. Read or parse failures stay with
/// the caller so a broken restore is loud, quarantined, and recoverable.
pub(crate) fn load_local_state(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
pub(crate) fn quarantine_local_state(path: &Path) -> io::Result<PathBuf> {
let quarantined = path.with_extension("json.corrupt");
fs::rename(path, &quarantined)?;
Ok(quarantined)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn save_then_load_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let path = local_state_path(directory.path(), &profile_id);
assert_eq!(load_local_state(&path)?, None);
save_local_state(&path, b"{\"local_rev\":1}")?;
assert_eq!(load_local_state(&path)?, Some(b"{\"local_rev\":1}".to_vec()));
save_local_state(&path, b"{\"local_rev\":2}")?;
assert_eq!(load_local_state(&path)?, Some(b"{\"local_rev\":2}".to_vec()));
Ok(())
}
#[test]
fn quarantine_moves_the_corrupt_file_aside() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let path = local_state_path(directory.path(), &profile_id);
save_local_state(&path, b"broken")?;
let quarantined = quarantine_local_state(&path)?;
assert_eq!(load_local_state(&path)?, None);
assert_eq!(std::fs::read(quarantined)?, b"broken");
Ok(())
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod http_downloads;
pub(crate) mod iosurface_mach; pub(crate) mod iosurface_mach;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub(crate) mod iosurface_metal; pub(crate) mod iosurface_metal;
pub(crate) mod local_state;
pub mod plugin_package_store; pub mod plugin_package_store;
pub mod plugin_packages; pub mod plugin_packages;
pub mod plugin_signatures; pub mod plugin_signatures;
@@ -0,0 +1,164 @@
//! Shell glue for on-disk browser state: restore at construction, a
//! debounced save after every mutation that schedules a sync upload, and
//! a final synchronous save when the app quits.
use std::{
path::{Path, PathBuf},
time::Duration,
};
use ely_browser_core::BrowserCore;
use ely_domain::ProfileId;
use gpui::{Context, Subscription, Timer};
use super::{ElyShell, ShellState};
use crate::services::local_state::{
load_local_state, local_state_path, quarantine_local_state, save_local_state,
};
use crate::services::servo_profile_data::default_profile_data_root;
const LOCAL_STATE_SAVE_DEBOUNCE: Duration = Duration::from_secs(1);
pub(super) fn resolve_local_state_path(default_profile_id: Option<&ProfileId>) -> Option<PathBuf> {
// Harness tests build the real shell; persistence stays inert there so
// tests never read or write the developer's actual profile.
if cfg!(test) {
return None;
}
let profile_id = default_profile_id?;
let root = default_profile_data_root()?;
Some(local_state_path(&root, profile_id))
}
/// Restore persisted state into a freshly constructed core. A corrupt or
/// unreadable file is quarantined loudly instead of silently replaced, so
/// the previous state stays recoverable for diagnosis.
pub(super) fn restore_local_state(core: &mut BrowserCore, path: &Path) {
let bytes = match load_local_state(path) {
Ok(Some(bytes)) => bytes,
Ok(None) => return,
Err(error) => {
tracing::error!(
target: "ely::local_state",
error = %error,
path = %path.display(),
"local state read failed; starting from defaults",
);
return;
}
};
match core.apply_local_state_bytes(&bytes) {
Ok(()) => {
tracing::info!(
target: "ely::local_state",
path = %path.display(),
bytes = bytes.len(),
"local state restored",
);
}
Err(error) => {
tracing::error!(
target: "ely::local_state",
error = %error,
path = %path.display(),
"local state restore failed; quarantining the file",
);
match quarantine_local_state(path) {
Ok(quarantined) => tracing::warn!(
target: "ely::local_state",
path = %quarantined.display(),
"corrupt local state preserved for diagnosis",
),
Err(error) => tracing::error!(
target: "ely::local_state",
error = %error,
"local state quarantine failed",
),
}
}
}
}
pub(super) fn register_quit_save(cx: &mut Context<ElyShell>) -> Subscription {
cx.on_app_quit(|shell, _cx| {
shell.save_local_state_blocking();
async {}
})
}
impl ElyShell {
/// Every mutation that schedules a cloud sync upload also schedules a
/// local save; the debounce collapses bursts into one write.
pub(crate) fn schedule_local_state_save(&mut self, cx: &mut Context<Self>) {
if self.local_state_path.is_none() || self.local_state_save_scheduled {
return;
}
self.local_state_save_scheduled = true;
cx.spawn(async move |shell, cx| {
Timer::after(LOCAL_STATE_SAVE_DEBOUNCE).await;
let _ = shell.update(cx, |shell, _| {
shell.local_state_save_scheduled = false;
shell.save_local_state_in_background();
});
})
.detach();
}
fn save_local_state_in_background(&self) {
let Some((path, bytes)) = self.build_local_state_write() else {
return;
};
std::thread::Builder::new()
.name("ely-local-state-save".to_string())
.spawn(move || {
if let Err(error) = save_local_state(&path, &bytes) {
tracing::error!(
target: "ely::local_state",
error = %error,
path = %path.display(),
"local state save failed",
);
}
})
.map(|_| ())
.unwrap_or_else(|error| {
tracing::warn!(
target: "ely::local_state",
error = %error,
"spawn local state save failed",
);
});
}
pub(super) fn save_local_state_blocking(&self) {
let Some((path, bytes)) = self.build_local_state_write() else {
return;
};
if let Err(error) = save_local_state(&path, &bytes) {
tracing::error!(
target: "ely::local_state",
error = %error,
path = %path.display(),
"local state save on quit failed",
);
}
}
fn build_local_state_write(&self) -> Option<(PathBuf, Vec<u8>)> {
let path = self.local_state_path.clone()?;
let ShellState::Ready(core) = &self.state else {
return None;
};
match core.build_local_state_bytes() {
Ok(bytes) => Some((path, bytes)),
Err(error) => {
tracing::error!(
target: "ely::local_state",
error = %error,
"local state serialization failed",
);
None
}
}
}
}
+16 -1
View File
@@ -12,6 +12,7 @@ mod focus;
mod history; mod history;
mod internal_pages; mod internal_pages;
mod local_data_files; mod local_data_files;
mod local_persistence;
mod navigation; mod navigation;
mod notes; mod notes;
mod plugins; mod plugins;
@@ -130,8 +131,11 @@ pub struct ElyShell {
pub(crate) auth_email_input: Entity<InputState>, pub(crate) auth_email_input: Entity<InputState>,
pub(crate) auth_otp_input: Entity<InputState>, pub(crate) auth_otp_input: Entity<InputState>,
pub(crate) auth_flow_phase: auth::AuthFlowPhase, pub(crate) auth_flow_phase: auth::AuthFlowPhase,
pub(crate) local_state_path: Option<std::path::PathBuf>,
pub(crate) local_state_save_scheduled: bool,
_command_subscription: Subscription, _command_subscription: Subscription,
_translucency_subscription: Subscription, _translucency_subscription: Subscription,
_quit_save_subscription: Option<Subscription>,
} }
impl ElyShell { impl ElyShell {
@@ -225,10 +229,17 @@ impl ElyShell {
}, },
); );
let local_state_path =
local_persistence::resolve_local_state_path(default_profile_id.as_ref());
let state = match config let state = match config
.and_then(|config| BrowserCore::new(config).map_err(|error| error.to_string())) .and_then(|config| BrowserCore::new(config).map_err(|error| error.to_string()))
{ {
Ok(core) => ShellState::Ready(Box::new(core)), Ok(mut core) => {
if let Some(path) = &local_state_path {
local_persistence::restore_local_state(&mut core, path);
}
ShellState::Ready(Box::new(core))
}
Err(error) => ShellState::StartupError(error), Err(error) => ShellState::StartupError(error),
}; };
@@ -280,6 +291,8 @@ impl ElyShell {
authenticated_operation_gate: AuthenticatedOperationGate::open(), authenticated_operation_gate: AuthenticatedOperationGate::open(),
auth_flow_barrier: None, auth_flow_barrier: None,
sign_out_phases: std::collections::HashMap::new(), sign_out_phases: std::collections::HashMap::new(),
local_state_path,
local_state_save_scheduled: false,
sync_upload_scheduled: false, sync_upload_scheduled: false,
sync_upload_in_flight: false, sync_upload_in_flight: false,
sync_upload_pending: false, sync_upload_pending: false,
@@ -293,7 +306,9 @@ impl ElyShell {
auth_flow_phase: auth::AuthFlowPhase::Idle, auth_flow_phase: auth::AuthFlowPhase::Idle,
_command_subscription: command_subscription, _command_subscription: command_subscription,
_translucency_subscription: translucency_subscription, _translucency_subscription: translucency_subscription,
_quit_save_subscription: None,
}; };
shell._quit_save_subscription = Some(local_persistence::register_quit_save(cx));
let should_run_initial_sync = shell.probe_initial_sync_state(); let should_run_initial_sync = shell.probe_initial_sync_state();
if should_run_initial_sync { if should_run_initial_sync {
shell.trigger_cloud_sync_upload(); shell.trigger_cloud_sync_upload();
+1
View File
@@ -65,6 +65,7 @@ pub(crate) const fn sync_platform_label() -> &'static str {
impl ElyShell { impl ElyShell {
pub(crate) fn schedule_cloud_sync_upload(&mut self, cx: &mut Context<Self>) { pub(crate) fn schedule_cloud_sync_upload(&mut self, cx: &mut Context<Self>) {
self.schedule_local_state_save(cx);
self.reconcile_active_sync_profile(); self.reconcile_active_sync_profile();
if !self.can_schedule_cloud_sync_upload() { if !self.can_schedule_cloud_sync_upload() {
return; return;
+3
View File
@@ -27,6 +27,9 @@ pub enum CoreError {
#[error("invalid .elydata package: {reason}")] #[error("invalid .elydata package: {reason}")]
InvalidLocalDataPackage { reason: String }, InvalidLocalDataPackage { reason: String },
#[error("local state persistence failed: {reason}")]
LocalState { reason: String },
#[error("trashed space not found: {id}")] #[error("trashed space not found: {id}")]
TrashedSpaceNotFound { id: SpaceId }, TrashedSpaceNotFound { id: SpaceId },
+1
View File
@@ -1,4 +1,5 @@
mod error; mod error;
mod local_state;
mod navigation; mod navigation;
mod state; mod state;
mod sync_engine; 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 history;
mod local_data_export_records; mod local_data_export_records;
mod local_data_exports; mod local_data_exports;
mod local_visibility;
mod notes; mod notes;
mod plugins; mod plugins;
mod privacy; 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 { impl ProfileSyncRecord {
fn from_profile(profile: &Profile) -> Self { pub(crate) fn from_profile(profile: &Profile) -> Self {
Self { Self {
id: profile.id().as_str().to_string(), id: profile.id().as_str().to_string(),
name: profile.name().to_string(), name: profile.name().to_string(),
@@ -189,7 +189,7 @@ pub(crate) struct SpaceSyncRecord {
} }
impl SpaceSyncRecord { impl SpaceSyncRecord {
fn from_space(space: &Space) -> Self { pub(crate) fn from_space(space: &Space) -> Self {
Self { Self {
id: space.id().as_str().to_string(), id: space.id().as_str().to_string(),
name: space.name().to_string(), name: space.name().to_string(),
@@ -246,7 +246,7 @@ pub(crate) struct BookmarkSyncRecord {
} }
impl 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 { Self {
id: entry.id().as_str().to_string(), id: entry.id().as_str().to_string(),
title: entry.title().to_string(), title: entry.title().to_string(),
@@ -285,7 +285,7 @@ pub(crate) struct TabSyncRecord {
} }
impl 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 { Self {
id: entry.id().as_str().to_string(), id: entry.id().as_str().to_string(),
title: entry.title().to_string(), title: entry.title().to_string(),
@@ -320,7 +320,7 @@ pub(crate) struct NoteSyncRecord {
} }
impl 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 { Self {
id: entry.id().as_str().to_string(), id: entry.id().as_str().to_string(),
profile_id: entry.profile_id().as_str().to_string(), profile_id: entry.profile_id().as_str().to_string(),
@@ -366,7 +366,7 @@ pub(crate) struct ReadingListSyncRecord {
} }
impl 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 { Self {
id: entry.id().as_str().to_string(), id: entry.id().as_str().to_string(),
profile_id: entry.profile_id().as_str().to_string(), profile_id: entry.profile_id().as_str().to_string(),
@@ -407,7 +407,7 @@ pub(crate) struct SitePermissionSyncRecord {
} }
impl SitePermissionSyncRecord { impl SitePermissionSyncRecord {
fn from_entry(entry: &SitePermissionEntry) -> Self { pub(crate) fn from_entry(entry: &SitePermissionEntry) -> Self {
Self { Self {
profile_id: entry.profile_id().as_str().to_string(), profile_id: entry.profile_id().as_str().to_string(),
origin: entry.origin().as_str().to_string(), origin: entry.origin().as_str().to_string(),
@@ -433,7 +433,7 @@ pub(crate) struct HistorySyncRecord {
} }
impl 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 { Self {
profile_id: entry.profile_id().as_str().to_string(), profile_id: entry.profile_id().as_str().to_string(),
space_id: entry.space_id().as_str().to_string(), space_id: entry.space_id().as_str().to_string(),
@@ -457,7 +457,7 @@ pub(crate) struct PluginSettingsSyncRecord {
} }
impl PluginSettingsSyncRecord { impl PluginSettingsSyncRecord {
fn from_plugin(plugin: &InstalledPlugin) -> Self { pub(crate) fn from_plugin(plugin: &InstalledPlugin) -> Self {
Self { Self {
plugin_id: plugin.id().as_str().to_string(), plugin_id: plugin.id().as_str().to_string(),
checksum: plugin.manifest().checksum().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(())
}