diff --git a/packages/timer/README.md b/packages/timer/README.md
index 5bc74c9..062cb24 100644
--- a/packages/timer/README.md
+++ b/packages/timer/README.md
@@ -27,9 +27,37 @@ Start a timer for 1 minute and 42 seconds
Get help
```sh
-> search --help
+> timer --help
```
+## Notifications
+
+When the timer is done it raises a desktop notification. On macOS this is a
+dialog carrying the timer's own icon, which stays on screen until you dismiss
+it — a banner auto-dismisses after a few seconds, so a timer that fires while
+you are away from the keyboard would leave nothing behind. The dialog is
+released detached, so the timer itself exits immediately and never blocks
+whatever you chained behind it.
+
+Prefer the passive banner:
+
+```sh
+> timer 1m42s --banner
+```
+
+Turn notifications off entirely:
+
+```sh
+> timer 1m42s --no-notification
+```
+
+`--banner` is macOS-only; Linux (`notify-send`) and Windows (toast) always use
+their native banner.
+
+Be aware that macOS silently suppresses banners while a Focus mode is on or
+while you are sharing your screen — `--banner` will appear to do nothing. The
+dialog is an ordinary window, so it is not affected by either.
+
## License
MIT © Arno Versini
diff --git a/packages/timer/assets/hourglass.png b/packages/timer/assets/hourglass.png
new file mode 100644
index 0000000..efe0717
Binary files /dev/null and b/packages/timer/assets/hourglass.png differ
diff --git a/packages/timer/assets/hourglass.svg b/packages/timer/assets/hourglass.svg
new file mode 100644
index 0000000..2c07d92
--- /dev/null
+++ b/packages/timer/assets/hourglass.svg
@@ -0,0 +1,30 @@
+
diff --git a/packages/timer/package.json b/packages/timer/package.json
index 96712a8..451ca85 100644
--- a/packages/timer/package.json
+++ b/packages/timer/package.json
@@ -14,6 +14,7 @@
"exports": "./dist/timer.js",
"bin": "dist/timer.js",
"files": [
+ "assets",
"dist",
"README.md"
],
diff --git a/packages/timer/src/__tests__/notify.test.ts b/packages/timer/src/__tests__/notify.test.ts
index 21f89cf..36ccae8 100644
--- a/packages/timer/src/__tests__/notify.test.ts
+++ b/packages/timer/src/__tests__/notify.test.ts
@@ -1,4 +1,4 @@
-import { execFile } from "node:child_process";
+import { execFile, spawn } from "node:child_process";
import { vi } from "vitest";
import { buildNotifyCommand, type Notification, notify } from "../notify.js";
@@ -34,10 +34,20 @@ vi.mock("node:child_process", () => {
);
return promise;
};
- return { execFile };
+ return { execFile, spawn: vi.fn() };
});
const mockedExecFile = vi.mocked(execFile);
+const mockedSpawn = vi.mocked(spawn);
+
+/**
+ * Mimics the subset of ChildProcess that the detached path touches.
+ */
+const spawnReturnsChild = () => {
+ const child = { on: vi.fn(), unref: vi.fn() };
+ mockedSpawn.mockReturnValue(child as unknown as ReturnType);
+ return child;
+};
type ExecFileCallback = (error: NodeJS.ErrnoException | null) => void;
@@ -91,10 +101,46 @@ const notification = {
title: "Timer Notification",
};
+const bannerNotification = { ...notification, banner: true };
+
describe("when building the platform-native notification command", () => {
- it("should use osascript on macOS", async () => {
- expect(buildNotifyCommand("darwin", notification)).toStrictEqual({
+ /**
+ * The dialog is the macOS default because a banner cannot carry the timer's
+ * own icon and disappears on its own, which loses the alarm entirely when
+ * nobody is watching the screen.
+ */
+ it("should default to an alert dialog on macOS", async () => {
+ const { args, command, detached } = buildNotifyCommand(
+ "darwin",
+ notification,
+ );
+
+ expect(command).toBe("osascript");
+ expect(detached).toBe(true);
+ expect(args.at(-1)).toContain('display dialog "Time\'s up!"');
+ expect(args.at(-1)).toContain('with title "Timer Notification"');
+ expect(args.at(-1)).toContain("hourglass.png");
+ expect(args.at(-1)).toContain('buttons {"OK"} default button "OK"');
+ });
+
+ it("should play the sound alongside the alert dialog on macOS", async () => {
+ const { args } = buildNotifyCommand("darwin", notification);
+
+ expect(args[0]).toBe("-e");
+ expect(args[1]).toContain("afplay /System/Library/Sounds/Funk.aiff");
+ });
+
+ it("should omit the sound script when the alert has no sound", async () => {
+ const { args } = buildNotifyCommand("darwin", { message: "a", title: "b" });
+
+ expect(args).toHaveLength(2);
+ expect(args[1]).not.toContain("afplay");
+ });
+
+ it("should use a banner on macOS when one is asked for", async () => {
+ expect(buildNotifyCommand("darwin", bannerNotification)).toStrictEqual({
command: "osascript",
+ detached: false,
args: [
"-e",
'display notification "Time\'s up!" with title "Timer Notification" sound name "Funk"',
@@ -104,15 +150,17 @@ describe("when building the platform-native notification command", () => {
it("should omit the sound on macOS when none is requested", async () => {
expect(
- buildNotifyCommand("darwin", { message: "a", title: "b" }),
+ buildNotifyCommand("darwin", { banner: true, message: "a", title: "b" }),
).toStrictEqual({
command: "osascript",
+ detached: false,
args: ["-e", 'display notification "a" with title "b"'],
});
});
it("should escape quotes and backslashes in the AppleScript literal", async () => {
const { args } = buildNotifyCommand("darwin", {
+ banner: true,
message: 'say "hi" \\ bye',
title: 'a "quoted" title',
});
@@ -121,9 +169,23 @@ describe("when building the platform-native notification command", () => {
);
});
+ it("should escape quotes and backslashes in the alert dialog literal", async () => {
+ const { args } = buildNotifyCommand("darwin", {
+ message: 'say "hi" \\ bye',
+ title: 'a "quoted" title',
+ });
+ expect(args.at(-1)).toContain('display dialog "say \\"hi\\" \\\\ bye"');
+ expect(args.at(-1)).toContain('with title "a \\"quoted\\" title"');
+ });
+
+ /**
+ * The alert is macOS-only: the other two platforms get their native banner
+ * whether or not one was asked for.
+ */
it("should use notify-send on Linux", async () => {
expect(buildNotifyCommand("linux", notification)).toStrictEqual({
command: "notify-send",
+ detached: false,
args: ["Timer Notification", "Time's up!"],
});
});
@@ -153,11 +215,12 @@ describe("when building the platform-native notification command", () => {
describe("when displaying a notification", () => {
beforeEach(() => {
mockedExecFile.mockReset();
+ mockedSpawn.mockReset();
});
it("should resolve to true when the notifier runs", async () => {
spawnSucceeds();
- await expect(notify(notification, "darwin")).resolves.toBe(true);
+ await expect(notify(bannerNotification, "darwin")).resolves.toBe(true);
expect(mockedExecFile).toHaveBeenCalledTimes(1);
});
@@ -169,7 +232,7 @@ describe("when displaying a notification", () => {
*/
it("should resolve to false when the notifier cannot run on this CPU", async () => {
spawnThrowsBadArch();
- await expect(notify(notification, "darwin")).resolves.toBe(false);
+ await expect(notify(bannerNotification, "darwin")).resolves.toBe(false);
});
it("should resolve to false when the notifier is not installed", async () => {
@@ -184,7 +247,7 @@ describe("when displaying a notification", () => {
*/
it("should bound the spawn so a hung notifier cannot keep the CLI alive", async () => {
spawnSucceeds();
- await notify(notification, "darwin");
+ await notify(bannerNotification, "darwin");
expect(mockedExecFile).toHaveBeenCalledWith(
"osascript",
expect.any(Array),
@@ -193,6 +256,44 @@ describe("when displaying a notification", () => {
);
});
+ /**
+ * The alert waits for a human, so awaiting it would hold the timer open until
+ * the dialog is clicked and stall anything chained behind it.
+ */
+ it("should detach the alert instead of waiting for it to be dismissed", async () => {
+ const child = spawnReturnsChild();
+
+ await expect(notify(notification, "darwin")).resolves.toBe(true);
+ expect(mockedExecFile).not.toHaveBeenCalled();
+ expect(mockedSpawn).toHaveBeenCalledWith(
+ "osascript",
+ expect.any(Array),
+ expect.objectContaining({ detached: true, stdio: "ignore" }),
+ );
+ expect(child.unref).toHaveBeenCalledTimes(1);
+ });
+
+ /**
+ * An unhandled "error" event on a child process is thrown, so a notifier that
+ * cannot spawn at all would otherwise take the timer down with it.
+ */
+ it("should swallow a spawn failure on the detached alert", async () => {
+ const child = spawnReturnsChild();
+
+ await notify(notification, "darwin");
+ expect(child.on).toHaveBeenCalledWith("error", expect.any(Function));
+
+ const handler = child.on.mock.calls[0][1] as () => void;
+ expect(handler).not.toThrow();
+ });
+
+ it("should resolve to false when the alert cannot be spawned at all", async () => {
+ mockedSpawn.mockImplementation(() => {
+ throw new Error("spawn EACCES");
+ });
+ await expect(notify(notification, "darwin")).resolves.toBe(false);
+ });
+
it("should resolve to false when a malformed notification is passed", async () => {
await expect(
notify(
diff --git a/packages/timer/src/defaults.ts b/packages/timer/src/defaults.ts
index fb9da31..a57b6b9 100644
--- a/packages/timer/src/defaults.ts
+++ b/packages/timer/src/defaults.ts
@@ -1,5 +1,6 @@
/* v8 ignore start */
export const defaultFlags = {
+ banner: false,
boring: false,
notification: true,
};
diff --git a/packages/timer/src/notify.ts b/packages/timer/src/notify.ts
index 53cc22e..714f301 100644
--- a/packages/timer/src/notify.ts
+++ b/packages/timer/src/notify.ts
@@ -1,4 +1,5 @@
-import { execFile } from "node:child_process";
+import { execFile, spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
@@ -10,7 +11,17 @@ const execFileAsync = promisify(execFile);
*/
const NOTIFIER_TIMEOUT_MS = 5000;
+/**
+ * Resolved from this module rather than the process, so it survives being
+ * invoked through a symlinked bin. `src` and `dist` sit at the same depth, so
+ * the same relative path works whether the source or the build is running.
+ */
+const ALERT_ICON_PATH = fileURLToPath(
+ new URL("../assets/hourglass.png", import.meta.url),
+);
+
export type Notification = {
+ banner?: boolean;
message: string;
sound?: string;
title: string;
@@ -19,6 +30,7 @@ export type Notification = {
export type NotifyCommand = {
args: string[];
command: string;
+ detached: boolean;
};
/**
@@ -34,7 +46,11 @@ const escapeAppleScript = (value: string): string =>
*/
const escapePowerShell = (value: string): string => value.replaceAll("'", "''");
-const macCommand = ({ message, sound, title }: Notification): NotifyCommand => {
+const macBannerCommand = ({
+ message,
+ sound,
+ title,
+}: Notification): NotifyCommand => {
const script = [
`display notification "${escapeAppleScript(message)}"`,
`with title "${escapeAppleScript(title)}"`,
@@ -43,12 +59,53 @@ const macCommand = ({ message, sound, title }: Notification): NotifyCommand => {
.filter(Boolean)
.join(" ");
- return { args: ["-e", script], command: "osascript" };
+ return { args: ["-e", script], command: "osascript", detached: false };
+};
+
+/**
+ * A banner auto-dismisses after a few seconds, so a timer that fires while you
+ * are away from the keyboard leaves nothing behind. This dialog waits until it
+ * is acknowledged, which is the behaviour an alarm actually wants.
+ *
+ * `display dialog` takes an arbitrary image, which is the only way to escape
+ * the Script Editor icon macOS otherwise stamps on anything osascript posts.
+ *
+ */
+const macAlertCommand = ({
+ message,
+ sound,
+ title,
+}: Notification): NotifyCommand => {
+ const dialog = [
+ `display dialog "${escapeAppleScript(message)}"`,
+ `with title "${escapeAppleScript(title)}"`,
+ `with icon (POSIX file "${escapeAppleScript(ALERT_ICON_PATH)}")`,
+ 'buttons {"OK"} default button "OK"',
+ ].join(" ");
+
+ /**
+ * `display dialog` has no sound parameter, so the alert sound is played
+ * alongside it. Backgrounded and silenced because a missing sound file must
+ * not stall or fail the dialog.
+ */
+ const play = sound
+ ? [
+ "-e",
+ `do shell script "afplay /System/Library/Sounds/${escapeAppleScript(sound)}.aiff > /dev/null 2>&1 &"`,
+ ]
+ : [];
+
+ return {
+ args: [...play, "-e", dialog],
+ command: "osascript",
+ detached: true,
+ };
};
const linuxCommand = ({ message, title }: Notification): NotifyCommand => ({
args: [title, message],
command: "notify-send",
+ detached: false,
});
const windowsCommand = ({ message, title }: Notification): NotifyCommand => {
@@ -60,7 +117,11 @@ const windowsCommand = ({ message, title }: Notification): NotifyCommand => {
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('@node-cli/timer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))",
].join("; ");
- return { args: ["-NoProfile", "-Command", script], command: "powershell" };
+ return {
+ args: ["-NoProfile", "-Command", script],
+ command: "powershell",
+ detached: false,
+ };
};
/**
@@ -70,6 +131,10 @@ const windowsCommand = ({ message, title }: Notification): NotifyCommand => {
* Every supported platform ships its notifier as part of the OS, so nothing is
* vendored and nothing has to match the host CPU architecture.
*
+ * Only macOS distinguishes an alert from a banner: it is the platform whose
+ * banner cannot carry our icon, and the one where the dialog is available for
+ * free. Linux and Windows always get their native banner.
+ *
*/
export const buildNotifyCommand = (
platform: string,
@@ -77,7 +142,9 @@ export const buildNotifyCommand = (
): NotifyCommand | null => {
switch (platform) {
case "darwin": {
- return macCommand(notification);
+ return notification.banner
+ ? macBannerCommand(notification)
+ : macAlertCommand(notification);
}
case "linux": {
return linuxCommand(notification);
@@ -91,6 +158,28 @@ export const buildNotifyCommand = (
}
};
+/**
+ * Releases a notifier that must outlive the CLI. An alert waits for a human, so
+ * awaiting it would hold the timer open until the dialog is clicked and stall
+ * anything chained behind it.
+ *
+ * Detaching costs the delivery result: spawn reports failure asynchronously,
+ * long after the CLI is gone. The caller is told only that the alert was
+ * released, which is all a best-effort courtesy needs to report.
+ *
+ */
+const release = ({ args, command }: NotifyCommand): boolean => {
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
+
+ /**
+ * An unhandled "error" event on a child process is thrown, so a notifier that
+ * cannot spawn at all would take the timer down with it.
+ */
+ child.on("error", () => {});
+ child.unref();
+ return true;
+};
+
/**
* Displays a desktop notification, resolving to true when it was shown.
*
@@ -110,6 +199,10 @@ export const notify = async (
return false;
}
+ if (notifyCommand.detached) {
+ return release(notifyCommand);
+ }
+
await execFileAsync(notifyCommand.command, notifyCommand.args, {
killSignal: "SIGKILL",
timeout: NOTIFIER_TIMEOUT_MS,
diff --git a/packages/timer/src/parse.ts b/packages/timer/src/parse.ts
index b703cf2..58c8c4c 100644
--- a/packages/timer/src/parse.ts
+++ b/packages/timer/src/parse.ts
@@ -25,6 +25,12 @@ export const config: Configuration = parser({
},
],
flags: {
+ banner: {
+ default: defaultFlags.banner,
+ description:
+ "Show a passive banner instead of a dialog that waits to be dismissed (macOS only)",
+ type: "boolean",
+ },
boring: {
shortFlag: "b",
default: defaultFlags.boring,
diff --git a/packages/timer/src/utilities.ts b/packages/timer/src/utilities.ts
index 7a95a88..cbbc6a5 100644
--- a/packages/timer/src/utilities.ts
+++ b/packages/timer/src/utilities.ts
@@ -104,6 +104,7 @@ export class Timer {
if (this.config.flags.notification) {
try {
await notify({
+ banner: this.config.flags.banner,
message,
sound: "Funk",
title: "Timer Notification",