Skip to main content

max / alloy

settings: remember the theme in the config store, off console.toml The System tab's theme row wrote $XDG_CONFIG_HOME/alloy/console.toml through toml_edit. Move it onto the family's shared config store: a SQLite key/value table (synckit-config's ConfigStore) at $XDG_CONFIG_HOME/alloy/console.db, under the convention's unprefixed `theme` key. The console never syncs, so it declares a ConfigSpec with an inert Local posture and only ever get/sets. A config set is neither a command nor a hand-written file, so it gets its own Effect::Config variant the log renders as `set theme = akari-night` — the pane's every-action-is-shown contract holds. Reading never creates the store, so a console never told a theme leaves no db behind. No migration from the old console.toml: it existed only across the last few commits of this same work and only for a dev who had already picked a theme. toml_edit stays — the `alloy config` TOML editor (bind.rs) still needs it. Git-deps synckit-config over mnw HTTPS (keyless clone), which now re-exports rusqlite so the console links one crate and version-matches nothing.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 21:52 UTC
Signed with PGP, not checked
Commit: 28f80d934b21a512758f1dec6546424e3a373d9c
Parent: 3890e4e
5 files changed, +267 insertions, -61 deletions
M Cargo.lock +113
@@ -30,6 +30,7 @@
30 30 "serde",
31 31 "serde_json",
32 32 "sha-crypt",
33 + "synckit-config",
33 34 "toml",
34 35 "toml_edit",
35 36 ]
@@ -193,6 +194,16 @@
193 194 "rustversion",
194 195 ]
195 196
197 + [[package]]
198 + name = "cc"
199 + version = "1.4.0"
200 + source = "registry+https://github.com/rust-lang/crates.io-index"
201 + checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
202 + dependencies = [
203 + "find-msvc-tools",
204 + "shlex",
205 + ]
206 +
196 207 [[package]]
197 208 name = "cfg-if"
198 209 version = "1.0.4"
@@ -495,6 +506,18 @@
495 506 "num-traits",
496 507 ]
497 508
509 + [[package]]
510 + name = "fallible-iterator"
511 + version = "0.3.0"
512 + source = "registry+https://github.com/rust-lang/crates.io-index"
513 + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
514 +
515 + [[package]]
516 + name = "fallible-streaming-iterator"
517 + version = "0.1.9"
518 + source = "registry+https://github.com/rust-lang/crates.io-index"
519 + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
520 +
498 521 [[package]]
499 522 name = "fancy-regex"
500 523 version = "0.11.0"
@@ -516,6 +539,12 @@
516 539 "winapi",
517 540 ]
518 541
542 + [[package]]
543 + name = "find-msvc-tools"
544 + version = "0.1.9"
545 + source = "registry+https://github.com/rust-lang/crates.io-index"
546 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
547 +
519 548 [[package]]
520 549 name = "finl_unicode"
521 550 version = "1.4.0"
@@ -614,6 +643,15 @@
614 643 source = "registry+https://github.com/rust-lang/crates.io-index"
615 644 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
616 645
646 + [[package]]
647 + name = "hashlink"
648 + version = "0.11.1"
649 + source = "registry+https://github.com/rust-lang/crates.io-index"
650 + checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
651 + dependencies = [
652 + "hashbrown 0.16.1",
653 + ]
654 +
617 655 [[package]]
618 656 name = "heck"
619 657 version = "0.5.0"
@@ -753,6 +791,17 @@
753 791 source = "registry+https://github.com/rust-lang/crates.io-index"
754 792 checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
755 793
794 + [[package]]
795 + name = "libsqlite3-sys"
796 + version = "0.37.0"
797 + source = "registry+https://github.com/rust-lang/crates.io-index"
798 + checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
799 + dependencies = [
800 + "cc",
801 + "pkg-config",
802 + "vcpkg",
803 + ]
804 +
756 805 [[package]]
757 806 name = "line-clipping"
758 807 version = "0.3.7"
@@ -1075,6 +1124,12 @@
1075 1124 source = "registry+https://github.com/rust-lang/crates.io-index"
1076 1125 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1077 1126
1127 + [[package]]
1128 + name = "pkg-config"
1129 + version = "0.3.33"
1130 + source = "registry+https://github.com/rust-lang/crates.io-index"
1131 + checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
1132 +
1078 1133 [[package]]
1079 1134 name = "portable-atomic"
1080 1135 version = "1.14.0"
@@ -1255,6 +1310,31 @@
1255 1310 source = "registry+https://github.com/rust-lang/crates.io-index"
1256 1311 checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1257 1312
1313 + [[package]]
1314 + name = "rsqlite-vfs"
1315 + version = "0.1.1"
1316 + source = "registry+https://github.com/rust-lang/crates.io-index"
1317 + checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
1318 + dependencies = [
1319 + "hashbrown 0.16.1",
1320 + "thiserror 2.0.18",
1321 + ]
1322 +
1323 + [[package]]
1324 + name = "rusqlite"
1325 + version = "0.39.0"
1326 + source = "registry+https://github.com/rust-lang/crates.io-index"
1327 + checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
1328 + dependencies = [
1329 + "bitflags 2.13.0",
1330 + "fallible-iterator",
1331 + "fallible-streaming-iterator",
1332 + "hashlink",
1333 + "libsqlite3-sys",
1334 + "smallvec",
1335 + "sqlite-wasm-rs",
1336 + ]
1337 +
1258 1338 [[package]]
1259 1339 name = "rustc_version"
1260 1340 version = "0.4.1"
@@ -1388,6 +1468,12 @@
1388 1468 "digest 0.11.3",
1389 1469 ]
1390 1470
1471 + [[package]]
1472 + name = "shlex"
1473 + version = "2.0.1"
1474 + source = "registry+https://github.com/rust-lang/crates.io-index"
1475 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1476 +
1391 1477 [[package]]
1392 1478 name = "signal-hook"
1393 1479 version = "0.3.18"
@@ -1437,6 +1523,18 @@
1437 1523 source = "registry+https://github.com/rust-lang/crates.io-index"
1438 1524 checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1439 1525
1526 + [[package]]
1527 + name = "sqlite-wasm-rs"
1528 + version = "0.5.5"
1529 + source = "registry+https://github.com/rust-lang/crates.io-index"
1530 + checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
1531 + dependencies = [
1532 + "cc",
1533 + "js-sys",
1534 + "rsqlite-vfs",
1535 + "wasm-bindgen",
1536 + ]
1537 +
1440 1538 [[package]]
1441 1539 name = "static_assertions"
1442 1540 version = "1.1.0"
@@ -1503,6 +1601,15 @@
1503 1601 "unicode-ident",
1504 1602 ]
1505 1603
1604 + [[package]]
1605 + name = "synckit-config"
1606 + version = "0.1.1"
1607 + source = "git+https://makenot.work/git/max/synckit#698c801d3c22008217209e09d2088c96cbbf7533"
1608 + dependencies = [
1609 + "rusqlite",
1610 + "thiserror 2.0.18",
1611 + ]
1612 +
1506 1613 [[package]]
1507 1614 name = "terminfo"
1508 1615 version = "0.9.0"
@@ -1738,6 +1845,12 @@
1738 1845 "wasm-bindgen",
1739 1846 ]
1740 1847
1848 + [[package]]
1849 + name = "vcpkg"
1850 + version = "0.2.15"
1851 + source = "registry+https://github.com/rust-lang/crates.io-index"
1852 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
1853 +
1741 1854 [[package]]
1742 1855 name = "version_check"
1743 1856 version = "0.9.5"
@@ -24,6 +24,7 @@
24 24 sha-crypt = "0.6.0"
25 25 getrandom = "0.4.3"
26 26 toml_edit = "0.25.13"
27 + synckit-config = { git = "https://makenot.work/git/max/synckit" }
27 28
28 29 [lints]
29 30 workspace = true
@@ -425,6 +425,19 @@
425 425 contents: String,
426 426 mode: u32,
427 427 },
428 + /// Set a key in the console's config store.
429 + ///
430 + /// The theme preference is neither a command nor a file the way a wrapper
431 + /// script is — it is a row in the SQLite settings store the shared config
432 + /// crate owns. Modelled as its own effect so the log still shows it: the
433 + /// pane renders `set theme = akari-night`, which is what happened even
434 + /// though no command ran and no file was written by hand.
435 + Config {
436 + db: PathBuf,
437 + spec: synckit_config::ConfigSpec,
438 + key: String,
439 + value: String,
440 + },
428 441 }
429 442
430 443 impl Effect {
@@ -438,6 +451,7 @@
438 451 match self {
439 452 Effect::Run(invocation) => invocation.display(),
440 453 Effect::Write { path, .. } => format!("write {}", contract_home(path)),
454 + Effect::Config { key, value, .. } => format!("set {key} = {value}"),
441 455 }
442 456 }
443 457
@@ -461,10 +475,51 @@
461 475 );
462 476 result
463 477 }
478 + Effect::Config {
479 + db,
480 + spec,
481 + key,
482 + value,
483 + } => {
484 + let result = write_config(db, *spec, key, value);
485 + log.record(
486 + self.display(),
487 + if result.is_ok() {
488 + Severity::Healthy
489 + } else {
490 + Severity::Error
491 + },
492 + );
493 + result
494 + }
464 495 }
465 496 }
466 497 }
467 498
499 + /// Set one key in the console's config store, creating the store on first write.
500 + ///
501 + /// The store's directory is the console's own config dir, which a fresh account
502 + /// may not have yet; creating it is part of writing there, the same as for a
503 + /// file write. Opening the store runs `CREATE TABLE IF NOT EXISTS`, so the first
504 + /// set is also what brings the store into existence — a console never told
505 + /// anything leaves no `console.db` behind.
506 + fn write_config(db: &Path, spec: synckit_config::ConfigSpec, key: &str, value: &str) -> Result<()> {
507 + use synckit_config::{ConfigStore, rusqlite::Connection};
508 +
509 + if let Some(parent) = db.parent() {
510 + std::fs::create_dir_all(parent)
511 + .with_context(|| format!("failed to create {}", contract_home(parent)))?;
512 + }
513 + let conn =
514 + Connection::open(db).with_context(|| format!("failed to open {}", contract_home(db)))?;
515 + let store = ConfigStore::open(&conn, &spec)
516 + .with_context(|| format!("failed to open the config store in {}", contract_home(db)))?;
517 + store
518 + .set(&conn, key, value)
519 + .with_context(|| format!("failed to set `{key}` in {}", contract_home(db)))?;
520 + Ok(())
521 + }
522 +
468 523 fn write_file(path: &Path, contents: &str, mode: u32) -> Result<()> {
469 524 use std::os::unix::fs::PermissionsExt;
470 525
@@ -26,8 +26,8 @@
26 26 //! | lang | `localectl set-locale` | `locale1.set-locale` |
27 27 //! | keymap | `localectl set-keymap` | `locale1.set-keyboard` |
28 28 //!
29 - //! The theme row is the exception that proves the shape: it writes
30 - //! `$XDG_CONFIG_HOME/alloy/console.toml` rather than running anything, because
29 + //! The theme row is the exception that proves the shape: it sets a key in
30 + //! `$XDG_CONFIG_HOME/alloy/console.db` rather than running anything, because
31 31 //! which theme a terminal renders in is the person's business and not the
32 32 //! machine's. It needs no privilege and grants none, and the console re-themes
33 33 //! the moment it is written.
@@ -552,11 +552,11 @@
552 552 (KEYMAP, Value::String(keymap)) => {
553 553 Invocation::new("localectl").args(["set-keymap", keymap])
554 554 }
555 - // A file, not a command, and the only row here that needs no
556 - // privilege at all.
555 + // A config-store set, not a command, and the only row here that
556 + // needs no privilege at all.
557 557 (THEME, Value::String(chosen)) => {
558 558 let selection = makeover::ThemeSelection::parse(Some(chosen));
559 - return crate::theme::remember(&selection).map(|write| vec![write]);
559 + return crate::theme::remember(&selection).map(|effect| vec![effect]);
560 560 }
561 561 _ => anyhow::bail!("`{path}` has no setter"),
562 562 };
@@ -864,32 +864,33 @@
864 864 let effects = bind
865 865 .commit(THEME, Value::String(makeover::FOLLOW.into()))
866 866 .unwrap();
867 - let [Effect::Write { contents, path, .. }] = effects.as_slice() else {
868 - panic!("a theme is a file write, not a command: {effects:?}")
867 + let [Effect::Config { key, value, .. }] = effects.as_slice() else {
868 + panic!("a theme is a config set, not a command or file write: {effects:?}")
869 869 };
870 - assert!(
871 - contents.contains(&format!("theme = \"{}\"", makeover::FOLLOW)),
872 - "{contents}",
870 + assert_eq!(key, "theme");
871 + assert_eq!(
872 + value,
873 + makeover::FOLLOW,
874 + "follow is stored as itself, not resolved to an id",
873 875 );
874 - assert!(path.ends_with("alloy/console.toml"), "{}", path.display(),);
875 876 }
876 877
877 878 // A theme id round-trips as a pin.
878 879 #[test]
879 - fn choosing_a_theme_writes_its_id() {
880 + fn choosing_a_theme_sets_its_id() {
880 881 let mut bind = bind();
881 882 let effects = bind
882 883 .commit(THEME, Value::String("akari-night".into()))
883 884 .unwrap();
884 - let [Effect::Write { contents, .. }] = effects.as_slice() else {
885 - panic!("one write")
885 + let [Effect::Config { value, .. }] = effects.as_slice() else {
886 + panic!("one config set")
886 887 };
887 - assert!(contents.contains("theme = \"akari-night\""), "{contents}");
888 + assert_eq!(value, "akari-night");
888 889 }
889 890
890 891 // The one row that runs no command and needs no privilege.
891 892 #[test]
892 - fn the_theme_row_is_the_only_one_that_writes_a_file() {
893 + fn every_row_but_the_theme_runs_a_command() {
893 894 let mut bind = bind();
894 895 for path in [ZONE, NTP, HOSTNAME, LOCALE, KEYMAP] {
895 896 let value = match path {
@@ -10,6 +10,8 @@
10 10 use alloy_tui::Theme;
11 11 use anyhow::{Context, Result};
12 12 use makeover::{ThemeDefaults, ThemeDirs, ThemeMeta, ThemeSelection, Variant};
13 + use synckit_config::rusqlite::Connection;
14 + use synckit_config::{ConfigSpec, ConfigStore, Posture};
13 15
14 16 use crate::cli::Effect;
15 17
@@ -19,6 +21,19 @@
19 21 /// Default dark theme (docs/TOKENS.md).
20 22 pub(crate) const DEFAULT_DARK: &str = "akari-night";
21 23
24 + /// The console's config store: one table, one key.
25 + ///
26 + /// Declared for the family's shared config store even though the console never
27 + /// syncs — so posture is inert here and `theme` takes the safe default,
28 + /// [`Posture::Local`]. The key name is the convention's, not this crate's: every
29 + /// app in the family stores the chosen theme under `theme`.
30 + pub(crate) const CONFIG: ConfigSpec =
31 + ConfigSpec::new("console_config", &[(THEME_KEY, Posture::Local)]);
32 +
33 + /// The one key the console stores, named once so the read and the write cannot
34 + /// disagree about its spelling.
35 + const THEME_KEY: &str = "theme";
36 +
22 37 /// Theme search path: the user's own themes win, then whatever the image ships,
23 38 /// then makeover's bundled set for a run from a dev tree.
24 39 ///
@@ -85,19 +100,21 @@
85 100 makeover::list_themes_from_dirs(&search_path())
86 101 }
87 102
88 - /// Where the console remembers a chosen theme.
103 + /// Where the console remembers what it is told.
89 104 ///
90 - /// `$XDG_CONFIG_HOME/alloy/console.toml`. The console already owns that
91 - /// directory — custom themes live in `alloy/themes/`, user schemas in
92 - /// `alloy/schemas/` — so this needed no new place, only a file. TOML because
93 - /// everything else in the ecosystem is, and because a console preference that
94 - /// is not a theme will want a key of its own eventually.
105 + /// `$XDG_CONFIG_HOME/alloy/console.db`, a SQLite store the shared config crate
106 + /// owns. The console already owns that directory — custom themes live in
107 + /// `alloy/themes/`, user schemas in `alloy/schemas/` — so the store joins them
108 + /// rather than opening a new root. SQLite rather than the old `console.toml`
109 + /// because every app in the family now keeps settings in one store, so a
110 + /// preference can carry across devices where the app syncs; the console does not
111 + /// sync, but it reads and writes the same shape.
95 112 ///
96 113 /// Deliberately not a system-wide file: which theme a terminal renders in is
97 114 /// the person's, not the machine's, and two accounts on one machine should not
98 115 /// fight over it.
99 - pub(crate) fn preference_path() -> Option<PathBuf> {
100 - dirs_config_home().map(|config| config.join("alloy").join("console.toml"))
116 + pub(crate) fn store_path() -> Option<PathBuf> {
117 + dirs_config_home().map(|config| config.join("alloy").join("console.db"))
101 118 }
102 119
103 120 /// What the user chose, which is not the same as what is being rendered.
@@ -106,21 +123,25 @@
106 123 /// never been told anything tracks the terminal rather than pinning whatever it
107 124 /// guessed on first launch.
108 125 pub(crate) fn selection() -> ThemeSelection {
109 - let stored = preference_path()
110 - .and_then(|path| std::fs::read_to_string(path).ok())
111 - .as_deref()
112 - .and_then(read_preference);
113 - ThemeSelection::parse(stored.as_deref())
126 + ThemeSelection::parse(stored_theme().as_deref())
114 127 }
115 128
116 - /// The `theme` key, however it is spelled. The key name is the convention's,
117 - /// not this file's: every app in the family stores it under `theme`.
118 - fn read_preference(text: &str) -> Option<String> {
119 - text.parse::<toml_edit::DocumentMut>()
120 - .ok()?
121 - .get("theme")?
122 - .as_str()
123 - .map(str::to_string)
129 + /// The stored `theme` value, or `None` when nothing is saved or the store will
130 + /// not read.
131 + ///
132 + /// Best-effort by design: a missing store is the normal first-launch state, and
133 + /// a store that will not open is not worth crashing the console over before the
134 + /// first frame — either reads as "nothing chosen", which follows the terminal.
135 + /// Reading deliberately does **not** create the store; it appears only when a
136 + /// choice is written, so a console never told anything leaves no file behind.
137 + fn stored_theme() -> Option<String> {
138 + let path = store_path()?;
139 + if !path.exists() {
140 + return None;
141 + }
142 + let conn = Connection::open(&path).ok()?;
143 + let store = ConfigStore::open(&conn, &CONFIG).ok()?;
144 + store.get(&conn, THEME_KEY).ok()?
124 145 }
125 146
126 147 /// The id the console is rendering in.
@@ -136,22 +157,16 @@
136 157 /// The write that remembers a choice.
137 158 ///
138 159 /// Returned rather than performed, like every other effect in the console, so
139 - /// the log shows the file being written and the caller decides when. Edits the
140 - /// document in place, so a console preference this version does not know about
141 - /// survives being written by it.
160 + /// the log shows the setting being written and the caller decides when. The set
161 + /// is an upsert on one key, so writing again replaces the choice rather than
162 + /// accumulating rows.
142 163 pub(crate) fn remember(selection: &ThemeSelection) -> Result<Effect> {
143 - let path = preference_path().context("no config directory to remember a theme in")?;
144 - let text = std::fs::read_to_string(&path).unwrap_or_default();
145 - let mut document: toml_edit::DocumentMut = text
146 - .parse()
147 - .with_context(|| format!("{} is not valid TOML", path.display()))?;
148 - document["theme"] = toml_edit::value(selection.as_str());
149 -
150 - Ok(Effect::Write {
151 - path,
152 - contents: document.to_string(),
153 - // A preference, not a secret and not executable.
154 - mode: 0o644,
164 + let db = store_path().context("no config directory to remember a theme in")?;
165 + Ok(Effect::Config {
166 + db,
167 + spec: CONFIG,
168 + key: THEME_KEY.to_string(),
169 + value: selection.as_str().to_string(),
155 170 })
156 171 }
157 172
@@ -262,19 +277,40 @@
262 277 );
263 278 }
264 279
280 + // A choice becomes a config-store set under the family's `theme` key, for
281 + // both a pinned id and a standing "follow the terminal". Follow is stored as
282 + // itself, not resolved to whatever id it happens to point at right now.
265 283 #[test]
266 - fn a_remembered_theme_is_read_back_out_of_the_file() {
284 + fn remembering_a_choice_sets_the_theme_key() {
285 + for selection in [
286 + ThemeSelection::parse(Some("akari-night")),
287 + ThemeSelection::Follow,
288 + ] {
289 + let Effect::Config {
290 + key, value, spec, ..
291 + } = remember(&selection).unwrap()
292 + else {
293 + panic!("a theme is a config set, not a command or file write");
294 + };
295 + assert_eq!(key, THEME_KEY, "the family convention's key name");
296 + assert_eq!(value, selection.as_str());
297 + assert_eq!(spec.table(), "console_config");
298 + }
299 + }
300 +
301 + // The console's spec drives a real store the same as any other consumer: a
302 + // set under `theme` reads back, proving the key name the console writes is
303 + // the key name it later reads.
304 + #[test]
305 + fn the_theme_key_round_trips_through_the_store() {
306 + let conn = Connection::open_in_memory().unwrap();
307 + let store = ConfigStore::open(&conn, &CONFIG).unwrap();
308 + assert_eq!(store.get(&conn, THEME_KEY).unwrap(), None, "unset follows");
309 + store.set(&conn, THEME_KEY, "akari-night").unwrap();
267 310 assert_eq!(
268 - read_preference("theme = \"akari-night\"\n").as_deref(),
311 + store.get(&conn, THEME_KEY).unwrap().as_deref(),
269 312 Some("akari-night"),
270 313 );
271 - assert_eq!(
272 - read_preference("# nothing chosen yet\n"),
273 - None,
274 - "an empty file is not a choice",
275 - );
276 - assert_eq!(read_preference("theme = 3\n"), None, "nor is a non-string");
277 - assert_eq!(read_preference("theme = ["), None, "nor is broken TOML");
278 314 }
279 315
280 316 // The console ships against these two ids; a rename in makeover's themes