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 |
|
||||
| --- | --- | --- |
|
||||
| **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
LINUX_INHIBIT_COMMAND,
|
||||
MACOS_CAFFEINATE_PATH,
|
||||
UnsupportedPlatformError,
|
||||
argsFromFlags,
|
||||
buildCommand,
|
||||
buildMarker,
|
||||
describeFlags,
|
||||
detectPlatform,
|
||||
flagsFromOptions,
|
||||
parseMarker,
|
||||
} from "../lib/platform";
|
||||
|
||||
const base = { preventDisplaySleep: false, preventSystemSleep: false, reason: "test" };
|
||||
const noOptions = { preventDisplaySleep: false, preventSystemSleep: false };
|
||||
|
||||
describe("detectPlatform", () => {
|
||||
it("accepts the supported platforms", () => {
|
||||
@@ -21,46 +27,102 @@ describe("detectPlatform", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCommand on macOS", () => {
|
||||
describe("flags", () => {
|
||||
it("always blocks idle sleep", () => {
|
||||
expect(buildCommand("darwin", base)).toEqual({
|
||||
command: MACOS_CAFFEINATE_PATH,
|
||||
args: ["-i"],
|
||||
processName: "caffeinate",
|
||||
expect(flagsFromOptions(noOptions)).toBe("i");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true }).args).toEqual(["-i", "-d"]);
|
||||
expect(buildCommand("darwin", { ...base, preventSystemSleep: true }).args).toEqual(["-i", "-s"]);
|
||||
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true, preventSystemSleep: true }).args).toEqual([
|
||||
"-i",
|
||||
"-d",
|
||||
"-s",
|
||||
]);
|
||||
it("does not confuse systemd-inhibit's own colons for marker fields", () => {
|
||||
// `--what=idle:sleep` contains colons, and the marker follows it.
|
||||
const marker = buildMarker(1737000000000, "i");
|
||||
const line = `${LINUX_INHIBIT_COMMAND} --what=idle:sleep --why=Caffeinate (Vicinae extension) [${marker}] sleep infinity`;
|
||||
expect(parseMarker(line)).toEqual({ marker, startedAt: 1737000000000, flags: "i" });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(buildCommand("darwin", base).args).not.toContain("-t");
|
||||
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true }).args).not.toContain("-t");
|
||||
expect(buildCommand("darwin", "ids", marker).args[1]).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", () => {
|
||||
it("uses systemd-inhibit with an infinite sleep", () => {
|
||||
expect(buildCommand("linux", base)).toEqual({
|
||||
command: "systemd-inhibit",
|
||||
args: ["--what=idle:sleep", "--why=test", "sleep", "infinity"],
|
||||
processName: "systemd-inhibit",
|
||||
});
|
||||
const marker = buildMarker(1737000000000, "i");
|
||||
|
||||
it("carries the marker in --why and holds the lock with sleep infinity", () => {
|
||||
const spec = buildCommand("linux", "i", marker);
|
||||
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", () => {
|
||||
const { args } = buildCommand("linux", { ...base, preventDisplaySleep: true });
|
||||
expect(args).toEqual(["--what=idle:sleep", "--why=test", "sleep", "infinity"]);
|
||||
// The `d` flag is a macOS concept; Linux maps it away rather than pretending.
|
||||
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", () => {
|
||||
expect(buildCommand("linux", { ...base, reason: "Plex" }).args).toContain("--why=Plex");
|
||||
describe("describeFlags", () => {
|
||||
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 { getPreferenceValues } from "@vicinae/api";
|
||||
|
||||
import { MACOS_CAFFEINATE_PATH, buildCommand, detectPlatform } from "./platform";
|
||||
import { clearState, readState, writeState } from "./state";
|
||||
import {
|
||||
MACOS_CAFFEINATE_PATH,
|
||||
binaryForPlatform,
|
||||
buildCommand,
|
||||
buildMarker,
|
||||
detectPlatform,
|
||||
flagsFromOptions,
|
||||
parseMarker,
|
||||
} from "./platform";
|
||||
import { CaffeinateOptions, CaffeinateState, CaffeinationStatus, ToggleResult } from "./types";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_REASON = "Caffeinate (Vicinae extension)";
|
||||
const LIVENESS_TIMEOUT_MS = 900;
|
||||
const LIVENESS_TIMEOUT_MS = 1200;
|
||||
const LIVENESS_POLL_MS = 90;
|
||||
|
||||
export interface CaffeinatePreferences {
|
||||
@@ -18,12 +24,11 @@ export interface CaffeinatePreferences {
|
||||
preventSystemSleep?: boolean;
|
||||
}
|
||||
|
||||
export function optionsFromPreferences(reason: string = DEFAULT_REASON): CaffeinateOptions {
|
||||
export function optionsFromPreferences(): CaffeinateOptions {
|
||||
const prefs = getPreferenceValues<CaffeinatePreferences>();
|
||||
return {
|
||||
preventDisplaySleep: Boolean(prefs.preventDisplaySleep),
|
||||
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. */
|
||||
async function resolveExecutable(platform: string, command: string): Promise<string> {
|
||||
if (platform === "darwin" && command === MACOS_CAFFEINATE_PATH) {
|
||||
/** Fail early with a clear message if the platform tool is missing. */
|
||||
async function assertBinaryAvailable(platform: string): Promise<void> {
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
await access(MACOS_CAFFEINATE_PATH);
|
||||
return MACOS_CAFFEINATE_PATH;
|
||||
return;
|
||||
} catch {
|
||||
// fall through to PATH lookup
|
||||
}
|
||||
}
|
||||
const resolved = await which(command);
|
||||
if (resolved) return resolved;
|
||||
const binary = binaryForPlatform(platform === "linux" ? "linux" : "darwin");
|
||||
if (await which(binary)) return;
|
||||
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?" : ""
|
||||
}`.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
async function processNameFor(pid: number): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "comm="]);
|
||||
const name = stdout.trim();
|
||||
return name || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
interface MarkedProcess {
|
||||
pid: number;
|
||||
marker: string;
|
||||
startedAt: number;
|
||||
flags: string;
|
||||
invocation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* report success for an unrelated process and we would later signal the wrong
|
||||
* thing.
|
||||
* This is the whole source of truth. Nothing is persisted, so there is no
|
||||
* record that can be lost while the process keeps running — the failure mode
|
||||
* 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> {
|
||||
try {
|
||||
process.kill(state.pid, 0);
|
||||
} catch {
|
||||
return false;
|
||||
async function scanSessions(): Promise<MarkedProcess[]> {
|
||||
const { stdout } = await execFileAsync("ps", ["-axo", "pid=,command="]);
|
||||
const found: MarkedProcess[] = [];
|
||||
|
||||
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 name.split("/").pop() === state.processName;
|
||||
|
||||
return found.sort((a, b) => a.startedAt - b.startedAt);
|
||||
}
|
||||
|
||||
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;
|
||||
for (;;) {
|
||||
if (await isProcessRunning(state)) return true;
|
||||
if (Date.now() >= deadline) return false;
|
||||
const found = (await scanSessions()).find((p) => p.marker === marker);
|
||||
if (found) return found;
|
||||
if (Date.now() >= deadline) return null;
|
||||
await new Promise((resolve) => setTimeout(resolve, LIVENESS_POLL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const platform = detectPlatform();
|
||||
const spec = buildCommand(platform, options);
|
||||
const command = await resolveExecutable(platform, spec.command);
|
||||
await assertBinaryAvailable(platform);
|
||||
|
||||
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
|
||||
// as its function returns, so the process has to outlive the command that
|
||||
// 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();
|
||||
|
||||
if (child.pid === undefined) {
|
||||
throw new Error(`Failed to start ${spec.processName}.`);
|
||||
const confirmed = await waitForMarker(marker);
|
||||
if (!confirmed) {
|
||||
// Never leave a half-started session behind.
|
||||
await stopCaffeination();
|
||||
throw new Error(`${spec.display} exited immediately or never registered.`);
|
||||
}
|
||||
|
||||
const state: CaffeinateState = {
|
||||
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;
|
||||
return toState(confirmed, platform);
|
||||
}
|
||||
|
||||
/** @returns true when a running process was actually stopped. */
|
||||
export async function stopCaffeination(): Promise<boolean> {
|
||||
const state = await readState();
|
||||
if (!state) return false;
|
||||
/** @returns how many sessions were stopped. */
|
||||
export async function stopCaffeination(): Promise<number> {
|
||||
const sessions = await scanSessions();
|
||||
let stopped = 0;
|
||||
|
||||
let stopped = false;
|
||||
if (await isProcessRunning(state)) {
|
||||
try {
|
||||
process.kill(state.pid, "SIGTERM");
|
||||
stopped = true;
|
||||
} catch {
|
||||
// Disappeared between the check and the signal — nothing to do.
|
||||
for (const session of sessions) {
|
||||
if (signalTree(session.pid, "SIGTERM")) stopped += 1;
|
||||
}
|
||||
|
||||
if (stopped > 0) {
|
||||
// Give them a moment to leave the process table so a following scan is accurate.
|
||||
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;
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<CaffeinationStatus> {
|
||||
const state = await readState();
|
||||
if (!state) return { active: false, state: null, stale: false };
|
||||
const platform = detectPlatform();
|
||||
const sessions = await scanSessions();
|
||||
|
||||
if (!(await isProcessRunning(state))) {
|
||||
// The process was killed outside the extension, or the machine rebooted. Self-heal.
|
||||
await clearState();
|
||||
return { active: false, state, stale: true };
|
||||
}
|
||||
if (sessions.length === 0) return { active: false, state: null, extra: [] };
|
||||
|
||||
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> {
|
||||
|
||||
+84
-16
@@ -10,29 +10,97 @@ export class UnsupportedPlatformError extends Error {
|
||||
}
|
||||
|
||||
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 {
|
||||
if (platform === "darwin" || platform === "linux") return platform;
|
||||
throw new UnsupportedPlatformError(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the command that holds the sleep assertion.
|
||||
*
|
||||
* Both variants run until they are signalled, which is what "on" means here:
|
||||
* there is no duration, so nothing has to be timed or refreshed.
|
||||
*/
|
||||
export function buildCommand(platform: SupportedPlatform, options: CaffeinateOptions): CaffeinateCommand {
|
||||
/** Canonical flag letters: always blocks idle sleep, optionally display and system. */
|
||||
export function flagsFromOptions(options: CaffeinateOptions): string {
|
||||
let flags = "i";
|
||||
if (options.preventDisplaySleep) flags += "d";
|
||||
if (options.preventSystemSleep) flags += "s";
|
||||
return flags;
|
||||
}
|
||||
|
||||
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") {
|
||||
// -i prevents idle sleep; the assertion is held for as long as this process lives.
|
||||
const args = ["-i"];
|
||||
if (options.preventDisplaySleep) args.push("-d");
|
||||
if (options.preventSystemSleep) args.push("-s");
|
||||
return { command: MACOS_CAFFEINATE_PATH, args, processName: "caffeinate" };
|
||||
// `caffeinate` has no free-form field, so the marker is injected as argv[0]
|
||||
// via the shell's `exec -a`. `exec` replaces the shell in place, so the
|
||||
// child pid Node hands back is the caffeinate pid.
|
||||
const invocation = `exec -a '${marker}' '${MACOS_CAFFEINATE_PATH}' ${flagArgs.join(" ")}`;
|
||||
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
|
||||
// preventDisplaySleep is intentionally not mapped here.
|
||||
const args = ["--what=idle:sleep", `--why=${options.reason}`, "sleep", "infinity"];
|
||||
return { command: "systemd-inhibit", args, processName: "systemd-inhibit" };
|
||||
// systemd-inhibit takes a free-form --why, so the marker rides along with a
|
||||
// human-readable reason. `sleep infinity` holds the lock until signalled.
|
||||
return {
|
||||
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 {
|
||||
preventDisplaySleep: boolean;
|
||||
preventSystemSleep: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CaffeinateCommand {
|
||||
/** Executable to spawn. */
|
||||
command: string;
|
||||
args: string[];
|
||||
/**
|
||||
* Basename of the process as reported by `ps -o comm=`. Used to confirm a
|
||||
* stored PID still belongs to us before signalling it, since PIDs are recycled.
|
||||
*/
|
||||
processName: string;
|
||||
/** Human-readable equivalent, for display. */
|
||||
display: string;
|
||||
/** Canonical flag letters, e.g. "i", "id", "ids". Encoded into the marker. */
|
||||
flags: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A caffeination session, reconstructed from the running process rather than
|
||||
* from stored state.
|
||||
*/
|
||||
export interface CaffeinateState {
|
||||
pid: number;
|
||||
startedAt: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
processName: string;
|
||||
reason: string;
|
||||
flags: string;
|
||||
platform: SupportedPlatform;
|
||||
marker: string;
|
||||
/** The process's own command line, for the status view. */
|
||||
invocation: string;
|
||||
}
|
||||
|
||||
export interface CaffeinationStatus {
|
||||
active: boolean;
|
||||
/** The oldest marked process, if any. */
|
||||
state: CaffeinateState | null;
|
||||
/** State was recorded but the process is gone (killed elsewhere, or a reboot). */
|
||||
stale: boolean;
|
||||
/** Any further marked processes. Should stay empty; self-heals on the next start/stop. */
|
||||
extra: CaffeinateState[];
|
||||
}
|
||||
|
||||
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 { getStatus, optionsFromPreferences, startCaffeination, stopCaffeination } from "./lib/caffeinate";
|
||||
import { describeFlags } from "./lib/platform";
|
||||
import { CaffeinationStatus } from "./lib/types";
|
||||
|
||||
export default function CaffeinationStatusView() {
|
||||
@@ -49,6 +50,7 @@ export default function CaffeinationStatusView() {
|
||||
|
||||
const state = status?.state ?? null;
|
||||
const active = status?.active ?? false;
|
||||
const extra = status?.extra ?? [];
|
||||
|
||||
return (
|
||||
<List isLoading={isLoading} navigationTitle="Caffeination">
|
||||
@@ -77,6 +79,19 @@ export default function CaffeinationStatusView() {
|
||||
</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>
|
||||
);
|
||||
@@ -92,7 +107,9 @@ function buildDetail(active: boolean, state: CaffeinationStatus["state"]): strin
|
||||
return [
|
||||
"# 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} |`,
|
||||
`| Platform | ${state.platform} |`,
|
||||
"",
|
||||
`\`${state.command} ${state.args.join(" ")}\``,
|
||||
`\`${state.invocation}\``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user