From be8b96fa5d01e96a53b37a80d60a628bbfa407fe Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 11 Nov 2026 10:06:00 +0100 Subject: [PATCH] feat(desktop): keep a closed MacBook awake without the administrator password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep This Mac Awake > With the Lid Closed is a plain switch now. 1.50.1 installed a root LaunchDaemon behind the administrator prompt, which set `pmset disablesleep` while the app asked for it. The app now turns off lid sleep in the kernel's power management root itself (`kPMSetClamshellSleepState`, a call any process may make), the method LogoLight ships as Lit with Lid Closed. That switch has no owner (read from Apple's sources of the kernel and of powerd): the kernel keeps it after the process that set it has gone and honours it on battery, macOS's power daemon and LogoLight write the same bit, and clearing it under a closed lid puts the Mac to sleep at once. On an Apple silicon Mac it is also what keeps a docked MacBook awake with its lid closed, set by the power daemon once, when a display comes. So a child process holds it (`src/lid_awake.rs`, this binary with `--keep-awake-lid`): - it sets the switch again every second and holds two assertions with it (system sleep and idle sleep), the three LogoLight measured together; - it reads the power source itself and holds nothing on battery; - it writes nothing at all while an external display is connected: a zero from here would put a docked Mac to sleep, or leave it without its closed-lid mode until the display is plugged in again. What the child set before the display came is cleared once the display has been gone for five seconds, and the child outlives the app for that if it has to; - it lets go when the pipe from the app closes (quit, crash, Force Quit) and when the app has not written for a minute (a hang), the assertions first, then the switch; - it ignores SIGHUP, SIGINT and SIGTERM, so the pipe is its one way out; - it leaves the switch alone at its end while LogoLight holds it for itself (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 the file is non-empty while the switch may be set. A child that was killed leaves it marked and unlocked: the app turns lid sleep back on when it sees a child end and at its next launch, and the next child takes the mark over. The status line says what came of it: the kernel took the call, the lid is left to macOS at a display, on battery, refused, or that the Mac slept under the lid all the same and until when. A display has its own wording so a Mac that sleeps there never reads as covered. The helper of 1.50.1 is no longer installed or used. Its request file is never written and is deleted at launch, so a helper that is still there does nothing. While it is installed the menu has Remove the Old Lid Helper…, the one item left behind the administrator prompt. `macos/keep-awake-lid.sh` and its vitest suite go with the install path. Checked on a Mac mini (macOS 27.2, no lid, no display): 276 tests, clippy and fmt; the kernel takes the call from an unprivileged process; the real child holds its assertions, lets go on a closed pipe and after a minute of silence, ignores SIGTERM, waits for a child before it, writes nothing when told a display is connected, and a SIGKILLed child is replaced by the app and its mark undone. 34 deliberate breaks of these rules each fail a test. Not run yet: the app itself with this change (the mini has no session to open it in), a real closed lid, the menu items, and the removal of the old helper. Co-Authored-By: Claude Fable 5.2 --- .changeset/lid-awake-no-password.md | 5 + docs/wiki/Installation.md | 8 +- packages/desktop/README.md | 123 +- packages/desktop/macos/keep-awake-lid.sh | 74 - packages/desktop/src/keep_awake.rs | 713 +++------ packages/desktop/src/lid_awake.rs | 1745 ++++++++++++++++++++++ packages/desktop/src/logo.rs | 7 +- packages/desktop/src/main.rs | 129 +- packages/desktop/src/menu.rs | 47 +- test/desktop-keep-awake-lid.test.ts | 234 --- 10 files changed, 2164 insertions(+), 921 deletions(-) create mode 100644 .changeset/lid-awake-no-password.md delete mode 100644 packages/desktop/macos/keep-awake-lid.sh create mode 100644 packages/desktop/src/lid_awake.rs delete mode 100644 test/desktop-keep-awake-lid.test.ts diff --git a/.changeset/lid-awake-no-password.md b/.changeset/lid-awake-no-password.md new file mode 100644 index 00000000..5bad515e --- /dev/null +++ b/.changeset/lid-awake-no-password.md @@ -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. diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index f62b9938..73603fa0 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -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 diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 9fd1e4bb..ab780aa6 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -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 diff --git a/packages/desktop/macos/keep-awake-lid.sh b/packages/desktop/macos/keep-awake-lid.sh deleted file mode 100644 index f545d5fc..00000000 --- a/packages/desktop/macos/keep-awake-lid.sh +++ /dev/null @@ -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 -# 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 }" -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 diff --git a/packages/desktop/src/keep_awake.rs b/packages/desktop/src/keep_awake.rs index a1a62773..5246fd63 100644 --- a/packages/desktop/src/keep_awake.rs +++ b/packages/desktop/src/keep_awake.rs @@ -1,24 +1,23 @@ //! Keep This Mac Awake: while Codeman's agents work on this Mac, the app keeps the -//! Mac from going to sleep, and with a small root helper also with the lid closed. -//! Opt-in (app menu > Keep This Mac Awake), off by default. +//! Mac from going to sleep, also with the lid closed. Opt-in (app menu > Keep This +//! Mac Awake), off by default, and no password anywhere. //! //! Two layers, because macOS draws a line between them: //! -//! - **Idle sleep**, no password: the app holds a power assertion of its own -//! (`NSProcessInfo` activity with `IdleSystemSleepDisabled`, which is -//! `PreventUserIdleSystemSleep` in `pmset -g assertions`). The kernel drops it -//! with the process, so a crash or a Force Quit can never leave the Mac awake. -//! The display still sleeps. -//! - **Lid close**, once behind macOS's administrator prompt: nothing an app holds -//! stops a MacBook from sleeping when its lid closes. Only `pmset -a disablesleep 1` -//! does, a machine-wide root setting with no owner. So a LaunchDaemon runs -//! `macos/keep-awake-lid.sh` every 20 seconds, and it sets that only while this app -//! keeps a fresh request file holding its live pid, and only on the power adapter. -//! An app that quits or crashes is a dead pid, so the lid is released at the next -//! run (measured on a Mac mini with the same script: the next tick, both after a -//! clean stop and after a SIGKILL). The app writes the helper itself, to a -//! root-owned folder, from the copy built into it (`include_str!`), never from a -//! file a user could edit. +//! - **Idle sleep**: the app holds a power assertion of its own (`NSProcessInfo` +//! activity with `IdleSystemSleepDisabled`, which is `PreventUserIdleSystemSleep` +//! in `pmset -g assertions`). The kernel drops it with the process, so a crash or a +//! Force Quit can never leave the Mac awake. The display still sleeps. +//! - **Lid close**: nothing an app holds stops a MacBook from sleeping when its lid +//! closes, but one switch in the kernel does (lid sleep off), and any process may +//! set it. It has no owner and outlives the process that set it, so a child process +//! holds it and always lets go (`lid_awake`): when the app stops asking, when the +//! app ends however it ends, when the app hangs, and on battery whatever the app +//! says. Codeman 1.50.1 did this job with a root helper behind the administrator +//! password (`pmset disablesleep`). Nothing uses that helper any more: it only acts +//! on a request file this version never writes, and the menu offers to remove it +//! where it is still installed (`remove_command`, the one thing left that asks for +//! the password). //! //! 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 @@ -28,9 +27,7 @@ //! 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. +//! (Only When Plugged In, With the Lid Closed). //! //! 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 it watches @@ -43,44 +40,36 @@ //! so a hidden app does not see work late. use crate::agents::{Poll, Poller, POLL_EVERY}; -use base64::Engine as _; +use crate::lid_awake::Lid; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::net::IpAddr; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::time::{Duration, Instant}; use url::{Host, Url}; -/// How often the worker looks: at the agents (While Agents Work), the power source -/// and the lid request. +/// How often the worker looks: at the agents (While Agents Work) and the power +/// source. Each look is also the word the lid keeper waits for (`lid_awake`). const TICK: Duration = POLL_EVERY; /// How long the Mac stays awake after the last agent stopped working: the gap /// between two turns, a respawn, a short Bash call the server reports as idle, and /// time to answer from the phone. pub const GRACE: Duration = Duration::from_secs(5 * 60); -/// The helper honours a request file younger than 2 minutes (`find -mmin -2`); the -/// app rewrites it this often while it wants the lid covered. -const LID_REFRESH: Duration = Duration::from_secs(30); -/// How often the worker reads whether the lid is covered (`pmset -g`, a process -/// spawn) and whether the helper is installed, while it asks for the lid. The helper -/// itself runs every 20 seconds. -const LID_CHECK: Duration = Duration::from_secs(20); -pub const HELPER_LABEL: &str = "com.getcodeman.keepawake"; -pub const HELPER_PLIST: &str = "/Library/LaunchDaemons/com.getcodeman.keepawake.plist"; -pub const HELPER_DIR: &str = "/Library/Application Support/Codeman"; -pub const HELPER_SCRIPT: &str = "/Library/Application Support/Codeman/keep-awake-lid.sh"; +// The root helper Codeman 1.50.1 installed for the lid, only named here to find it +// and to remove it. +const HELPER_LABEL: &str = "com.getcodeman.keepawake"; +const HELPER_PLIST: &str = "/Library/LaunchDaemons/com.getcodeman.keepawake.plist"; +const HELPER_DIR: &str = "/Library/Application Support/Codeman"; +const HELPER_SCRIPT: &str = "/Library/Application Support/Codeman/keep-awake-lid.sh"; const HELPER_OWNED: &str = "/Library/Application Support/Codeman/keep-awake.owned"; -const DAEMONS_DIR: &str = "/Library/LaunchDaemons"; -/// The helper exactly as it is installed; `test/desktop-keep-awake-lid.test.ts` runs -/// this file against a stub `pmset`. -const HELPER_SOURCE: &str = include_str!("../macos/keep-awake-lid.sh"); -/// Next to `desktop.json`. Its path is written into the helper's plist at install. -const REQUEST_FILE: &str = "keep-awake-lid.pid"; +/// Where 1.50.1 asked its helper for the lid, next to `desktop.json`. +const LEGACY_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_OLD_HELPER_ID: &str = "keep-awake-remove-old-helper"; /// The status line while nothing is watched. pub const OFF_TEXT: &str = "Sleeps as usual"; @@ -88,9 +77,8 @@ 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, so this Mac sleeps again when the lid closes."; +const REMOVE_PROMPT: &str = "Codeman wants to remove the lid helper that version 1.50.1 installed. It is no \ +longer needed: Codeman keeps a closed Mac awake without it now."; /// When the Mac is kept awake. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -135,7 +123,7 @@ pub struct Settings { /// Only while the Mac runs on its power adapter (a MacBook; a desktop Mac always /// does). On by default. pub ac_only: bool, - /// Also with the lid closed, through the root helper. Never on battery. + /// Also with the lid closed (`lid_awake`). Never on battery. pub lid: bool, } @@ -186,8 +174,6 @@ pub struct Facts { pub local: bool, pub on_battery: bool, pub agents: Agents, - /// The lid helper is installed as this version of the app installs it. - pub helper: bool, } /// Why the Mac is or is not held, which the status line says. @@ -224,7 +210,7 @@ impl Status { pub struct Decision { /// Hold the idle-sleep assertion. pub hold: bool, - /// Ask the helper to keep the Mac awake with the lid closed. + /// Keep the Mac awake with the lid closed as well. pub lid: bool, pub status: Status, } @@ -247,28 +233,32 @@ pub fn decide(settings: Settings, facts: Facts) -> Decision { let hold = matches!(status, Status::Working | Status::Recently | Status::Always); Decision { hold, - lid: hold && settings.lid && facts.helper && !facts.on_battery, + lid: hold && settings.lid && !facts.on_battery, status, } } -/// The line under the menu's choices. `covered` is what `pmset -g` says about -/// `SleepDisabled` while the lid is asked for. -pub fn status_line(decision: Decision, settings: Settings, facts: Facts, covered: Option) -> String { +/// The line under the menu's choices. `lid` is what the lid keeper said on this +/// look, while the lid is asked for. +pub fn status_line(decision: Decision, settings: Settings, facts: Facts, lid: &Lid) -> String { let text = decision.status.text(); if !decision.hold || !settings.lid { return text.to_owned(); } - // 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 { - "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) { - "lid closed too" - } else { - "lid closed in a moment" + let lid = match lid { + _ if facts.on_battery => "not with the lid closed on battery".to_owned(), + Lid::Held { slept: None } => "lid closed too".to_owned(), + // It held and the Mac slept anyway (macOS forced it, or let go of the switch). + Lid::Held { slept: Some(until) } => format!("lid closed too, but the Mac slept until {until}"), + // Nothing of Codeman's holds the lid: macOS's own closed-lid mode is left to. + Lid::Docked { slept: None } => "lid left to macOS at a display".to_owned(), + Lid::Docked { slept: Some(until) } => { + format!("lid left to macOS at a display, but the Mac slept until {until}") + } + Lid::Off | Lid::Starting => "lid closed in a moment".to_owned(), + Lid::OnBattery => "not with the lid closed on battery".to_owned(), + Lid::Refused => "not with the lid closed: macOS refused it".to_owned(), + Lid::Failed => "not with the lid closed: it could not start".to_owned(), }; format!("{text}, {lid}") } @@ -343,12 +333,9 @@ pub enum Hold { pub trait System { fn on_battery(&mut self) -> bool; fn set_hold(&mut self, hold: Hold); - fn helper_installed(&mut self) -> bool; - /// Write the lid request, or freshen it; false when it could not be written. - fn write_request(&mut self) -> bool; - fn clear_request(&mut self); - /// `SleepDisabled` as `pmset -g` reports it; None when it could not be read. - fn lid_covered(&mut self) -> Option; + /// Ask for the lid, or stop asking, and what came of it. Called on every look: + /// while it is asked for, the call is also the word the lid keeper waits for. + fn lid(&mut self, wanted: bool) -> Lid; fn is_this_mac(&mut self, server: &Url) -> bool; } @@ -365,11 +352,6 @@ struct Worker { refused: bool, working: Option, last_work: Option, - /// The helper's state and when it was read. - helper: Option<(bool, Instant)>, - /// When the lid request was last written, while the app asks for the lid. - request_at: Option, - covered: Option<(Option, Instant)>, hold: Hold, status: Option, } @@ -385,9 +367,6 @@ impl Worker { refused: false, working: None, last_work: None, - helper: None, - request_at: None, - covered: None, hold: Hold::None, status: None, } @@ -411,9 +390,6 @@ impl Worker { }; } self.watch = watch; - // Read again: the settings may ask for the lid now, or an install just ran. - self.helper = None; - self.covered = None; // The app numbers its values, so the line goes out again under the new one. self.status = None; } @@ -440,7 +416,6 @@ impl Worker { local: self.local, on_battery: self.local && settings.mode != Mode::Off && self.sys.on_battery(), agents, - helper: settings.lid && self.helper_installed(now), }; let decision = decide(settings, facts); let watching = self.local && settings.mode == Mode::WhileWorking && !self.refused; @@ -449,8 +424,8 @@ impl Worker { (false, true) => Hold::Watching, (false, false) => Hold::None, }); - let covered = self.lid(decision.lid, now); - let line = status_line(decision, settings, facts, covered); + let lid = self.sys.lid(decision.lid); + let line = status_line(decision, settings, facts, &lid); (self.status.as_deref() != Some(line.as_str())).then(|| { self.status = Some(line.clone()); line @@ -492,17 +467,6 @@ impl Worker { } } - fn helper_installed(&mut self, now: Instant) -> bool { - match self.helper { - Some((installed, at)) if now.duration_since(at) < LID_CHECK => installed, - _ => { - let installed = self.sys.helper_installed(); - self.helper = Some((installed, now)); - installed - } - } - } - fn set_hold(&mut self, hold: Hold) { if hold != self.hold { self.sys.set_hold(hold); @@ -510,35 +474,10 @@ impl Worker { } } - /// Ask for the lid or stop asking, and what `pmset` says while asking. - fn lid(&mut self, wanted: bool, now: Instant) -> Option { - if !wanted { - if self.request_at.take().is_some() { - self.sys.clear_request(); - } - self.covered = None; - return None; - } - if self.request_at.is_none_or(|at| now.duration_since(at) >= LID_REFRESH) && self.sys.write_request() { - self.request_at = Some(now); - } - match self.covered { - Some((covered, at)) if now.duration_since(at) < LID_CHECK => covered, - _ => { - let covered = self.sys.lid_covered(); - self.covered = Some((covered, now)); - covered - } - } - } - /// Let go of everything: the app went away or watches nothing. fn release(&mut self) { self.set_hold(Hold::None); - if self.request_at.take().is_some() { - self.sys.clear_request(); - } - self.covered = None; + self.sys.lid(false); } } @@ -600,14 +539,6 @@ impl KeepAwake { } } - /// Look again at once, under a new number: an install or a removal of the helper - /// just ran. - pub fn refresh(&mut self) { - if self.current.is_some() { - self.send(); - } - } - /// Whether a status line is about the latest value. pub fn is_current(&self, generation: u64) -> bool { generation == self.generation && self.current.is_some() @@ -623,14 +554,10 @@ impl KeepAwake { } } - /// The app is about to exit. The power assertion goes with the process; the lid - /// request is removed here so the helper lets go at its next run without having to - /// find the pid dead. + /// The app is about to exit: the worker ends and lets go. The power assertion + /// and the pipe to the lid keeper go with the process in any case. pub fn shutdown(&mut self) { self.sender = None; - if let Some(path) = request_path() { - clear_request_file(&path); - } } } @@ -644,7 +571,7 @@ fn spawn(report: Report) -> Option)>> { Poller::new(&watch.server, watch.authorization.clone(), false) .map(|poller| Box::new(poller) as Box) }); - let worker = Worker::new(system::Mac::new(request_path()), new_source); + let worker = Worker::new(system::Mac::new(), new_source); run(&rx, worker, &*report); }) .ok()?; @@ -661,142 +588,30 @@ pub fn available() -> bool { cfg!(target_os = "macos") } -/// Where the app asks the helper for the lid: next to `desktop.json`. -pub fn request_path() -> Option { - crate::config::Config::default_path().map(|path| path.with_file_name(REQUEST_FILE)) +/// Whether the root helper Codeman 1.50.1 installed for the lid is still on this +/// Mac. Anyone may look. +pub fn old_helper_installed() -> bool { + old_helper_under("") } -fn write_request_file(path: &Path) -> bool { - let write = || -> std::io::Result<()> { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; - } - // Written aside and renamed in: the helper never reads half a pid. - let aside = path.with_extension("pid.new"); - std::fs::write(&aside, format!("{}\n", std::process::id()))?; - std::fs::rename(&aside, path) - }; - write().is_ok() +fn old_helper_under(root: &str) -> bool { + [HELPER_PLIST, HELPER_SCRIPT] + .iter() + .any(|path| Path::new(&format!("{root}{path}")).exists()) } -/// Removes the request only while it is this process's, so a second copy of the app -/// (`open -n`) is not cut off. -fn clear_request_file(path: &Path) { - let ours = std::fs::read_to_string(path).is_ok_and(|text| text.trim() == std::process::id().to_string()); - if ours { - let _ = std::fs::remove_file(path); +/// Removes the request file 1.50.1 kept for its helper, so a helper that is still +/// installed has nothing to act on (it honours a file younger than 2 minutes that +/// names a live process). +pub fn forget_legacy_request() { + if let Some(path) = crate::config::Config::default_path() { + let _ = std::fs::remove_file(path.with_file_name(LEGACY_REQUEST_FILE)); } } -/// Whether `pmset -g` says sleep is disabled. A missing line is 0, as the helper reads it. -pub fn sleep_disabled(pmset_g: &str) -> bool { - pmset_g.lines().any(|line| { - let mut words = line.split_whitespace(); - words.next() == Some("SleepDisabled") && words.next() == Some("1") - }) -} - -fn xml_escape(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -/// The helper's LaunchDaemon: the installed script, with this user's request file, every -/// 20 seconds and once at load (and so at every boot, which undoes a `disablesleep` a -/// power cut left behind). -pub fn plist_text(request: &Path) -> String { - format!( - r#" - - - - Label - {HELPER_LABEL} - ProgramArguments - - /bin/bash - {script} - {request} - - StartInterval - 20 - RunAtLoad - - - -"#, - script = xml_escape(HELPER_SCRIPT), - request = xml_escape(&request.to_string_lossy()), - ) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HelperState { - Missing, - /// Exactly as this version of the app installs it, for this user. - Installed, - /// From another version of the app, or another user's. - Outdated, -} - -/// Read from the installed files, which anyone may read. -pub fn helper_state(request: &Path) -> HelperState { - helper_state_under("", request) -} - -fn helper_state_under(root: &str, request: &Path) -> HelperState { - let Ok(plist) = std::fs::read_to_string(format!("{root}{HELPER_PLIST}")) else { - return HelperState::Missing; - }; - let script = std::fs::read_to_string(format!("{root}{HELPER_SCRIPT}")).unwrap_or_default(); - if plist == plist_text(request) && script == HELPER_SOURCE { - HelperState::Installed - } else { - HelperState::Outdated - } -} - -fn base64(text: &str) -> String { - base64::engine::general_purpose::STANDARD.encode(text) -} - -/// The root shell command that installs (or replaces) the helper. The two files go in -/// as base64 written by root straight into root-owned folders, so there is no copy a -/// user could swap between the prompt and the write, and no file content ever needs -/// quoting. `root` is empty, except in tests, which run it in a temporary folder. -pub fn install_command(root: &str, request: &Path) -> String { - let dir = format!("{root}{HELPER_DIR}"); - let daemons = format!("{root}{DAEMONS_DIR}"); - let script = format!("{root}{HELPER_SCRIPT}"); - let plist = format!("{root}{HELPER_PLIST}"); - [ - "set -e".to_owned(), - "umask 022".to_owned(), - format!("mkdir -p '{dir}' '{daemons}'"), - format!("chown root:wheel '{dir}'"), - format!("chmod 755 '{dir}'"), - format!("echo {} | base64 --decode > '{script}.new'", base64(HELPER_SOURCE)), - format!("chown root:wheel '{script}.new'"), - format!("chmod 755 '{script}.new'"), - format!("mv -f '{script}.new' '{script}'"), - format!( - "echo {} | base64 --decode > '{plist}.new'", - base64(&plist_text(request)) - ), - format!("chown root:wheel '{plist}.new'"), - format!("chmod 644 '{plist}.new'"), - format!("mv -f '{plist}.new' '{plist}'"), - format!("launchctl bootout system/{HELPER_LABEL} 2>/dev/null || true"), - format!("launchctl bootstrap system '{plist}'"), - ] - .join("; ") -} - -/// The root shell command that removes the helper: stop it, undo a `disablesleep` it set -/// (its marker; an administrator's own setting is left alone), delete its files. +/// The root shell command that removes 1.50.1's helper: stop it, undo a `disablesleep` +/// it set (its marker; an administrator's own setting is left alone), delete its files. +/// `root` is empty, except in tests, which run it in a temporary folder. pub fn remove_command(root: &str) -> String { let dir = format!("{root}{HELPER_DIR}"); let script = format!("{root}{HELPER_SCRIPT}"); @@ -826,16 +641,8 @@ pub enum AdminOutcome { Failed(String), } -/// Install the helper behind the administrator prompt. Blocks until it is answered. -pub fn install_helper() -> AdminOutcome { - let Some(request) = request_path() else { - return AdminOutcome::Failed("The app has no settings folder for the request file.".into()); - }; - run_admin(&install_command("", &request), INSTALL_PROMPT) -} - -/// Remove the helper behind the administrator prompt. Blocks until it is answered. -pub fn remove_helper() -> AdminOutcome { +/// Remove 1.50.1's helper behind the administrator prompt. Blocks until it is answered. +pub fn remove_old_helper() -> AdminOutcome { run_admin(&remove_command(""), REMOVE_PROMPT) } @@ -865,7 +672,7 @@ fn run_admin(command: &str, prompt: &str) -> AdminOutcome { } #[cfg(target_os = "macos")] -pub use system::has_lid; +pub use system::{has_lid, on_battery}; /// A desktop Mac has no lid and always runs on mains. #[cfg(not(target_os = "macos"))] @@ -875,13 +682,13 @@ pub fn has_lid() -> bool { #[cfg(target_os = "macos")] mod system { - use super::{clear_request_file, helper_state, sleep_disabled, write_request_file, HelperState, Hold, System}; + use super::{Hold, Lid, System}; + use crate::lid_awake::Holder; use objc2::rc::Retained; use objc2::runtime::{NSObjectProtocol, ProtocolObject}; use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; use std::ffi::{c_char, c_void}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - use std::path::PathBuf; use std::sync::OnceLock; use url::Url; @@ -910,8 +717,8 @@ mod system { } /// "Battery Power" as the source in use. "AC Power", "UPS Power" and no answer - /// all count as mains here; the helper asks `pmset` for itself before the lid. - fn on_battery() -> bool { + /// all count as mains. The lid keeper reads the same for itself. + pub fn on_battery() -> bool { // SAFETY: IOPSCopyPowerSourcesInfo returns an owned CFTypeRef or NULL, released // below. IOPSGetProvidingPowerSourceType returns a CFStringRef owned by that // snapshot (the Get rule), read before the release; a CFString is toll-free @@ -993,14 +800,14 @@ mod system { pub struct Mac { activity: Option>>, - request: Option, + lid: Holder, } impl Mac { - pub fn new(request: Option) -> Self { + pub fn new() -> Self { Mac { activity: None, - request, + lid: Holder::default(), } } } @@ -1025,27 +832,8 @@ mod system { } } - fn helper_installed(&mut self) -> bool { - self.request - .as_deref() - .is_some_and(|request| helper_state(request) == HelperState::Installed) - } - - fn write_request(&mut self) -> bool { - self.request.as_deref().is_some_and(write_request_file) - } - - fn clear_request(&mut self) { - if let Some(path) = &self.request { - clear_request_file(path); - } - } - - fn lid_covered(&mut self) -> Option { - let out = std::process::Command::new("/usr/bin/pmset").arg("-g").output().ok()?; - out.status - .success() - .then(|| sleep_disabled(&String::from_utf8_lossy(&out.stdout))) + fn lid(&mut self, wanted: bool) -> Lid { + self.lid.hold(wanted) } fn is_this_mac(&mut self, server: &Url) -> bool { @@ -1055,6 +843,7 @@ mod system { impl Drop for Mac { fn drop(&mut self) { + // The pipe to the lid keeper closes with the `Holder`. self.set_hold(Hold::None); } } @@ -1072,7 +861,7 @@ mod system { println!("has a lid: {}", has_lid()); println!("on battery: {}", on_battery()); println!("addresses: {:?}", local_addresses()); - let mut mac = Mac::new(None); + let mut mac = Mac::new(); mac.set_hold(Hold::Holding); println!("holding for 15 s"); std::thread::sleep(std::time::Duration::from_secs(15)); @@ -1081,31 +870,24 @@ mod system { std::thread::sleep(std::time::Duration::from_secs(15)); mac.set_hold(Hold::None); println!("let go"); - println!("lid covered: {:?}", mac.lid_covered()); } /// The exact AppleScript the app runs, compiled by macOS without running it /// (no prompt, nothing as root). Also in the gate, since it needs no hardware. #[test] - fn the_admin_scripts_compile() { - use super::super::{applescript, install_command, remove_command, INSTALL_PROMPT, REMOVE_PROMPT}; - let request = std::path::Path::new("/Users/x/Library/Application Support/Codeman/keep-awake-lid.pid"); + fn the_admin_script_compiles() { + use super::super::{applescript, remove_command, REMOVE_PROMPT}; let out_dir = std::env::temp_dir().join(format!("keep-awake-osa-{}", std::process::id())); std::fs::create_dir_all(&out_dir).unwrap(); - for (name, command, prompt) in [ - ("install", install_command("", request), INSTALL_PROMPT), - ("remove", remove_command(""), REMOVE_PROMPT), - ] { - let script = applescript(&command, prompt).unwrap(); - let out = std::process::Command::new("/usr/bin/osacompile") - .arg("-e") - .arg(&script) - .arg("-o") - .arg(out_dir.join(format!("{name}.scpt"))) - .output() - .unwrap(); - assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); - } + let script = applescript(&remove_command(""), REMOVE_PROMPT).unwrap(); + let out = std::process::Command::new("/usr/bin/osacompile") + .arg("-e") + .arg(&script) + .arg("-o") + .arg(out_dir.join("remove.scpt")) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); let _ = std::fs::remove_dir_all(&out_dir); } @@ -1200,7 +982,6 @@ mod tests { local: true, on_battery: false, agents: Agents::Working, - helper: false, }; #[test] @@ -1330,18 +1111,16 @@ mod tests { } #[test] - fn the_lid_needs_the_setting_the_helper_mains_and_a_hold() { + fn the_lid_needs_the_setting_mains_and_a_hold() { let lid = Settings { lid: true, ..WORK }; - let ready = Facts { helper: true, ..HERE }; - assert!(decide(lid, ready).lid); - assert!(!decide(WORK, ready).lid, "setting off"); - assert!(!decide(lid, HERE).lid, "no helper"); + assert!(decide(lid, HERE).lid); + assert!(!decide(WORK, HERE).lid, "setting off"); assert!( !decide( lid, Facts { agents: Agents::Idle, - ..ready + ..HERE } ) .lid, @@ -1351,7 +1130,7 @@ mod tests { let anywhere = Settings { ac_only: false, ..lid }; let battery = Facts { on_battery: true, - ..ready + ..HERE }; assert!(decide(anywhere, battery).hold); assert!(!decide(anywhere, battery).lid); @@ -1360,34 +1139,64 @@ mod tests { #[test] fn the_status_line_says_what_the_lid_does() { 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 working"); - assert_eq!(line(lid, ready, Some(true)), "Awake: agents working, lid closed too"); + let held = Lid::Held { slept: None }; + let line = |settings, facts, lid: &Lid| status_line(decide(settings, facts), settings, facts, lid); + assert_eq!(line(WORK, HERE, &Lid::Off), "Awake: agents working"); + assert_eq!(line(lid, HERE, &held), "Awake: agents working, lid closed too"); assert_eq!( - line(lid, ready, Some(false)), + line(lid, HERE, &Lid::Starting), "Awake: agents working, lid closed in a moment" ); + // It held and the Mac slept all the same: the line is the evidence. + let slept = Lid::Held { + slept: Some("07:42".into()), + }; + assert_eq!( + line(lid, HERE, &slept), + "Awake: agents working, lid closed too, but the Mac slept until 07:42" + ); + // At a display Codeman writes nothing, and the line must not read as if it did. + assert_eq!( + line(lid, HERE, &Lid::Docked { slept: None }), + "Awake: agents working, lid left to macOS at a display" + ); + let slept_docked = Lid::Docked { + slept: Some("07:42".into()), + }; + assert_eq!( + line(lid, HERE, &slept_docked), + "Awake: agents working, lid left to macOS at a display, but the Mac slept until 07:42" + ); + assert_eq!( + line(lid, HERE, &Lid::Refused), + "Awake: agents working, not with the lid closed: macOS refused it" + ); + assert_eq!( + line(lid, HERE, &Lid::Failed), + "Awake: agents working, not with the lid closed: it could not start" + ); + // The app's reading of the power source, and the keeper's own. let battery = Facts { on_battery: true, - ..ready + ..HERE }; let anywhere = Settings { ac_only: false, ..lid }; + for said in [&Lid::Off, &held] { + assert_eq!( + line(anywhere, battery, said), + "Awake: agents working, not with the lid closed on battery" + ); + } assert_eq!( - line(anywhere, battery, None), + line(lid, HERE, &Lid::OnBattery), "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 working, not with the lid closed: turn it on again" - ); // Not held: nothing about the lid. let idle = Facts { agents: Agents::Idle, - ..ready + ..HERE }; - assert_eq!(line(lid, idle, None), "Sleeps as usual: no agent working"); + assert_eq!(line(lid, idle, &held), "Sleeps as usual: no agent working"); } #[test] @@ -1405,50 +1214,20 @@ mod tests { } #[test] - fn pmset_says_whether_sleep_is_disabled() { - let on = "System-wide power settings:\n SleepDisabled\t\t1\nCurrently in use:\n sleep 1\n"; - let off = "System-wide power settings:\n SleepDisabled\t\t0\n"; - assert!(sleep_disabled(on)); - assert!(!sleep_disabled(off)); - assert!(!sleep_disabled("Currently in use:\n sleep 1\n")); - } - - #[test] - fn the_plist_runs_the_installed_script_on_this_users_request() { - let text = plist_text(Path::new( - "/Users/a & b/Library/Application Support/Codeman/keep-awake-lid.pid", - )); - assert!(text.contains("com.getcodeman.keepawake")); - assert!(text.contains("/Library/Application Support/Codeman/keep-awake-lid.sh")); - assert!( - text.contains("/Users/a & b/Library/Application Support/Codeman/keep-awake-lid.pid") - ); - assert!(text.contains("StartInterval\n 20")); - assert!(text.contains("RunAtLoad\n ")); - } - - #[test] - fn the_admin_commands_fit_in_an_applescript_string() { - // A home with a quote and a backslash in it: the request path goes in base64. - let request = Path::new("/Users/o'neil \"x\\y\"/Library/Application Support/Codeman/keep-awake-lid.pid"); - for (command, prompt) in [ - (install_command("", request), INSTALL_PROMPT), - (remove_command(""), REMOVE_PROMPT), - ] { - let script = applescript(&command, prompt).expect("no quote or backslash"); - assert!(script.starts_with("do shell script \"")); - assert!(script.ends_with("with administrator privileges")); - } + fn the_removal_fits_in_an_applescript_string() { + let script = applescript(&remove_command(""), REMOVE_PROMPT).expect("no quote or backslash"); + assert!(script.starts_with("do shell script \"")); + assert!(script.ends_with("with administrator privileges")); assert_eq!(applescript("echo \"x\"", "p"), None); assert_eq!(applescript("echo x", "a \\ b"), None); } - /// The real install and removal commands, run by `sh` in a temporary folder with - /// stand-ins for what needs root (`chown`, `launchctl`, `pmset`). `base64 --decode` - /// is the real one, macOS's or GNU's. + /// The real removal command, run by `sh` in a temporary folder that looks like a + /// Mac where 1.50.1 installed its helper, with stand-ins for what needs root + /// (`launchctl`, `pmset`). #[cfg(unix)] #[test] - fn the_install_and_removal_commands_do_what_they_say() { + fn the_removal_command_takes_the_old_helper_away() { use std::os::unix::fs::PermissionsExt; use std::process::Command; @@ -1457,13 +1236,12 @@ mod tests { let bin = root.join("bin"); std::fs::create_dir_all(&bin).unwrap(); let log = root.join("calls.log"); - for tool in ["chown", "launchctl", "pmset"] { + for tool in ["launchctl", "pmset"] { let stub = bin.join(tool); std::fs::write(&stub, format!("#!/bin/sh\necho \"{tool} $*\" >> '{}'\n", log.display())).unwrap(); std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap(); } let prefix = root.join("fs").to_string_lossy().into_owned(); - let request = Path::new("/Users/someone/Library/Application Support/Codeman/keep-awake-lid.pid"); let sh = |command: &str| { let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default()); let out = Command::new("sh") @@ -1474,38 +1252,23 @@ mod tests { .unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); }; + let install = |owned: bool| { + for dir in [HELPER_DIR, "/Library/LaunchDaemons"] { + std::fs::create_dir_all(format!("{prefix}{dir}")).unwrap(); + } + for file in [HELPER_PLIST, HELPER_SCRIPT] { + std::fs::write(format!("{prefix}{file}"), "as 1.50.1 wrote it").unwrap(); + } + if owned { + std::fs::write(format!("{prefix}{HELPER_OWNED}"), "").unwrap(); + } + std::fs::write(&log, "").unwrap(); + }; - sh(&install_command(&prefix, request)); - let script = format!("{prefix}{HELPER_SCRIPT}"); - let plist = format!("{prefix}{HELPER_PLIST}"); - assert_eq!(std::fs::read_to_string(&script).unwrap(), HELPER_SOURCE); - assert_eq!(std::fs::read_to_string(&plist).unwrap(), plist_text(request)); - let mode = |path: &str| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode(&script), 0o755); - assert_eq!(mode(&plist), 0o644); - assert_eq!(helper_state_under(&prefix, request), HelperState::Installed); - assert_eq!( - helper_state_under(&prefix, Path::new("/Users/other/x.pid")), - HelperState::Outdated - ); - let calls = std::fs::read_to_string(&log).unwrap(); - assert!(calls.contains(&format!("chown root:wheel {script}.new")), "{calls}"); - assert!( - calls.contains(&format!("launchctl bootout system/{HELPER_LABEL}")), - "{calls}" - ); - assert!( - calls.ends_with(&format!("launchctl bootstrap system {plist}\n")), - "{calls}" - ); - - // A second install replaces the first. - sh(&install_command(&prefix, request)); - assert_eq!(helper_state_under(&prefix, request), HelperState::Installed); - + assert!(!old_helper_under(&prefix)); // The helper had set disablesleep: the removal undoes it, then nothing is left. - std::fs::write(format!("{prefix}{HELPER_OWNED}"), "").unwrap(); - std::fs::write(&log, "").unwrap(); + install(true); + assert!(old_helper_under(&prefix)); sh(&remove_command(&prefix)); let calls = std::fs::read_to_string(&log).unwrap(); assert!( @@ -1513,33 +1276,22 @@ mod tests { "{calls}" ); assert!(calls.contains("pmset -a disablesleep 0"), "{calls}"); - assert_eq!(helper_state_under(&prefix, request), HelperState::Missing); + assert!(!old_helper_under(&prefix)); assert!(!Path::new(&format!("{prefix}{HELPER_DIR}")).exists()); // Without the marker, an administrator's own setting is left alone. - sh(&install_command(&prefix, request)); - std::fs::write(&log, "").unwrap(); + install(false); sh(&remove_command(&prefix)); assert!(!std::fs::read_to_string(&log).unwrap().contains("pmset")); - let _ = std::fs::remove_dir_all(&root); - } + assert!(!old_helper_under(&prefix)); - #[test] - fn the_request_file_holds_this_pid_and_is_only_removed_while_it_does() { - let dir = std::env::temp_dir().join(format!("keep-awake-request-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let path = dir.join(REQUEST_FILE); - assert!(write_request_file(&path), "creates the folder"); - assert_eq!( - std::fs::read_to_string(&path).unwrap(), - format!("{}\n", std::process::id()) - ); - clear_request_file(&path); - assert!(!path.exists()); - std::fs::write(&path, "1\n").unwrap(); - clear_request_file(&path); - assert!(path.exists(), "another copy's request stays"); - let _ = std::fs::remove_dir_all(&dir); + // Half of it is still a helper to remove (a removal that was interrupted). + install(false); + std::fs::remove_file(format!("{prefix}{HELPER_PLIST}")).unwrap(); + assert!(old_helper_under(&prefix)); + sh(&remove_command(&prefix)); + assert!(!old_helper_under(&prefix)); + let _ = std::fs::remove_dir_all(&root); } // The worker, with stand-ins for the Mac and the server. @@ -1547,13 +1299,12 @@ mod tests { #[derive(Default)] struct FakeMac { battery: bool, - helper: bool, - covered: Option, local: bool, holds: Vec, - writes: usize, - clears: usize, - covered_reads: usize, + /// What the lid keeper answers while it is asked. + lid: Option, + /// Every look's asking, in order. + lid_asks: Vec, } #[derive(Clone, Default)] @@ -1566,20 +1317,13 @@ mod tests { fn set_hold(&mut self, hold: Hold) { self.0.borrow_mut().holds.push(hold); } - fn helper_installed(&mut self) -> bool { - self.0.borrow().helper - } - fn write_request(&mut self) -> bool { - self.0.borrow_mut().writes += 1; - true - } - fn clear_request(&mut self) { - self.0.borrow_mut().clears += 1; - } - fn lid_covered(&mut self) -> Option { + fn lid(&mut self, wanted: bool) -> Lid { let mut mac = self.0.borrow_mut(); - mac.covered_reads += 1; - mac.covered + mac.lid_asks.push(wanted); + match wanted { + true => mac.lid.clone().unwrap_or(Lid::Starting), + false => Lid::Off, + } } fn is_this_mac(&mut self, _server: &Url) -> bool { self.0.borrow().local @@ -1765,25 +1509,21 @@ mod tests { } #[test] - fn the_lid_request_is_written_freshened_and_cleared() { + fn the_lid_is_asked_for_on_every_look_and_let_go_of() { let mut rig = rig(true); - rig.mac.0.borrow_mut().helper = true; - rig.mac.0.borrow_mut().covered = Some(true); rig.watch(Settings { lid: true, ..ALWAYS }); assert_eq!( rig.look_at(0).as_deref(), + Some("Awake: Codeman is open, lid closed in a moment") + ); + rig.mac.0.borrow_mut().lid = Some(Lid::Held { slept: None }); + assert_eq!( + rig.look_at(2).as_deref(), Some("Awake: Codeman is open, lid closed too") ); - assert_eq!(rig.mac.0.borrow().writes, 1); - rig.look_at(10); - assert_eq!(rig.mac.0.borrow().writes, 1, "fresh enough"); - assert_eq!(rig.mac.0.borrow().covered_reads, 1, "pmset is not asked every look"); - rig.look_at(30); - assert_eq!( - rig.mac.0.borrow().writes, - 2, - "freshened well inside the helper's 2 minutes" - ); + assert_eq!(rig.look_at(4), None); + // Every look asks: it is the word the keeper waits for. + assert_eq!(rig.mac.0.borrow().lid_asks, [true, true, true]); // On battery the lid is never asked for, whatever the setting. rig.watch(Settings { lid: true, @@ -1792,18 +1532,47 @@ mod tests { }); rig.mac.0.borrow_mut().battery = true; assert_eq!( - rig.look_at(32).as_deref(), + rig.look_at(6).as_deref(), 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. + assert_eq!(rig.mac.0.borrow().lid_asks.last(), Some(&false)); rig.mac.0.borrow_mut().battery = false; - rig.look_at(34); - assert_eq!(rig.mac.0.borrow().writes, 3); + rig.look_at(8); + assert_eq!(rig.mac.0.borrow().lid_asks.last(), Some(&true)); + // The setting off: let go, though the Mac is still held awake. + rig.watch(ALWAYS); + assert_eq!(rig.look_at(10).as_deref(), Some("Awake: Codeman is open")); + assert_eq!(rig.mac.0.borrow().lid_asks.last(), Some(&false)); + assert_eq!(rig.mac.last_hold(), Some(Hold::Holding)); + // Nothing to watch: everything let go. + rig.watch(Settings { lid: true, ..ALWAYS }); + rig.look_at(12); + assert_eq!(rig.mac.0.borrow().lid_asks.last(), Some(&true)); rig.worker.update(None); - assert_eq!(rig.look_at(36), None); + assert_eq!(rig.look_at(14), None); assert_eq!(rig.mac.last_hold(), Some(Hold::None)); - assert_eq!(rig.mac.0.borrow().clears, 2); + assert_eq!(rig.mac.0.borrow().lid_asks.last(), Some(&false)); + } + + #[test] + fn the_lid_goes_with_the_hold_when_the_agents_stop() { + let mut rig = rig(true); + rig.mac.0.borrow_mut().lid = Some(Lid::Held { slept: None }); + rig.watch(Settings { lid: true, ..WORK }); + rig.answer(vec![seen(true)]); + assert_eq!(rig.look_at(0).as_deref(), Some("Awake: agents working, lid closed too")); + rig.answer(vec![seen(false)]); + rig.look_at(2); + assert_eq!( + rig.mac.0.borrow().lid_asks.last(), + Some(&true), + "held through the grace" + ); + assert_eq!( + rig.look_at(2 + GRACE.as_secs()).as_deref(), + Some("Sleeps as usual: no agent working") + ); + assert_eq!(rig.mac.0.borrow().lid_asks.last(), Some(&false)); } #[test] diff --git a/packages/desktop/src/lid_awake.rs b/packages/desktop/src/lid_awake.rs new file mode 100644 index 00000000..a2a94cb8 --- /dev/null +++ b/packages/desktop/src/lid_awake.rs @@ -0,0 +1,1745 @@ +//! With the Lid Closed: keeping a closed MacBook awake, with no password. +//! +//! macOS sleeps when the lid closes whatever power assertions an app holds. What +//! stops it is one switch in the kernel's power management root (`IOPMrootDomain`): +//! lid sleep off (`kPMSetClamshellSleepState`, a call any process may make). With it +//! set a closed lid changes nothing: the Mac stays at full wake, which is also what +//! lets the Apple logo go on blinking under the lid (logo.rs). It is the method +//! LogoLight ships as Lit with Lid Closed, measured there on a MacBook Pro. Codeman +//! 1.50.1 did the same job with a root helper and `pmset disablesleep`, behind the +//! administrator password. +//! +//! The switch has no owner, which is what the rest of this file is about (read from +//! Apple's open sources, `IOPMrootDomain.cpp` and powerd's `PMAssertions.c`): +//! +//! - The kernel keeps it after the process that set it has gone, and honours it on +//! battery too. Left set, a Mac in a bag stays awake. +//! - It is one bit, shared. macOS's own power daemon writes it when its own reasons +//! change, and LogoLight writes it for its feature. Whoever writes last wins. +//! - 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 this bit, once, when that changes. A zero written +//! by anyone else then stays until the display or the power adapter next changes. +//! - Clearing it while the lid is closed puts the Mac to sleep at once. +//! +//! So a child process holds it (this same binary with `LID_ARG`), and does nothing +//! else. The app's keep-awake worker starts it when the lid is to be covered and +//! writes a byte down a pipe on every look. The child: +//! +//! - sets the switch again every second, so a write by anyone else is undone within +//! a second, and holds two power assertions with it (against system sleep and +//! idle sleep), the three LogoLight measured together; +//! - 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 holds the switch then, and a zero from here would put a docked +//! Mac to sleep, or leave it without that mode until the display is plugged in +//! again. The Mac is kept awake by the assertions meanwhile, and what the child +//! set before the display came is cleared once the display has been gone for +//! `SETTLE`, for which the child outlives the app if it has to; +//! - lets go when the pipe closes, however the app ended (quit, crash, Force Quit), +//! and when the app has not written for `STALE` (an app that hangs): the +//! assertions first, then the switch, so a closed Mac goes straight to sleep; +//! - leaves the switch alone on its way out while LogoLight holds it for itself +//! (LogoLight sets it once and would not notice it gone), and LogoLight's own +//! release turns it off; +//! - ignores the signals that arrive together with the app's end, as the logo child +//! does, so the pipe is its one way out. +//! +//! One child at a time may hold it: a lock on a file next to the app's settings +//! (`lid-awake`), taken before the first write. The file is non-empty while the +//! switch may be set, so a child that was killed leaves a file that is marked and +//! unlocked: the app turns lid sleep back on when it sees a child end, and at its next +//! launch (`heal`). Until then the Mac stays awake under a closed lid, which only a +//! SIGKILL of the child together with the app, or a kernel that stops answering, can +//! cause; a restart of the Mac clears the switch as well. +//! +//! The child tells the app what it did in lines on its standard output (`Said`): +//! that the kernel took the call, that a display is connected and nothing was +//! written, that the Mac is on battery, that the call was refused, and that the Mac +//! slept all the same (wall-clock time that passed while the monotonic clock stood +//! still), which the menu's status line then says. A display has its own word on +//! purpose: a Mac that sleeps although the line said the lid was covered would leave +//! nothing to go by. + +use std::time::{Duration, Instant}; + +/// The argument that makes this binary the lid keeper instead of the app. +pub const LID_ARG: &str = "--keep-awake-lid"; + +/// How often the child sets the switch again and reads the power source. +const TICK: Duration = Duration::from_secs(1); +/// The app writes on every look of its worker (every 2 seconds, longer while the +/// server is slow to answer). Without a word for this long the app hangs, and the +/// child lets go. +const STALE: Duration = Duration::from_secs(60); +/// How long no external display must have been seen before the switch is cleared. +/// The display list is short for a moment while displays are being rearranged. +const SETTLE: Duration = Duration::from_secs(5); +/// How often a child looks that only waits for the display to go, so it can clear +/// what it set. +const LINGER: Duration = Duration::from_secs(5); + +/// What the app knows about the lid, for the status line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Lid { + /// Not asked for. + Off, + /// A child was started and has not said anything yet. + Starting, + /// The kernel took the call. `slept`: the Mac slept all the same, until this + /// time of day. + Held { slept: Option }, + /// An external display is connected: macOS's own closed-lid mode has the switch, + /// and the child writes nothing. `slept` as for `Held`. + Docked { slept: Option }, + /// The child reads the power source as the battery. + OnBattery, + /// The kernel turned the call down. + Refused, + /// The child could not start, or could not keep its file. + Failed, +} + +/// A line from the child to the app. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Said { + Held, + /// Nothing is written: an external display is connected. + Docked, + OnBattery, + Refused, + Failed, + /// The Mac slept while the child held, until this time of day. + Slept(String), +} + +impl Said { + fn line(&self) -> String { + match self { + Said::Held => "held".into(), + Said::Docked => "docked".into(), + Said::OnBattery => "battery".into(), + Said::Refused => "refused".into(), + Said::Failed => "failed".into(), + Said::Slept(until) => format!("slept {until}"), + } + } + + fn parse(line: &str) -> Option { + match line.trim() { + "held" => Some(Said::Held), + "docked" => Some(Said::Docked), + "battery" => Some(Said::OnBattery), + "refused" => Some(Said::Refused), + "failed" => Some(Said::Failed), + other => other + .strip_prefix("slept ") + .map(|until| Said::Slept(until.trim().to_owned())), + } + } +} + +/// What the app's reader has heard from one child. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct Heard { + /// The last state line. + said: Option, + slept: Option, + /// The child has exited. + gone: bool, +} + +impl Heard { + fn hear(&mut self, said: Said) { + match said { + Said::Slept(until) => self.slept = Some(until), + state => self.said = Some(state), + } + } + + /// What a running child's last word means. `slept` is the app's own note, which + /// outlives one child. + fn lid(&self, slept: Option) -> Lid { + match self.said { + None | Some(Said::Slept(_)) => Lid::Starting, + Some(Said::Held) => Lid::Held { slept }, + Some(Said::Docked) => Lid::Docked { slept }, + Some(Said::OnBattery) => Lid::OnBattery, + Some(Said::Refused) => Lid::Refused, + Some(Said::Failed) => Lid::Failed, + } + } +} + +/// What the app said while the child waited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Told { + Nothing, + /// The app wrote: it is alive and still wants the lid covered. + Beat, + /// The pipe closed: the app stopped asking, or is gone. + Closed, +} + +/// Whether the lock on the file was had. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Taken { + Yes, + /// Another child holds it. + Busy, + /// The file cannot be opened or locked: without it nothing could undo the + /// switch after a kill, so nothing is held. + Broken, +} + +/// What the child does to the Mac, so tests can stand in for it. +trait Kernel { + /// Turn lid sleep off, or back on. Whether the kernel took it. + fn set_lid_sleep_disabled(&mut self, disabled: bool) -> bool; + /// Hold or release the assertions against system sleep and idle sleep. + fn assert_awake(&mut self, hold: bool); + fn on_battery(&mut self) -> bool; + /// An external display is connected: macOS's own closed-lid mode has the switch. + fn docked(&mut self) -> bool; + /// Another app (LogoLight) holds lid sleep off for itself right now. + fn held_elsewhere(&mut self) -> bool; +} + +/// The file next to the app's settings: locked while a child lives, non-empty while +/// the switch may be set. +trait Claim { + fn try_take(&mut self) -> Taken; + fn marked(&mut self) -> bool; + /// Mark the file or empty it. False when it could not be written. + fn mark(&mut self, held: bool) -> bool; +} + +/// The child's clock, pipe and voice. +trait Signals { + /// Waits up to `time` for a word from the app. + fn wait(&mut self, time: Duration) -> Told; + fn now(&self) -> Instant; + /// The time of day, when the last wait spanned a sleep of the Mac. Asked once + /// per wait. + fn slept(&mut self) -> Option; + fn say(&mut self, said: &Said); + /// Waits `time` with nothing to listen for (the pipe has closed). + fn pause(&mut self, time: Duration); +} + +/// Why the child ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Ended { + /// The pipe closed. + Released, + /// The app stopped writing. + Stale, + Refused, + Failed, +} + +/// Says a state once, not on every tick. +fn tell(signals: &mut impl Signals, last: &mut Option, said: Said) { + if last.as_ref() != Some(&said) { + signals.say(&said); + *last = Some(said); + } +} + +/// What the child knows about its file and the display between ticks. +#[derive(Default)] +struct Ours { + /// This child has the lock. + claimed: bool, + /// The switch may be set by a Codeman child: this one, or one that was killed + /// and left the file marked. + marked: bool, + /// Since when no external display has been seen. + undocked_since: Option, +} + +impl Ours { + fn see(&mut self, docked: bool, now: Instant) { + match docked { + true => self.undocked_since = None, + false => self.undocked_since = self.undocked_since.or(Some(now)), + } + } + + /// Clears what a Codeman child set, once no display has been seen for `SETTLE`: + /// until then the switch may be what keeps a docked Mac awake. It also stays + /// while LogoLight holds it for itself. The caller has let go of the assertions + /// first: with the lid closed, clearing the switch is what puts the Mac to + /// sleep, and an assertion still held then would make that a dark wake. + fn settle(&mut self, kernel: &mut impl Kernel, claim: &mut impl Claim, now: Instant) { + let undocked = self + .undocked_since + .is_some_and(|since| now.duration_since(since) >= SETTLE); + if self.marked && undocked { + if !kernel.held_elsewhere() { + kernel.set_lid_sleep_disabled(false); + } + claim.mark(false); + self.marked = false; + } + } +} + +/// The child's whole life. +fn keep(kernel: &mut impl Kernel, claim: &mut impl Claim, signals: &mut impl Signals) -> Ended { + let mut ours = Ours::default(); + // The Mac is meant to stay awake under a closed lid: by this child's switch, or + // at a display by macOS's own closed-lid mode. + let mut awake = false; + let mut last = None; + let mut last_beat = signals.now(); + let ended = loop { + let told = signals.wait(TICK); + // Only a sleep that happened while the Mac was meant to stay awake is worth a + // word: at a display too, where a sleep says this is not macOS's closed-lid + // mode after all. + let slept = signals.slept().filter(|_| awake); + match told { + Told::Closed => break Ended::Released, + Told::Beat => last_beat = signals.now(), + Told::Nothing => {} + } + let now = signals.now(); + if now.duration_since(last_beat) >= STALE { + break Ended::Stale; + } + if !ours.claimed { + match claim.try_take() { + Taken::Yes => { + ours.claimed = true; + // A child that was killed left its mark: this one's to clear now. + ours.marked = claim.marked(); + } + // The child before this one still has the file. + Taken::Busy => {} + Taken::Broken => break Ended::Failed, + } + } + let battery = kernel.on_battery(); + let docked = kernel.docked(); + ours.see(docked, now); + if battery || docked { + // Not this child's to hold. On battery nothing keeps the Mac awake. + // Docked, macOS's own closed-lid mode does, with the same switch, so it + // is neither set nor cleared here, and the assertions do the rest. + awake = !battery; + kernel.assert_awake(awake); + ours.settle(kernel, claim, now); + if let Some(until) = slept.filter(|_| awake) { + signals.say(&Said::Slept(until)); + } + tell(signals, &mut last, if battery { Said::OnBattery } else { Said::Docked }); + continue; + } + if !ours.claimed { + continue; + } + if !ours.marked { + // Marked before the first write, so a kill right after it is seen. + if !claim.mark(true) { + break Ended::Failed; + } + ours.marked = true; + } + kernel.assert_awake(true); + awake = true; + // Every tick: macOS's power daemon and LogoLight write the same switch. + if !kernel.set_lid_sleep_disabled(true) { + break Ended::Refused; + } + if let Some(until) = slept { + signals.say(&Said::Slept(until)); + } + tell(signals, &mut last, Said::Held); + }; + kernel.assert_awake(false); + match ended { + Ended::Refused => signals.say(&Said::Refused), + Ended::Failed => signals.say(&Said::Failed), + Ended::Released | Ended::Stale => {} + } + // What is left of this child's goes at once, unless a display is connected (or + // was a moment ago): then the child waits for it to go, however long. + loop { + ours.settle(kernel, claim, signals.now()); + if !ours.marked { + break ended; + } + signals.pause(LINGER); + let docked = kernel.docked(); + ours.see(docked, signals.now()); + } +} + +/// Turns lid sleep back on after a child that was killed: its file is marked and +/// nobody holds the lock. Not while a display is connected: the next child to take +/// the file finds the mark and clears it once the display has gone. Whether there +/// was anything to undo. +fn heal_with(kernel: &mut impl Kernel, claim: &mut impl Claim) -> bool { + if claim.try_take() != Taken::Yes || !claim.marked() || kernel.docked() { + return false; + } + if !kernel.held_elsewhere() { + kernel.set_lid_sleep_disabled(false); + } + claim.mark(false); + true +} + +#[cfg(target_os = "macos")] +pub use system::{child_main, heal, Holder}; + +#[cfg(not(target_os = "macos"))] +pub fn child_main() -> ! { + std::process::exit(0) +} + +#[cfg(not(target_os = "macos"))] +pub fn heal() {} + +#[cfg(target_os = "macos")] +mod system { + use super::{heal_with, keep, Claim, Ended, Heard, Kernel, Lid, Said, Signals, Taken, Told, LID_ARG}; + use objc2::msg_send; + use objc2::rc::{autoreleasepool, Retained}; + use objc2::runtime::AnyObject; + use objc2_foundation::NSString; + use std::ffi::{c_char, c_void}; + use std::fs::{File, OpenOptions, TryLockError}; + use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; + use std::path::PathBuf; + use std::process::{ChildStdin, Command, Stdio}; + use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant, SystemTime}; + + /// `kPMSetClamshellSleepState` (IOKit/pwr_mgt/IOPMLibDefs.h). + const SET_CLAMSHELL_SLEEP_STATE: u32 = 12; + /// `kIOPMAssertionLevelOn`. + const LEVEL_ON: u32 = 255; + /// What `pmset -g assertions` and Activity Monitor show for the child. + const HOLDING: &str = "Codeman keeps this Mac awake with the lid closed"; + /// The name LogoLight (1.3.1) gives the assertions it holds exactly while it has + /// lid sleep off for its own Lit with Lid Closed. It sets the switch once, so a + /// write of ours under it would end its feature until it is toggled. + const LOGOLIGHT_HOLDING: &str = "LogoLight keeps the logo lit with the lid closed"; + /// More wall-clock time than this, past the monotonic time, in one wait means + /// the Mac slept (the logo child's reading, logo.rs). + const SLEPT_GAP: Duration = Duration::from_secs(10); + /// Next to `desktop.json`. + const CLAIM_FILE: &str = "lid-awake"; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGGetOnlineDisplayList(max_displays: u32, displays: *mut u32, count: *mut u32) -> i32; + fn CGDisplayIsBuiltin(display: u32) -> u32; + } + + #[link(name = "IOKit", kind = "framework")] + extern "C" { + fn IOServiceMatching(name: *const c_char) -> *mut c_void; + fn IOServiceGetMatchingService(main_port: u32, matching: *mut c_void) -> u32; + fn IOObjectRelease(object: u32) -> i32; + fn IOServiceOpen(service: u32, owning_task: u32, kind: u32, connect: *mut u32) -> i32; + fn IOServiceClose(connect: u32) -> i32; + fn IOConnectCallScalarMethod( + connection: u32, + selector: u32, + input: *const u64, + input_count: u32, + output: *mut u64, + output_count: *mut u32, + ) -> i32; + fn IOPMAssertionCreateWithName(kind: *const c_void, level: u32, name: *const c_void, id: *mut u32) -> i32; + fn IOPMAssertionRelease(id: u32) -> i32; + fn IOPMCopyAssertionsByProcess(by_process: *mut *const c_void) -> i32; + } + + extern "C" { + /// What the C macro `mach_task_self()` reads. + static mach_task_self_: u32; + } + + /// The power management root, through the user client any process may open. + pub(super) struct RootDomain { + /// Kept for the child's life: one call a second needs no new connection. + connection: Option, + assertions: Vec, + } + + impl RootDomain { + pub(super) fn new() -> Self { + RootDomain { + connection: None, + assertions: Vec::new(), + } + } + + fn open() -> Option { + // SAFETY: IOServiceMatching returns a dictionary that + // IOServiceGetMatchingService consumes; the service is released once the + // connection is open. IOServiceOpen fills `connection` on success. + unsafe { + let matching = IOServiceMatching(c"IOPMrootDomain".as_ptr()); + if matching.is_null() { + return None; + } + let root = IOServiceGetMatchingService(0, matching); + if root == 0 { + return None; + } + let mut connection = 0u32; + let opened = IOServiceOpen(root, mach_task_self_, 0, &mut connection); + IOObjectRelease(root); + (opened == 0 && connection != 0).then_some(connection) + } + } + + fn close(&mut self) { + if let Some(connection) = self.connection.take() { + // SAFETY: a connection IOServiceOpen returned, closed once. + unsafe { IOServiceClose(connection) }; + } + } + } + + impl Kernel for RootDomain { + fn set_lid_sleep_disabled(&mut self, disabled: bool) -> bool { + let input = u64::from(disabled); + // A connection that stopped working is opened again, once. + for _ in 0..2 { + if self.connection.is_none() { + self.connection = Self::open(); + } + let Some(connection) = self.connection else { + return false; + }; + // SAFETY: one scalar in, as the selector takes, and no output. + let taken = unsafe { + IOConnectCallScalarMethod( + connection, + SET_CLAMSHELL_SLEEP_STATE, + &input, + 1, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if taken == 0 { + return true; + } + self.close(); + } + false + } + + fn assert_awake(&mut self, hold: bool) { + if !hold { + for id in self.assertions.drain(..) { + // SAFETY: an ID IOPMAssertionCreateWithName returned, released once. + unsafe { IOPMAssertionRelease(id) }; + } + return; + } + if !self.assertions.is_empty() { + return; + } + for kind in ["PreventSystemSleep", "PreventUserIdleSystemSleep"] { + self.assertions.extend(assertion(kind, HOLDING)); + } + } + + fn on_battery(&mut self) -> bool { + crate::keep_awake::on_battery() + } + + fn docked(&mut self) -> bool { + external_display_connected() + } + + fn held_elsewhere(&mut self) -> bool { + assertion_named(LOGOLIGHT_HOLDING) + } + } + + impl Drop for RootDomain { + fn drop(&mut self) { + self.close(); + } + } + + /// Whether a display other than the built-in one is connected, asleep or not: the + /// nearest an app gets to what the window server tells the power daemon. The + /// built-in display stays on this list under a closed lid while the Mac is kept + /// awake without another display. If the list cannot be had the answer is yes, + /// and nothing is written. + fn external_display_connected() -> bool { + // For the tests, on a Mac that has no display to plug in or pull out + // ("1" or "0"). Not in a release build. + #[cfg(debug_assertions)] + if let Some(pretend) = std::env::var_os("CODEMAN_LID_TEST_DISPLAY") { + return pretend == "1"; + } + let mut displays = [0u32; 16]; + let mut count = 0u32; + // SAFETY: the buffer holds 16 IDs and the call is told so. + if unsafe { CGGetOnlineDisplayList(16, displays.as_mut_ptr(), &mut count) } != 0 { + return true; + } + displays[..(count as usize).min(displays.len())] + .iter() + // SAFETY: takes any display ID and answers a boolean. + .any(|&display| unsafe { CGDisplayIsBuiltin(display) } == 0) + } + + /// A power assertion of this kind under this name, held until it is released. + fn assertion(kind: &str, name: &str) -> Option { + let (kind, name) = (NSString::from_str(kind), NSString::from_str(name)); + let mut id = 0u32; + // SAFETY: both are NSStrings, toll-free bridged to the CFStrings the call + // takes, and alive for the call; `id` is filled on success. + let created = unsafe { + IOPMAssertionCreateWithName( + Retained::as_ptr(&kind).cast(), + LEVEL_ON, + Retained::as_ptr(&name).cast(), + &mut id, + ) + }; + (created == 0).then_some(id) + } + + /// Whether any process holds a power assertion of this name. Read from the + /// description of what `IOPMCopyAssertionsByProcess` answers (a dictionary of + /// arrays of dictionaries, printed in full), where the name stands as it was + /// given. Anything unreadable is a no: the switch is then cleared, the safe way. + fn assertion_named(name: &str) -> bool { + let mut by_process: *const c_void = std::ptr::null(); + // SAFETY: the call fills the pointer with an owned CFDictionary, or leaves + // it NULL. + if unsafe { IOPMCopyAssertionsByProcess(&mut by_process) } != 0 || by_process.is_null() { + return false; + } + autoreleasepool(|_| { + // SAFETY: an owned CFDictionary is a toll-free bridged NSDictionary, and + // `from_raw` takes over the reference the call handed out. + let Some(dictionary) = (unsafe { Retained::from_raw(by_process.cast_mut().cast::()) }) else { + return false; + }; + // SAFETY: every NSObject answers `description` with an NSString. + let text: Option> = unsafe { msg_send![&*dictionary, description] }; + text.is_some_and(|text| text.to_string().contains(name)) + }) + } + + /// The file and its lock (`flock`, which the kernel drops with the process). + pub(super) struct FileClaim { + path: Option, + file: Option, + } + + impl FileClaim { + pub(super) fn at(path: Option) -> Self { + FileClaim { path, file: None } + } + + fn default_location() -> Self { + Self::at(crate::config::Config::default_path().map(|path| path.with_file_name(CLAIM_FILE))) + } + } + + impl Claim for FileClaim { + fn try_take(&mut self) -> Taken { + if self.file.is_some() { + return Taken::Yes; + } + let Some(path) = &self.path else { + return Taken::Broken; + }; + if path.parent().is_some_and(|dir| std::fs::create_dir_all(dir).is_err()) { + return Taken::Broken; + } + // Never removed and never truncated on open: its content is the mark. + let file = match OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + { + Ok(file) => file, + Err(_) => return Taken::Broken, + }; + match file.try_lock() { + Ok(()) => { + self.file = Some(file); + Taken::Yes + } + Err(TryLockError::WouldBlock) => Taken::Busy, + Err(TryLockError::Error(_)) => Taken::Broken, + } + } + + fn marked(&mut self) -> bool { + self.file + .as_ref() + .and_then(|file| file.metadata().ok()) + .is_some_and(|data| data.len() > 0) + } + + fn mark(&mut self, held: bool) -> bool { + let Some(file) = &mut self.file else { + return false; + }; + let write = |file: &mut File| -> std::io::Result<()> { + file.set_len(0)?; + if held { + file.seek(SeekFrom::Start(0))?; + file.write_all(format!("{}\n", std::process::id()).as_bytes())?; + file.sync_data()?; + } + Ok(()) + }; + write(file).is_ok() + } + } + + struct Live { + told: Receiver, + slept: bool, + } + + impl Signals for Live { + fn wait(&mut self, time: Duration) -> Told { + let (wall, monotonic) = (SystemTime::now(), Instant::now()); + let told = match self.told.recv_timeout(time) { + Ok(told) => told, + Err(RecvTimeoutError::Timeout) => Told::Nothing, + Err(RecvTimeoutError::Disconnected) => Told::Closed, + }; + if wall.elapsed().unwrap_or_default() > monotonic.elapsed() + SLEPT_GAP { + self.slept = true; + } + told + } + + fn now(&self) -> Instant { + Instant::now() + } + + fn slept(&mut self) -> Option { + std::mem::take(&mut self.slept).then(crate::logo::clock) + } + + fn say(&mut self, said: &Said) { + // Never `println!`: it panics once the app has gone, and a panic aborts + // the child before it has let go. + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{}", said.line()); + let _ = out.flush(); + } + + fn pause(&mut self, time: Duration) { + std::thread::sleep(time); + } + } + + /// The child process. It ends when the pipe from the app closes or goes quiet, + /// and the signals that would end it sooner are ignored: they arrive together + /// with the app's own end (a logout, Ctrl-C on a terminal that started the app), + /// and a child they killed would leave lid sleep off. + pub fn child_main() -> ! { + // SAFETY: `signal` with SIG_IGN, before any other thread exists. + unsafe { + for signal in [libc::SIGHUP, libc::SIGINT, libc::SIGTERM] { + libc::signal(signal, libc::SIG_IGN); + } + } + let (tell, told) = mpsc::channel(); + let reader = std::thread::Builder::new().name("lid-pipe".into()).spawn(move || { + let mut stdin = std::io::stdin().lock(); + let mut buf = [0u8; 64]; + loop { + let told = match stdin.read(&mut buf) { + Ok(0) => Told::Closed, + Ok(_) => Told::Beat, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => Told::Closed, + }; + if tell.send(told).is_err() || told == Told::Closed { + return; + } + } + }); + if reader.is_err() { + // Nothing would hear the app go. + std::process::exit(4); + } + let mut signals = Live { told, slept: false }; + let ended = keep(&mut RootDomain::new(), &mut FileClaim::default_location(), &mut signals); + std::process::exit(match ended { + Ended::Released | Ended::Stale => 0, + Ended::Refused => 3, + Ended::Failed => 4, + }) + } + + /// Turns lid sleep back on if a child of an earlier run was killed before it + /// could. Called at launch and whenever a child ends. + pub fn heal() { + heal_with(&mut RootDomain::new(), &mut FileClaim::default_location()); + } + + struct Running { + stdin: ChildStdin, + heard: Arc>, + } + + /// The app's side: the pipe to the child, and what it said. + #[derive(Default)] + pub struct Holder { + child: Option, + /// The child gave up for good (refused, or no file): not started again until + /// the lid is asked for anew. + ended: Option, + /// The Mac slept while a child of this asking held. + slept: Option, + /// The app's binary, where the test binary stands in for the app. + #[cfg(test)] + exe: Option, + } + + impl Holder { + /// Ask for the lid, or stop asking. Called on every look of the worker: with + /// `wanted` it is also the word the child waits for. + pub fn hold(&mut self, wanted: bool) -> Lid { + if !wanted { + // Dropping the pipe is the release, the same one a crash takes. + self.child = None; + self.ended = None; + self.slept = None; + return Lid::Off; + } + if let Some(ended) = &self.ended { + return ended.clone(); + } + if let Some(running) = &mut self.child { + let heard = running.heard.lock().map(|heard| heard.clone()).unwrap_or(Heard { + gone: true, + ..Heard::default() + }); + if heard.slept.is_some() { + self.slept = heard.slept.clone(); + } + let lid = heard.lid(self.slept.clone()); + let alive = !heard.gone && running.stdin.write_all(b"\n").is_ok() && running.stdin.flush().is_ok(); + if alive { + return lid; + } + self.child = None; + if matches!(lid, Lid::Refused | Lid::Failed) { + self.ended = Some(lid.clone()); + return lid; + } + // It let go because the app was slow, or was killed: start another. + } + self.child = self.exe().and_then(|exe| spawn(&exe)); + match self.child { + Some(_) => Lid::Starting, + None => Lid::Failed, + } + } + } + + impl Holder { + /// The child is this same binary. + fn exe(&self) -> Option { + #[cfg(test)] + if let Some(exe) = &self.exe { + return Some(exe.clone()); + } + std::env::current_exe().ok() + } + } + + fn spawn(exe: &std::path::Path) -> Option { + let mut child = Command::new(exe) + .arg(LID_ARG) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .ok()?; + let stdin = child.stdin.take()?; + let stdout = child.stdout.take()?; + let heard = Arc::new(Mutex::new(Heard::default())); + let shared = heard.clone(); + std::thread::Builder::new() + .name("lid-child".into()) + .spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if let (Some(said), Ok(mut heard)) = (Said::parse(&line), shared.lock()) { + heard.hear(said); + } + } + let _ = child.wait(); + if let Ok(mut heard) = shared.lock() { + heard.gone = true; + } + // A child that was killed could not turn lid sleep back on. + heal(); + }) + .ok()?; + Some(Running { stdin, heard }) + } + + #[cfg(test)] + mod tests { + use super::*; + + fn temp(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lid-awake-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir.join(CLAIM_FILE) + } + + #[test] + fn the_file_is_locked_by_one_and_keeps_its_mark_for_the_next() { + let path = temp("claim"); + let mut first = FileClaim::at(Some(path.clone())); + assert_eq!(first.try_take(), Taken::Yes, "creates the folder and the file"); + assert_eq!(first.try_take(), Taken::Yes, "taken stays taken"); + assert!(!first.marked()); + let mut second = FileClaim::at(Some(path.clone())); + assert_eq!(second.try_take(), Taken::Busy); + assert!(!second.mark(true), "only the holder writes"); + assert!(first.mark(true)); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + format!("{}\n", std::process::id()) + ); + // The holder dies without emptying it: the lock goes, the mark stays. + drop(first); + assert_eq!(second.try_take(), Taken::Yes); + assert!(second.marked()); + assert!(second.mark(false)); + assert!(!second.marked()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), ""); + drop(second); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn a_file_that_cannot_be_had_is_broken_not_busy() { + assert_eq!(FileClaim::at(None).try_take(), Taken::Broken); + // A folder where the file should be. + let path = temp("broken"); + std::fs::create_dir_all(&path).unwrap(); + assert_eq!(FileClaim::at(Some(path.clone())).try_take(), Taken::Broken); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + /// This process's lines in `pmset -g assertions`. + fn own_assertions() -> String { + let out = Command::new("/usr/bin/pmset") + .args(["-g", "assertions"]) + .output() + .unwrap(); + let pid = format!("pid {}(", std::process::id()); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|line| line.contains(&pid)) + .collect::>() + .join("\n") + } + + /// The two assertions under their name, and LogoLight's found by its name, + /// on a real Mac (it reads what the power daemon holds for every process, + /// so not in the gate): `cargo test --locked the_assertions_are_held -- --ignored --nocapture`. + #[test] + #[ignore] + fn the_assertions_are_held_under_their_name_and_logolights_are_found() { + let mut root = RootDomain::new(); + root.assert_awake(true); + root.assert_awake(true); + let held = own_assertions(); + println!("while it holds:\n{held}"); + assert!(held.contains("PreventSystemSleep"), "{held}"); + assert!(held.contains("PreventUserIdleSystemSleep"), "{held}"); + assert_eq!(held.matches(HOLDING).count(), 2, "held once each: {held}"); + assert!(assertion_named(HOLDING)); + root.assert_awake(false); + assert!(own_assertions().is_empty()); + + // What LogoLight holds while it has lid sleep off for itself. + let before = root.held_elsewhere(); + let theirs = assertion("PreventSystemSleep", LOGOLIGHT_HOLDING).unwrap(); + assert!(root.held_elsewhere()); + // SAFETY: the ID the call above returned, released once. + unsafe { IOPMAssertionRelease(theirs) }; + assert_eq!(root.held_elsewhere(), before, "gone with its release"); + } + + /// The app's side against the real child, on a real Mac: start, hold, a kill + /// of the child and the start of another, and the release. Needs the app + /// built next to the tests: + /// `cargo build --locked && cargo test --locked the_holder_drives -- --ignored --nocapture`. + /// It sets lid sleep off and back on, like `the_kernel_takes_the_call`. + #[test] + #[ignore] + fn the_holder_drives_a_real_child() { + let exe = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/debug/codeman-desktop"); + assert!(exe.exists(), "build the app first: {}", exe.display()); + let file = FileClaim::default_location().path.unwrap(); + let pid_in_file = || std::fs::read_to_string(&file).unwrap_or_default().trim().to_owned(); + let mut holder = Holder { + exe: Some(exe), + ..Holder::default() + }; + // Looks as the worker makes them, until the child says it holds. + let until_held = |holder: &mut Holder| { + (0..20).any(|_| { + std::thread::sleep(Duration::from_millis(500)); + holder.hold(true) == Lid::Held { slept: None } + }) + }; + assert_eq!(holder.hold(true), Lid::Starting); + assert!(until_held(&mut holder), "the child never said it holds"); + assert!(assertion_named(HOLDING)); + let first = pid_in_file(); + println!("held by child {first}"); + + // Killed: the app sees it gone, starts another, and that one holds. + let pid: libc::pid_t = first.parse().unwrap(); + // SAFETY: the process ID the child wrote to its file a moment ago. + unsafe { libc::kill(pid, libc::SIGKILL) }; + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(holder.hold(true), Lid::Starting, "a new child for the killed one"); + assert!(until_held(&mut holder), "the second child never said it holds"); + let second = pid_in_file(); + println!("held by child {second} after the kill"); + assert_ne!(first, second); + + // Let go: the pipe closes, the child ends, nothing is left. A child this + // young first makes sure no display is connected (`SETTLE`). + assert_eq!(holder.hold(false), Lid::Off); + let gone = (0..48).any(|_| { + std::thread::sleep(Duration::from_millis(250)); + !assertion_named(HOLDING) && pid_in_file().is_empty() + }); + assert!(gone, "assertions or the mark are still there"); + println!("let go: no assertion, file empty"); + } + + /// `heal` on this Mac, for a look by hand after a child was killed with + /// SIGKILL (its file is then marked and unlocked): + /// `cargo test --locked heal_by_hand -- --ignored --nocapture`. + #[test] + #[ignore] + fn heal_by_hand() { + let mut claim = FileClaim::default_location(); + println!("before: lock {:?}, marked {}", claim.try_take(), claim.marked()); + drop(claim); + heal(); + let mut claim = FileClaim::default_location(); + println!("after heal: lock {:?}, marked {}", claim.try_take(), claim.marked()); + assert!(!claim.marked()); + } + + /// What this Mac's kernel answers to the call itself, for a look by hand: + /// `cargo test --locked the_kernel_takes_the_call -- --ignored --nocapture`. + /// It sets lid sleep off and back on, as an unprivileged process. On a Mac + /// without a lid that changes nothing; on a MacBook, keep the lid open. + #[test] + #[ignore] + fn the_kernel_takes_the_call() { + let mut root = RootDomain::new(); + let off = root.set_lid_sleep_disabled(true); + println!("lid sleep off: taken = {off}"); + let on = root.set_lid_sleep_disabled(false); + println!("lid sleep back on: taken = {on}"); + println!("on battery: {}", root.on_battery()); + println!("an external display is connected: {}", root.docked()); + println!("LogoLight holds it: {}", root.held_elsewhere()); + assert!(off && on); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + #[derive(Debug, Clone, PartialEq, Eq)] + enum Did { + LidSleepOff, + LidSleepOn, + Assert, + Release, + Mark, + Unmark, + } + + #[derive(Default)] + struct FakeMac { + did: Vec, + battery: bool, + /// An external display is connected. + docked: bool, + /// How often the child waited with the pipe closed. + pauses: usize, + /// The kernel turns the call down from this many writes on. + refuses_after: Option, + elsewhere: bool, + /// What the lock answers, one per try; `Yes` once they run out. + takes: VecDeque, + marked: bool, + unwritable: bool, + asserted: bool, + disabled: bool, + } + + impl FakeMac { + fn writes(&self) -> usize { + self.did + .iter() + .filter(|did| matches!(did, Did::LidSleepOff | Did::LidSleepOn)) + .count() + } + } + + /// One fake for both traits would need two `&mut`; the claim borrows the Mac's + /// state through a pointer-free split instead. + struct Split<'a>(&'a std::cell::RefCell); + + impl Kernel for Split<'_> { + fn set_lid_sleep_disabled(&mut self, disabled: bool) -> bool { + let mut mac = self.0.borrow_mut(); + if mac.refuses_after.is_some_and(|after| mac.writes() >= after) { + return false; + } + mac.did.push(if disabled { Did::LidSleepOff } else { Did::LidSleepOn }); + mac.disabled = disabled; + true + } + fn assert_awake(&mut self, hold: bool) { + let mut mac = self.0.borrow_mut(); + if mac.asserted != hold { + mac.asserted = hold; + mac.did.push(if hold { Did::Assert } else { Did::Release }); + } + } + fn on_battery(&mut self) -> bool { + self.0.borrow().battery + } + fn docked(&mut self) -> bool { + self.0.borrow().docked + } + fn held_elsewhere(&mut self) -> bool { + self.0.borrow().elsewhere + } + } + + impl Claim for Split<'_> { + fn try_take(&mut self) -> Taken { + self.0.borrow_mut().takes.pop_front().unwrap_or(Taken::Yes) + } + fn marked(&mut self) -> bool { + self.0.borrow().marked + } + fn mark(&mut self, held: bool) -> bool { + let mut mac = self.0.borrow_mut(); + if mac.unwritable { + return false; + } + mac.marked = held; + mac.did.push(if held { Did::Mark } else { Did::Unmark }); + true + } + } + + /// The app's words, one per wait, each with what the fake Mac is like from then + /// on. The pipe closes when they run out. A wait with the pipe closed takes the + /// next step for what the Mac is like, if there is one. + struct Script<'a> { + mac: &'a std::cell::RefCell, + steps: VecDeque, + now: Instant, + slept: Option, + said: Vec, + } + + type Change = Box; + + struct Step { + told: Told, + /// How long the wait took. + took: Duration, + slept: Option<&'static str>, + change: Option, + } + + fn step(told: Told) -> Step { + Step { + told, + took: TICK, + slept: None, + change: None, + } + } + + impl Step { + fn then(mut self, change: impl Fn(&mut FakeMac) + 'static) -> Self { + self.change = Some(Box::new(change)); + self + } + fn slept(mut self, until: &'static str) -> Self { + self.slept = Some(until); + self + } + fn took(mut self, took: Duration) -> Self { + self.took = took; + self + } + } + + impl Signals for Script<'_> { + fn wait(&mut self, _time: Duration) -> Told { + let Some(step) = self.steps.pop_front() else { + return Told::Closed; + }; + self.now += step.took; + self.slept = step.slept.map(str::to_owned); + if let Some(change) = step.change { + change(&mut self.mac.borrow_mut()); + } + step.told + } + fn now(&self) -> Instant { + self.now + } + fn slept(&mut self) -> Option { + self.slept.take() + } + fn say(&mut self, said: &Said) { + self.said.push(said.clone()); + } + fn pause(&mut self, time: Duration) { + self.now += time; + let mut mac = self.mac.borrow_mut(); + mac.pauses += 1; + assert!(mac.pauses < 1000, "the child waits for a display that never goes"); + if let Some(change) = self.steps.pop_front().and_then(|step| step.change) { + change(&mut mac); + } + } + } + + /// As many words from the app, a tick apart. + fn beats(count: usize) -> Vec { + (0..count).map(|_| step(Told::Beat)).collect() + } + + /// Something for the fake Mac to become, with the pipe open or closed. + fn becomes(change: impl Fn(&mut FakeMac) + 'static) -> Step { + step(Told::Beat).then(change) + } + + /// Runs the child against a fake Mac. What it ended with, what it did, what it + /// said. + fn run(mac: FakeMac, steps: Vec) -> (Ended, FakeMac, Vec) { + let mac = std::cell::RefCell::new(mac); + let mut script = Script { + mac: &mac, + steps: steps.into(), + now: Instant::now(), + slept: None, + said: Vec::new(), + }; + let ended = keep(&mut Split(&mac), &mut Split(&mac), &mut script); + let said = script.said; + (ended, mac.into_inner(), said) + } + + use Did::*; + + #[test] + fn it_marks_asserts_and_sets_the_switch_then_sets_it_again_every_tick() { + let (ended, mac, said) = run( + FakeMac::default(), + vec![step(Told::Beat), step(Told::Nothing), step(Told::Beat)], + ); + assert_eq!(ended, Ended::Released); + assert_eq!( + mac.did, + [ + // Marked before the first write: a kill right after it is seen. + Mark, + Assert, + LidSleepOff, + // macOS's power daemon and LogoLight write the same switch. + LidSleepOff, + LidSleepOff, + // The pipe closed: the assertions first, then the switch. + Release, + LidSleepOn, + Unmark, + ] + ); + assert_eq!(said, [Said::Held], "said once, not on every tick"); + assert!(!mac.marked && !mac.asserted && !mac.disabled); + // It had not watched the displays for `SETTLE` yet, so it looked once more. + assert_eq!(mac.pauses, 1); + + // One that has held for a while lets go at once. + let (_, mac, _) = run(FakeMac::default(), beats(SETTLE.as_secs() as usize + 2)); + assert_eq!(mac.did[mac.did.len() - 3..], [Release, LidSleepOn, Unmark]); + assert_eq!(mac.pauses, 0); + } + + #[test] + fn a_pipe_that_closes_before_anything_was_held_writes_nothing() { + let (ended, mac, said) = run(FakeMac::default(), vec![]); + assert_eq!(ended, Ended::Released); + assert!(mac.did.is_empty(), "{:?}", mac.did); + assert!(said.is_empty()); + } + + #[test] + fn an_app_that_stops_writing_is_let_go_of() { + // Quiet waits only: the app hangs. + let quiet = STALE.as_secs() as usize + 5; + let (ended, mac, _) = run(FakeMac::default(), (0..quiet).map(|_| step(Told::Nothing)).collect()); + assert_eq!(ended, Ended::Stale); + assert_eq!(mac.did.last(), Some(&Unmark)); + assert!(!mac.disabled && !mac.asserted && !mac.marked); + // It held until then: just under a minute of ticks. + assert_eq!( + mac.did.iter().filter(|did| **did == LidSleepOff).count(), + STALE.as_secs() as usize - 1 + ); + + // A word now and then keeps it, however long. + let steps = (0..quiet * 2) + .map(|tick| step(if tick % 20 == 0 { Told::Beat } else { Told::Nothing })) + .collect(); + let (ended, _, _) = run(FakeMac::default(), steps); + assert_eq!(ended, Ended::Released, "ended by the pipe, not by the clock"); + } + + #[test] + fn one_slow_look_of_the_app_is_not_a_hang() { + let slow = step(Told::Beat).took(STALE - Duration::from_secs(1)); + let (ended, mac, _) = run(FakeMac::default(), vec![step(Told::Beat), slow, step(Told::Beat)]); + assert_eq!(ended, Ended::Released); + assert_eq!(mac.did.iter().filter(|did| **did == LidSleepOff).count(), 3); + } + + #[test] + fn on_battery_nothing_is_held_whatever_the_app_says() { + let battery = FakeMac { + battery: true, + ..FakeMac::default() + }; + let (ended, mac, said) = run(battery, vec![step(Told::Beat), step(Told::Beat)]); + assert_eq!(ended, Ended::Released); + assert!(mac.did.is_empty(), "{:?}", mac.did); + assert_eq!(said, [Said::OnBattery]); + } + + #[test] + fn unplugging_lets_go_and_plugging_in_holds_again() { + let held = SETTLE.as_secs() as usize + 1; + let mut steps = beats(held); + steps.extend([ + becomes(|mac| mac.battery = true), + step(Told::Beat), + becomes(|mac| mac.battery = false), + ]); + let (ended, mac, said) = run(FakeMac::default(), steps); + assert_eq!(ended, Ended::Released); + let mut expected = vec![Mark, Assert]; + expected.extend(std::iter::repeat_n(LidSleepOff, held)); + expected.extend([ + // Unplugged: a Mac in a bag sleeps. + Release, + LidSleepOn, + Unmark, + // Plugged in again. + Mark, + Assert, + LidSleepOff, + Release, + LidSleepOn, + Unmark, + ]); + assert_eq!(mac.did, expected); + assert_eq!(said, [Said::Held, Said::OnBattery, Said::Held]); + assert_eq!(mac.pauses, 0); + } + + #[test] + fn docked_it_writes_nothing_and_the_assertions_keep_the_mac_awake() { + let docked = FakeMac { + docked: true, + ..FakeMac::default() + }; + let (ended, mac, said) = run(docked, beats(SETTLE.as_secs() as usize + 3)); + assert_eq!(ended, Ended::Released); + // macOS's own closed-lid mode has the switch: a zero from here would put a + // docked Mac to sleep. + assert_eq!(mac.did, [Assert, Release]); + // Its own word, so the menu does not read as if Codeman held the lid. + assert_eq!(said, [Said::Docked]); + assert_eq!(mac.pauses, 0, "nothing of its own to wait for"); + assert!(!mac.marked); + + // Docked on battery: nothing at all. + let battery = FakeMac { + docked: true, + battery: true, + ..FakeMac::default() + }; + let (_, mac, said) = run(battery, beats(3)); + assert!(mac.did.is_empty(), "{:?}", mac.did); + assert_eq!(said, [Said::OnBattery]); + } + + /// What a child that held undocked for a while did up to then. + fn held_for(ticks: usize) -> Vec { + let mut did = vec![Mark, Assert]; + did.extend(std::iter::repeat_n(LidSleepOff, ticks)); + did + } + + #[test] + fn a_display_that_comes_while_it_holds_keeps_its_switch_until_the_display_goes() { + let held = SETTLE.as_secs() as usize + 1; + let mut steps = beats(held); + steps.extend([ + // Plugged into a display: macOS takes the switch over. + becomes(|mac| mac.docked = true), + step(Told::Beat), + // The app lets go with the display still there. + step(Told::Closed), + // Looks with the pipe closed: still there, still there, gone. + step(Told::Nothing), + step(Told::Nothing), + becomes(|mac| mac.docked = false), + ]); + let (ended, mac, said) = run(FakeMac::default(), steps); + assert_eq!(ended, Ended::Released); + let mut expected = held_for(held); + expected.extend([ + // Nothing is written while the display is there. The assertions go + // with the app's asking. + Release, // The display has gone and stayed gone: what this child set is cleared. + LidSleepOn, Unmark, + ]); + assert_eq!(mac.did, expected); + assert_eq!(said, [Said::Held, Said::Docked]); + assert_eq!(mac.pauses, 4, "three looks, and one more for the display to stay gone"); + } + + #[test] + fn a_display_list_that_is_short_for_a_moment_clears_nothing() { + let held = SETTLE.as_secs() as usize + 1; + let mut steps = beats(held); + steps.extend([ + becomes(|mac| mac.docked = true), + step(Told::Closed), + // Gone at one look, back at the next: displays being rearranged. + becomes(|mac| mac.docked = false), + becomes(|mac| mac.docked = true), + step(Told::Nothing), + becomes(|mac| mac.docked = false), + ]); + let (_, mac, _) = run(FakeMac::default(), steps); + let mut expected = held_for(held); + expected.extend([Release, LidSleepOn, Unmark]); + assert_eq!(mac.did, expected); + assert_eq!(mac.pauses, 5, "not cleared at the first look without a display"); + } + + #[test] + fn on_battery_at_a_display_its_switch_waits_for_the_display_to_go() { + let held = SETTLE.as_secs() as usize + 1; + let gone_for = |ticks: usize| { + let mut steps = beats(held); + steps.extend([ + becomes(|mac| mac.docked = true), + becomes(|mac| mac.battery = true), + step(Told::Beat), + becomes(|mac| mac.docked = false), + ]); + steps.extend(beats(ticks)); + run(FakeMac::default(), steps) + }; + let mut expected = held_for(held); + expected.extend([Release, LidSleepOn, Unmark]); + // The display has been gone for `SETTLE` while the app still asks. + let (_, mac, said) = gone_for(SETTLE.as_secs() as usize); + assert_eq!(mac.did, expected); + assert_eq!(said, [Said::Held, Said::Docked, Said::OnBattery]); + assert_eq!(mac.pauses, 0, "cleared while the pipe was open"); + // Not before. + let (_, mac, _) = gone_for(SETTLE.as_secs() as usize - 1); + assert_eq!(mac.did, expected); + assert_eq!(mac.pauses, 1, "not yet cleared when the pipe closed"); + } + + #[test] + fn a_mark_a_killed_child_left_is_cleared_by_the_next_one() { + let left = || FakeMac { + marked: true, + disabled: true, + ..FakeMac::default() + }; + let long = SETTLE.as_secs() as usize + 2; + // On battery, so it holds nothing of its own. + let battery = FakeMac { + battery: true, + ..left() + }; + let (_, mac, _) = run(battery, beats(long)); + assert_eq!(mac.did, [LidSleepOn, Unmark]); + assert_eq!(mac.pauses, 0); + + // At a display, not while the display is there. + let docked = FakeMac { + battery: true, + docked: true, + ..left() + }; + let mut steps = beats(long); + steps.extend([ + step(Told::Closed), + step(Told::Nothing), + becomes(|mac| mac.docked = false), + ]); + let (_, mac, _) = run(docked, steps); + assert_eq!(mac.did, [LidSleepOn, Unmark]); + assert_eq!(mac.pauses, 3); + + // Plugged in and undocked it holds, and the mark is already there. + let (_, mac, _) = run(left(), beats(2)); + assert_eq!(mac.did, [Assert, LidSleepOff, LidSleepOff, Release, LidSleepOn, Unmark]); + } + + #[test] + fn a_kernel_that_refuses_ends_it_with_nothing_left_held() { + let refusing = FakeMac { + refuses_after: Some(0), + ..FakeMac::default() + }; + let (ended, mac, said) = run(refusing, vec![step(Told::Beat), step(Told::Beat)]); + assert_eq!(ended, Ended::Refused); + assert_eq!(mac.did, [Mark, Assert, Release, Unmark]); + assert_eq!(said, [Said::Refused]); + assert!(!mac.marked && !mac.asserted); + + // Refused later (a connection that died for good): the same end. + let later = FakeMac { + refuses_after: Some(2), + ..FakeMac::default() + }; + let (ended, mac, said) = run(later, (0..5).map(|_| step(Told::Beat)).collect()); + assert_eq!(ended, Ended::Refused); + assert_eq!(mac.did, [Mark, Assert, LidSleepOff, LidSleepOff, Release, Unmark]); + assert_eq!(said, [Said::Held, Said::Refused]); + } + + #[test] + fn the_switch_is_left_to_a_logolight_that_holds_it() { + let (ended, mac, _) = run( + FakeMac::default(), + vec![step(Told::Beat), step(Told::Beat).then(|mac| mac.elsewhere = true)], + ); + assert_eq!(ended, Ended::Released); + assert_eq!( + mac.did, + [Mark, Assert, LidSleepOff, LidSleepOff, Release, Unmark], + "our assertions and our mark go, the switch stays LogoLight's" + ); + assert!(mac.disabled); + } + + #[test] + fn it_waits_for_the_child_before_it_and_touches_nothing_until_then() { + let busy = FakeMac { + takes: VecDeque::from([Taken::Busy, Taken::Busy]), + ..FakeMac::default() + }; + let (ended, mac, said) = run(busy, vec![step(Told::Beat), step(Told::Beat), step(Told::Beat)]); + assert_eq!(ended, Ended::Released); + assert_eq!(mac.did, [Mark, Assert, LidSleepOff, Release, LidSleepOn, Unmark]); + assert_eq!(said, [Said::Held]); + + // The pipe closes while it still waits: the other child's switch and mark + // are not this one's to undo. + let never = FakeMac { + takes: VecDeque::from([Taken::Busy, Taken::Busy]), + marked: true, + disabled: true, + ..FakeMac::default() + }; + let (ended, mac, said) = run(never, vec![step(Told::Beat), step(Told::Beat)]); + assert_eq!(ended, Ended::Released); + assert!(mac.did.is_empty(), "{:?}", mac.did); + assert!(mac.marked && mac.disabled); + assert!(said.is_empty()); + } + + #[test] + fn without_its_file_it_holds_nothing() { + // No lock to be had: nothing could undo the switch after a kill. + let broken = FakeMac { + takes: VecDeque::from([Taken::Broken]), + ..FakeMac::default() + }; + let (ended, mac, said) = run(broken, vec![step(Told::Beat), step(Told::Beat)]); + assert_eq!(ended, Ended::Failed); + assert!(mac.did.is_empty(), "{:?}", mac.did); + assert_eq!(said, [Said::Failed]); + + // The lock, but the mark cannot be written. + let unwritable = FakeMac { + unwritable: true, + ..FakeMac::default() + }; + let (ended, mac, said) = run(unwritable, vec![step(Told::Beat), step(Told::Beat)]); + assert_eq!(ended, Ended::Failed); + assert!(mac.did.is_empty(), "{:?}", mac.did); + assert_eq!(said, [Said::Failed]); + } + + #[test] + fn a_sleep_while_it_held_is_said_and_one_before_it_held_is_not() { + let (_, _, said) = run( + FakeMac::default(), + vec![step(Told::Beat), step(Told::Beat).slept("07:42"), step(Told::Beat)], + ); + assert_eq!(said, [Said::Held, Said::Slept("07:42".into())]); + + // Asleep on battery in a bag, then plugged in: that sleep was the point. + let battery = FakeMac { + battery: true, + ..FakeMac::default() + }; + let (_, _, said) = run( + battery, + vec![ + step(Told::Beat), + step(Told::Beat).slept("07:42").then(|mac| mac.battery = false), + step(Told::Beat), + ], + ); + assert_eq!(said, [Said::OnBattery, Said::Held]); + + // At a display the Mac is meant to stay awake too, by macOS's own mode. A + // sleep there says it is not that mode after all. + let docked = FakeMac { + docked: true, + ..FakeMac::default() + }; + let (_, _, said) = run(docked, vec![step(Told::Beat), step(Told::Beat).slept("07:42")]); + assert_eq!(said, [Said::Docked, Said::Slept("07:42".into())]); + } + + #[test] + fn healing_undoes_only_what_a_dead_child_left() { + let heal = |mac: FakeMac| { + let mac = std::cell::RefCell::new(mac); + let healed = heal_with(&mut Split(&mac), &mut Split(&mac)); + (healed, mac.into_inner()) + }; + // Marked and unlocked: a child was killed. + let (healed, mac) = heal(FakeMac { + marked: true, + disabled: true, + ..FakeMac::default() + }); + assert!(healed); + assert_eq!(mac.did, [LidSleepOn, Unmark]); + // A clean end left nothing. + let (healed, mac) = heal(FakeMac::default()); + assert!(!healed); + assert!(mac.did.is_empty()); + // A child lives (this app's, or another copy's): its switch. + let (healed, mac) = heal(FakeMac { + marked: true, + disabled: true, + takes: VecDeque::from([Taken::Busy]), + ..FakeMac::default() + }); + assert!(!healed); + assert!(mac.did.is_empty() && mac.marked && mac.disabled); + // LogoLight holds the switch for itself: only the mark goes. + let (healed, mac) = heal(FakeMac { + marked: true, + disabled: true, + elsewhere: true, + ..FakeMac::default() + }); + assert!(healed); + assert_eq!(mac.did, [Unmark]); + assert!(mac.disabled); + // At a display the switch may be macOS's: the mark stays for the next child. + let (healed, mac) = heal(FakeMac { + marked: true, + disabled: true, + docked: true, + ..FakeMac::default() + }); + assert!(!healed); + assert!(mac.did.is_empty() && mac.marked && mac.disabled); + } + + #[test] + fn a_line_reads_back_as_what_was_said() { + for said in [ + Said::Held, + Said::Docked, + Said::OnBattery, + Said::Refused, + Said::Failed, + Said::Slept("07:42".into()), + ] { + assert_eq!(Said::parse(&said.line()), Some(said)); + } + assert_eq!(Said::parse("held\r\n"), Some(Said::Held)); + assert_eq!(Said::parse(""), None); + assert_eq!(Said::parse("something else"), None); + } + + #[test] + fn what_the_app_heard_is_what_the_status_line_gets() { + let mut heard = Heard::default(); + assert_eq!(heard.lid(None), Lid::Starting); + heard.hear(Said::Held); + assert_eq!(heard.lid(None), Lid::Held { slept: None }); + // A sleep is a note, not a state: the lid is still held after it. + heard.hear(Said::Slept("07:42".into())); + assert_eq!(heard.slept.as_deref(), Some("07:42")); + assert_eq!( + heard.lid(heard.slept.clone()), + Lid::Held { + slept: Some("07:42".into()) + } + ); + heard.hear(Said::Docked); + assert_eq!(heard.lid(None), Lid::Docked { slept: None }); + heard.hear(Said::OnBattery); + assert_eq!(heard.lid(None), Lid::OnBattery); + heard.hear(Said::Refused); + assert_eq!(heard.lid(None), Lid::Refused); + heard.hear(Said::Failed); + assert_eq!(heard.lid(None), Lid::Failed); + } +} diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index 1eb39296..4017ac45 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -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 { diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index c296c3a1..7d5f32b5 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -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::::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(), diff --git a/packages/desktop/src/menu.rs b/packages/desktop/src/menu.rs index f6e01722..3103ab25 100644 --- a/packages/desktop/src/menu.rs +++ b/packages/desktop/src/menu.rs @@ -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, - /// 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, + /// 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, + 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, }, )) diff --git a/test/desktop-keep-awake-lid.test.ts b/test/desktop-keep-awake-lid.test.ts deleted file mode 100644 index 642db390..00000000 --- a/test/desktop-keep-awake-lid.test.ts +++ /dev/null @@ -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(''); - }); -});