import { APP_NAME } from "./constants.ts"; export const NotifyProtocol = { Bell: "\x07", Osc99: "\x1b]99;;", Osc9: "\x1b]9;", } as const; export type NotifyProtocol = (typeof NotifyProtocol)[keyof typeof NotifyProtocol]; export type NotificationUrgency = "low" | "normal" | "critical"; export type NotificationActions = "focus" | "report" | "focus-report" | "none"; export type NotificationSound = "silent" | "system" | "info" | "warning" | "error" | "question"; export interface TerminalNotification { title?: string; body?: string; id?: string; type?: string | string[]; urgency?: NotificationUrgency; iconName?: string; sound?: NotificationSound; actions?: NotificationActions; expiresMs?: number; } export type NotificationInput = string | TerminalNotification; const MAX = { title: 256, body: 4096, id: 128, type: 128, iconName: 128 } as const; const unsafeControls = /[\u0000-\u001f\u007f-\u009f]/gu; function isRecord(value: unknown): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function boundedString(value: unknown, max: number): value is string { return typeof value === "string" && value.length <= max; } function validEnum(value: unknown, values: readonly T[]): value is T { return typeof value === "string" && values.includes(value as T); } const urgencies = ["low", "normal", "critical"] as const; const sounds = ["silent", "system", "info", "warning", "error", "question"] as const; const actions = ["focus", "report", "focus-report", "none"] as const; /** Validate once at the boundary. No inherited properties are accepted. */ export function normalizeNotification(input: unknown): TerminalNotification | null { if (typeof input === "string") return input.length <= MAX.body ? { body: input } : null; if (!isRecord(input)) return null; const output: TerminalNotification = {}; const own = (key: string): unknown => Object.hasOwn(input, key) ? input[key] : undefined; const title = own("title"); const body = own("body"); const id = own("id"); const type = own("type"); const urgency = own("urgency"); const iconName = own("iconName"); const sound = own("sound"); const notificationActions = own("actions"); const expiresMs = own("expiresMs"); if (title !== undefined && !boundedString(title, MAX.title)) return null; if (body !== undefined && !boundedString(body, MAX.body)) return null; if (id !== undefined && !boundedString(id, MAX.id)) return null; if (iconName !== undefined && !boundedString(iconName, MAX.iconName)) return null; if (urgency !== undefined && !validEnum(urgency, urgencies)) return null; if (sound !== undefined && !validEnum(sound, sounds)) return null; if (notificationActions !== undefined && !validEnum(notificationActions, actions)) return null; if (expiresMs !== undefined && (typeof expiresMs !== "number" || !Number.isFinite(expiresMs))) return null; if (type !== undefined) { if (typeof type === "string") { if (type.length > MAX.type) return null; } else if (Array.isArray(type)) { if (type.some(entry => !boundedString(entry, MAX.type))) return null; } else { return null; } } if (title !== undefined) output.title = title; if (body !== undefined) output.body = body; if (id !== undefined) output.id = id; if (type !== undefined) output.type = Array.isArray(type) ? [...type] : type; if (urgency !== undefined) output.urgency = urgency; if (iconName !== undefined) output.iconName = iconName; if (sound !== undefined) output.sound = sound; if (notificationActions !== undefined) output.actions = notificationActions; if (expiresMs !== undefined) output.expiresMs = expiresMs; return output; } /** Replace C0/C1 controls before sending text to line protocols or child argv. */ export function sanitizeNotificationText(value: string): string { return value.replace(unsafeControls, " "); } export function notificationToSafeLine(notification: TerminalNotification): string { const title = notification.title === undefined ? undefined : sanitizeNotificationText(notification.title); const body = notification.body === undefined ? undefined : sanitizeNotificationText(notification.body); if (title && body) return `${title}: ${body}`; return title ?? body ?? ""; } export interface DesktopNotificationFields { title: string; body: string; urgency: NotificationUrgency; } export function notificationToDesktopText(input: NotificationInput): DesktopNotificationFields { const notification = typeof input === "string" ? { body: input } : input; return { title: sanitizeNotificationText(notification.title?.trim() || APP_NAME), body: sanitizeNotificationText(notification.body ?? ""), urgency: notification.urgency === "low" || notification.urgency === "critical" ? notification.urgency : "normal", }; } export function hasStructuredFields(notification: TerminalNotification): boolean { return Object.keys(notification).length > 0; } export const CONTROL_CHARACTER_PATTERN = unsafeControls;