Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
- run: deno fmt --check extensions scripts
- run: deno lint extensions scripts
- run: deno check extensions/*/commands/*.ts
- run: deno test extensions
# Tests may use files, the environment and programs (fake CLIs, `sh -n`), never the network.
- run: deno test --allow-read --allow-write --allow-env --allow-run extensions
- name: Verify registry entries (clones each extension at its commit)
run: deno run -A scripts/validate.ts
70 changes: 53 additions & 17 deletions README.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions extensions/caffeine/backslash.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[extension]
id = "caffeine"
title = "Caffeine"
description = "Keep the computer awake for a while, or until you turn it off, even with Backslash closed"
version = "1.0.0"
author = "Backslash"
icon = "bs-coffee"
license = "MIT"

# A transient systemd user unit (systemd-run) holds a systemd-inhibit lock on
# idle and sleep, plus GNOME's or KDE's own inhibitor when available, so it
# survives the launcher; systemctl stops it. systemd-inhibit --list shows the
# other programs keeping the computer awake.
[permissions]
run = ["systemd-run", "systemctl", "systemd-inhibit"]
env = ["XDG_CURRENT_DESKTOP"]

[[commands]]
name = "caffeine"
title = "Caffeine"
description = "Keep the computer awake: 15 min, 30 min, 1 h, 2 h or until turned off"
icon = "bs-coffee"
keywords = ["caffeine", "awake", "keep awake", "sleep", "veille", "mise en veille", "réveil", "café", "inhibit", "amphetamine", "insomnia"]

[[commands]]
name = "toggle"
title = "Toggle Caffeine"
description = "Keep awake, or let the computer sleep again"
icon = "bs-coffee"
mode = "no-view"
keywords = ["caffeine", "awake", "sleep", "veille", "café", "inhibit"]

[[commands]]
name = "keep-awake"
title = "Keep Awake For…"
description = "e.g. 45m, 1h30, 3h"
icon = "bs-timer"
mode = "no-view"
keywords = ["caffeine", "awake", "veille", "durée", "duration"]
[[commands.arguments]]
name = "duration"
placeholder = "45m, 1h30, 3h"
required = true

[[preferences]]
name = "duration"
title = "Toggle Caffeine keeps awake for"
description = "A duration like 1h or 30m; empty means until turned off"
default = ""
53 changes: 53 additions & 0 deletions extensions/caffeine/caffeine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// The running inhibitor: a transient systemd user unit, so it survives the launcher.

import { exec, storage } from "@backslash/api";
import { type Session, systemdRunArgs, UNIT } from "./lib.ts";

const desktop = () => {
try {
return Deno.env.get("XDG_CURRENT_DESKTOP");
} catch {
return undefined;
}
};

async function active(): Promise<boolean> {
try {
const state = await exec("systemctl", ["--user", "is-active", UNIT]);
return state === "active" || state === "activating";
} catch {
return false; // is-active exits non-zero when inactive
}
}

/** The current session, if the computer is kept awake. */
export async function current(): Promise<Session | undefined> {
const saved = (await storage.get("session")) as unknown as Session | undefined;
if (!(await active())) {
if (saved) await storage.delete("session");
return undefined;
}
if (saved && (saved.until === null || saved.until > Date.now())) return saved;
// Started elsewhere, or our record is gone: awake, end unknown.
return { started: Date.now(), until: null };
}

export async function start(seconds?: number): Promise<Session> {
await stop();
try {
await exec("systemd-run", systemdRunArgs(seconds, desktop()));
} catch (e) {
if (String(e).includes("Failed to connect to bus")) {
throw new Error("No systemd user session: Caffeine runs with systemd-run --user");
}
throw e;
}
const session = { started: Date.now(), until: seconds ? Date.now() + seconds * 1000 : null };
await storage.set("session", session as any);
return session;
}

export async function stop(): Promise<void> {
await exec("systemctl", ["--user", "stop", UNIT]).catch(() => {});
await storage.delete("session");
}
165 changes: 165 additions & 0 deletions extensions/caffeine/commands/caffeine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
action,
close,
type Color,
exec,
field,
form,
Icons,
type Item,
list,
meta,
refresh,
submenu,
toast,
updateCommandMetadata,
} from "@backslash/api";
import { countdown, humanize, parseDuration, parseInhibitors, type Session, subtitle } from "../lib.ts";
import { current, start, stop } from "../caffeine.ts";

const PRESETS: [string, number | undefined, Color][] = [
["15 Minutes", 15 * 60, "teal"],
["30 Minutes", 30 * 60, "green"],
["1 Hour", 60 * 60, "yellow"],
["2 Hours", 2 * 60 * 60, "orange"],
["Until Turned Off", undefined, "red"],
];

const clockTime = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });

const begin = (seconds: number | undefined) => async () => {
await start(seconds);
return close(seconds ? `Awake for ${humanize(seconds)}` : "Awake until turned off");
};

const custom = form({
title: "Keep Awake",
submitTitle: "Start",
fields: [field.text("duration", "For how long", { placeholder: "45m, 1h30, 3h", required: true })],
async submit(values) {
const seconds = parseDuration(String(values.duration));
if (!seconds) {
await toast("Not a duration: try 45m, 1h30 or 3h", { style: "failure" });
return;
}
return begin(seconds)();
},
});

const PREVENTS = meta.tags("Prevents", [
{ text: "Screen dimming", color: "blue" },
{ text: "Screen lock", color: "blue" },
{ text: "Sleep", color: "blue" },
]);

function awakeItem(s: Session, others: string[]): Item {
const left = s.until === null ? undefined : Math.max(0, (s.until - Date.now()) / 1000);
const total = s.until === null ? undefined : (s.until - s.started) / 1000;
const extend = (minutes: number) =>
action(
`${minutes >= 60 ? `${minutes / 60} Hour${minutes > 60 ? "s" : ""}` : `${minutes} Minutes`} More`,
async () => {
await start((left ?? 0) + minutes * 60);
return refresh();
},
{ icon: Icons.plus },
);
return {
id: "awake",
title: s.until === null ? "Awake until turned off" : `Awake until ${clockTime(s.until)}`,
subtitle: left === undefined ? "Keeping the computer awake" : `${humanize(left)} left`,
icon: { source: Icons.coffee, tint: "orange" },
section: "Now",
accessories: [left === undefined ? { tag: "On", color: "orange" } : { tag: countdown(left), color: "orange" }],
detail: {
stats: [
{
label: left === undefined ? "awake" : "left",
value: left === undefined ? "∞" : countdown(left),
color: "orange",
},
{ label: "since", value: clockTime(s.started) },
...(s.until !== null ? [{ label: "until", value: clockTime(s.until) }] : []),
],
metadata: [
...(left !== undefined && total ? [meta.progress("Elapsed", 1 - left / total)] : []),
PREVENTS,
meta.text("Keeps running", "Even when Backslash is closed (systemd user unit)", { icon: Icons.info }),
...(others.length ? [meta.tags("Also keeping awake", others)] : []),
],
},
actions: [
action("Turn Off", async () => {
await stop();
await updateCommandMetadata({ subtitle: null }).catch(() => {});
return close("Caffeine off: the computer may sleep again");
}, { icon: Icons.stop, destructive: true, shortcut: "<Ctrl>k" }),
...(left !== undefined
? [
submenu("Extend", [extend(15), extend(30), extend(60)], { icon: Icons.plus, shortcut: "<Ctrl>e" }),
action("Keep Awake Until Turned Off", begin(undefined), { icon: Icons.coffee, section: "Change" }),
]
: []),
],
};
}

function presetItems(s: Session | undefined): Item[] {
return [
...PRESETS.map(([title, seconds, tint]) => ({
id: `preset:${title}`,
title,
subtitle: seconds ? `until ${clockTime(Date.now() + seconds * 1000)}` : "until you turn it off",
icon: { source: seconds ? Icons.timer : Icons.coffee, tint },
section: s ? "Change" : "Keep Awake",
keywords: ["awake", "réveillé", "veille", "sleep", "inhibit", "caffeine", "café"],
detail: {
stats: [{ label: "for", value: seconds ? humanize(seconds) : "∞" }],
markdown: s ? "Replaces the current session." : undefined,
metadata: [
...(seconds ? [meta.text("Ends at", clockTime(Date.now() + seconds * 1000), { icon: Icons.clock })] : []),
PREVENTS,
],
},
actions: [
action(seconds ? `Keep Awake for ${humanize(seconds)}` : "Keep Awake", begin(seconds), { icon: Icons.coffee }),
],
})),
{
id: "custom",
title: "Custom Duration…",
subtitle: "45m, 1h30, 3h",
icon: { source: Icons.plus, tint: "gray" },
section: s ? "Change" : "Keep Awake",
actions: [action("Custom Duration…", () => custom, { icon: Icons.plus })],
},
];
}

/** Other programs holding an idle or sleep inhibitor (a video, a download…). */
async function otherInhibitors(): Promise<string[]> {
const out = await exec("systemd-inhibit", ["--list", "--no-legend", "--no-pager"]).catch(() => "");
return [...new Set(parseInhibitors(out).map((i) => i.who))];
}

export default list({
placeholder: "Keep the computer awake…",
empty: { title: "Caffeine", icon: Icons.coffee },
status: (items) => items.find((i) => i.id === "awake")?.subtitle ?? "The computer sleeps as usual",
// Live while awake: the countdown ticks every second.
async *items({ signal }) {
const others = await otherInhibitors();
let last: string | null | undefined;
while (!signal.aborted) {
const s = await current();
const sub = subtitle(s);
if (sub !== last) {
last = sub;
updateCommandMetadata({ subtitle: sub }).catch(() => {});
}
yield [...(s ? [awakeItem(s, others)] : []), ...presetItems(s)];
if (!s) return;
await new Promise((r) => setTimeout(r, 1000));
}
},
});
10 changes: 10 additions & 0 deletions extensions/caffeine/commands/keep-awake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { run } from "@backslash/api";
import { humanize, parseDuration } from "../lib.ts";
import { start } from "../caffeine.ts";

export default run<{ duration: string }>(async ({ args }) => {
const seconds = parseDuration(args.duration);
if (!seconds) throw new Error(`“${args.duration}” is not a duration: try 45m, 1h30 or 3h`);
await start(seconds);
return `Awake for ${humanize(seconds)}`;
});
13 changes: 13 additions & 0 deletions extensions/caffeine/commands/toggle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { run } from "@backslash/api";
import { humanize, parseDuration } from "../lib.ts";
import { current, start, stop } from "../caffeine.ts";

export default run(async ({ preferences }) => {
if (await current()) {
await stop();
return "Caffeine off";
}
const seconds = parseDuration(String(preferences.duration ?? ""));
await start(seconds);
return seconds ? `Awake for ${humanize(seconds)}` : "Awake until turned off";
});
Loading
Loading