Skip to main content

max / goingson

9.7 KB · 284 lines History Blame Raw
1 //! Cloud sync: what it is doing, and the settings that do not need a server.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! Nearly the whole of what this section shows is local. `sync_status` reads
6 //! `client.config()`, `client.session_info()`, `client.has_master_key()` and
7 //! two synchronous database queries; `sync_update_settings`, `sync_disconnect`
8 //! and `sync_start_auth` have no awaits at all. A section is not undescribable
9 //! because its loudest feature is: the test is what the *data* needs rather
10 //! than what the busiest control does.
11 //!
12 //! # What is here, and what is not
13 //!
14 //! Here: whether sync is configured and signed in, the server, whether
15 //! encryption is ready, the device, the last sync, the pending-change count,
16 //! the auto-sync switch, the interval, and Disconnect.
17 //!
18 //! Not here, and absent rather than drawn as controls that do nothing: Connect,
19 //! Sync Now, Set up encryption, Subscribe, and the held-changes drill-in. Every
20 //! one of those is a conversation with a server, which is the half that stays
21 //! host-bound, the same arrangement as Email, where the accounts are described
22 //! and the OAuth handshake is not.
23 //!
24 //! That leaves the section honest rather than whole: a person can see the state
25 //! of sync and change how it behaves, and cannot start one from here.
26 //!
27 //! # The two facts left out of the readout
28 //!
29 //! `sync_status`'s two awaits are both written to survive not getting an
30 //! answer: `has_server_key()` ends in `.ok()`, and `held_counts()` degrades to
31 //! zero rather than failing the whole status call. Both are missing here rather
32 //! than fetched. Holding the last known value of each on [`AppState`] and
33 //! saying how old it is would work, and two facts do not pay for the
34 //! machinery. If the held count ever needs to be on screen, that is the shape
35 //! it takes.
36 //!
37 //! # Disconnect is here and Connect is not, which looks odd and is right
38 //!
39 //! `sync_disconnect` deletes the stored token and tells the store to stop. Both
40 //! are local: nothing is asked of the server, and a server that never hears
41 //! about it is not a failure case, because the token is what this device holds
42 //! rather than a session the server tracks.
43 //!
44 //! Connecting is a handshake, a poll and an exchange, so it stays host-bound.
45
46 use quasi_declare::declare;
47 use quasi_router::screen::Choice;
48 use quasi_router::{Action, RouteError};
49
50 use crate::state::AppState;
51 use crate::syncstore::sync_state;
52
53 /// The keys this section writes, which are `sync_state` rows rather than
54 /// `user_config` ones.
55 ///
56 /// Sync's own settings live in the table the sync engine reads, not in the
57 /// config table the rest of this screen writes. So they cannot go through
58 /// `POST /settings/config/{key}` and have routes of their own.
59 pub(super) const AUTO_SYNC: &str = "auto_sync_enabled";
60 /// How often the scheduler syncs, in minutes.
61 pub(super) const INTERVAL: &str = "sync_interval_minutes";
62
63 /// What the section shows.
64 struct State {
65 configured: bool,
66 authenticated: bool,
67 server_url: Option<String>,
68 encryption_ready: bool,
69 device_id: Option<String>,
70 auto_sync_enabled: bool,
71 interval_minutes: u32,
72 last_sync_at: Option<String>,
73 pending_changes: i64,
74 }
75
76 /// Read it, all of it locally.
77 ///
78 /// The same reads `sync_status` makes, minus its two awaits. A missing row is
79 /// the default rather than an error: this is a status readout, and a database
80 /// that cannot answer one key should not blank the section.
81 fn read(state: &AppState) -> State {
82 let client = state.read_recovering();
83 let (configured, authenticated, server_url, encryption_ready) =
84 client.map_or((false, false, None, false), |client| {
85 (
86 true,
87 client.session_info().is_some(),
88 Some(client.config().server_url.clone()),
89 client.has_master_key(),
90 )
91 });
92
93 let states = sync_state::get_sync_states_batch(
94 &state.db,
95 &[
96 "device_id",
97 "auto_sync_enabled",
98 "sync_interval_minutes",
99 "last_sync_at",
100 ],
101 )
102 .unwrap_or_default();
103
104 State {
105 configured,
106 authenticated,
107 server_url,
108 encryption_ready,
109 device_id: states.get("device_id").filter(|s| !s.is_empty()).cloned(),
110 // Absent means on, which is what `sync_status` says and what the
111 // scheduler assumes.
112 auto_sync_enabled: states.get("auto_sync_enabled").is_none_or(|v| v == "1"),
113 interval_minutes: states
114 .get("sync_interval_minutes")
115 .and_then(|v| v.parse().ok())
116 .unwrap_or(5),
117 last_sync_at: states.get("last_sync_at").cloned(),
118 pending_changes: sync_state::count_pending_changes(&state.db).unwrap_or(0),
119 }
120 }
121
122 /// Which option the auto-sync picker opens on.
123 fn auto_choice(state: &State) -> &'static str {
124 if state.auto_sync_enabled {
125 "enabled"
126 } else {
127 "disabled"
128 }
129 }
130
131 /// The server this device syncs with, once it knows one.
132 fn server(state: &State) -> Option<&str> {
133 state.server_url.as_deref().filter(|url| !url.is_empty())
134 }
135
136 /// This device's id, once it has one.
137 fn device(state: &State) -> Option<&str> {
138 state.device_id.as_deref().filter(|id| !id.is_empty())
139 }
140
141 /// Signed in, or set up and not signed in.
142 fn status_label(state: &State) -> &'static str {
143 if state.authenticated {
144 "Signed in"
145 } else {
146 "Set up, not signed in"
147 }
148 }
149
150 /// Whether the keys are in place.
151 fn encryption_label(state: &State) -> &'static str {
152 if state.encryption_ready {
153 "Ready"
154 } else {
155 "Not set up"
156 }
157 }
158
159 /// When it last ran, or never.
160 fn last_sync(state: &State) -> String {
161 state
162 .last_sync_at
163 .clone()
164 .unwrap_or_else(|| "Never".to_owned())
165 }
166
167 /// How much is waiting.
168 ///
169 /// Said as a count rather than hidden at zero: "nothing waiting" is the answer
170 /// somebody opening this section is looking for.
171 fn waiting(state: &State) -> String {
172 match state.pending_changes {
173 0 => "Nothing".to_owned(),
174 1 => "1 change".to_owned(),
175 n => format!("{n} changes"),
176 }
177 }
178
179 declare! {
180 /// The section.
181 ///
182 /// Nothing to say and nothing to set when sync is not configured. Said
183 /// plainly rather than drawn as a section full of dashes and disabled
184 /// switches.
185 ///
186 /// The auto-sync question is an On/Off choice rather than a toggle kind,
187 /// which is how every other boolean on this screen is said. One shape for
188 /// one question, and the renderer decides whether that draws as a switch.
189 pub(super) shape pane(app: &AppState) -> Vec<Node>;
190
191 let state = read(app);
192
193 section "Cloud Sync";
194
195 empty "Sync is not set up on this device. Setting it up asks a server for an \
196 account, which this screen cannot do yet."
197 unless state.configured;
198
199 list {
200 row "Status" {
201 meta status_label(&state);
202 }
203 for server in server(&state).into_iter() {
204 row "Server" {
205 meta server;
206 }
207 }
208 row "Encryption" {
209 meta encryption_label(&state);
210 }
211 for device in device(&state).into_iter() {
212 row "This device" {
213 meta device;
214 }
215 }
216 row "Last sync" {
217 meta last_sync(&state);
218 }
219 row "Waiting to send" {
220 meta waiting(&state);
221 }
222 } when state.configured;
223
224 field Select AUTO_SYNC "Sync automatically" when state.configured {
225 option Choice::new("enabled", "Enabled (default)");
226 option Choice::new("disabled", "Disabled");
227 value auto_choice(&state);
228 hint "When off, nothing is sent or fetched until a sync is started by hand.";
229 writes Action::post("/settings/sync/auto");
230 }
231
232 field Number INTERVAL "Minutes between syncs" when state.configured {
233 within "1" "1440";
234 value state.interval_minutes.to_string();
235 writes Action::post("/settings/sync/interval");
236 }
237
238 act "Disconnect" to post "/settings/sync/disconnect"
239 when state.configured and state.authenticated {
240 tone Danger;
241 confirm "Disconnect this device from cloud sync? Your data stays here; \
242 nothing more will be sent or fetched until you sign in again.";
243 }
244 }
245
246 /// Turn automatic syncing on or off.
247 pub(super) fn set_auto(app: &AppState, on: bool) -> Result<&'static str, RouteError> {
248 sync_state::set_sync_state(&app.db, AUTO_SYNC, if on { "1" } else { "0" })
249 .map_err(|error| RouteError::internal(error.to_string()))?;
250 Ok(if on {
251 "Syncing automatically."
252 } else {
253 "Automatic syncing off."
254 })
255 }
256
257 /// Set how often the scheduler syncs.
258 ///
259 /// Clamped rather than refused, which is what every other numeric control on
260 /// this screen does: a silly number should show a sane one.
261 pub(super) fn set_interval(app: &AppState, minutes: u32) -> Result<String, RouteError> {
262 let minutes = minutes.clamp(1, 1440);
263 sync_state::set_sync_state(&app.db, INTERVAL, &minutes.to_string())
264 .map_err(|error| RouteError::internal(error.to_string()))?;
265 Ok(format!("Syncing every {minutes} minutes."))
266 }
267
268 /// Forget the stored token and stop syncing.
269 ///
270 /// Local on both halves: the token is this device's, and the store is told to
271 /// stop in this process. Nothing is asked of the server, which is why this is
272 /// here and Connect is not.
273 pub(super) fn disconnect(app: &AppState) -> Result<&'static str, RouteError> {
274 crate::oauth::credentials::CredentialStore::delete_sync_token()
275 .map_err(RouteError::internal)?;
276 if let Some(store) = &app.sync_store {
277 store.disconnect();
278 }
279 Ok("Disconnected. Your data is still here.")
280 }
281
282 #[cfg(test)]
283 mod tests;
284