feat(calls): server foundation for native CallKit/VoIP calling (phase A)

First slice of the native call feature. Backward-compatible: with no VoIP tokens
registered yet it behaves exactly like today's banner push.

- push.js: sendApnsVoip() sends a PushKit VoIP push (apns-push-type 'voip', topic
  <bundle>.voip, reusing the same .p8) to wake a killed app for CallKit; and
  sendCallNotification() which PREFERS a VoIP push when the user has an 'ios-voip'
  token, else falls back to the normal alert/banner push (Android/web/pre-CallKit iOS).
- routes.js: /api/devices now accepts platform 'ios-voip' (the PushKit token, stored
  alongside the normal alert token in device_tokens).
- calls.js: each call now carries a stable crypto.randomUUID() (CallKit needs a UUID
  to report + later cancel the call); DM and group call notifications route through
  PUSH.sendCallNotification instead of the raw banner push.

Next: the native-call Capacitor plugin (PushKit + CallKit + LiveKit iOS SDK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 20:57:56 +05:30
parent 37e58f6087
commit fd2eb42e25
3 changed files with 68 additions and 11 deletions
+56 -1
View File
@@ -101,6 +101,61 @@ function sendApns(token, payload) {
});
}
// ---------------- VoIP push (PushKit → CallKit), iOS ----------------
// Wakes the app even when force-killed so it can report an incoming call to CallKit. Uses the SAME
// token-based APNs auth (.p8) as alert pushes, but with apns-push-type 'voip' and the '<bundle>.voip'
// topic. The payload is arbitrary call data delivered to the app's PushKit handler (no aps alert). The
// destination is a PushKit "VoIP token" (distinct from the alert APNs token) that the native plugin
// registers as device_tokens.platform = 'ios-voip'.
function sendApnsVoip(token, data) {
return new Promise((resolve) => {
if (!apnsCfg) return resolve({});
let client;
try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
const body = JSON.stringify({ aps: {}, ...(data || {}) });
const req = client.request({
':method': 'POST', ':path': '/3/device/' + token,
authorization: 'bearer ' + apnsToken(),
'apns-topic': apnsCfg.bundle + '.voip', 'apns-push-type': 'voip',
'apns-priority': '10', 'apns-expiration': '0', // ring now or drop — never store a stale call
});
let status = 0;
req.on('response', (h) => { status = h[':status']; });
req.on('data', () => {});
req.on('end', () => { try { client.close(); } catch (_) {} resolve({ ok: status >= 200 && status < 300, dead: status === 410 }); });
req.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
req.end(body);
});
}
// Notify a user of an INCOMING CALL. Prefers a VoIP push (→ CallKit native ring, works when the app is
// killed) if the user has a registered 'ios-voip' token; otherwise falls back to a normal alert push
// (banner + ring sound) so Android / web / iOS-without-the-CallKit-build still get notified. So this is
// backward-compatible: with no VoIP tokens registered yet, it behaves exactly like the old call push.
async function sendCallNotification(userId, data) {
// data: { callUUID, room, kind, callerId, callerName, groupId, groupName, title, body, hasVideo }
let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
const voip = toks.filter((t) => t.platform === 'ios-voip');
if (voip.length && apnsCfg) {
const payload = {
callUUID: data.callUUID, room: data.room, kind: data.kind,
callerId: data.callerId || '', callerName: data.callerName || 'Incoming call',
groupId: data.groupId || '', groupName: data.groupName || '', hasVideo: !!data.hasVideo,
};
for (const t of voip) {
try { const r = await sendApnsVoip(t.token, payload); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
}
return 'voip';
}
await sendToUser(userId, {
title: data.title, body: data.body, kind: data.kind, id: data.callerId || data.groupId,
room: data.room, tag: 'call:' + data.room, data: { kind: data.kind, id: data.callerId || data.groupId, room: data.room, call: 1 },
});
return 'push';
}
// ---------------- public API ----------------
const nativeReady = !!(fcmSA || apnsCfg);
const enabled = [webReady && 'WebPush', fcmSA && 'FCM', apnsCfg && 'APNs'].filter(Boolean);
@@ -136,4 +191,4 @@ async function sendToUser(userId, payload) {
}
}
module.exports = { isEnabled, publicKey, sendToUser };
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification };