import { execFileSync, spawn } from "node:child_process"; import type { NotificationBackend, NotifyConfig } from "./types.ts"; const ESC = "\x1b"; const BEL = "\x07"; const ST = `${ESC}\\`; type NotifierDependencies = { spawn: typeof spawn; readTmuxClientInfo: () => string | undefined; platform: NodeJS.Platform; }; export function createNotifier(dependencies: Partial = {}) { const spawnCommand = dependencies.spawn ?? spawn; const tmuxClientInfo = dependencies.readTmuxClientInfo ?? readTmuxClientInfo; const platform = dependencies.platform ?? process.platform; return { getBackend(): NotificationBackend { return detectNotificationBackend(tmuxClientInfo, platform); }, sendNotification(config: NotifyConfig): void { try { const backend = detectNotificationBackend(tmuxClientInfo, platform); if (backend === "kitty") notifyKitty(config); else if (backend === "apple-script") notifyAppleScript(config.title, config.body, spawnCommand); else notifyOsc777(config.title, config.body); runSoundHook(config.soundCommand, spawnCommand); } catch {} }, }; } const defaultNotifier = createNotifier(); export function sendNotification(config: NotifyConfig): void { defaultNotifier.sendNotification(config); } export function getNotificationBackend(): NotificationBackend { return defaultNotifier.getBackend(); } export function detectNotificationBackend( readClientInfo: () => string | undefined = readTmuxClientInfo, platform: NodeJS.Platform = process.platform, ): NotificationBackend { if (isKitty(readClientInfo)) return "kitty"; return platform === "darwin" ? "apple-script" : "osc-777"; } function isKitty(readClientInfo: () => string | undefined): boolean { if (!process.env.TMUX) return Boolean(process.env.KITTY_WINDOW_ID); const info = readClientInfo()?.toLowerCase(); if (info) return info.includes("kitty"); return Boolean(process.env.KITTY_WINDOW_ID); } function readTmuxClientInfo(): string | undefined { try { return execFileSync("tmux", ["display-message", "-p", "#{client_termname} #{client_termtype}"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); } catch { return undefined; } } function notifyKitty(config: NotifyConfig): void { const safeTitle = Buffer.from(config.title, "utf8").toString("base64"); const safeBody = Buffer.from(config.body, "utf8").toString("base64"); const id = sanitizeMetadataValue(config.notificationId); writeSequence( `${ESC}]99;i=${id}:d=0:e=1:o=${config.visibility}:a=${config.action};${safeTitle}${ST}`, ); writeSequence(`${ESC}]99;i=${id}:p=body:e=1;${safeBody}${ST}`); } function notifyAppleScript(title: string, body: string, spawnCommand: typeof spawn): void { const script = `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`; spawnDetached(spawnCommand, "osascript", ["-e", script]); } function notifyOsc777(title: string, body: string): void { process.stdout.write(`${ESC}]777;notify;${sanitizeOsc777(title)};${sanitizeOsc777(body)}${BEL}`); } function writeSequence(sequence: string): void { process.stdout.write(wrapForTmux(sequence)); } function wrapForTmux(sequence: string): string { if (!process.env.TMUX) return sequence; return `${ESC}Ptmux;${sequence.replaceAll(ESC, ESC + ESC)}${ST}`; } function sanitizeMetadataValue(value: string): string { const sanitized = value.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 128); return sanitized || `pi-${process.pid}`; } function sanitizeOsc777(value: string): string { return value.replace(/[\x00-\x1f\x7f;]/g, " ").replace(/\s+/g, " ").trim(); } function appleScriptString(value: string): string { return `"${value.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"').trim()}"`; } function runSoundHook(command: string | undefined, spawnCommand: typeof spawn): void { if (!command) return; spawnDetached(spawnCommand, command, [], true); } function spawnDetached(spawnCommand: typeof spawn, command: string, args: string[], shell = false): void { const child = spawnCommand(command, args, { shell, detached: true, stdio: "ignore", }); child.on?.("error", () => {}); child.unref(); }