From 8b9c4579c158b9d2682b13c300268197e0ad9d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 9 May 2026 08:11:26 -0400 Subject: [PATCH] Add active profile local data export --- .../src/services/servo_sidecar_tests.rs | 95 +++- crates/ely_app/src/shell/command_actions.rs | 37 +- .../internal_pages/privacy_data_inventory.rs | 79 ++- .../shell/internal_pages/privacy_security.rs | 7 +- crates/ely_app/src/shell/local_data_files.rs | 200 ++++++++ crates/ely_app/src/shell/mod.rs | 5 + .../src/shell/web_surface_live_site_tests.rs | 93 ++-- crates/ely_browser_core/src/error.rs | 3 + crates/ely_browser_core/src/lib.rs | 8 +- crates/ely_browser_core/src/navigation.rs | 5 +- crates/ely_browser_core/src/state.rs | 3 + crates/ely_browser_core/src/state/commands.rs | 10 + .../src/state/local_data_export_records.rs | 482 ++++++++++++++++++ .../src/state/local_data_exports.rs | 136 +++++ crates/ely_browser_core/src/state/privacy.rs | 4 +- crates/ely_browser_core/tests/privacy.rs | 77 ++- .../ely_servo_host/tests/sidecar/support.rs | 55 +- 17 files changed, 1209 insertions(+), 90 deletions(-) create mode 100644 crates/ely_app/src/shell/local_data_files.rs create mode 100644 crates/ely_browser_core/src/state/local_data_export_records.rs create mode 100644 crates/ely_browser_core/src/state/local_data_exports.rs diff --git a/crates/ely_app/src/services/servo_sidecar_tests.rs b/crates/ely_app/src/services/servo_sidecar_tests.rs index 4d85129..bfbe4e8 100644 --- a/crates/ely_app/src/services/servo_sidecar_tests.rs +++ b/crates/ely_app/src/services/servo_sidecar_tests.rs @@ -10,6 +10,8 @@ use crate::services::prd_live_sites::{ assert_prd_reference_urls_are_covered, }; +#[cfg(feature = "live-site-smoke")] +const LIVE_SITE_RENDER_ATTEMPTS: usize = 3; #[cfg(feature = "live-site-smoke")] const LIVE_SITE_WIDTH: u32 = 934; #[cfg(feature = "live-site-smoke")] @@ -147,23 +149,7 @@ fn prd_reference_live_site_cases_cover_prd_urls() -> Result<(), Box> fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box> { let client = ServoSidecarClient::new()?; for case in cases { - let request = SidecarSnapshotRequest::new( - UrlText::parse(case.url)?, - ProfileId::new(), - LIVE_SITE_WIDTH, - LIVE_SITE_HEIGHT, - ); - let snapshot = client.snapshot(request)?; - - assert_eq!(snapshot.width(), LIVE_SITE_WIDTH, "{}", case.url); - assert_eq!(snapshot.height(), LIVE_SITE_HEIGHT, "{}", case.url); - assert_render_state_is_open(snapshot.render_state(), case.url); - assert_loaded_url_contains(&snapshot, case.url)?; - assert_title_contains(&snapshot, case.title_fragment)?; - assert!(snapshot.non_white_pixel_count > 0, "{}", case.url); - assert!(snapshot.content_pixel_count >= MINIMUM_CONTENT_PIXELS, "{}", case.url); - assert!(snapshot.sample_hash > 0, "{}", case.url); - + let snapshot = render_live_site_snapshot(&client, case)?; let rgba_bytes = snapshot.into_rgba_bytes(); assert_eq!( rgba_bytes.len(), @@ -176,26 +162,81 @@ fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box } #[cfg(feature = "live-site-smoke")] -fn assert_render_state_is_open(state: &str, url: &str) { - assert!(matches!(state, "complete" | "loading"), "{url} state: {state}"); +fn render_live_site_snapshot( + client: &ServoSidecarClient, + case: &LiveSiteCase, +) -> Result> { + let mut last_error = String::new(); + + for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS { + let request = SidecarSnapshotRequest::new( + UrlText::parse(case.url)?, + ProfileId::new(), + LIVE_SITE_WIDTH, + LIVE_SITE_HEIGHT, + ); + + match client.snapshot(request) { + Ok(snapshot) => match validate_live_site_snapshot(&snapshot, case) { + Ok(()) => return Ok(snapshot), + Err(error) => last_error = error, + }, + Err(error) => last_error = error.to_string(), + } + + if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + + Err(last_error.into()) } #[cfg(feature = "live-site-smoke")] -fn assert_loaded_url_contains( +fn validate_live_site_snapshot( snapshot: &SidecarSnapshot, - fragment: &str, -) -> Result<(), Box> { + case: &LiveSiteCase, +) -> Result<(), String> { + require( + snapshot.width() == LIVE_SITE_WIDTH, + format!("{} width: {}", case.url, snapshot.width()), + )?; + require( + snapshot.height() == LIVE_SITE_HEIGHT, + format!("{} height: {}", case.url, snapshot.height()), + )?; + require_render_state_is_open(snapshot.render_state(), case.url)?; + require_loaded_url_contains(snapshot, case.url)?; + require_title_contains(snapshot, case.title_fragment)?; + require(snapshot.non_white_pixel_count > 0, case.url.to_string())?; + require( + snapshot.content_pixel_count >= MINIMUM_CONTENT_PIXELS, + format!("{} content pixels: {}", case.url, snapshot.content_pixel_count), + )?; + require(snapshot.sample_hash > 0, case.url.to_string()) +} + +#[cfg(feature = "live-site-smoke")] +fn require_render_state_is_open(state: &str, url: &str) -> Result<(), String> { + require(matches!(state, "complete" | "loading"), format!("{url} state: {state}")) +} + +#[cfg(feature = "live-site-smoke")] +fn require_loaded_url_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> { let loaded_url = snapshot.loaded_url().ok_or_else(|| format!("missing loaded URL for {fragment}"))?; - assert!(loaded_url.contains(fragment), "loaded_url: {loaded_url}"); - Ok(()) + require(loaded_url.contains(fragment), format!("loaded_url: {loaded_url}")) } #[cfg(feature = "live-site-smoke")] -fn assert_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), Box> { +fn require_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> { let title = snapshot.title().ok_or_else(|| format!("missing title containing {fragment}"))?; - assert!(title.contains(fragment), "title: {title}"); - Ok(()) + require(title.contains(fragment), format!("title: {title}")) +} + +#[cfg(feature = "live-site-smoke")] +fn require(condition: bool, message: String) -> Result<(), String> { + if condition { Ok(()) } else { Err(message) } } fn report_with_state(state: &str, profile_id: &ProfileId) -> SidecarReport { diff --git a/crates/ely_app/src/shell/command_actions.rs b/crates/ely_app/src/shell/command_actions.rs index 41f4ee0..7319d6f 100644 --- a/crates/ely_app/src/shell/command_actions.rs +++ b/crates/ely_app/src/shell/command_actions.rs @@ -23,6 +23,11 @@ enum BookmarkFileCommand { Import, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LocalDataFileCommand { + Export, +} + impl ElyShell { pub(super) fn handle_shell_command_intent( &mut self, @@ -60,6 +65,11 @@ impl ElyShell { Some(BookmarkFileCommand::Import) => self.choose_bookmark_import(window, cx), None => {} } + + match local_data_file_command(command) { + Some(LocalDataFileCommand::Export) => self.export_local_data(window, cx), + None => {} + } } } @@ -110,11 +120,22 @@ fn bookmark_file_command(command: &str) -> Option { } } +fn local_data_file_command(command: &str) -> Option { + match command.trim().to_ascii_lowercase().as_str() { + "export-local-data" + | "export local data" + | "export-privacy-data" + | "export privacy data" => Some(LocalDataFileCommand::Export), + _ => None, + } +} + #[cfg(test)] mod tests { use super::{ - BookmarkFileCommand, ShortcutFileCommand, SpaceFileCommand, bookmark_file_command, - install_plugin_from_file_command, shortcut_file_command, space_file_command, + BookmarkFileCommand, LocalDataFileCommand, ShortcutFileCommand, SpaceFileCommand, + bookmark_file_command, install_plugin_from_file_command, local_data_file_command, + shortcut_file_command, space_file_command, }; #[test] @@ -160,4 +181,16 @@ mod tests { assert_eq!(bookmark_file_command("export-bookmarks"), Some(BookmarkFileCommand::Export)); assert_eq!(bookmark_file_command("import bookmarks"), Some(BookmarkFileCommand::Import)); } + + #[test] + fn local_data_file_command_matches_export_aliases() { + assert_eq!( + local_data_file_command("export-local-data"), + Some(LocalDataFileCommand::Export) + ); + assert_eq!( + local_data_file_command("export privacy data"), + Some(LocalDataFileCommand::Export) + ); + } } diff --git a/crates/ely_app/src/shell/internal_pages/privacy_data_inventory.rs b/crates/ely_app/src/shell/internal_pages/privacy_data_inventory.rs index 90a95cb..b91fa03 100644 --- a/crates/ely_app/src/shell/internal_pages/privacy_data_inventory.rs +++ b/crates/ely_app/src/shell/internal_pages/privacy_data_inventory.rs @@ -1,9 +1,20 @@ use ely_browser_core::{BrowserSnapshot, LocalDataInventory}; use ely_design_system::colors; -use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, rgb}; -use gpui_component::{IconName, StyledExt}; +use gpui::prelude::FluentBuilder; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, rgb}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, +}; -pub(super) fn render_local_data_inventory(snapshot: &BrowserSnapshot) -> AnyElement { +use super::ElyShell; + +pub(super) fn render_local_data_inventory( + snapshot: &BrowserSnapshot, + notice: Option<&str>, + error: Option<&str>, + cx: &mut Context, +) -> AnyElement { let inventory = snapshot.local_data_inventory; div() @@ -16,7 +27,8 @@ pub(super) fn render_local_data_inventory(snapshot: &BrowserSnapshot) -> AnyElem .flex() .flex_col() .gap_3() - .child(render_inventory_header(snapshot, inventory)) + .child(render_inventory_header(snapshot, inventory, cx)) + .when_some(file_message(notice, error), |this, message| this.child(message)) .child(render_inventory_rows(inventory)) .into_any_element() } @@ -24,6 +36,7 @@ pub(super) fn render_local_data_inventory(snapshot: &BrowserSnapshot) -> AnyElem fn render_inventory_header( snapshot: &BrowserSnapshot, inventory: LocalDataInventory, + cx: &mut Context, ) -> AnyElement { div() .flex() @@ -33,6 +46,7 @@ fn render_inventory_header( .child( div() .min_w_0() + .flex_1() .flex() .items_center() .gap_3() @@ -61,20 +75,57 @@ fn render_inventory_header( .child( div() .flex_none() - .rounded_md() - .border_1() - .border_color(rgb(colors::HAIRLINE)) - .bg(rgb(colors::CANVAS)) - .px_3() - .py_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::INK)) - .child(format!("{} items", inventory.total_items())), + .flex() + .items_center() + .gap_2() + .child( + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS)) + .px_3() + .py_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(format!("{} items", inventory.total_items())), + ) + .child( + Button::new("export-local-data") + .ghost() + .xsmall() + .icon(IconName::File) + .label("Export") + .tooltip("Export Local Data") + .on_click(cx.listener(|shell, _, window, cx| { + shell.export_local_data(window, cx); + })), + ), ) .into_any_element() } +fn file_message(notice: Option<&str>, error: Option<&str>) -> Option { + notice + .map(|message| render_file_message(message, colors::SUCCESS)) + .or_else(|| error.map(|message| render_file_message(message, colors::ERROR))) +} + +fn render_file_message(message: &str, color: u32) -> AnyElement { + div() + .rounded_md() + .border_1() + .border_color(rgb(color)) + .px_3() + .py_2() + .text_xs() + .font_semibold() + .text_color(rgb(color)) + .child(message.to_string()) + .into_any_element() +} + fn render_inventory_rows(inventory: LocalDataInventory) -> AnyElement { div() .flex() diff --git a/crates/ely_app/src/shell/internal_pages/privacy_security.rs b/crates/ely_app/src/shell/internal_pages/privacy_security.rs index 87bdb83..d8a4b39 100644 --- a/crates/ely_app/src/shell/internal_pages/privacy_security.rs +++ b/crates/ely_app/src/shell/internal_pages/privacy_security.rs @@ -32,7 +32,12 @@ impl ElyShell { .when(snapshot.active_profile_history_entry_count > 0, |this| { this.child(render_history_clear_controls(confirming_clear, cx)) }) - .child(render_local_data_inventory(snapshot)) + .child(render_local_data_inventory( + snapshot, + self.local_data_file_notice.as_deref(), + self.local_data_file_error.as_deref(), + cx, + )) .child(render_privacy_settings_rows(snapshot, cx)), ) } diff --git a/crates/ely_app/src/shell/local_data_files.rs b/crates/ely_app/src/shell/local_data_files.rs new file mode 100644 index 0000000..9876c60 --- /dev/null +++ b/crates/ely_app/src/shell/local_data_files.rs @@ -0,0 +1,200 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use directories::UserDirs; +use ely_browser_core::ELYDATA_FILE_EXTENSION; +use gpui::{Context, Window}; + +use super::{ElyShell, ShellState}; + +const PRIVACY_SECURITY_URL: &str = "ely://settings/privacy-security"; + +impl ElyShell { + pub(super) fn export_local_data(&mut self, window: &mut Window, cx: &mut Context) { + self.ensure_privacy_security_surface(window, cx); + self.clear_local_data_file_message(); + + let export = match &mut self.state { + ShellState::Ready(core) => { + let package_json = match core.export_local_data_package_json() { + Ok(package_json) => package_json, + Err(error) => { + self.set_local_data_file_error(error.to_string(), cx); + return; + } + }; + match core.snapshot() { + Ok(snapshot) => Ok((snapshot.active_profile_name, package_json)), + Err(error) => Err(error.to_string()), + } + } + ShellState::StartupError(message) => Err(message.clone()), + }; + + let (profile_name, package_json) = match export { + Ok(export) => export, + Err(error) => { + self.set_local_data_file_error(error, cx); + return; + } + }; + let directory = match default_export_directory() { + Ok(directory) => directory, + Err(error) => { + self.set_local_data_file_error(error, cx); + return; + } + }; + let suggested_name = local_data_export_filename(&profile_name); + let prompt = cx.prompt_for_new_path(&directory, Some(&suggested_name)); + + cx.spawn_in(window, async move |shell, window| { + let selected_path = match prompt.await { + Ok(Ok(path)) => path, + Ok(Err(error)) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_local_data_file_error(error.to_string(), cx); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_local_data_file_error(error.to_string(), cx); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + let result = window + .background_executor() + .spawn(async move { write_local_data_package(path, package_json) }) + .await; + _ = shell.update_in(window, |shell, _, cx| { + shell.handle_local_data_export_result(result, cx); + }); + }) + .detach(); + } + + fn ensure_privacy_security_surface(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_tab_matches_url(PRIVACY_SECURITY_URL) { + return; + } + + self.open_internal_tab(PRIVACY_SECURITY_URL, window, cx); + } + + fn clear_local_data_file_message(&mut self) { + self.local_data_file_error = None; + self.local_data_file_notice = None; + } + + fn set_local_data_file_error(&mut self, message: String, cx: &mut Context) { + self.local_data_file_error = Some(message); + self.local_data_file_notice = None; + cx.notify(); + } + + fn set_local_data_file_notice(&mut self, message: String, cx: &mut Context) { + self.local_data_file_notice = Some(message); + self.local_data_file_error = None; + cx.notify(); + } + + fn handle_local_data_export_result( + &mut self, + result: Result, + cx: &mut Context, + ) { + match result { + Ok(path) => self.set_local_data_file_notice(format!("Exported {}", path.display()), cx), + Err(error) => self.set_local_data_file_error(error, cx), + } + } +} + +fn default_export_directory() -> Result { + UserDirs::new() + .and_then(|dirs| dirs.document_dir().map(Path::to_path_buf)) + .ok_or_else(|| "Documents directory is unavailable.".to_string()) +} + +fn write_local_data_package(path: PathBuf, package_json: String) -> Result { + let path = normalize_export_path(path)?; + fs::write(&path, package_json) + .map_err(|error| format!("Unable to write {}: {error}", path.display()))?; + Ok(path) +} + +fn normalize_export_path(mut path: PathBuf) -> Result { + if path.extension().is_none() { + path.set_extension(ELYDATA_FILE_EXTENSION); + return Ok(path); + } + + if path_has_elydata_extension(&path) { + Ok(path) + } else { + Err("Export path must use .elydata extension.".to_string()) + } +} + +fn path_has_elydata_extension(path: &Path) -> bool { + path.extension().is_some_and(|extension| { + extension.to_string_lossy().eq_ignore_ascii_case(ELYDATA_FILE_EXTENSION) + }) +} + +fn local_data_export_filename(profile_name: &str) -> String { + let mut stem = String::new(); + let mut previous_separator = false; + + for ch in profile_name.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + stem.push(ch); + previous_separator = false; + } else if !previous_separator { + stem.push('-'); + previous_separator = true; + } + } + + let stem = stem.trim_matches('-'); + let stem = if stem.is_empty() { "local-data" } else { stem }; + format!("{stem}.{ELYDATA_FILE_EXTENSION}") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{local_data_export_filename, normalize_export_path}; + + #[test] + fn local_data_export_filename_sanitizes_profile_names() { + assert_eq!(local_data_export_filename("Default"), "Default.elydata"); + assert_eq!(local_data_export_filename("Work / Client"), "Work-Client.elydata"); + assert_eq!(local_data_export_filename(" "), "local-data.elydata"); + } + + #[test] + fn normalize_export_path_adds_elydata_extension() -> Result<(), String> { + let path = normalize_export_path(PathBuf::from("Default"))?; + + assert_eq!(path, PathBuf::from("Default.elydata")); + Ok(()) + } + + #[test] + fn normalize_export_path_rejects_other_extensions() { + let error = normalize_export_path(PathBuf::from("Default.json")); + + assert_eq!(error, Err("Export path must use .elydata extension.".to_string())); + } +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index e43ddd8..54c13f9 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -6,6 +6,7 @@ mod downloads; mod focus; mod history; mod internal_pages; +mod local_data_files; mod navigation; mod notes; mod plugins; @@ -74,6 +75,8 @@ pub struct ElyShell { bookmark_edit_error: Option, bookmark_file_error: Option, bookmark_file_notice: Option, + local_data_file_error: Option, + local_data_file_notice: Option, plugin_install_error: Option, pending_plugin_install: Option, pending_plugin_uninstall: Option, @@ -161,6 +164,8 @@ impl ElyShell { bookmark_edit_error: None, bookmark_file_error: None, bookmark_file_notice: None, + local_data_file_error: None, + local_data_file_notice: None, plugin_install_error: None, pending_plugin_install: None, pending_plugin_uninstall: None, diff --git a/crates/ely_app/src/shell/web_surface_live_site_tests.rs b/crates/ely_app/src/shell/web_surface_live_site_tests.rs index b61c996..44bb8bb 100644 --- a/crates/ely_app/src/shell/web_surface_live_site_tests.rs +++ b/crates/ely_app/src/shell/web_surface_live_site_tests.rs @@ -21,6 +21,7 @@ use super::WebSurfaceStore; const LIVE_SURFACE_WIDTH: u32 = 934; const LIVE_SURFACE_HEIGHT: u32 = 657; const MINIMUM_CONTENT_PIXELS: u64 = 1_000; +const LIVE_SITE_RENDER_ATTEMPTS: usize = 3; #[test] fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box> { @@ -38,12 +39,20 @@ fn web_surface_opens_and_renders_prd_reference_sites() -> Result<(), Box Result<(), Box> { - let mut store = WebSurfaceStore::new(); for case in cases { - let tab = web_tab(case.url)?; - let bounds = live_surface_bounds(); + let frame = render_web_surface_frame(case)?; + log_prd_frame("web-surface", &frame, case); + } + Ok(()) +} - assert!(store.record_viewport_size(tab.id(), bounds), "{}", case.url); +fn render_web_surface_frame(case: &LiveSiteCase) -> Result> { + let mut last_error = String::new(); + + for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS { + let mut store = WebSurfaceStore::new(); + let tab = web_tab(case.url)?; + assert!(store.record_viewport_size(tab.id(), live_surface_bounds()), "{}", case.url); let request = store .prepare_request(&tab, ProfileDataMode::Persistent) .ok_or_else(|| format!("missing web surface request for {}", case.url))?; @@ -57,32 +66,54 @@ fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box { + store.finish(tab_id, WebSurfaceState::Ready(frame.clone())); + let Some(WebSurfaceState::Ready(stored_frame)) = store.state(tab.id()) else { + return Err(format!("web surface state is not ready for {}", case.url).into()); + }; + validate_prd_frame(stored_frame, case)?; + return Ok(frame); + } + Err(error) => last_error = error, + } + + if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS { + std::thread::sleep(std::time::Duration::from_millis(250)); + } } - Ok(()) + + Err(last_error.into()) } -fn assert_prd_frame_is_ready(frame: &WebSurfaceFrame, case: &LiveSiteCase) { - assert_eq!( - frame.size(), - WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT }, - "{}", - case.url - ); - assert_eq!(frame.scroll_offset(), WebSurfaceScrollOffset::default(), "{}", case.url); - assert_render_state_is_open(frame.render_state(), case.url); - assert!(frame.url_label().contains(normalized_url(case.url)), "{}", frame.url_label()); - assert!(frame.title_label().contains(case.title_fragment), "{}", frame.title_label()); - assert_eq!(frame.detail_label(), format!("{} 934x657", frame.render_state()), "{}", case.url); - assert!(frame.non_white_pixel_count() > 0, "{}", case.url); - assert!(frame.content_pixel_count() >= MINIMUM_CONTENT_PIXELS, "{}", case.url); - assert!(frame.sample_hash() > 0, "{}", case.url); +fn validate_prd_frame(frame: &WebSurfaceFrame, case: &LiveSiteCase) -> Result<(), String> { + require( + frame.size() == WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT }, + format!("{} size: {:?}", case.url, frame.size()), + )?; + require( + frame.scroll_offset() == WebSurfaceScrollOffset::default(), + format!("{} scroll: {:?}", case.url, frame.scroll_offset()), + )?; + require_render_state_is_open(frame.render_state(), case.url)?; + require( + frame.url_label().contains(normalized_url(case.url)), + format!("url: {}", frame.url_label()), + )?; + require( + frame.title_label().contains(case.title_fragment), + format!("title: {}", frame.title_label()), + )?; + require( + frame.detail_label() == format!("{} 934x657", frame.render_state()), + format!("{} detail: {}", case.url, frame.detail_label()), + )?; + require(frame.non_white_pixel_count() > 0, case.url.to_string())?; + require( + frame.content_pixel_count() >= MINIMUM_CONTENT_PIXELS, + format!("{} content pixels: {}", case.url, frame.content_pixel_count()), + )?; + require(frame.sample_hash() > 0, case.url.to_string()) } fn log_prd_frame(label: &str, frame: &WebSurfaceFrame, case: &LiveSiteCase) { @@ -100,8 +131,12 @@ fn log_prd_frame(label: &str, frame: &WebSurfaceFrame, case: &LiveSiteCase) { ); } -fn assert_render_state_is_open(state: &str, url: &str) { - assert!(matches!(state, "complete" | "loading"), "{url} state: {state}"); +fn require_render_state_is_open(state: &str, url: &str) -> Result<(), String> { + require(matches!(state, "complete" | "loading"), format!("{url} state: {state}")) +} + +fn require(condition: bool, message: String) -> Result<(), String> { + if condition { Ok(()) } else { Err(message) } } fn live_surface_bounds() -> Bounds { diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index 7057405..c4ac91a 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -24,6 +24,9 @@ pub enum CoreError { #[error("invalid .elybookmarks package: {reason}")] InvalidBookmarkPackage { reason: String }, + #[error("invalid .elydata package: {reason}")] + InvalidLocalDataPackage { reason: String }, + #[error("trashed space not found: {id}")] TrashedSpaceNotFound { id: SpaceId }, diff --git a/crates/ely_browser_core/src/lib.rs b/crates/ely_browser_core/src/lib.rs index a0b4a94..56b01f0 100644 --- a/crates/ely_browser_core/src/lib.rs +++ b/crates/ely_browser_core/src/lib.rs @@ -5,8 +5,8 @@ mod state; pub use error::CoreError; pub use state::{ BookmarkImportSummary, BrowserCore, BrowserSnapshot, ELYBOOKMARKS_FILE_EXTENSION, - ELYBOOKMARKS_SCHEMA_VERSION, ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION, - ElyBookmarksPackage, ElySpacePackage, InitialBrowserConfig, InstalledPlugin, - LocalDataInventory, PluginAuditAction, PluginAuditEvent, SiteDataClearance, - SpaceImportProfileMapping, TrashedSpace, + ELYBOOKMARKS_SCHEMA_VERSION, ELYDATA_FILE_EXTENSION, ELYDATA_SCHEMA_VERSION, + ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION, ElyBookmarksPackage, ElyLocalDataPackage, + ElySpacePackage, InitialBrowserConfig, InstalledPlugin, LocalDataInventory, PluginAuditAction, + PluginAuditEvent, SiteDataClearance, SpaceImportProfileMapping, TrashedSpace, }; diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 467dcb4..f41fddc 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -345,12 +345,15 @@ const SETTINGS_ROUTE_MATCHES: &[SettingsRouteMatch] = &[ "history recording", "diagnostics", "diagnostic reporting", + "export local data", + "export privacy data", ], search_terms: &[ "Privacy & Security", - "History recording, diagnostics, and profile-scoped privacy controls.", + "History recording, diagnostics, local data export, and profile-scoped privacy controls.", "profile privacy", "recording policy", + "local data export", ], }, SettingsRouteMatch { diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 90bef8e..423ebc2 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -17,6 +17,8 @@ mod commands; mod diagnostics; mod downloads; mod history; +mod local_data_export_records; +mod local_data_exports; mod notes; mod plugins; mod privacy; @@ -39,6 +41,7 @@ pub use bookmarks::{ BookmarkImportSummary, ELYBOOKMARKS_FILE_EXTENSION, ELYBOOKMARKS_SCHEMA_VERSION, ElyBookmarksPackage, }; +pub use local_data_exports::{ELYDATA_FILE_EXTENSION, ELYDATA_SCHEMA_VERSION, ElyLocalDataPackage}; pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent}; pub use privacy::LocalDataInventory; pub use site_data::SiteDataClearance; diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 5ce2627..df8f408 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -313,6 +313,16 @@ impl BrowserCore { self.open_tab(space_settings_url()?); Ok(true) } + "export-local-data" + | "export local data" + | "export-privacy-data" + | "export privacy data" => { + let Some(url) = settings_page_url("privacy")? else { + return Ok(false); + }; + self.open_tab(url); + Ok(true) + } "site-settings" | "open-site-settings" | "open site settings" => { let Some(url) = self.active_tab_site_settings_url()? else { return Ok(false); diff --git a/crates/ely_browser_core/src/state/local_data_export_records.rs b/crates/ely_browser_core/src/state/local_data_export_records.rs new file mode 100644 index 0000000..edddccd --- /dev/null +++ b/crates/ely_browser_core/src/state/local_data_export_records.rs @@ -0,0 +1,482 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use ely_domain::{ + ArchiveSource, ArchivedTab, BookmarkEntry, BrowserTab, DownloadChecksum, DownloadDestination, + DownloadEntry, DownloadSecurity, DownloadState, HistoryEntry, NoteEntry, NoteTarget, Profile, + ProfileKind, ReadingListEntry, ReadingProgress, SitePermissionAuditAction, + SitePermissionAuditEvent, SitePermissionEntry, TabFlags, TabState, +}; +use serde::{Deserialize, Serialize}; + +use super::LocalDataInventory; +use crate::CoreError; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ElyLocalDataPackage { + pub(super) version: u16, + pub(super) exported_at_unix_seconds: u64, + pub(super) profile: ElyLocalProfileRecord, + pub(super) inventory: LocalDataInventory, + pub(super) open_tabs: Vec, + pub(super) archived_tabs: Vec, + pub(super) bookmarks: Vec, + pub(super) notes: Vec, + pub(super) reading_list: Vec, + pub(super) history: Vec, + pub(super) downloads: Vec, + pub(super) site_permissions: Vec, + pub(super) site_permission_audit_events: Vec, +} + +impl ElyLocalDataPackage { + #[must_use] + pub fn version(&self) -> u16 { + self.version + } + + #[must_use] + pub fn profile_id(&self) -> &str { + &self.profile.id + } + + #[must_use] + pub fn profile_name(&self) -> &str { + &self.profile.name + } + + #[must_use] + pub fn inventory(&self) -> LocalDataInventory { + self.inventory + } + + pub(super) fn unix_seconds(time: SystemTime) -> Result { + time.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|error| CoreError::InvalidLocalDataPackage { reason: error.to_string() }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalProfileRecord { + id: String, + name: String, + kind: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalTabRecord { + id: String, + space_id: String, + space_name: String, + title: String, + url: String, + favicon_key: Option, + parent_tab_id: Option, + state: String, + flags: ElyLocalTabFlagsRecord, + group_id: Option, + split_id: Option, + sort_key: u64, + sync_enabled: bool, + created_at_unix_seconds: u64, + last_active_at_unix_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ElyLocalTabFlagsRecord { + pinned: bool, + favorite: bool, + muted: bool, + unread: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalArchivedTabRecord { + tab: ElyLocalTabRecord, + archived_at_unix_seconds: u64, + source: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalBookmarkRecord { + id: String, + space_id: String, + space_name: String, + collection_name: String, + title: String, + url: String, + tags: Vec, + note: Option, + thumbnail_key: Option, + added_at_unix_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalNoteRecord { + id: String, + space_id: String, + space_name: String, + target: ElyLocalNoteTargetRecord, + title: String, + source_url: String, + body: String, + created_at_unix_seconds: u64, + updated_at_unix_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ElyLocalNoteTargetRecord { + Url { url: String }, + Tab { tab_id: String }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalReadingListRecord { + id: String, + space_id: String, + space_name: String, + title: String, + source_url: String, + progress: ElyLocalReadingProgressRecord, + added_at_unix_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +enum ElyLocalReadingProgressRecord { + Unread, + InProgress { percent: u8 }, + Finished, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalHistoryRecord { + space_id: String, + space_name: String, + source_tab_id: String, + title: String, + url: String, + favicon_key: Option, + visited_at_unix_seconds: u64, + visit_count: u32, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalDownloadRecord { + id: String, + source_url: String, + file_name: String, + destination: ElyLocalDownloadDestinationRecord, + target_file_path: Option, + security: String, + state: String, + received_bytes: u64, + total_bytes: Option, + checksum: Option, + security_prompt_confirmed: bool, + started_at_unix_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ElyLocalDownloadDestinationRecord { + AskEveryTime, + FixedDirectory { path: String }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ElyLocalDownloadChecksumRecord { + algorithm: String, + value: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalSitePermissionRecord { + origin: String, + feature: String, + decision: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ElyLocalSitePermissionAuditRecord { + origin: String, + feature: String, + action: ElyLocalSitePermissionAuditActionRecord, + created_at_unix_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ElyLocalSitePermissionAuditActionRecord { + Set { decision: String }, + Revoked, +} + +impl ElyLocalProfileRecord { + pub(super) fn from_profile(profile: &Profile) -> Self { + Self { + id: profile.id().as_str().to_string(), + name: profile.name().to_string(), + kind: profile_kind(profile.kind()).to_string(), + } + } +} + +impl ElyLocalTabRecord { + pub(super) fn from_tab(tab: &BrowserTab, space_name: String) -> Result { + Ok(Self { + id: tab.id().as_str().to_string(), + space_id: tab.space_id().as_str().to_string(), + space_name, + title: tab.title().to_string(), + url: tab.url().as_str().to_string(), + favicon_key: tab.favicon_key().map(str::to_string), + parent_tab_id: tab.parent_tab_id().map(|id| id.as_str().to_string()), + state: tab_state(tab.state()).to_string(), + flags: ElyLocalTabFlagsRecord::from_flags(tab.flags()), + group_id: tab.group_id().map(|id| id.as_str().to_string()), + split_id: tab.split_id().map(|id| id.as_str().to_string()), + sort_key: tab.sort_key(), + sync_enabled: tab.sync_enabled(), + created_at_unix_seconds: ElyLocalDataPackage::unix_seconds(tab.created_at())?, + last_active_at_unix_seconds: ElyLocalDataPackage::unix_seconds(tab.last_active_at())?, + }) + } +} + +impl ElyLocalTabFlagsRecord { + fn from_flags(flags: &TabFlags) -> Self { + Self { + pinned: flags.pinned, + favorite: flags.favorite, + muted: flags.muted, + unread: flags.unread, + } + } +} + +impl ElyLocalArchivedTabRecord { + pub(super) fn from_archived_tab( + archived: &ArchivedTab, + tab: ElyLocalTabRecord, + ) -> Result { + Ok(Self { + tab, + archived_at_unix_seconds: ElyLocalDataPackage::unix_seconds(archived.archived_at())?, + source: archive_source(archived.source()).to_string(), + }) + } +} + +impl ElyLocalBookmarkRecord { + pub(super) fn from_bookmark( + bookmark: &BookmarkEntry, + space_name: String, + ) -> Result { + Ok(Self { + id: bookmark.id().as_str().to_string(), + space_id: bookmark.space_id().as_str().to_string(), + space_name, + collection_name: bookmark.collection_name().to_string(), + title: bookmark.title().to_string(), + url: bookmark.url().as_str().to_string(), + tags: bookmark.tags().to_vec(), + note: bookmark.note().map(str::to_string), + thumbnail_key: bookmark.thumbnail_key().map(str::to_string), + added_at_unix_seconds: ElyLocalDataPackage::unix_seconds(bookmark.added_at())?, + }) + } +} + +impl ElyLocalNoteRecord { + pub(super) fn from_note(note: &NoteEntry, space_name: String) -> Result { + Ok(Self { + id: note.id().as_str().to_string(), + space_id: note.space_id().as_str().to_string(), + space_name, + target: ElyLocalNoteTargetRecord::from_note_target(note.target()), + title: note.title().to_string(), + source_url: note.source_url().as_str().to_string(), + body: note.body().to_string(), + created_at_unix_seconds: ElyLocalDataPackage::unix_seconds(note.created_at())?, + updated_at_unix_seconds: ElyLocalDataPackage::unix_seconds(note.updated_at())?, + }) + } +} + +impl ElyLocalReadingListRecord { + pub(super) fn from_entry( + entry: &ReadingListEntry, + space_name: String, + ) -> Result { + Ok(Self { + id: entry.id().as_str().to_string(), + space_id: entry.space_id().as_str().to_string(), + space_name, + title: entry.title().to_string(), + source_url: entry.source_url().as_str().to_string(), + progress: ElyLocalReadingProgressRecord::from_progress(*entry.progress()), + added_at_unix_seconds: ElyLocalDataPackage::unix_seconds(entry.added_at())?, + }) + } +} + +impl ElyLocalHistoryRecord { + pub(super) fn from_entry(entry: &HistoryEntry, space_name: String) -> Result { + Ok(Self { + space_id: entry.space_id().as_str().to_string(), + space_name, + source_tab_id: entry.source_tab_id().as_str().to_string(), + title: entry.title().to_string(), + url: entry.url().as_str().to_string(), + favicon_key: entry.favicon_key().map(str::to_string), + visited_at_unix_seconds: ElyLocalDataPackage::unix_seconds(entry.visited_at())?, + visit_count: entry.visit_count(), + }) + } +} + +impl ElyLocalDownloadRecord { + pub(super) fn from_download(entry: &DownloadEntry) -> Result { + Ok(Self { + id: entry.id().as_str().to_string(), + source_url: entry.source_url().as_str().to_string(), + file_name: entry.file_name().to_string(), + destination: ElyLocalDownloadDestinationRecord::from_destination(entry.destination()), + target_file_path: entry.target_file_path().map(|path| path.display().to_string()), + security: download_security(entry.security()).to_string(), + state: download_state(entry.state()).to_string(), + received_bytes: entry.received_bytes(), + total_bytes: entry.total_bytes(), + checksum: entry.checksum().map(ElyLocalDownloadChecksumRecord::from_checksum), + security_prompt_confirmed: entry.security_prompt_confirmed(), + started_at_unix_seconds: ElyLocalDataPackage::unix_seconds(entry.started_at())?, + }) + } +} + +impl ElyLocalDownloadDestinationRecord { + fn from_destination(destination: &DownloadDestination) -> Self { + match destination { + DownloadDestination::AskEveryTime => Self::AskEveryTime, + DownloadDestination::FixedDirectory(path) => { + Self::FixedDirectory { path: path.display().to_string() } + } + } + } +} + +impl ElyLocalDownloadChecksumRecord { + fn from_checksum(checksum: &DownloadChecksum) -> Self { + Self { + algorithm: checksum.algorithm().as_str().to_string(), + value: checksum.value().to_string(), + } + } +} + +impl ElyLocalSitePermissionRecord { + pub(super) fn from_site_permission(entry: &SitePermissionEntry) -> Self { + Self { + origin: entry.origin().as_str().to_string(), + feature: entry.feature().as_str().to_string(), + decision: entry.decision().as_str().to_string(), + } + } +} + +impl ElyLocalSitePermissionAuditRecord { + pub(super) fn from_audit_event(event: &SitePermissionAuditEvent) -> Result { + Ok(Self { + origin: event.origin().as_str().to_string(), + feature: event.feature().as_str().to_string(), + action: ElyLocalSitePermissionAuditActionRecord::from_action(event.action()), + created_at_unix_seconds: ElyLocalDataPackage::unix_seconds(event.created_at())?, + }) + } +} + +impl ElyLocalNoteTargetRecord { + fn from_note_target(target: &NoteTarget) -> Self { + match target { + NoteTarget::Url(url) => Self::Url { url: url.as_str().to_string() }, + NoteTarget::Tab(tab_id) => Self::Tab { tab_id: tab_id.as_str().to_string() }, + } + } +} + +impl ElyLocalReadingProgressRecord { + fn from_progress(progress: ReadingProgress) -> Self { + match progress { + ReadingProgress::Unread => Self::Unread, + ReadingProgress::InProgress(percent) => Self::InProgress { percent: percent.value() }, + ReadingProgress::Finished => Self::Finished, + } + } +} + +impl ElyLocalSitePermissionAuditActionRecord { + fn from_action(action: &SitePermissionAuditAction) -> Self { + match action { + SitePermissionAuditAction::Set(decision) => { + Self::Set { decision: decision.as_str().to_string() } + } + SitePermissionAuditAction::Revoked => Self::Revoked, + } + } +} + +fn profile_kind(kind: &ProfileKind) -> &'static str { + match kind { + ProfileKind::Standard => "standard", + ProfileKind::Private => "private", + } +} + +fn tab_state(state: &TabState) -> &'static str { + match state { + TabState::Loading => "loading", + TabState::Ready => "ready", + TabState::Crashed => "crashed", + TabState::Discarded => "discarded", + TabState::Archived => "archived", + } +} + +fn archive_source(source: &ArchiveSource) -> &'static str { + match source { + ArchiveSource::ManualClose => "manual_close", + ArchiveSource::AutoArchive => "auto_archive", + } +} + +fn download_security(security: &DownloadSecurity) -> &'static str { + match security { + DownloadSecurity::Standard => "standard", + DownloadSecurity::DangerousExtension => "dangerous_extension", + } +} + +fn download_state(state: &DownloadState) -> &'static str { + match state { + DownloadState::InProgress => "in_progress", + DownloadState::Paused => "paused", + DownloadState::Completed => "completed", + DownloadState::Cancelled => "cancelled", + DownloadState::Failed => "failed", + } +} diff --git a/crates/ely_browser_core/src/state/local_data_exports.rs b/crates/ely_browser_core/src/state/local_data_exports.rs new file mode 100644 index 0000000..f73f840 --- /dev/null +++ b/crates/ely_browser_core/src/state/local_data_exports.rs @@ -0,0 +1,136 @@ +use std::time::SystemTime; + +use ely_domain::{ArchivedTab, BookmarkEntry, BrowserTab, HistoryEntry, NoteEntry, SpaceId}; + +use super::BrowserCore; +pub use super::local_data_export_records::ElyLocalDataPackage; +use super::local_data_export_records::{ + ElyLocalArchivedTabRecord, ElyLocalBookmarkRecord, ElyLocalDownloadRecord, + ElyLocalHistoryRecord, ElyLocalNoteRecord, ElyLocalProfileRecord, ElyLocalReadingListRecord, + ElyLocalSitePermissionAuditRecord, ElyLocalSitePermissionRecord, ElyLocalTabRecord, +}; +use crate::CoreError; + +pub const ELYDATA_SCHEMA_VERSION: u16 = 1; +pub const ELYDATA_FILE_EXTENSION: &str = "elydata"; + +impl BrowserCore { + pub fn export_local_data_package_json(&self) -> Result { + serde_json::to_string_pretty(&self.export_local_data_package()?) + .map_err(invalid_local_data_package) + } + + pub fn export_local_data_package(&self) -> Result { + let profile = self.active_profile()?; + let profile_id = profile.id(); + + Ok(ElyLocalDataPackage { + version: ELYDATA_SCHEMA_VERSION, + exported_at_unix_seconds: ElyLocalDataPackage::unix_seconds(SystemTime::now())?, + profile: ElyLocalProfileRecord::from_profile(profile), + inventory: self.active_profile_local_data_inventory(), + open_tabs: self + .tabs + .iter() + .filter(|tab| tab.profile_id() == profile_id) + .map(|tab| self.local_tab_record(tab)) + .collect::, _>>()?, + archived_tabs: self + .archived_tabs + .iter() + .filter(|archived| archived.tab().profile_id() == profile_id) + .map(|archived| self.local_archived_tab_record(archived)) + .collect::, _>>()?, + bookmarks: self + .bookmarks + .iter() + .filter(|bookmark| bookmark.profile_id() == profile_id) + .map(|bookmark| self.local_bookmark_record(bookmark)) + .collect::, _>>()?, + notes: self + .notes + .iter() + .filter(|note| note.profile_id() == profile_id) + .map(|note| self.local_note_record(note)) + .collect::, _>>()?, + reading_list: self + .reading_list + .iter() + .filter(|entry| entry.profile_id() == profile_id) + .map(|entry| { + self.space_name(entry.space_id()).and_then(|space_name| { + ElyLocalReadingListRecord::from_entry(entry, space_name) + }) + }) + .collect::, _>>()?, + history: self + .history_entries + .iter() + .filter(|entry| entry.profile_id() == profile_id) + .map(|entry| self.local_history_record(entry)) + .collect::, _>>()?, + downloads: self + .download_entries + .iter() + .filter(|entry| entry.profile_id() == profile_id) + .map(ElyLocalDownloadRecord::from_download) + .collect::, _>>()?, + site_permissions: self + .site_permissions + .iter() + .filter(|entry| entry.profile_id() == profile_id) + .map(ElyLocalSitePermissionRecord::from_site_permission) + .collect(), + site_permission_audit_events: self + .site_permission_audit_events + .iter() + .filter(|event| event.profile_id() == profile_id) + .map(ElyLocalSitePermissionAuditRecord::from_audit_event) + .collect::, _>>()?, + }) + } + + fn local_tab_record(&self, tab: &BrowserTab) -> Result { + ElyLocalTabRecord::from_tab(tab, self.space_name(tab.space_id())?) + } + + fn local_archived_tab_record( + &self, + archived: &ArchivedTab, + ) -> Result { + ElyLocalArchivedTabRecord::from_archived_tab( + archived, + self.local_tab_record(archived.tab())?, + ) + } + + fn local_bookmark_record( + &self, + bookmark: &BookmarkEntry, + ) -> Result { + ElyLocalBookmarkRecord::from_bookmark(bookmark, self.space_name(bookmark.space_id())?) + } + + fn local_note_record(&self, note: &NoteEntry) -> Result { + ElyLocalNoteRecord::from_note(note, self.space_name(note.space_id())?) + } + + fn local_history_record( + &self, + entry: &HistoryEntry, + ) -> Result { + ElyLocalHistoryRecord::from_entry(entry, self.space_name(entry.space_id())?) + } + + fn space_name(&self, space_id: &SpaceId) -> Result { + self.spaces + .iter() + .find(|space| space.id() == space_id) + .map(|space| space.name().to_string()) + .ok_or_else(|| CoreError::SpaceNotFound { id: space_id.clone() }) + } +} + +fn invalid_local_data_package(error: serde_json::Error) -> CoreError { + CoreError::InvalidLocalDataPackage { reason: error.to_string() } +} diff --git a/crates/ely_browser_core/src/state/privacy.rs b/crates/ely_browser_core/src/state/privacy.rs index 58e3b30..05a0527 100644 --- a/crates/ely_browser_core/src/state/privacy.rs +++ b/crates/ely_browser_core/src/state/privacy.rs @@ -1,8 +1,10 @@ use ely_domain::{DiagnosticsReportingPolicy, HistoryRecordingPolicy}; +use serde::{Deserialize, Serialize}; use super::BrowserCore; -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] pub struct LocalDataInventory { open_tabs: usize, archived_tabs: usize, diff --git a/crates/ely_browser_core/tests/privacy.rs b/crates/ely_browser_core/tests/privacy.rs index 57f1c1e..9eb27f6 100644 --- a/crates/ely_browser_core/tests/privacy.rs +++ b/crates/ely_browser_core/tests/privacy.rs @@ -1,7 +1,9 @@ use std::error::Error; -use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{ProfileKind, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText}; +use ely_browser_core::{BrowserCore, ELYDATA_SCHEMA_VERSION, InitialBrowserConfig}; +use ely_domain::{ + CommandIntent, ProfileKind, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText, +}; #[test] fn local_data_inventory_counts_active_profile_data() -> Result<(), Box> { @@ -66,3 +68,74 @@ fn local_data_inventory_counts_active_profile_data() -> Result<(), Box Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let default_profile_id = core.snapshot()?.active_profile_id; + + core.open_tab(UrlText::parse("https://example.com/research")?); + core.bookmark_active_tab()?; + core.save_active_url_note("profile note")?; + core.save_active_tab_to_reading_list()?; + core.record_download_started( + UrlText::parse("https://example.com/report.pdf")?, + "report.pdf", + Some(2048), + )?; + core.set_site_permission( + SiteOrigin::parse("https://example.com")?, + SitePermissionFeature::Camera, + SitePermissionDecision::AllowAlways, + )?; + let archived_tab_id = core.open_tab(UrlText::parse("https://servo.org/")?); + core.close_tab(&archived_tab_id)?; + + core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?; + core.open_tab(UrlText::parse("https://personal.example/research")?); + core.bookmark_active_tab()?; + + core.select_profile(&default_profile_id)?; + let package = core.export_local_data_package()?; + let package_json = core.export_local_data_package_json()?; + let document: serde_json::Value = serde_json::from_str(&package_json)?; + + assert_eq!(package.version(), ELYDATA_SCHEMA_VERSION); + assert_eq!(package.profile_id(), default_profile_id.as_str()); + assert_eq!(package.profile_name(), "Default"); + assert_eq!(package.inventory().total_items(), 11); + assert_eq!(document["version"], ELYDATA_SCHEMA_VERSION); + assert_eq!(document["profile"]["id"], default_profile_id.as_str()); + assert_eq!(array_len(&document, "open_tabs"), 2); + assert_eq!(array_len(&document, "archived_tabs"), 1); + assert_eq!(array_len(&document, "bookmarks"), 1); + assert_eq!(array_len(&document, "notes"), 1); + assert_eq!(array_len(&document, "reading_list"), 1); + assert_eq!(array_len(&document, "history"), 2); + assert_eq!(array_len(&document, "downloads"), 1); + assert_eq!(array_len(&document, "site_permissions"), 1); + assert_eq!(array_len(&document, "site_permission_audit_events"), 1); + assert_eq!(document["bookmarks"][0]["url"], "https://example.com/research"); + assert_eq!(document["downloads"][0]["file_name"], "report.pdf"); + assert_eq!(document["site_permissions"][0]["origin"], "https://example.com"); + Ok(()) +} + +#[test] +fn export_local_data_command_opens_privacy_security_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">export-local-data"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("export-local-data".to_string()))); + assert_eq!(active_tab.title(), "Privacy & Security Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/privacy-security"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + +fn array_len(document: &serde_json::Value, field: &str) -> usize { + document[field].as_array().map_or(0, Vec::len) +} diff --git a/crates/ely_servo_host/tests/sidecar/support.rs b/crates/ely_servo_host/tests/sidecar/support.rs index add3dd1..dc7271b 100644 --- a/crates/ely_servo_host/tests/sidecar/support.rs +++ b/crates/ely_servo_host/tests/sidecar/support.rs @@ -57,9 +57,37 @@ pub(super) fn snapshot_prd_site( scroll_offset.y )); + snapshot_prd_site_with_retry(case, &output_path, size, scroll_offset) +} + +fn snapshot_prd_site_with_retry( + case: &PrdSiteCompatibilityCase, + output_path: &Path, + size: FrameSize, + scroll_offset: ScrollOffset, +) -> Result> { + for attempt in 0..SIDECAR_MAX_ATTEMPTS { + match snapshot_prd_site_once(case, output_path, size, scroll_offset) { + Ok(report) => return Ok(report), + Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error), + Err(_) => remove_file_if_present(output_path)?, + } + + thread::sleep(SIDECAR_RETRY_INTERVAL); + } + + Err("sidecar PRD snapshot retry did not produce output".into()) +} + +fn snapshot_prd_site_once( + case: &PrdSiteCompatibilityCase, + output_path: &Path, + size: FrameSize, + scroll_offset: ScrollOffset, +) -> Result> { let output = run_sidecar_snapshot_with_retry( case.url, - &output_path, + output_path, size, scroll_offset, SnapshotInput::default(), @@ -96,14 +124,22 @@ pub(super) fn snapshot_prd_site( case.url ); assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", case.url); - assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4); + assert_eq!(std::fs::metadata(output_path)?.len(), size.width * size.height * 4); log_prd_report(&report, case, size)?; - std::fs::remove_file(&output_path)?; + std::fs::remove_file(output_path)?; Ok(report) } +fn remove_file_if_present(path: &Path) -> Result<(), Box> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + pub(super) fn snapshot_click_probe( click_point: Option, ) -> Result> { @@ -325,8 +361,7 @@ fn assert_report_text_equals( expected: &str, ) -> Result<(), Box> { let value = report_field_as_text(report, field)?; - assert_eq!(value, expected, "{field}"); - Ok(()) + if value == expected { Ok(()) } else { Err(format!("{field}: {value}").into()) } } fn assert_report_text_contains( @@ -335,14 +370,16 @@ fn assert_report_text_contains( fragment: &str, ) -> Result<(), Box> { let value = report_field_as_text(report, field)?; - assert!(value.contains(fragment), "{field}: {value}"); - Ok(()) + if value.contains(fragment) { Ok(()) } else { Err(format!("{field}: {value}").into()) } } fn assert_report_state_is_renderable(report: &serde_json::Value) -> Result<(), Box> { let state = report_field_as_text(report, "state")?; - assert!(matches!(state, "complete" | "loading"), "state: {state}"); - Ok(()) + if matches!(state, "complete" | "loading") { + Ok(()) + } else { + Err(format!("state: {state}").into()) + } } fn log_prd_report(