- voip-push: build both APNs Provider (production+sandbox) and try each per token with memoization. Fixes BadDeviceToken on Xcode-Dev-Builds where the token is Sandbox-only. - stores/call: only call callkit.displayIncomingCall when app NOT in foreground \u2014 in foreground the /call route handles ringing UI, otherwise double UI (system banner + fullscreen). - patch react-native-callkeep: New-Arch TurboModule compatibility (no overloads, no Bundle params in @ReactMethod). - pushTokenRegistration: more verbose [voip] diagnostics.
181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
/**
|
|
* 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<ApnModule["Provider"]>;
|
|
type ApnNotification = InstanceType<ApnModule["Notification"]>;
|
|
|
|
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<string, "prod" | "sandbox">();
|
|
|
|
async function ensureInit(): Promise<boolean> {
|
|
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<boolean> {
|
|
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);
|
|
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;
|
|
}
|