diff --git a/README.md b/README.md index 95e5fb4a..56049142 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ The first run asks three questions (data folder, port, password; Enter takes the **[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later -A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB. +A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while they work, blinks when one waits for you and flashes when one needs permission, and each state can take a style of your own. It stays off until you turn it on. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB. Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**. diff --git a/README.zh-CN.md b/README.zh-CN.md index b7eca438..678ad81d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,7 +110,7 @@ bash docker/Start-Codeman.sh **[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本 -一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。 +一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志显示智能体在做的事:工作时缓慢呼吸,有智能体等你回复时闪烁,需要权限时快闪三下;每种状态也可以换成你自己选的样式。在你打开之前,它保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。 打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。 diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 094aa160..67b622ba 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -248,9 +248,10 @@ A small native app that opens Codeman in its own window. Its first screen has tw runs the installer on that machine over SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, -**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of -your own for working, for waiting for you and for needing permission. All three are off -until you choose, and the menu only exists on such a Mac. +**View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while +they work, blinks when one waits for you and flashes three times when one needs permission. +Each state can take another style under **View > Apple Logo Blink Styles**. It is off until +you turn it on, and the menu only exists on such a Mac. ### First launch diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 01aa4a22..b42d762d 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, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. | +| **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. | | **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) @@ -225,31 +225,43 @@ If that probe is refused or rate limited, the connect screen comes back; the app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server restart, each request that arrives without a cookie gets a 401 before the -login goes out, and those count until one succeeds. Many windows reconnecting -at once can push a request past 10, and that request then fails with a 429 -instead of being answered. A refusal that lands while you are in another app -does not bring Codeman forward: the connect screen loads where the window is, -and the Dock icon bounces. +login goes out, and those count until on**The logo moves on its own hardware fades, from a child process, through CoreBrightness.** +Apple documents one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)): +`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`, an on/off setting that +`corebrightnessd` keeps in root's preferences. A process of the logged-in user can reach the +daemon without an administrator through `BrightnessSystemClient` in the private CoreBrightness +framework, and 1.50.0 blinked by switching that setting there. The blink now uses two other +properties of the same client, which [LogoLight](https://github.com/Ark0N/LogoLight) (MIT, its +`docs/corebrightnessd.md`) measured on the real logo and `src/logo.rs` reimplements: +`LEPWMFactorFadeDuration` sets how long the next fade takes, and `DisplayLogicalState` 1 +(Blank) fades the logo to dark and 2 (Unblank) back to lit, on the LED's own spring curve. +Nothing of that is saved, so a blink writes no setting and leaves nothing behind a restart; +`LEBacklightMode` is only read. -**The logo blinks from a child process, through CoreBrightness.** Apple documents -one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)): -`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that -for every blink would need a root helper and an administrator password. The same -setting is also a property of `BrightnessSystemClient` in the private -CoreBrightness framework, and a process of the logged-in user can set it there, -which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an -ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond -after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled` -keeps its value while the logo is dark. +The daemon decides the rest. Writing the direction the logo already heads in is ignored, and +the state cannot be read back, so the child keeps track of it and, when it does not know, +first turns the logo round with a fade too slow to see. The keyboard backlight follows +`DisplayLogicalState` too, so after every Blank the keyboards alone are told Unblank +(`setProperty:withKey:keyboardID:`), only while the built-in display is awake +(`CGDisplayIsAsleep`); when it sleeps they are told Blank, and Unblank when it wakes. A rise +from exactly dark waits for the display to show a frame, up to half a second with nothing +changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast's) turn round +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. -The setting is system-wide and survives a reboot, so the app must not end with the -logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the -app can be relied on. So all of it runs as this same binary with -`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app -by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo -back and exits. Switching it off closes the same pipe, so the path a crash -takes is the one used every day. A marker file -(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself: +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 +nothing but a pipe: when the pipe closes, however the app ended, it puts the logo back (lit, +with the daemon's automatic fade duration) and exits. Switching it off closes the same pipe, +so the path a crash takes is the one used every day. A marker file (`logo-fade`, next to +`desktop.json`) covers what the child cannot survive itself: at the next launch the app puts +back a logo a blink left dark, and with 1.50.0's marker (`logo-blink`) it switches back on a +logo that version left off. The framework is loaded at run time and never linked, and +`LEBacklightMode` reading nil (a Mac mini answers nil) is what hides the option, so a macOS +that drops it loses this feature and nothing else. + +e child cannot survive itself: at the next launch the app puts back a logo a blink left dark. The framework is loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini answers nil) is what hides the option, so a macOS that drops it loses this feature @@ -308,17 +320,17 @@ crosses, so the login is never on a command line. look as a failed login, like any look at a server the app has no login for. *Check again* asks again. - **The logo blink rests on a private macOS interface.** Apple documents the - setting, not the way the app reaches it, so a macOS update can take it away; - the menu then disappears. Every change of the logo writes `LEBacklightMode`, - from about once every two seconds (*Slow*, *Wink*) to about three times a - second (*Fast*), which `corebrightnessd` keeps in root's preferences. The - setting is on or off, so these styles are rhythms of hard changes. After a - blink the key stays there rather than being absent as on a fresh Mac. That - reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness - LEBacklightMode` removes it. After a power cut in a - dark phase the logo stays dark until the app next starts. `api/sessions` does - not say which turns are Codeman's own, so with prompt-cache keep-warm on, a - keep-alive turn blinks too. + on/off setting, not the fades the app uses, so a macOS update can take them + away. The menu disappears if `LEBacklightMode` goes; if only the fade keys + change, the logo stops moving. A long dark beat (*Calm*, *Slow*, the rest in + *Breathe*) can be up to half a second longer than its style says, while the + display waits for a frame before the logo rises. Every Blank dims the + keyboard backlight a little before it is told to stay lit. A child killed + 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 + prompt-cache keep-warm on, a keep-alive turn blinks too. - **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a self-signed certificate, and WKWebView rejects it (wry has no trust override). Use `http://127.0.0.1:3000` for a server on this Mac. For another diff --git a/packages/desktop/src/config.rs b/packages/desktop/src/config.rs index 98b4ee57..45e9a97a 100644 --- a/packages/desktop/src/config.rs +++ b/packages/desktop/src/config.rs @@ -44,9 +44,10 @@ pub struct Config { pub auto_connect: bool, /// Always open in full screen (otherwise the last state is restored). pub start_fullscreen: bool, - /// What the lit Apple logo does for each state of the agents (logo.rs). The - /// menu only shows on a Mac that has one. A value this version does not know - /// reads as off: a bad style must not make the whole file unreadable. + /// Whether the lit Apple logo shows what the agents are doing, and its style + /// for each state (logo.rs). The menu only shows on a Mac that has one. A + /// value this version does not know reads as off: a bad style must not make + /// the whole file unreadable. #[serde(deserialize_with = "lenient_blink")] pub logo_blink: BlinkStyles, /// Anything that is not a whole frame reads as None: a bad frame must not @@ -415,9 +416,12 @@ mod tests { use crate::logo::BlinkStyle; let mut c = Config::default(); assert_eq!(c.logo_blink, BlinkStyles::default()); - c.logo_blink.needs_you = BlinkStyle::Alarm; + assert!(!c.logo_blink.enabled, "off until the user turns it on"); + c.logo_blink.enabled = true; + c.logo_blink.needs_you = BlinkStyle::Wink; let text = serde_json::to_string(&c).unwrap(); - assert!(text.contains(r#""needsYou":"alarm""#), "{text}"); + assert!(text.contains(r#""needsYou":"wink""#), "{text}"); + assert!(text.contains(r#""enabled":true"#), "{text}"); assert_eq!(serde_json::from_str::(&text).unwrap().logo_blink, c.logo_blink); for bad in [ r#"{"logoBlink":3}"#, @@ -434,6 +438,7 @@ mod tests { use crate::logo::BlinkStyle; let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap(); assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat); + assert!(c.logo_blink.enabled); } #[test] diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index 11a75c46..de26fe9b 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -1,45 +1,62 @@ //! The lit Apple logo on the lid of a MacBook Pro with a PROLED XDR display -//! blinks with what the agents are doing, so you can read it across the room: one -//! rhythm while they work, another for a yellow tab (an agent finished and waits -//! for you) and a sharper one for a red tab (it needs a permission or an answer). -//! Opt-in (View menu, a style per state), and the menu exists only on a Mac that -//! has such a logo. +//! shows what the agents are doing, so you can read it across the room. One +//! rule: the more it moves, the more it needs you. It breathes while they work +//! (soft fades: busy, leave it), blinks for a yellow tab (an agent finished and +//! waits for you) and flashes for a red tab (it needs a permission or an +//! answer). One switch turns that on (View > Blink Apple Logo), and each state +//! can take another style under View > Apple Logo Blink Styles. The menu exists +//! only on a Mac that has such a logo. //! -//! Apple documents one control for the logo (support.apple.com/en-us/149248): -//! `sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. What -//! the app uses is the same setting, reached as a property of -//! `BrightnessSystemClient` in the private CoreBrightness framework, which needs -//! no administrator. Measured on a Mac17,13 with macOS 27.2 from an ad-hoc signed -//! binary: the write lands in about a millisecond with no fade, `corebrightnessd` -//! keeps it in its own defaults, and the NVRAM variable `bklb-backlight-enabled` -//! keeps its value while the logo is dark. The mode reads 2 when nothing was ever -//! set. A Mac without the logo answers nil, which is what `available` goes by; a -//! macOS that drops the property makes the feature disappear rather than fail. +//! How it moves the logo is what LogoLight (github.com/Ark0N/LogoLight, MIT, +//! its `docs/corebrightnessd.md`) measured on that MacBook, reimplemented here. +//! `corebrightnessd` owns the LED, and `BrightnessSystemClient` in the private +//! CoreBrightness framework reaches it without an administrator. Writing +//! `DisplayLogicalState` 1 (Blank) fades the logo to dark and 2 (Unblank) back +//! to lit, over `LEPWMFactorFadeDuration` seconds, on the LED's own spring +//! curve. Nothing of that is saved anywhere, so unlike the first version of this +//! feature (which switched `LEBacklightMode`, a setting `corebrightnessd` keeps +//! in root's preferences) a blink writes no setting and nothing it does survives +//! a restart. `LEBacklightMode` is only read: a logo the user switched off +//! stays off. //! -//! The setting is system-wide and survives a reboot, so the one thing that -//! must never happen is the app ending while the logo is dark. All of this -//! therefore runs in a child process (this same binary with `BLINK_ARG`), for as -//! long as any style is chosen and a server is open. The app hands it the server -//! and the login over a pipe (never on its command line, where `ps` shows them) -//! and keeps the pipe open. When the pipe closes, however the app ended (quit, -//! crash, `panic = "abort"`, Force Quit), the child puts the logo back and -//! exits. Switching it off closes the same pipe, so there is one way out -//! and it is the one a crash takes. For what even the child cannot survive -//! (power loss, a SIGKILL of the child itself), a marker file lies next to the -//! app's settings while a blink runs, and `recover` puts the logo back at the -//! next launch. +//! What the daemon does with those keys decides the shape of the code: +//! - A fade only starts on a change of direction, and the state cannot be read +//! back (it reads the last value sent to anything). So the direction is +//! tracked here, and when it is not known, the logo is first turned round +//! with a fade so slow it never visibly moves. +//! - The keyboard backlight follows `DisplayLogicalState` too. After every Blank +//! the keyboards alone are told Unblank, while the display is awake; when it +//! sleeps they are told Blank, and Unblank again when it wakes. +//! - A rise from exactly dark waits for the display to show a frame, up to half +//! a second with nothing changing on screen. A short dark beat (Alarm's, +//! Heartbeat's) is therefore turned round while its fall is still under way, +//! so the logo never quite reaches 0 and the rise is not held back. A long one +//! (Calm's) goes all the way to dark, and its light is timed from when the +//! rise really starts, so a held-back rise makes that dark beat longer and +//! leaves the light as long as it should be. //! //! When several states hold at once the most urgent one with a style is shown: //! red, then yellow, then work. //! +//! 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` +//! shows them) and keeps the pipe open. When the pipe closes, however the app +//! ended (quit, crash, `panic = "abort"`, Force Quit), the child puts the logo +//! back to normal (lit, with the daemon's automatic fade duration) and exits. +//! Switching it off closes the same pipe, so there is one way out and it is the +//! one a crash takes. A child that cannot finish (a SIGKILL) leaves a marker +//! file next to the app's settings, and the next launch puts the logo back; +//! until then it stays where the blink left it, or the display's next sleep and +//! wake relight it. +//! //! The child also does the asking (`agents`), so it needs nothing from the app //! once started: a hidden window, a stalled page and App Nap cannot hold up the //! logo, and the app's event loop gets no timers for it. //! -//! The logo is only ever blinked from mode 2. A logo the user switched off -//! stays off, and a mode that changes under a running blink means someone else -//! wants the setting: the child leaves it as it found it and does not try again -//! until the agents have finished. +//! 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 +//! user has it switched off: the child tries again once the agents go quiet. use crate::agents::Activity; use serde::{Deserialize, Serialize}; @@ -52,13 +69,41 @@ use std::time::{Duration, Instant}; /// The argument that makes this binary the child instead of the app. pub const BLINK_ARG: &str = "--logo-blink"; -/// The logo is dark in every lighting condition (Apple's documented value). +/// `LEBacklightMode`: the logo is dark in every lighting condition (Apple's +/// documented value). Only read, apart from undoing what the first version of +/// this feature could leave behind (`recover`). const MODE_OFF: i64 = 0; -/// The logo follows the display and the ambient light. What the setting reads -/// on a Mac where nobody touched it. +/// `LEBacklightMode`: the logo follows the display and the ambient light. What +/// the setting reads on a Mac where nobody touched it. const MODE_AUTO: i64 = 2; -/// One dark moment and the light after it. +/// A fade duration so long that a fade started with it never visibly moves: +/// how the logo is stopped where it is, or turned round without moving. +const HOLD_SECONDS: f64 = 1e9; +/// How long a new fade takes to start moving. The daemon moves the logo in +/// frames of about 17.5 ms, and a new fade first moved 18 to 20 ms after the +/// write (LogoLight's measurements). +const START_LATENCY: Duration = Duration::from_millis(18); +/// The longest a rise from exactly dark waits for the display: the daemon's +/// display-ready gate gives up after 0.5 s. +const GATE_WAIT: Duration = Duration::from_millis(600); +/// How often the logo is looked at while a rise from dark waits for that gate. +const RISE_POLL: Duration = Duration::from_millis(10); +/// The fades of the hard styles: LogoLight's Heartbeat beats, which were seen +/// as beats on the real logo. A rise from dark looks about twice as fast as a +/// fall over the same time, so the rise is the shorter of the two. +const HARD_FALL: Duration = Duration::from_millis(180); +const HARD_RISE: Duration = Duration::from_millis(120); +/// A dark beat up to this long is a dip: it turns round before its fall ends. +const DIP_LIMIT: Duration = Duration::from_millis(350); +/// How much longer than its dark beat a dip's fall is set to take, so the rise +/// starts while the fall is still under way: about three of the daemon's +/// frames, more than a late sleep and the start latency together. +const DIP_OVERRUN: Duration = Duration::from_millis(60); +/// A fade level the logo counts as fully lit at. +const LIT: f64 = 0.999; + +/// One dark moment and the light after it: the hard styles. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Beat { dark: Duration, @@ -72,15 +117,65 @@ const fn beat(dark_ms: u64, lit_ms: u64) -> Beat { } } -/// A rhythm for the logo, the user's pick in the View menu for each `Signal`. A -/// style switches `LEBacklightMode`, which is on or off, so it is a rhythm of -/// hard changes. +/// One move of the logo: fade toward lit or dark over `fade`, then wait `wait` +/// (counted from the write, or for a rise from dark, from when it moves) before +/// the next move. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Move { + lit: bool, + fade: Duration, + wait: Duration, +} + +const fn fade(lit: bool, fade_ms: u64, wait_ms: u64) -> Move { + Move { + lit, + fade: Duration::from_millis(fade_ms), + wait: Duration::from_millis(wait_ms), + } +} + +impl Beat { + /// A hard beat as moves: a quick fall, and a quick rise after the dark time. + /// A short dark time is a dip, whose fall is set to take longer than the dark + /// time, so the rise turns it round before it reaches 0. + fn moves(self) -> [Move; 2] { + let fall = if self.dark <= DIP_LIMIT { + (self.dark + DIP_OVERRUN).max(HARD_FALL) + } else { + HARD_FALL + }; + [ + Move { + lit: false, + fade: fall, + wait: self.dark, + }, + Move { + lit: true, + fade: HARD_RISE, + wait: self.lit, + }, + ] + } +} + +/// How a style moves the logo. +enum Rhythm { + /// Hard changes, made of quick fades. + Hard(&'static [Beat]), + /// Slow fades. + Soft(&'static [Move]), +} + +/// A rhythm for the logo, the user's pick in the styles menu for each `Signal`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum BlinkStyle { - /// The default: it changes a system-wide setting, so it is asked for. + /// Nothing for this state; the next one down shows instead. #[default] Off, + Breathe, Calm, Slow, Wink, @@ -90,8 +185,9 @@ pub enum BlinkStyle { } impl BlinkStyle { - pub const ALL: [BlinkStyle; 7] = [ + pub const ALL: [BlinkStyle; 8] = [ BlinkStyle::Off, + BlinkStyle::Breathe, BlinkStyle::Calm, BlinkStyle::Slow, BlinkStyle::Wink, @@ -103,6 +199,7 @@ impl BlinkStyle { pub fn label(self) -> &'static str { match self { BlinkStyle::Off => "Off", + BlinkStyle::Breathe => "Breathe", BlinkStyle::Calm => "Calm", BlinkStyle::Slow => "Slow", BlinkStyle::Wink => "Wink", @@ -123,21 +220,30 @@ impl BlinkStyle { .find(|(signal, style)| style.menu_id(*signal) == id) } - /// The rhythm, repeated for as long as its state holds. - const fn pattern(self) -> &'static [Beat] { + fn rhythm(self) -> Rhythm { match self { - BlinkStyle::Off => &[], - BlinkStyle::Calm => CALM, - BlinkStyle::Slow => SLOW, - BlinkStyle::Wink => WINK, - BlinkStyle::Heartbeat => HEARTBEAT, - BlinkStyle::Fast => FAST, - BlinkStyle::Alarm => ALARM, + BlinkStyle::Off => Rhythm::Hard(&[]), + BlinkStyle::Breathe => Rhythm::Soft(BREATHE), + BlinkStyle::Calm => Rhythm::Hard(CALM), + BlinkStyle::Slow => Rhythm::Hard(SLOW), + BlinkStyle::Wink => Rhythm::Hard(WINK), + BlinkStyle::Heartbeat => Rhythm::Hard(HEARTBEAT), + BlinkStyle::Fast => Rhythm::Hard(FAST), + BlinkStyle::Alarm => Rhythm::Hard(ALARM), + } + } + + /// The moves, repeated for as long as its state holds. Every cycle starts + /// with a fall, and the moves alternate between falls and rises. + fn moves(self) -> Vec { + match self.rhythm() { + Rhythm::Hard(beats) => beats.iter().flat_map(|beat| beat.moves()).collect(), + Rhythm::Soft(moves) => moves.to_vec(), } } } -/// What the logo can tell, in the order of the View menu. +/// What the logo can tell, in the order of the styles menu. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Signal { Working, @@ -165,35 +271,83 @@ impl Signal { } } -/// The user's style for each `Signal`, `logoBlink` in the settings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +/// The switch and the style for each `Signal`: `logoBlink` in the settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct BlinkStyles { + /// The one switch. Off until the user turns it on: it moves a light the whole + /// room sees. + pub enabled: bool, pub working: BlinkStyle, pub waiting: BlinkStyle, pub needs_you: BlinkStyle, } +impl Default for BlinkStyles { + /// The switch off, and the styles it turns on: the more the logo moves, the + /// more it needs you. + fn default() -> Self { + BlinkStyles { + enabled: false, + ..Self::RECOMMENDED + } + } +} + impl BlinkStyles { + /// Soft while they work, a plain blink for yellow, three flashes for red. + pub const RECOMMENDED: BlinkStyles = BlinkStyles { + enabled: true, + working: BlinkStyle::Breathe, + waiting: BlinkStyle::Calm, + needs_you: BlinkStyle::Alarm, + }; + /// Reads whatever the file holds. A bad value is off, for its state alone: it - /// must not make the file unreadable. A single style is what this setting was - /// before it had states: that was the choice for work, and whoever made it - /// gets the alerts with it, since they asked for a logo that talks. + /// must not make the file unreadable. + /// + /// Two older shapes are kept. Before the switch, a style per state was all + /// there was, and any style but off meant on: such a file keeps its styles + /// and its switch reads as on, unless all three are off, which was the old + /// default and means it was never touched, so it gets the recommended styles + /// with the switch off. Before the states, a single style was the choice for + /// work, and whoever made it gets the alerts with it, since they asked for a + /// logo that talks. pub fn read(value: &Value) -> Self { - let style = |value: Option<&Value>| { + let style = |value: Option<&Value>| -> BlinkStyle { value .and_then(|value| serde_json::from_value(value.clone()).ok()) .unwrap_or_default() }; match value { - Value::Object(styles) => BlinkStyles { - working: style(styles.get("working")), - waiting: style(styles.get("waiting")), - needs_you: style(styles.get("needsYou")), - }, + Value::Object(saved) => { + let recommended = Self::default(); + let pick = |key: &str, fallback: BlinkStyle| match saved.get(key) { + None => fallback, + some => style(some), + }; + let styles = BlinkStyles { + enabled: false, + working: pick("working", recommended.working), + waiting: pick("waiting", recommended.waiting), + needs_you: pick("needsYou", recommended.needs_you), + }; + match saved.get("enabled") { + Some(enabled) => BlinkStyles { + enabled: enabled.as_bool().unwrap_or(false), + ..styles + }, + None if styles.any_style() => BlinkStyles { + enabled: true, + ..styles + }, + None => recommended, + } + } Value::String(_) => match style(Some(value)) { BlinkStyle::Off => Self::default(), working => BlinkStyles { + enabled: true, working, waiting: BlinkStyle::Wink, needs_you: BlinkStyle::Alarm, @@ -219,19 +373,29 @@ impl BlinkStyles { } } - pub fn any(self) -> bool { + /// Whether any state has a style, switch or not. + pub fn any_style(self) -> bool { Signal::ALL.iter().any(|&signal| self.get(signal) != BlinkStyle::Off) } + /// Whether the logo is to be watched at all: the switch is on and some state + /// has a style. + pub fn active(self) -> bool { + self.enabled && self.any_style() + } + /// Whether anything but work has a style, which is what makes the alerts /// worth asking the server for. fn wants_alerts(self) -> bool { - self.waiting != BlinkStyle::Off || self.needs_you != BlinkStyle::Off + self.enabled && (self.waiting != BlinkStyle::Off || self.needs_you != BlinkStyle::Off) } /// What the logo should do: the most urgent state that holds and has a /// style. A state set to off is not a reason for silence, so it falls through. fn pick(self, activity: Activity) -> BlinkStyle { + if !self.enabled { + return BlinkStyle::Off; + } [ (activity.needs_you, self.needs_you), (activity.waiting, self.waiting), @@ -243,11 +407,15 @@ impl BlinkStyles { } } -// Every change of the logo is a preference write, so these also set how often -// that happens: from about one every two seconds (Slow, Wink) to about four a -// second (Alarm). No style goes above three flashes in any second, the limit -// accessibility guidelines set for flashing light, so what makes Alarm urgent -// is its shape: three hard flashes with a short rest, not a higher rate. +/// LogoLight's Breathe, like the old MacBook sleep light: a slow fall to dark, a +/// short rest, a slower rise and a short hold (fade + wait, in ms). A rise from +/// dark looks about twice as fast as a fall over the same time, so the rise is +/// the longer one. +const BREATHE: &[Move] = &[fade(false, 2000, 2500), fade(true, 3500, 3800)]; +// The hard styles. No style goes above three flashes in any second, the limit +// accessibility guidelines set for flashing light, so what makes Alarm urgent is +// its shape: three hard flashes with a short rest, not a higher rate. +/// A plain blink, every two seconds: yellow's. const CALM: &[Beat] = &[beat(800, 1200)]; const SLOW: &[Beat] = &[beat(1500, 3000)]; /// Mostly lit: a short dark moment every few seconds. @@ -270,22 +438,149 @@ const SETTLE: Duration = Duration::from_secs(3); const LOOK_EVERY: Duration = Duration::from_millis(250); /// What a blink needs from the machine, so its decisions can be tested. -trait Logo { - /// The current mode, None if it could not be read. +pub trait Logo { + /// `LEBacklightMode`, None if it could not be read. fn mode(&self) -> Option; - /// Set the mode; false if the daemon did not take it. + /// Only to put back a logo the first version of this feature left switched off. fn set_mode(&self, mode: i64) -> bool; - /// What the logo is driven at in the current mode, in nits: 0 while it is - /// off for its own reasons (the display sleeps, the lid is closed). - fn nits(&self) -> Option; + /// Where the logo is in a fade, from 0 (dark) to 1 (lit): `LEPWMState.Factor`. + fn factor(&self) -> Option; + /// `LEPWMFactorFadeDuration`, in seconds, for the next fade; 0 is the + /// daemon's automatic one. False if the daemon did not take it. + fn set_fade_duration(&self, seconds: f64) -> bool; + /// `DisplayLogicalState`: fade toward lit (Unblank) or dark (Blank). Ignored + /// by the daemon when it already heads that way. + fn set_heading(&self, lit: bool) -> bool; + /// The same key, sent to the keyboard backlights only. + 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. + fn driven_elsewhere(&self) -> bool; +} + +/// 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> { + logo: &'a L, + /// Which way the logo was last told to fade (true is toward lit), or None + /// when that is not known: at the start, and once the display slept (the + /// daemon changes the state itself then). + heading: Option, + /// What the keyboards were last told, if anything. + keyboards: Option, + /// Anything was written. + touched: bool, + /// Every write was taken. + ok: bool, +} + +impl<'a, L: Logo> Fader<'a, L> { + fn new(logo: &'a L) -> Self { + Fader { + logo, + heading: None, + keyboards: None, + touched: false, + ok: true, + } + } + + fn duration(&mut self, seconds: f64) { + self.touched = true; + self.ok &= self.logo.set_fade_duration(seconds); + } + + /// The keyboards would darken with the logo, so after a Blank they alone are + /// told the display is still on, which they take without a visible change. + /// Never while the display sleeps: that would light the keyboard of a + /// sleeping Mac. + fn heading(&mut self, lit: bool, awake: bool) { + self.touched = true; + self.ok &= self.logo.set_heading(lit); + self.heading = Some(lit); + if !lit && awake { + self.keyboards(true); + } + } + + fn keyboards(&mut self, lit: bool) { + self.logo.set_keyboards_lit(lit); + self.keyboards = Some(lit); + } + + /// Starts a fade the other way that is too slow to see. It stops the logo + /// where it is, and makes heading `toward` a change, which a fade needs. + fn turn_round(&mut self, toward: bool, awake: bool) { + self.duration(HOLD_SECONDS); + self.heading(!toward, awake); + } + + /// Fade toward lit or dark over `time`. + fn fade(&mut self, lit: bool, time: Duration, awake: bool) { + if self.heading != Some(!lit) { + self.turn_round(lit, awake); + } + self.duration(time.as_secs_f64()); + self.heading(lit, awake); + } + + /// The display went to sleep: the daemon now moves the logo itself, with the + /// automatic duration rather than the last one written here (LogoLight does + /// the same), and the keyboards follow the display again. + fn display_slept(&mut self) { + self.heading = None; + if self.touched { + self.duration(0.0); + } + if self.keyboards == Some(true) { + self.keyboards(false); + } + } + + fn display_woke(&mut self) { + if self.keyboards == Some(false) { + self.keyboards(true); + } + } + + /// 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. + fn restore(&mut self) -> bool { + if !self.touched && self.keyboards.is_none() { + return true; + } + let awake = self.logo.display_awake(); + // 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 { + self.heading(true, awake); + } + self.ok && awake + } } /// The file that says a blink may have left the logo dark. -struct Marker(Option); +pub struct Marker(Option); impl Marker { + fn named(name: &str) -> Self { + Marker(dirs::config_dir().map(|d| d.join("Codeman").join(name))) + } + fn default_location() -> Self { - Marker(dirs::config_dir().map(|d| d.join("Codeman").join("logo-blink"))) + Self::named("logo-fade") + } + + /// The first version's marker, which meant the logo may have been left + /// switched off. + fn legacy_location() -> Self { + Self::named("logo-blink") } fn set(&self) -> bool { @@ -306,69 +601,93 @@ impl Marker { #[derive(Debug, PartialEq, Eq)] enum Ended { - /// `wait` said stop, and the logo is lit again. + /// `wait` said stop, and the logo is back to normal. Stopped, /// The logo is not ours to blink, or stopped doing what it is told. Declined, } -/// Blink until `wait` reports that it is time to stop, then leave the logo -/// lit. `wait` sleeps for the given time and answers true if the blink should -/// end. A marker that cannot be written refuses the blink: without it a power -/// cut in a dark phase would have no way back. -fn blink(logo: &impl Logo, marker: &Marker, pattern: &[Beat], mut wait: impl FnMut(Duration) -> bool) -> Ended { - if logo.mode() != Some(MODE_AUTO) || !marker.set() { +/// 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; } - let mut dark = false; + let mut fader = Fader::new(logo); let mut ended = Ended::Stopped; - for beat in pattern.iter().cycle() { - if logo.mode() != Some(MODE_AUTO) { - marker.clear(); - return Ended::Declined; - } - if logo.nits() == Some(0.0) { - if wait(NOT_LIT_RECHECK) { + let mut asleep = false; + let mut next = 0; + 'blink: loop { + let step = moves[next % moves.len()]; + let awake = logo.display_awake(); + // Every cycle starts with a fall: the moment to check that the logo is + // still the agents' and can be seen. + if !step.lit { + if logo.mode() != Some(MODE_AUTO) || logo.driven_elsewhere() { + ended = Ended::Declined; break; } - continue; + if !awake { + if !asleep { + fader.display_slept(); + asleep = true; + } + if wait(NOT_LIT_RECHECK) { + break; + } + continue; + } + if asleep { + fader.display_woke(); + asleep = false; + } } - if !logo.set_mode(MODE_OFF) { + let from_dark = step.lit && logo.factor().is_some_and(|factor| factor <= 0.0); + fader.fade(step.lit, step.fade, awake); + if !fader.ok { ended = Ended::Declined; break; } - dark = true; - if wait(beat.dark) { - break; + let mut left = step.wait; + if from_dark { + // 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 mut waited = Duration::ZERO; + while waited < GATE_WAIT && logo.factor().is_some_and(|factor| factor <= 0.0) { + if wait(RISE_POLL) { + break 'blink; + } + waited += RISE_POLL; + } + left = left.saturating_sub(START_LATENCY); } - if logo.mode() != Some(MODE_OFF) { - marker.clear(); - return Ended::Declined; - } - if !logo.set_mode(MODE_AUTO) { - ended = Ended::Declined; - break; - } - dark = false; - if wait(beat.lit) { + if wait(left) { break; } + next += 1; } - // A logo already lit needs no write, and a failed restore keeps the marker - // so the next launch tries again. - if !dark || logo.set_mode(MODE_AUTO) { + if fader.restore() { marker.clear(); } ended } /// Put back a logo that a blink could not finish with. -fn recover(logo: &impl Logo, marker: &Marker) { - if !marker.is_set() { - return; +pub fn recover(logo: &impl Logo, marker: &Marker, legacy: &Marker) { + // The first version blinked by switching the setting off, so with its marker + // a logo switched off is its doing. + if legacy.is_set() && (logo.mode() != Some(MODE_OFF) || logo.set_mode(MODE_AUTO)) { + legacy.clear(); } - if logo.mode() != Some(MODE_OFF) || logo.set_mode(MODE_AUTO) { - marker.clear(); + if marker.is_set() { + let mut fader = Fader::new(logo); + // Which way it heads is not known: `restore` turns it round first. + fader.touched = true; + if fader.restore() { + marker.clear(); + } } } @@ -409,8 +728,8 @@ trait Signals { } /// The child's life: show what the agents are doing, until the app goes away. A -/// change of state ends the blink that is running, which leaves the logo lit, and -/// the next look starts the new one. +/// change of state ends the blink that is running, which puts the logo back to +/// normal, and the next look starts the new one. fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut impl Signals) { let mut settled = Settled::new(BlinkStyle::Off, signals.now()); let mut declined = false; @@ -427,7 +746,7 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i continue; } let mut app_gone = false; - let ended = blink(logo, marker, style.pattern(), |time| { + let ended = blink(logo, marker, &style.moves(), |time| { let deadline = signals.now() + time; loop { let left = deadline.saturating_duration_since(signals.now()); @@ -467,7 +786,7 @@ pub struct Watcher { impl Watcher { /// Watch this server with this login in these styles, or nothing. A child /// for anything else ends first: its pipe closes with the old value. It is - /// back to lit well before the next child looks, which takes `SETTLE`. + /// back to normal well before the next child looks, which takes `SETTLE`. pub fn sync(&mut self, want: Option<(&str, Option<&str>, BlinkStyles)>) { let same = match (&self.current, want) { (Some((now, _)), Some((server, authorization, styles))) => { @@ -507,24 +826,30 @@ fn spawn_child(connection: Connection) -> Option<(Connection, ChildStdin)> { Some((connection, stdin)) } -pub use system::{available, blink_main, recover_at_launch, switched_off}; +#[cfg(target_os = "macos")] +pub use system::{available, blink_main, driven_elsewhere, recover_at_launch, switched_off}; -/// No lit logo to blink outside macOS. #[cfg(not(target_os = "macos"))] -mod system { - pub fn available() -> bool { - false - } +pub fn available() -> bool { + false +} - pub fn blink_main() -> ! { - std::process::exit(0) - } +#[cfg(not(target_os = "macos"))] +pub fn blink_main() -> ! { + std::process::exit(0) +} - pub fn recover_at_launch() {} +#[cfg(not(target_os = "macos"))] +pub fn recover_at_launch() {} - pub fn switched_off() -> bool { - false - } +#[cfg(not(target_os = "macos"))] +pub fn switched_off() -> bool { + false +} + +#[cfg(not(target_os = "macos"))] +pub fn driven_elsewhere() -> bool { + false } #[cfg(target_os = "macos")] @@ -532,7 +857,7 @@ mod system { use super::{recover, watch, Connection, Logo, Marker, Signals, MODE_OFF}; use crate::agents::{Activity, Poll, Poller, POLL_EVERY}; use objc2::rc::{autoreleasepool, Retained}; - use objc2::runtime::{AnyClass, AnyObject}; + use objc2::runtime::{AnyClass, AnyObject, Bool}; use objc2::{msg_send, sel}; use objc2_foundation::{ns_string, NSBundle, NSDictionary, NSNumber, NSString}; use std::io::Read; @@ -541,8 +866,21 @@ mod system { use std::time::{Duration, Instant}; use url::Url; + /// The process name of the other app that drives the logo this way. + const LOGOLIGHT: &[u8] = b"LogoLight"; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGGetOnlineDisplayList(max_displays: u32, displays: *mut u32, count: *mut u32) -> i32; + fn CGDisplayIsBuiltin(display: u32) -> u32; + fn CGDisplayIsAsleep(display: u32) -> u32; + } + struct SystemLogo { client: Retained, + /// 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, } impl SystemLogo { @@ -565,7 +903,12 @@ mod system { } // SAFETY: `new` is NSObject's, and the class was checked above. let client: Option> = unsafe { msg_send![class, new] }; - client.map(|client| SystemLogo { client }) + let keyboards = if class.instance_method(sel!(setProperty:withKey:keyboardID:)).is_some() { + keyboard_ids() + } else { + Vec::new() + }; + client.map(|client| SystemLogo { client, keyboards }) }) } @@ -574,6 +917,53 @@ mod system { // answers an owned object or nil. unsafe { msg_send![&*self.client, copyPropertyForKey: key] } } + + fn set(&self, value: &NSNumber, key: &NSString) -> bool { + // SAFETY: the method was checked in `open`. It takes an object and a + // string and answers a BOOL, which says the message was delivered. + unsafe { msg_send![&*self.client, setProperty: value, forKey: key] } + } + + /// A number in `LEPWMState`. + fn pwm(&self, key: &NSString) -> Option { + let state = self.property(ns_string!("LEPWMState"))?; + let state = state.downcast_ref::()?; + // SAFETY: an NSDictionary answers objectForKey:, with nil for a key it + // does not have. + let value: Option> = unsafe { msg_send![state, objectForKey: key] }; + Some(value?.downcast_ref::()?.as_f64()) + } + } + + /// The IDs of the keyboard backlights, from CoreBrightness's own client for them. + fn keyboard_ids() -> Vec { + let Some(class) = AnyClass::get(c"KeyboardBrightnessClient") else { + return Vec::new(); + }; + if class.instance_method(sel!(copyKeyboardBacklightIDs)).is_none() { + return Vec::new(); + } + // SAFETY: `new` is NSObject's, and the class was checked above. + let Some(client): Option> = (unsafe { msg_send![class, new] }) else { + return Vec::new(); + }; + // SAFETY: checked above; a "copy" method, so it answers an owned array or nil. + let Some(ids): Option> = (unsafe { msg_send![&*client, copyKeyboardBacklightIDs] }) else { + return Vec::new(); + }; + // 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")] }; + if !is_array.as_bool() { + return Vec::new(); + } + let count: usize = unsafe { msg_send![&*ids, count] }; + (0..count) + .filter_map(|i| { + let id: Option> = unsafe { msg_send![&*ids, objectAtIndex: i] }; + Some(id?.downcast_ref::()?.as_u64()) + }) + .collect() } impl Logo for SystemLogo { @@ -585,25 +975,95 @@ mod system { } fn set_mode(&self, mode: i64) -> bool { + autoreleasepool(|_| self.set(&NSNumber::new_i64(mode), ns_string!("LEBacklightMode"))) + } + + fn factor(&self) -> Option { + autoreleasepool(|_| self.pwm(ns_string!("Factor"))) + } + + fn set_fade_duration(&self, seconds: f64) -> bool { autoreleasepool(|_| { - let value = NSNumber::new_i64(mode); - // SAFETY: the method was checked in `open`. It takes an object - // and a string and answers a BOOL. - unsafe { msg_send![&*self.client, setProperty: &*value, forKey: ns_string!("LEBacklightMode")] } + self.set( + &NSNumber::new_f64(seconds.max(0.0)), + ns_string!("LEPWMFactorFadeDuration"), + ) }) } - fn nits(&self) -> Option { + fn set_heading(&self, lit: bool) -> bool { autoreleasepool(|_| { - let state = self.property(ns_string!("LEPWMState"))?; - let state = state.downcast_ref::()?; - // SAFETY: an NSDictionary answers objectForKey:, with nil for a - // key it does not have. - let value: Option> = - unsafe { msg_send![state, objectForKey: ns_string!("FinalNits")] }; - Some(value?.downcast_ref::()?.as_f64()) + self.set( + &NSNumber::new_i64(if lit { 2 } else { 1 }), + ns_string!("DisplayLogicalState"), + ) }) } + + fn set_keyboards_lit(&self, lit: bool) { + autoreleasepool(|_| { + let value = NSNumber::new_i64(if lit { 2 } else { 1 }); + for &id in &self.keyboards { + // SAFETY: the method was checked in `open`. It takes an object, + // a string and a 64-bit keyboard ID and answers a BOOL. + let _: bool = unsafe { + msg_send![&*self.client, setProperty: &*value, withKey: ns_string!("DisplayLogicalState"), keyboardID: id] + }; + } + }) + } + + fn display_awake(&self) -> bool { + built_in_display_awake() + } + + fn driven_elsewhere(&self) -> bool { + driven_elsewhere() + } + } + + /// Whether the built-in display is on. A closed lid takes it off the list. + /// If the list cannot be had, the answer is yes: the logo is then blinked as + /// before there was a check. + fn built_in_display_awake() -> bool { + 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 true; + } + let online = &displays[..(count as usize).min(displays.len())]; + // SAFETY: both take any display ID and answer a boolean. + online + .iter() + .find(|&&display| unsafe { CGDisplayIsBuiltin(display) } != 0) + .is_some_and(|&display| unsafe { CGDisplayIsAsleep(display) } == 0) + } + + /// Whether LogoLight runs, which drives the logo with the same fades. + pub fn driven_elsewhere() -> bool { + running(LOGOLIGHT) + } + + /// 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 { + // 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; + } + 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| { + 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) }; + len > 0 && name.get(..len as usize) == Some(wanted) + }) } /// Whether this Mac has a logo the app can drive. Asked once: the answer @@ -619,10 +1079,10 @@ mod system { } pub fn recover_at_launch() { - let marker = Marker::default_location(); - if marker.is_set() { + let (marker, legacy) = (Marker::default_location(), Marker::legacy_location()); + if marker.is_set() || legacy.is_set() { if let Some(logo) = SystemLogo::open() { - recover(&logo, &marker); + recover(&logo, &marker, &legacy); } } } @@ -710,17 +1170,50 @@ mod system { watch(&logo, &Marker::default_location(), styles, &mut signals); std::process::exit(0) } + + #[cfg(test)] + mod probe { + use super::*; + + /// Reads what a blink goes by on this Mac, and writes nothing: + /// `cargo test --locked what_this_mac_answers -- --ignored --nocapture`. + #[test] + #[ignore = "reads this Mac's displays, processes and logo: see the comment"] + fn what_this_mac_answers() { + println!("logo available: {}", available()); + println!("built-in display awake: {}", built_in_display_awake()); + 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 {:?}", + logo.mode(), + logo.factor(), + logo.keyboards + ), + None => println!("no BrightnessSystemClient"), + } + } + } } #[cfg(test)] mod tests { use super::*; use std::cell::{Cell, RefCell}; + use std::rc::Rc; - const DARK_FOR: Duration = CALM[0].dark; - const LIT_FOR: Duration = CALM[0].lit; - /// The style most of these tests watch with: work only. + /// The daemon moves the logo in frames about this far apart. + const FRAME: f64 = 0.0175; + /// A new fade first moves this long after the write. + const LATENCY: Duration = Duration::from_millis(19); + /// The daemon's automatic fade duration. + const AUTOMATIC: f64 = 0.3; + + /// The style most of these tests watch with: work only, in Calm. const WORK: BlinkStyles = BlinkStyles { + enabled: true, working: BlinkStyle::Calm, waiting: BlinkStyle::Off, needs_you: BlinkStyle::Off, @@ -747,26 +1240,127 @@ mod tests { } } - /// A logo that records every write. + /// The critically damped spring LogoLight fitted to the logo's fades. + fn spring(s: f64) -> f64 { + let k = 6.51; + if s <= 0.0 { + 0.0 + } else { + 1.0 - (1.0 + k * s) * (-k * s).exp() + } + } + + #[derive(Debug, Clone, Copy, PartialEq)] + enum W { + Mode(i64), + Duration(f64), + Heading(bool), + Keyboards(bool), + } + + #[derive(Debug, Clone, Copy)] + struct Fade { + from: f64, + to: f64, + start: Duration, + length: f64, + } + + /// 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. struct FakeLogo { + now: Rc>, mode: Cell, - nits: Cell, - writes: RefCell>, + awake: Cell, + elsewhere: Cell, + gate: Cell, refuse_writes: Cell, + lit: Cell, + duration: Cell, + fade: Cell, + writes: RefCell>, + /// The level each rise left from, and whether the display was awake. + rises_from: RefCell>, } impl FakeLogo { fn new(mode: i64) -> Self { FakeLogo { + now: Rc::new(Cell::new(Duration::ZERO)), mode: Cell::new(mode), - nits: Cell::new(200.0), - writes: RefCell::new(Vec::new()), + awake: Cell::new(true), + elsewhere: Cell::new(false), + gate: Cell::new(Duration::ZERO), refuse_writes: Cell::new(false), + lit: Cell::new(true), + duration: Cell::new(0.0), + fade: Cell::new(Fade { + from: 1.0, + to: 1.0, + start: Duration::ZERO, + length: AUTOMATIC, + }), + writes: RefCell::new(Vec::new()), + rises_from: RefCell::new(Vec::new()), } } - fn writes(&self) -> Vec { - self.writes.borrow().clone() + fn advance(&self, time: Duration) { + self.now.set(self.now.get() + time); + } + + fn level_at(&self, at: Duration) -> f64 { + let fade = self.fade.get(); + if at <= fade.start { + return fade.from; + } + let moved = ((at - fade.start).as_secs_f64() / FRAME).floor() * FRAME; + let s = moved / fade.length; + if s >= 1.0 { + fade.to + } else { + fade.from + (fade.to - fade.from) * spring(s) + } + } + + fn level(&self) -> f64 { + self.level_at(self.now.get()) + } + + fn writes(&self) -> Vec { + self.writes.borrow().iter().map(|&(_, w)| w).collect() + } + + fn record(&self, w: W) { + self.writes.borrow_mut().push((self.now.get(), w)); + } + + /// The falls a blink made: Blanks written with a duration that moves. + fn falls(&self) -> usize { + let mut duration = 0.0; + let mut falls = 0; + for w in self.writes() { + match w { + W::Duration(d) => duration = d, + W::Heading(false) if duration < HOLD_SECONDS => falls += 1, + _ => {} + } + } + falls + } + + /// The last fade duration written, if any. + fn last_duration(&self) -> Option { + self.writes().iter().rev().find_map(|w| match w { + W::Duration(d) => Some(*d), + _ => None, + }) + } + + /// Where the logo settles once every fade has run. + fn settled_level(&self) -> f64 { + self.level_at(self.now.get() + Duration::from_secs(10)) } } @@ -779,61 +1373,272 @@ mod tests { if self.refuse_writes.get() { return false; } + self.record(W::Mode(mode)); self.mode.set(mode); - self.writes.borrow_mut().push(mode); true } - fn nits(&self) -> Option { - Some(self.nits.get()) + fn factor(&self) -> Option { + Some(if self.awake.get() && self.mode.get() == MODE_AUTO { + self.level() + } else { + 0.0 + }) + } + + fn set_fade_duration(&self, seconds: f64) -> bool { + if self.refuse_writes.get() { + return false; + } + self.record(W::Duration(seconds)); + self.duration.set(seconds); + true + } + + fn set_heading(&self, lit: bool) -> bool { + if self.refuse_writes.get() { + return false; + } + self.record(W::Heading(lit)); + if lit == self.lit.get() { + return true; + } + let now = self.now.get(); + let from = self.level(); + let mut start = now + LATENCY; + if lit { + self.rises_from.borrow_mut().push(from); + if from <= 0.0 { + start += self.gate.get(); + } + } + let length = match self.duration.get() { + d if d <= 0.0 => AUTOMATIC, + d => d, + }; + self.fade.set(Fade { + from, + to: if lit { 1.0 } else { 0.0 }, + start, + length, + }); + self.lit.set(lit); + true + } + + fn set_keyboards_lit(&self, lit: bool) { + assert!( + self.awake.get() || !lit, + "a keyboard told to light while the display sleeps" + ); + self.record(W::Keyboards(lit)); + } + + fn display_awake(&self) -> bool { + self.awake.get() + } + + fn driven_elsewhere(&self) -> bool { + self.elsewhere.get() } } fn temp_marker(name: &str) -> (Marker, PathBuf) { let dir = std::env::temp_dir().join(format!("codeman-logo-{}-{name}", std::process::id())); - let path = dir.join("nested").join("logo-blink"); + let path = dir.join("nested").join("logo-fade"); let _ = std::fs::remove_dir_all(&dir); (Marker(Some(path.clone())), path) } - /// A `wait` that stops the blink on its Nth call, and records the times. - fn stop_on(call: usize, times: &RefCell>) -> impl FnMut(Duration) -> bool + '_ { - move |time| { - times.borrow_mut().push(time); - times.borrow().len() >= call - } + /// Blink `style` until `stop_at` on the fake's clock, calling `meanwhile` at + /// every wait with the time. + fn run_until( + logo: &FakeLogo, + marker: &Marker, + style: BlinkStyle, + stop_at: Duration, + mut meanwhile: impl FnMut(&FakeLogo, Duration), + ) -> Ended { + blink(logo, marker, &style.moves(), |time| { + logo.advance(time); + meanwhile(logo, logo.now.get()); + logo.now.get() >= stop_at + }) + } + + fn run(logo: &FakeLogo, marker: &Marker, style: BlinkStyle, stop_at: Duration) -> Ended { + run_until(logo, marker, style, stop_at, |_, _| {}) + } + + const fn secs(s: u64) -> Duration { + Duration::from_secs(s) } #[test] - fn a_blink_alternates_and_ends_lit() { + fn breathe_fades_out_and_back_in_and_ends_lit() { let logo = FakeLogo::new(MODE_AUTO); - let (marker, path) = temp_marker("alternates"); - let times = RefCell::new(Vec::new()); - blink(&logo, &marker, CALM, stop_on(4, ×)); - // dark, lit, dark, and the stop in the fourth wait is a dark phase's. - assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO, MODE_OFF, MODE_AUTO]); - assert_eq!(*times.borrow(), [DARK_FOR, LIT_FOR, DARK_FOR, LIT_FOR]); - assert_eq!(logo.mode.get(), MODE_AUTO); + let (marker, path) = temp_marker("breathe"); + let mut lowest = 1.0_f64; + let mut highest_while_dark = 0.0_f64; + run_until(&logo, &marker, BlinkStyle::Breathe, secs(20), |logo, now| { + lowest = lowest.min(logo.level()); + // The rest in the first cycle, between the fall (2 s) and the rise (2.5 s). + if now > Duration::from_millis(2_100) && now < Duration::from_millis(2_450) { + highest_while_dark = highest_while_dark.max(logo.level()); + } + }); + assert_eq!(lowest, 0.0, "a breath goes all the way to dark"); + assert_eq!(highest_while_dark, 0.0); + let writes = logo.writes(); + // Heading unknown at the start: turned round first, without moving. + assert_eq!(writes[..2], [W::Duration(HOLD_SECONDS), W::Heading(true)]); + assert_eq!(writes[2..5], [W::Duration(2.0), W::Heading(false), W::Keyboards(true)]); + assert_eq!(writes[5..7], [W::Duration(3.5), W::Heading(true)]); + assert_eq!(logo.falls(), 4, "about 6.3 s a breath"); + assert_eq!(logo.last_duration(), Some(0.0), "it ends on the automatic duration"); + assert_eq!(logo.settled_level(), 1.0); assert!(!path.exists(), "a finished blink leaves no marker"); } #[test] - fn a_stop_in_a_dark_phase_restores_the_logo_at_once() { + fn short_dark_beats_turn_round_before_the_logo_is_dark() { + for style in [ + BlinkStyle::Alarm, + BlinkStyle::Heartbeat, + BlinkStyle::Wink, + BlinkStyle::Fast, + ] { + let logo = FakeLogo::new(MODE_AUTO); + // A rise from exactly dark would wait this long: none may. + logo.gate.set(Duration::from_millis(500)); + let (marker, _) = temp_marker("dips"); + let mut lowest = 1.0_f64; + run_until(&logo, &marker, style, secs(12), |logo, _| { + lowest = lowest.min(logo.level()) + }); + let rises = logo.rises_from.borrow().clone(); + assert!(rises.len() >= 3, "{style:?}"); + for from in &rises[..rises.len() - 1] { + assert!(*from > 0.0, "{style:?} rose from exactly dark"); + assert!(*from < 0.2, "{style:?} dips only to {from}"); + } + assert!(lowest < 0.2, "{style:?}"); + } + } + + #[test] + fn a_long_dark_beat_times_its_light_from_when_the_rise_starts() { let logo = FakeLogo::new(MODE_AUTO); - let (marker, path) = temp_marker("dark-stop"); - let times = RefCell::new(Vec::new()); - blink(&logo, &marker, CALM, stop_on(1, ×)); - assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + let gate = Duration::from_millis(400); + logo.gate.set(gate); + let (marker, _) = temp_marker("gated"); + run(&logo, &marker, BlinkStyle::Calm, secs(30)); + let writes = logo.writes.borrow().clone(); + let falls: Vec = writes + .windows(2) + .filter(|pair| matches!(pair[0].1, W::Duration(d) if d < HOLD_SECONDS) && pair[1].1 == W::Heading(false)) + .map(|pair| pair[1].0) + .collect(); + let rises: Vec = writes + .windows(2) + .filter(|pair| { + matches!(pair[0].1, W::Duration(d) if d < HOLD_SECONDS && d > 0.0) && pair[1].1 == W::Heading(true) + }) + .map(|pair| pair[1].0) + .collect(); + assert!(falls.len() >= 10, "{}", falls.len()); + for (rise, next_fall) in rises.iter().zip(falls.iter().skip(1)) { + // Lit for Calm's 1.2 s from when it moved: the gate, the latency and a + // poll's worth of slack after the write. + let lit = *next_fall - *rise; + let expected = CALM[0].lit + gate + LATENCY; + assert!( + lit + RISE_POLL >= expected && lit <= expected + RISE_POLL * 2, + "{lit:?}" + ); + } + for (fall, rise) in falls.iter().zip(rises.iter()) { + assert_eq!(*rise - *fall, CALM[0].dark, "the dark beat is written as long as it is"); + } + } + + #[test] + fn every_blank_is_followed_by_the_keyboards_told_to_stay_lit() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("keyboards"); + run(&logo, &marker, BlinkStyle::Alarm, secs(6)); + let writes = logo.writes(); + for (i, w) in writes.iter().enumerate() { + if *w == W::Heading(false) { + assert_eq!(writes.get(i + 1), Some(&W::Keyboards(true)), "after write {i}"); + } + } + } + + #[test] + fn the_keyboards_follow_the_display_to_sleep_and_back() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("sleep"); + let mut writes_while_asleep = None; + run_until(&logo, &marker, BlinkStyle::Calm, secs(30), |logo, now| { + logo.awake.set(!(secs(8)..secs(16)).contains(&now)); + if now >= secs(10) && writes_while_asleep.is_none() { + writes_while_asleep = Some(logo.writes().len()); + } + if now >= secs(15) && now < secs(16) { + assert_eq!( + Some(logo.writes().len()), + writes_while_asleep, + "nothing written while asleep" + ); + } + }); + let writes = logo.writes(); + let slept = writes + .iter() + .position(|w| *w == W::Keyboards(false)) + .expect("told to sleep"); + assert_eq!( + writes[slept - 1], + W::Duration(0.0), + "the daemon's own sleep and wake get the automatic duration" + ); + assert_eq!( + writes.iter().filter(|w| **w == W::Keyboards(false)).count(), + 1, + "once, when it slept" + ); + let woke = writes[slept..] + .iter() + .position(|w| *w == W::Keyboards(true)) + .expect("told to wake"); + assert!( + writes[slept + woke..].contains(&W::Heading(false)), + "it blinks again after the wake" + ); + assert_eq!(logo.last_duration(), Some(0.0)); + assert_eq!(logo.settled_level(), 1.0); assert!(!path.exists()); } #[test] - fn a_stop_while_lit_writes_nothing_more() { - let logo = FakeLogo::new(MODE_AUTO); - let (marker, _) = temp_marker("lit-stop"); - let times = RefCell::new(Vec::new()); - blink(&logo, &marker, CALM, stop_on(2, ×)); - assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + fn every_end_puts_the_logo_back_to_normal() { + for style in BlinkStyle::ALL.into_iter().filter(|&s| s != BlinkStyle::Off) { + for stop_ms in [50, 150, 700, 1_300, 2_200, 4_000, 6_100] { + let logo = FakeLogo::new(MODE_AUTO); + logo.gate.set(Duration::from_millis(300)); + let (marker, path) = temp_marker("ends"); + run(&logo, &marker, style, Duration::from_millis(stop_ms)); + assert_eq!(logo.last_duration(), Some(0.0), "{style:?} at {stop_ms} ms"); + assert_eq!( + logo.writes().last(), + Some(&W::Heading(true)), + "{style:?} at {stop_ms} ms" + ); + assert_eq!(logo.settled_level(), 1.0, "{style:?} at {stop_ms} ms"); + assert!(!path.exists()); + } + } } #[test] @@ -841,114 +1646,139 @@ mod tests { for mode in [MODE_OFF, 1, 7] { let logo = FakeLogo::new(mode); let (marker, path) = temp_marker("user-off"); - let times = RefCell::new(Vec::new()); - assert_eq!(blink(&logo, &marker, CALM, stop_on(1, ×)), Ended::Declined); - assert_eq!(logo.writes(), [] as [i64; 0]); - assert!(times.borrow().is_empty(), "it does not even wait"); + let mut waits = 0; + let ended = blink(&logo, &marker, &BlinkStyle::Calm.moves(), |_| { + waits += 1; + true + }); + assert_eq!(ended, Ended::Declined); + assert_eq!(logo.writes(), []); + assert_eq!(waits, 0, "it does not even wait"); assert!(!path.exists()); } } #[test] - fn a_marker_that_cannot_be_written_refuses_the_blink() { + fn another_app_driving_the_logo_is_left_alone() { let logo = FakeLogo::new(MODE_AUTO); - let times = RefCell::new(Vec::new()); - assert_eq!(blink(&logo, &Marker(None), CALM, stop_on(1, ×)), Ended::Declined); - assert_eq!(logo.writes(), [] as [i64; 0]); + logo.elsewhere.set(true); + let (marker, path) = temp_marker("elsewhere"); + assert_eq!(run(&logo, &marker, BlinkStyle::Calm, secs(5)), Ended::Declined); + assert_eq!(logo.writes(), []); + assert!(!path.exists()); + + // One that starts during a blink ends it at the next cycle, put back. + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("elsewhere-later"); + let ended = run_until(&logo, &marker, BlinkStyle::Calm, secs(60), |logo, now| { + logo.elsewhere.set(now >= secs(5)); + }); + assert_eq!(ended, Ended::Declined); + assert!(logo + .writes + .borrow() + .iter() + .all(|&(at, w)| at < secs(7) || !matches!(w, W::Heading(false)))); + assert_eq!(logo.last_duration(), Some(0.0)); + assert!(!path.exists()); } #[test] - fn a_mode_changed_from_outside_ends_the_blink_without_a_write() { - // Someone switches the logo off during a lit phase: their setting stands. + fn a_logo_switched_off_during_a_blink_stays_off() { let logo = FakeLogo::new(MODE_AUTO); - let (marker, path) = temp_marker("outside-lit"); - let mut calls = 0; - let ended = blink(&logo, &marker, CALM, |_| { - calls += 1; - if calls == 2 { + let (marker, path) = temp_marker("off-during"); + let ended = run_until(&logo, &marker, BlinkStyle::Calm, secs(60), |logo, now| { + if now >= secs(5) { logo.mode.set(MODE_OFF); } - false }); assert_eq!(ended, Ended::Declined); - assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + assert!( + !logo.writes().iter().any(|w| matches!(w, W::Mode(_))), + "the setting is never written" + ); assert_eq!(logo.mode.get(), MODE_OFF); assert!(!path.exists()); + } - // Someone deletes the setting during a dark phase: it stays as they left it. + #[test] + fn a_marker_that_cannot_be_written_refuses_the_blink() { let logo = FakeLogo::new(MODE_AUTO); - let (marker, path) = temp_marker("outside-dark"); - let ended = blink(&logo, &marker, CALM, |_| { - logo.mode.set(MODE_AUTO); - false - }); - assert_eq!(ended, Ended::Declined); - assert_eq!(logo.writes(), [MODE_OFF]); - assert!(!path.exists()); + assert_eq!(run(&logo, &Marker(None), BlinkStyle::Calm, secs(5)), Ended::Declined); + assert_eq!(logo.writes(), []); } #[test] fn nothing_is_written_while_the_logo_cannot_be_seen() { let logo = FakeLogo::new(MODE_AUTO); - logo.nits.set(0.0); + logo.awake.set(false); let (marker, path) = temp_marker("not-lit"); - let times = RefCell::new(Vec::new()); - blink(&logo, &marker, CALM, stop_on(3, ×)); - assert_eq!(logo.writes(), [] as [i64; 0]); - assert_eq!(*times.borrow(), [NOT_LIT_RECHECK; 3]); + let mut waits = Vec::new(); + blink(&logo, &marker, &BlinkStyle::Calm.moves(), |time| { + waits.push(time); + waits.len() >= 3 + }); + assert_eq!(logo.writes(), []); + assert_eq!(waits, [NOT_LIT_RECHECK; 3]); assert!(!path.exists()); } #[test] - fn a_blink_resumes_when_the_logo_comes_back() { + fn a_refused_write_ends_the_blink_and_keeps_the_marker() { let logo = FakeLogo::new(MODE_AUTO); - logo.nits.set(0.0); - let (marker, _) = temp_marker("wakes"); - let mut calls = 0; - blink(&logo, &marker, CALM, |_| { - calls += 1; - logo.nits.set(200.0); - calls == 2 + let (marker, path) = temp_marker("refused"); + let ended = run_until(&logo, &marker, BlinkStyle::Calm, secs(60), |logo, now| { + logo.refuse_writes.set(now >= secs(3)); }); - assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); - } - - #[test] - fn a_failed_restore_keeps_the_marker_for_the_next_launch() { - let logo = FakeLogo::new(MODE_AUTO); - let (marker, path) = temp_marker("failed-restore"); - blink(&logo, &marker, CALM, |_| { - logo.refuse_writes.set(true); - true - }); - assert_eq!(logo.mode.get(), MODE_OFF); - assert!(path.exists()); + assert_eq!(ended, Ended::Declined); + assert!(path.exists(), "the next launch tries again"); logo.refuse_writes.set(false); - recover(&logo, &marker); - assert_eq!(logo.mode.get(), MODE_AUTO); + recover(&logo, &marker, &Marker(None)); + assert_eq!(logo.last_duration(), Some(0.0)); + assert_eq!(logo.settled_level(), 1.0); assert!(!path.exists()); } #[test] - fn recover_needs_the_marker() { + fn recover_puts_back_a_logo_a_killed_child_left_dark() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, path) = temp_marker("killed"); + // A blink stopped mid-fall without putting anything back: the child was killed. + assert!(marker.set()); + assert!(logo.set_fade_duration(0.18) && logo.set_heading(false)); + logo.advance(secs(1)); + assert_eq!(logo.level(), 0.0); + recover(&logo, &marker, &Marker(None)); + assert_eq!(logo.last_duration(), Some(0.0)); + assert_eq!(logo.settled_level(), 1.0); + assert!(!path.exists()); + } + + #[test] + fn recover_needs_a_marker() { let logo = FakeLogo::new(MODE_OFF); let (marker, _) = temp_marker("no-marker"); - recover(&logo, &marker); - assert_eq!( - logo.writes(), - [] as [i64; 0], - "without a marker a dark logo is the user's" - ); + let (legacy, _) = temp_marker("no-legacy"); + recover(&logo, &marker, &legacy); + assert_eq!(logo.writes(), [], "without a marker a dark logo is the user's"); } #[test] - fn recover_leaves_a_lit_logo_and_clears_the_marker() { + fn recover_switches_back_on_a_logo_the_first_version_left_off() { + let logo = FakeLogo::new(MODE_OFF); + let (marker, _) = temp_marker("legacy-new"); + let (legacy, legacy_path) = temp_marker("legacy"); + assert!(legacy.set()); + recover(&logo, &marker, &legacy); + assert_eq!(logo.writes(), [W::Mode(MODE_AUTO)]); + assert!(!legacy_path.exists()); + + // A lit logo with that marker only loses the marker. let logo = FakeLogo::new(MODE_AUTO); - let (marker, path) = temp_marker("lit-recover"); - assert!(marker.set()); - recover(&logo, &marker); - assert_eq!(logo.writes(), [] as [i64; 0]); - assert!(!path.exists()); + assert!(legacy.set()); + recover(&logo, &marker, &legacy); + assert_eq!(logo.writes(), []); + assert!(!legacy_path.exists()); } #[test] @@ -974,39 +1804,38 @@ mod tests { assert!(SETTLE >= Duration::from_secs(3)); } - /// A clock that `wait` moves, and a server that shows the given activity in - /// the given spans of time. The app goes away at `app_gone_at`. + /// A server that shows the given activity in the given spans of time, on the + /// fake logo's clock. The app goes away at `app_gone_at`. struct Script { start: Instant, - clock: Cell, + now: Rc>, spans: Vec<(u64, u64, Activity)>, app_gone_at: u64, } impl Script { - fn new(work: &[(u64, u64)], app_gone_at: u64) -> Self { + fn new(logo: &FakeLogo, work: &[(u64, u64)], app_gone_at: u64) -> Self { let spans: Vec<_> = work.iter().map(|&(from, to)| (from, to, working())).collect(); - Self::with(&spans, app_gone_at) + Self::with(logo, &spans, app_gone_at) } - fn with(spans: &[(u64, u64, Activity)], app_gone_at: u64) -> Self { - let start = Instant::now(); + fn with(logo: &FakeLogo, spans: &[(u64, u64, Activity)], app_gone_at: u64) -> Self { Script { - start, - clock: Cell::new(start), + start: Instant::now(), + now: logo.now.clone(), spans: spans.to_vec(), app_gone_at, } } fn elapsed_ms(&self) -> u64 { - self.clock.get().duration_since(self.start).as_millis() as u64 + self.now.get().as_millis() as u64 } } impl Signals for Script { fn wait(&mut self, time: Duration) -> bool { - self.clock.set(self.clock.get() + time); + self.now.set(self.now.get() + time); self.elapsed_ms() >= self.app_gone_at } @@ -1019,81 +1848,111 @@ mod tests { } fn now(&self) -> Instant { - self.clock.get() + self.start + self.now.get() } } - const CYCLE_MS: u64 = (LIT_FOR.as_millis() + DARK_FOR.as_millis()) as u64; + const CYCLE_MS: u64 = (CALM[0].lit.as_millis() + CALM[0].dark.as_millis()) as u64; #[test] fn nothing_is_written_while_no_agent_works() { let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("watch-quiet"); - watch(&logo, &marker, WORK, &mut Script::new(&[], 60_000)); - assert_eq!(logo.writes(), [] as [i64; 0]); + let mut script = Script::new(&logo, &[], 60_000); + watch(&logo, &marker, WORK, &mut script); + assert_eq!(logo.writes(), []); } #[test] fn a_short_flicker_of_work_does_not_blink() { let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("watch-flicker"); - watch(&logo, &marker, WORK, &mut Script::new(&[(1_000, 2_500)], 60_000)); - assert_eq!(logo.writes(), [] as [i64; 0]); + let mut script = Script::new(&logo, &[(1_000, 2_500)], 60_000); + watch(&logo, &marker, WORK, &mut script); + assert_eq!(logo.writes(), []); + } + + #[test] + fn the_switch_off_shows_nothing_whatever_the_styles() { + let logo = FakeLogo::new(MODE_AUTO); + let (marker, _) = temp_marker("watch-switch-off"); + let styles = BlinkStyles { enabled: false, ..WORK }; + let mut script = Script::new(&logo, &[(0, 30_000)], 40_000); + watch(&logo, &marker, styles, &mut script); + assert_eq!(logo.writes(), []); } #[test] fn it_blinks_for_the_length_of_the_work_and_ends_lit() { let logo = FakeLogo::new(MODE_AUTO); let (marker, path) = temp_marker("watch-turn"); - let mut script = Script::new(&[(1_000, 21_000)], 60_000); + let mut script = Script::new(&logo, &[(1_000, 21_000)], 60_000); watch(&logo, &marker, WORK, &mut script); - let writes = logo.writes(); - let dark_phases = writes.iter().filter(|&&m| m == MODE_OFF).count() as u64; + let falls = logo.falls() as u64; // Settling delays the start and the end alike, so the blink lasts as long - // as the 20 s of work did, with one dark phase a cycle. + // as the 20 s of work did, with one fall a cycle. let cycles = 20_000 / CYCLE_MS; - assert!(dark_phases >= cycles, "{dark_phases} dark phases, {cycles} cycles"); - assert!(dark_phases <= cycles + 2, "{dark_phases} dark phases, {cycles} cycles"); - assert_eq!(logo.mode.get(), MODE_AUTO); + assert!(falls >= cycles, "{falls} falls, {cycles} cycles"); + assert!(falls <= cycles + 2, "{falls} falls, {cycles} cycles"); + assert_eq!(logo.last_duration(), Some(0.0)); + assert_eq!(logo.settled_level(), 1.0); assert!(!path.exists()); } #[test] - fn the_end_of_the_work_stops_a_blink_in_the_middle_of_a_dark_phase() { + fn the_end_of_the_work_relights_a_logo_in_the_middle_of_a_dark_beat() { let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("watch-stop"); - // Work has to hold to start and to stop. Started at about 4.25 s, a dark - // phase runs from every 2 s after that, and work that ends at 5.5 s - // settles at 8.5 s, inside the third one. - let mut script = Script::new(&[(1_000, 5_500)], 60_000); - watch(&logo, &marker, WORK, &mut script); - assert_eq!( - logo.writes(), - [MODE_OFF, MODE_AUTO, MODE_OFF, MODE_AUTO, MODE_OFF, MODE_AUTO] + // Started at about 4.25 s, a dark beat runs from every 2 s after that, and + // work that ends at 5.5 s settles at 8.5 s, inside the third one. + let mut script = Script::new(&logo, &[(1_000, 5_500)], 60_000); + let mut level_at_stop = None; + struct Watching<'a>(&'a mut Script, &'a FakeLogo, &'a mut Option); + impl Signals for Watching<'_> { + fn wait(&mut self, time: Duration) -> bool { + if self.0.elapsed_ms() >= 8_500 && self.2.is_none() { + *self.2 = Some(self.1.level()); + } + self.0.wait(time) + } + fn activity(&self) -> Activity { + self.0.activity() + } + fn now(&self) -> Instant { + self.0.now() + } + } + watch( + &logo, + &marker, + WORK, + &mut Watching(&mut script, &logo, &mut level_at_stop), ); + assert_eq!(level_at_stop, Some(0.0), "it was dark when the work ended"); + assert_eq!(logo.falls(), 3); + assert_eq!(logo.settled_level(), 1.0); } #[test] fn a_gap_between_two_turns_does_not_stop_the_blink() { let logo = FakeLogo::new(MODE_AUTO); let (marker, _) = temp_marker("watch-gap"); - let mut script = Script::new(&[(1_000, 15_000), (16_000, 30_000)], 60_000); + let mut script = Script::new(&logo, &[(1_000, 15_000), (16_000, 30_000)], 60_000); watch(&logo, &marker, WORK, &mut script); - let writes = logo.writes(); - let dark_phases = writes.iter().filter(|&&m| m == MODE_OFF).count() as u64; - assert!( - dark_phases >= (29_000 - 2 * SETTLE.as_millis() as u64) / CYCLE_MS, - "{dark_phases}" - ); + let falls = logo.falls() as u64; + assert!(falls >= (29_000 - 2 * SETTLE.as_millis() as u64) / CYCLE_MS, "{falls}"); } #[test] - fn the_app_going_away_in_a_dark_phase_puts_the_logo_back() { + fn the_app_going_away_in_a_dark_beat_puts_the_logo_back() { let logo = FakeLogo::new(MODE_AUTO); let (marker, path) = temp_marker("watch-gone"); - // Work from the start; the first dark phase begins once it has settled. - watch(&logo, &marker, WORK, &mut Script::new(&[(0, 60_000)], 3_500)); - assert_eq!(logo.writes(), [MODE_OFF, MODE_AUTO]); + // Work from the start; the first dark beat begins once it has settled. + let mut script = Script::new(&logo, &[(0, 60_000)], 3_500); + watch(&logo, &marker, WORK, &mut script); + assert_eq!(logo.falls(), 1); + assert_eq!(logo.last_duration(), Some(0.0)); + assert_eq!(logo.settled_level(), 1.0); assert!(!path.exists()); } @@ -1101,9 +1960,9 @@ mod tests { fn a_logo_the_user_switched_off_is_not_asked_again_during_the_work() { let logo = FakeLogo::new(MODE_OFF); let (marker, _) = temp_marker("watch-declined"); - let mut script = Script::new(&[(0, 30_000)], 60_000); + let mut script = Script::new(&logo, &[(0, 30_000)], 60_000); watch(&logo, &marker, WORK, &mut script); - assert_eq!(logo.writes(), [] as [i64; 0]); + assert_eq!(logo.writes(), []); } #[test] @@ -1111,12 +1970,12 @@ mod tests { // Declined in the first turn, the logo is back on for the second. let logo = FakeLogo::new(MODE_OFF); let (marker, _) = temp_marker("watch-again"); - let mut script = Script::new(&[(0, 5_000), (20_000, 30_000)], 60_000); + let mut script = Script::new(&logo, &[(0, 5_000), (20_000, 30_000)], 60_000); struct TurnsOn<'a>(&'a mut Script, &'a FakeLogo); impl Signals for TurnsOn<'_> { fn wait(&mut self, time: Duration) -> bool { let gone = self.0.wait(time); - if self.0.elapsed_ms() >= 10_000 && self.0.elapsed_ms() < 10_300 { + if self.0.elapsed_ms() >= 10_000 { self.1.mode.set(MODE_AUTO); } gone @@ -1129,8 +1988,8 @@ mod tests { } } watch(&logo, &marker, WORK, &mut TurnsOn(&mut script, &logo)); - assert!(logo.writes().contains(&MODE_OFF), "{:?}", logo.writes()); - assert_eq!(logo.mode.get(), MODE_AUTO); + assert!(logo.falls() > 0, "{:?}", logo.writes()); + assert_eq!(logo.settled_level(), 1.0); } #[test] @@ -1151,44 +2010,46 @@ mod tests { } #[test] - fn a_style_of_several_beats_runs_them_in_turn() { - let logo = FakeLogo::new(MODE_AUTO); - let (marker, _) = temp_marker("beats"); - let pattern = BlinkStyle::Heartbeat.pattern(); - let times = RefCell::new(Vec::new()); - blink(&logo, &marker, pattern, stop_on(6, ×)); - let [one, two] = pattern else { panic!("two beats") }; - assert_eq!( - *times.borrow(), - [one.dark, one.lit, two.dark, two.lit, one.dark, one.lit] - ); + fn every_style_but_off_starts_with_a_fall_and_alternates() { + for style in BlinkStyle::ALL { + let moves = style.moves(); + if style == BlinkStyle::Off { + assert!(moves.is_empty()); + continue; + } + assert!(moves.len() >= 2 && moves.len() % 2 == 0, "{style:?}"); + for (i, step) in moves.iter().enumerate() { + assert_eq!(step.lit, i % 2 == 1, "{style:?}"); + assert!(!step.fade.is_zero() && !step.wait.is_zero(), "{style:?}"); + } + } } #[test] - fn every_style_but_off_has_a_rhythm_with_light_and_dark() { + fn a_hard_style_keeps_its_rhythm() { + // The waits add up to the beats, so a hard style's cycle is what it says. for style in BlinkStyle::ALL { - if style == BlinkStyle::Off { - assert!(style.pattern().is_empty()); - continue; - } - assert!(!style.pattern().is_empty(), "{style:?}"); - for beat in style.pattern() { - assert!(!beat.dark.is_zero() && !beat.lit.is_zero(), "{style:?}"); - } + let Rhythm::Hard(beats) = style.rhythm() else { continue }; + let beats: Duration = beats.iter().map(|beat| beat.dark + beat.lit).sum(); + let moves: Duration = style.moves().iter().map(|step| step.wait).sum(); + assert_eq!(beats, moves, "{style:?}"); } } #[test] fn no_style_flashes_more_than_three_times_in_any_second() { for style in BlinkStyle::ALL { - let mut starts = Vec::new(); + let moves = style.moves(); + let mut falls = Vec::new(); let mut at = Duration::ZERO; - for beat in style.pattern().iter().cycle().take(40) { - starts.push(at); - at += beat.dark + beat.lit; + for step in moves.iter().cycle().take(80) { + if !step.lit { + falls.push(at); + } + at += step.wait; } - for (i, from) in starts.iter().enumerate() { - let in_a_second = starts[i..].iter().filter(|&&t| t - *from < Duration::from_secs(1)); + for (i, from) in falls.iter().enumerate() { + let in_a_second = falls[i..].iter().filter(|&&t| t - *from < Duration::from_secs(1)); assert!(in_a_second.count() <= 3, "{style:?}"); } } @@ -1207,7 +2068,8 @@ mod tests { } const ALL_ON: BlinkStyles = BlinkStyles { - working: BlinkStyle::Calm, + enabled: true, + working: BlinkStyle::Breathe, waiting: BlinkStyle::Wink, needs_you: BlinkStyle::Alarm, }; @@ -1227,8 +2089,13 @@ mod tests { }), BlinkStyle::Wink ); - assert_eq!(ALL_ON.pick(working()), BlinkStyle::Calm); + assert_eq!(ALL_ON.pick(working()), BlinkStyle::Breathe); assert_eq!(ALL_ON.pick(Activity::default()), BlinkStyle::Off); + let switched_off = BlinkStyles { + enabled: false, + ..ALL_ON + }; + assert_eq!(switched_off.pick(everything), BlinkStyle::Off); } #[test] @@ -1242,7 +2109,7 @@ mod tests { styles.needs_you = BlinkStyle::Off; assert_eq!(styles.pick(everything), BlinkStyle::Wink); styles.waiting = BlinkStyle::Off; - assert_eq!(styles.pick(everything), BlinkStyle::Calm); + assert_eq!(styles.pick(everything), BlinkStyle::Breathe); styles.working = BlinkStyle::Off; assert_eq!(styles.pick(everything), BlinkStyle::Off); } @@ -1264,25 +2131,31 @@ mod tests { self.0.now() } } - let script = Script::with(&[(0, 30_000, waiting())], 40_000); + let script = Script::with(&logo, &[(0, 30_000, waiting())], 40_000); watch(&logo, &marker, WORK, &mut Watching(script, &path)); - assert_eq!(logo.writes(), [] as [i64; 0]); + assert_eq!(logo.writes(), []); } - fn dark_phases(styles: BlinkStyles, spans: &[(u64, u64, Activity)], end: u64) -> usize { + fn falls_with(styles: BlinkStyles, spans: &[(u64, u64, Activity)], end: u64) -> usize { let logo = FakeLogo::new(MODE_AUTO); let (marker, path) = temp_marker("phases"); - watch(&logo, &marker, styles, &mut Script::with(spans, end)); - assert_eq!(logo.mode.get(), MODE_AUTO, "it ends lit"); + let mut script = Script::with(&logo, spans, end); + watch(&logo, &marker, styles, &mut script); + assert_eq!(logo.settled_level(), 1.0, "it ends lit"); + assert_eq!(logo.last_duration(), Some(0.0)); assert!(!path.exists()); - logo.writes().iter().filter(|&&mode| mode == MODE_OFF).count() + logo.falls() } #[test] fn a_change_of_state_moves_to_the_other_rhythm_and_back() { - let alone = dark_phases(ALL_ON, &[(0, 60_000, working())], 60_000); - let interrupted = dark_phases( - ALL_ON, + let styles = BlinkStyles { + working: BlinkStyle::Calm, + ..ALL_ON + }; + let alone = falls_with(styles, &[(0, 60_000, working())], 60_000); + let interrupted = falls_with( + styles, &[ (0, 20_000, working()), (20_000, 40_000, needs_you()), @@ -1290,16 +2163,16 @@ mod tests { ], 60_000, ); - // About 29 dark phases of Calm alone, and Alarm's 2 a second for a third - // of the time. Calm alone again at the end ends the same as it began. + // About 29 falls of Calm alone, and Alarm's 2 a second for a third of the + // time. Calm alone again at the end ends the same as it began. assert!((27..=31).contains(&alone), "{alone}"); assert!(interrupted >= alone + 20, "{interrupted} against {alone}"); } #[test] fn red_interrupts_yellow_and_yellow_comes_back() { - let yellow_only = dark_phases(ALL_ON, &[(0, 60_000, waiting())], 60_000); - let with_red = dark_phases( + let yellow_only = falls_with(ALL_ON, &[(0, 60_000, waiting())], 60_000); + let with_red = falls_with( ALL_ON, &[ (0, 20_000, waiting()), @@ -1311,11 +2184,29 @@ mod tests { assert!(with_red > yellow_only * 3, "{with_red} against {yellow_only}"); } + #[test] + fn the_recommended_styles_rise_with_the_need() { + // Steady, then a slow soft breath, then a plain blink, then flashes: each + // more urgent state moves the logo more often. + let per_minute = |style: BlinkStyle| { + let cycle: Duration = style.moves().iter().map(|step| step.wait).sum(); + let falls = style.moves().iter().filter(|step| !step.lit).count(); + falls as f64 * 60.0 / cycle.as_secs_f64() + }; + let r = BlinkStyles::RECOMMENDED; + assert!(per_minute(r.working) < per_minute(r.waiting)); + assert!(per_minute(r.waiting) * 2.0 < per_minute(r.needs_you)); + // Busy is soft, you are hard. + assert!(matches!(r.working.rhythm(), Rhythm::Soft(_))); + assert!(matches!(r.waiting.rhythm(), Rhythm::Hard(_))); + assert!(matches!(r.needs_you.rhythm(), Rhythm::Hard(_))); + } + #[test] fn alarm_is_the_sharpest_style_a_second_can_hold() { let flashes = |style: BlinkStyle| { - let cycle: Duration = style.pattern().iter().map(|beat| beat.dark + beat.lit).sum(); - style.pattern().len() as f64 / cycle.as_secs_f64() + let cycle: Duration = style.moves().iter().map(|step| step.wait).sum(); + style.moves().iter().filter(|step| !step.lit).count() as f64 / cycle.as_secs_f64() }; assert!(flashes(BlinkStyle::Alarm) > flashes(BlinkStyle::Fast)); assert!(flashes(BlinkStyle::Alarm) > flashes(BlinkStyle::Heartbeat) * 2.0); @@ -1324,10 +2215,19 @@ mod tests { #[test] fn the_setting_reads_every_shape_it_has_had() { let read = |text: &str| BlinkStyles::read(&serde_json::from_str(text).unwrap()); + // A new Mac: the switch off, the recommended styles ready behind it. + assert_eq!( + BlinkStyles::default(), + BlinkStyles { + enabled: false, + ..BlinkStyles::RECOMMENDED + } + ); // The one style of the first version keeps meaning work, and brings the alerts. assert_eq!( read(r#""heartbeat""#), BlinkStyles { + enabled: true, working: BlinkStyle::Heartbeat, waiting: BlinkStyle::Wink, needs_you: BlinkStyle::Alarm, @@ -1337,26 +2237,72 @@ mod tests { assert_eq!(read(r#""strobe""#), BlinkStyles::default()); assert_eq!(read("3"), BlinkStyles::default()); assert_eq!(read("null"), BlinkStyles::default()); + // A style per state, before the switch: what was chosen stays, and is on. + assert_eq!( + read(r#"{"working":"heartbeat","waiting":"off","needsYou":"alarm"}"#), + BlinkStyles { + enabled: true, + working: BlinkStyle::Heartbeat, + waiting: BlinkStyle::Off, + needs_you: BlinkStyle::Alarm, + } + ); + // All three off was that version's default: never touched. + assert_eq!( + read(r#"{"working":"off","waiting":"off","needsYou":"off"}"#), + BlinkStyles::default() + ); // A bad style costs its own state, not the others. assert_eq!( - read(r#"{"working":"calm","waiting":"strobe","needsYou":"alarm"}"#), + read(r#"{"enabled":true,"working":"calm","waiting":"strobe","needsYou":"alarm"}"#), BlinkStyles { + enabled: true, working: BlinkStyle::Calm, waiting: BlinkStyle::Off, needs_you: BlinkStyle::Alarm, } ); + // A missing state takes the recommended style; a bad switch is off. + assert_eq!( + read(r#"{"enabled":"yes","working":"calm"}"#), + BlinkStyles { + enabled: false, + working: BlinkStyle::Calm, + ..BlinkStyles::RECOMMENDED + } + ); + // The switch off keeps the styles behind it. + let off = BlinkStyles { + enabled: false, + ..ALL_ON + }; + assert_eq!(BlinkStyles::read(&serde_json::to_value(off).unwrap()), off); let saved = serde_json::to_value(ALL_ON).unwrap(); assert_eq!(saved["needsYou"], "alarm"); + assert_eq!(saved["working"], "breathe"); + assert_eq!(saved["enabled"], true); assert_eq!(BlinkStyles::read(&saved), ALL_ON); } #[test] - fn nothing_but_work_asks_for_no_alerts() { + fn the_switch_and_the_styles_decide_together() { assert!(!WORK.wants_alerts()); assert!(ALL_ON.wants_alerts()); - assert!(!BlinkStyles::default().any()); - assert!(WORK.any()); + assert!(!BlinkStyles::default().active(), "off on a new Mac"); + assert!(BlinkStyles::default().any_style()); + assert!(WORK.active()); + assert!(!BlinkStyles { + enabled: false, + ..ALL_ON + } + .wants_alerts()); + let nothing = BlinkStyles { + enabled: true, + working: BlinkStyle::Off, + waiting: BlinkStyle::Off, + needs_you: BlinkStyle::Off, + }; + assert!(!nothing.active()); } #[test] diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index d2a637a8..4145ce7f 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -110,6 +110,8 @@ 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."; /// 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."; @@ -1030,34 +1032,63 @@ impl App { } } - /// The logo is watched while any style is chosen and a server is open. + /// The logo is watched while the switch is on, a state has a style and a + /// server is open. fn sync_logo_watch(&mut self) { let styles = self.config.logo_blink; let phase = self.phase.borrow(); - let server = phase.loaded().filter(|_| styles.any() && logo::available()); + let server = phase.loaded().filter(|_| styles.active() && logo::available()); self.logo_watch .sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles))); } fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) { - // A click flips the item it landed on, the chosen one included, so all - // of that state's are set. - for (item_signal, item_style, item) in &self.menu.logo_styles { - if *item_signal == signal { - item.set_checked(*item_style == style); - } - } - let was_off = !self.config.logo_blink.any(); + let was_active = self.config.logo_blink.active(); self.config.logo_blink.set(signal, style); + // Picking a style is asking for the logo to show it. + if style != BlinkStyle::Off { + self.config.logo_blink.enabled = true; + } + self.logo_blink_changed(was_active); + } + + /// The one switch. Turned on with every state off, it brings the recommended + /// styles, so that on always shows something. + fn toggle_logo_blink(&mut self) { + let was_active = self.config.logo_blink.active(); + let on = !self.config.logo_blink.enabled; + self.config.logo_blink.enabled = on; + if on && !self.config.logo_blink.any_style() { + self.config.logo_blink = logo::BlinkStyles::RECOMMENDED; + } + self.logo_blink_changed(was_active); + } + + fn logo_blink_changed(&mut self, was_active: bool) { + // A click flips the item it landed on, so every item is set from the + // settings, the clicked one included. + let styles = self.config.logo_blink; + if let Some(switch) = &self.menu.logo_switch { + switch.set_checked(styles.enabled); + } + for (signal, style, item) in &self.menu.logo_styles { + item.set_checked(styles.get(*signal) == *style); + } self.config_dirty = true; self.sync_logo_watch(); - // Only from off: with a style running, a dark logo may be our own. - if was_off && style != BlinkStyle::Off && logo::switched_off() { - if let Some(main) = self.windows.get(&self.main_id) { - let _ = main - .webview - .evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning")); - } + // Only from off: with a blink running, a dark logo may be our own. + if was_active || !styles.active() { + return; + } + let warning = if logo::switched_off() { + LOGO_SWITCHED_OFF + } else if logo::driven_elsewhere() { + LOGO_DRIVEN_ELSEWHERE + } else { + return; + }; + if let Some(main) = self.windows.get(&self.main_id) { + let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning")); } } @@ -1070,6 +1101,7 @@ impl App { menu_id::SWITCH => self.show_connect_page(None, true), menu_id::ADD_AGENT => self.add_agent(), menu_id::RELOAD => self.reload_focused(), + menu_id::LOGO_BLINK => self.toggle_logo_blink(), menu_id::START_FULLSCREEN => { let value = self.menu.start_fullscreen.is_checked(); self.config.start_fullscreen = value; diff --git a/packages/desktop/src/menu.rs b/packages/desktop/src/menu.rs index dff442e3..b07b006a 100644 --- a/packages/desktop/src/menu.rs +++ b/packages/desktop/src/menu.rs @@ -16,6 +16,7 @@ pub mod menu_id { pub const ADD_AGENT: &str = "add-agent"; pub const RELOAD: &str = "reload"; pub const START_FULLSCREEN: &str = "start-fullscreen"; + pub const LOGO_BLINK: &str = "logo-blink"; pub const DEVTOOLS: &str = "devtools"; pub const HELP_DOCS: &str = "help-docs"; pub const HELP_GITHUB: &str = "help-github"; @@ -26,8 +27,10 @@ pub struct AppMenu { pub window_menu: Submenu, pub help_menu: Submenu, pub start_fullscreen: CheckMenuItem, - /// One item per state and style. Empty on a Mac without a lit logo, which has no + /// The one switch for the logo. None on a Mac without a lit logo, which has no /// such menu. + pub logo_switch: Option, + /// One item per state and style, empty without the switch. pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>, } @@ -110,10 +113,19 @@ pub fn build_menu(config: &Config) -> muda::Result { ), ], )?; - // Only a Mac with a lit Apple logo has this menu at all. + // Only a Mac with a lit Apple logo has these items at all: the switch, and the + // style for each state behind it. let mut logo_styles = Vec::new(); + let mut logo_switch = None; if logo::available() { - let submenu = Submenu::new("Blink Apple Logo", true); + let switch = CheckMenuItem::with_id( + menu_id::LOGO_BLINK, + "Blink Apple Logo", + true, + config.logo_blink.enabled, + None, + ); + let submenu = Submenu::new("Apple Logo Blink Styles", true); for signal in Signal::ALL { let group = Submenu::new(signal.label(), true); for style in BlinkStyle::ALL { @@ -128,7 +140,9 @@ pub fn build_menu(config: &Config) -> muda::Result { submenu.append(&group)?; } // Right under "Always Open in Full Screen", before the separator. - view.insert(&submenu, 4)?; + view.insert(&switch, 4)?; + view.insert(&submenu, 5)?; + logo_switch = Some(switch); } let window_menu = Submenu::with_items( "Window", @@ -156,6 +170,7 @@ pub fn build_menu(config: &Config) -> muda::Result { window_menu, help_menu, start_fullscreen, + logo_switch, logo_styles, }) }