/** * iOS-VoIP-Push via PushKit + APNs HTTP/2. * * Unterschied zu services/push.ts (Expo Server SDK): * - Reguläre Pushes laufen über Expo's Proxy (token = ExponentPushToken[xxx]) * - VoIP-Pushes MÜSSEN direkt an APNs gehen (Apple verbietet Proxy für PushKit) * - Nutzt das VoIP-Services-Cert (.p12) das wir bei Apple beantragt haben * - Wake-from-killed-State: einziger Weg ein iOS-App aufzuwecken um CallKit * UI anzuzeigen * * Env-Vars (aus Infisical): * APNS_VOIP_P12_PATH Pfad zur .p12-Datei (z.B. /root/.secrets/rebreak-voip.p12) * APNS_VOIP_P12_PASSWORD Cert-Password * APNS_VOIP_TOPIC Bundle-ID + ".voip" (org.rebreak.app.voip) * APNS_VOIP_PRODUCTION "true" für Production-APNs-Endpoint * * Wenn ENV-Vars fehlen: Service no-op (kein Error, nur Log-Warnung beim ersten Send). * → Macht reguläre Pushes nicht kaputt wenn das VoIP-Setup noch unfertig ist. */ import fs from "node:fs"; // node-apn dynamisch laden (await import statt static import). Grund: nitro/rollup // bricht beim Bundlen von node-apn → undici → `Class extends value [object Module]` // at push.mjs. Dynamic import wird nicht statisch getraced, lädt Modul zur Laufzeit // aus node_modules wo es korrekt als CJS funktioniert. type ApnModule = typeof import("@parse/node-apn"); type ApnProvider = InstanceType; type ApnNotification = InstanceType; let apnMod: ApnModule | null = null; let provider: ApnProvider | null = null; let initialized = false; let topic: string | null = null; async function getProvider(): Promise { if (initialized) return provider; initialized = true; const p12Path = process.env.APNS_VOIP_P12_PATH; const p12Pass = process.env.APNS_VOIP_P12_PASSWORD; const tpc = process.env.APNS_VOIP_TOPIC; const production = process.env.APNS_VOIP_PRODUCTION === "true"; if (!p12Path || !p12Pass || !tpc) { console.warn( "[voip-push] disabled — missing env (APNS_VOIP_P12_PATH/PASSWORD/TOPIC)", ); return null; } if (!fs.existsSync(p12Path)) { console.warn(`[voip-push] disabled — p12 file not found at ${p12Path}`); return null; } try { // Dynamic import — vermeidet bundler statisches Tracing. const mod = (await import("@parse/node-apn")) as unknown as ApnModule & { default?: ApnModule; }; apnMod = mod.default ?? mod; topic = tpc; provider = new apnMod.Provider({ pfx: p12Path, passphrase: p12Pass, production, }); console.log(`[voip-push] initialized (topic=${tpc}, production=${production})`); return provider; } catch (err) { console.error("[voip-push] init failed:", err); return null; } } export interface VoIPCallPayload { /** VoIP-PushKit-Token des Empfänger-Devices (64-char hex, NICHT der Expo-Token) */ voipToken: string; /** Eindeutige Call-ID (matcht callIdToUuid() im Client) */ callId: string; /** Display-Name für CallKit-UI */ callerName: string; /** User-ID des Anrufers — Client braucht das um peer-Profile zu fetchen */ callerId: string; callerNickname: string; callerAvatar: string | null; } /** * Sendet einen VoIP-Push an genau ein iOS-Device. * * Apple-Vorgaben (verifiziert via developer.apple.com/documentation/pushkit): * - apns-push-type: voip * - apns-topic: bundle-id + ".voip" (NICHT der reguläre bundle-id) * - apns-priority: 10 * - apns-expiration: 0 (kein Storage — wenn Device offline, verworfen) * * @returns true bei Erfolg (oder no-op wenn Service disabled), false bei Fehler. */ export async function sendVoIPPush(payload: VoIPCallPayload): Promise { const p = await getProvider(); if (!p || !topic || !apnMod) return true; // no-op, regulärer Push übernimmt const note: ApnNotification = new apnMod.Notification(); note.topic = topic; note.expiry = 0; // sofort verwerfen wenn Device unreachable note.priority = 10; note.pushType = "voip"; note.payload = { type: "call", callId: payload.callId, callerName: payload.callerName, from: { id: payload.callerId, nickname: payload.callerNickname, avatar: payload.callerAvatar, }, }; try { const result = await p.send(note, payload.voipToken); if (result.failed.length > 0) { const f = result.failed[0]; console.warn( `[voip-push] failed token=${payload.voipToken.slice(0, 8)}… reason=`, f.response ?? f.error, ); return false; } return true; } catch (err) { console.error("[voip-push] send threw:", err); return false; } } /** Cleanup bei Server-Shutdown — wichtig wegen HTTP/2-Verbindung an Apple. */ export function shutdownVoIPProvider(): void { if (provider) { provider.shutdown(); provider = null; initialized = false; } }