Reduce to a pure on/off extension

Durations were the only thing keeping a lot of machinery alive, so drop
them and let the dead code go with them.

Removed:
- The "Caffeinate for a Duration" command.
- lib/duration.ts entirely (parseDuration / formatDuration /
  formatRemaining / normalizeDuration / DURATION_PRESETS) plus its test
  file. Every export was reachable only from duration paths.
- The 30s setInterval re-render in the status view. It existed only to
  keep a "27m left" countdown honest, and with no timed sessions there is
  nothing to count down.
- The duration-preset actions in the status view.
- describeOptions(), which only the duration form used to explain itself.
- durationSeconds / expiresAt from the options, state and command
  builder, along with the `-t <seconds>` and `sleep <n>` branches. Both
  platforms now hold the assertion until signalled, which is what "on"
  means here.
- The unused "restarted" member of ToggleResult.

The status view now reports "since <time>" instead of a countdown, and
keeps a Refresh action.

Also fixes a type error the narrower helper surfaced: the status view's
run() helper was typed () => Promise<void> while startCaffeination
returns Promise<CaffeinateState> and stopCaffeination returns
Promise<boolean>.

407 deletions against 57 insertions.

Verified: vici lint, tsc --noEmit, eslint, 8/8 vitest, vici build, and
live in Vicinae — toggle on spawns `caffeinate -i`, toggle off stops it,
and a pre-existing user-owned `caffeinate -u -dmi` is left untouched
throughout.
This commit is contained in:
2026-09-20 17:01:30 +08:00
parent 65005b22a6
commit a303e161d3
10 changed files with 57 additions and 407 deletions
-87
View File
@@ -1,87 +0,0 @@
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");
});
});
+13 -33
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { MACOS_CAFFEINATE_PATH, UnsupportedPlatformError, buildCommand, describeOptions, detectPlatform } from "../lib/platform";
import {
MACOS_CAFFEINATE_PATH,
UnsupportedPlatformError,
buildCommand,
detectPlatform,
} from "../lib/platform";
const base = { preventDisplaySleep: false, preventSystemSleep: false, reason: "test" };
@@ -26,6 +31,8 @@ describe("buildCommand on macOS", () => {
});
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",
@@ -33,23 +40,14 @@ describe("buildCommand on macOS", () => {
]);
});
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"]);
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");
});
});
describe("buildCommand on Linux", () => {
it("uses systemd-inhibit with an infinite sleep by default", () => {
it("uses systemd-inhibit with an infinite sleep", () => {
expect(buildCommand("linux", base)).toEqual({
command: "systemd-inhibit",
args: ["--what=idle:sleep", "--why=test", "sleep", "infinity"],
@@ -57,30 +55,12 @@ describe("buildCommand on Linux", () => {
});
});
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/);
it("passes the reason through so it shows up in systemd-inhibit --list", () => {
expect(buildCommand("linux", { ...base, reason: "Plex" }).args).toContain("--why=Plex");
});
});
-70
View File
@@ -1,70 +0,0 @@
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>
);
}
+2 -5
View File
@@ -18,12 +18,11 @@ export interface CaffeinatePreferences {
preventSystemSleep?: boolean;
}
export function optionsFromPreferences(durationSeconds?: number, reason: string = DEFAULT_REASON): CaffeinateOptions {
export function optionsFromPreferences(reason: string = DEFAULT_REASON): CaffeinateOptions {
const prefs = getPreferenceValues<CaffeinatePreferences>();
return {
preventDisplaySleep: Boolean(prefs.preventDisplaySleep),
preventSystemSleep: Boolean(prefs.preventSystemSleep),
durationSeconds,
reason,
};
}
@@ -115,8 +114,6 @@ export async function startCaffeination(options: CaffeinateOptions): Promise<Caf
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,
@@ -156,7 +153,7 @@ export async function getStatus(): Promise<CaffeinationStatus> {
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.
// The process was killed outside the extension, or the machine rebooted. Self-heal.
await clearState();
return { active: false, state, stale: true };
}
-77
View File
@@ -1,77 +0,0 @@
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);
}
+4 -30
View File
@@ -19,46 +19,20 @@ export function detectPlatform(platform: string = process.platform): SupportedPl
/**
* 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.
* 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 {
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.
// -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");
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"];
const args = ["--what=idle:sleep", `--why=${options.reason}`, "sleep", "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;
}
+2 -6
View File
@@ -3,8 +3,6 @@ export type SupportedPlatform = "darwin" | "linux";
export interface CaffeinateOptions {
preventDisplaySleep: boolean;
preventSystemSleep: boolean;
/** Omitted or <= 0 means "until stopped". */
durationSeconds?: number;
reason: string;
}
@@ -21,8 +19,6 @@ export interface CaffeinateCommand {
export interface CaffeinateState {
pid: number;
startedAt: number;
expiresAt: number | null;
durationSeconds: number | null;
command: string;
args: string[];
processName: string;
@@ -33,8 +29,8 @@ export interface CaffeinateState {
export interface CaffeinationStatus {
active: boolean;
state: CaffeinateState | null;
/** State was recorded but the process is gone (timed out, or killed elsewhere). */
/** State was recorded but the process is gone (killed elsewhere, or a reboot). */
stale: boolean;
}
export type ToggleResult = "started" | "stopped" | "restarted";
export type ToggleResult = "started" | "stopped";
+17 -74
View File
@@ -2,22 +2,16 @@ import { Action, ActionPanel, Color, Icon, List, Toast, openExtensionPreferences
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,
@@ -33,17 +27,11 @@ export default function CaffeinationStatusView() {
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) => {
async (action: () => Promise<unknown>, successTitle: string) => {
try {
const message = await action();
await showToast({ style: Toast.Style.Success, title: successTitle(message) });
await action();
await showToast({ style: Toast.Style.Success, title: successTitle });
await refresh();
} catch (error) {
await showToast({
@@ -56,74 +44,32 @@ export default function CaffeinationStatusView() {
[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 start = () => run(() => startCaffeination(optionsFromPreferences()), "Staying awake until stopped");
const stop = () => run(() => stopCaffeination(), "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}
icon={
active
? { source: Icon.MugSteam, tintColor: Color.Green }
: { source: Icon.Mug, tintColor: Color.SecondaryText }
}
title={active ? "Keeping this computer awake" : "Sleep is not being prevented"}
subtitle={active && state ? `Since ${new Date(state.startedAt).toLocaleTimeString()}` : undefined}
accessories={active ? [{ tag: { value: "on", color: Color.Green } }] : []}
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} />
<Action title="Keep Awake" icon={Icon.MugSteam} onAction={start} />
)}
<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} />
@@ -138,22 +84,19 @@ export default function CaffeinationStatusView() {
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(
return ["# Sleep is not being prevented", "", "Use the action panel to start keeping this computer awake."].join(
"\n",
);
}
const started = new Date(state.startedAt).toLocaleString();
return [
"# Keeping awake",
"",
`Holding a sleep assertion via \`${state.processName}\`.`,
`Holding a sleep assertion via \`${state.processName}\`. It stays in place until stopped.`,
"",
"| | |",
"|---|---|",
`| Started | ${started} |`,
`| Ends | ${state.expiresAt === null ? "When stopped" : new Date(state.expiresAt).toLocaleString()} |`,
`| Started | ${new Date(state.startedAt).toLocaleString()} |`,
`| PID | ${state.pid} |`,
`| Platform | ${state.platform} |`,
"",