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:
@@ -1,6 +1,6 @@
|
|||||||
# Caffeinate
|
# Caffeinate
|
||||||
|
|
||||||
> Keep your computer awake from [Vicinae](https://vicinae.com). Start, stop, and time-limit sleep prevention without touching a terminal.
|
> Keep your computer awake from [Vicinae](https://vicinae.com). One toggle, no terminal.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -14,9 +14,8 @@ This is a native `@vicinae/api` extension, so none of that applies.
|
|||||||
|
|
||||||
| Command | Mode | What it does |
|
| Command | Mode | What it does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **Toggle Caffeination** | no-view | Starts or stops sleep prevention, whichever is appropriate. |
|
| **Toggle Caffeination** | no-view | Flips sleep prevention on or off, whichever is appropriate. The fast path. |
|
||||||
| **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. |
|
| **Caffeination Status** | view | Whether sleep is being blocked, since when, and the exact command holding it. Start/stop from here. |
|
||||||
| **Caffeinate for a Duration** | view | Keeps the computer awake for a set time, then lets it sleep again automatically. |
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -24,16 +23,18 @@ Sleep prevention is an OS-level assertion, so the extension delegates to the pla
|
|||||||
|
|
||||||
| Platform | Mechanism |
|
| Platform | Mechanism |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| macOS | `/usr/bin/caffeinate -i [-d] [-s] [-t <seconds>]` |
|
| macOS | `/usr/bin/caffeinate -i [-d] [-s]` |
|
||||||
| Linux | `systemd-inhibit --what=idle:sleep --why=<reason> sleep <seconds\|infinity>` |
|
| Linux | `systemd-inhibit --what=idle:sleep --why=<reason> sleep 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.
|
Both run until signalled — there is no timer, so nothing needs to be refreshed or babysat.
|
||||||
|
|
||||||
State is recorded in `LocalStorage` as `{ pid, startedAt, expiresAt, command, args, processName, … }`.
|
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, … }`.
|
||||||
|
|
||||||
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.
|
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.
|
Status is self-healing: if the recorded process is gone (killed elsewhere, or a reboot), the stale state is cleared on the next read.
|
||||||
|
|
||||||
## Preferences
|
## Preferences
|
||||||
|
|
||||||
@@ -42,12 +43,6 @@ Status is self-healing: if the recorded process is gone (timed out, or killed el
|
|||||||
| 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 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. |
|
| 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
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -63,11 +58,16 @@ To install a production build into Vicinae:
|
|||||||
npm run build # writes to ~/.local/share/vicinae/extensions/caffeinate
|
npm run build # writes to ~/.local/share/vicinae/extensions/caffeinate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## No top bar indicator
|
||||||
|
|
||||||
|
Vicinae has no menu-bar command support. This is not a Raycast-compat gap — a native `mode: "menu-bar"` command is accepted by the manifest and builds cleanly, then is **silently dropped** at registration, with no warning. `@vicinae/api` exports a `MenuBarExtra` component, but there is no host to render it into.
|
||||||
|
|
||||||
|
For a real top bar indicator you need a standalone macOS menu-bar app; it cannot be part of a Vicinae extension.
|
||||||
|
|
||||||
## Not implemented
|
## Not implemented
|
||||||
|
|
||||||
- **Windows.** `detectPlatform` throws with a clear message instead of failing obscurely. `powercfg` support would be the path.
|
- **Windows.** `detectPlatform` throws with a clear message instead of failing obscurely. `powercfg` would be the path.
|
||||||
- **While-an-app-is-running / scheduled windows.** `caffeinate -w <pid>` covers the former on macOS; neither is wired up.
|
- **Timed windows** (`caffeinate -t`), **while-an-app-is-running** (`caffeinate -w <pid>`), and **scheduled** windows. Deliberately omitted — on/off covers the common case.
|
||||||
- **A persistent menu bar indicator** — Vicinae has no menu bar support. Use **Caffeination Status** instead.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+1
-7
@@ -21,12 +21,6 @@
|
|||||||
"title": "Caffeination Status",
|
"title": "Caffeination Status",
|
||||||
"description": "See whether sleep is currently being prevented, and for how much longer. Start or stop it from here.",
|
"description": "See whether sleep is currently being prevented, and for how much longer. Start or stop it from here.",
|
||||||
"mode": "view"
|
"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": [
|
"preferences": [
|
||||||
@@ -74,4 +68,4 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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" };
|
const base = { preventDisplaySleep: false, preventSystemSleep: false, reason: "test" };
|
||||||
|
|
||||||
@@ -26,6 +31,8 @@ describe("buildCommand on macOS", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("adds display and system assertions when requested", () => {
|
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([
|
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true, preventSystemSleep: true }).args).toEqual([
|
||||||
"-i",
|
"-i",
|
||||||
"-d",
|
"-d",
|
||||||
@@ -33,23 +40,14 @@ describe("buildCommand on macOS", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds a self-expiring timeout for durations", () => {
|
it("never adds a timeout, so the assertion lasts until stopped", () => {
|
||||||
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");
|
expect(buildCommand("darwin", base).args).not.toContain("-t");
|
||||||
|
expect(buildCommand("darwin", { ...base, preventDisplaySleep: true }).args).not.toContain("-t");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildCommand on Linux", () => {
|
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({
|
expect(buildCommand("linux", base)).toEqual({
|
||||||
command: "systemd-inhibit",
|
command: "systemd-inhibit",
|
||||||
args: ["--what=idle:sleep", "--why=test", "sleep", "infinity"],
|
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", () => {
|
it("does not claim to control the display, which systemd-inhibit cannot do", () => {
|
||||||
const { args } = buildCommand("linux", { ...base, preventDisplaySleep: true });
|
const { args } = buildCommand("linux", { ...base, preventDisplaySleep: true });
|
||||||
expect(args).toEqual(["--what=idle:sleep", "--why=test", "sleep", "infinity"]);
|
expect(args).toEqual(["--what=idle:sleep", "--why=test", "sleep", "infinity"]);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("describeOptions", () => {
|
it("passes the reason through so it shows up in systemd-inhibit --list", () => {
|
||||||
it("only mentions the display when it can actually be held", () => {
|
expect(buildCommand("linux", { ...base, reason: "Plex" }).args).toContain("--why=Plex");
|
||||||
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/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,12 +18,11 @@ export interface CaffeinatePreferences {
|
|||||||
preventSystemSleep?: boolean;
|
preventSystemSleep?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionsFromPreferences(durationSeconds?: number, reason: string = DEFAULT_REASON): CaffeinateOptions {
|
export function optionsFromPreferences(reason: string = DEFAULT_REASON): CaffeinateOptions {
|
||||||
const prefs = getPreferenceValues<CaffeinatePreferences>();
|
const prefs = getPreferenceValues<CaffeinatePreferences>();
|
||||||
return {
|
return {
|
||||||
preventDisplaySleep: Boolean(prefs.preventDisplaySleep),
|
preventDisplaySleep: Boolean(prefs.preventDisplaySleep),
|
||||||
preventSystemSleep: Boolean(prefs.preventSystemSleep),
|
preventSystemSleep: Boolean(prefs.preventSystemSleep),
|
||||||
durationSeconds,
|
|
||||||
reason,
|
reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,8 +114,6 @@ export async function startCaffeination(options: CaffeinateOptions): Promise<Caf
|
|||||||
const state: CaffeinateState = {
|
const state: CaffeinateState = {
|
||||||
pid: child.pid,
|
pid: child.pid,
|
||||||
startedAt: Date.now(),
|
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,
|
command,
|
||||||
args: spec.args,
|
args: spec.args,
|
||||||
processName: spec.processName,
|
processName: spec.processName,
|
||||||
@@ -156,7 +153,7 @@ export async function getStatus(): Promise<CaffeinationStatus> {
|
|||||||
if (!state) return { active: false, state: null, stale: false };
|
if (!state) return { active: false, state: null, stale: false };
|
||||||
|
|
||||||
if (!(await isProcessRunning(state))) {
|
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();
|
await clearState();
|
||||||
return { active: false, state, stale: true };
|
return { active: false, state, stale: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -19,46 +19,20 @@ export function detectPlatform(platform: string = process.platform): SupportedPl
|
|||||||
/**
|
/**
|
||||||
* Build the command that holds the sleep assertion.
|
* Build the command that holds the sleep assertion.
|
||||||
*
|
*
|
||||||
* The returned process is expected to run for the whole caffeination window:
|
* Both variants run until they are signalled, which is what "on" means here:
|
||||||
* `caffeinate -t N` (macOS) and `sleep N` (Linux) both self-terminate once the
|
* there is no duration, so nothing has to be timed or refreshed.
|
||||||
* duration elapses, so nothing has to babysit them.
|
|
||||||
*/
|
*/
|
||||||
export function buildCommand(platform: SupportedPlatform, options: CaffeinateOptions): CaffeinateCommand {
|
export function buildCommand(platform: SupportedPlatform, options: CaffeinateOptions): CaffeinateCommand {
|
||||||
const duration =
|
|
||||||
options.durationSeconds !== undefined && options.durationSeconds > 0
|
|
||||||
? Math.floor(options.durationSeconds)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (platform === "darwin") {
|
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"];
|
const args = ["-i"];
|
||||||
if (options.preventDisplaySleep) args.push("-d");
|
if (options.preventDisplaySleep) args.push("-d");
|
||||||
if (options.preventSystemSleep) args.push("-s");
|
if (options.preventSystemSleep) args.push("-s");
|
||||||
if (duration !== null) args.push("-t", String(duration));
|
|
||||||
return { command: MACOS_CAFFEINATE_PATH, args, processName: "caffeinate" };
|
return { command: MACOS_CAFFEINATE_PATH, args, processName: "caffeinate" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// systemd-inhibit has no equivalent for keeping the display on, so
|
// systemd-inhibit has no equivalent for keeping the display on, so
|
||||||
// preventDisplaySleep is intentionally not mapped here.
|
// 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" };
|
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
@@ -3,8 +3,6 @@ export type SupportedPlatform = "darwin" | "linux";
|
|||||||
export interface CaffeinateOptions {
|
export interface CaffeinateOptions {
|
||||||
preventDisplaySleep: boolean;
|
preventDisplaySleep: boolean;
|
||||||
preventSystemSleep: boolean;
|
preventSystemSleep: boolean;
|
||||||
/** Omitted or <= 0 means "until stopped". */
|
|
||||||
durationSeconds?: number;
|
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +19,6 @@ export interface CaffeinateCommand {
|
|||||||
export interface CaffeinateState {
|
export interface CaffeinateState {
|
||||||
pid: number;
|
pid: number;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
expiresAt: number | null;
|
|
||||||
durationSeconds: number | null;
|
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
processName: string;
|
processName: string;
|
||||||
@@ -33,8 +29,8 @@ export interface CaffeinateState {
|
|||||||
export interface CaffeinationStatus {
|
export interface CaffeinationStatus {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
state: CaffeinateState | null;
|
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;
|
stale: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToggleResult = "started" | "stopped" | "restarted";
|
export type ToggleResult = "started" | "stopped";
|
||||||
|
|||||||
+17
-74
@@ -2,22 +2,16 @@ import { Action, ActionPanel, Color, Icon, List, Toast, openExtensionPreferences
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getStatus, optionsFromPreferences, startCaffeination, stopCaffeination } from "./lib/caffeinate";
|
import { getStatus, optionsFromPreferences, startCaffeination, stopCaffeination } from "./lib/caffeinate";
|
||||||
import { DURATION_PRESETS, formatDuration, formatRemaining } from "./lib/duration";
|
|
||||||
import { CaffeinationStatus } from "./lib/types";
|
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() {
|
export default function CaffeinationStatusView() {
|
||||||
const [status, setStatus] = useState<CaffeinationStatus | null>(null);
|
const [status, setStatus] = useState<CaffeinationStatus | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
setStatus(await getStatus());
|
setStatus(await getStatus());
|
||||||
setNow(Date.now());
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await showToast({
|
await showToast({
|
||||||
style: Toast.Style.Failure,
|
style: Toast.Style.Failure,
|
||||||
@@ -33,17 +27,11 @@ export default function CaffeinationStatusView() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!status?.active) return;
|
|
||||||
const timer = setInterval(() => setNow(Date.now()), TICK_MS);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [status?.active]);
|
|
||||||
|
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
async (action: () => Promise<string>, successTitle: (message: string) => string) => {
|
async (action: () => Promise<unknown>, successTitle: string) => {
|
||||||
try {
|
try {
|
||||||
const message = await action();
|
await action();
|
||||||
await showToast({ style: Toast.Style.Success, title: successTitle(message) });
|
await showToast({ style: Toast.Style.Success, title: successTitle });
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await showToast({
|
await showToast({
|
||||||
@@ -56,74 +44,32 @@ export default function CaffeinationStatusView() {
|
|||||||
[refresh],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startFor = (seconds: number) =>
|
const start = () => run(() => startCaffeination(optionsFromPreferences()), "Staying awake until stopped");
|
||||||
run(
|
const stop = () => run(() => stopCaffeination(), "Sleep is allowed again");
|
||||||
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 state = status?.state ?? null;
|
||||||
const active = status?.active ?? false;
|
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 (
|
return (
|
||||||
<List isLoading={isLoading} navigationTitle="Caffeination">
|
<List isLoading={isLoading} navigationTitle="Caffeination">
|
||||||
<List.Section title={active ? "Active" : "Inactive"}>
|
<List.Section title={active ? "Active" : "Inactive"}>
|
||||||
<List.Item
|
<List.Item
|
||||||
icon={active ? { source: Icon.MugSteam, tintColor: Color.Orange } : { source: Icon.Mug, tintColor: Color.SecondaryText }}
|
icon={
|
||||||
title={title}
|
active
|
||||||
subtitle={subtitle}
|
? { source: Icon.MugSteam, tintColor: Color.Green }
|
||||||
accessories={accessories}
|
: { 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)} />}
|
detail={<List.Item.Detail markdown={buildDetail(active, state)} />}
|
||||||
actions={
|
actions={
|
||||||
<ActionPanel>
|
<ActionPanel>
|
||||||
{active ? (
|
{active ? (
|
||||||
<Action title="Stop Caffeinating" icon={Icon.StopFilled} onAction={stop} />
|
<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>
|
<ActionPanel.Section>
|
||||||
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={refresh} />
|
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={refresh} />
|
||||||
<Action title="Open Extension Preferences" icon={Icon.Cog} onAction={openExtensionPreferences} />
|
<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 {
|
function buildDetail(active: boolean, state: CaffeinationStatus["state"]): string {
|
||||||
if (!active || !state) {
|
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",
|
"\n",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const started = new Date(state.startedAt).toLocaleString();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"# Keeping awake",
|
"# Keeping awake",
|
||||||
"",
|
"",
|
||||||
`Holding a sleep assertion via \`${state.processName}\`.`,
|
`Holding a sleep assertion via \`${state.processName}\`. It stays in place until stopped.`,
|
||||||
"",
|
"",
|
||||||
"| | |",
|
"| | |",
|
||||||
"|---|---|",
|
"|---|---|",
|
||||||
`| Started | ${started} |`,
|
`| Started | ${new Date(state.startedAt).toLocaleString()} |`,
|
||||||
`| Ends | ${state.expiresAt === null ? "When stopped" : new Date(state.expiresAt).toLocaleString()} |`,
|
|
||||||
`| PID | ${state.pid} |`,
|
`| PID | ${state.pid} |`,
|
||||||
`| Platform | ${state.platform} |`,
|
`| Platform | ${state.platform} |`,
|
||||||
"",
|
"",
|
||||||
|
|||||||
Reference in New Issue
Block a user