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:
2026-09-20 17:08:19 +08:00
parent a303e161d3
commit 5ca37fbc79
7 changed files with 360 additions and 182 deletions
+27 -4
View File
@@ -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
+88 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
}
-43
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}