diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 6a09e1f..782a7d8 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -35,6 +35,7 @@ mod sync_context; mod sync_notes; mod sync_profiles; mod sync_reading_list; +mod sync_site_permissions; mod tab_group_order; mod tab_groups; mod tab_lifecycle; diff --git a/crates/ely_browser_core/src/state/sync_apply.rs b/crates/ely_browser_core/src/state/sync_apply.rs index d1112e1..f2e9ce2 100644 --- a/crates/ely_browser_core/src/state/sync_apply.rs +++ b/crates/ely_browser_core/src/state/sync_apply.rs @@ -28,6 +28,9 @@ impl BrowserCore { for record in body.reading_list { self.apply_reading_list_sync_record(record, &mut summary, &context)?; } + for record in body.site_permissions { + self.apply_site_permission_sync_record(record, &mut summary, &context)?; + } Ok(summary) } } diff --git a/crates/ely_browser_core/src/state/sync_site_permissions.rs b/crates/ely_browser_core/src/state/sync_site_permissions.rs new file mode 100644 index 0000000..169e9bf --- /dev/null +++ b/crates/ely_browser_core/src/state/sync_site_permissions.rs @@ -0,0 +1,53 @@ +use ely_domain::{ + SiteOrigin, SitePermissionDecision, SitePermissionEntry, SitePermissionFeature, SyncObjectKind, + SyncObjectPolicy, +}; +use ely_sync_client::SyncClientError; + +use super::{BrowserCore, sync::snapshot_schema_error, sync_context::SyncSnapshotApplyContext}; +use crate::{sync_engine::SyncSnapshotApplySummary, sync_records::SitePermissionSyncRecord}; + +impl BrowserCore { + pub(crate) fn visible_site_permissions_for_sync(&self) -> Vec<&SitePermissionEntry> { + if self.sync_object_policy(SyncObjectKind::SitePermissions) == SyncObjectPolicy::Paused { + return Vec::new(); + } + self.site_permissions + .iter() + .filter(|entry| self.profile_allows_cloud_sync(entry.profile_id())) + .collect() + } + + pub(super) fn apply_site_permission_sync_record( + &mut self, + record: SitePermissionSyncRecord, + summary: &mut SyncSnapshotApplySummary, + context: &SyncSnapshotApplyContext, + ) -> Result<(), SyncClientError> { + let profile_id = self.sync_profile_id(&record.profile_id, context)?; + let origin = SiteOrigin::parse(record.origin).map_err(snapshot_schema_error)?; + let feature = + SitePermissionFeature::parse(&record.feature).map_err(snapshot_schema_error)?; + let decision = + SitePermissionDecision::parse(&record.decision).map_err(snapshot_schema_error)?; + let existing_index = self.site_permissions.iter().position(|entry| { + entry.profile_id() == &profile_id + && entry.origin() == &origin + && entry.feature() == feature + }); + let entry = SitePermissionEntry::new(profile_id, origin, feature, decision); + + match existing_index { + Some(index) if self.site_permissions[index] == entry => summary.record_skipped(), + Some(index) => { + self.site_permissions[index] = entry; + summary.record_updated(); + } + None => { + self.site_permissions.push(entry); + summary.record_imported(); + } + } + Ok(()) + } +} diff --git a/crates/ely_browser_core/src/sync_records.rs b/crates/ely_browser_core/src/sync_records.rs index 6aef27e..a92d86e 100644 --- a/crates/ely_browser_core/src/sync_records.rs +++ b/crates/ely_browser_core/src/sync_records.rs @@ -2,7 +2,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use ely_domain::{ ArchivePolicy, BookmarkEntry, BrowserTab, NoteEntry, NoteTarget, Profile, ProfileKind, - ProfileSyncPolicy, ReadingListEntry, ReadingProgress, Space, TabFlags, + ProfileSyncPolicy, ReadingListEntry, ReadingProgress, SitePermissionEntry, Space, TabFlags, }; use serde::{Deserialize, Serialize}; @@ -24,6 +24,8 @@ pub(crate) struct SyncSnapshotBody { pub(crate) notes: Vec, #[serde(default)] pub(crate) reading_list: Vec, + #[serde(default)] + pub(crate) site_permissions: Vec, } impl SyncSnapshotBody { @@ -74,6 +76,11 @@ impl SyncSnapshotBody { ) }) .collect(), + site_permissions: core + .visible_site_permissions_for_sync() + .into_iter() + .map(SitePermissionSyncRecord::from_entry) + .collect(), } } } @@ -373,6 +380,25 @@ impl ReadingProgressSyncRecord { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct SitePermissionSyncRecord { + pub(crate) profile_id: String, + pub(crate) origin: String, + pub(crate) feature: String, + pub(crate) decision: String, +} + +impl SitePermissionSyncRecord { + fn from_entry(entry: &SitePermissionEntry) -> Self { + Self { + profile_id: entry.profile_id().as_str().to_string(), + origin: entry.origin().as_str().to_string(), + feature: entry.feature().as_str().to_string(), + decision: entry.decision().as_str().to_string(), + } + } +} + fn default_sync_enabled() -> bool { true } diff --git a/crates/ely_browser_core/tests/sync_profiles.rs b/crates/ely_browser_core/tests/sync_profiles.rs index fdca504..ff010cb 100644 --- a/crates/ely_browser_core/tests/sync_profiles.rs +++ b/crates/ely_browser_core/tests/sync_profiles.rs @@ -1,7 +1,10 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{ProfileKind, ProfileSyncPolicy, UrlText}; +use ely_domain::{ + ProfileKind, ProfileSyncPolicy, SiteOrigin, SitePermissionDecision, SitePermissionFeature, + UrlText, +}; #[test] fn sync_snapshot_imports_remote_profiles_before_spaces() -> Result<(), Box> { @@ -76,6 +79,11 @@ fn paused_profile_data_is_omitted_from_sync_snapshots() -> Result<(), Box Result<(), Box Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let origin = SiteOrigin::parse("https://example.com")?; + source.set_site_permission( + origin.clone(), + SitePermissionFeature::Camera, + SitePermissionDecision::AllowAlways, + )?; + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_profile_id = target.snapshot()?.active_profile_id; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [entry] = snapshot.site_permissions.as_slice() else { + return Err( + format!("expected 1 site permission, got {}", snapshot.site_permissions.len()).into() + ); + }; + + assert_eq!(summary.imported(), 1); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert_eq!(entry.profile_id(), &target_profile_id); + assert_eq!(entry.origin(), &origin); + assert_eq!(entry.feature(), SitePermissionFeature::Camera); + assert_eq!(entry.decision(), SitePermissionDecision::AllowAlways); + assert!(snapshot.site_permission_audit_events.is_empty()); + Ok(()) +} + +#[test] +fn sync_snapshot_updates_existing_site_permission() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let origin = SiteOrigin::parse("https://example.com")?; + source.set_site_permission( + origin.clone(), + SitePermissionFeature::Notifications, + SitePermissionDecision::AllowAlways, + )?; + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + target.set_site_permission( + origin, + SitePermissionFeature::Notifications, + SitePermissionDecision::DenyAlways, + )?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [entry] = snapshot.site_permissions.as_slice() else { + return Err( + format!("expected 1 site permission, got {}", snapshot.site_permissions.len()).into() + ); + }; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 1); + assert_eq!(summary.skipped(), 0); + assert_eq!(entry.feature(), SitePermissionFeature::Notifications); + assert_eq!(entry.decision(), SitePermissionDecision::AllowAlways); + assert_eq!(snapshot.site_permission_audit_events.len(), 1); + Ok(()) +} + +#[test] +fn sync_snapshot_omits_paused_site_permissions() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + source.set_site_permission( + SiteOrigin::parse("https://example.com")?, + SitePermissionFeature::Popups, + SitePermissionDecision::DenyAlways, + )?; + source.set_sync_object_policy(SyncObjectKind::SitePermissions, SyncObjectPolicy::Paused); + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert!(snapshot.site_permissions.is_empty()); + Ok(()) +}