/** * 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 providerProd: ApnProvider | null = null; let providerSandbox: ApnProvider | null = null; let initialized = false; let topic: string | null = null; /** Token → "prod" | "sandbox" Memoization. Vermeidet 2-Round-Trip pro Push. */ const tokenEnvCache = new Map(); async function ensureInit(): Promise { if (initialized) return providerProd !== null || providerSandbox !== null; 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; if (!p12Path || !p12Pass || !tpc) { console.warn( "[voip-push] disabled — missing env (APNS_VOIP_P12_PATH/PASSWORD/TOPIC)", ); return false; } if (!fs.existsSync(p12Path)) { console.warn(`[voip-push] disabled — p12 file not found at ${p12Path}`); return false; } try { const mod = (await import("@parse/node-apn")) as unknown as ApnModule & { default?: ApnModule; }; apnMod = mod.default ?? mod; topic = tpc; // VoIP-Services-Cert ist Universal — wir bauen BEIDE Provider (Production + // Sandbox) und wählen pro Token via Memoization. So funktioniert es für // Xcode-Dev-Builds (Sandbox) UND TestFlight/AppStore (Production) parallel. providerProd = new apnMod.Provider({ pfx: p12Path, passphrase: p12Pass, production: true, }); providerSandbox = new apnMod.Provider({ pfx: p12Path, passphrase: p12Pass, production: false, }); console.log(`[voip-push] initialized (topic=${tpc}, prod+sandbox providers ready)`); return true; } catch (err) { console.error("[voip-push] init failed:", err); return false; } } 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 ok = await ensureInit(); if (!ok || !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, }, }; // Reihenfolge: zuerst memoized Env (falls bekannt), sonst Production first → // Sandbox als Fallback. const cached = tokenEnvCache.get(payload.voipToken); const order: Array<"prod" | "sandbox"> = cached === "sandbox" ? ["sandbox", "prod"] : ["prod", "sandbox"]; for (const env of order) { const prov = env === "prod" ? providerProd : providerSandbox; if (!prov) continue; try { const result = await prov.send(note, payload.voipToken); if (result.failed.length === 0) { tokenEnvCache.set(payload.voipToken, env); console.log(`[voip-push] sent token=${payload.voipToken.slice(0, 8)}… env=${env} callId=${payload.callId}`); return true; } const f = result.failed[0]; const reason = (f.response as { reason?: string })?.reason; // BadDeviceToken → nur dann Failover, sonst Abbruch (z.B. Unregistered). if (reason === "BadDeviceToken") { if (env === order[order.length - 1]) { console.warn( `[voip-push] failed token=${payload.voipToken.slice(0, 8)}… BadDeviceToken on both envs`, ); return false; } // try next env continue; } console.warn( `[voip-push] failed token=${payload.voipToken.slice(0, 8)}… env=${env} reason=`, f.response ?? f.error, ); return false; } catch (err) { console.error("[voip-push] send threw:", err); return false; } } return false; } /** Cleanup bei Server-Shutdown — wichtig wegen HTTP/2-Verbindung an Apple. */ export function shutdownVoIPProvider(): void { if (providerProd) { providerProd.shutdown(); providerProd = null; } if (providerSandbox) { providerSandbox.shutdown(); providerSandbox = null; } initialized = false; }