Skip to main content

max / goingson

Settings > About comes back, on the answer theme_dirs already gave First of the six the swap took out. `quasi::settings`'s header listed About among five sections left out because "a route handler is fn(&AppState, Params), and those sections are about the host rather than about the app". That was right about About, and the fix was not to argue with it: the same header names the general answer two paragraphs later, where AppState::theme_dirs holds a path only an AppHandle could resolve so that Appearance stopped being host-bound. AppState gains `about` (version, platform) and `config_dir`, both resolved at startup by the thing that can resolve them. The section is then an ordinary described pane. THE PLATFORM READS DIFFERENTLY, deliberately. The shipped row showed navigator.userAgentData?.platform, the browser's guess; this is std::env::consts, so it says "linux aarch64" where the old row said "Linux". Same question, answered by something that knows. The update-check preference is not a user_config key and cannot become one: preferences.json is read before the database is open, which is the whole reason that file exists. `commands::preferences` grew a path-taking half so a described handler can reach it, and POST /settings/about/update-check is its own route rather than an arm of the config writer. A host that gives up no config directory leaves config_dir None; the section shows the default and refuses the write with a complaint, rather than writing to a guessed path where nothing would read it back. Said as an On/Off choice, not a toggle kind, because that is how plan_nudges and review_nudges are already said on this screen. One shape for one question. TWO THINGS STAY OUT, both pinned by tests so they read as decisions. The biometric app-lock row is offered only where the OS can answer the prompt, and that is a live question to a mobile platform rather than a fact a startup can resolve and hold. The Keyboard Shortcuts button has no described help screen to open and no chrome binding to open it with, quasicoherent 858be2a6. The header's five-sections paragraph is corrected rather than deleted: three of the five have left it now, each for a different reason, which is why a single-cause explanation was worth doubting. Sync and Sharing remain, and their reason is async I/O rather than the AppHandle it blamed. Six tests, all through the router: calling pane directly would prove the function works and say nothing about whether a request can reach it. 774 total.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 17:10 UTC
Signed with PGP, not checked
Commit: 078a44de47537abf49ffa9dc85fc12d84f6850d6
Parent: f4edefb
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 &amp; 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 + }