Add site permission settings

This commit is contained in:
2026-05-08 01:24:10 -04:00
parent b91f2c0ca1
commit 84149ea4df
16 changed files with 1064 additions and 3 deletions
@@ -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),
@@ -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<Self>,
) -> 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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<SitePermissionDecision>,
cx: &mut Context<ElyShell>,
) -> 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<ElyShell>,
) -> 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::<Vec<_>>();
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<SitePermissionDecision> {
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<SitePermissionDecision>) -> 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.",
}
}
@@ -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",
}
+1
View File
@@ -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};
@@ -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<Self>,
) {
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<Self>,
) {
if let ShellState::Ready(core) = &mut self.state
&& core.revoke_site_permission(&origin, feature).is_ok()
{
cx.notify();
}
}
}
+2 -1
View File
@@ -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"),
+11 -2
View File
@@ -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<ArchivedTab>,
pub bookmarks: Vec<BookmarkEntry>,
pub reading_list: Vec<ReadingListEntry>,
pub site_permissions: Vec<SitePermissionEntry>,
pub site_permission_audit_events: Vec<SitePermissionAuditEvent>,
pub download_entries: Vec<DownloadEntry>,
pub history_entries: Vec<HistoryEntry>,
pub installed_plugins: Vec<InstalledPlugin>,
@@ -71,6 +74,8 @@ pub struct BrowserCore {
archived_tabs: Vec<ArchivedTab>,
bookmarks: Vec<BookmarkEntry>,
reading_list: Vec<ReadingListEntry>,
site_permissions: Vec<SitePermissionEntry>,
site_permission_audit_events: Vec<SitePermissionAuditEvent>,
download_entries: Vec<DownloadEntry>,
history_entries: Vec<HistoryEntry>,
installed_plugins: Vec<InstalledPlugin>,
@@ -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(),
@@ -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)
@@ -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<Option<UrlText>, 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<SitePermissionEntry> {
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<SitePermissionAuditEvent> {
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<usize> {
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(),
));
}
}
@@ -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(),
@@ -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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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(())
}
+1
View File
@@ -25,6 +25,7 @@ fn default_sync_status_reflects_local_browser_state() -> Result<(), Box<dyn Erro
SyncObjectStatus::new(SyncObjectKind::Bookmarks, 1, SyncObjectState::LocalOnly),
SyncObjectStatus::new(SyncObjectKind::ReadingList, 1, SyncObjectState::LocalOnly),
SyncObjectStatus::new(SyncObjectKind::Profiles, 1, SyncObjectState::LocalOnly),
SyncObjectStatus::new(SyncObjectKind::SitePermissions, 0, SyncObjectState::LocalOnly),
SyncObjectStatus::new(SyncObjectKind::History, 1, SyncObjectState::PrivacyControlled),
SyncObjectStatus::new(SyncObjectKind::PluginSettings, 0, SyncObjectState::LocalOnly),
],
+3
View File
@@ -8,6 +8,9 @@ pub enum DomainError {
#[error("invalid URL: {value}")]
InvalidUrl { value: String },
#[error("invalid site origin: {value}")]
InvalidSiteOrigin { value: String },
#[error("invalid file name: {value}")]
InvalidFileName { value: String },
+5
View File
@@ -8,6 +8,7 @@ mod identifiers;
mod plugin;
mod profile;
mod reading_list;
mod site_permission;
mod space;
mod split;
mod sync;
@@ -32,6 +33,10 @@ pub use plugin::{
};
pub use profile::{Profile, ProfileKind};
pub use reading_list::{ReadingListEntry, ReadingProgress};
pub use site_permission::{
SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, SitePermissionDecision,
SitePermissionEntry, SitePermissionFeature,
};
pub use space::{ArchivePolicy, Space};
pub use split::{SplitAxis, SplitLayout, SplitPane};
pub use sync::{
+259
View File
@@ -0,0 +1,259 @@
use std::time::SystemTime;
use url::Url;
use crate::{DomainError, ProfileId, UrlText};
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct SiteOrigin(String);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SitePermissionFeature {
Camera,
Microphone,
ScreenCapture,
Location,
Notifications,
ClipboardRead,
ClipboardWrite,
Downloads,
Popups,
Autoplay,
WebUsb,
WebHid,
WebSerial,
StoragePersistence,
InsecureContent,
CertificateException,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SitePermissionDecision {
AllowOnce,
AllowAlways,
DenyAlways,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SitePermissionEntry {
profile_id: ProfileId,
origin: SiteOrigin,
feature: SitePermissionFeature,
decision: SitePermissionDecision,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SitePermissionAuditAction {
Set(SitePermissionDecision),
Revoked,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SitePermissionAuditEvent {
profile_id: ProfileId,
origin: SiteOrigin,
feature: SitePermissionFeature,
action: SitePermissionAuditAction,
created_at: SystemTime,
}
impl SiteOrigin {
pub fn parse(value: impl Into<String>) -> Result<Self, DomainError> {
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<Option<Self>, 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<Option<Self>, 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, DomainError> {
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<SiteOrigin, DomainError> {
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()))
}
+1
View File
@@ -10,6 +10,7 @@ pub enum SyncObjectKind {
Bookmarks,
ReadingList,
Profiles,
SitePermissions,
History,
PluginSettings,
}