budctl7b28e7d
Config file, a starter theme.json, and a log for the desktop app
- ~/.config/budctl/config.json (budctl_core::config), created with the defaults when missing: "device" (default for --device, CLI and GUI), and for the GUI "idle_seconds" (how long the control channel stays open, 1-600) and "start_page". Unknown keys are errors, and a file that cannot be used is reported and the defaults used, so a typo never stops `budctl --json status`. - budctl-gui writes the built-in palette to ~/.config/budctl/theme.json when there is none (never overwriting), as a start for a matugen template. - budctl-gui logs errors and session events to ~/.local/state/budctl/gui.log, replaced each launch, so a failure can be explained when the window was started from a launcher. The CLI keeps stderr only: status bars run it every few seconds. New dependency: serde_json in budctl-core, to read config.json (already in the tree through the CLI and GUI).
+465 −32
13 files changed
- M CLAUDE.md +4 −3
- M Cargo.lock +1 −0
- M README.md +27 −2
- M crates/budctl-cli/src/main.rs +13 −2
- M crates/budctl-cli/tests/cli.rs +6 −1
- M crates/budctl-core/Cargo.toml +1 −0
- A crates/budctl-core/src/config.rs +188 −0
- M crates/budctl-core/src/lib.rs +1 −0
- A crates/budctl-gui/src/log.rs +60 −0
- M crates/budctl-gui/src/main.rs +77 −6
- M crates/budctl-gui/src/settings.rs +21 −1
- M crates/budctl-gui/src/theme.rs +46 −5
- M crates/budctl-gui/src/worker.rs +20 −12
| @@ -10,7 +10,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 10 | 10 | - Two front ends, one core: the `budctl` CLI and the `budctl-gui` desktop app, both thin layers over the same library (`budctl-core`). Scripts and the owner's Quickshell (Aurora) can use `budctl --json`. | |
| 11 | 11 | - One small, readable codebase that the owner fully understands. | |
| 12 | 12 | - Device support is pluggable. Adding a new pair of buds means adding one module under `crates/budctl-core/src/devices/` and registering it in `devices/mod.rs`, without touching transport, CLI or GUI code. | |
| 13 | − | - Coexist with the phone app. Settings live on the buds, so budctl never stores device state locally beyond user config. | |
| 13 | + | - Coexist with the phone app. Settings live on the buds, so budctl never stores device state locally beyond user config (`~/.config/budctl/config.json`, `budctl_core::config`: default device and GUI preferences, created with defaults when missing; a broken file is reported and the defaults are used). | |
| 14 | 14 | ||
| 15 | 15 | ## Non-goals | |
| 16 | 16 | ||
| @@ -43,7 +43,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 43 | 43 | - CLI: `clap` (derive). It is parsed with `try_parse`, so usage errors exit 1, not clap's default 2. `serde_json` provides `--json`. | |
| 44 | 44 | - Tests: `cargo test --workspace`. Lints: `cargo clippy --workspace --all-targets -- -D warnings`. | |
| 45 | 45 | - Keep dependencies minimal. Every new dependency needs a reason in the commit message. The current direct dependencies: | |
| 46 | − | - core: `libc`, `zbus`, `serde` | |
| 46 | + | - core: `libc`, `zbus`, `serde`, `serde_json` (config file) | |
| 47 | 47 | - cli: `clap`, `serde_json` | |
| 48 | 48 | - gui: `iced`, `serde`, `serde_json`; `iced_test` for tests | |
| 49 | 49 | ||
| @@ -121,7 +121,8 @@ Exit codes: | |||
| 121 | 121 | - `h`/`l` pick the previous or next choice, or turn a switch off or on, and `l` opens a row's page. Enter and Space toggle, step round the choices, or open. | |
| 122 | 122 | - `J`/`K`, `Ctrl+Tab`, `gt`/`gT` and `1`–`5` change pages. `Esc` hides the cursor, `q` quits. | |
| 123 | 123 | - The cursor shows only after a key press, and the first key only shows it, so nothing changes where it cannot be seen. A click hides it. | |
| 124 | − | - **Buds:** `worker.rs` owns the session, on a thread of its own, and answers each command with one event, in order. It opens the session when a command needs it, answers the buds' own requests while it waits (`Session::serve`), and closes it 10 s after the last command. A write shows "Saving…" until the buds' read-back arrives, and takes no second write meanwhile. A failure says why, with "Try again". `--demo` shows made-up buds; `--debug` hex-dumps frames like the CLI. | |
| 124 | + | - **Buds:** `worker.rs` owns the session, on a thread of its own, and answers each command with one event, in order. It opens the session when a command needs it, answers the buds' own requests while it waits (`Session::serve`), and closes it 10 s after the last command (`gui.idle_seconds` in the config). A write shows "Saving…" until the buds' read-back arrives, and takes no second write meanwhile. A failure says why, with "Try again". `--demo` shows made-up buds; `--debug` hex-dumps frames like the CLI. | |
| 125 | + | - **Files:** writes the built-in palette to `~/.config/budctl/theme.json` when missing (never overwrites it), and logs errors and session events to `~/.local/state/budctl/gui.log` (`log.rs`), replaced each launch. Frames stay on stderr with `--debug`. The CLI logs to stderr only, since status bars run it every few seconds. | |
| 125 | 126 | - **Logic:** keys and clicks both become messages for one `update` function, which stays testable without a display. `iced_test` renders pages to PNG, so the look can be checked without the owner. | |
| 126 | 127 | - **Screenshots:** the README's come from the demo buds: `cargo test -p budctl-gui readme_screenshots -- --ignored` renders them again into `docs/screenshots/`. | |
| 127 | 128 | ||
| @@ -340,6 +340,7 @@ version = "0.1.0" | |||
| 340 | 340 | dependencies = [ | |
| 341 | 341 | "libc", | |
| 342 | 342 | "serde", | |
| 343 | + | "serde_json", | |
| 343 | 344 | "zbus", | |
| 344 | 345 | ] | |
| 345 | 346 | ||
| @@ -51,7 +51,8 @@ budctl find stop | |||
| 51 | 51 | ||
| 52 | 52 | - `--json` prints machine-readable output for scripts and status bars (`"schema": 1`). | |
| 53 | 53 | - `--debug` hex-dumps every frame sent and received, on stderr. | |
| 54 | − | - `--device <MAC>` picks the buds when more than one supported pair is connected. | |
| 54 | + | - `--device <MAC>` picks the buds when more than one supported pair is connected. Without it, | |
| 55 | + | `"device"` in the config file (below) is used, if set. | |
| 55 | 56 | ||
| 56 | 57 | Exit codes: | |
| 57 | 58 | ||
| @@ -109,11 +110,35 @@ The cursor shows only after you press a key, and the first key only shows it. | |||
| 109 | 110 | palette: `--theme <file>`, else `~/.config/budctl/theme.json`. The file is JSON with these keys, all | |
| 110 | 111 | optional: `bg`, `fg`, `primary`, `primaryFg`, `primaryContainer`, `secondary`, `surfaceLow`, | |
| 111 | 112 | `surface`, `surfaceHigh`, `surfaceHighest`, `outlineVariant`, `surfaceVariantFg`, `err`. It is read | |
| 112 | − | again whenever the window gets focus, so a new wallpaper's colors show up there too. | |
| 113 | + | again whenever the window gets focus, so a new wallpaper's colors show up there too. When the file | |
| 114 | + | is missing, the app writes the built-in colors there: a starting point for a matugen template. | |
| 115 | + | ||
| 116 | + | **Log:** each launch writes errors and session events (connected, each change and its answer, | |
| 117 | + | closed) to `~/.local/state/budctl/gui.log`, replacing the last launch's. | |
| 113 | 118 | ||
| 114 | 119 | Other options: `--demo` shows made-up buds without Bluetooth; `--debug` and `--device` work as in | |
| 115 | 120 | the command-line tool. | |
| 116 | 121 | ||
| 122 | + | ## Config | |
| 123 | + | ||
| 124 | + | `~/.config/budctl/config.json` is created with the defaults the first time it is needed. Every key | |
| 125 | + | is optional; a file with a mistake is reported and the defaults are used. | |
| 126 | + | ||
| 127 | + | ```json | |
| 128 | + | { | |
| 129 | + | "device": null, | |
| 130 | + | "gui": { | |
| 131 | + | "idle_seconds": 10, | |
| 132 | + | "start_page": "overview" | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ``` | |
| 136 | + | ||
| 137 | + | - `device`: the buds to use when `--device` is not given, as `"AA:BB:CC:DD:EE:FF"`. | |
| 138 | + | - `gui.idle_seconds`: how long the app keeps the control channel open after the last change | |
| 139 | + | (1–600). | |
| 140 | + | - `gui.start_page`: `overview`, `equalizer`, `gestures`, `earbuds` or `settings`. | |
| 141 | + | ||
| 117 | 142 | ## How it works | |
| 118 | 143 | ||
| 119 | 144 | budctl speaks the buds' own control protocol over an RFCOMM socket. What is known about it, and | |
| @@ -17,7 +17,8 @@ use clap::{Parser, Subcommand, ValueEnum}; | |||
| 17 | 17 | #[derive(Parser)] | |
| 18 | 18 | #[command(name = "budctl", version)] | |
| 19 | 19 | struct Cli { | |
| 20 | − | /// The buds to use, when more than one supported pair is paired. | |
| 20 | + | /// The buds to use, when more than one supported pair is paired. Default: "device" in | |
| 21 | + | /// ~/.config/budctl/config.json. | |
| 21 | 22 | #[arg(long, global = true, value_name = "MAC")] | |
| 22 | 23 | device: Option<BdAddr>, | |
| 23 | 24 | ||
| @@ -247,6 +248,16 @@ fn main() -> ExitCode { | |||
| 247 | 248 | } | |
| 248 | 249 | } | |
| 249 | 250 | ||
| 251 | + | /// The config's default device. A config that cannot be used is reported and left out, so it | |
| 252 | + | /// never stops a command. | |
| 253 | + | fn default_device() -> Option<BdAddr> { | |
| 254 | + | let (config, problem) = budctl_core::config::load(); | |
| 255 | + | if let Some(problem) = problem { | |
| 256 | + | eprintln!("budctl: {problem}; ignoring it"); | |
| 257 | + | } | |
| 258 | + | config.device().unwrap_or_default() | |
| 259 | + | } | |
| 260 | + | ||
| 250 | 261 | fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |
| 251 | 262 | // Checked before connecting: ringing is loud. Spec: realme_t310.md#find | |
| 252 | 263 | if let Command::Find { | |
| @@ -264,7 +275,7 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 264 | 275 | return output::list(out, &found, cli.json); | |
| 265 | 276 | } | |
| 266 | 277 | ||
| 267 | − | let (device, model) = budctl_core::select(found, cli.device)?; | |
| 278 | + | let (device, model) = budctl_core::select(found, cli.device.or_else(default_device))?; | |
| 268 | 279 | let mut session = budctl_core::connect(&device, model)?; | |
| 269 | 280 | match cli.command { | |
| 270 | 281 | Command::List => Ok(()), | |
| @@ -6,7 +6,12 @@ | |||
| 6 | 6 | use std::process::Command; | |
| 7 | 7 | ||
| 8 | 8 | fn budctl(args: &[&str]) -> std::process::Output { | |
| 9 | − | Command::new(env!("CARGO_BIN_EXE_budctl")).args(args).output().unwrap() | |
| 9 | + | // Never the real ~/.config: budctl writes a default config there. | |
| 10 | + | Command::new(env!("CARGO_BIN_EXE_budctl")) | |
| 11 | + | .env("XDG_CONFIG_HOME", env!("CARGO_TARGET_TMPDIR")) | |
| 12 | + | .args(args) | |
| 13 | + | .output() | |
| 14 | + | .unwrap() | |
| 10 | 15 | } | |
| 11 | 16 | ||
| 12 | 17 | #[test] | |
| @@ -10,6 +10,7 @@ publish.workspace = true | |||
| 10 | 10 | [dependencies] | |
| 11 | 11 | libc = "0.2.191" | |
| 12 | 12 | serde = { version = "1.0.229", features = ["derive"] } | |
| 13 | + | serde_json = "1.0.154" | |
| 13 | 14 | zbus = { version = "5.19.0", default-features = false, features = ["blocking-api", "async-io"] } | |
| 14 | 15 | ||
| 15 | 16 | [lints] | |
| @@ -0,0 +1,188 @@ | |||
| 1 | + | //! The user's config: `~/.config/budctl/config.json`, shared by the CLI and the GUI. | |
| 2 | + | //! | |
| 3 | + | //! It only holds preferences. Settings live on the buds, so nothing here mirrors them (CLAUDE.md, | |
| 4 | + | //! goals). A missing file is written with the defaults, so there is something to edit; a file | |
| 5 | + | //! that cannot be used is reported and the defaults are used instead, so a typo never stops a | |
| 6 | + | //! status bar's `budctl --json status`. | |
| 7 | + | ||
| 8 | + | use std::fs; | |
| 9 | + | use std::path::{Path, PathBuf}; | |
| 10 | + | ||
| 11 | + | use serde::{Deserialize, Serialize}; | |
| 12 | + | ||
| 13 | + | use crate::bt::BdAddr; | |
| 14 | + | ||
| 15 | + | /// What the file holds. A key the file lacks keeps its default; an unknown key is an error, so a | |
| 16 | + | /// misspelt one is not silently ignored. | |
| 17 | + | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 18 | + | #[serde(default, deny_unknown_fields)] | |
| 19 | + | pub struct Config { | |
| 20 | + | /// The buds to use when `--device` is not given, as `AA:BB:CC:DD:EE:FF`. | |
| 21 | + | pub device: Option<String>, | |
| 22 | + | pub gui: Gui, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// `budctl-gui` only. | |
| 26 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 27 | + | #[serde(default, deny_unknown_fields)] | |
| 28 | + | pub struct Gui { | |
| 29 | + | /// Seconds the GUI keeps the control channel open after the last command. | |
| 30 | + | pub idle_seconds: u64, | |
| 31 | + | /// The page the window opens on: `overview`, `equalizer`, `gestures`, `earbuds` or `settings`. | |
| 32 | + | pub start_page: String, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl Default for Gui { | |
| 36 | + | fn default() -> Self { | |
| 37 | + | Gui { | |
| 38 | + | idle_seconds: 10, | |
| 39 | + | start_page: "overview".to_owned(), | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// The longest `gui.idle_seconds` may be: the channel is meant to be free for other apps. | |
| 45 | + | const MAX_IDLE_SECONDS: u64 = 600; | |
| 46 | + | ||
| 47 | + | impl Config { | |
| 48 | + | /// The default device, checked. | |
| 49 | + | pub fn device(&self) -> Result<Option<BdAddr>, String> { | |
| 50 | + | self.device | |
| 51 | + | .as_deref() | |
| 52 | + | .map(|address| address.parse().map_err(|e| format!("device: {e}"))) | |
| 53 | + | .transpose() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | fn check(&self) -> Result<(), String> { | |
| 57 | + | self.device()?; | |
| 58 | + | if !(1..=MAX_IDLE_SECONDS).contains(&self.gui.idle_seconds) { | |
| 59 | + | return Err(format!("gui.idle_seconds: must be 1 to {MAX_IDLE_SECONDS}")); | |
| 60 | + | } | |
| 61 | + | Ok(()) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// A config from the text of a config file. | |
| 66 | + | pub fn parse(json: &str) -> Result<Config, String> { | |
| 67 | + | let config: Config = serde_json::from_str(json).map_err(|e| e.to_string())?; | |
| 68 | + | config.check()?; | |
| 69 | + | Ok(config) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// budctl's directory under `$XDG_CONFIG_HOME`, else `~/.config`. | |
| 73 | + | pub fn dir() -> Option<PathBuf> { | |
| 74 | + | xdg_dir("XDG_CONFIG_HOME", ".config") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// budctl's directory under `$XDG_STATE_HOME`, else `~/.local/state`: logs. | |
| 78 | + | pub fn state_dir() -> Option<PathBuf> { | |
| 79 | + | xdg_dir("XDG_STATE_HOME", ".local/state") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> { | |
| 83 | + | let base = std::env::var_os(variable) | |
| 84 | + | .map(PathBuf::from) | |
| 85 | + | .filter(|p| p.is_absolute()) | |
| 86 | + | .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(fallback)))?; | |
| 87 | + | Some(base.join("budctl")) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// `config.json` in [`dir`]. | |
| 91 | + | pub fn file() -> Option<PathBuf> { | |
| 92 | + | dir().map(|dir| dir.join("config.json")) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// The config, and why the file could not be used if it could not (the defaults are used then). | |
| 96 | + | /// A missing file is written with the defaults; failing to write it is not a problem. | |
| 97 | + | pub fn load() -> (Config, Option<String>) { | |
| 98 | + | let Some(file) = file() else { | |
| 99 | + | return (Config::default(), None); | |
| 100 | + | }; | |
| 101 | + | load_from(&file) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | fn load_from(file: &Path) -> (Config, Option<String>) { | |
| 105 | + | match fs::read_to_string(file) { | |
| 106 | + | Ok(json) => match parse(&json) { | |
| 107 | + | Ok(config) => (config, None), | |
| 108 | + | Err(e) => (Config::default(), Some(format!("{}: {e}", file.display()))), | |
| 109 | + | }, | |
| 110 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => { | |
| 111 | + | let _ = create(file, &defaults_json()); | |
| 112 | + | (Config::default(), None) | |
| 113 | + | } | |
| 114 | + | Err(e) => (Config::default(), Some(format!("{}: {e}", file.display()))), | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// The default config, as written to a new file. | |
| 119 | + | pub fn defaults_json() -> String { | |
| 120 | + | // A plain struct of strings and numbers always serializes. | |
| 121 | + | let mut json = serde_json::to_string_pretty(&Config::default()).unwrap_or_default(); | |
| 122 | + | json.push('\n'); | |
| 123 | + | json | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Write `contents` to `file`, creating its directory, unless the file already exists. | |
| 127 | + | pub fn create(file: &Path, contents: &str) -> std::io::Result<()> { | |
| 128 | + | if let Some(dir) = file.parent() { | |
| 129 | + | fs::create_dir_all(dir)?; | |
| 130 | + | } | |
| 131 | + | let mut new = fs::OpenOptions::new().write(true).create_new(true).open(file)?; | |
| 132 | + | std::io::Write::write_all(&mut new, contents.as_bytes()) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[cfg(test)] | |
| 136 | + | mod tests { | |
| 137 | + | use super::*; | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn the_defaults_read_back_as_the_defaults() { | |
| 141 | + | assert_eq!(parse(&defaults_json()).unwrap(), Config::default()); | |
| 142 | + | assert_eq!(parse("{}").unwrap(), Config::default()); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | #[test] | |
| 146 | + | fn keys_override_the_defaults() { | |
| 147 | + | let config = parse(r#"{"device": "AA:BB:CC:DD:EE:FF", "gui": {"idle_seconds": 30}}"#).unwrap(); | |
| 148 | + | assert_eq!(config.device().unwrap(), Some("AA:BB:CC:DD:EE:FF".parse().unwrap())); | |
| 149 | + | assert_eq!(config.gui.idle_seconds, 30); | |
| 150 | + | assert_eq!(config.gui.start_page, "overview"); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn bad_files_are_errors() { | |
| 155 | + | assert!(parse("not json").is_err()); | |
| 156 | + | assert!( | |
| 157 | + | parse(r#"{"devise": "AA:BB:CC:DD:EE:FF"}"#) | |
| 158 | + | .unwrap_err() | |
| 159 | + | .contains("devise") | |
| 160 | + | ); | |
| 161 | + | assert!(parse(r#"{"device": "nope"}"#).unwrap_err().contains("device")); | |
| 162 | + | assert!( | |
| 163 | + | parse(r#"{"gui": {"idle_seconds": 0}}"#) | |
| 164 | + | .unwrap_err() | |
| 165 | + | .contains("idle_seconds") | |
| 166 | + | ); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[test] | |
| 170 | + | fn a_missing_file_is_created_and_an_existing_one_kept() { | |
| 171 | + | let dir = std::env::temp_dir().join(format!("budctl-config-test-{}", std::process::id())); | |
| 172 | + | let file = dir.join("nested/config.json"); | |
| 173 | + | let _ = fs::remove_dir_all(&dir); | |
| 174 | + | ||
| 175 | + | assert_eq!(load_from(&file), (Config::default(), None)); | |
| 176 | + | assert_eq!(fs::read_to_string(&file).unwrap(), defaults_json()); | |
| 177 | + | ||
| 178 | + | fs::write(&file, r#"{"gui": {"start_page": "equalizer"}}"#).unwrap(); | |
| 179 | + | assert_eq!(load_from(&file).0.gui.start_page, "equalizer"); | |
| 180 | + | assert!(create(&file, "{}").is_err(), "an existing file is never overwritten"); | |
| 181 | + | ||
| 182 | + | fs::write(&file, "{").unwrap(); | |
| 183 | + | let (config, problem) = load_from(&file); | |
| 184 | + | assert_eq!(config, Config::default()); | |
| 185 | + | assert!(problem.unwrap().contains("config.json")); | |
| 186 | + | let _ = fs::remove_dir_all(&dir); | |
| 187 | + | } | |
| 188 | + | } | |
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | ||
| 8 | 8 | pub mod bluez; | |
| 9 | 9 | pub mod bt; | |
| 10 | + | pub mod config; | |
| 10 | 11 | pub mod debug; | |
| 11 | 12 | pub mod devices; | |
| 12 | 13 | mod error; | |
| @@ -0,0 +1,60 @@ | |||
| 1 | + | //! `~/.local/state/budctl/gui.log`: errors and session events (connected, each write and its | |
| 2 | + | //! answer, closed), so a "Try again" can be explained after the fact even when the window was | |
| 3 | + | //! started from a launcher and stderr went nowhere. Each launch starts the file afresh. | |
| 4 | + | //! | |
| 5 | + | //! Lines start with the seconds since launch, like `--debug` output. Frames are not logged; | |
| 6 | + | //! `--debug` still hex-dumps them on stderr. | |
| 7 | + | ||
| 8 | + | use std::fs::{self, File}; | |
| 9 | + | use std::io::Write; | |
| 10 | + | use std::path::{Path, PathBuf}; | |
| 11 | + | use std::sync::{Mutex, OnceLock}; | |
| 12 | + | use std::time::{Instant, SystemTime, UNIX_EPOCH}; | |
| 13 | + | ||
| 14 | + | struct Log { | |
| 15 | + | file: Mutex<File>, | |
| 16 | + | started: Instant, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | static LOG: OnceLock<Log> = OnceLock::new(); | |
| 20 | + | ||
| 21 | + | /// `gui.log` in budctl's state directory. | |
| 22 | + | pub fn file() -> Option<PathBuf> { | |
| 23 | + | budctl_core::config::state_dir().map(|dir| dir.join("gui.log")) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Start the log at `path`, replacing the last launch's. Until this runs, and if it fails, | |
| 27 | + | /// [`line`] does nothing. | |
| 28 | + | pub fn start(path: &Path) -> std::io::Result<()> { | |
| 29 | + | if let Some(dir) = path.parent() { | |
| 30 | + | fs::create_dir_all(dir)?; | |
| 31 | + | } | |
| 32 | + | let mut file = File::create(path)?; | |
| 33 | + | let since_epoch = SystemTime::now() | |
| 34 | + | .duration_since(UNIX_EPOCH) | |
| 35 | + | .map(|d| d.as_secs()) | |
| 36 | + | .unwrap_or_default(); | |
| 37 | + | writeln!( | |
| 38 | + | file, | |
| 39 | + | "budctl-gui {} started (unix time {since_epoch}, pid {})", | |
| 40 | + | env!("CARGO_PKG_VERSION"), | |
| 41 | + | std::process::id() | |
| 42 | + | )?; | |
| 43 | + | let _ = LOG.set(Log { | |
| 44 | + | file: Mutex::new(file), | |
| 45 | + | started: Instant::now(), | |
| 46 | + | }); | |
| 47 | + | Ok(()) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Add a line to the log, if it is open. | |
| 51 | + | pub fn line(message: impl AsRef<str>) { | |
| 52 | + | let Some(log) = LOG.get() else { | |
| 53 | + | return; | |
| 54 | + | }; | |
| 55 | + | let elapsed = log.started.elapsed().as_secs_f64(); | |
| 56 | + | if let Ok(mut file) = log.file.lock() { | |
| 57 | + | // A log that cannot be written must not take the window down with it. | |
| 58 | + | let _ = writeln!(file, "{elapsed:>9.3} {}", message.as_ref()); | |
| 59 | + | } | |
| 60 | + | } | |
| @@ -10,6 +10,7 @@ mod equalizer; | |||
| 10 | 10 | mod gestures; | |
| 11 | 11 | mod icons; | |
| 12 | 12 | mod keys; | |
| 13 | + | mod log; | |
| 13 | 14 | mod overview; | |
| 14 | 15 | mod settings; | |
| 15 | 16 | mod theme; | |
| @@ -40,8 +41,11 @@ const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] | |||
| 40 | 41 | --device picks the buds when more than one supported pair is connected. | |
| 41 | 42 | --demo shows made-up buds, without Bluetooth. | |
| 42 | 43 | --debug hex-dumps every frame sent and received on stderr, as budctl --debug does. | |
| 43 | − | Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in. | |
| 44 | − | The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …). | |
| 44 | + | Colors come from --theme, else from ~/.config/budctl/theme.json, else built in. That file is | |
| 45 | + | written with the built-in colors when missing. It takes the keys of a matugen palette like | |
| 46 | + | Aurora's (bg, fg, primary, …). | |
| 47 | + | Preferences (default device, idle time, start page): ~/.config/budctl/config.json. | |
| 48 | + | Each launch logs errors and session events to ~/.local/state/budctl/gui.log. | |
| 45 | 49 | Press ? in the window for the keys."; | |
| 46 | 50 | ||
| 47 | 51 | /// The page's scrolling area, so the cursor can be kept in view. | |
| @@ -68,9 +72,32 @@ fn main() -> ExitCode { | |||
| 68 | 72 | if options.debug { | |
| 69 | 73 | budctl_core::debug::enable(); | |
| 70 | 74 | } | |
| 75 | + | if let Some(file) = log::file() | |
| 76 | + | && let Err(e) = log::start(&file) | |
| 77 | + | { | |
| 78 | + | eprintln!("budctl-gui: cannot write the log {}: {e}", file.display()); | |
| 79 | + | } | |
| 80 | + | let config = load_config(); | |
| 81 | + | let start_page = Page::named(&config.gui.start_page).unwrap_or_else(|| { | |
| 82 | + | warn(format!( | |
| 83 | + | "gui.start_page: no page {:?}; opening Overview", | |
| 84 | + | config.gui.start_page | |
| 85 | + | )); | |
| 86 | + | Page::Overview | |
| 87 | + | }); | |
| 88 | + | let idle = Duration::from_secs(config.gui.idle_seconds); | |
| 89 | + | let device = options.device.or_else(|| config.device().unwrap_or_default()); | |
| 90 | + | if options.theme.is_none() { | |
| 91 | + | write_built_in_theme(); | |
| 92 | + | } | |
| 71 | 93 | let theme_file = options.theme.clone().or_else(theme::default_file); | |
| 72 | 94 | let font = theme::system_font(); | |
| 73 | − | let boot = move || App::new(theme_file.clone(), font, options.device, options.demo); | |
| 95 | + | let boot = move || { | |
| 96 | + | let mut app = App::new(theme_file.clone(), font, device, options.demo); | |
| 97 | + | app.page = start_page; | |
| 98 | + | app.idle = idle; | |
| 99 | + | app | |
| 100 | + | }; | |
| 74 | 101 | let result = iced::application(boot, App::update, App::view) | |
| 75 | 102 | .title("budctl") | |
| 76 | 103 | .theme(App::theme) | |
| @@ -95,6 +122,35 @@ fn main() -> ExitCode { | |||
| 95 | 122 | } | |
| 96 | 123 | } | |
| 97 | 124 | ||
| 125 | + | /// A problem worth knowing that does not stop the window: on stderr and in the log. | |
| 126 | + | fn warn(message: String) { | |
| 127 | + | eprintln!("budctl-gui: {message}"); | |
| 128 | + | log::line(message); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// The config, with the defaults when it cannot be used. | |
| 132 | + | fn load_config() -> budctl_core::config::Config { | |
| 133 | + | let (config, problem) = budctl_core::config::load(); | |
| 134 | + | match problem { | |
| 135 | + | Some(problem) => warn(format!("{problem}; using the defaults")), | |
| 136 | + | None => log::line(format!("config: {config:?}")), | |
| 137 | + | } | |
| 138 | + | config | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// Write the built-in colors to `~/.config/budctl/theme.json` when there is no such file, as a | |
| 142 | + | /// start for a matugen template. | |
| 143 | + | fn write_built_in_theme() { | |
| 144 | + | let Some(file) = theme::default_file() else { | |
| 145 | + | return; | |
| 146 | + | }; | |
| 147 | + | match budctl_core::config::create(&file, &theme::to_json(&theme::BUILT_IN)) { | |
| 148 | + | Ok(()) => log::line(format!("wrote the built-in colors to {}", file.display())), | |
| 149 | + | Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} | |
| 150 | + | Err(e) => warn(format!("cannot write {}: {e}", file.display())), | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 98 | 154 | #[derive(Default)] | |
| 99 | 155 | struct Options { | |
| 100 | 156 | theme: Option<PathBuf>, | |
| @@ -164,6 +220,18 @@ impl Page { | |||
| 164 | 220 | } | |
| 165 | 221 | } | |
| 166 | 222 | ||
| 223 | + | /// A page by its name in the config: `overview`, `equalizer`, `gestures`, `earbuds`, `settings`. | |
| 224 | + | fn named(name: &str) -> Option<Page> { | |
| 225 | + | match name { | |
| 226 | + | "overview" => Some(Page::Overview), | |
| 227 | + | "equalizer" => Some(Page::Equalizer), | |
| 228 | + | "gestures" => Some(Page::Gestures), | |
| 229 | + | "earbuds" => Some(Page::Earbuds), | |
| 230 | + | "settings" => Some(Page::Settings), | |
| 231 | + | _ => None, | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 167 | 235 | /// The next page in the sidebar, or the previous one, going round. | |
| 168 | 236 | fn step(self, forward: bool) -> Page { | |
| 169 | 237 | let count = Page::ALL.len(); | |
| @@ -340,8 +408,10 @@ struct App { | |||
| 340 | 408 | cursor_shown: bool, | |
| 341 | 409 | /// Whether the list of keys is open. | |
| 342 | 410 | help: bool, | |
| 343 | − | /// `--device`, for the worker. | |
| 411 | + | /// `--device`, else the config's, for the worker. | |
| 344 | 412 | device: Option<BdAddr>, | |
| 413 | + | /// How long the worker keeps an idle session open. | |
| 414 | + | idle: Duration, | |
| 345 | 415 | link: Link, | |
| 346 | 416 | /// Where commands go, once the worker is up. | |
| 347 | 417 | worker: Option<Sender<Command>>, | |
| @@ -397,6 +467,7 @@ impl App { | |||
| 397 | 467 | cursor_shown: false, | |
| 398 | 468 | help: false, | |
| 399 | 469 | device, | |
| 470 | + | idle: worker::IDLE, | |
| 400 | 471 | link: if demo { Link::Demo } else { Link::Connecting }, | |
| 401 | 472 | worker: None, | |
| 402 | 473 | in_flight: VecDeque::new(), | |
| @@ -1186,7 +1257,7 @@ impl App { | |||
| 1186 | 1257 | palette | |
| 1187 | 1258 | } | |
| 1188 | 1259 | Some(Err(e)) => { | |
| 1189 | − | eprintln!("budctl-gui: {e}; using the built-in colors"); | |
| 1260 | + | warn(format!("{e}; using the built-in colors")); | |
| 1190 | 1261 | self.palette_problem = Some(e); | |
| 1191 | 1262 | theme::BUILT_IN | |
| 1192 | 1263 | } | |
| @@ -1211,7 +1282,7 @@ impl App { | |||
| 1211 | 1282 | if self.link == Link::Demo { | |
| 1212 | 1283 | return events; | |
| 1213 | 1284 | } | |
| 1214 | − | let worker = Subscription::run_with(self.device, worker::run).map(Message::Worker); | |
| 1285 | + | let worker = Subscription::run_with((self.device, self.idle), worker::run).map(Message::Worker); | |
| 1215 | 1286 | Subscription::batch([events, worker]) | |
| 1216 | 1287 | } | |
| 1217 | 1288 | ||
| @@ -27,7 +27,8 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> { | |||
| 27 | 27 | let palette = ui::setting(look, Icon::Settings, "Colors", Some(source), none(), None, false); | |
| 28 | 28 | let how = text( | |
| 29 | 29 | "budctl follows a matugen palette with Aurora's keys (bg, fg, primary, surface…): the file given \ | |
| 30 | − | with --theme, else ~/.config/budctl/theme.json. It is read again whenever the window gets focus.", | |
| 30 | + | with --theme, else ~/.config/budctl/theme.json, which starts out with the built-in colors. \ | |
| 31 | + | It is read again whenever the window gets focus.", | |
| 31 | 32 | ) | |
| 32 | 33 | .size(13) | |
| 33 | 34 | .color(p.dim); | |
| @@ -40,6 +41,24 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> { | |||
| 40 | 41 | None, | |
| 41 | 42 | false, | |
| 42 | 43 | ); | |
| 44 | + | let files = ui::setting( | |
| 45 | + | look, | |
| 46 | + | Icon::Settings, | |
| 47 | + | "Preferences", | |
| 48 | + | Some("~/.config/budctl/config.json: default device, idle time, start page. Read at launch.".to_owned()), | |
| 49 | + | none(), | |
| 50 | + | None, | |
| 51 | + | false, | |
| 52 | + | ); | |
| 53 | + | let log = ui::setting( | |
| 54 | + | look, | |
| 55 | + | Icon::Info, | |
| 56 | + | "Log", | |
| 57 | + | Some("~/.local/state/budctl/gui.log: errors and session events, started afresh each launch.".to_owned()), | |
| 58 | + | none(), | |
| 59 | + | None, | |
| 60 | + | false, | |
| 61 | + | ); | |
| 43 | 62 | let about = ui::setting( | |
| 44 | 63 | look, | |
| 45 | 64 | Icon::Info, | |
| @@ -53,6 +72,7 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> { | |||
| 53 | 72 | ui::title(look, "App settings"), | |
| 54 | 73 | column![ui::card(look, vec![palette]), how].spacing(10), | |
| 55 | 74 | ui::card(look, vec![keys]), | |
| 75 | + | ui::card(look, vec![files, log]), | |
| 56 | 76 | ui::card(look, vec![about]), | |
| 57 | 77 | ] | |
| 58 | 78 | .spacing(18) | |
| @@ -127,11 +127,42 @@ fn parse_hex(hex: &str) -> Option<Color> { | |||
| 127 | 127 | ||
| 128 | 128 | /// The palette file to follow: `--theme <file>`, else `~/.config/budctl/theme.json`. | |
| 129 | 129 | pub fn default_file() -> Option<PathBuf> { | |
| 130 | − | let config = std::env::var_os("XDG_CONFIG_HOME") | |
| 131 | − | .map(PathBuf::from) | |
| 132 | − | .filter(|p| p.is_absolute()) | |
| 133 | − | .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?; | |
| 134 | − | Some(config.join("budctl/theme.json")) | |
| 130 | + | budctl_core::config::dir().map(|dir| dir.join("theme.json")) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// A palette as a palette file, with every key [`parse`] reads: the built-in one is written to | |
| 134 | + | /// [`default_file`] when there is none, to start a matugen template from. | |
| 135 | + | pub fn to_json(p: &Palette) -> String { | |
| 136 | + | let keys = [ | |
| 137 | + | ("bg", p.bg), | |
| 138 | + | ("fg", p.fg), | |
| 139 | + | ("primary", p.accent), | |
| 140 | + | ("primaryFg", p.accent_fg), | |
| 141 | + | ("primaryContainer", p.accent_dim), | |
| 142 | + | ("secondary", p.icon), | |
| 143 | + | ("surfaceLow", p.surface_low), | |
| 144 | + | ("surface", p.surface), | |
| 145 | + | ("surfaceHigh", p.surface_high), | |
| 146 | + | ("surfaceHighest", p.surface_highest), | |
| 147 | + | ("outlineVariant", p.line), | |
| 148 | + | ("surfaceVariantFg", p.dim), | |
| 149 | + | ("err", p.err), | |
| 150 | + | ]; | |
| 151 | + | let lines: Vec<String> = keys | |
| 152 | + | .iter() | |
| 153 | + | .map(|(key, color)| format!(" \"{key}\": \"{}\"", to_hex(*color))) | |
| 154 | + | .collect(); | |
| 155 | + | format!("{{\n{}\n}}\n", lines.join(",\n")) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// `#rrggbb`, or `#rrggbbaa` when not opaque. | |
| 159 | + | fn to_hex(color: Color) -> String { | |
| 160 | + | let [r, g, b, a] = color.into_rgba8(); | |
| 161 | + | if a == u8::MAX { | |
| 162 | + | format!("#{r:02x}{g:02x}{b:02x}") | |
| 163 | + | } else { | |
| 164 | + | format!("#{r:02x}{g:02x}{b:02x}{a:02x}") | |
| 165 | + | } | |
| 135 | 166 | } | |
| 136 | 167 | ||
| 137 | 168 | /// The palette file and the version of it last read, so it is only read again when it changes. | |
| @@ -269,6 +300,16 @@ mod tests { | |||
| 269 | 300 | assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err()); | |
| 270 | 301 | } | |
| 271 | 302 | ||
| 303 | + | #[test] | |
| 304 | + | fn the_written_palette_reads_back_the_same() { | |
| 305 | + | assert_eq!(parse(&to_json(&BUILT_IN)).unwrap(), BUILT_IN); | |
| 306 | + | let see_through = Palette { | |
| 307 | + | accent: Color::from_rgba8(0x11, 0x22, 0x33, 0.8), | |
| 308 | + | ..BUILT_IN | |
| 309 | + | }; | |
| 310 | + | assert_eq!(parse(&to_json(&see_through)).unwrap(), see_through); | |
| 311 | + | } | |
| 312 | + | ||
| 272 | 313 | #[test] | |
| 273 | 314 | fn a_missing_file_changes_nothing() { | |
| 274 | 315 | let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json"))); | |
| @@ -19,8 +19,10 @@ use budctl_core::{Error, Kind, Result}; | |||
| 19 | 19 | use iced::futures::channel::mpsc as channel; | |
| 20 | 20 | use iced::futures::{SinkExt, Stream, StreamExt}; | |
| 21 | 21 | ||
| 22 | − | /// How long the session stays open after the last command. | |
| 23 | − | const IDLE: Duration = Duration::from_secs(10); | |
| 22 | + | use crate::log; | |
| 23 | + | ||
| 24 | + | /// How long the session stays open after the last command, unless the config says otherwise. | |
| 25 | + | pub const IDLE: Duration = Duration::from_secs(10); | |
| 24 | 26 | /// How often an open session answers the buds while it waits for a command. | |
| 25 | 27 | const SERVE_EVERY: Duration = Duration::from_millis(100); | |
| 26 | 28 | ||
| @@ -104,16 +106,15 @@ pub struct Reading { | |||
| 104 | 106 | } | |
| 105 | 107 | ||
| 106 | 108 | /// The worker, as a stream of events for an iced subscription. `device` picks the buds, as | |
| 107 | − | /// `budctl --device` does. | |
| 108 | − | pub fn run(device: &Option<BdAddr>) -> impl Stream<Item = Event> + use<> { | |
| 109 | − | let device = *device; | |
| 109 | + | /// `budctl --device` does; `idle` is how long a session stays open after the last command. | |
| 110 | + | pub fn run(&(device, idle): &(Option<BdAddr>, Duration)) -> impl Stream<Item = Event> + use<> { | |
| 110 | 111 | iced::stream::channel(16, async move |mut output: channel::Sender<Event>| { | |
| 111 | 112 | let (commands, inbox) = mpsc::channel(); | |
| 112 | 113 | let (events, mut outbox) = channel::unbounded(); | |
| 113 | 114 | if output.send(Event::Ready(commands)).await.is_err() { | |
| 114 | 115 | return; | |
| 115 | 116 | } | |
| 116 | − | let worker = Worker::new(bluetooth(device), events, IDLE); | |
| 117 | + | let worker = Worker::new(bluetooth(device), events, idle); | |
| 117 | 118 | let started = thread::Builder::new() | |
| 118 | 119 | .name("budctl-buds".to_owned()) | |
| 119 | 120 | .spawn(move || worker.run(&inbox)); | |
| @@ -193,19 +194,20 @@ impl Worker { | |||
| 193 | 194 | }; | |
| 194 | 195 | if self.last_command.elapsed() >= self.idle { | |
| 195 | 196 | self.session = None; | |
| 197 | + | let message = format!("session closed after {} s without a command", self.idle.as_secs()); | |
| 196 | 198 | if budctl_core::debug::enabled() { | |
| 197 | − | eprintln!( | |
| 198 | − | "# session closed after {} s without a command", | |
| 199 | − | self.idle.as_secs() | |
| 200 | − | ); | |
| 199 | + | eprintln!("# {message}"); | |
| 201 | 200 | } | |
| 201 | + | log::line(message); | |
| 202 | 202 | } else if let Err(error) = session.serve() { | |
| 203 | 203 | self.session = None; | |
| 204 | + | log::line(format!("link lost: {error}")); | |
| 204 | 205 | self.send(Event::Lost(error.to_string())); | |
| 205 | 206 | } | |
| 206 | 207 | } | |
| 207 | 208 | ||
| 208 | 209 | fn handle(&mut self, command: Command) { | |
| 210 | + | log::line(format!("{command:?}")); | |
| 209 | 211 | let answer = match command { | |
| 210 | 212 | Command::Read => self.read().map(|reading| Event::Read(Box::new(reading))), | |
| 211 | 213 | Command::Noise(mode) => self | |
| @@ -262,8 +264,12 @@ impl Worker { | |||
| 262 | 264 | } | |
| 263 | 265 | }; | |
| 264 | 266 | match answer { | |
| 265 | − | Ok(event) => self.send(event), | |
| 267 | + | Ok(event) => { | |
| 268 | + | log::line(" done"); | |
| 269 | + | self.send(event); | |
| 270 | + | } | |
| 266 | 271 | Err(error) => { | |
| 272 | + | log::line(format!(" failed ({:?}): {error}", error.kind())); | |
| 267 | 273 | self.session = None; | |
| 268 | 274 | self.send(Event::Failed { | |
| 269 | 275 | kind: error.kind(), | |
| @@ -301,10 +307,12 @@ impl Worker { | |||
| 301 | 307 | fn session(&mut self) -> Result<&mut Box<dyn Session>> { | |
| 302 | 308 | if self.session.is_none() { | |
| 303 | 309 | let events = self.events.clone(); | |
| 304 | − | let found = move |name| { | |
| 310 | + | let found = move |name: String| { | |
| 311 | + | log::line(format!(" connecting to {name}")); | |
| 305 | 312 | let _ = events.unbounded_send(Event::Found(name)); | |
| 306 | 313 | }; | |
| 307 | 314 | self.session = Some((self.connect)(&found)?); | |
| 315 | + | log::line(" session open"); | |
| 308 | 316 | } | |
| 309 | 317 | self.session | |
| 310 | 318 | .as_mut() | |