//! Cloud sync: what it is doing, and the settings that do not need a server. //! //! //! //! Nearly the whole of what this section shows is local. `sync_status` reads //! `client.config()`, `client.session_info()`, `client.has_master_key()` and //! two synchronous database queries; `sync_update_settings`, `sync_disconnect` //! and `sync_start_auth` have no awaits at all. A section is not undescribable //! because its loudest feature is: the test is what the *data* needs rather //! than what the busiest control does. //! //! # What is here, and what is not //! //! Here: whether sync is configured and signed in, the server, whether //! encryption is ready, the device, the last sync, the pending-change count, //! the auto-sync switch, the interval, and Disconnect. //! //! Not here, and absent rather than drawn as controls that do nothing: Connect, //! Sync Now, Set up encryption, Subscribe, and the held-changes drill-in. Every //! one of those is a conversation with a server, which is the half that stays //! host-bound, the same arrangement as Email, where the accounts are described //! and the OAuth handshake is not. //! //! That leaves the section honest rather than whole: a person can see the state //! of sync and change how it behaves, and cannot start one from here. //! //! # The two facts left out of the readout //! //! `sync_status`'s two awaits are both written to survive not getting an //! answer: `has_server_key()` ends in `.ok()`, and `held_counts()` degrades to //! zero rather than failing the whole status call. Both are missing here rather //! than fetched. Holding the last known value of each on [`AppState`] and //! saying how old it is would work, and two facts do not pay for the //! machinery. If the held count ever needs to be on screen, that is the shape //! it takes. //! //! # Disconnect is here and Connect is not, which looks odd and is right //! //! `sync_disconnect` deletes the stored token and tells the store to stop. Both //! are local: nothing is asked of the server, and a server that never hears //! about it is not a failure case, because the token is what this device holds //! rather than a session the server tracks. //! //! Connecting is a handshake, a poll and an exchange, so it stays host-bound. use quasi_declare::declare; use quasi_router::screen::Choice; use quasi_router::{Action, RouteError}; use crate::state::AppState; use crate::syncstore::sync_state; /// The keys this section writes, which are `sync_state` rows rather than /// `user_config` ones. /// /// Sync's own settings live in the table the sync engine reads, not in the /// config table the rest of this screen writes. So they cannot go through /// `POST /settings/config/{key}` and have routes of their own. pub(super) const AUTO_SYNC: &str = "auto_sync_enabled"; /// How often the scheduler syncs, in minutes. pub(super) const INTERVAL: &str = "sync_interval_minutes"; /// What the section shows. struct State { configured: bool, authenticated: bool, server_url: Option, encryption_ready: bool, device_id: Option, auto_sync_enabled: bool, interval_minutes: u32, last_sync_at: Option, pending_changes: i64, } /// Read it, all of it locally. /// /// The same reads `sync_status` makes, minus its two awaits. A missing row is /// the default rather than an error: this is a status readout, and a database /// that cannot answer one key should not blank the section. fn read(state: &AppState) -> State { let client = state.read_recovering(); let (configured, authenticated, server_url, encryption_ready) = client.map_or((false, false, None, false), |client| { ( true, client.session_info().is_some(), Some(client.config().server_url.clone()), client.has_master_key(), ) }); let states = sync_state::get_sync_states_batch( &state.db, &[ "device_id", "auto_sync_enabled", "sync_interval_minutes", "last_sync_at", ], ) .unwrap_or_default(); State { configured, authenticated, server_url, encryption_ready, device_id: states.get("device_id").filter(|s| !s.is_empty()).cloned(), // Absent means on, which is what `sync_status` says and what the // scheduler assumes. auto_sync_enabled: states.get("auto_sync_enabled").is_none_or(|v| v == "1"), interval_minutes: states .get("sync_interval_minutes") .and_then(|v| v.parse().ok()) .unwrap_or(5), last_sync_at: states.get("last_sync_at").cloned(), pending_changes: sync_state::count_pending_changes(&state.db).unwrap_or(0), } } /// Which option the auto-sync picker opens on. fn auto_choice(state: &State) -> &'static str { if state.auto_sync_enabled { "enabled" } else { "disabled" } } /// The server this device syncs with, once it knows one. fn server(state: &State) -> Option<&str> { state.server_url.as_deref().filter(|url| !url.is_empty()) } /// This device's id, once it has one. fn device(state: &State) -> Option<&str> { state.device_id.as_deref().filter(|id| !id.is_empty()) } /// Signed in, or set up and not signed in. fn status_label(state: &State) -> &'static str { if state.authenticated { "Signed in" } else { "Set up, not signed in" } } /// Whether the keys are in place. fn encryption_label(state: &State) -> &'static str { if state.encryption_ready { "Ready" } else { "Not set up" } } /// When it last ran, or never. fn last_sync(state: &State) -> String { state .last_sync_at .clone() .unwrap_or_else(|| "Never".to_owned()) } /// How much is waiting. /// /// Said as a count rather than hidden at zero: "nothing waiting" is the answer /// somebody opening this section is looking for. fn waiting(state: &State) -> String { match state.pending_changes { 0 => "Nothing".to_owned(), 1 => "1 change".to_owned(), n => format!("{n} changes"), } } declare! { /// The section. /// /// Nothing to say and nothing to set when sync is not configured. Said /// plainly rather than drawn as a section full of dashes and disabled /// switches. /// /// The auto-sync question is an On/Off choice rather than a toggle kind, /// which is how every other boolean on this screen is said. One shape for /// one question, and the renderer decides whether that draws as a switch. pub(super) shape pane(app: &AppState) -> Vec; let state = read(app); section "Cloud Sync"; empty "Sync is not set up on this device. Setting it up asks a server for an \ account, which this screen cannot do yet." unless state.configured; list { row "Status" { meta status_label(&state); } for server in server(&state).into_iter() { row "Server" { meta server; } } row "Encryption" { meta encryption_label(&state); } for device in device(&state).into_iter() { row "This device" { meta device; } } row "Last sync" { meta last_sync(&state); } row "Waiting to send" { meta waiting(&state); } } when state.configured; field Select AUTO_SYNC "Sync automatically" when state.configured { option Choice::new("enabled", "Enabled (default)"); option Choice::new("disabled", "Disabled"); value auto_choice(&state); hint "When off, nothing is sent or fetched until a sync is started by hand."; writes Action::post("/settings/sync/auto"); } field Number INTERVAL "Minutes between syncs" when state.configured { within "1" "1440"; value state.interval_minutes.to_string(); writes Action::post("/settings/sync/interval"); } act "Disconnect" to post "/settings/sync/disconnect" when state.configured and state.authenticated { tone Danger; confirm "Disconnect this device from cloud sync? Your data stays here; \ nothing more will be sent or fetched until you sign in again."; } } /// Turn automatic syncing on or off. pub(super) fn set_auto(app: &AppState, on: bool) -> Result<&'static str, RouteError> { sync_state::set_sync_state(&app.db, AUTO_SYNC, if on { "1" } else { "0" }) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(if on { "Syncing automatically." } else { "Automatic syncing off." }) } /// Set how often the scheduler syncs. /// /// Clamped rather than refused, which is what every other numeric control on /// this screen does: a silly number should show a sane one. pub(super) fn set_interval(app: &AppState, minutes: u32) -> Result { let minutes = minutes.clamp(1, 1440); sync_state::set_sync_state(&app.db, INTERVAL, &minutes.to_string()) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(format!("Syncing every {minutes} minutes.")) } /// Forget the stored token and stop syncing. /// /// Local on both halves: the token is this device's, and the store is told to /// stop in this process. Nothing is asked of the server, which is why this is /// here and Connect is not. pub(super) fn disconnect(app: &AppState) -> Result<&'static str, RouteError> { crate::oauth::credentials::CredentialStore::delete_sync_token() .map_err(RouteError::internal)?; if let Some(store) = &app.sync_store { store.disconnect(); } Ok("Disconnected. Your data is still here.") } #[cfg(test)] mod tests;