feat(core): persist and restore browser state across launches
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod http_downloads;
|
||||
pub(crate) mod iosurface_mach;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod iosurface_metal;
|
||||
pub(crate) mod local_state;
|
||||
pub mod plugin_package_store;
|
||||
pub mod plugin_packages;
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ mod focus;
|
||||
mod history;
|
||||
mod internal_pages;
|
||||
mod local_data_files;
|
||||
mod local_persistence;
|
||||
mod navigation;
|
||||
mod notes;
|
||||
mod plugins;
|
||||
@@ -130,8 +131,11 @@ pub struct ElyShell {
|
||||
pub(crate) auth_email_input: Entity<InputState>,
|
||||
pub(crate) auth_otp_input: Entity<InputState>,
|
||||
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,
|
||||
_translucency_subscription: Subscription,
|
||||
_quit_save_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
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
|
||||
.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),
|
||||
};
|
||||
|
||||
@@ -280,6 +291,8 @@ impl ElyShell {
|
||||
authenticated_operation_gate: AuthenticatedOperationGate::open(),
|
||||
auth_flow_barrier: None,
|
||||
sign_out_phases: std::collections::HashMap::new(),
|
||||
local_state_path,
|
||||
local_state_save_scheduled: false,
|
||||
sync_upload_scheduled: false,
|
||||
sync_upload_in_flight: false,
|
||||
sync_upload_pending: false,
|
||||
@@ -293,7 +306,9 @@ impl ElyShell {
|
||||
auth_flow_phase: auth::AuthFlowPhase::Idle,
|
||||
_command_subscription: command_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();
|
||||
if should_run_initial_sync {
|
||||
shell.trigger_cloud_sync_upload();
|
||||
|
||||
@@ -65,6 +65,7 @@ pub(crate) const fn sync_platform_label() -> &'static str {
|
||||
|
||||
impl ElyShell {
|
||||
pub(crate) fn schedule_cloud_sync_upload(&mut self, cx: &mut Context<Self>) {
|
||||
self.schedule_local_state_save(cx);
|
||||
self.reconcile_active_sync_profile();
|
||||
if !self.can_schedule_cloud_sync_upload() {
|
||||
return;
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
mod error;
|
||||
mod local_state;
|
||||
mod navigation;
|
||||
mod state;
|
||||
mod sync_engine;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user