Skip to content

Commit a1548dd

Browse files
authored
feat(browser): per-tab persistence + legacy state.json retirement
feat(browser): per-tab persistence + legacy state.json retirement
2 parents 9a63977 + d133182 commit a1548dd

14 files changed

Lines changed: 273 additions & 268 deletions

File tree

app/src/app_state.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ pub enum LeafContents {
130130
ExecutionProfileEditor,
131131
CodeReview(CodeReviewPaneSnapshot),
132132
AmbientAgent(AmbientAgentPaneSnapshot),
133+
/// An embedded browser pane. Carries the stable per-pane session id
134+
/// keying the WebKit data dir plus the open tabs / active tab state.
135+
Browser(BrowserPaneSnapshot),
133136
/// The in-app network log pane. Not persisted across restarts because the
134137
/// backing log is an in-memory ring buffer that starts empty on launch.
135138
NetworkLog,
@@ -173,12 +176,26 @@ impl LeafContents {
173176
| LeafContents::ExecutionProfileEditor
174177
| LeafContents::CodeReview(_)
175178
| LeafContents::AmbientAgent(_)
179+
| LeafContents::Browser(_)
176180
| LeafContents::Welcome { .. }
177181
| LeafContents::GetStarted => true,
178182
}
179183
}
180184
}
181185

186+
/// Snapshot of an embedded browser pane.
187+
#[derive(Clone, Debug, PartialEq)]
188+
pub struct BrowserPaneSnapshot {
189+
/// Stable per-pane UUID that keys the WebKit data dir at
190+
/// `<warp_home>/browser/data/<session_id>/`. Generated when the pane
191+
/// is first created (see `Workspace::open_browser_pane`); preserved
192+
/// across snapshot/restore so cookies/localStorage survive.
193+
pub session_id: String,
194+
/// Open intra-pane browser tabs, active index, etc. Serialized as
195+
/// JSON in the SQLite row.
196+
pub state: crate::pane_group::pane::browser::browser_model::BrowserState,
197+
}
198+
182199
/// Snapshot of an ambient agent pane.
183200
#[derive(Clone, Debug, PartialEq)]
184201
pub struct AmbientAgentPaneSnapshot {

app/src/browser/browser_view.rs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
// BrowserView is a non-wasm-only render surface; many of its helpers
2-
// (persistence import, internal model accessor) compile on wasm but
3-
// the WKWebView-driven code paths that consume them don't.
1+
// BrowserView is a non-wasm-only render surface; some helpers compile on
2+
// wasm even though the WKWebView-driven code paths that consume them don't.
43
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
54

65
use std::collections::HashMap;
@@ -47,7 +46,6 @@ use super::browser_model::{BrowserModel, TabId, DEFAULT_BROWSER_URL};
4746
#[cfg(not(target_family = "wasm"))]
4847
use super::data_dir;
4948
use super::find::FindState;
50-
use super::persistence;
5149
use super::url_input::{resolve_with_engine, Resolved};
5250
#[cfg(not(target_family = "wasm"))]
5351
use super::webview_host::SharedWebContext;
@@ -284,6 +282,11 @@ pub struct BrowserView {
284282
/// per wry's docs). `None` on wasm.
285283
#[cfg(not(target_family = "wasm"))]
286284
web_context: Option<SharedWebContext>,
285+
/// Stable per-pane UUID that keys this pane's WebKit data dir. Captured
286+
/// at construction (fresh `Uuid::v4` for new panes; restored from
287+
/// `BrowserPaneSnapshot` for rehydrated panes) so the data dir at
288+
/// `<warp_home>/browser/data/<session_id>/` survives app restarts.
289+
session_id: String,
287290
/// Per-tab UI mouse states keyed by stable [`TabId`] so they survive tab
288291
/// closures (which shift indices).
289292
tab_ui_states: HashMap<TabId, TabUiState>,
@@ -314,6 +317,11 @@ impl BrowserView {
314317
pub(crate) fn model(&self) -> &BrowserModel {
315318
&self.model
316319
}
320+
321+
/// Stable per-pane session id. Empty on wasm (no WebKit data store).
322+
pub(crate) fn session_id(&self) -> &str {
323+
&self.session_id
324+
}
317325
}
318326

319327
impl BrowserView {
@@ -322,6 +330,8 @@ impl BrowserView {
322330
#[cfg(not(target_family = "wasm"))] session_id: &str,
323331
ctx: &mut ViewContext<Self>,
324332
) -> Self {
333+
#[cfg(not(target_family = "wasm"))]
334+
let session_id = data_dir::normalize_session_id(session_id);
325335
let model = BrowserModel::new(initial_url.unwrap_or_default());
326336
let pane_configuration =
327337
ctx.add_model(|_ctx| PaneConfiguration::new(model.display_title()));
@@ -334,7 +344,7 @@ impl BrowserView {
334344
// WKWebsiteDataStore default store regardless (wry 0.38
335345
// limitation, see `data_dir`), but creating the directory keeps
336346
// the layout consistent for future macOS plumbing.
337-
let dir = data_dir::browser_data_dir(session_id);
347+
let dir = data_dir::browser_data_dir(&session_id);
338348
// Construct the WebContext even when dir is None — wry handles
339349
// the missing-dir case internally with its platform default.
340350
Some(Rc::new(RefCell::new(wry::WebContext::new(dir))))
@@ -414,6 +424,10 @@ impl BrowserView {
414424
event_tx,
415425
#[cfg(not(target_family = "wasm"))]
416426
web_context,
427+
#[cfg(not(target_family = "wasm"))]
428+
session_id,
429+
#[cfg(target_family = "wasm")]
430+
session_id: String::new(),
417431
tab_ui_states,
418432
workspace_tab_visible: true,
419433
back_button_mouse_state: MouseStateHandle::default(),
@@ -443,14 +457,15 @@ impl BrowserView {
443457
session_id: &str,
444458
ctx: &mut ViewContext<Self>,
445459
) -> Self {
460+
let session_id = data_dir::normalize_session_id(session_id);
446461
let model = BrowserModel::restore(state);
447462
let pane_configuration =
448463
ctx.add_model(|_ctx| PaneConfiguration::new(model.display_title()));
449464
let (event_tx, event_rx) = async_channel::unbounded::<NativeWebViewEvent>();
450465

451466
let web_context: Option<SharedWebContext> = {
452467
// Per-session data dir; see `Self::new` for the platform notes.
453-
let dir = data_dir::browser_data_dir(session_id);
468+
let dir = data_dir::browser_data_dir(&session_id);
454469
Some(Rc::new(RefCell::new(wry::WebContext::new(dir))))
455470
};
456471

@@ -531,6 +546,7 @@ impl BrowserView {
531546
webviews,
532547
event_tx,
533548
web_context,
549+
session_id,
534550
tab_ui_states,
535551
workspace_tab_visible: true,
536552
back_button_mouse_state: MouseStateHandle::default(),
@@ -935,14 +951,6 @@ impl BrowserView {
935951
});
936952
}
937953

938-
#[cfg(not(target_family = "wasm"))]
939-
fn persist_open_state(&self, open: bool) {
940-
let state = self.model.snapshot(open);
941-
if let Err(err) = persistence::save_to_default_dir(&state) {
942-
log::warn!("failed to persist browser state: {err}");
943-
}
944-
}
945-
946954
#[allow(clippy::too_many_arguments)]
947955
fn render_toolbar_button(
948956
&self,
@@ -1557,7 +1565,6 @@ impl BackingView for BrowserView {
15571565
for webview in &self.webviews {
15581566
webview.borrow_mut().detach_native();
15591567
}
1560-
self.persist_open_state(false);
15611568
}
15621569
ctx.emit(BrowserViewEvent::Pane(PaneEvent::Close));
15631570
}

app/src/browser/data_dir.rs

Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
11
//! Per-session WebKit data directory resolution.
22
//!
3-
//! Returns `<warp_home_config_dir>/browser/data/<session_id>/`. Each
4-
//! workspace tab owns its own browser pane and its own session id (the
5-
//! pane group's [`EntityId`]), so cookies, localStorage, IndexedDB, and
6-
//! cache are isolated per workspace tab. Sessions remain scoped to the
7-
//! CastCodes app (i.e., separate from any system browser).
8-
//!
9-
//! Session ids are runtime [`EntityId`]s — stable within a single app
10-
//! launch but not across restarts. That is acceptable for Layer B: a
11-
//! fresh app launch yields fresh tabs with fresh contexts. Persistence
12-
//! across restarts (Layer C) will need a stable per-tab UUID stored on
13-
//! `TabData`; when that lands, the same directory layout still applies
14-
//! and only the id source changes.
3+
//! Returns `<warp_home_config_dir>/browser/data/<session_id>/`, where
4+
//! `session_id` is a UUID v4 string persisted with the browser pane
5+
//! snapshot. Each browser pane owns its own session id, so cookies,
6+
//! localStorage, IndexedDB, and cache are isolated per pane while staying
7+
//! scoped to the CastCodes app (i.e., separate from any system browser).
158
//!
169
//! ## Platform reality check (wry 0.38)
1710
//!
@@ -29,28 +22,108 @@
2922
//! isolation works as expected.
3023
//! - **Windows / WebView2**: Honored via the same path.
3124
//!
32-
//! [`EntityId`]: warpui_core::EntityId
33-
3425
use std::path::PathBuf;
3526

27+
use uuid::Uuid;
28+
29+
fn parse_session_uuid(session_id: &str) -> Option<Uuid> {
30+
let uuid = Uuid::parse_str(session_id).ok()?;
31+
(uuid.get_version_num() == 4).then_some(uuid)
32+
}
33+
34+
/// Returns a canonical UUID session id, replacing invalid persisted values
35+
/// with a fresh UUID v4 before they can become path components.
36+
pub fn normalize_session_id(session_id: &str) -> String {
37+
match parse_session_uuid(session_id) {
38+
Some(uuid) => uuid.to_string(),
39+
None => {
40+
log::warn!("invalid browser session id; generated a replacement");
41+
Uuid::new_v4().to_string()
42+
}
43+
}
44+
}
45+
3646
/// Resolves the per-session WebKit data directory, creating it on disk
37-
/// if missing. `session_id` is typically the owning pane group's
38-
/// [`EntityId`] rendered via `Display` — any short, filesystem-safe
39-
/// identifier works.
47+
/// if missing. `session_id` must be a UUID string; callers restoring
48+
/// persisted state should call [`normalize_session_id`] before storing
49+
/// the id on live browser views.
4050
///
4151
/// Returns `None` if the CastCodes home dir is unavailable or the
42-
/// directory could not be created (filesystem error logged at WARN
43-
/// level).
44-
///
45-
/// [`EntityId`]: warpui_core::EntityId
52+
/// session id is invalid, or if the directory could not be created
53+
/// (filesystem error logged at WARN level).
4654
pub fn browser_data_dir(session_id: &str) -> Option<PathBuf> {
55+
let session_uuid = match parse_session_uuid(session_id) {
56+
Some(uuid) => uuid,
57+
None => {
58+
log::warn!("refusing browser data dir for invalid session id");
59+
return None;
60+
}
61+
};
62+
4763
let mut dir = warp_core::paths::warp_home_config_dir()?;
4864
dir.push("browser");
4965
dir.push("data");
50-
dir.push(session_id);
66+
dir.push(session_uuid.to_string());
5167
if let Err(err) = std::fs::create_dir_all(&dir) {
5268
log::warn!("failed to create browser data dir {dir:?}: {err}");
5369
return None;
5470
}
5571
Some(dir)
5672
}
73+
74+
/// Best-effort one-shot cleanup of the pre-Layer-C
75+
/// `<warp_home_config_dir>/browser/state.json` file. That file was the
76+
/// single-pane JSON store; the SQLite app-state path now persists every
77+
/// browser pane, so the legacy file is orphaned. Safe to call repeatedly
78+
/// — missing file is silent, other errors log at WARN.
79+
pub fn delete_legacy_state_file() {
80+
let Some(mut path) = warp_core::paths::warp_home_config_dir() else {
81+
return;
82+
};
83+
path.push("browser");
84+
path.push("state.json");
85+
match std::fs::remove_file(&path) {
86+
Ok(()) => log::info!("removed legacy browser state file {path:?}"),
87+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
88+
Err(err) => log::warn!("failed to remove legacy browser state file {path:?}: {err}"),
89+
}
90+
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use super::*;
95+
96+
#[test]
97+
fn normalize_session_id_preserves_valid_uuid() {
98+
let id = "5f2b3ce0-94f3-49ac-bab5-4f229a159f50";
99+
assert_eq!(normalize_session_id(id), id);
100+
}
101+
102+
#[test]
103+
fn normalize_session_id_canonicalizes_uuid() {
104+
assert_eq!(
105+
normalize_session_id("5F2B3CE0-94F3-49AC-BAB5-4F229A159F50"),
106+
"5f2b3ce0-94f3-49ac-bab5-4f229a159f50"
107+
);
108+
}
109+
110+
#[test]
111+
fn normalize_session_id_replaces_invalid_values() {
112+
let id = normalize_session_id("../state.json");
113+
assert_ne!(id, "../state.json");
114+
assert!(Uuid::parse_str(&id).is_ok());
115+
}
116+
117+
#[test]
118+
fn normalize_session_id_replaces_non_v4_uuid() {
119+
let id = normalize_session_id("550e8400-e29b-11d4-a716-446655440000");
120+
assert_ne!(id, "550e8400-e29b-11d4-a716-446655440000");
121+
assert_eq!(Uuid::parse_str(&id).unwrap().get_version_num(), 4);
122+
}
123+
124+
#[test]
125+
fn browser_data_dir_rejects_invalid_session_ids() {
126+
assert!(browser_data_dir("../state.json").is_none());
127+
assert!(browser_data_dir("550e8400-e29b-11d4-a716-446655440000").is_none());
128+
}
129+
}

app/src/browser/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ pub(crate) mod data_dir;
66
#[cfg(not(target_family = "wasm"))]
77
pub(crate) mod downloads;
88
pub(crate) mod find;
9-
pub(crate) mod persistence;
109
pub(crate) mod popup_policy;
1110
pub(crate) mod url_input;
1211
pub(crate) mod webview_host;

0 commit comments

Comments
 (0)