From fd2eb42e25954589472aaf0c9d22d8b8e0c04ca0 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 28 Jul 2026 20:57:56 +0530 Subject: [PATCH] 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 .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 --- server/calls.js | 19 ++++++++-------- server/push.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++- server/routes.js | 3 ++- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/server/calls.js b/server/calls.js index 7540b84..9fbd68d 100644 --- a/server/calls.js +++ b/server/calls.js @@ -2,6 +2,7 @@ // ends (with a duration line in the chat) when the last participant's mesh room empties. const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const R = require('./repos'); const A = require('./auth'); const CHAT = require('./chat'); @@ -65,16 +66,16 @@ async function startGroupCall(group, teamId, user) { if (existing) return { room: existing.room, active: true, already: true }; let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); meetingRooms.set(room, new Map()); - const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email }; + const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email }; // Log the call as a meeting so it appears under Past meetings (history) with the group name. try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {} groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {}); let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName }); - // Native push to the OTHER members so a closed app (no live WS) is notified of the group call. The - // broadcast() above only reaches connected sockets. Best-effort; sendToUser never throws. - try { for (const mid of await R.conversations.members(group)) { if (mid !== user.id) PUSH.sendToUser(mid, { title: gName, body: '📞 ' + call.startedByName + ' started a group call', kind: 'group', id: group, room, tag: 'call:' + room, data: { kind: 'group', id: group, room } }); } } catch (_) {} + // Notify the OTHER members so a closed app is alerted to the group call — VoIP/CallKit if available, + // else a banner. broadcast() above only reaches connected sockets. Best-effort; never throws. + try { for (const mid of await R.conversations.members(group)) { if (mid !== user.id) PUSH.sendCallNotification(mid, { callUUID: call.uuid, room, kind: 'group', groupId: group, groupName: gName, callerId: user.id, callerName: call.startedByName, title: gName, body: '📞 ' + call.startedByName + ' started a group call', hasVideo: true }); } } catch (_) {} return { room, active: true }; } @@ -99,7 +100,7 @@ async function startDmCall(me, otherId, teamId) { let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); meetingRooms.set(room, new Map()); const byName = me.name || me.email; - const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false }; + const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false }; // Log to history (both participants) so the call shows under Past meetings with its transcript. try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {} dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host @@ -119,10 +120,10 @@ async function startDmCall(me, otherId, teamId) { try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {} try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {} try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {} - // Native push so a CLOSED app (no live WS) still rings — the CHAT.pushToUser events above only reach a - // connected socket. Best-effort. On reconnect the callee's app re-shows this invite (replayActiveCalls), - // so tapping the banner into the app lets them answer while the caller is still ringing. - try { PUSH.sendToUser(otherId, { title: byName, body: '📞 Incoming call', kind: 'dm', id: me.id, room, tag: 'call:' + room, icon: me.avatar_url || undefined, data: { kind: 'dm', id: me.id, room } }); } catch (_) {} + // Notify the callee so a CLOSED app still rings — VoIP/CallKit if the device registered a VoIP token, + // else a banner (the CHAT.pushToUser events above only reach a connected socket). Best-effort. On + // reconnect the callee's app also re-shows the invite (replayActiveCalls), so answering works either way. + try { PUSH.sendCallNotification(otherId, { callUUID: call.uuid, room, kind: 'dm', callerId: me.id, callerName: byName, title: byName, body: '📞 Incoming call', hasVideo: false }); } catch (_) {} return { room, active: true }; } diff --git a/server/push.js b/server/push.js index 4a65c10..f2156b9 100644 --- a/server/push.js +++ b/server/push.js @@ -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 '.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 }; diff --git a/server/routes.js b/server/routes.js index 510dbee..6f7661c 100644 --- a/server/routes.js +++ b/server/routes.js @@ -412,7 +412,8 @@ route('POST', '/api/devices', async (req, res) => { if (!u) return json(res, 401, { error: 'unauthorized' }); const { platform, token } = await readBody(req); if (!token || typeof token !== 'string') return json(res, 400, { error: 'token required' }); - if (platform !== 'ios' && platform !== 'android') return json(res, 400, { error: 'platform must be ios or android' }); + // 'ios-voip' = a PushKit VoIP token (CallKit wake), stored alongside the normal alert token. + if (platform !== 'ios' && platform !== 'android' && platform !== 'ios-voip') return json(res, 400, { error: 'platform must be ios, android or ios-voip' }); try { await R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {} json(res, 200, { ok: true }); });