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",