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;
|
||||
|
||||
Reference in New Issue
Block a user