use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use cloud_object_models::JsonSerializer; use galaxy_core::execution_mode::AppExecutionMode; use galaxy_core::r#async::debounce; use galaxy_core::settings::ChangeEventReason; use galaxy_core::user_preferences::GetUserPreferences; use galaxyui::r#async::Timer; use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity}; use galaxyui_extras::user_preferences::toml_backed::TomlBackedUserPreferences; use lazy_static::lazy_static; use settings::{Setting as _, SyncToCloud}; use super::cloud_preferences::{CloudPreferencesSettings, CloudPreferencesSettingsChangedEvent}; use super::manager::SettingsEvent; use super::PrivacySettings; use crate::auth::auth_state::AuthState; use crate::cloud_object::model::generic_string_model::GenericStringObjectId; use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{CloudObjectEventEntrypoint, GenericStringObjectFormat, JsonObjectType}; use crate::drive::CloudObjectTypeAndId; use crate::report_if_error; use crate::server::cloud_objects::update_manager::{ GenericStringObjectInput, InitiatedBy, UpdateManager, UpdateManagerEvent, }; use crate::server::ids::{ClientId, SyncId}; use crate::server::sync_queue::{SyncQueue, SyncQueueEvent}; use crate::settings::cloud_preferences::{ CloudPreference, CloudPreferenceModel, Platform, Preference, }; use crate::settings::manager::SettingsManager; use crate::workspaces::user_workspaces::UserWorkspaces; /// Provides client ids for creating cloud preferences. /// We define this as a trait so tests can track what client ids are created and use /// them for mocking server responses. pub trait ClientIdProvider { fn next_client_id(&self) -> ClientId; } struct DefaultClientIdProvider; impl ClientIdProvider for DefaultClientIdProvider { fn next_client_id(&self) -> ClientId { ClientId::new() } } /// Key used to persist the hash of the settings file content as of the /// last successful cloud sync reconciliation. Used on next startup to /// detect whether the user made local changes (via file edit or offline /// UI change) that cloud sync doesn't know about yet. pub(super) const SETTINGS_FILE_LAST_SYNCED_HASH_KEY: &str = "SettingsFileLastSyncedHash"; /// Constructs the cloud preferences syncer, computing the /// `force_local_wins_on_startup` flag by comparing the current settings /// file hash against the last-synced hash stored in private preferences. /// /// This is the only entry point used to construct the syncer at app /// startup; production code in `lib.rs` and end-to-end tests both call /// it so they exercise the same code path. pub fn initialize_cloud_preferences_syncer( toml_file_path: PathBuf, startup_toml_parse_error: Option<&str>, ctx: &mut ModelContext, ) -> CloudPreferencesSyncer { let current_hash = TomlBackedUserPreferences::file_content_hash(&toml_file_path); let stored_hash = ctx .private_user_preferences() .read_value(SETTINGS_FILE_LAST_SYNCED_HASH_KEY) .unwrap_or_default(); let file_has_unsynced_changes = match (current_hash, stored_hash) { // File present, stored hash present: trust the comparison. (Some(current), Some(stored)) => current != stored, // File present, no stored hash (first launch, fresh install, or // the stored hash was cleared): cloud wins, consistent with // today's behavior. (Some(_), None) => false, // File missing/empty, stored hash present (user deleted or // emptied the file): cloud wins. If we treated this as "local // differs" we'd upload defaults and wipe the user's cloud // settings — exactly what they likely don't want. (None, Some(_)) => false, // File missing/empty, no stored hash (fresh install with no // file yet): cloud wins. (None, None) => false, }; // Broken-file guard: when the file can't be parsed, there are no // meaningful local values to preserve. Cloud sync restores settings // in memory while flush suppression protects the broken file on // disk. let force_local_wins_on_startup = file_has_unsynced_changes && startup_toml_parse_error.is_none(); CloudPreferencesSyncer::new(force_local_wins_on_startup, toml_file_path, ctx) } /// Handles syncing CloudPreferences (the Warp Drive objects) and local Settings models that /// have been created using the define_settings_group macro. pub struct CloudPreferencesSyncer { // A channel used for debouncing local settings updates so that we don't spam the // server with requests. Most important for settings that continuously update // like ones that are driven by sliders. #[allow(dead_code)] update_tx: async_channel::Sender<()>, // Local prefs awaiting syncing to the cloud after a debounce period. dirty_local_prefs: HashSet, // Provides the next ClientId to use in creating cloud preferences. client_id_provider: Arc, has_completed_initial_load: bool, /// When `true`, the first `handle_initial_load` will force local /// values to be uploaded to cloud rather than accepting cloud /// values — equivalent to `ForceCloudToMatchLocal::Yes`. Only /// consulted on the first initial load; subsequent `sync()` calls /// use their own flag. force_local_wins_on_startup: bool, /// Path to the user's `settings.toml` file, used by /// `update_stored_settings_hash` to compute the hash persisted /// after every successful cloud sync reconciliation. toml_file_path: PathBuf, } /// Event fired by the CloudPreferencesSyncer when a cloud preference has changed. #[derive(Debug)] pub enum CloudPreferencesSyncerEvent { /// Emitted when the local preferences are updated to match values from cloud upon initial load. InitialLoadCompleted, /// Event variant indicating there's a new value for the preference with /// a specific storage key Updated { key: String, value: String }, } /// Whether to force the cloud to match the local settings or not. /// Used to force a resync of the cloud state to the current local state when a /// user manually re-enables settings sync. #[derive(Debug)] pub enum ForceCloudToMatchLocal { Yes, No, } struct PreferenceToCreate { value: String, syncing_mode: SyncToCloud, } lazy_static! { static ref LEGACY_CLOUD_SETTINGS_STORAGE_KEYS: Vec<&'static str> = vec![ super::privacy::TELEMETRY_ENABLED_DEFAULTS_KEY, super::privacy::CRASH_REPORTING_ENABLED_DEFAULTS_KEY, super::privacy::CLOUD_CONVERSATION_STORAGE_ENABLED_DEFAULTS_KEY, ]; } const PREFERENCES_DEBOUNCE_PERIOD: Duration = Duration::from_millis(500); impl CloudPreferencesSyncer { // Retry preferences every five minutes until they are successfully synced. // Only enabled for users in the warp drive preferences experiment. const RETRY_POLL: Duration = Duration::from_secs(60 * 5); #[cfg(test)] pub fn new_for_test( ctx: &mut ModelContext, client_id_provider: Arc, ) -> Self { // Existing tests do not exercise the hash-update path; the // default empty path causes `file_content_hash` to return // `None` and `update_stored_settings_hash` becomes a no-op. // End-to-end tests that care about the hash path construct // the syncer via `initialize_cloud_preferences_syncer`. Self::new_internal(ctx, client_id_provider, PathBuf::new()) } pub fn new( force_local_wins_on_startup: bool, toml_file_path: PathBuf, ctx: &mut ModelContext, ) -> Self { let mut me = Self::new_internal(ctx, Arc::new(DefaultClientIdProvider), toml_file_path); me.force_local_wins_on_startup = force_local_wins_on_startup; me.retry_failed_settings(ctx); me } fn new_internal( ctx: &mut ModelContext, client_id_provider: Arc, toml_file_path: PathBuf, ) -> Self { // Set up event syncing in both directions (local -> cloud and cloud -> local). // We only apply cloud->local updates AFTER the initial load has been processed by // handle_initial_load. This prevents the CloudPreferencesUpdated event (which fires // synchronously in on_changed_objects_fetched) from overwriting local settings // before handle_initial_load has a chance to determine sync direction. ctx.subscribe_to_model(&UpdateManager::handle(ctx), |syncer, _, event, ctx| { if let UpdateManagerEvent::CloudPreferencesUpdated { updated } = event { // Defer cloud→local updates until `handle_initial_load` // has determined the correct sync direction. The // `CloudPreferencesUpdated` event fires synchronously // during `on_changed_objects_fetched` and would // overwrite local settings before `handle_initial_load` // gets a chance to decide whether local or cloud wins. if !syncer.has_completed_initial_load { return; } for preference in updated { syncer.maybe_sync_cloud_pref_to_local(&preference.storage_key, ctx); } } }); let (update_tx, update_rx) = async_channel::unbounded(); ctx.spawn_stream_local( debounce(PREFERENCES_DEBOUNCE_PERIOD, update_rx), |me, _, ctx| { let prefs_to_sync = me.dirty_local_prefs.drain().collect(); me.maybe_sync_local_prefs_to_cloud(prefs_to_sync, ctx); }, |_, _| {}, ); ctx.subscribe_to_model( &SettingsManager::handle(ctx), |me, _, event, ctx| match event { SettingsEvent::LocalPreferencesUpdated { storage_key, .. } => { me.handle_local_preference_updated(storage_key, ctx); } }, ); // Update the stored settings file hash whenever a preference is // successfully created or updated on the server. This ensures the // hash only moves forward when the cloud has actually accepted // local changes — if the upload fails (e.g. offline), the hash // stays stale and the next startup will correctly detect // divergence. ctx.subscribe_to_model(&SyncQueue::handle(ctx), Self::handle_sync_queue_event); ctx.subscribe_to_model( &CloudPreferencesSettings::handle(ctx), |me, _, event, ctx| match event { CloudPreferencesSettingsChangedEvent::IsSettingsSyncEnabled { change_event_reason, } => { let force_cloud_to_match_local = match change_event_reason { ChangeEventReason::CloudSync => ForceCloudToMatchLocal::No, ChangeEventReason::LocalChange => ForceCloudToMatchLocal::Yes, ChangeEventReason::Clear => { log::info!( "Not resyncing cloud preferences because the setting was cleared \ (typically on logout)" ); return; } }; log::info!( "Settings sync enabled setting changed. Resyncing cloud preferences. Force \ cloud to match local: {force_cloud_to_match_local:?}" ); // Always resync from the local client when the setting changes, // but only force cloud to match this client's local settings if the change in the setting // was initiated in this client. me.sync(force_cloud_to_match_local, ctx); } }, ); Self { update_tx, dirty_local_prefs: HashSet::new(), client_id_provider, has_completed_initial_load: false, force_local_wins_on_startup: false, toml_file_path, } } /// Handles SyncQueue success events by updating the stored /// settings file hash when a cloud preference is successfully /// created or updated on the server. fn handle_sync_queue_event( &mut self, _: ModelHandle, event: &SyncQueueEvent, ctx: &mut ModelContext, ) { let server_id = match event { SyncQueueEvent::ObjectCreationSuccessful { server_creation_info, .. } => Some(server_creation_info.server_id_and_type.id), SyncQueueEvent::ObjectUpdateSuccessful { server_id, .. } => Some(*server_id), _ => None, }; if let Some(server_id) = server_id { // Check whether this object is a cloud preference. // GenericStringObject is a superset that also includes // env var collections, workflow enums, MCP servers, etc. // Only preference changes should update the stored hash. let sync_id = SyncId::ServerId(server_id); let is_preference = CloudModel::as_ref(ctx) .get_all_cloud_preferences_by_storage_key() .values() .any(|pref| pref.id == sync_id); if is_preference { self.update_stored_settings_hash(ctx); } } } /// Reads the current settings file hash from disk and persists it /// as the last-synced hash in private preferences. Called at every /// sync reconciliation point so that on the next startup, the /// stored hash accurately reflects what the cloud last saw. /// /// This is a no-op when the file is missing, empty, or unreadable /// (`file_content_hash` returns `None`). fn update_stored_settings_hash(&self, ctx: &mut ModelContext) { let Some(hash) = TomlBackedUserPreferences::file_content_hash(&self.toml_file_path) else { return; }; if let Err(err) = ctx .private_user_preferences() .write_value(SETTINGS_FILE_LAST_SYNCED_HASH_KEY, hash) { log::warn!("Failed to persist settings file hash after sync: {err}"); } } #[cfg(not(test))] fn handle_local_preference_updated(&mut self, storage_key: &str, _: &mut ModelContext) { self.dirty_local_prefs.insert(storage_key.to_string()); let _ = self.update_tx.try_send(()); } #[cfg(test)] fn handle_local_preference_updated(&mut self, storage_key: &str, ctx: &mut ModelContext) { // Don't debounce in tests - they have enough async stuff going self.maybe_sync_local_prefs_to_cloud(vec![storage_key.to_string()], ctx); } /// This method recursively calls itself after a delay. Call it once and only once to start the /// loop. It ensures failed preferences are retried until they are successfully synced. fn retry_failed_settings(&mut self, ctx: &mut ModelContext) { ctx.spawn( async { Timer::after(Self::RETRY_POLL).await; }, |me, _, ctx| { let ids_to_retry = CloudModel::handle(ctx).update(ctx, |cloud_model, _ctx| { cloud_model .cloud_objects() .filter_map(move |object| { if !object.metadata().is_errored() { return None; } let settings_object: Option<&CloudPreference> = object.into(); settings_object.map(|object| object.id) }) .collect::>() }); if !ids_to_retry.is_empty() { log::info!( "Retrying {} failed preference objects...", ids_to_retry.len() ); } for sync_id in ids_to_retry { log::debug!("Retrying failed preference object with sync_id {sync_id:?}"); UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.resync_object( &CloudObjectTypeAndId::GenericStringObject { object_type: GenericStringObjectFormat::Json( JsonObjectType::Preference, ), id: sync_id, }, ctx, ); }); } me.retry_failed_settings(ctx); }, ); } /// Handler for when the user has been fetched. Potentially kicks off a sync. pub fn handle_user_fetched( &mut self, auth_state: Arc, ctx: &mut ModelContext, ) { let is_onboarded = auth_state.is_onboarded(); // Reset the initial load flag so that we re-evaluate sync direction // based on the new user's fresh cloud data rather than stale data from // a previous session (e.g. anonymous user's cloud prefs). self.has_completed_initial_load = false; // The startup hash-based override was computed for the app launch // and consumed on the first initial load. Clear it so it doesn't // re-trigger for the new user session. self.force_local_wins_on_startup = false; if is_onboarded == Some(false) { log::info!("Opting first-time user into cloud preferences"); CloudPreferencesSettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings.settings_sync_enabled.set_value(true, ctx)); }); } else { log::info!("Not opting existing user into cloud preferences"); } // Always trigger a sync explicitly. For not-yet-onboarded users the // set_value call above may be a no-op if settings_sync_enabled was // already true (e.g. from a prior anonymous session), so no change // event would fire. We need the sync to happen regardless so that // handle_initial_load can examine the *new* user's cloud state and // decide whether to preserve local settings (brand-new user, no cloud // prefs) or apply cloud settings (existing user with cloud prefs). self.sync(ForceCloudToMatchLocal::No, ctx); } /// Performs a settings sync. Checks internally to confirm that the correct settings are /// synced based on whether the user has opted in to settings sync. /// /// Specifically, this call spawns a future waiting for cloud preferences to load and then /// 1) If no cloud preferences exist yet, creates and stores them from the local prefs. /// 2) If cloud prefs do exist, they are merged into the local preferences, with the cloud /// value overwriting any local values for the same keys. This is only true if force_cloud_to_match_local /// is false. If force_cloud_to_match_local is true, then the local values will overwrite the cloud value. /// This is the behavior we want when a user is enabling settings sync manually in the UI - /// we should disregard any potentially stale cloud values and overwrite them with the current /// local settings. pub fn sync( &self, force_cloud_to_match_local: ForceCloudToMatchLocal, ctx: &mut ModelContext, ) { let update_manager = UpdateManager::as_ref(ctx); // We wait for the cloud objects to load because we need to know if there are any cloud preferences // to sync. ctx.spawn(update_manager.initial_load_complete(), move |me, _, ctx| { me.handle_initial_load(force_cloud_to_match_local, ctx); }); PrivacySettings::handle(ctx).update(ctx, |privacy_settings, ctx| { // Note that this also blocks on update_manager.initial_load_complete() privacy_settings.maybe_sync_with_warp_drive_prefs(ctx); }); } /// Fixes https://linear.app/warpdotdev/issue/CLD-2629/duplicate-prefs-for-users fn ensure_no_duplicate_cloud_prefs(&mut self, ctx: &mut ModelContext) { log::info!("Ensuring no duplicate cloud prefs"); let ids_to_delete = CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| { let cloud_prefs = cloud_model .get_all_objects_of_type::(); // First group all the cloud prefs by storage key. let mut prefs_by_storage_key = HashMap::new(); for pref in cloud_prefs { let storage_key = pref.model().string_model.storage_key.clone(); prefs_by_storage_key .entry(storage_key) .or_insert_with(Vec::new) .push(pref); } // Then, for any storage key that has multiple prefs (which is an error introduced in the linked issue // mentioned in the function comment), we delete all but whichever one is set as the current pref value on this client. // If none of the prefs have the current value, we delete all of them, and the local value will end // up being the value of the preference when we sync. let pref_ids = prefs_by_storage_key .iter() .filter_map(|(storage_key, prefs)| { if prefs.len() == 1 { return None; } let current_value = SettingsManager::as_ref(ctx) .read_local_setting_value(storage_key, ctx) .unwrap_or_default() .unwrap_or_default(); let current_pref_id = prefs .iter() .find(|pref| { let pref_value = pref.model().string_model.value.to_string(); pref_value == current_value }) .map(|pref| pref.id); log::debug!( "Cleaning up duplicate prefs for storage key {storage_key} and current pref value: {current_value:?}" ); let pref_ids = prefs.iter().filter_map(|pref| { let should_delete = current_pref_id != Some(pref.id); if should_delete { log::debug!( "Deleting duplicate pref with id {} for storage key {} with value {}", pref.id, storage_key, pref.model().string_model.value ); Some(pref.id) } else { None } }).collect::>(); Some(pref_ids) }) .collect::>(); pref_ids.iter().flatten().cloned().collect::>() }); for pref_id in ids_to_delete { UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.delete_object_with_initiated_by( CloudObjectTypeAndId::GenericStringObject { object_type: GenericStringObjectFormat::Json(JsonObjectType::Preference), id: pref_id, }, InitiatedBy::System, ctx, ); }); } } fn handle_initial_load( &mut self, force_cloud_to_match_local: ForceCloudToMatchLocal, ctx: &mut ModelContext, ) { self.ensure_no_duplicate_cloud_prefs(ctx); // First-load override: if the startup hash check detected // local changes that cloud sync doesn't know about, force // local to win on this one call. The `!has_completed_initial_load` // guard makes this a genuine one-time override — robust // against any future code path that calls `sync()` again. let force_cloud_to_match_local = if !self.has_completed_initial_load && self.force_local_wins_on_startup { log::info!( "Local has unsynced changes on startup; forcing cloud to match local \ on initial load" ); ForceCloudToMatchLocal::Yes } else { force_cloud_to_match_local }; log::info!( "Initial load complete, syncing cloud preferences. \ Force cloud to match local: {force_cloud_to_match_local:?}" ); // These are the preferences that the cloud model currently knows about (i.e. the // preferences that have been synced to the cloud) let prefs_in_cloud_model = CloudModel::as_ref(ctx) .get_all_cloud_preferences_by_storage_key() .keys() .cloned() .collect::>(); // Keys to sync is a list of all of the preferences that *should* be "cloud synced" // We identify these preferences by their storage key (which is the unique key they are // stored under in the local preferences store) // // Sort so that settings with `RespectUserSyncSetting::No` (i.e. // settings that sync regardless of the user's sync-enabled // toggle) are processed first. This ensures that // `IsSettingsSyncEnabled` is restored from cloud before other // settings check `settings_sync_enabled`. Without this, // HashMap iteration order could cause the sync-enabled flag to // still be at its default (false) when other settings are // processed, silently skipping them. let settings_manager = SettingsManager::as_ref(ctx); let mut keys_to_sync = settings_manager .all_storage_keys() .cloned() .collect::>(); keys_to_sync.sort_by_key(|key| { if settings_manager.sync_regardless_of_users_syncing_setting(key) { 0 } else { 1 } }); let mut keys_to_sync_to_cloud = Vec::new(); for storage_key in keys_to_sync { if prefs_in_cloud_model.contains(&storage_key) && matches!(force_cloud_to_match_local, ForceCloudToMatchLocal::No) { // Update local pref to match cloud pref unless we are doing a forced preferences sync. self.maybe_sync_cloud_pref_to_local(&storage_key, ctx) } else if !LEGACY_CLOUD_SETTINGS_STORAGE_KEYS.contains(&storage_key.as_str()) { // For all settings except legacy cloud-synced settings, we sync them immediately to warp drive on // initial load. keys_to_sync_to_cloud.push(storage_key); } else { // This is one of the two legacy settings stored in the user_settings table and // it has not yet been saved to warp drive. In this case we want to wait for // these settings to load from the server, and then sync them to warp drive. // The logic for this is in privacy.rs. log::info!( "Waiting to sync legacy cloud preference with storage key {storage_key} until it is explicitly set" ); } } // Create a new cloud setting with the local value. self.maybe_sync_local_prefs_to_cloud(keys_to_sync_to_cloud, ctx); if !self.has_completed_initial_load { self.has_completed_initial_load = true; ctx.emit(CloudPreferencesSyncerEvent::InitialLoadCompleted); } // Reconciliation is complete (or a no-op). Persist the current // file hash so the next startup can detect further divergence. self.update_stored_settings_hash(ctx); } /// Syncs the local preferences with the given storage keys to the cloud. /// For each storage key, if there is an existing cloud preference for that key, it updates it. /// Otherwise, it creates a new one. All creations happen in a single bulk request. pub(crate) fn maybe_sync_local_prefs_to_cloud( &mut self, keys_to_sync: Vec, ctx: &mut ModelContext, ) { if !AppExecutionMode::as_ref(ctx).can_sync_preferences() { // Early exit if the app can't sync preferences. return; } let mut cloud_prefs_to_create = HashMap::new(); let cloud_prefs_by_storage_key = CloudModel::as_ref(ctx) .get_all_cloud_preferences_by_storage_key() .iter() .map(|(storage_key, cloud_pref)| (storage_key.clone(), (*cloud_pref).clone())) .collect::>(); let settings_sync_enabled = *CloudPreferencesSettings::as_ref(ctx) .settings_sync_enabled .value(); for storage_key in &keys_to_sync { let settings_manager = SettingsManager::as_ref(ctx); if !settings_sync_enabled && !settings_manager.sync_regardless_of_users_syncing_setting(storage_key) { // Skip syncing if settings sync is disabled and this particular cloud pref is not always synced. log::debug!("Not syncing cloud preference with storage key {storage_key} because settings sync is disabled for it"); continue; } let syncing_mode = settings_manager .cloud_syncing_mode_for_storage_key(storage_key) .unwrap_or(SyncToCloud::Never); if syncing_mode == SyncToCloud::Never { // Skip non-cloud-synced prefs continue; } let is_current_value_syncable = settings_manager .is_current_value_syncable(storage_key, ctx) .unwrap_or(false); if !is_current_value_syncable { // Don't sync this preference if the current value is not syncable. log::debug!("Not syncing cloud preference with storage key {storage_key} because the current value is not syncable"); continue; } let Ok(Some(local_value)) = settings_manager.read_local_setting_value(storage_key, ctx) else { log::debug!( "No local value set for preference with storage key {storage_key}. Skipping cloud sync." ); continue; }; let Some(supported_platforms) = SettingsManager::as_ref(ctx).supported_platforms_for_storage_key(storage_key) else { log::warn!( "No supported platforms found for preference with storage key {storage_key}. Skipping cloud sync." ); continue; }; if !supported_platforms.matches_current_platform() { log::debug!( "Preference with storage key {storage_key} is not supported on the current platform. Skipping cloud sync." ); continue; } if let Some(cloud_pref) = cloud_prefs_by_storage_key.get(storage_key) { self.maybe_update_cloud_pref_to_match_local( storage_key, syncing_mode, cloud_pref, &local_value, ctx, ); } else { cloud_prefs_to_create.insert( storage_key.clone(), PreferenceToCreate { value: local_value, syncing_mode, }, ); } } self.bulk_create_cloud_prefs_from_local(cloud_prefs_to_create, ctx); } fn maybe_update_cloud_pref_to_match_local( &self, storage_key: &str, syncing_mode: SyncToCloud, cloud_pref: &CloudPreference, local_value: &str, ctx: &mut ModelContext, ) { // Preference has already been synced to the cloud, so update it to the new value if it's different // than the current value. let cloud_value = &cloud_pref.model().string_model.value.to_string(); let settings_manager = SettingsManager::as_ref(ctx); let local_and_cloud_values_are_equal = match settings_manager.are_equal_settings(storage_key, local_value, cloud_value) { Ok(equal) => equal, Err(e) => { log::warn!( "Error {e} comparing local value {local_value} for cloud preference with \ storage key {storage_key}", ); return; } }; let model_revision_and_id = if local_and_cloud_values_are_equal { None } else { // Create a new instance of the cloud model with the new preference. let mut model = cloud_pref.model().clone(); match Preference::new(storage_key.to_owned(), local_value, syncing_mode) { Ok(updated_pref) => model.string_model = updated_pref, Err(e) => { log::warn!( "Error updating cloud preference with storage key {storage_key} from \ local value {local_value}: {e}" ); } } let revision = CloudModel::as_ref(ctx) .current_revision(&cloud_pref.id) .cloned(); Some((model, revision, cloud_pref.id)) }; if let Some((model, revision, id)) = model_revision_and_id { // Save the update. UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { log::info!( "Updating cloud preference with storage key {storage_key} to value \ {local_value}" ); update_manager.update_object(model, id, revision, ctx); }); } } fn bulk_create_cloud_prefs_from_local( &self, cloud_prefs_to_create: HashMap, ctx: &mut ModelContext, ) { let inputs = cloud_prefs_to_create .into_iter() .filter_map(|(storage_key, preference_to_create)| { // Create a new instance of the cloud model with the new preference. match Preference::new( storage_key.to_owned(), &preference_to_create.value, preference_to_create.syncing_mode, ) { Ok(new_pref) => Some(GenericStringObjectInput:: { id: self.client_id_provider.next_client_id(), model: CloudPreferenceModel::new(new_pref), initial_folder_id: None, entrypoint: CloudObjectEventEntrypoint::Unknown, }), Err(e) => { log::warn!("Error {e} creating cloud preference with {storage_key}"); None } } }) .collect::>(); let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) else { log::warn!("Unable to create cloud preferences due to unset personal drive"); return; }; // Preferences don't yet exist in the cloud, so create them. // Note that there is a potential race condition here with the same storage key being created // on different clients at the same time. The server handles this and will only accept the first // create request for each storage key. if !inputs.is_empty() { log::debug!( "Bulk creating {} generic string objects with storage keys {:?}", inputs.len(), inputs .iter() .map(|input| input.model.string_model.storage_key.clone()) .collect::>() ); UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.bulk_create_generic_string_objects(personal_drive, inputs, ctx); }); } } // Syncs the given cloud pref to local, if cloud syncing is enabled for the pref on this client. // Returns early if the pref with the given storage key isn't actually synced to the cloud. fn maybe_sync_cloud_pref_to_local(&self, storage_key: &str, ctx: &mut ModelContext) { let Some(model) = CloudModel::as_ref(ctx) .get_all_cloud_preferences_by_storage_key() .get(storage_key) .filter(|object| !object.metadata.pending_changes_statuses.pending_delete) .map(|cloud_pref| cloud_pref.model().clone()) else { // No cloud pref to sync return; }; let settings_sync_enabled = *CloudPreferencesSettings::as_ref(ctx).settings_sync_enabled; SettingsManager::handle(ctx).update(ctx, |manager, ctx| { let always_sync = manager.sync_regardless_of_users_syncing_setting(storage_key); if !settings_sync_enabled && !always_sync { // Early exit in the case where settings sync is disabled, unless this particular cloud pref is always synced. return; } let syncing_mode = manager .cloud_syncing_mode_for_storage_key(storage_key) .unwrap_or(SyncToCloud::Never); if syncing_mode == SyncToCloud::Never { // Early exit if this isn't a cloud synced key. Could happen if the current client // is on a different build from another one which happened to sync this key. We // always honor the syncability setting on the current client. return; } let Some(supported_platforms) = manager.supported_platforms_for_storage_key(storage_key) else { log::warn!( "No supported platforms for storage key {storage_key}. Not updating local \ pref to match cloud pref" ); return; }; if !supported_platforms.matches_current_platform() { log::debug!( "Preference with storage key {storage_key} is not supported on the current \ platform. Not updating local pref to match cloud pref" ); return; } let platform = model.string_model.platform; if matches!(syncing_mode, SyncToCloud::PerPlatform(_)) && !platform.applies_to_current_platform() { log::debug!( "Not applying platform-specific preference for {platform:?} with storage key \ {storage_key} on current platform {:?}", Platform::current_platform() ); return; } let is_current_value_syncable = manager .is_current_value_syncable(storage_key, ctx) .unwrap_or(false); if !is_current_value_syncable { log::info!( "Not syncing cloud preference with storage key {storage_key} to local because \ the current value is not syncable and we don't want to overwrite it with a \ cloud value" ); return; } let value = &model.string_model.value; let value_str = value.to_string(); // Get current local value to compare if it's changing let current_value = manager .read_local_setting_value(storage_key, ctx) .ok() .flatten(); let is_changing = current_value .as_ref() .and_then(|current| { manager .are_equal_settings(storage_key, current, &value_str) .ok() }) .map(|are_equal| !are_equal) .unwrap_or(true); // If we can't determine, assume it's changing if is_changing { log::info!( "Updating local preference with storage key {storage_key} and value {value} to \ match cloud preference" ); if let Err(e) = manager.update_setting_with_storage_key( storage_key, value.to_string(), true, /* from_cloud_sync */ ctx, ) { log::warn!( "Error updating setting with storage key {storage_key} while merging cloud \ prefs to local: {e}" ); } } else { log::debug!( "Local preference with storage key {storage_key} already matches cloud value \ {value}" ); } }) } } impl Entity for CloudPreferencesSyncer { type Event = CloudPreferencesSyncerEvent; } /// Mark CloudPreferencesSyncer as global application state. impl SingletonEntity for CloudPreferencesSyncer {} #[cfg(test)] #[path = "cloud_preferences_syncer_tests.rs"] mod tests;