Compare commits

...

47 Commits

Author SHA1 Message Date
Sravan b68ba94d2a SPM: wire local plugins correctly — add Package.swift to each plugin's files array
Root cause of the SPM runtime regressions (call/chat push when closed, photo save):
our local file: plugins didn't function because their package.json `files` array
omitted "Package.swift", so npm can drop it from node_modules on install → cap sync
can't wire the plugin into the CapApp-SPM package → the plugin isn't loaded at
runtime. Official plugins all list Package.swift in files; ours now do too.

Also add build-log diagnostics (node_modules symlink/copy + Package.swift presence,
and the generated CapApp-SPM/Package.swift) so the wiring is provable, not guessed.

Reapplies the SPM migration (Step B) that was reverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:56:13 +05:30
Sravan 7f54182186 Reapply "Capacitor 8 — Step B: migrate iOS from CocoaPods to Swift Package Manager"
This reverts commit 166bea4314.
2026-08-03 15:52:46 +05:30
Sravan 166bea4314 Revert "Capacitor 8 — Step B: migrate iOS from CocoaPods to Swift Package Manager"
This reverts commit 92f1f8f1a3.
2026-08-03 15:43:23 +05:30
Sravan 92f1f8f1a3 Capacitor 8 — Step B: migrate iOS from CocoaPods to Swift Package Manager
Move off CocoaPods (sunsetting; trunk goes read-only Dec 2026) to SPM, the Cap 8
default. This also ends the LiveKit git-pin hack — LiveKit becomes a real SPM dep.

- Each local plugin gets a Package.swift (swift-tools 5.9, iOS 15, capacitor-swift-pm
  from 8.0.0, source path ios/Sources/<Plugin>). native-call additionally declares
  LiveKit: .package(client-sdk-swift, exact 2.15.3) + product "LiveKit".
- native-call Swift: import LiveKitClient -> import LiveKit (SPM product name).
- codemagic.yaml: `cap add ios --packagemanager SPM`; removed the "Install CocoaPods"
  (pod install) step; XCODE_WORKSPACE -> XCODE_PROJECT (App.xcodeproj); build-ipa
  --workspace -> --project. Xcode resolves the Swift packages during archive.
- ios-patch.sh: removed the Podfile LiveKit git-pin injection + Podfile.lock deletion
  (no Podfile under SPM). Info.plist/entitlements/AppDelegate/notif.wav patches stay.
- add-share-extension.rb is SPM-safe (operates on App.xcodeproj, no workspace/Pods refs).

Authored blind (no local Xcode) — expect build iterations on the first SPM build
(cap-add SPM layout, CapApp-SPM plugin wiring, LiveKit SPM resolution, signing).
Fully revertible: git revert -> back to Cap 8 + CocoaPods (working).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 11:48:45 +05:30
Sravan 9d0fd93d7e Fix outgoing-call re-ring: never send the cancel push to the caller
Hanging up an UNANSWERED outgoing call re-rang the caller's own phone: the server
sent the "cancel" VoIP push to BOTH parties, and the plugin must reportNewIncomingCall
for every VoIP push (iOS rule) → a phantom ring on the caller who just hung up.
Incoming calls don't hit this (an answered call sends no cancel). Fix: DM cancel goes
only to the callee (not startedBy); group cancel skips the starter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 11:38:53 +05:30
Sravan 29106370fd Capacitor 8 upgrade — Step A (keep CocoaPods)
Bump to Capacitor 8 while staying on CocoaPods (decouples the major-version bump
from the SPM switch, per the verified plan). Changes:
- mobile/package.json: all @capacitor/* + @capacitor-community/safe-area + cli → ^8.0.0
  (safe-area 8.0.1 verified Cap 8 compatible; @capacitor/assets stays 3.x, version-agnostic).
- 4 local plugins: @capacitor/core peer/dev dep → ^8.0.0; podspec ios deployment_target 14→15.
- codemagic.yaml: node 20→22 (Cap 8 requires Node 22+); `cap add ios --packagemanager Cocoapods`
  (Cap 8 defaults to SPM — force CocoaPods); fail loudly if no Podfile is generated.
- add-share-extension.rb: fallback deployment target 14→15.
Requirements per the Cap 7→8 guide: Node 22+, Xcode 26+, iOS 15 min. capacitor.config has no
adjustMarginsForEdgeToEdge to remove. LiveKit git-pins + native calling unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 18:48:57 +05:30
Sravan fafc480904 Fix crash after multiple calls: always reportNewIncomingCall for every VoIP push
iOS 13+ terminates the app if a PushKit VoIP push doesn't call reportNewIncomingCall
before completion(). My earlier "no re-ring blip" change made the cancel handler SKIP
the report for already-known/ended calls — which is exactly what iOS kills the app
for, surfacing as a crash after several calls (a cancel push for a prior call's UUID).
Revert to ALWAYS report then immediately end: for a still-active/ringing UUID the
report errors harmlessly (no second ring) and reportCall ends it; only a late duplicate
cancel shows a brief, unavoidable blip. A crash is far worse than a blip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 13:09:25 +05:30
Sravan f4dbbde558 Native calls: route audio to the speaker by default (was earpiece)
The .voiceChat AVAudioSession mode defaults to the earpiece, so call audio came
out of the earpiece and only settled after mute/unmute toggling. Switch to
.videoChat + .defaultToSpeaker + allow Bluetooth so audio goes to the loudspeaker
by default while wired/BT headsets still win. Add preferSpeaker() (override to
speaker when on the built-in receiver) in didActivate AND after mic toggles (the
route can flip back to earpiece on unmute). Report the chosen route in telemetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:50:40 +05:30
Sravan 8c737372de Fix pod install: git-pin LiveKitWebRTC 144.7559.11 too (last SPM-only binary)
LiveKitClient + LiveKitUniFFI now pre-download OK; the last unresolved dep is
LiveKitWebRTC (= 144.7559.11), also SPM-only (CDN only has 125.x). Its repo ships
a podspec at that tag with NO further deps, but its source is an :http release-zip
(not in the git tree), so we pin via :podspec => <podspec URL> (not :git, which
would clone a repo with no xcframework). SwiftProtobuf resolves from the CDN. This
should complete resolution for LiveKit 2.15.3 on CocoaPods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:16:59 +05:30
Sravan 21430bd590 Fix pod install: also git-pin LiveKitUniFFI 0.0.6 (LiveKit 2.15.3's SPM-only dep)
--repo-update still failed: "Unable to find a specification for LiveKitUniFFI
(= 0.0.6)". LiveKitUniFFI is genuinely NOT on the CocoaPods CDN (trunk returns
"No pod found") — it's SPM-only — but its repo ships a podspec at tag 0.0.6 with
no further deps (it just downloads its prebuilt XCFramework). So pin it to its
git tag too, alongside LiveKitClient. LiveKitWebRTC 144.x + SwiftProtobuf resolve
from the CDN. This should complete resolution and keep us on CocoaPods (no SPM
migration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 20:25:35 +05:30
Sravan 46cc2610b9 Fix pod install: --repo-update for LiveKit 2.15.3's transitive deps
Git pin now works (LiveKitClient 2.15.3 pre-downloaded), but the build box's
cached spec repo is stale: "Unable to find a specification for LiveKitUniFFI
(= 0.0.6)". Both LiveKitUniFFI 0.0.6 and LiveKitWebRTC 144.7559.11 are published
on the trunk, so --repo-update on the real pod install refreshes the repo and
resolves them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 20:14:57 +05:30
Sravan bcd5699e2a Fix pod install: drop the stale Podfile.lock that cap-sync's pod install created
The git-tag pin worked, but the build failed: "could not find compatible versions
… In snapshot (Podfile.lock): LiveKitClient (= 2.0.18) … In Podfile: LiveKitClient
(from git, tag 2.15.3)". Cause: `npx cap sync` runs `pod install` internally with
the PRE-pin Podfile, creating a Podfile.lock pinned to 2.0.18; our injected git-tag
source then conflicts with that lock. Fix: rm the Podfile.lock right after injecting
the pin so the real "Install CocoaPods" step re-resolves against tag 2.15.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 19:55:10 +05:30
Sravan 2c97f45245 Remove stray scratchpad probe files committed by mistake
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 19:47:36 +05:30
Sravan a92cdd69f8 Native calls: get LiveKit 2.15.3 via git-tag pin (stay on CocoaPods) + audio fix
The LiveKitClient CocoaPod on trunk caps at 2.0.18 (2.1+ is SPM-only), so the
2.15 CallKit audio API was unreachable. BUT the repo still ships a valid podspec
at tag 2.15.3, and its deps (LiveKitWebRTC 144.7559.11, LiveKitUniFFI 0.0.6,
SwiftProtobuf) ARE on trunk. So instead of a risky SPM migration:
- ios-patch.sh injects `pod 'LiveKitClient', :git => <repo>, :tag => '2.15.3'`
  into the generated Podfile (after cap sync, before pod install). The NativeCall
  podspec's '~> 2.0' is satisfied by 2.15.3. Idempotent; hard-fails if it can't
  find the App target so we never silently fall back to 2.0.18.
- Plugin re-adds the CallKit<->LiveKit audio-session coordination, now compilable:
  auto-config OFF + engine OFF at load; configure session + enable engine in
  didActivate; disable in didDeactivate; request mic permission on connect.

Core Room APIs (connect/disconnect/setMicrophone) verified compatible between
2.0.18 and 2.15.3 against the real source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 19:46:48 +05:30
Sravan c3de5ac8e3 Revert "Native calls: upgrade to LiveKit 2.15 + add CallKit audio-session coordination"
This reverts commit 455d16abcc.
2026-07-31 19:35:57 +05:30
Sravan 455d16abcc Native calls: upgrade to LiveKit 2.15 + add CallKit audio-session coordination
Root cause of the intermittent dead-mic / no-audio / late-speaker: LiveKit's
automatic AVAudioSession config races CallKit's activation. The fix needs the
2.15+ audio API, which the build wasn't getting ('~> 2.0' resolved an older 2.x
from a stale spec cache). So:
- NativeCall.podspec: pin LiveKitClient '~> 2.15'.
- codemagic.yaml: 'pod install --repo-update' so the spec repo knows 2.15.x.
- Plugin: disable LiveKit auto audio-session config + keep the engine OFF; in
  CXProvider didActivate set the session category and enable the engine; in
  didDeactivate disable it. Request mic permission on connect so enabling the
  engine in didActivate doesn't block on undetermined permission.

All AudioManager APIs verified against the raw 2.15.3 source (setEngineAvailability,
AudioEngineAvailability.default/.none, audioSession.isAutomaticConfigurationEnabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 19:21:39 +05:30
Sravan 155e89c6c0 Fix iOS build: remove LiveKit AudioManager usage (build resolves older 2.x)
Second failure showed 'AudioManager' has no member 'audioSession' either — even
though the 2.15.3 source has both audioSession and setEngineAvailability. So the
build's CocoaPods is resolving an OLDER 2.x (stale spec repo/cache) that predates
the engine-observer audio API. Rather than keep guessing, drop ALL LiveKit
AudioManager audio-session code and keep the last known-good audio behavior
(LiveKit defaults). The valuable fixes that use only CallKit/AVFoundation stay:
re-ring blip guard, CallKit<->UI mute sync, and the WS reportIncomingCall ring
path. Proper CallKit audio-session coordination is deferred until the pod is
pinned/upgraded to LiveKit 2.15+.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 18:36:20 +05:30
Sravan c472ee2618 Fix iOS build: use LiveKit 2.15.3 audio API (drop nonexistent setEngineAvailability)
The build failed: 'AudioManager' has no member 'setEngineAvailability' — that API
is only on unreleased/main docs, not in the resolved LiveKitClient 2.15.3. Use the
API that actually ships (per audio.md): disable LiveKit's automatic AVAudioSession
configuration (AudioManager.shared.audioSession.isAutomaticConfigurationEnabled =
false) and configure the session ourselves in CXProvider didActivate. Removed all
setEngineAvailability(.none/.default) calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 18:24:54 +05:30
Sravan 36edc6de12 Native calls: CallKit audio-session coordination, no re-ring blip, mute sync, WS ring path
Plugin (rides next build):
- Audio: disable LiveKit auto audio-session config + keep the engine OFF, then
  configure the session and start the engine ONLY in CXProvider didActivate
  (stop in didDeactivate). Fixes intermittent dead mic / no audio and the "speaker
  turns on late" routing. Request mic permission on connect so enabling the engine
  in didActivate can't block on undetermined permission (SDK #815).
- Re-ring blip: a cancel push for a call we already ended/known no longer reports
  a NEW incoming call (that was the phantom "rings back for a second"); it ends
  the known call cleanly, and only reports+ends for a truly unknown (cold) call.
- Mute display: answer/outgoing reflect muted-by-default on the CallKit screen;
  setMuted now drives mute THROUGH CallKit so the system screen and the in-app
  meeting UI stay in sync.
- reportIncomingCall: new method to ring CallKit from a WebSocket call event — a
  2nd path alongside the VoIP push for when the app is open (push can be delayed);
  deduped by UUID.

Web (deploys now; the WS ring path activates once the build has the new method):
- onDmCall/onGroupCall call nativeReportIncoming for native incoming calls.
- audioActivated telemetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 18:09:32 +05:30
Sravan 199c32a319 Native calls: answer MUTED by default (house rule)
Plugin connects the LiveKit room without enabling the mic (nothing captured/
published until the user taps Mic, which is also when iOS asks permission).
Web: meetMic starts false so the mic button shows muted; on callConnected the
web pushes the muted state to the plugin so builds whose plugin still connects
the mic live are muted too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:46:13 +05:30
Sravan c2b339e284 Native calls: open the REAL meeting window (join the mesh), drop the bespoke overlay
The custom call overlay was wrong — the native call must use the app's actual
meeting UI. Fix: a native call now JOINS the mesh room like any participant, so
the caller/callee tiles, roster, mute state and the whole answer/end lifecycle
run through the existing (tested) meeting code. The only native-specific bit is
meetNative=true → the WebView does NOT open its own SFU media connection (the
plugin already owns this identity's one LiveKit connection); mic/hang-up bridge
to the plugin. This fixes, via existing server code, all the reported bugs:
- "no meeting window" → the real meeting window opens on answer/outgoing.
- "caller stuck Ringing after pickup" → mesh peer-join clears the waiting tile
  and finishMeetingJoin marks the call answered.
- "call still running after the other side hung up" → mesh leave ends the DM
  for both (signaling leaveMeeting); plus an idempotent endDmCallByRoom backup
  kicks a stuck peer when the ending side's WebSocket is down.
- "accept on one device doesn't stop the other" → markDmAnswered (fired on mesh
  join) emits call-taken to the user's other sockets; deliverLocal fans to all.
- "second-device accept wins / collision" → the other device's ring is dismissed
  so it can't double-join the same identity.

home.html: enterMeeting(code, audioOnly, {native, uuid}); skip sfuConnect when
native; toggleMic->plugin; toggleCam blocked (video is the next phase); leave ->
callkitEnd (guarded against the plugin's endCall re-firing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 19:31:19 +05:30
Sravan 11f72b2592 Native calls: fix answered-call drop, multi-device ring, add in-app call screen
Root cause of "answered but the call disconnects": native calls carry media
over LiveKit and bypass the mesh, so the server only learned "answered" from a
WebView POST. On a cold/locked answer the app is still launching and that event
was lost, so the 40s unanswered timer fired and cancelled the live call. Fix:
- Plugin: notifyListeners("answerCall", retainUntilConsumed:true) so a killed/
  locked pickup isn't lost before the WebView JS attaches.
- Server markDmAnswered: emit call-taken to the callee's OTHER devices (stop the
  ring; no teardown) and call-answered to the caller (flip UI to connected).
- Server declineDmCall: ignore a decline once the call is answered, so dismissing
  a stale ring on a second device can't kill the live call.

Also adds a UI-only in-app call screen for native calls (caller + callee):
avatar, name, live timer, mute (-> plugin), end (-> CallKit). Native media has
no meeting window of its own; this covers "no meeting window / can't unmute".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:23:21 +05:30
Sravan 82c6c9ce0e fix(calls): no duplicate native tile; drop Ringing placeholder; name+DP from roster
Caller saw the native callee TWICE — the outgoing 'Ringing…' placeholder (never cleared,
since a native callee doesn't send the mesh 'answered') plus a separate id-labelled tile.
Now when a native LiveKit participant joins: remove the __waiting placeholder, stop
ringback, and label the tile from CONTACTS (name + DP) instead of the raw user id.

Also (plugin, needs build): connectRoom disconnects any previous LiveKit connection
before joining, so repeated calls never leave duplicate/stale participants in the room.

WebView fix deploys now (no rebuild); the connectRoom fix rides the next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:51:46 +05:30
Sravan 3d35a9ece7 fix(calls): play native LiveKit participants in the WebView meeting (inc 1 interop)
Native (CallKit+LiveKit) participants join the LiveKit room but not our WS mesh, so
peerIdForUid() had no mapping and sfuAttach dropped their track — the web caller stayed
on 'waiting' and never heard the native callee. Now sfuAttach/sfuDetach fall back to
keying the tile+audio by the LiveKit identity ('lk:'+id) when there's no mesh peer, and
stop the ringback. So a native<->web call crosses audio. Served — no rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:39:34 +05:30
Sravan 2d3ab3dbd3 fix(calls): only cancel-push unanswered calls; add native-call telemetry
- Re-ring on disconnect: sendCallCancel now only fires for UNANSWERED calls. An answered
  call ends via the WS event on both (awake) sides; a cancel push was re-ringing the
  device that just hung up.
- Native-call telemetry (temporary): the plugin fires callConnected/callError on its
  LiveKit connection; the WebView reports nc-answer/nc-connected/nc-error/nc-end/
  nc-outgoing to /api/push-debug so we can see from server logs whether the native room
  actually connects (no device console available). Served — no rebuild needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:20:32 +05:30
Sravan 3ac5b6e7bd fix(ios): import LiveKitClient (the CocoaPod module name), not LiveKit
Build error 'unable to resolve module dependency: LiveKit' — the LiveKitClient pod
sets no module_name, so CocoaPods names the module after the pod (LiveKitClient). The
SDK compiled/linked fine; only the import statement was wrong. Types (Room, etc.)
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:47:39 +05:30
Sravan e60e80e4bd feat(calls): native-call lifecycle endpoints + WebView steps aside (inc 1)
Complete the WebView/server side of native LiveKit calls:
- routes.js: /api/calls/answered (markDmAnswered) + /api/calls/end (endCallByRoom) so
  the server learns a NATIVE call was answered/ended (native media runs over LiveKit,
  bypassing our mesh/WS lifecycle). Additive no-ops for WebView/mesh calls.
- home.html: for native calls the WebView no longer joins the room (one connection per
  identity — the plugin holds it). answerCall -> POST /api/calls/answered + clear invite;
  endCall -> /api/calls/end (answered) or /api/calls/decline (still ringing). Outgoing
  DM/group calls fetch a LiveKit token and hand it to NativeCall.reportOutgoingCall
  instead of enterMeeting. Removed the old callHandoff mic-repush.

Server deploys now; the plugin (native LiveKit) needs a Codemagic build. Still gated by
CALLKIT_ENABLED=0 — flip to 1 only after the build is installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:32:13 +05:30
Sravan 3e8aaad68c feat(ios): native LiveKit connection in the call plugin (inc 1, WIP)
The plugin now carries the call media NATIVELY: on answer it connects the LiveKit Room
from the VoIP payload's url+token and publishes the mic; outgoing calls connect via
reportOutgoingCall(url,token). CallKit stays ACTIVE for the whole call (foregrounds the
app, keeps it alive) — the mic works because LiveKit's audio runs natively and
coordinates with CallKit (unlike WebKit's WebRTC). setMuted -> setMicrophone; end ->
disconnect. Removed the handoff hack.

NOT testable yet: still need (a) WebView to stop joining the room for native calls (one
connection per identity) and drive outgoing via reportOutgoingCall, and (b) server
lifecycle endpoints for native calls (answered/ended), since native media bypasses our
mesh/WS signaling. LiveKit Swift API authored without a local compile — expect a build
iteration or two. Don't build/flip CALLKIT_ENABLED yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:15:43 +05:30
Sravan aae663ccea feat(calls): mint a LiveKit join token into the native VoIP call payload (inc 1)
Increment 1 server side. Move livekitToken() into server/livekit.js (shared by routes.js
and calls.js). calls.js now mints a per-callee LiveKit join token and calls.js/push.js
put {livekitUrl, livekitToken} in the VoIP invite payload, so the native plugin can
connect the LiveKit room immediately on answer — even from a killed state, before the
WebView loads. No behaviour change while CALLKIT_ENABLED=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:11:21 +05:30
Sravan c4ffe2a4e9 build(ios): add LiveKitClient pod to native-call (native call media)
Foundation for native LiveKit calling: the LiveKit iOS SDK carries the call media
natively and coordinates its audio engine with CallKit's AVAudioSession (AudioManager
.setEngineAvailability on didActivate/didDeactivate) — the thing WebKit's WebRTC can't
do. With this, the CallKit call stays active (stable ring + lock-screen answer +
background) AND the mic works. Native connection code + server LiveKit token in the VoIP
payload + WebView coordination come next. Don't build yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:03:01 +05:30
Sravan e09d17f1a7 fix(ios): stop VoIP-push crash; hand mic to WebView after answer; missed-call banner
CRASH: iOS terminates an app that receives a VoIP push without calling
reportNewIncomingCall. My re-ring 'fix' made the cancel path call completion() without
reporting -> crash. Always report then immediately end on cancel (a tiny ring blip is
unavoidable; a crash is worse).

MIC + earpiece: an ACTIVE CallKit call reserves the mic (WebView WebRTC gets a dead mic
+ earpiece routing). So on answer keep CallKit active only long enough to foreground the
app, then end it and fire 'callHandoff'; the WebView forces the loudspeaker and
re-acquires the mic (sfuSetMic off/on, retried while it finishes joining).

MISSED CALL: endDmCallByRoom now sends a plain missed-call banner to the callee when the
call ends unanswered (timeout / caller hung up before pickup); skipped on decline.

Server (missed banner) deploys now; plugin + web handoff need a Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 22:48:24 +05:30
Sravan 9dc253143e fix(ios): keep CallKit call active on answer so the app foregrounds
Answering did not open the app: ending the CallKit call immediately (to free the mic)
made iOS cancel the app launch before it foregrounded. Keep the call ACTIVE on answer
— fulfilling an active-call answer is what foregrounds/unlocks the app — and fire the
answerCall event so the WebView joins. didActivate stays a no-op (don't fight WebKit's
mic). The CallKit call is ended later when the WebView call ends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 20:44:09 +05:30
Sravan 6096b62368 feat(calls): CallKit ring-only + WebView media (background audio via voip mode)
Pivot away from the native-LiveKit rewrite. Discovery: the 'voip' UIBackgroundMode
already keeps the WebView's call audio alive when backgrounded (confirmed on device),
so background audio is solved WITHOUT native media. The only issue was CallKit
reserving the mic. So use CallKit purely for the incoming RING:

- Plugin CXAnswerCallAction: fulfill, then immediately end the CallKit call
  (reportCall endedAt) to RELEASE the mic, and fire answerCall to the WebView after a
  ~1s beat so iOS tears down the CallKit audio session first. didActivate no longer
  reconfigures the session (was fighting WebKit).
- home.html: outgoing calls no longer register with CallKit (WebView-only → mic works);
  incoming still rings via CallKit → hands off to the WebView on answer.

Net: native full-screen ring + working mic + background audio + all existing call
features. Needs a Codemagic build; then flip CALLKIT_ENABLED=1 to test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:25:59 +05:30
Sravan e7c3231c50 feat(calls): CallKit kill-switch + fix ~1s re-ring on cancel
MIC BROKEN with CallKit: a CallKit call reserves the microphone, so the WebView's
WebRTC can't capture it — calls are unusable until native LiveKit media lands. Add a
server kill-switch (CALLKIT_ENABLED, default OFF) so CallKit can be flipped without an
app rebuild: /api/meetings/config now returns callkit; setupNativeCall bails when off
(-> WebView calls, mic works); push.js only sends VoIP/CallKit pushes when enabled.
Deploying with the flag unset immediately restores working WebView calls.

RE-RING: a late cancel push for an already-declined call hit the plugin's 'unknown
uuid' path and re-reported a fresh incoming call (~1s re-ring). Track endedCalls and
make a late cancel for an already-ended call a no-op.

Server part deploys now (no rebuild); plugin re-ring fix ships with the native build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 17:54:21 +05:30
Sravan 3e21aa30d7 fix(calls): cancel the CallKit ring when the caller ends before answer
Bug: a killed/backgrounded callee is woken only for the CallKit ring and has no
WebSocket yet, so the existing dm-call active:false (WS-only) never reaches it and
it keeps ringing after the caller hangs up.

Fix: send a 'cancel' VoIP push on call teardown.
- push.js: sendCallCancel() sends a {type:'cancel',callUUID} VoIP push to the user's
  ios-voip tokens; invites now carry type:'invite'.
- calls.js: endDmCallByRoom + endGroupCallByRoom fire sendCallCancel to the rung users.
- NativeCallPlugin: on a cancel push, end the reported call (reportCall endedAt); if the
  invite was never seen, report-then-end to satisfy iOS's 'report a call per VoIP push'.

Server part deploys now; the plugin part needs the next Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 11:09:07 +05:30
Sravan cd7ab74eed fix(ios): NativeCallPlugin Swift compile errors
- didReceiveIncomingPush: normalise payload.dictionaryPayload ([AnyHashable:Any])
  to [String:Any] before storing/using (the reported build error at :121).
- CXProviderConfiguration(localizedName:) instead of the no-arg init (available on
  all deployment targets, avoids an availability edge).
- Make two 'calls[uuid] ?? [:]' bindings explicitly [String:Any] to avoid empty-
  literal inference ambiguity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 23:03:01 +05:30
Sravan f63aba0ed1 feat(ios): native CallKit + PushKit VoIP calling plugin + web bridge
The native call feature (iOS). Backward-compatible: without the plugin (current
builds) nativeCallOn() is false and every CallKit branch is skipped, so web/older
builds behave exactly as before.

Native (mobile/plugins/native-call, a local Capacitor plugin like audio-route):
- PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices
  'ios-voip'). On an incoming VoIP push, reports a CallKit incoming call (full-screen
  ring, works when the app is force-killed).
- CallKit: answer/decline/end -> events to JS; configures the call AVAudioSession on
  didActivate so the WebView's WebRTC audio rides a call-priority session (background).
- Outgoing calls register with CallKit too (reportOutgoingCall) so they get the same
  active-call background-audio context.
- NativeCall.podspec (frameworks CallKit/PushKit/AVFoundation); added to mobile deps;
  ios-patch.sh now sets UIBackgroundModes = [audio, voip] (voip required for PushKit).

Web bridge (home.html): setupNativeCall() registers the VoIP token, joins on CallKit
answer, leaves/declines on CallKit end; on CallKit devices the in-app call-invite popup
+ WebAudio ring are suppressed (the system rings instead); outgoing calls are reported
to CallKit; call-end events dismiss the CallKit call. calls.js threads a stable call
uuid through the dm-call/group-call WS events + start responses so both sides can match
the CallKit call.

Needs a Codemagic build to compile the plugin; first on-device iteration expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 22:34:27 +05:30
Sravan fd2eb42e25 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>
2026-07-28 20:57:56 +05:30
Sravan 37e58f6087 feat(push): bundle a custom notification sound (notif.wav) for chat + calls
Notifications were silent despite the payload requesting sound:'default' and correct
device settings. Ship an explicit tone: a generated PCM .wav is bundled into the app
(ios-patch.sh copies it in; add-share-extension.rb adds it to the App target's Copy
Bundle Resources, tolerantly) and the server now sends sound:'notif.wav'. Part of the
consolidated iOS build alongside the background-audio + call-push + AppDelegate fixes.

Only affects iOS-native-app tokens (currently just the one test device); web push
ignores the field. Needs a fresh Codemagic build for the bundled file to exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 23:03:45 +05:30
Sravan 5a1ce5fdba fix(ios): declare background audio so calls survive minimising the app
Capacitor/WebView apps get suspended by iOS a few seconds after backgrounding, which
freezes the WebRTC mic + audio pipeline — so when the user minimised the app or locked
the phone during a call, no one could hear anyone. Add UIBackgroundModes=[audio] to
Info.plist (via ios-patch.sh) so iOS keeps the audio session (and the app) alive while
a call is actively playing/recording. Video rendering still pauses in the background
(unavoidable in a WebView) but voice continues. Needs a fresh Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 22:24:39 +05:30
Sravan a0b3936799 fix(calls): send native push for incoming calls + replay on reconnect
Calls only notified over the chat WebSocket (CHAT.pushToUser), so a CLOSED app
(no live socket) never rang — unlike messages, which also call PUSH.sendToUser.
Add PUSH.sendToUser for both DM (startDmCall -> callee) and group (startGroupCall
-> other members) so APNs/FCM/WebPush alerts a closed device.

To make the alert actionable, add CALLS.replayActiveCalls(userId, ws), invoked
from the chat-hello handler: when a socket (re)connects, re-send any dm-call /
group-call the user is currently being rung into (the original events fire once at
call start and are missed by an app that was closed). Opening the app from the push
then re-surfaces the invite so they can answer within the ring window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 22:13:52 +05:30
Sravan 1920258fd6 fix(ios): forward APNs device token from AppDelegate to Capacitor
ROOT CAUSE of iOS push not working: Capacitor 7's default AppDelegate.swift
template does NOT implement application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
or ...didFailToRegisterForRemoteNotificationsWithError:. So when @capacitor/push-
notifications calls registerForRemoteNotifications(), iOS fetches the APNs token
and calls the AppDelegate, but nothing posts .capacitorDidRegisterForRemoteNotifications,
so the plugin never delivers the token to JS. register() 'succeeds' yet neither the
registration nor registrationError event fires — proven by server push telemetry
(register-called logged; no token, no error; device_tokens stayed empty).

inject-push.js adds the two forwarding methods to the CI-generated AppDelegate
(idempotent, tolerant — never fails the build), wired into ios-patch.sh after the
audio patch. Verified against the real Capacitor 7 template: methods land inside
the class, braces balance, both listeners present.

This is the missing piece alongside the earlier aps-environment entitlement fix and
the server APNs config. Needs a fresh Codemagic build to take effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 21:27:25 +05:30
Sravan c6523a8461 debug(push): add native push-setup telemetry to trace iOS registration
device_tokens stays empty after reinstall+Allow, so the APNs token is never
obtained or never reaches the server, and there's no device console on Windows.
Add a /api/push-debug collector and breadcrumbs through setupNativePush (plugin
presence, permission state, register call, registration event/error, token POST
result) so the failing step is visible in server logs. Temporary — remove once
push is confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 21:20:03 +05:30
Sravan 83b445e32d fix(ios): inject aps-environment entitlement so APNs push registration works
The @capacitor/push-notifications plugin does not add the Push Notifications
capability to the CI-generated Xcode project (that's a manual Xcode step), and
add-share-extension.rb only merged the App Group into App.entitlements, assuming
aps-environment was already there. It never was — so on device PushNotifications.
register() failed with 'no valid aps-environment entitlement', no APNs token was
obtained, and device_tokens stayed empty (server had nothing to push to).

ios-patch.sh now creates App/App.entitlements with aps-environment=production
before the share-extension script merges the App Group in. Still requires the App
ID to have Push Notifications enabled (so the profile carries the entitlement) and
the server APNS_* key set (Step 5) for end-to-end delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:04:12 +05:30
Sravan 381c4ddfee fix(deploy): reload NPM after deploy so it re-resolves the app's IP
Root cause of a post-deploy outage: recreating the app container can assign it a NEW
docker network IP. Nginx Proxy Manager caches the app's upstream IP at config-load,
so it kept connecting to the OLD IP (which, after adding the postgres/redis services,
was reassigned to bizgaze-postgres) → 'Connection refused' → site down until nginx
re-resolved. deploy.sh now runs 'nginx -s reload' in the NPM container after verify
(normal + rollback), non-fatal if NPM isn't detected. This makes deploys self-healing
for the app-IP-change case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:43:28 +05:30
Sravan 63f2c588da feat(scale): swappable pub/sub layer + fix chat.js missed awaits (Phase 6)
Two things:

1. FIX a live regression the async conversion missed: chat.js calls repos via the
   lazy repos() helper (not the R. prefix), so my sweep skipped it — effectiveStatus
   / broadcastPresence read `repos().users.byId(userId)` synchronously, but that's a
   Promise now, so presence broadcasts always reported status 'active' and dropped
   last_seen. Now awaited (effectiveStatus/broadcastPresence async); touchSeen is a
   fire-and-forget UPDATE with .catch. Audited all non-R. repo calls — only chat.js
   was affected (media.js backfill was already awaited).

2. Swappable pub/sub for multi-instance real-time fan-out (the actual blocker to
   running >1 instance — not the DB). server/pubsub.js picks a backend by
   PUBSUB_BACKEND (default 'memory'). Local socket delivery is UNCHANGED; publish is
   additive — memory = no-op (zero hot-path cost, identical single-instance
   behaviour), redis = fan-out to other instances with a self-echo guard. chat.js
   pushToUser/broadcastPresence now also publish; each instance subscribes to deliver
   remote events to its local sockets. Interface is tiny so Redis is one swappable
   file (Postgres LISTEN/NOTIFY or NATS could drop in the same way — never hardwired,
   as requested). Dormant redis service added to compose behind the 'scale' profile;
   redis dep added; PUBSUB_BACKEND/REDIS_URL documented.

Validated: smoke 22/22 (memory), e2e chat delivery green. NOTE: full multi-instance
also needs distributed presence (isOnline is per-process) + meeting-signaling
sharing — chat/presence fan out via this layer; those are follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:20:02 +05:30
Sravan 363c4a539f feat(db): add bizgaze-postgres service to compose (engine cutover infra)
Adds a dedicated Postgres 16 service (container bizgaze-postgres, own named volume
bizgaze_pg_data, healthcheck) on the shared NPM network. The app depends_on it
healthy. Inert until DB_BACKEND=pg is set in .env — default stays SQLite, so this
deploy changes nothing functionally; it just makes the engine available. Documented
POSTGRES_PASSWORD / DATABASE_URL / DB_BACKEND in .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:08:32 +05:30
36 changed files with 1224 additions and 120 deletions
+13
View File
@@ -29,3 +29,16 @@ TURN_CREDENTIAL=
# LIVEKIT_URL=wss://livekit.bizgaze.com
# LIVEKIT_API_KEY=
# LIVEKIT_API_SECRET=
# Optional: PostgreSQL data store. Leave unset to use the built-in SQLite file (DB_PATH). To move to
# Postgres: set POSTGRES_PASSWORD (used by the bizgaze-postgres container AND the URL below), then set
# DATABASE_URL + DB_BACKEND=pg after running db/migrate-sqlite-to-pg.js. See db/schema.pg.sql.
# POSTGRES_PASSWORD=
# DATABASE_URL=postgres://bizgaze:PASSWORD@bizgaze-postgres:5432/bizgaze
# DB_BACKEND=pg
# Optional: cross-instance real-time (chat/presence) fan-out via Redis, for running MULTIPLE app instances.
# Leave unset for single-instance (in-memory pub/sub, the default). To scale out: start the redis service
# (`docker compose --profile scale up -d`), then set both below + a sticky load balancer for /ws.
# PUBSUB_BACKEND=redis
# REDIS_URL=redis://bizgaze-redis:6379
+21 -8
View File
@@ -27,9 +27,9 @@ workflows:
- ios_signing # ← Codemagic variable group holding CERTIFICATE_PRIVATE_KEY (secure). See below.
vars:
BUNDLE_ID: "com.bizgaze.connect"
XCODE_WORKSPACE: "mobile/ios/App/App.xcworkspace"
XCODE_PROJECT: "mobile/ios/App/App.xcodeproj"
XCODE_SCHEME: "App"
node: 20
node: 22
xcode: latest
cocoapods: default
scripts:
@@ -44,8 +44,23 @@ workflows:
script: |
cd mobile
# `cap add ios` scaffolds ios/App; safe to re-run — it no-ops if it already exists.
if [ ! -d "ios" ]; then npx cap add ios; fi
# Capacitor 8 Swift Package Manager: generate an SPM project (no Podfile). LiveKit is pulled via the
# native-call plugin's Package.swift; `cap sync` wires all local plugins into the CapApp-SPM package.
if [ ! -d "ios" ]; then npx cap add ios --packagemanager SPM; fi
npx cap sync ios
# Sanity: the Xcode project must exist. Print the iOS dir so the log shows the SPM layout (CapApp-SPM
# present, NO Podfile) — if a Podfile appears, the SPM flag didn't take and we'd need to fix it.
test -d ios/App/App.xcodeproj || { echo "ERROR: iOS Xcode project not generated"; ls -la ios/App || true; exit 1; }
echo "iOS project layout:"; ls -la ios/App
# ── Diagnose local-plugin SPM wiring (the regression: our file: plugins didn't function at runtime) ──
echo "=== local plugins in node_modules — symlink vs copy, and is Package.swift present? ==="
for p in native-call media-library audio-route share-inbox; do
echo "-- $p --"; ls -ld "node_modules/$p" 2>/dev/null || echo " (dir missing)"
( ls "node_modules/$p/Package.swift" >/dev/null 2>&1 && echo " Package.swift PRESENT" ) || echo " Package.swift MISSING"
done
echo "=== CapApp-SPM Package.swift — are our local plugins + LiveKit wired in? ==="
CAPSPM=$(find ios -path "*CapApp-SPM*Package.swift" 2>/dev/null | head -1)
if [ -n "$CAPSPM" ]; then echo "found: $CAPSPM"; cat "$CAPSPM"; else echo " CapApp-SPM/Package.swift NOT FOUND"; find ios -name Package.swift 2>/dev/null; fi
# App icon + splash from resources/icon.png & resources/splash*.png (1024x1024 icon, 2732² splash).
npx capacitor-assets generate --ios || echo "asset generation skipped"
@@ -90,10 +105,8 @@ workflows:
--create
keychain add-certificates
- name: Install CocoaPods
script: |
cd mobile/ios/App
pod install
# (No "Install CocoaPods" step under SPM — there is no Podfile. Xcode resolves the Swift packages
# (Capacitor, plugins, LiveKit + its WebRTC/UniFFI/SwiftProtobuf) during the archive below.)
- name: Build the signed IPA
script: |
@@ -104,7 +117,7 @@ workflows:
# `xcode-project build-ipa` prints a PRETTIFIED summary and swallows the raw xcodebuild "error:"
# lines — a failed archive shows only "Failed to archive" with no reason. On failure, surface the
# actual errors from the raw log so we don't have to dig through the artifact.
if ! xcode-project build-ipa --workspace "$XCODE_WORKSPACE" --scheme "$XCODE_SCHEME"; then
if ! xcode-project build-ipa --project "$XCODE_PROJECT" --scheme "$XCODE_SCHEME"; then
echo "======================= xcodebuild errors ======================="
grep -h -E "error:|errSec|Provisioning profile|entitlement|Code ?Sign|does not (support|contain)|requires a provisioning|No profile|No signing|doesn't (include|match)|Command .* failed" /tmp/xcodebuild_logs/*.log 2>/dev/null | grep -vi "warning:" | tail -60 || echo "(no matching lines — open the xcodebuild_logs artifact)"
echo "================================================================="
+20 -1
View File
@@ -84,6 +84,24 @@ verify() {
fi
}
# Recreating the app container can give it a NEW docker network IP. Nginx Proxy Manager caches the app's
# upstream IP at config-load, so it would keep hitting the OLD IP ("Connection refused" → site down) until
# it re-resolves. Reload NPM's nginx so it picks up the current IP. Non-fatal: warn (don't fail) if NPM
# isn't found — some environments run a different reverse proxy.
reload_proxy() {
local npm
npm="$(docker ps --format '{{.Names}}' | grep -iE 'nginx.?proxy.?manager.*app' | head -n1 || true)"
if [ -n "$npm" ] && docker exec "$npm" nginx -t >/dev/null 2>&1; then
if docker exec "$npm" nginx -s reload >/dev/null 2>&1; then
ok "Reloaded $npm (re-resolved app upstream IP)."
else
warn "Could not reload $npm — if the site 502s, run: docker exec $npm nginx -s reload"
fi
else
warn "Reverse proxy (NPM) not auto-detected; if the site fails after deploy, reload it so it re-resolves the app IP."
fi
}
# --- rollback mode ---
if [ "$ROLLBACK" -eq 1 ]; then
latest="$(ls -1t "$BK_DIR"/*.tgz 2>/dev/null | head -n1 || true)"
@@ -91,7 +109,7 @@ if [ "$ROLLBACK" -eq 1 ]; then
log "Rolling back from: $latest"
snapshot # snapshot the (broken) current state first
tar -xzf "$latest" -C "$APP_DIR"
rebuild; verify
rebuild; verify; reload_proxy
ok "Rolled back to $latest"
exit 0
fi
@@ -106,5 +124,6 @@ if [ "$DO_PULL" -eq 1 ]; then
fi
rebuild
verify
reload_proxy
ok "Deploy complete. Backups kept: $KEEP_BACKUPS (in $BK_DIR)."
echo " Rollback with: ./deploy.sh --rollback"
+43
View File
@@ -34,6 +34,48 @@ services:
- bizgaze_support_data:/data # persists data.db across rebuilds
networks:
- npm
# Wait for Postgres to be healthy before starting. Only matters when DB_BACKEND=pg (else the app uses
# the local SQLite file and ignores pg), but it's harmless on SQLite — pg comes up in a second or two.
depends_on:
bizgazepg:
condition: service_healthy
# PostgreSQL — the app's data store when DB_BACKEND=pg (default is the local SQLite file). Distinct
# service/container name so it never collides with the other postgres containers on the shared NPM
# network; the app reaches it as `bizgaze-postgres`. Data on its own named volume.
bizgazepg:
image: postgres:16
container_name: bizgaze-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=bizgaze
- POSTGRES_DB=bizgaze
# Password comes from the same .env as the app (compose interpolates ${...} from the .env in this dir).
# NOTE: Postgres only applies POSTGRES_PASSWORD on FIRST init of an empty data volume.
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-bizgaze_local}
volumes:
- bizgaze_pg_data:/var/lib/postgresql/data
networks:
- npm
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bizgaze -d bizgaze"]
interval: 5s
timeout: 3s
retries: 12
# Redis — cross-instance real-time fan-out (chat/presence), used only when PUBSUB_BACKEND=redis. Dormant
# by default (behind the 'scale' profile, like livekit), so a normal deploy never starts it and the app
# stays single-instance on the in-memory pubsub. To run multiple app instances: start this
# (`docker compose --profile scale up -d`), set PUBSUB_BACKEND=redis + REDIS_URL in .env, and put the app
# behind a load balancer with sticky sessions for the /ws WebSocket. (Meeting SIGNALING state is still
# per-process — cross-instance meetings need sticky routing or further work; chat/presence fan out here.)
bizgazeredis:
image: redis:7-alpine
container_name: bizgaze-redis
restart: unless-stopped
profiles: ["scale"]
networks:
- npm
# LiveKit SFU — meeting media server. Optional: only started/used when the app's .env has
# LIVEKIT_URL/API_KEY/API_SECRET set (otherwise meetings use the built-in P2P mesh). NPM proxies
@@ -68,3 +110,4 @@ networks:
volumes:
bizgaze_support_data:
bizgaze_pg_data:
+11 -1
View File
@@ -40,7 +40,17 @@ On the **Register an App ID** page, only three fields matter — leave everythin
- Add yourself under **TestFlight → Internal Testing** to install via the TestFlight app on your iPhone.
## Step 5 — Push notifications (APNs) — do this once, then tell me
So the app gets **calls/messages while it's closed**:
So the app gets **calls/messages while it's closed**. Two independent pieces must BOTH be in place:
> **A. App ID capability (client side).** The App ID `com.bizgaze.connect` must have **Push Notifications**
> enabled (Step 0 ticks it). The build now injects the `aps-environment` entitlement automatically
> (`ios-patch.sh`), so the app can obtain an APNs token — but if the App ID lacks the Push capability, the
> archive fails code-signing on `aps-environment`. If a build errors on that after you enable the
> capability, delete the app's provisioning profile in App Store Connect so the next Codemagic run
> regenerates one that includes push.
>
> **B. APNs key (server side).** Do the two steps below so the server can actually SEND to that token.
1. developer.apple.com → **Keys → +** → enable **Apple Push Notifications service (APNs)** → download the
**`.p8`**. Note its **Key ID** and your **Team ID** (top-right of the developer portal).
2. **Send me**: the `.p8` contents, the **Key ID**, and the **Team ID**. I set these in the server `.env`
Binary file not shown.
+14 -13
View File
@@ -12,22 +12,23 @@
"dependencies": {
"audio-route": "file:plugins/audio-route",
"media-library": "file:plugins/media-library",
"native-call": "file:plugins/native-call",
"share-inbox": "file:plugins/share-inbox",
"@capacitor-community/safe-area": "^7.0.0",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
"@capacitor/camera": "^7.0.0",
"@capacitor/core": "^7.0.0",
"@capacitor/filesystem": "^7.0.0",
"@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@capacitor/share": "^7.0.0",
"@capacitor/push-notifications": "^7.0.0",
"@capacitor/splash-screen": "^7.0.0",
"@capacitor/status-bar": "^7.0.0"
"@capacitor-community/safe-area": "^8.0.0",
"@capacitor/android": "^8.0.0",
"@capacitor/app": "^8.0.0",
"@capacitor/camera": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@capacitor/filesystem": "^8.0.0",
"@capacitor/ios": "^8.0.0",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/share": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0",
"@capacitor/splash-screen": "^8.0.0",
"@capacitor/status-bar": "^8.0.0"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^7.0.0"
"@capacitor/cli": "^8.0.0"
}
}
@@ -15,7 +15,7 @@ Pod::Spec.new do |s|
s.author = 'BizGaze'
s.source = { :git => 'https://bizgaze.com/audio-route.git', :tag => s.version.to_s }
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
s.ios.deployment_target = '14.0'
s.ios.deployment_target = '15.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end
+22
View File
@@ -0,0 +1,22 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "AudioRoute",
platforms: [.iOS(.v15)],
products: [
.library(name: "AudioRoute", targets: ["AudioRoutePlugin"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
],
targets: [
.target(
name: "AudioRoutePlugin",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm")
],
path: "ios/Sources/AudioRoutePlugin")
]
)
+4 -3
View File
@@ -10,7 +10,8 @@
"files": [
"dist/",
"ios/",
"AudioRoute.podspec"
"AudioRoute.podspec",
"Package.swift"
],
"capacitor": {
"ios": {
@@ -18,9 +19,9 @@
}
},
"devDependencies": {
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^8.0.0"
},
"peerDependencies": {
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^8.0.0"
}
}
@@ -16,7 +16,7 @@ Pod::Spec.new do |s|
s.author = 'BizGaze'
s.source = { :git => 'https://bizgaze.com/media-library.git', :tag => s.version.to_s }
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
s.ios.deployment_target = '14.0'
s.ios.deployment_target = '15.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end
@@ -0,0 +1,22 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MediaLibrary",
platforms: [.iOS(.v15)],
products: [
.library(name: "MediaLibrary", targets: ["MediaLibraryPlugin"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
],
targets: [
.target(
name: "MediaLibraryPlugin",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm")
],
path: "ios/Sources/MediaLibraryPlugin")
]
)
+4 -3
View File
@@ -10,7 +10,8 @@
"files": [
"dist/",
"ios/",
"MediaLibrary.podspec"
"MediaLibrary.podspec",
"Package.swift"
],
"capacitor": {
"ios": {
@@ -18,9 +19,9 @@
}
},
"devDependencies": {
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^8.0.0"
},
"peerDependencies": {
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^8.0.0"
}
}
@@ -0,0 +1,31 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
Pod::Spec.new do |s|
# NOTE: the pod name MUST be 'NativeCall' (PascalCase of the npm package name 'native-call').
# Capacitor's `cap sync` writes `pod 'NativeCall', :path => '../../plugins/native-call'` into the
# generated Podfile, and CocoaPods then looks for a file literally named NativeCall.podspec whose
# s.name is 'NativeCall'. Any other name → "No podspec found for `NativeCall`" and pod install fails.
s.name = 'NativeCall'
s.version = package['version']
s.summary = package['description']
s.license = package['license']
s.homepage = 'https://bizgaze.com'
s.author = 'BizGaze'
s.source = { :git => 'https://bizgaze.com/native-call.git', :tag => s.version.to_s }
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
s.ios.deployment_target = '15.0'
s.dependency 'Capacitor'
# LiveKit iOS SDK — carries the call media NATIVELY so audio survives backgrounding AND coordinates with
# CallKit's audio session (auto-config OFF via AudioManager.shared.audioSession.isAutomaticConfigurationEnabled
# + setEngineAvailability in CXProvider didActivate/didDeactivate), which the WebView's WebRTC could not do.
# NOTE ON VERSION: this constraint stays '~> 2.0', but the actual version is pinned to 2.15.3 by a git-tag
# `pod 'LiveKitClient', :git => ...` line that ios-patch.sh injects into the generated Podfile — because the
# CallKit audio API needs 2.1+, which LiveKit publishes SPM-only (the CocoaPods TRUNK caps at 2.0.18, but the
# repo still ships a valid podspec at tag 2.15.3, and its deps are on trunk). 2.15.3 satisfies '~> 2.0'.
s.dependency 'LiveKitClient', '~> 2.0'
# CallKit + PushKit + AVFoundation are system frameworks (no external pod).
s.frameworks = 'CallKit', 'PushKit', 'AVFoundation'
s.swift_version = '5.1'
end
+27
View File
@@ -0,0 +1,27 @@
// swift-tools-version: 5.9
import PackageDescription
// SPM manifest for the native-call Capacitor plugin (Capacitor 8 uses SPM). LiveKit is declared here as a
// REAL SPM dependency LiveKit 2.1+ is SPM-native, so this replaces the CocoaPods git-tag pin hack entirely.
// SPM resolves LiveKit + its LiveKitWebRTC / LiveKitUniFFI / SwiftProtobuf sub-packages directly.
let package = Package(
name: "NativeCall",
platforms: [.iOS(.v15)],
products: [
.library(name: "NativeCall", targets: ["NativeCallPlugin"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
.package(url: "https://github.com/livekit/client-sdk-swift.git", exact: "2.15.3")
],
targets: [
.target(
name: "NativeCallPlugin",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "LiveKit", package: "client-sdk-swift")
],
path: "ios/Sources/NativeCallPlugin")
]
)
@@ -0,0 +1,321 @@
import Foundation
import Capacitor
import PushKit
import CallKit
import AVFoundation
import LiveKit // SPM product name is 'LiveKit' (Package.swift). (The old CocoaPods module was 'LiveKitClient'.)
// Native calling for Biz Connect (iOS). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall.
//
// ARCHITECTURE (native media):
// * PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices 'ios-voip').
// * CallKit: incoming VoIP push -> full-screen system ring (works when force-killed). The CallKit call
// stays ACTIVE for the whole call (that's what foregrounds/unlocks the app on answer and keeps the call
// alive in the background).
// * LiveKit: the call MEDIA runs NATIVELY via the LiveKit iOS SDK so the mic works with an active CallKit
// call (WebKit's WebRTC could not) and audio survives backgrounding. The VoIP payload carries the
// LiveKit url+token so we can connect immediately on answer, even from a killed state; outgoing calls get
// the token from the WebView via reportOutgoingCall(). The WebView is UI only for native calls (it must
// NOT also join the room LiveKit allows one connection per identity).
@objc(NativeCallPlugin)
public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate {
public let identifier = "NativeCallPlugin"
public let jsName = "NativeCall"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
]
private var pushRegistry: PKPushRegistry?
private var provider: CXProvider?
private let callController = CXCallController()
private var voipToken: String = ""
// callUUID -> the call's data (room, kind, callerId, livekitUrl, livekitToken, ).
private var calls: [UUID: [String: Any]] = [:]
// Calls we've already ended locally so a LATE cancel push doesn't re-report (which briefly re-rang).
private var endedCalls = Set<UUID>()
private var room: Room?
private var activeUUID: UUID?
override public func load() {
let config = CXProviderConfiguration(localizedName: "Biz Connect")
config.supportsVideo = true
config.maximumCallGroups = 1
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.generic]
let p = CXProvider(configuration: config)
p.setDelegate(self, queue: nil)
provider = p
let registry = PKPushRegistry(queue: .main)
registry.delegate = self
registry.desiredPushTypes = [.voIP]
pushRegistry = registry
// CallKit audio coordination (LiveKit 2.15+, pinned to the git tag in ios-patch.sh). LiveKit's automatic
// AVAudioSession config RACES CallKit's own activation intermittent dead mic / no audio + the output
// route only settling once audio flows ("speaker turns on late"). Fix: turn auto-config OFF and keep the
// engine OFF; we configure the session and enable the engine ONLY in didActivate.
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
try? AudioManager.shared.setEngineAvailability(.none)
}
// MARK: - LiveKit media
private func connectRoom(url: String, token: String) {
guard !url.isEmpty, !token.isEmpty else { return }
// Ask for mic permission now (we still connect MUTED). If it stays "undetermined", enabling the audio
// engine in didActivate can block on first use determining it up front avoids that.
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
let old = room
let r = Room()
room = r
Task { [weak self] in
await old?.disconnect() // drop any previous/stale connection so we never leave DUPLICATE participants
do {
try await r.connect(url: url, token: token)
// House rule: answer MUTED. Not enabling the mic here means nothing is captured/published
// until the user taps Mic in the meeting UI ( setMuted(false) setMicrophone(true)), which
// is also when iOS asks for mic permission. Playback (hearing others) is unaffected.
try await r.localParticipant.setMicrophone(enabled: false)
self?.notifyListeners("callConnected", data: ["ok": true])
} catch {
self?.notifyListeners("callError", data: ["error": String(describing: error)])
}
}
}
private func disconnectRoom() {
let r = room
room = nil
Task { await r?.disconnect() }
}
// Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected
// wired/Bluetooth headset (any non-receiver route) still wins. Re-asserted on mute toggles because enabling
// the mic can flip the route back to the earpiece.
private func preferSpeaker() {
let s = AVAudioSession.sharedInstance()
if s.currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver }) {
try? s.overrideOutputAudioPort(.speaker)
}
}
// MARK: - JS-callable methods
@objc func getToken(_ call: CAPPluginCall) { call.resolve(["token": voipToken]) }
// Outgoing call from the web app: start a CallKit outgoing call AND connect the LiveKit room natively.
@objc func reportOutgoingCall(_ call: CAPPluginCall) {
guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else {
call.reject("callUUID required"); return
}
let url = call.getString("url") ?? ""
let token = call.getString("token") ?? ""
let video = call.getBool("hasVideo") ?? false
calls[uuid] = ["room": call.getString("room") ?? "", "kind": call.getString("kind") ?? "dm",
"livekitUrl": url, "livekitToken": token]
activeUUID = uuid
let handle = CXHandle(type: .generic, value: call.getString("peerName") ?? "Call")
let start = CXStartCallAction(call: uuid, handle: handle)
start.isVideo = video
callController.request(CXTransaction(action: start)) { [weak self] error in
if let error = error { call.reject(error.localizedDescription); return }
self?.connectRoom(url: url, token: token)
// Start muted (house rule) also reflect it on the CallKit system call screen.
self?.callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: true))) { _ in }
self?.provider?.reportOutgoingCall(with: uuid, connectedAt: Date())
call.resolve()
}
}
// Ring CallKit from a WEBSOCKET call event (a 2nd path alongside the VoIP push). When the app is open the
// WS is connected and reliable, but the VoIP push can be delayed/dropped "sometimes it doesn't ring".
// Deduped by UUID: if the VoIP push already reported this call, this is a no-op (and vice-versa).
@objc func reportIncomingCall(_ call: CAPPluginCall) {
guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else {
call.reject("callUUID required"); return
}
// Already handled (ended, ringing, or active) don't re-report (CallKit would reject a dup anyway).
if endedCalls.contains(uuid) || calls[uuid] != nil || activeUUID == uuid { call.resolve(); return }
let callerName = call.getString("callerName") ?? call.getString("groupName") ?? "Incoming call"
let hasVideo = call.getBool("hasVideo") ?? false
calls[uuid] = [
"callUUID": uuidStr, "room": call.getString("room") ?? "", "kind": call.getString("kind") ?? "dm",
"callerId": call.getString("callerId") ?? "", "callerName": callerName,
"groupId": call.getString("groupId") ?? "", "groupName": call.getString("groupName") ?? "",
"hasVideo": hasVideo, "livekitUrl": call.getString("url") ?? "", "livekitToken": call.getString("token") ?? "",
]
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerName)
update.localizedCallerName = callerName
update.hasVideo = hasVideo
update.supportsHolding = false; update.supportsGrouping = false; update.supportsUngrouping = false
provider?.reportNewIncomingCall(with: uuid, update: update) { _ in }
call.resolve()
}
@objc func setMuted(_ call: CAPPluginCall) {
let muted = call.getBool("muted") ?? false
// Drive mute THROUGH CallKit so the system call screen and the in-app meeting UI stay in sync (the
// CXSetMutedCallAction handler does the actual setMicrophone). Fall back to a direct call if somehow
// there's no active CallKit call.
if let uuid = activeUUID {
callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: muted))) { _ in }
} else {
let r = room
Task { try? await r?.localParticipant.setMicrophone(enabled: !muted) }
}
call.resolve()
}
// End the CallKit call (remote hung up / user ended from the web UI). No callUUID -> end all.
@objc func endCall(_ call: CAPPluginCall) {
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) {
requestEnd(uuid)
} else {
for uuid in calls.keys { requestEnd(uuid) }
if let a = activeUUID { requestEnd(a) }
}
call.resolve()
}
private func requestEnd(_ uuid: UUID) {
callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { _ in }
calls.removeValue(forKey: uuid)
endedCalls.insert(uuid)
}
// MARK: - PushKit (VoIP)
public func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
voipToken = token
notifyListeners("voipToken", data: ["token": token])
}
public func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
voipToken = ""
}
// Incoming VoIP push. iOS 13+: we MUST reportNewIncomingCall for EVERY push before completion(), or the
// app is terminated.
public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
var dict: [String: Any] = [:]
for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } }
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID()
// CANCEL: caller hung up / declined / timed out. iOS requires a reported call per push, but blindly
// reporting a NEW incoming call is what caused the "rings back for a second" blip on a call we've
// already handled. So:
if (dict["type"] as? String) == "cancel" {
// iOS 13+ TERMINATES the app if a VoIP push doesn't result in reportNewIncomingCall before
// completion() that's the crash after several calls (my earlier "no-blip" optimization SKIPPED
// the report for known/ended calls, which iOS kills for). So ALWAYS report, then immediately end:
// * uuid already ringing/active the report errors harmlessly (NO second ring), reportCall ends it.
// * late/duplicate cancel (done) a brief, unavoidable blip (acceptable; a crash is not).
let u = CXCallUpdate()
u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call")
let wasActive = (activeUUID == uuid)
var ev = calls[uuid] ?? [:]; ev["callUUID"] = uuid.uuidString
calls.removeValue(forKey: uuid)
endedCalls.insert(uuid)
if wasActive { disconnectRoom(); activeUUID = nil }
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
if wasActive { self?.notifyListeners("endCall", data: ev) } // active call cancelled leave the meeting
completion()
}
return
}
let callerName = (dict["callerName"] as? String) ?? (dict["groupName"] as? String) ?? "Incoming call"
let hasVideo = (dict["hasVideo"] as? Bool) ?? false
calls[uuid] = dict
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerName)
update.localizedCallerName = callerName
update.hasVideo = hasVideo
update.supportsHolding = false
update.supportsGrouping = false
update.supportsUngrouping = false
provider?.reportNewIncomingCall(with: uuid, update: update) { _ in completion() }
}
// MARK: - CXProviderDelegate
public func providerDidReset(_ provider: CXProvider) {
disconnectRoom()
calls.removeAll(); endedCalls.removeAll(); activeUUID = nil
}
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
let uuid = action.callUUID
let data = calls[uuid] ?? [:]
activeUUID = uuid
action.fulfill()
// Keep the CallKit call ACTIVE (foregrounds the app + keeps the call alive). Connect the LiveKit room
// NATIVELY so the mic works with the active CallKit call and audio survives backgrounding.
connectRoom(url: (data["livekitUrl"] as? String) ?? "", token: (data["livekitToken"] as? String) ?? "")
// We answer muted (house rule) reflect that on the CallKit system call screen (the mic button there
// showed unmuted before, even though we were functionally muted).
callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: true))) { _ in }
var ev = data; ev["callUUID"] = uuid.uuidString
// Answering from a KILLED/locked state launches the app the WebView's JS listener may not be attached
// yet, so retain the event until it is. Without this the "answered" signal is lost, the server's
// unanswered timer fires, and the call is cancelled ~40s after pickup.
notifyListeners("answerCall", data: ev, retainUntilConsumed: true)
}
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
var data: [String: Any] = calls[action.callUUID] ?? [:]
data["callUUID"] = action.callUUID.uuidString
disconnectRoom()
notifyListeners("endCall", data: data)
calls.removeValue(forKey: action.callUUID)
endedCalls.insert(action.callUUID)
if activeUUID == action.callUUID { activeUUID = nil }
action.fulfill()
}
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date())
action.fulfill()
}
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
let r = room
Task { [weak self] in
try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted)
self?.preferSpeaker() // toggling the mic can flip the route back to the earpiece re-assert speaker
}
notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted])
action.fulfill()
}
// CallKit owns the AVAudioSession lifecycle. Since LiveKit auto-config is OFF, configure the session HERE
// when CallKit activates it and enable LiveKit's audio engine. This is the fix for the intermittent
// dead mic / no-audio and late speaker routing. Don't call setActive(true): CallKit already activated it.
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
do {
// Route to the LOUDSPEAKER by default (earpiece was the .voiceChat default). .videoChat +
// .defaultToSpeaker prefers the speaker while still letting wired/Bluetooth headsets win.
try audioSession.setCategory(.playAndRecord, mode: .videoChat,
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
try AudioManager.shared.setEngineAvailability(.default)
preferSpeaker() // belt-and-braces: if we still landed on the earpiece, force the speaker
let route = audioSession.currentRoute.outputs.first?.portType.rawValue ?? "none"
notifyListeners("audioActivated", data: ["ok": true, "route": route])
} catch {
notifyListeners("audioActivated", data: ["ok": false, "error": String(describing: error)])
}
}
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
try? AudioManager.shared.setEngineAvailability(.none)
notifyListeners("audioDeactivated", data: ["ok": true])
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "native-call",
"version": "1.0.0",
"description": "Native CallKit + PushKit VoIP calling for Biz Connect (iOS)",
"main": "dist/plugin.cjs.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"author": "BizGaze",
"license": "MIT",
"files": [
"dist/",
"ios/",
"NativeCall.podspec",
"Package.swift"
],
"capacitor": {
"ios": {
"src": "ios"
}
},
"devDependencies": {
"@capacitor/core": "^8.0.0"
},
"peerDependencies": {
"@capacitor/core": "^8.0.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "ShareInbox",
platforms: [.iOS(.v15)],
products: [
.library(name: "ShareInbox", targets: ["ShareInboxPlugin"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
],
targets: [
.target(
name: "ShareInboxPlugin",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm")
],
path: "ios/Sources/ShareInboxPlugin")
]
)
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
s.author = 'BizGaze'
s.source = { :git => 'https://bizgaze.com/share-inbox.git', :tag => s.version.to_s }
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
s.ios.deployment_target = '14.0'
s.ios.deployment_target = '15.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end
+4 -3
View File
@@ -10,7 +10,8 @@
"files": [
"dist/",
"ios/",
"ShareInbox.podspec"
"ShareInbox.podspec",
"Package.swift"
],
"capacitor": {
"ios": {
@@ -18,9 +19,9 @@
}
},
"devDependencies": {
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^8.0.0"
},
"peerDependencies": {
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^8.0.0"
}
}
+21 -1
View File
@@ -66,7 +66,7 @@ puts "App entitlements: app-group ensured (kept #{(app_ent.keys - ['com.apple.se
# ── Create the extension target ──────────────────────────────────────────────────────────────────────
# deployment_target can be nil when it's only set at the project level — fall back so we never create a
# target with an empty minimum-OS (which Xcode then flags).
deployment = app.deployment_target || project.build_configurations.first&.build_settings&.[]('IPHONEOS_DEPLOYMENT_TARGET') || '14.0'
deployment = app.deployment_target || project.build_configurations.first&.build_settings&.[]('IPHONEOS_DEPLOYMENT_TARGET') || '15.0'
ext = project.new_target(
:app_extension, EXT_NAME, :ios,
deployment, project.products_group, :swift
@@ -112,5 +112,25 @@ appex = ext.product_reference
build_file = embed.add_file_reference(appex)
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
# ── Bundle the custom notification sound (notif.wav) into the App target ─────────────────────────────
# ios-patch.sh copied it to App/App/notif.wav. Add it as a resource so it ships in the bundle root where
# APNs can find it (the server sends sound:'notif.wav'). Tolerant: never fail the build over the sound.
begin
snd_path = File.join(APP_DIR, 'App', 'notif.wav')
if File.exist?(snd_path)
grp = project.main_group.find_subpath('App', false) || project.main_group
already = app.resources_build_phase.files.any? { |bf| bf.file_ref && bf.file_ref.respond_to?(:display_name) && bf.file_ref.display_name == 'notif.wav' }
unless already
snd_ref = grp.new_reference(snd_path)
app.resources_build_phase.add_file_reference(snd_ref)
end
puts "Notification sound bundled into App resources: notif.wav"
else
puts " (notif.wav not in project — sound not bundled)"
end
rescue => e
puts " (notif.wav bundling skipped: #{e.message})"
end
project.save
puts "Share Extension injected: #{EXT_NAME} (#{EXT_BUNDLE}) embedded in #{APP_TARGET}"
+50
View File
@@ -0,0 +1,50 @@
// Inject the APNs registration-forwarding methods into the Capacitor iOS AppDelegate.
// Run on Codemagic (macOS) from ios-patch.sh:
// node mobile/scripts/inject-push.js mobile/ios/App/App/AppDelegate.swift
//
// WHY: Capacitor 7's default AppDelegate.swift template does NOT implement
// application(_:didRegisterForRemoteNotificationsWithDeviceToken:) / ...didFailToRegister...
// so when @capacitor/push-notifications calls registerForRemoteNotifications(), iOS DOES obtain the
// APNs token but the AppDelegate never posts .capacitorDidRegisterForRemoteNotifications — so the plugin
// never delivers the token to JS. register() "succeeds", yet NEITHER the `registration` nor the
// `registrationError` event ever fires (proven via server-side push telemetry: register-called logged,
// no token, no error). These two methods forward the token / error to Capacitor. The plugin listens for
// the notifications defined in @capacitor/ios CAPNotifications.swift; `import Capacitor` (already in the
// AppDelegate) exposes the Notification.Name values.
//
// TOLERANT: exits 0 and no-ops if anything is off, so it can NEVER fail the build. Idempotent (keyed on
// the bzcPushForward marker), so re-runs never duplicate the methods.
const fs = require('fs');
const p = process.argv[2];
if (!p || !fs.existsSync(p)) { console.log(' (AppDelegate.swift not found — push forwarding patch skipped)'); process.exit(0); }
try {
let s = fs.readFileSync(p, 'utf8');
if (s.includes('bzcPushForward') || s.includes('capacitorDidRegisterForRemoteNotifications')) {
console.log(' push forwarding already patched'); process.exit(0);
}
const methods = [
'',
' // bzcPushForward: forward APNs device-token registration to Capacitor. The Capacitor 7 AppDelegate',
' // template omits these, so @capacitor/push-notifications never receives the token without them.',
' func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {',
' NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)',
' }',
'',
' func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {',
' NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)',
' }',
'',
].join('\n');
// Insert the methods just before the final closing brace of the file (which closes the AppDelegate class).
const orig = s;
s = s.replace(/\}\s*$/, methods + '}\n');
if (s !== orig && s.includes('bzcPushForward')) {
fs.writeFileSync(p, s);
console.log(' APNs registration forwarding methods injected into AppDelegate');
} else {
console.log(' (AppDelegate closing brace not matched — push forwarding patch skipped)');
}
} catch (e) {
console.log(' (push forwarding patch error, skipped: ' + (e && e.message) + ')');
}
process.exit(0);
+58
View File
@@ -40,6 +40,21 @@ set_bool() { "$PB" -c "Add :$1 bool $2" "$PLIST" 2>/dev/null || "$PB" -c "Set :$
set_bool UIFileSharingEnabled true
set_bool LSSupportsOpeningDocumentsInPlace true
# ── Keep CALL AUDIO alive when the app is BACKGROUNDED (minimised / screen locked) ──────────────────
# Without the 'audio' background mode, iOS suspends the WebView a few seconds after it backgrounds, which
# freezes the WebRTC mic + audio pipeline — so participants can't hear each other once the app is minimised
# or the phone locks. Declaring background audio keeps the audio session (and the app) running so a voice
# call continues in the background. (Video RENDERING still pauses while backgrounded — unavoidable in a
# WebView — but audio keeps flowing, which is what matters for a call.) Idempotent: rebuild the array each run.
# 'audio' keeps the call audio session alive when backgrounded; 'voip' is REQUIRED for PushKit to deliver
# VoIP pushes (CallKit incoming-call wake). Both are legitimate for a calling app and accepted by review
# because the app uses CallKit.
"$PB" -c "Delete :UIBackgroundModes" "$PLIST" 2>/dev/null || true
"$PB" -c "Add :UIBackgroundModes array" "$PLIST"
"$PB" -c "Add :UIBackgroundModes:0 string audio" "$PLIST"
"$PB" -c "Add :UIBackgroundModes:1 string voip" "$PLIST"
echo "UIBackgroundModes: audio, voip (call audio + CallKit VoIP wake)"
# ── Custom URL scheme so the Share Extension can bounce the user back into the app ──────────────────
# The extension stages the shared files into the App Group, then opens bizconnect://share; the app reads
# the staged files and shows "Send to…". Registering the scheme is what makes that openURL succeed.
@@ -51,6 +66,31 @@ if ! "$PB" -c "Print :CFBundleURLTypes" "$PLIST" >/dev/null 2>&1; then
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string bizconnect" "$PLIST"
fi
# ── Push Notifications entitlement (aps-environment) ────────────────────────────────────────────────
# The @capacitor/push-notifications plugin does NOT add the Push Notifications capability to the generated
# Xcode project — in Xcode that's a manual "Signing & Capabilities → + Push Notifications" click, which
# never happens on a fresh CI checkout. Without the aps-environment entitlement, PushNotifications.register()
# fails on device ("no valid 'aps-environment' entitlement string found") and NO APNs token is ever
# obtained, so device_tokens stays empty and the server has nothing to push to. Create the entitlements
# file with aps-environment HERE; add-share-extension.rb (runs after this) MERGES the App Group into the
# same file, preserving this key. REQUIRES: the App ID com.bizgaze.connect must have the Push Notifications
# capability enabled in the Apple Developer portal, so the fetched provisioning profile carries
# aps-environment — otherwise the archive fails code-signing. "production" is correct for App Store +
# TestFlight (pair it with APNS_PRODUCTION=1 on the server).
ENT="mobile/ios/App/App/App.entitlements"
if [ ! -f "$ENT" ]; then
cat > "$ENT" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>
PLIST
fi
"$PB" -c "Add :aps-environment string production" "$ENT" 2>/dev/null || "$PB" -c "Set :aps-environment production" "$ENT"
echo "Entitlements: aps-environment=production ensured in $ENT"
# We use only standard encryption (HTTPS/TLS), which is exempt — declaring this up front stops App Store
# Connect from asking the "export compliance" question on every single build/TestFlight upload.
"$PB" -c "Add :ITSAppUsesNonExemptEncryption bool false" "$PLIST" 2>/dev/null \
@@ -65,12 +105,30 @@ fi
# the speaker (headphones/Bluetooth still win when connected). The helper is tolerant and exits 0 even if
# the template differs, so it NEVER fails the build. First-pass fix — if WebRTC re-grabs the session
# mid-call on device, we follow up with a plugin that re-asserts .overrideOutputAudioPort(.speaker).
# ── Bundle the custom notification sound so chat/call pushes have an explicit tone ──────────────────
# APNs plays the sound named in the push payload (the server sends sound:'notif.wav'). The file must be a
# resource in the app bundle ROOT; copy it in here — add-share-extension.rb then adds it to the App
# target's "Copy Bundle Resources". iOS accepts a PCM .wav (<30s) for a notification sound.
SND_SRC="mobile/ios-assets/notif.wav"
SND_DST="mobile/ios/App/App/notif.wav"
if [ -f "$SND_SRC" ]; then cp "$SND_SRC" "$SND_DST" && echo "Copied notification sound -> $SND_DST"; else echo " (notif.wav source missing — sound not bundled)"; fi
AD="mobile/ios/App/App/AppDelegate.swift"
if [ -f "$AD" ]; then
echo "Patching AppDelegate audio session"
node "$(dirname "$0")/inject-audio.js" "$AD" || echo " (AVAudioSession patch skipped — non-fatal)"
# Capacitor 7's AppDelegate template omits the APNs registration callbacks, so the push-notifications
# plugin never receives the device token (register() fires no event at all). Inject the forwarding methods.
echo "Patching AppDelegate APNs registration forwarding"
node "$(dirname "$0")/inject-push.js" "$AD" || echo " (push forwarding patch skipped — non-fatal)"
fi
# ── LiveKit under SPM (Capacitor 8) ─────────────────────────────────────────────────────────────────
# LiveKit is now a proper Swift Package Manager dependency declared in the native-call plugin's Package.swift
# (github.com/livekit/client-sdk-swift, exact 2.15.3) — SPM resolves it + its WebRTC/UniFFI/SwiftProtobuf
# sub-packages at build time. So there is NO Podfile to patch here anymore (the old CocoaPods git-tag pin
# hack is gone).
echo "Info.plist patched:"
"$PB" -c "Print :NSCameraUsageDescription" "$PLIST"
"$PB" -c "Print :NSMicrophoneUsageDescription" "$PLIST"
+80 -13
View File
@@ -2,9 +2,12 @@
// 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');
const PUSH = require('./push'); // native push (APNs/FCM/WebPush) so a CLOSED app is notified of calls
const LK = require('./livekit'); // mint the callee's LiveKit join token for the native VoIP call payload
const { TRANS_DIR } = require('./config');
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const now = () => Date.now();
@@ -61,17 +64,20 @@ async function postSystem(group, teamId, text) {
async function startGroupCall(group, teamId, user) {
const existing = groupCalls.get(group);
if (existing) return { room: existing.room, active: true, already: true };
if (existing) return { room: existing.room, uuid: existing.uuid, 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 });
return { room, active: true };
broadcast(group, { type: 'group-call', group, active: true, room, uuid: call.uuid, by: user.id, startedByName: call.startedByName, groupName: gName });
// 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, livekitUrl: LK.LIVEKIT_URL, livekitToken: LK.livekitToken(mid, null, room) }); } } catch (_) {}
return { room, uuid: call.uuid, active: true };
}
// Called from signaling when a mesh room empties — ends the group call if this room was one.
@@ -83,7 +89,9 @@ async function endGroupCallByRoom(room) {
if (call) {
let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
broadcast(group, { type: 'group-call', group, active: false, room });
broadcast(group, { type: 'group-call', group, active: false, room, uuid: call.uuid });
// Stop any CallKit ring on members' killed/backgrounded devices.
try { for (const mid of await R.conversations.members(group)) { if (mid !== call.startedBy) PUSH.sendCallCancel(mid, call.uuid); } } catch (_) {} // not the starter — a cancel to them re-rings their own phone
}
}
@@ -91,11 +99,11 @@ async function endGroupCallByRoom(room) {
async function startDmCall(me, otherId, teamId) {
const key = pairKey(me.id, otherId);
const existing = dmCalls.get(key);
if (existing) return { room: existing.room, active: true, already: true };
if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
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
@@ -113,9 +121,13 @@ async function startDmCall(me, otherId, teamId) {
const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
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 (_) {}
return { room, active: true };
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, uuid: call.uuid, with: me.id, by: me.id, byName }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, uuid: call.uuid, with: otherId, by: me.id, byName }); } 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, livekitUrl: LK.LIVEKIT_URL, livekitToken: LK.livekitToken(otherId, null, room) }); } catch (_) {}
return { room, uuid: call.uuid, active: true };
}
async function endDmCallByRoom(room, silent) {
@@ -125,6 +137,11 @@ async function endDmCallByRoom(room, silent) {
if (!call) return;
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
// A native side that ends the call (POST /api/calls/end) may have a dropped WebSocket, so the OTHER party
// can be left sitting in the mesh meeting "still in the call". Close their window. Idempotent: when this
// runs from the mesh emptying normally, the room is already gone → no-op (so it never double-ends).
const stuck = meetingRooms.get(room);
if (stuck) { for (const [, p] of stuck) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
if (!silent) try {
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
@@ -132,7 +149,20 @@ async function endDmCallByRoom(room, silent) {
const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
} catch (_) {}
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, uuid: call.uuid, with: call.users[1 - i], room }); } catch (_) {} });
// Stop the CallKit ring on a device that's still RINGING (unanswered) — a killed/asleep callee has no WS
// to receive the dm-call above. For an ANSWERED call both sides are awake (the WS event ends it), and a
// cancel push would re-ring the device that just hung up — so only cancel when it was NOT answered.
// Cancel the ring ONLY on the CALLEE's device(s) — never the caller (startedBy). The caller is placing the
// call, not ringing, so a cancel push to them made their OWN phone re-ring after they hung up an unanswered
// outgoing call (the plugin must reportNewIncomingCall for every VoIP push → a phantom ring on the caller).
if (!call.answered) { const callee = call.users.find((u) => u !== call.startedBy); if (callee) { try { PUSH.sendCallCancel(callee, call.uuid); } catch (_) {} } }
// Missed-call banner to the callee (a plain notification, like a phone's missed call) when the call ended
// UNANSWERED — timeout or the caller hung up before pickup. Skipped on decline (silent): they chose to.
if (!silent && !call.answered) {
const callee = call.users.find((u) => u !== call.startedBy);
if (callee) { try { PUSH.sendToUser(callee, { title: call.startedByName || 'Missed call', body: '📞 Missed call', kind: 'dm', id: call.startedBy, tag: 'missed:' + room, data: { kind: 'dm', id: call.startedBy } }); } catch (_) {} }
}
}
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
@@ -140,8 +170,41 @@ async function endDmCallByRoom(room, silent) {
function markDmAnswered(room, userId) {
const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key); if (!call) return;
if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } }
if (userId && userId !== call.startedBy && !call.answered) {
call.answered = true; call.answeredAt = now();
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; }
// Native calls carry media over LiveKit, not our mesh — so the callee's OTHER devices never learn the
// call was picked up here and keep ringing forever (and dismissing that stale ring as a "decline" would
// tear down THIS live call). Tell them it was taken (dismiss the ring, no teardown), and flip the
// caller's UI from "ringing" to "connected".
try { CHAT.pushToUser(userId, { type: 'call-taken', room, uuid: call.uuid }); } catch (_) {}
try { CHAT.pushToUser(call.startedBy, { type: 'call-answered', room, uuid: call.uuid, by: userId }); } catch (_) {}
}
}
// When a user's chat socket (re)connects, re-send any call they're currently being rung into. The
// original dm-call / group-call events fire ONCE at call start, so an app that was closed then misses
// them. This makes the call PUSH actionable: tapping the banner opens the app, the socket connects, and
// the invite re-appears so they can answer (while the caller is still within the ring window). Sends only
// to the freshly-connected socket. Best-effort; never throws.
async function replayActiveCalls(userId, ws) {
if (!userId || !ws || ws.readyState !== 1) return;
try {
for (const [, call] of dmCalls) {
if (call.answered) continue;
if (call.users.includes(userId) && call.startedBy !== userId) {
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {}
}
}
for (const [group, call] of groupCalls) {
if (call.startedBy === userId) continue;
let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
if (!member) continue;
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {}
}
} catch (_) {}
}
// Called from signaling when any mesh room empties.
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
@@ -149,6 +212,10 @@ async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDm
async function declineDmCall(room, byUser) {
const key = roomToDmCall.get(room); if (!key) return { ok: false };
const call = dmCalls.get(key); if (!call) return { ok: false };
// Already ANSWERED (e.g. a native CallKit pickup on this user's other device, which bypasses our mesh so
// the check below can't see it): a "decline" here is just the stale ring on a second device — dismiss it,
// do NOT tear down the live call.
if (call.answered && byUser.id !== call.startedBy) return { ok: true, alreadyAnswered: true };
// #8: if this user has ALREADY accepted on another device (they're in the room), this is just the
// ringing invite on a second device — dismiss it silently, do NOT tear down the active call.
const inRoom = meetingRooms.get(room);
@@ -168,4 +235,4 @@ async function declineDmCall(room, byUser) {
return { ok: true };
}
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, finalizeTranscript, meetingContext, fmtDur, pairKey };
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, finalizeTranscript, meetingContext, fmtDur, pairKey };
+44 -23
View File
@@ -1,14 +1,28 @@
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends `chat-hello`;
// signaling.js registers the socket here. Messages are persisted over HTTP (routes.js) and pushed live to
// the recipient's sockets via pushToUser().
//
// MULTI-INSTANCE: local delivery (to sockets on THIS process) is unchanged. Every push is ALSO published
// via the swappable pubsub layer so, when >1 instance runs, a recipient connected to another instance
// still gets it. With the default in-memory pubsub (single instance) publish is a no-op, so this is
// behaviourally identical to before — no hot-path cost.
const { chatClients, meetingRooms } = require('./presence');
const pubsub = require('./pubsub');
let _repos = null; const repos = () => (_repos || (_repos = require('./repos'))); // lazy: avoid a require cycle
// Deliver an already-built or plain object to a user's sockets on THIS instance.
function deliverLocal(userId, obj) {
const s = chatClients.get(userId);
if (!s) return;
const data = typeof obj === 'string' ? obj : JSON.stringify(obj);
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
}
function register(userId, ws) {
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
chatClients.get(userId).add(ws);
ws._chatUserId = userId;
try { repos().users.touchSeen(userId); } catch (_) {} // "last seen" (#2)
repos().users.touchSeen(userId).catch(() => {}); // "last seen" (#2) — fire-and-forget UPDATE
}
function unregister(ws) {
@@ -18,7 +32,7 @@ function unregister(ws) {
if (set) {
set.delete(ws);
// Their LAST socket went away → stamp when they were last here, so contacts can show "last seen …".
if (!set.size) { chatClients.delete(id); try { repos().users.touchSeen(id); } catch (_) {} }
if (!set.size) { chatClients.delete(id); repos().users.touchSeen(id).catch(() => {}); }
}
}
@@ -28,37 +42,44 @@ function isOnline(userId) {
}
function pushToUser(userId, obj) {
const s = chatClients.get(userId);
if (!s) return;
const data = JSON.stringify(obj);
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
deliverLocal(userId, obj); // sockets on this instance
pubsub.publish('u:' + userId, obj); // other instances (no-op on the memory backend)
}
// --- Live presence -------------------------------------------------------------------------
// A user's effective status (online / in-a-call / away / …) changes with no HTTP round-trip:
// they connect or disconnect a socket, or join/leave a call. Without pushing that change, OTHER
// users only see it after a full page reload — impossible in the desktop/mobile apps. So whenever
// it changes we broadcast the user's fresh status to everyone else's sockets, and the client
// updates that contact's dot/subtitle in place.
// A user's effective status (online / in-a-call / away / …) changes with no HTTP round-trip: they connect
// or disconnect a socket, or join/leave a call. Without pushing that change, OTHER users only see it after
// a full page reload — impossible in the desktop/mobile apps. So whenever it changes we broadcast the
// user's fresh status to everyone else's sockets, and the client updates that contact's dot/subtitle.
function isInCall(userId) {
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId === userId) return true; } }
return false;
}
function effectiveStatus(userId) {
async function effectiveStatus(userId) {
if (isInCall(userId)) return 'incall'; // derived (overrides the stored status)
try { const u = repos().users.byId(userId); return (u && u.status) || 'active'; } catch (_) { return 'active'; }
try { const u = await repos().users.byId(userId); return (u && u.status) || 'active'; } catch (_) { return 'active'; }
}
function broadcastPresence(userId) {
// Send a presence payload to every local socket EXCEPT the subject's own.
function deliverPresenceLocal(subjectId, payload) {
for (const [uid, set] of chatClients) {
if (uid === subjectId) continue;
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
}
}
async function broadcastPresence(userId) {
if (!userId) return;
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
// bare "Offline" until the next sidebar reload (#2).
let lastSeen = null;
try { const u = repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: effectiveStatus(userId), lastSeen });
for (const [uid, set] of chatClients) {
if (uid === userId) continue; // no need to tell someone about their own status
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
}
try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: await effectiveStatus(userId), lastSeen });
deliverPresenceLocal(userId, payload); // this instance
pubsub.publish('presence', { userId, payload }); // other instances (no-op on memory)
}
// Cross-instance inbound: deliver events published by OTHER instances to our local sockets. On the memory
// backend these never fire; on redis they carry the fan-out. (Buffered until pubsub.init() connects.)
pubsub.subscribe('u:*', (channel, obj) => deliverLocal(channel.slice(2), obj));
pubsub.subscribe('presence', (channel, msg) => { if (msg && msg.payload) deliverPresenceLocal(msg.userId, msg.payload); });
module.exports = { register, unregister, isOnline, pushToUser, broadcastPresence };
+7
View File
@@ -42,6 +42,12 @@ const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://remote.bizgaze.
// calls our /api/gifs proxy. GIF picker is hidden when this isn't configured.
const GIPHY_API_KEY = process.env.GIPHY_API_KEY || '';
// Native CallKit / PushKit VoIP calling (iOS). Config-gated so it can be flipped WITHOUT an app rebuild:
// OFF (default) → calls use the WebView flow (works today); ON → iOS rings via CallKit + a VoIP push.
// Only turn ON once native LiveKit media carries the call audio — a CallKit call reserves the mic, so the
// WebView's WebRTC can't capture it (mic dead). Set CALLKIT_ENABLED=1 in the server .env to enable.
const CALLKIT_ENABLED = process.env.CALLKIT_ENABLED === '1';
module.exports = {
PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
@@ -58,6 +64,7 @@ module.exports = {
SMTP_ENABLED,
PUBLIC_BASE_URL,
GIPHY_API_KEY,
CALLKIT_ENABLED,
PUBLIC_DIR,
REC_DIR,
TRANS_DIR,
+23
View File
@@ -0,0 +1,23 @@
// LiveKit helpers shared by routes.js (browser meeting/call join) and calls.js (native VoIP call payload).
// Mint an access token (HS256 JWT signed with the API secret) — hand-rolled, same approach as push.js's
// JWTs, so there's no SDK dependency. Grants join+publish+subscribe on exactly one room, as one identity.
// The secret stays server-side.
const crypto = require('crypto');
const { LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED } = require('./config');
const _b64u = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
function livekitToken(identity, name, room, metadata) {
const nowSec = Math.floor(Date.now() / 1000);
const header = _b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = _b64u(JSON.stringify({
iss: LIVEKIT_API_KEY, sub: identity, name: name || identity,
nbf: nowSec, exp: nowSec + 6 * 3600, // 6h — long enough for any meeting/call
metadata: metadata || '',
video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
}));
const sig = _b64u(crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(header + '.' + payload).digest());
return header + '.' + payload + '.' + sig;
}
module.exports = { livekitToken, LIVEKIT_URL, LIVEKIT_ENABLED };
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"pg": "^8.13.1",
"redis": "^4.7.0",
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
+142 -26
View File
@@ -2546,7 +2546,7 @@ function refreshGroupRowTick(gid){
}
// Shared group call: start it (or join the live one — the server returns the existing room).
async function startOrJoinGroupCall(group){
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } }
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); meetReturn={kind:'group',id:group}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); return; } /* native: CallKit ring + native LiveKit + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); } }
catch(e){ toast(e.message||'Could not start the call'); }
}
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
@@ -2554,14 +2554,16 @@ function onGroupCall(d){
if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null;
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } }
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in
if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing
// Ring members in. On a CallKit device the system rings it (VoIP push) — skip the in-app popup.
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group');
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'group', groupId:d.group, callerName:(d.groupName||(it&&it.name)||'Group call') }); // WS path → CallKit
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
renderChats(searchVal());
}
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
async function startOrJoinDmCall(otherId){
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); startRingback(); return; } /* native: CallKit ring + native LiveKit media + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
catch(e){ toast(e.message||'Could not start the call'); }
}
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
@@ -2579,8 +2581,11 @@ function onDmCall(d){
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; // #4: drop the stuck "in call" status
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call
if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing
// Incoming 1:1 call. On a CallKit device the SYSTEM rings it (via the VoIP push) — don't also show the
// in-app popup, and let CallKit answer drive the join. Off CallKit, show the in-app invite as before.
if(d.active && d.by && d.by!==ME.id && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with});
if(d.active && d.by && d.by!==ME.id && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'dm', callerId:d.with, callerName:d.byName }); // WS path → CallKit (2nd path alongside the VoIP push)
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
renderChats(searchVal());
}
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
@@ -3195,7 +3200,7 @@ async function openConvo(kind,id){
// If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat
// is opened from a call notification, the original transient invite popup may already have closed —
// this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification.
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom)){
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom) && !nativeCallOn()){ // CallKit devices ring via the system, not this popup
showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true);
}
const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput');
@@ -3710,22 +3715,28 @@ function reportInstall(){
}catch(_){}
}
let _nativeTok=null;
// TEMP push diagnostics: mirror each step to the server so we can see where iOS registration fails
// without a Mac/device console. Remove once native push is confirmed working end-to-end.
function pdbg(step, extra){ try{ postJSON('/api/v1/push-debug', Object.assign({ step, t: Date.now() }, extra||{})).catch(()=>{}); }catch(_){}
try{ console.log('[push]', step, extra||''); }catch(_){} }
async function setupNativePush(){
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
if(!PN || (plat!=='ios' && plat!=='android')) return false; // not a mobile native app
const C=window.Capacitor;
pdbg('native-setup-enter', { plat, hasPlugin: !!PN, isNative: !!(C&&C.isNativePlatform&&C.isNativePlatform()), plugins: (C&&C.Plugins)?Object.keys(C.Plugins).join(','):'' });
if(!PN || (plat!=='ios' && plat!=='android')){ pdbg('native-setup-skip', { plat, hasPlugin: !!PN }); return false; } // not a mobile native app
try{
PN.addListener('registration', async (t)=>{ const token=t&&t.value; if(!token) return; _nativeTok=token;
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; console.log('[push] native device registered'); }
catch(e){ console.warn('[push] device register failed:', e); } });
PN.addListener('registrationError', (e)=>console.warn('[push] native registration error:', e));
PN.addListener('registration', async (t)=>{ const token=t&&t.value; pdbg('registration-event', { tokenLen: token?token.length:0 }); if(!token) return; _nativeTok=token;
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; pdbg('device-post-ok', { plat }); console.log('[push] native device registered'); }
catch(e){ pdbg('device-post-fail', { err:String((e&&e.message)||e) }); console.warn('[push] device register failed:', e); } });
PN.addListener('registrationError', (e)=>{ pdbg('registration-error', { err:(e&&(e.error||e.message))||JSON.stringify(e) }); console.warn('[push] native registration error:', e); });
// Tapping an OS notification opens the relevant chat (payload carries kind+id).
PN.addListener('pushNotificationActionPerformed', (a)=>{ try{ const d=(a&&a.notification&&a.notification.data)||{}; if(d.id) selectChat(d.kind||'dm', d.id); }catch(_){} });
let perm=await PN.checkPermissions();
if(perm.receive!=='granted') perm=await PN.requestPermissions();
if(perm.receive!=='granted'){ console.log('[push] native permission not granted'); return true; }
await PN.register();
let perm=await PN.checkPermissions(); pdbg('perm-check', { receive: perm&&perm.receive });
if(perm.receive!=='granted'){ perm=await PN.requestPermissions(); pdbg('perm-after-request', { receive: perm&&perm.receive }); }
if(perm.receive!=='granted'){ pdbg('perm-not-granted', { receive: perm&&perm.receive }); console.log('[push] native permission not granted'); return true; }
await PN.register(); pdbg('register-called');
console.log('[push] native push registered');
}catch(e){ console.warn('[push] native push setup failed:', e); }
}catch(e){ pdbg('native-setup-exception', { err:String((e&&e.message)||e) }); console.warn('[push] native push setup failed:', e); }
return true; // handled the native path (skip Web Push regardless of outcome)
}
async function setupPush(){
@@ -3766,6 +3777,90 @@ async function unsubscribePush(){
pushActive=false; console.log('[push] unsubscribed (logout)');
}catch(_){}
}
// ---------- Native calling (iOS CallKit + PushKit VoIP) ----------
// The native-call plugin rings incoming calls via CallKit (full-screen, works when the app is killed) and
// reports its VoIP token; on answer/end it fires events we bridge into the existing meeting join/leave. On
// a CallKit device the SYSTEM owns the incoming ring, so we suppress the in-app call-invite popup
// (onDmCall / the group invite check nativeCallOn()).
let _callkitReady=false;
let _nativeAnswered=new Set(); // rooms this device answered natively (so a "call-taken" from the server doesn't end our own call)
function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; }
function nativeCallOn(){ return _callkitReady; }
async function setupNativeCall(){
const NC=nativeCallPlugin(); if(!NC) return;
// Server kill-switch: only take over calls with CallKit when the server enables it (callkit:true). When
// off, calls use the WebView flow (mic works). Flipped on only once native media carries the audio.
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
_callkitReady=true;
// VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes.
NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); });
try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){}
// Native media: the plugin carries the call over its OWN LiveKit room. The WebView must NOT also join
// (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server
// the call was answered (native media bypasses our WS, so the server can't learn it otherwise) and clear
// the in-app invite. Track answered rooms so end vs decline is signalled correctly.
NC.addListener('answerCall', (d)=>{ try{
pdbg('nc-answer', { room:(d&&d.room)||'', hasUrl:!!(d&&d.livekitUrl), hasToken:!!(d&&d.livekitToken) });
if(!d||!d.room) return;
_nativeAnswered.add(d.room);
dismissCallInvite(d.room);
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
// tile, and wires the end-both-sides lifecycle. The plugin already holds the LiveKit media, so this joins
// native (no 2nd SFU connection). meetReturn = the chat to land back on when the call ends.
const isGroup=(d.kind==='group');
meetReturn = isGroup ? { kind:'group', id:d.groupId } : { kind:'dm', id:d.callerId };
switchTab('meeting'); enterMeeting(d.room, false, { native:true, uuid:d.callUUID });
}catch(e){ pdbg('nc-answer-err', { err:String((e&&e.message)||e) }); } });
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} } });
NC.addListener('audioActivated', (e)=>{ pdbg('nc-audio', { ok:!!(e&&e.ok), err:(e&&e.error)||'', route:(e&&e.route)||'' }); }); // CallKit audio-session activation + output route (debug audio)
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); });
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
// Ended on the CallKit system screen / plugin ended on remote hang-up. Leave the meeting window too (guarded
// so our own in-app hang-up, which already called the plugin, doesn't loop). Then tell the server.
NC.addListener('endCall', (d)=>{ try{
const room=d&&d.room; pdbg('nc-end', { room:room||'', answered:_nativeAnswered.has(room) });
if(!room) return;
dismissCallInvite(room);
if(_ncEnding){ _ncEnding=false; }
else if(meetState==='call' && meetRoom===room){ leaveMeeting(true); }
if(_nativeAnswered.has(room)){ _nativeAnswered.delete(room); postJSON('/api/calls/end',{ room }).catch(()=>{}); }
else { postJSON('/api/calls/decline',{ room }).catch(()=>{}); }
}catch(_){} });
console.log('[callkit] native calling ready');
}
// Start an OUTGOING native call: fetch a LiveKit join token for the room, then have the plugin start the
// CallKit call AND connect the LiveKit room natively (the WebView does NOT join — one connection per identity).
async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){
const NC=nativeCallPlugin(); if(!NC||!uuid) return;
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room }); }catch(_){}
pdbg('nc-outgoing', { room, hasUrl:!!(tk&&tk.url), hasToken:!!(tk&&tk.token) });
try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo, url:tk.url||'', token:tk.token||'' }); }catch(_){}
}
// End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit.
function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} }
// Ring CallKit from a WebSocket call event — a 2nd, reliable path alongside the VoIP push for when the app is
// OPEN (the push can be delayed/dropped). Deduped by UUID in the plugin, so double-firing is harmless.
async function nativeReportIncoming(o){
const NC=nativeCallPlugin(); if(!NC||!o||!o.callUUID||!o.room) return;
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room:o.room }); }catch(_){}
// .catch: on older builds without this plugin method, Capacitor REJECTS the promise (not a sync throw) — swallow it.
try{ const p=NC.reportIncomingCall({ callUUID:o.callUUID, room:o.room, kind:o.kind||'dm', callerId:o.callerId||'', callerName:o.callerName||'Incoming call', groupId:o.groupId||'', groupName:o.groupName||'', hasVideo:false, url:(tk&&tk.url)||'', token:(tk&&tk.token)||'' }); if(p&&p.catch) p.catch(()=>{}); }catch(_){}
}
// NATIVE calls use your REAL meeting window: the plugin owns this user's ONE LiveKit connection (media +
// CallKit ring/background/lock-screen), and the WebView joins the same mesh room for the UI — so the caller/
// callee tiles, roster, mute state and the whole answer/end lifecycle are the normal meeting code. The only
// difference vs a web call is meetNative=true → the WebView does NOT open its own SFU media connection (that
// would be a 2nd connection for the same identity), and mute/hang-up bridge to the plugin.
// Another of the callee's devices answered — stop ringing here (never on the device that answered).
function onCallTaken(d){ if(!d||!d.room) return; if(_nativeAnswered.has(d.room)) return; dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); }
// The callee picked up (server signal; the mesh peer-join already clears these on the caller — belt & braces).
function onCallAnswered(d){ if(d&&d.room && meetRoom===d.room){ stopRingback(); removeWaitingTile(); _dmCallWaiting=null; } }
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
// notification click is not an in-page gesture, so an in-place open won't paint until you
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
@@ -4086,7 +4181,7 @@ function connectChatWs(){
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
}catch(_){}
}
@@ -4342,10 +4437,20 @@ function sfuRebuild(pid){
addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st);
}
function sfuAttach(pub, track, participant, _try){
const pid=peerIdForUid(participant.identity);
if(!pid){ // #9: uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times
if((_try||0)<10){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); }
return;
let pid=peerIdForUid(participant.identity);
if(!pid){ // uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times
if((_try||0)<6){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); return; }
// …then fall back: a NATIVE (CallKit + LiveKit) participant joins LiveKit but NOT our WS mesh, so it has
// no mesh peer id. Key its tile + audio by the LiveKit identity so it still shows and is heard. The
// outgoing "Ringing…" placeholder is cleared here (its mesh 'answered' signal never fires for native),
// and we label the tile from the contact roster so it shows a NAME + DP, not a raw user id.
pid='lk:'+participant.identity;
removeWaitingTile(); stopRingback();
if(!meetNames.get(pid)){
const c=(typeof CONTACTS!=='undefined'?CONTACTS:[]).find(x=>x&&x.id===participant.identity);
meetNames.set(pid, (c&&c.name) || (_dmCallWaiting&&_dmCallWaiting.name) || participant.name || 'Caller');
if(c&&c.avatar) meetAvatars.set(pid, c.avatar);
}
}
const p=SFU.peers.get(pid)||{}; SFU.peers.set(pid,p); const mt=track.mediaStreamTrack;
if(track.kind==='audio') p.audio=mt;
@@ -4354,7 +4459,7 @@ function sfuAttach(pub, track, participant, _try){
sfuRebuild(pid); updateShareMode();
}
function sfuDetach(pub, track, participant){
const pid=peerIdForUid(participant.identity); if(!pid) return; const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack;
let pid=peerIdForUid(participant.identity); if(!pid) pid='lk:'+participant.identity; const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack;
if(p.audio===mt) p.audio=null; else if(p.screen===mt){ p.screen=null; meetSharers.delete(pid); setTileScreen(pid,false); } else if(p.cam===mt) p.cam=null;
sfuRebuild(pid); updateShareMode();
}
@@ -5201,9 +5306,11 @@ function bzUnlockAudio(){
}
document.addEventListener('touchend', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true}); // fallback: any tap during a call restarts silent remote audio
document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});
async function enterMeeting(code, audioOnly){
let meetNative=false, meetNativeUuid=null, _ncEnding=false; // native call: WebView joins the mesh for UI; the plugin owns media/CallKit
async function enterMeeting(code, audioOnly, opts){
bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join
meetNative=!!(opts&&opts.native); meetNativeUuid=(opts&&opts.uuid)||null; // native → skip our own SFU media; mute/end bridge to the plugin
// Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring).
// Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces
// we clear ALL open invites, since you can only be in one call at a time.
@@ -5213,7 +5320,7 @@ async function enterMeeting(code, audioOnly){
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
meetLocalStream=new MediaStream();
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null;
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null; // answer MUTED by default (house rule) — tap Mic to speak
meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false;
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
@@ -5237,7 +5344,9 @@ async function onMeetMsg(e){
if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
if(SFU.on){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isnt active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
if(SFU.on && !meetNative){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isnt active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
// native: the plugin already holds the LiveKit media (one connection per identity) — we joined the mesh
// for the UI only. Media is native; tell peers our mic is live.
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
refreshMeetPanel(); updateHostControls();
@@ -5288,6 +5397,7 @@ function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
async function toggleMic(){
if(!meetLocalStream) return;
if(meetNative){ const next=!meetMic; const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!next }); }catch(_){} } meetMic=next; updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } // native: mute the plugin's mic
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
const hasTrack=meetLocalStream.getAudioTracks().length>0;
if(!hasTrack){
@@ -5305,6 +5415,7 @@ async function toggleMic(){
// renegotiates with every peer, so you can always turn video on once a meeting has started.
async function toggleCam(){
if(!meetLocalStream) return;
if(meetNative){ toast('Video isnt available on native calls yet'); return; } // native path is audio-only for now (video rendering is the next phase)
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
const hasTrack=meetLocalStream.getVideoTracks().length>0;
if(!hasTrack){
@@ -5328,6 +5439,10 @@ let meetLeaving=false;
// there wrongly announced "Host handed to <the person who just left>" (bug).
function leaveMeeting(forced){
if(meetLeaving) return; meetLeaving=true;
// Native call: also end the CallKit/plugin call. _ncEnding tells the plugin's endCall listener this leave
// originated here, so it doesn't call leaveMeeting again (the plugin ending the call re-fires endCall).
if(meetNative && meetNativeUuid){ _ncEnding=true; callkitEnd(meetNativeUuid); }
meetNative=false; meetNativeUuid=null;
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: any call exit stops the ring + ringing tile
const isDm=!!(meetReturn && meetReturn.kind==='dm');
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
@@ -5655,6 +5770,7 @@ window.addEventListener('message',(e)=>{
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
wireUpdateBanner(); // #3: desktop update-progress banner
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
setupNativeCall(); // iOS CallKit: register VoIP token + bridge answer/end to the meeting join/leave
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
// Fast-path: when opened from a notification, show the chat immediately (only needs the
+10
View File
@@ -0,0 +1,10 @@
// Swappable pub/sub for cross-instance real-time fan-out. The app delivers to its OWN WebSocket clients
// locally (chat.js) exactly as before; this layer only carries a copy to OTHER app instances so a message
// POSTed on instance A reaches a recipient whose socket lives on instance B.
//
// Backend chosen by PUBSUB_BACKEND (default 'memory'). 'memory' = single instance: publish is a no-op and
// subscriptions never fire, so behaviour is identical to before this layer existed — zero hot-path cost.
// 'redis' fans out via Redis. The interface (publish/subscribe/init) is deliberately tiny so Redis is one
// swappable file — a Postgres LISTEN/NOTIFY or NATS backend could drop in the same way. Never hardwired.
const name = process.env.PUBSUB_BACKEND || 'memory';
module.exports = require('./pubsub/' + name);
+9
View File
@@ -0,0 +1,9 @@
// Single-instance pub/sub backend. There are no OTHER app instances, so a cross-instance publish has
// nowhere to go (no-op) and remote subscriptions never fire. All real-time delivery happens locally in
// chat.js — this is exactly the pre-pubsub behaviour, at zero cost. The default backend.
module.exports = {
name: 'memory',
init: () => Promise.resolve(),
publish: () => {}, // no other instance to reach
subscribe: () => {}, // nothing remote will ever arrive
};
+46
View File
@@ -0,0 +1,46 @@
// Redis pub/sub backend — enables running MULTIPLE app instances. Each instance publishes every local
// real-time event; every other instance receives it and delivers to ITS local sockets. Selected by
// PUBSUB_BACKEND=redis; connection from REDIS_URL (default redis://bizgaze-redis:6379).
//
// Self-echo guard: Redis delivers a publish to ALL subscribers including the publisher, but the publishing
// instance ALREADY delivered locally — so every message is tagged with this instance's id and ignored on
// the way back in. Subscriptions made before connect are buffered and flushed in init().
const crypto = require('crypto');
const INSTANCE = crypto.randomBytes(8).toString('hex');
let pub = null, sub = null;
const pending = []; // [pattern, handler] queued before connect
async function doSubscribe(pattern, handler) {
const onMessage = (message, channel) => {
let m; try { m = JSON.parse(message); } catch { return; }
if (m.i === INSTANCE) return; // our own publish — already delivered locally
try { handler(channel, m.d); } catch (_) {}
};
if (pattern.includes('*')) await sub.pSubscribe(pattern, onMessage);
else await sub.subscribe(pattern, onMessage);
}
async function init() {
const { createClient } = require('redis');
const url = process.env.REDIS_URL || 'redis://bizgaze-redis:6379';
pub = createClient({ url });
sub = pub.duplicate();
pub.on('error', () => {}); sub.on('error', () => {}); // never let a redis blip crash the app
await pub.connect();
await sub.connect();
for (const [pattern, handler] of pending) { try { await doSubscribe(pattern, handler); } catch (_) {} }
pending.length = 0;
}
function publish(channel, data) {
if (!pub) return;
pub.publish(channel, JSON.stringify({ i: INSTANCE, d: data })).catch(() => {});
}
function subscribe(pattern, handler) {
if (!sub) { pending.push([pattern, handler]); return; } // buffer until init() connects
doSubscribe(pattern, handler).catch(() => {});
}
module.exports = { name: 'redis', init, publish, subscribe };
+75 -2
View File
@@ -88,7 +88,9 @@ function sendApns(token, payload) {
let client;
try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
const body = JSON.stringify({ aps: { alert: { title: payload.title || 'Biz Connect', body: payload.body || '' }, sound: 'default' }, ...(payload.data || {}) });
// Custom bundled sound (notif.wav, shipped in the app via ios-patch.sh + add-share-extension.rb) gives
// chat/call notifications an explicit, distinctive tone. A call site may override via payload.sound.
const body = JSON.stringify({ aps: { alert: { title: payload.title || 'Biz Connect', body: payload.body || '' }, sound: payload.sound || 'notif.wav' }, ...(payload.data || {}) });
const req = client.request({ ':method': 'POST', ':path': '/3/device/' + token, authorization: 'bearer ' + apnsToken(), 'apns-topic': apnsCfg.bundle, 'apns-push-type': 'alert' });
let status = 0;
req.on('response', (h) => { status = h[':status']; });
@@ -99,6 +101,77 @@ 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 && process.env.CALLKIT_ENABLED === '1') { // kill-switch: off → fall through to a banner
const payload = {
type: 'invite',
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,
// LiveKit join credentials so the native plugin can connect the room immediately on answer — even
// from a killed state, before the WebView has loaded.
livekitUrl: data.livekitUrl || '', livekitToken: data.livekitToken || '',
};
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';
}
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out). Sent as a
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call.
// No-op for non-VoIP devices (their ring is a normal notification that just goes away).
async function sendCallCancel(userId, callUUID) {
if (!apnsCfg || !callUUID || process.env.CALLKIT_ENABLED !== '1') return;
let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
}
}
// ---------------- public API ----------------
const nativeReady = !!(fcmSA || apnsCfg);
const enabled = [webReady && 'WebPush', fcmSA && 'FCM', apnsCfg && 'APNs'].filter(Boolean);
@@ -134,4 +207,4 @@ async function sendToUser(userId, payload) {
}
}
module.exports = { isEnabled, publicKey, sendToUser };
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel };
+39 -19
View File
@@ -116,7 +116,7 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY } = require('./config');
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED } = require('./config');
const https = require('https');
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
function fetchJSON(url) {
@@ -137,22 +137,9 @@ const crypto = require('crypto');
const MAX_UPLOAD_MB = parseInt(process.env.MAX_UPLOAD_MB, 10) || 1024; // default 1 GB per chat attachment
const MAX_FILE_BYTES = MAX_UPLOAD_MB * 1024 * 1024; // NOTE: also raise Nginx Proxy Manager's client_max_body_size to match (default is 1 MB) or large uploads are rejected at the proxy before reaching here.
// Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT
// approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+
// subscribe on exactly one room, as one identity. Secret stays server-side.
const _b64u = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
function livekitToken(identity, name, room, metadata) {
const nowSec = Math.floor(Date.now() / 1000);
const header = _b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = _b64u(JSON.stringify({
iss: LIVEKIT_API_KEY, sub: identity, name: name || identity,
nbf: nowSec, exp: nowSec + 6 * 3600, // 6h — long enough for any meeting
metadata: metadata || '',
video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
}));
const sig = _b64u(crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(header + '.' + payload).digest());
return header + '.' + payload + '.' + sig;
}
// LiveKit access-token minting lives in ./livekit (shared with calls.js, which mints a token for the native
// VoIP call payload). Same hand-rolled HS256 JWT — grants join+publish+subscribe on one room, one identity.
const { livekitToken } = require('./livekit');
// Issue a refresh token (native clients), store only its hash, return the plaintext once.
async function issueRefreshToken(userId) {
@@ -412,7 +399,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 });
});
@@ -424,6 +412,18 @@ route('POST', '/api/devices/remove', async (req, res) => {
json(res, 200, { ok: true });
});
// --- Push diagnostics (temporary): the native app reports each step of push setup here so we can see
// WHERE iOS registration fails without a Mac/device console. Best-effort; logs and returns 200. ---
route('POST', '/api/push-debug', async (req, res) => {
try {
const b = await readBody(req);
let uid = 'anon'; try { const u = await currentUser(req); if (u) uid = u.id; } catch (_) {}
const line = (typeof b === 'object' ? JSON.stringify(b) : String(b)).slice(0, 800);
console.log('[push-debug] user=' + uid + ' ' + line);
} catch (_) {}
json(res, 200, { ok: true });
});
// --- App install telemetry: records each install and, once the user signs in, who's using it. ---
route('POST', '/api/telemetry/install', async (req, res) => {
const { installId, platform, appVersion, os } = await readBody(req);
@@ -982,7 +982,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route('GET', '/api/meetings/config', (req, res) => {
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' });
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED });
});
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
@@ -1074,6 +1074,26 @@ route('POST', '/api/calls/decline', async (req, res) => {
json(res, 200, await CALLS.declineDmCall(String(room), u));
});
// --- Native-call lifecycle. NATIVE (CallKit + LiveKit) calls carry media over LiveKit, NOT our mesh/WS, so
// the server can't learn from a room emptying that a native call was answered/ended. The app signals it
// explicitly. (WebView/mesh calls keep using the WS room lifecycle — these are additive no-ops there.) ---
route('POST', '/api/calls/answered', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { room } = await readBody(req);
if (!room) return json(res, 400, { error: 'room required' });
try { CALLS.markDmAnswered(String(room), u.id); } catch (_) {}
json(res, 200, { ok: true });
});
route('POST', '/api/calls/end', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { room } = await readBody(req);
if (!room) return json(res, 400, { error: 'room required' });
try { await CALLS.endCallByRoom(String(room)); } catch (_) {}
json(res, 200, { ok: true });
});
// Toggle "only admins can add/remove members" (any admin).
route('POST', '/api/groups/admin-only', async (req, res) => {
const u = await currentUser(req);
+7 -1
View File
@@ -40,6 +40,7 @@ wss.on('connection', onConnection);
// no-op (the schema is already applied synchronously when db.js is required). Serving only starts once the
// store is ready, so the first request can never hit a missing table.
const db = require('./dbx');
const pubsub = require('./pubsub');
function startListening() {
server.listen(PORT, () => {
@@ -72,6 +73,11 @@ function startListening() {
}
}
db.init().then(startListening).catch((e) => { console.error('DB init failed:', (e && e.message) || e); process.exit(1); });
// DB schema first, then the pub/sub layer (redis connects + flushes buffered subscriptions; memory is a
// no-op), then serve. A pubsub failure must NOT block booting — degrade to local-only delivery.
db.init()
.then(() => pubsub.init().catch((e) => console.error('pubsub init failed (local-only delivery):', (e && e.message) || e)))
.then(startListening)
.catch((e) => { console.error('DB init failed:', (e && e.message) || e); process.exit(1); });
module.exports = { server };
+3
View File
@@ -92,6 +92,9 @@ async function handle(ws, m, req) {
CHAT.register(u.id, ws);
ws.send(JSON.stringify({ type: 'chat-ready' }));
CHAT.broadcastPresence(u.id); // tell contacts this user just came online
// Re-ring any call this user is currently being called into (missed while their app was closed) —
// makes the call push actionable: opening the app resurfaces the invite so they can answer.
try { require('./calls').replayActiveCalls(u.id, ws); } catch (_) {}
break;
}
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.