feat(notification): add cmux as notification provider
Add cmux as the highest-priority notification provider on macOS. cmux delivers notifications via native UNUserNotificationCenter, properly attributed to the cmux app instead of Script Editor. Notification priority: cmux > terminal-notifier > osascript Tests cover the full fallback chain: - cmux available: uses cmux, skips others - cmux fails: falls back to terminal-notifier - cmux + terminal-notifier fail: falls back to osascript - cmux not available: skips to terminal-notifier Fixes #3628
This commit is contained in:
@@ -66,6 +66,7 @@ function createThrowingShellPromise(shouldThrow: (cmdStr: string) => boolean) {
|
|||||||
describe("session-notification-sender", () => {
|
describe("session-notification-sender", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.restoreAllMocks()
|
jest.restoreAllMocks()
|
||||||
|
spyOn(utils, "getCmuxPath").mockResolvedValue(null)
|
||||||
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
|
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
|
||||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||||
@@ -137,6 +138,79 @@ describe("session-notification-sender", () => {
|
|||||||
expect(quietCalls[0]).toContain("osascript")
|
expect(quietCalls[0]).toContain("osascript")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#then should use cmux when available", async () => {
|
||||||
|
spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux")
|
||||||
|
|
||||||
|
const calls: string[] = []
|
||||||
|
const mockCtx = {
|
||||||
|
$: createShellPromise((cmdStr) => { calls.push(cmdStr) }),
|
||||||
|
} as unknown as PluginInput
|
||||||
|
|
||||||
|
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
|
||||||
|
|
||||||
|
expect(calls.length).toBe(1)
|
||||||
|
expect(calls[0]).toContain("cmux")
|
||||||
|
expect(calls[0]).not.toContain("terminal-notifier")
|
||||||
|
expect(calls[0]).not.toContain("osascript")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#then should fall back to terminal-notifier when cmux fails", async () => {
|
||||||
|
spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux")
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
$: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify")),
|
||||||
|
} as unknown as PluginInput
|
||||||
|
|
||||||
|
const originalFactory = mockCtx.$
|
||||||
|
const trackingCalls: string[] = []
|
||||||
|
mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||||
|
const cmdStr = cmd.reduce((acc: string, part: string, i: number) => acc + part + (values[i] ?? ""), "")
|
||||||
|
trackingCalls.push(cmdStr)
|
||||||
|
return originalFactory(cmd, ...values)
|
||||||
|
}) as typeof mockCtx.$
|
||||||
|
|
||||||
|
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
|
||||||
|
|
||||||
|
expect(trackingCalls.some((c) => c.includes("cmux notify"))).toBe(true)
|
||||||
|
expect(trackingCalls.some((c) => c.includes("terminal-notifier"))).toBe(true)
|
||||||
|
expect(trackingCalls.some((c) => c.includes("osascript"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#then should fall back to osascript when cmux and terminal-notifier both fail", async () => {
|
||||||
|
spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux")
|
||||||
|
|
||||||
|
const trackingCalls: string[] = []
|
||||||
|
const mockCtx = {
|
||||||
|
$: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify") || cmdStr.includes("terminal-notifier")),
|
||||||
|
} as unknown as PluginInput
|
||||||
|
|
||||||
|
const originalFactory = mockCtx.$
|
||||||
|
mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||||
|
const cmdStr = cmd.reduce((acc: string, part: string, i: number) => acc + part + (values[i] ?? ""), "")
|
||||||
|
trackingCalls.push(cmdStr)
|
||||||
|
return originalFactory(cmd, ...values)
|
||||||
|
}) as typeof mockCtx.$
|
||||||
|
|
||||||
|
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
|
||||||
|
|
||||||
|
expect(trackingCalls.some((c) => c.includes("cmux notify"))).toBe(true)
|
||||||
|
expect(trackingCalls.some((c) => c.includes("terminal-notifier"))).toBe(true)
|
||||||
|
expect(trackingCalls.some((c) => c.includes("osascript"))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#then should skip cmux when not available and use terminal-notifier", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const mockCtx = {
|
||||||
|
$: createShellPromise((cmdStr) => { calls.push(cmdStr) }),
|
||||||
|
} as unknown as PluginInput
|
||||||
|
|
||||||
|
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
|
||||||
|
|
||||||
|
expect(calls.length).toBe(1)
|
||||||
|
expect(calls[0]).toContain("terminal-notifier")
|
||||||
|
expect(calls[0]).not.toContain("cmux notify")
|
||||||
|
})
|
||||||
|
|
||||||
test("#then should call .quiet() on linux notify-send", async () => {
|
test("#then should call .quiet() on linux notify-send", async () => {
|
||||||
const quietCalls: string[] = []
|
const quietCalls: string[] = []
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { platform } from "os"
|
import { platform } from "os"
|
||||||
import {
|
import {
|
||||||
|
getCmuxPath,
|
||||||
getOsascriptPath,
|
getOsascriptPath,
|
||||||
getNotifySendPath,
|
getNotifySendPath,
|
||||||
getPowershellPath,
|
getPowershellPath,
|
||||||
@@ -40,7 +41,17 @@ export async function sendSessionNotification(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case "darwin": {
|
case "darwin": {
|
||||||
// Try terminal-notifier first - deterministic click-to-focus
|
// Try cmux first - native UNUserNotificationCenter, properly attributed
|
||||||
|
const cmuxPath = await getCmuxPath()
|
||||||
|
if (cmuxPath) {
|
||||||
|
try {
|
||||||
|
await ctx.$`${cmuxPath} notify --title ${title} --body ${message}`.quiet()
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try terminal-notifier - deterministic click-to-focus
|
||||||
const terminalNotifierPath = await getTerminalNotifierPath()
|
const terminalNotifierPath = await getTerminalNotifierPath()
|
||||||
if (terminalNotifierPath) {
|
if (terminalNotifierPath) {
|
||||||
const bundleId = process.env.__CFBundleIdentifier
|
const bundleId = process.env.__CFBundleIdentifier
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ export const getAfplayPath = createCommandFinder("afplay")
|
|||||||
export const getPaplayPath = createCommandFinder("paplay")
|
export const getPaplayPath = createCommandFinder("paplay")
|
||||||
export const getAplayPath = createCommandFinder("aplay")
|
export const getAplayPath = createCommandFinder("aplay")
|
||||||
export const getTerminalNotifierPath = createCommandFinder("terminal-notifier")
|
export const getTerminalNotifierPath = createCommandFinder("terminal-notifier")
|
||||||
|
export const getCmuxPath = createCommandFinder("cmux")
|
||||||
|
|
||||||
export function startBackgroundCheck(platform: Platform): void {
|
export function startBackgroundCheck(platform: Platform): void {
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
|
getCmuxPath().catch((error) => {
|
||||||
|
logBackgroundCheckError("cmux", error)
|
||||||
|
})
|
||||||
getOsascriptPath().catch((error) => {
|
getOsascriptPath().catch((error) => {
|
||||||
logBackgroundCheckError("osascript", error)
|
logBackgroundCheckError("osascript", error)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user