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:
+10
@@ -0,0 +1,10 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# generated by the vicinae toolchain
|
||||
vicinae-env.d.ts
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Caffeinate
|
||||
|
||||
> Keep your computer awake from [Vicinae](https://vicinae.com). Start, stop, and time-limit sleep prevention without touching a terminal.
|
||||
|
||||

|
||||
|
||||
## Why this exists
|
||||
|
||||
The Raycast [`coffee`](https://www.raycast.com/mooxl/coffee) extension does not work on Vicinae. Its `index` command is a `menu-bar` command, which Vicinae cannot run at all, and its `status` command crashes because it gates on `LocalStorage.getItem(key) === undefined` — Vicinae resolves a missing key to `null` instead of `undefined`.
|
||||
|
||||
This is a native `@vicinae/api` extension, so none of that applies.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Mode | What it does |
|
||||
| --- | --- | --- |
|
||||
| **Toggle Caffeination** | no-view | Starts or stops sleep prevention, whichever is appropriate. |
|
||||
| **Caffeination Status** | view | Live readout of whether sleep is being blocked, for how much longer, and the exact command holding it. Start/stop from the actions panel. |
|
||||
| **Caffeinate for a Duration** | view | Keeps the computer awake for a set time, then lets it sleep again automatically. |
|
||||
|
||||
## How it works
|
||||
|
||||
Sleep prevention is an OS-level assertion, so the extension delegates to the platform tool and then gets out of the way:
|
||||
|
||||
| Platform | Mechanism |
|
||||
| --- | --- |
|
||||
| macOS | `/usr/bin/caffeinate -i [-d] [-s] [-t <seconds>]` |
|
||||
| Linux | `systemd-inhibit --what=idle:sleep --why=<reason> sleep <seconds\|infinity>` |
|
||||
|
||||
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. Timed sessions need no babysitter — `caffeinate -t` and `sleep <seconds>` self-terminate when the window elapses.
|
||||
|
||||
State is recorded in `LocalStorage` as `{ pid, startedAt, expiresAt, command, args, processName, … }`.
|
||||
|
||||
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.
|
||||
|
||||
Status is self-healing: if the recorded process is gone (timed out, or killed elsewhere), the stale state is cleared on the next read.
|
||||
|
||||
## Preferences
|
||||
|
||||
| Preference | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| Also Prevent Display Sleep | off | macOS only. `systemd-inhibit` cannot hold the display awake — that is the compositor's job, and the UI says so rather than pretending. |
|
||||
| Also Prevent System Sleep | off | On macOS this assertion only takes effect while on AC power. |
|
||||
|
||||
## Duration formats
|
||||
|
||||
A bare number means **minutes**. Otherwise: `45m`, `2h`, `90s`, `1d`, `1h30m`, or friendly words like `1 hour` / `30 minutes`.
|
||||
|
||||
The whole string must parse — `1h30` is rejected rather than silently read as `1h`.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev # vici develop — Vicinae must be running
|
||||
```
|
||||
|
||||
Other scripts: `npm run lint` (manifest), `npm run lint:code` (ESLint), `npm run typecheck`, `npm test`.
|
||||
|
||||
To install a production build into Vicinae:
|
||||
|
||||
```sh
|
||||
npm run build # writes to ~/.local/share/vicinae/extensions/caffeinate
|
||||
```
|
||||
|
||||
## Not implemented
|
||||
|
||||
- **Windows.** `detectPlatform` throws with a clear message instead of failing obscurely. `powercfg` support would be the path.
|
||||
- **While-an-app-is-running / scheduled windows.** `caffeinate -w <pid>` covers the former on macOS; neither is wired up.
|
||||
- **A persistent menu bar indicator** — Vicinae has no menu bar support. Use **Caffeination Status** instead.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,19 @@
|
||||
const { defineConfig } = require("eslint/config");
|
||||
const js = require("@eslint/js");
|
||||
const tseslint = require("typescript-eslint");
|
||||
const prettier = require("eslint-config-prettier");
|
||||
const globals = require("globals");
|
||||
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
ignores: ["node_modules/**", "vicinae-env.d.ts", "eslint.config.js"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
]);
|
||||
Generated
+3724
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json",
|
||||
"name": "caffeinate",
|
||||
"title": "Caffeinate",
|
||||
"description": "Keep your computer awake. Start, stop, and time-limit sleep prevention without touching a terminal.",
|
||||
"icon": "caffeinate.png",
|
||||
"author": "adrianbonpin",
|
||||
"categories": [
|
||||
"System"
|
||||
],
|
||||
"license": "MIT",
|
||||
"commands": [
|
||||
{
|
||||
"name": "toggle",
|
||||
"title": "Toggle Caffeination",
|
||||
"description": "Start or stop keeping your computer awake, depending on the current state.",
|
||||
"mode": "no-view"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"title": "Caffeination Status",
|
||||
"description": "See whether sleep is currently being prevented, and for how much longer. Start or stop it from here.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "caffeinate-for",
|
||||
"title": "Caffeinate for a Duration",
|
||||
"description": "Keep your computer awake for a set amount of time, then let it sleep again automatically.",
|
||||
"mode": "view"
|
||||
}
|
||||
],
|
||||
"preferences": [
|
||||
{
|
||||
"name": "preventDisplaySleep",
|
||||
"title": "Also Prevent Display Sleep",
|
||||
"description": "Keep the screen on as well. Off means the display may still turn off while the system stays awake.",
|
||||
"type": "checkbox",
|
||||
"label": "Prevent display sleep",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "preventSystemSleep",
|
||||
"title": "Also Prevent System Sleep",
|
||||
"description": "Prevents full system sleep. Note: on macOS this assertion only takes effect while on AC power.",
|
||||
"type": "checkbox",
|
||||
"label": "Prevent system sleep",
|
||||
"required": false,
|
||||
"default": false
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@vicinae/api": "^0.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/node": "22.13.10",
|
||||
"@types/react": "19.1.17",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "19.1.1",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vici develop",
|
||||
"build": "vici build",
|
||||
"lint": "vici lint",
|
||||
"lint:code": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"include": ["src/**/*", "vicinae-env.d.ts"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"],
|
||||
"module": "commonjs",
|
||||
"target": "ES2023",
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user