diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 29ca25f..e50cb65 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -8,6 +8,7 @@ mod plugin_details; mod plugins; mod profiles; mod reading_list; +mod site_settings; mod sync; mod task_manager; @@ -40,6 +41,9 @@ impl ElyShell { url if url.starts_with("ely://plugin/") => { self.render_plugin_detail_page(snapshot, url, cx) } + url if url.starts_with("ely://site/") => { + self.render_site_settings_page(snapshot, url, cx) + } "ely://about" => self.render_about_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/site_settings.rs b/crates/ely_app/src/shell/internal_pages/site_settings.rs new file mode 100644 index 0000000..6847af6 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/site_settings.rs @@ -0,0 +1,433 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{ + SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, SitePermissionDecision, + SitePermissionFeature, +}; +use gpui::prelude::FluentBuilder; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_site_settings_page( + &mut self, + snapshot: &BrowserSnapshot, + route: &str, + cx: &mut Context, + ) -> AnyElement { + let Some(origin) = SiteOrigin::from_site_route(route).ok().flatten() else { + return render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_invalid_site_route()), + ); + }; + + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_site_settings_header(snapshot, &origin)) + .child(render_site_permission_summary(snapshot, &origin)) + .child(render_site_permission_rows(snapshot, &origin, cx)) + .child(render_site_permission_audit(snapshot, &origin)), + ) + } +} + +fn render_site_settings_header(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child( + div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Site Settings"), + ) + .child(div().text_sm().truncate().text_color(rgb(colors::MUTED)).child(format!( + "{} / {}", + snapshot.active_profile_name, + origin.as_str() + ))), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::Globe) + .child("Profile scoped"), + ) + .into_any_element() +} + +fn render_site_permission_summary(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> AnyElement { + div() + .border_t_1() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .children([ + site_metric("Configured", configured_count(snapshot, origin)), + site_metric("Allowed", allowed_count(snapshot, origin)), + site_metric("Denied", denied_count(snapshot, origin)), + site_metric("Audit Events", audit_count(snapshot, origin)), + ]) + .into_any_element() +} + +fn site_metric(label: &'static str, value: usize) -> AnyElement { + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child(div().text_xs().text_color(rgb(colors::MUTED)).child(label)) + .child( + div().text_sm().font_semibold().text_color(rgb(colors::INK)).child(value.to_string()), + ) + .into_any_element() +} + +fn render_site_permission_rows( + snapshot: &BrowserSnapshot, + origin: &SiteOrigin, + cx: &mut Context, +) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .gap_3() + .child(div().text_xs().font_semibold().text_color(rgb(colors::MUTED)).child("Permissions")) + .child(div().flex_1().min_h_0().flex().flex_col().overflow_y_scrollbar().children( + SitePermissionFeature::all().iter().copied().enumerate().map(|(index, feature)| { + render_site_permission_row(snapshot, origin, index, feature, cx) + }), + )) + .into_any_element() +} + +fn render_site_permission_row( + snapshot: &BrowserSnapshot, + origin: &SiteOrigin, + index: usize, + feature: SitePermissionFeature, + cx: &mut Context, +) -> AnyElement { + let decision = decision_for(snapshot, origin, feature); + let status = decision.map_or("Ask", |decision| decision.label()); + let status_color = decision.map_or(colors::MUTED, decision_color); + let button_base = index * 4; + + div() + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(div().text_color(rgb(status_color)).child(permission_icon(decision))) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(feature.label()), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(feature_scope_label(feature)), + ), + ), + ) + .child( + div() + .flex() + .items_center() + .justify_end() + .gap_2() + .child( + div() + .min_w(px(78.0)) + .text_xs() + .font_semibold() + .text_color(rgb(status_color)) + .child(status), + ) + .child(permission_decision_button( + button_base, + origin.clone(), + feature, + SitePermissionDecision::AllowOnce, + "Once", + decision, + cx, + )) + .child(permission_decision_button( + button_base + 1, + origin.clone(), + feature, + SitePermissionDecision::AllowAlways, + "Allow", + decision, + cx, + )) + .child(permission_decision_button( + button_base + 2, + origin.clone(), + feature, + SitePermissionDecision::DenyAlways, + "Deny", + decision, + cx, + )) + .when(decision.is_some(), |this| { + this.child(permission_reset_button( + button_base + 3, + origin.clone(), + feature, + cx, + )) + }), + ) + .into_any_element() +} + +fn permission_decision_button( + id: usize, + origin: SiteOrigin, + feature: SitePermissionFeature, + target: SitePermissionDecision, + label: &'static str, + current: Option, + cx: &mut Context, +) -> AnyElement { + let button = Button::new(("site-permission-decision", id)) + .xsmall() + .label(label) + .tooltip(target.label()) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_site_permission(origin.clone(), feature, target, cx); + })); + + match (target, current == Some(target)) { + (SitePermissionDecision::DenyAlways, true) => button.danger().into_any_element(), + (_, true) => button.primary().into_any_element(), + _ => button.ghost().into_any_element(), + } +} + +fn permission_reset_button( + id: usize, + origin: SiteOrigin, + feature: SitePermissionFeature, + cx: &mut Context, +) -> AnyElement { + Button::new(("site-permission-reset", id)) + .ghost() + .xsmall() + .icon(IconName::Undo2) + .tooltip("Reset Permission") + .on_click(cx.listener(move |shell, _, _, cx| { + shell.revoke_site_permission(origin.clone(), feature, cx); + })) + .into_any_element() +} + +fn render_site_permission_audit(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> AnyElement { + let events = snapshot + .site_permission_audit_events + .iter() + .filter(|event| event.origin() == origin) + .rev() + .take(4) + .collect::>(); + + if events.is_empty() { + return div().into_any_element(); + } + + div() + .flex() + .flex_col() + .gap_2() + .child(div().text_xs().font_semibold().text_color(rgb(colors::MUTED)).child("Audit")) + .children(events.into_iter().map(render_audit_row)) + .into_any_element() +} + +fn render_audit_row(event: &SitePermissionAuditEvent) -> AnyElement { + div() + .py_2() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_3() + .text_xs() + .child(div().min_w_0().truncate().text_color(rgb(colors::BODY)).child(format!( + "{} - {}", + event.feature().label(), + audit_action_label(event.action()) + ))) + .child(div().text_color(rgb(colors::MUTED)).child("Local audit")) + .into_any_element() +} + +fn render_invalid_site_route() -> AnyElement { + div() + .size_full() + .flex() + .flex_col() + .gap_5() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Site Settings"), + ) + .child( + div() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("Site settings require an http or https origin."), + ), + ) + .into_any_element() +} + +fn decision_for( + snapshot: &BrowserSnapshot, + origin: &SiteOrigin, + feature: SitePermissionFeature, +) -> Option { + snapshot + .site_permissions + .iter() + .find(|entry| entry.origin() == origin && entry.feature() == feature) + .map(|entry| entry.decision()) +} + +fn configured_count(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> usize { + snapshot.site_permissions.iter().filter(|entry| entry.origin() == origin).count() +} + +fn allowed_count(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> usize { + snapshot + .site_permissions + .iter() + .filter(|entry| entry.origin() == origin) + .filter(|entry| { + matches!( + entry.decision(), + SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways + ) + }) + .count() +} + +fn denied_count(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> usize { + snapshot + .site_permissions + .iter() + .filter(|entry| { + entry.origin() == origin && entry.decision() == SitePermissionDecision::DenyAlways + }) + .count() +} + +fn audit_count(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> usize { + snapshot.site_permission_audit_events.iter().filter(|event| event.origin() == origin).count() +} + +fn decision_color(decision: SitePermissionDecision) -> u32 { + match decision { + SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways => colors::SUCCESS, + SitePermissionDecision::DenyAlways => colors::ERROR, + } +} + +fn permission_icon(decision: Option) -> IconName { + match decision { + Some(SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways) => { + IconName::CircleCheck + } + Some(SitePermissionDecision::DenyAlways) => IconName::CircleX, + None => IconName::Info, + } +} + +fn audit_action_label(action: &SitePermissionAuditAction) -> &'static str { + match action { + SitePermissionAuditAction::Set(decision) => decision.label(), + SitePermissionAuditAction::Revoked => "Reset", + } +} + +fn feature_scope_label(feature: SitePermissionFeature) -> &'static str { + match feature { + SitePermissionFeature::Camera => "Controls camera capture requests.", + SitePermissionFeature::Microphone => "Controls microphone capture requests.", + SitePermissionFeature::ScreenCapture => "Controls screen capture requests.", + SitePermissionFeature::Location => "Controls location access requests.", + SitePermissionFeature::Notifications => "Controls system notification prompts.", + SitePermissionFeature::ClipboardRead => "Controls clipboard read requests.", + SitePermissionFeature::ClipboardWrite => "Controls clipboard write requests.", + SitePermissionFeature::Downloads => "Controls automatic download requests.", + SitePermissionFeature::Popups => "Controls popup window requests.", + SitePermissionFeature::Autoplay => "Controls autoplay behavior.", + SitePermissionFeature::WebUsb => "Controls WebUSB device access.", + SitePermissionFeature::WebHid => "Controls WebHID device access.", + SitePermissionFeature::WebSerial => "Controls WebSerial device access.", + SitePermissionFeature::StoragePersistence => "Controls persistent storage requests.", + SitePermissionFeature::InsecureContent => "Controls insecure content loading.", + SitePermissionFeature::CertificateException => "Controls certificate exceptions.", + } +} diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs index 0575622..54f8ef4 100644 --- a/crates/ely_app/src/shell/internal_pages/sync.rs +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -154,6 +154,7 @@ fn sync_object_kind_label(kind: &SyncObjectKind) -> &'static str { SyncObjectKind::Bookmarks => "Bookmarks", SyncObjectKind::ReadingList => "Reading List", SyncObjectKind::Profiles => "Profiles", + SyncObjectKind::SitePermissions => "Site permissions", SyncObjectKind::History => "History", SyncObjectKind::PluginSettings => "Plugin settings", } diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index a7aae6c..56e4415 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -2,6 +2,7 @@ mod downloads; mod internal_pages; mod plugins; mod render; +mod site_permissions; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{CommandIntent, ProfileId, SpaceId, TabId, UrlText}; diff --git a/crates/ely_app/src/shell/site_permissions.rs b/crates/ely_app/src/shell/site_permissions.rs new file mode 100644 index 0000000..2b72853 --- /dev/null +++ b/crates/ely_app/src/shell/site_permissions.rs @@ -0,0 +1,33 @@ +use ely_domain::{SiteOrigin, SitePermissionDecision, SitePermissionFeature}; +use gpui::Context; + +use super::{ElyShell, ShellState}; + +impl ElyShell { + pub(super) fn set_site_permission( + &mut self, + origin: SiteOrigin, + feature: SitePermissionFeature, + decision: SitePermissionDecision, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && core.set_site_permission(origin, feature, decision).is_ok() + { + cx.notify(); + } + } + + pub(super) fn revoke_site_permission( + &mut self, + origin: SiteOrigin, + feature: SitePermissionFeature, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && core.revoke_site_permission(&origin, feature).is_ok() + { + cx.notify(); + } + } +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 3a0fe41..4f3d210 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -1,4 +1,4 @@ -use ely_domain::{BrowserTab, DomainError, PluginId, UrlText}; +use ely_domain::{BrowserTab, DomainError, PluginId, SiteOrigin, UrlText}; use url::Url; use crate::CoreError; @@ -24,6 +24,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://task-manager" => Some("Task Manager"), "ely://plugins" => Some("Plugin Marketplace"), url if plugin_detail_route_id(url).is_some() => Some("Plugin Details"), + url if SiteOrigin::from_site_route(url).ok().flatten().is_some() => Some("Site Settings"), "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), "ely://settings/plugins" => Some("Plugin Settings"), diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 603d53b..e78999b 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use ely_domain::{ ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, - HistoryEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, Space, SpaceId, SyncStatus, - TabId, UrlText, + HistoryEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, SitePermissionAuditEvent, + SitePermissionEntry, Space, SpaceId, SyncStatus, TabId, UrlText, }; use crate::CoreError; @@ -15,6 +15,7 @@ mod history; mod plugins; mod profiles; mod reading_list; +mod site_permissions; mod sync; mod tabs; @@ -47,6 +48,8 @@ pub struct BrowserSnapshot { pub archived_tabs: Vec, pub bookmarks: Vec, pub reading_list: Vec, + pub site_permissions: Vec, + pub site_permission_audit_events: Vec, pub download_entries: Vec, pub history_entries: Vec, pub installed_plugins: Vec, @@ -71,6 +74,8 @@ pub struct BrowserCore { archived_tabs: Vec, bookmarks: Vec, reading_list: Vec, + site_permissions: Vec, + site_permission_audit_events: Vec, download_entries: Vec, history_entries: Vec, installed_plugins: Vec, @@ -117,6 +122,8 @@ impl BrowserCore { archived_tabs: Vec::new(), bookmarks: Vec::new(), reading_list: Vec::new(), + site_permissions: Vec::new(), + site_permission_audit_events: Vec::new(), download_entries: Vec::new(), history_entries: Vec::new(), installed_plugins: Vec::new(), @@ -200,6 +207,8 @@ impl BrowserCore { archived_tabs: self.archived_tabs.clone(), bookmarks: self.visible_bookmarks(), reading_list: self.visible_reading_list(), + site_permissions: self.visible_site_permissions(), + site_permission_audit_events: self.visible_site_permission_audit_events(), download_entries: self.visible_downloads(), history_entries: self.visible_history(), installed_plugins: self.installed_plugins.clone(), diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 1358b7b..63a0fc8 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -146,6 +146,13 @@ impl BrowserCore { self.open_tab(plugins_url()?); Ok(true) } + "site-settings" | "open-site-settings" | "open site settings" => { + let Some(url) = self.active_tab_site_settings_url()? else { + return Ok(false); + }; + self.open_tab(url); + Ok(true) + } "about" | "open-about" | "open about" => { self.open_tab(about_url()?); Ok(true) diff --git a/crates/ely_browser_core/src/state/site_permissions.rs b/crates/ely_browser_core/src/state/site_permissions.rs new file mode 100644 index 0000000..49e5aba --- /dev/null +++ b/crates/ely_browser_core/src/state/site_permissions.rs @@ -0,0 +1,158 @@ +use std::time::SystemTime; + +use ely_domain::{ + ProfileId, SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, + SitePermissionDecision, SitePermissionEntry, SitePermissionFeature, UrlText, +}; + +use crate::CoreError; + +use super::BrowserCore; + +impl BrowserCore { + pub fn set_site_permission( + &mut self, + origin: SiteOrigin, + feature: SitePermissionFeature, + decision: SitePermissionDecision, + ) -> Result<(), CoreError> { + let profile_id = self.active_profile_id.clone(); + self.set_site_permission_for_profile(&profile_id, origin, feature, decision) + } + + pub fn revoke_site_permission( + &mut self, + origin: &SiteOrigin, + feature: SitePermissionFeature, + ) -> Result<(), CoreError> { + let profile_id = self.active_profile_id.clone(); + self.revoke_site_permission_for_profile(&profile_id, origin, feature) + } + + pub fn active_tab_site_settings_url(&self) -> Result, CoreError> { + let active_tab = self.active_tab()?; + let Some(origin) = SiteOrigin::from_url(active_tab.url())? else { + return Ok(None); + }; + origin.site_settings_url().map(Some).map_err(CoreError::from) + } + + pub(super) fn visible_site_permissions(&self) -> Vec { + self.site_permissions + .iter() + .filter(|entry| entry.profile_id() == &self.active_profile_id) + .cloned() + .collect() + } + + pub(super) fn visible_site_permission_audit_events(&self) -> Vec { + self.site_permission_audit_events + .iter() + .filter(|event| event.profile_id() == &self.active_profile_id) + .cloned() + .collect() + } + + fn set_site_permission_for_profile( + &mut self, + profile_id: &ProfileId, + origin: SiteOrigin, + feature: SitePermissionFeature, + decision: SitePermissionDecision, + ) -> Result<(), CoreError> { + self.require_profile(profile_id)?; + + if let Some(entry) = self.site_permission_entry_mut(profile_id, &origin, feature) { + if entry.decision() == decision { + return Ok(()); + } + entry.set_decision(decision); + } else { + self.site_permissions.push(SitePermissionEntry::new( + profile_id.clone(), + origin.clone(), + feature, + decision, + )); + } + + self.record_site_permission_audit_event( + profile_id.clone(), + origin, + feature, + SitePermissionAuditAction::Set(decision), + ); + Ok(()) + } + + fn revoke_site_permission_for_profile( + &mut self, + profile_id: &ProfileId, + origin: &SiteOrigin, + feature: SitePermissionFeature, + ) -> Result<(), CoreError> { + self.require_profile(profile_id)?; + let Some(index) = self.site_permission_entry_index(profile_id, origin, feature) else { + return Ok(()); + }; + + let entry = self.site_permissions.remove(index); + self.record_site_permission_audit_event( + profile_id.clone(), + entry.origin().clone(), + feature, + SitePermissionAuditAction::Revoked, + ); + Ok(()) + } + + fn require_profile(&self, profile_id: &ProfileId) -> Result<(), CoreError> { + if self.profiles.iter().any(|profile| profile.id() == profile_id) { + return Ok(()); + } + + Err(CoreError::ProfileNotFound { id: profile_id.clone() }) + } + + fn site_permission_entry_mut( + &mut self, + profile_id: &ProfileId, + origin: &SiteOrigin, + feature: SitePermissionFeature, + ) -> Option<&mut SitePermissionEntry> { + self.site_permissions.iter_mut().find(|entry| { + entry.profile_id() == profile_id + && entry.origin() == origin + && entry.feature() == feature + }) + } + + fn site_permission_entry_index( + &self, + profile_id: &ProfileId, + origin: &SiteOrigin, + feature: SitePermissionFeature, + ) -> Option { + self.site_permissions.iter().position(|entry| { + entry.profile_id() == profile_id + && entry.origin() == origin + && entry.feature() == feature + }) + } + + fn record_site_permission_audit_event( + &mut self, + profile_id: ProfileId, + origin: SiteOrigin, + feature: SitePermissionFeature, + action: SitePermissionAuditAction, + ) { + self.site_permission_audit_events.push(SitePermissionAuditEvent::new( + profile_id, + origin, + feature, + action, + SystemTime::now(), + )); + } +} diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index c151649..e8f85a8 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -30,6 +30,11 @@ impl BrowserCore { self.profiles.len(), SyncObjectState::LocalOnly, ), + SyncObjectStatus::new( + SyncObjectKind::SitePermissions, + self.site_permissions.len(), + SyncObjectState::LocalOnly, + ), SyncObjectStatus::new( SyncObjectKind::History, self.history_entries.len(), diff --git a/crates/ely_browser_core/tests/site_permissions.rs b/crates/ely_browser_core/tests/site_permissions.rs new file mode 100644 index 0000000..b416303 --- /dev/null +++ b/crates/ely_browser_core/tests/site_permissions.rs @@ -0,0 +1,140 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{ + CommandIntent, ProfileKind, SiteOrigin, SitePermissionAuditAction, SitePermissionDecision, + SitePermissionFeature, UrlText, +}; + +#[test] +fn set_site_permission_records_active_profile_origin_and_audit() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let profile_id = core.snapshot()?.active_profile_id; + let origin = SiteOrigin::parse("https://example.com")?; + + core.set_site_permission( + origin.clone(), + SitePermissionFeature::Camera, + SitePermissionDecision::AllowAlways, + )?; + + let snapshot = core.snapshot()?; + assert_eq!(snapshot.site_permissions.len(), 1); + let entry = &snapshot.site_permissions[0]; + assert_eq!(entry.profile_id(), &profile_id); + assert_eq!(entry.origin(), &origin); + assert_eq!(entry.feature(), SitePermissionFeature::Camera); + assert_eq!(entry.decision(), SitePermissionDecision::AllowAlways); + + assert_eq!(snapshot.site_permission_audit_events.len(), 1); + let audit_event = &snapshot.site_permission_audit_events[0]; + assert_eq!(audit_event.profile_id(), &profile_id); + assert_eq!(audit_event.origin(), &origin); + assert_eq!(audit_event.feature(), SitePermissionFeature::Camera); + assert_eq!( + audit_event.action(), + &SitePermissionAuditAction::Set(SitePermissionDecision::AllowAlways), + ); + Ok(()) +} + +#[test] +fn site_permissions_stay_with_active_profile() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let default_profile_id = core.snapshot()?.active_profile_id; + let personal_profile_id = core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?; + let origin = SiteOrigin::parse("https://example.com")?; + + core.select_profile(&personal_profile_id)?; + core.set_site_permission( + origin.clone(), + SitePermissionFeature::Notifications, + SitePermissionDecision::DenyAlways, + )?; + + let personal_snapshot = core.snapshot()?; + assert_eq!(personal_snapshot.site_permissions.len(), 1); + assert_eq!(personal_snapshot.site_permissions[0].profile_id(), &personal_profile_id); + + core.select_profile(&default_profile_id)?; + let default_snapshot = core.snapshot()?; + assert!(default_snapshot.site_permissions.is_empty()); + assert!(default_snapshot.site_permission_audit_events.is_empty()); + + core.select_profile(&personal_profile_id)?; + let personal_snapshot = core.snapshot()?; + assert_eq!(personal_snapshot.site_permissions.len(), 1); + assert_eq!(personal_snapshot.site_permission_audit_events.len(), 1); + Ok(()) +} + +#[test] +fn revoke_site_permission_removes_entry_and_records_audit() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let origin = SiteOrigin::parse("https://example.com")?; + + core.set_site_permission( + origin.clone(), + SitePermissionFeature::Popups, + SitePermissionDecision::DenyAlways, + )?; + core.revoke_site_permission(&origin, SitePermissionFeature::Popups)?; + + let snapshot = core.snapshot()?; + assert!(snapshot.site_permissions.is_empty()); + assert_eq!(snapshot.site_permission_audit_events.len(), 2); + assert_eq!( + snapshot.site_permission_audit_events[1].action(), + &SitePermissionAuditAction::Revoked, + ); + Ok(()) +} + +#[test] +fn site_settings_command_opens_active_origin() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/path")?); + + core.set_command_query(">site-settings"); + let intent = core.submit_command()?; + + assert_eq!(intent, Some(CommandIntent::Command("site-settings".to_string())),); + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + assert_eq!(snapshot.command_query, ""); + assert_eq!(active_tab.url().as_str(), "ely://site/https://example.com"); + assert_eq!(active_tab.title(), "Site Settings"); + Ok(()) +} + +#[test] +fn site_settings_command_preserves_query_for_internal_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">site-settings"); + let intent = core.submit_command()?; + + assert_eq!(intent, Some(CommandIntent::Command("site-settings".to_string())),); + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + assert_eq!(snapshot.command_query, ">site-settings"); + assert_eq!(active_tab.url().as_str(), "ely://new-tab"); + Ok(()) +} + +#[test] +fn site_origin_from_route_and_url_require_web_origins() -> Result<(), Box> { + let origin = SiteOrigin::from_site_route("ely://site/https://example.com/path")?; + let Some(origin) = origin else { + return Err("missing site origin".into()); + }; + + assert_eq!(origin.as_str(), "https://example.com"); + assert_eq!(SiteOrigin::from_url(&UrlText::parse("ely://settings")?)?, None,); + assert_eq!( + SiteOrigin::from_url(&UrlText::parse("https://example.com/path")?)?, + Some(SiteOrigin::parse("https://example.com")?), + ); + + Ok(()) +} diff --git a/crates/ely_browser_core/tests/sync.rs b/crates/ely_browser_core/tests/sync.rs index a0846df..b202140 100644 --- a/crates/ely_browser_core/tests/sync.rs +++ b/crates/ely_browser_core/tests/sync.rs @@ -25,6 +25,7 @@ fn default_sync_status_reflects_local_browser_state() -> Result<(), Box) -> Result { + let value = value.into(); + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyField { field: "site_origin" }); + } + + let url = Url::parse(trimmed) + .map_err(|_| DomainError::InvalidSiteOrigin { value: trimmed.to_string() })?; + site_origin_from_url(&url) + } + + pub fn from_url(url: &UrlText) -> Result, DomainError> { + let parsed = Url::parse(url.as_str()) + .map_err(|_| DomainError::InvalidUrl { value: url.as_str().to_string() })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Ok(None); + } + + site_origin_from_url(&parsed).map(Some) + } + + pub fn from_site_route(route: &str) -> Result, DomainError> { + let Some(origin) = route.strip_prefix("ely://site/") else { + return Ok(None); + }; + Self::parse(origin).map(Some) + } + + pub fn site_settings_url(&self) -> Result { + UrlText::parse(format!("ely://site/{}", self.as_str())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl SitePermissionFeature { + #[must_use] + pub fn all() -> &'static [Self] { + &[ + Self::Camera, + Self::Microphone, + Self::ScreenCapture, + Self::Location, + Self::Notifications, + Self::ClipboardRead, + Self::ClipboardWrite, + Self::Downloads, + Self::Popups, + Self::Autoplay, + Self::WebUsb, + Self::WebHid, + Self::WebSerial, + Self::StoragePersistence, + Self::InsecureContent, + Self::CertificateException, + ] + } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Camera => "camera", + Self::Microphone => "microphone", + Self::ScreenCapture => "screen-capture", + Self::Location => "location", + Self::Notifications => "notifications", + Self::ClipboardRead => "clipboard-read", + Self::ClipboardWrite => "clipboard-write", + Self::Downloads => "downloads", + Self::Popups => "popups", + Self::Autoplay => "autoplay", + Self::WebUsb => "webusb", + Self::WebHid => "webhid", + Self::WebSerial => "webserial", + Self::StoragePersistence => "storage-persistence", + Self::InsecureContent => "insecure-content", + Self::CertificateException => "certificate-exception", + } + } + + #[must_use] + pub fn label(&self) -> &'static str { + match self { + Self::Camera => "Camera", + Self::Microphone => "Microphone", + Self::ScreenCapture => "Screen capture", + Self::Location => "Location", + Self::Notifications => "Notifications", + Self::ClipboardRead => "Clipboard read", + Self::ClipboardWrite => "Clipboard write", + Self::Downloads => "Downloads", + Self::Popups => "Popups", + Self::Autoplay => "Autoplay", + Self::WebUsb => "WebUSB", + Self::WebHid => "WebHID", + Self::WebSerial => "WebSerial", + Self::StoragePersistence => "Storage persistence", + Self::InsecureContent => "Insecure content", + Self::CertificateException => "Certificate exception", + } + } +} + +impl SitePermissionDecision { + #[must_use] + pub fn label(&self) -> &'static str { + match self { + Self::AllowOnce => "Allow once", + Self::AllowAlways => "Allow always", + Self::DenyAlways => "Deny always", + } + } +} + +impl SitePermissionEntry { + #[must_use] + pub fn new( + profile_id: ProfileId, + origin: SiteOrigin, + feature: SitePermissionFeature, + decision: SitePermissionDecision, + ) -> Self { + Self { profile_id, origin, feature, decision } + } + + #[must_use] + pub fn profile_id(&self) -> &ProfileId { + &self.profile_id + } + + #[must_use] + pub fn origin(&self) -> &SiteOrigin { + &self.origin + } + + #[must_use] + pub fn feature(&self) -> SitePermissionFeature { + self.feature + } + + #[must_use] + pub fn decision(&self) -> SitePermissionDecision { + self.decision + } + + pub fn set_decision(&mut self, decision: SitePermissionDecision) { + self.decision = decision; + } +} + +impl SitePermissionAuditEvent { + #[must_use] + pub fn new( + profile_id: ProfileId, + origin: SiteOrigin, + feature: SitePermissionFeature, + action: SitePermissionAuditAction, + created_at: SystemTime, + ) -> Self { + Self { profile_id, origin, feature, action, created_at } + } + + #[must_use] + pub fn profile_id(&self) -> &ProfileId { + &self.profile_id + } + + #[must_use] + pub fn origin(&self) -> &SiteOrigin { + &self.origin + } + + #[must_use] + pub fn feature(&self) -> SitePermissionFeature { + self.feature + } + + #[must_use] + pub fn action(&self) -> &SitePermissionAuditAction { + &self.action + } + + #[must_use] + pub fn created_at(&self) -> SystemTime { + self.created_at + } +} + +fn site_origin_from_url(url: &Url) -> Result { + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(DomainError::InvalidSiteOrigin { value: url.to_string() }); + } + + Ok(SiteOrigin(url.origin().ascii_serialization())) +} diff --git a/crates/ely_domain/src/sync.rs b/crates/ely_domain/src/sync.rs index 696c6c3..a7f671e 100644 --- a/crates/ely_domain/src/sync.rs +++ b/crates/ely_domain/src/sync.rs @@ -10,6 +10,7 @@ pub enum SyncObjectKind { Bookmarks, ReadingList, Profiles, + SitePermissions, History, PluginSettings, }