diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 9e1c1e11..05e18a4a 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -41,7 +41,7 @@ cannot do. | **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. | | **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. | | **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks, and while the display sleeps or the lid is closed the blink pauses and writes nothing. | -| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always** (while the app is open). Off until you choose. Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says so. The display still sleeps. No password is needed for this part: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only When Plugged In** (on by default) pauses it on battery, and **With the Lid Closed…** keeps a closed MacBook awake as well: nothing an app holds can do that, so ticking it asks for your administrator password and installs a small helper (see Decisions), and unticking it asks again and removes the helper. The lid is never kept awake on battery, whatever the settings, and while the lid is covered *Sleep* in the Apple menu does nothing. A line at the bottom of the menu says what it does right now. It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. That is why, while it is on, closing the Codeman window hides the app instead of quitting it, as Cmd+H does: a click on its Dock icon brings it back, and only Cmd+Q (or Quit from the Dock) ends it. | +| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always** (while the app is open). Off until you choose. Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says so. The display still sleeps. No password is needed for this part: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only When Plugged In** (on by default) pauses it on battery, and **With the Lid Closed…** keeps a closed MacBook awake as well: nothing an app holds can do that, so ticking it asks for your administrator password and installs a small helper (see Decisions), and unticking it asks again and removes the helper. The lid is never kept awake on battery, whatever the settings, and while the lid is covered *Sleep* in the Apple menu does nothing. A line at the bottom of the menu says what it does right now. It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. That is why, while it watches a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, as Cmd+H does, also while it is paused (on battery, say), since it carries on by itself: a click on its Dock icon brings it back, and only Cmd+Q (or Quit from the Dock) ends it. With no server open, or one on another machine, closing the window quits. | | **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) diff --git a/packages/desktop/src/keep_awake.rs b/packages/desktop/src/keep_awake.rs index 646f5d58..a1a62773 100644 --- a/packages/desktop/src/keep_awake.rs +++ b/packages/desktop/src/keep_awake.rs @@ -33,9 +33,9 @@ //! another, so no separate "remove" item and no helper left behind unasked. //! //! It lives in the app, so it ends with the app: Codeman's LaunchAgent keeps the server -//! running after the app quits, but nothing keeps the Mac awake then. So while a mode is -//! on, closing the main window hides the app instead of quitting it -//! (`Mode::close_hides_app`): only Cmd+Q (or Quit from the Dock) ends it. +//! running after the app quits, but nothing keeps the Mac awake then. So while it watches +//! a Codeman on this Mac, closing the main window hides the app instead of quitting it +//! (`close_hides_app`): only Cmd+Q (or Quit from the Dock) ends it. //! //! The agents are asked natively, like the logo blink does (`agents`): WebKit stalls the //! event stream of a hidden page. A worker thread does the asking and the holding, and @@ -125,14 +125,6 @@ impl Mode { pub fn from_menu_id(id: &str) -> Option { Self::ALL.into_iter().find(|mode| mode.menu_id() == id) } - - /// Whether closing the main window hides the app instead of quitting it. - /// Keep-awake ends with the app, so while a mode is on, the red button (or - /// Cmd+W) must not end it: the app hides as Cmd+H does, a click on its Dock - /// icon brings it back, and Cmd+Q still quits. - pub fn close_hides_app(self) -> bool { - available() && self != Mode::Off - } } /// The `keepAwake` object in `desktop.json`. @@ -281,6 +273,29 @@ pub fn status_line(decision: Decision, settings: Settings, facts: Facts, covered format!("{text}, {lid}") } +/// Whether closing the main window hides the app instead of quitting it. +/// Keep-awake ends with the app, so while it watches a Codeman on this Mac the +/// red button (or Cmd+W) must not end it: the app hides as Cmd+H does, a click +/// on its Dock icon brings it back, and Cmd+Q still quits. That holds while it +/// is only paused (on battery, no agent at work, no answer), since it carries +/// on by itself once that changes. With no server open (the connect page), or +/// one on another machine, it keeps nothing awake, so closing the window quits. +/// `local_addresses` is only read with a mode on and a server open. +pub fn close_hides_app(mode: Mode, server: Option<&Url>, local_addresses: impl FnOnce() -> Vec) -> bool { + mode != Mode::Off && server.is_some_and(|server| is_this_mac(server, &local_addresses())) +} + +/// Every address of this Mac's interfaces. +#[cfg(target_os = "macos")] +pub fn local_addresses() -> Vec { + system::local_addresses() +} + +#[cfg(not(target_os = "macos"))] +pub fn local_addresses() -> Vec { + Vec::new() +} + /// Whether `server` is this Mac: a loopback address or `localhost`, or an address /// literal one of this Mac's interfaces has. A host name is not looked up. pub fn is_this_mac(server: &Url, local_addresses: &[IpAddr]) -> bool { @@ -944,7 +959,7 @@ mod system { } /// Every address of this Mac's interfaces. - fn local_addresses() -> Vec { + pub(super) fn local_addresses() -> Vec { let mut found = Vec::new(); // SAFETY: getifaddrs fills a list that freeifaddrs frees; each ifa_addr is // NULL or a sockaddr whose family says which struct it is. @@ -1232,13 +1247,23 @@ mod tests { } #[test] - fn closing_the_window_hides_the_app_only_while_a_mode_is_on() { - // Off quits as it always did; a mode that keeps the Mac awake must not - // end with the red button, since keep-awake ends with the app. - assert!(!Mode::Off.close_hides_app()); + fn closing_the_window_hides_the_app_only_while_it_watches_this_mac() { + let own = || vec!["192.168.1.20".parse::().unwrap()]; + let unread = || -> Vec { panic!("the addresses decide nothing here") }; + let here = url("http://127.0.0.1:3000/"); + let lan = url("http://192.168.1.20:3000/"); + let elsewhere = url("http://192.168.1.21:3000/"); for mode in [Mode::WhileWorking, Mode::Always] { - assert_eq!(mode.close_hides_app(), available(), "{mode:?}"); + // Keep-awake ends with the app, so the red button must not end it. + assert!(close_hides_app(mode, Some(&here), own), "{mode:?}"); + assert!(close_hides_app(mode, Some(&lan), own), "{mode:?}"); + // Another machine's Codeman, or the connect page: nothing is kept + // awake, so closing the window quits. + assert!(!close_hides_app(mode, Some(&elsewhere), own), "{mode:?}"); + assert!(!close_hides_app(mode, None, unread), "{mode:?}"); } + // Off quits as it always did. + assert!(!close_hides_app(Mode::Off, Some(&here), unread)); } #[test] diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 2296436c..13153e5b 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -759,7 +759,9 @@ impl App { Event::UserEvent(event) => self.on_user_event(event, target), // A click on the Dock icon while no window is visible, which is // the main window minimized: tao only reports it, so bring the - // window back here. + // window back here. An app hidden by closing its window + // (`hide_app`) needs nothing here: AppKit unhides it, with every + // window as it was, before it reports the click. Event::Reopen { has_visible_windows: false, .. @@ -835,24 +837,25 @@ impl App { control_flow: &mut ControlFlow, ) { match event { + WindowEvent::CloseRequested if id != self.main_id => self.remove_window(id), WindowEvent::CloseRequested => { - if id != self.main_id { - self.remove_window(id); - } else if self.config.keep_awake.mode.close_hides_app() { + self.record_main_frame(); + self.save_config(); + let server = self.phase.borrow().loaded().cloned(); + let mode = self.config.keep_awake.mode; + if keep_awake::available() + && keep_awake::close_hides_app(mode, server.as_ref(), keep_awake::local_addresses) + { // Keep This Mac Awake ends with the app, so closing the - // window while it is on hides Codeman instead, as Cmd+H - // does (full screen and pop-outs included): the Mac stays - // awake, a click on the Dock icon brings it back, and - // Cmd+Q still quits. - self.record_main_frame(); - self.save_config(); + // window while it watches a Codeman on this Mac hides + // Codeman instead, as Cmd+H does (full screen and pop-outs + // included): the Mac stays awake, a click on the Dock icon + // brings it back, and Cmd+Q still quits. hide_app(target); } else { // Closing the main window quits; the server and its // sessions keep running, so nothing is lost. self.keep_awake.shutdown(); - self.record_main_frame(); - self.save_config(); *control_flow = ControlFlow::Exit; } }