Add native Vicinae caffeinate extension

Keeps the computer awake from Vicinae. Written against @vicinae/api
directly because the Raycast `coffee` extension cannot work here:

- Its `index` command is `menu-bar` mode, which Vicinae does not support.
- Its `status` command crashes on `Cannot read properties of null
  (reading 'IsManuallyDecafed')` because it gates on
  `LocalStorage.getItem(key) === undefined`. Vicinae resolves a missing
  key to `null`, not `undefined` (verified with a runtime probe).

Sleep prevention is an OS-level assertion, so the extension delegates and
gets out of the way:

- macOS:  caffeinate -i [-d] [-s] [-t <seconds>]
- Linux:  systemd-inhibit --what=idle:sleep --why=<reason> sleep <n|infinity>

The process is spawned detached + unref'd, because Vicinae unloads a
no-view command as soon as its function returns. Timed sessions need no
babysitter: `caffeinate -t` and `sleep <n>` self-terminate.

Commands: toggle (no-view), status (view), caffeinate-for (view).

Two deliberate safety choices:
- Stopping signals the recorded PID rather than running `killall
  caffeinate`, so a caffeinate the user started themselves survives.
  Verified against a real concurrent `caffeinate -u -dmi` process.
- The PID's name is re-checked with `ps -o comm=` before signalling, since
  PIDs get recycled and a stale entry could otherwise kill something
  unrelated.

Also: display-sleep prevention is macOS-only, and the UI says so on Linux
rather than pretending systemd-inhibit can hold the display awake.

Verified: vici lint, tsc --noEmit, eslint, 23/23 vitest, vici build, and
live in Vicinae — toggle starts `caffeinate -i` which survives the
command unloading, toggle again stops only ours, both view commands load
with clean stdout/stderr.
This commit is contained in:
2026-09-20 16:16:57 +08:00
commit 65005b22a6
19 changed files with 4749 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { formatDuration, formatRemaining, normalizeDuration, parseDuration } from "../lib/duration";
describe("parseDuration", () => {
it("treats a bare number as minutes", () => {
expect(parseDuration("30")).toBe(1800);
expect(parseDuration("1")).toBe(60);
expect(parseDuration("1.5")).toBe(90);
});
it("parses single units", () => {
expect(parseDuration("90s")).toBe(90);
expect(parseDuration("45m")).toBe(2700);
expect(parseDuration("2h")).toBe(7200);
expect(parseDuration("1d")).toBe(86400);
});
it("parses combined units", () => {
expect(parseDuration("1h30m")).toBe(5400);
expect(parseDuration("1d2h")).toBe(93600);
expect(parseDuration("1h30m10s")).toBe(5410);
});
it("accepts friendly words and spacing", () => {
expect(parseDuration("1 hour")).toBe(3600);
expect(parseDuration("30 minutes")).toBe(1800);
expect(parseDuration("2 hrs")).toBe(7200);
expect(parseDuration(" 45 m ")).toBe(2700);
expect(parseDuration("90 seconds")).toBe(90);
});
it("is case insensitive", () => {
expect(parseDuration("2H")).toBe(7200);
expect(parseDuration("30M")).toBe(1800);
});
it("rejects invalid or non-positive input", () => {
expect(parseDuration("")).toBeNull();
expect(parseDuration(" ")).toBeNull();
expect(parseDuration("abc")).toBeNull();
expect(parseDuration("0")).toBeNull();
expect(parseDuration("0m")).toBeNull();
expect(parseDuration("-5")).toBeNull();
expect(parseDuration("h")).toBeNull();
expect(parseDuration("m30")).toBeNull();
});
it("rejects partially consumed input rather than misreading it", () => {
expect(parseDuration("1h30")).toBeNull();
expect(parseDuration("30m garbage")).toBeNull();
expect(parseDuration("1h 30x")).toBeNull();
});
});
describe("normalizeDuration", () => {
it("expands words and strips whitespace", () => {
expect(normalizeDuration("1 Hour 30 Minutes")).toBe("1h30m");
expect(normalizeDuration(" 45 mins ")).toBe("45m");
expect(normalizeDuration("1 day")).toBe("1d");
});
});
describe("formatDuration", () => {
it("formats the largest two units", () => {
expect(formatDuration(45)).toBe("45s");
expect(formatDuration(90)).toBe("1m 30s");
expect(formatDuration(1800)).toBe("30m");
expect(formatDuration(3600)).toBe("1h");
expect(formatDuration(5400)).toBe("1h 30m");
expect(formatDuration(93600)).toBe("1d 2h");
});
it("handles zero and negatives", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(-10)).toBe("0s");
});
});
describe("formatRemaining", () => {
it("rounds down to whole units and never goes negative", () => {
const now = 1_000_000;
expect(formatRemaining(now + 90_000, now)).toBe("1m 30s");
expect(formatRemaining(now + 3_600_000, now)).toBe("1h");
expect(formatRemaining(now - 5_000, now)).toBe("0s");
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { MACOS_CAFFEINATE_PATH, UnsupportedPlatformError, buildCommand, describeOptions, detectPlatform } from "../lib/platform";
const base = { preventDisplaySleep: false, preventSystemSleep: false, reason: "test" };
describe("detectPlatform", () => {
it("accepts the supported platforms", () => {
expect(detectPlatform("darwin")).toBe("darwin");
expect(detectPlatform("linux")).toBe("linux");
});
it("throws a helpful error elsewhere", () => {
expect(() => detectPlatform("win32")).toThrow(UnsupportedPlatformError);
expect(() => detectPlatform("win32")).toThrow(/systemd-inhibit/);
});
});
describe("buildCommand on macOS", () => {
it("always blocks idle sleep", () => {
expect(buildCommand("darwin", base)).toEqual({
command: MACOS_CAFFEINATE_PATH,
args: ["-i"],
processName: "caffeinate",
});
});
it("adds display and system assertions when requested", () => {
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true, preventSystemSleep: true }).args).toEqual([
"-i",
"-d",
"-s",
]);
});
it("adds a self-expiring timeout for durations", () => {
expect(buildCommand("darwin", { ...base, durationSeconds: 1800 }).args).toEqual(["-i", "-t", "1800"]);
});
it("floors fractional durations", () => {
expect(buildCommand("darwin", { ...base, durationSeconds: 90.7 }).args).toEqual(["-i", "-t", "90"]);
});
it("omits the timeout for indefinite or non-positive durations", () => {
expect(buildCommand("darwin", { ...base, durationSeconds: 0 }).args).toEqual(["-i"]);
expect(buildCommand("darwin", { ...base, durationSeconds: -60 }).args).toEqual(["-i"]);
expect(buildCommand("darwin", base).args).not.toContain("-t");
});
});
describe("buildCommand on Linux", () => {
it("uses systemd-inhibit with an infinite sleep by default", () => {
expect(buildCommand("linux", base)).toEqual({
command: "systemd-inhibit",
args: ["--what=idle:sleep", "--why=test", "sleep", "infinity"],
processName: "systemd-inhibit",
});
});
it("hands the duration to sleep so it self-terminates", () => {
expect(buildCommand("linux", { ...base, durationSeconds: 600 }).args).toEqual([
"--what=idle:sleep",
"--why=test",
"sleep",
"600",
]);
});
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"]);
});
});
describe("describeOptions", () => {
it("only mentions the display when it can actually be held", () => {
const macLines = describeOptions("darwin", { ...base, preventDisplaySleep: true });
const linuxLines = describeOptions("linux", { ...base, preventDisplaySleep: true });
expect(macLines.join(" ")).toMatch(/Display stays on/);
expect(linuxLines.join(" ")).toMatch(/cannot be held/);
});
it("always describes the baseline behaviour", () => {
expect(describeOptions("darwin", base)[0]).toMatch(/idle sleep blocked/);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { Action, ActionPanel, Form, Icon, Toast, popToRoot, showHUD, showToast } from "@vicinae/api";
import { useState } from "react";
import { optionsFromPreferences, startCaffeination } from "./lib/caffeinate";
import { formatDuration, parseDuration } from "./lib/duration";
import { describeOptions, detectPlatform } from "./lib/platform";
export default function CaffeinateFor() {
const [duration, setDuration] = useState("30");
const parsed = parseDuration(duration);
const resolved = parsed === null ? "Enter a duration, e.g. 30, 45m, 2h or 1h30m" : `Staying awake for ${formatDuration(parsed)}`;
let behaviour: string[];
try {
behaviour = describeOptions(detectPlatform(), optionsFromPreferences(parsed ?? undefined));
} catch (error) {
behaviour = [error instanceof Error ? error.message : String(error)];
}
async function handleSubmit(values: Form.Values) {
const seconds = parseDuration(String(values.duration ?? ""));
if (seconds === null) {
await showToast({
style: Toast.Style.Failure,
title: "Not a valid duration",
message: "Try a number of minutes, or values like 45m, 2h or 1h30m.",
});
return;
}
try {
await startCaffeination(optionsFromPreferences(seconds, `Caffeinate for ${formatDuration(seconds)}`));
await showHUD(`☕ Staying awake for ${formatDuration(seconds)}`);
await popToRoot();
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Could not start caffeinating",
message: error instanceof Error ? error.message : String(error),
});
}
}
return (
<Form
navigationTitle="Caffeinate for a Duration"
actions={
<ActionPanel>
<Action.SubmitForm title="Start Caffeinating" icon={Icon.MugSteam} onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.TextField
id="duration"
title="Duration"
placeholder="30"
defaultValue="30"
info="A bare number means minutes. Also accepts 45m, 2h, 1h30m, 90s."
onChange={setDuration}
/>
<Form.Description title="Will run" text={resolved} />
<Form.Separator />
{behaviour.map((line) => (
<Form.Description key={line} text={line} />
))}
<Form.Description text="Tip: open the extension preferences to also hold off display or system sleep." />
</Form>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { spawn, execFile } from "node:child_process";
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 { CaffeinateOptions, CaffeinateState, CaffeinationStatus, ToggleResult } from "./types";
const execFileAsync = promisify(execFile);
const DEFAULT_REASON = "Caffeinate (Vicinae extension)";
const LIVENESS_TIMEOUT_MS = 900;
const LIVENESS_POLL_MS = 90;
export interface CaffeinatePreferences {
preventDisplaySleep?: boolean;
preventSystemSleep?: boolean;
}
export function optionsFromPreferences(durationSeconds?: number, reason: string = DEFAULT_REASON): CaffeinateOptions {
const prefs = getPreferenceValues<CaffeinatePreferences>();
return {
preventDisplaySleep: Boolean(prefs.preventDisplaySleep),
preventSystemSleep: Boolean(prefs.preventSystemSleep),
durationSeconds,
reason,
};
}
async function which(command: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync("which", [command]);
const first = stdout.trim().split("\n")[0];
return first || null;
} catch {
return 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) {
try {
await access(MACOS_CAFFEINATE_PATH);
return MACOS_CAFFEINATE_PATH;
} catch {
// fall through to PATH lookup
}
}
const resolved = await which(command);
if (resolved) return resolved;
throw new Error(
`Could not find \`${command}\` 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;
}
}
/**
* Is the recorded process still alive *and* still ours?
*
* 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.
*/
export async function isProcessRunning(state: CaffeinateState): Promise<boolean> {
try {
process.kill(state.pid, 0);
} catch {
return false;
}
const name = await processNameFor(state.pid);
if (!name) return false;
return name.split("/").pop() === state.processName;
}
async function waitUntilRunning(state: CaffeinateState): Promise<boolean> {
const deadline = Date.now() + LIVENESS_TIMEOUT_MS;
for (;;) {
if (await isProcessRunning(state)) return true;
if (Date.now() >= deadline) return false;
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.
await stopCaffeination();
const platform = detectPlatform();
const spec = buildCommand(platform, options);
const command = await resolveExecutable(platform, spec.command);
// `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" });
child.unref();
if (child.pid === undefined) {
throw new Error(`Failed to start ${spec.processName}.`);
}
const state: CaffeinateState = {
pid: child.pid,
startedAt: Date.now(),
expiresAt: options.durationSeconds && options.durationSeconds > 0 ? Date.now() + options.durationSeconds * 1000 : null,
durationSeconds: options.durationSeconds && options.durationSeconds > 0 ? Math.floor(options.durationSeconds) : null,
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. */
export async function stopCaffeination(): Promise<boolean> {
const state = await readState();
if (!state) return false;
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.
}
}
await clearState();
return stopped;
}
export async function getStatus(): Promise<CaffeinationStatus> {
const state = await readState();
if (!state) return { active: false, state: null, stale: false };
if (!(await isProcessRunning(state))) {
// The process timed out or was killed outside the extension. Self-heal.
await clearState();
return { active: false, state, stale: true };
}
return { active: true, state, stale: false };
}
export async function toggleCaffeination(): Promise<ToggleResult> {
const status = await getStatus();
if (status.active) {
await stopCaffeination();
return "stopped";
}
await startCaffeination(optionsFromPreferences());
return "started";
}
+77
View File
@@ -0,0 +1,77 @@
const UNIT_SECONDS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
export const DURATION_PRESETS: { title: string; seconds: number }[] = [
{ title: "15 minutes", seconds: 15 * 60 },
{ title: "30 minutes", seconds: 30 * 60 },
{ title: "45 minutes", seconds: 45 * 60 },
{ title: "1 hour", seconds: 3600 },
{ title: "2 hours", seconds: 2 * 3600 },
{ title: "4 hours", seconds: 4 * 3600 },
];
/** Lowercase, expand friendly words, and drop all whitespace. */
export function normalizeDuration(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/\b(?:minutes?|mins?)\b/g, "m")
.replace(/\b(?:hours?|hrs?)\b/g, "h")
.replace(/\b(?:seconds?|secs?)\b/g, "s")
.replace(/\b(?:days?)\b/g, "d")
.replace(/\s+/g, "");
}
/**
* Parse a human duration into seconds.
*
* Accepts a bare number (interpreted as minutes), or unit-suffixed values such
* as `45m`, `2h`, `90s`, `1d`, and combinations like `1h30m`. The whole string
* must be consumed, so `1h30` is rejected rather than silently misread.
*
* @returns seconds, or null when the input is not a valid positive duration.
*/
export function parseDuration(input: string): number | null {
const text = normalizeDuration(input);
if (!text) return null;
if (/^\d+(?:\.\d+)?$/.test(text)) {
const minutes = Number(text);
return minutes > 0 ? Math.round(minutes * 60) : null;
}
const part = /(\d+(?:\.\d+)?)([dhms])/g;
let total = 0;
let consumed = "";
let match: RegExpExecArray | null;
while ((match = part.exec(text)) !== null) {
total += Number(match[1]) * UNIT_SECONDS[match[2]];
consumed += match[0];
}
if (!consumed || consumed !== text) return null;
return total > 0 ? Math.round(total) : null;
}
/** "1h 30m", "45m", "30s" — largest two units only. */
export function formatDuration(seconds: number): string {
if (seconds <= 0) return "0s";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (minutes) parts.push(`${minutes}m`);
if (!days && !hours && secs) parts.push(`${secs}s`);
if (!parts.length) parts.push(`${secs}s`);
return parts.slice(0, 2).join(" ");
}
export function formatRemaining(expiresAt: number, now: number): string {
const remaining = Math.max(0, Math.round((expiresAt - now) / 1000));
return formatDuration(remaining);
}
+64
View File
@@ -0,0 +1,64 @@
import { CaffeinateCommand, CaffeinateOptions, SupportedPlatform } from "./types";
export class UnsupportedPlatformError extends Error {
constructor(platform: string) {
super(
`Caffeinate does not support ${platform} yet. On macOS it uses the built-in \`caffeinate\` command; on Linux it uses \`systemd-inhibit\`.`,
);
this.name = "UnsupportedPlatformError";
}
}
export const MACOS_CAFFEINATE_PATH = "/usr/bin/caffeinate";
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.
*
* The returned process is expected to run for the whole caffeination window:
* `caffeinate -t N` (macOS) and `sleep N` (Linux) both self-terminate once the
* duration elapses, so nothing has to babysit them.
*/
export function buildCommand(platform: SupportedPlatform, options: CaffeinateOptions): CaffeinateCommand {
const duration =
options.durationSeconds !== undefined && options.durationSeconds > 0
? Math.floor(options.durationSeconds)
: null;
if (platform === "darwin") {
// -i prevents idle sleep. Without -t the assertion is held until the process dies.
const args = ["-i"];
if (options.preventDisplaySleep) args.push("-d");
if (options.preventSystemSleep) args.push("-s");
if (duration !== null) args.push("-t", String(duration));
return { command: MACOS_CAFFEINATE_PATH, args, processName: "caffeinate" };
}
// 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", duration !== null ? String(duration) : "infinity"];
return { command: "systemd-inhibit", args, processName: "systemd-inhibit" };
}
export function describeOptions(platform: SupportedPlatform, options: CaffeinateOptions): string[] {
const lines: string[] = ["System stays awake (idle sleep blocked)."];
if (options.preventDisplaySleep) {
lines.push(
platform === "darwin"
? "Display stays on."
: "Display sleep is controlled by your compositor and cannot be held by systemd-inhibit.",
);
}
if (options.preventSystemSleep) {
lines.push(
platform === "darwin"
? "Full system sleep is blocked (macOS only honours this on AC power)."
: "Full system sleep is blocked.",
);
}
return lines;
}
+43
View File
@@ -0,0 +1,43 @@
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);
}
+40
View File
@@ -0,0 +1,40 @@
export type SupportedPlatform = "darwin" | "linux";
export interface CaffeinateOptions {
preventDisplaySleep: boolean;
preventSystemSleep: boolean;
/** Omitted or <= 0 means "until stopped". */
durationSeconds?: number;
reason: string;
}
export interface CaffeinateCommand {
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;
}
export interface CaffeinateState {
pid: number;
startedAt: number;
expiresAt: number | null;
durationSeconds: number | null;
command: string;
args: string[];
processName: string;
reason: string;
platform: SupportedPlatform;
}
export interface CaffeinationStatus {
active: boolean;
state: CaffeinateState | null;
/** State was recorded but the process is gone (timed out, or killed elsewhere). */
stale: boolean;
}
export type ToggleResult = "started" | "stopped" | "restarted";
+162
View File
@@ -0,0 +1,162 @@
import { Action, ActionPanel, Color, Icon, List, Toast, openExtensionPreferences, showToast } from "@vicinae/api";
import { useCallback, useEffect, useState } from "react";
import { getStatus, optionsFromPreferences, startCaffeination, stopCaffeination } from "./lib/caffeinate";
import { DURATION_PRESETS, formatDuration, formatRemaining } from "./lib/duration";
import { CaffeinationStatus } from "./lib/types";
/** Re-render cadence so the "time remaining" readout stays roughly honest. */
const TICK_MS = 30_000;
export default function CaffeinationStatusView() {
const [status, setStatus] = useState<CaffeinationStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [now, setNow] = useState(() => Date.now());
const refresh = useCallback(async () => {
setIsLoading(true);
try {
setStatus(await getStatus());
setNow(Date.now());
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Could not read caffeination status",
message: error instanceof Error ? error.message : String(error),
});
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!status?.active) return;
const timer = setInterval(() => setNow(Date.now()), TICK_MS);
return () => clearInterval(timer);
}, [status?.active]);
const run = useCallback(
async (action: () => Promise<string>, successTitle: (message: string) => string) => {
try {
const message = await action();
await showToast({ style: Toast.Style.Success, title: successTitle(message) });
await refresh();
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Caffeinate failed",
message: error instanceof Error ? error.message : String(error),
});
}
},
[refresh],
);
const startFor = (seconds: number) =>
run(
async () => {
await startCaffeination(optionsFromPreferences(seconds, `Caffeinate for ${formatDuration(seconds)}`));
return formatDuration(seconds);
},
(label) => `Staying awake for ${label}`,
);
const startIndefinitely = () =>
run(
async () => {
await startCaffeination(optionsFromPreferences(undefined, "Caffeinate until stopped"));
return "";
},
() => "Staying awake until stopped",
);
const stop = () =>
run(
async () => {
await stopCaffeination();
return "";
},
() => "Sleep is allowed again",
);
const state = status?.state ?? null;
const active = status?.active ?? false;
const title = active ? "Keeping this computer awake" : "Sleep is not being prevented";
const subtitle = (() => {
if (!active || !state) return undefined;
if (state.expiresAt === null) return "Until stopped";
return `${formatRemaining(state.expiresAt, now)} left`;
})();
const accessories = active
? [{ tag: { value: state?.expiresAt === null ? "indefinite" : "timed", color: state?.expiresAt === null ? Color.Green : Color.Orange } }]
: [];
return (
<List isLoading={isLoading} navigationTitle="Caffeination">
<List.Section title={active ? "Active" : "Inactive"}>
<List.Item
icon={active ? { source: Icon.MugSteam, tintColor: Color.Orange } : { source: Icon.Mug, tintColor: Color.SecondaryText }}
title={title}
subtitle={subtitle}
accessories={accessories}
detail={<List.Item.Detail markdown={buildDetail(active, state)} />}
actions={
<ActionPanel>
{active ? (
<Action title="Stop Caffeinating" icon={Icon.StopFilled} onAction={stop} />
) : (
<Action title="Keep Awake Until Stopped" icon={Icon.MugSteam} onAction={startIndefinitely} />
)}
<ActionPanel.Section title="Keep awake for…">
{DURATION_PRESETS.map((preset) => (
<Action
key={preset.title}
title={preset.title}
icon={Icon.Clock}
onAction={() => startFor(preset.seconds)}
/>
))}
</ActionPanel.Section>
<ActionPanel.Section>
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={refresh} />
<Action title="Open Extension Preferences" icon={Icon.Cog} onAction={openExtensionPreferences} />
</ActionPanel.Section>
</ActionPanel>
}
/>
</List.Section>
</List>
);
}
function buildDetail(active: boolean, state: CaffeinationStatus["state"]): string {
if (!active || !state) {
return ["# Sleep is not being prevented", "", "Run **Toggle Caffeination**, or start a window from the actions panel."].join(
"\n",
);
}
const started = new Date(state.startedAt).toLocaleString();
return [
"# Keeping awake",
"",
`Holding a sleep assertion via \`${state.processName}\`.`,
"",
"| | |",
"|---|---|",
`| Started | ${started} |`,
`| Ends | ${state.expiresAt === null ? "When stopped" : new Date(state.expiresAt).toLocaleString()} |`,
`| PID | ${state.pid} |`,
`| Platform | ${state.platform} |`,
"",
`\`${state.command} ${state.args.join(" ")}\``,
].join("\n");
}
+15
View File
@@ -0,0 +1,15 @@
import { Toast, showHUD, showToast } from "@vicinae/api";
import { toggleCaffeination } from "./lib/caffeinate";
export default async function ToggleCaffeination() {
try {
const result = await toggleCaffeination();
await showHUD(result === "stopped" ? "☕ Sleep is allowed again" : "☕ Staying awake");
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Could not change caffeination",
message: error instanceof Error ? error.message : String(error),
});
}
}