feat(desktop): a simpler Keep This Mac Awake menu

The owner found the options confusing after the first test build. The
menu now reads Off / While Agents Work / Always, then on a MacBook
Only When Plugged In and With the Lid Closed…, then one status line.

- "Always While Codeman Is Open" is now "Always"; the status line says
  "Awake: Codeman is open".
- "Only on Power Adapter" is now "Only When Plugged In".
- "Remove the Lid Helper…" is gone: the lid item is the helper.
  Ticking it installs the helper behind the administrator prompt,
  unticking it removes it behind another. The setting changes only
  once the prompt's command ran, so a cancelled prompt leaves the item
  as it was.
- Shorter status lines ("Awake: agents working, lid closed too",
  "Paused: on battery", "Sleeps as usual: no agent working").

Config keys and values are unchanged, so a saved choice carries over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-09 07:44:26 +01:00
1 parent 79a6c03363
commit 2f9843c94a
5 files changed
+89 -99

No files matched your search

+6 -6
View File
@@ -254,12 +254,12 @@ until you choose, and the menu only exists on such a Mac.
**Codeman > Keep This Mac Awake** stops the Mac from going to sleep while your agents work
on it (**While Agents Work**, plus 5 minutes after the last one stops), or for as long as
the app is open (**Always While Codeman Is Open**). It is off until you choose, only acts
for a Codeman running on this Mac, needs no password, and ends when the app quits. On a
MacBook it pauses on battery unless you untick **Only on Power Adapter**, and **Also With
the Lid Closed…** keeps a closed MacBook awake too: that one asks for your administrator
password once to install a small helper, never acts on battery, and **Remove the Lid
Helper…** takes it out again.
the app is open (**Always**). It is off until you choose, only acts for a Codeman running
on this Mac, needs no password, and ends when the app quits. On a MacBook it pauses on
battery unless you untick **Only When Plugged In**, and **With the Lid Closed…** keeps a
closed MacBook awake too: ticking it asks for your administrator password to install a
small helper, unticking it asks again and removes the helper, and it never acts on
battery.
### First launch
+2 -2
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 Codeman 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 there is nothing to keep awake here. 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 on Power Adapter** (on by default) pauses it on battery, and **Also With the Lid Closed…** keeps a closed MacBook awake as well: nothing an app holds can do that, so the first time it asks for your administrator password and installs a small helper (see Decisions). The lid is never kept awake on battery, whatever the settings, and while the lid is covered *Sleep* in the Apple menu does nothing. **Remove the Lid Helper…** takes it out again. 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. |
| **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. |
| **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)
@@ -152,7 +152,7 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
macOS sleeps on lid close whatever an app asks: `caffeinate` and every power
assertion stop idle sleep only. `pmset -a disablesleep 1` keeps a closed MacBook
awake, but it is a machine-wide root setting with no owner process, so nothing
would undo it after a crash. So *Also With the Lid Closed…* installs a
would undo it after a crash. So ticking *With the Lid Closed…* installs a
LaunchDaemon (`com.getcodeman.keepawake`) that runs `macos/keep-awake-lid.sh`
every 20 seconds. The script sets `disablesleep` only while a request file next
to `desktop.json` is younger than 2 minutes, is not a symlink, names a live
+42 -47
View File
@@ -22,11 +22,16 @@
//!
//! When it holds is the user's pick: **While Agents Work** (an agent at work, and for
//! `GRACE` after the last one stopped, so the gap between two turns or a reply from
//! the phone does not let the Mac drop off), or **Always While Codeman Is Open**. Only
//! for a Codeman on this Mac: keeping this Mac awake does nothing for agents that run
//! on another machine. Only on the power adapter unless the user says otherwise (a
//! the phone does not let the Mac drop off), or **Always** (while the app is open).
//! Only for a Codeman on this Mac: keeping this Mac awake does nothing for agents that
//! run on another machine. Only when plugged in unless the user says otherwise (a
//! MacBook only), and the lid never on battery, whatever the setting.
//!
//! The menu is kept short on purpose: three modes, then on a MacBook two check items
//! (Only When Plugged In, With the Lid Closed…). The lid item IS the helper: ticking it
//! installs the helper behind the administrator prompt, unticking it removes it behind
//! 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.
//!
@@ -74,17 +79,16 @@ const REQUEST_FILE: &str = "keep-awake-lid.pid";
pub const AC_ONLY_ID: &str = "keep-awake-ac-only";
pub const LID_ID: &str = "keep-awake-lid";
pub const REMOVE_HELPER_ID: &str = "keep-awake-remove-helper";
/// The status line while nothing is watched.
pub const OFF_TEXT: &str = "Off: this Mac sleeps as usual";
pub const NO_SERVER_TEXT: &str = "Starts when Codeman on this Mac is open";
pub const OFF_TEXT: &str = "Sleeps as usual";
pub const NO_SERVER_TEXT: &str = "Waiting for Codeman on this Mac";
/// What macOS shows in the administrator prompt (`with prompt`). No double quote or
/// backslash: they would end the AppleScript string (`applescript` refuses them).
const INSTALL_PROMPT: &str = "Codeman wants to install a small helper that keeps this Mac awake with the lid \
closed while your agents work. It only acts on the power adapter, and only while the Codeman app asks it to.";
const REMOVE_PROMPT: &str = "Codeman wants to remove its lid helper.";
const REMOVE_PROMPT: &str = "Codeman wants to remove its lid helper, so this Mac sleeps again when the lid closes.";
/// When the Mac is kept awake.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
@@ -104,7 +108,7 @@ impl Mode {
match self {
Mode::Off => "Off",
Mode::WhileWorking => "While Agents Work",
Mode::Always => "Always While Codeman Is Open",
Mode::Always => "Always",
}
}
@@ -202,14 +206,14 @@ impl Status {
fn text(self) -> &'static str {
match self {
Status::Off => OFF_TEXT,
Status::Remote => "Nothing to keep awake: this Codeman runs on another machine",
Status::OnBattery => "Paused on battery power",
Status::NoAnswer => "Codeman does not answer, so this Mac may sleep",
Status::Refused => "Codeman refused the login, so the agents cannot be seen",
Status::Idle => "No agent is working, so this Mac may sleep",
Status::Working => "Awake: agents are working",
Status::Recently => "Awake: an agent worked in the last 5 minutes",
Status::Always => "Awake while Codeman is open",
Status::Remote => "Sleeps as usual: Codeman runs on another machine",
Status::OnBattery => "Paused: on battery",
Status::NoAnswer => "Paused: Codeman does not answer",
Status::Refused => "Paused: Codeman refused the login",
Status::Idle => "Sleeps as usual: no agent working",
Status::Working => "Awake: agents working",
Status::Recently => "Awake: an agent finished in the last 5 minutes",
Status::Always => "Awake: Codeman is open",
}
}
}
@@ -256,15 +260,15 @@ pub fn status_line(decision: Decision, settings: Settings, facts: Facts, covered
// Asked for, but missing or from another version of the app (an update that
// changed the script, a removal by hand): the lid item offers the install again.
let lid = if !facts.helper {
"the lid helper needs installing again"
"not with the lid closed: turn it on again"
} else if facts.on_battery {
"not with the lid closed on battery"
} else if covered == Some(true) {
"also with the lid closed"
"lid closed too"
} else {
"the lid helper has not answered yet"
"lid closed in a moment"
};
format!("{text}; {lid}")
format!("{text}, {lid}")
}
/// Whether `server` is this Mac: a loopback address or `localhost`, or an address
@@ -1313,14 +1317,11 @@ mod tests {
let lid = Settings { lid: true, ..WORK };
let ready = Facts { helper: true, ..HERE };
let line = |settings, facts, covered| status_line(decide(settings, facts), settings, facts, covered);
assert_eq!(line(WORK, HERE, None), "Awake: agents are working");
assert_eq!(
line(lid, ready, Some(true)),
"Awake: agents are working; also with the lid closed"
);
assert_eq!(line(WORK, HERE, None), "Awake: agents working");
assert_eq!(line(lid, ready, Some(true)), "Awake: agents working, lid closed too");
assert_eq!(
line(lid, ready, Some(false)),
"Awake: agents are working; the lid helper has not answered yet"
"Awake: agents working, lid closed in a moment"
);
let battery = Facts {
on_battery: true,
@@ -1329,19 +1330,19 @@ mod tests {
let anywhere = Settings { ac_only: false, ..lid };
assert_eq!(
line(anywhere, battery, None),
"Awake: agents are working; not with the lid closed on battery"
"Awake: agents working, not with the lid closed on battery"
);
// Asked for, but no helper (an update changed it): the line says so.
assert_eq!(
line(lid, HERE, None),
"Awake: agents are working; the lid helper needs installing again"
"Awake: agents working, not with the lid closed: turn it on again"
);
// Not held: nothing about the lid.
let idle = Facts {
agents: Agents::Idle,
..ready
};
assert_eq!(line(lid, idle, None), "No agent is working, so this Mac may sleep");
assert_eq!(line(lid, idle, None), "Sleeps as usual: no agent working");
}
#[test]
@@ -1623,28 +1624,25 @@ mod tests {
fn the_worker_holds_while_agents_work_and_lets_go_after_the_grace() {
let mut rig = rig(true);
rig.watch(WORK);
assert_eq!(
rig.look_at(0).as_deref(),
Some("No agent is working, so this Mac may sleep")
);
assert_eq!(rig.look_at(0).as_deref(), Some("Sleeps as usual: no agent working"));
assert_eq!(
rig.mac.last_hold(),
Some(Hold::Watching),
"App Nap off while it watches"
);
rig.answer(vec![seen(true)]);
assert_eq!(rig.look_at(2).as_deref(), Some("Awake: agents are working"));
assert_eq!(rig.look_at(2).as_deref(), Some("Awake: agents working"));
assert_eq!(rig.mac.last_hold(), Some(Hold::Holding));
rig.answer(vec![seen(false)]);
assert_eq!(
rig.look_at(4).as_deref(),
Some("Awake: an agent worked in the last 5 minutes")
Some("Awake: an agent finished in the last 5 minutes")
);
assert_eq!(rig.mac.last_hold(), Some(Hold::Holding));
assert_eq!(rig.look_at(200), None, "unchanged: nothing to report");
assert_eq!(
rig.look_at(2 + GRACE.as_secs()).as_deref(),
Some("No agent is working, so this Mac may sleep")
Some("Sleeps as usual: no agent working")
);
assert_eq!(rig.mac.last_hold(), Some(Hold::Watching));
}
@@ -1660,7 +1658,7 @@ mod tests {
assert_eq!(rig.mac.last_hold(), Some(Hold::Holding));
assert_eq!(
rig.look_at(GRACE.as_secs()).as_deref(),
Some("Codeman does not answer, so this Mac may sleep")
Some("Paused: Codeman does not answer")
);
assert_eq!(rig.mac.last_hold(), Some(Hold::Watching));
}
@@ -1670,10 +1668,7 @@ mod tests {
let mut rig = rig(true);
rig.watch(WORK);
rig.answer(vec![Poll::Refused]);
assert_eq!(
rig.look_at(0).as_deref(),
Some("Codeman refused the login, so the agents cannot be seen")
);
assert_eq!(rig.look_at(0).as_deref(), Some("Paused: Codeman refused the login"));
rig.look_at(2);
rig.look_at(4);
assert_eq!(*rig.asked.borrow(), 1);
@@ -1693,7 +1688,7 @@ mod tests {
fn always_holds_without_asking_the_server() {
let mut rig = rig(true);
rig.watch(ALWAYS);
assert_eq!(rig.look_at(0).as_deref(), Some("Awake while Codeman is open"));
assert_eq!(rig.look_at(0).as_deref(), Some("Awake: Codeman is open"));
assert_eq!(rig.mac.last_hold(), Some(Hold::Holding));
assert_eq!(*rig.asked.borrow(), 0);
}
@@ -1705,7 +1700,7 @@ mod tests {
assert!(!rig.worker.ticking());
assert_eq!(
rig.look_at(0).as_deref(),
Some("Nothing to keep awake: this Codeman runs on another machine")
Some("Sleeps as usual: Codeman runs on another machine")
);
assert_eq!(*rig.asked.borrow(), 0);
assert!(rig.mac.0.borrow().holds.is_empty());
@@ -1717,7 +1712,7 @@ mod tests {
rig.watch(ALWAYS);
rig.look_at(0);
rig.mac.0.borrow_mut().battery = true;
assert_eq!(rig.look_at(2).as_deref(), Some("Paused on battery power"));
assert_eq!(rig.look_at(2).as_deref(), Some("Paused: on battery"));
assert_eq!(rig.mac.last_hold(), Some(Hold::None));
rig.mac.0.borrow_mut().battery = false;
rig.look_at(4);
@@ -1732,7 +1727,7 @@ mod tests {
rig.watch(Settings { lid: true, ..ALWAYS });
assert_eq!(
rig.look_at(0).as_deref(),
Some("Awake while Codeman is open; also with the lid closed")
Some("Awake: Codeman is open, lid closed too")
);
assert_eq!(rig.mac.0.borrow().writes, 1);
rig.look_at(10);
@@ -1753,7 +1748,7 @@ mod tests {
rig.mac.0.borrow_mut().battery = true;
assert_eq!(
rig.look_at(32).as_deref(),
Some("Awake while Codeman is open; not with the lid closed on battery")
Some("Awake: Codeman is open, not with the lid closed on battery")
);
assert_eq!(rig.mac.0.borrow().clears, 1);
// Nothing to watch: everything let go.
@@ -1800,7 +1795,7 @@ mod tests {
run(&rx, worker, &|generation, line| {
lines.borrow_mut().push((generation, line))
});
assert_eq!(lines.into_inner(), vec![(2, "Awake while Codeman is open".to_owned())]);
assert_eq!(lines.into_inner(), vec![(2, "Awake: Codeman is open".to_owned())]);
assert_eq!(mac.last_hold(), Some(Hold::None), "the end lets go");
}
}
+29 -17
View File
@@ -1138,28 +1138,41 @@ impl App {
self.sync_keep_awake();
}
/// Choosing the lid item asks for the administrator password first while the
/// helper is missing or from another version; the setting goes on once it is in.
/// The lid item is the helper: ticking it installs the helper behind the
/// administrator prompt (unless this version's is already in), unticking it removes
/// it behind another. The setting changes only once the prompt's command ran, so a
/// cancelled or failed prompt leaves the item as it was.
fn toggle_keep_awake_lid(&mut self) {
let Some(items) = &self.menu.keep_awake else {
use keep_awake::HelperState;
let Some(wanted) = self
.menu
.keep_awake
.as_ref()
.and_then(|items| items.lid.as_ref())
.map(|item| item.is_checked())
else {
return;
};
let Some(item) = &items.lid else {
return;
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let prompt = match (wanted, state) {
_ if self.lid_helper_busy => None,
(true, HelperState::Installed) | (false, HelperState::Missing) => {
self.config.keep_awake.lid = wanted;
self.config_dirty = true;
None
}
(install, _) => Some(install),
};
let state = keep_awake::request_path().map_or(keep_awake::HelperState::Missing, |request| {
keep_awake::helper_state(&request)
});
if !item.is_checked() || state == keep_awake::HelperState::Installed {
self.config.keep_awake.lid = item.is_checked();
self.config_dirty = true;
// The click flipped the item already. Until a prompt's command ran it shows
// what is true.
if let Some(items) = &self.menu.keep_awake {
items.show_helper(state, self.config.keep_awake.lid);
self.sync_keep_awake();
return;
}
// Not on until the helper is in.
item.set_checked(false);
self.run_lid_helper(true);
match prompt {
Some(install) => self.run_lid_helper(install),
None => self.sync_keep_awake(),
}
}
fn run_lid_helper(&mut self, install: bool) {
@@ -1229,7 +1242,6 @@ impl App {
match id {
keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(),
keep_awake::LID_ID => self.toggle_keep_awake_lid(),
keep_awake::REMOVE_HELPER_ID => self.run_lid_helper(false),
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
+10 -27
View File
@@ -34,35 +34,25 @@ pub struct AppMenu {
pub keep_awake: Option<KeepAwakeMenu>,
}
/// App menu > Keep This Mac Awake.
/// App menu > Keep This Mac Awake: Off / While Agents Work / Always, then on a
/// MacBook Only When Plugged In and With the Lid Closed…, then one status line.
pub struct KeepAwakeMenu {
pub modes: Vec<(Mode, CheckMenuItem)>,
/// Only on a Mac with a lid: a desktop Mac always runs on mains and cannot close.
pub ac_only: Option<CheckMenuItem>,
/// Ticked = the helper is in and asked for. Ticking or unticking it asks for the
/// administrator password (install, remove), hence the ellipsis.
pub lid: Option<CheckMenuItem>,
pub remove_helper: Option<MenuItem>,
/// What it does right now (keep_awake::status_line). Not clickable.
pub status: MenuItem,
}
impl KeepAwakeMenu {
/// The lid items as the helper now is. The lid item ends in an ellipsis while
/// choosing it asks for the administrator password first.
/// The lid item as the helper now is: ticked only while it is installed and on.
pub fn show_helper(&self, state: HelperState, lid_on: bool) {
if let Some(item) = &self.lid {
item.set_text(lid_label(state));
item.set_checked(lid_on && state == HelperState::Installed);
}
if let Some(item) = &self.remove_helper {
item.set_enabled(state != HelperState::Missing);
}
}
}
fn lid_label(state: HelperState) -> &'static str {
match state {
HelperState::Installed => "Also With the Lid Closed",
_ => "Also With the Lid Closed\u{2026}",
}
}
@@ -75,32 +65,26 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
submenu.append(&item)?;
modes.push((mode, item));
}
let (mut ac_only, mut lid, mut remove_helper) = (None, None, None);
let (mut ac_only, mut lid) = (None, None);
if keep_awake::has_lid() {
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let ac = CheckMenuItem::with_id(
keep_awake::AC_ONLY_ID,
"Only on Power Adapter",
"Only When Plugged In",
true,
settings.ac_only,
None,
);
let lid_item = CheckMenuItem::with_id(
keep_awake::LID_ID,
lid_label(state),
"With the Lid Closed\u{2026}",
true,
settings.lid && state == HelperState::Installed,
None,
);
let remove = MenuItem::with_id(
keep_awake::REMOVE_HELPER_ID,
"Remove the Lid Helper\u{2026}",
state != HelperState::Missing,
None,
);
submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item, &remove])?;
(ac_only, lid, remove_helper) = (Some(ac), Some(lid_item), Some(remove));
submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item])?;
(ac_only, lid) = (Some(ac), Some(lid_item));
}
let initial = match settings.mode {
Mode::Off => keep_awake::OFF_TEXT,
@@ -114,7 +98,6 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
modes,
ac_only,
lid,
remove_helper,
status,
},
))