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
+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),
],