Make orphaning structurally impossible: the process is the state
The extension used to record the spawned pid in LocalStorage, which meant two pieces of state had to stay in sync: the record and the process. When the record was lost (extension reinstalled, storage cleared, a crash between spawn and save) the process kept holding a sleep assertion that nothing could ever release — a machine that silently never sleeps. That happened for real during development. Fix it by removing the thing that can be lost rather than guarding it: the process now carries its own identity, so the OS is the single source of truth. Each session is tagged in its own command line: vicinae-caffeinate:v1:<startedAt>:<flags> e.g. ...:1789895271765:id - macOS: caffeinate has no free-form field, so the marker is installed as argv[0] via the shell's `exec -a`. exec replaces the shell in place, so the pid Node receives is the caffeinate pid. (Verified that both `ps -o command=` and `ps -o comm=` surface the custom argv[0].) - Linux: the marker rides in systemd-inhibit's --why next to a human-readable reason. status is now a `ps` scan for the marker; stop signals every match. This deletes lib/state.ts and every LocalStorage call — the extension is stateless, which also retires the Vicinae null-vs-undefined storage quirk as a hazard for this codebase. Also: - Signal the process group (-pid) rather than a single pid. Sessions are spawned detached so the child is a group leader, and on Linux systemd-inhibit forks a `sleep` that a parent-only signal can leave behind. - start() clears existing sessions first, so assertions cannot stack. - If the spawn never registers a marker, roll back instead of leaving a half-started session. - Drop the now-meaningless "stale" concept; add `extra` so an unexpected duplicate surfaces in the status view instead of being invisible. - Add describeFlags() to render flag letters for humans. Verified by deleting the installed extension while a session was active, restarting Vicinae, then reinstalling: the fresh install saw the leftover as ACTIVE and stopped it rather than starting a second and orphaning the first. A user-owned `caffeinate -u -dmi` was untouched throughout. vici lint, tsc, eslint, 17/17 vitest, vici build all green; live toggle on/off round-trips cleanly.
This commit is contained in:
@@ -15,7 +15,7 @@ This is a native `@vicinae/api` extension, so none of that applies.
|
|||||||
| Command | Mode | What it does |
|
| Command | Mode | What it does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **Toggle Caffeination** | no-view | Flips sleep prevention on or off, whichever is appropriate. The fast path. |
|
| **Toggle Caffeination** | no-view | Flips sleep prevention on or off, whichever is appropriate. The fast path. |
|
||||||
| **Caffeination Status** | view | Whether sleep is being blocked, since when, and the exact command holding it. Start/stop from here. |
|
| **Caffeination Status** | view | Whether sleep is being blocked, since when, and the exact process holding it. Start/stop from here. |
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -30,11 +30,34 @@ Both run until signalled — there is no timer, so nothing needs to be refreshed
|
|||||||
|
|
||||||
The process is spawned **detached and unref'd**. That is required: Vicinae unloads a `no-view` command as soon as its function returns, so a child process would otherwise be torn down with it.
|
The process is spawned **detached and unref'd**. That is required: Vicinae unloads a `no-view` command as soon as its function returns, so a child process would otherwise be torn down with it.
|
||||||
|
|
||||||
State is recorded in `LocalStorage` as `{ pid, startedAt, command, args, processName, … }`.
|
## The process is the state
|
||||||
|
|
||||||
Stopping signals the **recorded PID**, not `killall caffeinate`, so a `caffeinate` you started yourself in a terminal is left alone. Before signalling, the PID's process name is re-checked via `ps -o comm=` — PIDs get recycled, and without that check a stale entry could kill an unrelated process.
|
**Nothing is persisted.** The running process carries its own identity in its command line:
|
||||||
|
|
||||||
Status is self-healing: if the recorded process is gone (killed elsewhere, or a reboot), the stale state is cleared on the next read.
|
```
|
||||||
|
vicinae-caffeinate:v1:<startedAt>:<flags> e.g. vicinae-caffeinate:v1:1789895271765:id
|
||||||
|
```
|
||||||
|
|
||||||
|
- **macOS** has no free-form field on `caffeinate`, so the marker is installed as `argv[0]` via the shell's `exec -a`. `exec` replaces the shell in place, so the pid Node receives *is* the `caffeinate` pid.
|
||||||
|
- **Linux** passes it in `systemd-inhibit`'s `--why`, alongside a human-readable reason.
|
||||||
|
|
||||||
|
Status is therefore a `ps` scan for that marker, and stopping signals every match.
|
||||||
|
|
||||||
|
### Why this design
|
||||||
|
|
||||||
|
The obvious approach — remember the pid in `LocalStorage` — has a failure mode that is worse than it sounds. Two pieces of state have to stay in sync: the record, and the actual process. If the record is ever lost (extension reinstalled, storage cleared, a crash between spawning and saving) the process keeps holding a sleep assertion that **nothing will ever release**. A laptop that silently never sleeps, with no way to find the culprit.
|
||||||
|
|
||||||
|
Making the process self-describing removes the class of bug rather than guarding against it: there is no record to lose, so the extension cannot lose track of a session. Any `status` or `toggle` reconciles against the truth, and `start` always clears existing sessions first so assertions cannot stack.
|
||||||
|
|
||||||
|
Verified by deleting the installed extension while a session was active, restarting Vicinae, then reinstalling: the fresh install saw the leftover as **active** and stopped it, instead of starting a second one and orphaning the first.
|
||||||
|
|
||||||
|
Signals go to the **process group** (`kill -TERM -<pid>`). Sessions are spawned detached, so the child is a group leader; on Linux that matters because `systemd-inhibit` forks a `sleep`, and signalling only the parent can leave the child behind.
|
||||||
|
|
||||||
|
A `caffeinate` you started yourself is never touched — only processes carrying the marker are ever signalled. If a session ever does need to be cleared by hand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pkill -f vicinae-caffeinate
|
||||||
|
```
|
||||||
|
|
||||||
## Preferences
|
## Preferences
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
LINUX_INHIBIT_COMMAND,
|
||||||
MACOS_CAFFEINATE_PATH,
|
MACOS_CAFFEINATE_PATH,
|
||||||
UnsupportedPlatformError,
|
UnsupportedPlatformError,
|
||||||
|
argsFromFlags,
|
||||||
buildCommand,
|
buildCommand,
|
||||||
|
buildMarker,
|
||||||
|
describeFlags,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
|
flagsFromOptions,
|
||||||
|
parseMarker,
|
||||||
} from "../lib/platform";
|
} from "../lib/platform";
|
||||||
|
|
||||||
const base = { preventDisplaySleep: false, preventSystemSleep: false, reason: "test" };
|
const noOptions = { preventDisplaySleep: false, preventSystemSleep: false };
|
||||||
|
|
||||||
describe("detectPlatform", () => {
|
describe("detectPlatform", () => {
|
||||||
it("accepts the supported platforms", () => {
|
it("accepts the supported platforms", () => {
|
||||||
@@ -21,46 +27,102 @@ describe("detectPlatform", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildCommand on macOS", () => {
|
describe("flags", () => {
|
||||||
it("always blocks idle sleep", () => {
|
it("always blocks idle sleep", () => {
|
||||||
expect(buildCommand("darwin", base)).toEqual({
|
expect(flagsFromOptions(noOptions)).toBe("i");
|
||||||
command: MACOS_CAFFEINATE_PATH,
|
});
|
||||||
args: ["-i"],
|
|
||||||
processName: "caffeinate",
|
it("appends display then system in a stable order", () => {
|
||||||
|
expect(flagsFromOptions({ ...noOptions, preventDisplaySleep: true })).toBe("id");
|
||||||
|
expect(flagsFromOptions({ ...noOptions, preventSystemSleep: true })).toBe("is");
|
||||||
|
expect(flagsFromOptions({ preventDisplaySleep: true, preventSystemSleep: true })).toBe("ids");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips back to argv", () => {
|
||||||
|
expect(argsFromFlags("i")).toEqual(["-i"]);
|
||||||
|
expect(argsFromFlags("ids")).toEqual(["-i", "-d", "-s"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("marker", () => {
|
||||||
|
it("round-trips through a command line", () => {
|
||||||
|
const marker = buildMarker(1737000000000, "id");
|
||||||
|
expect(marker).toBe("vicinae-caffeinate:v1:1737000000000:id");
|
||||||
|
expect(parseMarker(`/bin/sh -c exec -a '${marker}' /usr/bin/caffeinate -i -d`)).toEqual({
|
||||||
|
marker,
|
||||||
|
startedAt: 1737000000000,
|
||||||
|
flags: "id",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds display and system assertions when requested", () => {
|
it("does not confuse systemd-inhibit's own colons for marker fields", () => {
|
||||||
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true }).args).toEqual(["-i", "-d"]);
|
// `--what=idle:sleep` contains colons, and the marker follows it.
|
||||||
expect(buildCommand("darwin", { ...base, preventSystemSleep: true }).args).toEqual(["-i", "-s"]);
|
const marker = buildMarker(1737000000000, "i");
|
||||||
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true, preventSystemSleep: true }).args).toEqual([
|
const line = `${LINUX_INHIBIT_COMMAND} --what=idle:sleep --why=Caffeinate (Vicinae extension) [${marker}] sleep infinity`;
|
||||||
"-i",
|
expect(parseMarker(line)).toEqual({ marker, startedAt: 1737000000000, flags: "i" });
|
||||||
"-d",
|
});
|
||||||
"-s",
|
|
||||||
]);
|
it("handles empty flags", () => {
|
||||||
|
const marker = buildMarker(1, "");
|
||||||
|
expect(parseMarker(`foo ${marker} bar`)?.flags).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated processes", () => {
|
||||||
|
expect(parseMarker("/usr/bin/caffeinate -u -dmi")).toBeNull();
|
||||||
|
expect(parseMarker("ps -axo pid=,command=")).toBeNull();
|
||||||
|
expect(parseMarker("/usr/bin/caffeinate -i")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildCommand on macOS", () => {
|
||||||
|
const marker = buildMarker(1737000000000, "i");
|
||||||
|
|
||||||
|
it("injects the marker as argv[0] via exec -a", () => {
|
||||||
|
const spec = buildCommand("darwin", "i", marker);
|
||||||
|
expect(spec.command).toBe("/bin/sh");
|
||||||
|
expect(spec.args[0]).toBe("-c");
|
||||||
|
expect(spec.args[1]).toBe(`exec -a '${marker}' '${MACOS_CAFFEINATE_PATH}' -i`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the requested flags through", () => {
|
||||||
|
expect(buildCommand("darwin", "ids", marker).args[1]).toMatch(/caffeinate' -i -d -s$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never adds a timeout, so the assertion lasts until stopped", () => {
|
it("never adds a timeout, so the assertion lasts until stopped", () => {
|
||||||
expect(buildCommand("darwin", base).args).not.toContain("-t");
|
expect(buildCommand("darwin", "ids", marker).args[1]).not.toContain("-t");
|
||||||
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true }).args).not.toContain("-t");
|
});
|
||||||
|
|
||||||
|
it("reports a readable display command", () => {
|
||||||
|
expect(buildCommand("darwin", "id", marker).display).toBe(`${MACOS_CAFFEINATE_PATH} -i -d`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildCommand on Linux", () => {
|
describe("buildCommand on Linux", () => {
|
||||||
it("uses systemd-inhibit with an infinite sleep", () => {
|
const marker = buildMarker(1737000000000, "i");
|
||||||
expect(buildCommand("linux", base)).toEqual({
|
|
||||||
command: "systemd-inhibit",
|
it("carries the marker in --why and holds the lock with sleep infinity", () => {
|
||||||
args: ["--what=idle:sleep", "--why=test", "sleep", "infinity"],
|
const spec = buildCommand("linux", "i", marker);
|
||||||
processName: "systemd-inhibit",
|
expect(spec.command).toBe(LINUX_INHIBIT_COMMAND);
|
||||||
});
|
expect(spec.args[0]).toBe("--what=idle:sleep");
|
||||||
|
expect(spec.args[1]).toContain(marker);
|
||||||
|
expect(spec.args.slice(2)).toEqual(["sleep", "infinity"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not claim to control the display, which systemd-inhibit cannot do", () => {
|
it("does not claim to control the display, which systemd-inhibit cannot do", () => {
|
||||||
const { args } = buildCommand("linux", { ...base, preventDisplaySleep: true });
|
// The `d` flag is a macOS concept; Linux maps it away rather than pretending.
|
||||||
expect(args).toEqual(["--what=idle:sleep", "--why=test", "sleep", "infinity"]);
|
const flags = flagsFromOptions({ ...noOptions, preventDisplaySleep: true });
|
||||||
|
expect(buildCommand("linux", flags, marker).args.join(" ")).not.toContain("-d");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("passes the reason through so it shows up in systemd-inhibit --list", () => {
|
describe("describeFlags", () => {
|
||||||
expect(buildCommand("linux", { ...base, reason: "Plex" }).args).toContain("--why=Plex");
|
it("always mentions idle sleep", () => {
|
||||||
|
expect(describeFlags("i")[0]).toMatch(/Idle sleep/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only mentions display and system when they are held", () => {
|
||||||
|
expect(describeFlags("i")).toHaveLength(1);
|
||||||
|
expect(describeFlags("id").join(" ")).toMatch(/Display/);
|
||||||
|
expect(describeFlags("ids").join(" ")).toMatch(/system sleep/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+126
-79
@@ -3,14 +3,20 @@ import { access } from "node:fs/promises";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { getPreferenceValues } from "@vicinae/api";
|
import { getPreferenceValues } from "@vicinae/api";
|
||||||
|
|
||||||
import { MACOS_CAFFEINATE_PATH, buildCommand, detectPlatform } from "./platform";
|
import {
|
||||||
import { clearState, readState, writeState } from "./state";
|
MACOS_CAFFEINATE_PATH,
|
||||||
|
binaryForPlatform,
|
||||||
|
buildCommand,
|
||||||
|
buildMarker,
|
||||||
|
detectPlatform,
|
||||||
|
flagsFromOptions,
|
||||||
|
parseMarker,
|
||||||
|
} from "./platform";
|
||||||
import { CaffeinateOptions, CaffeinateState, CaffeinationStatus, ToggleResult } from "./types";
|
import { CaffeinateOptions, CaffeinateState, CaffeinationStatus, ToggleResult } from "./types";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const DEFAULT_REASON = "Caffeinate (Vicinae extension)";
|
const LIVENESS_TIMEOUT_MS = 1200;
|
||||||
const LIVENESS_TIMEOUT_MS = 900;
|
|
||||||
const LIVENESS_POLL_MS = 90;
|
const LIVENESS_POLL_MS = 90;
|
||||||
|
|
||||||
export interface CaffeinatePreferences {
|
export interface CaffeinatePreferences {
|
||||||
@@ -18,12 +24,11 @@ export interface CaffeinatePreferences {
|
|||||||
preventSystemSleep?: boolean;
|
preventSystemSleep?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionsFromPreferences(reason: string = DEFAULT_REASON): CaffeinateOptions {
|
export function optionsFromPreferences(): CaffeinateOptions {
|
||||||
const prefs = getPreferenceValues<CaffeinatePreferences>();
|
const prefs = getPreferenceValues<CaffeinatePreferences>();
|
||||||
return {
|
return {
|
||||||
preventDisplaySleep: Boolean(prefs.preventDisplaySleep),
|
preventDisplaySleep: Boolean(prefs.preventDisplaySleep),
|
||||||
preventSystemSleep: Boolean(prefs.preventSystemSleep),
|
preventSystemSleep: Boolean(prefs.preventSystemSleep),
|
||||||
reason,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,128 +42,170 @@ async function which(command: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve an absolute path for the command, preferring well-known locations. */
|
/** Fail early with a clear message if the platform tool is missing. */
|
||||||
async function resolveExecutable(platform: string, command: string): Promise<string> {
|
async function assertBinaryAvailable(platform: string): Promise<void> {
|
||||||
if (platform === "darwin" && command === MACOS_CAFFEINATE_PATH) {
|
if (platform === "darwin") {
|
||||||
try {
|
try {
|
||||||
await access(MACOS_CAFFEINATE_PATH);
|
await access(MACOS_CAFFEINATE_PATH);
|
||||||
return MACOS_CAFFEINATE_PATH;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to PATH lookup
|
// fall through to PATH lookup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resolved = await which(command);
|
const binary = binaryForPlatform(platform === "linux" ? "linux" : "darwin");
|
||||||
if (resolved) return resolved;
|
if (await which(binary)) return;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not find \`${command}\` on this system. ${
|
`Could not find \`${binary}\` on this system. ${
|
||||||
platform === "linux" ? "It ships with systemd; is systemd available?" : ""
|
platform === "linux" ? "It ships with systemd; is systemd available?" : ""
|
||||||
}`.trim(),
|
}`.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processNameFor(pid: number): Promise<string | null> {
|
interface MarkedProcess {
|
||||||
try {
|
pid: number;
|
||||||
const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "comm="]);
|
marker: string;
|
||||||
const name = stdout.trim();
|
startedAt: number;
|
||||||
return name || null;
|
flags: string;
|
||||||
} catch {
|
invocation: string;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the recorded process still alive *and* still ours?
|
* Find every running caffeination session by scanning the process table for our
|
||||||
|
* marker.
|
||||||
*
|
*
|
||||||
* The name check matters: PIDs are recycled, so a bare `kill(pid, 0)` could
|
* This is the whole source of truth. Nothing is persisted, so there is no
|
||||||
* report success for an unrelated process and we would later signal the wrong
|
* record that can be lost while the process keeps running — the failure mode
|
||||||
* thing.
|
* that used to leave an unkillable sleep assertion behind.
|
||||||
|
*
|
||||||
|
* Our own `ps` invocation never contains the marker, so it cannot self-match.
|
||||||
*/
|
*/
|
||||||
export async function isProcessRunning(state: CaffeinateState): Promise<boolean> {
|
async function scanSessions(): Promise<MarkedProcess[]> {
|
||||||
try {
|
const { stdout } = await execFileAsync("ps", ["-axo", "pid=,command="]);
|
||||||
process.kill(state.pid, 0);
|
const found: MarkedProcess[] = [];
|
||||||
} catch {
|
|
||||||
return false;
|
for (const line of stdout.split("\n")) {
|
||||||
|
const row = /^\s*(\d+)\s+(.*)$/.exec(line);
|
||||||
|
if (!row) continue;
|
||||||
|
|
||||||
|
const parsed = parseMarker(row[2]);
|
||||||
|
if (!parsed) continue;
|
||||||
|
|
||||||
|
found.push({
|
||||||
|
pid: Number(row[1]),
|
||||||
|
marker: parsed.marker,
|
||||||
|
startedAt: parsed.startedAt,
|
||||||
|
flags: parsed.flags,
|
||||||
|
invocation: row[2].trim(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const name = await processNameFor(state.pid);
|
|
||||||
if (!name) return false;
|
return found.sort((a, b) => a.startedAt - b.startedAt);
|
||||||
return name.split("/").pop() === state.processName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitUntilRunning(state: CaffeinateState): Promise<boolean> {
|
/**
|
||||||
|
* Signal the whole process group.
|
||||||
|
*
|
||||||
|
* Sessions are spawned detached, so the child is a group leader (pgid === pid).
|
||||||
|
* On Linux that matters: `systemd-inhibit` forks `sleep`, and signalling only
|
||||||
|
* the parent can leave the child behind.
|
||||||
|
*/
|
||||||
|
function signalTree(pid: number, signal: NodeJS.Signals): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(-pid, signal);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
process.kill(pid, signal);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toState(process_: MarkedProcess, platform: CaffeinateState["platform"]): CaffeinateState {
|
||||||
|
return {
|
||||||
|
pid: process_.pid,
|
||||||
|
startedAt: process_.startedAt,
|
||||||
|
flags: process_.flags,
|
||||||
|
platform,
|
||||||
|
marker: process_.marker,
|
||||||
|
invocation: process_.invocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMarker(marker: string): Promise<MarkedProcess | null> {
|
||||||
const deadline = Date.now() + LIVENESS_TIMEOUT_MS;
|
const deadline = Date.now() + LIVENESS_TIMEOUT_MS;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (await isProcessRunning(state)) return true;
|
const found = (await scanSessions()).find((p) => p.marker === marker);
|
||||||
if (Date.now() >= deadline) return false;
|
if (found) return found;
|
||||||
|
if (Date.now() >= deadline) return null;
|
||||||
await new Promise((resolve) => setTimeout(resolve, LIVENESS_POLL_MS));
|
await new Promise((resolve) => setTimeout(resolve, LIVENESS_POLL_MS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startCaffeination(options: CaffeinateOptions): Promise<CaffeinateState> {
|
export async function startCaffeination(options: CaffeinateOptions): Promise<CaffeinateState> {
|
||||||
// Never stack assertions: an existing one is replaced rather than joined.
|
const platform = detectPlatform();
|
||||||
|
|
||||||
|
// Reconcile first: any lingering sessions are cleared so assertions never stack.
|
||||||
await stopCaffeination();
|
await stopCaffeination();
|
||||||
|
|
||||||
const platform = detectPlatform();
|
await assertBinaryAvailable(platform);
|
||||||
const spec = buildCommand(platform, options);
|
|
||||||
const command = await resolveExecutable(platform, spec.command);
|
const startedAt = Date.now();
|
||||||
|
const flags = flagsFromOptions(options);
|
||||||
|
const marker = buildMarker(startedAt, flags);
|
||||||
|
const spec = buildCommand(platform, flags, marker);
|
||||||
|
|
||||||
// `detached` + `unref` is required: Vicinae unloads a no-view command as soon
|
// `detached` + `unref` is required: Vicinae unloads a no-view command as soon
|
||||||
// as its function returns, so the process has to outlive the command that
|
// as its function returns, so the process has to outlive the command that
|
||||||
// started it.
|
// started it.
|
||||||
const child = spawn(command, spec.args, { detached: true, stdio: "ignore" });
|
const child = spawn(spec.command, spec.args, { detached: true, stdio: "ignore" });
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
if (child.pid === undefined) {
|
const confirmed = await waitForMarker(marker);
|
||||||
throw new Error(`Failed to start ${spec.processName}.`);
|
if (!confirmed) {
|
||||||
|
// Never leave a half-started session behind.
|
||||||
|
await stopCaffeination();
|
||||||
|
throw new Error(`${spec.display} exited immediately or never registered.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: CaffeinateState = {
|
return toState(confirmed, platform);
|
||||||
pid: child.pid,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
command,
|
|
||||||
args: spec.args,
|
|
||||||
processName: spec.processName,
|
|
||||||
reason: options.reason,
|
|
||||||
platform,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!(await waitUntilRunning(state))) {
|
|
||||||
throw new Error(`${spec.processName} exited immediately (${command} ${spec.args.join(" ")}).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeState(state);
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns true when a running process was actually stopped. */
|
/** @returns how many sessions were stopped. */
|
||||||
export async function stopCaffeination(): Promise<boolean> {
|
export async function stopCaffeination(): Promise<number> {
|
||||||
const state = await readState();
|
const sessions = await scanSessions();
|
||||||
if (!state) return false;
|
let stopped = 0;
|
||||||
|
|
||||||
let stopped = false;
|
for (const session of sessions) {
|
||||||
if (await isProcessRunning(state)) {
|
if (signalTree(session.pid, "SIGTERM")) stopped += 1;
|
||||||
try {
|
}
|
||||||
process.kill(state.pid, "SIGTERM");
|
|
||||||
stopped = true;
|
if (stopped > 0) {
|
||||||
} catch {
|
// Give them a moment to leave the process table so a following scan is accurate.
|
||||||
// Disappeared between the check and the signal — nothing to do.
|
const deadline = Date.now() + 500;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if ((await scanSessions()).length === 0) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await clearState();
|
|
||||||
return stopped;
|
return stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStatus(): Promise<CaffeinationStatus> {
|
export async function getStatus(): Promise<CaffeinationStatus> {
|
||||||
const state = await readState();
|
const platform = detectPlatform();
|
||||||
if (!state) return { active: false, state: null, stale: false };
|
const sessions = await scanSessions();
|
||||||
|
|
||||||
if (!(await isProcessRunning(state))) {
|
if (sessions.length === 0) return { active: false, state: null, extra: [] };
|
||||||
// The process was killed outside the extension, or the machine rebooted. Self-heal.
|
|
||||||
await clearState();
|
|
||||||
return { active: false, state, stale: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { active: true, state, stale: false };
|
const [first, ...rest] = sessions;
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
state: toState(first, platform),
|
||||||
|
extra: rest.map((session) => toState(session, platform)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleCaffeination(): Promise<ToggleResult> {
|
export async function toggleCaffeination(): Promise<ToggleResult> {
|
||||||
|
|||||||
+84
-16
@@ -10,29 +10,97 @@ export class UnsupportedPlatformError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MACOS_CAFFEINATE_PATH = "/usr/bin/caffeinate";
|
export const MACOS_CAFFEINATE_PATH = "/usr/bin/caffeinate";
|
||||||
|
export const LINUX_INHIBIT_COMMAND = "systemd-inhibit";
|
||||||
|
|
||||||
|
export const MARKER_PREFIX = "vicinae-caffeinate";
|
||||||
|
export const MARKER_VERSION = "v1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every session is tagged in the process's own command line, so the set of
|
||||||
|
* running sessions can always be recovered by scanning `ps`. This is what makes
|
||||||
|
* orphaning impossible: there is no separate record that can be lost.
|
||||||
|
*
|
||||||
|
* Format: `vicinae-caffeinate:v1:<startedAtMs>:<flagLetters>`
|
||||||
|
*/
|
||||||
|
const MARKER_PATTERN = `${MARKER_PREFIX}:${MARKER_VERSION}:(\\d+):([a-z]*)`;
|
||||||
|
|
||||||
|
export const MARKER_REGEX = new RegExp(MARKER_PATTERN, "g");
|
||||||
|
export const MARKER_PATTERN_STRING = MARKER_PATTERN;
|
||||||
|
|
||||||
|
export function buildMarker(startedAt: number, flags: string): string {
|
||||||
|
return `${MARKER_PREFIX}:${MARKER_VERSION}:${startedAt}:${flags}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMarker(text: string): { marker: string; startedAt: number; flags: string } | null {
|
||||||
|
const re = new RegExp(MARKER_PATTERN);
|
||||||
|
const match = re.exec(text);
|
||||||
|
if (!match) return null;
|
||||||
|
return { marker: match[0], startedAt: Number(match[1]), flags: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
export function detectPlatform(platform: string = process.platform): SupportedPlatform {
|
export function detectPlatform(platform: string = process.platform): SupportedPlatform {
|
||||||
if (platform === "darwin" || platform === "linux") return platform;
|
if (platform === "darwin" || platform === "linux") return platform;
|
||||||
throw new UnsupportedPlatformError(platform);
|
throw new UnsupportedPlatformError(platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Canonical flag letters: always blocks idle sleep, optionally display and system. */
|
||||||
* Build the command that holds the sleep assertion.
|
export function flagsFromOptions(options: CaffeinateOptions): string {
|
||||||
*
|
let flags = "i";
|
||||||
* Both variants run until they are signalled, which is what "on" means here:
|
if (options.preventDisplaySleep) flags += "d";
|
||||||
* there is no duration, so nothing has to be timed or refreshed.
|
if (options.preventSystemSleep) flags += "s";
|
||||||
*/
|
return flags;
|
||||||
export function buildCommand(platform: SupportedPlatform, options: CaffeinateOptions): CaffeinateCommand {
|
}
|
||||||
|
|
||||||
|
export function argsFromFlags(flags: string): string[] {
|
||||||
|
return [...flags].map((letter) => `-${letter}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The binary that actually holds the assertion, needed to check it exists. */
|
||||||
|
export function binaryForPlatform(platform: SupportedPlatform): string {
|
||||||
|
return platform === "darwin" ? MACOS_CAFFEINATE_PATH : LINUX_INHIBIT_COMMAND;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REASON_LABEL = "Caffeinate (Vicinae extension)";
|
||||||
|
|
||||||
|
/** Turn flag letters into something a human can read in the status view. */
|
||||||
|
export function describeFlags(flags: string): string[] {
|
||||||
|
const lines = ["Idle sleep is blocked."];
|
||||||
|
if (flags.includes("d")) {
|
||||||
|
lines.push("Display sleep is blocked (macOS only).");
|
||||||
|
}
|
||||||
|
if (flags.includes("s")) {
|
||||||
|
lines.push("Full system sleep is blocked (macOS only honours this on AC power).");
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCommand(platform: SupportedPlatform, flags: string, marker: string): CaffeinateCommand {
|
||||||
|
const flagArgs = argsFromFlags(flags);
|
||||||
|
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
// -i prevents idle sleep; the assertion is held for as long as this process lives.
|
// `caffeinate` has no free-form field, so the marker is injected as argv[0]
|
||||||
const args = ["-i"];
|
// via the shell's `exec -a`. `exec` replaces the shell in place, so the
|
||||||
if (options.preventDisplaySleep) args.push("-d");
|
// child pid Node hands back is the caffeinate pid.
|
||||||
if (options.preventSystemSleep) args.push("-s");
|
const invocation = `exec -a '${marker}' '${MACOS_CAFFEINATE_PATH}' ${flagArgs.join(" ")}`;
|
||||||
return { command: MACOS_CAFFEINATE_PATH, args, processName: "caffeinate" };
|
return {
|
||||||
|
command: "/bin/sh",
|
||||||
|
args: ["-c", invocation],
|
||||||
|
display: `${MACOS_CAFFEINATE_PATH} ${flagArgs.join(" ")}`,
|
||||||
|
flags,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// systemd-inhibit has no equivalent for keeping the display on, so
|
// systemd-inhibit takes a free-form --why, so the marker rides along with a
|
||||||
// preventDisplaySleep is intentionally not mapped here.
|
// human-readable reason. `sleep infinity` holds the lock until signalled.
|
||||||
const args = ["--what=idle:sleep", `--why=${options.reason}`, "sleep", "infinity"];
|
return {
|
||||||
return { command: "systemd-inhibit", args, processName: "systemd-inhibit" };
|
command: LINUX_INHIBIT_COMMAND,
|
||||||
|
args: [
|
||||||
|
"--what=idle:sleep",
|
||||||
|
`--why=${REASON_LABEL} [${marker}]`,
|
||||||
|
"sleep",
|
||||||
|
"infinity",
|
||||||
|
],
|
||||||
|
display: `${LINUX_INHIBIT_COMMAND} --what=idle:sleep --why="${REASON_LABEL}" sleep infinity`,
|
||||||
|
flags,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { LocalStorage } from "@vicinae/api";
|
|
||||||
import { CaffeinateState } from "./types";
|
|
||||||
|
|
||||||
const STATE_KEY = "caffeinate_state";
|
|
||||||
|
|
||||||
function isState(value: unknown): value is CaffeinateState {
|
|
||||||
if (typeof value !== "object" || value === null) return false;
|
|
||||||
const s = value as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof s.pid === "number" &&
|
|
||||||
typeof s.startedAt === "number" &&
|
|
||||||
typeof s.command === "string" &&
|
|
||||||
typeof s.processName === "string" &&
|
|
||||||
Array.isArray(s.args)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the recorded caffeination state.
|
|
||||||
*
|
|
||||||
* Note the falsy check rather than `=== undefined`: Vicinae's
|
|
||||||
* `LocalStorage.getItem` resolves to `null` for a missing key where Raycast
|
|
||||||
* resolves to `undefined`. Guarding on falsiness is the only form that is
|
|
||||||
* correct on both runtimes.
|
|
||||||
*/
|
|
||||||
export async function readState(): Promise<CaffeinateState | null> {
|
|
||||||
const raw = await LocalStorage.getItem<string>(STATE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
|
||||||
return isState(parsed) ? parsed : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeState(state: CaffeinateState): Promise<void> {
|
|
||||||
await LocalStorage.setItem(STATE_KEY, JSON.stringify(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearState(): Promise<void> {
|
|
||||||
await LocalStorage.removeItem(STATE_KEY);
|
|
||||||
}
|
|
||||||
+16
-12
@@ -3,34 +3,38 @@ export type SupportedPlatform = "darwin" | "linux";
|
|||||||
export interface CaffeinateOptions {
|
export interface CaffeinateOptions {
|
||||||
preventDisplaySleep: boolean;
|
preventDisplaySleep: boolean;
|
||||||
preventSystemSleep: boolean;
|
preventSystemSleep: boolean;
|
||||||
reason: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaffeinateCommand {
|
export interface CaffeinateCommand {
|
||||||
|
/** Executable to spawn. */
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
/**
|
/** Human-readable equivalent, for display. */
|
||||||
* Basename of the process as reported by `ps -o comm=`. Used to confirm a
|
display: string;
|
||||||
* stored PID still belongs to us before signalling it, since PIDs are recycled.
|
/** Canonical flag letters, e.g. "i", "id", "ids". Encoded into the marker. */
|
||||||
*/
|
flags: string;
|
||||||
processName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A caffeination session, reconstructed from the running process rather than
|
||||||
|
* from stored state.
|
||||||
|
*/
|
||||||
export interface CaffeinateState {
|
export interface CaffeinateState {
|
||||||
pid: number;
|
pid: number;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
command: string;
|
flags: string;
|
||||||
args: string[];
|
|
||||||
processName: string;
|
|
||||||
reason: string;
|
|
||||||
platform: SupportedPlatform;
|
platform: SupportedPlatform;
|
||||||
|
marker: string;
|
||||||
|
/** The process's own command line, for the status view. */
|
||||||
|
invocation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaffeinationStatus {
|
export interface CaffeinationStatus {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** The oldest marked process, if any. */
|
||||||
state: CaffeinateState | null;
|
state: CaffeinateState | null;
|
||||||
/** State was recorded but the process is gone (killed elsewhere, or a reboot). */
|
/** Any further marked processes. Should stay empty; self-heals on the next start/stop. */
|
||||||
stale: boolean;
|
extra: CaffeinateState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToggleResult = "started" | "stopped";
|
export type ToggleResult = "started" | "stopped";
|
||||||
|
|||||||
+19
-2
@@ -2,6 +2,7 @@ import { Action, ActionPanel, Color, Icon, List, Toast, openExtensionPreferences
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getStatus, optionsFromPreferences, startCaffeination, stopCaffeination } from "./lib/caffeinate";
|
import { getStatus, optionsFromPreferences, startCaffeination, stopCaffeination } from "./lib/caffeinate";
|
||||||
|
import { describeFlags } from "./lib/platform";
|
||||||
import { CaffeinationStatus } from "./lib/types";
|
import { CaffeinationStatus } from "./lib/types";
|
||||||
|
|
||||||
export default function CaffeinationStatusView() {
|
export default function CaffeinationStatusView() {
|
||||||
@@ -49,6 +50,7 @@ export default function CaffeinationStatusView() {
|
|||||||
|
|
||||||
const state = status?.state ?? null;
|
const state = status?.state ?? null;
|
||||||
const active = status?.active ?? false;
|
const active = status?.active ?? false;
|
||||||
|
const extra = status?.extra ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List isLoading={isLoading} navigationTitle="Caffeination">
|
<List isLoading={isLoading} navigationTitle="Caffeination">
|
||||||
@@ -77,6 +79,19 @@ export default function CaffeinationStatusView() {
|
|||||||
</ActionPanel>
|
</ActionPanel>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{extra.length > 0 && (
|
||||||
|
<List.Item
|
||||||
|
icon={{ source: Icon.Exclamationmark, tintColor: Color.Orange }}
|
||||||
|
title={`${extra.length} extra session${extra.length === 1 ? "" : "s"} running`}
|
||||||
|
subtitle="Unexpected — pressing Stop clears them all"
|
||||||
|
actions={
|
||||||
|
<ActionPanel>
|
||||||
|
<Action title="Stop All Sessions" icon={Icon.StopFilled} onAction={stop} />
|
||||||
|
</ActionPanel>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</List.Section>
|
</List.Section>
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
@@ -92,7 +107,9 @@ function buildDetail(active: boolean, state: CaffeinationStatus["state"]): strin
|
|||||||
return [
|
return [
|
||||||
"# Keeping awake",
|
"# Keeping awake",
|
||||||
"",
|
"",
|
||||||
`Holding a sleep assertion via \`${state.processName}\`. It stays in place until stopped.`,
|
"It stays in place until stopped.",
|
||||||
|
"",
|
||||||
|
...describeFlags(state.flags).map((line) => `- ${line}`),
|
||||||
"",
|
"",
|
||||||
"| | |",
|
"| | |",
|
||||||
"|---|---|",
|
"|---|---|",
|
||||||
@@ -100,6 +117,6 @@ function buildDetail(active: boolean, state: CaffeinationStatus["state"]): strin
|
|||||||
`| PID | ${state.pid} |`,
|
`| PID | ${state.pid} |`,
|
||||||
`| Platform | ${state.platform} |`,
|
`| Platform | ${state.platform} |`,
|
||||||
"",
|
"",
|
||||||
`\`${state.command} ${state.args.join(" ")}\``,
|
`\`${state.invocation}\``,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user