max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+480 insertions,
-26 deletions
| @@ -83,6 +83,40 @@ | |||
| 83 | 83 | /// theme writes a file into the custom directory, and every read walks the | |
| 84 | 84 | /// directories again. | |
| 85 | 85 | pub theme_dirs: Vec<(PathBuf, bool)>, | |
| 86 | + | /// Where `preferences.json` lives, and `None` if the host would not say. | |
| 87 | + | /// | |
| 88 | + | /// Held for the reason [`theme_dirs`](Self::theme_dirs) is: the path comes | |
| 89 | + | /// off the `AppHandle`'s config directory, and the described About section | |
| 90 | + | /// reads and writes the file through a route handler that sees this struct | |
| 91 | + | /// and nothing else. `None` is a host that could not resolve or create the | |
| 92 | + | /// directory, and the section then shows the defaults and refuses the | |
| 93 | + | /// write rather than inventing a path. | |
| 94 | + | pub config_dir: Option<PathBuf>, | |
| 95 | + | /// What the app says about itself: its version and the platform it is on. | |
| 96 | + | /// | |
| 97 | + | /// The version is the `AppHandle`'s, which is the one fact here that a | |
| 98 | + | /// process genuinely cannot work out for itself. The platform is | |
| 99 | + | /// `std::env::consts`, resolved at compile time and held beside it so the | |
| 100 | + | /// About section reads one thing. | |
| 101 | + | pub about: About, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// The app's own identity, for the About section. | |
| 105 | + | /// | |
| 106 | + | /// Every field is a host fact or a constant, which is why the section was | |
| 107 | + | /// left out of the described settings screen until 2026-08-22: a route handler | |
| 108 | + | /// is `fn(&AppState, Params)` and none of this was in `AppState`. Putting it | |
| 109 | + | /// here is the general answer `theme_dirs` established, applied a second time. | |
| 110 | + | #[derive(Debug, Clone)] | |
| 111 | + | pub struct About { | |
| 112 | + | /// The version this build reports, from `tauri.conf.json`. | |
| 113 | + | pub version: String, | |
| 114 | + | /// The operating system and architecture, as `std::env::consts` names them. | |
| 115 | + | /// | |
| 116 | + | /// The shipped screen read `navigator.userAgentData?.platform`, which is | |
| 117 | + | /// the browser's guess at the same question and is coarser: it says | |
| 118 | + | /// "Linux" where this says "linux aarch64". | |
| 119 | + | pub platform: String, | |
| 86 | 120 | } | |
| 87 | 121 | ||
| 88 | 122 | /// Server-side storage for a pending OAuth flow. | |
| @@ -238,6 +272,11 @@ | |||
| 238 | 272 | pending_oauth_servers: Arc::new(Mutex::new(std::collections::HashMap::new())), | |
| 239 | 273 | data_dir: app_data_dir, | |
| 240 | 274 | theme_dirs: crate::commands::theme_dirs(app), | |
| 275 | + | config_dir: crate::commands::config_dir(app), | |
| 276 | + | about: About { | |
| 277 | + | version: app.package_info().version.to_string(), | |
| 278 | + | platform: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH), | |
| 279 | + | }, | |
| 241 | 280 | }) | |
| 242 | 281 | } | |
| 243 | 282 |
| @@ -48,6 +48,19 @@ | |||
| 48 | 48 | ||
| 49 | 49 | /// Creates a fully initialized AppState with in-memory database for testing. | |
| 50 | 50 | pub async fn setup_test_state() -> (Arc<AppState>, UserId) { | |
| 51 | + | let (state, user_id) = setup_test_state_owned().await; | |
| 52 | + | (Arc::new(state), user_id) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// The same state, unwrapped, for a test that needs to change a field before | |
| 56 | + | /// sharing it. `config_dir` is the one that matters: a host that would not give | |
| 57 | + | /// up a config directory is a case the About section has to answer, and there | |
| 58 | + | /// is no other way to build one. | |
| 59 | + | #[allow( | |
| 60 | + | clippy::unused_async, | |
| 61 | + | reason = "mirrors setup_test_state, which every test calls with .await; a sync sibling would be one signature to remember for no gain" | |
| 62 | + | )] | |
| 63 | + | pub async fn setup_test_state_owned() -> (AppState, UserId) { | |
| 51 | 64 | let db = setup_test_db(); | |
| 52 | 65 | let user_id = create_test_user(&db); | |
| 53 | 66 | ||
| @@ -86,9 +99,22 @@ | |||
| 86 | 99 | std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"), | |
| 87 | 100 | false, | |
| 88 | 101 | )], | |
| 102 | + | // A directory of its own per state, because `preferences.json` is a | |
| 103 | + | // real file and tests run in parallel: one shared path and two tests | |
| 104 | + | // writing the update-check preference would race on it. | |
| 105 | + | config_dir: { | |
| 106 | + | let dir = std::env::temp_dir() | |
| 107 | + | .join("goingson-test-config") | |
| 108 | + | .join(uuid::Uuid::new_v4().to_string()); | |
| 109 | + | std::fs::create_dir_all(&dir).ok().map(|()| dir) | |
| 110 | + | }, | |
| 111 | + | about: crate::state::About { | |
| 112 | + | version: "0.0.0-test".to_owned(), | |
| 113 | + | platform: "test".to_owned(), | |
| 114 | + | }, | |
| 89 | 115 | }; | |
| 90 | 116 | ||
| 91 | - | (Arc::new(state), user_id) | |
| 117 | + | (state, user_id) | |
| 92 | 118 | } | |
| 93 | 119 | ||
| 94 | 120 | /// Creates a test project and returns its ID. |
| @@ -40,21 +40,40 @@ | |||
| 40 | 40 | } | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | + | /// The directory the preferences file lives in. | |
| 44 | + | /// | |
| 45 | + | /// Split from [`preferences_path`] so [`AppState`](crate::state::AppState) can | |
| 46 | + | /// resolve it once at startup and hold it. A described route handler sees | |
| 47 | + | /// `&AppState` and nothing else, which is the same shape `theme_dirs` is in and | |
| 48 | + | /// is answered the same way: the host fact is resolved by the only thing that | |
| 49 | + | /// can resolve it, while it still has a handle to ask. | |
| 50 | + | pub fn config_dir(app: &AppHandle) -> Option<PathBuf> { | |
| 51 | + | let dir = app.path().app_config_dir().ok()?; | |
| 52 | + | std::fs::create_dir_all(&dir).ok()?; | |
| 53 | + | Some(dir) | |
| 54 | + | } | |
| 55 | + | ||
| 43 | 56 | fn preferences_path(app: &AppHandle) -> Result<PathBuf, ApiError> { | |
| 44 | - | let dir = app | |
| 45 | - | .path() | |
| 46 | - | .app_config_dir() | |
| 47 | - | .map_err(|e| ApiError::internal(format!("Resolve app config dir: {e}")))?; | |
| 48 | - | std::fs::create_dir_all(&dir) | |
| 49 | - | .map_err(|e| ApiError::internal(format!("Create app config dir: {e}")))?; | |
| 50 | - | Ok(dir.join(PREFERENCES_FILE)) | |
| 57 | + | config_dir(app) | |
| 58 | + | .map(|dir| dir.join(PREFERENCES_FILE)) | |
| 59 | + | .ok_or_else(|| ApiError::internal("Resolve app config dir".to_owned())) | |
| 51 | 60 | } | |
| 52 | 61 | ||
| 53 | 62 | pub fn load(app: &AppHandle) -> Preferences { | |
| 54 | 63 | let Ok(path) = preferences_path(app) else { | |
| 55 | 64 | return Preferences::default(); | |
| 56 | 65 | }; | |
| 57 | - | let Ok(bytes) = std::fs::read(&path) else { | |
| 66 | + | load_at(&path) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Read the preferences out of a known directory. | |
| 70 | + | /// | |
| 71 | + | /// The half a described handler can call. A missing or unreadable file is the | |
| 72 | + | /// defaults, which is what [`load`] has always done: preferences are read | |
| 73 | + | /// before the database is open and a first launch has no file at all. | |
| 74 | + | #[must_use] | |
| 75 | + | pub fn load_at(path: &std::path::Path) -> Preferences { | |
| 76 | + | let Ok(bytes) = std::fs::read(path) else { | |
| 58 | 77 | return Preferences::default(); | |
| 59 | 78 | }; | |
| 60 | 79 | serde_json::from_slice(&bytes).unwrap_or_default() | |
| @@ -62,13 +81,26 @@ | |||
| 62 | 81 | ||
| 63 | 82 | fn save(app: &AppHandle, prefs: &Preferences) -> Result<(), ApiError> { | |
| 64 | 83 | let path = preferences_path(app)?; | |
| 84 | + | save_at(&path, prefs) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Write the preferences to a known path. The other half a described handler | |
| 88 | + | /// can call. | |
| 89 | + | pub fn save_at(path: &std::path::Path, prefs: &Preferences) -> Result<(), ApiError> { | |
| 65 | 90 | let json = serde_json::to_vec_pretty(prefs) | |
| 66 | 91 | .map_err(|e| ApiError::internal(format!("Serialize preferences: {e}")))?; | |
| 67 | - | std::fs::write(&path, json) | |
| 92 | + | std::fs::write(path, json) | |
| 68 | 93 | .map_err(|e| ApiError::internal(format!("Write preferences: {e}")))?; | |
| 69 | 94 | Ok(()) | |
| 70 | 95 | } | |
| 71 | 96 | ||
| 97 | + | /// The file, under a directory [`AppState`](crate::state::AppState) already | |
| 98 | + | /// holds. | |
| 99 | + | #[must_use] | |
| 100 | + | pub fn path_in(dir: &std::path::Path) -> PathBuf { | |
| 101 | + | dir.join(PREFERENCES_FILE) | |
| 102 | + | } | |
| 103 | + | ||
| 72 | 104 | #[tauri::command] | |
| 73 | 105 | #[instrument(skip_all)] | |
| 74 | 106 | #[allow( |
| @@ -52,7 +52,7 @@ | |||
| 52 | 52 | //! |---|---| | |
| 53 | 53 | //! | Compose, reply, forward | `3fb2526a`, Max's call on the shape | | |
| 54 | 54 | //! | Settings > Sync, Settings > Sharing | a route can await a network client | | |
| 55 | - | //! | Settings > About | the app holds the host facts, as `AppState::theme_dirs` already does for the theme path | | |
| 55 | + | //! | ~~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 | 56 | //! | Create Backup | [`data`] finding 2: a described write cannot be long-running | | |
| 57 | 57 | //! | The search box | quasicoherent `d52884b0` settled the caret as the renderer's | | |
| 58 | 58 | //! | The blocking graph | `524261ac` ruled it bespoke; it draws an SVG with computed coordinates | |
| @@ -25,16 +25,33 @@ | |||
| 25 | 25 | //! Tauri command is refused by. A screen that is a key/value editor should read | |
| 26 | 26 | //! as one. | |
| 27 | 27 | //! | |
| 28 | - | //! # The five that are not, and why | |
| 28 | + | //! # The five that were not, and what became of them | |
| 29 | 29 | //! | |
| 30 | - | //! Email, Sync, Sharing, Import & Export and About are left out rather than | |
| 31 | - | //! half-described, and it is one cause rather than five. **A route handler is | |
| 30 | + | //! Email, Sync, Sharing, Import & Export and About were left out rather than | |
| 31 | + | //! half-described, under one cause rather than five. **A route handler is | |
| 32 | 32 | //! `fn(&AppState, Params)`, and those sections are about the host rather than | |
| 33 | - | //! about the app.** About is nothing else: the version comes from | |
| 34 | - | //! `window.__TAURI__.app.getVersion()`, the platform from `navigator`, and | |
| 35 | - | //! whether to offer the app-lock switch at all from asking the OS whether | |
| 36 | - | //! biometry is enrolled. Sync and Sharing reach a network client through | |
| 37 | - | //! commands that take an `AppHandle`. | |
| 33 | + | //! about the app.** | |
| 34 | + | //! | |
| 35 | + | //! Three of the five have left that list since, and each left for a different | |
| 36 | + | //! reason, which is why the single-cause explanation was worth doubting: | |
| 37 | + | //! | |
| 38 | + | //! - **Email** left on 2026-08-21 because the claim was wrong about it. | |
| 39 | + | //! `commands/email_account.rs` never mentions `AppHandle`; what is host-bound | |
| 40 | + | //! is the OAuth handshake, not the account. See [`email`]. | |
| 41 | + | //! - **Import & Export** left on 2026-08-16 as [`data`](super::data), a screen | |
| 42 | + | //! of its own, and was finished on 2026-08-21 when `Outcome::File` made the | |
| 43 | + | //! exports sayable. | |
| 44 | + | //! - **About** left on 2026-08-22, and it is the one where the claim was right | |
| 45 | + | //! and the answer was to change `AppState`. The version and the platform are | |
| 46 | + | //! resolved at startup and held, which is the move this header names below | |
| 47 | + | //! for `theme_dirs`. See [`about`]. | |
| 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. | |
| 38 | 55 | //! | |
| 39 | 56 | //! Import & Export left that sentence entirely. It went half out on 2026-08-16, | |
| 40 | 57 | //! as [`data`](super::data), a screen of its own: picking a file to submit is | |
| @@ -92,6 +109,7 @@ | |||
| 92 | 109 | use crate::commands::{all_config, write_config}; | |
| 93 | 110 | use crate::state::AppState; | |
| 94 | 111 | ||
| 112 | + | pub(crate) mod about; | |
| 95 | 113 | pub(crate) mod email; | |
| 96 | 114 | ||
| 97 | 115 | #[cfg(test)] | |
| @@ -134,7 +152,7 @@ | |||
| 134 | 152 | /// absent rather than disabled, for the reason the task overview left Edit out: | |
| 135 | 153 | /// a control that is drawn and does nothing is worse than a control that is not | |
| 136 | 154 | /// drawn, and the module header says which and why. | |
| 137 | - | const SECTIONS: [Section; 5] = [ | |
| 155 | + | const SECTIONS: [Section; 6] = [ | |
| 138 | 156 | Section { | |
| 139 | 157 | slug: "appearance", | |
| 140 | 158 | title: "Appearance", | |
| @@ -165,6 +183,15 @@ | |||
| 165 | 183 | title: "Import & Export", | |
| 166 | 184 | at: Some("/data"), | |
| 167 | 185 | }, | |
| 186 | + | // Added 2026-08-22. The header below lists About among the sections that | |
| 187 | + | // are "about the host rather than about the app". That was true, and what | |
| 188 | + | // changed is that `AppState` now holds the two host facts it needed. See | |
| 189 | + | // `about`. | |
| 190 | + | Section { | |
| 191 | + | slug: "about", | |
| 192 | + | title: "About", | |
| 193 | + | at: None, | |
| 194 | + | }, | |
| 168 | 195 | ]; | |
| 169 | 196 | ||
| 170 | 197 | /// What the app falls back to when a key has never been written. | |
| @@ -484,6 +511,7 @@ | |||
| 484 | 511 | "notifications" => notifications(&config), | |
| 485 | 512 | "planning" => planning(&config), | |
| 486 | 513 | "email" => email::pane(state)?, | |
| 514 | + | "about" => about::pane(state), | |
| 487 | 515 | _ => appearance(state, &config), | |
| 488 | 516 | }); | |
| 489 | 517 | ||
| @@ -498,6 +526,24 @@ | |||
| 498 | 526 | Ok(screen(state, "appearance")?.into()) | |
| 499 | 527 | } | |
| 500 | 528 | ||
| 529 | + | /// Turn the launch-time update check on or off. | |
| 530 | + | /// | |
| 531 | + | /// Its own route rather than a `user_config` key, because the value is not in | |
| 532 | + | /// the database: `preferences.json` is read before a connection exists, which | |
| 533 | + | /// is what `commands::preferences` is for. See [`about`]. | |
| 534 | + | fn set_update_check( | |
| 535 | + | state: &AppState, | |
| 536 | + | request: quasi_router::Request, | |
| 537 | + | ) -> Result<Response, RouteError> { | |
| 538 | + | let on = request.payload.get(about::UPDATE_CHECK).unwrap_or_default() != "disabled"; | |
| 539 | + | let said = about::write(state, on)?; | |
| 540 | + | Ok(Response::fragment( | |
| 541 | + | "settings-content", | |
| 542 | + | Node::Region(Slot::new("settings-content", RegionKind::Pane).extend(about::pane(state))), | |
| 543 | + | ) | |
| 544 | + | .toast(quasi_router::layout::Tone::Success, said)) | |
| 545 | + | } | |
| 546 | + | ||
| 501 | 547 | /// One section. | |
| 502 | 548 | fn section(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 503 | 549 | let slug = request | |
| @@ -591,8 +637,8 @@ | |||
| 591 | 637 | .get("/settings", index) | |
| 592 | 638 | .post("/settings/config/{key}", set) | |
| 593 | 639 | .post("/settings/notifications", set_notifications); | |
| 594 | - | // Above the section capture, so `email` is a literal rather than a section | |
| 595 | - | // name that happens to match. | |
| 596 | - | let router = email::routes(router); | |
| 640 | + | // Above the section capture, so `email` and `about` are literals rather | |
| 641 | + | // than section names that happen to match. | |
| 642 | + | let router = email::routes(router).post("/settings/about/update-check", set_update_check); | |
| 597 | 643 | router.get("/settings/{section}", section) | |
| 598 | 644 | } |
| @@ -76,9 +76,14 @@ | |||
| 76 | 76 | assert!(page.contains("Planning & Review")); | |
| 77 | 77 | // The app's own pointer, not the user's tick. | |
| 78 | 78 | assert!(page.contains("aria-current")); | |
| 79 | - | // The sections that are about the host rather than the app are absent | |
| 80 | - | // rather than drawn as controls that do nothing. See the module header. | |
| 81 | - | for missing in ["Sync", "Sharing", "About"] { | |
| 79 | + | // About joined them on 2026-08-22, once `AppState` held the two host facts | |
| 80 | + | // it needed. | |
| 81 | + | assert!(page.contains("About")); | |
| 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"] { | |
| 82 | 87 | assert!(!page.contains(missing), "should not offer: {missing}"); | |
| 83 | 88 | } | |
| 84 | 89 | } |
| @@ -1,0 +1,171 @@ | |||
| 1 | + | //! What the app says about itself, and the one preference that lives beside it. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! # The parent module's claim, corrected a second time | |
| 6 | + | //! | |
| 7 | + | //! [`super`]'s header listed five sections left out under one cause: "a route | |
| 8 | + | //! handler is `fn(&AppState, Params)`, and those sections are about the host | |
| 9 | + | //! rather than about the app." Email left that list on 2026-08-21 because the | |
| 10 | + | //! claim was wrong about it. About is here on different terms: the claim was | |
| 11 | + | //! *right*, and what changed is [`AppState`]. | |
| 12 | + | //! | |
| 13 | + | //! The header said About is "nothing else": the version comes from | |
| 14 | + | //! `window.__TAURI__.app.getVersion()`, the platform from `navigator`, and | |
| 15 | + | //! whether to offer the app-lock switch from asking the OS about biometry. Two | |
| 16 | + | //! of those three are now resolved at startup by the thing that can resolve | |
| 17 | + | //! them and held on the state, which is the general answer that module already | |
| 18 | + | //! named when `theme_dirs` did it for Appearance: | |
| 19 | + | //! | |
| 20 | + | //! > a host fact a described screen needs is a host fact the app has to put in | |
| 21 | + | //! > `S` while it still has a handle to ask. | |
| 22 | + | //! | |
| 23 | + | //! So this section is not a new capability in the vocabulary. It is the same | |
| 24 | + | //! move made a third time, and the header's sentence about About was true right | |
| 25 | + | //! up until somebody applied its own advice to it. | |
| 26 | + | //! | |
| 27 | + | //! # The platform reads differently, deliberately | |
| 28 | + | //! | |
| 29 | + | //! The shipped screen showed `navigator.userAgentData?.platform`, the browser's | |
| 30 | + | //! guess. [`About::platform`](crate::state::About::platform) is | |
| 31 | + | //! `std::env::consts`, so it says `linux aarch64` where the old row said | |
| 32 | + | //! `Linux`. That is the same question answered by something that knows. | |
| 33 | + | //! | |
| 34 | + | //! # Why the preference is not a `user_config` key | |
| 35 | + | //! | |
| 36 | + | //! "Check for updates on launch" is in `preferences.json` rather than the | |
| 37 | + | //! database, and `commands::preferences` says why: it is read before the | |
| 38 | + | //! database is open, and a launch-time check cannot wait for a connection. The | |
| 39 | + | //! described handler reads and writes that file through | |
| 40 | + | //! [`AppState::config_dir`](crate::state::AppState::config_dir), which is the | |
| 41 | + | //! path resolved at startup for exactly this. | |
| 42 | + | //! | |
| 43 | + | //! A host that would not give up a config directory leaves `config_dir` as | |
| 44 | + | //! `None`. The section then shows the default and refuses the write with a | |
| 45 | + | //! complaint, rather than inventing a path and writing somewhere nobody reads. | |
| 46 | + | //! | |
| 47 | + | //! # What is not here | |
| 48 | + | //! | |
| 49 | + | //! **The biometric app-lock row.** It is offered only where the OS can answer | |
| 50 | + | //! the prompt, which the shipped screen established by asking the plugin | |
| 51 | + | //! (`GoingsOn.appLock.available()`). That is a live question to a mobile | |
| 52 | + | //! platform, not a fact a startup can resolve and hold: biometry can be | |
| 53 | + | //! enrolled and unenrolled while the app runs. Offering a toggle that cannot | |
| 54 | + | //! work is worse than not offering one, which is the reasoning the JS carried | |
| 55 | + | //! and it is unchanged. The preference itself still exists and still syncs | |
| 56 | + | //! nowhere; nothing in the described app writes it, and on desktop nothing ever | |
| 57 | + | //! did. | |
| 58 | + | //! | |
| 59 | + | //! **The Keyboard Shortcuts button.** There is no described help screen to open | |
| 60 | + | //! and no chrome binding to open it with; see [`super::super::shell`], and | |
| 61 | + | //! quasicoherent `858be2a6` for the container an overlay needs. | |
| 62 | + | //! | |
| 63 | + | //! **Re-run the first-run welcome.** `whats-new.js` drew it and went with the | |
| 64 | + | //! swap. | |
| 65 | + | ||
| 66 | + | use quasi_router::screen::{Choice, Field, Row}; | |
| 67 | + | use quasi_router::{Action, Node, RouteError}; | |
| 68 | + | ||
| 69 | + | use crate::commands::Preferences; | |
| 70 | + | use crate::state::AppState; | |
| 71 | + | ||
| 72 | + | /// The key the update-check toggle sends under. | |
| 73 | + | pub(super) const UPDATE_CHECK: &str = "update_check_on_launch"; | |
| 74 | + | ||
| 75 | + | /// The facts, then the preference. | |
| 76 | + | pub(super) fn pane(state: &AppState) -> Vec<Node> { | |
| 77 | + | let prefs = read(state); | |
| 78 | + | ||
| 79 | + | vec![ | |
| 80 | + | Node::section("About GoingsOn"), | |
| 81 | + | Node::text("Tasks, email, calendar, contacts."), | |
| 82 | + | Node::list(facts(state).into_iter()), | |
| 83 | + | // An On/Off choice rather than a toggle kind, which is how every other | |
| 84 | + | // boolean on this screen is said (`plan_nudges`, `review_nudges`). One | |
| 85 | + | // shape for one question, and the renderer decides whether that draws | |
| 86 | + | // as a switch. | |
| 87 | + | Node::field( | |
| 88 | + | Field { | |
| 89 | + | options: vec![ | |
| 90 | + | Choice::new("enabled", "Enabled (default)"), | |
| 91 | + | Choice::new("disabled", "Disabled"), | |
| 92 | + | ], | |
| 93 | + | value: Some( | |
| 94 | + | if prefs.update_check_on_launch { | |
| 95 | + | "enabled" | |
| 96 | + | } else { | |
| 97 | + | "disabled" | |
| 98 | + | } | |
| 99 | + | .to_owned(), | |
| 100 | + | ), | |
| 101 | + | ..Field::new( | |
| 102 | + | makeover_layout::FieldKind::Select, | |
| 103 | + | UPDATE_CHECK, | |
| 104 | + | "Check for updates on launch", | |
| 105 | + | ) | |
| 106 | + | } | |
| 107 | + | .hint( | |
| 108 | + | "When a new signed release is available, a banner appears in the app. \ | |
| 109 | + | Install is always user-initiated.", | |
| 110 | + | ) | |
| 111 | + | .changes(Action::post("/settings/about/update-check")), | |
| 112 | + | ), | |
| 113 | + | ] | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// The identity rows, in the order the shipped screen listed them. | |
| 117 | + | /// | |
| 118 | + | /// A list of rows rather than a `dl`, because a description has no word for a | |
| 119 | + | /// definition list and does not need one: each row is a label and a value, | |
| 120 | + | /// which is what [`Row::meta`] already is. The renderer decides whether that | |
| 121 | + | /// draws as two columns. | |
| 122 | + | fn facts(state: &AppState) -> Vec<Row> { | |
| 123 | + | [ | |
| 124 | + | ("Version", state.about.version.clone()), | |
| 125 | + | ("Platform", state.about.platform.clone()), | |
| 126 | + | ("Publisher", "Make Creative, LLC".to_owned()), | |
| 127 | + | ("License", "PolyForm Noncommercial 1.0.0".to_owned()), | |
| 128 | + | ("Contact", "info@makenot.work".to_owned()), | |
| 129 | + | ("Source", "makenot.work".to_owned()), | |
| 130 | + | ("Privacy", "makenot.work/policy".to_owned()), | |
| 131 | + | ] | |
| 132 | + | .into_iter() | |
| 133 | + | .map(|(label, value)| Row::new(label).meta(value)) | |
| 134 | + | .collect() | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// The preferences as they stand, or the defaults. | |
| 138 | + | fn read(state: &AppState) -> Preferences { | |
| 139 | + | state | |
| 140 | + | .config_dir | |
| 141 | + | .as_ref() | |
| 142 | + | .map_or_else(Preferences::default, |dir| { | |
| 143 | + | crate::commands::load_at(&crate::commands::path_in(dir)) | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Write the update-check preference. | |
| 148 | + | /// | |
| 149 | + | /// Answers a complaint rather than a fault when there is nowhere to write: | |
| 150 | + | /// a host with no config directory is a host this app cannot remember anything | |
| 151 | + | /// on, and that is worth saying to the person who just pressed the switch. | |
| 152 | + | pub(super) fn write(state: &AppState, on: bool) -> Result<&'static str, RouteError> { | |
| 153 | + | let Some(dir) = state.config_dir.as_ref() else { | |
| 154 | + | return Err(RouteError::internal( | |
| 155 | + | "This machine did not give the app a config directory, so the setting cannot be saved.", | |
| 156 | + | )); | |
| 157 | + | }; | |
| 158 | + | let path = crate::commands::path_in(dir); | |
| 159 | + | let mut prefs = crate::commands::load_at(&path); | |
| 160 | + | prefs.update_check_on_launch = on; | |
| 161 | + | crate::commands::save_at(&path, &prefs) | |
| 162 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 163 | + | Ok(if on { | |
| 164 | + | "Update checks on." | |
| 165 | + | } else { | |
| 166 | + | "Update checks off." | |
| 167 | + | }) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[cfg(test)] | |
| 171 | + | mod tests; |
| @@ -1,0 +1,135 @@ | |||
| 1 | + | //! The About section, driven through the router against a real state. | |
| 2 | + | //! | |
| 3 | + | //! The assertion worth reading is the last one. This section exists because two | |
| 4 | + | //! host facts moved onto `AppState`, and the whole claim is that a route handler | |
| 5 | + | //! can now answer them with nothing but `&AppState`. Every test here goes | |
| 6 | + | //! through `router()` for that reason: calling `pane` directly would prove the | |
| 7 | + | //! function works and say nothing about whether a request can reach it. | |
| 8 | + | ||
| 9 | + | use std::sync::Arc; | |
| 10 | + | ||
| 11 | + | use quasi_http::Serves as _; | |
| 12 | + | use quasi_router::{Outcome, Params, Request}; | |
| 13 | + | ||
| 14 | + | use crate::quasi::router; | |
| 15 | + | use crate::state::AppState; | |
| 16 | + | ||
| 17 | + | async fn state() -> Arc<AppState> { | |
| 18 | + | let (state, _) = crate::test_utils::setup_test_state().await; | |
| 19 | + | state | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | fn html(state: &AppState, path: &str) -> String { | |
| 23 | + | let response = router() | |
| 24 | + | .handle(state, Request::get(path).carrying(Params::new())) | |
| 25 | + | .expect("the route answers"); | |
| 26 | + | match &response.outcome { | |
| 27 | + | Outcome::Screen(screen) => quasi_webview::Webview::new().screen(screen), | |
| 28 | + | Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(node), | |
| 29 | + | other => panic!("expected content, got {other:?}"), | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[tokio::test] | |
| 34 | + | async fn about_is_a_section_of_settings() { | |
| 35 | + | let state = state().await; | |
| 36 | + | let markup = html(&state, "/settings/about"); | |
| 37 | + | ||
| 38 | + | assert!(markup.contains("About GoingsOn"), "{markup}"); | |
| 39 | + | // The sidebar offers it, which is the half that makes it reachable. | |
| 40 | + | assert!(markup.contains("/settings/about"), "{markup}"); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | #[tokio::test] | |
| 44 | + | async fn the_version_and_the_platform_come_off_the_state() { | |
| 45 | + | // The two facts the old header called host-bound. They are on `AppState` | |
| 46 | + | // now, resolved at startup, which is what `theme_dirs` did for Appearance. | |
| 47 | + | let state = state().await; | |
| 48 | + | let markup = html(&state, "/settings/about"); | |
| 49 | + | ||
| 50 | + | assert!(markup.contains("0.0.0-test"), "{markup}"); | |
| 51 | + | assert!(markup.contains("test"), "{markup}"); | |
| 52 | + | assert!(markup.contains("Make Creative, LLC"), "{markup}"); | |
| 53 | + | assert!(markup.contains("PolyForm Noncommercial 1.0.0"), "{markup}"); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | #[tokio::test] | |
| 57 | + | async fn the_update_check_shows_the_default_before_anyone_has_chosen() { | |
| 58 | + | // A first launch has no `preferences.json` at all, and the default is on. | |
| 59 | + | // The section must not read that as off. | |
| 60 | + | let state = state().await; | |
| 61 | + | let markup = html(&state, "/settings/about"); | |
| 62 | + | assert!(markup.contains("update_check_on_launch"), "{markup}"); | |
| 63 | + | ||
| 64 | + | let prefs = super::read(&state); | |
| 65 | + | assert!(prefs.update_check_on_launch, "the default is on"); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | #[tokio::test] | |
| 69 | + | async fn turning_the_update_check_off_writes_the_file_the_launch_path_reads() { | |
| 70 | + | // The point of the route: `preferences.json` is read before the database is | |
| 71 | + | // open, so this is a real file rather than a `user_config` row, and the | |
| 72 | + | // described handler reaches it through the directory `AppState` holds. | |
| 73 | + | let state = state().await; | |
| 74 | + | ||
| 75 | + | let response = router() | |
| 76 | + | .handle( | |
| 77 | + | &state, | |
| 78 | + | Request::post("/settings/about/update-check") | |
| 79 | + | .sending(Params::new().with(super::UPDATE_CHECK, "disabled")), | |
| 80 | + | ) | |
| 81 | + | .expect("the route answers"); | |
| 82 | + | ||
| 83 | + | assert!( | |
| 84 | + | matches!(response.outcome, Outcome::Fragment { .. }), | |
| 85 | + | "the section is answered back, not a whole screen" | |
| 86 | + | ); | |
| 87 | + | ||
| 88 | + | // Read through the same function the launch path uses, not through the | |
| 89 | + | // response: the file is the fact. | |
| 90 | + | let dir = state.config_dir.as_ref().expect("a test has a config dir"); | |
| 91 | + | let written = crate::commands::load_at(&crate::commands::path_in(dir)); | |
| 92 | + | assert!(!written.update_check_on_launch); | |
| 93 | + | ||
| 94 | + | // And back on again, so the route is not one-way. | |
| 95 | + | router() | |
| 96 | + | .handle( | |
| 97 | + | &state, | |
| 98 | + | Request::post("/settings/about/update-check") | |
| 99 | + | .sending(Params::new().with(super::UPDATE_CHECK, "enabled")), | |
| 100 | + | ) | |
| 101 | + | .expect("the route answers"); | |
| 102 | + | let written = crate::commands::load_at(&crate::commands::path_in(dir)); | |
| 103 | + | assert!(written.update_check_on_launch); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[tokio::test] | |
| 107 | + | async fn the_biometric_row_is_not_offered_anywhere() { | |
| 108 | + | // Pinned so it reads as a decision rather than an omission somebody closes. | |
| 109 | + | // The row is offered only where the OS can answer the prompt, and that is a | |
| 110 | + | // live question to a mobile platform rather than a fact a startup resolves. | |
| 111 | + | let state = state().await; | |
| 112 | + | let markup = html(&state, "/settings/about"); | |
| 113 | + | assert!(!markup.contains("require_biometric"), "{markup}"); | |
| 114 | + | assert!(!markup.contains("Require unlock"), "{markup}"); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | #[tokio::test] | |
| 118 | + | async fn a_host_with_nowhere_to_write_says_so_rather_than_inventing_a_path() { | |
| 119 | + | // `config_dir` is `None` when the host would not give one up. Showing the | |
| 120 | + | // default and refusing the write is the honest pair; writing to a guessed | |
| 121 | + | // path would be a preference that silently never comes back. | |
| 122 | + | let (mut state, _) = crate::test_utils::setup_test_state_owned().await; | |
| 123 | + | state.config_dir = None; | |
| 124 | + | let state = Arc::new(state); | |
| 125 | + | ||
| 126 | + | let markup = html(&state, "/settings/about"); | |
| 127 | + | assert!(markup.contains("About GoingsOn"), "the section still draws"); | |
| 128 | + | ||
| 129 | + | let refused = router().handle( | |
| 130 | + | &state, | |
| 131 | + | Request::post("/settings/about/update-check") | |
| 132 | + | .sending(Params::new().with(super::UPDATE_CHECK, "disabled")), | |
| 133 | + | ); | |
| 134 | + | assert!(refused.is_err(), "the write is refused"); | |
| 135 | + | } |