fix(desktop): closing the window quits when keep-awake watches nothing here

From the review of #800: the window hid the app whenever a keep-awake mode
was chosen, also with no server open (the connect page) or with a Codeman
on another machine, where keep-awake holds nothing. Closing the window then
left a hidden app in the Dock for nothing, and it never quit on close again
until Cmd+Q.

close_hides_app() now decides from the open server as well: it hides only
while keep-awake watches a Codeman on this Mac, the same server and the same
is_this_mac() rule the worker uses. It still hides while keep-awake is only
paused (on battery with Only When Plugged In, no agent at work, no answer),
since it carries on by itself once that changes.

The test now covers loopback, this Mac's own address, another machine's
address and no server, and fails with the old rule. The frame is recorded
and the config saved once, before the hide or quit choice, and the Reopen
comment says how a hidden app comes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-10 17:57:06 +01:00
1 parent 430bf6217b
commit 3a3e2ee1a4
3 files changed
+58 -30

No files matched your search

+1 -1
View File
@@ -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, 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. |
| **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)
+42 -17
View File
@@ -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> {
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<IpAddr>) -> 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<IpAddr> {
system::local_addresses()
}
#[cfg(not(target_os = "macos"))]
pub fn local_addresses() -> Vec<IpAddr> {
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<IpAddr> {
pub(super) fn local_addresses() -> Vec<IpAddr> {
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::<IpAddr>().unwrap()];
let unread = || -> Vec<IpAddr> { 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]
+15 -12
View File
@@ -757,7 +757,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,
..
@@ -833,24 +835,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;
}
}