diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index 4ac0c4d8c..f7e3e2ce6 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -44,12 +44,15 @@ export async function sendSessionNotification( const terminalNotifierPath = await getTerminalNotifierPath() if (terminalNotifierPath) { const bundleId = process.env.__CFBundleIdentifier - const args = [terminalNotifierPath, "-title", title, "-message", message] - if (bundleId) { - args.push("-activate", bundleId) + try { + if (bundleId) { + await ctx.$`${terminalNotifierPath} -title ${title} -message ${message} -activate ${bundleId}` + } else { + await ctx.$`${terminalNotifierPath} -title ${title} -message ${message}` + } + break + } catch { } - await ctx.$`${args}`.catch(() => {}) - break } // Fallback: osascript (click may open Finder instead of terminal) diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index 9d9c4706b..8ab8933ce 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -425,6 +425,67 @@ describe("session-notification", () => { expect(tnCall).toBeUndefined() }) + test("should fall back to osascript when terminal-notifier execution fails", async () => { + // given - terminal-notifier exists but invocation fails + spyOn(sender, "sendSessionNotification").mockRestore() + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => { + const cmdStr = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "") + notifyCalls.push(cmdStr) + + if (cmdStr.includes("terminal-notifier")) { + throw new Error("terminal-notifier failed") + } + + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") + + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - osascript fallback should be attempted after terminal-notifier failure + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + const osascriptCall = notifyCalls.find(c => c.includes("osascript")) + expect(tnCall).toBeDefined() + expect(osascriptCall).toBeDefined() + }) + + test("should invoke terminal-notifier without array interpolation", async () => { + // given - shell interpolation rejects array values + spyOn(sender, "sendSessionNotification").mockRestore() + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => { + if (values.some(Array.isArray)) { + throw new Error("array interpolation unsupported") + } + + const commandString = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "") + notifyCalls.push(commandString) + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") + + // when - terminal-notifier command is executed + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - terminal-notifier succeeds directly and fallback is not used + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + const osascriptCall = notifyCalls.find(c => c.includes("osascript")) + expect(tnCall).toBeDefined() + expect(osascriptCall).toBeUndefined() + }) + test("should use terminal-notifier without -activate when __CFBundleIdentifier is not set", async () => { // given - terminal-notifier available but no bundle ID spyOn(sender, "sendSessionNotification").mockRestore()