diff --git a/README.md b/README.md index 99b2d73..83eedc5 100644 --- a/README.md +++ b/README.md @@ -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:: 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 -`). 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 diff --git a/src/__tests__/platform.test.ts b/src/__tests__/platform.test.ts index b262906..db89bc0 100644 --- a/src/__tests__/platform.test.ts +++ b/src/__tests__/platform.test.ts @@ -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/); }); }); diff --git a/src/lib/caffeinate.ts b/src/lib/caffeinate.ts index fd0283f..0475e16 100644 --- a/src/lib/caffeinate.ts +++ b/src/lib/caffeinate.ts @@ -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(); return { preventDisplaySleep: Boolean(prefs.preventDisplaySleep), preventSystemSleep: Boolean(prefs.preventSystemSleep), - reason, }; } @@ -37,128 +42,170 @@ async function which(command: string): Promise { } } -/** Resolve an absolute path for the command, preferring well-known locations. */ -async function resolveExecutable(platform: string, command: string): Promise { - 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 { + 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 { - 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 { - try { - process.kill(state.pid, 0); - } catch { - return false; +async function scanSessions(): Promise { + 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 { +/** + * 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 { 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 { - // 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 { - const state = await readState(); - if (!state) return false; +/** @returns how many sessions were stopped. */ +export async function stopCaffeination(): Promise { + 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 { - 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 { diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 67d5faf..ef73aa0 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -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::` + */ +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, + }; } diff --git a/src/lib/state.ts b/src/lib/state.ts deleted file mode 100644 index 90e2383..0000000 --- a/src/lib/state.ts +++ /dev/null @@ -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; - 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 { - const raw = await LocalStorage.getItem(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 { - await LocalStorage.setItem(STATE_KEY, JSON.stringify(state)); -} - -export async function clearState(): Promise { - await LocalStorage.removeItem(STATE_KEY); -} diff --git a/src/lib/types.ts b/src/lib/types.ts index f4895f0..be210f9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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"; diff --git a/src/status.tsx b/src/status.tsx index fc85ea8..e70c918 100644 --- a/src/status.tsx +++ b/src/status.tsx @@ -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 ( @@ -77,6 +79,19 @@ export default function CaffeinationStatusView() { } /> + + {extra.length > 0 && ( + + + + } + /> + )} ); @@ -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"); }