From cd4cee15a7e33996d13af92ca6791d302c258d10 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 20:56:30 +0100 Subject: [PATCH] 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); }