Add active profile local data export
This commit is contained in:
@@ -10,6 +10,8 @@ use crate::services::prd_live_sites::{
|
|||||||
assert_prd_reference_urls_are_covered,
|
assert_prd_reference_urls_are_covered,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "live-site-smoke")]
|
||||||
|
const LIVE_SITE_RENDER_ATTEMPTS: usize = 3;
|
||||||
#[cfg(feature = "live-site-smoke")]
|
#[cfg(feature = "live-site-smoke")]
|
||||||
const LIVE_SITE_WIDTH: u32 = 934;
|
const LIVE_SITE_WIDTH: u32 = 934;
|
||||||
#[cfg(feature = "live-site-smoke")]
|
#[cfg(feature = "live-site-smoke")]
|
||||||
@@ -147,23 +149,7 @@ fn prd_reference_live_site_cases_cover_prd_urls() -> Result<(), Box<dyn Error>>
|
|||||||
fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
|
fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
|
||||||
let client = ServoSidecarClient::new()?;
|
let client = ServoSidecarClient::new()?;
|
||||||
for case in cases {
|
for case in cases {
|
||||||
let request = SidecarSnapshotRequest::new(
|
let snapshot = render_live_site_snapshot(&client, case)?;
|
||||||
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 rgba_bytes = snapshot.into_rgba_bytes();
|
let rgba_bytes = snapshot.into_rgba_bytes();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rgba_bytes.len(),
|
rgba_bytes.len(),
|
||||||
@@ -176,26 +162,81 @@ fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "live-site-smoke")]
|
#[cfg(feature = "live-site-smoke")]
|
||||||
fn assert_render_state_is_open(state: &str, url: &str) {
|
fn render_live_site_snapshot(
|
||||||
assert!(matches!(state, "complete" | "loading"), "{url} state: {state}");
|
client: &ServoSidecarClient,
|
||||||
|
case: &LiveSiteCase,
|
||||||
|
) -> Result<SidecarSnapshot, Box<dyn Error>> {
|
||||||
|
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")]
|
#[cfg(feature = "live-site-smoke")]
|
||||||
fn assert_loaded_url_contains(
|
fn validate_live_site_snapshot(
|
||||||
snapshot: &SidecarSnapshot,
|
snapshot: &SidecarSnapshot,
|
||||||
fragment: &str,
|
case: &LiveSiteCase,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> 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 =
|
let loaded_url =
|
||||||
snapshot.loaded_url().ok_or_else(|| format!("missing loaded URL for {fragment}"))?;
|
snapshot.loaded_url().ok_or_else(|| format!("missing loaded URL for {fragment}"))?;
|
||||||
assert!(loaded_url.contains(fragment), "loaded_url: {loaded_url}");
|
require(loaded_url.contains(fragment), format!("loaded_url: {loaded_url}"))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "live-site-smoke")]
|
#[cfg(feature = "live-site-smoke")]
|
||||||
fn assert_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), Box<dyn Error>> {
|
fn require_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> {
|
||||||
let title = snapshot.title().ok_or_else(|| format!("missing title containing {fragment}"))?;
|
let title = snapshot.title().ok_or_else(|| format!("missing title containing {fragment}"))?;
|
||||||
assert!(title.contains(fragment), "title: {title}");
|
require(title.contains(fragment), format!("title: {title}"))
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
#[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 {
|
fn report_with_state(state: &str, profile_id: &ProfileId) -> SidecarReport {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ enum BookmarkFileCommand {
|
|||||||
Import,
|
Import,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum LocalDataFileCommand {
|
||||||
|
Export,
|
||||||
|
}
|
||||||
|
|
||||||
impl ElyShell {
|
impl ElyShell {
|
||||||
pub(super) fn handle_shell_command_intent(
|
pub(super) fn handle_shell_command_intent(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -60,6 +65,11 @@ impl ElyShell {
|
|||||||
Some(BookmarkFileCommand::Import) => self.choose_bookmark_import(window, cx),
|
Some(BookmarkFileCommand::Import) => self.choose_bookmark_import(window, cx),
|
||||||
None => {}
|
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<BookmarkFileCommand> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_data_file_command(command: &str) -> Option<LocalDataFileCommand> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BookmarkFileCommand, ShortcutFileCommand, SpaceFileCommand, bookmark_file_command,
|
BookmarkFileCommand, LocalDataFileCommand, ShortcutFileCommand, SpaceFileCommand,
|
||||||
install_plugin_from_file_command, shortcut_file_command, space_file_command,
|
bookmark_file_command, install_plugin_from_file_command, local_data_file_command,
|
||||||
|
shortcut_file_command, space_file_command,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -160,4 +181,16 @@ mod tests {
|
|||||||
assert_eq!(bookmark_file_command("export-bookmarks"), Some(BookmarkFileCommand::Export));
|
assert_eq!(bookmark_file_command("export-bookmarks"), Some(BookmarkFileCommand::Export));
|
||||||
assert_eq!(bookmark_file_command("import bookmarks"), Some(BookmarkFileCommand::Import));
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
use ely_browser_core::{BrowserSnapshot, LocalDataInventory};
|
use ely_browser_core::{BrowserSnapshot, LocalDataInventory};
|
||||||
use ely_design_system::colors;
|
use ely_design_system::colors;
|
||||||
use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, rgb};
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui_component::{IconName, StyledExt};
|
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<ElyShell>,
|
||||||
|
) -> AnyElement {
|
||||||
let inventory = snapshot.local_data_inventory;
|
let inventory = snapshot.local_data_inventory;
|
||||||
|
|
||||||
div()
|
div()
|
||||||
@@ -16,7 +27,8 @@ pub(super) fn render_local_data_inventory(snapshot: &BrowserSnapshot) -> AnyElem
|
|||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_3()
|
.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))
|
.child(render_inventory_rows(inventory))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -24,6 +36,7 @@ pub(super) fn render_local_data_inventory(snapshot: &BrowserSnapshot) -> AnyElem
|
|||||||
fn render_inventory_header(
|
fn render_inventory_header(
|
||||||
snapshot: &BrowserSnapshot,
|
snapshot: &BrowserSnapshot,
|
||||||
inventory: LocalDataInventory,
|
inventory: LocalDataInventory,
|
||||||
|
cx: &mut Context<ElyShell>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -33,6 +46,7 @@ fn render_inventory_header(
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
|
.flex_1()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
@@ -61,6 +75,11 @@ fn render_inventory_header(
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
.rounded_md()
|
.rounded_md()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(rgb(colors::HAIRLINE))
|
.border_color(rgb(colors::HAIRLINE))
|
||||||
@@ -72,6 +91,38 @@ fn render_inventory_header(
|
|||||||
.text_color(rgb(colors::INK))
|
.text_color(rgb(colors::INK))
|
||||||
.child(format!("{} items", inventory.total_items())),
|
.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<AnyElement> {
|
||||||
|
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()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ impl ElyShell {
|
|||||||
.when(snapshot.active_profile_history_entry_count > 0, |this| {
|
.when(snapshot.active_profile_history_entry_count > 0, |this| {
|
||||||
this.child(render_history_clear_controls(confirming_clear, cx))
|
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)),
|
.child(render_privacy_settings_rows(snapshot, cx)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>) {
|
||||||
|
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<Self>) {
|
||||||
|
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>) {
|
||||||
|
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>) {
|
||||||
|
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<PathBuf, String>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ mod downloads;
|
|||||||
mod focus;
|
mod focus;
|
||||||
mod history;
|
mod history;
|
||||||
mod internal_pages;
|
mod internal_pages;
|
||||||
|
mod local_data_files;
|
||||||
mod navigation;
|
mod navigation;
|
||||||
mod notes;
|
mod notes;
|
||||||
mod plugins;
|
mod plugins;
|
||||||
@@ -74,6 +75,8 @@ pub struct ElyShell {
|
|||||||
bookmark_edit_error: Option<String>,
|
bookmark_edit_error: Option<String>,
|
||||||
bookmark_file_error: Option<String>,
|
bookmark_file_error: Option<String>,
|
||||||
bookmark_file_notice: Option<String>,
|
bookmark_file_notice: Option<String>,
|
||||||
|
local_data_file_error: Option<String>,
|
||||||
|
local_data_file_notice: Option<String>,
|
||||||
plugin_install_error: Option<String>,
|
plugin_install_error: Option<String>,
|
||||||
pending_plugin_install: Option<PendingPluginInstall>,
|
pending_plugin_install: Option<PendingPluginInstall>,
|
||||||
pending_plugin_uninstall: Option<PendingPluginUninstall>,
|
pending_plugin_uninstall: Option<PendingPluginUninstall>,
|
||||||
@@ -161,6 +164,8 @@ impl ElyShell {
|
|||||||
bookmark_edit_error: None,
|
bookmark_edit_error: None,
|
||||||
bookmark_file_error: None,
|
bookmark_file_error: None,
|
||||||
bookmark_file_notice: None,
|
bookmark_file_notice: None,
|
||||||
|
local_data_file_error: None,
|
||||||
|
local_data_file_notice: None,
|
||||||
plugin_install_error: None,
|
plugin_install_error: None,
|
||||||
pending_plugin_install: None,
|
pending_plugin_install: None,
|
||||||
pending_plugin_uninstall: None,
|
pending_plugin_uninstall: None,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use super::WebSurfaceStore;
|
|||||||
const LIVE_SURFACE_WIDTH: u32 = 934;
|
const LIVE_SURFACE_WIDTH: u32 = 934;
|
||||||
const LIVE_SURFACE_HEIGHT: u32 = 657;
|
const LIVE_SURFACE_HEIGHT: u32 = 657;
|
||||||
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
||||||
|
const LIVE_SITE_RENDER_ATTEMPTS: usize = 3;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box<dyn Error>> {
|
fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box<dyn Error>> {
|
||||||
@@ -38,12 +39,20 @@ fn web_surface_opens_and_renders_prd_reference_sites() -> Result<(), Box<dyn Err
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
|
fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
|
||||||
let mut store = WebSurfaceStore::new();
|
|
||||||
for case in cases {
|
for case in cases {
|
||||||
let tab = web_tab(case.url)?;
|
let frame = render_web_surface_frame(case)?;
|
||||||
let bounds = live_surface_bounds();
|
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<WebSurfaceFrame, Box<dyn Error>> {
|
||||||
|
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
|
let request = store
|
||||||
.prepare_request(&tab, ProfileDataMode::Persistent)
|
.prepare_request(&tab, ProfileDataMode::Persistent)
|
||||||
.ok_or_else(|| format!("missing web surface request for {}", case.url))?;
|
.ok_or_else(|| format!("missing web surface request for {}", case.url))?;
|
||||||
@@ -57,32 +66,54 @@ fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Erro
|
|||||||
snapshot,
|
snapshot,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
assert_prd_frame_is_ready(&frame, case);
|
match validate_prd_frame(&frame, case) {
|
||||||
log_prd_frame("web-surface", &frame, case);
|
Ok(()) => {
|
||||||
store.finish(tab_id, WebSurfaceState::Ready(frame));
|
store.finish(tab_id, WebSurfaceState::Ready(frame.clone()));
|
||||||
let Some(WebSurfaceState::Ready(frame)) = store.state(tab.id()) else {
|
let Some(WebSurfaceState::Ready(stored_frame)) = store.state(tab.id()) else {
|
||||||
return Err(format!("web surface state is not ready for {}", case.url).into());
|
return Err(format!("web surface state is not ready for {}", case.url).into());
|
||||||
};
|
};
|
||||||
assert_prd_frame_is_ready(frame, case);
|
validate_prd_frame(stored_frame, case)?;
|
||||||
|
return Ok(frame);
|
||||||
}
|
}
|
||||||
Ok(())
|
Err(error) => last_error = error,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_prd_frame_is_ready(frame: &WebSurfaceFrame, case: &LiveSiteCase) {
|
if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS {
|
||||||
assert_eq!(
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
frame.size(),
|
}
|
||||||
WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT },
|
}
|
||||||
"{}",
|
|
||||||
case.url
|
Err(last_error.into())
|
||||||
);
|
}
|
||||||
assert_eq!(frame.scroll_offset(), WebSurfaceScrollOffset::default(), "{}", case.url);
|
|
||||||
assert_render_state_is_open(frame.render_state(), case.url);
|
fn validate_prd_frame(frame: &WebSurfaceFrame, case: &LiveSiteCase) -> Result<(), String> {
|
||||||
assert!(frame.url_label().contains(normalized_url(case.url)), "{}", frame.url_label());
|
require(
|
||||||
assert!(frame.title_label().contains(case.title_fragment), "{}", frame.title_label());
|
frame.size() == WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT },
|
||||||
assert_eq!(frame.detail_label(), format!("{} 934x657", frame.render_state()), "{}", case.url);
|
format!("{} size: {:?}", case.url, frame.size()),
|
||||||
assert!(frame.non_white_pixel_count() > 0, "{}", case.url);
|
)?;
|
||||||
assert!(frame.content_pixel_count() >= MINIMUM_CONTENT_PIXELS, "{}", case.url);
|
require(
|
||||||
assert!(frame.sample_hash() > 0, "{}", case.url);
|
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) {
|
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) {
|
fn require_render_state_is_open(state: &str, url: &str) -> Result<(), String> {
|
||||||
assert!(matches!(state, "complete" | "loading"), "{url} state: {state}");
|
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<gpui::Pixels> {
|
fn live_surface_bounds() -> Bounds<gpui::Pixels> {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ pub enum CoreError {
|
|||||||
#[error("invalid .elybookmarks package: {reason}")]
|
#[error("invalid .elybookmarks package: {reason}")]
|
||||||
InvalidBookmarkPackage { reason: String },
|
InvalidBookmarkPackage { reason: String },
|
||||||
|
|
||||||
|
#[error("invalid .elydata package: {reason}")]
|
||||||
|
InvalidLocalDataPackage { reason: String },
|
||||||
|
|
||||||
#[error("trashed space not found: {id}")]
|
#[error("trashed space not found: {id}")]
|
||||||
TrashedSpaceNotFound { id: SpaceId },
|
TrashedSpaceNotFound { id: SpaceId },
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ mod state;
|
|||||||
pub use error::CoreError;
|
pub use error::CoreError;
|
||||||
pub use state::{
|
pub use state::{
|
||||||
BookmarkImportSummary, BrowserCore, BrowserSnapshot, ELYBOOKMARKS_FILE_EXTENSION,
|
BookmarkImportSummary, BrowserCore, BrowserSnapshot, ELYBOOKMARKS_FILE_EXTENSION,
|
||||||
ELYBOOKMARKS_SCHEMA_VERSION, ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION,
|
ELYBOOKMARKS_SCHEMA_VERSION, ELYDATA_FILE_EXTENSION, ELYDATA_SCHEMA_VERSION,
|
||||||
ElyBookmarksPackage, ElySpacePackage, InitialBrowserConfig, InstalledPlugin,
|
ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION, ElyBookmarksPackage, ElyLocalDataPackage,
|
||||||
LocalDataInventory, PluginAuditAction, PluginAuditEvent, SiteDataClearance,
|
ElySpacePackage, InitialBrowserConfig, InstalledPlugin, LocalDataInventory, PluginAuditAction,
|
||||||
SpaceImportProfileMapping, TrashedSpace,
|
PluginAuditEvent, SiteDataClearance, SpaceImportProfileMapping, TrashedSpace,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -345,12 +345,15 @@ const SETTINGS_ROUTE_MATCHES: &[SettingsRouteMatch] = &[
|
|||||||
"history recording",
|
"history recording",
|
||||||
"diagnostics",
|
"diagnostics",
|
||||||
"diagnostic reporting",
|
"diagnostic reporting",
|
||||||
|
"export local data",
|
||||||
|
"export privacy data",
|
||||||
],
|
],
|
||||||
search_terms: &[
|
search_terms: &[
|
||||||
"Privacy & Security",
|
"Privacy & Security",
|
||||||
"History recording, diagnostics, and profile-scoped privacy controls.",
|
"History recording, diagnostics, local data export, and profile-scoped privacy controls.",
|
||||||
"profile privacy",
|
"profile privacy",
|
||||||
"recording policy",
|
"recording policy",
|
||||||
|
"local data export",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
SettingsRouteMatch {
|
SettingsRouteMatch {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ mod commands;
|
|||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod downloads;
|
mod downloads;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod local_data_export_records;
|
||||||
|
mod local_data_exports;
|
||||||
mod notes;
|
mod notes;
|
||||||
mod plugins;
|
mod plugins;
|
||||||
mod privacy;
|
mod privacy;
|
||||||
@@ -39,6 +41,7 @@ pub use bookmarks::{
|
|||||||
BookmarkImportSummary, ELYBOOKMARKS_FILE_EXTENSION, ELYBOOKMARKS_SCHEMA_VERSION,
|
BookmarkImportSummary, ELYBOOKMARKS_FILE_EXTENSION, ELYBOOKMARKS_SCHEMA_VERSION,
|
||||||
ElyBookmarksPackage,
|
ElyBookmarksPackage,
|
||||||
};
|
};
|
||||||
|
pub use local_data_exports::{ELYDATA_FILE_EXTENSION, ELYDATA_SCHEMA_VERSION, ElyLocalDataPackage};
|
||||||
pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent};
|
pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent};
|
||||||
pub use privacy::LocalDataInventory;
|
pub use privacy::LocalDataInventory;
|
||||||
pub use site_data::SiteDataClearance;
|
pub use site_data::SiteDataClearance;
|
||||||
|
|||||||
@@ -313,6 +313,16 @@ impl BrowserCore {
|
|||||||
self.open_tab(space_settings_url()?);
|
self.open_tab(space_settings_url()?);
|
||||||
Ok(true)
|
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" => {
|
"site-settings" | "open-site-settings" | "open site settings" => {
|
||||||
let Some(url) = self.active_tab_site_settings_url()? else {
|
let Some(url) = self.active_tab_site_settings_url()? else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
|
|||||||
@@ -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<ElyLocalTabRecord>,
|
||||||
|
pub(super) archived_tabs: Vec<ElyLocalArchivedTabRecord>,
|
||||||
|
pub(super) bookmarks: Vec<ElyLocalBookmarkRecord>,
|
||||||
|
pub(super) notes: Vec<ElyLocalNoteRecord>,
|
||||||
|
pub(super) reading_list: Vec<ElyLocalReadingListRecord>,
|
||||||
|
pub(super) history: Vec<ElyLocalHistoryRecord>,
|
||||||
|
pub(super) downloads: Vec<ElyLocalDownloadRecord>,
|
||||||
|
pub(super) site_permissions: Vec<ElyLocalSitePermissionRecord>,
|
||||||
|
pub(super) site_permission_audit_events: Vec<ElyLocalSitePermissionAuditRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u64, CoreError> {
|
||||||
|
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<String>,
|
||||||
|
parent_tab_id: Option<String>,
|
||||||
|
state: String,
|
||||||
|
flags: ElyLocalTabFlagsRecord,
|
||||||
|
group_id: Option<String>,
|
||||||
|
split_id: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
note: Option<String>,
|
||||||
|
thumbnail_key: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
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<String>,
|
||||||
|
security: String,
|
||||||
|
state: String,
|
||||||
|
received_bytes: u64,
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
checksum: Option<ElyLocalDownloadChecksumRecord>,
|
||||||
|
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<Self, CoreError> {
|
||||||
|
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<Self, CoreError> {
|
||||||
|
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<Self, CoreError> {
|
||||||
|
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<Self, CoreError> {
|
||||||
|
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<Self, CoreError> {
|
||||||
|
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<Self, CoreError> {
|
||||||
|
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<Self, CoreError> {
|
||||||
|
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<Self, CoreError> {
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, CoreError> {
|
||||||
|
serde_json::to_string_pretty(&self.export_local_data_package()?)
|
||||||
|
.map_err(invalid_local_data_package)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_local_data_package(&self) -> Result<ElyLocalDataPackage, CoreError> {
|
||||||
|
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::<Result<Vec<_>, _>>()?,
|
||||||
|
archived_tabs: self
|
||||||
|
.archived_tabs
|
||||||
|
.iter()
|
||||||
|
.filter(|archived| archived.tab().profile_id() == profile_id)
|
||||||
|
.map(|archived| self.local_archived_tab_record(archived))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
bookmarks: self
|
||||||
|
.bookmarks
|
||||||
|
.iter()
|
||||||
|
.filter(|bookmark| bookmark.profile_id() == profile_id)
|
||||||
|
.map(|bookmark| self.local_bookmark_record(bookmark))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
notes: self
|
||||||
|
.notes
|
||||||
|
.iter()
|
||||||
|
.filter(|note| note.profile_id() == profile_id)
|
||||||
|
.map(|note| self.local_note_record(note))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
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::<Result<Vec<_>, _>>()?,
|
||||||
|
history: self
|
||||||
|
.history_entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.profile_id() == profile_id)
|
||||||
|
.map(|entry| self.local_history_record(entry))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
downloads: self
|
||||||
|
.download_entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.profile_id() == profile_id)
|
||||||
|
.map(ElyLocalDownloadRecord::from_download)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
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::<Result<Vec<_>, _>>()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_tab_record(&self, tab: &BrowserTab) -> Result<ElyLocalTabRecord, CoreError> {
|
||||||
|
ElyLocalTabRecord::from_tab(tab, self.space_name(tab.space_id())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_archived_tab_record(
|
||||||
|
&self,
|
||||||
|
archived: &ArchivedTab,
|
||||||
|
) -> Result<ElyLocalArchivedTabRecord, CoreError> {
|
||||||
|
ElyLocalArchivedTabRecord::from_archived_tab(
|
||||||
|
archived,
|
||||||
|
self.local_tab_record(archived.tab())?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_bookmark_record(
|
||||||
|
&self,
|
||||||
|
bookmark: &BookmarkEntry,
|
||||||
|
) -> Result<ElyLocalBookmarkRecord, CoreError> {
|
||||||
|
ElyLocalBookmarkRecord::from_bookmark(bookmark, self.space_name(bookmark.space_id())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_note_record(&self, note: &NoteEntry) -> Result<ElyLocalNoteRecord, CoreError> {
|
||||||
|
ElyLocalNoteRecord::from_note(note, self.space_name(note.space_id())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_history_record(
|
||||||
|
&self,
|
||||||
|
entry: &HistoryEntry,
|
||||||
|
) -> Result<ElyLocalHistoryRecord, CoreError> {
|
||||||
|
ElyLocalHistoryRecord::from_entry(entry, self.space_name(entry.space_id())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn space_name(&self, space_id: &SpaceId) -> Result<String, CoreError> {
|
||||||
|
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() }
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
use ely_domain::{DiagnosticsReportingPolicy, HistoryRecordingPolicy};
|
use ely_domain::{DiagnosticsReportingPolicy, HistoryRecordingPolicy};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::BrowserCore;
|
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 {
|
pub struct LocalDataInventory {
|
||||||
open_tabs: usize,
|
open_tabs: usize,
|
||||||
archived_tabs: usize,
|
archived_tabs: usize,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
use ely_browser_core::{BrowserCore, ELYDATA_SCHEMA_VERSION, InitialBrowserConfig};
|
||||||
use ely_domain::{ProfileKind, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText};
|
use ely_domain::{
|
||||||
|
CommandIntent, ProfileKind, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_data_inventory_counts_active_profile_data() -> Result<(), Box<dyn Error>> {
|
fn local_data_inventory_counts_active_profile_data() -> Result<(), Box<dyn Error>> {
|
||||||
@@ -66,3 +68,74 @@ fn local_data_inventory_counts_active_profile_data() -> Result<(), Box<dyn Error
|
|||||||
assert_eq!(personal_inventory.downloads(), 1);
|
assert_eq!(personal_inventory.downloads(), 1);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_data_export_contains_active_profile_records() -> Result<(), Box<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,9 +57,37 @@ pub(super) fn snapshot_prd_site(
|
|||||||
scroll_offset.y
|
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<serde_json::Value, Box<dyn Error>> {
|
||||||
|
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<serde_json::Value, Box<dyn Error>> {
|
||||||
let output = run_sidecar_snapshot_with_retry(
|
let output = run_sidecar_snapshot_with_retry(
|
||||||
case.url,
|
case.url,
|
||||||
&output_path,
|
output_path,
|
||||||
size,
|
size,
|
||||||
scroll_offset,
|
scroll_offset,
|
||||||
SnapshotInput::default(),
|
SnapshotInput::default(),
|
||||||
@@ -96,14 +124,22 @@ pub(super) fn snapshot_prd_site(
|
|||||||
case.url
|
case.url
|
||||||
);
|
);
|
||||||
assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", 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)?;
|
log_prd_report(&report, case, size)?;
|
||||||
|
|
||||||
std::fs::remove_file(&output_path)?;
|
std::fs::remove_file(output_path)?;
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_file_if_present(path: &Path) -> Result<(), Box<dyn Error>> {
|
||||||
|
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(
|
pub(super) fn snapshot_click_probe(
|
||||||
click_point: Option<ClickPoint>,
|
click_point: Option<ClickPoint>,
|
||||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||||
@@ -325,8 +361,7 @@ fn assert_report_text_equals(
|
|||||||
expected: &str,
|
expected: &str,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let value = report_field_as_text(report, field)?;
|
let value = report_field_as_text(report, field)?;
|
||||||
assert_eq!(value, expected, "{field}");
|
if value == expected { Ok(()) } else { Err(format!("{field}: {value}").into()) }
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_report_text_contains(
|
fn assert_report_text_contains(
|
||||||
@@ -335,14 +370,16 @@ fn assert_report_text_contains(
|
|||||||
fragment: &str,
|
fragment: &str,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let value = report_field_as_text(report, field)?;
|
let value = report_field_as_text(report, field)?;
|
||||||
assert!(value.contains(fragment), "{field}: {value}");
|
if value.contains(fragment) { Ok(()) } else { Err(format!("{field}: {value}").into()) }
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_report_state_is_renderable(report: &serde_json::Value) -> Result<(), Box<dyn Error>> {
|
fn assert_report_state_is_renderable(report: &serde_json::Value) -> Result<(), Box<dyn Error>> {
|
||||||
let state = report_field_as_text(report, "state")?;
|
let state = report_field_as_text(report, "state")?;
|
||||||
assert!(matches!(state, "complete" | "loading"), "state: {state}");
|
if matches!(state, "complete" | "loading") {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("state: {state}").into())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_prd_report(
|
fn log_prd_report(
|
||||||
|
|||||||
Reference in New Issue
Block a user