max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+520 insertions,
-24 deletions
| @@ -8493,6 +8493,10 @@ | |||
| 8493 | 8493 | "winnow 1.0.4", | |
| 8494 | 8494 | ] | |
| 8495 | 8495 | ||
| 8496 | + | [[patch.unused]] | |
| 8497 | + | name = "ops-status" | |
| 8498 | + | version = "0.1.0" | |
| 8499 | + | ||
| 8496 | 8500 | [[patch.unused]] | |
| 8497 | 8501 | name = "quasi-axum" | |
| 8498 | 8502 | version = "0.54.0" | |
| @@ -8508,7 +8512,3 @@ | |||
| 8508 | 8512 | [[patch.unused]] | |
| 8509 | 8513 | name = "quasi-store" | |
| 8510 | 8514 | version = "0.1.0" | |
| 8511 | - | ||
| 8512 | - | [[patch.unused]] | |
| 8513 | - | name = "ops-status" | |
| 8514 | - | version = "0.1.0" |
| @@ -51,7 +51,8 @@ | |||
| 51 | 51 | //! | Gone | Comes back when | | |
| 52 | 52 | //! |---|---| | |
| 53 | 53 | //! | Compose, reply, forward | `3fb2526a`, Max's call on the shape | | |
| 54 | - | //! | Settings > Sync, Settings > Sharing | a route can await a network client | | |
| 54 | + | //! | ~~Settings > Sync~~ | back 2026-08-22: measured per command, its reads are local. See [`settings::sync`] | | |
| 55 | + | //! | Settings > Sharing | its reads are remote, so there is no local state to draw a section from. quasicoherent `82273265` | | |
| 55 | 56 | //! | ~~Settings > About~~ | back 2026-08-22: `AppState` holds the version and the platform now, which is what `theme_dirs` did for the theme path. See [`settings::about`] | | |
| 56 | 57 | //! | Create Backup | [`data`] finding 2: a described write cannot be long-running | | |
| 57 | 58 | //! | The search box | quasicoherent `d52884b0` settled the caret as the renderer's | |
| @@ -46,12 +46,16 @@ | |||
| 46 | 46 | //! resolved at startup and held, which is the move this header names below | |
| 47 | 47 | //! for `theme_dirs`. See [`about`]. | |
| 48 | 48 | //! | |
| 49 | - | //! Sync and Sharing are still out, and the cause stated here is not theirs | |
| 50 | - | //! either: measured 2026-08-21, every group and sync command awaits a network | |
| 51 | - | //! client (12 `.await` sites in `commands/group.rs` alone) and a route handler | |
| 52 | - | //! is synchronous. `AppHandle` has nothing to do with it. They went out with | |
| 53 | - | //! the swap under goingson `da48cb6d` and come back when a route can hand work | |
| 54 | - | //! to a runtime. | |
| 49 | + | //! - **Sync** left on 2026-08-22, and it is the one that shows why counting is | |
| 50 | + | //! not measuring. It was held out on 13 `.await` sites in `commands/sync.rs`; | |
| 51 | + | //! per command, `sync_status` reads local state and the settings, disconnect | |
| 52 | + | //! and auth-start are synchronous. Only the server-talking actions stay out. | |
| 53 | + | //! See [`sync`]. | |
| 54 | + | //! | |
| 55 | + | //! Sharing is the one still out, and its reason is neither `AppHandle` nor a | |
| 56 | + | //! slow write: `group_list`, `group_list_members` and `group_list_invitations` | |
| 57 | + | //! all await, so there is no local state to draw a section from at all. That is | |
| 58 | + | //! the last consumer on quasicoherent `82273265`. | |
| 55 | 59 | //! | |
| 56 | 60 | //! Import & Export left that sentence entirely. It went half out on 2026-08-16, | |
| 57 | 61 | //! as [`data`](super::data), a screen of its own: picking a file to submit is | |
| @@ -111,10 +115,19 @@ | |||
| 111 | 115 | ||
| 112 | 116 | pub(crate) mod about; | |
| 113 | 117 | pub(crate) mod email; | |
| 118 | + | pub(crate) mod sync; | |
| 114 | 119 | ||
| 115 | 120 | #[cfg(test)] | |
| 116 | 121 | mod tests; | |
| 117 | 122 | ||
| 123 | + | /// The region a section's contents answer at. | |
| 124 | + | /// | |
| 125 | + | /// Named once because four things now aim at it: the screen that builds it, and | |
| 126 | + | /// the three sections that answer themselves back after a write. It is also the | |
| 127 | + | /// id `settings-sync.js` wrote into before the swap, which is not a | |
| 128 | + | /// coincidence worth relying on and is worth knowing. | |
| 129 | + | const SECTION_REGION: &str = "settings-content"; | |
| 130 | + | ||
| 118 | 131 | /// A section of the screen: its address, its heading, and the keys it writes. | |
| 119 | 132 | struct Section { | |
| 120 | 133 | /// The last path segment, and what the sidebar sends. | |
| @@ -152,7 +165,7 @@ | |||
| 152 | 165 | /// absent rather than disabled, for the reason the task overview left Edit out: | |
| 153 | 166 | /// a control that is drawn and does nothing is worse than a control that is not | |
| 154 | 167 | /// drawn, and the module header says which and why. | |
| 155 | - | const SECTIONS: [Section; 6] = [ | |
| 168 | + | const SECTIONS: [Section; 7] = [ | |
| 156 | 169 | Section { | |
| 157 | 170 | slug: "appearance", | |
| 158 | 171 | title: "Appearance", | |
| @@ -192,6 +205,13 @@ | |||
| 192 | 205 | title: "About", | |
| 193 | 206 | at: None, | |
| 194 | 207 | }, | |
| 208 | + | // Added 2026-08-22. Listed as host-bound until somebody measured it per | |
| 209 | + | // command rather than per file; see `sync`. | |
| 210 | + | Section { | |
| 211 | + | slug: "sync", | |
| 212 | + | title: "Sync", | |
| 213 | + | at: None, | |
| 214 | + | }, | |
| 195 | 215 | ]; | |
| 196 | 216 | ||
| 197 | 217 | /// What the app falls back to when a key has never been written. | |
| @@ -506,12 +526,13 @@ | |||
| 506 | 526 | }, | |
| 507 | 527 | ))); | |
| 508 | 528 | ||
| 509 | - | let mut pane = Slot::new("settings-content", RegionKind::Pane); | |
| 529 | + | let mut pane = Slot::new(SECTION_REGION, RegionKind::Pane); | |
| 510 | 530 | pane = pane.extend(match section.slug { | |
| 511 | 531 | "notifications" => notifications(&config), | |
| 512 | 532 | "planning" => planning(&config), | |
| 513 | 533 | "email" => email::pane(state)?, | |
| 514 | 534 | "about" => about::pane(state), | |
| 535 | + | "sync" => sync::pane(state), | |
| 515 | 536 | _ => appearance(state, &config), | |
| 516 | 537 | }); | |
| 517 | 538 | ||
| @@ -538,12 +559,56 @@ | |||
| 538 | 559 | let on = request.payload.get(about::UPDATE_CHECK).unwrap_or_default() != "disabled"; | |
| 539 | 560 | let said = about::write(state, on)?; | |
| 540 | 561 | Ok(Response::fragment( | |
| 541 | - | "settings-content", | |
| 542 | - | Node::Region(Slot::new("settings-content", RegionKind::Pane).extend(about::pane(state))), | |
| 562 | + | SECTION_REGION, | |
| 563 | + | Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(about::pane(state))), | |
| 543 | 564 | ) | |
| 544 | 565 | .toast(quasi_router::layout::Tone::Success, said)) | |
| 545 | 566 | } | |
| 546 | 567 | ||
| 568 | + | /// The Sync section, answered back after a write to it. | |
| 569 | + | /// | |
| 570 | + | /// Its own three routes rather than arms of the config writer, because these | |
| 571 | + | /// are `sync_state` rows read by the sync engine rather than `user_config` | |
| 572 | + | /// keys. See [`sync`]. | |
| 573 | + | fn sync_pane(state: &AppState, said: String) -> Result<Response, RouteError> { | |
| 574 | + | Ok(Response::fragment( | |
| 575 | + | SECTION_REGION, | |
| 576 | + | Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(sync::pane(state))), | |
| 577 | + | ) | |
| 578 | + | .toast(quasi_router::layout::Tone::Success, said)) | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | /// Turn automatic syncing on or off. | |
| 582 | + | fn set_sync_auto(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 583 | + | let on = request.payload.get(sync::AUTO_SYNC).unwrap_or_default() != "disabled"; | |
| 584 | + | let said = sync::set_auto(state, on)?; | |
| 585 | + | sync_pane(state, said.to_owned()) | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | /// Set how often the scheduler syncs. | |
| 589 | + | fn set_sync_interval( | |
| 590 | + | state: &AppState, | |
| 591 | + | request: quasi_router::Request, | |
| 592 | + | ) -> Result<Response, RouteError> { | |
| 593 | + | let minutes = request | |
| 594 | + | .payload | |
| 595 | + | .get(sync::INTERVAL) | |
| 596 | + | .unwrap_or_default() | |
| 597 | + | .parse() | |
| 598 | + | .unwrap_or(5); | |
| 599 | + | let said = sync::set_interval(state, minutes)?; | |
| 600 | + | sync_pane(state, said) | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | /// Forget the stored token and stop syncing. | |
| 604 | + | fn disconnect_sync( | |
| 605 | + | state: &AppState, | |
| 606 | + | _request: quasi_router::Request, | |
| 607 | + | ) -> Result<Response, RouteError> { | |
| 608 | + | let said = sync::disconnect(state)?; | |
| 609 | + | sync_pane(state, said.to_owned()) | |
| 610 | + | } | |
| 611 | + | ||
| 547 | 612 | /// One section. | |
| 548 | 613 | fn section(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 549 | 614 | let slug = request | |
| @@ -639,6 +704,10 @@ | |||
| 639 | 704 | .post("/settings/notifications", set_notifications); | |
| 640 | 705 | // Above the section capture, so `email` and `about` are literals rather | |
| 641 | 706 | // than section names that happen to match. | |
| 642 | - | let router = email::routes(router).post("/settings/about/update-check", set_update_check); | |
| 707 | + | let router = email::routes(router) | |
| 708 | + | .post("/settings/about/update-check", set_update_check) | |
| 709 | + | .post("/settings/sync/auto", set_sync_auto) | |
| 710 | + | .post("/settings/sync/interval", set_sync_interval) | |
| 711 | + | .post("/settings/sync/disconnect", disconnect_sync); | |
| 643 | 712 | router.get("/settings/{section}", section) | |
| 644 | 713 | } |
| @@ -80,12 +80,14 @@ | |||
| 80 | 80 | // it needed. | |
| 81 | 81 | assert!(page.contains("About")); | |
| 82 | 82 | ||
| 83 | - | // Sync and Sharing are still absent, and absent rather than drawn as | |
| 84 | - | // controls that do nothing. Their reason is async I/O rather than the | |
| 85 | - | // `AppHandle` this header used to blame; see the module header. | |
| 86 | - | for missing in ["Sync", "Sharing"] { | |
| 87 | - | assert!(!page.contains(missing), "should not offer: {missing}"); | |
| 88 | - | } | |
| 83 | + | // Sync joined them on 2026-08-22, once somebody measured `commands/sync.rs` | |
| 84 | + | // per command rather than per file: its reads are local. | |
| 85 | + | assert!(page.contains("Sync")); | |
| 86 | + | ||
| 87 | + | // Sharing is the one still absent, and absent rather than drawn as a | |
| 88 | + | // control that does nothing. Its reads are remote, so there is no local | |
| 89 | + | // state to draw a section from at all; see quasicoherent `82273265`. | |
| 90 | + | assert!(!page.contains("Sharing"), "should not offer: Sharing"); | |
| 89 | 91 | } | |
| 90 | 92 | ||
| 91 | 93 | #[tokio::test] | |
| @@ -120,8 +122,8 @@ | |||
| 120 | 122 | async fn a_section_that_is_not_described_is_a_not_found() { | |
| 121 | 123 | let state = state().await; | |
| 122 | 124 | let error = router() | |
| 123 | - | .handle(&state, Request::get("/settings/sync")) | |
| 124 | - | .expect_err("sync is not described"); | |
| 125 | + | .handle(&state, Request::get("/settings/sharing")) | |
| 126 | + | .expect_err("sharing is not described"); | |
| 125 | 127 | assert_eq!(error.class.http_status(), 404); | |
| 126 | 128 | } | |
| 127 | 129 |
| @@ -1,0 +1,283 @@ | |||
| 1 | + | //! Cloud sync: what it is doing, and the settings that do not need a server. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! # The section was called host-bound and it is mostly not | |
| 6 | + | //! | |
| 7 | + | //! [`super`]'s header listed Sync among the sections left out, and the reason | |
| 8 | + | //! recorded against it was async I/O: thirteen `.await` sites in | |
| 9 | + | //! `commands/sync.rs`. The swap took `settings-sync.js` with the rest of the | |
| 10 | + | //! frontend, so the section has been absent since 2026-08-22. | |
| 11 | + | //! | |
| 12 | + | //! Counted per command rather than per file, that number is the busiest control | |
| 13 | + | //! and not what the data needs. It is the same mistake [`super::email`]'s header | |
| 14 | + | //! names having made once, about OAuth: | |
| 15 | + | //! | |
| 16 | + | //! > a section is not undescribable because its loudest feature is, and the test | |
| 17 | + | //! > is what the *data* needs rather than what the busiest control does. | |
| 18 | + | //! | |
| 19 | + | //! `sync_status` — which is nearly the whole of what this section shows — reads | |
| 20 | + | //! `client.config()`, `client.session_info()`, `client.has_master_key()` and two | |
| 21 | + | //! synchronous database queries. `sync_update_settings`, `sync_disconnect` and | |
| 22 | + | //! `sync_start_auth` have no awaits at all. | |
| 23 | + | //! | |
| 24 | + | //! # What is here, and what is not | |
| 25 | + | //! | |
| 26 | + | //! Here: whether sync is configured and signed in, the server, whether | |
| 27 | + | //! encryption is ready, the device, the last sync, the pending-change count, | |
| 28 | + | //! the auto-sync switch, the interval, and Disconnect. | |
| 29 | + | //! | |
| 30 | + | //! Not here, and absent rather than drawn as controls that do nothing: Connect, | |
| 31 | + | //! Sync Now, Set up encryption, Subscribe, and the held-changes drill-in. Every | |
| 32 | + | //! one of those is a conversation with a server, which is the half that stays | |
| 33 | + | //! host-bound — the same arrangement as Email, where the accounts are described | |
| 34 | + | //! and the OAuth handshake is not. | |
| 35 | + | //! | |
| 36 | + | //! That leaves the section honest rather than whole, and the distinction is | |
| 37 | + | //! worth keeping in view: a person can see the state of sync and change how it | |
| 38 | + | //! behaves, and cannot start one from here. | |
| 39 | + | //! | |
| 40 | + | //! # The two facts left out of the readout | |
| 41 | + | //! | |
| 42 | + | //! `sync_status` has exactly two awaits, and both are already written to | |
| 43 | + | //! survive not getting an answer: `has_server_key()` ends in `.ok()`, and | |
| 44 | + | //! `held_counts()` "degrades to zero rather than failing the whole status | |
| 45 | + | //! call". So both are facts the shipped screen is content to be missing. | |
| 46 | + | //! | |
| 47 | + | //! They are missing here too, rather than being fetched. Holding the last known | |
| 48 | + | //! value of each on [`AppState`] and saying how old it is would work, and it is | |
| 49 | + | //! `82273265` option (d) in miniature; two facts do not pay for the machinery. | |
| 50 | + | //! If the held count ever needs to be on screen, that is the shape it takes. | |
| 51 | + | //! | |
| 52 | + | //! # Disconnect is here and Connect is not, which looks odd and is right | |
| 53 | + | //! | |
| 54 | + | //! `sync_disconnect` deletes the stored token and tells the store to stop. Both | |
| 55 | + | //! are local: nothing is asked of the server, and a server that never hears | |
| 56 | + | //! about it is not a failure case, because the token is what this device holds | |
| 57 | + | //! rather than a session the server tracks. | |
| 58 | + | //! | |
| 59 | + | //! Connecting is the other way round entirely — a handshake, a poll, an | |
| 60 | + | //! exchange — so it stays where it was. | |
| 61 | + | ||
| 62 | + | use quasi_router::layout::Tone; | |
| 63 | + | use quasi_router::screen::{Act, Choice, Field, Row}; | |
| 64 | + | use quasi_router::{Action, Node, RouteError}; | |
| 65 | + | ||
| 66 | + | use crate::state::AppState; | |
| 67 | + | use crate::syncstore::sync_state; | |
| 68 | + | ||
| 69 | + | /// The keys this section writes, which are `sync_state` rows rather than | |
| 70 | + | /// `user_config` ones. | |
| 71 | + | /// | |
| 72 | + | /// Sync's own settings live in the table the sync engine reads, not in the | |
| 73 | + | /// config table the rest of this screen writes. So they cannot go through | |
| 74 | + | /// `POST /settings/config/{key}` and have routes of their own. | |
| 75 | + | pub(super) const AUTO_SYNC: &str = "auto_sync_enabled"; | |
| 76 | + | /// How often the scheduler syncs, in minutes. | |
| 77 | + | pub(super) const INTERVAL: &str = "sync_interval_minutes"; | |
| 78 | + | ||
| 79 | + | /// What the section shows. | |
| 80 | + | struct State { | |
| 81 | + | configured: bool, | |
| 82 | + | authenticated: bool, | |
| 83 | + | server_url: Option<String>, | |
| 84 | + | encryption_ready: bool, | |
| 85 | + | device_id: Option<String>, | |
| 86 | + | auto_sync_enabled: bool, | |
| 87 | + | interval_minutes: u32, | |
| 88 | + | last_sync_at: Option<String>, | |
| 89 | + | pending_changes: i64, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Read it, all of it locally. | |
| 93 | + | /// | |
| 94 | + | /// The same reads `sync_status` makes, minus its two awaits. A missing row is | |
| 95 | + | /// the default rather than an error: this is a status readout, and a database | |
| 96 | + | /// that cannot answer one key should not blank the section. | |
| 97 | + | fn read(state: &AppState) -> State { | |
| 98 | + | let client = state.read_recovering(); | |
| 99 | + | let (configured, authenticated, server_url, encryption_ready) = | |
| 100 | + | client.map_or((false, false, None, false), |client| { | |
| 101 | + | ( | |
| 102 | + | true, | |
| 103 | + | client.session_info().is_some(), | |
| 104 | + | Some(client.config().server_url.clone()), | |
| 105 | + | client.has_master_key(), | |
| 106 | + | ) | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | let states = sync_state::get_sync_states_batch( | |
| 110 | + | &state.db, | |
| 111 | + | &[ | |
| 112 | + | "device_id", | |
| 113 | + | "auto_sync_enabled", | |
| 114 | + | "sync_interval_minutes", | |
| 115 | + | "last_sync_at", | |
| 116 | + | ], | |
| 117 | + | ) | |
| 118 | + | .unwrap_or_default(); | |
| 119 | + | ||
| 120 | + | State { | |
| 121 | + | configured, | |
| 122 | + | authenticated, | |
| 123 | + | server_url, | |
| 124 | + | encryption_ready, | |
| 125 | + | device_id: states.get("device_id").filter(|s| !s.is_empty()).cloned(), | |
| 126 | + | // Absent means on, which is what `sync_status` says and what the | |
| 127 | + | // scheduler assumes. | |
| 128 | + | auto_sync_enabled: states.get("auto_sync_enabled").is_none_or(|v| v == "1"), | |
| 129 | + | interval_minutes: states | |
| 130 | + | .get("sync_interval_minutes") | |
| 131 | + | .and_then(|v| v.parse().ok()) | |
| 132 | + | .unwrap_or(5), | |
| 133 | + | last_sync_at: states.get("last_sync_at").cloned(), | |
| 134 | + | pending_changes: sync_state::count_pending_changes(&state.db).unwrap_or(0), | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// The section. | |
| 139 | + | pub(super) fn pane(app: &AppState) -> Vec<Node> { | |
| 140 | + | let state = read(app); | |
| 141 | + | ||
| 142 | + | let mut nodes = vec![Node::section("Cloud Sync")]; | |
| 143 | + | ||
| 144 | + | if !state.configured { | |
| 145 | + | // Nothing to say and nothing to set. Said plainly rather than drawn as | |
| 146 | + | // a section full of dashes and disabled switches. | |
| 147 | + | nodes.push(Node::empty( | |
| 148 | + | "Sync is not set up on this device. Setting it up asks a server for an \ | |
| 149 | + | account, which this screen cannot do yet.", | |
| 150 | + | )); | |
| 151 | + | return nodes; | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | nodes.push(Node::list(facts(&state))); | |
| 155 | + | nodes.push(Node::field( | |
| 156 | + | Field { | |
| 157 | + | options: vec![ | |
| 158 | + | Choice::new("enabled", "Enabled (default)"), | |
| 159 | + | Choice::new("disabled", "Disabled"), | |
| 160 | + | ], | |
| 161 | + | value: Some( | |
| 162 | + | if state.auto_sync_enabled { | |
| 163 | + | "enabled" | |
| 164 | + | } else { | |
| 165 | + | "disabled" | |
| 166 | + | } | |
| 167 | + | .to_owned(), | |
| 168 | + | ), | |
| 169 | + | ..Field::new( | |
| 170 | + | makeover_layout::FieldKind::Select, | |
| 171 | + | AUTO_SYNC, | |
| 172 | + | "Sync automatically", | |
| 173 | + | ) | |
| 174 | + | } | |
| 175 | + | .hint("When off, nothing is sent or fetched until a sync is started by hand.") | |
| 176 | + | .changes(Action::post("/settings/sync/auto")), | |
| 177 | + | )); | |
| 178 | + | nodes.push(Node::field( | |
| 179 | + | Field { | |
| 180 | + | min: Some("1".to_owned()), | |
| 181 | + | max: Some("1440".to_owned()), | |
| 182 | + | value: Some(state.interval_minutes.to_string()), | |
| 183 | + | ..Field::new( | |
| 184 | + | makeover_layout::FieldKind::Number, | |
| 185 | + | INTERVAL, | |
| 186 | + | "Minutes between syncs", | |
| 187 | + | ) | |
| 188 | + | } | |
| 189 | + | .changes(Action::post("/settings/sync/interval")), | |
| 190 | + | )); | |
| 191 | + | ||
| 192 | + | if state.authenticated { | |
| 193 | + | nodes.push(Node::Act( | |
| 194 | + | Act::new("Disconnect", Action::post("/settings/sync/disconnect")) | |
| 195 | + | .tone(Tone::Danger) | |
| 196 | + | .confirm( | |
| 197 | + | "Disconnect this device from cloud sync? Your data stays here; \ | |
| 198 | + | nothing more will be sent or fetched until you sign in again.", | |
| 199 | + | ), | |
| 200 | + | )); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | nodes | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// The readout, as labelled rows. | |
| 207 | + | fn facts(state: &State) -> Vec<Row> { | |
| 208 | + | let mut rows = vec![Row::new("Status").meta(if state.authenticated { | |
| 209 | + | "Signed in".to_owned() | |
| 210 | + | } else { | |
| 211 | + | "Set up, not signed in".to_owned() | |
| 212 | + | })]; | |
| 213 | + | ||
| 214 | + | if let Some(url) = &state.server_url { | |
| 215 | + | rows.push(Row::new("Server").meta(url.clone())); | |
| 216 | + | } | |
| 217 | + | rows.push(Row::new("Encryption").meta(if state.encryption_ready { | |
| 218 | + | "Ready".to_owned() | |
| 219 | + | } else { | |
| 220 | + | "Not set up".to_owned() | |
| 221 | + | })); | |
| 222 | + | if let Some(device) = &state.device_id { | |
| 223 | + | rows.push(Row::new("This device").meta(device.clone())); | |
| 224 | + | } | |
| 225 | + | rows.push( | |
| 226 | + | Row::new("Last sync").meta( | |
| 227 | + | state | |
| 228 | + | .last_sync_at | |
| 229 | + | .clone() | |
| 230 | + | .unwrap_or_else(|| "Never".to_owned()), | |
| 231 | + | ), | |
| 232 | + | ); | |
| 233 | + | // Said as a count rather than hidden at zero: "nothing waiting" is the | |
| 234 | + | // answer somebody opening this section is looking for. | |
| 235 | + | rows.push( | |
| 236 | + | Row::new("Waiting to send").meta(match state.pending_changes { | |
| 237 | + | 0 => "Nothing".to_owned(), | |
| 238 | + | 1 => "1 change".to_owned(), | |
| 239 | + | n => format!("{n} changes"), | |
| 240 | + | }), | |
| 241 | + | ); | |
| 242 | + | ||
| 243 | + | rows | |
| 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; |
| @@ -1,0 +1,141 @@ | |||
| 1 | + | //! The Sync section, driven through the router. | |
| 2 | + | //! | |
| 3 | + | //! The section exists because somebody measured `commands/sync.rs` per command | |
| 4 | + | //! rather than per file, so the tests worth reading are the ones that pin what | |
| 5 | + | //! is here and what is not. A later pass that "finishes" this section by adding | |
| 6 | + | //! Sync Now would be undoing a measurement, not filling a gap. | |
| 7 | + | ||
| 8 | + | use std::sync::Arc; | |
| 9 | + | ||
| 10 | + | use quasi_http::Serves as _; | |
| 11 | + | use quasi_router::{Outcome, Params, Request}; | |
| 12 | + | ||
| 13 | + | use crate::quasi::router; | |
| 14 | + | use crate::state::AppState; | |
| 15 | + | ||
| 16 | + | async fn state() -> Arc<AppState> { | |
| 17 | + | let (state, _) = crate::test_utils::setup_test_state().await; | |
| 18 | + | state | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | fn html(state: &AppState, path: &str) -> String { | |
| 22 | + | let response = router() | |
| 23 | + | .handle(state, Request::get(path).carrying(Params::new())) | |
| 24 | + | .expect("the route answers"); | |
| 25 | + | match &response.outcome { | |
| 26 | + | Outcome::Screen(screen) => quasi_webview::Webview::new().screen(screen), | |
| 27 | + | Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(node), | |
| 28 | + | other => panic!("expected content, got {other:?}"), | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | #[tokio::test] | |
| 33 | + | async fn sync_is_a_section_of_settings() { | |
| 34 | + | let state = state().await; | |
| 35 | + | let markup = html(&state, "/settings/sync"); | |
| 36 | + | assert!(markup.contains("Cloud Sync"), "{markup}"); | |
| 37 | + | assert!(markup.contains("/settings/sync"), "the sidebar offers it"); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[tokio::test] | |
| 41 | + | async fn a_device_with_no_sync_says_so_rather_than_drawing_an_empty_form() { | |
| 42 | + | // A test state has no sync client. Drawing dashes and disabled switches | |
| 43 | + | // would be a section pretending to be about something. | |
| 44 | + | let state = state().await; | |
| 45 | + | assert!( | |
| 46 | + | state.read_recovering().is_none(), | |
| 47 | + | "the fixture has no client" | |
| 48 | + | ); | |
| 49 | + | ||
| 50 | + | let markup = html(&state, "/settings/sync"); | |
| 51 | + | assert!(markup.contains("not set up"), "{markup}"); | |
| 52 | + | assert!(!markup.contains("Minutes between syncs"), "{markup}"); | |
| 53 | + | assert!(!markup.contains("Disconnect"), "{markup}"); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | #[tokio::test] | |
| 57 | + | async fn the_settings_are_written_even_with_no_client() { | |
| 58 | + | // The switch and the interval are `sync_state` rows the scheduler reads. | |
| 59 | + | // They are not the client's, so they are writable whether or not sync is | |
| 60 | + | // set up, and this is the half that proves the section is local. | |
| 61 | + | let state = state().await; | |
| 62 | + | ||
| 63 | + | let response = router() | |
| 64 | + | .handle( | |
| 65 | + | &state, | |
| 66 | + | Request::post("/settings/sync/interval") | |
| 67 | + | .sending(Params::new().with(super::INTERVAL, "30")), | |
| 68 | + | ) | |
| 69 | + | .expect("the route answers"); | |
| 70 | + | assert!(matches!(response.outcome, Outcome::Fragment { .. })); | |
| 71 | + | ||
| 72 | + | let written = | |
| 73 | + | crate::syncstore::sync_state::get_sync_states_batch(&state.db, &[super::INTERVAL]) | |
| 74 | + | .expect("read back"); | |
| 75 | + | assert_eq!(written.get(super::INTERVAL).map(String::as_str), Some("30")); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[tokio::test] | |
| 79 | + | async fn a_silly_interval_is_clamped_rather_than_refused() { | |
| 80 | + | // What every other numeric control on this screen does. | |
| 81 | + | let state = state().await; | |
| 82 | + | router() | |
| 83 | + | .handle( | |
| 84 | + | &state, | |
| 85 | + | Request::post("/settings/sync/interval") | |
| 86 | + | .sending(Params::new().with(super::INTERVAL, "999999")), | |
| 87 | + | ) | |
| 88 | + | .expect("the route answers"); | |
| 89 | + | ||
| 90 | + | let written = | |
| 91 | + | crate::syncstore::sync_state::get_sync_states_batch(&state.db, &[super::INTERVAL]) | |
| 92 | + | .expect("read back"); | |
| 93 | + | assert_eq!( | |
| 94 | + | written.get(super::INTERVAL).map(String::as_str), | |
| 95 | + | Some("1440") | |
| 96 | + | ); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[tokio::test] | |
| 100 | + | async fn turning_automatic_syncing_off_writes_the_row_the_scheduler_reads() { | |
| 101 | + | let state = state().await; | |
| 102 | + | router() | |
| 103 | + | .handle( | |
| 104 | + | &state, | |
| 105 | + | Request::post("/settings/sync/auto") | |
| 106 | + | .sending(Params::new().with(super::AUTO_SYNC, "disabled")), | |
| 107 | + | ) | |
| 108 | + | .expect("the route answers"); | |
| 109 | + | ||
| 110 | + | let written = | |
| 111 | + | crate::syncstore::sync_state::get_sync_states_batch(&state.db, &[super::AUTO_SYNC]) | |
| 112 | + | .expect("read back"); | |
| 113 | + | assert_eq!(written.get(super::AUTO_SYNC).map(String::as_str), Some("0")); | |
| 114 | + | ||
| 115 | + | // Absent means on, so turning it back on has to write the row rather than | |
| 116 | + | // clear it: the scheduler reads `is_none_or(|v| v == "1")`. | |
| 117 | + | router() | |
| 118 | + | .handle( | |
| 119 | + | &state, | |
| 120 | + | Request::post("/settings/sync/auto") | |
| 121 | + | .sending(Params::new().with(super::AUTO_SYNC, "enabled")), | |
| 122 | + | ) | |
| 123 | + | .expect("the route answers"); | |
| 124 | + | let written = | |
| 125 | + | crate::syncstore::sync_state::get_sync_states_batch(&state.db, &[super::AUTO_SYNC]) | |
| 126 | + | .expect("read back"); | |
| 127 | + | assert_eq!(written.get(super::AUTO_SYNC).map(String::as_str), Some("1")); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[tokio::test] | |
| 131 | + | async fn the_controls_that_need_a_server_are_absent() { | |
| 132 | + | // Pinned so they read as a measurement rather than an unfinished section. | |
| 133 | + | // Each of these is a conversation with a server, which a synchronous route | |
| 134 | + | // handler cannot have; see the module header and quasicoherent 82273265. | |
| 135 | + | let state = state().await; | |
| 136 | + | let markup = html(&state, "/settings/sync"); | |
| 137 | + | ||
| 138 | + | for absent in ["Sync Now", "Set up encryption", "Subscribe", "Connect"] { | |
| 139 | + | assert!(!markup.contains(absent), "should not offer: {absent}"); | |
| 140 | + | } | |
| 141 | + | } |