From cd4cee15a7e33996d13af92ca6791d302c258d10 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 20:56:30 +0100 Subject: [PATCH 1/2] feat(desktop): blink the lid Apple logo while agents work On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid can blink while agents work and stop when they finish, so a run can be read from across the room. View > Blink Apple Logo While Agents Work picks a style (Calm, Slow, Wink, Heartbeat, Fast). It is off by default, and the menu exists only on a Mac that has the logo. Apple documents one control for the logo, `sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. The same setting is a property of BrightnessSystemClient in the private CoreBrightness framework, which a process of the logged-in user can set with no administrator, so nothing here needs a helper or a password. The setting is system-wide and survives a reboot, and the release profile has panic = "abort", so the work runs in a child process (this binary with --logo-blink) tied to the app by a pipe. When the pipe closes, however the app ended, the child restores the logo and exits. A marker file covers power loss and a SIGKILL of the child itself. The child asks api/sessions itself: WebKit stalls the event stream of a hidden page, which lost whole turns and ran a blink 39 s past the end. It sends the session cookie of its first answer with every poll, because a good password without a cookie creates a login session each time and Codeman evicts the oldest of 100. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/Cargo.lock | 1 + packages/desktop/Cargo.toml | 6 + packages/desktop/README.md | 57 +- packages/desktop/src/agents.rs | 285 +++++++++ packages/desktop/src/assets.rs | 14 +- packages/desktop/src/config.rs | 33 + packages/desktop/src/logo.rs | 1042 ++++++++++++++++++++++++++++++++ packages/desktop/src/main.rs | 72 ++- packages/desktop/src/probe.rs | 27 +- 9 files changed, 1518 insertions(+), 19 deletions(-) create mode 100644 packages/desktop/src/agents.rs create mode 100644 packages/desktop/src/logo.rs diff --git a/packages/desktop/Cargo.lock b/packages/desktop/Cargo.lock index 62564b67..d6f7ad0d 100644 --- a/packages/desktop/Cargo.lock +++ b/packages/desktop/Cargo.lock @@ -164,6 +164,7 @@ dependencies = [ "base64 0.22.1", "block2", "dirs 6.0.0", + "libc", "muda", "objc2", "objc2-app-kit", diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 8255a486..8790f795 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -30,6 +30,9 @@ ureq = { version = "3", features = ["platform-verifier"] } [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3" +# Ignoring signals in the logo blinker (src/logo.rs). Already in the tree +# through wry and tao, so no new crate is added. +libc = "0.2" # JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's # content size (src/macos.rs). The versions wry already pulls in, so no new # crate is added. @@ -39,7 +42,9 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [ "std", "objc2-core-foundation", "block2", + "NSBundle", "NSDate", + "NSDictionary", "NSGeometry", "NSObject", "NSOperation", @@ -53,6 +58,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [ "NSURLRequest", "NSURLResponse", "NSURLSession", + "NSValue", ] } objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "std", diff --git a/packages/desktop/README.md b/packages/desktop/README.md index cf7e1c17..5a69bf40 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -13,8 +13,8 @@ Windows and Linux later. - **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop), [wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and [muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2) - for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login). - No bundled browser and no Node in the app. + for what wry leaves out on macOS (JavaScript dialogs, HTTP Basic login) and for + the lid logo. No bundled browser and no Node in the app. - **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab. ## Why it is cheap @@ -40,6 +40,7 @@ cannot do. | **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). | | **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. | | **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. | +| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid blinks while agents work and stops when they finish, so you can see from across the room that they are still going. *View > Blink Apple Logo While Agents Work* picks how: *Calm*, *Slow*, *Wink* (a short dark moment every few seconds), *Heartbeat* (two quick beats, then a rest) or *Fast*. It is off by default, and the menu does not exist on a Mac without that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for two seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. | | **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. | ## Build (on a Mac) @@ -187,8 +188,60 @@ instead of being answered. A refusal that lands while you are in another app does not bring Codeman forward: the connect screen loads where the window is, and the Dock icon bounces. +**The logo blinks from a child process, through CoreBrightness.** Apple documents +one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)): +`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that +for every blink would need a root helper and an administrator password. The same +setting is also a property of `BrightnessSystemClient` in the private +CoreBrightness framework, and a process of the logged-in user can set it there, +which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an +ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond +after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled` +keeps its value while the logo is dark. + +The setting is system-wide and survives a reboot, so the app must not end with the +logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the +app can be relied on. So all of it runs as this same binary with +`--logo-blink`, for as long as a style is chosen and a server is open, tied to the app +by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo +back and exits. Switching it off closes the same pipe, so the path a crash +takes is the one used every day. A marker file +(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself: +at the next launch the app puts back a logo a blink left dark. The framework is +loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini +answers nil) is what hides the option, so a macOS that drops it loses this feature +and nothing else. + +**The agent count is asked for natively, because the page cannot be trusted with it.** +The first version read the page's own event stream, which needs no second connection +and no login. On a real Mac it failed in both directions as soon as the window was +covered, because WebKit stalls the stream of a hidden page: a turn that started while +it was stalled was never seen and the logo stayed still, and in another run the logo +kept blinking 39 seconds after the agent finished, until the page's watchdog reopened +the stream. A hidden window is the normal case for a signal meant to be read from +across the room, so `src/agents.rs` asks `api/sessions` itself, from the child process, +every two seconds. That needs care with the login. A request with a good password and +no session cookie makes Codeman create a login session, it keeps 100 of them, and the +oldest goes first, so polling with the password alone would log every browser out +within minutes. The poller therefore sends the cookie of the first answer with every +request, which Codeman reads first, so one session is created per server start. A +refused login ends the polling for good, because Codeman counts each refusal against +the login rate limit. The app and the child share nothing but the pipe the login +crosses, so the login is never on a command line. + ## Known limits +- **The logo blink rests on a private macOS interface.** Apple documents the + setting, not the way the app reaches it, so a macOS update can take it away; + the menu then disappears. Every change of the logo writes `LEBacklightMode`, + from about once every two seconds (*Slow*, *Wink*) to about three times a + second (*Fast*), which `corebrightnessd` keeps in root's preferences. The + setting is on or off, so these styles are rhythms of hard changes. After a blink + the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete + com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a + dark phase the logo stays dark until the app next starts. `api/sessions` does + not say which turns are Codeman's own, so with prompt-cache keep-warm on, a + keep-alive turn blinks too. - **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a self-signed certificate, and WKWebView rejects it (wry has no trust override). Use `http://127.0.0.1:3000` for a server on this Mac. For another diff --git a/packages/desktop/src/agents.rs b/packages/desktop/src/agents.rs new file mode 100644 index 00000000..b4712971 --- /dev/null +++ b/packages/desktop/src/agents.rs @@ -0,0 +1,285 @@ +//! How many agents are at work on a server, asked from outside the web view. +//! +//! The page knows this too, but it cannot be relied on for it: WebKit stalls the +//! event stream of a hidden page. Measured on macOS 27.2 with the page hidden: a +//! `session:idle` the server sent never reached it, its own watchdog reopened the +//! stream 39 seconds later, and in another run a whole turn went unseen. A hidden +//! window is the normal case for this question, so it is asked natively, of +//! `api/sessions`, a stable route that works with any Codeman. +//! +//! A login has a price here that a single request does not show. A request with +//! a good password and no session cookie makes Codeman create a login session, +//! it keeps 100 of them, and the oldest goes first, so asking every two seconds +//! with the password alone would log every browser out within minutes. So the +//! cookie of the first answer goes along from then on, and Codeman reads the +//! cookie first: the password is only used when the cookie has lapsed (a server +//! restart), and then it creates the one new session that replaces it. Sending +//! both also means a stale cookie costs no failed login. A refused login ends +//! the polling for good, since Codeman counts each one against the login rate +//! limit (see `probe`). + +use serde::Deserialize; +use std::time::Duration; +use url::Url; + +/// How long the logo can lag behind the server. The list is cached for a +/// second on the server's side and is a few kilobytes. +pub const POLL_EVERY: Duration = Duration::from_secs(2); +const TIMEOUT: Duration = Duration::from_secs(5); +const SESSIONS_PATH: &str = "api/sessions"; +const COOKIE_NAME: &str = "codeman_session"; + +#[derive(Debug, PartialEq, Eq)] +pub enum Poll { + /// This many agents are working. + Working(u32), + /// No answer this time. Worth asking again. + Failed, + /// The server turned the login down. Never ask again. + Refused, +} + +#[derive(Deserialize)] +struct Session { + #[serde(default)] + status: String, + #[serde(default)] + mode: String, +} + +/// Codeman wraps the list in its API envelope, and older servers sent it bare. +#[derive(Deserialize)] +#[serde(untagged)] +enum Sessions { + Envelope { data: Vec }, + Bare(Vec), +} + +/// Agents only: a shell tab with a `tail -f` is a run of output, which the +/// server may call busy, and that is nobody's agent. +fn count_working(body: &str) -> Option { + let (Sessions::Envelope { data: sessions } | Sessions::Bare(sessions)) = serde_json::from_str(body).ok()?; + let working = sessions.iter().filter(|s| s.status == "busy" && s.mode != "shell"); + u32::try_from(working.count()).ok() +} + +/// `name=value` out of a `Set-Cookie` header, for Codeman's session cookie. +fn session_cookie(header: &str) -> Option<&str> { + let pair = header.split(';').next()?.trim(); + let (name, value) = pair.split_once('=')?; + (name == COOKIE_NAME && !value.is_empty()).then_some(pair) +} + +/// What a status code says about asking again. +fn answer(status: u16, body: impl FnOnce() -> Option) -> Poll { + match status { + 200 => body() + .as_deref() + .and_then(count_working) + .map_or(Poll::Failed, Poll::Working), + 401 | 403 | 429 => Poll::Refused, + _ => Poll::Failed, + } +} + +pub struct Poller { + agent: ureq::Agent, + url: String, + authorization: Option, + cookie: Option, +} + +impl Poller { + pub fn new(server: &Url, authorization: Option) -> Option { + Some(Poller { + agent: crate::probe::agent(TIMEOUT), + url: server.join(SESSIONS_PATH).ok()?.to_string(), + authorization, + cookie: None, + }) + } + + pub fn poll(&mut self) -> Poll { + let mut request = self.agent.get(&self.url).header("Accept", "application/json"); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + if let Some(cookie) = &self.cookie { + request = request.header("Cookie", cookie); + } + let Ok(mut response) = request.call() else { + return Poll::Failed; + }; + let fresh = response + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .find_map(session_cookie) + .map(str::to_owned); + if fresh.is_some() { + self.cookie = fresh; + } + let status = response.status().as_u16(); + answer(status, || { + let body = response.body_mut().with_config(); + body.limit(crate::probe::MAX_BODY).read_to_string().ok() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + + /// The start of a real answer of `GET /api/sessions`, captured from a + /// server: other fields around the two that matter, in Codeman's envelope. + const REAL_ANSWER: &str = r#"{"success":true,"data":[{"id":"0f8d0215-661e-4a1c-bfad-d7f6fd2ed4b5", + "pid":61017,"status":"busy","workingDir":"/Users/lee/codeman-cases/testcase", + "paneCwd":"/Users/lee/codeman-cases/testcase","currentTaskId":null,"mode":"opencode"}]}"#; + + #[test] + fn reads_the_answer_a_real_server_gives() { + assert_eq!(count_working(REAL_ANSWER), Some(1)); + assert_eq!(count_working(r#"{"success":true,"data":[]}"#), Some(0)); + } + + #[test] + fn counts_agents_at_work_and_nothing_else() { + let body = r#"{"success":true,"data":[ + {"id":"a","status":"busy","mode":"claude"}, + {"id":"b","status":"busy","mode":"codex"}, + {"id":"c","status":"idle","mode":"claude"}, + {"id":"d","status":"busy","mode":"shell"}, + {"id":"e","status":"stopped","mode":"claude"}, + {"id":"f","status":"error","mode":"opencode"} + ]}"#; + assert_eq!(count_working(body), Some(2)); + } + + #[test] + fn reads_the_bare_list_of_an_older_server() { + assert_eq!(count_working(r#"[{"status":"busy","mode":"claude"}]"#), Some(1)); + assert_eq!(count_working("[]"), Some(0)); + } + + #[test] + fn a_session_without_a_mode_counts_and_one_without_a_status_does_not() { + assert_eq!( + count_working(r#"{"data":[{"status":"busy"},{"mode":"claude"}]}"#), + Some(1) + ); + } + + #[test] + fn a_body_that_is_not_the_list_is_no_answer() { + assert_eq!(count_working(""), None); + assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None); + assert_eq!(answer(200, || Some("".into())), Poll::Failed); + assert_eq!(answer(200, || None), Poll::Failed); + } + + #[test] + fn only_a_login_refusal_ends_the_polling() { + for status in [401, 403, 429] { + assert_eq!(answer(status, || None), Poll::Refused, "{status}"); + } + for status in [301, 404, 500, 502, 503] { + assert_eq!(answer(status, || None), Poll::Failed, "{status}"); + } + } + + #[test] + fn finds_codemans_cookie_in_a_set_cookie_header() { + assert_eq!( + session_cookie("codeman_session=abc123; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax"), + Some("codeman_session=abc123") + ); + assert_eq!(session_cookie("codeman_session=abc123"), Some("codeman_session=abc123")); + assert_eq!(session_cookie("other=1; Path=/"), None); + assert_eq!(session_cookie("codeman_session=; Max-Age=0"), None); + assert_eq!(session_cookie(""), None); + } + + /// A server that answers each connection with the next canned response + /// and records the headers it was sent. + fn serve(responses: Vec) -> (u16, Arc>>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + std::thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut headers = Vec::new(); + let mut line = String::new(); + while reader.read_line(&mut line).is_ok() && line.trim() != "" { + headers.push(line.trim().to_ascii_lowercase()); + line.clear(); + } + log.lock().unwrap().push(headers); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + (port, seen) + } + + const EMPTY: &str = r#"{"success":true,"data":[]}"#; + + fn ok(extra_headers: &str, body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n{extra_headers}\r\n{body}", + body.len() + ) + } + + #[test] + fn keeps_one_login_session_across_polls() { + let body = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#; + let (port, seen) = serve(vec![ + ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body), + ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body), + ok("Set-Cookie: codeman_session=second; Max-Age=86400; Path=/\r\n", EMPTY), + ok("", EMPTY), + ]); + let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); + let mut poller = Poller::new(&server, Some("Basic abc".into())).unwrap(); + assert_eq!(poller.poll(), Poll::Working(1)); + assert_eq!(poller.poll(), Poll::Working(1)); + assert_eq!(poller.poll(), Poll::Working(0)); + assert_eq!(poller.poll(), Poll::Working(0)); + + let seen = seen.lock().unwrap(); + let cookie = |n: usize| seen[n].iter().find(|h| h.starts_with("cookie:")).cloned(); + assert_eq!(cookie(0), None); + assert_eq!(cookie(1).as_deref(), Some("cookie: codeman_session=first")); + assert_eq!(cookie(2).as_deref(), Some("cookie: codeman_session=first")); + // A server restart hands out a new one; a response without one keeps it. + assert_eq!(cookie(3).as_deref(), Some("cookie: codeman_session=second")); + assert!(seen.iter().all(|h| h.contains(&"authorization: basic abc".to_string()))); + assert!(seen[0][0].starts_with("get /api/sessions "), "{:?}", seen[0][0]); + } + + #[test] + fn asks_under_the_servers_base_path() { + let (port, seen) = serve(vec![ok("", EMPTY)]); + let server = Url::parse(&format!("http://127.0.0.1:{port}/codeman/")).unwrap(); + assert_eq!(Poller::new(&server, None).unwrap().poll(), Poll::Working(0)); + let seen = seen.lock().unwrap(); + assert!(seen[0][0].starts_with("get /codeman/api/sessions "), "{:?}", seen[0][0]); + assert!(!seen[0].iter().any(|h| h.starts_with("authorization:"))); + } + + #[test] + fn a_server_that_answers_nothing_is_only_a_failed_poll() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); + assert_eq!(Poller::new(&server, None).unwrap().poll(), Poll::Failed); + } +} diff --git a/packages/desktop/src/assets.rs b/packages/desktop/src/assets.rs index 65db1b0f..50ab13ec 100644 --- a/packages/desktop/src/assets.rs +++ b/packages/desktop/src/assets.rs @@ -184,17 +184,21 @@ pub const HOST_SHIM: &str = r#"(function () { } })();"#; -/// A toast inside Codeman when a download finished (Codeman's own -/// `showToast`; nothing happens on a page without it). +/// A toast inside Codeman (its own `showToast`; nothing happens on a page +/// without it). `kind` is one of Codeman's fixed toast kinds. +pub fn toast_script(text: &str, kind: &str) -> String { + let text = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into()); + format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, \"{kind}\"); }} catch (e) {{}}") +} + +/// A toast inside Codeman when a download finished. pub fn download_toast_script(file_name: &str, ok: bool) -> String { let text = if ok { format!("Downloaded {file_name} to Downloads") } else { format!("Download of {file_name} failed") }; - let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into()); - let kind = if ok { "\"success\"" } else { "\"error\"" }; - format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}") + toast_script(&text, if ok { "success" } else { "error" }) } /// Runs in the window that asked for a window the native side did not open diff --git a/packages/desktop/src/config.rs b/packages/desktop/src/config.rs index ec199259..4075ea68 100644 --- a/packages/desktop/src/config.rs +++ b/packages/desktop/src/config.rs @@ -3,6 +3,7 @@ //! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords //! never go here; they live in the Keychain (see `credentials`). +use crate::logo::BlinkStyle; use serde::{Deserialize, Serialize}; use std::fs; use std::io; @@ -43,6 +44,11 @@ pub struct Config { pub auto_connect: bool, /// Always open in full screen (otherwise the last state is restored). pub start_fullscreen: bool, + /// What the lit Apple logo does while agents work (logo.rs). The menu only + /// shows on a Mac that has one. A value this version does not know reads as + /// off: a bad style must not make the whole file unreadable. + #[serde(deserialize_with = "lenient_blink")] + pub logo_blink: BlinkStyle, /// Anything that is not a whole frame reads as None: a bad frame must not /// make the whole file unreadable, which would cost the server list. #[serde(deserialize_with = "lenient_window")] @@ -54,12 +60,18 @@ fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + impl Default for Config { fn default() -> Self { Config { servers: Vec::new(), auto_connect: true, start_fullscreen: false, + logo_blink: BlinkStyle::Off, window: None, } } @@ -399,6 +411,27 @@ mod tests { assert!(can_save); } + #[test] + fn the_logo_blink_style_round_trips_and_a_bad_one_reads_as_off() { + let mut c = Config::default(); + assert_eq!(c.logo_blink, BlinkStyle::Off); + c.logo_blink = BlinkStyle::Heartbeat; + let text = serde_json::to_string(&c).unwrap(); + assert!(text.contains(r#""logoBlink":"heartbeat""#), "{text}"); + assert_eq!( + serde_json::from_str::(&text).unwrap().logo_blink, + BlinkStyle::Heartbeat + ); + for bad in [ + r#"{"logoBlink":"strobe"}"#, + r#"{"logoBlink":3}"#, + r#"{"logoBlink":null}"#, + ] { + let c: Config = serde_json::from_str(bad).unwrap(); + assert_eq!(c.logo_blink, BlinkStyle::Off, "{bad}"); + } + } + #[test] fn round_trip_and_atomic_save() { let dir = tmp_dir("roundtrip"); diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs new file mode 100644 index 00000000..48e3e9bb --- /dev/null +++ b/packages/desktop/src/logo.rs @@ -0,0 +1,1042 @@ +//! The lit Apple logo on the lid of a MacBook Pro with a PROLED XDR display +//! blinks while agents work, so you can see across the room that they are +//! still going. Opt-in (View menu, in the style the user picks), and the menu +//! exists only on a Mac that has such a logo. +//! +//! Apple documents one control for the logo (support.apple.com/en-us/149248): +//! `sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. What +//! the app uses is the same setting, reached as a property of +//! `BrightnessSystemClient` in the private CoreBrightness framework, which needs +//! no administrator. Measured on a Mac17,13 with macOS 27.2 from an ad-hoc signed +//! binary: the write lands in about a millisecond with no fade, `corebrightnessd` +//! keeps it in its own defaults, and the NVRAM variable `bklb-backlight-enabled` +//! keeps its value while the logo is dark. The mode reads 2 when nothing was ever +//! set. A Mac without the logo answers nil, which is what `available` goes by; a +//! macOS that drops the property makes the feature disappear rather than fail. +//! +//! The setting is system-wide and survives a reboot, so the one thing that +//! must never happen is the app ending while the logo is dark. All of this +//! therefore runs in a child process (this same binary with `BLINK_ARG`), for as +//! long as a style is chosen and a server is open. The app hands it the server +//! and the login over a pipe (never on its command line, where `ps` shows them) +//! and keeps the pipe open. When the pipe closes, however the app ended (quit, +//! crash, `panic = "abort"`, Force Quit), the child puts the logo back and +//! exits. Switching it off closes the same pipe, so there is one way out +//! and it is the one a crash takes. For what even the child cannot survive +//! (power loss, a SIGKILL of the child itself), a marker file lies next to the +//! app's settings while a blink runs, and `recover` puts the logo back at the +//! next launch. +//! +//! The child also does the asking (`agents`), so it needs nothing from the app +//! once started: a hidden window, a stalled page and App Nap cannot hold up the +//! logo, and the app's event loop gets no timers for it. +//! +//! The logo is only ever blinked from mode 2. A logo the user switched off +//! stays off, and a mode that changes under a running blink means someone else +//! wants the setting: the child leaves it as it found it and does not try again +//! until the agents have finished. + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::PathBuf; +use std::process::{ChildStdin, Command, Stdio}; +use std::time::{Duration, Instant}; + +/// The argument that makes this binary the child instead of the app. +pub const BLINK_ARG: &str = "--logo-blink"; + +/// The logo is dark in every lighting condition (Apple's documented value). +const MODE_OFF: i64 = 0; +/// The logo follows the display and the ambient light. What the setting reads +/// on a Mac where nobody touched it. +const MODE_AUTO: i64 = 2; + +/// One dark moment and the light after it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Beat { + dark: Duration, + lit: Duration, +} + +const fn beat(dark_ms: u64, lit_ms: u64) -> Beat { + Beat { + dark: Duration::from_millis(dark_ms), + lit: Duration::from_millis(lit_ms), + } +} + +/// What the logo does while agents work, the user's pick in the View menu. A +/// style switches `LEBacklightMode`, which is on or off, so it is a rhythm of +/// hard changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlinkStyle { + /// The default: it changes a system-wide setting, so it is asked for. + #[default] + Off, + Calm, + Slow, + Wink, + Heartbeat, + Fast, +} + +impl BlinkStyle { + pub const ALL: [BlinkStyle; 6] = [ + BlinkStyle::Off, + BlinkStyle::Calm, + BlinkStyle::Slow, + BlinkStyle::Wink, + BlinkStyle::Heartbeat, + BlinkStyle::Fast, + ]; + + pub fn label(self) -> &'static str { + match self { + BlinkStyle::Off => "Off", + BlinkStyle::Calm => "Calm", + BlinkStyle::Slow => "Slow", + BlinkStyle::Wink => "Wink", + BlinkStyle::Heartbeat => "Heartbeat", + BlinkStyle::Fast => "Fast", + } + } + + pub fn menu_id(self) -> &'static str { + match self { + BlinkStyle::Off => "logo-blink-off", + BlinkStyle::Calm => "logo-blink-calm", + BlinkStyle::Slow => "logo-blink-slow", + BlinkStyle::Wink => "logo-blink-wink", + BlinkStyle::Heartbeat => "logo-blink-heartbeat", + BlinkStyle::Fast => "logo-blink-fast", + } + } + + pub fn from_menu_id(id: &str) -> Option { + Self::ALL.into_iter().find(|style| style.menu_id() == id) + } + + /// The rhythm, repeated for as long as agents work. + const fn pattern(self) -> &'static [Beat] { + match self { + BlinkStyle::Off => &[], + BlinkStyle::Calm => CALM, + BlinkStyle::Slow => SLOW, + BlinkStyle::Wink => WINK, + BlinkStyle::Heartbeat => HEARTBEAT, + BlinkStyle::Fast => FAST, + } + } +} + +// Every change of the logo is a preference write, so these also set how often +// that happens: from about one every two seconds (Slow, Wink) to about three a +// second (Fast). No style goes above three flashes in any second, the limit +// accessibility guidelines set for flashing light. +const CALM: &[Beat] = &[beat(800, 1200)]; +const SLOW: &[Beat] = &[beat(1500, 3000)]; +/// Mostly lit: a short dark moment every few seconds. +const WINK: &[Beat] = &[beat(250, 3500)]; +/// Two quick beats, then a rest. +const HEARTBEAT: &[Beat] = &[beat(120, 180), beat(120, 1600)]; +const FAST: &[Beat] = &[beat(300, 300)]; + +/// How often to look again while the logo cannot be seen (the display sleeps, +/// the lid is closed). Nothing is written in that time. +const NOT_LIT_RECHECK: Duration = Duration::from_secs(2); +/// How long a change has to hold before the logo follows it, so a server +/// restart or an agent between two turns does not flicker it. +const SETTLE: Duration = Duration::from_secs(2); +/// How often the child looks at what the poller found, and at the pipe. +const LOOK_EVERY: Duration = Duration::from_millis(250); + +/// What a blink needs from the machine, so its decisions can be tested. +pub trait Logo { + /// The current mode, None if it could not be read. + fn mode(&self) -> Option; + /// Set the mode; false if the daemon did not take it. + fn set_mode(&self, mode: i64) -> bool; + /// What the logo is driven at in the current mode, in nits: 0 while it is + /// off for its own reasons (the display sleeps, the lid is closed). + fn nits(&self) -> Option; +} + +/// The file that says a blink may have left the logo dark. +pub struct Marker(Option); + +impl Marker { + fn default_location() -> Self { + Marker(dirs::config_dir().map(|d| d.join("Codeman").join("logo-blink"))) + } + + fn set(&self) -> bool { + let Some(path) = &self.0 else { return false }; + path.parent().is_some_and(|dir| std::fs::create_dir_all(dir).is_ok()) && std::fs::write(path, b"").is_ok() + } + + fn clear(&self) { + if let Some(path) = &self.0 { + let _ = std::fs::remove_file(path); + } + } + + fn is_set(&self) -> bool { + self.0.as_ref().is_some_and(|path| path.exists()) + } +} + +#[derive(Debug, PartialEq, Eq)] +enum Ended { + /// `wait` said stop, and the logo is lit again. + Stopped, + /// The logo is not ours to blink, or stopped doing what it is told. + Declined, +} + +/// Blink until `wait` reports that it is time to stop, then leave the logo +/// lit. `wait` sleeps for the given time and answers true if the blink should +/// end. A marker that cannot be written refuses the blink: without it a power +/// cut in a dark phase would have no way back. +fn blink(logo: &impl Logo, marker: &Marker, pattern: &[Beat], mut wait: impl FnMut(Duration) -> bool) -> Ended { + if logo.mode() != Some(MODE_AUTO) || !marker.set() { + return Ended::Declined; + } + let mut dark = false; + let mut ended = Ended::Stopped; + for beat in pattern.iter().cycle() { + if logo.mode() != Some(MODE_AUTO) { + marker.clear(); + return Ended::Declined; + } + if logo.nits() == Some(0.0) { + if wait(NOT_LIT_RECHECK) { + break; + } + continue; + } + if !logo.set_mode(MODE_OFF) { + ended = Ended::Declined; + break; + } + dark = true; + if wait(beat.dark) { + break; + } + if logo.mode() != Some(MODE_OFF) { + marker.clear(); + return Ended::Declined; + } + if !logo.set_mode(MODE_AUTO) { + ended = Ended::Declined; + break; + } + dark = false; + if wait(beat.lit) { + break; + } + } + // A logo already lit needs no write, and a failed restore keeps the marker + // so the next launch tries again. + if !dark || logo.set_mode(MODE_AUTO) { + marker.clear(); + } + ended +} + +/// Put back a logo that a blink could not finish with. +pub fn recover(logo: &impl Logo, marker: &Marker) { + if !marker.is_set() { + return; + } + if logo.mode() != Some(MODE_OFF) || logo.set_mode(MODE_AUTO) { + marker.clear(); + } +} + +/// A yes or no that only changes once the new answer has held for `SETTLE`. +struct Settled { + answer: bool, + latest: bool, + changed_at: Instant, +} + +impl Settled { + fn new(now: Instant) -> Self { + Settled { + answer: false, + latest: false, + changed_at: now, + } + } + + fn update(&mut self, latest: bool, now: Instant) -> bool { + if latest != self.latest { + self.latest = latest; + self.changed_at = now; + } + if now.duration_since(self.changed_at) >= SETTLE { + self.answer = latest; + } + self.answer + } +} + +/// What the child goes by. +trait Signals { + /// Sleep this long; true if the app went away, right when it did. + fn wait(&mut self, time: Duration) -> bool; + fn agents_working(&self) -> bool; + fn now(&self) -> Instant; +} + +/// The child's life: blink while agents work, until the app goes away. +fn watch(logo: &impl Logo, marker: &Marker, pattern: &[Beat], signals: &mut impl Signals) { + let mut settled = Settled::new(signals.now()); + let mut declined = false; + loop { + if signals.wait(LOOK_EVERY) { + return; + } + if !settled.update(signals.agents_working(), signals.now()) { + declined = false; + continue; + } + if declined { + continue; + } + let mut app_gone = false; + let ended = blink(logo, marker, pattern, |time| { + let deadline = signals.now() + time; + loop { + let left = deadline.saturating_duration_since(signals.now()); + if left.is_zero() { + return false; + } + if signals.wait(left.min(LOOK_EVERY)) { + app_gone = true; + return true; + } + if !settled.update(signals.agents_working(), signals.now()) { + return true; + } + } + }); + if app_gone { + return; + } + declined = ended == Ended::Declined; + } +} + +/// The one line the app writes to the child. +#[derive(Serialize, Deserialize)] +struct Connection { + server: String, + authorization: Option, + style: BlinkStyle, +} + +/// The app's side: the pipe to the child, which is all there is to it. +#[derive(Default)] +pub struct Watcher { + current: Option<(Connection, ChildStdin)>, +} + +impl Watcher { + /// Watch this server with this login in this style, or nothing. A child + /// for anything else ends first: its pipe closes with the old value. It is + /// back to lit well before the next child looks, which takes `SETTLE`. + pub fn sync(&mut self, want: Option<(&str, Option<&str>, BlinkStyle)>) { + let same = match (&self.current, want) { + (Some((now, _)), Some((server, authorization, style))) => { + now.server == server && now.authorization.as_deref() == authorization && now.style == style + } + (None, None) => true, + _ => false, + }; + if same { + return; + } + self.current = want.and_then(|(server, authorization, style)| { + spawn_child(Connection { + server: server.to_owned(), + authorization: authorization.map(str::to_owned), + style, + }) + }); + } +} + +fn spawn_child(connection: Connection) -> Option<(Connection, ChildStdin)> { + let exe = std::env::current_exe().ok()?; + let mut child = Command::new(exe) + .arg(BLINK_ARG) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .ok()?; + let mut stdin = child.stdin.take()?; + let line = serde_json::to_string(&connection).ok()? + "\n"; + stdin.write_all(line.as_bytes()).ok()?; + // It ends by itself once the pipe closes. This only collects it. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Some((connection, stdin)) +} + +#[cfg(target_os = "macos")] +pub use system::{available, blink_main, recover_at_launch, switched_off}; + +#[cfg(not(target_os = "macos"))] +pub fn available() -> bool { + false +} + +#[cfg(not(target_os = "macos"))] +pub fn blink_main() -> ! { + std::process::exit(0) +} + +#[cfg(not(target_os = "macos"))] +pub fn recover_at_launch() {} + +#[cfg(not(target_os = "macos"))] +pub fn switched_off() -> bool { + false +} + +#[cfg(target_os = "macos")] +mod system { + use super::{recover, watch, Connection, Logo, Marker, Signals, MODE_OFF}; + use crate::agents::{Poll, Poller, POLL_EVERY}; + use objc2::rc::{autoreleasepool, Retained}; + use objc2::runtime::{AnyClass, AnyObject}; + use objc2::{msg_send, sel}; + use objc2_foundation::{ns_string, NSBundle, NSDictionary, NSNumber, NSString}; + use std::io::Read; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; + use std::sync::{Arc, OnceLock}; + use std::time::{Duration, Instant}; + use url::Url; + + struct SystemLogo { + client: Retained, + } + + impl SystemLogo { + /// The framework is private, so it is looked up at run time and never + /// linked: on a macOS without it this is None, not a failed launch. + fn open() -> Option { + autoreleasepool(|_| { + let path = ns_string!("/System/Library/PrivateFrameworks/CoreBrightness.framework"); + let bundle = NSBundle::bundleWithPath(path)?; + // SAFETY: an Apple system framework, which every process that + // shows a window has mapped already; it runs no code of ours. + if !unsafe { bundle.load() } { + return None; + } + let class = AnyClass::get(c"BrightnessSystemClient")?; + let answers = class.instance_method(sel!(copyPropertyForKey:)).is_some() + && class.instance_method(sel!(setProperty:forKey:)).is_some(); + if !answers { + return None; + } + // SAFETY: `new` is NSObject's, and the class was checked above. + let client: Option> = unsafe { msg_send![class, new] }; + client.map(|client| SystemLogo { client }) + }) + } + + fn property(&self, key: &NSString) -> Option> { + // SAFETY: the method was checked in `open`. It takes a string and + // answers an owned object or nil. + unsafe { msg_send![&*self.client, copyPropertyForKey: key] } + } + } + + impl Logo for SystemLogo { + fn mode(&self) -> Option { + autoreleasepool(|_| { + let value = self.property(ns_string!("LEBacklightMode"))?; + Some(value.downcast_ref::()?.as_i64()) + }) + } + + fn set_mode(&self, mode: i64) -> bool { + autoreleasepool(|_| { + let value = NSNumber::new_i64(mode); + // SAFETY: the method was checked in `open`. It takes an object + // and a string and answers a BOOL. + unsafe { msg_send![&*self.client, setProperty: &*value, forKey: ns_string!("LEBacklightMode")] } + }) + } + + fn nits(&self) -> Option { + autoreleasepool(|_| { + let state = self.property(ns_string!("LEPWMState"))?; + let state = state.downcast_ref::()?; + // SAFETY: an NSDictionary answers objectForKey:, with nil for a + // key it does not have. + let value: Option> = + unsafe { msg_send![state, objectForKey: ns_string!("FinalNits")] }; + Some(value?.downcast_ref::()?.as_f64()) + }) + } + } + + /// Whether this Mac has a logo the app can drive. Asked once: the answer + /// is the hardware's and takes a few milliseconds. + pub fn available() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| SystemLogo::open().is_some_and(|logo| logo.mode().is_some())) + } + + /// The user (or anything else that is not a blink) has the logo off. + pub fn switched_off() -> bool { + SystemLogo::open().is_some_and(|logo| logo.mode() == Some(MODE_OFF)) + } + + pub fn recover_at_launch() { + let marker = Marker::default_location(); + if marker.is_set() { + if let Some(logo) = SystemLogo::open() { + recover(&logo, &marker); + } + } + } + + struct Live { + pipe_closed: Receiver<()>, + working: Arc, + } + + impl Signals for Live { + fn wait(&mut self, time: Duration) -> bool { + !matches!(self.pipe_closed.recv_timeout(time), Err(RecvTimeoutError::Timeout)) + } + + fn agents_working(&self) -> bool { + self.working.load(Ordering::Relaxed) + } + + fn now(&self) -> Instant { + Instant::now() + } + } + + /// Runs for the life of the child. A server that does not answer counts as + /// nothing working, and one that refuses the login is never asked again. + fn poll_until_refused(mut poller: Poller, working: &AtomicBool) { + loop { + let poll = poller.poll(); + working.store(matches!(poll, Poll::Working(n) if n > 0), Ordering::Relaxed); + if poll == Poll::Refused { + return; + } + std::thread::sleep(POLL_EVERY); + } + } + + /// The child process. It ends when the pipe from the app closes, and the + /// signals that would end it sooner are ignored, because they arrive + /// together with the app's own end (a logout, Ctrl-C on a terminal that + /// started the app) and could land in a dark phase. + pub fn blink_main() -> ! { + // SAFETY: `signal` with SIG_IGN, before any other thread exists. + unsafe { + for signal in [libc::SIGHUP, libc::SIGINT, libc::SIGTERM] { + libc::signal(signal, libc::SIG_IGN); + } + } + let mut line = String::new(); + let connection = std::io::stdin() + .read_line(&mut line) + .ok() + .and_then(|_| serde_json::from_str::(&line).ok()); + let Some(Connection { + server, + authorization, + style, + }) = connection + else { + std::process::exit(0); + }; + let poller = Url::parse(&server) + .ok() + .and_then(|url| Poller::new(&url, authorization)); + let (Some(poller), Some(logo)) = (poller, SystemLogo::open()) else { + std::process::exit(0); + }; + + let (closed, pipe_closed) = mpsc::channel::<()>(); + std::thread::spawn(move || { + let mut stdin = std::io::stdin().lock(); + let mut buf = [0u8; 64]; + while matches!(stdin.read(&mut buf), Ok(n) if n > 0) {} + let _ = closed.send(()); + }); + let working = Arc::new(AtomicBool::new(false)); + let found = working.clone(); + std::thread::spawn(move || poll_until_refused(poller, &found)); + + let mut signals = Live { pipe_closed, working }; + watch(&logo, &Marker::default_location(), style.pattern(), &mut signals); + std::process::exit(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + const DARK_FOR: Duration = CALM[0].dark; + const LIT_FOR: Duration = CALM[0].lit; + + /// A logo that records every write. + struct FakeLogo { + mode: Cell, + nits: Cell, + writes: RefCell>, + refuse_writes: Cell, + } + + impl FakeLogo { + fn new(mode: i64) -> Self { + FakeLogo { + mode: Cell::new(mode), + nits: Cell::new(200.0), + writes: RefCell::new(Vec::new()), + refuse_writes: Cell::new(false), + } + } + + fn writes(&self) -> Vec { + self.writes.borrow().clone() + } + } + + impl Logo for FakeLogo { + fn mode(&self) -> Option { + Some(self.mode.get()) + } + + fn set_mode(&self, mode: i64) -> bool { + if self.refuse_writes.get() { + return false; + } + self.mode.set(mode); + self.writes.borrow_mut().push(mode); + true + } + + fn nits(&self) -> Option { + Some(self.nits.get()) + } + } + + fn temp_marker(name: &str) -> (Marker, PathBuf) { + let dir = std::env::temp_dir().join(format!("codeman-logo-{}-{name}", std::process::id())); + let path = dir.join("nested").join("logo-blink"); + let _ = std::fs::remove_dir_all(&dir); + (Marker(Some(path.clone())), path) + } + + /// A `wait` that stops the blink on its Nth call, and records the times. + fn stop_on(call: usize, times: &RefCell>) -> impl FnMut(Duration) -> bool + '_ { + move |time| { + times.borrow_mut().push(time); + times.borrow().len() >= call + } + } + + #[test] + fn a_blink_alternates_and_ends_lit() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("alternates"); + let times = RefCell::new(Vec::new()); + blink(&logo, &marker, CALM, stop_on(4, ×)); + // dark, lit, dark, and the stop in the fourth wait is a dark phase's. + assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO, MODE_OFF, MODE_AUTO]); + assert_eq!(*times.borrow(), [DARK_FOR, LIT_FOR, DARK_FOR, LIT_FOR]); + assert_eq!(logo.mode.get(), MODE_AUTO); + assert!(!path.exists(), "a finished blink leaves no marker"); + } + + #[test] + fn a_stop_in_a_dark_phase_restores_the_logo_at_once() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("dark-stop"); + let times = RefCell::new(Vec::new()); + blink(&logo, &marker, CALM, stop_on(1, ×)); + assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + assert!(!path.exists()); + } + + #[test] + fn a_stop_while_lit_writes_nothing_more() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("lit-stop"); + let times = RefCell::new(Vec::new()); + blink(&logo, &marker, CALM, stop_on(2, ×)); + assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + } + + #[test] + fn a_logo_the_user_switched_off_is_left_alone() { + for mode in [MODE_OFF, 1, 7] { + let logo = FakeLogo::new(mode); + let (marker, path) = temp_marker("user-off"); + let times = RefCell::new(Vec::new()); + assert_eq!(blink(&logo, &marker, CALM, stop_on(1, ×)), Ended::Declined); + assert_eq!(logo.writes(), [] as [i64; 0]); + assert!(times.borrow().is_empty(), "it does not even wait"); + assert!(!path.exists()); + } + } + + #[test] + fn a_marker_that_cannot_be_written_refuses_the_blink() { + let logo = FakeLogo::new(MODE_AUTO); + let times = RefCell::new(Vec::new()); + assert_eq!(blink(&logo, &Marker(None), CALM, stop_on(1, ×)), Ended::Declined); + assert_eq!(logo.writes(), [] as [i64; 0]); + } + + #[test] + fn a_mode_changed_from_outside_ends_the_blink_without_a_write() { + // Someone switches the logo off during a lit phase: their setting stands. + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("outside-lit"); + let mut calls = 0; + let ended = blink(&logo, &marker, CALM, |_| { + calls += 1; + if calls == 2 { + logo.mode.set(MODE_OFF); + } + false + }); + assert_eq!(ended, Ended::Declined); + assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + assert_eq!(logo.mode.get(), MODE_OFF); + assert!(!path.exists()); + + // Someone deletes the setting during a dark phase: it stays as they left it. + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("outside-dark"); + let ended = blink(&logo, &marker, CALM, |_| { + logo.mode.set(MODE_AUTO); + false + }); + assert_eq!(ended, Ended::Declined); + assert_eq!(logo.writes(), [MODE_OFF]); + assert!(!path.exists()); + } + + #[test] + fn nothing_is_written_while_the_logo_cannot_be_seen() { + let logo = FakeLogo::new(MODE_AUTO); + logo.nits.set(0.0); + let (marker, path) = temp_marker("not-lit"); + let times = RefCell::new(Vec::new()); + blink(&logo, &marker, CALM, stop_on(3, ×)); + assert_eq!(logo.writes(), [] as [i64; 0]); + assert_eq!(*times.borrow(), [NOT_LIT_RECHECK; 3]); + assert!(!path.exists()); + } + + #[test] + fn a_blink_resumes_when_the_logo_comes_back() { + let logo = FakeLogo::new(MODE_AUTO); + logo.nits.set(0.0); + let (marker, _) = temp_marker("wakes"); + let mut calls = 0; + blink(&logo, &marker, CALM, |_| { + calls += 1; + logo.nits.set(200.0); + calls == 2 + }); + assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + } + + #[test] + fn a_failed_restore_keeps_the_marker_for_the_next_launch() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("failed-restore"); + blink(&logo, &marker, CALM, |_| { + logo.refuse_writes.set(true); + true + }); + assert_eq!(logo.mode.get(), MODE_OFF); + assert!(path.exists()); + logo.refuse_writes.set(false); + recover(&logo, &marker); + assert_eq!(logo.mode.get(), MODE_AUTO); + assert!(!path.exists()); + } + + #[test] + fn recover_needs_the_marker() { + let logo = FakeLogo::new(MODE_OFF); + let (marker, _) = temp_marker("no-marker"); + recover(&logo, &marker); + assert_eq!( + logo.writes(), + [] as [i64; 0], + "without a marker a dark logo is the user's" + ); + } + + #[test] + fn recover_leaves_a_lit_logo_and_clears_the_marker() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("lit-recover"); + assert!(marker.set()); + recover(&logo, &marker); + assert_eq!(logo.writes(), [] as [i64; 0]); + assert!(!path.exists()); + } + + #[test] + fn a_change_counts_once_it_held() { + let t0 = Instant::now(); + let mut settled = Settled::new(t0); + assert!(!settled.update(true, t0)); + assert!(!settled.update(true, t0 + SETTLE - Duration::from_millis(1))); + assert!(settled.update(true, t0 + SETTLE)); + // Back to false and on again within the time changes nothing. + let t1 = t0 + Duration::from_secs(10); + assert!(settled.update(false, t1)); + assert!(settled.update(true, t1 + Duration::from_secs(1))); + assert!(settled.update(true, t1 + Duration::from_secs(9))); + assert!(settled.update(false, t1 + Duration::from_secs(10))); + assert!(!settled.update(false, t1 + Duration::from_secs(10) + SETTLE)); + } + + /// A clock that `wait` moves, and a server that has agents at work in the + /// given spans of time. The app goes away at `app_gone_at`. + struct Script { + start: Instant, + clock: Cell, + work: Vec<(u64, u64)>, + app_gone_at: u64, + } + + impl Script { + fn new(work: &[(u64, u64)], app_gone_at: u64) -> Self { + let start = Instant::now(); + Script { + start, + clock: Cell::new(start), + work: work.to_vec(), + app_gone_at, + } + } + + fn elapsed_ms(&self) -> u64 { + self.clock.get().duration_since(self.start).as_millis() as u64 + } + } + + impl Signals for Script { + fn wait(&mut self, time: Duration) -> bool { + self.clock.set(self.clock.get() + time); + self.elapsed_ms() >= self.app_gone_at + } + + fn agents_working(&self) -> bool { + let t = self.elapsed_ms(); + self.work.iter().any(|&(from, to)| t >= from && t < to) + } + + fn now(&self) -> Instant { + self.clock.get() + } + } + + const CYCLE_MS: u64 = (LIT_FOR.as_millis() + DARK_FOR.as_millis()) as u64; + + #[test] + fn nothing_is_written_while_no_agent_works() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("watch-quiet"); + watch(&logo, &marker, CALM, &mut Script::new(&[], 60_000)); + assert_eq!(logo.writes(), [] as [i64; 0]); + } + + #[test] + fn a_short_flicker_of_work_does_not_blink() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("watch-flicker"); + watch(&logo, &marker, CALM, &mut Script::new(&[(1_000, 2_500)], 60_000)); + assert_eq!(logo.writes(), [] as [i64; 0]); + } + + #[test] + fn it_blinks_for_the_length_of_the_work_and_ends_lit() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("watch-turn"); + let mut script = Script::new(&[(1_000, 21_000)], 60_000); + watch(&logo, &marker, CALM, &mut script); + let writes = logo.writes(); + let dark_phases = writes.iter().filter(|&&m| m == MODE_OFF).count() as u64; + // 20 s of work, less the settling at the start, one dark phase a cycle. + let cycles = (20_000 - SETTLE.as_millis() as u64) / CYCLE_MS; + assert!(dark_phases >= cycles, "{dark_phases} dark phases, {cycles} cycles"); + assert!(dark_phases <= cycles + 2, "{dark_phases} dark phases, {cycles} cycles"); + assert_eq!(logo.mode.get(), MODE_AUTO); + assert!(!path.exists()); + } + + #[test] + fn the_end_of_the_work_stops_a_blink_in_the_middle_of_a_dark_phase() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("watch-stop"); + // Work has to hold to start and to stop, so the first dark phase is + // over by then. Started at about 3.25 s, the second one runs from + // about 5.25 s, and work that ends at 3.5 s settles at 5.5 s, inside it. + let mut script = Script::new(&[(1_000, 3_500)], 60_000); + watch(&logo, &marker, CALM, &mut script); + assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO, MODE_OFF, MODE_AUTO]); + } + + #[test] + fn a_gap_between_two_turns_does_not_stop_the_blink() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("watch-gap"); + let mut script = Script::new(&[(1_000, 15_000), (16_000, 30_000)], 60_000); + watch(&logo, &marker, CALM, &mut script); + let writes = logo.writes(); + let dark_phases = writes.iter().filter(|&&m| m == MODE_OFF).count() as u64; + assert!( + dark_phases >= (29_000 - 2 * SETTLE.as_millis() as u64) / CYCLE_MS, + "{dark_phases}" + ); + } + + #[test] + fn the_app_going_away_in_a_dark_phase_puts_the_logo_back() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("watch-gone"); + // Work from the start; the first dark phase begins once it has settled. + watch(&logo, &marker, CALM, &mut Script::new(&[(0, 60_000)], 3_000)); + assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + assert!(!path.exists()); + } + + #[test] + fn a_logo_the_user_switched_off_is_not_asked_again_during_the_work() { + let logo = FakeLogo::new(MODE_OFF); + let (marker, _) = temp_marker("watch-declined"); + let mut script = Script::new(&[(0, 30_000)], 60_000); + watch(&logo, &marker, CALM, &mut script); + assert_eq!(logo.writes(), [] as [i64; 0]); + } + + #[test] + fn after_the_agents_finish_it_may_ask_again() { + // Declined in the first turn, the logo is back on for the second. + let logo = FakeLogo::new(MODE_OFF); + let (marker, _) = temp_marker("watch-again"); + let mut script = Script::new(&[(0, 5_000), (20_000, 30_000)], 60_000); + struct TurnsOn<'a>(&'a mut Script, &'a FakeLogo); + impl Signals for TurnsOn<'_> { + fn wait(&mut self, time: Duration) -> bool { + let gone = self.0.wait(time); + if self.0.elapsed_ms() >= 10_000 && self.0.elapsed_ms() < 10_300 { + self.1.mode.set(MODE_AUTO); + } + gone + } + fn agents_working(&self) -> bool { + self.0.agents_working() + } + fn now(&self) -> Instant { + self.0.now() + } + } + watch(&logo, &marker, CALM, &mut TurnsOn(&mut script, &logo)); + assert!(logo.writes().contains(&MODE_OFF), "{:?}", logo.writes()); + assert_eq!(logo.mode.get(), MODE_AUTO); + } + + #[test] + fn the_connection_line_round_trips_with_and_without_a_login() { + for authorization in [None, Some("Basic abc".to_string())] { + let line = serde_json::to_string(&Connection { + server: "http://127.0.0.1:3000/".into(), + authorization: authorization.clone(), + style: BlinkStyle::Heartbeat, + }) + .unwrap(); + assert!(!line.contains('\n'), "one line"); + let back: Connection = serde_json::from_str(&line).unwrap(); + assert_eq!(back.server, "http://127.0.0.1:3000/"); + assert_eq!(back.authorization, authorization); + assert_eq!(back.style, BlinkStyle::Heartbeat); + } + } + + #[test] + fn a_style_of_several_beats_runs_them_in_turn() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("beats"); + let pattern = BlinkStyle::Heartbeat.pattern(); + let times = RefCell::new(Vec::new()); + blink(&logo, &marker, pattern, stop_on(6, ×)); + let [one, two] = pattern else { panic!("two beats") }; + assert_eq!( + *times.borrow(), + [one.dark, one.lit, two.dark, two.lit, one.dark, one.lit] + ); + } + + #[test] + fn every_style_but_off_has_a_rhythm_with_light_and_dark() { + for style in BlinkStyle::ALL { + if style == BlinkStyle::Off { + assert!(style.pattern().is_empty()); + continue; + } + assert!(!style.pattern().is_empty(), "{style:?}"); + for beat in style.pattern() { + assert!(!beat.dark.is_zero() && !beat.lit.is_zero(), "{style:?}"); + } + } + } + + #[test] + fn no_style_flashes_more_than_three_times_in_any_second() { + for style in BlinkStyle::ALL { + let mut starts = Vec::new(); + let mut at = Duration::ZERO; + for beat in style.pattern().iter().cycle().take(40) { + starts.push(at); + at += beat.dark + beat.lit; + } + for (i, from) in starts.iter().enumerate() { + let in_a_second = starts[i..].iter().filter(|&&t| t - *from < Duration::from_secs(1)); + assert!(in_a_second.count() <= 3, "{style:?}"); + } + } + } + + #[test] + fn a_menu_id_names_its_style_and_nothing_else() { + for style in BlinkStyle::ALL { + assert_eq!(BlinkStyle::from_menu_id(style.menu_id()), Some(style)); + } + assert_eq!(BlinkStyle::from_menu_id("reload"), None); + assert_eq!(BlinkStyle::from_menu_id(""), None); + } + + #[test] + fn a_watcher_with_nothing_to_watch_starts_nothing() { + let mut watcher = Watcher::default(); + watcher.sync(None); + assert!(watcher.current.is_none()); + } +} diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 8c70d836..79489dae 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -6,8 +6,9 @@ //! installs Codeman on this machine (`setup`, `local_server`) and installs it //! on another one over SSH, real full screen, native menus, pop-outs as app //! windows (Codeman's `window.CodemanHost` bridge), downloads to ~/Downloads, -//! passwords in the Keychain, and on macOS the JavaScript dialogs and HTTP -//! Basic challenges wry leaves out (`macos`). +//! passwords in the Keychain, on macOS the JavaScript dialogs and HTTP Basic +//! challenges wry leaves out (`macos`), and on a MacBook Pro with a lit Apple +//! logo a blink while agents work (`logo`). //! //! Every URL and message decision is in `policy` (pure, unit-tested); this file //! only wires those decisions to windows. The main window moves through @@ -19,10 +20,12 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod agents; mod assets; mod config; mod credentials; mod local_server; +mod logo; #[cfg(target_os = "macos")] mod macos; mod policy; @@ -51,6 +54,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView use config::{Config, Rect, WindowFrame}; use credentials::Keychain; +use logo::BlinkStyle; use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via}; use probe::ProbeOutcome; @@ -96,6 +100,7 @@ const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The s enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes."; const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again."; const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again."; +const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink."; const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address."; mod menu_id { @@ -234,6 +239,9 @@ struct AppMenu { window_menu: Submenu, help_menu: Submenu, start_fullscreen: CheckMenuItem, + /// One item per style. Empty on a Mac without a lit logo, which has no + /// such menu. + logo_styles: Vec<(BlinkStyle, CheckMenuItem)>, } enum Load<'a> { @@ -289,9 +297,16 @@ struct App { /// While the main window may still have to be put into the full screen it /// asked for at launch: when to look next, and how many retries were made. fullscreen_retry: Option<(Instant, u8)>, + /// The child that watches the connected server and blinks the logo. + logo_watch: logo::Watcher, } fn main() { + // The blinker is this binary without a window (logo.rs). + if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) { + logo::blink_main(); + } + let event_loop = EventLoopBuilder::::with_user_event().build(); let proxy = event_loop.create_proxy(); @@ -320,6 +335,7 @@ fn main() { }; let config_path = config_path.filter(|_| can_save); let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string()); + logo::recover_at_launch(); let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}"))); let phase = Rc::new(RefCell::new(Phase::Connect)); // Started once: the probe workers and the event loop share this queue, @@ -371,6 +387,7 @@ fn main() { menu, keychain, fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)), + logo_watch: logo::Watcher::default(), }; event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow)); @@ -459,6 +476,22 @@ fn build_menu(config: &Config) -> muda::Result { ), ], )?; + // Only a Mac with a lit Apple logo has this menu at all. + let mut logo_styles = Vec::new(); + if logo::available() { + let submenu = Submenu::new("Blink Apple Logo While Agents Work", true); + for style in BlinkStyle::ALL { + let chosen = style == config.logo_blink; + let item = CheckMenuItem::with_id(style.menu_id(), style.label(), true, chosen, None); + submenu.append(&item)?; + if style == BlinkStyle::Off { + submenu.append(&PredefinedMenuItem::separator())?; + } + logo_styles.push((style, item)); + } + // Right under "Always Open in Full Screen", before the separator. + view.insert(&submenu, 4)?; + } let window_menu = Submenu::with_items( "Window", true, @@ -485,6 +518,7 @@ fn build_menu(config: &Config) -> muda::Result { window_menu, help_menu, start_fullscreen, + logo_styles, }) } @@ -925,6 +959,7 @@ impl App { if let Some(next) = next { *self.phase.borrow_mut() = next; } + self.sync_logo_watch(); } if let Some(w) = self.windows.get_mut(&window) { w.committed = Url::parse(&url).ok(); @@ -1102,7 +1137,40 @@ impl App { } } + /// The logo is watched while a style is chosen and a server is open. + fn sync_logo_watch(&mut self) { + let style = self.config.logo_blink; + let phase = self.phase.borrow(); + let server = phase.loaded().filter(|_| style != BlinkStyle::Off && logo::available()); + self.logo_watch + .sync(server.map(|url| (url.as_str(), self.auth.as_deref(), style))); + } + + fn choose_logo_blink(&mut self, style: BlinkStyle) { + // A click flips the item it landed on, the chosen one included, so all + // of them are set. + for (item_style, item) in &self.menu.logo_styles { + item.set_checked(*item_style == style); + } + let was_off = self.config.logo_blink == BlinkStyle::Off; + self.config.logo_blink = style; + self.config_dirty = true; + self.sync_logo_watch(); + // Only from off: with a style running, a dark logo may be our own. + if was_off && style != BlinkStyle::Off && logo::switched_off() { + if let Some(main) = self.windows.get(&self.main_id) { + let _ = main + .webview + .evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning")); + } + } + } + fn on_menu(&mut self, id: &str) { + if let Some(style) = BlinkStyle::from_menu_id(id) { + self.choose_logo_blink(style); + return; + } match id { menu_id::SWITCH => self.show_connect_page(None, true), menu_id::RELOAD => self.reload_focused(), diff --git a/packages/desktop/src/probe.rs b/packages/desktop/src/probe.rs index 41276e35..c4d66715 100644 --- a/packages/desktop/src/probe.rs +++ b/packages/desktop/src/probe.rs @@ -10,7 +10,7 @@ use std::time::Duration; use url::Url; const TIMEOUT: Duration = Duration::from_secs(5); -const MAX_BODY: u64 = 8 * 1024 * 1024; +pub const MAX_BODY: u64 = 8 * 1024 * 1024; /// The path the probe asks for, relative to the server's base URL. const STATUS_PATH: &str = "api/status"; @@ -231,16 +231,13 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome { probe_within(server, authorization, TIMEOUT) } -/// `probe` with the time the whole exchange may take. -fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome { - let status_url = match server.join(STATUS_PATH) { - Ok(u) => u, - Err(e) => return ProbeOutcome::Failed { detail: e.to_string() }, - }; +/// The app's one way of asking a server something: the OS's certificate trust, +/// a bound on the whole exchange, and status codes as answers. +pub fn agent(timeout: Duration) -> ureq::Agent { let tls = ureq::tls::TlsConfig::builder() .root_certs(ureq::tls::RootCerts::PlatformVerifier) .build(); - let agent: ureq::Agent = ureq::Agent::config_builder() + ureq::Agent::config_builder() .timeout_global(Some(timeout)) .http_status_as_error(false) // Never follow: a 3xx comes back as an answer and is explained. @@ -248,8 +245,18 @@ fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> .tls_config(tls) .user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION"))) .build() - .into(); - let mut req = agent.get(status_url.as_str()).header("Accept", "application/json"); + .into() +} + +/// `probe` with the time the whole exchange may take. +fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome { + let status_url = match server.join(STATUS_PATH) { + Ok(u) => u, + Err(e) => return ProbeOutcome::Failed { detail: e.to_string() }, + }; + let mut req = agent(timeout) + .get(status_url.as_str()) + .header("Accept", "application/json"); if let Some(auth) = authorization { req = req.header("Authorization", auth); } From 7edf1a1c27255d8eaa61e1b5a5b5750bc4a8792f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 7 Nov 2026 16:24:39 +0100 Subject: [PATCH 2/2] feat(desktop): a blink style for each state of the agents The logo said one thing: agents are working. It now has a style of its own for each state the tabs show: at work, finished and waiting for you (yellow), and stopped on a permission or a question (red), set under View > Blink Apple Logo. The most urgent state with a style wins, red then yellow then work, and a state set to Off falls through to the next instead of silencing the logo. Red and yellow come from `GET /api/approvals`, the list the page rebuilds those tabs from after a reload, read natively for the reason the session list is: WebKit stalls a hidden page's stream. An idle item stops counting once it is acknowledged, so the logo stops when the tab's colour goes. The request is skipped while neither alert has a style, a 404 from an older server reads as no alerts, and a request that fails keeps the last answer. A dialog on screen costs the server one tmux capture per poll. Alarm is new: three sharp flashes and a short rest. It stays under the limit of three flashes in any second, so its urgency comes from its shape. A style saved by the earlier version stays as the one for work, and yellow and red start as Wink and Alarm, since whoever chose one asked for a logo that talks. `SETTLE` goes from 2 s to 3 s, the time the server takes to judge a new dialog, so a prompt answered before its hook landed never flashes red. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/README.md | 12 +- packages/desktop/src/agents.rs | 221 ++++++++++++-- packages/desktop/src/config.rs | 42 +-- packages/desktop/src/logo.rs | 507 +++++++++++++++++++++++++++------ packages/desktop/src/main.rs | 54 ++-- 5 files changed, 679 insertions(+), 157 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 5a69bf40..34043a5c 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -40,7 +40,7 @@ cannot do. | **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). | | **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. | | **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. | -| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid blinks while agents work and stops when they finish, so you can see from across the room that they are still going. *View > Blink Apple Logo While Agents Work* picks how: *Calm*, *Slow*, *Wink* (a short dark moment every few seconds), *Heartbeat* (two quick beats, then a rest) or *Fast*. It is off by default, and the menu does not exist on a Mac without that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for two seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. | +| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. | | **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. | ## Build (on a Mac) @@ -202,7 +202,7 @@ keeps its value while the logo is dark. The setting is system-wide and survives a reboot, so the app must not end with the logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the app can be relied on. So all of it runs as this same binary with -`--logo-blink`, for as long as a style is chosen and a server is open, tied to the app +`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo back and exits. Switching it off closes the same pipe, so the path a crash takes is the one used every day. A marker file @@ -212,6 +212,14 @@ loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mi answers nil) is what hides the option, so a macOS that drops it loses this feature and nothing else. +**Red and yellow come from the server's approvals list.** `GET /api/approvals` is what the +page reads to rebuild its red and yellow tabs after a reload, and it follows the same rules: +`permission` and `question` items are red, an `idle` item is yellow until someone has looked +at it (`acknowledgedAt`). So the logo stops when the tab's colour goes. The request is skipped +when neither alert has a style, and a server from before the list reads as no alerts. Each +poll with a dialog on screen costs that server one tmux capture, because the list re-checks +the dialog, which also clears red soon after a dialog is answered in the terminal. + **The agent count is asked for natively, because the page cannot be trusted with it.** The first version read the page's own event stream, which needs no second connection and no login. On a real Mac it failed in both directions as soon as the window was diff --git a/packages/desktop/src/agents.rs b/packages/desktop/src/agents.rs index b4712971..ca4e1c13 100644 --- a/packages/desktop/src/agents.rs +++ b/packages/desktop/src/agents.rs @@ -1,11 +1,16 @@ -//! How many agents are at work on a server, asked from outside the web view. +//! What the agents on a server are doing, asked from outside the web view: at +//! work, finished with nobody having looked yet (a yellow tab), or stopped on a +//! permission or a question (a red tab). //! //! The page knows this too, but it cannot be relied on for it: WebKit stalls the //! event stream of a hidden page. Measured on macOS 27.2 with the page hidden: a //! `session:idle` the server sent never reached it, its own watchdog reopened the //! stream 39 seconds later, and in another run a whole turn went unseen. A hidden //! window is the normal case for this question, so it is asked natively, of -//! `api/sessions`, a stable route that works with any Codeman. +//! `api/sessions`, a stable route that works with any Codeman, and of +//! `api/approvals`, the list the page itself builds its red and yellow tabs from +//! when it loads. A server from before that list has no such route, which reads +//! as no alerts. //! //! A login has a price here that a single request does not show. A request with //! a good password and no session cookie makes Codeman create a login session, @@ -27,12 +32,23 @@ use url::Url; pub const POLL_EVERY: Duration = Duration::from_secs(2); const TIMEOUT: Duration = Duration::from_secs(5); const SESSIONS_PATH: &str = "api/sessions"; +const APPROVALS_PATH: &str = "api/approvals"; const COOKIE_NAME: &str = "codeman_session"; +/// What the agents on a server are doing. Any number of these can be true. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Activity { + /// At least one agent is at work. + pub working: bool, + /// An agent finished and nobody has looked at it yet: a yellow tab. + pub waiting: bool, + /// An agent stopped on a permission or a question: a red tab. + pub needs_you: bool, +} + #[derive(Debug, PartialEq, Eq)] pub enum Poll { - /// This many agents are working. - Working(u32), + Seen(Activity), /// No answer this time. Worth asking again. Failed, /// The server turned the login down. Never ask again. @@ -63,6 +79,35 @@ fn count_working(body: &str) -> Option { u32::try_from(working.count()).ok() } +#[derive(Deserialize)] +struct Approval { + #[serde(default)] + kind: String, + #[serde(default, rename = "acknowledgedAt")] + acknowledged_at: Option, +} + +#[derive(Deserialize)] +struct Approvals { + approvals: Vec, +} + +#[derive(Deserialize)] +struct ApprovalsAnswer { + data: Approvals, +} + +/// The two alerts, by the page's own rules: `permission_prompt` and a question +/// make a tab red, an idle prompt makes it yellow until someone has looked at it, +/// and an acknowledged item stays listed but alerts nobody. +fn read_alerts(body: &str) -> Option<(bool, bool)> { + let answer: ApprovalsAnswer = serde_json::from_str(body).ok()?; + let items = &answer.data.approvals; + let waiting = items.iter().any(|i| i.kind == "idle" && i.acknowledged_at.is_none()); + let needs_you = items.iter().any(|i| i.kind == "permission" || i.kind == "question"); + Some((waiting, needs_you)) +} + /// `name=value` out of a `Set-Cookie` header, for Codeman's session cookie. fn session_cookie(header: &str) -> Option<&str> { let pair = header.split(';').next()?.trim(); @@ -76,7 +121,12 @@ fn answer(status: u16, body: impl FnOnce() -> Option) -> Poll { 200 => body() .as_deref() .and_then(count_working) - .map_or(Poll::Failed, Poll::Working), + .map_or(Poll::Failed, |working| { + Poll::Seen(Activity { + working: working > 0, + ..Activity::default() + }) + }), 401 | 403 | 429 => Poll::Refused, _ => Poll::Failed, } @@ -84,32 +134,41 @@ fn answer(status: u16, body: impl FnOnce() -> Option) -> Poll { pub struct Poller { agent: ureq::Agent, - url: String, + sessions_url: String, + /// None when no alert has a style: that request is then never made. + approvals_url: Option, authorization: Option, cookie: Option, + /// The last alerts seen, kept through a request that failed. + alerts: (bool, bool), } impl Poller { - pub fn new(server: &Url, authorization: Option) -> Option { + pub fn new(server: &Url, authorization: Option, alerts: bool) -> Option { + let approvals_url = match alerts { + true => Some(server.join(APPROVALS_PATH).ok()?.to_string()), + false => None, + }; Some(Poller { agent: crate::probe::agent(TIMEOUT), - url: server.join(SESSIONS_PATH).ok()?.to_string(), + sessions_url: server.join(SESSIONS_PATH).ok()?.to_string(), + approvals_url, authorization, cookie: None, + alerts: (false, false), }) } - pub fn poll(&mut self) -> Poll { - let mut request = self.agent.get(&self.url).header("Accept", "application/json"); + /// One GET with the login, and the status with the body of a 200. + fn ask(&mut self, url: &str) -> Option<(u16, Option)> { + let mut request = self.agent.get(url).header("Accept", "application/json"); if let Some(authorization) = &self.authorization { request = request.header("Authorization", authorization); } if let Some(cookie) = &self.cookie { request = request.header("Cookie", cookie); } - let Ok(mut response) = request.call() else { - return Poll::Failed; - }; + let mut response = request.call().ok()?; let fresh = response .headers() .get_all("set-cookie") @@ -121,10 +180,37 @@ impl Poller { self.cookie = fresh; } let status = response.status().as_u16(); - answer(status, || { + let body = (status == 200).then(|| { let body = response.body_mut().with_config(); body.limit(crate::probe::MAX_BODY).read_to_string().ok() - }) + }); + Some((status, body.flatten())) + } + + pub fn poll(&mut self) -> Poll { + let url = self.sessions_url.clone(); + let Some((status, body)) = self.ask(&url) else { + return Poll::Failed; + }; + let mut activity = match answer(status, || body) { + Poll::Seen(activity) => activity, + other => return other, + }; + if let Some(url) = self.approvals_url.clone() { + match self.ask(&url) { + Some((200, body)) => { + if let Some(alerts) = body.as_deref().and_then(read_alerts) { + self.alerts = alerts; + } + } + Some((401 | 403 | 429, _)) => return Poll::Refused, + // A server from before the list has no alerts to report. + Some((404, _)) => self.alerts = (false, false), + _ => {} + } + } + (activity.waiting, activity.needs_you) = self.alerts; + Poll::Seen(activity) } } @@ -230,6 +316,97 @@ mod tests { const EMPTY: &str = r#"{"success":true,"data":[]}"#; + fn plain(working: bool) -> Poll { + Poll::Seen(Activity { + working, + ..Activity::default() + }) + } + + /// The shape of a real `GET /api/approvals`, with the frame text cut. + fn approvals(items: &[&str]) -> String { + format!(r#"{{"success":true,"data":{{"approvals":[{}]}}}}"#, items.join(",")) + } + const IDLE: &str = r#"{"id":"s:1","sessionId":"s","sessionName":"w4","kind":"idle","createdAt":1,"context":"x"}"#; + const LOOKED_AT: &str = r#"{"id":"s:2","sessionId":"s","kind":"idle","createdAt":1,"acknowledgedAt":2}"#; + const PERMISSION: &str = r#"{"id":"s:3","sessionId":"s","kind":"permission","createdAt":1,"toolName":"Bash"}"#; + const QUESTION: &str = r#"{"id":"s:4","sessionId":"s","kind":"question","createdAt":1}"#; + + #[test] + fn the_alerts_follow_the_pages_rules() { + assert_eq!(read_alerts(&approvals(&[])), Some((false, false))); + assert_eq!(read_alerts(&approvals(&[IDLE])), Some((true, false))); + assert_eq!(read_alerts(&approvals(&[LOOKED_AT])), Some((false, false)), "seen"); + assert_eq!(read_alerts(&approvals(&[PERMISSION])), Some((false, true))); + assert_eq!(read_alerts(&approvals(&[QUESTION])), Some((false, true))); + assert_eq!(read_alerts(&approvals(&[IDLE, PERMISSION])), Some((true, true))); + assert_eq!(read_alerts("not json"), None); + assert_eq!(read_alerts(EMPTY), None); + } + + #[test] + fn reads_all_three_states_from_one_poll() { + let busy = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#; + let (port, seen_requests) = serve(vec![ok("", busy), ok("", &approvals(&[IDLE, PERMISSION]))]); + let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); + let mut poller = Poller::new(&server, None, true).unwrap(); + let everything = Activity { + working: true, + waiting: true, + needs_you: true, + }; + assert_eq!(poller.poll(), Poll::Seen(everything)); + assert!(seen_requests.lock().unwrap()[1][0].starts_with("get /api/approvals ")); + } + + fn status(code: u16) -> String { + format!("HTTP/1.1 {code} X\r\nConnection: close\r\nContent-Length: 0\r\n\r\n") + } + + #[test] + fn a_server_without_the_list_still_shows_work() { + let busy = r#"{"data":[{"status":"busy","mode":"claude"}]}"#; + let (port, _) = serve(vec![ok("", busy), status(404)]); + let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); + assert_eq!(Poller::new(&server, None, true).unwrap().poll(), plain(true)); + } + + #[test] + fn a_refused_list_ends_the_polling_and_a_failed_one_keeps_the_last_answer() { + let (port, _) = serve(vec![ok("", EMPTY), status(401)]); + let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); + assert_eq!(Poller::new(&server, None, true).unwrap().poll(), Poll::Refused); + + let (port, _) = serve(vec![ + ok("", EMPTY), + ok("", &approvals(&[PERMISSION])), + ok("", EMPTY), + status(500), + ]); + let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); + let mut poller = Poller::new(&server, None, true).unwrap(); + let red = Poll::Seen(Activity { + needs_you: true, + ..Activity::default() + }); + assert_eq!(poller.poll(), red); + assert_eq!( + poller.poll(), + Poll::Seen(Activity { + needs_you: true, + ..Activity::default() + }) + ); + } + + #[test] + fn no_alert_with_a_style_means_no_second_request() { + let (port, seen_requests) = serve(vec![ok("", EMPTY)]); + let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); + assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false)); + assert_eq!(seen_requests.lock().unwrap().len(), 1); + } + fn ok(extra_headers: &str, body: &str) -> String { format!( "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n{extra_headers}\r\n{body}", @@ -247,11 +424,11 @@ mod tests { ok("", EMPTY), ]); let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); - let mut poller = Poller::new(&server, Some("Basic abc".into())).unwrap(); - assert_eq!(poller.poll(), Poll::Working(1)); - assert_eq!(poller.poll(), Poll::Working(1)); - assert_eq!(poller.poll(), Poll::Working(0)); - assert_eq!(poller.poll(), Poll::Working(0)); + let mut poller = Poller::new(&server, Some("Basic abc".into()), false).unwrap(); + assert_eq!(poller.poll(), plain(true)); + assert_eq!(poller.poll(), plain(true)); + assert_eq!(poller.poll(), plain(false)); + assert_eq!(poller.poll(), plain(false)); let seen = seen.lock().unwrap(); let cookie = |n: usize| seen[n].iter().find(|h| h.starts_with("cookie:")).cloned(); @@ -268,7 +445,7 @@ mod tests { fn asks_under_the_servers_base_path() { let (port, seen) = serve(vec![ok("", EMPTY)]); let server = Url::parse(&format!("http://127.0.0.1:{port}/codeman/")).unwrap(); - assert_eq!(Poller::new(&server, None).unwrap().poll(), Poll::Working(0)); + assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false)); let seen = seen.lock().unwrap(); assert!(seen[0][0].starts_with("get /codeman/api/sessions "), "{:?}", seen[0][0]); assert!(!seen[0].iter().any(|h| h.starts_with("authorization:"))); @@ -280,6 +457,6 @@ mod tests { let port = listener.local_addr().unwrap().port(); drop(listener); let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(); - assert_eq!(Poller::new(&server, None).unwrap().poll(), Poll::Failed); + assert_eq!(Poller::new(&server, None, false).unwrap().poll(), Poll::Failed); } } diff --git a/packages/desktop/src/config.rs b/packages/desktop/src/config.rs index 4075ea68..1130a338 100644 --- a/packages/desktop/src/config.rs +++ b/packages/desktop/src/config.rs @@ -3,7 +3,7 @@ //! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords //! never go here; they live in the Keychain (see `credentials`). -use crate::logo::BlinkStyle; +use crate::logo::BlinkStyles; use serde::{Deserialize, Serialize}; use std::fs; use std::io; @@ -44,11 +44,11 @@ pub struct Config { pub auto_connect: bool, /// Always open in full screen (otherwise the last state is restored). pub start_fullscreen: bool, - /// What the lit Apple logo does while agents work (logo.rs). The menu only - /// shows on a Mac that has one. A value this version does not know reads as - /// off: a bad style must not make the whole file unreadable. + /// What the lit Apple logo does for each state of the agents (logo.rs). The + /// menu only shows on a Mac that has one. A value this version does not know + /// reads as off: a bad style must not make the whole file unreadable. #[serde(deserialize_with = "lenient_blink")] - pub logo_blink: BlinkStyle, + pub logo_blink: BlinkStyles, /// Anything that is not a whole frame reads as None: a bad frame must not /// make the whole file unreadable, which would cost the server list. #[serde(deserialize_with = "lenient_window")] @@ -60,9 +60,8 @@ fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result>(d: D) -> Result { - let value = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(value).unwrap_or_default()) +fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?)) } impl Default for Config { @@ -71,7 +70,7 @@ impl Default for Config { servers: Vec::new(), auto_connect: true, start_fullscreen: false, - logo_blink: BlinkStyle::Off, + logo_blink: BlinkStyles::default(), window: None, } } @@ -412,26 +411,31 @@ mod tests { } #[test] - fn the_logo_blink_style_round_trips_and_a_bad_one_reads_as_off() { + fn the_logo_blink_styles_round_trip_and_a_bad_one_reads_as_off() { + use crate::logo::BlinkStyle; let mut c = Config::default(); - assert_eq!(c.logo_blink, BlinkStyle::Off); - c.logo_blink = BlinkStyle::Heartbeat; + assert_eq!(c.logo_blink, BlinkStyles::default()); + c.logo_blink.needs_you = BlinkStyle::Alarm; let text = serde_json::to_string(&c).unwrap(); - assert!(text.contains(r#""logoBlink":"heartbeat""#), "{text}"); - assert_eq!( - serde_json::from_str::(&text).unwrap().logo_blink, - BlinkStyle::Heartbeat - ); + assert!(text.contains(r#""needsYou":"alarm""#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap().logo_blink, c.logo_blink); for bad in [ - r#"{"logoBlink":"strobe"}"#, r#"{"logoBlink":3}"#, r#"{"logoBlink":null}"#, + r#"{"logoBlink":"strobe"}"#, ] { let c: Config = serde_json::from_str(bad).unwrap(); - assert_eq!(c.logo_blink, BlinkStyle::Off, "{bad}"); + assert_eq!(c.logo_blink, BlinkStyles::default(), "{bad}"); } } + #[test] + fn the_style_a_mac_saved_before_there_were_states_is_kept() { + use crate::logo::BlinkStyle; + let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap(); + assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat); + } + #[test] fn round_trip_and_atomic_save() { let dir = tmp_dir("roundtrip"); diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index 48e3e9bb..634b2938 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -1,7 +1,9 @@ //! The lit Apple logo on the lid of a MacBook Pro with a PROLED XDR display -//! blinks while agents work, so you can see across the room that they are -//! still going. Opt-in (View menu, in the style the user picks), and the menu -//! exists only on a Mac that has such a logo. +//! blinks with what the agents are doing, so you can read it across the room: one +//! rhythm while they work, another for a yellow tab (an agent finished and waits +//! for you) and a sharper one for a red tab (it needs a permission or an answer). +//! Opt-in (View menu, a style per state), and the menu exists only on a Mac that +//! has such a logo. //! //! Apple documents one control for the logo (support.apple.com/en-us/149248): //! `sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. What @@ -17,7 +19,7 @@ //! The setting is system-wide and survives a reboot, so the one thing that //! must never happen is the app ending while the logo is dark. All of this //! therefore runs in a child process (this same binary with `BLINK_ARG`), for as -//! long as a style is chosen and a server is open. The app hands it the server +//! long as any style is chosen and a server is open. The app hands it the server //! and the login over a pipe (never on its command line, where `ps` shows them) //! and keeps the pipe open. When the pipe closes, however the app ended (quit, //! crash, `panic = "abort"`, Force Quit), the child puts the logo back and @@ -27,6 +29,9 @@ //! app's settings while a blink runs, and `recover` puts the logo back at the //! next launch. //! +//! When several states hold at once the most urgent one with a style is shown: +//! red, then yellow, then work. +//! //! The child also does the asking (`agents`), so it needs nothing from the app //! once started: a hidden window, a stalled page and App Nap cannot hold up the //! logo, and the app's event loop gets no timers for it. @@ -36,7 +41,9 @@ //! wants the setting: the child leaves it as it found it and does not try again //! until the agents have finished. +use crate::agents::Activity; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::io::Write; use std::path::PathBuf; use std::process::{ChildStdin, Command, Stdio}; @@ -65,7 +72,7 @@ const fn beat(dark_ms: u64, lit_ms: u64) -> Beat { } } -/// What the logo does while agents work, the user's pick in the View menu. A +/// A rhythm for the logo, the user's pick in the View menu for each `Signal`. A /// style switches `LEBacklightMode`, which is on or off, so it is a rhythm of /// hard changes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -79,16 +86,18 @@ pub enum BlinkStyle { Wink, Heartbeat, Fast, + Alarm, } impl BlinkStyle { - pub const ALL: [BlinkStyle; 6] = [ + pub const ALL: [BlinkStyle; 7] = [ BlinkStyle::Off, BlinkStyle::Calm, BlinkStyle::Slow, BlinkStyle::Wink, BlinkStyle::Heartbeat, BlinkStyle::Fast, + BlinkStyle::Alarm, ]; pub fn label(self) -> &'static str { @@ -99,25 +108,22 @@ impl BlinkStyle { BlinkStyle::Wink => "Wink", BlinkStyle::Heartbeat => "Heartbeat", BlinkStyle::Fast => "Fast", + BlinkStyle::Alarm => "Alarm", } } - pub fn menu_id(self) -> &'static str { - match self { - BlinkStyle::Off => "logo-blink-off", - BlinkStyle::Calm => "logo-blink-calm", - BlinkStyle::Slow => "logo-blink-slow", - BlinkStyle::Wink => "logo-blink-wink", - BlinkStyle::Heartbeat => "logo-blink-heartbeat", - BlinkStyle::Fast => "logo-blink-fast", - } + pub fn menu_id(self, signal: Signal) -> String { + format!("logo-blink-{}-{}", signal.key(), self.label().to_ascii_lowercase()) } - pub fn from_menu_id(id: &str) -> Option { - Self::ALL.into_iter().find(|style| style.menu_id() == id) + pub fn from_menu_id(id: &str) -> Option<(Signal, Self)> { + Signal::ALL + .into_iter() + .flat_map(|signal| Self::ALL.map(|style| (signal, style))) + .find(|(signal, style)| style.menu_id(*signal) == id) } - /// The rhythm, repeated for as long as agents work. + /// The rhythm, repeated for as long as its state holds. const fn pattern(self) -> &'static [Beat] { match self { BlinkStyle::Off => &[], @@ -126,14 +132,122 @@ impl BlinkStyle { BlinkStyle::Wink => WINK, BlinkStyle::Heartbeat => HEARTBEAT, BlinkStyle::Fast => FAST, + BlinkStyle::Alarm => ALARM, } } } +/// What the logo can tell, in the order of the View menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Signal { + Working, + Waiting, + NeedsYou, +} + +impl Signal { + pub const ALL: [Signal; 3] = [Signal::Working, Signal::Waiting, Signal::NeedsYou]; + + pub fn label(self) -> &'static str { + match self { + Signal::Working => "While Working", + Signal::Waiting => "Waiting for You (Yellow)", + Signal::NeedsYou => "Needs Permission (Red)", + } + } + + fn key(self) -> &'static str { + match self { + Signal::Working => "working", + Signal::Waiting => "waiting", + Signal::NeedsYou => "needs-you", + } + } +} + +/// The user's style for each `Signal`, `logoBlink` in the settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct BlinkStyles { + pub working: BlinkStyle, + pub waiting: BlinkStyle, + pub needs_you: BlinkStyle, +} + +impl BlinkStyles { + /// Reads whatever the file holds. A bad value is off, for its state alone: it + /// must not make the file unreadable. A single style is what this setting was + /// before it had states: that was the choice for work, and whoever made it + /// gets the alerts with it, since they asked for a logo that talks. + pub fn read(value: &Value) -> Self { + let style = |value: Option<&Value>| { + value + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or_default() + }; + match value { + Value::Object(styles) => BlinkStyles { + working: style(styles.get("working")), + waiting: style(styles.get("waiting")), + needs_you: style(styles.get("needsYou")), + }, + Value::String(_) => match style(Some(value)) { + BlinkStyle::Off => Self::default(), + working => BlinkStyles { + working, + waiting: BlinkStyle::Wink, + needs_you: BlinkStyle::Alarm, + }, + }, + _ => Self::default(), + } + } + + pub fn get(self, signal: Signal) -> BlinkStyle { + match signal { + Signal::Working => self.working, + Signal::Waiting => self.waiting, + Signal::NeedsYou => self.needs_you, + } + } + + pub fn set(&mut self, signal: Signal, style: BlinkStyle) { + match signal { + Signal::Working => self.working = style, + Signal::Waiting => self.waiting = style, + Signal::NeedsYou => self.needs_you = style, + } + } + + pub fn any(self) -> bool { + Signal::ALL.iter().any(|&signal| self.get(signal) != BlinkStyle::Off) + } + + /// Whether anything but work has a style, which is what makes the alerts + /// worth asking the server for. + fn wants_alerts(self) -> bool { + self.waiting != BlinkStyle::Off || self.needs_you != BlinkStyle::Off + } + + /// What the logo should do: the most urgent state that holds and has a + /// style. A state set to off is not a reason for silence, so it falls through. + fn pick(self, activity: Activity) -> BlinkStyle { + [ + (activity.needs_you, self.needs_you), + (activity.waiting, self.waiting), + (activity.working, self.working), + ] + .into_iter() + .find(|&(holds, style)| holds && style != BlinkStyle::Off) + .map_or(BlinkStyle::Off, |(_, style)| style) + } +} + // Every change of the logo is a preference write, so these also set how often -// that happens: from about one every two seconds (Slow, Wink) to about three a -// second (Fast). No style goes above three flashes in any second, the limit -// accessibility guidelines set for flashing light. +// that happens: from about one every two seconds (Slow, Wink) to about four a +// second (Alarm). No style goes above three flashes in any second, the limit +// accessibility guidelines set for flashing light, so what makes Alarm urgent +// is its shape: three hard flashes with a short rest, not a higher rate. const CALM: &[Beat] = &[beat(800, 1200)]; const SLOW: &[Beat] = &[beat(1500, 3000)]; /// Mostly lit: a short dark moment every few seconds. @@ -141,13 +255,17 @@ const WINK: &[Beat] = &[beat(250, 3500)]; /// Two quick beats, then a rest. const HEARTBEAT: &[Beat] = &[beat(120, 180), beat(120, 1600)]; const FAST: &[Beat] = &[beat(300, 300)]; +/// Three sharp flashes and a short rest, over and over: hey, I need something. +const ALARM: &[Beat] = &[beat(120, 120), beat(120, 120), beat(120, 900)]; /// How often to look again while the logo cannot be seen (the display sleeps, /// the lid is closed). Nothing is written in that time. const NOT_LIT_RECHECK: Duration = Duration::from_secs(2); /// How long a change has to hold before the logo follows it, so a server -/// restart or an agent between two turns does not flicker it. -const SETTLE: Duration = Duration::from_secs(2); +/// restart or an agent between two turns does not flicker it. Not below the 3 s +/// the server waits before it judges a new dialog: a prompt that was answered +/// before its hook landed lives that long and should never flash red. +const SETTLE: Duration = Duration::from_secs(3); /// How often the child looks at what the poller found, and at the pipe. const LOOK_EVERY: Duration = Duration::from_millis(250); @@ -254,23 +372,23 @@ pub fn recover(logo: &impl Logo, marker: &Marker) { } } -/// A yes or no that only changes once the new answer has held for `SETTLE`. -struct Settled { - answer: bool, - latest: bool, +/// An answer that only changes once the new one has held for `SETTLE`. +struct Settled { + answer: T, + latest: T, changed_at: Instant, } -impl Settled { - fn new(now: Instant) -> Self { +impl Settled { + fn new(answer: T, now: Instant) -> Self { Settled { - answer: false, - latest: false, + answer, + latest: answer, changed_at: now, } } - fn update(&mut self, latest: bool, now: Instant) -> bool { + fn update(&mut self, latest: T, now: Instant) -> T { if latest != self.latest { self.latest = latest; self.changed_at = now; @@ -286,19 +404,22 @@ impl Settled { trait Signals { /// Sleep this long; true if the app went away, right when it did. fn wait(&mut self, time: Duration) -> bool; - fn agents_working(&self) -> bool; + fn activity(&self) -> Activity; fn now(&self) -> Instant; } -/// The child's life: blink while agents work, until the app goes away. -fn watch(logo: &impl Logo, marker: &Marker, pattern: &[Beat], signals: &mut impl Signals) { - let mut settled = Settled::new(signals.now()); +/// The child's life: show what the agents are doing, until the app goes away. A +/// change of state ends the blink that is running, which leaves the logo lit, and +/// the next look starts the new one. +fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut impl Signals) { + let mut settled = Settled::new(BlinkStyle::Off, signals.now()); let mut declined = false; loop { if signals.wait(LOOK_EVERY) { return; } - if !settled.update(signals.agents_working(), signals.now()) { + let style = settled.update(styles.pick(signals.activity()), signals.now()); + if style == BlinkStyle::Off { declined = false; continue; } @@ -306,7 +427,7 @@ fn watch(logo: &impl Logo, marker: &Marker, pattern: &[Beat], signals: &mut impl continue; } let mut app_gone = false; - let ended = blink(logo, marker, pattern, |time| { + let ended = blink(logo, marker, style.pattern(), |time| { let deadline = signals.now() + time; loop { let left = deadline.saturating_duration_since(signals.now()); @@ -317,7 +438,7 @@ fn watch(logo: &impl Logo, marker: &Marker, pattern: &[Beat], signals: &mut impl app_gone = true; return true; } - if !settled.update(signals.agents_working(), signals.now()) { + if settled.update(styles.pick(signals.activity()), signals.now()) != style { return true; } } @@ -334,7 +455,7 @@ fn watch(logo: &impl Logo, marker: &Marker, pattern: &[Beat], signals: &mut impl struct Connection { server: String, authorization: Option, - style: BlinkStyle, + styles: BlinkStyles, } /// The app's side: the pipe to the child, which is all there is to it. @@ -344,13 +465,13 @@ pub struct Watcher { } impl Watcher { - /// Watch this server with this login in this style, or nothing. A child + /// Watch this server with this login in these styles, or nothing. A child /// for anything else ends first: its pipe closes with the old value. It is /// back to lit well before the next child looks, which takes `SETTLE`. - pub fn sync(&mut self, want: Option<(&str, Option<&str>, BlinkStyle)>) { + pub fn sync(&mut self, want: Option<(&str, Option<&str>, BlinkStyles)>) { let same = match (&self.current, want) { - (Some((now, _)), Some((server, authorization, style))) => { - now.server == server && now.authorization.as_deref() == authorization && now.style == style + (Some((now, _)), Some((server, authorization, styles))) => { + now.server == server && now.authorization.as_deref() == authorization && now.styles == styles } (None, None) => true, _ => false, @@ -358,11 +479,11 @@ impl Watcher { if same { return; } - self.current = want.and_then(|(server, authorization, style)| { + self.current = want.and_then(|(server, authorization, styles)| { spawn_child(Connection { server: server.to_owned(), authorization: authorization.map(str::to_owned), - style, + styles, }) }); } @@ -410,15 +531,14 @@ pub fn switched_off() -> bool { #[cfg(target_os = "macos")] mod system { use super::{recover, watch, Connection, Logo, Marker, Signals, MODE_OFF}; - use crate::agents::{Poll, Poller, POLL_EVERY}; + use crate::agents::{Activity, Poll, Poller, POLL_EVERY}; use objc2::rc::{autoreleasepool, Retained}; use objc2::runtime::{AnyClass, AnyObject}; use objc2::{msg_send, sel}; use objc2_foundation::{ns_string, NSBundle, NSDictionary, NSNumber, NSString}; use std::io::Read; - use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; - use std::sync::{Arc, OnceLock}; + use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use url::Url; @@ -510,7 +630,7 @@ mod system { struct Live { pipe_closed: Receiver<()>, - working: Arc, + activity: Arc>, } impl Signals for Live { @@ -518,8 +638,8 @@ mod system { !matches!(self.pipe_closed.recv_timeout(time), Err(RecvTimeoutError::Timeout)) } - fn agents_working(&self) -> bool { - self.working.load(Ordering::Relaxed) + fn activity(&self) -> Activity { + self.activity.lock().map_or(Activity::default(), |activity| *activity) } fn now(&self) -> Instant { @@ -528,11 +648,16 @@ mod system { } /// Runs for the life of the child. A server that does not answer counts as - /// nothing working, and one that refuses the login is never asked again. - fn poll_until_refused(mut poller: Poller, working: &AtomicBool) { + /// nothing going on, and one that refuses the login is never asked again. + fn poll_until_refused(mut poller: Poller, shared: &Mutex) { loop { let poll = poller.poll(); - working.store(matches!(poll, Poll::Working(n) if n > 0), Ordering::Relaxed); + if let Ok(mut activity) = shared.lock() { + *activity = match poll { + Poll::Seen(seen) => seen, + _ => Activity::default(), + }; + } if poll == Poll::Refused { return; } @@ -559,14 +684,14 @@ mod system { let Some(Connection { server, authorization, - style, + styles, }) = connection else { std::process::exit(0); }; let poller = Url::parse(&server) .ok() - .and_then(|url| Poller::new(&url, authorization)); + .and_then(|url| Poller::new(&url, authorization, styles.wants_alerts())); let (Some(poller), Some(logo)) = (poller, SystemLogo::open()) else { std::process::exit(0); }; @@ -578,12 +703,12 @@ mod system { while matches!(stdin.read(&mut buf), Ok(n) if n > 0) {} let _ = closed.send(()); }); - let working = Arc::new(AtomicBool::new(false)); - let found = working.clone(); + let activity = Arc::new(Mutex::new(Activity::default())); + let found = activity.clone(); std::thread::spawn(move || poll_until_refused(poller, &found)); - let mut signals = Live { pipe_closed, working }; - watch(&logo, &Marker::default_location(), style.pattern(), &mut signals); + let mut signals = Live { pipe_closed, activity }; + watch(&logo, &Marker::default_location(), styles, &mut signals); std::process::exit(0) } } @@ -595,6 +720,33 @@ mod tests { const DARK_FOR: Duration = CALM[0].dark; const LIT_FOR: Duration = CALM[0].lit; + /// The style most of these tests watch with: work only. + const WORK: BlinkStyles = BlinkStyles { + working: BlinkStyle::Calm, + waiting: BlinkStyle::Off, + needs_you: BlinkStyle::Off, + }; + + fn working() -> Activity { + Activity { + working: true, + ..Activity::default() + } + } + + fn waiting() -> Activity { + Activity { + waiting: true, + ..Activity::default() + } + } + + fn needs_you() -> Activity { + Activity { + needs_you: true, + ..Activity::default() + } + } /// A logo that records every write. struct FakeLogo { @@ -803,7 +955,7 @@ mod tests { #[test] fn a_change_counts_once_it_held() { let t0 = Instant::now(); - let mut settled = Settled::new(t0); + let mut settled = Settled::new(false, t0); assert!(!settled.update(true, t0)); assert!(!settled.update(true, t0 + SETTLE - Duration::from_millis(1))); assert!(settled.update(true, t0 + SETTLE)); @@ -816,22 +968,34 @@ mod tests { assert!(!settled.update(false, t1 + Duration::from_secs(10) + SETTLE)); } - /// A clock that `wait` moves, and a server that has agents at work in the - /// given spans of time. The app goes away at `app_gone_at`. + #[test] + fn a_new_dialog_is_judged_before_the_logo_would_follow_it() { + // The server's check of a new dialog (`STALE_CHECK_DELAY_MS`), which a + // prompt answered before its hook landed does not survive. + assert!(SETTLE >= Duration::from_secs(3)); + } + + /// A clock that `wait` moves, and a server that shows the given activity in + /// the given spans of time. The app goes away at `app_gone_at`. struct Script { start: Instant, clock: Cell, - work: Vec<(u64, u64)>, + spans: Vec<(u64, u64, Activity)>, app_gone_at: u64, } impl Script { fn new(work: &[(u64, u64)], app_gone_at: u64) -> Self { + let spans: Vec<_> = work.iter().map(|&(from, to)| (from, to, working())).collect(); + Self::with(&spans, app_gone_at) + } + + fn with(spans: &[(u64, u64, Activity)], app_gone_at: u64) -> Self { let start = Instant::now(); Script { start, clock: Cell::new(start), - work: work.to_vec(), + spans: spans.to_vec(), app_gone_at, } } @@ -847,9 +1011,12 @@ mod tests { self.elapsed_ms() >= self.app_gone_at } - fn agents_working(&self) -> bool { + fn activity(&self) -> Activity { let t = self.elapsed_ms(); - self.work.iter().any(|&(from, to)| t >= from && t < to) + self.spans + .iter() + .find(|&&(from, to, _)| t >= from && t < to) + .map_or(Activity::default(), |&(_, _, activity)| activity) } fn now(&self) -> Instant { @@ -863,7 +1030,7 @@ mod tests { fn nothing_is_written_while_no_agent_works() { let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("watch-quiet"); - watch(&logo, &marker, CALM, &mut Script::new(&[], 60_000)); + watch(&logo, &marker, WORK, &mut Script::new(&[], 60_000)); assert_eq!(logo.writes(), [] as [i64; 0]); } @@ -871,7 +1038,7 @@ mod tests { fn a_short_flicker_of_work_does_not_blink() { let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("watch-flicker"); - watch(&logo, &marker, CALM, &mut Script::new(&[(1_000, 2_500)], 60_000)); + watch(&logo, &marker, WORK, &mut Script::new(&[(1_000, 2_500)], 60_000)); assert_eq!(logo.writes(), [] as [i64; 0]); } @@ -880,11 +1047,12 @@ mod tests { let logo = FakeLogo::new(MODE_AUTO); let (marker, path) = temp_marker("watch-turn"); let mut script = Script::new(&[(1_000, 21_000)], 60_000); - watch(&logo, &marker, CALM, &mut script); + watch(&logo, &marker, WORK, &mut script); let writes = logo.writes(); let dark_phases = writes.iter().filter(|&&m| m == MODE_OFF).count() as u64; - // 20 s of work, less the settling at the start, one dark phase a cycle. - let cycles = (20_000 - SETTLE.as_millis() as u64) / CYCLE_MS; + // Settling delays the start and the end alike, so the blink lasts as long + // as the 20 s of work did, with one dark phase a cycle. + let cycles = 20_000 / CYCLE_MS; assert!(dark_phases >= cycles, "{dark_phases} dark phases, {cycles} cycles"); assert!(dark_phases <= cycles + 2, "{dark_phases} dark phases, {cycles} cycles"); assert_eq!(logo.mode.get(), MODE_AUTO); @@ -895,12 +1063,15 @@ mod tests { fn the_end_of_the_work_stops_a_blink_in_the_middle_of_a_dark_phase() { let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("watch-stop"); - // Work has to hold to start and to stop, so the first dark phase is - // over by then. Started at about 3.25 s, the second one runs from - // about 5.25 s, and work that ends at 3.5 s settles at 5.5 s, inside it. - let mut script = Script::new(&[(1_000, 3_500)], 60_000); - watch(&logo, &marker, CALM, &mut script); - assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO, MODE_OFF, MODE_AUTO]); + // Work has to hold to start and to stop. Started at about 4.25 s, a dark + // phase runs from every 2 s after that, and work that ends at 5.5 s + // settles at 8.5 s, inside the third one. + let mut script = Script::new(&[(1_000, 5_500)], 60_000); + watch(&logo, &marker, WORK, &mut script); + assert_eq!( + logo.writes(), + [MODE_OFF, MODE_AUTO, MODE_OFF, MODE_AUTO, MODE_OFF, MODE_AUTO] + ); } #[test] @@ -908,7 +1079,7 @@ mod tests { let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("watch-gap"); let mut script = Script::new(&[(1_000, 15_000), (16_000, 30_000)], 60_000); - watch(&logo, &marker, CALM, &mut script); + watch(&logo, &marker, WORK, &mut script); let writes = logo.writes(); let dark_phases = writes.iter().filter(|&&m| m == MODE_OFF).count() as u64; assert!( @@ -922,7 +1093,7 @@ mod tests { let logo = FakeLogo::new(MODE_AUTO); let (marker, path) = temp_marker("watch-gone"); // Work from the start; the first dark phase begins once it has settled. - watch(&logo, &marker, CALM, &mut Script::new(&[(0, 60_000)], 3_000)); + watch(&logo, &marker, WORK, &mut Script::new(&[(0, 60_000)], 3_500)); assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); assert!(!path.exists()); } @@ -932,7 +1103,7 @@ mod tests { let logo = FakeLogo::new(MODE_OFF); let (marker, _) = temp_marker("watch-declined"); let mut script = Script::new(&[(0, 30_000)], 60_000); - watch(&logo, &marker, CALM, &mut script); + watch(&logo, &marker, WORK, &mut script); assert_eq!(logo.writes(), [] as [i64; 0]); } @@ -951,14 +1122,14 @@ mod tests { } gone } - fn agents_working(&self) -> bool { - self.0.agents_working() + fn activity(&self) -> Activity { + self.0.activity() } fn now(&self) -> Instant { self.0.now() } } - watch(&logo, &marker, CALM, &mut TurnsOn(&mut script, &logo)); + watch(&logo, &marker, WORK, &mut TurnsOn(&mut script, &logo)); assert!(logo.writes().contains(&MODE_OFF), "{:?}", logo.writes()); assert_eq!(logo.mode.get(), MODE_AUTO); } @@ -969,14 +1140,14 @@ mod tests { let line = serde_json::to_string(&Connection { server: "http://127.0.0.1:3000/".into(), authorization: authorization.clone(), - style: BlinkStyle::Heartbeat, + styles: WORK, }) .unwrap(); assert!(!line.contains('\n'), "one line"); let back: Connection = serde_json::from_str(&line).unwrap(); assert_eq!(back.server, "http://127.0.0.1:3000/"); assert_eq!(back.authorization, authorization); - assert_eq!(back.style, BlinkStyle::Heartbeat); + assert_eq!(back.styles, WORK); } } @@ -1025,14 +1196,170 @@ mod tests { } #[test] - fn a_menu_id_names_its_style_and_nothing_else() { - for style in BlinkStyle::ALL { - assert_eq!(BlinkStyle::from_menu_id(style.menu_id()), Some(style)); + fn a_menu_id_names_its_state_and_style_and_nothing_else() { + for signal in Signal::ALL { + for style in BlinkStyle::ALL { + assert_eq!(BlinkStyle::from_menu_id(&style.menu_id(signal)), Some((signal, style))); + } } assert_eq!(BlinkStyle::from_menu_id("reload"), None); + assert_eq!(BlinkStyle::from_menu_id("logo-blink-calm"), None); assert_eq!(BlinkStyle::from_menu_id(""), None); } + const ALL_ON: BlinkStyles = BlinkStyles { + working: BlinkStyle::Calm, + waiting: BlinkStyle::Wink, + needs_you: BlinkStyle::Alarm, + }; + + #[test] + fn the_most_urgent_state_wins() { + let everything = Activity { + working: true, + waiting: true, + needs_you: true, + }; + assert_eq!(ALL_ON.pick(everything), BlinkStyle::Alarm); + assert_eq!( + ALL_ON.pick(Activity { + needs_you: false, + ..everything + }), + BlinkStyle::Wink + ); + assert_eq!(ALL_ON.pick(working()), BlinkStyle::Calm); + assert_eq!(ALL_ON.pick(Activity::default()), BlinkStyle::Off); + } + + #[test] + fn a_state_set_to_off_falls_through_to_the_next_one() { + let everything = Activity { + working: true, + waiting: true, + needs_you: true, + }; + let mut styles = ALL_ON; + styles.needs_you = BlinkStyle::Off; + assert_eq!(styles.pick(everything), BlinkStyle::Wink); + styles.waiting = BlinkStyle::Off; + assert_eq!(styles.pick(everything), BlinkStyle::Calm); + styles.working = BlinkStyle::Off; + assert_eq!(styles.pick(everything), BlinkStyle::Off); + } + + #[test] + fn a_state_with_no_style_touches_nothing_not_even_the_marker() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("no-style"); + struct Watching<'a>(Script, &'a PathBuf); + impl Signals for Watching<'_> { + fn wait(&mut self, time: Duration) -> bool { + assert!(!self.1.exists(), "no marker was ever needed"); + self.0.wait(time) + } + fn activity(&self) -> Activity { + self.0.activity() + } + fn now(&self) -> Instant { + self.0.now() + } + } + let script = Script::with(&[(0, 30_000, waiting())], 40_000); + watch(&logo, &marker, WORK, &mut Watching(script, &path)); + assert_eq!(logo.writes(), [] as [i64; 0]); + } + + fn dark_phases(styles: BlinkStyles, spans: &[(u64, u64, Activity)], end: u64) -> usize { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("phases"); + watch(&logo, &marker, styles, &mut Script::with(spans, end)); + assert_eq!(logo.mode.get(), MODE_AUTO, "it ends lit"); + assert!(!path.exists()); + logo.writes().iter().filter(|&&mode| mode == MODE_OFF).count() + } + + #[test] + fn a_change_of_state_moves_to_the_other_rhythm_and_back() { + let alone = dark_phases(ALL_ON, &[(0, 60_000, working())], 60_000); + let interrupted = dark_phases( + ALL_ON, + &[ + (0, 20_000, working()), + (20_000, 40_000, needs_you()), + (40_000, 60_000, working()), + ], + 60_000, + ); + // About 29 dark phases of Calm alone, and Alarm's 2 a second for a third + // of the time. Calm alone again at the end ends the same as it began. + assert!((27..=31).contains(&alone), "{alone}"); + assert!(interrupted >= alone + 20, "{interrupted} against {alone}"); + } + + #[test] + fn red_interrupts_yellow_and_yellow_comes_back() { + let yellow_only = dark_phases(ALL_ON, &[(0, 60_000, waiting())], 60_000); + let with_red = dark_phases( + ALL_ON, + &[ + (0, 20_000, waiting()), + (20_000, 40_000, needs_you()), + (40_000, 60_000, waiting()), + ], + 60_000, + ); + assert!(with_red > yellow_only * 3, "{with_red} against {yellow_only}"); + } + + #[test] + fn alarm_is_the_sharpest_style_a_second_can_hold() { + let flashes = |style: BlinkStyle| { + let cycle: Duration = style.pattern().iter().map(|beat| beat.dark + beat.lit).sum(); + style.pattern().len() as f64 / cycle.as_secs_f64() + }; + assert!(flashes(BlinkStyle::Alarm) > flashes(BlinkStyle::Fast)); + assert!(flashes(BlinkStyle::Alarm) > flashes(BlinkStyle::Heartbeat) * 2.0); + } + + #[test] + fn the_setting_reads_every_shape_it_has_had() { + let read = |text: &str| BlinkStyles::read(&serde_json::from_str(text).unwrap()); + // The one style of the first version keeps meaning work, and brings the alerts. + assert_eq!( + read(r#""heartbeat""#), + BlinkStyles { + working: BlinkStyle::Heartbeat, + waiting: BlinkStyle::Wink, + needs_you: BlinkStyle::Alarm, + } + ); + assert_eq!(read(r#""off""#), BlinkStyles::default()); + assert_eq!(read(r#""strobe""#), BlinkStyles::default()); + assert_eq!(read("3"), BlinkStyles::default()); + assert_eq!(read("null"), BlinkStyles::default()); + // A bad style costs its own state, not the others. + assert_eq!( + read(r#"{"working":"calm","waiting":"strobe","needsYou":"alarm"}"#), + BlinkStyles { + working: BlinkStyle::Calm, + waiting: BlinkStyle::Off, + needs_you: BlinkStyle::Alarm, + } + ); + let saved = serde_json::to_value(ALL_ON).unwrap(); + assert_eq!(saved["needsYou"], "alarm"); + assert_eq!(BlinkStyles::read(&saved), ALL_ON); + } + + #[test] + fn nothing_but_work_asks_for_no_alerts() { + assert!(!WORK.wants_alerts()); + assert!(ALL_ON.wants_alerts()); + assert!(!BlinkStyles::default().any()); + assert!(WORK.any()); + } + #[test] fn a_watcher_with_nothing_to_watch_starts_nothing() { let mut watcher = Watcher::default(); diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 79489dae..638a8dc8 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -8,7 +8,7 @@ //! windows (Codeman's `window.CodemanHost` bridge), downloads to ~/Downloads, //! passwords in the Keychain, on macOS the JavaScript dialogs and HTTP Basic //! challenges wry leaves out (`macos`), and on a MacBook Pro with a lit Apple -//! logo a blink while agents work (`logo`). +//! logo a blink that says what the agents are doing (`logo`). //! //! Every URL and message decision is in `policy` (pure, unit-tested); this file //! only wires those decisions to windows. The main window moves through @@ -54,7 +54,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView use config::{Config, Rect, WindowFrame}; use credentials::Keychain; -use logo::BlinkStyle; +use logo::{BlinkStyle, Signal}; use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via}; use probe::ProbeOutcome; @@ -239,9 +239,9 @@ struct AppMenu { window_menu: Submenu, help_menu: Submenu, start_fullscreen: CheckMenuItem, - /// One item per style. Empty on a Mac without a lit logo, which has no + /// One item per state and style. Empty on a Mac without a lit logo, which has no /// such menu. - logo_styles: Vec<(BlinkStyle, CheckMenuItem)>, + logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>, } enum Load<'a> { @@ -479,15 +479,19 @@ fn build_menu(config: &Config) -> muda::Result { // Only a Mac with a lit Apple logo has this menu at all. let mut logo_styles = Vec::new(); if logo::available() { - let submenu = Submenu::new("Blink Apple Logo While Agents Work", true); - for style in BlinkStyle::ALL { - let chosen = style == config.logo_blink; - let item = CheckMenuItem::with_id(style.menu_id(), style.label(), true, chosen, None); - submenu.append(&item)?; - if style == BlinkStyle::Off { - submenu.append(&PredefinedMenuItem::separator())?; + let submenu = Submenu::new("Blink Apple Logo", true); + for signal in Signal::ALL { + let group = Submenu::new(signal.label(), true); + for style in BlinkStyle::ALL { + let chosen = style == config.logo_blink.get(signal); + let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None); + group.append(&item)?; + if style == BlinkStyle::Off { + group.append(&PredefinedMenuItem::separator())?; + } + logo_styles.push((signal, style, item)); } - logo_styles.push((style, item)); + submenu.append(&group)?; } // Right under "Always Open in Full Screen", before the separator. view.insert(&submenu, 4)?; @@ -1137,23 +1141,25 @@ impl App { } } - /// The logo is watched while a style is chosen and a server is open. + /// The logo is watched while any style is chosen and a server is open. fn sync_logo_watch(&mut self) { - let style = self.config.logo_blink; + let styles = self.config.logo_blink; let phase = self.phase.borrow(); - let server = phase.loaded().filter(|_| style != BlinkStyle::Off && logo::available()); + let server = phase.loaded().filter(|_| styles.any() && logo::available()); self.logo_watch - .sync(server.map(|url| (url.as_str(), self.auth.as_deref(), style))); + .sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles))); } - fn choose_logo_blink(&mut self, style: BlinkStyle) { + fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) { // A click flips the item it landed on, the chosen one included, so all - // of them are set. - for (item_style, item) in &self.menu.logo_styles { - item.set_checked(*item_style == style); + // of that state's are set. + for (item_signal, item_style, item) in &self.menu.logo_styles { + if *item_signal == signal { + item.set_checked(*item_style == style); + } } - let was_off = self.config.logo_blink == BlinkStyle::Off; - self.config.logo_blink = style; + let was_off = !self.config.logo_blink.any(); + self.config.logo_blink.set(signal, style); self.config_dirty = true; self.sync_logo_watch(); // Only from off: with a style running, a dark logo may be our own. @@ -1167,8 +1173,8 @@ impl App { } fn on_menu(&mut self, id: &str) { - if let Some(style) = BlinkStyle::from_menu_id(id) { - self.choose_logo_blink(style); + if let Some((signal, style)) = BlinkStyle::from_menu_id(id) { + self.choose_logo_blink(signal, style); return; } match id {