mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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 <noreply@anthropic.com>
235 lines
6.8 KiB
TypeScript
235 lines
6.8 KiB
TypeScript
/**
|
|
* @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('');
|
|
});
|
|
});
|