From 7eef7bb7868498365024e766126478649a9ba241 Mon Sep 17 00:00:00 2001 From: Arno V Date: Thu, 13 Aug 2026 23:15:58 -0400 Subject: [PATCH] feat(timer): show a branded alert that stays until dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the timer finished, macOS showed a banner carrying the generic Script Editor icon, because a notification is attributed to whichever app posted it and `osascript` is owned by Script Editor. There is no parameter to override that icon. On macOS the timer now raises a dialog instead. `display dialog` takes an arbitrary image, which is the only way to escape the Script Editor icon, and it stays on screen until acknowledged rather than fading after a few seconds. That also survives Focus modes and screen sharing, both of which silently suppress banners. The dialog is spawned detached and unref'd, so the CLI exits immediately and never blocks anything chained behind it. `display dialog` has no sound parameter, so the alert sound is played alongside it via a backgrounded `afplay`. Pass `--banner` for the old passive notification. Linux (`notify-send`) and Windows (toast) are unchanged and always use their native banner. Consumer impact: the macOS default changes from a banner to a dialog; `--banner` restores the previous behaviour. `NotifyCommand` gained a required `detached` field, and detaching means `notify()` can no longer report delivery for the alert path — it resolves true on release, which is all a best-effort courtesy needs to report. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsRJCdUiefZVC2ckCbqKJ7 --- packages/timer/README.md | 30 ++++- packages/timer/assets/hourglass.png | Bin 0 -> 40892 bytes packages/timer/assets/hourglass.svg | 30 +++++ packages/timer/package.json | 1 + packages/timer/src/__tests__/notify.test.ts | 117 ++++++++++++++++++-- packages/timer/src/defaults.ts | 1 + packages/timer/src/notify.ts | 103 ++++++++++++++++- packages/timer/src/parse.ts | 6 + packages/timer/src/utilities.ts | 1 + 9 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 packages/timer/assets/hourglass.png create mode 100644 packages/timer/assets/hourglass.svg diff --git a/packages/timer/README.md b/packages/timer/README.md index 5bc74c9c..062cb240 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 0000000000000000000000000000000000000000..efe07175b4ba727d9ca5786cb1a87bdeecb52d1c GIT binary patch literal 40892 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 literal 0 HcmV?d00001 diff --git a/packages/timer/assets/hourglass.svg b/packages/timer/assets/hourglass.svg new file mode 100644 index 00000000..2c07d922 --- /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 96712a82..451ca850 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 21f89cfb..36ccae8c 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 fb9da31d..a57b6b9b 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 53cc22eb..714f3019 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 b703cf24..58c8c4c6 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 7a95a886..cbbc6a5b 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",