import { Capacitor } from "@capacitor/core"; import { EventEmitter } from "node:events"; import type { PluginEventMap } from "../types.js"; export interface PushNotificationEventMap extends PluginEventMap { "notification:received": { title: string; body: string; taskId?: string; data?: Record; }; "notification:tapped": { taskId?: string; data?: Record }; "permission:changed": { granted: boolean }; "token:registered": { token: string }; "ntfy:message": { id: string; title: string; message: string; priority: string; clickUrl?: string; event?: string; taskId?: string; }; } export interface PushNotificationManagerOptions { /** ntfy.sh base URL. Default: https://ntfy.sh */ ntfyBaseUrl?: string; /** How to fetch settings for ntfy configuration. Called periodically. */ settingsFetcher?: () => Promise<{ ntfyEnabled?: boolean; ntfyTopic?: string }>; /** Polling interval in ms for ntfy.sh settings refresh. Default: 30000 */ ntfyPollIntervalMs?: number; } type PushNotificationsModule = typeof import("@capacitor/push-notifications"); export class PushNotificationManager extends EventEmitter { private deviceToken: string | undefined; private ntfyBaseUrl: string; private settingsFetcher?: PushNotificationManagerOptions["settingsFetcher"]; private ntfyPollIntervalMs: number; private ntfyAbortController: AbortController | null = null; private ntfyCurrentTopic: string | undefined; private settingsInterval: ReturnType | null = null; private listenerHandles: Array<{ remove: () => Promise }> = []; private pushNotifications: PushNotificationsModule["PushNotifications"] | null = null; constructor(options: PushNotificationManagerOptions = {}) { super(); this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh"; this.settingsFetcher = options.settingsFetcher; this.ntfyPollIntervalMs = options.ntfyPollIntervalMs ?? 30_000; } override on( eventName: K, listener: (payload: PushNotificationEventMap[K]) => void, ): this; override on(eventName: string | symbol, listener: (...args: any[]) => void): this; override on(eventName: string | symbol, listener: (...args: any[]) => void): this { return super.on(eventName, listener); } override off( eventName: K, listener: (payload: PushNotificationEventMap[K]) => void, ): this; override off(eventName: string | symbol, listener: (...args: any[]) => void): this; override off(eventName: string | symbol, listener: (...args: any[]) => void): this { return super.off(eventName, listener); } emit( eventName: K, payload: PushNotificationEventMap[K], ): boolean; emit(eventName: string | symbol, payload?: unknown): boolean; emit(eventName: string | symbol, payload?: unknown): boolean { return super.emit(eventName, payload); } async start(): Promise { await this.initListeners(); void this.requestPermission(); this.startSettingsPoll(); } async destroy(): Promise { for (const handle of this.listenerHandles) { try { await handle.remove(); } catch (error) { console.warn("Failed to remove push notification listener", error); } } this.listenerHandles = []; this.stopNtfySubscription(); if (this.settingsInterval) { clearInterval(this.settingsInterval); this.settingsInterval = null; } this.removeAllListeners(); this.deviceToken = undefined; this.pushNotifications = null; } async requestPermission(): Promise { if (!Capacitor.isNativePlatform()) { return false; } try { const pushNotifications = await this.loadPushNotifications(); if (!pushNotifications) { this.emit("permission:changed", { granted: false }); return false; } const result = await pushNotifications.requestPermissions(); const granted = result.receive === "granted"; if (!granted) { this.emit("permission:changed", { granted: false }); return false; } await pushNotifications.register(); this.emit("permission:changed", { granted: true }); return true; } catch (error) { console.warn("Failed to request push notification permission", error); this.emit("permission:changed", { granted: false }); return false; } } getDeviceToken(): string | undefined { return this.deviceToken; } async initListeners(): Promise { if (!Capacitor.isNativePlatform()) { return; } const pushNotifications = await this.loadPushNotifications(); if (!pushNotifications) { return; } const registrationHandle = await pushNotifications.addListener("registration", (token) => { const value = typeof token?.value === "string" ? token.value : ""; if (!value) { return; } this.deviceToken = value; this.emit("token:registered", { token: value }); }); this.listenerHandles.push(registrationHandle); const registrationErrorHandle = await pushNotifications.addListener("registrationError", (error) => { console.warn("Push registration error", error); this.emit("permission:changed", { granted: false }); }); this.listenerHandles.push(registrationErrorHandle); const receivedHandle = await pushNotifications.addListener("pushNotificationReceived", (notification) => { const data = this.normalizeData(notification.data); const title = notification.title ?? ""; const body = notification.body ?? ""; const taskId = this.extractTaskId({ data, title, message: body, }); this.emit("notification:received", { title, body, taskId, data, }); }); this.listenerHandles.push(receivedHandle); const actionPerformedHandle = await pushNotifications.addListener("pushNotificationActionPerformed", (actionResult) => { const notification = actionResult.notification; const data = this.normalizeData(notification?.data); const taskId = this.extractTaskId({ data, title: notification?.title, message: notification?.body, }); this.emit("notification:tapped", { taskId, data, }); }); this.listenerHandles.push(actionPerformedHandle); } private normalizeData(data: unknown): Record | undefined { if (!data || typeof data !== "object" || Array.isArray(data)) { return undefined; } return data as Record; } private extractTaskId(params: { data?: Record; clickUrl?: string; title?: string; message?: string; }): string | undefined { const directTaskId = params.data?.taskId; if (typeof directTaskId === "string" && directTaskId.trim().length > 0) { return directTaskId; } if (params.clickUrl) { try { const url = new URL(params.clickUrl); const taskFromQuery = url.searchParams.get("task"); if (taskFromQuery && taskFromQuery.trim().length > 0) { return taskFromQuery; } } catch { // ignore malformed click URLs } } const combinedText = `${params.title ?? ""} ${params.message ?? ""}`; const taskMatch = combinedText.match(/(FN|KB)-\d{3,}/i); if (taskMatch?.[0]) { return taskMatch[0].toUpperCase(); } return undefined; } async startNtfySubscription(topic: string): Promise { const normalizedTopic = topic.trim(); if (!normalizedTopic) { this.stopNtfySubscription(); return; } if (this.ntfyCurrentTopic === normalizedTopic && this.ntfyAbortController) { return; } if (this.ntfyCurrentTopic && this.ntfyCurrentTopic !== normalizedTopic) { this.stopNtfySubscription(); } const abortController = new AbortController(); this.ntfyAbortController = abortController; this.ntfyCurrentTopic = normalizedTopic; try { const response = await fetch(`${this.ntfyBaseUrl}/${normalizedTopic}/json`, { method: "GET", headers: { Accept: "application/json", }, signal: abortController.signal, }); if (!response.ok || !response.body) { console.warn(`Failed to start ntfy subscription for topic ${normalizedTopic}`); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) { break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) { continue; } this.handleNtfyMessage(line); } } if (buffer.trim()) { this.handleNtfyMessage(buffer); } } catch (error) { if (error instanceof Error && error.name === "AbortError") { return; } console.warn(`ntfy subscription error for topic ${normalizedTopic}`, error); } finally { if (this.ntfyAbortController === abortController) { this.ntfyAbortController = null; this.ntfyCurrentTopic = undefined; } } } stopNtfySubscription(): void { if (this.ntfyAbortController) { this.ntfyAbortController.abort(); } this.ntfyAbortController = null; this.ntfyCurrentTopic = undefined; } startSettingsPoll(): void { if (!this.settingsFetcher) { return; } if (this.settingsInterval) { clearInterval(this.settingsInterval); this.settingsInterval = null; } const checkSettings = async () => { if (!this.settingsFetcher) { return; } try { const settings = await this.settingsFetcher(); const ntfyEnabled = settings.ntfyEnabled === true; const ntfyTopic = settings.ntfyTopic?.trim(); if (ntfyEnabled && ntfyTopic) { if (this.ntfyCurrentTopic !== ntfyTopic) { void this.startNtfySubscription(ntfyTopic); } return; } this.stopNtfySubscription(); } catch (error) { console.warn("Failed to fetch ntfy settings", error); } }; void checkSettings(); this.settingsInterval = setInterval(() => { void checkSettings(); }, this.ntfyPollIntervalMs); } private handleNtfyMessage(rawLine: string): void { let parsed: Record; try { const json = JSON.parse(rawLine); if (!json || typeof json !== "object" || Array.isArray(json)) { return; } parsed = json as Record; } catch { return; } if (parsed.event !== "message") { return; } const title = typeof parsed.title === "string" ? parsed.title : ""; const message = typeof parsed.message === "string" ? parsed.message : ""; const clickUrl = typeof parsed.click === "string" ? parsed.click : undefined; const taskId = this.extractTaskId({ clickUrl, title, message, }); const priorityNumber = typeof parsed.priority === "number" ? parsed.priority : 3; const priority = this.mapNtfyPriority(priorityNumber); const id = typeof parsed.id === "string" ? parsed.id : ""; this.emit("ntfy:message", { id, title, message, priority, clickUrl, event: "message", taskId, }); } private mapNtfyPriority(priority: number): string { switch (priority) { case 1: return "low"; case 4: return "high"; case 5: return "urgent"; case 3: default: return "default"; } } private async loadPushNotifications(): Promise { if (!Capacitor.isNativePlatform()) { return null; } if (this.pushNotifications) { return this.pushNotifications; } try { const mod: PushNotificationsModule = await import("@capacitor/push-notifications"); this.pushNotifications = mod.PushNotifications; return this.pushNotifications; } catch (error) { console.warn("Failed to load @capacitor/push-notifications", error); return null; } } }