/** * 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 apn from "@parse/node-apn"; import fs from "node:fs"; let provider: apn.Provider | null = null; let initialized = false; let topic: string | null = null; function getProvider(): apn.Provider | null { 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 { topic = tpc; provider = new apn.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 = getProvider(); if (!p || !topic) return true; // no-op, regulärer Push übernimmt const note = new apn.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; } }