962 lines
42 KiB
Rust
962 lines
42 KiB
Rust
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>,
|
|
) -> 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<String>,
|
|
|
|
// Provides the next ClientId to use in creating cloud preferences.
|
|
client_id_provider: Arc<dyn ClientIdProvider>,
|
|
|
|
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<Self>,
|
|
client_id_provider: Arc<dyn ClientIdProvider>,
|
|
) -> 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>,
|
|
) -> 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<Self>,
|
|
client_id_provider: Arc<dyn ClientIdProvider>,
|
|
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<SyncQueue>,
|
|
event: &SyncQueueEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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>) {
|
|
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<Self>) {
|
|
// 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<Self>) {
|
|
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::<Vec<_>>()
|
|
});
|
|
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<AuthState>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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::<GenericStringObjectId, CloudPreferenceModel>();
|
|
|
|
// 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::<Vec<_>>();
|
|
Some(pref_ids)
|
|
}) .collect::<Vec<_>>();
|
|
pref_ids.iter().flatten().cloned().collect::<Vec<_>>()
|
|
});
|
|
|
|
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>,
|
|
) {
|
|
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::<HashSet<_>>();
|
|
|
|
// 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::<Vec<_>>();
|
|
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<String>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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::<HashMap<_, _>>();
|
|
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<Self>,
|
|
) {
|
|
// 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<String, PreferenceToCreate>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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::<Preference, JsonSerializer> {
|
|
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::<Vec<_>>();
|
|
|
|
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::<Vec<_>>()
|
|
);
|
|
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<Self>) {
|
|
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;
|