Merge the lid keep-awake without the administrator password (desktop)

The Mac app keeps a closed MacBook awake through the kernel's lid-sleep
switch, held by a child process of the app, instead of the root helper that
1.50.1 installed behind an administrator prompt. The old helper is no longer
installed or used; Keep This Mac Awake offers to remove it while its files
exist.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:15:24 +01:00
commit 2da3cf3853
10 files changed
+2164 -921

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---
**Mac app: With the Lid Closed no longer asks for a password.** Codeman > Keep This Mac Awake > With the Lid Closed is a plain switch now. 1.50.1 asked for your administrator password and installed a small root helper for it. The app now does what [LogoLight](https://logolight.app) does: it turns off macOS's sleep-on-lid-close itself while your agents work, and turns it back on when they stop, when the app quits or crashes, and on battery. If you ticked With the Lid Closed in 1.50.1, it keeps working after the update, and the old helper does nothing any more. Remove it with Codeman > Keep This Mac Awake > Remove the Old Lid Helper… (that asks for your password one last time). If the Mac ever sleeps under the lid anyway, the status line in the same menu says until when.
+4 -4
View File
@@ -257,10 +257,10 @@ you turn it on, and the menu only exists on such a Mac.
on it (**While Agents Work**, plus 5 minutes after the last one stops), or for as long as
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.
battery unless you untick **Only When Plugged In**, and **With the Lid Closed** keeps a
closed MacBook awake too, with no password and never on battery. Codeman 1.50.1 installed
a small helper for this behind your administrator password. It is no longer used:
**Remove the Old Lid Helper…** in the same menu takes it away.
### First launch
+89 -34
View File
@@ -40,8 +40,8 @@ cannot do.
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. LogoLight (1.3 or later) steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards, with whatever it showed before; while an older LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses and writes nothing. |
| **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. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. LogoLight (1.3 or later) steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards, with whatever it showed before; while an older LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses and writes nothing. |
| **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 anywhere: 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: while it holds, the app has macOS's sleep-on-lid-close turned off (see Decisions), and turns it back on when it lets go, quits or crashes. The lid is never kept awake on battery, whatever the settings: unplugged with the lid closed, the Mac goes to sleep. A line at the bottom of the menu says what it does right now: whether the lid is covered, that it is left to macOS while an external display is connected (a docked MacBook stays awake with its lid closed by macOS's own doing), and if the Mac slept under the lid all the same, until when. Codeman 1.50.1 kept the lid awake through a small root helper, behind the administrator password. Nothing uses that helper any more, and where it is still installed the menu has **Remove the Old Lid Helper…** (the one item that asks for the password). 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)
@@ -148,29 +148,67 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
- `codeman web -d` gets the port and base path as separate arguments, never as
shell text.
**Keeping a closed MacBook awake takes a root helper, installed on request.**
**Keeping a closed MacBook awake is one kernel switch, held by a child process.**
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 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
process of the file's owner, and `pmset -g batt` says the Mac runs on its power
adapter. The app writes its own pid there and rewrites it every 30 seconds while
it wants the lid covered, so an app that quits or crashes is a dead pid and the
lid is released at the next run. The script undoes only a `disablesleep` it set
itself (a marker file), never an administrator's own. The app writes the script
and the plist itself, as root, straight into `/Library/Application
Support/Codeman` and `/Library/LaunchDaemons` from the copies built into it
(base64 in the root command, so nothing user-writable is copied and no file
content needs quoting), behind macOS's administrator prompt (`do shell script
... with administrator privileges` through `osascript`, so the event loop keeps
running while the prompt is open). The idle-sleep part needs none of this: an
`NSProcessInfo` activity with `IdleSystemSleepDisabled`, released by the kernel
with the process. While the app watches for work but holds nothing, a second
kind of activity keeps App Nap from slowing its look at the server, and still
allows sleep.
assertion stop idle sleep only, and an assertion against system sleep turns the
lid's sleep into a dark wake. What decides is the power management root's
lid-sleep switch (`kPMSetClamshellSleepState` on `IOPMrootDomain`), which any
process may set through the root domain's user client, with no entitlement and
no password. With it set a closed lid changes nothing and the Mac stays at full
wake. It is the method LogoLight ships as *Lit with Lid Closed*; 1.50.1 used
`pmset -a disablesleep 1` through a root LaunchDaemon instead.
The switch has no owner (read from Apple's open sources of the kernel and of
its power daemon, `IOPMrootDomain.cpp` and `PMAssertions.c`). The kernel keeps
it after the process that set it has gone, and honours it on battery. It is a
single bit that the power daemon also writes when its own reasons change, and
that LogoLight writes for its feature. On an Apple silicon Mac it is also what
keeps a docked MacBook awake with its lid closed: the window server tells the
power daemon about the external display, and the daemon sets the bit once, when
that changes, so a zero written by anyone else stays until the display or the
adapter next changes. Clearing it under a closed lid puts the Mac to sleep at
once. So the app never writes it itself. A child process (`src/lid_awake.rs`, this binary with
`--keep-awake-lid`) holds it together with two assertions, against system sleep
and idle sleep (LogoLight measured the three together), and:
- sets the switch again every second, so a write by anyone else is undone
within a second;
- reads the power source itself and holds nothing on battery, whatever the app
says;
- writes nothing at all while an external display is connected: macOS's own
closed-lid mode has the switch then, and a zero from Codeman would put a
docked Mac to sleep, or leave it without that mode until the display is
plugged in again. The assertions keep the Mac awake meanwhile. What the child
set before the display came is cleared once the display has been gone for five
seconds (the display list is short for a moment while displays are
rearranged), and for that the child outlives the app if it has to;
- lets go when the pipe from the app closes (a quit, a crash, a Force Quit) or
when the app has not written to it for a minute (a hang): the assertions
first, then the switch, so a closed Mac goes straight to sleep;
- ignores SIGHUP, SIGINT and SIGTERM, which arrive together with the app's own
end, so the pipe is its one way out;
- leaves the switch alone on its way out while LogoLight holds it for its own
feature (LogoLight's power assertions say so): LogoLight sets it once and
would not notice it gone.
One child at a time holds a lock on `lid-awake` next to `desktop.json`, and
that file is non-empty while the switch may be set. A child that was killed
leaves it marked and unlocked, and the app turns lid sleep back on when it sees
a child end and at its next launch. The child tells the app what it did (the
kernel took the call, a display is connected so nothing was written, on
battery, refused, or that the Mac slept all the same, which it sees as
wall-clock time that passed while its monotonic clock stood still), and the
menu's status line says it.
The helper of 1.50.1 gets nothing to act on: its request file is never written
and is deleted at launch. It stays installed until the user removes it from
the menu, the one thing left behind macOS's administrator prompt (`do shell
script ... with administrator privileges` through `osascript`, so the event
loop keeps running while the prompt is open). The idle-sleep part needs none of
this: an `NSProcessInfo` activity with `IdleSystemSleepDisabled`, released by
the kernel with the process. While the app watches for work but holds nothing,
a second kind of activity keeps App Nap from slowing its look at the server,
and still allows sleep.
**The app runs the installer itself; Terminal is the fallback.** `install.sh
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
@@ -306,8 +344,8 @@ the app shows its first line as **Lid Closed: …** under View > Apple Logo Blin
says nothing watches the lid while no child runs.
Whether the Mac stays awake under the lid is not the logo's business: Keep This Mac Awake's
lid helper does that (`pmset disablesleep`, which holds the Mac at full wake), or LogoLight's
own Lit with Lid Closed, or an external display. An assertion against system sleep alone would
lid keeper does that (lid sleep off in the kernel, which holds the Mac at full wake), or
LogoLight's own Lit with Lid Closed, which is the same switch, or an external display. An assertion against system sleep alone would
not do: LogoLight saw the Mac enter a dark wake with it, where the logo stays dark.
LogoLight drives the logo the same way, so the two would fight over every fade. The marker the
@@ -409,14 +447,31 @@ crosses, so the login is never on a command line.
own tests, and ran in that release candidate next to LogoLight 1.3.1; its
timing on a real logo was not measured. `api/sessions` does not say which turns are Codeman's own, so with
prompt-cache keep-warm on, a keep-alive turn blinks too.
- **Keeping the lid awake has not been seen on a real closed MacBook yet.**
The helper ran every case against a stand-in `pmset`, the install and removal
commands ran in a temporary folder with stand-ins for `chown` and `launchctl`,
and the power assertion was measured on a Mac mini (it appears with its name
while agents work, and goes with a clean end and with a SIGKILL). Whether a
closed MacBook goes to sleep at once when the helper lets go (the app quit, an
agent finished, the adapter came out) has not been watched. The helper is
machine-wide: on a Mac where two users install it, the last install wins.
- **Keeping the lid awake without a password has not been seen on a real closed
MacBook yet.** The method is LogoLight's, which its author saw work on a
MacBook Pro (macOS 27.2, LogoLight 1.3.1). Codeman's own code ran on a Mac
mini, which has no lid (macOS 27.2): the kernel took the call from an
unprivileged process, the child held its two assertions under its name, let
go when its pipe closed and after a minute of silence, ignored SIGTERM, and
waited for a child before it; a child killed with SIGKILL was replaced by the
app, and its mark undone; told that a display is connected, it held its
assertions and wrote nothing. Not checked: *Sleep* in the Apple menu while the lid
is held, a run of many hours, and pulling the adapter with the lid closed.
Read from Apple's sources and not measured: when an external display is
unplugged under a closed lid, macOS's power daemon turns lid sleep back on
itself, and the Mac sleeps or drops to a dark wake (logo dark), although the
child sets the switch again a second later. Docked is read as "a display other
than the built-in one is connected", the nearest an app gets to what the
window server tells the power daemon: with a display macOS does not use for
its closed-lid mode (Sidecar and AirPlay were not checked), nobody holds the
lid, and the Mac sleeps or drops to a dark wake when it closes; the status
line then reads "lid left to macOS at a display", and after such a sleep
until when the Mac slept. A child that
set the switch and was then let go of at a display stays, without the app,
until the display has gone. A SIGKILL of the app together with its child
leaves lid sleep off until Codeman next starts or the Mac restarts. With two
copies of the app open, the lid is released for a second when the copy that
held it lets go.
- **Blinking with the lid closed was seen on one MacBook Pro so far** (macOS
27.2, on power, a release candidate of 1.50.1 on 2026-11-10; the method is
LogoLight's, which its author measured for LogoLight 1.3.1). Nothing was
-74
View File
@@ -1,74 +0,0 @@
#!/bin/bash
# keep-awake-lid.sh: the root helper behind "Keep This Mac Awake > Also With the Lid
# Closed" in the Codeman app for Mac.
#
# The app keeps the Mac from idle sleep on its own, with no root (a power assertion).
# Closing the lid is another matter: only `pmset -a disablesleep 1` keeps a closed
# MacBook awake, and that is a machine-wide root setting with no owner process. So this
# helper, run by the com.getcodeman.keepawake LaunchDaemon every 20 seconds, applies it
# while the app asks for it and undoes it when the app stops asking:
#
# - The app writes its pid to the request file, and rewrites it every 30 seconds, while
# it wants the lid covered.
# - The request counts only while the file is fresh (under 2 minutes old), is not a
# symlink, its pid is alive, that process belongs to the file's owner, and the Mac
# runs on its power adapter. An app that quits or crashes is a dead pid, so the lid
# is released at the next run.
# - Never on battery, whatever the app says: a closed MacBook that cannot sleep in a
# bag only heats up and drains.
# - It undoes only a disablesleep it set itself (the .owned file), so an
# administrator's own `pmset -a disablesleep 1` is never switched off.
#
# The app writes this file to a ROOT-OWNED path itself, behind macOS's administrator
# prompt; a root daemon must never run a file a user can edit.
#
# Usage: keep-awake-lid.sh <request-file>
# Test hooks (set only by the test suite; launchd passes no environment of the user's):
# CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR.
#
# bash 3.2 (what macOS ships) and BSD tools only.
set -u
REQ="${1:?usage: keep-awake-lid.sh <request-file>}"
PMSET="${CODEMAN_KEEPAWAKE_PMSET:-/usr/bin/pmset}"
STATE_DIR="${CODEMAN_KEEPAWAKE_STATE_DIR:-/Library/Application Support/Codeman}"
OWNED="$STATE_DIR/keep-awake.owned"
want=0
# -L: refuse a symlink (root must not be steered to read some other file).
# find -mmin -2: modified within the last 2 minutes (BSD and GNU find both support it).
if [ -f "$REQ" ] && [ ! -L "$REQ" ] && [ -n "$(find "$REQ" -mmin -2 2>/dev/null)" ]; then
pid=$(head -c 32 "$REQ" 2>/dev/null | tr -dc '0-9')
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
file_uid=$(ls -ln "$REQ" 2>/dev/null | awk '{print $3}')
pid_uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d ' ')
if [ -n "$file_uid" ] && [ "$file_uid" = "$pid_uid" ]; then
want=1
fi
fi
fi
# The power adapter, or nothing: an unreadable answer counts as battery.
if [ "$want" = "1" ] && ! "$PMSET" -g batt 2>/dev/null | grep -q "'AC Power'"; then
want=0
fi
current=$("$PMSET" -g 2>/dev/null | awk '/SleepDisabled/ {print $2; exit}')
[ -n "$current" ] || current=0
if [ "$want" = "1" ]; then
# Already 1 without our marker means an administrator set it: leave it and do not
# claim it, so releasing later never switches their setting off.
if [ "$current" != "1" ]; then
if "$PMSET" -a disablesleep 1; then
mkdir -p "$STATE_DIR" && : > "$OWNED"
fi
fi
elif [ -f "$OWNED" ]; then
if [ "$current" = "1" ]; then
"$PMSET" -a disablesleep 0 || exit 1
fi
rm -f "$OWNED"
fi
exit 0
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -1262,7 +1262,7 @@ pub fn watch_lid_status(changed: impl Fn(String) + Send + 'static) {
}
#[cfg(target_os = "macos")]
pub use system::{available, blink_main, driven_elsewhere, recover_at_launch, switched_off};
pub use system::{available, blink_main, clock, driven_elsewhere, recover_at_launch, switched_off};
#[cfg(not(target_os = "macos"))]
pub fn available() -> bool {
@@ -1624,8 +1624,9 @@ mod system {
}
}
/// The local time as hours and minutes, for the status line.
fn clock() -> String {
/// The local time as hours and minutes, for the status line (the lid keeper's
/// too, lid_awake.rs).
pub fn clock() -> String {
// SAFETY: time(NULL) answers the time; localtime_r fills the struct it is
// given and answers NULL on failure.
unsafe {
+47 -82
View File
@@ -28,6 +28,9 @@ mod config;
mod credentials;
mod gui_install;
mod keep_awake;
// The pure half is only used by the macOS half and the tests.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod lid_awake;
mod local_server;
mod login;
mod logo;
@@ -190,12 +193,9 @@ enum UserEvent {
generation: u64,
text: String,
},
/// The administrator prompt for the lid helper was answered, and its command
/// ran (keep_awake::install_helper, keep_awake::remove_helper).
LidHelperDone {
install: bool,
outcome: keep_awake::AdminOutcome,
},
/// The administrator prompt for removing the lid helper of 1.50.1 was answered,
/// and its command ran (keep_awake::remove_old_helper).
OldHelperRemoved(keep_awake::AdminOutcome),
/// What the closed lid did with the logo last (logo::watch_lid_status).
LogoLidStatus(String),
}
@@ -336,8 +336,8 @@ struct App {
logo_watch: logo::Watcher,
/// The worker that keeps this Mac awake while its agents work.
keep_awake: keep_awake::KeepAwake,
/// An administrator prompt for the lid helper is open.
lid_helper_busy: bool,
/// The administrator prompt for removing the old lid helper is open.
old_helper_busy: bool,
}
fn main() {
@@ -345,6 +345,10 @@ fn main() {
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
logo::blink_main();
}
// So is the lid keeper (lid_awake.rs).
if std::env::args().nth(1).as_deref() == Some(lid_awake::LID_ARG) {
lid_awake::child_main();
}
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
@@ -375,6 +379,10 @@ fn main() {
let config_path = config_path.filter(|_| can_save);
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
logo::recover_at_launch();
// A lid keeper that was killed left lid sleep off; and 1.50.1's helper, where
// it is still installed, gets nothing to act on.
lid_awake::heal();
keep_awake::forget_legacy_request();
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
let phase = Rc::new(RefCell::new(Phase::Connect));
// Started once: the probe workers and the event loop share this queue,
@@ -421,7 +429,7 @@ fn main() {
keep_awake: keep_awake::KeepAwake::new(move |generation, text| {
let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text });
}),
lid_helper_busy: false,
old_helper_busy: false,
};
if app.menu.logo_lid.is_some() {
@@ -1010,7 +1018,7 @@ impl App {
}
}
}
UserEvent::LidHelperDone { install, outcome } => self.on_lid_helper_done(install, outcome),
UserEvent::OldHelperRemoved(outcome) => self.on_old_helper_removed(outcome),
}
}
@@ -1181,7 +1189,10 @@ impl App {
/// Keep-awake watches the open server while a mode is chosen. Whether that
/// server is this Mac is the worker's to find out.
fn sync_keep_awake(&mut self) {
let settings = self.config.keep_awake;
let mut settings = self.config.keep_awake;
// The lid item only exists on a Mac with a lid; a setting copied from one
// does nothing elsewhere.
settings.lid &= keep_awake::has_lid();
let server = self.phase.borrow().loaded().cloned();
let watch = server
.filter(|_| settings.mode != keep_awake::Mode::Off)
@@ -1222,92 +1233,45 @@ impl App {
self.sync_keep_awake();
}
/// 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) {
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 {
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.lid.as_ref()) 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),
};
// 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);
}
match prompt {
Some(install) => self.run_lid_helper(install),
None => self.sync_keep_awake(),
}
self.config.keep_awake.lid = item.is_checked();
self.config_dirty = true;
self.sync_keep_awake();
}
fn run_lid_helper(&mut self, install: bool) {
if self.lid_helper_busy {
/// Codeman 1.50.1 kept the lid awake through a root helper. Nothing uses it any
/// more, and taking it away is the one thing left that asks for the
/// administrator password. The prompt runs off the event loop.
fn remove_old_helper(&mut self) {
if self.old_helper_busy {
return;
}
self.lid_helper_busy = true;
self.old_helper_busy = true;
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let outcome = match install {
true => keep_awake::install_helper(),
false => keep_awake::remove_helper(),
};
let _ = proxy.send_event(UserEvent::LidHelperDone { install, outcome });
let _ = proxy.send_event(UserEvent::OldHelperRemoved(keep_awake::remove_old_helper()));
});
}
fn on_lid_helper_done(&mut self, install: bool, outcome: keep_awake::AdminOutcome) {
use keep_awake::{AdminOutcome, HelperState};
self.lid_helper_busy = false;
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let failure = match (&outcome, install, state) {
(AdminOutcome::Cancelled, _, _) => None,
(AdminOutcome::Failed(error), true, _) => Some(("The lid helper could not be installed.", error.clone())),
(AdminOutcome::Failed(error), false, _) => Some(("The lid helper could not be removed.", error.clone())),
(AdminOutcome::Done, true, HelperState::Installed) => {
self.config.keep_awake.lid = true;
self.config_dirty = true;
fn on_old_helper_removed(&mut self, outcome: keep_awake::AdminOutcome) {
use keep_awake::AdminOutcome;
self.old_helper_busy = false;
let failure = match (outcome, keep_awake::old_helper_installed()) {
(AdminOutcome::Cancelled, _) => None,
(AdminOutcome::Failed(error), _) => Some(error),
(AdminOutcome::Done, false) => {
if let Some(items) = &mut self.menu.keep_awake {
items.old_helper_removed();
}
None
}
(AdminOutcome::Done, true, _) => Some((
"The lid helper could not be installed.",
"The installation ran, but the helper's files are not what the app wrote.".to_owned(),
)),
(AdminOutcome::Done, false, HelperState::Missing) => {
self.config.keep_awake.lid = false;
self.config_dirty = true;
None
}
(AdminOutcome::Done, false, _) => Some((
"The lid helper could not be removed.",
"The removal ran, but the helper's files are still there.".to_owned(),
)),
(AdminOutcome::Done, true) => Some("The removal ran, but the helper's files are still there.".to_owned()),
};
if let Some(items) = &self.menu.keep_awake {
items.show_helper(state, self.config.keep_awake.lid);
}
self.keep_awake.refresh();
self.sync_keep_awake();
if let Some((title, detail)) = failure {
if let Some(detail) = failure {
let title = "The old lid helper could not be removed.";
eprintln!("codeman-desktop: {title} {detail}");
#[cfg(target_os = "macos")]
macos::show_message(title, &detail);
@@ -1326,6 +1290,7 @@ 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_OLD_HELPER_ID => self.remove_old_helper(),
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
+29 -18
View File
@@ -4,7 +4,7 @@ use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::config::Config;
use crate::keep_awake::{self, HelperState, Mode};
use crate::keep_awake::{self, Mode};
use crate::logo::{self, BlinkStyle, Signal};
use crate::{APP_NAME, VERSION};
@@ -41,23 +41,26 @@ pub struct AppMenu {
}
/// 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.
/// 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.
/// No password behind it (lid_awake.rs).
pub lid: Option<CheckMenuItem>,
/// Only while the root helper Codeman 1.50.1 installed for the lid is still on
/// this Mac. Removing it asks for the administrator password, hence the ellipsis.
remove_old_helper: Option<MenuItem>,
submenu: Submenu,
/// What it does right now (keep_awake::status_line). Not clickable.
pub status: MenuItem,
}
impl KeepAwakeMenu {
/// 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_checked(lid_on && state == HelperState::Installed);
/// The old helper is gone, and with it the item that removes it.
pub fn old_helper_removed(&mut self) {
if let Some(item) = self.remove_old_helper.take() {
let _ = self.submenu.remove(&item);
}
}
}
@@ -73,8 +76,6 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
}
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 When Plugged In",
@@ -82,16 +83,24 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
settings.ac_only,
None,
);
let lid_item = CheckMenuItem::with_id(
keep_awake::LID_ID,
"With the Lid Closed\u{2026}",
true,
settings.lid && state == HelperState::Installed,
None,
);
let lid_item = CheckMenuItem::with_id(keep_awake::LID_ID, "With the Lid Closed", true, settings.lid, None);
submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item])?;
(ac_only, lid) = (Some(ac), Some(lid_item));
}
let mut remove_old_helper = None;
if keep_awake::old_helper_installed() {
let item = MenuItem::with_id(
keep_awake::REMOVE_OLD_HELPER_ID,
"Remove the Old Lid Helper\u{2026}",
true,
None,
);
if lid.is_none() {
submenu.append(&PredefinedMenuItem::separator())?;
}
submenu.append(&item)?;
remove_old_helper = Some(item);
}
let initial = match settings.mode {
Mode::Off => keep_awake::OFF_TEXT,
_ => keep_awake::NO_SERVER_TEXT,
@@ -99,11 +108,13 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
let status = MenuItem::new(initial, false, None);
submenu.append_items(&[&PredefinedMenuItem::separator(), &status])?;
Ok((
submenu,
submenu.clone(),
KeepAwakeMenu {
modes,
ac_only,
lid,
remove_old_helper,
submenu,
status,
},
))
-234
View File
@@ -1,234 +0,0 @@
/**
* @fileoverview Runs the real lid helper of the Codeman app for Mac
* (packages/desktop/macos/keep-awake-lid.sh) in bash against a stub `pmset` that
* records what it was asked to do. The helper runs as root from a LaunchDaemon, so the
* cases that matter are the ones where it must NOT act: a stale, dead, foreign or
* symlinked request, a Mac on battery, and an administrator's own `disablesleep 1`,
* which it must never switch off.
*
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
* here, so this passes on Linux and macOS alike. The app embeds the same file
* (include_str! in packages/desktop/src/keep_awake.rs), so this is the script that
* gets installed. Port: none.
*/
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const HELPER = fileURLToPath(new URL('../packages/desktop/macos/keep-awake-lid.sh', import.meta.url));
let dir: string;
let stateDir: string;
let request: string;
/**
* Stub pmset: `-g` prints the stored SleepDisabled, `-g batt` the stored power source,
* `-a disablesleep N` stores N and logs it.
*/
function writeStubPmset(initial: '0' | '1' | null, power: 'AC Power' | 'Battery Power' | null = 'AC Power') {
const value = join(dir, 'sleepdisabled');
if (initial !== null) writeFileSync(value, initial);
const source = join(dir, 'power');
if (power !== null) writeFileSync(source, power);
const stub = join(dir, 'pmset');
writeFileSync(
stub,
`#!/bin/bash
if [ "$1" = "-g" ] && [ "$2" = "batt" ]; then
[ -f "${source}" ] || exit 1
echo "Now drawing from '$(cat "${source}")'"
exit 0
fi
if [ "$1" = "-g" ]; then
echo "System-wide power settings:"
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
echo "Currently in use:"
echo " sleep 1"
exit 0
fi
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
printf '%s' "$3" > "${value}"
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
exit 0
fi
exit 1
`
);
chmodSync(stub, 0o755);
return stub;
}
const setPower = (power: 'AC Power' | 'Battery Power') => writeFileSync(join(dir, 'power'), power);
function run() {
const result = spawnSync('bash', [HELPER, request], {
encoding: 'utf-8',
env: {
...process.env,
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
},
});
expect(result.status, result.stderr).toBe(0);
}
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
/** What the app writes: its own pid and a newline. This test process stands in for it. */
const requestFromLiveApp = () => writeFileSync(request, `${process.pid}\n`);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'keep-awake-lid-'));
stateDir = join(dir, 'state');
request = join(dir, 'keep-awake-lid.pid');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('keep-awake-lid.sh', () => {
it('does nothing without a request', () => {
writeStubPmset('0');
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
});
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
run(); // steady state: no repeated pmset call
expect(calls()).toBe('disablesleep 1\n');
rmSync(request);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
});
it('never acts on battery, and lets go when the adapter is pulled', () => {
writeStubPmset('0', 'Battery Power');
requestFromLiveApp();
run();
expect(calls()).toBe('');
setPower('AC Power');
run();
expect(sleepDisabled()).toBe('1');
setPower('Battery Power');
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('reads an unanswerable power source as battery', () => {
writeStubPmset('0', null);
requestFromLiveApp();
run();
expect(calls()).toBe('');
});
it("never switches off an administrator's own disablesleep", () => {
writeStubPmset('1');
requestFromLiveApp();
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
rmSync(request);
run();
expect(sleepDisabled()).toBe('1');
expect(calls()).toBe('');
});
it("leaves an administrator's own disablesleep on when the adapter is pulled", () => {
writeStubPmset('1', 'Battery Power');
requestFromLiveApp();
run();
expect(calls()).toBe('');
expect(sleepDisabled()).toBe('1');
});
it('treats an absent SleepDisabled line as 0', () => {
writeStubPmset(null);
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
});
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
run();
expect(sleepDisabled()).toBe('1');
});
it('ignores a stale request (an app that hangs) and releases', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('releases at the next run once the app is gone, however fresh its file', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('ignores a request whose pid is not running', () => {
writeStubPmset('0');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(calls()).toBe('');
});
it('ignores garbage and refuses a symlinked request', () => {
writeStubPmset('0');
writeFileSync(request, 'not-a-pid\n');
run();
expect(calls()).toBe('');
rmSync(request);
const real = join(dir, 'elsewhere.pid');
writeFileSync(real, `${process.pid}\n`);
symlinkSync(real, request);
run();
expect(calls()).toBe('');
});
});