Skip to main content

max / goingson

Settings is described, as far as the host lets it be Sixth screen ported behind the off-by-default quasi feature, and the first whose subject is the app rather than the user's data. Three sections are here - appearance, notifications, planning - and all six of their controls are user_config keys, so the whole screen writes through one route that the spec's own closed set refuses an unknown key at. The other five sections are about the host, not the app, and a route handler is fn(&AppState, Params). Appearance was nearly a fourth casualty: the theme search path is built from the resource and config directories, so AppState now holds it, resolved at startup by the only thing that can resolve it. That is the general answer for a static host fact and it is the app's to give, not quasi's. What is left - asking whether biometry is enrolled, and a native save dialog's path - is filed, along with choices having no grouping and RouteError having no 400. First consumer of Node::Field with Field::changes, and the first use of Region::Sidebar for what it is named for.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 22:50 UTC
Signed with PGP, not checked
Commit: 8dac65bfbb993b1096ae904c3c1884919fdabb8f
Parent: a4322c9
7 files changed, +726 insertions, -19 deletions
@@ -68,6 +68,20 @@
68 68 pub pending_oauth_servers:
69 69 Arc<Mutex<std::collections::HashMap<u16, crate::oauth::OAuthCallbackServer>>>,
70 70 pub data_dir: PathBuf,
71 + /// Where themes are looked for, in precedence order, with `true` marking a
72 + /// directory the user owns.
73 + ///
74 + /// Built once here, by `commands::themes::theme_dirs`, because it is the
75 + /// only thing that can build it: the paths come off the `AppHandle`'s
76 + /// resource and config directories. Held rather than rebuilt per call so
77 + /// something that is not a Tauri command can still ask what themes exist —
78 + /// the described settings screen is the first such caller, and its handlers
79 + /// see this struct and nothing else.
80 + ///
81 + /// A path list rather than a theme list, so nothing goes stale: importing a
82 + /// theme writes a file into the custom directory, and every read walks the
83 + /// directories again.
84 + pub theme_dirs: Vec<(PathBuf, bool)>,
71 85 }
72 86
73 87 /// Server-side storage for a pending OAuth flow.
@@ -220,6 +234,7 @@
220 234 pending_oauth_flows: Arc::new(Mutex::new(std::collections::HashMap::new())),
221 235 pending_oauth_servers: Arc::new(Mutex::new(std::collections::HashMap::new())),
222 236 data_dir: app_data_dir,
237 + theme_dirs: crate::commands::theme_dirs(app),
223 238 })
224 239 }
225 240
@@ -77,6 +77,14 @@
77 77 pending_oauth_flows: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
78 78 pending_oauth_servers: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
79 79 data_dir: std::path::PathBuf::from("/tmp/goingson-test"),
80 + // The tree's own themes, which is the dev fallback `theme_dirs` builds
81 + // from `CARGO_MANIFEST_DIR`. A test has no `AppHandle` and so no
82 + // resource or config directory, and this is the one of the three that
83 + // does not need one.
84 + theme_dirs: vec![(
85 + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"),
86 + false,
87 + )],
80 88 };
81 89
82 90 (Arc::new(state), user_id)
@@ -56,13 +56,13 @@
56 56 .map_err(db_err)
57 57 }
58 58
59 - /// Every config key and value, for the frontend to preload into its cache so its
60 - /// synchronous reads stay synchronous.
61 - #[tauri::command]
62 - #[instrument(skip_all)]
63 - pub async fn get_all_config(
64 - state: State<'_, Arc<AppState>>,
65 - ) -> Result<HashMap<String, String>, ApiError> {
59 + /// Every config key and value.
60 + ///
61 + /// Split out of [`get_all_config`] so the described settings screen in `quasi`
62 + /// reads config the same way the frontend cache does. Same lift, and for the
63 + /// same reason, as `gather_weekly_review`: a route handler is a plain function
64 + /// over [`AppState`] and cannot call a Tauri command.
65 + pub fn all_config(state: &AppState) -> Result<HashMap<String, String>, ApiError> {
66 66 let conn = state.db.conn()?;
67 67 let mut stmt = conn
68 68 .prepare("SELECT key, value FROM user_config")
@@ -74,6 +74,35 @@
74 74 .map_err(db_err)
75 75 }
76 76
77 + /// Set a config value, refusing a key the spec does not declare.
78 + ///
79 + /// The other half of the lift, and the reason the described screen can offer one
80 + /// write route for every control on it: the closed set is checked here rather
81 + /// than by the screen deciding which keys it is willing to name.
82 + pub fn write_config(state: &AppState, key: &str, value: &str) -> Result<(), ApiError> {
83 + ensure_known(key)?;
84 + state
85 + .db
86 + .conn()?
87 + .execute(
88 + "INSERT INTO user_config (key, value) VALUES (?1, ?2) \
89 + ON CONFLICT(key) DO UPDATE SET value = excluded.value",
90 + rusqlite::params![key, value],
91 + )
92 + .map_err(db_err)?;
93 + Ok(())
94 + }
95 +
96 + /// Every config key and value, for the frontend to preload into its cache so its
97 + /// synchronous reads stay synchronous.
98 + #[tauri::command]
99 + #[instrument(skip_all)]
100 + pub async fn get_all_config(
101 + state: State<'_, Arc<AppState>>,
102 + ) -> Result<HashMap<String, String>, ApiError> {
103 + all_config(&state)
104 + }
105 +
77 106 /// Set a config value (upsert). A synced key's write is captured by the engine's
78 107 /// export trigger; a device-local key's is not.
79 108 #[tauri::command]
@@ -83,17 +112,7 @@
83 112 key: String,
84 113 value: String,
85 114 ) -> Result<(), ApiError> {
86 - ensure_known(&key)?;
87 - state
88 - .db
89 - .conn()?
90 - .execute(
91 - "INSERT INTO user_config (key, value) VALUES (?1, ?2) \
92 - ON CONFLICT(key) DO UPDATE SET value = excluded.value",
93 - rusqlite::params![&key, &value],
94 - )
95 - .map_err(db_err)?;
96 - Ok(())
115 + write_config(&state, &key, &value)
97 116 }
98 117
99 118 /// Remove a config key. Absent already is not an error.
@@ -13,7 +13,11 @@
13 13 /// bundles. Built through makeover's [`ThemeDirs`] so the precedence is stated
14 14 /// once, in the library, rather than in each app that needs it. This function
15 15 /// used to be duplicated byte-for-byte in Balanced Breakfast.
16 - fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
16 + ///
17 + /// Public to the crate because `AppState` holds the result: a described screen's
18 + /// handler is a plain function over the state and never sees an `AppHandle`, so
19 + /// the paths have to be resolved once at startup by whoever does.
20 + pub(crate) fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
17 21 ThemeDirs::new()
18 22 // Bundled themes, packaged with the app in production.
19 23 .bundled(app.path().resource_dir().ok().map(|d| d.join("themes")))
@@ -36,6 +36,7 @@
36 36
37 37 pub mod contacts;
38 38 pub mod projects;
39 + pub mod settings;
39 40 pub mod tasks;
40 41 pub mod weekly_review;
41 42
@@ -46,6 +47,7 @@
46 47 let router = projects::routes(router);
47 48 let router = contacts::routes(router);
48 49 let router = tasks::routes(router);
50 + let router = settings::routes(router);
49 51 weekly_review::routes(router)
50 52 }
51 53
@@ -1,0 +1,433 @@
1 + //! Settings, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Sixth screen ported. The shipped screen is `frontend/js/settings.js` and its
6 + //! two delegate modules exactly as before; see [the module above](super) for why
7 + //! both exist at once.
8 + //!
9 + //! The first port whose subject is not the user's data. Every other described
10 + //! screen reads tasks, projects, contacts or a week; this one reads *the app's
11 + //! own settings*, and that turned out to be the interesting part — three of its
12 + //! eight sections are not describable from a route handler at all, for a reason
13 + //! that is structural and worth stating plainly.
14 + //!
15 + //! # The three sections that are here
16 + //!
17 + //! - **Appearance** — the theme.
18 + //! - **Notifications** — the event indicator lead time.
19 + //! - **Planning & Review** — work hours and the two nudge switches.
20 + //!
21 + //! What those six controls have in common is that they are `user_config` keys:
22 + //! rows in a table this app owns, declared as a closed set in
23 + //! [`crate::config_key::CONFIG`]. So the whole of this screen's writing is one
24 + //! route, `POST /settings/config/{key}`, refused by the same `ensure_known` the
25 + //! Tauri command is refused by. A screen that is a key/value editor should read
26 + //! as one.
27 + //!
28 + //! # The five that are not, and why
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
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. Import & Export is native file dialogs. Sync and
37 + //! Sharing reach a network client through commands that take an `AppHandle`.
38 + //!
39 + //! Half of that is the app's own doing and is fixed here: the theme list also
40 + //! needed an `AppHandle`, because the search path is built from the resource and
41 + //! config directories, and Appearance would have been a fourth casualty.
42 + //! [`AppState::theme_dirs`](crate::state::AppState::theme_dirs) now holds that
43 + //! path, resolved once at startup by the only thing that can resolve it. That is
44 + //! the general answer and it is not quasi's to give: `S` is the app's own state,
45 + //! and a host fact a described screen needs is a host fact the app has to put
46 + //! in `S` while it still has a handle to ask.
47 + //!
48 + //! The other half does not yield to it. A version string would, and so would a
49 + //! platform name. Whether biometry is enrolled is asked at the moment it is
50 + //! asked, and a native save dialog returns a value from an interaction — neither
51 + //! is a fact that can be resolved at startup and held. That is filed.
52 + //!
53 + //! # The shape
54 + //!
55 + //! - `GET /settings` — Appearance, which is where the JS opens.
56 + //! - `GET /settings/{section}` — one section.
57 + //! - `POST /settings/config/{key}` — write one config key, under `value`.
58 + //!
59 + //! The section is an address rather than `settings.js:currentSection`, per
60 + //! decision 2. Unlike the projects filters and the weekly review's week it needs
61 + //! no carrying: a write answers with the section it happened in, and the
62 + //! handler knows which that is from the key.
63 +
64 + // Handlers take their params by value because `quasi_router::Handler` is a
65 + // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
66 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
67 + #![allow(clippy::needless_pass_by_value)]
68 +
69 + use std::collections::HashMap;
70 +
71 + use quasi_router::screen::{Choice, Field, Row};
72 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
73 +
74 + use crate::commands::{all_config, write_config};
75 + use crate::state::AppState;
76 +
77 + #[cfg(test)]
78 + mod tests;
79 +
80 + /// A section of the screen: its address, its heading, and the keys it writes.
81 + struct Section {
82 + /// The last path segment, and what the sidebar sends.
83 + slug: &'static str,
84 + /// What the section is called.
85 + title: &'static str,
86 + }
87 +
88 + /// The sections that are described, in the sidebar's order.
89 + ///
90 + /// The JS sidebar offers eight and this offers three. The five missing ones are
91 + /// absent rather than disabled, for the reason the task overview left Edit out:
92 + /// a control that is drawn and does nothing is worse than a control that is not
93 + /// drawn, and the module header says which and why.
94 + const SECTIONS: [Section; 3] = [
95 + Section {
96 + slug: "appearance",
97 + title: "Appearance",
98 + },
99 + Section {
100 + slug: "notifications",
101 + title: "Notifications",
102 + },
103 + Section {
104 + slug: "planning",
105 + title: "Planning & Review",
106 + },
107 + ];
108 +
109 + /// What the app falls back to when a key has never been written.
110 + ///
111 + /// Stated once here rather than at each read. The JS states each of these
112 + /// inline at its own call site — `|| 'system'`, `|| '15'`, `|| '9'` — which is
113 + /// how `event_lead_minutes` came to have its default written in three places.
114 + fn default_for(key: &str) -> &'static str {
115 + match key {
116 + "theme" => "system",
117 + "event_lead_minutes" => "15",
118 + "work_start_hour" => "9",
119 + "work_end_hour" => "17",
120 + // Both nudges are on unless someone turned them off, which is what the
121 + // JS's `!== 'disabled'` says the long way round.
122 + _ => "enabled",
123 + }
124 + }
125 +
126 + /// The value a key currently holds.
127 + fn value_of<'a>(config: &'a HashMap<String, String>, key: &str) -> &'a str {
128 + config.get(key).map_or_else(
129 + || {
130 + // Borrowed from a `'static`, which outlives `'a`.
131 + default_for(key)
132 + },
133 + String::as_str,
134 + )
135 + }
136 +
137 + /// The route that writes one config key.
138 + fn writes(key: &str) -> Action {
139 + Action::post(format!("/settings/config/{key}"))
140 + }
141 +
142 + /// A control that stands on its own and writes as soon as it changes.
143 + ///
144 + /// [`Node::Field`] and [`Field::changes`] together, which is what finding
145 + /// `14612ed8` was closed for and what this screen is the first consumer of. The
146 + /// JS says the same thing with `data-change` on a bare `<select>` with no form
147 + /// around it; wrapping these in a [`Node::Form`] would describe a submit button
148 + /// that does not exist.
149 + fn setting(field: Field) -> Node {
150 + let key = field.name.clone();
151 + Node::field(field.changes(writes(&key)))
152 + }
153 +
154 + /// A select over a fixed set of values, holding the one in force.
155 + fn choice_field(
156 + config: &HashMap<String, String>,
157 + key: &'static str,
158 + label: &str,
159 + options: Vec<Choice>,
160 + ) -> Field {
161 + Field::select(key, label, options).value(value_of(config, key))
162 + }
163 +
164 + /// The themes on offer.
165 + ///
166 + /// # The first finding
167 + ///
168 + /// **A set of choices cannot be grouped.** `renderAppearance` puts the themes in
169 + /// four `<optgroup>`s — System, Light Themes, Dark Themes, High Contrast — and
170 + /// [`Choice`] is a value and a label. A flat list of twenty themes with no
171 + /// grouping is a worse control than the one it replaces, so the variant goes
172 + /// into the label, which keeps the fact and loses the structure.
173 + ///
174 + /// Small, and filed rather than worked around further: the honest fix is a
175 + /// group on the choice, and it is the same shape in every app that has ever
176 + /// filled a select from more than one source.
177 + ///
178 + /// The list itself is read through [`AppState::theme_dirs`], which is the whole
179 + /// reason this section exists at all — see the module header.
180 + fn theme_choices(state: &AppState) -> Vec<Choice> {
181 + let mut choices = vec![Choice::new("system", "Follow System")];
182 + for theme in makeover::list_themes_from_dirs(&state.theme_dirs) {
183 + let variant = match theme.variant.as_str() {
184 + "high-contrast" => "High Contrast",
185 + "light" => "Light",
186 + // makeover's variant is a string and dark is what everything else
187 + // is, which is the same fallback `getThemesByType` makes.
188 + _ => "Dark",
189 + };
190 + choices.push(Choice::new(
191 + &theme.id,
192 + format!("{} ({variant})", theme.name),
193 + ));
194 + }
195 + choices
196 + }
197 +
198 + /// Appearance.
199 + ///
200 + /// # The second finding
201 + ///
202 + /// **Import and Export are absent because a file dialog is not an address.**
203 + /// `themes.importTheme` opens a native open-dialog and `themes.exportTheme` a
204 + /// native save-dialog, and both then call a command with the path the user
205 + /// picked. `FieldKind::File` covers picking a file to *submit*, which is the
206 + /// import half and would work here if the write route existed; the export half
207 + /// is a control that asks the host where to put something and then acts, and
208 + /// nothing in the vocabulary names that.
209 + ///
210 + /// Left out rather than dangled, to the standard the contacts port set. Filed
211 + /// on quasicoherent alongside the About section's host facts, because it is the
212 + /// same finding wearing different clothes: the description can say what to do
213 + /// and cannot reach what the host knows.
214 + fn appearance(state: &AppState, config: &HashMap<String, String>) -> Vec<Node> {
215 + vec![
216 + Node::section("Appearance"),
217 + setting(
218 + choice_field(config, "theme", "Theme", theme_choices(state))
219 + .hint("Choose a color theme for the interface."),
220 + ),
221 + ]
222 + }
223 +
224 + /// Notifications.
225 + fn notifications(config: &HashMap<String, String>) -> Vec<Node> {
226 + let options = [5, 10, 15, 30, 60]
227 + .into_iter()
228 + .map(|minutes| {
229 + let label = if minutes == 60 {
230 + "1 hour".to_owned()
231 + } else {
232 + format!("{minutes} minutes")
233 + };
234 + // The default is marked in the label because the control cannot
235 + // otherwise say which value it would hold if nobody had chosen. The
236 + // JS marks the same one the same way.
237 + let label = if minutes == 15 {
238 + format!("{label} (default)")
239 + } else {
240 + label
241 + };
242 + Choice::new(minutes.to_string(), label)
243 + })
244 + .collect();
245 +
246 + vec![
247 + Node::section("Notifications"),
248 + setting(
249 + choice_field(
250 + config,
251 + "event_lead_minutes",
252 + "Event indicator lead time",
253 + options,
254 + )
255 + .hint("How far in advance the Events tab dot turns yellow."),
256 + ),
257 + ]
258 + }
259 +
260 + /// Every hour of the day, as the clock writes it.
261 + ///
262 + /// `buildHourOptions`, which builds the same 24 labels. Twelve-hour with AM and
263 + /// PM because that is what the shipped screen shows; a description that emitted
264 + /// `09:00` would be answering a question about the user's locale that nothing
265 + /// here asked.
266 + fn hour_choices() -> Vec<Choice> {
267 + (0..24)
268 + .map(|hour| {
269 + let label = match hour {
270 + 0 => "12:00 AM".to_owned(),
271 + 1..=11 => format!("{hour}:00 AM"),
272 + 12 => "12:00 PM".to_owned(),
273 + _ => format!("{}:00 PM", hour - 12),
274 + };
275 + Choice::new(hour.to_string(), label)
276 + })
277 + .collect()
278 + }
279 +
280 + /// Whether a switch of this kind is on.
281 + fn on_off() -> Vec<Choice> {
282 + vec![
283 + Choice::new("enabled", "Enabled (default)"),
284 + Choice::new("disabled", "Disabled"),
285 + ]
286 + }
287 +
288 + /// Planning and review.
289 + ///
290 + /// # The third finding
291 + ///
292 + /// **Two controls answering one question are two controls.** Work hours is a
293 + /// start and an end, drawn by the JS as one labelled row reading "9:00 AM to
294 + /// 5:00 PM" with the word "to" between two selects. The description has one
295 + /// label per field, so it says "Work day starts" and "Work day ends" — every
296 + /// fact kept, and the pairing gone.
297 + ///
298 + /// Filed as a finding and expected to be refused, which is why it is written
299 + /// down rather than argued for. A group of fields inside a form is furniture,
300 + /// and the 2026-08-08 row ruling refused the same door on the same grounds. The
301 + /// labels here are the honest answer, not a workaround for a missing member.
302 + fn planning(config: &HashMap<String, String>) -> Vec<Node> {
303 + vec![
304 + Node::section("Planning & Review"),
305 + setting(
306 + choice_field(config, "work_start_hour", "Work day starts", hour_choices())
307 + .hint("Controls when plan and review nudge dots appear."),
308 + ),
309 + setting(choice_field(
310 + config,
311 + "work_end_hour",
312 + "Work day ends",
313 + hour_choices(),
314 + )),
315 + setting(choice_field(config, "plan_nudges", "Plan nudges", on_off())),
316 + setting(choice_field(
317 + config,
318 + "review_nudges",
319 + "Review nudges",
320 + on_off(),
321 + )),
322 + ]
323 + }
324 +
325 + /// The section under this slug, or 404.
326 + fn section_of(slug: &str) -> Result<&'static Section, RouteError> {
327 + SECTIONS
328 + .iter()
329 + .find(|section| section.slug == slug)
330 + .ok_or_else(|| RouteError::not_found("no such settings section"))
331 + }
332 +
333 + /// Which section a config key belongs to, so a write can answer with it.
334 + ///
335 + /// A match on the key rather than a param the control carries: the section a
336 + /// setting sits in is a fact about the setting, and threading it through every
337 + /// action would be the screen telling the handler something the handler already
338 + /// knows. An unknown key never reaches here — [`write_config`] refuses it first.
339 + fn section_for_key(key: &str) -> &'static str {
340 + match key {
341 + "event_lead_minutes" => "notifications",
342 + "work_start_hour" | "work_end_hour" | "plan_nudges" | "review_nudges" => "planning",
343 + _ => "appearance",
344 + }
345 + }
346 +
347 + /// The whole screen, showing one section.
348 + fn screen(state: &AppState, slug: &str) -> Result<Screen, RouteError> {
349 + let section = section_of(slug)?;
350 + let config = all_config(state).map_err(|error| RouteError::internal(error.to_string()))?;
351 +
352 + // The first port to use `Region::Sidebar` for what it is named for. The
353 + // weekly review took `SidebarContent` for a band and one pane; here the
354 + // sidebar is the section nav, which is what `settings.js` draws as
355 + // `.settings-nav-item` buttons and tracks with an `active` class.
356 + let nav = Slot::new("settings-nav", RegionKind::Sidebar).with(Node::list(SECTIONS.iter().map(
357 + |item| {
358 + let mut row =
359 + Row::new(item.title).activate(Action::get(format!("/settings/{}", item.slug)));
360 + // `current` and not `selected`: this is the app's own pointer at
361 + // what the pane is showing, which is the distinction the 2026-08-08
362 + // rename drew, and the first place in the port where the sidebar
363 + // half of it is what is wanted. Set on the field because the two
364 + // have no paired constructor the way `toggling` pairs the other
365 + // two, and inventing one for a plain bool would be noise.
366 + row.current = item.slug == section.slug;
367 + row
368 + },
369 + )));
370 +
371 + let mut pane = Slot::new("settings-content", RegionKind::Pane);
372 + pane = pane.extend(match section.slug {
373 + "notifications" => notifications(&config),
374 + "planning" => planning(&config),
375 + _ => appearance(state, &config),
376 + });
377 +
378 + Ok(Screen::sidebar_content("Settings").with(nav).with(pane))
379 + }
380 +
381 + /// Appearance, which is where the JS opens.
382 + fn index(state: &AppState, _params: quasi_router::Params) -> Result<Response, RouteError> {
383 + Ok(screen(state, "appearance")?.into())
384 + }
385 +
386 + /// One section.
387 + fn section(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
388 + let slug = params
389 + .get("section")
390 + .ok_or_else(|| RouteError::not_found("no section"))?;
391 + Ok(screen(state, slug)?.into())
392 + }
393 +
394 + /// Write one config key.
395 + ///
396 + /// One route for every control on the screen, because every control on the
397 + /// screen writes one `user_config` row. The closed set is [`write_config`]'s to
398 + /// enforce, so a key the spec does not declare is refused here exactly as it is
399 + /// refused through the command, and the screen does not carry a second list of
400 + /// what it is willing to name.
401 + ///
402 + /// A refusal is an [`Internal`](quasi_router::Class::Internal) rather than the
403 + /// field carrying its own error, and rather than the 400 that is not there to
404 + /// reach for. `RouteError` has four classes and none of them is "the caller
405 + /// asked for something malformed", which is a real gap in general and the right
406 + /// answer here: every value this route can receive was put on the control by
407 + /// this screen, so an undeclared key or a missing value is a bug in the
408 + /// description and not in what the user chose. `Field::error` is for the other
409 + /// case, which this screen does not have — nothing here is typed.
410 + fn set(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
411 + let key = params
412 + .get("key")
413 + .ok_or_else(|| RouteError::not_found("no config key"))?
414 + .to_owned();
415 + let value = params
416 + .get(Node::SELECTED)
417 + .ok_or_else(|| RouteError::internal("the control sent no value"))?;
418 +
419 + write_config(state, &key, value).map_err(|error| RouteError::internal(error.to_string()))?;
420 +
421 + // Answer with the section the setting lives in, re-read, for the reason
422 + // every other port re-reads: the write is the database's to confirm.
423 + Ok(screen(state, section_for_key(&key))?.into())
424 + }
425 +
426 + /// The settings screen's routes.
427 + #[must_use]
428 + pub fn routes(router: Router<AppState>) -> Router<AppState> {
429 + router
430 + .get("/settings", index)
431 + .get("/settings/:section", section)
432 + .post("/settings/config/:key", set)
433 + }
@@ -1,0 +1,226 @@
1 + //! Settings, driven through the router against a real database.
2 + //!
3 + //! Same standard as the screens before it: no Tauri runtime and no window, the
4 + //! description asserted, and the markup only where the markup is the point.
5 + //! Every workaround this port had to take is asserted here rather than left to
6 + //! be noticed, so closing a finding is a test that has to change.
7 +
8 + use std::sync::Arc;
9 +
10 + use quasi_http::Render as _;
11 + use quasi_router::Outcome;
12 + use quasi_router::{Method, Params, Response};
13 +
14 + use super::super::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 get(state: &AppState, path: &str) -> Response {
23 + router()
24 + .handle(state, Method::Get, path, Params::new())
25 + .expect("the route answers")
26 + }
27 +
28 + fn post(state: &AppState, path: &str, params: Params) -> Response {
29 + router()
30 + .handle(state, Method::Post, path, params)
31 + .expect("the route answers")
32 + }
33 +
34 + fn html(response: Response) -> String {
35 + match response.outcome {
36 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
37 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
38 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
39 + }
40 + }
41 +
42 + /// What the settings key holds right now, read the way the command does.
43 + fn stored(state: &AppState, key: &str) -> Option<String> {
44 + crate::commands::all_config(state)
45 + .unwrap()
46 + .get(key)
47 + .cloned()
48 + }
49 +
50 + #[tokio::test]
51 + async fn settings_opens_on_appearance_the_way_the_js_does() {
52 + let state = state().await;
53 + let page = html(get(&state, "/settings"));
54 +
55 + assert!(page.contains("Appearance"));
56 + assert!(page.contains("Theme"));
57 + // And the same screen is reachable under its own address.
58 + assert_eq!(page, html(get(&state, "/settings/appearance")));
59 + }
60 +
61 + #[tokio::test]
62 + async fn the_sidebar_offers_the_three_described_sections_and_points_at_the_open_one() {
63 + let state = state().await;
64 + let page = html(get(&state, "/settings/planning"));
65 +
66 + assert!(page.contains("Appearance"));
67 + assert!(page.contains("Notifications"));
68 + assert!(page.contains("Planning &amp; Review"));
69 + // The app's own pointer, not the user's tick.
70 + assert!(page.contains("aria-current"));
71 + // The five sections that are about the host rather than the app are absent
72 + // rather than drawn as controls that do nothing. See the module header.
73 + for missing in ["Sync", "Sharing", "Import &amp; Export", "About"] {
74 + assert!(!page.contains(missing), "should not offer: {missing}");
75 + }
76 + }
77 +
78 + #[tokio::test]
79 + async fn a_section_that_is_not_described_is_a_not_found() {
80 + let state = state().await;
81 + let error = router()
82 + .handle(&state, Method::Get, "/settings/sync", Params::new())
83 + .expect_err("sync is not described");
84 + assert_eq!(error.class.http_status(), 404);
85 + }
86 +
87 + #[tokio::test]
88 + async fn a_control_writes_as_soon_as_it_changes_with_no_form_around_it() {
89 + // What finding 14612ed8 was closed for, and this screen is its first
90 + // consumer: Node::Field plus Field::changes, which is a bare control with a
91 + // route on it. A Node::Form here would describe a submit that does not
92 + // exist.
93 + let state = state().await;
94 + let page = html(get(&state, "/settings/notifications"));
95 +
96 + assert!(page.contains("Event indicator lead time"));
97 + assert!(page.contains("/settings/config/event_lead_minutes"));
98 + // No submit, because there is no form.
99 + assert!(!page.contains("type=\"submit\""));
100 + }
101 +
102 + #[tokio::test]
103 + async fn every_control_on_the_screen_writes_through_the_one_route() {
104 + let state = state().await;
105 +
106 + for (key, value, section) in [
107 + ("event_lead_minutes", "30", "notifications"),
108 + ("work_start_hour", "7", "planning"),
109 + ("work_end_hour", "19", "planning"),
110 + ("plan_nudges", "disabled", "planning"),
111 + ("review_nudges", "disabled", "planning"),
112 + ] {
113 + let page = html(post(
114 + &state,
115 + &format!("/settings/config/{key}"),
116 + Params::new().with("value", value),
117 + ));
118 + assert_eq!(stored(&state, key).as_deref(), Some(value));
119 + // And the write answers with the section the setting lives in, re-read,
120 + // which the handler works out from the key rather than being told.
121 + assert!(
122 + page.contains(&format!("/settings/{section}")),
123 + "{key} should answer in {section}"
124 + );
125 + }
126 + }
127 +
128 + #[tokio::test]
129 + async fn a_key_the_spec_does_not_declare_is_refused_by_the_same_check_the_command_uses() {
130 + let state = state().await;
131 + let error = router()
132 + .handle(
133 + &state,
134 + Method::Post,
135 + "/settings/config/not_a_setting",
136 + Params::new().with("value", "x"),
137 + )
138 + .expect_err("the spec's closed set holds");
139 + assert_eq!(error.class.http_status(), 500);
140 + assert!(stored(&state, "not_a_setting").is_none());
141 + }
142 +
143 + #[tokio::test]
144 + async fn a_setting_nobody_has_touched_shows_its_default_rather_than_nothing() {
145 + let state = state().await;
146 + let page = html(get(&state, "/settings/planning"));
147 +
148 + // 9am to 5pm, which is what the JS falls back to at each of its call sites.
149 + assert!(page.contains(r#"value="9" selected"#) || page.contains(r#"selected value="9""#));
150 + assert!(page.contains("5:00 PM"));
151 + // And the written value wins over the default once there is one.
152 + post(
153 + &state,
154 + "/settings/config/work_start_hour",
155 + Params::new().with("value", "6"),
156 + );
157 + let page = html(get(&state, "/settings/planning"));
158 + assert!(page.contains(r#"value="6" selected"#) || page.contains(r#"selected value="6""#));
159 + }
160 +
161 + #[tokio::test]
162 + async fn the_theme_list_comes_from_the_state_and_carries_its_variant_in_the_label() {
163 + // The first finding, asserted as the loss it is. The JS groups the themes
164 + // in four optgroups; Choice is a value and a label, so the variant goes
165 + // into the label - the fact kept, the structure gone.
166 + //
167 + // It is also the assertion that the host fact reached the handler at all:
168 + // the search path is built from an AppHandle the handler never sees, and
169 + // AppState holds the result.
170 + let state = state().await;
171 + let page = html(get(&state, "/settings/appearance"));
172 +
173 + assert!(page.contains("Follow System"));
174 + assert!(page.contains("(Dark)") || page.contains("(Light)"));
175 + assert!(!page.contains("optgroup"));
176 + }
177 +
178 + #[tokio::test]
179 + async fn the_theme_writes_through_the_same_route_as_everything_else() {
180 + let state = state().await;
181 + let page = html(post(
182 + &state,
183 + "/settings/config/theme",
184 + Params::new().with("value", "catppuccin-mocha"),
185 + ));
186 + assert_eq!(stored(&state, "theme").as_deref(), Some("catppuccin-mocha"));
187 + assert!(page.contains("Appearance"));
188 + }
189 +
190 + #[tokio::test]
191 + async fn work_hours_are_two_labelled_controls_rather_than_one_row_reading_to() {
192 + // The third finding, asserted as written. The JS draws one labelled row -
193 + // a select, the word "to", a select. The description has one label per
194 + // field, so it says which is which and loses the pairing. Expected to be
195 + // refused as furniture; the test exists so the loss is visible.
196 + let state = state().await;
197 + let page = html(get(&state, "/settings/planning"));
198 +
199 + assert!(page.contains("Work day starts"));
200 + assert!(page.contains("Work day ends"));
201 + assert!(!page.contains(">to<"));
202 + }
203 +
204 + #[tokio::test]
205 + async fn a_theme_name_cannot_become_markup() {
206 + // Theme names come off disk, which is a place the app does not control:
207 + // importing a theme writes whatever the file says into the custom
208 + // directory, and the name inside it is never seen by anything that
209 + // validates. The renderer is what makes that safe, and this is the only
210 + // untrusted string on the screen.
211 + let dir = tempfile::tempdir().unwrap();
212 + std::fs::write(
213 + dir.path().join("hostile.toml"),
214 + "[meta]\nname = \"<script>alert(1)</script>\"\n",
215 + )
216 + .unwrap();
217 +
218 + let (mut state, _) = crate::test_utils::setup_test_state().await;
219 + Arc::get_mut(&mut state).expect("sole owner").theme_dirs =
220 + vec![(dir.path().to_path_buf(), true)];
221 +
222 + let page = html(get(&state, "/settings/appearance"));
223 +
224 + assert!(page.contains("&lt;script&gt;"));
225 + assert!(!page.contains("<script>alert"));
226 + }