From 39f2e847d8eda00c1f6a2d5a0cc4debb2c0eeed6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 10 Nov 2026 21:36:32 +0100 Subject: [PATCH 1/6] feat(desktop): the lid logo keeps blinking with the lid closed With the lid closed, corebrightnessd tells every module so and keeps the logo dark, so the blink stopped as soon as the lid came down, which is exactly when the logo on the lid is the one thing in view. While the Mac stays awake with the lid closed on its power adapter (Keep This Mac Awake's lid helper, or an external display), the blink child now does what LogoLight's Lit with Lid Closed measured on the real MacBook: once the daemon has handled the lid (CBClamshellState), it tells the logo the lid is open (ClamshellUpdate 0) and each keyboard alone that it is closed, so the keyboard stays dark and is never relit after a Blank. The first rise after that has to move the logo within 1.5 s. If it does not (with no display on, the daemon may still hold the logo dark), the lid is told closed again, tried once more, then left dark until the lid opens, a display comes on or the agents' state changes. A logo the daemon takes back (it resends the lid state when a display or keyboard comes up) is told again. Ending a blink under the closed lid hands the logo back to normal and then writes ClamshellUpdate 1, since normal there is dark; the marker recovery does the same for a killed child. Never on battery (a dark wake must not light the logo in a bag), never without the keyboard IDs, and nothing is written when the lid opens. Every step is a line on stderr so a real-Mac run shows which one stopped it. Not seen on a real MacBook yet; the README says so. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/README.md | 26 +- packages/desktop/src/logo.rs | 560 +++++++++++++++++++++++++++++++++-- 2 files changed, 568 insertions(+), 18 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index b42d762d..90aed13f 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 says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks, and while the display sleeps or the lid is closed the blink pauses 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. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. If the logo does not move that way, it stays dark. On battery, and while the display only sleeps with the lid open, the blink pauses 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) @@ -249,6 +249,23 @@ changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast' while their fall is still under way and never quite reach dark, and a long one (Calm's, Slow's, the breath's rest) times its light from when the rise starts. +With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and the logo's +target is then 0 whatever `DisplayLogicalState` says. A client's `ClamshellUpdate` reaches the +same modules, which is how LogoLight measured it on the real Mac: once the daemon has handled +the lid (`CBClamshellState` reads 1), the child writes `ClamshellUpdate` 0 and then 1 to each +keyboard alone, so the logo can move and the keyboard stays dark. It does that only on the +power adapter (on battery a closed MacBook sleeps, and a dark wake must not light the logo in +a bag), and only with every keyboard reachable. The first rise after it has to move the logo +within 1.5 s; if it does not, the child writes `ClamshellUpdate` 1 again and tries once more, +and then leaves the logo dark until the lid opens, a display comes on or the agents' state +changes. When the logo goes dark again by itself (the daemon sends the lid state again when a +display or a keyboard comes up), it is told once more. Ending a blink with the lid closed hands +the logo back to normal (the automatic duration, then Unblank, which a held level needs) and +then writes `ClamshellUpdate` 1, since normal for a closed lid is dark; the marker covers a +child killed in between. When the lid opens, the daemon tells every module itself and nothing +is written. Each of these steps is a line on the app's stderr (`open -a Codeman --stderr +`), so a test on a real Mac shows which one stopped the blink. + 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 the switch is on and a server is open, tied to the app by @@ -331,6 +348,13 @@ crosses, so the login is never on a command line. the logo the same way and is not LogoLight is not recognised, and the two fight. `api/sessions` does not say which turns are Codeman's own, so with prompt-cache keep-warm on, a keep-alive turn blinks too. +- **Blinking with the lid closed has not been seen on a real MacBook yet.** + LogoLight measured the lid override with an external display attached. With + none, the Mac kept awake by the lid helper, the daemon may still hold the + logo dark (it does for a display that sleeps); the app then leaves it dark + and says so on stderr. Not checked either, by LogoLight or here: whether + telling the display the lid is open changes anything on an external display, + or the built-in display's brightness when the lid opens again. - **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/logo.rs b/packages/desktop/src/logo.rs index de26fe9b..dee062fa 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -38,6 +38,21 @@ //! When several states hold at once the most urgent one with a style is shown: //! red, then yellow, then work. //! +//! With the lid closed `corebrightnessd` keeps the logo dark: it tells every +//! module the lid is closed, and the logo's target is then 0 whatever +//! `DisplayLogicalState` says. While the Mac stays awake with the lid closed on +//! its power adapter (Keep This Mac Awake's lid helper, or an external display), +//! the blink goes on, the way LogoLight's Lit with Lid Closed works (its +//! `docs/corebrightnessd.md`, "The lid"): once the daemon has handled the lid +//! (`CBClamshellState`), the logo is told the lid is open (`ClamshellUpdate` 0) +//! and the keyboards alone that it is closed, so they stay dark. The first rise +//! after that has to move the logo. If it does not (the daemon may still keep +//! it dark for a display that sleeps), the logo is told the lid is closed again +//! and left dark. A blink that ends with the lid closed puts the logo back to +//! normal, and normal for a closed lid is dark. Each step of this is a line on +//! stderr, so a test on a real Mac (`open -a Codeman --stderr `) shows +//! which one stopped it. +//! //! All of this runs in a child process (this same binary with `BLINK_ARG`), for //! as long as the switch is on and a server is open. The app hands it the //! server and the login over a pipe (never on its command line, where `ps` @@ -429,6 +444,13 @@ 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 the first rise after the logo was told the lid is open may take to +/// move it: the display-ready gate waits up to a second, and LogoLight saw that +/// rise start 0.5 to 0.8 s after the write. +const LID_PROOF_WAIT: Duration = Duration::from_millis(1500); +/// How often the logo is told the lid is open, for one closed lid and the same +/// displays on, before it is left dark. +const LID_TRIES: u32 = 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. Not below the 3 s /// the server waits before it judges a new dialog: a prompt that was answered @@ -457,6 +479,25 @@ pub trait Logo { fn display_awake(&self) -> bool; /// Another app drives the logo the same way. fn driven_elsewhere(&self) -> bool; + /// `CBClamshellState`: whether `corebrightnessd` has handled a closed lid. Only + /// a real lid change moves it, whatever `tell_lid` said since. None if it + /// cannot say. + fn lid_closed(&self) -> Option; + /// `ClamshellUpdate`: tell the logo the lid is open and then the keyboards + /// alone that it is closed, so they stay dark (`open`), or tell everything + /// it is closed. False if that was not done: opening it needs the keyboards + /// within reach, or a keyboard would light under the closed lid. + fn tell_lid(&self, open: bool) -> bool; + /// The Mac runs on battery. It sleeps with the lid closed then, and a dark + /// wake (Power Nap) must not light the logo in a bag. + fn on_battery(&self) -> bool; + /// How many displays other than the built-in one are on. A change gives a + /// closed lid another try: an external display that woke. + fn other_displays_awake(&self) -> usize; + /// What a line on stderr says about the logo, for a test on a real Mac. + fn describe(&self) -> String { + String::new() + } } /// Moves the logo, and keeps what cannot be read back: which way it heads, and @@ -469,6 +510,8 @@ struct Fader<'a, L: Logo> { heading: Option, /// What the keyboards were last told, if anything. keyboards: Option, + /// The logo was told the lid is open while it is closed (`open_lid`). + lid_open: bool, /// Anything was written. touched: bool, /// Every write was taken. @@ -481,6 +524,7 @@ impl<'a, L: Logo> Fader<'a, L> { logo, heading: None, keyboards: None, + lid_open: false, touched: false, ok: true, } @@ -544,24 +588,45 @@ impl<'a, L: Logo> Fader<'a, L> { } } + /// Lets the logo move under a closed lid (`Logo::tell_lid`). + fn open_lid(&mut self) -> bool { + // A refusal has written nothing, or put back what it wrote. + self.lid_open = self.logo.tell_lid(true); + self.touched |= self.lid_open; + self.lid_open + } + + /// Ends `open_lid`: the logo goes dark, as a closed lid has it. Once the lid + /// has really opened the daemon has told every module so itself, and nothing + /// is written. + fn close_lid(&mut self) { + if std::mem::take(&mut self.lid_open) && self.logo.lid_closed() == Some(true) { + self.ok &= self.logo.tell_lid(false); + } + } + /// Back to normal: fully lit (when the display is on), with the automatic - /// fade duration. True once that is done; false while the display sleeps or - /// a write was refused, which keeps the marker for another try. + /// fade duration. With the lid closed, normal is dark: a logo told the lid is + /// open is handed back first (a held level would survive the lid) and then + /// told it is closed. True once that is done; false while the display sleeps + /// or a write was refused, which keeps the marker for another try. fn restore(&mut self) -> bool { if !self.touched && self.keyboards.is_none() { return true; } let awake = self.logo.display_awake(); + let reachable = awake || self.lid_open; // A fade toward lit may be a held one, or still under way at its own // duration: turn it round without moving, so relighting is a change. if self.heading != Some(false) && self.logo.factor().unwrap_or(1.0) < LIT { self.turn_round(true, awake); } self.duration(0.0); - if awake { + if reachable { self.heading(true, awake); } - self.ok && awake + self.close_lid(); + self.ok && reachable } } @@ -616,6 +681,7 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut return Ended::Declined; } let mut fader = Fader::new(logo); + let mut lid = Lid::default(); let mut ended = Ended::Stopped; let mut asleep = false; let mut next = 0; @@ -629,38 +695,68 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut ended = Ended::Declined; break; } - if !awake { + if awake { + if asleep { + fader.display_woke(); + asleep = false; + } + if std::mem::take(&mut fader.lid_open) { + // The daemon told every module itself when the lid opened. + note(logo, "the lid opened"); + } + lid = Lid::default(); + } else { if !asleep { fader.display_slept(); asleep = true; } - if wait(NOT_LIT_RECHECK) { - break; + if !through_the_lid(logo, &mut fader, &mut lid) { + if wait(NOT_LIT_RECHECK) { + break; + } + continue; } - continue; - } - if asleep { - fader.display_woke(); - asleep = false; } } + let proving = step.lit && fader.lid_open && !lid.proven; let from_dark = step.lit && logo.factor().is_some_and(|factor| factor <= 0.0); + // With the display off the keyboards are not touched: under a closed lid + // they stay dark. fader.fade(step.lit, step.fade, awake); if !fader.ok { ended = Ended::Declined; break; } let mut left = step.wait; - if from_dark { + if from_dark || proving { // Held back until the display shows a frame: time the light from when // it moves, so a held-back rise lengthens the dark, not shortens the light. + let gate = if proving { LID_PROOF_WAIT } else { GATE_WAIT }; let mut waited = Duration::ZERO; - while waited < GATE_WAIT && logo.factor().is_some_and(|factor| factor <= 0.0) { + while waited < gate && logo.factor().is_some_and(|factor| factor <= 0.0) { if wait(RISE_POLL) { break 'blink; } waited += RISE_POLL; } + if proving { + if logo.factor().is_some_and(|factor| factor > 0.0) { + lid.proven = true; + note( + logo, + &format!("the logo rose under the closed lid after {} ms", waited.as_millis()), + ); + } else { + fader.close_lid(); + note( + logo, + &format!( + "the logo did not rise under the closed lid within {} ms: told the lid is closed again", + gate.as_millis() + ), + ); + } + } left = left.saturating_sub(START_LATENCY); } if wait(left) { @@ -674,6 +770,63 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut ended } +/// What a closed lid has shown during one blink. +#[derive(Debug, Default)] +struct Lid { + /// The logo moved since it was last told the lid is open. + proven: bool, + /// How often it was told so with these displays on. + tries: u32, + /// How many other displays were on for those tries. + displays: Option, +} + +/// At the start of a cycle with the built-in display off: whether the logo can +/// be seen through a closed lid, telling it the lid is open when that is worth a +/// try. Never while the display only sleeps (the lid is open), nor on battery. +fn through_the_lid<'a, L: Logo>(logo: &'a L, fader: &mut Fader<'a, L>, lid: &mut Lid) -> bool { + if logo.lid_closed() != Some(true) || logo.on_battery() { + fader.close_lid(); + lid.proven = false; + return false; + } + let displays = logo.other_displays_awake(); + if lid.displays != Some(displays) { + lid.displays = Some(displays); + lid.tries = 0; + } + if fader.lid_open { + if !lid.proven || logo.factor().is_some_and(|factor| factor > 0.0) { + return true; + } + // Dark although its last rise lit it: the daemon told the logo the lid is + // closed again, as it does when a display or a keyboard comes up. + fader.lid_open = false; + note(logo, "the logo went dark under the closed lid"); + } + lid.proven = false; + if lid.tries >= LID_TRIES { + return false; + } + lid.tries += 1; + let open = fader.open_lid(); + note( + logo, + if open { + "the lid is closed: told the logo it is open, and the keyboards that it is closed" + } else { + "the lid is closed, and the keyboards cannot be kept dark: the logo stays dark" + }, + ); + open +} + +/// A line on stderr about the closed lid, which `open -a Codeman --stderr ` +/// keeps: how blinking under it is checked on a real Mac. +fn note(logo: &impl Logo, text: &str) { + eprintln!("codeman logo: {text} ({})", logo.describe()); +} + /// Put back a logo that a blink could not finish with. pub fn recover(logo: &impl Logo, marker: &Marker, legacy: &Marker) { // The first version blinked by switching the setting off, so with its marker @@ -685,6 +838,8 @@ pub fn recover(logo: &impl Logo, marker: &Marker, legacy: &Marker) { let mut fader = Fader::new(logo); // Which way it heads is not known: `restore` turns it round first. fader.touched = true; + // A child killed with the lid closed may have told the logo it is open. + fader.lid_open = !logo.display_awake() && logo.lid_closed() == Some(true); if fader.restore() { marker.clear(); } @@ -860,6 +1015,7 @@ mod system { use objc2::runtime::{AnyClass, AnyObject, Bool}; use objc2::{msg_send, sel}; use objc2_foundation::{ns_string, NSBundle, NSDictionary, NSNumber, NSString}; + use std::ffi::c_void; use std::io::Read; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::sync::{Arc, Mutex, OnceLock}; @@ -876,6 +1032,17 @@ mod system { fn CGDisplayIsAsleep(display: u32) -> u32; } + #[link(name = "IOKit", kind = "framework")] + extern "C" { + fn IOPSCopyPowerSourcesInfo() -> *const c_void; + fn IOPSGetProvidingPowerSourceType(snapshot: *const c_void) -> *const c_void; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFRelease(object: *const c_void); + } + struct SystemLogo { client: Retained, /// The keyboard backlights, for telling them apart from the logo. Empty if @@ -1020,6 +1187,95 @@ mod system { fn driven_elsewhere(&self) -> bool { driven_elsewhere() } + + fn lid_closed(&self) -> Option { + autoreleasepool(|_| { + let value = self.property(ns_string!("CBClamshellState"))?; + Some(value.downcast_ref::()?.as_bool()) + }) + } + + fn tell_lid(&self, open: bool) -> bool { + if open && self.keyboards.is_empty() { + return false; + } + autoreleasepool(|_| { + let closed = NSNumber::new_bool(true); + if !open { + return self.set(&closed, ns_string!("ClamshellUpdate")); + } + if !self.set(&NSNumber::new_bool(false), ns_string!("ClamshellUpdate")) { + return false; + } + for &id in &self.keyboards { + // SAFETY: the method was checked in `open` (there are keyboards + // only if it exists). It takes an object, a string and a 64-bit + // keyboard ID and answers a BOOL, which is not read: LogoLight's + // measured override does not read it either. + let _: bool = unsafe { + msg_send![&*self.client, setProperty: &*closed, withKey: ns_string!("ClamshellUpdate"), keyboardID: id] + }; + } + true + }) + } + + fn on_battery(&self) -> bool { + on_battery() + } + + fn other_displays_awake(&self) -> usize { + other_displays_awake() + } + + fn describe(&self) -> String { + autoreleasepool(|_| { + format!( + "factor {:?}, FinalNits {:?}, CBClamshellState {:?}, built-in display on {}, other displays on {}, on battery {}", + self.pwm(ns_string!("Factor")), + self.pwm(ns_string!("FinalNits")), + self.lid_closed(), + built_in_display_awake(), + other_displays_awake(), + on_battery() + ) + }) + } + } + + /// How many displays other than the built-in one are on (an external display + /// that sleeps does not count). + fn other_displays_awake() -> usize { + let mut displays = [0u32; 16]; + let mut count = 0u32; + // SAFETY: the buffer holds 16 IDs and the call is told so. + if unsafe { CGGetOnlineDisplayList(16, displays.as_mut_ptr(), &mut count) } != 0 { + return 0; + } + displays[..(count as usize).min(displays.len())] + .iter() + // SAFETY: both take any display ID and answer a boolean. + .filter(|&&display| unsafe { CGDisplayIsBuiltin(display) == 0 && CGDisplayIsAsleep(display) == 0 }) + .count() + } + + /// "Battery Power" as the source in use; "AC Power", "UPS Power" and no + /// answer count as the adapter. The same reading as Keep This Mac Awake's. + fn on_battery() -> bool { + // SAFETY: IOPSCopyPowerSourcesInfo returns an owned CFTypeRef or NULL, + // released below. IOPSGetProvidingPowerSourceType returns a CFStringRef + // owned by that snapshot (the Get rule), read before the release; a + // CFString is toll-free bridged to NSString. + unsafe { + let snapshot = IOPSCopyPowerSourcesInfo(); + if snapshot.is_null() { + return false; + } + let kind = IOPSGetProvidingPowerSourceType(snapshot); + let battery = !kind.is_null() && (*kind.cast::()).to_string() == "Battery Power"; + CFRelease(snapshot); + battery + } } /// Whether the built-in display is on. A closed lid takes it off the list. @@ -1182,14 +1438,17 @@ mod system { fn what_this_mac_answers() { println!("logo available: {}", available()); println!("built-in display awake: {}", built_in_display_awake()); + println!("other displays awake: {}", other_displays_awake()); + println!("on battery: {}", on_battery()); println!("driven elsewhere: {}", driven_elsewhere()); println!("finds the shell that ran this (bash): {}", running(b"bash")); println!("keyboard backlight IDs: {:?}", keyboard_ids()); match SystemLogo::open() { Some(logo) => println!( - "mode {:?}, factor {:?}, keyboards the client can reach {:?}", + "mode {:?}, factor {:?}, lid closed {:?}, keyboards the client can reach {:?}", logo.mode(), logo.factor(), + logo.lid_closed(), logo.keyboards ), None => println!("no BrightnessSystemClient"), @@ -1256,6 +1515,9 @@ mod tests { Duration(f64), Heading(bool), Keyboards(bool), + /// `ClamshellUpdate`: true is "the lid is open" to the logo (and closed + /// to the keyboards), false is "closed" to everything. + Lid(bool), } #[derive(Debug, Clone, Copy)] @@ -1268,11 +1530,21 @@ mod tests { /// A `corebrightnessd` that moves like the real one: in frames, on the spring, /// starting a little after the write, only on a change of direction, and with - /// a rise from exactly dark held back by `gate`. Asleep, the logo is dark. + /// a rise from exactly dark held back by `gate`. Asleep, the logo is dark, + /// and so it is under a closed lid unless told the lid is open, with + /// `through_lid` saying whether that lets it move. struct FakeLogo { now: Rc>, mode: Cell, awake: Cell, + /// `CBClamshellState`. + lid: Cell>, + /// What the logo was last told about the lid (true: open). + told_open: Cell, + through_lid: Cell, + keyboards_reachable: Cell, + battery: Cell, + displays: Cell, elsewhere: Cell, gate: Cell, refuse_writes: Cell, @@ -1290,6 +1562,12 @@ mod tests { now: Rc::new(Cell::new(Duration::ZERO)), mode: Cell::new(mode), awake: Cell::new(true), + lid: Cell::new(Some(false)), + told_open: Cell::new(false), + through_lid: Cell::new(true), + keyboards_reachable: Cell::new(true), + battery: Cell::new(false), + displays: Cell::new(0), elsewhere: Cell::new(false), gate: Cell::new(Duration::ZERO), refuse_writes: Cell::new(false), @@ -1310,6 +1588,42 @@ mod tests { self.now.set(self.now.get() + time); } + /// The lid closes and the daemon handles it: the built-in display goes, + /// and every module is told the lid is closed. + fn close_the_lid(&self) { + self.awake.set(false); + self.lid.set(Some(true)); + self.told_open.set(false); + } + + /// The lid opens: the daemon tells every module so, and the display is on. + fn open_the_lid(&self) { + self.awake.set(true); + self.lid.set(Some(false)); + self.told_open.set(false); + } + + /// The logo can be seen: the display is on, or the lid is closed and the + /// logo was told it is open, and that works on this Mac. + fn shown(&self) -> bool { + self.awake.get() || (self.lid.get() == Some(true) && self.told_open.get() && self.through_lid.get()) + } + + /// The `ClamshellUpdate` writes. + fn lid_writes(&self) -> Vec { + self.writes().into_iter().filter(|w| matches!(w, W::Lid(_))).collect() + } + + /// The writes made from `from` on. + fn writes_since(&self, from: Duration) -> Vec { + self.writes + .borrow() + .iter() + .filter(|&&(at, _)| at >= from) + .map(|&(_, w)| w) + .collect() + } + fn level_at(&self, at: Duration) -> f64 { let fade = self.fade.get(); if at <= fade.start { @@ -1379,7 +1693,7 @@ mod tests { } fn factor(&self) -> Option { - Some(if self.awake.get() && self.mode.get() == MODE_AUTO { + Some(if self.shown() && self.mode.get() == MODE_AUTO { self.level() } else { 0.0 @@ -1441,6 +1755,27 @@ mod tests { fn driven_elsewhere(&self) -> bool { self.elsewhere.get() } + + fn lid_closed(&self) -> Option { + self.lid.get() + } + + fn tell_lid(&self, open: bool) -> bool { + if self.refuse_writes.get() || (open && !self.keyboards_reachable.get()) { + return false; + } + self.record(W::Lid(open)); + self.told_open.set(open); + true + } + + fn on_battery(&self) -> bool { + self.battery.get() + } + + fn other_displays_awake(&self) -> usize { + self.displays.get() + } } fn temp_marker(name: &str) -> (Marker, PathBuf) { @@ -1754,6 +2089,197 @@ mod tests { assert!(!path.exists()); } + #[test] + fn with_the_lid_closed_it_blinks_on_and_the_keyboards_stay_dark() { + for style in [BlinkStyle::Breathe, BlinkStyle::Calm, BlinkStyle::Alarm] { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("lid-blinks"); + let mut closed_at = None; + run_until(&logo, &marker, style, secs(60), |logo, now| { + if now >= secs(10) && closed_at.is_none() { + logo.close_the_lid(); + closed_at = Some(now); + } + }); + let since = logo.writes_since(closed_at.unwrap()); + let told = since + .iter() + .position(|w| *w == W::Lid(true)) + .expect("told the lid is open"); + assert_eq!( + since[..told].iter().filter(|w| **w == W::Keyboards(false)).count(), + 1, + "{style:?}: the keyboards are told the display is off first" + ); + let falls = since[told..].iter().filter(|w| **w == W::Heading(false)).count(); + assert!(falls >= 5, "{style:?}: it blinks on under the lid ({falls} Blanks)"); + assert!( + !since[told..].contains(&W::Keyboards(true)), + "{style:?}: no keyboard lit under the closed lid" + ); + // The end: back to normal, and then the lid is closed again, so it goes dark. + assert_eq!( + &since[since.len() - 2..], + [W::Heading(true), W::Lid(false)], + "{style:?}" + ); + assert_eq!(logo.lid_writes(), [W::Lid(true), W::Lid(false)], "{style:?}"); + assert_eq!(logo.last_duration(), Some(0.0)); + assert!(!path.exists(), "{style:?}"); + } + } + + #[test] + fn the_logo_is_told_only_once_the_daemon_has_handled_the_lid() { + let logo = FakeLogo::new(MODE_AUTO); + logo.awake.set(false); + // Closed, but the daemon has not caught up: an override now would come + // before its own update and be undone. + logo.lid.set(Some(false)); + let (marker, _) = temp_marker("lid-late"); + run_until(&logo, &marker, BlinkStyle::Calm, secs(20), |logo, now| { + if now >= secs(7) { + logo.lid.set(Some(true)); + } + }); + let first = logo + .writes + .borrow() + .iter() + .find(|(_, w)| *w == W::Lid(true)) + .map(|&(at, _)| at) + .expect("told once the daemon had it"); + assert!(first >= secs(7), "told at {first:?}"); + } + + #[test] + fn a_closed_lid_that_keeps_the_logo_dark_is_left_dark() { + let logo = FakeLogo::new(MODE_AUTO); + // What the daemon may do with no display on: the logo stays at 0. + logo.through_lid.set(false); + let (marker, path) = temp_marker("lid-dark"); + let mut quiet_from = None; + run_until(&logo, &marker, BlinkStyle::Calm, secs(120), |logo, now| { + if now >= secs(5) && logo.awake.get() && quiet_from.is_none() { + logo.close_the_lid(); + } + if quiet_from.is_none() && logo.lid_writes().len() == 2 * LID_TRIES as usize { + quiet_from = Some(now); + } + }); + assert_eq!( + logo.lid_writes(), + [W::Lid(true), W::Lid(false), W::Lid(true), W::Lid(false)], + "two tries, each taken back" + ); + let quiet_from = quiet_from.expect("it gave up"); + let before_the_end: Vec<_> = logo + .writes + .borrow() + .iter() + .filter(|&&(at, _)| at > quiet_from + secs(1) && at < secs(120)) + .map(|&(_, w)| w) + .collect(); + assert_eq!(before_the_end, [], "then nothing, until something changes"); + assert!(path.exists(), "dark and unseen: the next launch puts it back"); + } + + #[test] + fn a_display_that_comes_on_gives_the_closed_lid_another_try() { + let logo = FakeLogo::new(MODE_AUTO); + logo.through_lid.set(false); + logo.close_the_lid(); + let (marker, _) = temp_marker("lid-display"); + run_until(&logo, &marker, BlinkStyle::Calm, secs(90), |logo, now| { + if now >= secs(60) { + logo.displays.set(1); + logo.through_lid.set(true); + } + }); + let lid = logo.lid_writes(); + assert_eq!(&lid[..4], [W::Lid(true), W::Lid(false), W::Lid(true), W::Lid(false)]); + assert_eq!( + &lid[4..], + [W::Lid(true), W::Lid(false)], + "once more, kept until the end" + ); + assert!(logo.falls() >= 10); + } + + #[test] + fn a_logo_the_daemon_takes_back_is_told_again() { + let logo = FakeLogo::new(MODE_AUTO); + logo.close_the_lid(); + let (marker, _) = temp_marker("lid-taken"); + let mut taken = false; + run_until(&logo, &marker, BlinkStyle::Breathe, secs(80), |logo, now| { + if now >= secs(30) && !taken { + // A keyboard came up: the daemon tells every module the lid is closed. + logo.told_open.set(false); + taken = true; + } + }); + assert_eq!(logo.lid_writes(), [W::Lid(true), W::Lid(true), W::Lid(false)]); + } + + #[test] + fn the_lid_opening_ends_it_without_a_word_to_the_lid() { + let logo = FakeLogo::new(MODE_AUTO); + logo.close_the_lid(); + let (marker, path) = temp_marker("lid-opens"); + run_until(&logo, &marker, BlinkStyle::Calm, secs(40), |logo, now| { + if now >= secs(20) && !logo.awake.get() { + logo.open_the_lid(); + } + }); + assert_eq!(logo.lid_writes(), [W::Lid(true)], "the daemon told it the lid is open"); + assert!( + logo.writes_since(secs(21)).contains(&W::Keyboards(true)), + "the keyboards follow the blink again" + ); + assert_eq!(logo.settled_level(), 1.0); + assert!(!path.exists()); + } + + #[test] + fn on_battery_or_with_keyboards_out_of_reach_a_closed_lid_stays_dark() { + for (battery, keyboards) in [(true, true), (false, false)] { + let logo = FakeLogo::new(MODE_AUTO); + logo.battery.set(battery); + logo.keyboards_reachable.set(keyboards); + logo.close_the_lid(); + let (marker, _) = temp_marker("lid-never"); + run(&logo, &marker, BlinkStyle::Calm, secs(30)); + assert_eq!(logo.lid_writes(), [], "battery {battery}, keyboards {keyboards}"); + assert!(!logo.writes().contains(&W::Heading(false)), "battery {battery}"); + } + } + + #[test] + fn a_display_that_only_sleeps_is_not_a_closed_lid() { + for lid in [Some(false), None] { + let logo = FakeLogo::new(MODE_AUTO); + logo.awake.set(false); + logo.lid.set(lid); + let (marker, _) = temp_marker("lid-asleep"); + run(&logo, &marker, BlinkStyle::Calm, secs(30)); + assert_eq!(logo.writes(), [], "{lid:?}"); + } + } + + #[test] + fn recover_darkens_a_logo_a_killed_child_left_lit_under_the_closed_lid() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("lid-killed"); + assert!(marker.set()); + logo.close_the_lid(); + assert!(logo.tell_lid(true)); + recover(&logo, &marker, &Marker(None)); + let writes = logo.writes(); + assert_eq!(&writes[writes.len() - 2..], [W::Heading(true), W::Lid(false)]); + assert!(!path.exists()); + } + #[test] fn recover_needs_a_marker() { let logo = FakeLogo::new(MODE_OFF); From 4c53d424a72c520ff31fee785fff4bea71b1ec26 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 10 Nov 2026 21:41:49 +0100 Subject: [PATCH 2/6] fix(desktop): a firmer proof under the closed lid, and a stderr note that cannot abort The proof that the logo moves under the closed lid now needs a fade level above 0.01, not just above 0, so no drift of a fade held at 0 passes for a rise; the slowest rise (the breath's) is past it in about 80 ms. The note on stderr is a writeln! whose error is dropped: eprintln! panics on a failed write, and the blink child has panic = "abort". Co-Authored-By: Claude Opus 5.5 --- packages/desktop/src/logo.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index dee062fa..2d3d3681 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -451,6 +451,10 @@ const LID_PROOF_WAIT: Duration = Duration::from_millis(1500); /// How often the logo is told the lid is open, for one closed lid and the same /// displays on, before it is left dark. const LID_TRIES: u32 = 2; +/// The fade level that counts as the logo having moved under the closed lid: +/// above any drift of a fade held at 0, and reached in well under the proof's +/// wait by the slowest rise (the breath's). +const LID_MOVED: f64 = 0.01; /// 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. Not below the 3 s /// the server waits before it judges a new dialog: a prompt that was answered @@ -731,16 +735,20 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut if from_dark || proving { // Held back until the display shows a frame: time the light from when // it moves, so a held-back rise lengthens the dark, not shortens the light. - let gate = if proving { LID_PROOF_WAIT } else { GATE_WAIT }; + let (gate, still) = if proving { + (LID_PROOF_WAIT, LID_MOVED) + } else { + (GATE_WAIT, 0.0) + }; let mut waited = Duration::ZERO; - while waited < gate && logo.factor().is_some_and(|factor| factor <= 0.0) { + while waited < gate && logo.factor().is_some_and(|factor| factor <= still) { if wait(RISE_POLL) { break 'blink; } waited += RISE_POLL; } if proving { - if logo.factor().is_some_and(|factor| factor > 0.0) { + if logo.factor().is_some_and(|factor| factor > LID_MOVED) { lid.proven = true; note( logo, @@ -796,7 +804,7 @@ fn through_the_lid<'a, L: Logo>(logo: &'a L, fader: &mut Fader<'a, L>, lid: &mut lid.tries = 0; } if fader.lid_open { - if !lid.proven || logo.factor().is_some_and(|factor| factor > 0.0) { + if !lid.proven || logo.factor().is_some_and(|factor| factor > LID_MOVED) { return true; } // Dark although its last rise lit it: the daemon told the logo the lid is @@ -824,7 +832,9 @@ fn through_the_lid<'a, L: Logo>(logo: &'a L, fader: &mut Fader<'a, L>, lid: &mut /// A line on stderr about the closed lid, which `open -a Codeman --stderr ` /// keeps: how blinking under it is checked on a real Mac. fn note(logo: &impl Logo, text: &str) { - eprintln!("codeman logo: {text} ({})", logo.describe()); + // Not eprintln!, which panics on a failed write, and the child has + // `panic = "abort"`: a broken stderr must not stop the blink mid-pattern. + let _ = writeln!(std::io::stderr().lock(), "codeman logo: {text} ({})", logo.describe()); } /// Put back a logo that a blink could not finish with. From c307d63d3b78b3bb3c3fe069e2995f935e5d269d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 10 Nov 2026 22:25:22 +0100 Subject: [PATCH 3/6] fix(desktop): under a closed lid, just keep blinking, and say what happened RC3's lid path went dark on the real MacBook without a word: it waited for CBClamshellState, gave up after two tries, and wrote ClamshellUpdate 1 back on every failed proof, and none of its silent paths left a trace without a Terminal launch. Now, while the lid sensor (AppleClamshellState) says closed, the built-in display is off and the Mac is on its power adapter, the logo is told the lid is open (and each keyboard that it is closed) at the start of every cycle, with no tries and no give-up: monotone, so nothing is toggled in between. With no other display on the display sleeps, which also keeps the logo dark, so if the first rise does not move it the logo is told the display woke (DisplayPowerStateEvent 2); DidSleep is written back at the end only if that was sent. The end of a blink, and the marker recovery, still put the logo back to dark as a closed lid has it. What the closed lid did last is a line in the View menu, under Apple Logo Blink Styles: "Lid Closed: blinking", "blinking (display told awake)", "stayed dark, macOS keeps the logo off", or why it was not tried (on battery, no keyboard to keep dark, no lid sensor), with the time. The child writes it to logo-lid next to desktop.json, with the readings on a second line; the app reads it every two seconds. The battery and keyboard gates stay: the lid helper only holds on the adapter, and a keyboard told the lid is open would light under it. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/README.md | 43 +-- packages/desktop/src/logo.rs | 603 ++++++++++++++++++++++++----------- packages/desktop/src/main.rs | 14 + packages/desktop/src/menu.rs | 8 + 4 files changed, 470 insertions(+), 198 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 90aed13f..79c0ece9 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 says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. If the logo does not move that way, it stays dark. On battery, and while the display only sleeps with the lid open, the blink pauses 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. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does, and, with no display on, that the display is awake. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses 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) @@ -250,21 +250,24 @@ while their fall is still under way and never quite reach dark, and a long one ( Slow's, the breath's rest) times its light from when the rise starts. With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and the logo's -target is then 0 whatever `DisplayLogicalState` says. A client's `ClamshellUpdate` reaches the -same modules, which is how LogoLight measured it on the real Mac: once the daemon has handled -the lid (`CBClamshellState` reads 1), the child writes `ClamshellUpdate` 0 and then 1 to each -keyboard alone, so the logo can move and the keyboard stays dark. It does that only on the -power adapter (on battery a closed MacBook sleeps, and a dark wake must not light the logo in -a bag), and only with every keyboard reachable. The first rise after it has to move the logo -within 1.5 s; if it does not, the child writes `ClamshellUpdate` 1 again and tries once more, -and then leaves the logo dark until the lid opens, a display comes on or the agents' state -changes. When the logo goes dark again by itself (the daemon sends the lid state again when a -display or a keyboard comes up), it is told once more. Ending a blink with the lid closed hands -the logo back to normal (the automatic duration, then Unblank, which a held level needs) and -then writes `ClamshellUpdate` 1, since normal for a closed lid is dark; the marker covers a -child killed in between. When the lid opens, the daemon tells every module itself and nothing -is written. Each of these steps is a line on the app's stderr (`open -a Codeman --stderr -`), so a test on a real Mac shows which one stopped the blink. +target is then 0 whatever `DisplayLogicalState` says; the same goes while the display sleeps +(`DisplayPowerStateEvent` WillSleep or DidSleep). A client's `ClamshellUpdate` reaches the +same modules, which is how LogoLight measured it on the real Mac with an external display. +So while the lid sensor (`AppleClamshellState`) says closed, the built-in display is off and +the Mac is on its power adapter (on battery a closed MacBook sleeps, and a dark wake must not +light the logo in a bag), the child writes `ClamshellUpdate` 0 and then 1 to each keyboard +alone at the start of every cycle: said again each time, because the daemon repeats the real +lid state whenever a display or a keyboard comes up, and handles a closing lid a moment after +the sensor. If the first rise does not move the logo within 1.5 s and no other display is on, +the display sleeps, so the child also writes `DisplayPowerStateEvent` 2 (DidWake). Ending a +blink with the lid closed hands the logo back to normal (the automatic duration, then +Unblank, which a held level needs), then writes DidSleep (only if DidWake was written and no +other display is on) and `ClamshellUpdate` 1, since normal for a closed lid is dark; the +marker covers a child killed in between. When the lid opens, the daemon tells every module +itself and nothing is written. What the closed lid did last (blinking, blinking once the +display was told awake, stayed dark, or not tried and why) goes to a file next to +`desktop.json` (`logo-lid`, with the readings behind it on its second line), and the app +shows its first line as **Lid Closed: …** under View > Apple Logo Blink Styles. 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 @@ -350,9 +353,11 @@ crosses, so the login is never on a command line. prompt-cache keep-warm on, a keep-alive turn blinks too. - **Blinking with the lid closed has not been seen on a real MacBook yet.** LogoLight measured the lid override with an external display attached. With - none, the Mac kept awake by the lid helper, the daemon may still hold the - logo dark (it does for a display that sleeps); the app then leaves it dark - and says so on stderr. Not checked either, by LogoLight or here: whether + none, the Mac kept awake by the lid helper, the display sleeps, and the app + also tells the logo the display woke (`DisplayPowerStateEvent`), which nobody + has tried: it reaches every module the way `ClamshellUpdate` does, and what it + does to anything but the logo is not known. If neither moves the logo, the + menu line says "stayed dark". Not checked either, by LogoLight or here: whether telling the display the lid is open changes anything on an external display, or the built-in display's brightness when the lid opens again. - **Self-signed HTTPS is refused.** A server started with `codeman web --https` diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index 2d3d3681..b5d2f875 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -40,18 +40,20 @@ //! //! With the lid closed `corebrightnessd` keeps the logo dark: it tells every //! module the lid is closed, and the logo's target is then 0 whatever -//! `DisplayLogicalState` says. While the Mac stays awake with the lid closed on -//! its power adapter (Keep This Mac Awake's lid helper, or an external display), -//! the blink goes on, the way LogoLight's Lit with Lid Closed works (its -//! `docs/corebrightnessd.md`, "The lid"): once the daemon has handled the lid -//! (`CBClamshellState`), the logo is told the lid is open (`ClamshellUpdate` 0) -//! and the keyboards alone that it is closed, so they stay dark. The first rise -//! after that has to move the logo. If it does not (the daemon may still keep -//! it dark for a display that sleeps), the logo is told the lid is closed again -//! and left dark. A blink that ends with the lid closed puts the logo back to -//! normal, and normal for a closed lid is dark. Each step of this is a line on -//! stderr, so a test on a real Mac (`open -a Codeman --stderr `) shows -//! which one stopped it. +//! `DisplayLogicalState` says; the same goes while the display sleeps. While the +//! Mac stays awake with the lid closed on its power adapter (Keep This Mac +//! Awake's lid helper, or an external display), the blink simply goes on. At +//! every cycle the logo is told the lid is open (`ClamshellUpdate` 0) and the +//! keyboards alone that it is closed, so they stay dark: the way LogoLight's Lit +//! with Lid Closed works (its `docs/corebrightnessd.md`, "The lid"), said again +//! each time because the daemon repeats the real lid state whenever a display or +//! a keyboard comes up. If the first rise does not move the logo and no other +//! display is on, the display sleeps, so the logo is also told the display woke +//! (`DisplayPowerStateEvent` 2). A blink that ends with the lid closed puts the +//! logo back to normal and tells it the display sleeps and the lid is closed, so +//! it is dark as a closed lid has it. What happened is the line under View > +//! Blink Apple Logo (`LidStatus`, through a file next to the app's settings), +//! and each step is a line on stderr. //! //! All of this runs in a child process (this same binary with `BLINK_ARG`), for //! as long as the switch is on and a server is open. The app hands it the @@ -444,13 +446,10 @@ 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 the first rise after the logo was told the lid is open may take to -/// move it: the display-ready gate waits up to a second, and LogoLight saw that -/// rise start 0.5 to 0.8 s after the write. +/// How long the first rise under the closed lid may take to move the logo: the +/// display-ready gate waits up to a second, and LogoLight saw that rise start +/// 0.5 to 0.8 s after the write. const LID_PROOF_WAIT: Duration = Duration::from_millis(1500); -/// How often the logo is told the lid is open, for one closed lid and the same -/// displays on, before it is left dark. -const LID_TRIES: u32 = 2; /// The fade level that counts as the logo having moved under the closed lid: /// above any drift of a fade held at 0, and reached in well under the proof's /// wait by the slowest rise (the breath's). @@ -483,20 +482,25 @@ pub trait Logo { fn display_awake(&self) -> bool; /// Another app drives the logo the same way. fn driven_elsewhere(&self) -> bool; - /// `CBClamshellState`: whether `corebrightnessd` has handled a closed lid. Only - /// a real lid change moves it, whatever `tell_lid` said since. None if it - /// cannot say. + /// The lid is closed: the lid sensor (`AppleClamshellState`, what `ioreg -k + /// AppleClamshellState` shows). None if it cannot be read. fn lid_closed(&self) -> Option; /// `ClamshellUpdate`: tell the logo the lid is open and then the keyboards /// alone that it is closed, so they stay dark (`open`), or tell everything /// it is closed. False if that was not done: opening it needs the keyboards /// within reach, or a keyboard would light under the closed lid. fn tell_lid(&self, open: bool) -> bool; + /// `DisplayPowerStateEvent`: tell the logo the display woke (2, DidWake) or + /// went to sleep (4, DidSleep). The logo is also kept dark while the display + /// sleeps, which with the lid closed and no other display on it does. + fn tell_display(&self, awake: bool) -> bool; + /// What the closed lid did this time, for the line in the View menu. + fn lid_status(&self, status: LidStatus); /// The Mac runs on battery. It sleeps with the lid closed then, and a dark /// wake (Power Nap) must not light the logo in a bag. fn on_battery(&self) -> bool; - /// How many displays other than the built-in one are on. A change gives a - /// closed lid another try: an external display that woke. + /// How many displays other than the built-in one are on. With one, the display + /// is never told it woke: it has not gone to sleep. fn other_displays_awake(&self) -> usize; /// What a line on stderr says about the logo, for a test on a real Mac. fn describe(&self) -> String { @@ -504,6 +508,33 @@ pub trait Logo { } } +/// What the closed lid did, as the line under View > Blink Apple Logo says it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LidStatus { + /// Told the lid is open, the logo moved. + Blinking, + /// It moved once the display was told it woke as well. + BlinkingDisplayWoken, + /// Neither moved it: macOS keeps the logo dark under this closed lid. + StayedDark, + OnBattery, + NoKeyboards, + NoLidSensor, +} + +impl LidStatus { + pub fn text(self) -> &'static str { + match self { + LidStatus::Blinking => "blinking", + LidStatus::BlinkingDisplayWoken => "blinking (display told awake)", + LidStatus::StayedDark => "stayed dark, macOS keeps the logo off", + LidStatus::OnBattery => "not tried, on battery", + LidStatus::NoKeyboards => "not tried, no keyboard to keep dark", + LidStatus::NoLidSensor => "not tried, the lid sensor could not be read", + } + } +} + /// Moves the logo, and keeps what cannot be read back: which way it heads, and /// what the keyboards were last told. struct Fader<'a, L: Logo> { @@ -516,6 +547,8 @@ struct Fader<'a, L: Logo> { keyboards: Option, /// The logo was told the lid is open while it is closed (`open_lid`). lid_open: bool, + /// The logo was told the display woke while it sleeps (`wake_display`). + display_woken: bool, /// Anything was written. touched: bool, /// Every write was taken. @@ -529,6 +562,7 @@ impl<'a, L: Logo> Fader<'a, L> { heading: None, keyboards: None, lid_open: false, + display_woken: false, touched: false, ok: true, } @@ -592,19 +626,39 @@ impl<'a, L: Logo> Fader<'a, L> { } } - /// Lets the logo move under a closed lid (`Logo::tell_lid`). + /// Lets the logo move under a closed lid (`Logo::tell_lid`). Said again at + /// every cycle: the daemon tells it the lid is closed again whenever a + /// display or a keyboard comes up, and when it has handled the lid late. fn open_lid(&mut self) -> bool { - // A refusal has written nothing, or put back what it wrote. - self.lid_open = self.logo.tell_lid(true); - self.touched |= self.lid_open; - self.lid_open + // A refusal has written nothing. + let open = self.logo.tell_lid(true); + self.lid_open |= open; + self.touched |= open; + open } - /// Ends `open_lid`: the logo goes dark, as a closed lid has it. Once the lid - /// has really opened the daemon has told every module so itself, and nothing - /// is written. + /// Tells the logo the display woke, for a closed lid with no display on. + fn wake_display(&mut self) { + self.touched = true; + self.display_woken |= self.logo.tell_display(true); + } + + /// Ends `open_lid` and `wake_display`: the logo goes dark, as a closed lid + /// has it. Once the lid has really opened the daemon has told every module + /// so itself, and nothing is written; nor is the display said to sleep once + /// another display is on. fn close_lid(&mut self) { - if std::mem::take(&mut self.lid_open) && self.logo.lid_closed() == Some(true) { + let (open, woken) = ( + std::mem::take(&mut self.lid_open), + std::mem::take(&mut self.display_woken), + ); + if self.logo.lid_closed() != Some(true) { + return; + } + if woken && self.logo.other_displays_awake() == 0 { + self.ok &= self.logo.tell_display(false); + } + if open { self.ok &= self.logo.tell_lid(false); } } @@ -704,7 +758,7 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut fader.display_woke(); asleep = false; } - if std::mem::take(&mut fader.lid_open) { + if std::mem::take(&mut fader.lid_open) | std::mem::take(&mut fader.display_woken) { // The daemon told every module itself when the lid opened. note(logo, "the lid opened"); } @@ -722,7 +776,7 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut } } } - let proving = step.lit && fader.lid_open && !lid.proven; + let proving = step.lit && fader.lid_open && !lid.proven && !lid.dark; let from_dark = step.lit && logo.factor().is_some_and(|factor| factor <= 0.0); // With the display off the keyboards are not touched: under a closed lid // they stay dark. @@ -748,22 +802,7 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut waited += RISE_POLL; } if proving { - if logo.factor().is_some_and(|factor| factor > LID_MOVED) { - lid.proven = true; - note( - logo, - &format!("the logo rose under the closed lid after {} ms", waited.as_millis()), - ); - } else { - fader.close_lid(); - note( - logo, - &format!( - "the logo did not rise under the closed lid within {} ms: told the lid is closed again", - gate.as_millis() - ), - ); - } + lid.proved(logo, &mut fader, logo.factor().is_some_and(|factor| factor > LID_MOVED)); } left = left.saturating_sub(START_LATENCY); } @@ -781,52 +820,89 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut /// What a closed lid has shown during one blink. #[derive(Debug, Default)] struct Lid { - /// The logo moved since it was last told the lid is open. + /// The logo moved under it. proven: bool, - /// How often it was told so with these displays on. - tries: u32, - /// How many other displays were on for those tries. - displays: Option, + /// Nothing moved it, the display told it woke included. + dark: bool, + /// The last status written, so it is written once per change. + status: Option, +} + +impl Lid { + fn report(&mut self, logo: &impl Logo, status: LidStatus) { + if self.status != Some(status) { + self.status = Some(status); + logo.lid_status(status); + note(logo, status.text()); + } + } + + /// The first rise under the closed lid moved the logo, or did not. If it did + /// not, and no other display is on (so the display sleeps, which keeps the + /// logo dark too), the display is told it woke and the next rise proves it. + fn proved<'a, L: Logo>(&mut self, logo: &'a L, fader: &mut Fader<'a, L>, moved: bool) { + if moved { + self.proven = true; + let status = if fader.display_woken { + LidStatus::BlinkingDisplayWoken + } else { + LidStatus::Blinking + }; + self.report(logo, status); + } else if !fader.display_woken && logo.other_displays_awake() == 0 { + note( + logo, + "the logo did not rise under the closed lid: telling it the display woke", + ); + fader.wake_display(); + } else { + self.dark = true; + self.report(logo, LidStatus::StayedDark); + } + } } /// At the start of a cycle with the built-in display off: whether the logo can -/// be seen through a closed lid, telling it the lid is open when that is worth a -/// try. Never while the display only sleeps (the lid is open), nor on battery. +/// be seen through a closed lid, telling it again that the lid is open. Never +/// while the display only sleeps (the lid is open), nor on battery. fn through_the_lid<'a, L: Logo>(logo: &'a L, fader: &mut Fader<'a, L>, lid: &mut Lid) -> bool { - if logo.lid_closed() != Some(true) || logo.on_battery() { + match logo.lid_closed() { + Some(true) => {} + Some(false) => { + fader.close_lid(); + *lid = Lid::default(); + return false; + } + None => { + lid.report(logo, LidStatus::NoLidSensor); + return false; + } + } + if logo.on_battery() { fader.close_lid(); lid.proven = false; + lid.report(logo, LidStatus::OnBattery); return false; } - let displays = logo.other_displays_awake(); - if lid.displays != Some(displays) { - lid.displays = Some(displays); - lid.tries = 0; + if lid.dark && logo.factor().is_some_and(|factor| factor > LID_MOVED) { + lid.dark = false; + lid.proven = true; + lid.report(logo, LidStatus::Blinking); } - if fader.lid_open { - if !lid.proven || logo.factor().is_some_and(|factor| factor > LID_MOVED) { - return true; - } + if lid.proven && logo.factor().is_some_and(|factor| factor <= LID_MOVED) { // Dark although its last rise lit it: the daemon told the logo the lid is - // closed again, as it does when a display or a keyboard comes up. - fader.lid_open = false; - note(logo, "the logo went dark under the closed lid"); + // closed again (a display or a keyboard came up), or the display slept. + note(logo, "the logo went dark under the closed lid: telling it again"); + lid.proven = false; + if fader.display_woken { + fader.wake_display(); + } } - lid.proven = false; - if lid.tries >= LID_TRIES { + if !fader.open_lid() { + lid.report(logo, LidStatus::NoKeyboards); return false; } - lid.tries += 1; - let open = fader.open_lid(); - note( - logo, - if open { - "the lid is closed: told the logo it is open, and the keyboards that it is closed" - } else { - "the lid is closed, and the keyboards cannot be kept dark: the logo stays dark" - }, - ); - open + true } /// A line on stderr about the closed lid, which `open -a Codeman --stderr ` @@ -848,8 +924,11 @@ pub fn recover(logo: &impl Logo, marker: &Marker, legacy: &Marker) { let mut fader = Fader::new(logo); // Which way it heads is not known: `restore` turns it round first. fader.touched = true; - // A child killed with the lid closed may have told the logo it is open. - fader.lid_open = !logo.display_awake() && logo.lid_closed() == Some(true); + // A child killed with the lid closed may have told the logo it is open, + // and the display that it woke. + let under_the_lid = !logo.display_awake() && logo.lid_closed() == Some(true); + fader.lid_open = under_the_lid; + fader.display_woken = under_the_lid; if fader.restore() { marker.clear(); } @@ -991,6 +1070,37 @@ fn spawn_child(connection: Connection) -> Option<(Connection, ChildStdin)> { Some((connection, stdin)) } +/// Where the child leaves what the closed lid did last (`Logo::lid_status`): +/// next to the app's settings, like the marker. +fn lid_status_path() -> Option { + dirs::config_dir().map(|dir| dir.join("Codeman").join("logo-lid")) +} + +/// The line under View > Blink Apple Logo. +pub fn lid_status_line() -> String { + let last = lid_status_path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| text.lines().next().map(str::to_owned)) + .filter(|line| !line.is_empty()); + format!("Lid Closed: {}", last.as_deref().unwrap_or("not tried yet")) +} + +/// Calls `changed` with the line whenever the child writes a new one. A thread +/// that reads one small file every two seconds, for the life of the app. +pub fn watch_lid_status(changed: impl Fn(String) + Send + 'static) { + std::thread::spawn(move || { + let mut shown = lid_status_line(); + loop { + std::thread::sleep(Duration::from_secs(2)); + let line = lid_status_line(); + if line != shown { + shown = line; + changed(shown.clone()); + } + } + }); +} + #[cfg(target_os = "macos")] pub use system::{available, blink_main, driven_elsewhere, recover_at_launch, switched_off}; @@ -1019,13 +1129,13 @@ pub fn driven_elsewhere() -> bool { #[cfg(target_os = "macos")] mod system { - use super::{recover, watch, Connection, Logo, Marker, Signals, MODE_OFF}; + use super::{recover, watch, Connection, LidStatus, Logo, Marker, Signals, MODE_OFF}; use crate::agents::{Activity, Poll, Poller, POLL_EVERY}; use objc2::rc::{autoreleasepool, Retained}; use objc2::runtime::{AnyClass, AnyObject, Bool}; use objc2::{msg_send, sel}; use objc2_foundation::{ns_string, NSBundle, NSDictionary, NSNumber, NSString}; - use std::ffi::c_void; + use std::ffi::{c_char, c_void}; use std::io::Read; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::sync::{Arc, Mutex, OnceLock}; @@ -1046,11 +1156,23 @@ mod system { extern "C" { fn IOPSCopyPowerSourcesInfo() -> *const c_void; fn IOPSGetProvidingPowerSourceType(snapshot: *const c_void) -> *const c_void; + fn IOServiceMatching(name: *const c_char) -> *mut c_void; + fn IOServiceGetMatchingService(main_port: u32, matching: *mut c_void) -> u32; + fn IORegistryEntryCreateCFProperty( + entry: u32, + key: *const c_void, + allocator: *const c_void, + options: u32, + ) -> *const c_void; + fn IOObjectRelease(object: u32) -> i32; } #[link(name = "CoreFoundation", kind = "framework")] extern "C" { fn CFRelease(object: *const c_void); + fn CFGetTypeID(object: *const c_void) -> usize; + fn CFBooleanGetTypeID() -> usize; + fn CFBooleanGetValue(boolean: *const c_void) -> u8; } struct SystemLogo { @@ -1199,10 +1321,7 @@ mod system { } fn lid_closed(&self) -> Option { - autoreleasepool(|_| { - let value = self.property(ns_string!("CBClamshellState"))?; - Some(value.downcast_ref::()?.as_bool()) - }) + lid_sensor() } fn tell_lid(&self, open: bool) -> bool { @@ -1230,6 +1349,28 @@ mod system { }) } + fn tell_display(&self, awake: bool) -> bool { + autoreleasepool(|_| { + self.set( + &NSNumber::new_i64(if awake { 2 } else { 4 }), + ns_string!("DisplayPowerStateEvent"), + ) + }) + } + + fn lid_status(&self, status: LidStatus) { + let text = format!("{} ({})\n{}\n", status.text(), clock(), self.describe()); + if let Some(path) = super::lid_status_path() { + let tmp = path.with_extension("tmp"); + // Written whole and renamed, so the app never reads half of it. + let written = path.parent().is_some_and(|dir| std::fs::create_dir_all(dir).is_ok()) + && std::fs::write(&tmp, text).is_ok(); + if written { + let _ = std::fs::rename(&tmp, &path); + } + } + } + fn on_battery(&self) -> bool { on_battery() } @@ -1241,10 +1382,12 @@ mod system { fn describe(&self) -> String { autoreleasepool(|_| { format!( - "factor {:?}, FinalNits {:?}, CBClamshellState {:?}, built-in display on {}, other displays on {}, on battery {}", + "factor {:?}, FinalNits {:?}, lid sensor {:?}, CBClamshellState {:?}, built-in display on {}, other displays on {}, on battery {}", self.pwm(ns_string!("Factor")), self.pwm(ns_string!("FinalNits")), - self.lid_closed(), + lid_sensor(), + self.property(ns_string!("CBClamshellState")) + .and_then(|value| value.downcast_ref::().map(|n| n.as_i64())), built_in_display_awake(), other_displays_awake(), on_battery() @@ -1253,6 +1396,49 @@ mod system { } } + /// The lid sensor: `AppleClamshellState` on the power management root, the + /// same entry Keep This Mac Awake reads to tell a MacBook. + fn lid_sensor() -> Option { + let key = NSString::from_str("AppleClamshellState"); + // SAFETY: IOServiceMatching returns a dictionary that + // IOServiceGetMatchingService consumes; the service is released below. + // IORegistryEntryCreateCFProperty returns an owned CFTypeRef or NULL, takes + // the NSString as its toll-free bridged CFString, and is released here; its + // type is checked before it is read as a CFBoolean. + unsafe { + let matching = IOServiceMatching(c"IOPMrootDomain".as_ptr()); + if matching.is_null() { + return None; + } + let root = IOServiceGetMatchingService(0, matching); + if root == 0 { + return None; + } + let value = IORegistryEntryCreateCFProperty(root, Retained::as_ptr(&key).cast(), std::ptr::null(), 0); + IOObjectRelease(root); + if value.is_null() { + return None; + } + let closed = (CFGetTypeID(value) == CFBooleanGetTypeID()).then(|| CFBooleanGetValue(value) != 0); + CFRelease(value); + closed + } + } + + /// The local time as hours and minutes, for the status line. + fn clock() -> String { + // SAFETY: time(NULL) answers the time; localtime_r fills the struct it is + // given and answers NULL on failure. + unsafe { + let now = libc::time(std::ptr::null_mut()); + let mut tm: libc::tm = std::mem::zeroed(); + if libc::localtime_r(&now, &mut tm).is_null() { + return String::new(); + } + format!("{:02}:{:02}", tm.tm_hour, tm.tm_min) + } + } + /// How many displays other than the built-in one are on (an external display /// that sleeps does not count). fn other_displays_awake() -> usize { @@ -1450,6 +1636,8 @@ mod system { println!("built-in display awake: {}", built_in_display_awake()); println!("other displays awake: {}", other_displays_awake()); println!("on battery: {}", on_battery()); + println!("lid sensor: {:?}", lid_sensor()); + println!("clock: {}", clock()); println!("driven elsewhere: {}", driven_elsewhere()); println!("finds the shell that ran this (bash): {}", running(b"bash")); println!("keyboard backlight IDs: {:?}", keyboard_ids()); @@ -1528,6 +1716,8 @@ mod tests { /// `ClamshellUpdate`: true is "the lid is open" to the logo (and closed /// to the keyboards), false is "closed" to everything. Lid(bool), + /// `DisplayPowerStateEvent`: true is DidWake, false DidSleep. + Display(bool), } #[derive(Debug, Clone, Copy)] @@ -1541,8 +1731,8 @@ mod tests { /// A `corebrightnessd` that moves like the real one: in frames, on the spring, /// starting a little after the write, only on a change of direction, and with /// a rise from exactly dark held back by `gate`. Asleep, the logo is dark, - /// and so it is under a closed lid unless told the lid is open, with - /// `through_lid` saying whether that lets it move. + /// and so it is under a closed lid unless told the lid is open, which moves + /// it if `lid_alone`, or with the display told it woke if `with_wake`. struct FakeLogo { now: Rc>, mode: Cell, @@ -1551,7 +1741,11 @@ mod tests { lid: Cell>, /// What the logo was last told about the lid (true: open). told_open: Cell, - through_lid: Cell, + /// What the logo was last told about the display (true: it woke). + told_awake: Cell, + lid_alone: Cell, + with_wake: Cell, + statuses: RefCell>, keyboards_reachable: Cell, battery: Cell, displays: Cell, @@ -1574,7 +1768,10 @@ mod tests { awake: Cell::new(true), lid: Cell::new(Some(false)), told_open: Cell::new(false), - through_lid: Cell::new(true), + told_awake: Cell::new(false), + lid_alone: Cell::new(true), + with_wake: Cell::new(true), + statuses: RefCell::new(Vec::new()), keyboards_reachable: Cell::new(true), battery: Cell::new(false), displays: Cell::new(0), @@ -1604,6 +1801,7 @@ mod tests { self.awake.set(false); self.lid.set(Some(true)); self.told_open.set(false); + self.told_awake.set(false); } /// The lid opens: the daemon tells every module so, and the display is on. @@ -1611,17 +1809,28 @@ mod tests { self.awake.set(true); self.lid.set(Some(false)); self.told_open.set(false); + self.told_awake.set(false); } /// The logo can be seen: the display is on, or the lid is closed and the /// logo was told it is open, and that works on this Mac. fn shown(&self) -> bool { - self.awake.get() || (self.lid.get() == Some(true) && self.told_open.get() && self.through_lid.get()) + self.awake.get() + || (self.lid.get() == Some(true) + && self.told_open.get() + && (self.lid_alone.get() || (self.told_awake.get() && self.with_wake.get()))) } - /// The `ClamshellUpdate` writes. + /// The `ClamshellUpdate` and `DisplayPowerStateEvent` writes. fn lid_writes(&self) -> Vec { - self.writes().into_iter().filter(|w| matches!(w, W::Lid(_))).collect() + self.writes() + .into_iter() + .filter(|w| matches!(w, W::Lid(_) | W::Display(_))) + .collect() + } + + fn statuses(&self) -> Vec { + self.statuses.borrow().clone() } /// The writes made from `from` on. @@ -1779,6 +1988,19 @@ mod tests { true } + fn tell_display(&self, awake: bool) -> bool { + if self.refuse_writes.get() { + return false; + } + self.record(W::Display(awake)); + self.told_awake.set(awake); + true + } + + fn lid_status(&self, status: LidStatus) { + self.statuses.borrow_mut().push(status); + } + fn on_battery(&self) -> bool { self.battery.get() } @@ -2099,6 +2321,13 @@ mod tests { assert!(!path.exists()); } + /// Run `style` with the lid closed from the start, until `stop_at`. + fn under_the_lid(logo: &FakeLogo, style: BlinkStyle, stop_at: Duration) -> Ended { + logo.close_the_lid(); + let (marker, _) = temp_marker("under-lid"); + run(logo, &marker, style, stop_at) + } + #[test] fn with_the_lid_closed_it_blinks_on_and_the_keyboards_stay_dark() { for style in [BlinkStyle::Breathe, BlinkStyle::Calm, BlinkStyle::Alarm] { @@ -2127,109 +2356,96 @@ mod tests { !since[told..].contains(&W::Keyboards(true)), "{style:?}: no keyboard lit under the closed lid" ); + let lid = logo.lid_writes(); + assert!(lid.len() >= 5, "{style:?}: told again at every cycle"); + assert!( + lid[..lid.len() - 1].iter().all(|w| *w == W::Lid(true)), + "{style:?}: never told closed in between, never the display: {lid:?}" + ); // The end: back to normal, and then the lid is closed again, so it goes dark. assert_eq!( &since[since.len() - 2..], [W::Heading(true), W::Lid(false)], "{style:?}" ); - assert_eq!(logo.lid_writes(), [W::Lid(true), W::Lid(false)], "{style:?}"); + assert_eq!(logo.statuses(), [LidStatus::Blinking], "{style:?}"); assert_eq!(logo.last_duration(), Some(0.0)); assert!(!path.exists(), "{style:?}"); } } #[test] - fn the_logo_is_told_only_once_the_daemon_has_handled_the_lid() { + fn a_logo_the_display_sleep_keeps_dark_is_told_the_display_woke() { let logo = FakeLogo::new(MODE_AUTO); - logo.awake.set(false); - // Closed, but the daemon has not caught up: an override now would come - // before its own update and be undone. - logo.lid.set(Some(false)); - let (marker, _) = temp_marker("lid-late"); - run_until(&logo, &marker, BlinkStyle::Calm, secs(20), |logo, now| { - if now >= secs(7) { - logo.lid.set(Some(true)); - } - }); - let first = logo - .writes - .borrow() - .iter() - .find(|(_, w)| *w == W::Lid(true)) - .map(|&(at, _)| at) - .expect("told once the daemon had it"); - assert!(first >= secs(7), "told at {first:?}"); - } - - #[test] - fn a_closed_lid_that_keeps_the_logo_dark_is_left_dark() { - let logo = FakeLogo::new(MODE_AUTO); - // What the daemon may do with no display on: the logo stays at 0. - logo.through_lid.set(false); - let (marker, path) = temp_marker("lid-dark"); - let mut quiet_from = None; - run_until(&logo, &marker, BlinkStyle::Calm, secs(120), |logo, now| { - if now >= secs(5) && logo.awake.get() && quiet_from.is_none() { - logo.close_the_lid(); - } - if quiet_from.is_none() && logo.lid_writes().len() == 2 * LID_TRIES as usize { - quiet_from = Some(now); - } - }); - assert_eq!( - logo.lid_writes(), - [W::Lid(true), W::Lid(false), W::Lid(true), W::Lid(false)], - "two tries, each taken back" - ); - let quiet_from = quiet_from.expect("it gave up"); - let before_the_end: Vec<_> = logo - .writes - .borrow() - .iter() - .filter(|&&(at, _)| at > quiet_from + secs(1) && at < secs(120)) - .map(|&(_, w)| w) - .collect(); - assert_eq!(before_the_end, [], "then nothing, until something changes"); - assert!(path.exists(), "dark and unseen: the next launch puts it back"); - } - - #[test] - fn a_display_that_comes_on_gives_the_closed_lid_another_try() { - let logo = FakeLogo::new(MODE_AUTO); - logo.through_lid.set(false); - logo.close_the_lid(); - let (marker, _) = temp_marker("lid-display"); - run_until(&logo, &marker, BlinkStyle::Calm, secs(90), |logo, now| { - if now >= secs(60) { - logo.displays.set(1); - logo.through_lid.set(true); - } - }); + logo.lid_alone.set(false); + under_the_lid(&logo, BlinkStyle::Calm, secs(60)); let lid = logo.lid_writes(); - assert_eq!(&lid[..4], [W::Lid(true), W::Lid(false), W::Lid(true), W::Lid(false)]); + assert_eq!(lid.iter().filter(|w| **w == W::Display(true)).count(), 1, "{lid:?}"); assert_eq!( - &lid[4..], - [W::Lid(true), W::Lid(false)], - "once more, kept until the end" + &lid[lid.len() - 2..], + [W::Display(false), W::Lid(false)], + "put back at the end" ); + assert_eq!(logo.statuses(), [LidStatus::BlinkingDisplayWoken]); assert!(logo.falls() >= 10); } + #[test] + fn a_closed_lid_nothing_lights_says_so_and_puts_everything_back() { + let logo = FakeLogo::new(MODE_AUTO); + logo.lid_alone.set(false); + logo.with_wake.set(false); + under_the_lid(&logo, BlinkStyle::Calm, secs(60)); + let lid = logo.lid_writes(); + assert_eq!(lid.iter().filter(|w| **w == W::Display(true)).count(), 1, "{lid:?}"); + assert_eq!(&lid[lid.len() - 2..], [W::Display(false), W::Lid(false)]); + assert_eq!(logo.statuses(), [LidStatus::StayedDark]); + } + + #[test] + fn with_another_display_on_the_display_is_never_told_it_woke() { + let logo = FakeLogo::new(MODE_AUTO); + logo.lid_alone.set(false); + logo.displays.set(1); + under_the_lid(&logo, BlinkStyle::Calm, secs(30)); + assert!(!logo.lid_writes().iter().any(|w| matches!(w, W::Display(_)))); + assert_eq!(logo.statuses(), [LidStatus::StayedDark]); + } + + #[test] + fn a_dark_lid_that_lights_later_says_it_blinks() { + let logo = FakeLogo::new(MODE_AUTO); + logo.lid_alone.set(false); + logo.with_wake.set(false); + logo.close_the_lid(); + let (marker, _) = temp_marker("lid-later"); + run_until(&logo, &marker, BlinkStyle::Calm, secs(60), |logo, now| { + if now >= secs(30) { + logo.lid_alone.set(true); + } + }); + assert_eq!(logo.statuses(), [LidStatus::StayedDark, LidStatus::Blinking]); + } + #[test] fn a_logo_the_daemon_takes_back_is_told_again() { let logo = FakeLogo::new(MODE_AUTO); logo.close_the_lid(); let (marker, _) = temp_marker("lid-taken"); let mut taken = false; + let mut back = false; run_until(&logo, &marker, BlinkStyle::Breathe, secs(80), |logo, now| { if now >= secs(30) && !taken { // A keyboard came up: the daemon tells every module the lid is closed. logo.told_open.set(false); taken = true; } + if now >= secs(45) && logo.factor().is_some_and(|f| f > 0.5) { + back = true; + } }); - assert_eq!(logo.lid_writes(), [W::Lid(true), W::Lid(true), W::Lid(false)]); + assert!(back, "lit again under the lid"); + assert_eq!(logo.statuses(), [LidStatus::Blinking]); } #[test] @@ -2242,7 +2458,11 @@ mod tests { logo.open_the_lid(); } }); - assert_eq!(logo.lid_writes(), [W::Lid(true)], "the daemon told it the lid is open"); + let lid = logo.lid_writes(); + assert!( + !lid.is_empty() && lid.iter().all(|w| *w == W::Lid(true)), + "the daemon told it the lid is open: {lid:?}" + ); assert!( logo.writes_since(secs(21)).contains(&W::Keyboards(true)), "the keyboards follow the blink again" @@ -2253,27 +2473,30 @@ mod tests { #[test] fn on_battery_or_with_keyboards_out_of_reach_a_closed_lid_stays_dark() { - for (battery, keyboards) in [(true, true), (false, false)] { + for (battery, keyboards, status) in [ + (true, true, LidStatus::OnBattery), + (false, false, LidStatus::NoKeyboards), + ] { let logo = FakeLogo::new(MODE_AUTO); logo.battery.set(battery); logo.keyboards_reachable.set(keyboards); - logo.close_the_lid(); - let (marker, _) = temp_marker("lid-never"); - run(&logo, &marker, BlinkStyle::Calm, secs(30)); - assert_eq!(logo.lid_writes(), [], "battery {battery}, keyboards {keyboards}"); - assert!(!logo.writes().contains(&W::Heading(false)), "battery {battery}"); + under_the_lid(&logo, BlinkStyle::Calm, secs(30)); + assert_eq!(logo.lid_writes(), [], "{status:?}"); + assert!(!logo.writes().contains(&W::Heading(false)), "{status:?}"); + assert_eq!(logo.statuses(), [status]); } } #[test] fn a_display_that_only_sleeps_is_not_a_closed_lid() { - for lid in [Some(false), None] { + for (lid, statuses) in [(Some(false), vec![]), (None, vec![LidStatus::NoLidSensor])] { let logo = FakeLogo::new(MODE_AUTO); logo.awake.set(false); logo.lid.set(lid); let (marker, _) = temp_marker("lid-asleep"); run(&logo, &marker, BlinkStyle::Calm, secs(30)); assert_eq!(logo.writes(), [], "{lid:?}"); + assert_eq!(logo.statuses(), statuses, "{lid:?}"); } } @@ -2286,10 +2509,32 @@ mod tests { assert!(logo.tell_lid(true)); recover(&logo, &marker, &Marker(None)); let writes = logo.writes(); - assert_eq!(&writes[writes.len() - 2..], [W::Heading(true), W::Lid(false)]); + assert_eq!( + &writes[writes.len() - 3..], + [W::Heading(true), W::Display(false), W::Lid(false)] + ); assert!(!path.exists()); } + #[test] + fn the_status_line_reads_like_the_menu() { + assert_eq!(LidStatus::Blinking.text(), "blinking"); + for status in [ + LidStatus::Blinking, + LidStatus::BlinkingDisplayWoken, + LidStatus::StayedDark, + LidStatus::OnBattery, + LidStatus::NoKeyboards, + LidStatus::NoLidSensor, + ] { + let text = status.text(); + assert!( + !text.is_empty() && text.len() < 45 && !text.contains('\n'), + "{status:?}" + ); + } + } + #[test] fn recover_needs_a_marker() { let logo = FakeLogo::new(MODE_OFF); diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 4145ce7f..94ae1068 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -184,6 +184,8 @@ enum UserEvent { /// install opened in Terminal, the install command copied): the message /// goes to the page if it is still the one choosing a server. ToConnectPage(serde_json::Value), + /// What the closed lid did with the logo last (logo::watch_lid_status). + LogoLidStatus(String), } struct ProbeDone { @@ -401,6 +403,13 @@ fn main() { logo_watch: logo::Watcher::default(), }; + if app.menu.logo_lid.is_some() { + let lid_proxy = app.proxy.clone(); + logo::watch_lid_status(move |line| { + let _ = lid_proxy.send_event(UserEvent::LogoLidStatus(line)); + }); + } + event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow)); } @@ -823,6 +832,11 @@ impl App { self.to_connect_page(msg); } } + UserEvent::LogoLidStatus(line) => { + if let Some(item) = &self.menu.logo_lid { + item.set_text(line); + } + } UserEvent::ServerStartDone { generation, url, diff --git a/packages/desktop/src/menu.rs b/packages/desktop/src/menu.rs index b07b006a..5d1e5bd2 100644 --- a/packages/desktop/src/menu.rs +++ b/packages/desktop/src/menu.rs @@ -32,6 +32,9 @@ pub struct AppMenu { pub logo_switch: Option, /// One item per state and style, empty without the switch. pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>, + /// What the closed lid did with the logo last, a line that cannot be + /// clicked. None without the switch. + pub logo_lid: Option, } pub fn build_menu(config: &Config) -> muda::Result { @@ -117,6 +120,7 @@ pub fn build_menu(config: &Config) -> muda::Result { // style for each state behind it. let mut logo_styles = Vec::new(); let mut logo_switch = None; + let mut logo_lid = None; if logo::available() { let switch = CheckMenuItem::with_id( menu_id::LOGO_BLINK, @@ -140,9 +144,12 @@ pub fn build_menu(config: &Config) -> muda::Result { submenu.append(&group)?; } // Right under "Always Open in Full Screen", before the separator. + let lid = MenuItem::new(logo::lid_status_line(), false, None); view.insert(&switch, 4)?; view.insert(&submenu, 5)?; + view.insert(&lid, 6)?; logo_switch = Some(switch); + logo_lid = Some(lid); } let window_menu = Submenu::with_items( "Window", @@ -172,5 +179,6 @@ pub fn build_menu(config: &Config) -> muda::Result { start_fullscreen, logo_switch, logo_styles, + logo_lid, }) } From ede99b309cf71c4a7528026b899ca99baabdeef4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 10 Nov 2026 22:36:50 +0100 Subject: [PATCH 4/6] fix(desktop): the lid sensor decides, and every lid close leaves a reason On RC4 the owner closed the lid with LogoLight not running and the menu line still said "not tried yet": the child never wrote anything. Three ways that could happen left no trace: the display list still showing the built-in display awake under the closed lid (the blink then never took the lid path), no blink running because no agent was at work, and the Mac going to sleep at the lid close. The lid sensor now wins: a closed lid counts as closed whatever the display list says. While no blink runs and the lid is closed, the line says why (nothing to show, no agent at work; the logo switched off; LogoLight has the logo; a refused change). The child notices a sleep as wall-clock time that passed while Instant stood still, and the line then says "the Mac went to sleep". With no child at all (switch off, no server open) the app says nothing watches the lid. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/README.md | 12 ++- packages/desktop/src/logo.rs | 185 +++++++++++++++++++++++++++++++++-- packages/desktop/src/main.rs | 20 +++- 3 files changed, 199 insertions(+), 18 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 79c0ece9..1e256b22 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -253,8 +253,8 @@ With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and target is then 0 whatever `DisplayLogicalState` says; the same goes while the display sleeps (`DisplayPowerStateEvent` WillSleep or DidSleep). A client's `ClamshellUpdate` reaches the same modules, which is how LogoLight measured it on the real Mac with an external display. -So while the lid sensor (`AppleClamshellState`) says closed, the built-in display is off and -the Mac is on its power adapter (on battery a closed MacBook sleeps, and a dark wake must not +So while the lid sensor (`AppleClamshellState`) says closed (it wins over a display list that +still shows the built-in display awake) and the Mac is on its power adapter (on battery a closed MacBook sleeps, and a dark wake must not light the logo in a bag), the child writes `ClamshellUpdate` 0 and then 1 to each keyboard alone at the start of every cycle: said again each time, because the daemon repeats the real lid state whenever a display or a keyboard comes up, and handles a closing lid a moment after @@ -265,9 +265,11 @@ Unblank, which a held level needs), then writes DidSleep (only if DidWake was wr other display is on) and `ClamshellUpdate` 1, since normal for a closed lid is dark; the marker covers a child killed in between. When the lid opens, the daemon tells every module itself and nothing is written. What the closed lid did last (blinking, blinking once the -display was told awake, stayed dark, or not tried and why) goes to a file next to -`desktop.json` (`logo-lid`, with the readings behind it on its second line), and the app -shows its first line as **Lid Closed: …** under View > Apple Logo Blink Styles. +display was told awake, stayed dark, nothing to show with no agent at work, not tried and why, +or that the Mac went to sleep, which the child sees as wall-clock time that passed while its +monotonic clock stood still) goes to a file next to `desktop.json` (`logo-lid`, with the +readings behind it on its second line), and the app shows its first line as **Lid Closed: …** +under View > Apple Logo Blink Styles, or says nothing watches the lid while no child runs. 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 diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index b5d2f875..936d549d 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -520,6 +520,14 @@ pub enum LidStatus { OnBattery, NoKeyboards, NoLidSensor, + /// No agent is working (and none waits for you): nothing to show. + Idle, + SwitchedOff, + LogoLight, + /// The logo refused a change, or the marker could not be written. + Refused, + /// The Mac slept: with the lid closed, the lid helper did not keep it awake. + Slept, } impl LidStatus { @@ -531,6 +539,11 @@ impl LidStatus { LidStatus::OnBattery => "not tried, on battery", LidStatus::NoKeyboards => "not tried, no keyboard to keep dark", LidStatus::NoLidSensor => "not tried, the lid sensor could not be read", + LidStatus::Idle => "nothing to show, no agent at work", + LidStatus::SwitchedOff => "not tried, the logo is switched off", + LidStatus::LogoLight => "not tried, LogoLight has the logo", + LidStatus::Refused => "not tried, the logo refused a change", + LidStatus::Slept => "the Mac went to sleep", } } } @@ -672,7 +685,7 @@ impl<'a, L: Logo> Fader<'a, L> { if !self.touched && self.keyboards.is_none() { return true; } - let awake = self.logo.display_awake(); + let awake = seen(self.logo); let reachable = awake || self.lid_open; // A fade toward lit may be a held one, or still under way at its own // duration: turn it round without moving, so relighting is a change. @@ -745,7 +758,7 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut let mut next = 0; 'blink: loop { let step = moves[next % moves.len()]; - let awake = logo.display_awake(); + let awake = seen(logo); // Every cycle starts with a fall: the moment to check that the logo is // still the agents' and can be seen. if !step.lit { @@ -817,6 +830,13 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut ended } +/// The built-in display can be seen: it is on, and the lid sensor does not say +/// closed. A closed lid counts as closed even when the display list still has the +/// built-in display awake. +fn seen(logo: &impl Logo) -> bool { + logo.display_awake() && logo.lid_closed() != Some(true) +} + /// What a closed lid has shown during one blink. #[derive(Debug, Default)] struct Lid { @@ -926,7 +946,7 @@ pub fn recover(logo: &impl Logo, marker: &Marker, legacy: &Marker) { fader.touched = true; // A child killed with the lid closed may have told the logo it is open, // and the display that it woke. - let under_the_lid = !logo.display_awake() && logo.lid_closed() == Some(true); + let under_the_lid = logo.lid_closed() == Some(true); fader.lid_open = under_the_lid; fader.display_woken = under_the_lid; if fader.restore() { @@ -969,6 +989,10 @@ trait Signals { fn wait(&mut self, time: Duration) -> bool; fn activity(&self) -> Activity; fn now(&self) -> Instant; + /// The Mac slept since this was last asked. + fn slept(&mut self) -> bool { + false + } } /// The child's life: show what the agents are doing, until the app goes away. A @@ -977,16 +1001,42 @@ trait Signals { 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; + // What the menu line was last told from here, and when the lid is next read. + let mut said: Option = None; + let mut look_at_lid = signals.now(); loop { if signals.wait(LOOK_EVERY) { return; } + if signals.slept() { + logo.lid_status(LidStatus::Slept); + said = Some(LidStatus::Slept); + } let style = settled.update(styles.pick(signals.activity()), signals.now()); if style == BlinkStyle::Off { declined = false; - continue; } - if declined { + if style == BlinkStyle::Off || declined { + // No blink runs: if the lid is closed, the menu line says why. + if signals.now() >= look_at_lid { + look_at_lid = signals.now() + NOT_LIT_RECHECK; + if logo.lid_closed() == Some(true) { + let why = if style == BlinkStyle::Off { + LidStatus::Idle + } else if logo.mode() != Some(MODE_AUTO) { + LidStatus::SwitchedOff + } else if logo.driven_elsewhere() { + LidStatus::LogoLight + } else { + LidStatus::Refused + }; + if said != Some(why) && said != Some(LidStatus::Slept) { + logo.lid_status(why); + note(logo, why.text()); + } + said = Some(why); + } + } continue; } let mut app_gone = false; @@ -1001,6 +1051,9 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i app_gone = true; return true; } + if signals.slept() { + logo.lid_status(LidStatus::Slept); + } if settled.update(styles.pick(signals.activity()), signals.now()) != style { return true; } @@ -1010,6 +1063,9 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i return; } declined = ended == Ended::Declined; + // The blink may have said something since: say it again once it ends. + said = None; + look_at_lid = signals.now(); } } @@ -1028,6 +1084,11 @@ pub struct Watcher { } impl Watcher { + /// A child runs. + pub fn watching(&self) -> bool { + self.current.is_some() + } + /// 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 normal well before the next child looks, which takes `SETTLE`. @@ -1076,13 +1137,16 @@ fn lid_status_path() -> Option { dirs::config_dir().map(|dir| dir.join("Codeman").join("logo-lid")) } +/// The line under View > Blink Apple Logo while no child runs. +pub const LID_NOT_WATCHED: &str = "Lid Closed: not watched (Blink Apple Logo off, or no server open)"; + /// The line under View > Blink Apple Logo. pub fn lid_status_line() -> String { let last = lid_status_path() .and_then(|path| std::fs::read_to_string(path).ok()) .and_then(|text| text.lines().next().map(str::to_owned)) .filter(|line| !line.is_empty()); - format!("Lid Closed: {}", last.as_deref().unwrap_or("not tried yet")) + format!("Lid Closed: {}", last.as_deref().unwrap_or("not closed yet")) } /// Calls `changed` with the line whenever the child writes a new one. A thread @@ -1542,11 +1606,25 @@ mod system { struct Live { pipe_closed: Receiver<()>, activity: Arc>, + slept: bool, } + /// More wall-clock time than this, past the monotonic time, in one wait means + /// the Mac slept: `Instant` stops while it sleeps, the wall clock does not. + const SLEPT_GAP: Duration = Duration::from_secs(10); + impl Signals for Live { fn wait(&mut self, time: Duration) -> bool { - !matches!(self.pipe_closed.recv_timeout(time), Err(RecvTimeoutError::Timeout)) + let (wall, monotonic) = (std::time::SystemTime::now(), Instant::now()); + let gone = !matches!(self.pipe_closed.recv_timeout(time), Err(RecvTimeoutError::Timeout)); + if wall.elapsed().unwrap_or_default() > monotonic.elapsed() + SLEPT_GAP { + self.slept = true; + } + gone + } + + fn slept(&mut self) -> bool { + std::mem::take(&mut self.slept) } fn activity(&self) -> Activity { @@ -1618,7 +1696,11 @@ mod system { let found = activity.clone(); std::thread::spawn(move || poll_until_refused(poller, &found)); - let mut signals = Live { pipe_closed, activity }; + let mut signals = Live { + pipe_closed, + activity, + slept: false, + }; watch(&logo, &Marker::default_location(), styles, &mut signals); std::process::exit(0) } @@ -2500,6 +2582,29 @@ mod tests { } } + #[test] + fn the_lid_sensor_wins_over_a_display_list_that_still_has_the_display() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("lid-sensor"); + run_until(&logo, &marker, BlinkStyle::Calm, secs(40), |logo, now| { + if now >= secs(10) && logo.lid.get() != Some(true) { + // The sensor says closed; the display list still shows the display. + logo.lid.set(Some(true)); + logo.told_open.set(false); + } + }); + let since = logo.writes_since(secs(10)); + let told = since + .iter() + .position(|w| *w == W::Lid(true)) + .expect("told the lid is open"); + assert!( + !since[told..].contains(&W::Keyboards(true)), + "no keyboard lit under the lid" + ); + assert_eq!(logo.statuses(), [LidStatus::Blinking]); + } + #[test] fn recover_darkens_a_logo_a_killed_child_left_lit_under_the_closed_lid() { let logo = FakeLogo::new(MODE_AUTO); @@ -2526,6 +2631,11 @@ mod tests { LidStatus::OnBattery, LidStatus::NoKeyboards, LidStatus::NoLidSensor, + LidStatus::Idle, + LidStatus::SwitchedOff, + LidStatus::LogoLight, + LidStatus::Refused, + LidStatus::Slept, ] { let text = status.text(); assert!( @@ -2592,6 +2702,8 @@ mod tests { now: Rc>, spans: Vec<(u64, u64, Activity)>, app_gone_at: u64, + /// When the Mac is found to have slept, once. + slept_at: Option, } impl Script { @@ -2606,6 +2718,7 @@ mod tests { now: logo.now.clone(), spans: spans.to_vec(), app_gone_at, + slept_at: None, } } @@ -2631,6 +2744,62 @@ mod tests { fn now(&self) -> Instant { self.start + self.now.get() } + + fn slept(&mut self) -> bool { + let due = self.slept_at.is_some_and(|at| self.elapsed_ms() >= at); + if due { + self.slept_at = None; + } + due + } + } + + #[test] + fn with_the_lid_closed_and_no_agent_at_work_the_line_says_so() { + let logo = FakeLogo::new(MODE_AUTO); + logo.close_the_lid(); + let (marker, _) = temp_marker("watch-lid-idle"); + let mut script = Script::new(&logo, &[], 20_000); + watch(&logo, &marker, WORK, &mut script); + assert_eq!(logo.statuses(), [LidStatus::Idle]); + assert_eq!(logo.writes(), []); + } + + #[test] + fn with_the_lid_closed_a_declined_blink_says_why() { + for (mode, elsewhere, why) in [ + (MODE_OFF, false, LidStatus::SwitchedOff), + (MODE_AUTO, true, LidStatus::LogoLight), + ] { + let logo = FakeLogo::new(mode); + logo.elsewhere.set(elsewhere); + logo.close_the_lid(); + let (marker, _) = temp_marker("watch-lid-declined"); + let mut script = Script::new(&logo, &[(0, 30_000)], 30_000); + watch(&logo, &marker, WORK, &mut script); + // Idle while the work settles, then the reason. + assert_eq!(logo.statuses(), [LidStatus::Idle, why]); + } + } + + #[test] + fn a_sleep_is_the_line_too() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("watch-slept"); + let mut script = Script::new(&logo, &[], 20_000); + script.slept_at = Some(5_000); + watch(&logo, &marker, WORK, &mut script); + assert_eq!(logo.statuses(), [LidStatus::Slept]); + } + + #[test] + fn work_then_rest_under_the_closed_lid_reads_blinking_then_idle() { + let logo = FakeLogo::new(MODE_AUTO); + logo.close_the_lid(); + let (marker, _) = temp_marker("watch-lid-work"); + let mut script = Script::new(&logo, &[(0, 30_000)], 50_000); + watch(&logo, &marker, WORK, &mut script); + assert_eq!(logo.statuses(), [LidStatus::Idle, LidStatus::Blinking, LidStatus::Idle]); } const CYCLE_MS: u64 = (CALM[0].lit.as_millis() + CALM[0].dark.as_millis()) as u64; diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 94ae1068..b91f2fe7 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -832,11 +832,7 @@ impl App { self.to_connect_page(msg); } } - UserEvent::LogoLidStatus(line) => { - if let Some(item) = &self.menu.logo_lid { - item.set_text(line); - } - } + UserEvent::LogoLidStatus(line) => self.show_lid_status(line), UserEvent::ServerStartDone { generation, url, @@ -1054,6 +1050,20 @@ impl App { let server = phase.loaded().filter(|_| styles.active() && logo::available()); self.logo_watch .sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles))); + drop(phase); + self.show_lid_status(logo::lid_status_line()); + } + + /// The line under the logo's styles: what the closed lid did last, or that + /// nothing watches it now. + fn show_lid_status(&self, line: String) { + if let Some(item) = &self.menu.logo_lid { + if self.logo_watch.watching() { + item.set_text(line); + } else { + item.set_text(logo::LID_NOT_WATCHED); + } + } } fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) { From 979275af1fcbd7bbc5b2034d052cf0d435dda9c0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 10 Nov 2026 23:06:29 +0100 Subject: [PATCH 5/6] fix(desktop): blink under a closed lid the way LogoLight 1.3.1 measured it LogoLight 1.3.1 lights the logo behind a closed lid on a real MacBook kept awake with no display attached, and its research notes say why the earlier tries here stayed dark and what must not be written: - Kept awake like that, the built-in display stays online and CGDisplayIsAsleep says 0, so the lid path was never taken ("not tried yet"). The lid sensor decides now (in since the last commit). - DisplayPowerStateEvent is gone. A client's DidSleep keeps the logo dark, lid open too, until a DidWake is written; the last commit wrote DidSleep when a blink ended, and at launch with a marker and the lid closed. - After ClamshellUpdate 0, each display in CBDisplayList is told the lid is closed again (setProperty:withKey:andDisplay:), as each keyboard is: told it is open, True Tone tints an external display warm and dim. - The logo is told once, when the daemon has handled the lid (CBClamshellState, bounded to two seconds of waiting), not at every cycle, which would toggle those display modules with every beat. It is told again when it is found dark at the start of a beat, or when a rise did not move it: three times in a row, then every half minute, with "stayed dark" in the menu line. - Unplugging under the lid tells the logo the lid is closed again. - "The Mac went to sleep" is only said while the lid is closed. The keep-awake side needs nothing new: pmset disablesleep holds the Mac at full wake, which is the state LogoLight measured; an assertion against system sleep alone gives a dark wake, where the logo stays dark. 221 tests pass on the Mac mini, and 13 deliberate breaks of the lid rules each fail one. Not seen from this app on a real lid yet. Co-Authored-By: Claude Fable 5.2 --- packages/desktop/README.md | 70 +++-- packages/desktop/src/logo.rs | 579 ++++++++++++++++++++--------------- 2 files changed, 378 insertions(+), 271 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 1e256b22..86f19bd3 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 says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does, and, with no display on, that the display is awake. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses 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. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses 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) @@ -250,26 +250,40 @@ while their fall is still under way and never quite reach dark, and a long one ( Slow's, the breath's rest) times its light from when the rise starts. With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and the logo's -target is then 0 whatever `DisplayLogicalState` says; the same goes while the display sleeps -(`DisplayPowerStateEvent` WillSleep or DidSleep). A client's `ClamshellUpdate` reaches the -same modules, which is how LogoLight measured it on the real Mac with an external display. -So while the lid sensor (`AppleClamshellState`) says closed (it wins over a display list that -still shows the built-in display awake) and the Mac is on its power adapter (on battery a closed MacBook sleeps, and a dark wake must not -light the logo in a bag), the child writes `ClamshellUpdate` 0 and then 1 to each keyboard -alone at the start of every cycle: said again each time, because the daemon repeats the real -lid state whenever a display or a keyboard comes up, and handles a closing lid a moment after -the sensor. If the first rise does not move the logo within 1.5 s and no other display is on, -the display sleeps, so the child also writes `DisplayPowerStateEvent` 2 (DidWake). Ending a -blink with the lid closed hands the logo back to normal (the automatic duration, then -Unblank, which a held level needs), then writes DidSleep (only if DidWake was written and no -other display is on) and `ClamshellUpdate` 1, since normal for a closed lid is dark; the +target is then 0 whatever `DisplayLogicalState` says. A client's `ClamshellUpdate` reaches the +same modules, and LogoLight measured on the real MacBook what follows from that, first with an +external display and, for its 1.3.1, on a Mac kept awake with none: +- Kept awake with the lid closed and no other display, the built-in display stays online and + `CGDisplayIsAsleep` says 0. So the lid sensor (`AppleClamshellState` on `IOPMrootDomain`) + decides that the logo is out of sight, not the display list. +- Once the daemon has handled the lid (`CBClamshellState` reads 1, a moment after the sensor), + `ClamshellUpdate` 0 relights the logo and fades work as with the lid open. The child then + writes `ClamshellUpdate` 1 to each keyboard alone, which stays dark, and to each display in + `CBDisplayList` alone (`setProperty:withKey:andDisplay:`): told the lid is open, True Tone + adapts an external display to the covered light sensor and tints it warm and dim. +- The daemon sends the real lid state again when a display or a keyboard comes up. Every beat + ends lit, so a logo found dark at the start of the next one is told again. +- `DisplayPowerStateEvent` is never written. A client's DidSleep keeps the logo dark, with the + lid open too, until someone writes DidWake. + +The child does this only on the power adapter (on battery a closed MacBook sleeps, and a dark +wake must not light the logo in a bag) and only with every keyboard reachable. The first rise +has to move the logo within 1.5 s. If it does not, the logo is told again, three times in a +row and then every half minute, and the menu line says it stayed dark. Ending a blink with the +lid closed hands the logo back to normal (the automatic duration, then Unblank, which a held +level needs) and then writes `ClamshellUpdate` 1, since normal for a closed lid is dark; the marker covers a child killed in between. When the lid opens, the daemon tells every module -itself and nothing is written. What the closed lid did last (blinking, blinking once the -display was told awake, stayed dark, nothing to show with no agent at work, not tried and why, -or that the Mac went to sleep, which the child sees as wall-clock time that passed while its -monotonic clock stood still) goes to a file next to `desktop.json` (`logo-lid`, with the -readings behind it on its second line), and the app shows its first line as **Lid Closed: …** -under View > Apple Logo Blink Styles, or says nothing watches the lid while no child runs. +itself and nothing is written. What the closed lid did last (blinking, stayed dark, nothing to +show with no agent at work, not tried and why, or that the Mac went to sleep under it, which +the child sees as wall-clock time that passed while its monotonic clock stood still) goes to a +file next to `desktop.json` (`logo-lid`, with the readings behind it on its second line), and +the app shows its first line as **Lid Closed: …** under View > Apple Logo Blink Styles, or +says nothing watches the lid while no child runs. + +Whether the Mac stays awake under the lid is not the logo's business: Keep This Mac Awake's +lid helper does that (`pmset disablesleep`, which holds the Mac at full wake), or LogoLight's +own Lit with Lid Closed, or an external display. An assertion against system sleep alone would +not do: LogoLight saw the Mac enter a dark wake with it, where the logo stays dark. 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 @@ -353,15 +367,13 @@ crosses, so the login is never on a command line. the logo the same way and is not LogoLight is not recognised, and the two fight. `api/sessions` does not say which turns are Codeman's own, so with prompt-cache keep-warm on, a keep-alive turn blinks too. -- **Blinking with the lid closed has not been seen on a real MacBook yet.** - LogoLight measured the lid override with an external display attached. With - none, the Mac kept awake by the lid helper, the display sleeps, and the app - also tells the logo the display woke (`DisplayPowerStateEvent`), which nobody - has tried: it reaches every module the way `ClamshellUpdate` does, and what it - does to anything but the logo is not known. If neither moves the logo, the - menu line says "stayed dark". Not checked either, by LogoLight or here: whether - telling the display the lid is open changes anything on an external display, - or the built-in display's brightness when the lid opens again. +- **Blinking with the lid closed has not been seen from this app on a real + MacBook yet.** The method is LogoLight's, which its author saw working on the + real lid, on power with no display attached (LogoLight 1.3.1). This app's own + run of it, with the Mac kept awake by the lid helper, has not been watched. If + the logo does not move, the menu line says "stayed dark". Not checked either, + by LogoLight or here: the built-in display's brightness when the lid opens + again, and an Apple display with its own light sensor. - **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/logo.rs b/packages/desktop/src/logo.rs index 936d549d..78286eed 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -40,20 +40,28 @@ //! //! With the lid closed `corebrightnessd` keeps the logo dark: it tells every //! module the lid is closed, and the logo's target is then 0 whatever -//! `DisplayLogicalState` says; the same goes while the display sleeps. While the -//! Mac stays awake with the lid closed on its power adapter (Keep This Mac -//! Awake's lid helper, or an external display), the blink simply goes on. At -//! every cycle the logo is told the lid is open (`ClamshellUpdate` 0) and the -//! keyboards alone that it is closed, so they stay dark: the way LogoLight's Lit -//! with Lid Closed works (its `docs/corebrightnessd.md`, "The lid"), said again -//! each time because the daemon repeats the real lid state whenever a display or -//! a keyboard comes up. If the first rise does not move the logo and no other -//! display is on, the display sleeps, so the logo is also told the display woke -//! (`DisplayPowerStateEvent` 2). A blink that ends with the lid closed puts the -//! logo back to normal and tells it the display sleeps and the lid is closed, so -//! it is dark as a closed lid has it. What happened is the line under View > -//! Blink Apple Logo (`LidStatus`, through a file next to the app's settings), -//! and each step is a line on stderr. +//! `DisplayLogicalState` says. While the Mac stays awake with the lid closed on +//! its power adapter (Keep This Mac Awake's lid helper, LogoLight's Lit with +//! Lid Closed, or an external display), the blink goes on, the way LogoLight +//! does it and measured it on the real MacBook (its `docs/corebrightnessd.md`, +//! "The lid", and its 1.3.1 for the Mac with no display attached): +//! - Kept awake like that, the built-in display stays online and CoreGraphics +//! does not call it asleep, so the lid sensor (`AppleClamshellState`) is what +//! says the logo cannot be seen (`seen`). +//! - Once the daemon has handled the lid (`CBClamshellState`), the logo is told +//! the lid is open (`ClamshellUpdate` 0), and each keyboard and each display +//! alone that it is closed: the keyboards stay dark, and True Tone does not +//! adapt an external display to the covered light sensor. +//! - The daemon sends the real lid state again when a display or a keyboard +//! comes up, so a logo found dark where the blink has it lit is told again. +//! - `DisplayPowerStateEvent` is never written: a client's DidSleep keeps the +//! logo dark, lid open too, until someone writes DidWake. +//! +//! The first rise after that has to move the logo; a blink that ends with the +//! lid closed puts the logo back to normal and tells it the lid is closed, so it +//! is dark as a closed lid has it. What the closed lid did last is the line +//! under View > Apple Logo Blink Styles (`LidStatus`, through a file next to the +//! app's settings), and each step is a line on stderr. //! //! All of this runs in a child process (this same binary with `BLINK_ARG`), for //! as long as the switch is on and a server is open. The app hands it the @@ -454,6 +462,15 @@ const LID_PROOF_WAIT: Duration = Duration::from_millis(1500); /// above any drift of a fade held at 0, and reached in well under the proof's /// wait by the slowest rise (the breath's). const LID_MOVED: f64 = 0.01; +/// How many rises in a row may fail to move the logo before the closed lid is +/// said to keep it dark. +const LID_TRIES: u32 = 3; +/// From then on, how many looks (`NOT_LIT_RECHECK` apart) lie between two more +/// tries: half a minute. +const LID_RETRY_LOOKS: u32 = 15; +/// How many looks (`LOOK_EVERY` apart) the daemon gets to handle a lid the +/// sensor already calls closed, before the logo is told anyway. +const LID_HANDLED_LOOKS: u32 = 8; /// 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. Not below the 3 s /// the server waits before it judges a new dialog: a prompt that was answered @@ -485,23 +502,20 @@ pub trait Logo { /// The lid is closed: the lid sensor (`AppleClamshellState`, what `ioreg -k /// AppleClamshellState` shows). None if it cannot be read. fn lid_closed(&self) -> Option; - /// `ClamshellUpdate`: tell the logo the lid is open and then the keyboards - /// alone that it is closed, so they stay dark (`open`), or tell everything - /// it is closed. False if that was not done: opening it needs the keyboards + /// `CBClamshellState`: `corebrightnessd` has handled the closed lid, a moment + /// after the sensor. Only a real lid change moves it, whatever `tell_lid` + /// said since. None if it cannot say. + fn lid_handled(&self) -> Option; + /// `ClamshellUpdate`: tell the logo the lid is open, and then each keyboard + /// and each display alone that it is closed (`open`), or tell everything it + /// is closed. False if that was not done: opening it needs the keyboards /// within reach, or a keyboard would light under the closed lid. fn tell_lid(&self, open: bool) -> bool; - /// `DisplayPowerStateEvent`: tell the logo the display woke (2, DidWake) or - /// went to sleep (4, DidSleep). The logo is also kept dark while the display - /// sleeps, which with the lid closed and no other display on it does. - fn tell_display(&self, awake: bool) -> bool; /// What the closed lid did this time, for the line in the View menu. fn lid_status(&self, status: LidStatus); /// The Mac runs on battery. It sleeps with the lid closed then, and a dark /// wake (Power Nap) must not light the logo in a bag. fn on_battery(&self) -> bool; - /// How many displays other than the built-in one are on. With one, the display - /// is never told it woke: it has not gone to sleep. - fn other_displays_awake(&self) -> usize; /// What a line on stderr says about the logo, for a test on a real Mac. fn describe(&self) -> String { String::new() @@ -513,9 +527,7 @@ pub trait Logo { pub enum LidStatus { /// Told the lid is open, the logo moved. Blinking, - /// It moved once the display was told it woke as well. - BlinkingDisplayWoken, - /// Neither moved it: macOS keeps the logo dark under this closed lid. + /// It did not: macOS keeps the logo dark under this closed lid. StayedDark, OnBattery, NoKeyboards, @@ -526,7 +538,7 @@ pub enum LidStatus { LogoLight, /// The logo refused a change, or the marker could not be written. Refused, - /// The Mac slept: with the lid closed, the lid helper did not keep it awake. + /// The Mac slept with the lid closed: nothing kept it awake. Slept, } @@ -534,7 +546,6 @@ impl LidStatus { pub fn text(self) -> &'static str { match self { LidStatus::Blinking => "blinking", - LidStatus::BlinkingDisplayWoken => "blinking (display told awake)", LidStatus::StayedDark => "stayed dark, macOS keeps the logo off", LidStatus::OnBattery => "not tried, on battery", LidStatus::NoKeyboards => "not tried, no keyboard to keep dark", @@ -560,8 +571,6 @@ struct Fader<'a, L: Logo> { keyboards: Option, /// The logo was told the lid is open while it is closed (`open_lid`). lid_open: bool, - /// The logo was told the display woke while it sleeps (`wake_display`). - display_woken: bool, /// Anything was written. touched: bool, /// Every write was taken. @@ -575,7 +584,6 @@ impl<'a, L: Logo> Fader<'a, L> { heading: None, keyboards: None, lid_open: false, - display_woken: false, touched: false, ok: true, } @@ -639,9 +647,7 @@ impl<'a, L: Logo> Fader<'a, L> { } } - /// Lets the logo move under a closed lid (`Logo::tell_lid`). Said again at - /// every cycle: the daemon tells it the lid is closed again whenever a - /// display or a keyboard comes up, and when it has handled the lid late. + /// Lets the logo move under a closed lid (`Logo::tell_lid`). fn open_lid(&mut self) -> bool { // A refusal has written nothing. let open = self.logo.tell_lid(true); @@ -650,28 +656,11 @@ impl<'a, L: Logo> Fader<'a, L> { open } - /// Tells the logo the display woke, for a closed lid with no display on. - fn wake_display(&mut self) { - self.touched = true; - self.display_woken |= self.logo.tell_display(true); - } - - /// Ends `open_lid` and `wake_display`: the logo goes dark, as a closed lid - /// has it. Once the lid has really opened the daemon has told every module - /// so itself, and nothing is written; nor is the display said to sleep once - /// another display is on. + /// Ends `open_lid`: the logo goes dark, as a closed lid has it. Once the lid + /// has really opened the daemon has told every module so itself, and nothing + /// is written. fn close_lid(&mut self) { - let (open, woken) = ( - std::mem::take(&mut self.lid_open), - std::mem::take(&mut self.display_woken), - ); - if self.logo.lid_closed() != Some(true) { - return; - } - if woken && self.logo.other_displays_awake() == 0 { - self.ok &= self.logo.tell_display(false); - } - if open { + if std::mem::take(&mut self.lid_open) && self.logo.lid_closed() == Some(true) { self.ok &= self.logo.tell_lid(false); } } @@ -771,7 +760,7 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut fader.display_woke(); asleep = false; } - if std::mem::take(&mut fader.lid_open) | std::mem::take(&mut fader.display_woken) { + if std::mem::take(&mut fader.lid_open) { // The daemon told every module itself when the lid opened. note(logo, "the lid opened"); } @@ -781,15 +770,28 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut fader.display_slept(); asleep = true; } - if !through_the_lid(logo, &mut fader, &mut lid) { - if wait(NOT_LIT_RECHECK) { - break; + match look_at_lid(logo, &mut fader, &mut lid) { + LidLook::Go => {} + LidLook::Tell => { + if !fader.open_lid() { + lid.report(logo, LidStatus::NoKeyboards); + if wait(NOT_LIT_RECHECK) { + break; + } + continue; + } + note(logo, "the lid is closed: told the logo it is open"); + } + LidLook::Wait(time) => { + if wait(time) { + break; + } + continue; } - continue; } } } - let proving = step.lit && fader.lid_open && !lid.proven && !lid.dark; + let proving = step.lit && fader.lid_open && !lid.proven; let from_dark = step.lit && logo.factor().is_some_and(|factor| factor <= 0.0); // With the display off the keyboards are not touched: under a closed lid // they stay dark. @@ -815,7 +817,7 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut waited += RISE_POLL; } if proving { - lid.proved(logo, &mut fader, logo.factor().is_some_and(|factor| factor > LID_MOVED)); + lid.proved(logo, logo.factor().is_some_and(|factor| factor > LID_MOVED)); } left = left.saturating_sub(START_LATENCY); } @@ -840,10 +842,14 @@ fn seen(logo: &impl Logo) -> bool { /// What a closed lid has shown during one blink. #[derive(Debug, Default)] struct Lid { - /// The logo moved under it. + /// The logo moved under it since it was last told the lid is open. proven: bool, - /// Nothing moved it, the display told it woke included. - dark: bool, + /// Rises in a row that did not move it. + failed: u32, + /// Looks since the last try, once `LID_TRIES` rises failed. + waited: u32, + /// Looks at a lid the daemon has not handled yet. + unhandled: u32, /// The last status written, so it is written once per change. status: Option, } @@ -857,72 +863,90 @@ impl Lid { } } - /// The first rise under the closed lid moved the logo, or did not. If it did - /// not, and no other display is on (so the display sleeps, which keeps the - /// logo dark too), the display is told it woke and the next rise proves it. - fn proved<'a, L: Logo>(&mut self, logo: &'a L, fader: &mut Fader<'a, L>, moved: bool) { + /// A rise under the closed lid moved the logo, or did not. + fn proved(&mut self, logo: &impl Logo, moved: bool) { if moved { self.proven = true; - let status = if fader.display_woken { - LidStatus::BlinkingDisplayWoken - } else { - LidStatus::Blinking - }; - self.report(logo, status); - } else if !fader.display_woken && logo.other_displays_awake() == 0 { - note( - logo, - "the logo did not rise under the closed lid: telling it the display woke", - ); - fader.wake_display(); + self.failed = 0; + self.waited = 0; + self.report(logo, LidStatus::Blinking); } else { - self.dark = true; - self.report(logo, LidStatus::StayedDark); + self.failed += 1; + if self.failed >= LID_TRIES { + self.report(logo, LidStatus::StayedDark); + } else { + note(logo, "the logo did not rise under the closed lid"); + } } } } -/// At the start of a cycle with the built-in display off: whether the logo can -/// be seen through a closed lid, telling it again that the lid is open. Never -/// while the display only sleeps (the lid is open), nor on battery. -fn through_the_lid<'a, L: Logo>(logo: &'a L, fader: &mut Fader<'a, L>, lid: &mut Lid) -> bool { +/// What to do about the lid at the start of a cycle with the built-in display +/// out of sight. +#[derive(Debug, PartialEq)] +enum LidLook { + /// The logo was told the lid is open, and nothing says otherwise: blink on. + Go, + /// Tell the logo the lid is open (again), then blink on. + Tell, + /// Nothing to blink for now: look again after this long. + Wait(Duration), +} + +/// Never while the display only sleeps (the lid is open), nor on battery. The +/// logo is told the lid is open once the daemon has handled the closed lid, and +/// again when it is found dark where the blink has it lit, or when a rise did +/// not move it: a few times in a row, then every half minute. +fn look_at_lid<'a, L: Logo>(logo: &'a L, fader: &mut Fader<'a, L>, lid: &mut Lid) -> LidLook { match logo.lid_closed() { Some(true) => {} Some(false) => { - fader.close_lid(); + // The display sleeps with the lid open; an override ended when it opened. + fader.lid_open = false; *lid = Lid::default(); - return false; + return LidLook::Wait(NOT_LIT_RECHECK); } None => { lid.report(logo, LidStatus::NoLidSensor); - return false; + return LidLook::Wait(NOT_LIT_RECHECK); } } if logo.on_battery() { + // Unplugged under the lid: the logo goes dark, and the Mac to sleep. fader.close_lid(); - lid.proven = false; + let status = lid.status; + *lid = Lid { + status, + ..Lid::default() + }; lid.report(logo, LidStatus::OnBattery); - return false; + return LidLook::Wait(NOT_LIT_RECHECK); } - if lid.dark && logo.factor().is_some_and(|factor| factor > LID_MOVED) { - lid.dark = false; - lid.proven = true; - lid.report(logo, LidStatus::Blinking); - } - if lid.proven && logo.factor().is_some_and(|factor| factor <= LID_MOVED) { - // Dark although its last rise lit it: the daemon told the logo the lid is - // closed again (a display or a keyboard came up), or the display slept. - note(logo, "the logo went dark under the closed lid: telling it again"); - lid.proven = false; - if fader.display_woken { - fader.wake_display(); + if fader.lid_open { + if lid.proven { + // Every cycle ends lit, so dark here is not the blink's doing: the + // daemon sent the real lid state again (a display or a keyboard came up). + if !logo.factor().is_some_and(|factor| factor <= LID_MOVED) { + return LidLook::Go; + } + note(logo, "the logo went dark under the closed lid"); + lid.proven = false; + } else if lid.failed == 0 { + return LidLook::Go; } + } else if logo.lid_handled() == Some(false) && lid.unhandled < LID_HANDLED_LOOKS { + // The daemon's own update for this lid is still to come, and would undo ours. + lid.unhandled += 1; + return LidLook::Wait(LOOK_EVERY); } - if !fader.open_lid() { - lid.report(logo, LidStatus::NoKeyboards); - return false; + if lid.failed >= LID_TRIES { + lid.waited += 1; + if lid.waited < LID_RETRY_LOOKS { + return LidLook::Wait(NOT_LIT_RECHECK); + } + lid.waited = 0; } - true + LidLook::Tell } /// A line on stderr about the closed lid, which `open -a Codeman --stderr ` @@ -944,11 +968,8 @@ pub fn recover(logo: &impl Logo, marker: &Marker, legacy: &Marker) { let mut fader = Fader::new(logo); // Which way it heads is not known: `restore` turns it round first. fader.touched = true; - // A child killed with the lid closed may have told the logo it is open, - // and the display that it woke. - let under_the_lid = logo.lid_closed() == Some(true); - fader.lid_open = under_the_lid; - fader.display_woken = under_the_lid; + // A child killed with the lid closed may have told the logo it is open. + fader.lid_open = logo.lid_closed() == Some(true); if fader.restore() { marker.clear(); } @@ -1008,7 +1029,7 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i if signals.wait(LOOK_EVERY) { return; } - if signals.slept() { + if signals.slept() && logo.lid_closed() == Some(true) { logo.lid_status(LidStatus::Slept); said = Some(LidStatus::Slept); } @@ -1051,7 +1072,7 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i app_gone = true; return true; } - if signals.slept() { + if signals.slept() && logo.lid_closed() == Some(true) { logo.lid_status(LidStatus::Slept); } if settled.update(styles.pick(signals.activity()), signals.now()) != style { @@ -1146,7 +1167,7 @@ pub fn lid_status_line() -> String { .and_then(|path| std::fs::read_to_string(path).ok()) .and_then(|text| text.lines().next().map(str::to_owned)) .filter(|line| !line.is_empty()); - format!("Lid Closed: {}", last.as_deref().unwrap_or("not closed yet")) + format!("Lid Closed: {}", last.as_deref().unwrap_or("not seen yet")) } /// Calls `changed` with the line whenever the child writes a new one. A thread @@ -1244,6 +1265,8 @@ mod system { /// The keyboard backlights, for telling them apart from the logo. Empty if /// this macOS has no way to: the keyboard then dims with each dark beat. keyboards: Vec, + /// The client can tell one display alone (`setProperty:withKey:andDisplay:`). + per_display: bool, } impl SystemLogo { @@ -1271,10 +1294,31 @@ mod system { } else { Vec::new() }; - client.map(|client| SystemLogo { client, keyboards }) + let per_display = class.instance_method(sel!(setProperty:withKey:andDisplay:)).is_some(); + client.map(|client| SystemLogo { + client, + keyboards, + per_display, + }) }) } + /// The displays the daemon has a container for (`CBDisplayList`), read + /// each time since displays come and go. The IDs are CoreGraphics's. + fn display_ids(&self) -> Vec { + let Some(list) = self.property(ns_string!("CBDisplayList")) else { + return Vec::new(); + }; + let Some(list) = list.downcast_ref::() else { + return Vec::new(); + }; + // SAFETY: an NSDictionary answers objectForKey:, with nil for a key it + // does not have. + let ids: Option> = + unsafe { msg_send![list, objectForKey: ns_string!("CBDisplayDeviceIDs")] }; + ids.map_or(Vec::new(), |ids| numbers(&ids)) + } + fn property(&self, key: &NSString) -> Option> { // SAFETY: the method was checked in `open`. It takes a string and // answers an owned object or nil. @@ -1314,16 +1358,21 @@ mod system { let Some(ids): Option> = (unsafe { msg_send![&*client, copyKeyboardBacklightIDs] }) else { return Vec::new(); }; + numbers(&ids) + } + + /// The numbers in an array, or none if it is not an array. + fn numbers(ids: &AnyObject) -> Vec { // SAFETY: an array answers count and objectAtIndex: below its count; it is // checked to be one first. - let is_array: Bool = unsafe { msg_send![&*ids, isKindOfClass: AnyClass::get(c"NSArray")] }; + let is_array: Bool = unsafe { msg_send![ids, isKindOfClass: AnyClass::get(c"NSArray")] }; if !is_array.as_bool() { return Vec::new(); } - let count: usize = unsafe { msg_send![&*ids, count] }; + let count: usize = unsafe { msg_send![ids, count] }; (0..count) .filter_map(|i| { - let id: Option> = unsafe { msg_send![&*ids, objectAtIndex: i] }; + let id: Option> = unsafe { msg_send![ids, objectAtIndex: i] }; Some(id?.downcast_ref::()?.as_u64()) }) .collect() @@ -1388,6 +1437,13 @@ mod system { lid_sensor() } + fn lid_handled(&self) -> Option { + autoreleasepool(|_| { + let value = self.property(ns_string!("CBClamshellState"))?; + Some(value.downcast_ref::()?.as_bool()) + }) + } + fn tell_lid(&self, open: bool) -> bool { if open && self.keyboards.is_empty() { return false; @@ -1400,28 +1456,32 @@ mod system { if !self.set(&NSNumber::new_bool(false), ns_string!("ClamshellUpdate")) { return false; } + // The answers below are not read: LogoLight's measured override + // does not read them either. for &id in &self.keyboards { // SAFETY: the method was checked in `open` (there are keyboards // only if it exists). It takes an object, a string and a 64-bit - // keyboard ID and answers a BOOL, which is not read: LogoLight's - // measured override does not read it either. + // keyboard ID and answers a BOOL. let _: bool = unsafe { msg_send![&*self.client, setProperty: &*closed, withKey: ns_string!("ClamshellUpdate"), keyboardID: id] }; } + // Each display's own modules (brightness, True Tone) too: told the + // lid is open, True Tone adapts an external display to the covered + // light sensor. The logo's module is in no display's container. + if self.per_display { + for id in self.display_ids() { + // SAFETY: the method was checked in `open`. It takes an object, + // a string and a 64-bit display ID and answers a BOOL. + let _: bool = unsafe { + msg_send![&*self.client, setProperty: &*closed, withKey: ns_string!("ClamshellUpdate"), andDisplay: id] + }; + } + } true }) } - fn tell_display(&self, awake: bool) -> bool { - autoreleasepool(|_| { - self.set( - &NSNumber::new_i64(if awake { 2 } else { 4 }), - ns_string!("DisplayPowerStateEvent"), - ) - }) - } - fn lid_status(&self, status: LidStatus) { let text = format!("{} ({})\n{}\n", status.text(), clock(), self.describe()); if let Some(path) = super::lid_status_path() { @@ -1439,21 +1499,17 @@ mod system { on_battery() } - fn other_displays_awake(&self) -> usize { - other_displays_awake() - } - fn describe(&self) -> String { autoreleasepool(|_| { format!( - "factor {:?}, FinalNits {:?}, lid sensor {:?}, CBClamshellState {:?}, built-in display on {}, other displays on {}, on battery {}", + "factor {:?}, FinalNits {:?}, lid sensor {:?}, CBClamshellState {:?}, built-in display on {}, other displays on {}, displays told {:?}, on battery {}", self.pwm(ns_string!("Factor")), self.pwm(ns_string!("FinalNits")), lid_sensor(), - self.property(ns_string!("CBClamshellState")) - .and_then(|value| value.downcast_ref::().map(|n| n.as_i64())), + self.lid_handled(), built_in_display_awake(), other_displays_awake(), + self.per_display.then(|| self.display_ids()), on_battery() ) }) @@ -1725,11 +1781,13 @@ mod system { println!("keyboard backlight IDs: {:?}", keyboard_ids()); match SystemLogo::open() { Some(logo) => println!( - "mode {:?}, factor {:?}, lid closed {:?}, keyboards the client can reach {:?}", + "mode {:?}, factor {:?}, lid handled {:?}, keyboards the client can reach {:?}, displays {:?} (one alone: {})", logo.mode(), logo.factor(), - logo.lid_closed(), - logo.keyboards + logo.lid_handled(), + logo.keyboards, + logo.display_ids(), + logo.per_display ), None => println!("no BrightnessSystemClient"), } @@ -1796,10 +1854,8 @@ mod tests { Heading(bool), Keyboards(bool), /// `ClamshellUpdate`: true is "the lid is open" to the logo (and closed - /// to the keyboards), false is "closed" to everything. + /// to the keyboards and the displays), false is "closed" to everything. Lid(bool), - /// `DisplayPowerStateEvent`: true is DidWake, false DidSleep. - Display(bool), } #[derive(Debug, Clone, Copy)] @@ -1813,24 +1869,22 @@ mod tests { /// A `corebrightnessd` that moves like the real one: in frames, on the spring, /// starting a little after the write, only on a change of direction, and with /// a rise from exactly dark held back by `gate`. Asleep, the logo is dark, - /// and so it is under a closed lid unless told the lid is open, which moves - /// it if `lid_alone`, or with the display told it woke if `with_wake`. + /// and so it is under a closed lid unless told the lid is open, which lets + /// it move if `through_lid`. struct FakeLogo { now: Rc>, mode: Cell, awake: Cell, - /// `CBClamshellState`. + /// The lid sensor. lid: Cell>, + /// `CBClamshellState`: the daemon has handled the closed lid. + handled: Cell>, /// What the logo was last told about the lid (true: open). told_open: Cell, - /// What the logo was last told about the display (true: it woke). - told_awake: Cell, - lid_alone: Cell, - with_wake: Cell, + through_lid: Cell, statuses: RefCell>, keyboards_reachable: Cell, battery: Cell, - displays: Cell, elsewhere: Cell, gate: Cell, refuse_writes: Cell, @@ -1849,14 +1903,12 @@ mod tests { mode: Cell::new(mode), awake: Cell::new(true), lid: Cell::new(Some(false)), + handled: Cell::new(Some(false)), told_open: Cell::new(false), - told_awake: Cell::new(false), - lid_alone: Cell::new(true), - with_wake: Cell::new(true), + through_lid: Cell::new(true), statuses: RefCell::new(Vec::new()), keyboards_reachable: Cell::new(true), battery: Cell::new(false), - displays: Cell::new(0), elsewhere: Cell::new(false), gate: Cell::new(Duration::ZERO), refuse_writes: Cell::new(false), @@ -1882,33 +1934,31 @@ mod tests { fn close_the_lid(&self) { self.awake.set(false); self.lid.set(Some(true)); + self.handled.set(Some(true)); self.told_open.set(false); - self.told_awake.set(false); } /// The lid opens: the daemon tells every module so, and the display is on. fn open_the_lid(&self) { self.awake.set(true); self.lid.set(Some(false)); + self.handled.set(Some(false)); self.told_open.set(false); - self.told_awake.set(false); } - /// The logo can be seen: the display is on, or the lid is closed and the - /// logo was told it is open, and that works on this Mac. + /// The logo can be seen: the lid is open and the display on, or the lid + /// is closed and the logo was told it is open, and that works on this Mac. fn shown(&self) -> bool { - self.awake.get() - || (self.lid.get() == Some(true) - && self.told_open.get() - && (self.lid_alone.get() || (self.told_awake.get() && self.with_wake.get()))) + if self.lid.get() == Some(true) { + self.told_open.get() && self.through_lid.get() + } else { + self.awake.get() + } } - /// The `ClamshellUpdate` and `DisplayPowerStateEvent` writes. + /// The `ClamshellUpdate` writes. fn lid_writes(&self) -> Vec { - self.writes() - .into_iter() - .filter(|w| matches!(w, W::Lid(_) | W::Display(_))) - .collect() + self.writes().into_iter().filter(|w| matches!(w, W::Lid(_))).collect() } fn statuses(&self) -> Vec { @@ -2043,8 +2093,8 @@ mod tests { fn set_keyboards_lit(&self, lit: bool) { assert!( - self.awake.get() || !lit, - "a keyboard told to light while the display sleeps" + (self.awake.get() && self.lid.get() != Some(true)) || !lit, + "a keyboard told to light while the display sleeps or the lid is closed" ); self.record(W::Keyboards(lit)); } @@ -2061,6 +2111,10 @@ mod tests { self.lid.get() } + fn lid_handled(&self) -> Option { + self.handled.get() + } + fn tell_lid(&self, open: bool) -> bool { if self.refuse_writes.get() || (open && !self.keyboards_reachable.get()) { return false; @@ -2070,15 +2124,6 @@ mod tests { true } - fn tell_display(&self, awake: bool) -> bool { - if self.refuse_writes.get() { - return false; - } - self.record(W::Display(awake)); - self.told_awake.set(awake); - true - } - fn lid_status(&self, status: LidStatus) { self.statuses.borrow_mut().push(status); } @@ -2086,10 +2131,6 @@ mod tests { fn on_battery(&self) -> bool { self.battery.get() } - - fn other_displays_awake(&self) -> usize { - self.displays.get() - } } fn temp_marker(name: &str) -> (Marker, PathBuf) { @@ -2438,13 +2479,9 @@ mod tests { !since[told..].contains(&W::Keyboards(true)), "{style:?}: no keyboard lit under the closed lid" ); - let lid = logo.lid_writes(); - assert!(lid.len() >= 5, "{style:?}: told again at every cycle"); - assert!( - lid[..lid.len() - 1].iter().all(|w| *w == W::Lid(true)), - "{style:?}: never told closed in between, never the display: {lid:?}" - ); - // The end: back to normal, and then the lid is closed again, so it goes dark. + // Told once, and at the end: back to normal, then the lid is closed + // again, so it goes dark. + assert_eq!(logo.lid_writes(), [W::Lid(true), W::Lid(false)], "{style:?}"); assert_eq!( &since[since.len() - 2..], [W::Heading(true), W::Lid(false)], @@ -2457,56 +2494,88 @@ mod tests { } #[test] - fn a_logo_the_display_sleep_keeps_dark_is_told_the_display_woke() { + fn the_logo_is_told_once_the_daemon_has_handled_the_lid() { + // The sensor says closed first; the daemon's own update would undo ours. let logo = FakeLogo::new(MODE_AUTO); - logo.lid_alone.set(false); - under_the_lid(&logo, BlinkStyle::Calm, secs(60)); + logo.awake.set(false); + logo.lid.set(Some(true)); + logo.handled.set(Some(false)); + let (marker, _) = temp_marker("lid-late"); + run_until(&logo, &marker, BlinkStyle::Calm, secs(20), |logo, now| { + if now >= secs(1) { + logo.handled.set(Some(true)); + } + }); + let told_at = |logo: &FakeLogo| { + logo.writes + .borrow() + .iter() + .find(|(_, w)| *w == W::Lid(true)) + .map(|&(at, _)| at) + }; + let first = told_at(&logo).expect("told once the daemon had it"); + assert!(first >= secs(1) && first < secs(2), "told at {first:?}"); + assert_eq!(logo.statuses(), [LidStatus::Blinking]); + + // A daemon that never says so (or cannot) does not hold the logo up for long. + for handled in [Some(false), None] { + let logo = FakeLogo::new(MODE_AUTO); + logo.awake.set(false); + logo.lid.set(Some(true)); + logo.handled.set(handled); + let (marker, _) = temp_marker("lid-unhandled"); + run(&logo, &marker, BlinkStyle::Calm, secs(20)); + let first = told_at(&logo).expect("told anyway"); + assert!( + first <= LOOK_EVERY * LID_HANDLED_LOOKS, + "{handled:?}: told at {first:?}" + ); + } + } + + #[test] + fn a_closed_lid_that_keeps_the_logo_dark_says_so_and_tries_again_now_and_then() { + let logo = FakeLogo::new(MODE_AUTO); + logo.through_lid.set(false); + let (marker, path) = temp_marker("lid-dark"); + logo.close_the_lid(); + let mut tells_at_a_minute = None; + run_until(&logo, &marker, BlinkStyle::Calm, secs(130), |logo, now| { + if now >= secs(60) && tells_at_a_minute.is_none() { + tells_at_a_minute = Some(logo.lid_writes().len()); + } + }); + assert_eq!(logo.statuses(), [LidStatus::StayedDark]); let lid = logo.lid_writes(); - assert_eq!(lid.iter().filter(|w| **w == W::Display(true)).count(), 1, "{lid:?}"); - assert_eq!( - &lid[lid.len() - 2..], - [W::Display(false), W::Lid(false)], - "put back at the end" + assert!( + lid[..lid.len() - 1].iter().all(|w| *w == W::Lid(true)), + "never told closed in between: {lid:?}" ); - assert_eq!(logo.statuses(), [LidStatus::BlinkingDisplayWoken]); - assert!(logo.falls() >= 10); - } - - #[test] - fn a_closed_lid_nothing_lights_says_so_and_puts_everything_back() { - let logo = FakeLogo::new(MODE_AUTO); - logo.lid_alone.set(false); - logo.with_wake.set(false); - under_the_lid(&logo, BlinkStyle::Calm, secs(60)); - let lid = logo.lid_writes(); - assert_eq!(lid.iter().filter(|w| **w == W::Display(true)).count(), 1, "{lid:?}"); - assert_eq!(&lid[lid.len() - 2..], [W::Display(false), W::Lid(false)]); - assert_eq!(logo.statuses(), [LidStatus::StayedDark]); - } - - #[test] - fn with_another_display_on_the_display_is_never_told_it_woke() { - let logo = FakeLogo::new(MODE_AUTO); - logo.lid_alone.set(false); - logo.displays.set(1); - under_the_lid(&logo, BlinkStyle::Calm, secs(30)); - assert!(!logo.lid_writes().iter().any(|w| matches!(w, W::Display(_)))); - assert_eq!(logo.statuses(), [LidStatus::StayedDark]); + assert_eq!(lid.last(), Some(&W::Lid(false)), "put back at the end"); + // Three tries in the first seconds, then one about every half minute. + let early = tells_at_a_minute.unwrap(); + assert!( + (LID_TRIES as usize..=LID_TRIES as usize + 2).contains(&early), + "{early}" + ); + let later = lid.len() - 1 - early; + assert!((1..=3).contains(&later), "{later} more in the next 70 s"); + assert!(!path.exists()); } #[test] fn a_dark_lid_that_lights_later_says_it_blinks() { let logo = FakeLogo::new(MODE_AUTO); - logo.lid_alone.set(false); - logo.with_wake.set(false); + logo.through_lid.set(false); logo.close_the_lid(); let (marker, _) = temp_marker("lid-later"); - run_until(&logo, &marker, BlinkStyle::Calm, secs(60), |logo, now| { - if now >= secs(30) { - logo.lid_alone.set(true); + run_until(&logo, &marker, BlinkStyle::Calm, secs(120), |logo, now| { + if now >= secs(20) { + logo.through_lid.set(true); } }); assert_eq!(logo.statuses(), [LidStatus::StayedDark, LidStatus::Blinking]); + assert!(logo.falls() >= 10, "and blinks on from there"); } #[test] @@ -2527,6 +2596,7 @@ mod tests { } }); assert!(back, "lit again under the lid"); + assert_eq!(logo.lid_writes(), [W::Lid(true), W::Lid(true), W::Lid(false)]); assert_eq!(logo.statuses(), [LidStatus::Blinking]); } @@ -2540,11 +2610,7 @@ mod tests { logo.open_the_lid(); } }); - let lid = logo.lid_writes(); - assert!( - !lid.is_empty() && lid.iter().all(|w| *w == W::Lid(true)), - "the daemon told it the lid is open: {lid:?}" - ); + assert_eq!(logo.lid_writes(), [W::Lid(true)], "the daemon told it the lid is open"); assert!( logo.writes_since(secs(21)).contains(&W::Keyboards(true)), "the keyboards follow the blink again" @@ -2553,6 +2619,27 @@ mod tests { assert!(!path.exists()); } + #[test] + fn unplugged_under_the_lid_the_logo_goes_dark() { + let logo = FakeLogo::new(MODE_AUTO); + logo.close_the_lid(); + let (marker, _) = temp_marker("lid-unplugged"); + let mut falls_once_dark = None; + run_until(&logo, &marker, BlinkStyle::Calm, secs(40), |logo, now| { + logo.battery.set(now >= secs(20)); + if now >= secs(23) && falls_once_dark.is_none() { + falls_once_dark = Some(logo.falls()); + } + }); + assert_eq!(logo.lid_writes(), [W::Lid(true), W::Lid(false)]); + assert_eq!(logo.statuses(), [LidStatus::Blinking, LidStatus::OnBattery]); + assert_eq!(Some(logo.falls()), falls_once_dark, "no blink on battery"); + assert!( + !logo.writes_since(secs(23)).contains(&W::Heading(true)), + "and nothing lights it: the Mac is about to sleep" + ); + } + #[test] fn on_battery_or_with_keyboards_out_of_reach_a_closed_lid_stays_dark() { for (battery, keyboards, status) in [ @@ -2584,12 +2671,14 @@ mod tests { #[test] fn the_lid_sensor_wins_over_a_display_list_that_still_has_the_display() { + // What a Mac kept awake with no other display shows: the built-in display + // stays online and is not called asleep. let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("lid-sensor"); run_until(&logo, &marker, BlinkStyle::Calm, secs(40), |logo, now| { if now >= secs(10) && logo.lid.get() != Some(true) { - // The sensor says closed; the display list still shows the display. logo.lid.set(Some(true)); + logo.handled.set(Some(true)); logo.told_open.set(false); } }); @@ -2614,10 +2703,7 @@ mod tests { assert!(logo.tell_lid(true)); recover(&logo, &marker, &Marker(None)); let writes = logo.writes(); - assert_eq!( - &writes[writes.len() - 3..], - [W::Heading(true), W::Display(false), W::Lid(false)] - ); + assert_eq!(&writes[writes.len() - 2..], [W::Heading(true), W::Lid(false)]); assert!(!path.exists()); } @@ -2626,7 +2712,6 @@ mod tests { assert_eq!(LidStatus::Blinking.text(), "blinking"); for status in [ LidStatus::Blinking, - LidStatus::BlinkingDisplayWoken, LidStatus::StayedDark, LidStatus::OnBattery, LidStatus::NoKeyboards, @@ -2783,13 +2868,23 @@ mod tests { } #[test] - fn a_sleep_is_the_line_too() { + fn a_sleep_under_the_closed_lid_is_the_line_too() { + // Seen at a dark wake: the lid is still closed. let logo = FakeLogo::new(MODE_AUTO); + logo.close_the_lid(); let (marker, _) = temp_marker("watch-slept"); let mut script = Script::new(&logo, &[], 20_000); + script.slept_at = Some(100); + watch(&logo, &marker, WORK, &mut script); + assert_eq!(logo.statuses(), [LidStatus::Slept]); + + // A sleep with the lid open says nothing about the lid. + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("watch-slept-open"); + let mut script = Script::new(&logo, &[], 20_000); script.slept_at = Some(5_000); watch(&logo, &marker, WORK, &mut script); - assert_eq!(logo.statuses(), [LidStatus::Slept]); + assert_eq!(logo.statuses(), []); } #[test] From 5cd8fa049ae50d1d6b4685efc86a6abe67597de3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 9 Nov 2026 16:09:16 +0100 Subject: [PATCH 6/6] feat(desktop): blink the logo while a LogoLight that steps aside runs LogoLight drives the lid logo with the same DisplayLogicalState fades, so Codeman used to leave the logo to it whenever it ran: the owner had LogoLight open and the release candidate never breathed. LogoLight steps aside instead since its 1.3.0 ("LogoLight steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards"). This is Codeman's side of that contract, which lives in the marker the blinker already keeps for crash recovery: - it now holds the blinker's process ID; - it goes down right before the first write rather than when the blink starts, so a blink waiting for the display does not pause LogoLight for nothing; under a closed lid the first write is telling the logo the lid is open, so the marker and the wait come before that; - with a LogoLight that says LogoLightStepsAsideForCodeman in its Info.plist, the blinker waits two seconds after writing it, since LogoLight looks every second. A blink that starts within a second of the last one's end (work, then a permission) skips the wait: LogoLight takes the logo back only after the marker has been gone for 1.5 s. The key is read from the running LogoLight's bundle by process ID (proc_pidpath, then the plist file, never NSBundle, which would keep a stale copy across a LogoLight update). An older LogoLight is still left the logo, and the warning when the switch is turned on now says to update it. At launch, a logo a killed child left is put back as before, but with any LogoLight running the lid state is left to it. Tests: 229 pass on the Mac mini, and six deliberate breaks of the handover each fail one. The Info.plist lookup was run against a stand-in process carrying the Info.plist of the released LogoLight 1.3.1: it reads "steps aside". Not seen with a real LogoLight on a real logo yet. Co-Authored-By: Claude Fable 5.2 --- packages/desktop/README.md | 19 +- packages/desktop/src/logo.rs | 377 ++++++++++++++++++++++++++++++++--- packages/desktop/src/main.rs | 3 +- 3 files changed, 371 insertions(+), 28 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 86f19bd3..1c7315d5 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 says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses 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. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. LogoLight (1.3 or later) steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards, with whatever it showed before; while an older LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses 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) @@ -285,6 +285,20 @@ lid helper does that (`pmset disablesleep`, which holds the Mac at full wake), o own Lit with Lid Closed, or an external display. An assertion against system sleep alone would not do: LogoLight saw the Mac enter a dark wake with it, where the logo stays dark. +LogoLight drives the logo the same way, so the two would fight over every fade. The marker the +child keeps next to the app's settings while it moves the logo (`logo-fade`, which also lets the +next launch put back a logo a killed child left dark) holds the child's process ID, goes down +right before the first write and comes off once the logo is back to normal. A LogoLight that says +`LogoLightStepsAsideForCodeman` in its Info.plist (read from the running app's bundle, by its +process ID) looks at that file every second and pauses while it names a running +`codeman-desktop`, so the child waits two seconds after writing it before its first fade. +LogoLight takes the logo back only once the file has been gone for a second and a half, so a +blink that starts within a second of the last one's end (work, then a permission) does not +wait again. An older LogoLight is left the logo, as before. LogoLight ships its side since +1.3.0. With the lid closed, the two tell the logo the same thing (the lid is open), so a +LogoLight that lights the logo under the lid and a blink do not undo each other; when a blink +ends there the logo goes dark, and LogoLight lights it again as it takes it back. + 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 the switch is on and a server is open, tied to the app by @@ -365,7 +379,8 @@ crosses, so the login is never on a command line. outright (SIGKILL) leaves the logo where it was, perhaps dark, until the display next sleeps and wakes or the app next starts. Another app that drives the logo the same way and is not LogoLight is not recognised, and the two - fight. `api/sessions` does not say which turns are Codeman's own, so with + fight. The handover with a LogoLight that steps aside was checked against a + stand-in process and LogoLight's own tests, not yet on a real logo. `api/sessions` does not say which turns are Codeman's own, so with prompt-cache keep-warm on, a keep-alive turn blinks too. - **Blinking with the lid closed has not been seen from this app on a real MacBook yet.** The method is LogoLight's, which its author saw working on the diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index 78286eed..1eb39296 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -80,7 +80,14 @@ //! logo, and the app's event loop gets no timers for it. //! //! Another app driving the logo the same way (LogoLight itself) would fight -//! every fade, so while one runs the logo is left to it, as it is while the +//! every fade. A LogoLight that says it steps aside (`LogoLightStepsAsideForCodeman` +//! in its Info.plist) is lent the logo instead: the marker above holds the +//! child's process ID, goes down right before the blink's first write, and +//! comes off once the logo is back to normal, and LogoLight pauses while it +//! names a running `codeman-desktop`. So the child waits `LOAN_GRACE` after +//! writing it, for LogoLight to have paused, unless the last blink ended a +//! moment ago (`LOAN_LINGER`): LogoLight is then still paused. LogoLight ships +//! its side since 1.3.0. An older LogoLight is left the logo, as it is while the //! user has it switched off: the child tries again once the agents go quiet. use crate::agents::Activity; @@ -478,6 +485,15 @@ const LID_HANDLED_LOOKS: u32 = 8; 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); +/// How long a blink waits after writing its marker, before its first write, +/// while a LogoLight that steps aside runs: LogoLight looks at the marker every +/// second, and pauses its effect when it sees it. +const LOAN_GRACE: Duration = Duration::from_secs(2); +/// How long after a blink's end that LogoLight is surely still paused: it takes +/// the logo back only once the marker has been gone for 1.5 s (its +/// `LoanWatcher.returnDelay`). A blink that starts within this of the last one's +/// end (a change of state: work, then a permission) skips the wait above. +const LOAN_LINGER: Duration = Duration::from_secs(1); /// What a blink needs from the machine, so its decisions can be tested. pub trait Logo { @@ -497,8 +513,11 @@ pub trait Logo { fn set_keyboards_lit(&self, lit: bool); /// The built-in display is on: the lid is open and the display awake. fn display_awake(&self) -> bool; - /// Another app drives the logo the same way. + /// Another app drives the logo the same way, and would fight a blink. fn driven_elsewhere(&self) -> bool; + /// Another app drives the logo the same way, and steps aside while the + /// marker says a blink runs. + fn steps_aside_elsewhere(&self) -> bool; /// The lid is closed: the lid sensor (`AppleClamshellState`, what `ioreg -k /// AppleClamshellState` shows). None if it cannot be read. fn lid_closed(&self) -> Option; @@ -552,7 +571,7 @@ impl LidStatus { LidStatus::NoLidSensor => "not tried, the lid sensor could not be read", LidStatus::Idle => "nothing to show, no agent at work", LidStatus::SwitchedOff => "not tried, the logo is switched off", - LidStatus::LogoLight => "not tried, LogoLight has the logo", + LidStatus::LogoLight => "not tried, an older LogoLight has the logo", LidStatus::Refused => "not tried, the logo refused a change", LidStatus::Slept => "the Mac went to sleep", } @@ -708,9 +727,12 @@ impl Marker { Self::named("logo-blink") } + /// Writes the marker, holding this process's ID: LogoLight steps aside while + /// it names a running Codeman. 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() + path.parent().is_some_and(|dir| std::fs::create_dir_all(dir).is_ok()) + && std::fs::write(path, format!("{}\n", std::process::id())).is_ok() } fn clear(&self) { @@ -734,16 +756,32 @@ enum Ended { /// Move the logo through `moves`, over and over, until `wait` reports that it is /// time to stop, then put it back to normal. `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 child killed in a dark phase would have no way back. -fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut(Duration) -> bool) -> Ended { - if moves.is_empty() || logo.mode() != Some(MODE_AUTO) || logo.driven_elsewhere() || !marker.set() { - return Ended::Declined; +/// answers true if the blink should end. The marker goes down right before the +/// first write, and one that cannot be written refuses the blink: without it a +/// child killed in a dark phase would have no way back. +#[cfg(test)] +fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], wait: impl FnMut(Duration) -> bool) -> Ended { + blink_after(logo, marker, moves, LOAN_GRACE, wait).0 +} + +/// `blink`, giving a LogoLight that steps aside `grace` to pause before the +/// first write. Also answers whether the marker went down: LogoLight was then +/// paused until this blink's end, and stays so for a moment (`LOAN_LINGER`). +fn blink_after( + logo: &impl Logo, + marker: &Marker, + moves: &[Move], + grace: Duration, + mut wait: impl FnMut(Duration) -> bool, +) -> (Ended, bool) { + if moves.is_empty() || logo.mode() != Some(MODE_AUTO) || logo.driven_elsewhere() { + return (Ended::Declined, false); } let mut fader = Fader::new(logo); let mut lid = Lid::default(); let mut ended = Ended::Stopped; let mut asleep = false; + let mut claimed = false; let mut next = 0; 'blink: loop { let step = moves[next % moves.len()]; @@ -773,6 +811,14 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut match look_at_lid(logo, &mut fader, &mut lid) { LidLook::Go => {} LidLook::Tell => { + // Telling the lid is a write, and may be the blink's first. + if !claimed { + if let Some(end) = claim(logo, marker, grace, &mut wait) { + ended = end; + break; + } + claimed = true; + } if !fader.open_lid() { lid.report(logo, LidStatus::NoKeyboards); if wait(NOT_LIT_RECHECK) { @@ -791,6 +837,15 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut } } } + if !claimed { + // Only now, so a LogoLight is not paused for a blink that is waiting + // for the display. + if let Some(end) = claim(logo, marker, grace, &mut wait) { + ended = end; + break; + } + claimed = true; + } let proving = step.lit && fader.lid_open && !lid.proven; let from_dark = step.lit && logo.factor().is_some_and(|factor| factor <= 0.0); // With the display off the keyboards are not touched: under a closed lid @@ -829,7 +884,17 @@ fn blink(logo: &impl Logo, marker: &Marker, moves: &[Move], mut wait: impl FnMut if fader.restore() { marker.clear(); } - ended + (ended, claimed) +} + +/// Puts the marker down, right before a blink's first write, and gives a +/// LogoLight that steps aside `grace` to see it and pause. Some if the blink +/// ends here: the marker could not be written, or `wait` said stop. +fn claim(logo: &impl Logo, marker: &Marker, grace: Duration, wait: &mut impl FnMut(Duration) -> bool) -> Option { + if !marker.set() { + return Some(Ended::Declined); + } + (!grace.is_zero() && logo.steps_aside_elsewhere() && wait(grace)).then_some(Ended::Stopped) } /// The built-in display can be seen: it is on, and the lid sensor does not say @@ -969,7 +1034,10 @@ pub fn recover(logo: &impl Logo, marker: &Marker, legacy: &Marker) { // Which way it heads is not known: `restore` turns it round first. fader.touched = true; // A child killed with the lid closed may have told the logo it is open. - fader.lid_open = logo.lid_closed() == Some(true); + // With a LogoLight running the lid is its to tell: it may be lighting + // the logo under it (Lit with Lid Closed), and nothing here would make it + // tell the logo again. + fader.lid_open = logo.lid_closed() == Some(true) && !logo.driven_elsewhere() && !logo.steps_aside_elsewhere(); if fader.restore() { marker.clear(); } @@ -1024,7 +1092,9 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i let mut declined = false; // What the menu line was last told from here, and when the lid is next read. let mut said: Option = None; - let mut look_at_lid = signals.now(); + let mut lid_look_due = signals.now(); + // When the last blink that had its marker down ended. + let mut released: Option = None; loop { if signals.wait(LOOK_EVERY) { return; @@ -1039,8 +1109,8 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i } if style == BlinkStyle::Off || declined { // No blink runs: if the lid is closed, the menu line says why. - if signals.now() >= look_at_lid { - look_at_lid = signals.now() + NOT_LIT_RECHECK; + if signals.now() >= lid_look_due { + lid_look_due = signals.now() + NOT_LIT_RECHECK; if logo.lid_closed() == Some(true) { let why = if style == BlinkStyle::Off { LidStatus::Idle @@ -1061,7 +1131,11 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i continue; } let mut app_gone = false; - let ended = blink(logo, marker, &style.moves(), |time| { + let grace = match released { + Some(at) if signals.now().duration_since(at) < LOAN_LINGER => Duration::ZERO, + _ => LOAN_GRACE, + }; + let (ended, claimed) = blink_after(logo, marker, &style.moves(), grace, |time| { let deadline = signals.now() + time; loop { let left = deadline.saturating_duration_since(signals.now()); @@ -1084,9 +1158,10 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i return; } declined = ended == Ended::Declined; + released = claimed.then(|| signals.now()); // The blink may have said something since: say it again once it ends. said = None; - look_at_lid = signals.now(); + lid_look_due = signals.now(); } } @@ -1433,6 +1508,10 @@ mod system { driven_elsewhere() } + fn steps_aside_elsewhere(&self) -> bool { + steps_aside_elsewhere() + } + fn lid_closed(&self) -> Option { lid_sensor() } @@ -1612,25 +1691,96 @@ mod system { .is_some_and(|&display| unsafe { CGDisplayIsAsleep(display) } == 0) } - /// Whether LogoLight runs, which drives the logo with the same fades. + /// Whether a LogoLight runs that drives the logo with the same fades and + /// would fight a blink: one from before it learnt to step aside. pub fn driven_elsewhere() -> bool { - running(LOGOLIGHT) + logolight() == Some(false) } - /// Whether a process of this name runs (the name the kernel keeps, at most - /// 32 bytes, which LogoLight's is well inside). - fn running(wanted: &[u8]) -> bool { + /// Whether a LogoLight runs that steps aside while the marker says a blink + /// runs (`LogoLightStepsAsideForCodeman` in its Info.plist). + pub fn steps_aside_elsewhere() -> bool { + logolight() == Some(true) + } + + /// LogoLight's own key for its side of the contract (its `CodemanLoan`). + const STEPS_ASIDE_KEY: &str = "LogoLightStepsAsideForCodeman"; + + /// Whether LogoLight runs (None if not), and if so whether it steps aside. + /// Its Info.plist is read once per process: an update relaunches it. + fn logolight() -> Option { + static SEEN: Mutex> = Mutex::new(None); + let pid = find(LOGOLIGHT)?; + if let Some((seen, answer)) = SEEN.lock().ok().and_then(|seen| *seen) { + if seen == pid { + return Some(answer); + } + } + let answer = steps_aside(pid); + if let Ok(mut seen) = SEEN.lock() { + *seen = Some((pid, answer)); + } + Some(answer) + } + + /// Whether the app bundle this process runs from says it steps aside. Read + /// from the file each time, never through NSBundle, which keeps what it read + /// for a path for the life of this process. + fn steps_aside(pid: libc::pid_t) -> bool { + let mut path = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: the buffer is as long as the call is told, and the answer is + // how much of it the path filled. + let len = unsafe { libc::proc_pidpath(pid, path.as_mut_ptr().cast(), path.len() as u32) }; + let Some(path) = usize::try_from(len) + .ok() + .and_then(|len| std::str::from_utf8(&path[..len]).ok()) + else { + return false; + }; + let Some(app) = std::path::Path::new(path) + .ancestors() + .find(|dir| dir.extension().is_some_and(|ext| ext == "app")) + else { + return false; + }; + let info = app.join("Contents").join("Info.plist"); + let Some(info) = info.to_str() else { + return false; + }; + autoreleasepool(|_| { + let Some(class) = AnyClass::get(c"NSDictionary") else { + return false; + }; + let info = NSString::from_str(info); + // SAFETY: NSDictionary's class method takes a path and answers the + // property list there (XML or binary), or nil. + let plist: Option> = unsafe { msg_send![class, dictionaryWithContentsOfFile: &*info] }; + let Some(plist) = plist else { return false }; + let Some(plist) = plist.downcast_ref::() else { + return false; + }; + let key = NSString::from_str(STEPS_ASIDE_KEY); + // SAFETY: an NSDictionary answers objectForKey:, with nil for a key it + // does not have. + let value: Option> = unsafe { msg_send![plist, objectForKey: &*key] }; + value.and_then(|value| value.downcast_ref::().map(|n| n.as_i64() >= 1)) == Some(true) + }) + } + + /// The first process of this name (the name the kernel keeps, at most 32 + /// bytes, which LogoLight's is well inside). + fn find(wanted: &[u8]) -> Option { // SAFETY: with no buffer it answers how many processes there are. let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; if count <= 0 { - return false; + return None; } let mut pids = vec![0 as libc::pid_t; count as usize + 32]; let bytes = (pids.len() * std::mem::size_of::()) as libc::c_int; // SAFETY: the buffer is `bytes` long, and the answer is how many it filled. let filled = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) }; pids.truncate(filled.max(0) as usize); - pids.iter().any(|&pid| { + pids.into_iter().find(|&pid| { let mut name = [0u8; 64]; // SAFETY: the buffer is 64 bytes, and the answer is the name's length. let len = unsafe { libc::proc_name(pid, name.as_mut_ptr().cast(), name.len() as u32) }; @@ -1777,7 +1927,8 @@ mod system { println!("lid sensor: {:?}", lid_sensor()); println!("clock: {}", clock()); println!("driven elsewhere: {}", driven_elsewhere()); - println!("finds the shell that ran this (bash): {}", running(b"bash")); + println!("steps aside elsewhere: {}", steps_aside_elsewhere()); + println!("finds the shell that ran this (bash): {}", find(b"bash").is_some()); println!("keyboard backlight IDs: {:?}", keyboard_ids()); match SystemLogo::open() { Some(logo) => println!( @@ -1886,6 +2037,7 @@ mod tests { keyboards_reachable: Cell, battery: Cell, elsewhere: Cell, + aside: Cell, gate: Cell, refuse_writes: Cell, lit: Cell, @@ -1910,6 +2062,7 @@ mod tests { keyboards_reachable: Cell::new(true), battery: Cell::new(false), elsewhere: Cell::new(false), + aside: Cell::new(false), gate: Cell::new(Duration::ZERO), refuse_writes: Cell::new(false), lit: Cell::new(true), @@ -2107,6 +2260,10 @@ mod tests { self.elsewhere.get() } + fn steps_aside_elsewhere(&self) -> bool { + self.aside.get() + } + fn lid_closed(&self) -> Option { self.lid.get() } @@ -2373,6 +2530,178 @@ mod tests { assert!(!path.exists()); } + #[test] + fn an_app_that_steps_aside_is_given_the_marker_and_a_moment_first() { + let logo = FakeLogo::new(MODE_AUTO); + logo.aside.set(true); + let (marker, path) = temp_marker("aside"); + let mut first = None; + let ended = blink(&logo, &marker, &BlinkStyle::Calm.moves(), |time| { + if first.is_none() { + first = Some((time, logo.writes().len(), path.exists())); + } + logo.advance(time); + logo.now.get() >= secs(10) + }); + assert_eq!(ended, Ended::Stopped); + assert_eq!( + first, + Some((LOAN_GRACE, 0, true)), + "the marker first, then the wait, then the first write" + ); + assert!(logo.falls() >= 2, "it blinks after the wait"); + assert_eq!(logo.settled_level(), 1.0); + assert!(!path.exists()); + + // With no such app, the first write comes straight away. + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("no-aside"); + let mut first_writes = None; + blink(&logo, &marker, &BlinkStyle::Calm.moves(), |_| { + first_writes.get_or_insert(logo.writes().len()); + true + }); + assert!(first_writes.is_some_and(|writes| writes > 0)); + } + + #[test] + fn the_app_quitting_during_that_moment_leaves_the_logo_untouched() { + let logo = FakeLogo::new(MODE_AUTO); + logo.aside.set(true); + let (marker, path) = temp_marker("aside-quit"); + let ended = blink(&logo, &marker, &BlinkStyle::Calm.moves(), |_| true); + assert_eq!(ended, Ended::Stopped); + assert_eq!(logo.writes(), []); + assert!(!path.exists()); + } + + #[test] + fn the_marker_names_the_blinker() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("names"); + let mut seen = Vec::new(); + run_until(&logo, &marker, BlinkStyle::Calm, secs(5), |_, _| { + seen.push(std::fs::read_to_string(&path).ok()); + }); + let pid = format!("{}\n", std::process::id()); + assert!(!seen.is_empty()); + assert!( + seen.iter().all(|text| text.as_deref() == Some(pid.as_str())), + "{seen:?}" + ); + assert!(!path.exists()); + } + + #[test] + fn no_marker_while_the_blink_waits_for_the_display() { + let logo = FakeLogo::new(MODE_AUTO); + logo.awake.set(false); + let (marker, path) = temp_marker("waits"); + let (mut while_asleep, mut once_awake) = (false, false); + run_until(&logo, &marker, BlinkStyle::Calm, secs(10), |logo, now| { + if logo.awake.get() { + once_awake |= path.exists(); + } else { + while_asleep |= path.exists(); + } + logo.awake.set(now >= secs(4)); + }); + assert!( + !while_asleep, + "a LogoLight with the lid closed is not paused for nothing" + ); + assert!(once_awake); + assert!(!path.exists()); + } + + /// The times of the writes made from `from` to `to`. + fn write_times(logo: &FakeLogo, from: Duration, to: Duration) -> Vec { + logo.writes + .borrow() + .iter() + .map(|&(at, _)| at) + .filter(|&at| at >= from && at < to) + .collect() + } + + #[test] + fn a_blink_right_after_another_does_not_wait_for_logolight_again() { + let styles = BlinkStyles { + waiting: BlinkStyle::Fast, + ..WORK + }; + let logo = FakeLogo::new(MODE_AUTO); + logo.aside.set(true); + let (marker, path) = temp_marker("loan-linger"); + let mut script = Script::with(&logo, &[(0, 20_000, working()), (20_000, 40_000, waiting())], 45_000); + watch(&logo, &marker, styles, &mut script); + // The first blink waits: the work settles, the marker goes down, then the wait. + let first = write_times(&logo, Duration::ZERO, secs(60))[0]; + assert!(first >= SETTLE + LOAN_GRACE, "first write at {first:?}"); + // The change to waiting ends it and starts the next, with LogoLight still paused. + let around = write_times(&logo, secs(22), secs(30)); + let gap = around.windows(2).map(|w| w[1] - w[0]).max().expect("writes"); + assert!(gap < LOAN_GRACE, "a pause of {gap:?} at the change"); + assert!(!path.exists()); + } + + #[test] + fn a_blink_after_a_rest_waits_for_logolight_again() { + let logo = FakeLogo::new(MODE_AUTO); + logo.aside.set(true); + let (marker, _) = temp_marker("loan-again"); + let mut script = Script::new(&logo, &[(0, 10_000), (20_000, 30_000)], 35_000); + watch(&logo, &marker, WORK, &mut script); + // The first ends once the rest has settled; the second waits like the first. + let second = write_times(&logo, secs(15), secs(60)); + assert!( + second[0] >= secs(20) + SETTLE + LOAN_GRACE, + "its first write at {:?}", + second[0] + ); + } + + #[test] + fn under_the_closed_lid_the_marker_and_the_wait_come_before_the_logo_is_told() { + let logo = FakeLogo::new(MODE_AUTO); + logo.aside.set(true); + logo.close_the_lid(); + let (marker, path) = temp_marker("loan-lid"); + let mut first = None; + blink(&logo, &marker, &BlinkStyle::Calm.moves(), |time| { + if first.is_none() { + first = Some((time, logo.writes().len(), path.exists())); + } + logo.advance(time); + logo.now.get() >= secs(20) + }); + assert_eq!(first, Some((LOAN_GRACE, 0, true))); + assert_eq!( + logo.writes().first(), + Some(&W::Lid(true)), + "then the lid, then the blink" + ); + assert_eq!(logo.statuses(), [LidStatus::Blinking]); + assert!(!path.exists()); + } + + #[test] + fn recover_leaves_the_closed_lid_to_a_logolight() { + for (elsewhere, aside) in [(true, false), (false, true)] { + let logo = FakeLogo::new(MODE_AUTO); + logo.elsewhere.set(elsewhere); + logo.aside.set(aside); + let (marker, path) = temp_marker("lid-logolight"); + assert!(marker.set()); + logo.close_the_lid(); + // LogoLight lights the logo under the lid. + logo.told_open.set(true); + recover(&logo, &marker, &Marker(None)); + assert_eq!(logo.lid_writes(), [], "elsewhere {elsewhere}, aside {aside}"); + assert!(path.exists(), "tried again at a launch with the lid open"); + } + } + #[test] fn a_logo_switched_off_during_a_blink_stays_off() { let logo = FakeLogo::new(MODE_AUTO); diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index b91f2fe7..599c418d 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -110,8 +110,7 @@ enter it again (a single-user Codeman accepts the right password at once), or wa 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 LOGO_DRIVEN_ELSEWHERE: &str = - "LogoLight is driving the Apple logo, so Codeman leaves it alone. Quit LogoLight to see your agents on it."; +const LOGO_DRIVEN_ELSEWHERE: &str = "An older LogoLight is driving the Apple logo, so Codeman leaves it alone. Update LogoLight to 1.3 or later (or quit it) to see your agents on it."; /// What a download's toast calls a file whose name is not known. const UNNAMED_FILE: &str = "the file"; const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";