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;
#[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
}
}
}
}
+16 -1
View File
@@ -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();
+1
View File
@@ -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;