From 7823ecd0a3a425c27905d6d807523e9425c8b8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 16 May 2026 07:12:52 -0400 Subject: [PATCH] feat(sync): include plugin settings in snapshots --- crates/ely_browser_core/src/state.rs | 1 + crates/ely_browser_core/src/state/plugins.rs | 12 ++ .../ely_browser_core/src/state/sync_apply.rs | 3 + .../src/state/sync_plugin_settings.rs | 38 +++++ crates/ely_browser_core/src/sync_records.rs | 27 ++++ .../tests/sync_plugin_settings.rs | 149 ++++++++++++++++++ 6 files changed, 230 insertions(+) create mode 100644 crates/ely_browser_core/src/state/sync_plugin_settings.rs create mode 100644 crates/ely_browser_core/tests/sync_plugin_settings.rs diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 5825a9f..6f2bcb2 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -34,6 +34,7 @@ mod sync_apply; mod sync_context; mod sync_history; mod sync_notes; +mod sync_plugin_settings; mod sync_profiles; mod sync_reading_list; mod sync_site_permissions; diff --git a/crates/ely_browser_core/src/state/plugins.rs b/crates/ely_browser_core/src/state/plugins.rs index 49c7562..a9a7627 100644 --- a/crates/ely_browser_core/src/state/plugins.rs +++ b/crates/ely_browser_core/src/state/plugins.rs @@ -51,6 +51,18 @@ impl InstalledPlugin { self.private_window_allowed = allowed; } + pub(super) fn apply_synced_settings( + &mut self, + enabled: bool, + private_window_allowed: bool, + ) -> bool { + let changed = + self.enabled != enabled || self.private_window_allowed != private_window_allowed; + self.enabled = enabled; + self.private_window_allowed = private_window_allowed; + changed + } + #[must_use] pub fn manifest(&self) -> &PluginManifest { &self.manifest diff --git a/crates/ely_browser_core/src/state/sync_apply.rs b/crates/ely_browser_core/src/state/sync_apply.rs index c26227f..cb1dfd0 100644 --- a/crates/ely_browser_core/src/state/sync_apply.rs +++ b/crates/ely_browser_core/src/state/sync_apply.rs @@ -34,6 +34,9 @@ impl BrowserCore { for record in body.history { self.apply_history_sync_record(record, &mut summary, &context)?; } + for record in body.plugin_settings { + self.apply_plugin_settings_sync_record(record, &mut summary)?; + } Ok(summary) } } diff --git a/crates/ely_browser_core/src/state/sync_plugin_settings.rs b/crates/ely_browser_core/src/state/sync_plugin_settings.rs new file mode 100644 index 0000000..0e7348e --- /dev/null +++ b/crates/ely_browser_core/src/state/sync_plugin_settings.rs @@ -0,0 +1,38 @@ +use ely_domain::{PluginId, SyncObjectKind, SyncObjectPolicy}; +use ely_sync_client::SyncClientError; + +use super::{BrowserCore, InstalledPlugin, sync::snapshot_schema_error}; +use crate::{sync_engine::SyncSnapshotApplySummary, sync_records::PluginSettingsSyncRecord}; + +impl BrowserCore { + pub(crate) fn visible_plugin_settings_for_sync(&self) -> Vec<&InstalledPlugin> { + if self.sync_object_policy(SyncObjectKind::PluginSettings) == SyncObjectPolicy::Paused { + return Vec::new(); + } + self.installed_plugins.iter().collect() + } + + pub(super) fn apply_plugin_settings_sync_record( + &mut self, + record: PluginSettingsSyncRecord, + summary: &mut SyncSnapshotApplySummary, + ) -> Result<(), SyncClientError> { + let plugin_id = PluginId::parse(&record.plugin_id).map_err(snapshot_schema_error)?; + let Some(plugin) = + self.installed_plugins.iter_mut().find(|plugin| plugin.id() == &plugin_id) + else { + summary.record_skipped(); + return Ok(()); + }; + if plugin.manifest().checksum() != record.checksum { + summary.record_skipped(); + return Ok(()); + } + if plugin.apply_synced_settings(record.enabled, record.private_window_allowed) { + summary.record_updated(); + } else { + summary.record_skipped(); + } + Ok(()) + } +} diff --git a/crates/ely_browser_core/src/sync_records.rs b/crates/ely_browser_core/src/sync_records.rs index 403a4ae..0eb249c 100644 --- a/crates/ely_browser_core/src/sync_records.rs +++ b/crates/ely_browser_core/src/sync_records.rs @@ -8,6 +8,7 @@ use ely_domain::{ use serde::{Deserialize, Serialize}; use crate::state::BrowserCore; +use crate::state::InstalledPlugin; pub(crate) const SNAPSHOT_SCHEMA_REV: u32 = 1; @@ -29,6 +30,8 @@ pub(crate) struct SyncSnapshotBody { pub(crate) site_permissions: Vec, #[serde(default)] pub(crate) history: Vec, + #[serde(default)] + pub(crate) plugin_settings: Vec, } impl SyncSnapshotBody { @@ -91,6 +94,11 @@ impl SyncSnapshotBody { HistorySyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) }) .collect(), + plugin_settings: core + .visible_plugin_settings_for_sync() + .into_iter() + .map(PluginSettingsSyncRecord::from_plugin) + .collect(), } } } @@ -440,6 +448,25 @@ impl HistorySyncRecord { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct PluginSettingsSyncRecord { + pub(crate) plugin_id: String, + pub(crate) checksum: String, + pub(crate) enabled: bool, + pub(crate) private_window_allowed: bool, +} + +impl PluginSettingsSyncRecord { + fn from_plugin(plugin: &InstalledPlugin) -> Self { + Self { + plugin_id: plugin.id().as_str().to_string(), + checksum: plugin.manifest().checksum().to_string(), + enabled: plugin.enabled(), + private_window_allowed: plugin.private_window_allowed(), + } + } +} + fn default_sync_enabled() -> bool { true } diff --git a/crates/ely_browser_core/tests/sync_plugin_settings.rs b/crates/ely_browser_core/tests/sync_plugin_settings.rs new file mode 100644 index 0000000..4c5af56 --- /dev/null +++ b/crates/ely_browser_core/tests/sync_plugin_settings.rs @@ -0,0 +1,149 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{PluginManifest, SyncObjectKind, SyncObjectPolicy}; + +#[test] +fn sync_snapshot_updates_existing_plugin_settings() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let plugin_id = + source.install_plugin(plugin_manifest("com.elydora.reader", DEFAULT_CHECKSUM)?, false)?; + source.set_plugin_private_window_allowed(&plugin_id, true)?; + source.disable_plugin(&plugin_id)?; + pause_tab_sync(&mut source); + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_plugin_id = + target.install_plugin(plugin_manifest("com.elydora.reader", DEFAULT_CHECKSUM)?, false)?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [plugin] = snapshot.installed_plugins.as_slice() else { + return Err(format!( + "expected 1 installed plugin, got {}", + snapshot.installed_plugins.len() + ) + .into()); + }; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 1); + assert_eq!(summary.skipped(), 0); + assert_eq!(plugin.id(), &target_plugin_id); + assert!(!plugin.enabled()); + assert!(plugin.private_window_allowed()); + assert_eq!(snapshot.plugin_audit_events.len(), 1); + Ok(()) +} + +#[test] +fn sync_snapshot_omits_paused_plugin_settings() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let plugin_id = + source.install_plugin(plugin_manifest("com.elydora.reader", DEFAULT_CHECKSUM)?, false)?; + source.disable_plugin(&plugin_id)?; + source.set_sync_object_policy(SyncObjectKind::PluginSettings, SyncObjectPolicy::Paused); + pause_tab_sync(&mut source); + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + target.install_plugin(plugin_manifest("com.elydora.reader", DEFAULT_CHECKSUM)?, false)?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [plugin] = snapshot.installed_plugins.as_slice() else { + return Err(format!( + "expected 1 installed plugin, got {}", + snapshot.installed_plugins.len() + ) + .into()); + }; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert!(plugin.enabled()); + assert!(!plugin.private_window_allowed()); + Ok(()) +} + +#[test] +fn sync_snapshot_skips_missing_plugin_package() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let plugin_id = + source.install_plugin(plugin_manifest("com.elydora.reader", DEFAULT_CHECKSUM)?, false)?; + source.disable_plugin(&plugin_id)?; + pause_tab_sync(&mut source); + 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(), 1); + assert!(snapshot.installed_plugins.is_empty()); + Ok(()) +} + +#[test] +fn sync_snapshot_skips_checksum_mismatched_plugin_package() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let plugin_id = + source.install_plugin(plugin_manifest("com.elydora.reader", DEFAULT_CHECKSUM)?, false)?; + source.disable_plugin(&plugin_id)?; + pause_tab_sync(&mut source); + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + target.install_plugin(plugin_manifest("com.elydora.reader", OTHER_CHECKSUM)?, false)?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [plugin] = snapshot.installed_plugins.as_slice() else { + return Err(format!( + "expected 1 installed plugin, got {}", + snapshot.installed_plugins.len() + ) + .into()); + }; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 1); + assert!(plugin.enabled()); + assert_eq!(plugin.manifest().checksum(), OTHER_CHECKSUM.to_ascii_lowercase()); + Ok(()) +} + +const DEFAULT_CHECKSUM: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const OTHER_CHECKSUM: &str = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + +fn plugin_manifest(id: &str, checksum: &str) -> Result> { + PluginManifest::from_toml(plugin_manifest_toml(id, checksum).as_str()).map_err(Into::into) +} + +fn pause_tab_sync(core: &mut BrowserCore) { + core.set_sync_object_policy(SyncObjectKind::Tabs, SyncObjectPolicy::Paused); +} + +fn plugin_manifest_toml(id: &str, checksum: &str) -> String { + format!( + r#" +id = "{id}" +name = "Reader Exporter" +description = "Exports the active reader view." +author = "Elydora" +homepage = "https://elydora.com/plugins/reader" +permissions = ["page:metadata", "ui:command"] +contributes = ["command-bar-command"] +min_ely_build = "0.1.0" +checksum = "{checksum}" + +[signature] +algorithm = "ed25519" +key_id = "elydora-alpha-plugins" +public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +value = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +"# + ) +}