From 79a6c03363274f0804b5a45b574b7bad61161ddf Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 9 Nov 2026 04:51:39 +0100 Subject: [PATCH] feat(desktop): keep the Mac awake while agents work, lid closed included Codeman > Keep This Mac Awake in the macOS app: While Agents Work (an agent at work, plus 5 minutes after the last one stops) or Always While Codeman Is Open. Off by default, and only for a Codeman on this Mac. - Idle sleep needs no password: the app holds an NSProcessInfo activity with IdleSystemSleepDisabled, which the kernel drops with the process. While it only watches for work, an activity that still allows sleep keeps App Nap from slowing its look at the server. - On a MacBook, Only on Power Adapter (on by default) pauses it on battery, and Also With the Lid Closed installs a small LaunchDaemon behind macOS's administrator prompt. It sets pmset disablesleep only while the app keeps a fresh request file with its live pid, only on the power adapter, and undoes only what it set itself. The app writes the script and plist as root from the copies built into it. - A status line at the bottom of the submenu says what it does now. This brings the macOS part of the keep-awake idea from PR #595 into the app; the server-side setting and the Linux route are left out for now. Co-Authored-By: Claude Opus 5.5 --- docs/wiki/Installation.md | 9 + packages/desktop/Cargo.toml | 1 + packages/desktop/README.md | 33 + packages/desktop/macos/keep-awake-lid.sh | 74 + packages/desktop/src/config.rs | 37 + packages/desktop/src/keep_awake.rs | 1806 ++++++++++++++++++++++ packages/desktop/src/macos.rs | 18 + packages/desktop/src/main.rs | 163 ++ packages/desktop/src/menu.rs | 99 ++ test/desktop-keep-awake-lid.test.ts | 234 +++ 10 files changed, 2474 insertions(+) create mode 100644 packages/desktop/macos/keep-awake-lid.sh create mode 100644 packages/desktop/src/keep_awake.rs create mode 100644 test/desktop-keep-awake-lid.test.ts diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 094aa160..d2139b18 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -252,6 +252,15 @@ On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, your own for working, for waiting for you and for needing permission. All three are off until you choose, and the menu only exists on such a Mac. +**Codeman > Keep This Mac Awake** stops the Mac from going to sleep while your agents work +on it (**While Agents Work**, plus 5 minutes after the last one stops), or for as long as +the app is open (**Always While Codeman Is Open**). It is off until you choose, only acts +for a Codeman running on this Mac, needs no password, and ends when the app quits. On a +MacBook it pauses on battery unless you untick **Only on Power Adapter**, and **Also With +the Lid Closed…** keeps a closed MacBook awake too: that one asks for your administrator +password once to install a small helper, never acts on battery, and **Remove the Lid +Helper…** takes it out again. + ### First launch 1. Open `Codeman.dmg` and drag Codeman into Applications. diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 39708944..53db63bf 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -50,6 +50,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [ "NSGeometry", "NSObject", "NSOperation", + "NSProcessInfo", "NSRange", "NSSet", "NSString", diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 01aa4a22..d1a0742d 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -41,6 +41,7 @@ cannot do. | **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. | | **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. | | **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. | +| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always While Codeman Is Open**. Off until you choose. Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says there is nothing to keep awake here. The display still sleeps. No password is needed for this part: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only on Power Adapter** (on by default) pauses it on battery, and **Also With the Lid Closed…** keeps a closed MacBook awake as well: nothing an app holds can do that, so the first time it asks for your administrator password and installs a small helper (see Decisions). The lid is never kept awake on battery, whatever the settings, and while the lid is covered *Sleep* in the Apple menu does nothing. **Remove the Lid Helper…** takes it out again. A line at the bottom of the menu says what it does right now. It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. | | **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) @@ -147,6 +148,30 @@ 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.** +macOS sleeps on lid close whatever an app asks: `caffeinate` and every power +assertion stop idle sleep only. `pmset -a disablesleep 1` keeps a closed MacBook +awake, but it is a machine-wide root setting with no owner process, so nothing +would undo it after a crash. So *Also With the Lid Closed…* installs a +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. + **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 no password and therefore no terminal, and the two questions only a person can @@ -319,6 +344,14 @@ crosses, so the login is never on a command line. dark phase the logo stays dark until the app next starts. `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. - **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a self-signed certificate, and WKWebView rejects it (wry has no trust override). Use `http://127.0.0.1:3000` for a server on this Mac. For another diff --git a/packages/desktop/macos/keep-awake-lid.sh b/packages/desktop/macos/keep-awake-lid.sh new file mode 100644 index 00000000..f545d5fc --- /dev/null +++ b/packages/desktop/macos/keep-awake-lid.sh @@ -0,0 +1,74 @@ +#!/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/config.rs b/packages/desktop/src/config.rs index 98b4ee57..754202d6 100644 --- a/packages/desktop/src/config.rs +++ b/packages/desktop/src/config.rs @@ -3,6 +3,7 @@ //! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords //! never go here; they live in the Keychain (see `credentials`). +use crate::keep_awake; use crate::logo::BlinkStyles; use serde::{Deserialize, Serialize}; use std::fs; @@ -49,6 +50,10 @@ pub struct Config { /// reads as off: a bad style must not make the whole file unreadable. #[serde(deserialize_with = "lenient_blink")] pub logo_blink: BlinkStyles, + /// Keep This Mac Awake (keep_awake.rs): when, only on the power adapter, and + /// with the lid closed too. Read field by field, like `logo_blink`. + #[serde(deserialize_with = "lenient_keep_awake")] + pub keep_awake: keep_awake::Settings, /// Anything that is not a whole frame reads as None: a bad frame must not /// make the whole file unreadable, which would cost the server list. #[serde(deserialize_with = "lenient_window")] @@ -64,6 +69,10 @@ fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result>(d: D) -> Result { + Ok(keep_awake::Settings::read(&serde_json::Value::deserialize(d)?)) +} + impl Default for Config { fn default() -> Self { Config { @@ -71,6 +80,7 @@ impl Default for Config { auto_connect: true, start_fullscreen: false, logo_blink: BlinkStyles::default(), + keep_awake: keep_awake::Settings::default(), window: None, } } @@ -429,6 +439,33 @@ mod tests { } } + #[test] + fn keep_awake_round_trips_and_a_bad_value_never_costs_the_file() { + use crate::keep_awake::{Mode, Settings}; + let mut c = Config::default(); + assert_eq!(c.keep_awake, Settings::default()); + c.keep_awake.mode = Mode::WhileWorking; + c.keep_awake.lid = true; + let text = serde_json::to_string(&c).unwrap(); + assert!( + text.contains(r#""keepAwake":{"mode":"whileWorking","acOnly":true,"lid":true}"#), + "{text}" + ); + assert_eq!(serde_json::from_str::(&text).unwrap().keep_awake, c.keep_awake); + for bad in [ + r#"{"keepAwake":3}"#, + r#"{"keepAwake":null}"#, + r#"{"keepAwake":"always"}"#, + ] { + let c: Config = serde_json::from_str(bad).unwrap(); + assert_eq!(c.keep_awake, Settings::default(), "{bad}"); + } + // A file from before the setting: every other field still reads. + let c: Config = serde_json::from_str(r#"{"startFullscreen":true}"#).unwrap(); + assert!(c.start_fullscreen); + assert_eq!(c.keep_awake, Settings::default()); + } + #[test] fn the_style_a_mac_saved_before_there_were_states_is_kept() { use crate::logo::BlinkStyle; diff --git a/packages/desktop/src/keep_awake.rs b/packages/desktop/src/keep_awake.rs new file mode 100644 index 00000000..5ee9e83a --- /dev/null +++ b/packages/desktop/src/keep_awake.rs @@ -0,0 +1,1806 @@ +//! 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. +//! +//! 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. +//! +//! When it holds is the user's pick: **While Agents Work** (an agent at work, and for +//! `GRACE` after the last one stopped, so the gap between two turns or a reply from +//! the phone does not let the Mac drop off), or **Always While Codeman Is Open**. Only +//! for a Codeman on this Mac: keeping this Mac awake does nothing for agents that run +//! on another machine. Only on the power adapter unless the user says otherwise (a +//! MacBook only), and the lid never on battery, whatever the setting. +//! +//! 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. +//! +//! The agents are asked natively, like the logo blink does (`agents`): WebKit stalls the +//! event stream of a hidden page. A worker thread does the asking and the holding, and +//! while it watches for work it keeps App Nap off (an activity that still allows sleep), +//! so a hidden app does not see work late. + +use crate::agents::{Poll, Poller, POLL_EVERY}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +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. +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"; +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"; + +pub const AC_ONLY_ID: &str = "keep-awake-ac-only"; +pub const LID_ID: &str = "keep-awake-lid"; +pub const REMOVE_HELPER_ID: &str = "keep-awake-remove-helper"; + +/// The status line while nothing is watched. +pub const OFF_TEXT: &str = "Off: this Mac sleeps as usual"; +pub const NO_SERVER_TEXT: &str = "Starts when Codeman on this Mac is open"; + +/// 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."; + +/// When the Mac is kept awake. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mode { + /// The default: it changes how the Mac sleeps, so it is asked for. + #[default] + Off, + WhileWorking, + Always, +} + +impl Mode { + pub const ALL: [Mode; 3] = [Mode::Off, Mode::WhileWorking, Mode::Always]; + + pub fn label(self) -> &'static str { + match self { + Mode::Off => "Off", + Mode::WhileWorking => "While Agents Work", + Mode::Always => "Always While Codeman Is Open", + } + } + + pub fn menu_id(self) -> &'static str { + match self { + Mode::Off => "keep-awake-off", + Mode::WhileWorking => "keep-awake-while-working", + Mode::Always => "keep-awake-always", + } + } + + pub fn from_menu_id(id: &str) -> Option { + Self::ALL.into_iter().find(|mode| mode.menu_id() == id) + } +} + +/// The `keepAwake` object in `desktop.json`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Settings { + pub mode: Mode, + /// 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. + pub lid: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + mode: Mode::Off, + ac_only: true, + lid: false, + } + } +} + +impl Settings { + /// Each field on its own: a value this version does not know reads as that field's + /// default, never as a broken file. + pub fn read(value: &Value) -> Self { + let default = Settings::default(); + Settings { + mode: value + .get("mode") + .and_then(|mode| Mode::deserialize(mode).ok()) + .unwrap_or(default.mode), + ac_only: value.get("acOnly").and_then(Value::as_bool).unwrap_or(default.ac_only), + lid: value.get("lid").and_then(Value::as_bool).unwrap_or(default.lid), + } + } +} + +/// What the worker knows about the agents on one look. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Agents { + /// Not asked (Always, or a server elsewhere), or no answer and no recent work. + Unknown, + /// The server turned the login down; it is not asked again. + Refused, + Working, + /// None at work now, but one was within `GRACE` (or the server stopped answering + /// within it, a restart, say). + WorkedRecently, + Idle, +} + +/// What one look found. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Facts { + /// The server the window shows runs on this Mac. + 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Off, + Remote, + OnBattery, + NoAnswer, + Refused, + Idle, + Working, + Recently, + Always, +} + +impl Status { + fn text(self) -> &'static str { + match self { + Status::Off => OFF_TEXT, + Status::Remote => "Nothing to keep awake: this Codeman runs on another machine", + Status::OnBattery => "Paused on battery power", + Status::NoAnswer => "Codeman does not answer, so this Mac may sleep", + Status::Refused => "Codeman refused the login, so the agents cannot be seen", + Status::Idle => "No agent is working, so this Mac may sleep", + Status::Working => "Awake: agents are working", + Status::Recently => "Awake: an agent worked in the last 5 minutes", + Status::Always => "Awake while Codeman is open", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Decision { + /// Hold the idle-sleep assertion. + pub hold: bool, + /// Ask the helper to keep the Mac awake with the lid closed. + pub lid: bool, + pub status: Status, +} + +/// The whole rule, in one place. +pub fn decide(settings: Settings, facts: Facts) -> Decision { + let status = match settings.mode { + Mode::Off => Status::Off, + _ if !facts.local => Status::Remote, + _ if settings.ac_only && facts.on_battery => Status::OnBattery, + Mode::Always => Status::Always, + Mode::WhileWorking => match facts.agents { + Agents::Unknown => Status::NoAnswer, + Agents::Refused => Status::Refused, + Agents::Working => Status::Working, + Agents::WorkedRecently => Status::Recently, + Agents::Idle => Status::Idle, + }, + }; + let hold = matches!(status, Status::Working | Status::Recently | Status::Always); + Decision { + hold, + lid: hold && settings.lid && facts.helper && !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 { + 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 { + "the lid helper needs installing again" + } else if facts.on_battery { + "not with the lid closed on battery" + } else if covered == Some(true) { + "also with the lid closed" + } else { + "the lid helper has not answered yet" + }; + format!("{text}; {lid}") +} + +/// Whether `server` is this Mac: a loopback address or `localhost`, or an address +/// literal one of this Mac's interfaces has. A host name is not looked up. +pub fn is_this_mac(server: &Url, local_addresses: &[IpAddr]) -> bool { + if crate::policy::is_loopback(server) { + return true; + } + let ip = match server.host() { + Some(Host::Ipv4(ip)) => IpAddr::V4(ip), + Some(Host::Ipv6(ip)) => IpAddr::V6(ip), + _ => return false, + }; + local_addresses.contains(&ip) +} + +/// The server to watch, its login and what to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Watch { + pub server: Url, + pub authorization: Option, + pub settings: Settings, +} + +/// What the worker asks of the server, so tests can stand in for it. +pub trait AgentSource { + fn poll(&mut self) -> Poll; +} + +impl AgentSource for Poller { + fn poll(&mut self) -> Poll { + Poller::poll(self) + } +} + +/// What the process holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hold { + None, + /// Watching for work: App Nap off, sleep allowed. + Watching, + /// Idle sleep held off. + Holding, +} + +/// What the worker does to the Mac, so tests can stand in for it. +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; + fn is_this_mac(&mut self, server: &Url) -> bool; +} + +/// Made inside the worker thread, so it needs no `Send`. +type NewSource = Box Option>>; + +/// The worker's state between looks. +struct Worker { + sys: S, + new_source: NewSource, + watch: Option, + local: bool, + source: Option>, + 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, +} + +impl Worker { + fn new(sys: S, new_source: NewSource) -> Self { + Worker { + sys, + new_source, + watch: None, + local: false, + source: None, + refused: false, + working: None, + last_work: None, + helper: None, + request_at: None, + covered: None, + hold: Hold::None, + status: None, + } + } + + /// A new value from the app: another server, other settings, or nothing. + fn update(&mut self, watch: Option) { + let other_server = match (&self.watch, &watch) { + (Some(was), Some(now)) => was.server != now.server || was.authorization != now.authorization, + (None, None) => false, + _ => true, + }; + if other_server { + self.source = None; + self.refused = false; + self.working = None; + self.last_work = None; + self.local = match &watch { + Some(watch) => self.sys.is_this_mac(&watch.server), + None => false, + }; + } + 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; + } + + /// Whether the worker has to look again on its own: something to hold or to + /// watch for. A server elsewhere is told once and waits for the app. + fn ticking(&self) -> bool { + self.local + && self + .watch + .as_ref() + .is_some_and(|watch| watch.settings.mode != Mode::Off) + } + + /// One look. The new status line, when it changed. + fn look(&mut self, now: Instant) -> Option { + let Some(watch) = self.watch.clone() else { + self.release(); + return None; + }; + let settings = watch.settings; + let agents = self.agents(&watch, now); + let facts = Facts { + 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; + self.set_hold(match (decision.hold, watching) { + (true, _) => Hold::Holding, + (false, true) => Hold::Watching, + (false, false) => Hold::None, + }); + let covered = self.lid(decision.lid, now); + let line = status_line(decision, settings, facts, covered); + (self.status.as_deref() != Some(line.as_str())).then(|| { + self.status = Some(line.clone()); + line + }) + } + + fn agents(&mut self, watch: &Watch, now: Instant) -> Agents { + if !self.local || watch.settings.mode != Mode::WhileWorking { + return Agents::Unknown; + } + if !self.refused { + if self.source.is_none() { + self.source = (self.new_source)(watch); + } + let poll = self.source.as_mut().map_or(Poll::Failed, |source| source.poll()); + match poll { + Poll::Seen(activity) => { + self.working = Some(activity.working); + if activity.working { + self.last_work = Some(now); + } + } + Poll::Failed => self.working = None, + Poll::Refused => { + // Codeman counts each refused login against its rate limit. + self.refused = true; + self.working = None; + self.source = None; + } + } + } + let recently = self.last_work.is_some_and(|at| now.duration_since(at) < GRACE); + match (self.refused, self.working) { + (true, _) => Agents::Refused, + (false, Some(true)) => Agents::Working, + _ if recently => Agents::WorkedRecently, + (false, Some(false)) => Agents::Idle, + (false, None) => Agents::Unknown, + } + } + + 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); + self.hold = hold; + } + } + + /// 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; + } +} + +/// The worker thread: a look every `TICK` while there is something to watch, and at +/// once for every new value. Ends, and lets go of everything, when the app drops its +/// end of the channel. +fn run(rx: &Receiver<(u64, Option)>, mut worker: Worker, report: &dyn Fn(u64, String)) { + let mut generation = 0; + loop { + let message = if worker.ticking() { + rx.recv_timeout(TICK) + } else { + rx.recv().map_err(|_| RecvTimeoutError::Disconnected) + }; + match message { + Ok(first) => { + let (latest, watch) = rx.try_iter().last().unwrap_or(first); + generation = latest; + worker.update(watch); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + if let Some(line) = worker.look(Instant::now()) { + report(generation, line); + } + } + worker.release(); +} + +type Report = Box; + +/// The app's side: the channel to the worker, which starts with the first thing to +/// watch. +pub struct KeepAwake { + sender: Option)>>, + report: Option, + generation: u64, + current: Option, +} + +impl KeepAwake { + /// `report` gets the worker's status lines, each with the number of the value it + /// is about (`is_current`). + pub fn new(report: impl Fn(u64, String) + Send + 'static) -> Self { + KeepAwake { + sender: None, + report: Some(Box::new(report)), + generation: 0, + current: None, + } + } + + /// Watch this server with these settings, or nothing. + pub fn sync(&mut self, watch: Option) { + if watch != self.current { + self.current = watch; + self.send(); + } + } + + /// 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() + } + + fn send(&mut self) { + self.generation += 1; + if self.sender.is_none() && self.current.is_some() { + self.sender = self.report.take().and_then(spawn); + } + if let Some(sender) = &self.sender { + let _ = sender.send((self.generation, self.current.clone())); + } + } + + /// 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. + pub fn shutdown(&mut self) { + self.sender = None; + if let Some(path) = request_path() { + clear_request_file(&path); + } + } +} + +#[cfg(target_os = "macos")] +fn spawn(report: Report) -> Option)>> { + let (sender, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("keep-awake".into()) + .spawn(move || { + let new_source: NewSource = Box::new(|watch: &Watch| { + 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); + run(&rx, worker, &*report); + }) + .ok()?; + Some(sender) +} + +#[cfg(not(target_os = "macos"))] +fn spawn(_report: Report) -> Option)>> { + None +} + +/// Whether this build can keep the Mac awake at all. +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)) +} + +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() +} + +/// 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); + } +} + +/// 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. +pub fn remove_command(root: &str) -> String { + let dir = format!("{root}{HELPER_DIR}"); + let script = format!("{root}{HELPER_SCRIPT}"); + let plist = format!("{root}{HELPER_PLIST}"); + let owned = format!("{root}{HELPER_OWNED}"); + [ + format!("launchctl bootout system/{HELPER_LABEL} 2>/dev/null || true"), + format!("if [ -f '{owned}' ]; then pmset -a disablesleep 0 || true; fi"), + format!("rm -f '{plist}' '{script}' '{owned}'"), + format!("rmdir '{dir}' 2>/dev/null || true"), + ] + .join("; ") +} + +/// `do shell script` with macOS's administrator prompt. None for text that would end +/// the AppleScript string early. +pub fn applescript(command: &str, prompt: &str) -> Option { + let safe = |text: &str| !text.contains(['"', '\\']); + (safe(command) && safe(prompt)) + .then(|| format!("do shell script \"{command}\" with prompt \"{prompt}\" with administrator privileges")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdminOutcome { + Done, + Cancelled, + 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 { + run_admin(&remove_command(""), REMOVE_PROMPT) +} + +/// In osascript, not in the app: `do shell script` needs nothing of the app's hardened +/// runtime that way, and the event loop goes on while the prompt is open. +fn run_admin(command: &str, prompt: &str) -> AdminOutcome { + let Some(script) = applescript(command, prompt) else { + return AdminOutcome::Failed("The command could not be written safely.".into()); + }; + match std::process::Command::new("/usr/bin/osascript") + .arg("-e") + .arg(script) + .output() + { + Ok(out) if out.status.success() => AdminOutcome::Done, + Ok(out) => { + let error = String::from_utf8_lossy(&out.stderr); + // "execution error: User canceled. (-128)" + if error.contains("(-128)") { + AdminOutcome::Cancelled + } else { + AdminOutcome::Failed(error.trim().chars().take(400).collect()) + } + } + Err(error) => AdminOutcome::Failed(error.to_string()), + } +} + +#[cfg(target_os = "macos")] +pub use system::has_lid; + +/// A desktop Mac has no lid and always runs on mains. +#[cfg(not(target_os = "macos"))] +pub fn has_lid() -> bool { + false +} + +#[cfg(target_os = "macos")] +mod system { + use super::{clear_request_file, helper_state, sleep_disabled, write_request_file, HelperState, Hold, System}; + 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; + + /// What `pmset -g assertions` and Activity Monitor show for the app. + const HOLDING: &str = "Codeman keeps this Mac awake while its agents work"; + const WATCHING: &str = "Codeman watches its agents to keep this Mac awake"; + + #[link(name = "IOKit", kind = "framework")] + extern "C" { + fn IOPSCopyPowerSourcesInfo() -> *const c_void; + fn IOPSGetProvidingPowerSourceType(snapshot: *const c_void) -> *const c_void; + fn IOServiceMatching(name: *const c_char) -> *mut c_void; + fn IOServiceGetMatchingService(main_port: u32, matching: *mut c_void) -> u32; + fn IORegistryEntryCreateCFProperty( + entry: u32, + key: *const c_void, + allocator: *const c_void, + options: u32, + ) -> *const c_void; + fn IOObjectRelease(object: u32) -> i32; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFRelease(object: *const c_void); + } + + /// "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 { + // 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 + // bridged to NSString. + unsafe { + let snapshot = IOPSCopyPowerSourcesInfo(); + if snapshot.is_null() { + return false; + } + let kind = IOPSGetProvidingPowerSourceType(snapshot); + let battery = !kind.is_null() && (*kind.cast::()).to_string() == "Battery Power"; + CFRelease(snapshot); + battery + } + } + + /// A MacBook: the power management root has a clamshell state (what `ioreg -r -k + /// AppleClamshellState` finds). Asked once. + pub fn has_lid() -> bool { + static LID: OnceLock = OnceLock::new(); + *LID.get_or_init(|| { + let key = NSString::from_str("AppleClamshellState"); + // SAFETY: IOServiceMatching returns a dictionary that + // IOServiceGetMatchingService consumes; the service is released below. + // IORegistryEntryCreateCFProperty returns an owned CFTypeRef or NULL, and + // takes the NSString as its toll-free bridged CFString. + unsafe { + let matching = IOServiceMatching(c"IOPMrootDomain".as_ptr()); + if matching.is_null() { + return false; + } + let root = IOServiceGetMatchingService(0, matching); + if root == 0 { + return false; + } + let value = IORegistryEntryCreateCFProperty(root, Retained::as_ptr(&key).cast(), std::ptr::null(), 0); + IOObjectRelease(root); + if value.is_null() { + return false; + } + CFRelease(value); + true + } + }) + } + + /// Every address of this Mac's interfaces. + fn local_addresses() -> Vec { + let mut found = Vec::new(); + // SAFETY: getifaddrs fills a list that freeifaddrs frees; each ifa_addr is + // NULL or a sockaddr whose family says which struct it is. + unsafe { + let mut list: *mut libc::ifaddrs = std::ptr::null_mut(); + if libc::getifaddrs(&mut list) != 0 { + return found; + } + let mut entry = list; + while !entry.is_null() { + let addr = (*entry).ifa_addr; + if !addr.is_null() { + match i32::from((*addr).sa_family) { + libc::AF_INET => { + let v4 = &*addr.cast::(); + found.push(IpAddr::V4(Ipv4Addr::from(u32::from_be(v4.sin_addr.s_addr)))); + } + libc::AF_INET6 => { + let v6 = &*addr.cast::(); + found.push(IpAddr::V6(Ipv6Addr::from(v6.sin6_addr.s6_addr))); + } + _ => {} + } + } + entry = (*entry).ifa_next; + } + libc::freeifaddrs(list); + } + found + } + + pub struct Mac { + activity: Option>>, + request: Option, + } + + impl Mac { + pub fn new(request: Option) -> Self { + Mac { + activity: None, + request, + } + } + } + + impl System for Mac { + fn on_battery(&mut self) -> bool { + on_battery() + } + + fn set_hold(&mut self, hold: Hold) { + let info = NSProcessInfo::processInfo(); + let next = match hold { + Hold::None => None, + Hold::Watching => Some((NSActivityOptions::UserInitiatedAllowingIdleSystemSleep, WATCHING)), + Hold::Holding => Some((NSActivityOptions::UserInitiated, HOLDING)), + } + .map(|(options, reason)| info.beginActivityWithOptions_reason(options, &NSString::from_str(reason))); + // The new one first, so a change from watching to holding has no gap. + if let Some(previous) = std::mem::replace(&mut self.activity, next) { + // SAFETY: the object beginActivityWithOptions:reason: returned. + unsafe { info.endActivity(&previous) }; + } + } + + 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 is_this_mac(&mut self, server: &Url) -> bool { + super::is_this_mac(server, &local_addresses()) + } + } + + impl Drop for Mac { + fn drop(&mut self) { + self.set_hold(Hold::None); + } + } + + #[cfg(test)] + mod tests { + use super::*; + + /// What this Mac answers, for a look by hand on a real Mac: + /// `cargo test what_this_mac_answers -- --ignored --nocapture`, with + /// `pmset -g assertions | grep -i codeman` in another terminal. + #[test] + #[ignore] + fn what_this_mac_answers() { + println!("has a lid: {}", has_lid()); + println!("on battery: {}", on_battery()); + println!("addresses: {:?}", local_addresses()); + let mut mac = Mac::new(None); + mac.set_hold(Hold::Holding); + println!("holding for 15 s"); + std::thread::sleep(std::time::Duration::from_secs(15)); + mac.set_hold(Hold::Watching); + println!("watching for 15 s"); + 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"); + 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 _ = std::fs::remove_dir_all(&out_dir); + } + + /// This process's lines in `pmset -g assertions`. + fn own_assertions() -> String { + let out = std::process::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 whole chain on a real Mac: the worker thread, the poller and the + /// assertion, against a stand-in server on loopback whose agent works. + /// `cargo test holds_while_a_local_server_says_an_agent_works -- --ignored --nocapture` + #[test] + #[ignore] + fn holds_while_a_local_server_says_an_agent_works() { + use super::super::{KeepAwake, Mode, Settings, Watch}; + use std::io::{Read, Write}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for mut stream in listener.incoming().flatten() { + let mut request = [0u8; 4096]; + let _ = stream.read(&mut request); + let body = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + let lines = Arc::new(Mutex::new(Vec::new())); + let seen = lines.clone(); + let mut keep = KeepAwake::new(move |_, line| seen.lock().unwrap().push(line)); + assert!(own_assertions().is_empty()); + keep.sync(Some(Watch { + server: Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(), + authorization: None, + settings: Settings { + mode: Mode::WhileWorking, + ac_only: true, + lid: false, + }, + })); + std::thread::sleep(Duration::from_secs(3)); + let held = own_assertions(); + println!("while the agent works:\n{held}"); + assert!(held.contains("PreventUserIdleSystemSleep"), "{held}"); + println!("status lines: {:?}", lines.lock().unwrap()); + keep.sync(None); + std::thread::sleep(Duration::from_secs(1)); + let after = own_assertions(); + println!("after the app watches nothing: {after:?}"); + assert!(after.is_empty(), "{after}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::Activity; + use std::cell::RefCell; + use std::rc::Rc; + + fn url(text: &str) -> Url { + Url::parse(text).unwrap() + } + + const WORK: Settings = Settings { + mode: Mode::WhileWorking, + ac_only: true, + lid: false, + }; + const ALWAYS: Settings = Settings { + mode: Mode::Always, + ac_only: true, + lid: false, + }; + const HERE: Facts = Facts { + local: true, + on_battery: false, + agents: Agents::Working, + helper: false, + }; + + #[test] + fn the_setting_reads_each_field_on_its_own() { + assert_eq!(Settings::read(&Value::Null), Settings::default()); + assert_eq!(Settings::default().mode, Mode::Off); + assert!(Settings::default().ac_only); + assert!(!Settings::default().lid); + let read = Settings::read(&serde_json::json!({ "mode": "whileWorking", "acOnly": false, "lid": true })); + assert_eq!( + read, + Settings { + mode: Mode::WhileWorking, + ac_only: false, + lid: true + } + ); + // A mode from a later version, and fields of the wrong type: each its default. + let read = Settings::read(&serde_json::json!({ "mode": "sometimes", "acOnly": "no", "lid": 1 })); + assert_eq!(read, Settings::default()); + let read = Settings::read(&serde_json::json!({ "mode": "always" })); + assert_eq!(read, ALWAYS); + } + + #[test] + fn the_setting_round_trips() { + let settings = Settings { + mode: Mode::Always, + ac_only: false, + lid: true, + }; + let text = serde_json::to_value(settings).unwrap(); + assert_eq!( + text, + serde_json::json!({ "mode": "always", "acOnly": false, "lid": true }) + ); + assert_eq!(Settings::read(&text), settings); + } + + #[test] + fn the_grace_is_the_one_the_status_line_names() { + assert_eq!(GRACE, Duration::from_secs(5 * 60)); + assert!(Status::Recently.text().contains("5 minutes")); + } + + #[test] + fn a_menu_id_names_one_mode() { + for mode in Mode::ALL { + assert_eq!(Mode::from_menu_id(mode.menu_id()), Some(mode)); + } + assert_eq!(Mode::from_menu_id(AC_ONLY_ID), None); + assert_eq!(Mode::from_menu_id(LID_ID), None); + } + + #[test] + fn it_holds_while_agents_work_and_for_the_grace_after() { + assert!(decide(WORK, HERE).hold); + let recently = Facts { + agents: Agents::WorkedRecently, + ..HERE + }; + assert_eq!(decide(WORK, recently).status, Status::Recently); + assert!(decide(WORK, recently).hold); + for agents in [Agents::Idle, Agents::Unknown, Agents::Refused] { + assert!(!decide(WORK, Facts { agents, ..HERE }).hold, "{agents:?}"); + } + } + + #[test] + fn always_holds_whatever_the_agents_do() { + let idle = Facts { + agents: Agents::Unknown, + ..HERE + }; + assert_eq!(decide(ALWAYS, idle).status, Status::Always); + assert!(decide(ALWAYS, idle).hold); + } + + #[test] + fn off_and_a_server_elsewhere_never_hold() { + let off = Settings { + mode: Mode::Off, + ..ALWAYS + }; + assert_eq!(decide(off, HERE).status, Status::Off); + assert!(!decide(off, HERE).hold); + let elsewhere = Facts { local: false, ..HERE }; + for settings in [WORK, ALWAYS] { + assert_eq!(decide(settings, elsewhere).status, Status::Remote); + assert!(!decide(settings, elsewhere).hold); + } + } + + #[test] + fn battery_pauses_it_only_when_asked_to() { + let battery = Facts { + on_battery: true, + ..HERE + }; + assert_eq!(decide(ALWAYS, battery).status, Status::OnBattery); + assert!(!decide(ALWAYS, battery).hold); + let anywhere = Settings { + ac_only: false, + ..ALWAYS + }; + assert!(decide(anywhere, battery).hold); + } + + #[test] + fn the_lid_needs_the_setting_the_helper_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, + Facts { + agents: Agents::Idle, + ..ready + } + ) + .lid, + "nothing held" + ); + // Never on battery, even when the idle hold goes on there. + let anywhere = Settings { ac_only: false, ..lid }; + let battery = Facts { + on_battery: true, + ..ready + }; + assert!(decide(anywhere, battery).hold); + assert!(!decide(anywhere, battery).lid); + } + + #[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 are working"); + assert_eq!( + line(lid, ready, Some(true)), + "Awake: agents are working; also with the lid closed" + ); + assert_eq!( + line(lid, ready, Some(false)), + "Awake: agents are working; the lid helper has not answered yet" + ); + let battery = Facts { + on_battery: true, + ..ready + }; + let anywhere = Settings { ac_only: false, ..lid }; + assert_eq!( + line(anywhere, battery, None), + "Awake: agents are working; not with the lid closed on battery" + ); + // Asked for, but no helper (an update changed it): the line says so. + assert_eq!( + line(lid, HERE, None), + "Awake: agents are working; the lid helper needs installing again" + ); + // Not held: nothing about the lid. + let idle = Facts { + agents: Agents::Idle, + ..ready + }; + assert_eq!(line(lid, idle, None), "No agent is working, so this Mac may sleep"); + } + + #[test] + fn this_mac_is_loopback_or_one_of_its_own_addresses() { + let own: Vec = vec!["192.168.1.20".parse().unwrap(), "fd7a::5".parse().unwrap()]; + assert!(is_this_mac(&url("http://127.0.0.1:3000/"), &own)); + assert!(is_this_mac(&url("http://localhost:3000/"), &own)); + assert!(is_this_mac(&url("http://[::1]:3000/"), &own)); + assert!(is_this_mac(&url("http://192.168.1.20:3000/"), &own)); + assert!(is_this_mac(&url("https://[fd7a::5]/codeman/"), &own)); + assert!(!is_this_mac(&url("http://192.168.1.21:3000/"), &own)); + // A name is never looked up, this Mac's own included. + assert!(!is_this_mac(&url("https://mac.tailnet.ts.net/"), &own)); + assert!(!is_this_mac(&url("http://evil.localhost:3000/"), &own)); + } + + #[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")); + } + 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. + #[cfg(unix)] + #[test] + fn the_install_and_removal_commands_do_what_they_say() { + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + let root = std::env::temp_dir().join(format!("keep-awake-root-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let log = root.join("calls.log"); + for tool in ["chown", "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") + .arg("-c") + .arg(command) + .env("PATH", path) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + }; + + 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); + + // 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(); + sh(&remove_command(&prefix)); + let calls = std::fs::read_to_string(&log).unwrap(); + assert!( + calls.starts_with(&format!("launchctl bootout system/{HELPER_LABEL}\n")), + "{calls}" + ); + assert!(calls.contains("pmset -a disablesleep 0"), "{calls}"); + assert_eq!(helper_state_under(&prefix, request), HelperState::Missing); + 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(); + sh(&remove_command(&prefix)); + assert!(!std::fs::read_to_string(&log).unwrap().contains("pmset")); + let _ = std::fs::remove_dir_all(&root); + } + + #[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); + } + + // The worker, with stand-ins for the Mac and the server. + + #[derive(Default)] + struct FakeMac { + battery: bool, + helper: bool, + covered: Option, + local: bool, + holds: Vec, + writes: usize, + clears: usize, + covered_reads: usize, + } + + #[derive(Clone, Default)] + struct Shared(Rc>); + + impl System for Shared { + fn on_battery(&mut self) -> bool { + self.0.borrow().battery + } + 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 { + let mut mac = self.0.borrow_mut(); + mac.covered_reads += 1; + mac.covered + } + fn is_this_mac(&mut self, _server: &Url) -> bool { + self.0.borrow().local + } + } + + impl Shared { + fn last_hold(&self) -> Option { + self.0.borrow().holds.last().copied() + } + } + + /// The server's answers, one per poll; the last one repeats. + struct Answers { + polls: Rc>>, + asked: Rc>, + } + + impl AgentSource for Answers { + fn poll(&mut self) -> Poll { + *self.asked.borrow_mut() += 1; + let mut polls = self.polls.borrow_mut(); + if polls.len() > 1 { + return polls.remove(0); + } + match polls.first() { + Some(Poll::Seen(activity)) => Poll::Seen(*activity), + Some(Poll::Refused) => Poll::Refused, + Some(Poll::Failed) | None => Poll::Failed, + } + } + } + + fn seen(working: bool) -> Poll { + Poll::Seen(Activity { + working, + ..Activity::default() + }) + } + + struct Rig { + mac: Shared, + polls: Rc>>, + asked: Rc>, + worker: Worker, + start: Instant, + } + + fn rig(local: bool) -> Rig { + let mac = Shared::default(); + mac.0.borrow_mut().local = local; + let polls = Rc::new(RefCell::new(vec![seen(false)])); + let asked = Rc::new(RefCell::new(0)); + let (p, a) = (polls.clone(), asked.clone()); + let new_source: NewSource = Box::new(move |_| { + Some(Box::new(Answers { + polls: p.clone(), + asked: a.clone(), + }) as Box) + }); + Rig { + worker: Worker::new(mac.clone(), new_source), + mac, + polls, + asked, + start: Instant::now(), + } + } + + impl Rig { + fn watch(&mut self, settings: Settings) { + self.worker.update(Some(Watch { + server: url("http://127.0.0.1:3000/"), + authorization: None, + settings, + })); + } + fn answer(&self, polls: Vec) { + *self.polls.borrow_mut() = polls; + } + fn look_at(&mut self, secs: u64) -> Option { + self.worker.look(self.start + Duration::from_secs(secs)) + } + } + + #[test] + fn the_worker_holds_while_agents_work_and_lets_go_after_the_grace() { + let mut rig = rig(true); + rig.watch(WORK); + assert_eq!( + rig.look_at(0).as_deref(), + Some("No agent is working, so this Mac may sleep") + ); + assert_eq!( + rig.mac.last_hold(), + Some(Hold::Watching), + "App Nap off while it watches" + ); + rig.answer(vec![seen(true)]); + assert_eq!(rig.look_at(2).as_deref(), Some("Awake: agents are working")); + assert_eq!(rig.mac.last_hold(), Some(Hold::Holding)); + rig.answer(vec![seen(false)]); + assert_eq!( + rig.look_at(4).as_deref(), + Some("Awake: an agent worked in the last 5 minutes") + ); + assert_eq!(rig.mac.last_hold(), Some(Hold::Holding)); + assert_eq!(rig.look_at(200), None, "unchanged: nothing to report"); + assert_eq!( + rig.look_at(2 + GRACE.as_secs()).as_deref(), + Some("No agent is working, so this Mac may sleep") + ); + assert_eq!(rig.mac.last_hold(), Some(Hold::Watching)); + } + + #[test] + fn a_server_that_stops_answering_mid_work_keeps_the_hold_for_the_grace() { + let mut rig = rig(true); + rig.watch(WORK); + rig.answer(vec![seen(true)]); + rig.look_at(0); + rig.answer(vec![Poll::Failed]); + rig.look_at(10); + assert_eq!(rig.mac.last_hold(), Some(Hold::Holding)); + assert_eq!( + rig.look_at(GRACE.as_secs()).as_deref(), + Some("Codeman does not answer, so this Mac may sleep") + ); + assert_eq!(rig.mac.last_hold(), Some(Hold::Watching)); + } + + #[test] + fn a_refused_login_is_never_tried_again() { + let mut rig = rig(true); + rig.watch(WORK); + rig.answer(vec![Poll::Refused]); + assert_eq!( + rig.look_at(0).as_deref(), + Some("Codeman refused the login, so the agents cannot be seen") + ); + rig.look_at(2); + rig.look_at(4); + assert_eq!(*rig.asked.borrow(), 1); + // A new login is a new try. + rig.worker.update(Some(Watch { + server: url("http://127.0.0.1:3000/"), + authorization: Some("Basic eA==".into()), + settings: WORK, + })); + rig.answer(vec![seen(true)]); + rig.look_at(6); + assert_eq!(*rig.asked.borrow(), 2); + assert_eq!(rig.mac.last_hold(), Some(Hold::Holding)); + } + + #[test] + fn always_holds_without_asking_the_server() { + let mut rig = rig(true); + rig.watch(ALWAYS); + assert_eq!(rig.look_at(0).as_deref(), Some("Awake while Codeman is open")); + assert_eq!(rig.mac.last_hold(), Some(Hold::Holding)); + assert_eq!(*rig.asked.borrow(), 0); + } + + #[test] + fn a_server_elsewhere_is_told_once_and_neither_asked_nor_held() { + let mut rig = rig(false); + rig.watch(WORK); + assert!(!rig.worker.ticking()); + assert_eq!( + rig.look_at(0).as_deref(), + Some("Nothing to keep awake: this Codeman runs on another machine") + ); + assert_eq!(*rig.asked.borrow(), 0); + assert!(rig.mac.0.borrow().holds.is_empty()); + } + + #[test] + fn unplugging_pauses_it_and_plugging_in_resumes() { + let mut rig = rig(true); + rig.watch(ALWAYS); + rig.look_at(0); + rig.mac.0.borrow_mut().battery = true; + assert_eq!(rig.look_at(2).as_deref(), Some("Paused on battery power")); + assert_eq!(rig.mac.last_hold(), Some(Hold::None)); + rig.mac.0.borrow_mut().battery = false; + rig.look_at(4); + assert_eq!(rig.mac.last_hold(), Some(Hold::Holding)); + } + + #[test] + fn the_lid_request_is_written_freshened_and_cleared() { + 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 while Codeman is open; also with the lid closed") + ); + 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" + ); + // On battery the lid is never asked for, whatever the setting. + rig.watch(Settings { + lid: true, + ac_only: false, + ..ALWAYS + }); + rig.mac.0.borrow_mut().battery = true; + assert_eq!( + rig.look_at(32).as_deref(), + Some("Awake while Codeman is open; not with the lid closed on battery") + ); + assert_eq!(rig.mac.0.borrow().clears, 1); + // Nothing to watch: everything let go. + rig.mac.0.borrow_mut().battery = false; + rig.look_at(34); + assert_eq!(rig.mac.0.borrow().writes, 3); + rig.worker.update(None); + assert_eq!(rig.look_at(36), None); + assert_eq!(rig.mac.last_hold(), Some(Hold::None)); + assert_eq!(rig.mac.0.borrow().clears, 2); + } + + #[test] + fn a_new_value_reports_its_line_again() { + let mut rig = rig(true); + rig.watch(ALWAYS); + assert!(rig.look_at(0).is_some()); + assert!(rig.look_at(2).is_none()); + rig.watch(ALWAYS); + assert!( + rig.look_at(4).is_some(), + "the app numbers each value and waits for its line" + ); + } + + #[test] + fn the_thread_reports_under_the_latest_number_and_lets_go_at_the_end() { + let mac = Shared::default(); + mac.0.borrow_mut().local = true; + let new_source: NewSource = Box::new(|_| None); + let worker = Worker::new(mac.clone(), new_source); + let (sender, rx) = mpsc::channel(); + let watch = |settings| { + Some(Watch { + server: url("http://127.0.0.1:3000/"), + authorization: None, + settings, + }) + }; + sender.send((1, watch(WORK))).unwrap(); + sender.send((2, watch(ALWAYS))).unwrap(); + drop(sender); + let lines = RefCell::new(Vec::new()); + run(&rx, worker, &|generation, line| { + lines.borrow_mut().push((generation, line)) + }); + assert_eq!(lines.into_inner(), vec![(2, "Awake while Codeman is open".to_owned())]); + assert_eq!(mac.last_hold(), Some(Hold::None), "the end lets go"); + } +} diff --git a/packages/desktop/src/macos.rs b/packages/desktop/src/macos.rs index a26e4940..007f0d59 100644 --- a/packages/desktop/src/macos.rs +++ b/packages/desktop/src/macos.rs @@ -222,6 +222,13 @@ pub fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> { imp::content_size(window) } +/// An app-modal message with an OK button, for something the user asked the app +/// itself to do that failed. Main thread only; does nothing elsewhere. +#[cfg(target_os = "macos")] +pub fn show_message(title: &str, detail: &str) { + imp::show_message(title, detail); +} + #[cfg(target_os = "macos")] mod imp { use std::cell::{Cell, RefCell}; @@ -456,6 +463,17 @@ mod imp { } } + pub(super) fn show_message(title: &str, detail: &str) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let alert = NSAlert::new(mtm); + alert.setMessageText(&NSString::from_str(title)); + alert.setInformativeText(&NSString::from_str(detail)); + let _ = alert.addButtonWithTitle(&NSString::from_str("OK")); + alert.runModal(); + } + pub(super) fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> { // NSWindow is main-thread only. MainThreadMarker::new()?; diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index d2a637a8..e9764728 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -27,6 +27,7 @@ mod assets; mod config; mod credentials; mod gui_install; +mod keep_awake; mod local_server; mod login; mod logo; @@ -182,6 +183,18 @@ enum UserEvent { /// install opened in Terminal, the install command copied): the message /// goes to the page if it is still the one choosing a server. ToConnectPage(serde_json::Value), + /// The keep-awake worker's status line, for the value numbered `generation` + /// (keep_awake::KeepAwake::is_current). + KeepAwakeStatus { + 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, + }, } struct ProbeDone { @@ -318,6 +331,10 @@ struct App { fullscreen_retry: Option<(Instant, u8)>, /// The child that watches the connected server and blinks the logo. 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, } fn main() { @@ -370,6 +387,7 @@ fn main() { let mut windows = HashMap::new(); windows.insert(main_id, AppWindow::new(webview, window, Kind::Main)); + let keep_awake_proxy = proxy.clone(); let mut app = App { proxy, config, @@ -397,6 +415,10 @@ fn main() { add_agent_on_load: false, fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)), logo_watch: logo::Watcher::default(), + keep_awake: keep_awake::KeepAwake::new(move |generation, text| { + let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text }); + }), + lid_helper_busy: false, }; event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow)); @@ -730,6 +752,7 @@ impl App { } => self.show_main_window(), Event::MainEventsCleared => self.on_events_cleared(control_flow), Event::LoopDestroyed => { + self.keep_awake.shutdown(); self.record_main_frame(); self.save_config(); } @@ -796,6 +819,7 @@ impl App { if id == self.main_id { // Closing the main window quits; the server and its // sessions keep running, so nothing is lost. + self.keep_awake.shutdown(); self.record_main_frame(); self.save_config(); *control_flow = ControlFlow::Exit; @@ -875,6 +899,7 @@ impl App { *self.phase.borrow_mut() = next; } self.sync_logo_watch(); + self.sync_keep_awake(); } if let Some(w) = self.windows.get_mut(&window) { w.committed = Url::parse(&url).ok(); @@ -937,6 +962,14 @@ impl App { } UserEvent::AuthRejected => self.recheck_login(), UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window), + UserEvent::KeepAwakeStatus { generation, text } => { + if self.keep_awake.is_current(generation) { + if let Some(items) = &self.menu.keep_awake { + items.status.set_text(&text); + } + } + } + UserEvent::LidHelperDone { install, outcome } => self.on_lid_helper_done(install, outcome), } } @@ -1061,12 +1094,142 @@ 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 server = self.phase.borrow().loaded().cloned(); + let watch = server + .filter(|_| settings.mode != keep_awake::Mode::Off) + .map(|server| keep_awake::Watch { + server, + authorization: self.auth.clone(), + settings, + }); + let idle = match (settings.mode, &watch) { + (keep_awake::Mode::Off, _) => Some(keep_awake::OFF_TEXT), + (_, None) => Some(keep_awake::NO_SERVER_TEXT), + _ => None, + }; + self.keep_awake.sync(watch); + if let (Some(text), Some(items)) = (idle, &self.menu.keep_awake) { + items.status.set_text(text); + } + } + + fn choose_keep_awake_mode(&mut self, mode: keep_awake::Mode) { + if let Some(items) = &self.menu.keep_awake { + // A click flips the item it landed on, the chosen one included. + for (item_mode, item) in &items.modes { + item.set_checked(*item_mode == mode); + } + } + self.config.keep_awake.mode = mode; + self.config_dirty = true; + self.sync_keep_awake(); + } + + fn toggle_keep_awake_ac_only(&mut self) { + let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.ac_only.as_ref()) else { + return; + }; + self.config.keep_awake.ac_only = item.is_checked(); + self.config_dirty = true; + self.sync_keep_awake(); + } + + /// Choosing the lid item asks for the administrator password first while the + /// helper is missing or from another version; the setting goes on once it is in. + fn toggle_keep_awake_lid(&mut self) { + let Some(items) = &self.menu.keep_awake else { + return; + }; + let Some(item) = &items.lid else { + return; + }; + let state = keep_awake::request_path().map_or(keep_awake::HelperState::Missing, |request| { + keep_awake::helper_state(&request) + }); + if !item.is_checked() || state == keep_awake::HelperState::Installed { + self.config.keep_awake.lid = item.is_checked(); + self.config_dirty = true; + items.show_helper(state, self.config.keep_awake.lid); + self.sync_keep_awake(); + return; + } + // Not on until the helper is in. + item.set_checked(false); + self.run_lid_helper(true); + } + + fn run_lid_helper(&mut self, install: bool) { + if self.lid_helper_busy { + return; + } + self.lid_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 }); + }); + } + + 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; + 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(), + )), + }; + 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 { + eprintln!("codeman-desktop: {title} {detail}"); + #[cfg(target_os = "macos")] + macos::show_message(title, &detail); + } + } + fn on_menu(&mut self, id: &str) { if let Some((signal, style)) = BlinkStyle::from_menu_id(id) { self.choose_logo_blink(signal, style); return; } + if let Some(mode) = keep_awake::Mode::from_menu_id(id) { + self.choose_keep_awake_mode(mode); + return; + } match id { + keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(), + keep_awake::LID_ID => self.toggle_keep_awake_lid(), + keep_awake::REMOVE_HELPER_ID => self.run_lid_helper(false), menu_id::SWITCH => self.show_connect_page(None, true), menu_id::ADD_AGENT => self.add_agent(), menu_id::RELOAD => self.reload_focused(), diff --git a/packages/desktop/src/menu.rs b/packages/desktop/src/menu.rs index dff442e3..09af2ea0 100644 --- a/packages/desktop/src/menu.rs +++ b/packages/desktop/src/menu.rs @@ -4,6 +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::logo::{self, BlinkStyle, Signal}; use crate::{APP_NAME, VERSION}; @@ -29,6 +30,94 @@ pub struct AppMenu { /// One item per state and style. Empty on a Mac without a lit logo, which has no /// such menu. pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>, + /// None where the app cannot keep the Mac awake (a build for another OS). + pub keep_awake: Option, +} + +/// App menu > Keep This Mac Awake. +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, + pub lid: Option, + pub remove_helper: Option, + /// What it does right now (keep_awake::status_line). Not clickable. + pub status: MenuItem, +} + +impl KeepAwakeMenu { + /// The lid items as the helper now is. The lid item ends in an ellipsis while + /// choosing it asks for the administrator password first. + pub fn show_helper(&self, state: HelperState, lid_on: bool) { + if let Some(item) = &self.lid { + item.set_text(lid_label(state)); + item.set_checked(lid_on && state == HelperState::Installed); + } + if let Some(item) = &self.remove_helper { + item.set_enabled(state != HelperState::Missing); + } + } +} + +fn lid_label(state: HelperState) -> &'static str { + match state { + HelperState::Installed => "Also With the Lid Closed", + _ => "Also With the Lid Closed\u{2026}", + } +} + +fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> { + let settings = config.keep_awake; + let submenu = Submenu::new("Keep This Mac Awake", true); + let mut modes = Vec::new(); + for mode in Mode::ALL { + let item = CheckMenuItem::with_id(mode.menu_id(), mode.label(), true, settings.mode == mode, None); + submenu.append(&item)?; + modes.push((mode, item)); + } + let (mut ac_only, mut lid, mut remove_helper) = (None, None, None); + if keep_awake::has_lid() { + let state = + keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request)); + let ac = CheckMenuItem::with_id( + keep_awake::AC_ONLY_ID, + "Only on Power Adapter", + true, + settings.ac_only, + None, + ); + let lid_item = CheckMenuItem::with_id( + keep_awake::LID_ID, + lid_label(state), + true, + settings.lid && state == HelperState::Installed, + None, + ); + let remove = MenuItem::with_id( + keep_awake::REMOVE_HELPER_ID, + "Remove the Lid Helper\u{2026}", + state != HelperState::Missing, + None, + ); + submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item, &remove])?; + (ac_only, lid, remove_helper) = (Some(ac), Some(lid_item), Some(remove)); + } + let initial = match settings.mode { + Mode::Off => keep_awake::OFF_TEXT, + _ => keep_awake::NO_SERVER_TEXT, + }; + let status = MenuItem::new(initial, false, None); + submenu.append_items(&[&PredefinedMenuItem::separator(), &status])?; + Ok(( + submenu, + KeepAwakeMenu { + modes, + ac_only, + lid, + remove_helper, + status, + }, + )) } pub fn build_menu(config: &Config) -> muda::Result { @@ -67,6 +156,15 @@ pub fn build_menu(config: &Config) -> muda::Result { &PredefinedMenuItem::quit(None), ], )?; + // Right under "Add a Coding Agent to This Mac…". + let keep_awake = match keep_awake::available() { + true => { + let (submenu, items) = build_keep_awake(config)?; + app_menu.insert(&submenu, 4)?; + Some(items) + } + false => None, + }; // Without these items Cmd+C / Cmd+V do nothing inside a WKWebView. let edit = Submenu::with_items( "Edit", @@ -157,5 +255,6 @@ pub fn build_menu(config: &Config) -> muda::Result { help_menu, start_fullscreen, logo_styles, + keep_awake, }) } diff --git a/test/desktop-keep-awake-lid.test.ts b/test/desktop-keep-awake-lid.test.ts new file mode 100644 index 00000000..642db390 --- /dev/null +++ b/test/desktop-keep-awake-lid.test.ts @@ -0,0 +1,234 @@ +/** + * @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(''); + }); +});