Compare commits

...
130 Commits
Author SHA1 Message Date
SravanandClaude Opus 4.8 5f755fbf6c Add TECHSTACK.md — living tech-stack reference
Full stack: plain Node (http/https + ws, custom router, no framework),
PostgreSQL 16 (SQLite retired), vanilla-JS web SPA, Capacitor 8 mobile with
custom native-call/audio/media/share plugins, two Electron clients, LiveKit
SFU + WebRTC mesh, Web Push/FCM/APNs, Docker + Nginx Proxy Manager, Codemagic
CI. Includes a maintenance rule + changelog so it's kept current.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-27 12:16:20 +05:30
SravanandClaude Opus 4.8 0edbc3f459 Android calls: ring sounds even when unlocked (call-scoped Ringer)
On an unlocked Android 14 phone the OS shows our call as a heads-up and does
NOT launch the full-screen IncomingCallActivity (full-screen-intent is
restricted for non-dialer apps while unlocked), so the ring — which lived in
the activity — was silent when unlocked (it rang only when locked, where the
activity launches).

Move the ring to a call-scoped Ringer (looping ringtone + vibration) started
in CallStore.showIncoming and stopped in CallStore.cancel (which every
answer/decline/cancel/end path already calls). The notification channel stays
silent, so there's still a single ring source. IncomingCallActivity no longer
rings — it's just UI — and no longer stops the ring on destroy (backgrounding
the call screen keeps ringing like a real call until answer/decline/timeout).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 21:36:35 +05:30
SravanandClaude Opus 4.8 da5b44c0a5 Call push: ring ALL of a user's devices, not just the first
sendCallNotification returned after the iOS VoIP branch, so a user signed in
on both an iPhone (ios-voip token) and an Android phone only got the call on
iOS — the Android invite was never sent, so a swiped-away Android phone never
rang (confirmed on-device: only type=cancel reached Android, never
type=invite, since sendCallCancel does fan out to all platforms). Now send to
iOS VoIP AND Android FCM, and only fall back to the banner when no native
device was waked. Server-only; no app rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 20:40:46 +05:30
SravanandClaude Opus 4.8 cab0c79f57 Android calls: single-source ring (fix double-ring), drop battery dialog, add wake logging
On-device (OnePlus, build 5cb3d0c): new ring UI confirmed; backgrounded rang
twice (channel sound + activity ringtone, and both wake paths raised it);
battery dialog deemed unprofessional (WhatsApp/Teams don't prompt it).

- Double-ring: notification channel is now SILENT (new id bzc_incoming_calls_v2,
  no sound/vibration, onlyAlertOnce) so IncomingCallActivity is the single ring
  source (looping ringtone + vibrator). showIncoming dedups atomically
  (if(!active.add(uuid)) return) so the FCM push and WebSocket paths can't both
  ring the same call.
- Battery dialog removed: dropped requestCallReliability (web call + plugin
  method) and REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission.
- Logging: Log.i(bzcall, ...) across the wake chain to diagnose swiped-away
  (OnePlus force-kills on swipe and blocks FCM cold-start).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-28 18:53:06 +05:30
SravanandClaude Opus 4.8 5cb3d0c122 Android calls: professional incoming UI + killed-state ring reliability
Addresses on-device feedback from Increment 1:
- #2 Professional incoming screen: IncomingCallActivity redesigned — brand-
  blue backdrop, circular avatar with the caller's initial, name + call
  type, and two round icon buttons (red hang-up = Decline, green phone =
  Accept) instead of the full-screen colored buttons. New vector drawables
  ic_call_answer / ic_call_decline (also used on the notification actions).
- #1 Killed-state ring: the missed-call notification arriving while killed
  proves FCM reaches the app, so the ring (high-priority DATA) is being
  held by OEM battery management (OnePlus/Oppo Doze) until the call is over.
  Added REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + a requestCallReliability()
  plugin method that prompts the "allow background activity" dialog once;
  the web calls it on Android call setup. (OEM auto-launch is a manual
  toggle with no API — guide the user.)

#3 (speaker/Bluetooth) and #4 (background audio) ride Increment 2 (native
LiveKit Android media + foreground service) — kept separate so the LiveKit
dependency can't block these UI/reliability fixes (the iOS coupling lesson).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-27 15:15:18 +05:30
SravanandClaude Opus 4.8 b60ce6cda3 Android native calling — Increment 1: full-screen incoming ring + answer
First slice of Android Phase C (full LiveKit parity), mirroring how iOS
started: native full-screen incoming call UI that wakes the app from
closed, with call MEDIA still in the WebView (foreground). Native LiveKit
media + a foreground service for true background audio are Increment 2.

Native (new Android side of the native-call plugin, Java to match the
proven official-plugin module layout):
- NativeCallPlugin: registered as window.Capacitor.Plugins.NativeCall
  (same jsName as iOS, so the shared web bridge drives both). getToken
  (FCM token), reportIncomingCall (ring), reportOutgoingCall, endCall,
  setMuted; video/screen/hole-punch/transcript methods are safe no-ops
  until later increments so the shared bridge never rejects.
- BzCallMessagingService: extends @capacitor/push-notifications'
  MessagingService — handles high-priority call-wake DATA messages
  (data.bzcall==1) to ring when closed, forwards all other push via super.
- IncomingCallActivity: full-screen call screen over the lock screen
  (showWhenLocked/turnScreenOn), Accept/Decline, looping ringtone.
- CallStore: incoming-call notification (channel, full-screen-intent,
  actions) + UUID dedup so the FCM and WebSocket paths don't double-ring.
- Plugin manifest: BzCallMessagingService + IncomingCallActivity + call
  perms (USE_FULL_SCREEN_INTENT/VIBRATE/WAKE_LOCK).
- android-patch.js: removes Capacitor's MessagingService in the APP
  manifest (highest merge priority) so ours is the sole MESSAGING_EVENT
  receiver; ensures the tools namespace. Idempotent.
- package.json: capacitor.android.src wired.

Server (dormant until ANDROID_CALLS=1):
- push.js sendFcmData: high-priority data-only FCM (rings when closed).
  sendCallNotification rings 'android' tokens via it; sendCallCancel too.
- config.js ANDROID_CALLS flag; /api/meetings/config exposes androidCall.

Web (home.html) — platform-aware, iOS/web behavior byte-for-byte unchanged:
- setupNativeCall: Android gated on cfg.androidCall, sets _callWebViewMedia
  (native ring, WebView media); iOS keeps CallKit native-media path.
- answerCall + both outgoing sites branch on _callWebViewMedia so Android
  joins the meeting normally while the callee still rings via the server.

Safe to deploy now: dormant until ANDROID_CALLS=1 AND an APK carrying the
plugin is installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 11:44:52 +05:30
SravanandClaude Opus 4.8 a8e0f549d4 Fix Android keyboard gap: don't double-offset the composer
Device logs showed Android's interactive-widget=resizes-content already shrinks
the layout viewport to above the keyboard (innerHeight 800->472 for a 328px
keyboard), yet the app also lifted the composer by --kb=328 → a keyboard-height
white gap. Now gate the manual --kb lift to iOS only (WKWebView doesn't resize);
on Android keep --kb=0 and just toggle kb-open so --sab collapses and the composer
sits flush to the keyboard. Also removes the temporary kbdiag logging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 10:49:45 +05:30
SravanandClaude Opus 4.8 1ece8e1061 System bars: lock in Option 1 (brand-blue bars) on iOS + Android
User picked the brand-blue look for both platforms. Apply .sysbar-blue on any
native platform; set white icons via the safe-area plugin on Android and via
@capacitor/status-bar on iOS. capacitor.config statusBarStyle/navigationBarStyle
-> DARK (light icons) so native builds match before JS runs. iOS is live, so this
web change reaches it immediately — verify on an iPhone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:13:55 +05:30
SravanandClaude Opus 4.8 03de639e1a Android system bars — Option 2 preview: white bars + brand separator line
Swap the class to sysbar-sep (thin 1.5px brand line under the status bar + above
the nav bar, bars stay white) and icons back to dark. Second of the two looks for
the user to compare; pick one after this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:09:39 +05:30
SravanandClaude Opus 4.8 133208ae3f Android system bars — Option 1 preview: brand-blue bars + white icons
Fill the status/nav inset regions with brand blue (#1F3B73) via html.sysbar-blue
pseudo-elements and switch the bar icons to light so they read on blue. This is
one of two looks for the user to compare on-device; Option 2 (white + separator)
is already in the CSS and swaps in by changing the class + icon style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:07:31 +05:30
SravanandClaude Opus 4.8 703cf1fcf0 TEMP: keyboard diagnostics for the Android repeat-open gap
Behavior-neutral console logging on keyboard show/hide (kb height, innerHeight,
clientHeight, visualViewport height, --kb) to determine whether the Android
WebView resizes (interactive-widget) or relies on the manual --kb lift, and to
spot any residual between the first and second open. Remove once fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:00:47 +05:30
SravanandClaude Opus 4.8 9d5af66a0e Android: dark system-bar icons (visible on the white app)
Under Android edge-to-edge the status/nav bar icons default to light (white) and
vanished on the white app (clock/battery/nav invisible). Set SafeArea
statusBarStyle/navigationBarStyle to LIGHT (= dark content on a light background)
in capacitor.config.json, and add a runtime SafeArea.setSystemBarsStyle call in
home.html so already-installed APKs get dark icons on relaunch (no rebuild). iOS
unaffected (its status bar already shows dark content on white).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 00:24:39 +05:30
SravanandClaude Opus 4.8 ab6d8161ee push: accept FCM_SERVICE_ACCOUNT_B64 (base64 service-account key)
Base64 is a single env-safe token, so the FCM service-account key can live in
.env without the quoting/interpolation hazards of inline JSON. Falls back to the
existing FCM_SERVICE_ACCOUNT (inline JSON or file path). No behavior change when
neither is set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 00:04:31 +05:30
SravanandClaude Opus 4.8 c013536e2b Android: wire Firebase google-services.json into the build (client half)
Commit the client Firebase config (bizgaze-connect, package com.bizgaze.connect)
and have the Codemagic Android step copy it into android/app/ so the google-services
Gradle plugin applies and FirebaseApp initializes — fixing the root cause of the
"Default FirebaseApp is not initialized" crash. google-services.json is client-side
(ships in the APK), safe for this private repo. The service-account key stays OUT of
git (.gitignore: *firebase-adminsdk*.json / *service-account*.json) — it goes on the
server as FCM_SERVICE_ACCOUNT separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-17 19:22:22 +05:30
SravanandClaude Opus 4.8 ba75dc8daa Android: fix black gap above keyboard (SafeArea/SystemBars config)
The safe-area plugin logged: set SystemBars.insetsHandling to "disable". Under
Android edge-to-edge (targetSdk 36) this + offsetForKeyboardInsetBug were causing
a black strip between the input and the soft keyboard on the login view. Set
SystemBars.insetsHandling="disable" and SafeArea.offsetForKeyboardInsetBug=true.
Native config — takes effect on the next Android build; verify on device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 21:57:35 +05:30
SravanandClaude Opus 4.8 a4726dd10c Fix Android crash: don't register push without Firebase
On Android, PushNotifications.register() calls FirebaseMessaging.getInstance(),
which throws "Default FirebaseApp is not initialized" when the build has no
google-services.json. Capacitor runs the plugin method on its own thread, so the
exception is an UNCAUGHT NATIVE crash a JS try/catch can't stop — the app died
right after sign-in and on every relaunch.

Gate Android push registration on a new server flag: /api/meetings/config now
returns fcm (push.fcmReady() = FCM_SERVICE_ACCOUNT present). setupNativePush skips
register() on Android unless fcm is true. Web-side fix — deploys without a rebuild;
push auto-enables once Firebase (client google-services.json + server FCM) is set up.
iOS/APNs unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 21:55:09 +05:30
SravanandClaude Opus 4.8 b03a32b10d Android CI: use JDK 21 (Capacitor 8 Android requires it)
@capacitor/android is ^8; Capacitor 8's Android toolchain (AGP 8.7 / Gradle 8.11)
requires JDK 21. The workflow used java: 17, which fails the Gradle build before an
APK is produced. Bump to java: 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:36:47 +05:30
SravanandClaude Opus 4.8 e39828ed56 Android CI: fail the build on a Gradle error + surface the real cause
The build step ended with `find … *.apk`, which exits 0 even when Gradle failed
and no APK exists — so Codemagic marked a failed Gradle run as a green build with
no artifact. Now: capture gradle output, use PIPESTATUS to detect failure, print
the "What went wrong" / FAILURE block, exit non-zero, and assert an APK exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:35:39 +05:30
SravanandClaude Opus 4.8 35ce641046 Android CI: run on mac_mini_m2 (linux_x2 not on the billing plan)
The android-apk workflow requested linux_x2, which Codemagic rejected with
"selected instance type is not available with the current billing plan". Switch
to mac_mini_m2 — the same instance the iOS workflow already uses on this account;
Codemagic's macOS images include the Android SDK + JDK. Also make the SDK path
(ANDROID_SDK_ROOT -> ANDROID_HOME fallback) and the google-services base64 decode
(GNU --decode / BSD -D) portable to the macOS image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:08:52 +05:30
SravanandClaude Opus 4.8 1efb2e4314 Fix: message text not selectable on desktop
#msgs .bubble had a blanket user-select:none (added to suppress iOS's long-press
magnifier), which also blocked mouse text-selection on desktop. Scope the
none to touch devices only (@media (hover:none)); keep it in multi-select mode.
Desktop can now highlight/copy message text. Verified: desktop=auto,
touch=none, sel-mode=none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 19:23:21 +05:30
SravanandClaude Opus 4.8 677d418ed0 Fix: dragging multiple files into a chat only sent the first
The conversation drop handler read dataTransfer.files[0] (a single file), so a
multi-file drag-and-drop uploaded just one. Iterate every dropped file, matching
the file-picker path. Verified in-browser: dropping 3 files queues + sends all 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 18:22:57 +05:30
SravanandClaude Opus 4.8 07c728bf6a Android CI build + delete-chat (self-only) + SFU copy refresh
- codemagic.yaml: add an Android workflow that builds an installable debug
  APK (Linux instance, no signing needed to test) — the Android equivalent of
  the iOS TestFlight loop. mobile/scripts/android-patch.js injects the
  camera/mic/notification permissions into the CI-generated manifest (android/
  is gitignored, same as ios/).
- Delete chat (self-only): new POST /api/messages/clear bulk-hides a whole DM
  (or group) for the requester via the existing per-user message_hidden
  mechanism — the other party keeps their copy entirely. "Delete chat" button
  added to the DM contact-info panel; chat-cleared synced to my other devices.
  Verified end-to-end on Postgres (A clears -> empty for A, B untouched).
- Meetings copy: SFU is live, so drop the "coming soon" / "small group (mesh)"
  wording on the welcome card and the Meetings header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 17:12:42 +05:30
SravanandClaude Opus 4.8 948eae249f App Store: add 13-inch iPad screenshots (universal build requires them)
The Capacitor build is universal (iPhone + iPad), so App Store Connect requires
iPad screenshots. Added three 2064x2752 (13" iPad) shots rendered on the app's
two-pane iPad layout (chat sidebar + open conversation, meetings, schedule),
anonymized. No rebuild needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 16:13:55 +05:30
SravanandClaude Opus 4.8 88a6b6a21e App Store: add screen-sharing screenshot (6 total)
06-screenshare.png — a meeting with a shared screen (a slide + chart on the
stage, "Sharing" badge, participant tiles, controls), rendered on the real call
UI at 1320x2868 and anonymized. Updated the submission pack's screenshot order to
include it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 14:51:15 +05:30
SravanandClaude Opus 4.8 620039a2ff iOS remote support: Share Screen -> Connect Screen over LiveKit (core)
Rebuild the iOS remote-support screen share the RIGHT way: keep the exact
Share/Connect UX + session (consent + symmetric session-ended), swap only the
media to LiveKit since WKWebView can't getDisplayMedia. (Replaces the earlier
"route to a meeting" detour, which was reverted.)

Flow: customer taps Share Screen -> gets a 6-digit code (unchanged UI) -> helper
enters it in Connect Screen -> on the customer's "Allow", the app publishes the
screen natively (ReplayKit -> LiveKit) into a per-session room and tells the
agent it's a LiveKit session -> connect.html joins that room and shows the screen
in its existing viewer (recording/controls intact). Chat runs over the session
socket (no P2P data channel in this mode). Either side ending fires the existing
session-ended -> both tear down (symmetric disconnect).

- signaling.js: relay 'rs-livekit' + 'rs-chat' between the two ends.
- home.html: parent bridge so the /share iframe can drive startMeetingScreenShare/
  stop on the native plugin (+ a capability handshake).
- share.html (iOS): publish via native LiveKit instead of getDisplayMedia; chat
  over WS; hide mic (voice = next iteration) + remote-control (impossible on iOS).
- connect.html: LiveKit viewer for iOS-shared sessions, reusing the P2P viewer.

Web-only, no new build (reuses the shipped startMeetingScreenShare). Desktop
Share/Connect P2P unchanged. Two-way voice is the planned follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 13:16:06 +05:30
Sravan 224a2800c5 Revert "iOS "Share Screen": route to a screen-share meeting (remote-support on iPhone)"
This reverts commit eb685b42ca.
2026-08-22 12:21:36 +05:30
Sravan ba51a3c5d7 Revert "iOS Share Screen: show the code and wait; start sharing when the helper joins"
This reverts commit 1a089c0349.
2026-08-22 12:21:36 +05:30
SravanandClaude Opus 4.8 1a089c0349 iOS Share Screen: show the code and wait; start sharing when the helper joins
Fix the confusing flow: tapping Share Screen dumped the user straight into a
meeting and auto-shared, with the join code hard to find. Now it shows a clear
full-screen "Share your screen" step with the big 6-digit code + Copy + a
"Waiting for them to join…" spinner (and Cancel). The native screen broadcast
starts only when the helper actually joins with that code (meeting-peer-joined) —
matching the remote-support mental model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:00:14 +05:30
SravanandClaude Opus 4.8 eb685b42ca iOS "Share Screen": route to a screen-share meeting (remote-support on iPhone)
The remote-support Share/Connect tabs use P2P WebRTC (screen + voice + chat) that
WKWebView can't capture, so "Share Screen" never worked on iPhone. Rather than
rebuild the whole P2P session on LiveKit, route iOS "Share Screen" to a
screen-share MEETING (chosen with the user): tapping Share Screen on iOS starts
an instant meeting, auto-starts the native ReplayKit screen share (the path just
verified on device), and toasts the join code. A helper joins by code (Meeting)
to watch — and gets voice + chat for free. Viewing already works in the webview,
so only the SHARE side is rerouted; desktop keeps the full P2P remote-support
flow, and iOS Safari (no app) is unchanged.

Web-only, no new build (reuses the shipped startMeetingScreenShare native method).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 11:36:48 +05:30
SravanandClaude Opus 4.8 467a8b9c6e iOS meeting screen share: publish ReplayKit broadcast into the SFU room
Gap: on iOS, screen share only worked in native CallKit calls. In a scheduled /
code-joined SFU meeting the webview called setScreenShareEnabled() ->
getDisplayMedia(), which WKWebView does not implement, so it failed silently.
Production meetings are real LiveKit rooms, so we can publish natively instead.

Fix (native ReplayKit -> same LiveKit room as a dedicated screen participant):
- server: /api/meetings/token accepts screen:true -> mints a distinct
  <peerId>-screen identity (LiveKit allows one connection per identity), so the
  native publisher doesn't collide with the webview's own connection.
- native plugin: startMeetingScreenShare/stopMeetingScreenShare + connectScreenRoom
  (a screen-only LiveKit connection: mic off, no camera, no callConnected) that
  reuses the existing broadcast-extension publishing path.
- webview: toggleScreen routes to the native method on iOS SFU meetings; the
  screenShareState listener now drives the SFU case too (and tears the screen
  connection down on the system "Stop Broadcast"); sfuAttach/sfuDetach map the
  '<peerId>-screen' participant's screen track onto the sharer's tile and suppress
  the phantom person tile + the sharer's own self-view.

Web/server deploy now; the native method needs the next Codemagic build to test
on device. Verified: db-smoke 22/22; Swift braces balanced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 01:01:42 +05:30
SravanandClaude Opus 4.8 14eb99edaf App Store: add group-chat + schedule-meeting screenshots (5 total)
Two more anonymized 1320x2868 shots of features that work on iOS: a group
conversation (sender chips, read receipts) and the Schedule-a-call flow
(participant names anonymized). Deliberately did NOT screenshot Share/Connect:
webview screen capture (getDisplayMedia) is unavailable in WKWebView, so the
remote-support screen-share tab doesn't function on iOS — advertising it would
be inaccurate metadata. (Screen share works in native calls via ReplayKit and
fully on desktop.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 00:37:03 +05:30
SravanandClaude Opus 4.8 2948e0cfcc App Store assets: 6.9" screenshots + wired privacy/support URLs into submission pack
- mobile/appstore-screenshots/: 3 anonymized 1320x2868 screenshots generated from
  the live app (Chats, a Conversation, Meetings). Real names/faces replaced with
  demo identities, notification banner dismissed, internal roadmap copy removed.
- APPSTORE_SUBMISSION.md: pre-submission checklist now all green; Privacy Policy
  and Support URLs filled with the live /privacy and /support pages; demo login
  and export-compliance noted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 12:46:12 +05:30
SravanandClaude Opus 4.8 a9d95ed8c6 Add public /privacy and /support pages (App Store required URLs)
App Store submission needs a public Privacy Policy URL and Support URL, reachable
without login. Added branded, self-contained pages served at /privacy and
/support (static.js path mappings, like /home). The privacy policy accurately
describes Biz Connect: BizGaze-account login, messages/media stored on our
servers, call media via LiveKit (only recorded on explicit user action),
on-device transcription (audio never leaves the device), push via APNs/FCM, no
sale of data / no ads, retention, security, and user/GDPR rights. Contact:
support@bizgaze.com (confirm/replace if different).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 12:35:06 +05:30
SravanandClaude Opus 4.8 18edc5dbfb Moderation UX: Block on DM contact info + visible report-resolved notice
Tester feedback:
1. "Block contact not on the contact info." The earlier Block button was only on
   the group-sender mini-profile, which a 1:1 chat never opens. A DM's info panel
   is openSharedItems('dm',…) (tap the conversation header). Added a full-width
   "Block contact / Unblock contact" button there.
2. "Reporter gets no notification on resolution." Delivery was actually working
   (verified: the reporter receives the 'report-resolved' event over the chat WS),
   but the client only added a silent bell entry — easy to miss — and a self-
   resolve (same admin reported + resolved) is intentionally skipped. Made it a
   visible toast + ping for an online reporter (report-new likewise toasts admins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 22:39:13 +05:30
SravanandClaude Opus 4.8 928119725e Moderation follow-ups: block on contact profile, z-index, account suspend, reporter notify
Addresses tester feedback on the Report/Block feature:

1. Block/Unblock is now on the CONTACT's profile card (openMiniProfile), not only
   in a message's ⋮/long-press menu — you can block someone straight from their
   info popup.
2. Admin Delete/confirm popups were appearing BEHIND the Reports window. The
   moderation modals used z-index 100000 (above bzConfirm's .modal-ov at 9800);
   lowered them to 9750 so the confirm dialog sits on top.
3. Clarified admin action. "Block" is a PERSONAL mute (per guideline 1.2) and does
   not touch login. The report view now offers a real account action instead:
   "Suspend account" (deactivate -> signed out + cannot log in) with a confirm,
   toggling to "Reactivate account" — both reversible in-place, driven by a new
   reportedActive flag on /api/reports. (Uses the existing /api/users/manage
   deactivate/activate.)
4. Resolving a report now notifies the reporter (live 'report-resolved' event +
   background push), and admins get a 'report-new' activity entry.

repos: reports.byId. Verified: moderation suite 18/18 (adds reportedActive +
suspend->login-blocked->reactivate->login-restored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 11:50:55 +05:30
SravanandClaude Opus 4.8 10b2251efe Scheduled meetings: deliver a background push (fixes no iOS notification)
Root cause: the scheduled-meeting INVITE (routes.js) and the ~10-min REMINDER
(reminders.js) both notified only via CHAT.pushToUser — the WebSocket channel,
which only reaches an OPEN tab with a live socket. Neither called
PUSH.sendToUser, the path that produces a background APNs/FCM/web-push alert.
On iOS the webview is suspended in the background, so the WS event was simply
missed and no notification appeared. (Chat messages already call PUSH.sendToUser,
which is why chat notices arrive on iOS but meeting ones didn't.)

Fix:
- schedule invite: also PUSH.sendToUser to every invited participant + group
  members (kind:'meeting', id:roomCode) so a closed app is notified.
- reminders.js: also PUSH.sendToUser to all reminder recipients.
- client: a kind:'meeting' notification tap now opens the Meeting tab + its list
  (both the live-tab open-chat handler and the cold-boot openKind path), instead
  of calling selectChat with an unsupported kind.

Also: APPSTORE_SUBMISSION.md §8 updated — the UGC Report/Block gate (guideline
1.2) is now implemented, with a suggested reviewer note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 19:51:25 +05:30
SravanandClaude Opus 4.8 e4d361f298 Chat moderation: Report message + Block user (App Store guideline 1.2)
Apple requires user-generated-content apps to offer a way to report
objectionable content and block abusive users. The chat had neither, which is
the #1 rejection cause for messaging apps. Added both, server-enforced.

Server:
- schema: message_reports + user_blocks tables.
- repos: reports {add,listForTeam,setStatus}, blocks {add,remove,has,listFor},
  users.adminsOf(); thread + threadByConversation now exclude blocked senders in
  SQL (like message_hidden) so the LIMIT counts only visible rows (no pagination
  stall).
- routes: POST /api/messages/report, /api/users/block|unblock, GET
  /api/users/blocked, GET /api/reports + POST /api/reports/resolve (admin only).
- enforcement: a blocked sender's DM/group messages are persisted but not
  delivered (no live push, no background notification) to anyone who blocked
  them; blocked users can't ring you (/api/calls/dm/start + /api/calls/invite);
  admins can delete reported content (delete route now allows role=admin).

Client (home.html, all platforms via the web UI — no rebuild):
- message menu gains Report (canned-reason picker) + Block/Unblock.
- profile menu: "Blocked users" manager (list + unblock) for everyone;
  "Reported messages" review (delete / block / resolve) for admins.
- blocked DMs hidden from the sidebar; block list loaded on boot.
- reports route to the workspace's OWN admins (org-internal moderation).

Verified: db-smoke 22/22 + a new moderation suite 12/12 (report+admin-list,
non-admin 403, block hides post-block history but sender still sees sent,
blocked call 403, unblock restores history + calling). New flag/ban icons added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 19:46:22 +05:30
SravanandClaude Opus 4.8 7e3a94b04c iOS splash: quality-branded, crop-safe navy launch screen
The iOS splash read as unbranded for two concrete reasons:
1. The light-mode master (resources/splash.png) was on a WHITE background
   while the SplashScreen plugin paints #16294F navy -> a white->navy->app
   flash on light-mode devices.
2. The logo + "Biz Connect" wordmark spanned ~82% of the 2732 square, but the
   launch storyboard scales it scaleAspectFill; on tall iPhones ~27% of each
   side is cropped, clipping the wordmark edges.

New splash (both light + dark masters, identical navy so there is no flash):
brand navy gradient (#20396f -> #16294f) matching the plugin background, the
C-mark as hero, and the wordmark typeset in Corbel (closest installed match to
the brand geometric wordmark) sized to 1041px -> inside the ~1260px aspectFill
safe zone, so nothing clips on any device.

Also hardened codemagic.yaml: the asset step used `|| echo skipped`, which
silently shipped Capacitor's blank default splash if generation failed. Now it
hard-verifies the generated iOS Splash.imageset exists and fails the build
otherwise. Rides the next Codemagic build (native asset; no server redeploy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:06:50 +05:30
SravanandClaude Opus 4.8 343724df0f group chat: profile photos missing on a cached re-open (only initials showed)
The instant-paint work renders a group thread from cache BEFORE /api/groups/members
(convoMembers) loads, so senderAvatar had no avatar and drew initials only; the later
network render diffs/skips the unchanged messages, so the photos never came back until
a full reload. Platform-agnostic (desktop + iOS), group-only — matching the report.
Fix: senderAvatar now falls back to the global CONTACTS avatar so the cache paint is
already correct, and openConvo repaints the sender avatars (refreshSenderAvatars) once
convoMembers loads. Web-only; live on next app launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 14:22:00 +05:30
SravanandClaude Opus 4.8 3f41ca857a chat list: clear the stale "message deleted" label when a newer message arrives
The sidebar shows "This message was deleted" whenever a row's last_deleted flag is
set, but the new-message handlers (incoming, my-sent, edit) updated the preview text
and never cleared last_deleted. So once a conversation's last message had been deleted
(server-computed last_deleted=true at load), a NEWER message left the flag stale and
the list kept showing "deleted"/"you deleted this" even though the actual last message
was a normal one. Remote deletes already self-corrected via onChatDeleted->loadSidebar;
this was the in-session new-message path. Now last_deleted is cleared wherever a fresh
non-deleted message becomes the row's last message. Web-only; live on next app launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 14:08:31 +05:30
SravanandClaude Opus 4.8 87a76c09f3 transcript: fix duplicate copy on multi-device + iOS download hijacking the app
- Duplicate ("Transcript shows two times"): finalizeTranscript had a race — for the
  SAME user on two devices (#12 multi-device), both devices leaving at once each
  passed the subscriber membership check across an await before either removed the
  sub, so both wrote a private transcript. Now the subscriber is CLAIMED
  SYNCHRONOUSLY (subs.delete filter) before any await, so only the first writer wins.
  Verified with a concurrency simulation (2 concurrent leaves -> 1 write).
- iOS download: the /mrec transcript link had no `download` attribute, so WKWebView
  NAVIGATED to the file and loaded it inline with no way back (had to force-quit the
  app). Added download + data-mime so browsers download it and the existing native
  click-interceptor catches it: it now saves to the Files folder and opens in native
  Quick Look (view + its own share/save — into Files or Word) instead of hijacking
  the WebView. recDTO now exposes the recording mime.

Web/server only — no native build needed; live on next app launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 19:47:23 +05:30
SravanandClaude Opus 4.8 e46ac1e7cc #5 transcript: cover scheduled/web meetings on iOS too (not just native calls)
In a scheduled meeting the WebView owns the mic (the plugin has no LiveKit room), so
the native-call AudioRenderer tap didn't apply. Now the WebView reads its OWN local
mic PCM via Web Audio (the same non-intrusive tap the app already uses for
active-speaker metering — NOT a 2nd getUserMedia, which would fight the call's mic on
iOS and yield silence), downsamples to 16 kHz mono Int16, and forwards it to the
plugin's SFSpeechRecognizer via feedAudio(). Native calls keep the LiveKit tap.
Muted -> the local track carries silence -> nothing transcribed; the feed re-inits
when the mic goes live on unmute.

- Plugin: startTranscription({external:true}) runs the recognizer without a track;
  feedAudio({pcm,rate}) decodes base64 LE Int16 -> Float32 buffer -> recognizer.
- Web: startSR now uses the native recognizer for ALL iOS meetings (native call =
  LiveKit tap, scheduled = PCM feed); desktop/browser unchanged (Web Speech API).

Web deploys now; the plugin's feedAudio/startExternal ride the next Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 17:05:56 +05:30
SravanandClaude Opus 4.8 68ff3a878b #12 multi-device tiles + #5 native iOS transcript
#12 — same user on two devices now shows as two independent tiles (was: LiveKit
kicked the older connection, "audio jumps to whichever joined last"):
- LiveKit identity is now the per-connection mesh peerId, not the user id.
  /api/meetings/token + guest-token mint identity=peerId when the client supplies
  it (anti-hijack: never mint another live user's peerId). Web maps SFU tracks by
  identity==peerId, keeping peerIdForUid as a fallback for the transition/native.
- Mesh dedup (dropDupPeers) now keys on a stable per-device clientId (persisted,
  sent on meeting-join, echoed by the server) instead of user id — so two real
  devices keep separate tiles while a same-device reconnect ghost still collapses.
  Verified in a real browser: 2 devices -> 2 tiles; same-device reconnect -> 1.
- Native: plugin gains reconnectRoom(); after the native WebView joins the mesh it
  re-homes the LiveKit media onto its peerId identity. syncVideoTiles keys by peerId.
  Token-identity + anti-hijack + clientId echo verified by a server test.

#5 — iOS live transcript (WKWebView has no Web Speech API, so an iOS participant
was never transcribed; desktop already works):
- native-call plugin transcribes the local mic with SFSpeechRecognizer, fed by a
  LiveKit AudioRenderer on the local mic track (reuses the call's open mic — no 2nd
  AVAudioEngine). Finalized segments -> 'transcript' event -> web sends
  meeting-transcript (same server assembly as desktop). startSR/stopSR use the
  native recognizer on native calls; Web Speech API path unchanged elsewhere.
- NSSpeechRecognitionUsageDescription added to the iOS Info.plist.

Native pieces (#12 reconnect, #5 transcript) need a Codemagic build; the web+server
half is verified and deploys now (already fixes the reported laptop+phone case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 16:36:18 +05:30
SravanandClaude Opus 4.8 c322448774 #14 edit: PARK the edit on leave and RESUME it on return (Send updates the original)
Previous follow-up kept the in-progress edit text only as a plain draft, so sending
it after returning posted a NEW message instead of updating the original. Now leaving
a chat mid-edit parks {msgId, text, pre-edit draft} in _pendingEdits; reopening the
chat resumes edit MODE (editTarget + "Editing message" bar + the in-progress text)
once the thread is loaded (cache render, then network render as fallback). So Send
runs saveEdit → UPDATES the original message. If the message can't be found in the
loaded page (or was deleted) it falls back to a plain draft so the text isn't lost.

Verified with puppeteer against the live app:
 - edit "hi bob" -> switch to Cara -> back -> edit mode resumes (composer "hi bob
   EDITED", bar showing) -> Send -> thread count stays 1, message.edited_at set:
   PASS (updated original, no new message).
 - plain draft (no edit) switch-and-return still restores: PASS (no regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:52:38 +05:30
SravanandClaude Opus 4.8 354e65acdf #14 follow-up: keep in-progress edit text as a draft when leaving a chat mid-edit
persistCurrentDraft() no longer skips while editing — leaving a chat mid-edit
abandons the edit (openConvo clears editTarget) but now keeps whatever's in the
composer as that chat's draft, so your typing isn't lost. It returns as a normal
draft (sending posts a new message, not an edit). The edit-cancel (X) path still
restores the pre-edit draft, unchanged. Verified with puppeteer: edit "hi bob" ->
"hi bob EDITED" -> switch chats -> back -> composer holds "hi bob EDITED" (PASS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:38:05 +05:30
SravanandClaude Opus 4.8 785eeb7fbc #14 draft ROOT CAUSE: openConvo's cancelEdit() wiped the just-opened chat's draft
Reproduced with a real headless browser driving the live app: typing saved the draft
and it survived switching chats, but RETURNING to the chat deleted it. Stack trace
pinned it exactly:
  setDraft(REMOVE) <- _restoreDraftAfterEdit <- cancelEdit <- openConvo:3484 <- selectChat

openConvo ran `clearReply(); cancelEdit(); hideAttach();` on every open. cancelEdit ->
_restoreDraftAfterEdit -> setDraft(selected,'') — and since `selected` is already the
chat being opened, it wiped THAT chat's draft (and blanked the composer) BEFORE the
draft-restore a few lines later could read it. So the draft never survived reopening —
this predated the recent rounds; my _restoreDraftAfterEdit change just made the wipe
explicit. Fix: drop cancelEdit() from openConvo (edit state is already reset at the top
via editTarget=null/_editSavedDraft='', and the shell was just rebuilt fresh). The
edit-cancel button + saveEdit still call cancelEdit normally.

Verified with puppeteer: type in chat A -> open B -> back to A -> "hello draft" restored
(PASS), no setDraft REMOVE on return.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 11:50:20 +05:30
SravanandClaude Opus 4.8 25b0b39c49 #14 draft: guarantee the save at leave-time (fixes switch-chats-and-return loss)
Per-keystroke 'input' saves can be missed on mobile (predictive text or a fast
tap-away fires no final input event), which lost the whole draft when you switched
chats and came back. Added persistCurrentDraft() — snapshots the composer value
into the current chat's draft key the instant you leave it (selectChat before the
swap, and showWelcome/back). Restore on open was already correct. Skipped while
editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 22:39:16 +05:30
SravanandClaude Opus 4.8 6ee424db16 Round 4: hidden-msg pagination, iOS audio unlock, iOS long-press callout, draft hardening, pinned-by
Older-messages pagination (couple of chats wouldn't scroll back): the thread query
    returned the latest 40 rows and JS filtered out hidden (delete-for-me) messages
    AFTER the LIMIT, so a chat with a hidden recent message returned <40 → the client
    read that as "no older history." Now excluded in SQL (repos.thread /
    threadByConversation take the viewer id), so a page is always 40 VISIBLE rows.
    Verified locally: hide 3 recent → page still returns 40 (older ones fill in).
#2  iOS in-chat tone was silent: WebAudio context is created suspended and only
    resumes inside a user gesture. Added unlockAudio() on first tap/click (resume +
    0-gain blip), re-armed each gesture so a background→foreground re-suspend recovers.
#9  Long-press "works once then stops" on images was iOS's native touch-callout
    (Save Image / selection magnifier) hijacking the gesture. Disabled
    -webkit-touch-callout/user-select on #msgs bubbles; added a Save action to the
    sheet so image-saving isn't lost.
#13 Pin/unpin WAS being audited (verified: message.pin in /api/audit) — there's just
    no in-app viewer. Surfaced "Pinned by X" in the pinned bar for immediate context.
#14 Hardened draft save: it now runs BEFORE maybeAutocorrect/autoGrow (wrapped) in the
    input handler, so a throw there can't skip persisting the draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 22:29:34 +05:30
SravanandClaude Opus 4.8 c3c4178227 #8 root cause: listByTenant omitted last_seen/status → contacts/conversations sent lastSeen:null
repos.listByTenant selected id,email,name,role,active,avatar_url,created_at but NOT
last_seen or status. So /api/messages/contacts and /api/messages/conversations
always sent lastSeen:null (and status:'active'). Last-seen only ever appeared via
LIVE presence events (broadcastPresence reads the full row) — which is why it
"worked on desktop" (caught live), not on a fresh iOS load, and why round-2's
loadSidebar-on-focus then clobbered the live value → "Offline for all". Verified
locally: contacts now returns the real lastSeen timestamp; db-smoke 22/22.

Also lightened refreshPresenceOnResume: reconnect the socket if it's dead (its
onopen already resyncs the sidebar) but no longer force an unconditional
loadSidebar on every focus — that churn caused the #8 regression and could
momentarily reset an unread badge (#3). Session sliding (touchSession) stays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 17:28:39 +05:30
SravanandClaude Opus 4.8 a677675b8f Round 3: session longevity, in-chat tone, pin audit, older-msg loader, long-press sheet, draft fix
Session (New): stop the ~24h auto-logout. SESSION_TTL 24h -> 90d, and /api/me now
    SLIDES the session forward + re-stamps the cookie on every app load / focus /
    6h heartbeat — so an actively-used session never lapses; you only log out by
    choosing to. Login no longer depends on "remember me".
#2  A new message in the chat you're actively viewing now plays a soft, distinct
    in-chat tone (playMsgTone) — no popup — instead of being silent. A different
    chat / a backgrounded chat still gets the alert ping + notification.
#13 Pin/unpin is now written to the audit log (actor + which message, and whose pin
    was removed on an unpin) — the accountability gap when anyone can unpin.
Pagination: a floating "Loading earlier messages…" pill now shows while older
    history is being fetched (loadOlder had no visible indicator).
#9  Mobile long-press now opens a dimmed + blurred bottom ACTION SHEET (quick
    reactions + reply/edit/forward/copy/pin/delete) instead of the flaky hover-style
    reveal that hid behind images and broke after the lightbox opened.
#14 editTarget is cleared on conversation switch — starting an edit then switching
    chats used to leave editTarget set, which silently stopped ALL draft saving.
    Also added Edit to the shared action list so mobile long-press can edit too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 16:38:58 +05:30
SravanandClaude Opus 4.8 eaea1ccc3f test: wait for the server to LISTEN instead of a fixed 300ms
Cold Postgres boot (connect + apply the full schema) takes a few seconds — the
fixed 300ms that was fine for SQLite's instant in-memory init raced the server
bind and the first fetch hit ECONNREFUSED. Poll BASE/ until it responds (≤30s).

Validated on local PostgreSQL 16: db-smoke 22/22 pass; e2e passes all DB-backed
checks (auth, messages, polls, groups, calls+invite, meetings) then stops at the
known pre-existing WS meeting-joined flake (unrelated to the DB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 00:29:55 +05:30
SravanandClaude Opus 4.8 971a6fdf22 Round 2 fixes from on-device testing (#2,#3,#6,#8,#9,#13,#14,#18 + call re-ring)
#2  Don't ping/notify when you're ACTIVELY viewing a chat (app visible + chat
    open). Alert only when a different chat, OR the open chat while the app is
    minimised (the backgrounded case that used to stay silent).
#3  A reaction to my message now raises an unread badge on that conversation
    (like a new message), not just a notification.
#6  Image lightbox pulls EVERY image in the conversation via /api/messages/media
    (older images aren't in the DOM yet) — nav arrows reach them all. Nav buttons
    always in the DOM; syncArrows shows/hides at the ends and for a single image.
#8  On app resume (visibilitychange / native appStateChange), reconnect the chat
    socket if it isn't OPEN and re-pull the sidebar so online/last-seen refresh —
    iOS freezes the WebView so the socket can be dead while its onclose lags,
    leaving contacts stuck on a stale "Offline".
#9  Real cause was iOS "sticky :hover": a single tap latched :hover and popped the
    action bar. Gate the hover-reveal behind @media (hover:hover) so touch reveals
    actions ONLY via long-press; a plain tap performs the primary action.
#13 Pinned bar gains a "‹ 1 of n ›" pager to walk through multiple pinned messages
    (shown only when more than one is pinned).
#14 Editing a message no longer eats a half-written draft — the real draft is set
    aside on edit start and restored on save/cancel. edited_at is now in the message
    DTO so the "edited" tag survives a reload.
#18 One "Delete" entry opens a branded dialog with "Delete for me" / "Delete for
    everyone" (icons + descriptions) and a ✕/backdrop cancel, replacing the two
    separate menu items.
New: a participant who LEAVES a still-running call is no longer auto-rung back in
    on every socket reconnect. Track who left per call; replayActiveCalls sends
    them noRing state (refreshes the Join affordance without ringing). An explicit
    re-invite clears that and rings again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 00:22:50 +05:30
SravanandClaude Opus 4.8 ad48829337 Retire the SQLite backend — Postgres is the only engine
The dual backend (SQLite via db.js + Postgres via schema.pg.sql) was a
maintenance foot-gun: a schema change could land on the SQLite path only and
silently 500 every read on prod (it just did, with #18/#13). Production has run
on Postgres for weeks, so SQLite is retired: ONE schema source of truth
(db/schema.pg.sql), no drift possible.

- dbx.js: default DB_BACKEND=pg; an unknown backend now fails loudly at require
  time instead of silently selecting a stale engine.
- Deleted server/db.js, server/db/sqlite.js, server/db/migrate-sqlite-to-pg.js,
  server/scripts/migrate-bizgaze-only.js (all SQLite-only, none in the runtime
  path — the running server loads db/pg.js).
- Tests (e2e, db-smoke) target Postgres now and fail-fast (skip) unless
  DATABASE_URL points at a disposable test DB — never SQLite, never prod.
- Removed the dead DB_PATH env + fixed misleading SQLite comments in the
  Dockerfile / docker-compose (kept the /data volume: it holds
  uploads/recordings/transcripts/downloads, not just the old data.db).
- CLAUDE.md: stack + repo-layout + run-locally updated for Postgres-only.

Runtime is unaffected (prod already sets DB_BACKEND=pg and pg is a prod dep);
this only removes the unused SQLite path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 08:20:26 +05:30
SravanandClaude Opus 4.8 f3b6e67c19 HOTFIX: add #18/#13 schema to Postgres (chat history returned empty)
Production runs DB_BACKEND=pg, but the message_hidden table (#18) and
pinned_at/pinned_by columns (#13) were only added to the SQLite migrations in
db.js — never to schema.pg.sql. So thread/conversations/pinned queries hit a
missing relation/column and 500'd, which surfaced as "chat history removed"
(no data was ever deleted — the reads just errored).

pg.js init() runs schema.pg.sql on every boot. Added the message_hidden table
and, because CREATE TABLE IF NOT EXISTS can't add columns to the existing
messages table, idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS for
pinned_at/pinned_by. Restores all chat history and re-enables pin + delete-for-me.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 07:53:13 +05:30
SravanandClaude Opus 4.8 d0863351d2 #13: pin a message
Any participant can pin/unpin a message from its ⋮ menu. Adds pinned_at/pinned_by
columns, /api/messages/pin (toggle, broadcasts chat-pinned) and
/api/messages/pinned (list, newest first, excludes deleted + delete-for-me).
The conversation shows a pinned strip under the header (latest pin + count);
tap it to jump to the message, × to unpin. Live-updates across participants and
devices. Added pin/pinOff Lucide icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 23:00:06 +05:30
SravanandClaude Opus 4.8 9017d2ff25 #9: mobile — single tap acts, long-press reveals message actions
On touch, tapping a message used to reveal the reply/react/more bar (so the
action needed a second tap). Now a single tap performs the primary action
(open image/file, jump to a reply), and the action bar is revealed by a
~420ms long-press (with a small haptic); movement or an early release cancels,
and a fired long-press suppresses the trailing tap. A tap elsewhere dismisses
the revealed bar. Desktop hover behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:51:35 +05:30
SravanandClaude Opus 4.8 c1b67d38d3 #16: trim the schedule form when scheduling from a group
A group's members are automatically the meeting's participants, so the
Invite-participants, Invite-by-email and "Guests must be admitted by host"
sections are noise there. openScheduleModal now omits them when a group id is
present (inviteBlock is empty), and the email/participant/lobby handlers are
null-guarded so the save path still works without those fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:47:15 +05:30
SravanandClaude Opus 4.8 276c5e0929 Fix #1 (input-focus zoom), #6 (last-image arrow), #7 (join sound)
#1: focusing a text box zoomed + stretched the whole page on iOS. Several
inputs were < 16px and the existing 16px rule lived in a width-based mobile
@media that misses iPad/landscape (and maximum-scale is unreliable on iOS).
Added a @media (pointer: coarse) rule forcing 16px on every focusable field
for ALL touch devices; desktop is untouched.

#6: the image lightbox used visibility:hidden for the end arrows (invisible but
still occupying space / reading as a ghost button). Switched to display toggling
so there's truly no right arrow at the last image (and no left at the first).

#7: play a soft two-note chime + a brief "<name> joined the call" toast when a
new participant joins the meeting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:44:00 +05:30
SravanandClaude Opus 4.8 ecc8be3dba Fix #14 (edit leaves a draft) and #8 (last-seen flapping)
#14: after editing a message the edited text reappeared in the composer as a
draft and re-sent as a new message. The input listener saved a draft while
editing, but saveEdit/cancelEdit cleared only the input value, not the stored
draft — so openConvo restored it. Now editing never writes a draft, and
save/cancel clear it.

#8: a contact's subtitle flapped between "last seen …" and "Offline". A
loadSidebar rebuild replaced the row with the server's lastSeen, which is
sometimes null (the disconnect touchSeen is fire-and-forget and can lag the
presence broadcast). Now the client keeps last-seen sticky (never overwrites a
known value with null) and the server never broadcasts a null last-seen for an
offline user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:37:06 +05:30
SravanandClaude Opus 4.8 e359271157 #10 deleted-last-message preview + #18 delete for me / for everyone
#10: deleting the last message showed "No messages yet" in the chat list.
The sidebar sent an empty last_body for a deleted (content-cleared) row; now
it sends a last_deleted flag and the row renders "This message was deleted"
(or "You deleted this message"), matching the in-thread placeholder.

#18: added "Delete for me" alongside "Delete for everyone". A new message_hidden
table records a per-user hide; the thread + sidebar (last message, unread) filter
out the requesting user's hidden messages, and the hide is echoed to their other
devices (chat-hidden). "Delete for me" is offered on any message; "Delete for
everyone" stays sender-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:31:17 +05:30
SravanandClaude Opus 4.8 9e80aee1c6 Fix notification bugs #2 (open+backgrounded chat) and #3 (reactions)
#2: a message arriving in the currently-OPEN chat produced no notification
when the app was minimized. onChatMessage marked the open chat read even
while document.hidden, which fired a notif-clear that closed the very
notification the service worker had just shown. Now the open chat is only
marked read while visible; markOpenChatRead() catches up on focus/visibility
return, and a message received while hidden stays unread with its alert intact.

#3: reacting to a message fired no notification. The react route only pushed
over the live socket (nothing for a closed app) and the client added a silent
bell entry. Now the server sends a native/web push to the message owner, and
onChatReaction pings + shows an OS/in-page popup (unless you're viewing that chat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:23:16 +05:30
SravanandClaude Opus 4.8 a0f9ab10f2 #15: adding a 3rd person to a 1:1 call promotes it to a persistent group call
When someone is added to a 1:1 (DM) call, promoteDmToGroup() now creates a
real group conversation (named after the participants) and migrates the live
call from dmCalls -> groupCalls, keeping the same room/uuid/history so media
and the transcript continue uninterrupted. Two wins:
 - the call survives anyone leaving (group calls only end when the room empties)
 - an added person who drops can rejoin from the group's active-call banner
   (they're now a member, so replayActiveCalls / group-call resurface it)

/api/calls/invite promotes on a DM call and lets the group-call broadcast ring
the invitees in; it only sends the plain call-invite when NOT promoted. Guarded
so inviting an existing pair-member (memberIds < 3) stays a 1:1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:13:41 +05:30
SravanandClaude Opus 4.8 100a092ff9 Fix call-cluster bugs #11, #4a, #4b
#11: adding a 3rd person to a 1:1 call then having one participant close
the app disconnected the call for everyone. leaveMeeting() ended a DM room
for ALL peers on any leave; now it only tears down when <2 people remain,
otherwise it falls through to the normal peer-left path (call continues).

#4b: after someone left a call they never reappeared under "Add people".
meeting-peer-left cleaned meetPeers/tiles but not meetPeerUids/meetNames,
so the departed uid stayed in hereUids and was filtered out. Now deleted.

#4a: a guest who enabled mic/cam on the pre-join screen had to re-tap after
being admitted — the choices were applied on a blind 900ms timer that fired
while still in the lobby. Now applied in the meeting-joined handler, after
admission + media connect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:51:49 +05:30
SravanandClaude Opus 4.8 0cee72e73c Add a clear "Stop sharing" banner while sharing your screen
The only way to stop a screen share was toggling the share button again —
buried in the ⋮ More menu on mobile. Now a floating "You're sharing your
screen · Stop" banner appears at the top whenever you're sharing (driven by
meetScreen via updateScreenBtn), with a red Stop button that calls
toggleScreen. Works on iOS/desktop/web. Web-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 16:56:00 +05:30
SravanandClaude Opus 4.8 c24d5d828a Hole-punch attempt 3: set WebView transparent at startup + scrollView.isOpaque
Previous hole-punch builds showed no video because WKWebView can IGNORE
isOpaque=false when it's set AFTER the page has already rendered (during the
call). Now:
- makeWebViewTransparent() runs once at plugin load (startup): isOpaque=false,
  backgroundColor=.clear, and crucially scrollView.isOpaque=false +
  scrollView.backgroundColor=.clear (an opaque scrollView occludes content
  behind the WebView).
- setHolePunch now just toggles a BLACK backing on the WebView's parent during
  the call (transparency is already applied); restored after.
- Tile frames use host.convert(rect, to: superview) instead of a manual origin
  offset — robust to any WebView inset.

Plugin-only — needs a Codemagic build. If this still shows no video, WKWebView
hole-punch is a dead end here and we revert to native-video-on-top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 14:13:22 +05:30
SravanandClaude Opus 4.8 d9ea104b73 Fix hole-punch: put native video BEHIND the WebView, not under its scrollView
Diagnosis: no native video rendered at all (bg went black, but camera AND
screen tiles were empty). WKWebView does NOT composite native subviews placed
under its scrollView through transparent web content — only the webView's own
layer background shows through. So the video was effectively invisible.

Fix (the standard hole-punch): insert the video tiles into the WebView's
SUPERVIEW, BEHIND the (fully transparent) WebView, so the web content paints
on top and the video shows through. Changes:
- makeTileView / syncVideoTiles: insert belowSubview: host (the WebView) in
  host.superview, and offset frames by the WebView's origin (getBoundingClientRect
  is viewport-relative).
- webView.backgroundColor = .clear (was .black — a black webView bg would have
  occluded the video behind it); the VC view's black background is the backing.

Plugin-only change — needs a Codemagic build. Web (transparent chain, zoom,
bz-hasvid) already correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 12:29:01 +05:30
SravanandClaude Opus 4.8 c79d78485b Fix hole-punch regressions: transparency chain, black backing, keyboard
Three fixes for the hole-punch build:
1. White background + shared screen not showing: body and .content both use
   var(--bg) (a light colour) and were still opaque, occluding the native
   video behind the WebView. Make the WHOLE meeting chain transparent under
   .bz-hp (body + .content, on top of .meet-grid/tiles).
2. Backing: also set the view controller's view background to black (saved/
   restored) so any transparent gap reads black, not white — belt-and-braces
   with webView.backgroundColor.
3. Keyboard covering the in-call chat: keyboard resize is "none", so the
   absolutely-positioned meet panels don't move for the keyboard. Lift
   .meet-panel by the reported keyboard height (body.kb-open → bottom:
   calc(var(--kb)+12px)); --kb/kb-open are already set globally by the
   Keyboard listener.

Plugin change (VC background) needs a build; the CSS is web-deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 12:13:09 +05:30
SravanandClaude Opus 4.8 bdd0058c5c Hole-punch: render native video BEHIND a transparent WebView
Permanent fix for the z-order whack-a-mole (controls/menus/panels hiding
behind the native video). Instead of drawing native video ON TOP of the
WebView, draw it BEHIND a transparent WebView so ALL web UI floats on top
naturally — no suppressing, no clamping, no docked-only bar.

Plugin:
- setHolePunch(on): webView.isOpaque=false + black layer bg + clear scroll
  bg (restored on off / call end). Tiles inserted belowSubview:scrollView.
- Dropped native name/mute overlays (the web tile's own .nm/.meet-mute/avatar
  render on top now) and the PaddingLabel.
- Zoom re-plumbed: touches hit the WebView, so native gesture zoom can't work;
  new setTileZoom({uid,scale,tx,ty}) applies a web-forwarded transform to the
  tile's inner video. TileVideoView simplified to a container + applyZoom.

Web:
- bzNativeStartTiles/StopTiles toggle NC.setHolePunch + a body.bz-hp class
  (only when the plugin supports it — old builds keep the suppress fallback).
- Tiles with live native video get .bz-hasvid → CSS makes them transparent +
  hides the web avatar so the video shows through; name/mute/border stay.
- New web-forwarded pinch/pan/double-tap on the shared screen → setTileZoom.
- Suppress-on-overlay + the height clamp now only apply when NOT hole-punched.

Needs a Codemagic build (plugin). Web deployed; no-ops to the prior behavior
on builds without setHolePunch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 11:34:09 +05:30
SravanandClaude Opus 4.8 f971908d80 Fix: hide native video while a meeting menu/panel is open (z-order)
Native video draws on top of the WebView, so the More/audio menu,
participant panel, meeting chat, modals and the image lightbox all opened
BEHIND the shared screen. Now bzNativeSyncTiles clears the native tiles
whenever any of those overlays is present (.meet-panel/.spk-menu/.modal-ov/
.lightbox), and the menu/panel toggles trigger an immediate sync so there's
no lag; the video returns when they close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 11:14:41 +05:30
SravanandClaude Opus 4.8 f2efff394b Fix: dock the meeting bar on native calls (decouple it from the shared screen)
The control bar is a draggable FLOATING bar (position:fixed when moved/
restored) meant to sit over the shared screen — which works on web (video is
DOM) but on a native call the screen is a native overlay ABOVE the WebView,
so the floating bar hides behind it, and its live position fed my height
clamp → dragging it resized/reoriented the screen.

Fix: skip makeDraggable on native calls so the bar stays DOCKED in flow at
the bottom. The grid then reserves space above it and the native shared
screen fills that stable area — bar and screen are independent, controls
stay visible and tappable. (A floating bar that overlays the native video
would require a hole-punch rework.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 11:03:35 +05:30
SravanandClaude Opus 4.8 5a140e45a1 Fix: clamp native tile height so shared screen never covers the controls
The CSS-only stage sizing wasn't enough — the shared screen still overlapped
the meeting controls. Since the native video is drawn ON TOP of the WebView,
now clamp each tile's height in bzNativeSyncTiles so it can never extend past
the top of the .meet-bar (control bar) — regardless of how the web lays out
the stage. Robust and web-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 10:51:32 +05:30
SravanandClaude Opus 4.8 b35c95a5be Fix: shared screen no longer overlaps the meeting controls
scr-full forced #meetGrid to height:100%, so the stage (and the native
video positioned on its rect) extended down over the control bar. The grid
is already flex:1 — it should fill only the space above the bar. Now the
stage flexes to fill that area (flex:1 1 auto) instead of height:100%, so
the native screen view sits above the controls, not over them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 10:44:29 +05:30
SravanandClaude Opus 4.8 a561852067 Fix build: TileVideoView composes VideoView (can't subclass a non-open class)
Build error: `cannot inherit from non-open class 'VideoView' outside of its
defining module` — VideoView is public, not open, so it can't be subclassed
in the plugin module. Reworked TileVideoView from a VideoView subclass into a
UIView CONTAINER that holds a VideoView: the container is frame-synced to the
web tile rect, the pinch/pan zoom transform lives on the inner video (so it
never fights the position poll), and the name/mute overlays sit on the
container so they no longer scale with zoom. track/layoutMode are forwarded.

Also silenced the extension warning: SampleHandler now restates
`@unchecked Sendable`.

The broadcast extension itself compiled + linked in the failed build, so the
SPM-linked extension injection + App Group setup are working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 10:25:46 +05:30
SravanandClaude Opus 4.8 27c582bceb Outgoing iOS screen-share via ReplayKit broadcast extension
Lets a native-call user share their iPhone screen (whole device, works
backgrounded). LiveKit 2.15.3 ships the broadcast stack (BroadcastManager +
LKSampleHandler + IPC), so:

- New Broadcast Upload Extension target "BroadcastExtension"
  (com.bizgaze.connect.broadcast): SampleHandler.swift subclasses
  LKSampleHandler; injected by mobile/scripts/add-broadcast-extension.rb
  which also LINKS the LiveKit SPM product into the extension + sets the
  App Group. Sources in mobile/ios-broadcast/.
- Plugin: Room created with ScreenShareCaptureOptions(useBroadcastExtension:
  true); startScreenShare -> BroadcastManager.requestActivation() (system
  picker); stopScreenShare -> requestStop(); BroadcastManagerDelegate ->
  fires screenShareState to the web. LiveKit auto-publishes the track.
- ios-patch.sh: RTCScreenSharingExtension + RTCAppGroupIdentifier keys.
- codemagic.yaml: run the injector + sign the 3rd bundle id (.broadcast).
- home.html: toggleScreen native -> start/stop; screenShareState listener
  reflects state + broadcasts meeting-screen so peers' stage shows it.

REQUIRES a one-time manual Apple portal step: enable the App Group on the
com.bizgaze.connect.broadcast App ID (see mobile/IOS_SETUP.md) or the
archive fails code-signing. SPM-linked extension is new on our CI — expect
build iteration. Web deployed (no-ops on builds without startScreenShare).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 21:09:02 +05:30
SravanandClaude Opus 4.8 aed3d22675 Native screen-share: maximize the shared screen + pinch-to-zoom
Better visibility: on a native call, a shared screen now fills the whole
meeting area and hides the small participant tiles (new .scr-full grid
class, toggled when meetNative && sharing). The plugin renders the screen
over that full-area stage rect.

Zoom: the tile video view is now TileVideoView with pinch-to-zoom + pan
(and double-tap to reset) enabled only while it's showing a screen. While
zoomed the view holds a transform and syncVideoTiles stops overwriting its
frame; name/mute overlays hide during zoom.

Needs a Codemagic build (plugin change). Web deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 20:56:27 +05:30
SravanandClaude Opus 4.8 e6d3d2d66a Native video: render others' screen-share on native calls
Last native-video gap: on a native (iOS) call the WebView has no LiveKit
connection, so a screen shared by a web/desktop participant never rendered.
The mesh already flips the sharer's tile into the big "stage" (meeting-
peer-screen -> meetSharers -> sharing-mode), so extend the tile sync: each
tile now carries a `screen` flag (meetSharers.has(id)). The plugin renders
that participant's screen-share track (source .screenShareVideo) on the
tile with layoutMode .fit (contain, no crop) instead of the camera.

Outgoing screen-share from iOS is still unsupported (no getDisplayMedia /
ReplayKit broadcast extension) — toggleScreen now shows a clear toast on
native instead of silently failing.

Needs a Codemagic build (plugin change). Web deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:39:39 +05:30
SravanandClaude Opus 4.8 a5e2ed8a9d Native video: readable tile labels (red mute, name chip) + flip camera
Label legibility: the name + mute overlays were both white/invisible.
- Mute badge is now a RED (#dc2626) circle with a white mic-slash, matching
  the web tile's .meet-mute, moved to the top-left.
- Name is now white on a dark translucent pill (PaddingLabel) so it stays
  legible over any video, bottom-left.

Front/back camera switch: new NativeCall.switchCamera() flips the local
CameraCapturer front<->back (switchCameraPosition, verified in 2.15.3). New
"Flip camera" button on the meeting bar (switchCamera icon), shown only
while a native call's camera is on (updateFlipBtn). Mirroring auto-corrects
(VideoView mirrorMode .auto only mirrors the front camera).

Needs a Codemagic build (plugin change). Web deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:58:12 +05:30
SravanandClaude Opus 4.8 34bdd8ea16 Native video: name + mute labels on the native video tiles
The native video covers the web tile's name/mute badges, so redraw those
natively. syncVideoTiles now also carries each tile's name + muted state;
each tile VideoView gets a bottom-left name label (with shadow for
legibility) and a bottom-right mic-slash badge shown when that participant
is muted. Kept above the video renderer via bringSubviewToFront. Web sends
name/muted from meetNames/meetMuted (and ME.name/!meetMic for __local).

Front/back camera switch + screen-share rendering still deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 14:10:54 +05:30
SravanandClaude Opus 4.8 4415225407 Native video Increment 2b: remote tiles + self-view on tile; speaker fix
Video (2b): render every participant's camera natively, positioned to
match the web meeting tiles. The web has no LiveKit connection on a native
call, so the plugin draws native VideoViews over the WebView. New
NativeCall.syncVideoTiles({tiles:[{uid,local,x,y,w,h}]}) — the web polls
each tile's getBoundingClientRect + user id (400ms + on camera toggle) and
the plugin places a VideoView (subview of the WKWebView, so CSS-px rects ==
points) for whoever has a live, unmuted camera track; camera-off keeps the
web avatar. Replaces the 2a fixed-corner self-view: your own camera now
renders on the __local tile. Remote video correlated by LiveKit identity ==
meetPeerUids user id. APIs verified vs client-sdk-swift 2.15.3 source:
Room.remoteParticipants[Participant.Identity(from:)], Participant.videoTracks,
TrackPublication.source/.track/.isMuted, Track.Source.camera.

Audio: fix "sound starts on the earpiece until I tap something" — the
LiveKit audio engine starting after CallKit activates the session flips the
route to the receiver. Added an AVAudioSession routeChange observer that
re-asserts the loudspeaker (via preferSpeaker) whenever we land on the
built-in receiver mid-call (headset/BT still win).

Web change is safe on the current (2a) build: syncVideoTiles is absent so
the poll no-ops. Needs a Codemagic build to take effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 11:59:52 +05:30
SravanandClaude Opus 4.8 149e32b8d8 Native video Increment 2a: publish local camera + self-view
Wire the camera button on native calls to the plugin instead of a
"not available" toast. New NativeCall.setCamera({on}) calls LiveKit
localParticipant.setCamera(enabled:) so the iOS user's camera is
published to the room — every web/desktop peer renders it via their
existing SFU subscription. Locally the plugin shows a small rounded
self-view (VideoView) pinned top-right over the WebView.

APIs verified against client-sdk-swift 2.15.3 source: setCamera ->
LocalTrackPublication?, TrackPublication.track, VideoView(.track/.layoutMode),
CameraCaptureOptions(position:.front). Front camera only for now.

Rendering the OTHER participants as native tiles synced to the web
meeting grid is Increment 2b (the fragile part) — next build. Until the
new IPA ships, the web branch falls back to an "update the app" toast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 23:10:19 +05:30
SravanandClaude Opus 4.8 f27b7af9e4 Remove temporary push/call/photos debug telemetry
Push, native calling, and Photos save are all confirmed working, so strip
the diagnostic instrumentation: the pdbg() helper and all its call sites in
home.html (native-setup-*, registration-*, perm-*, nc-* call events,
photos-fail) and the matching /api/push-debug route in routes.js. Real
console.log/console.warn lines and all functional logic are kept; a couple
of pdbg-only error paths now log via console.warn instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 23:00:03 +05:30
SravanandClaude Opus 4.8 2c1e1c7ca5 Fix Photos save for images + add native Quick Look file preview
Photos/Files bug: the lightbox download handed nativeSaveFile a bare
"/files/<id>" with no name, extension, or mime. Empty mime made
bzSaveToPhotos short-circuit as "notmedia" — so saveToAlbum (and its
permission prompt) never ran, and the image landed in the generic Files
folder as an extension-less blob. Now nativeSaveFile trusts the server's
Content-Type when the mime is unknown and appends a real extension
(bzExtForMime/bzEnsureExt), so images reach the Images folder AND Photos.

File preview: replace the @capacitor/share "share sheet" open with a new
native FileOpener plugin (QLPreviewController). bzOpenFile now prefers a
real Quick Look preview and only falls back to the share sheet if the
plugin isn't in the build. Wired file-opener into mobile/package.json and
the codemagic SPM diagnostics loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 21:38:48 +05:30
SravanandClaude Opus 4.8 b2c2acbbc7 Chat files: native download→open control; diagnose Photos-save failures
3. File attachments on native now work like videos: a download icon (with %),
   tap to download into the app's Files folder, then the icon becomes an "open"
   (external-link) control — tap again to open the file via the iOS share/preview
   sheet (@capacitor/share; Quick Look / open-in). State tracked in the local
   library (bzSyncFileTiles), reconciled at startup. Web/PWA keeps the plain
   <a download> link. Added an externalLink icon.

2. Photos save: bzSaveToPhotos now returns a reason; the toast tells the user to
   allow Photos access in Settings when it's permission-denied (the likely cause
   after repeated reinstall testing), and pdbg logs the reason otherwise (noplugin
   vs error) so we can pinpoint it. media-library plugin Swift is unchanged/correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 11:48:47 +05:30
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
90 changed files with 5443 additions and 884 deletions

No files matched your search

+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
+4
View File
@@ -50,3 +50,7 @@ Thumbs.db
# Editor
.vscode/
.idea/
# Firebase service-account keys (SECRET private key — NEVER commit)
**/*firebase-adminsdk*.json
**/*service-account*.json
+15 -6
View File
@@ -12,8 +12,11 @@ Roadmap: grow into a communication platform (meetings + persistent chat) for
registered BizGaze users.
## Tech stack (intentionally minimal — keep it this way)
- **Node.js >= 22.5**, single npm dependency: `ws` (WebSocket).
- **Built-in `node:sqlite`** (no native modules). DB file: `server/data.db`.
- **Node.js >= 22.5**. npm deps: `ws`, `pg`, `redis`, `web-push` (+ optional `nodemailer`).
- **PostgreSQL** via the async adapter (`server/dbx.js` → `server/db/pg.js`). SQLite was RETIRED 2026-08-12:
there is now ONE schema source of truth, **`server/db/schema.pg.sql`** — every schema change goes there
(and post-cutover COLUMNS need an explicit `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, since the file is
applied idempotently on every boot and `CREATE TABLE IF NOT EXISTS` won't alter an existing table).
- **WebRTC** peer-to-peer for media (screen video + voice + data channels).
- **No build step, no framework.** Each page is a single self-contained HTML file
with inline `<style>` and `<script>`. Do not introduce React/bundlers.
@@ -30,9 +33,10 @@ server/
routes.js # HTTP JSON API (/api/*, /sso) -> { "METHOD /path": handler } map
static.js # static file serving + authenticated recording/transcript downloads
signaling.js # WebSocket signaling (consent + SDP/ICE relay)
repos.js # data-access layer — ALL SQL lives here (tenant-scoped)
repos.js # data-access layer — ALL SQL lives here (tenant-scoped, async)
bizgaze.js # BizGaze identity provider (validate login, env-gated)
db.js # node:sqlite schema + idempotent migrations
dbx.js # async DB adapter facade -> db/pg.js (Postgres; only backend)
db/pg.js # Postgres backend; db/schema.pg.sql = the single schema source of truth
auth.js # scrypt hashing, token/id generation, TOTP helpers
package.json # { "dependencies": { "ws": "^8.18" }, engines node>=22.5 }
test/e2e.js # 21-check backend e2e (register->login->session->signaling->audit)
@@ -48,11 +52,16 @@ server/
transcripts/ # saved transcripts (.txt) [created at runtime]
```
Architecture/roadmap detail lives in `ARCHITECTURE.md`. Backend SQL must go through
`repos.js` (never inline in routes/signaling). Run `node test/e2e.js` after backend edits.
`repos.js` (never inline in routes/signaling). ANY schema change goes in `db/schema.pg.sql` (see stack note).
After backend edits, run the tests against a DISPOSABLE Postgres (they no longer bundle SQLite):
`DATABASE_URL=postgres://…/bizgaze_test node test/db-smoke.js`.
## Run locally
```
cd server && npm install && node server.js
# Start a local Postgres (or use the compose one), then:
docker compose up -d bizgazepg
cd server && npm install
DB_BACKEND=pg DATABASE_URL=postgres://bizgaze:bizgaze_local@localhost:5432/bizgaze node server.js
# HTTP on :8090 (HTTPS on :8443 only if cert.pem + key.pem exist in server/)
# Env: ALLOW_REGISTRATION=1 opens the first-team registration
```
+4 -3
View File
@@ -1,5 +1,6 @@
# BizGaze Support — server image
# Node 24 ships node:sqlite as a stable built-in (no flag), which db.js relies on.
# Data store is PostgreSQL (DB_BACKEND=pg; the `pg` client is a prod dependency installed below). SQLite was
# retired 2026-08-12 — one schema source of truth (db/schema.pg.sql), no dual-maintenance drift.
FROM node:24-alpine
# ffmpeg: server-side video poster thumbnails (see static.js /thumbs/<id>). Small on Alpine.
@@ -15,9 +16,9 @@ RUN npm install --omit=dev --no-audit --no-fund
# App source
COPY server/ ./
# Served HTTP port (NPM terminates TLS and proxies here). DB lives on a volume.
# Served HTTP port (NPM terminates TLS and proxies here). The DB is Postgres (DATABASE_URL, set via .env);
# the /data volume holds uploads / recordings / transcripts / downloads (see docker-compose.yml).
ENV PORT=8090
ENV DB_PATH=/data/data.db
EXPOSE 8090
CMD ["node", "server.js"]
+158
View File
@@ -0,0 +1,158 @@
# Tech Stack — BizGaze Connect
> Living document. **Last updated: 2026-10-27.** Update this file whenever a dependency, platform, service, or
> tool is added/removed/version-bumped. See [Maintaining this file](#maintaining-this-file) and the
> [Changelog](#changelog) at the bottom.
## What this project is
BizGaze Connect is a self-hosted team **chat + meetings + calling** product (evolved from a remote
screen-sharing/"remote-access" tool, whose agent console still lives in the repo). One backend serves a web app
and is wrapped by native mobile (Capacitor) and desktop (Electron) shells. The UI loads live from the server, so
web changes deploy **without** rebuilding the native apps.
**Architecture style:** thin, dependency-light Node backend (custom modules, no web framework) + a single-page
vanilla-JS web UI + native shells that wrap the web and add native capabilities (push, calling, screen capture,
file access). Real-time via WebSockets; meeting media via WebRTC (P2P mesh, or LiveKit SFU when enabled).
---
## Backend (server)
- **Runtime:** Node.js — `engines` requires **>= 22.5**; the Docker image is **`node:24-alpine`**.
- **HTTP/HTTPS:** Node built-in `http`/`https` modules. **No Express/Koa/Fastify** — a small custom router
(`routes.js`) maps `"METHOD /path"` to handlers; static files + authenticated downloads via `static.js`.
- **WebSockets:** [`ws`](https://github.com/websockets/ws) `^8.18` (`WebSocketServer` on `/ws`) — chat, presence,
meeting signaling (SDP/ICE relay + consent).
- **Structure (server/):** `server.js` (wiring), `routes.js` (HTTP JSON API), `repos.js` (all SQL / data access),
`signaling.js` (WS), `calls.js`, `chat.js`, `presence.js`, `session.js`/`auth.js`, `push.js`, `livekit.js`,
`media.js`, `mailer.js`, `webhooks.js`, `bizgaze.js` (BizGaze identity provider), `directory.js`, `config.js`.
- **Media tooling:** **ffmpeg** (in the image) for server-side video poster thumbnails.
## Data layer
- **Database:** **PostgreSQL 16** (`pg` `^8.13`). This is the only backend — **SQLite was retired 2026-08-12**;
one schema source of truth at `server/db/schema.pg.sql`. Selected via `DB_BACKEND=pg` / `DATABASE_URL`.
- **DB access:** hand-written SQL through an async adapter (`server/dbx.js`, `server/db/`), centralized in
`repos.js`. No ORM.
- **Cache / pub-sub:** **Redis 7** (`redis` `^4.7`) for cross-instance real-time fan-out (chat/presence) —
**optional**, only when `PUBSUB_BACKEND=redis`; default is in-memory single-instance (`pubsub.js`).
## Real-time & media
- **WebRTC** for calls/meetings: built-in **P2P mesh** by default; **LiveKit SFU** when configured.
- **LiveKit SFU server:** `livekit/livekit-server:**v1.9**` (Docker, `sfu` compose profile, dormant unless
`LIVEKIT_*` env is set). Server mints per-user JWT join tokens (`server/livekit.js`).
- **LiveKit clients:**
- Web: self-hosted **`livekit-client` ~2.20** UMD (`server/public/vendor/livekit-client.umd.min.js`).
- iOS: **LiveKit Swift SDK `2.15.3`** (SPM, `client-sdk-swift`, product `LiveKit`).
- Android: **LiveKit Android SDK** (`io.livekit:livekit-android`) — planned for native-calling Increment 2.
## Web client (`server/public/`)
- **Vanilla JavaScript SPA** — `home.html` is the app (large single file with inline JS). **No framework, no
bundler, no build step.** Supporting files: `icons.js` (shared Lucide icon set via `ic()`), `connect.html`
(embed), `sw.js` (service worker), `bizconnect-toast.js`.
- **PWA:** installable; service worker for Web Push + offline shell.
- **Icons:** Lucide (shared set, rendered through `ic()`); no emoji as control icons.
## Mobile app (`mobile/`) — Capacitor
- **Framework:** **Capacitor 8** (`@capacitor/core` `^8`) — a native shell (iOS WKWebView / Android WebView)
loading the remote web UI from `https://remote.bizgaze.com`. **Not Flutter/React Native.**
- **Official plugins (`^8`):** App, Camera, Filesystem, Keyboard, Share, Push Notifications, Splash Screen,
Status Bar; **community** `@capacitor-community/safe-area` `^8`; `@capacitor/assets` `^3` (icons/splash).
- **Local/custom plugins (`mobile/plugins/`):**
- `native-call` — native calling. **iOS:** Swift, CallKit + PushKit (VoIP) + LiveKit Swift SDK (native media,
video tiles, screen-share via a ReplayKit broadcast extension), on-device live transcription
(`SFSpeechRecognizer`). **Android:** Java, full-screen incoming-call UI over the lock screen + a
`FirebaseMessagingService` woken by high-priority FCM data messages (native LiveKit media = Increment 2, WIP).
- `audio-route` (iOS audio output routing), `media-library` (save to Photos/gallery), `share-inbox` (inbound
share sheet), `file-opener` (native Quick Look / open-with).
- **iOS build:** Capacitor 8 + **SPM** (CocoaPods retired); Xcode 26; a share extension + a broadcast extension.
- **Android build:** JDK **21**, AGP 8.7 / Gradle 8.11, target SDK 36, edge-to-edge; manifest patched in CI
(`mobile/scripts/android-patch.js`) since `mobile/android/` is generated fresh per build.
## Desktop app (`desktop/`) — Electron
- **Electron 31** + **electron-builder 24** (NSIS/Windows, DMG/mac, AppImage/Linux) + **electron-updater 6**
(auto-update from `https://remote.bizgaze.com/downloads/`). Wraps the web UI and adds native screen capture.
Optional **`@nut-tree-fork/nut-js`** for input automation.
## Native host agent (`agent/`) — Electron
- Legacy **remote-access agent** (`remote-access-agent`): Electron 31 host for screen capture, WebRTC,
consent, and OS input injection (`@nut-tree-fork/nut-js`), signaling over `ws`.
## Push notifications
- **Web Push** via VAPID (`web-push` `^3.6`) for browsers/PWA.
- **Android:** Firebase Cloud Messaging (FCM) — Firebase project `bizgaze-connect`; client `google-services.json`
ships in the APK; server uses a service-account key (base64 in `.env`). High-priority **data** messages wake the
native-call plugin for incoming calls.
- **iOS:** Apple Push Notification service (APNs) over HTTP/2 with a token (`.p8`) auth key, including **VoIP
pushes** (PushKit → CallKit) for incoming calls.
## Infrastructure & deployment
- **Containers:** Docker + Docker Compose (`Dockerfile`, `docker-compose.yml`). Services: `app` (Node),
`bizgazepg` (Postgres 16), `bizgazeredis` (Redis 7, `scale` profile), `livekit` (SFU, `sfu` profile).
- **Reverse proxy / TLS:** **Nginx Proxy Manager** (external, shared `nginx_proxy_manager_default` network) —
terminates HTTPS and proxies `remote.bizgaze.com` → `app:8090` and `wss://livekit.bizgaze.com` → LiveKit.
- **Persistence:** named Docker volumes — app data (`/data`: uploads, recordings, transcripts, downloads) and
Postgres data.
- **Deploy flow:** push to `master` (Gitea remote `code.bizgaze.com`) → `redeploy.sh` (plink/SSH to the host) →
server-side `deploy.sh` (git pull + docker rebuild + recreate). Secrets live in a gitignored `.env` on the
server (`/opt/bizgaze-support/.env`); see `.env.example`.
- **CI (mobile builds):** **Codemagic** (`codemagic.yaml`) — iOS (`mac_mini_m2`, SPM, publishes to TestFlight) and
Android (`mac_mini_m2`, Node 22, JDK 21, produces an installable debug APK).
## External integrations
- **BizGaze** SSO / identity provider + outbound webhook (`bizgaze.js`, `webhooks.js`).
- **SMTP email** via **Nodemailer** `^6.9` (optional) — meeting invites to external participants.
- **GIPHY** GIF search (server-proxied, optional, key-gated).
- **TURN/STUN** for WebRTC NAT traversal (credentials via `.env`).
## Dev & test tooling
- **Local DB for tests:** PostgreSQL 16 (local).
- **Browser automation:** `puppeteer-core` driving local Chrome (scratch testing).
- **Android debugging:** `adb` (`platform-tools`).
- **Shell/OS:** developed on Windows; Git Bash + PowerShell; deploy via plink (PuTTY).
---
## Repo layout (key directories)
| Path | What |
|---|---|
| `server/` | Node backend (API, WS, data access, push, media) |
| `server/public/` | Vanilla-JS web UI (`home.html`, `icons.js`, `sw.js`, `vendor/`) |
| `server/db/` | PostgreSQL schema + DB adapter |
| `mobile/` | Capacitor app (iOS/Android) |
| `mobile/plugins/` | Custom Capacitor plugins (native-call, audio-route, media-library, share-inbox, file-opener) |
| `desktop/` | Electron technician desktop client |
| `agent/` | Legacy Electron remote-access host agent |
| `Dockerfile`, `docker-compose.yml`, `livekit.yaml` | Deploy stack |
| `codemagic.yaml` | Mobile CI (iOS + Android) |
| `redeploy.sh`, `deploy.sh` | Deployment |
| `ARCHITECTURE.md`, `DEPLOY.md`, `CLIENTS.md`, `README.md` | Docs |
---
## Maintaining this file
Update this document in the **same commit** as any change that alters the stack, specifically when you:
- add/remove a dependency, or bump a major version (server `package.json`, `mobile/package.json`,
`desktop/package.json`, `agent/package.json`, plugin manifests);
- change a platform/toolchain version (Node, Postgres, Redis, LiveKit, Capacitor, Electron, JDK, Xcode);
- add/remove a service in `docker-compose.yml` or a build workflow in `codemagic.yaml`;
- add a new custom Capacitor plugin or a new external integration.
Bump the **Last updated** date at the top and add a line to the Changelog below.
## Changelog
- **2026-10-27** — Initial tech-stack document created.
+145 -9
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,10 +44,33 @@ 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 file-opener; 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"
npx capacitor-assets generate --ios || echo "capacitor-assets returned non-zero (see above)"
# HARD-VERIFY the splash + icon actually landed in the iOS project. Previously this step swallowed
# failures with `|| echo skipped`, so a broken generation shipped Capacitor's BLANK default splash
# (looked unbranded on launch). Fail the build loudly instead of shipping an empty splash.
SPLASH_PNG=$(find ios -path "*Assets.xcassets/Splash.imageset*" -name "*.png" 2>/dev/null | head -1)
ICON_PNG=$(find ios -path "*Assets.xcassets/AppIcon.appiconset*" -name "*.png" 2>/dev/null | head -1)
if [ -z "$SPLASH_PNG" ]; then echo "ERROR: iOS Splash.imageset not generated — the app would launch with a blank splash"; exit 1; fi
echo "iOS splash asset OK -> $SPLASH_PNG"
echo "iOS app icon asset -> ${ICON_PNG:-MISSING}"
- name: Patch Info.plist (App-Review privacy strings) + bundle id
script: |
@@ -61,6 +84,15 @@ workflows:
# generates already contains the new target.
ruby mobile/scripts/add-share-extension.rb
- name: Add the Broadcast (screen-share) Extension target
script: |
# Inject the ReplayKit broadcast upload extension (lets the user share their iPhone screen).
# It links the LiveKit Swift package (LKSampleHandler). Runs after cap sync so the SPM project
# + App.entitlements already exist. A build failure here is most likely the SPM product-link or
# the missing App Group capability on the com.bizgaze.connect.broadcast App ID (a one-time manual
# step in the Apple Developer portal — see mobile/IOS_SETUP.md).
ruby mobile/scripts/add-broadcast-extension.rb
- name: Set up code signing
script: |
# Create the distribution certificate + provisioning profile from the ASC API key and add the
@@ -88,12 +120,17 @@ workflows:
--type IOS_APP_STORE \
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
--create
# THIRD bundle id: the broadcast (screen-share) extension. Same story as .share — its App Group
# capability (group.com.bizgaze.connect) must be enabled MANUALLY on the App ID in the Apple
# Developer portal (fetch-signing-files registers the id + profile but does NOT toggle App Group).
app-store-connect fetch-signing-files "${BUNDLE_ID}.broadcast" \
--type IOS_APP_STORE \
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
--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 +141,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 "================================================================="
@@ -128,3 +165,102 @@ workflows:
# submit_to_app_store: false
# No email recipients here on purpose — build status is watched on the Codemagic dashboard. Add
# per-user notifications in the Codemagic UI (or a `publishing.email` block) later if you want them.
# ── Android test build ──────────────────────────────────────────────────────────────────────────
# Builds an INSTALLABLE debug APK of the same Capacitor shell (loads https://remote.bizgaze.com). This is
# the Android equivalent of the iOS TestFlight loop, but simpler — no Google Play account or signing is
# needed to test: download the APK artifact from the Codemagic build page (or wire an email/Slack in the
# UI), sideload it on a phone (enable "Install unknown apps"), and run.
#
# Runs on the SAME macOS instance as the iOS workflow (mac_mini_m2) — Codemagic's macOS images ship the
# Android SDK + JDK too. (A Linux instance would be cheaper/faster but linux_x2 isn't on every billing
# plan; mac_mini_m2 is the one this account already uses for iOS.)
#
# Firebase push (FCM) is OPTIONAL for this test build: app/build.gradle only applies the google-services
# plugin when google-services.json is present, so the APK builds fine WITHOUT it (push/call-wake just
# won't fire). To enable push, base64 the google-services.json and store it as a secure Codemagic env var
# GOOGLE_SERVICES_JSON (group `android_config`); the step below decodes it into place.
android-apk:
name: Biz Connect Android → test APK
max_build_duration: 45
instance_type: mac_mini_m2
environment:
# To enable FCM push later, create a Codemagic variable group holding GOOGLE_SERVICES_JSON (base64 of
# google-services.json, marked secure) and uncomment the two lines below. Left out for now so the first
# test build needs ZERO Codemagic setup.
# groups:
# - android_config
vars:
PACKAGE_NAME: "com.bizgaze.connect"
node: 22
java: 21 # Capacitor 8's Android build (AGP 8.7 / Gradle 8.11) requires JDK 21 — JDK 17 fails the build.
scripts:
- name: Install JS dependencies
script: |
cd mobile
npm install
- name: Generate the Android project (Capacitor)
script: |
cd mobile
# `cap add android` scaffolds android/; safe to re-run — it no-ops if it already exists (our committed
# project has custom manifest permissions, which cap sync preserves).
if [ ! -d "android" ]; then npx cap add android; fi
npx cap sync android
# App icon + splash from resources/icon.png & resources/splash*.png.
npx capacitor-assets generate --android || echo "capacitor-assets returned non-zero (see above)"
# The generated project points sdk.dir at wherever it was made; overwrite it with the CI SDK path so
# Gradle finds the SDK. Codemagic exports ANDROID_SDK_ROOT (fall back to ANDROID_HOME on macOS images).
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
echo "sdk.dir=$SDK" > android/local.properties
echo "Android SDK -> $SDK"
# android/ is gitignored and regenerated fresh in CI, so its manifest only has INTERNET. Inject the
# camera/mic/notification permissions the web UI needs (mirrors ios-patch.sh for iOS). Tolerant + idempotent.
node scripts/android-patch.js android/app/src/main/AndroidManifest.xml
- name: Firebase google-services.json for FCM push
script: |
# Place the client Firebase config into android/app/ so the google-services Gradle plugin applies and
# FirebaseApp initializes at runtime — WITHOUT it, PushNotifications.register() throws
# "Default FirebaseApp is not initialized" and crashes the app on Android. Prefer the committed file;
# fall back to a base64 GOOGLE_SERVICES_JSON env var. (Runs from the repo root; the Android project was
# generated in the previous step.)
DEST=mobile/android/app/google-services.json
if [ -f mobile/FirebaseAccount_Google/google-services.json ]; then
cp mobile/FirebaseAccount_Google/google-services.json "$DEST"
echo "google-services.json copied from repo → FCM enabled"
elif [ -n "$GOOGLE_SERVICES_JSON" ]; then
echo "$GOOGLE_SERVICES_JSON" | { base64 --decode 2>/dev/null || base64 -D; } > "$DEST"
echo "google-services.json written from env → FCM enabled"
else
echo "No google-services.json found — building WITHOUT FCM push"
fi
ls -l "$DEST" 2>/dev/null || echo "(no google-services.json placed)"
- name: Build the debug APK
script: |
set -e
cd mobile/android
chmod +x ./gradlew
# Debug build type is auto-signed with the Android debug keystore → directly installable, no Play
# account or upload key needed. (A signed release AAB for the Play Store is a later, separate step.)
# Capture the output so that, on failure, we surface Gradle's actual "What went wrong" block instead
# of a wall of internal stack frames — and FAIL the step (a trailing `find` used to exit 0 and mask it).
set +e
./gradlew assembleDebug --stacktrace 2>&1 | tee /tmp/gradle.log
STATUS=${PIPESTATUS[0]}
set -e
if [ "$STATUS" != "0" ]; then
echo "======================= GRADLE FAILURE ======================="
grep -n -A 25 "What went wrong" /tmp/gradle.log || true
grep -n -A 3 "FAILURE:" /tmp/gradle.log || true
echo "=============================================================="
exit 1
fi
echo "APK(s):"; find app/build/outputs -name "*.apk"
test -n "$(find app/build/outputs -name '*.apk' -print -quit)" || { echo "ERROR: no APK produced"; exit 1; }
artifacts:
- mobile/android/app/build/outputs/**/*.apk
# Download the APK from the build page. To get it emailed like TestFlight, add a `publishing.email` block
# here (or notifications in the Codemagic UI). A signed release AAB → Google Play internal testing is a
# separate workflow we can add once the shell is verified on a device.
+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"
+44 -2
View File
@@ -10,7 +10,6 @@ services:
restart: unless-stopped
environment:
- PORT=8090
- DB_PATH=/data/data.db
# Desktop installers + auto-update feed live on the persistent volume so uploaded
# builds survive image rebuilds (a plain image path would be wiped on every deploy).
- DOWNLOADS_DIR=/data/downloads
@@ -31,7 +30,49 @@ services:
- path: .env
required: false
volumes:
- bizgaze_support_data:/data # persists data.db across rebuilds
- bizgaze_support_data:/data # persists uploads / recordings / transcripts / downloads across rebuilds
networks:
- npm
# The DB is Postgres (SQLite retired 2026-08-12), so wait for it to be healthy before the app starts —
# otherwise the first queries race the DB coming up.
depends_on:
bizgazepg:
condition: service_healthy
# PostgreSQL — the app's data store (DB_BACKEND=pg; the only backend since SQLite was retired). 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
@@ -68,3 +109,4 @@ networks:
volumes:
bizgaze_support_data:
bizgaze_pg_data:
+159
View File
@@ -0,0 +1,159 @@
# Biz Connect — App Store submission pack
Everything App Store Connect asks for, drafted. Fill the **`<< … >>`** placeholders (they're
account-specific or secret and must NOT be committed). Order below ≈ the order App Store Connect walks you through.
---
## 0. Before you submit (gates that cause rejection)
- [x] **Verified build** uploaded from Codemagic to App Store Connect (splash build; multi-device tiles, transcripts, calls ring; moderation + meeting-push are live web-side).
- [x] **Reviewer demo account** — connect@bizgaze.com / Qwerty@789 (works; non-admin so it sees Report/Block but not the admin Reports view).
- [x] **Privacy Policy URL** — https://remote.bizgaze.com/privacy (live, public).
- [x] **Support URL** — https://remote.bizgaze.com/support (live, public).
- [x] **Screenshots** — 6.9" set (1320×2868) in mobile/appstore-screenshots/ (01-chats, 02-conversation, 03-meetings).
- [x] **Export compliance** — ITSAppUsesNonExemptEncryption=false baked into the build (App Store Connect won't ask).
---
## 1. App information
| Field | Value |
|---|---|
| **App name** | BizGaze Connect (App Store listing name; renamed 2026-08-21) |
| **Subtitle** (30 char max) | Team chat, calls & meetings |
| **Primary category** | Business |
| **Secondary category** | Productivity |
| **Bundle ID** | com.bizgaze.connect |
| **Privacy Policy URL** | https://remote.bizgaze.com/privacy |
| **Support URL** | https://remote.bizgaze.com/support |
| **Marketing URL** (optional) | leave blank, or your product page |
| **Age rating** | 4+ (answer all content questions "None". Note: user-generated content via chat — see §8) |
---
## 2. Description
> Biz Connect keeps your team connected — chat, voice and video calls, and meetings, in one place.
>
> **Chat that works the way your team does**
> • Direct messages and group conversations
> • Reactions, replies, mentions, pinned messages, and polls
> • Share photos, videos, and files
> • Read receipts and typing indicators
>
> **Calls that ring like a real phone**
> • One-to-one and group voice & video calls
> • Full-screen incoming call ringing, even when the app is closed
> • Calls keep working when you switch apps or lock your phone
>
> **Meetings, built in**
> • Start instantly or schedule ahead
> • Screen sharing and camera, front or back
> • Live transcripts you can save and download
> • Meeting recordings for later
>
> **Everywhere you are**
> Your conversations stay in sync across iPhone, desktop, and the web.
>
> Biz Connect is for organizations using the BizGaze platform. Sign in with your BizGaze account to get started.
**Keywords** (100 char max, comma-separated, no spaces after commas):
`team chat,business messaging,video call,voice call,meetings,screen share,transcript,collaboration,work`
**Promotional text** (170 char, editable without a new build):
> Chat, call, and meet with your team — with real-phone-style ringing, screen sharing, and live meeting transcripts.
---
## 3. What's New (release notes for this version)
> • Live meeting transcripts on iPhone — for both calls and scheduled meetings
> • Join the same meeting from two devices at once, each as its own participant
> • Stability and audio-routing improvements
---
## 4. App Privacy ("nutrition label")
Answer these in App Store Connect → App Privacy. **Verify each against what the BizGaze backend actually stores**
before publishing — this is a legal declaration. Sensible defaults for a business comms app:
**Data used to identify the user (Linked to identity):**
- **Contact Info → Name, Email address** — App Functionality, Account management. (BizGaze login.)
- **User Content → Photos or Videos, Other User Content (messages, files)** — App Functionality. (Chat/meeting content stored on your server.)
- **Identifiers → User ID** — App Functionality.
**Diagnostics / Usage:** declare only if you actually collect analytics/crash data. If not, mark **"Data Not Collected"** for those.
**Important clarifications to make in the notes:**
- **Microphone & Camera** audio/video for calls is transmitted between participants (via your LiveKit server) but is only *recorded/stored* when a user explicitly starts a recording or transcript. Say so.
- **Speech recognition** for transcripts runs **on-device** (Apple's `SFSpeechRecognizer`, on-device mode) — the audio is not sent to Apple, and only the finished text is added to the meeting transcript. This is a good thing to state explicitly; it reassures review.
- **Third-party:** if BizGaze/LiveKit are your own infrastructure, no third-party SDK data-sharing to declare. Confirm you have no analytics/ad SDKs.
**Privacy usage strings** (already in the build via `ios-patch.sh` — for reference):
- Camera: "Biz Connect uses the camera for video calls and to share photos and your screen."
- Microphone: "Biz Connect uses the microphone for voice and video calls."
- Speech Recognition: "Biz Connect uses speech recognition to create live meeting transcripts from your microphone."
- Photo Library / Add: send/save images.
---
## 5. Screenshots
Required (App Store Connect accepts one size and scales, but do at least these two):
- [ ] **6.9" iPhone** (1320 × 2868) — iPhone 16 Pro Max class
- [ ] **6.5" iPhone** (1242 × 2688) — fallback for older devices
- [ ] (Optional) iPad if you enable iPad support
Ready-made set in `mobile/appstore-screenshots/` (1320×2868, anonymized). Upload in this order:
`01-chats → 02-conversation → 04-group → 03-meetings → 06-screenshare → 05-schedule`
---
## 6. App Review notes (paste into "Notes")
> Biz Connect requires a BizGaze account to sign in.
>
> Demo account for review:
> Email: << demo@yourdomain >>
> Password: << demo password >>
>
> How to test:
> 1. Open the app and sign in with the demo account above.
> 2. Chat tab: open a conversation to see messaging.
> 3. Start a call from a conversation, or the Meetings tab to start/join a meeting.
> 4. In a meeting, tap "Live transcript" to see on-device speech-to-text.
>
> Notes on permissions:
> • Microphone/Camera — used for voice and video calls.
> • Speech Recognition — used only to generate live meeting transcripts; recognition runs on-device.
> • Screen recording (broadcast) — used only when the user chooses to share their screen in a meeting.
> • VoIP push (PushKit) + CallKit — used to ring incoming calls like a normal phone call.
**Create the demo account now** and confirm it can actually log in and start a call. A dead demo login is the #1 rejection cause for account-gated apps.
---
## 7. Export compliance
The app uses only standard encryption (HTTPS/TLS, WebRTC/DTLS-SRTP) — no proprietary/custom crypto.
- In App Store Connect: **"Does your app use encryption?" → Yes**, then **"only … standard encryption algorithms" → Yes** → qualifies for the exemption (no CCATS/year-end self-classification report needed for standard encryption).
- Optional: set `ITSAppUsesNonExemptEncryption = NO` in Info.plist to skip the question each submission (add to `ios-patch.sh` if you want it permanent — say the word and I'll add it).
---
## 8. Likely review questions / risks (and answers)
- **Account-gated app** → mitigated by the demo account (§6). Also fine per guideline 3.1.1 since it's a business tool, not gating features behind sign-in for a consumer app.
- **User-generated content (chat)** → guideline 1.2 satisfied (shipped 2026-08-19): every message has **Report** (long-press / ⋮ → Report, canned reasons) and **Block user**; blocked users can't message or call you (server-enforced). A **Blocked users** manager lives in the profile menu (unblock anytime), and workspace **admins** get a **Reported messages** review screen (delete content / block / resolve). Reports are org-internal (routed to the workspace's own admins). Reviewer note suggestion: "Report and Block are available on any message via long-press; Blocked users are managed from the profile menu."
- **CallKit + VoIP push** → legitimate; the demo/reviewer flow should show a real incoming call if possible.
- **Background modes** (audio, voip) → justified by calls; the review notes cover it.
---
## 9. Nice-to-haves (not blockers)
- App Store promotional/preview **video** (optional).
- Localized metadata if you target non-English regions.
- A short **"in-app account deletion"** path — Apple requires apps with account creation to offer account deletion (guideline 5.1.1(v)). If BizGaze accounts are created/managed externally (admin-provisioned, not self-signup in the app), note that in review; if users *can* self-register in the app, an in-app "delete my account" (or a clear link to do so) is required.
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "860472998800",
"project_id": "bizgaze-connect",
"storage_bucket": "bizgaze-connect.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:860472998800:android:107cc0fc6936d6898dd115",
"android_client_info": {
"package_name": "com.bizgaze.connect"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyB6gSqIDYr21GKYA4c1e7plUBQMXIcX1Dw"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+30 -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`
@@ -115,3 +125,22 @@ to the Files folder, the Photos "Connect" album, Manage storage — works withou
Also enabled by this change (main app Info.plist, done automatically by `ios-patch.sh`):
- `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` → the **Biz Connect** folder in Files.
- `CFBundleURLTypes` scheme **`bizconnect`** → lets the extension bounce back into the app after staging.
## Broadcast Extension (share your iPhone SCREEN in a call) — one-time Apple portal setup
Screen sharing from iOS uses a **Broadcast Upload Extension** (`com.bizgaze.connect.broadcast`) — the same
pattern as the Share Extension. The Codemagic build injects the target, links LiveKit into it, and fetches a
profile automatically, but the **App Group capability can only be toggled by hand** in the Apple portal:
1. Reuse the SAME App Group as the Share Extension: **`group.com.bizgaze.connect`** (no new group needed).
2. **Enable the App Groups capability on the broadcast App ID** and assign it to that group:
- `com.bizgaze.connect.broadcast` (create this App ID if the first build hasn't yet — `fetch-signing-files
--create` registers it, then edit it to add App Groups). The app (`com.bizgaze.connect`) already has the
group from the Share Extension setup above.
After enabling it, re-run the Codemagic build so `fetch-signing-files` regenerates the profile.
Until the App Group is on the broadcast App ID, the **archive fails code-signing** (entitlement mismatch) —
that's the expected first-build failure. The shared App Group is how the extension (ReplayKit capture) hands
screen frames to the app over LiveKit's IPC socket. Receiving OTHERS' shared screens needs none of this — it
already works. `ios-patch.sh` sets `RTCScreenSharingExtension` + `RTCAppGroupIdentifier` in the app Info.plist
so LiveKit finds the extension + group.
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

+6 -1
View File
@@ -14,7 +14,12 @@
"SafeArea": {
"detectViewportFitCoverChanges": true,
"initialViewportFitCover": true,
"offsetForKeyboardInsetBug": false
"offsetForKeyboardInsetBug": true,
"statusBarStyle": "DARK",
"navigationBarStyle": "DARK"
},
"SystemBars": {
"insetsHandling": "disable"
},
"Keyboard": {
"resize": "none"
Binary file not shown.
@@ -0,0 +1,10 @@
<?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>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.bizgaze.connect</string>
</array>
</dict>
</plist>
+29
View File
@@ -0,0 +1,29 @@
<?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>
<key>CFBundleDisplayName</key>
<string>Biz Connect Screen</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.broadcast-services-upload</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SampleHandler</string>
<key>RPBroadcastProcessMode</key>
<string>RPBroadcastProcessModeSampleBuffer</string>
</dict>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
import ReplayKit
import LiveKit
// Principal class of the Broadcast Upload Extension (ReplayKit) that lets the iOS user share their screen.
//
// HOW IT WORKS: when the user starts a system broadcast, iOS launches THIS extension. LiveKit's LKSampleHandler
// does everything — on broadcastStarted it opens an IPC socket in the shared App Group (group.com.bizgaze.connect)
// and streams the ReplayKit sample buffers to the MAIN app, whose LiveKit connection publishes them as a
// screen-share track. The extension itself never creates a Room / initialises WebRTC, so it stays well under the
// 50 MB extension memory limit. So this subclass can be empty.
//
// The extension finds the app group + reports state to the app via LiveKit's Darwin-notification/socket
// convention, keyed off this extension's bundle id (com.bizgaze.connect.broadcast) and the default app group
// group.<appBundleId>. Both are also set explicitly on the app side (RTCScreenSharingExtension /
// RTCAppGroupIdentifier in Info.plist) so there's no ambiguity.
final class SampleHandler: LKSampleHandler, @unchecked Sendable {}
+15 -13
View File
@@ -11,23 +11,25 @@
},
"dependencies": {
"audio-route": "file:plugins/audio-route",
"file-opener": "file:plugins/file-opener",
"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"
}
}
@@ -0,0 +1,22 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
Pod::Spec.new do |s|
# NOTE: the pod name MUST be 'FileOpener' (PascalCase of the npm package name 'file-opener').
# `cap sync` writes `pod 'FileOpener', :path => '../../plugins/file-opener'` into the generated Podfile,
# and CocoaPods then looks for a file literally named FileOpener.podspec whose s.name is 'FileOpener'.
# Any other name → "No podspec found for `FileOpener`" and pod install fails. (Same trap that broke the
# AudioRoute build — see mobile/plugins/audio-route/AudioRoute.podspec.)
s.name = 'FileOpener'
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/file-opener.git', :tag => s.version.to_s }
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
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: "FileOpener",
platforms: [.iOS(.v15)],
products: [
.library(name: "FileOpener", targets: ["FileOpenerPlugin"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
],
targets: [
.target(
name: "FileOpenerPlugin",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm")
],
path: "ios/Sources/FileOpenerPlugin")
]
)
@@ -0,0 +1,71 @@
import Foundation
import Capacitor
import QuickLook
// Previews an already-downloaded file with iOS Quick Look — the native "look at this file" surface: swipe,
// pinch-zoom, print, and its own share button. Registered by cap sync as window.Capacitor.Plugins.FileOpener.
//
// WHY A PLUGIN: the app loads its UI from a REMOTE origin (remote.bizgaze.com), so it cannot open a local
// file:// URL in the WebView (cross-origin / capacitor local-serving isn't on this origin). @capacitor/share
// only offers the share SHEET ("open in another app"), not a preview. Only QLPreviewController, presented
// from native code, gives a real in-app preview. Quick Look picks the renderer from the file extension, which
// is why the web layer now saves downloads with a correct extension (see bzEnsureExt in home.html).
@objc(FileOpenerPlugin)
public class FileOpenerPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "FileOpenerPlugin"
public let jsName = "FileOpener"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "preview", returnType: CAPPluginReturnPromise)
]
// QLPreviewController holds its dataSource weakly, so we must keep a strong reference alive for the
// lifetime of the presented preview — otherwise it deallocates and the preview shows blank.
private var dataSource: QLDataSource?
@objc func preview(_ call: CAPPluginCall) {
guard let raw = call.getString("path"), !raw.isEmpty else {
call.reject("path is required"); return
}
let url = FileOpenerPlugin.fileURL(from: raw)
guard FileManager.default.fileExists(atPath: url.path) else {
call.reject("file not found: \(url.path)"); return
}
DispatchQueue.main.async {
let ds = QLDataSource(url: url)
self.dataSource = ds
let controller = QLPreviewController()
controller.dataSource = ds
controller.modalPresentationStyle = .fullScreen
guard let base = self.bridge?.viewController else {
call.reject("no view controller to present from"); return
}
// Present on top of whatever is already showing (a modal, another sheet) so it never fails silently.
var presenter = base
while let top = presenter.presentedViewController { presenter = top }
presenter.present(controller, animated: true) {
call.resolve(["ok": true])
}
}
}
// Accepts either a file:// URI (what Filesystem.writeFile returns) or a bare absolute path.
private static func fileURL(from raw: String) -> URL {
if raw.hasPrefix("file://") {
if let u = URL(string: raw) { return u }
// Un-encoded spaces make URL(string:) fail — percent-encode and retry before giving up.
let encoded = raw.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? raw
if let u = URL(string: encoded) { return u }
}
return URL(fileURLWithPath: raw)
}
}
// Single-item data source. NSURL already conforms to QLPreviewItem.
final class QLDataSource: NSObject, QLPreviewControllerDataSource {
private let url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
return url as NSURL
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "file-opener",
"version": "1.0.0",
"description": "Preview a downloaded file with native iOS Quick Look for Biz Connect",
"main": "dist/plugin.cjs.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"author": "BizGaze",
"license": "MIT",
"files": [
"dist/",
"ios/",
"FileOpener.podspec",
"Package.swift"
],
"capacitor": {
"ios": {
"src": "ios"
}
},
"devDependencies": {
"@capacitor/core": "^8.0.0"
},
"peerDependencies": {
"@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,65 @@
// Android build for the native-call plugin. Mirrors the official @capacitor/* plugin module layout (see
// node_modules/@capacitor/status-bar/android/build.gradle) so it drops into the CI-generated app exactly like
// the plugins that already build. Java (not Kotlin) to match the proven official-plugin structure — no extra
// kotlin-gradle-plugin wiring in the library module, one less thing to fail when authored blind.
ext {
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
// Same FCM version the @capacitor/push-notifications plugin pulls in, so we don't fork the Firebase version.
firebaseMessagingVersion = project.hasProperty('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '23.3.1'
}
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
}
}
apply plugin: 'com.android.library'
android {
namespace "com.bizgaze.connect.nativecall"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':capacitor-android')
// The Biz Connect call-wake FirebaseMessagingService extends the @capacitor/push-notifications one so a
// single service handles BOTH regular push (forwarded via super) and call-wake data messages.
implementation project(':capacitor-push-notifications')
implementation "androidx.core:core:$androidxCoreVersion"
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
// RemoteMessage / FirebaseMessaging (transitive from the push plugin, but declared so this module compiles
// against it directly; same version → no resolution conflict).
implementation "com.google.firebase:firebase-messaging:$firebaseMessagingVersion"
}
@@ -0,0 +1,36 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Full-screen incoming-call UI over the lock screen (Android's answer to CallKit). VIBRATE + WAKE_LOCK
for the ring; POST_NOTIFICATIONS is also added app-side by android-patch.js. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
DATA messages natively (to ring when the app is closed) and forwards everything else to Capacitor's
push handling via super.onMessageReceived(). Capacitor's service is removed in the APP manifest
(android-patch.js, highest merge priority) so there is exactly one MESSAGING_EVENT receiver; the high
intent-filter priority here is belt-and-braces so ours wins even if that removal ever no-ops. -->
<service
android:name="com.bizgaze.connect.nativecall.BzCallMessagingService"
android:exported="false">
<intent-filter android:priority="999">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- The full-screen incoming-call screen. showWhenLocked + turnScreenOn make it appear over the lock
screen and wake the display; excludeFromRecents keeps it out of the recents list. -->
<activity
android:name="com.bizgaze.connect.nativecall.IncomingCallActivity"
android:exported="false"
android:launchMode="singleTop"
android:excludeFromRecents="true"
android:showOnLockScreen="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar" />
</application>
</manifest>
@@ -0,0 +1,61 @@
package com.bizgaze.connect.nativecall;
import android.util.Log;
import androidx.annotation.NonNull;
import com.google.firebase.messaging.RemoteMessage;
import java.util.HashMap;
import java.util.Map;
// The single FirebaseMessagingService for the app. It REPLACES @capacitor/push-notifications' MessagingService
// (removed in AndroidManifest via tools:node="remove") so there is exactly one MESSAGING_EVENT receiver:
// * call-wake DATA messages (data.bzcall == "1") → ring natively, even when the app is closed. A high-priority
// data-only FCM message is delivered to onMessageReceived in any app state (a "notification" message would
// just sit in the tray and never run our code).
// * everything else → super.onMessageReceived(), i.e. Capacitor's normal push handling (chat/meeting banners).
// onNewToken is likewise forwarded so the web still registers this device's "android" FCM token — the same token
// the server targets for call-wakes.
public class BzCallMessagingService extends com.capacitorjs.plugins.pushnotifications.MessagingService {
@Override
public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
Map<String, String> data = remoteMessage.getData();
Log.i("bzcall", "FCM onMessageReceived: bzcall=" + (data == null ? "null" : data.get("bzcall"))
+ " type=" + (data == null ? "" : data.get("type")) + " keys=" + (data == null ? "0" : data.keySet()));
if (data != null && "1".equals(data.get("bzcall"))) {
String type = data.get("type");
String uuid = data.get("callUUID");
if ("cancel".equals(type)) {
CallStore.markEnded(uuid);
CallStore.cancel(getApplicationContext());
IncomingCallActivity.dismissIfShowing();
return;
}
Map<String, String> call = new HashMap<>();
call.put("callUUID", uuid == null ? "" : uuid);
call.put("room", or(data.get("room")));
call.put("kind", data.get("kind") == null ? "dm" : data.get("kind"));
call.put("callerId", or(data.get("callerId")));
call.put("callerName", data.get("callerName") != null ? data.get("callerName")
: (data.get("groupName") != null ? data.get("groupName") : "Incoming call"));
call.put("groupId", or(data.get("groupId")));
call.put("groupName", or(data.get("groupName")));
// Server sends hasVideo as "1"/""; normalize to "true"/"false" so the UI's checks are consistent
// with the WebSocket path (reportIncomingCall stores Boolean.toString()).
String hv = data.get("hasVideo");
call.put("hasVideo", ("1".equals(hv) || "true".equals(hv)) ? "true" : "false");
CallStore.showIncoming(getApplicationContext(), call);
return;
}
super.onMessageReceived(remoteMessage);
}
@Override
public void onNewToken(@NonNull String s) {
super.onNewToken(s);
}
private static String or(String v) { return v == null ? "" : v; }
}
@@ -0,0 +1,116 @@
package com.bizgaze.connect.nativecall;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// Builds and shows the full-screen incoming-call notification (Android's stand-in for CallKit's system ring),
// and dedupes calls so the FCM push path and the WebSocket path don't ring the same call twice. One call at a
// time is assumed (a single NOTIF_ID), matching the app's 1:1 / single-group-call model.
class CallStore {
// v2: SILENT channel. The ring + vibration are driven by IncomingCallActivity (a single, looping source);
// if the channel also played a sound we'd get the "ring twice overlapping" the user reported. A channel's
// sound is immutable once created, so we bump the id to apply the new silent config on existing installs.
static final String CHANNEL_ID = "bzc_incoming_calls_v2";
static final int NOTIF_ID = 2748;
static final String ACTION_RING = "com.bizgaze.connect.nativecall.RING";
static final String ACTION_ACCEPT = "com.bizgaze.connect.nativecall.ACCEPT";
static final String ACTION_DECLINE = "com.bizgaze.connect.nativecall.DECLINE";
// callUUIDs currently ringing/active and ones we've already ended — so a late/duplicate wake doesn't re-ring.
private static final java.util.Set<String> active = ConcurrentHashMap.newKeySet();
private static final java.util.Set<String> ended = ConcurrentHashMap.newKeySet();
static boolean isKnown(String uuid) { return uuid != null && (active.contains(uuid) || ended.contains(uuid)); }
static void markActive(String uuid) { if (uuid != null) active.add(uuid); }
static void markEnded(String uuid) { if (uuid != null) { active.remove(uuid); ended.add(uuid); } }
private static void ensureChannel(Context ctx) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null || nm.getNotificationChannel(CHANNEL_ID) != null) return;
NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "Incoming calls", NotificationManager.IMPORTANCE_HIGH);
ch.setDescription("Ringing for incoming Biz Connect calls");
// Silent + no vibration: IncomingCallActivity owns the ring + vibration (single source, avoids overlap).
ch.setSound(null, null);
ch.enableVibration(false);
ch.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
nm.createNotificationChannel(ch);
}
private static Intent activityIntent(Context ctx, String action, Map<String, String> call) {
Intent i = new Intent(ctx, IncomingCallActivity.class);
i.setAction(action);
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
for (Map.Entry<String, String> e : call.entrySet()) i.putExtra(e.getKey(), e.getValue());
return i;
}
private static PendingIntent activityPI(Context ctx, String action, int req, Map<String, String> call) {
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags |= PendingIntent.FLAG_IMMUTABLE;
return PendingIntent.getActivity(ctx, req, activityIntent(ctx, action, call), flags);
}
// Show the ringing notification with a full-screen intent (launches IncomingCallActivity over the lock
// screen when allowed; otherwise shows as a heads-up with Accept/Decline buttons).
static void showIncoming(Context ctx, Map<String, String> call) {
String uuid = call.get("callUUID");
Log.i("bzcall", "showIncoming uuid=" + uuid + " caller=" + call.get("callerName"));
if (uuid == null || ended.contains(uuid)) { Log.i("bzcall", "showIncoming skipped (ended/no-uuid)"); return; }
// Atomic dedup: add() returns false if already ringing → the FCM push and the WebSocket path can't both
// raise the same call (that was the "ring comes twice" bug).
if (!active.add(uuid)) { Log.i("bzcall", "showIncoming skipped (already ringing)"); return; }
ensureChannel(ctx);
String title = call.get("callerName");
if (title == null || title.isEmpty()) title = "Incoming call";
boolean video = "true".equals(call.get("hasVideo"));
PendingIntent full = activityPI(ctx, ACTION_RING, 1, call);
PendingIntent accept = activityPI(ctx, ACTION_ACCEPT, 2, call);
PendingIntent decline = activityPI(ctx, ACTION_DECLINE, 3, call);
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(android.R.drawable.sym_call_incoming)
.setContentTitle(title)
.setContentText(video ? "Incoming video call" : "Incoming call")
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(full)
.setFullScreenIntent(full, true)
.addAction(R.drawable.ic_call_decline, "Decline", decline)
.addAction(R.drawable.ic_call_answer, "Accept", accept);
try {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
Log.i("bzcall", "incoming-call notification posted (full-screen intent)");
} catch (SecurityException e) {
Log.w("bzcall", "notify() blocked — POST_NOTIFICATIONS not granted: " + e.getMessage());
}
// Start the ring HERE (not in the activity) so it sounds even when the full-screen screen doesn't launch
// — e.g. an unlocked Android 14 phone shows a heads-up instead of our activity.
Ringer.start(ctx);
}
static void cancel(Context ctx) {
Ringer.stop();
try { NotificationManagerCompat.from(ctx).cancel(NOTIF_ID); } catch (Exception ignored) {}
}
}
@@ -0,0 +1,234 @@
package com.bizgaze.connect.nativecall;
import android.app.Activity;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.getcapacitor.JSObject;
// The full-screen incoming-call UI (Android's stand-in for the CallKit ring screen). Launched over the lock
// screen by CallStore's full-screen-intent notification. Shows the caller + Accept/Decline; wires the choice
// back to the WebView via NativeCallPlugin. Media still runs in the WebView (Increment 1) — Accept just brings
// the app to the front and fires "answerCall", which the web joins as a normal meeting.
public class IncomingCallActivity extends Activity {
private static IncomingCallActivity current;
private JSObject callData;
private boolean handled = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
showOverLockScreen();
handleIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
}
private void handleIntent(Intent intent) {
String action = intent != null ? intent.getAction() : null;
Log.i("bzcall", "IncomingCallActivity action=" + action);
callData = extras(intent);
if (CallStore.ACTION_ACCEPT.equals(action)) { doAccept(); return; }
if (CallStore.ACTION_DECLINE.equals(action)) { doDecline(); return; }
// RING (default): show the UI. The ring itself is driven by Ringer (started in CallStore.showIncoming)
// so it sounds even when this activity doesn't launch (unlocked Android 14 shows a heads-up instead).
current = this;
setContentView(buildUi());
}
private JSObject extras(Intent intent) {
JSObject o = new JSObject();
String[] keys = {"callUUID", "room", "kind", "callerId", "callerName", "groupId", "groupName", "hasVideo"};
if (intent != null) for (String k : keys) { String v = intent.getStringExtra(k); if (v != null) o.put(k, v); }
return o;
}
private void showOverLockScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true);
setTurnScreenOn(true);
KeyguardManager km = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
if (km != null) km.requestDismissKeyguard(this, null);
} else {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
}
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
// A clean, phone-style incoming call screen: brand-blue backdrop, a circular avatar with the caller's
// initial, the name + call type, and two round icon buttons (red hang-up = decline, green phone = accept).
private View buildUi() {
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setGravity(Gravity.CENTER_HORIZONTAL);
root.setBackgroundColor(Color.parseColor("#16294F")); // brand blue
int side = dp(28);
root.setPadding(side, dp(64), side, dp(56));
boolean video = "true".equals(callData.getString("hasVideo", "false"));
String name = callData.getString("callerName", "Incoming call");
if (name == null || name.isEmpty()) name = "Incoming call";
// Top spacer pushes the avatar/name to the upper third.
root.addView(new View(this), new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
View avatar = avatarView(name);
LinearLayout.LayoutParams avLp = new LinearLayout.LayoutParams(dp(96), dp(96));
avLp.gravity = Gravity.CENTER_HORIZONTAL;
avLp.bottomMargin = dp(22);
root.addView(avatar, avLp);
TextView who = new TextView(this);
who.setText(name);
who.setTextColor(Color.WHITE);
who.setTextSize(27);
who.setGravity(Gravity.CENTER);
who.setTypeface(who.getTypeface(), android.graphics.Typeface.BOLD);
root.addView(who, wrap(Gravity.CENTER_HORIZONTAL, dp(6)));
TextView label = new TextView(this);
label.setText(video ? "Incoming video call" : "Incoming voice call");
label.setTextColor(Color.parseColor("#AEC2EA"));
label.setTextSize(15);
label.setGravity(Gravity.CENTER);
root.addView(label, wrap(Gravity.CENTER_HORIZONTAL, 0));
// Larger spacer drops the buttons to the bottom.
root.addView(new View(this), new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1.5f));
LinearLayout buttons = new LinearLayout(this);
buttons.setOrientation(LinearLayout.HORIZONTAL);
buttons.setGravity(Gravity.CENTER);
buttons.addView(callButtonColumn("Decline", "#DC2626", R.drawable.ic_call_decline, v -> doDecline()),
new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
buttons.addView(callButtonColumn("Accept", "#16A34A", R.drawable.ic_call_answer, v -> doAccept()),
new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
root.addView(buttons, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
return root;
}
private LinearLayout.LayoutParams wrap(int gravity, int bottomMargin) {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
lp.gravity = gravity;
lp.bottomMargin = bottomMargin;
return lp;
}
// Circular avatar with the caller's first initial.
private View avatarView(String name) {
android.widget.FrameLayout f = new android.widget.FrameLayout(this);
android.graphics.drawable.GradientDrawable bg = new android.graphics.drawable.GradientDrawable();
bg.setShape(android.graphics.drawable.GradientDrawable.OVAL);
bg.setColor(Color.parseColor("#2F4E8F"));
f.setBackground(bg);
TextView initial = new TextView(this);
String ch = (name != null && !name.trim().isEmpty()) ? name.trim().substring(0, 1).toUpperCase() : "?";
initial.setText(ch);
initial.setTextColor(Color.WHITE);
initial.setTextSize(40);
initial.setGravity(Gravity.CENTER);
f.addView(initial, new android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT, android.widget.FrameLayout.LayoutParams.MATCH_PARENT));
return f;
}
// A round colored icon button with a caption underneath.
private View callButtonColumn(String caption, String colorHex, int iconRes, View.OnClickListener onClick) {
LinearLayout col = new LinearLayout(this);
col.setOrientation(LinearLayout.VERTICAL);
col.setGravity(Gravity.CENTER_HORIZONTAL);
android.widget.ImageButton btn = new android.widget.ImageButton(this);
android.graphics.drawable.GradientDrawable circle = new android.graphics.drawable.GradientDrawable();
circle.setShape(android.graphics.drawable.GradientDrawable.OVAL);
circle.setColor(Color.parseColor(colorHex));
btn.setBackground(circle);
btn.setImageResource(iconRes);
btn.setColorFilter(Color.WHITE);
btn.setScaleType(android.widget.ImageView.ScaleType.CENTER);
btn.setOnClickListener(onClick);
col.addView(btn, new LinearLayout.LayoutParams(dp(72), dp(72)));
TextView t = new TextView(this);
t.setText(caption);
t.setTextColor(Color.parseColor("#D5E1F5"));
t.setTextSize(14);
t.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams tLp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
tLp.topMargin = dp(10);
tLp.gravity = Gravity.CENTER_HORIZONTAL;
col.addView(t, tLp);
return col;
}
private void doAccept() {
if (handled) { finish(); return; }
handled = true;
String uuid = callData.getString("callUUID", "");
CallStore.markActive(uuid);
CallStore.cancel(getApplicationContext()); // stops the Ringer + clears the notification
// Bring the app to the front so the WebView loads and joins the meeting, then fire the answer (retained
// in the plugin until the WebView JS attaches — covers a cold/locked pickup).
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launch != null) {
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
startActivity(launch);
}
NativeCallPlugin.deliverAnswer(callData);
finishAndClear();
}
private void doDecline() {
if (handled) { finish(); return; }
handled = true;
CallStore.markEnded(callData.getString("callUUID", ""));
CallStore.cancel(getApplicationContext()); // stops the Ringer + clears the notification
NativeCallPlugin.deliverEnd(callData);
finishAndClear();
}
private void finishAndClear() {
if (current == this) current = null;
finish();
}
@Override
protected void onDestroy() {
// Don't stop the ring here — it's call-scoped (Ringer), not screen-scoped. If the user just backgrounds the
// call screen without answering, the phone should keep ringing until answer/decline/cancel, like a real call.
if (current == this) current = null;
super.onDestroy();
}
private int dp(int v) { return Math.round(getResources().getDisplayMetrics().density * v); }
// Called when the call ends elsewhere (remote hang-up / another device answered) so the ring screen goes away.
// Close the ring screen when the call ends elsewhere. The Ringer is stopped by CallStore.cancel on every
// end/cancel path, so this only needs to finish the activity.
static void dismissIfShowing() {
final IncomingCallActivity a = current;
if (a != null) a.runOnUiThread(() -> { if (!a.isFinishing()) a.finish(); });
}
}
@@ -0,0 +1,131 @@
package com.bizgaze.connect.nativecall;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.google.firebase.messaging.FirebaseMessaging;
// Native calling for Biz Connect (Android). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall —
// the SAME jsName the iOS plugin uses, so the web bridge (setupNativeCall in home.html) drives both platforms
// through one code path.
//
// INCREMENT 1 (ring + answer): rings incoming calls with a full-screen-over-the-lock-screen UI (Android's
// answer to CallKit), woken by a high-priority FCM DATA message even when the app is closed
// (BzCallMessagingService). Answer/decline are bridged back to the WebView, which joins the meeting the normal
// way — MEDIA STILL RUNS IN THE WEBVIEW for now (works in the foreground). Background call audio via a native
// LiveKit connection + a foreground service is Increment 2, mirroring the iOS build history.
//
// The video / screen-share / hole-punch / transcription methods the iOS plugin implements are declared here as
// safe no-ops so the shared web bridge never rejects when it calls them on Android; they get real bodies in the
// later increments.
@CapacitorPlugin(name = "NativeCall")
public class NativeCallPlugin extends Plugin {
// The live plugin instance + a buffered answer for the cold-start case (a call answered from a killed app:
// the answer arrives before the WebView/JS has attached a listener). notifyListeners(..., retain=true) also
// buffers, but when the plugin itself isn't loaded yet we hold the event here and flush it in load().
private static NativeCallPlugin instance;
private static JSObject pendingAnswer;
@Override
public void load() {
instance = this;
if (pendingAnswer != null) {
JSObject a = pendingAnswer;
pendingAnswer = null;
notifyListeners("answerCall", a, true);
}
}
@Override
protected void handleOnDestroy() {
super.handleOnDestroy();
if (instance == this) instance = null;
}
// Called from IncomingCallActivity when the user accepts. Fires the shared "answerCall" event the web
// listens for; retained so a killed/locked pickup isn't lost before the WebView JS attaches.
static void deliverAnswer(JSObject data) {
if (instance != null) instance.notifyListeners("answerCall", data, true);
else pendingAnswer = data;
}
// Called on decline / remote hang-up. The web ends/declines the call server-side.
static void deliverEnd(JSObject data) {
if (instance != null) instance.notifyListeners("endCall", data);
}
// MARK: - JS-callable methods
// The FCM registration token — the same token @capacitor/push-notifications registers as an "android"
// device, which is exactly what the server call-wake targets. Returned so the web can confirm/re-register.
@PluginMethod
public void getToken(final PluginCall call) {
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
JSObject ret = new JSObject();
ret.put("token", task.isSuccessful() && task.getResult() != null ? task.getResult() : "");
call.resolve(ret);
});
}
// Ring CallKit-style from a WebSocket call event (a 2nd path alongside the FCM push, for when the app is
// open). Deduped by callUUID in CallStore, so double-firing with the push is harmless.
@PluginMethod
public void reportIncomingCall(PluginCall call) {
String uuid = call.getString("callUUID", "");
if (uuid.isEmpty()) { call.reject("callUUID required"); return; }
if (CallStore.isKnown(uuid)) { call.resolve(); return; }
java.util.Map<String, String> c = new java.util.HashMap<>();
c.put("callUUID", uuid);
c.put("room", call.getString("room", ""));
c.put("kind", call.getString("kind", "dm"));
c.put("callerId", call.getString("callerId", ""));
c.put("callerName", call.getString("callerName", call.getString("groupName", "Incoming call")));
c.put("groupId", call.getString("groupId", ""));
c.put("groupName", call.getString("groupName", ""));
c.put("hasVideo", Boolean.toString(Boolean.TRUE.equals(call.getBoolean("hasVideo", false))));
CallStore.showIncoming(getContext(), c);
call.resolve();
}
// Outgoing call from the web app. Increment 1: media stays in the WebView, so there is nothing to ring or
// connect natively here — the callee is rung by the server's FCM call-wake. Just record it so a later
// endCall/cancel can match. (Increment 2 will start a foreground service + native LiveKit here.)
@PluginMethod
public void reportOutgoingCall(PluginCall call) {
String uuid = call.getString("callUUID", "");
if (!uuid.isEmpty()) CallStore.markActive(uuid);
call.resolve();
}
// Stop ringing / clear the incoming UI for a call (remote hung up, we ended, or another device answered).
@PluginMethod
public void endCall(PluginCall call) {
String uuid = call.getString("callUUID", "");
if (!uuid.isEmpty()) CallStore.markEnded(uuid);
CallStore.cancel(getContext());
IncomingCallActivity.dismissIfShowing();
call.resolve();
}
// Increment 1: mute is handled by the WebView (it owns the media). No-op so the shared bridge doesn't reject.
@PluginMethod public void setMuted(PluginCall call) { call.resolve(); }
// --- Increment 2+ methods (video / screen-share / hole-punch / #12 reconnect / #5 transcript). Safe no-ops
// for now so the shared web bridge never rejects on Android; real bodies land with the native-media build. ---
@PluginMethod public void reconnectRoom(PluginCall call) { call.resolve(); }
@PluginMethod public void setCamera(PluginCall call) { call.resolve(); }
@PluginMethod public void switchCamera(PluginCall call) { call.resolve(); }
@PluginMethod public void startScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void stopScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void startMeetingScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void stopMeetingScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void setHolePunch(PluginCall call) { call.resolve(); }
@PluginMethod public void setTileZoom(PluginCall call) { call.resolve(); }
@PluginMethod public void syncVideoTiles(PluginCall call) { call.resolve(); }
@PluginMethod public void startTranscription(PluginCall call) { call.resolve(); }
@PluginMethod public void stopTranscription(PluginCall call) { call.resolve(); }
@PluginMethod public void feedAudio(PluginCall call) { call.resolve(); }
}
@@ -0,0 +1,62 @@
package com.bizgaze.connect.nativecall;
import android.content.Context;
import android.media.AudioAttributes;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.os.Vibrator;
import android.util.Log;
// The incoming-call ring + vibration, as ONE looping source tied to the CALL (not the call screen). On Android 14
// an UNLOCKED phone shows our call as a heads-up notification WITHOUT launching the full-screen IncomingCallActivity
// (full-screen-intent is restricted for non-dialer apps while unlocked), so if the ring lived only in the activity
// the phone would ring silently when unlocked. The notification channel is silent (to avoid a double ring), so
// this is the single ring source: started from CallStore.showIncoming, stopped from CallStore.cancel (which every
// answer/decline/cancel/end path already calls).
class Ringer {
private static Ringtone ringtone;
private static Vibrator vibrator;
static synchronized void start(Context ctx) {
if (ringtone != null) return; // already ringing
Context app = ctx.getApplicationContext();
try {
Uri uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
if (uri == null) uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
if (uri != null) {
Ringtone r = RingtoneManager.getRingtone(app, uri);
if (r != null) {
r.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) r.setLooping(true);
r.play();
ringtone = r;
Log.i("bzcall", "ringer started");
}
}
} catch (Exception e) { Log.w("bzcall", "ringer start failed: " + e.getMessage()); }
try {
Vibrator v = (Vibrator) app.getSystemService(Context.VIBRATOR_SERVICE);
if (v != null && v.hasVibrator()) {
long[] pattern = {0, 800, 1000};
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
v.vibrate(android.os.VibrationEffect.createWaveform(pattern, 0)); // 0 = loop from start
} else {
v.vibrate(pattern, 0);
}
vibrator = v;
}
} catch (Exception ignored) {}
}
static synchronized void stop() {
try { if (ringtone != null) ringtone.stop(); } catch (Exception ignored) {}
ringtone = null;
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
vibrator = null;
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z" />
</vector>
@@ -0,0 +1,829 @@
import Foundation
import UIKit
import WebKit
import Capacitor
import PushKit
import CallKit
import AVFoundation
import Speech // #5 native transcript: SFSpeechRecognizer (iOS on-device speech-to-text; WKWebView has no Web Speech API)
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, BroadcastManagerDelegate {
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: "reconnectRoom", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setCamera", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "startScreenShare", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopScreenShare", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "startMeetingScreenShare", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopMeetingScreenShare", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setHolePunch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setTileZoom", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "startTranscription", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopTranscription", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "feedAudio", 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?
// Native video: one native video view per visible participant (key "__local" or the remote user id),
// drawn over the WebView and positioned to match the web meeting tiles (see syncVideoTiles). UIKit views —
// only ever touched on the main thread. TileVideoView adds pinch-zoom/pan for shared screens.
private var tileViews: [String: TileVideoView] = [:]
// Hole-punch: draw the native video BEHIND a transparent WebView so all web UI (bar, menus, panels) floats
// on top. Saved so we can restore the WebView when the call ends.
private var holePunchOn = false
private var savedVCBg: UIColor? // the WebView parent's original background, restored when the call ends
// #5 native transcript: transcribes THIS device's mic (WKWebView has no Web Speech API). Fed by a LiveKit
// AudioRenderer on the local mic track, so it reuses the call's already-open mic (no 2nd audio engine).
private let transcriber = SpeechTranscriber()
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)
// Re-assert the LOUDSPEAKER whenever iOS routes call audio back to the quiet earpiece. The LiveKit
// audio engine starting up right after CallKit activates the session flips the route to the built-in
// receiver — that's the "sound is on the earpiece until I tap something" bug (tapping mic/cam re-ran
// preferSpeaker and fixed it). Listening for route changes makes that self-healing.
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteChanged(_:)),
name: AVAudioSession.routeChangeNotification, object: nil)
// Screen sharing (ReplayKit broadcast extension). LiveKit tells us when a broadcast starts/stops and
// (with shouldPublishTrack=true, the default) auto-publishes/unpublishes the screen-share track.
BroadcastManager.shared.delegate = self
// #5 native transcript: each finalized speech segment → the web (meeting-transcript over the WS), which
// merges it into the shared meeting transcript (same path as desktop's Web Speech API).
transcriber.onFinal = { [weak self] text in self?.notifyListeners("transcript", data: ["text": text]) }
// Make the WebView transparent at STARTUP. WKWebView can IGNORE isOpaque=false when it's flipped after
// the page has already rendered — the likely reason the hole-punch showed no video. Doing it once, up
// front, makes the transparent-meeting areas actually reveal the native video behind the WebView. The
// web body is opaque, so the app looks normal outside a call.
DispatchQueue.main.async { [weak self] in self?.makeWebViewTransparent() }
}
private func makeWebViewTransparent() {
guard let web = bridge?.webView else { return }
web.isOpaque = false
web.backgroundColor = .clear
web.scrollView.isOpaque = false // the scrollView being opaque can occlude native content behind the WebView
web.scrollView.backgroundColor = .clear
}
@objc private func audioRouteChanged(_ note: Notification) {
guard room != nil else { return } // only steer the route during an active native call
// Let the engine's own route change settle first, then override if we landed on the earpiece.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.preferSpeaker() }
}
// 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
// Route screen-share through the ReplayKit broadcast extension (so the user can share their screen
// even when the app is backgrounded, and it captures the whole phone, not just the WebView).
let opts = RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true))
let r = Room(roomOptions: opts)
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
transcriber.stop() // #5: end any live transcription with the call
Task { await r?.disconnect() }
removeAllTileViews()
DispatchQueue.main.async { [weak self] in guard let self = self, let web = self.bridge?.webView else { return }; self.applyHolePunchRestore(web) }
}
// MARK: - Native video (tile rendering, Increment 2b)
//
// The WebView owns the meeting UI (grid, roster, controls) but — for a native call — has NO LiveKit
// connection, so it can't render any video. The video lives only in the plugin's LiveKit connection.
// So we draw native VideoViews on top of the WebView, positioned to match each web tile: the web reports
// each tile's on-screen rect + the participant's user id (syncVideoTiles), and we place/size a VideoView
// for whichever participants currently have a live camera track. Views are subviews of the WKWebView, so
// their frames use the SAME coordinate space as getBoundingClientRect (CSS px == points, both
// viewport-relative) and stay aligned as the page scrolls.
// Find the live (unmuted, subscribed) camera track for a user id — nil when the camera is off, so the web
// tile's avatar shows through instead.
private func cameraTrack(forUid uid: String, isLocal: Bool) -> VideoTrack? {
guard let room = room else { return nil }
let pubs: [TrackPublication]
if isLocal {
pubs = room.localParticipant.videoTracks
} else {
guard let p = room.remoteParticipants[Participant.Identity(from: uid)] else { return nil }
pubs = p.videoTracks
}
guard let pub = pubs.first(where: { $0.source == .camera && !$0.isMuted && $0.track != nil }) else { return nil }
return pub.track as? VideoTrack
}
// The remote screen-share track for a user id — nil if they aren't sharing (or it isn't subscribed yet).
// (Local screen-share isn't supported on iOS — no ReplayKit broadcast extension — so this is remote-only.)
private func screenTrack(forUid uid: String) -> VideoTrack? {
guard let room = room, let p = room.remoteParticipants[Participant.Identity(from: uid)] else { return nil }
guard let pub = p.videoTracks.first(where: { $0.source == .screenShareVideo && !$0.isMuted && $0.track != nil }) else { return nil }
return pub.track as? VideoTrack
}
private func tileKey(uid: String, isLocal: Bool) -> String { isLocal ? "__local" : uid }
private func removeAllTileViews() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
for (_, vv) in self.tileViews { vv.removeFromSuperview() }
self.tileViews.removeAll()
}
}
// Create a tile view BEHIND the whole WebView (in its superview). WKWebView does NOT composite native
// subviews placed under its scrollView through transparent web content, so the video must sit behind the
// (transparent) WebView itself; the web UI then paints on top. No native overlays — the web tile draws them.
private func makeTileView(host: WKWebView, key: String) -> TileVideoView {
let v = TileVideoView(frame: .zero)
if let sup = host.superview { sup.insertSubview(v, belowSubview: host) } else { host.addSubview(v) }
tileViews[key] = v
return v
}
// Turn hole-punch on/off. Transparency is already set at startup (makeWebViewTransparent); here we just add
// a BLACK backing to the WebView's parent (the layer directly behind the video tiles) during the call, and
// remove it + the tiles afterwards.
@objc func setHolePunch(_ call: CAPPluginCall) {
let on = call.getBool("on") ?? false
DispatchQueue.main.async { [weak self] in
guard let self = self, let web = self.bridge?.webView else { call.resolve(); return }
self.makeWebViewTransparent() // belt-and-braces
let parent = web.superview
if on {
if !self.holePunchOn { self.savedVCBg = parent?.backgroundColor }
parent?.backgroundColor = .black
self.holePunchOn = true
} else {
self.applyHolePunchRestore(web)
for (_, vv) in self.tileViews { vv.removeFromSuperview() }
self.tileViews.removeAll()
}
call.resolve()
}
}
// Remove the black backing (leave the WebView transparent — the web body is opaque, so it looks normal).
private func applyHolePunchRestore(_ web: WKWebView) {
guard holePunchOn else { return }
web.superview?.backgroundColor = savedVCBg
holePunchOn = false
}
// Web-forwarded zoom: touches land on the WebView (on top), so the web captures pinch/pan on the shared
// screen and forwards the transform here; we apply it to that tile's inner video.
@objc func setTileZoom(_ call: CAPPluginCall) {
let key = tileKey(uid: call.getString("uid") ?? "", isLocal: call.getBool("local") ?? false)
let scale = CGFloat(call.getDouble("scale") ?? 1)
let tx = CGFloat(call.getDouble("tx") ?? 0)
let ty = CGFloat(call.getDouble("ty") ?? 0)
DispatchQueue.main.async { [weak self] in self?.tileViews[key]?.applyZoom(scale: scale, tx: tx, ty: ty) }
call.resolve()
}
// 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()
}
// #12 Multi-device: swap the media connection to a token whose identity = this device's mesh peerId (unique
// per connection). The web calls this once the native WebView has joined the mesh and has a peerId. On
// answer we connected INSTANTLY with the push token (identity=userId) for zero-latency audio; this re-homes
// the media onto the unique peerId identity so two devices of the same user are distinct LiveKit
// participants (LiveKit allows one connection per identity — otherwise the older device is kicked and
// "audio jumps to whichever joined last"). connectRoom disconnects the old room first; the CallKit call and
// its audio session stay active, so audio just re-attaches. callConnected fires on success → web re-applies
// mic/cam. Guarded to only run during an active call.
@objc func reconnectRoom(_ call: CAPPluginCall) {
let url = call.getString("url") ?? ""
let token = call.getString("token") ?? ""
guard room != nil, !url.isEmpty, !token.isEmpty else { call.resolve(); return }
connectRoom(url: url, token: token)
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()
}
// Enable/disable the local camera. Publishing it makes this user's video appear for everyone else (their
// web/desktop clients render it via their own SFU subscription); locally it's drawn on the __local tile by
// syncVideoTiles (the web triggers a sync right after this resolves). Front camera only for now.
@objc func setCamera(_ call: CAPPluginCall) {
guard let r = room else { call.reject("no active call"); return }
let on = call.getBool("on") ?? false
Task {
do {
try await r.localParticipant.setCamera(
enabled: on,
captureOptions: CameraCaptureOptions(position: .front))
call.resolve(["on": on])
} catch {
call.reject("camera failed: \(String(describing: error))")
}
}
}
// Flip the local camera between front and back.
@objc func switchCamera(_ call: CAPPluginCall) {
guard let r = room else { call.reject("no active call"); return }
let pub = r.localParticipant.videoTracks.first(where: { $0.source == .camera })
guard let track = pub?.track as? LocalVideoTrack, let cam = track.capturer as? CameraCapturer else {
call.reject("camera not active"); return
}
Task {
do { _ = try await cam.switchCameraPosition(); call.resolve() }
catch { call.reject("switch failed: \(String(describing: error))") }
}
}
// Screen sharing from iOS: show the system broadcast picker. When the user starts the broadcast, the
// extension streams the screen to us over IPC and LiveKit publishes it (BroadcastManager.shouldPublishTrack
// defaults true). broadcastManager(didChangeState:) fires screenShareState back to the web either way.
@objc func startScreenShare(_ call: CAPPluginCall) {
guard room != nil else { call.reject("no active call"); return }
DispatchQueue.main.async { BroadcastManager.shared.requestActivation() } // presents RPSystemBroadcastPickerView
call.resolve()
}
@objc func stopScreenShare(_ call: CAPPluginCall) {
BroadcastManager.shared.requestStop()
call.resolve()
}
// Screen share into a SCHEDULED / code-joined SFU meeting. The WebView already holds the meeting
// connection (identity = peerId) and WKWebView has no getDisplayMedia — so we open a SECOND, screen-ONLY
// native LiveKit connection under a distinct `<peerId>-screen` identity and publish the ReplayKit
// broadcast into the SAME room. The WebView maps that identity back onto the sharer's tile.
@objc func startMeetingScreenShare(_ call: CAPPluginCall) {
let url = call.getString("url") ?? ""
let token = call.getString("token") ?? ""
guard !url.isEmpty, !token.isEmpty else { call.reject("url/token required"); return }
connectScreenRoom(url: url, token: token)
DispatchQueue.main.async { BroadcastManager.shared.requestActivation() } // system broadcast picker
call.resolve()
}
@objc func stopMeetingScreenShare(_ call: CAPPluginCall) {
BroadcastManager.shared.requestStop()
let r = room; room = nil
Task { await r?.disconnect() } // drop the screen-only connection (no call/tile/transcriber side effects)
call.resolve()
}
// A screen-ONLY LiveKit connection (mic off, no camera) used purely to publish the ReplayKit broadcast
// into an SFU meeting. Unlike connectRoom() it enables no mic and fires no callConnected — this is not a call.
private func connectScreenRoom(url: String, token: String) {
guard !url.isEmpty, !token.isEmpty else { return }
let old = room
let opts = RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true))
let r = Room(roomOptions: opts)
room = r
Task { [weak self] in
await old?.disconnect() // never leave a duplicate connection
do { try await r.connect(url: url, token: token) }
catch { self?.notifyListeners("screenShareState", data: ["sharing": false, "error": String(describing: error)]) }
}
}
// MARK: - #5 Live transcript (native SFSpeechRecognizer)
// The current published local mic track — the source we tap for transcription. nil until the user has
// unmuted at least once (the mic is published on unmute), or between reconnects.
private func localAudioTrack() -> LocalAudioTrack? {
return room?.localParticipant.audioTracks.first?.track as? LocalAudioTrack
}
// Start transcribing this device's mic (WKWebView has no Web Speech API). Two audio sources:
// * NATIVE CALL (default): the plugin owns the LiveKit room, so we tap the local mic track with a LiveKit
// AudioRenderer (reuses the call's open mic — reliable, no 2nd capturer). Re-attaches on unmute.
// * SCHEDULED/WEB MEETING ({external:true}): the WebView owns the mic (the plugin has no room), so the web
// reads its own mic PCM via Web Audio and pushes it here with feedAudio() — no second mic capture on iOS
// (two input units would fight and yield silence).
@objc func startTranscription(_ call: CAPPluginCall) {
if call.getBool("external") == true {
transcriber.startExternal()
} else {
transcriber.start(track: localAudioTrack())
}
call.resolve()
}
@objc func stopTranscription(_ call: CAPPluginCall) {
transcriber.stop()
call.resolve()
}
// Web-forwarded mic PCM for the {external:true} path (scheduled/web meetings). `pcm` = base64 little-endian
// Int16 mono at `rate` Hz (the web downsamples to 16 kHz). Fed straight into the recognizer.
@objc func feedAudio(_ call: CAPPluginCall) {
guard let b64 = call.getString("pcm"), let data = Data(base64Encoded: b64) else { call.resolve(); return }
let rate = call.getDouble("rate") ?? 16000
transcriber.appendPCM(int16: data, sampleRate: rate)
call.resolve()
}
// MARK: - BroadcastManagerDelegate
public func broadcastManager(didChangeState isBroadcasting: Bool) {
notifyListeners("screenShareState", data: ["sharing": isBroadcasting])
}
// Position native video views to match the web meeting tiles. `tiles` = [{uid, local, x, y, w, h}] in
// CSS px (== points; getBoundingClientRect coords). We create/move a VideoView for each participant that
// has a live camera track, and remove views for tiles that are gone or whose camera is off (so the web
// avatar shows). Called on a short poll by the web while a native call is on screen, plus on demand.
@objc func syncVideoTiles(_ call: CAPPluginCall) {
let tiles = (call.getArray("tiles") as? [[String: Any]]) ?? []
DispatchQueue.main.async { [weak self] in
guard let self = self else { call.resolve(); return }
guard let host = self.bridge?.webView else { call.resolve(); return }
var wanted = Set<String>()
for t in tiles {
guard let uid = t["uid"] as? String, !uid.isEmpty else { continue }
let isLocal = (t["local"] as? Bool) ?? false
func num(_ k: String) -> CGFloat { CGFloat((t[k] as? NSNumber)?.doubleValue ?? 0) }
let x = num("x"), y = num("y"), w = num("w"), h = num("h")
if w < 2 || h < 2 { continue }
let key = self.tileKey(uid: uid, isLocal: isLocal)
// A tile flagged `screen` is a sharer's stage tile → show their screen-share track (fit, so it
// isn't cropped); otherwise the camera (fill). Either is nil when off → web avatar shows.
let wantScreen = (t["screen"] as? Bool) ?? false
guard let track = wantScreen ? self.screenTrack(forUid: uid)
: self.cameraTrack(forUid: uid, isLocal: isLocal) else { continue }
wanted.insert(key)
let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key)
if vv.superview !== host.superview, let sup = host.superview { sup.insertSubview(vv, belowSubview: host) } // keep BEHIND the transparent WebView
vv.layoutMode = wantScreen ? .fit : .fill
if vv.track !== track { vv.track = track }
// The container tracks the tile rect. getBoundingClientRect is in the WebView's coordinate space;
// convert it into the superview (where the tiles live) — robust to any WebView offset/inset. The
// zoom transform (web-forwarded via setTileZoom) lives on the INNER video, so this never fights it.
vv.frame = host.convert(CGRect(x: x, y: y, width: w, height: h), to: host.superview)
}
// Drop views for participants no longer present / camera turned off.
for (key, vv) in self.tileViews where !wanted.contains(key) {
vv.removeFromSuperview(); self.tileViews.removeValue(forKey: key)
}
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
// #5: unmuting publishes the mic track — (re)attach the transcriber's renderer to it if transcript is on.
if !action.isMuted { self?.transcriber.refresh(track: self?.localAudioTrack()) }
}
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])
}
}
// A tile view = a container holding a LiveKit VideoView. We CANNOT subclass VideoView (it's `public`, not
// `open`, so subclassing outside its module is illegal), so we compose. Under hole-punch the video sits BEHIND
// the (transparent) WebView, so touches never reach it — pinch-zoom is captured by the web and forwarded via
// applyZoom(). The container stays frame-synced to the web tile rect; the zoom transform lives on the inner
// video, so the two never fight.
final class TileVideoView: UIView {
let video = VideoView()
// Forward the two properties the plugin sets so call sites read like a VideoView.
var track: VideoTrack? { get { video.track } set { video.track = newValue } }
var layoutMode: VideoView.LayoutMode { get { video.layoutMode } set { video.layoutMode = newValue } }
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear // the web tile draws its own frame; gaps show the WebView's black bg
clipsToBounds = true
layer.cornerRadius = 12 // match .meet-tile's border-radius so corners don't poke past the web border
video.layoutMode = .fill
addSubview(video)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
// Position via bounds+center (not frame) so it coexists with the zoom transform.
override func layoutSubviews() {
super.layoutSubviews()
video.bounds = CGRect(origin: .zero, size: bounds.size)
video.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
// Web-forwarded zoom (scale + translation in points). scale<=1 clears the transform.
func applyZoom(scale: CGFloat, tx: CGFloat, ty: CGFloat) {
if scale <= 1.001 { if !video.transform.isIdentity { video.transform = .identity } }
else { video.transform = CGAffineTransform(translationX: tx, y: ty).scaledBy(x: scale, y: scale) }
}
}
// #5 Live transcript on iOS. WKWebView has no Web Speech API, so an iOS participant's speech was never captured
// into the meeting transcript (desktop Chrome/Edge already works). This transcribes THIS device's mic with
// SFSpeechRecognizer, fed by a LiveKit `AudioRenderer` attached to the local mic track — so it reuses the call's
// already-open capture (no second AVAudioEngine fighting WebRTC for the audio session). Each finished utterance
// fires `onFinal`; the plugin relays it to the web, which sends it over the meeting WS exactly like the desktop
// path. Segments are cut on a short silence gap (and on the recognizer's own isFinal), and the request is
// restarted per segment so text flows continuously and stays within the recognizer's limits. On-device
// recognition is used when available (offline, continuous, no ~1-minute cap). All state is main-confined except
// the locked `request` that the audio-thread renderer appends to.
final class SpeechTranscriber: NSObject, AudioRenderer, @unchecked Sendable {
var onFinal: ((String) -> Void)?
private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
private let lock = NSLock()
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
private weak var track: LocalAudioTrack?
private var running = false
private var latest = ""
private var lastEmitted = ""
private var silenceTimer: DispatchWorkItem?
// Begin transcription tapping a LiveKit local mic track (native calls). Idempotent; asks for Speech
// authorization once. Safe to call before the mic exists — `refresh` re-attaches when it publishes (unmute).
func start(track: LocalAudioTrack?) { begin { self.attach(track) } }
// Begin transcription in EXTERNAL mode (scheduled/web meetings): no LiveKit track to tap — PCM arrives via
// appendPCM() from the web. Same recognition pipeline.
func startExternal() { begin { } }
private func begin(_ afterStart: @escaping () -> Void) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.running { afterStart(); return }
SFSpeechRecognizer.requestAuthorization { status in
DispatchQueue.main.async {
guard status == .authorized, !self.running else { return }
self.running = true
self.beginRequest()
afterStart()
}
}
}
}
// Web-forwarded PCM (external mode): little-endian Int16 mono → Float32 buffer → recognizer.
func appendPCM(int16 data: Data, sampleRate: Double) {
let count = data.count / 2
guard count > 0,
let fmt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false),
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: AVAudioFrameCount(count)) else { return }
buf.frameLength = AVAudioFrameCount(count)
guard let dst = buf.floatChannelData?[0] else { return }
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
let src = raw.bindMemory(to: Int16.self)
for i in 0..<count { dst[i] = Float(src[i]) / 32768.0 }
}
lock.lock(); let r = request; lock.unlock()
r?.append(buf)
}
// (Re)attach to the current local mic track — called on start and whenever the mic (re)publishes on unmute.
func refresh(track: LocalAudioTrack?) { DispatchQueue.main.async { [weak self] in self?.attach(track) } }
func stop() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.running = false
self.silenceTimer?.cancel(); self.silenceTimer = nil
self.track?.remove(audioRenderer: self); self.track = nil
self.lock.lock(); let r = self.request; self.request = nil; self.lock.unlock()
r?.endAudio(); self.task?.cancel(); self.task = nil
}
}
private func attach(_ t: LocalAudioTrack?) {
guard running, let t = t, track !== t else { return }
track?.remove(audioRenderer: self)
t.add(audioRenderer: self)
track = t
}
private func beginRequest() {
guard let recognizer = recognizer, recognizer.isAvailable else { return }
let req = SFSpeechAudioBufferRecognitionRequest()
req.shouldReportPartialResults = true // stream results; we only EMIT a segment on silence / isFinal
if recognizer.supportsOnDeviceRecognition { req.requiresOnDeviceRecognition = true }
lock.lock(); request = req; lock.unlock()
latest = ""; lastEmitted = ""
task = recognizer.recognitionTask(with: req) { [weak self] result, error in
guard let self = self else { return }
if let result = result {
let text = result.bestTranscription.formattedString
let isFinal = result.isFinal
DispatchQueue.main.async {
guard self.running else { return }
self.latest = text
if isFinal { self.flushAndRestart() } else { self.armSilenceTimer() }
}
} else if error != nil {
DispatchQueue.main.async { if self.running { self.flushAndRestart() } }
}
}
}
// A short pause = end of an utterance → emit it and start a fresh request for the next one.
private func armSilenceTimer() {
silenceTimer?.cancel()
let work = DispatchWorkItem { [weak self] in guard let self = self, self.running else { return }; self.flushAndRestart() }
silenceTimer = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4, execute: work)
}
private func flushAndRestart() {
silenceTimer?.cancel(); silenceTimer = nil
let text = latest.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty && text != lastEmitted { lastEmitted = text; onFinal?(text) }
guard running else { return }
lock.lock(); let r = request; request = nil; lock.unlock()
r?.endAudio(); task?.cancel(); task = nil
beginRequest()
}
// MARK: AudioRenderer — receives the local mic PCM from LiveKit (audio thread).
func render(pcmBuffer: AVAudioPCMBuffer) {
lock.lock(); let r = request; lock.unlock()
r?.append(pcmBuffer)
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"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/",
"android/src/main/",
"android/build.gradle",
"NativeCall.podspec",
"Package.swift"
],
"capacitor": {
"ios": {
"src": "ios"
},
"android": {
"src": "android"
}
},
"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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 113 KiB

+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Inject the Broadcast Upload Extension (ReplayKit screen sharing) into the Capacitor-generated Xcode project.
# Mirrors add-share-extension.rb, with the extra step that this extension LINKS the LiveKit Swift package
# (LKSampleHandler lives in the LiveKit product), so it adds a package product dependency to the new target.
#
# WHAT IT WIRES:
# * a new app-extension target "BroadcastExtension" (bundle id <app>.broadcast) whose source is our
# SampleHandler.swift (subclass of LiveKit's LKSampleHandler) + Info.plist + entitlements, copied from
# mobile/ios-broadcast/
# * the App Group entitlement (group.com.bizgaze.connect) on the extension (LiveKit's IPC socket lives there)
# * a Swift Package product dependency on LiveKit (github.com/livekit/client-sdk-swift 2.15.3) so the
# extension can subclass LKSampleHandler
# * the extension embedded into the app ("Embed App Extensions") + set as a build dependency
#
# Idempotent: if the target already exists it is removed and rebuilt.
require 'xcodeproj'
require 'fileutils'
ROOT = File.expand_path('..', __dir__)
PROJECT = File.join(ROOT, 'ios', 'App', 'App.xcodeproj')
SRC_DIR = File.join(ROOT, 'ios-broadcast')
APP_DIR = File.join(ROOT, 'ios', 'App')
EXT_NAME = 'BroadcastExtension'
EXT_DIR = File.join(APP_DIR, EXT_NAME)
APP_TARGET = 'App'
APP_BUNDLE = ENV.fetch('BUNDLE_ID', 'com.bizgaze.connect')
EXT_BUNDLE = "#{APP_BUNDLE}.broadcast"
APP_GROUP = 'group.com.bizgaze.connect'
LK_URL = 'https://github.com/livekit/client-sdk-swift.git'
LK_VERSION = '2.15.3'
abort "xcodeproj not found at #{PROJECT}" unless File.directory?(PROJECT)
project = Xcodeproj::Project.open(PROJECT)
app = project.targets.find { |t| t.name == APP_TARGET }
abort "App target not found" unless app
# ── Clean any previous injection so this is idempotent ──────────────────────────────────────────────
project.targets.select { |t| t.name == EXT_NAME }.each do |t|
t.build_configuration_list&.build_configurations&.each { |c| c.remove_from_project }
t.remove_from_project
end
if (grp = project.main_group.children.find { |g| g.respond_to?(:display_name) && g.display_name == EXT_NAME })
grp.remove_from_project
end
# ── Copy our sources into the app project so paths inside the .xcodeproj are stable ──────────────────
FileUtils.mkdir_p(EXT_DIR)
%w[SampleHandler.swift Info.plist BroadcastExtension.entitlements].each do |f|
FileUtils.cp(File.join(SRC_DIR, f), File.join(EXT_DIR, f))
end
# ── Create the extension target ──────────────────────────────────────────────────────────────────────
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)
group = project.main_group.new_group(EXT_NAME, EXT_NAME.to_s)
swift_ref = group.new_reference(File.join(EXT_DIR, 'SampleHandler.swift'))
ext.add_file_references([swift_ref])
ext.build_configurations.each do |cfg|
s = cfg.build_settings
s['PRODUCT_BUNDLE_IDENTIFIER'] = EXT_BUNDLE
s['PRODUCT_NAME'] = '$(TARGET_NAME)'
s['INFOPLIST_FILE'] = "#{EXT_NAME}/Info.plist"
s['CODE_SIGN_ENTITLEMENTS'] = "#{EXT_NAME}/BroadcastExtension.entitlements"
s['IPHONEOS_DEPLOYMENT_TARGET'] = deployment
s['SWIFT_VERSION'] = '5.0'
s['TARGETED_DEVICE_FAMILY'] = '1,2'
s['GENERATE_INFOPLIST_FILE'] = 'NO'
s['SKIP_INSTALL'] = 'YES'
s['CODE_SIGN_STYLE'] = 'Manual'
s['MARKETING_VERSION'] = '1.0'
s['CURRENT_PROJECT_VERSION'] = ENV.fetch('BUILD_NUMBER', '1')
s['LD_RUNPATH_SEARCH_PATHS'] = '$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks'
end
# ── Link LiveKit (Swift Package product) so the extension can subclass LKSampleHandler ────────────────
# The app already resolves client-sdk-swift 2.15.3 (via the native-call plugin's Package.swift). Add a
# project-level remote package reference to the SAME repo+version (SPM dedupes it) and attach the "LiveKit"
# product to this extension target.
root = project.root_object
pkg = root.package_references.find { |r| r.respond_to?(:repositoryURL) && r.repositoryURL == LK_URL }
unless pkg
pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
pkg.repositoryURL = LK_URL
pkg.requirement = { 'kind' => 'exactVersion', 'version' => LK_VERSION }
root.package_references << pkg
end
prod = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
prod.package = pkg
prod.product_name = 'LiveKit'
ext.package_product_dependencies << prod
bf = project.new(Xcodeproj::Project::Object::PBXBuildFile)
bf.product_ref = prod
ext.frameworks_build_phase.files << bf
# ── App Group entitlement on the MAIN app target too (merge — keep aps-environment etc.) ──────────────
app_ent_path = File.join(APP_DIR, 'App', 'App.entitlements')
app_ent = File.exist?(app_ent_path) ? (Xcodeproj::Plist.read_from_path(app_ent_path) || {}) : {}
groups = app_ent['com.apple.security.application-groups'] || []
groups << APP_GROUP unless groups.include?(APP_GROUP)
app_ent['com.apple.security.application-groups'] = groups
Xcodeproj::Plist.write_to_path(app_ent, app_ent_path)
app.build_configurations.each { |cfg| cfg.build_settings['CODE_SIGN_ENTITLEMENTS'] = 'App/App.entitlements' }
# ── Embed the extension into the app + depend on it ──────────────────────────────────────────────────
app.add_dependency(ext)
embed = app.build_phases.find { |p| p.respond_to?(:symbol_dst_subfolder_spec) && p.symbol_dst_subfolder_spec == :plug_ins }
embed ||= begin
phase = app.new_copy_files_build_phase('Embed App Extensions')
phase.symbol_dst_subfolder_spec = :plug_ins
phase
end
build_file = embed.add_file_reference(ext.product_reference)
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
project.save
puts "Broadcast Extension injected: #{EXT_NAME} (#{EXT_BUNDLE}) linked to LiveKit #{LK_VERSION}, embedded in #{APP_TARGET}"
+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}"
+70
View File
@@ -0,0 +1,70 @@
// Inject the runtime permissions the web UI needs into the Capacitor-generated AndroidManifest.xml.
// Run on Codemagic from the Android workflow:
// node mobile/scripts/android-patch.js mobile/android/app/src/main/AndroidManifest.xml
//
// WHY: mobile/android/ is gitignored (regenerated in CI by `cap add android`, same as iOS ios/). The
// freshly generated manifest only declares INTERNET, so without this the WebRTC calls in the web UI can't
// get camera/mic and Android 13+ never prompts for notifications. This adds the same permissions listed in
// mobile/resources/android-permissions.xml. (When the native-call Android plugin lands, it will contribute
// its own manifest entries via Capacitor manifest-merging; this only covers the app-level WebView perms.)
//
// TOLERANT: exits 0 and no-ops if anything is off, so it can NEVER fail the build. Idempotent (keyed on
// the bzcAndroidPerms marker), so re-runs never duplicate the block.
const fs = require('fs');
const p = process.argv[2];
if (!p || !fs.existsSync(p)) { console.log(' (AndroidManifest.xml not found — permission patch skipped)'); process.exit(0); }
try {
let s = fs.readFileSync(p, 'utf8');
// (1) Native-call FirebaseMessagingService swap. The native-call plugin declares its own MESSAGING_EVENT
// service (BzCallMessagingService) that handles call-wake data messages AND forwards regular push to Capacitor
// via super. Remove @capacitor/push-notifications' own MessagingService here — the APP manifest is the highest
// merge priority, so this reliably guarantees exactly one receiver. Needs the `tools` namespace + an insert
// inside <application>. Keyed on bzcCallSvcRemove so re-runs never duplicate it.
if (!s.includes('bzcCallSvcRemove')) {
// Ensure xmlns:tools is declared on <manifest> (tools:node="remove" needs it).
if (!/<manifest[^>]*xmlns:tools=/.test(s)) {
s = s.replace(/<manifest(\s)/, '<manifest xmlns:tools="http://schemas.android.com/tools"$1');
}
const removal = [
'',
' <!-- bzcCallSvcRemove: the native-call plugin\'s BzCallMessagingService replaces this one (it forwards regular push via super) -->',
' <service android:name="com.capacitorjs.plugins.pushnotifications.MessagingService" tools:node="remove" />',
' ',
].join('\n');
const before = s;
s = s.replace(/<\/application>/, removal + '</application>');
if (s !== before) console.log(' Capacitor MessagingService removal injected (native-call takes over MESSAGING_EVENT)');
else console.log(' could not find </application> — MessagingService removal skipped');
fs.writeFileSync(p, s);
} else {
console.log(' Capacitor MessagingService removal already present');
}
// (2) WebView permissions.
if (s.includes('bzcAndroidPerms') || s.includes('android.permission.RECORD_AUDIO')) {
console.log(' Android permissions already present'); process.exit(0);
}
const block = [
'',
' <!-- bzcAndroidPerms: permissions the Biz Connect web UI needs (see mobile/resources/android-permissions.xml) -->',
' <!-- Push notifications: Android 13 (API 33)+ shows a runtime prompt -->',
' <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />',
' <!-- Voice / video calls + camera, used by the WebRTC features in the web UI -->',
' <uses-permission android:name="android.permission.CAMERA" />',
' <uses-permission android:name="android.permission.RECORD_AUDIO" />',
' <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />',
' <!-- Camera is optional hardware (tablets without one can still install) -->',
' <uses-feature android:name="android.hardware.camera" android:required="false" />',
'',
].join('\n');
const orig = s;
// Insert directly before the closing </manifest> tag.
s = s.replace(/<\/manifest>\s*$/, block + '</manifest>\n');
if (s === orig) { console.log(' could not find </manifest> — permission patch skipped'); process.exit(0); }
fs.writeFileSync(p, s);
console.log(' Android permissions injected into ' + p);
} catch (e) {
console.log(' Android permission patch skipped:', e.message);
}
process.exit(0);
+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);
+68
View File
@@ -17,6 +17,7 @@ set_str NSCameraUsageDescription "Biz Connect uses the camera for video cal
set_str NSMicrophoneUsageDescription "Biz Connect uses the microphone for voice and video calls."
set_str NSPhotoLibraryUsageDescription "Biz Connect needs photo access so you can send images in chat."
set_str NSPhotoLibraryAddUsageDescription "Biz Connect saves images and recordings you download to your photos."
set_str NSSpeechRecognitionUsageDescription "Biz Connect uses speech recognition to create live meeting transcripts from your microphone."
# Human-readable display name on the home screen.
set_str CFBundleDisplayName "Biz Connect"
@@ -40,6 +41,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 +67,39 @@ 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"
# ── Screen sharing from iOS (ReplayKit broadcast extension + LiveKit) ───────────────────────────────
# LiveKit's BroadcastManager finds the broadcast upload extension + the shared App Group via these two keys
# (BroadcastBundleInfo reads RTCScreenSharingExtension / RTCAppGroupIdentifier). They match the extension
# added by add-broadcast-extension.rb (bundle id <app>.broadcast) and the App Group used by the share
# extension. Set explicitly so there's no reliance on the default-derivation.
set_str RTCScreenSharingExtension "com.bizgaze.connect.broadcast"
set_str RTCAppGroupIdentifier "group.com.bizgaze.connect"
# 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 +114,31 @@ 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"
"$PB" -c "Print :NSSpeechRecognitionUsageDescription" "$PLIST"
+139 -17
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();
@@ -29,21 +32,25 @@ async function meetingContext(room) {
async function finalizeTranscript(room, onlyUserId) {
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
const buf = transcriptBuffers.get(room) || [];
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
// CLAIM the subscriber(s) SYNCHRONOUSLY (before any await). Two concurrent finalize calls for the SAME uid —
// e.g. the same user's two devices both leaving at once (#12 multi-device) — would otherwise both pass the
// membership check during the awaits below and write the transcript TWICE (the "transcript shows two times"
// bug). subs.delete() returns true only for the first caller, so the loser claims nothing and writes nothing.
const candidates = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
const ids = candidates.filter((uid) => subs.delete(uid));
if (ids.length && buf.length) {
const ctx = await meetingContext(room);
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
for (const uid of ids) {
let user = null; try { user = await R.users.byId(uid); } catch (_) {}
if (!user) { subs.delete(uid); continue; }
if (!user) continue;
const id = A.id(); const file = 'm_' + id + '.txt';
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
// groupId null → private to its creator (see canSeeRec / /mrec auth).
await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
subs.delete(uid);
}
} else { ids.forEach((uid) => subs.delete(uid)); }
}
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
}
@@ -61,17 +68,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, left: new Set() };
// 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 +93,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 +103,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, left: new Set() };
// 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 +125,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 +141,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 +153,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 +174,56 @@ 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) {
// #New1: if this user already LEFT the call, don't ring them back in (noRing) — just refresh the "Join" state.
const noRing = !!(call.left && call.left.has(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, noRing })); } 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 (_) {}
const noRing = !!(call.left && call.left.has(userId)); // #New1: left already → refresh Join, don't re-ring
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, noRing })); } catch (_) {}
}
} catch (_) {}
}
// #New1: a participant who EXPLICITLY leaves an active (still-running) call must not be auto-rung back into
// it. We remember who left per call; replayActiveCalls (on their next socket reconnect) then sends the call
// state with noRing:true so their client refreshes the passive "Join" affordance without ringing / popping
// CallKit again. Before this, every reconnect (constant on mobile) re-rang the leaver until the call ended.
function callForRoom(room) {
const gid = roomToGroupCall.get(room); if (gid) { const c = groupCalls.get(gid); if (c) return c; }
const key = roomToDmCall.get(room); if (key) { const c = dmCalls.get(key); if (c) return c; }
return null;
}
function markLeft(room, userId) { if (!userId) return; const c = callForRoom(room); if (c) { if (!c.left) c.left = new Set(); c.left.add(userId); } }
function clearLeft(room, ids) { const c = callForRoom(room); if (c && c.left) { for (const id of (ids || [])) c.left.delete(id); } } // an explicit re-invite should ring again
// Called from signaling when any mesh room empties.
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
@@ -149,6 +231,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 +254,40 @@ async function declineDmCall(room, byUser) {
return { ok: true };
}
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, finalizeTranscript, meetingContext, fmtDur, pairKey };
// #15: adding a 3rd person to a 1:1 (DM) call turns it into a real, PERSISTENT group call. Two wins:
// (1) the call now survives anyone leaving (a group call only ends when the room empties), and
// (2) an added person who drops can rejoin — they're a group member, so the group's active-call banner
// (replayActiveCalls / group-call) reappears for them.
// The SAME room/uuid/startedAt are kept, so live media + the transcript continue uninterrupted; we just move
// the room's bookkeeping from dmCalls → groupCalls and create the backing group conversation. Returns the new
// group id, or null when `room` isn't a DM call (a group/ad-hoc call needs no promotion — caller invites as usual).
async function promoteDmToGroup(room, inviter, inviteeIds) {
const key = roomToDmCall.get(room); if (!key) return null;
const call = dmCalls.get(key); if (!call) return null;
const teamId = call.teamId || (inviter && inviter.team_id);
const memberIds = [...new Set([...(call.users || []), ...(inviteeIds || [])])].filter(Boolean);
if (memberIds.length < 3) return null; // nothing new actually added → stay a 1:1
// Friendly name from participant first-names (e.g. "Ravi, Sara, Alex"), capped so it doesn't run long.
const names = [];
for (const uid of memberIds) { let usr = null; try { usr = await R.users.byId(uid); } catch (_) {} if (usr) names.push(((usr.name || usr.email || '').trim().split(/\s+/)[0]) || usr.email || 'Someone'); }
const groupName = (names.slice(0, 4).join(', ') + (names.length > 4 ? ' +' + (names.length - 4) : '')) || 'Group call';
const owner = call.startedBy || (inviter && inviter.id);
const gid = A.id();
await R.conversations.create({ id: gid, teamId, name: groupName, createdBy: owner });
for (const uid of memberIds) { try { await R.conversations.addMember(gid, uid, uid === owner); } catch (_) {} }
// Migrate the LIVE call: DM → group (same room/uuid/history so media, transcript and Past-meetings all continue).
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; }
dmCalls.delete(key); roomToDmCall.delete(room);
groupCalls.set(gid, { room, uuid: call.uuid, startedAt: call.startedAt, startedBy: owner, startedByName: call.startedByName, teamId, historyId: call.historyId, left: new Set() });
roomToGroupCall.set(room, gid);
postSystem(gid, teamId, '📞 ' + (call.startedByName || 'Someone') + ' turned this into a group call').catch(() => {});
// Tell every member's client: refresh the sidebar (the new group appears) and mark the call active (banner
// + ring the people not already in the room). Those already in the call ignore the ring (same room).
for (const uid of memberIds) {
try { CHAT.pushToUser(uid, { type: 'group-update', group: gid }); } catch (_) {}
try { CHAT.pushToUser(uid, { type: 'group-call', group: gid, active: true, room, uuid: call.uuid, by: owner, startedByName: call.startedByName, groupName }); } catch (_) {}
}
return gid;
}
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, promoteDmToGroup, markLeft, clearLeft, finalizeTranscript, meetingContext, fmtDur, pairKey };
+50 -24
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,49 @@ 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) {
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 });
// 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 === userId) continue; // no need to tell someone about their own status
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).
const online = isOnline(userId);
let lastSeen = null;
try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
// #8: never broadcast a NULL last-seen for someone who's offline — touchSeen() is fire-and-forget on
// disconnect, so the DB write can lag this broadcast and the client would flap to a bare "Offline". They're
// leaving now, so "just now" is accurate.
if (!lastSeen && !online) lastSeen = Date.now();
const payload = JSON.stringify({ type: 'presence', userId, online, 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 };
+19 -1
View File
@@ -42,6 +42,19 @@ 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';
// Native calling on ANDROID (ConnectionService-style full-screen ring via a high-priority FCM data message +
// the native-call plugin). Same config-gated pattern as CALLKIT_ENABLED, but INDEPENDENT so Android can be
// switched on without touching iOS. OFF (default) → Android calls use the WebView flow (works today, foreground
// only); ON → incoming calls ring natively (full-screen, over the lock screen, when the app is closed). Requires
// FCM configured (FCM_SERVICE_ACCOUNT) + an app build carrying the native-call Android plugin. Set ANDROID_CALLS=1.
const ANDROID_CALLS = process.env.ANDROID_CALLS === '1';
module.exports = {
PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
@@ -58,11 +71,16 @@ module.exports = {
SMTP_ENABLED,
PUBLIC_BASE_URL,
GIPHY_API_KEY,
CALLKIT_ENABLED,
ANDROID_CALLS,
PUBLIC_DIR,
REC_DIR,
TRANS_DIR,
UPLOADS_DIR,
DOWNLOADS_DIR,
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
// Access-token / web-cookie lifetime. Long by design + SLID FORWARD on every /api/me (app load / focus /
// heartbeat), so an actively-used session never lapses — you only get logged out by choosing to log out.
// (Was 24h, which logged people out overnight.)
SESSION_TTL: 1000 * 60 * 60 * 24 * 90, // 90d
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
};
-407
View File
@@ -1,407 +0,0 @@
// SQLite data layer + schema.
// Uses Node's built-in node:sqlite (no native compilation needed).
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const db = new DatabaseSync(process.env.DB_PATH || path.join(__dirname, 'data.db'));
// WAL is preferred but unsupported on some mounted/network filesystems; fall back quietly.
try { db.exec('PRAGMA journal_mode = WAL'); } catch { /* default rollback journal is fine */ }
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id),
email TEXT NOT NULL UNIQUE,
pw_hash TEXT NOT NULL,
pw_salt TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'technician',
mfa_secret TEXT,
mfa_enabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions_auth (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
mfa_passed INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS machines (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
enroll_token TEXT NOT NULL UNIQUE,
unattended INTEGER NOT NULL DEFAULT 0,
last_seen INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id TEXT NOT NULL,
user_id TEXT,
user_email TEXT,
machine_id TEXT,
machine_name TEXT,
action TEXT NOT NULL,
detail TEXT,
at INTEGER NOT NULL
);
`);
// Migration: optional display name for agents (shown to customers on consent)
try { db.exec('ALTER TABLE users ADD COLUMN name TEXT'); } catch (e) { /* already exists */ }
// Migration: agent active flag (deactivate without deleting)
try { db.exec('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1'); } catch (e) { /* exists */ }
// Session report: one row per support session with duration
db.exec(`
CREATE TABLE IF NOT EXISTS sessions_log (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
agent_email TEXT,
agent_name TEXT,
ticket TEXT,
started_at INTEGER NOT NULL,
ended_at INTEGER
);
`);
// Migration: stored recording filename for a session (null if not recorded)
try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ }
// Refresh tokens for native (desktop/mobile) clients: long-lived, rotated on use,
// stored as a SHA-256 hash so a DB leak doesn't expose usable tokens.
db.exec(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
`);
// API keys for third-party / system integrations (machine-to-machine, no human login).
// Scoped per tenant; the key is stored as a SHA-256 hash (plaintext shown once at creation).
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
name TEXT,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL DEFAULT '',
created_by TEXT,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked INTEGER NOT NULL DEFAULT 0
);
`);
// Outbound webhook subscriptions: per-tenant endpoints that receive signed event
// callbacks (session.started / session.ended). Each has its own signing secret.
db.exec(`
CREATE TABLE IF NOT EXISTS webhooks (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
url TEXT NOT NULL,
secret TEXT NOT NULL,
events TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_by TEXT,
created_at INTEGER NOT NULL,
last_status INTEGER,
last_error TEXT,
last_at INTEGER
);
`);
// Persistent 1:1 chat between users in the same team.
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
recipient_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
read_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
`);
// Migration: a message can quote/reply to another message.
try { db.exec('ALTER TABLE messages ADD COLUMN reply_to TEXT'); } catch (e) { /* exists */ }
// Emoji reactions on messages (one row per user+message+emoji; toggling adds/removes).
db.exec(`
CREATE TABLE IF NOT EXISTS message_reactions (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
emoji TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (message_id, user_id, emoji)
);
`);
// File attachments for chat messages (file bytes stored on disk at uploads/<id>).
db.exec(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
uploader_id TEXT NOT NULL,
name TEXT NOT NULL,
mime TEXT,
size INTEGER,
created_at INTEGER NOT NULL
);
`);
try { db.exec('ALTER TABLE messages ADD COLUMN attachment_id TEXT'); } catch (e) { /* exists */ }
// Group conversations + membership. (1:1 DMs keep using sender_id/recipient_id directly;
// group messages set conversation_id instead, with recipient_id left blank.)
db.exec(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'group',
name TEXT,
created_by TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_read_at INTEGER NOT NULL DEFAULT 0,
joined_at INTEGER NOT NULL,
PRIMARY KEY (conversation_id, user_id)
);
`);
try { db.exec('ALTER TABLE messages ADD COLUMN conversation_id TEXT'); } catch (e) { /* exists */ }
try { db.exec('CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at)'); } catch (e) {}
// Group admins: 1 = this member is an admin (multiple admins allowed). Creator seeded as admin.
try { db.exec('ALTER TABLE conversation_members ADD COLUMN admin INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
try { db.exec('UPDATE conversation_members SET admin=1 WHERE user_id IN (SELECT created_by FROM conversations WHERE conversations.id=conversation_members.conversation_id) AND admin=0'); } catch (e) {}
// Avatars: a user's profile picture (BizGaze photo URL) and a group's uploaded image
// (an attachment id, served via /files/<id> with group-membership auth).
try { db.exec('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE conversations ADD COLUMN avatar_id TEXT'); } catch (e) { /* exists */ }
// @mentions on a (group) message: JSON array of mentioned user ids, and/or the literal
// "everyone" for @everyone/@all. Used to highlight and notify mentioned members.
try { db.exec('ALTER TABLE messages ADD COLUMN mentions TEXT'); } catch (e) { /* exists */ }
// Delivered receipt for DMs (double tick): set when the recipient's client acknowledges.
try { db.exec('ALTER TABLE messages ADD COLUMN delivered_at INTEGER'); } catch (e) { /* exists */ }
// Group setting: when 1, only the creator can add/remove members.
try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// Polls live within a group conversation, attached to a message (the poll's question is
// the message body). options is a JSON array of option strings; votes are one row each.
try { db.exec('ALTER TABLE messages ADD COLUMN poll_id TEXT'); } catch (e) { /* exists */ }
// Activity/event lines (e.g. 'call-start','call-end') render as centered system messages.
try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* exists */ }
// Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment
// are cleared and clients render a "This message was deleted" placeholder.
try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// A message can be edited by its sender; edited_at marks it (shows an "edited" label).
try { db.exec('ALTER TABLE messages ADD COLUMN edited_at INTEGER'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE messages ADD COLUMN fwd_from TEXT'); } catch (e) { /* exists — original sender name when a message was forwarded (#5) */ }
// User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.)
try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ }
// BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or
// their mobile number, so this — not the typed login identifier — is the stable identity key.
// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge).
try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ }
// #2: when this user was last connected (stamped on connect + on their last socket closing), so contacts
// can show "last seen 10 minutes ago" instead of a bare "Offline".
try { db.exec('ALTER TABLE users ADD COLUMN last_seen INTEGER'); } catch (e) { /* exists */ }
// Backfill: the column is new, so every existing user was NULL and therefore still read as a bare
// "Offline" until they happened to reconnect. Seed it from the last message they sent — the best
// evidence we already have of when they were last around. Only fills rows that are still NULL.
try {
db.exec(`UPDATE users SET last_seen = (
SELECT MAX(created_at) FROM messages WHERE messages.sender_id = users.id
) WHERE last_seen IS NULL AND EXISTS (SELECT 1 FROM messages WHERE messages.sender_id = users.id)`);
} catch (e) { /* messages table may not exist yet on a fresh db */ }
try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ }
// When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so
// any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor
// instead of hitting a deleted user (which made messages to merged contacts silently vanish).
// #7: a log of finished CALLS (ad-hoc / group / 1:1). Scheduled meetings already have their own row, so
// they're not duplicated here. `peak` is the most people who were in the room at once — that's what lets
// "Past meetings" show a call that grew past 2 people while hiding plain 1:1s.
db.exec(`
CREATE TABLE IF NOT EXISTS call_history (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
room TEXT NOT NULL,
group_id TEXT,
kind TEXT,
title TEXT,
peak INTEGER NOT NULL DEFAULT 0,
participants TEXT,
uids TEXT,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL
)`);
try { db.exec('CREATE INDEX IF NOT EXISTS idx_call_history_team ON call_history(team_id, ended_at)'); } catch (e) {}
db.exec(`
CREATE TABLE IF NOT EXISTS user_aliases (
old_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
team_id TEXT,
created_at INTEGER NOT NULL
)`);
db.exec(`
CREATE TABLE IF NOT EXISTS polls (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
message_id TEXT,
question TEXT NOT NULL,
options TEXT NOT NULL,
multi INTEGER NOT NULL DEFAULT 0,
closed INTEGER NOT NULL DEFAULT 0,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS poll_votes (
poll_id TEXT NOT NULL,
user_id TEXT NOT NULL,
option_idx INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (poll_id, user_id, option_idx)
);
`);
// Scheduled meetings/calls. Each carries a stable room_code so a scheduled call can be
// joined later (the live mesh room is created on first join). group_id is optional — a
// scheduled meeting may target a specific group conversation or be standalone.
db.exec(`
CREATE TABLE IF NOT EXISTS scheduled_meetings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
group_id TEXT,
room_code TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
scheduled_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
ended_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code);
`);
// Invited participants (JSON array of user ids) + a one-shot "10-min reminder sent" flag.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN participants TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN reminded INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// Cancelled meetings are kept (shown as "Cancelled"), not deleted.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN duration_mins INTEGER'); } catch (e) { /* exists */ }
// Weekly recurrence: JSON array of weekdays (0=Sun..6=Sat), or null for a one-off.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN recurrence TEXT'); } catch (e) { /* exists */ }
// External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed
// guest join link instead of an in-app invite. (Must come AFTER the CREATE above — on a fresh DB these
// ALTERs previously ran before the table existed and were silently lost.)
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ }
// Lobby (#4): 1 = guests joining by link must be admitted by the host; 0 = they join directly. NULL is
// treated as "require approval" (safe default) by the signaling layer.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN lobby INTEGER'); } catch (e) { /* exists */ }
// Meeting recordings & transcripts. Video bytes live in recordings/m_<id>.webm, transcript text
// in transcripts/m_<id>.txt. Tied to a room (and group/scheduled meeting when applicable) so they
// surface under "Past meetings". kind = 'video' | 'transcript'.
db.exec(`
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
room TEXT,
group_id TEXT,
meeting_id TEXT,
title TEXT,
kind TEXT NOT NULL,
file TEXT,
mime TEXT,
size INTEGER,
duration_ms INTEGER,
created_by TEXT,
created_by_name TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
`);
// Web Push subscriptions (one per browser/device per user) for background/closed-tab
// notifications. endpoint is unique; p256dh+auth are the encryption keys from the browser.
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
`);
// Native device push tokens (FCM for Android, APNs for iOS) registered by the mobile app.
// Distinct from push_subscriptions (Web Push): a native token is just an opaque string + platform.
db.exec(`
CREATE TABLE IF NOT EXISTS device_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
tenant_id TEXT,
platform TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_seen INTEGER
);
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
`);
// App installs (desktop/mobile clients): one row per install, associated with the user once
// they sign in. Lets admins see who installed the app, which version, and when it was last used.
db.exec(`
CREATE TABLE IF NOT EXISTS app_installs (
id TEXT PRIMARY KEY,
install_id TEXT NOT NULL UNIQUE,
user_id TEXT,
user_email TEXT,
tenant_id TEXT,
platform TEXT,
app_version TEXT,
os TEXT,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_installs_tenant ON app_installs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_installs_user ON app_installs(user_id);
`);
// Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
user_id TEXT NOT NULL,
target TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, target)
);
`);
module.exports = db;
-56
View File
@@ -1,56 +0,0 @@
// One-shot data migration: copy every row from the SQLite data.db into Postgres. Run ONCE at cutover,
// with the app stopped, BEFORE switching DB_BACKEND to pg.
//
// DB_PATH=/data/data.db DATABASE_URL=postgres://user:pass@host/db node db/migrate-sqlite-to-pg.js
//
// It applies the Postgres schema first, TRUNCATEs the target tables (so a re-run re-copies cleanly), then
// bulk-inserts in FK-dependency order. audit_log.id is a GENERATED identity, so its id is not copied (PG
// assigns fresh ones — nothing references audit_log.id). Timestamps/flags are plain integers on both sides.
const fs = require('fs');
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const { Pool } = require('pg');
const SQLITE = process.env.DB_PATH || path.join(__dirname, '..', 'data.db');
if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is required'); process.exit(1); }
const src = new DatabaseSync(SQLITE);
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
// Parents before children (users→teams, sessions_auth→users, machines→teams); the rest have no FKs.
const ORDER = [
'teams', 'users', 'machines', 'sessions_auth', 'audit_log', 'sessions_log', 'refresh_tokens',
'api_keys', 'webhooks', 'messages', 'message_reactions', 'attachments', 'conversations',
'conversation_members', 'call_history', 'user_aliases', 'polls', 'poll_votes', 'scheduled_meetings',
'recordings', 'push_subscriptions', 'device_tokens', 'app_installs', 'favorites',
];
async function main() {
await pool.query(fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8')); // ensure schema exists
await pool.query('TRUNCATE ' + ORDER.map((t) => '"' + t + '"').join(', ') + ' RESTART IDENTITY CASCADE');
const totals = {};
for (const table of ORDER) {
let rows = [];
try { rows = src.prepare('SELECT * FROM ' + table).all(); } catch (e) { totals[table] = 'skip(' + e.message + ')'; continue; }
if (!rows.length) { totals[table] = 0; continue; }
let cols = Object.keys(rows[0]);
if (table === 'audit_log') cols = cols.filter((c) => c !== 'id'); // GENERATED — let PG assign
const colList = cols.map((c) => '"' + c + '"').join(',');
const CHUNK = 400; // keep param count well under Postgres' 65535 limit even for wide tables
for (let i = 0; i < rows.length; i += CHUNK) {
const batch = rows.slice(i, i + CHUNK);
const values = []; const params = [];
batch.forEach((r, ri) => {
values.push('(' + cols.map((c, ci) => '$' + (ri * cols.length + ci + 1)).join(',') + ')');
cols.forEach((c) => params.push(r[c] === undefined ? null : r[c]));
});
await pool.query('INSERT INTO "' + table + '" (' + colList + ') VALUES ' + values.join(','), params);
}
totals[table] = rows.length;
}
console.log('MIGRATED rows:', JSON.stringify(totals, null, 0));
await pool.end();
}
main().catch((e) => { console.error('MIGRATION FAILED:', e && e.message); process.exit(1); });
+3 -3
View File
@@ -1,6 +1,6 @@
// PostgreSQL backend for the async DB adapter. Same interface as db/sqlite.js — prepare(sql).{get,all,run},
// exec(sql), tx(fn), init() — so repos and app code are engine-agnostic. Selected by DB_BACKEND=pg;
// connection string from DATABASE_URL.
// PostgreSQL backend for the async DB adapter — the ONLY backend (SQLite retired 2026-08-12). Implements
// prepare(sql).{get,all,run}, exec(sql), tx(fn), init() so repos/app code stay engine-agnostic (the facade
// in dbx.js keeps the door open for future backends). Connection string from DATABASE_URL.
const { Pool, types } = require('pg');
const fs = require('fs');
const path = require('path');
+43 -1
View File
@@ -129,7 +129,9 @@ CREATE TABLE IF NOT EXISTS messages (
msg_type TEXT,
deleted SMALLINT NOT NULL DEFAULT 0,
edited_at BIGINT,
fwd_from TEXT
fwd_from TEXT,
pinned_at BIGINT, -- #13 pin a message
pinned_by TEXT -- #13
);
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
@@ -137,6 +139,21 @@ CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, crea
-- New: attachment lookups drove the /files auth scan (see static.js authAttachment). Index it so the
-- per-Range playback auth is a keyed lookup, not a table scan (the auth cache stays as a second line).
CREATE INDEX IF NOT EXISTS idx_messages_attachment ON messages(attachment_id);
-- Columns/tables added AFTER the initial PG cutover. The CREATE TABLE above only applies to a FRESH
-- database (IF NOT EXISTS is a no-op once the table exists), so add these idempotently for the existing
-- production table too. Safe to run on every boot. (Unlike the up-front rule at the top of this file, a
-- post-cutover column MUST also be ALTER-ed in — otherwise it silently never lands on the live DB.)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned_at BIGINT; -- #13
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned_by TEXT; -- #13
-- #18 "Delete for me": a per-user hide. The message row is untouched (everyone else still sees it); this
-- records that THIS user removed it from their own threads + sidebar.
CREATE TABLE IF NOT EXISTS message_hidden (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
hidden_at BIGINT,
PRIMARY KEY (message_id, user_id)
);
CREATE TABLE IF NOT EXISTS message_reactions (
message_id TEXT NOT NULL,
@@ -146,6 +163,31 @@ CREATE TABLE IF NOT EXISTS message_reactions (
PRIMARY KEY (message_id, user_id, emoji)
);
-- UGC moderation (App Store Review guideline 1.2): report a message + block a user.
-- Reports are workspace-internal — surfaced to the tenant's admins, who can delete the message / act.
CREATE TABLE IF NOT EXISTS message_reports (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
message_id TEXT NOT NULL,
reporter_id TEXT NOT NULL,
reported_id TEXT NOT NULL,
reason TEXT,
snippet TEXT,
created_at BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'open'
);
CREATE INDEX IF NOT EXISTS idx_reports_team ON message_reports(team_id, created_at);
-- A one-directional block: blocker no longer receives the blocked user's messages or calls.
CREATE TABLE IF NOT EXISTS user_blocks (
blocker_id TEXT NOT NULL,
blocked_id TEXT NOT NULL,
team_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON user_blocks(blocker_id);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
-50
View File
@@ -1,50 +0,0 @@
// SQLite backend for the async DB adapter (dev + tests; also the current prod engine until pg cutover).
//
// Wraps the synchronous node:sqlite instance (schema applied at load in ../db.js) in the async interface
// the repos call. Results are returned via resolved Promises, so the SAME repo code runs unchanged on this
// synchronous engine and on asynchronous Postgres — the app never sees the difference.
const raw = require('../db'); // DatabaseSync instance with the full schema already applied
// node:sqlite re-prepares cheaply, but caching by SQL text avoids re-parsing on hot paths.
const cache = new Map();
function stmt(sql) {
let s = cache.get(sql);
if (!s) { s = raw.prepare(sql); cache.set(sql, s); }
return s;
}
const num = (v) => (typeof v === 'bigint' ? Number(v) : v);
const runResult = (r) => ({ changes: num(r.changes), lastInsertRowid: num(r.lastInsertRowid) });
function prepare(sql) {
return {
get: (...p) => Promise.resolve(stmt(sql).get(...p)),
all: (...p) => Promise.resolve(stmt(sql).all(...p)),
run: (...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
};
}
function exec(sql) { raw.exec(sql); return Promise.resolve(); }
// Transaction primitive. SQLite is single-connection, so BEGIN/COMMIT/ROLLBACK on `raw` is safe; the
// callback gets a runner with the same async run/get shape. (The pg backend implements this on ONE pooled
// client — the reason repos must use tx() rather than bare exec('BEGIN') for multi-statement atomicity.)
async function tx(fn) {
raw.exec('BEGIN');
try {
const t = {
run: (sql, ...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
get: (sql, ...p) => Promise.resolve(stmt(sql).get(...p)),
all: (sql, ...p) => Promise.resolve(stmt(sql).all(...p)),
};
const out = await fn(t);
raw.exec('COMMIT');
return out;
} catch (e) {
try { raw.exec('ROLLBACK'); } catch (_) {}
throw e;
}
}
function init() { return Promise.resolve(); } // schema already applied synchronously in ../db.js
module.exports = { prepare, exec, tx, init, name: 'sqlite', _raw: raw };
+9 -5
View File
@@ -1,6 +1,10 @@
// Async DB adapter facade. The backend is chosen by DB_BACKEND (default 'sqlite'); 'pg' is added at
// cutover. Every backend implements the same async interface — prepare(sql).{get,all,run}, exec(sql),
// tx(fn), init() — so repos and app code are engine-agnostic. Swapping engines is one backend file, no
// repo changes. (This is the same "never hardwire the engine" principle we'll apply to the pub/sub layer.)
const name = process.env.DB_BACKEND || 'sqlite';
// Async DB adapter facade. Production runs PostgreSQL. SQLite was RETIRED on 2026-08-12 so there is exactly
// ONE schema source of truth (db/schema.pg.sql) — no more dual-maintenance drift between a SQLite migration
// list and the PG schema (that gap once made a column land on SQLite only and 500'd every read on prod).
//
// DB_BACKEND is kept for future swappable backends but defaults to 'pg', and only 'pg' ships today. An
// unknown value fails LOUDLY here at require time (module-not-found) rather than silently selecting a stale
// or non-existent engine. Every backend implements the same async interface: prepare(sql).{get,all,run},
// exec(sql), tx(fn), init().
const name = process.env.DB_BACKEND || 'pg';
module.exports = require('./db/' + name);
+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 };
+248
View File
@@ -8,6 +8,8 @@
"name": "bizgaze-support-server",
"version": "2.0.0",
"dependencies": {
"pg": "^8.13.1",
"redis": "^4.7.0",
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
@@ -18,6 +20,65 @@
"nodemailer": "^6.9.14"
}
},
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -51,6 +112,15 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -77,6 +147,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
@@ -157,6 +236,151 @@
"node": ">=6.0.0"
}
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -183,6 +407,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
@@ -220,6 +453,21 @@
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
}
}
}
+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"
},
+27 -1
View File
@@ -106,6 +106,26 @@ const card=document.getElementById('card'), wrap=document.getElementById('wrap')
agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
let ws,pc,inputChannel,chatChannel,sessionId,me=null;
let RS_LK=false, lkRoom=null; // iOS-shared session: view the screen over LiveKit instead of P2P
// Load the LiveKit browser SDK on demand (same vendored build the meeting UI uses).
function sfuLoadLib(){ return new Promise((res,rej)=>{ if(window.LivekitClient) return res(window.LivekitClient); const s=document.createElement('script'); s.src='/vendor/livekit-client.umd.min.js'; s.onload=()=>res(window.LivekitClient); s.onerror=()=>rej(new Error('livekit sdk failed to load')); document.head.appendChild(s); }); }
// The customer is on iPhone (WKWebView can't getDisplayMedia), so they publish their screen over LiveKit.
// Join that room and show the screen in the SAME viewer we use for P2P (recording/chat/controls unchanged).
async function startLiveKitView(room){
const statusEl=document.getElementById('status');
if(statusEl){ statusEl.className='status'; statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Connecting to the shared screen…'; }
try{
const LK=await sfuLoadLib();
const tk=await fetch('/api/meetings/token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room})}).then(r=>r.json());
if(!tk||!tk.token) throw new Error('no token');
const r=new LK.Room({adaptiveStream:false,dynacast:false}); lkRoom=r;
const showVideo=(mst)=>{ video.srcObject=new MediaStream([mst]); if(typeof wrap!=='undefined'&&wrap) wrap.style.display='none'; if(typeof topbar!=='undefined'&&topbar) topbar.style.display='none'; video.style.display='block'; try{ video.play(); }catch(_){}; try{ video.focus(); }catch(_){}; buildBar(); };
const attach=(track)=>{ if(!track) return; const mst=track.mediaStreamTrack; if(track.kind==='video'){ showVideo(mst); } else { let a=document.getElementById('remoteAudio'); if(!a){ a=document.createElement('audio'); a.id='remoteAudio'; a.autoplay=true; document.body.appendChild(a); } a.srcObject=new MediaStream([mst]); } };
r.on(LK.RoomEvent.TrackSubscribed,(track)=>attach(track));
await r.connect(tk.url, tk.token);
try{ r.remoteParticipants.forEach((p)=>{ p.trackPublications.forEach((pub)=>{ if(pub.track) attach(pub.track); }); }); }catch(_){}
}catch(e){ if(statusEl){ statusEl.className='status err'; statusEl.textContent='Could not connect to the shared screen.'; } }
}
async function api(path,body,method='POST'){
const opt={method,headers:{'Content-Type':'application/json'}};
@@ -191,6 +211,8 @@ function connectWS(){
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
case 'rs-livekit': RS_LK=true; startLiveKitView(m.room); break; // iOS customer shares over LiveKit — view it there
case 'rs-chat': if(m.msg) addChat({from:'other',name:m.msg.name||'Customer',text:m.msg.text}); break; // chat over the socket in LiveKit mode
case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break;
case 'session-denied': renderEnded('The customer declined the request.'); break;
case 'session-ended': {
@@ -213,6 +235,7 @@ function renderWaiting(){
function renderEnded(msg){
bzcSession(false);
try{ if(lkRoom){ lkRoom.disconnect(); lkRoom=null; } }catch(_){} // tear down the LiveKit view (symmetric disconnect)
try{ stopRecording(); }catch(_){}
removeSessionUI();
document.body.classList.remove('has-bar');
@@ -385,7 +408,10 @@ let __ac=null;
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;
if(RS_LK){ try{ ws.send(JSON.stringify({type:'rs-chat',sessionId,msg:{name:(me&&(me.name||me.email))||'Support agent',text:t}})); }catch(_){} }
else if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}
addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
async function setupPeer(){
+1177 -126
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -24,6 +24,9 @@
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
eyeOff: '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="m2 2 20 20"/>',
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
pinOff: '<path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
@@ -63,6 +66,8 @@
calendarX: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m14 14-4 4"/><path d="m10 14 4 4"/>',
calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>',
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
externalLink:'<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/>',
switchCamera:'<path d="M11 19H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/><path d="M13 5h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5"/><circle cx="12" cy="12" r="3"/><path d="m18 22-3-3 3-3"/><path d="m6 2 3 3-3 3"/>',
record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>',
callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
@@ -70,6 +75,8 @@
bluetooth: '<path d="m7 7 10 10-5 5V2l5 5L7 17"/>',
speaker: '<path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a9 9 0 0 1 0 14"/>',
speakerOff: '<path d="M11 5 6 9H2v6h4l5 4z"/><line x1="22" y1="9" x2="16" y2="15"/><line x1="16" y1="9" x2="22" y2="15"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>',
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
};
window.ICON = P;
window.ic = function (name, size) {
+97
View File
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy Policy — Biz Connect</title>
<style>
:root{--blue:#1F3B73;--blue-d:#16294f;--brand:#FFC708;--ink:#1f2430;--muted:#5b6472;--line:#e6e9ef;--bg:#f6f8fb;}
*{box-sizing:border-box}
body{margin:0;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--ink);line-height:1.65;}
.top{background:linear-gradient(180deg,#20396f,#16294f);color:#fff;padding:2.2rem 1.2rem;}
.wrap{max-width:760px;margin:0 auto;padding:0 1.2rem;}
.brand{display:flex;align-items:center;gap:.6rem;font-weight:700;font-size:1.25rem;}
.brand svg{width:34px;height:34px;flex:0 0 auto}
.brand .b{color:#fff}.brand .c{color:var(--brand)}
h1{font-size:1.5rem;margin:1rem 0 .2rem;color:#fff;}
.top .upd{color:#c9d4ec;font-size:.9rem;margin:0;}
main{max-width:760px;margin:0 auto;padding:1.8rem 1.2rem 3rem;}
h2{font-size:1.12rem;color:var(--blue);margin:1.8rem 0 .5rem;border-bottom:2px solid var(--line);padding-bottom:.3rem;}
p,li{color:#333a45;}
ul{padding-left:1.2rem;margin:.4rem 0;}
li{margin:.28rem 0;}
a{color:var(--blue);}
.note{background:#fff;border:1px solid var(--line);border-left:4px solid var(--brand);border-radius:10px;padding:.9rem 1rem;margin:1rem 0;font-size:.95rem;}
.foot{max-width:760px;margin:0 auto;padding:1rem 1.2rem 3rem;color:var(--muted);font-size:.85rem;border-top:1px solid var(--line);}
strong{color:var(--ink);}
</style>
</head>
<body>
<div class="top">
<div class="wrap">
<div class="brand">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle cx="50" cy="50" r="34" fill="none" stroke="#fff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/><circle cx="66" cy="50" r="8.6" fill="#FFC708"/></svg>
<span><span class="b">Biz</span> <span class="c">Connect</span></span>
</div>
<h1>Privacy Policy</h1>
<p class="upd">Last updated: 21 August 2026</p>
</div>
</div>
<main>
<p>Biz Connect is a business communication app — team chat, voice and video calls, and meetings — for organizations that use the BizGaze platform. This policy explains what information the app handles, how it is used, and the choices you have. By using Biz Connect you agree to this policy.</p>
<h2>1. Information we collect</h2>
<ul>
<li><strong>Account information.</strong> When you sign in with your BizGaze account, we receive your name and email address to identify you and your organization. Accounts are provisioned by your organization's administrator; Biz Connect does not offer public self-signup.</li>
<li><strong>Content you create.</strong> Messages, group conversations, photos, videos, and files you send; and any meeting recordings or transcripts you choose to create. This content is stored on our servers so it can be delivered and shown to the people you send it to.</li>
<li><strong>Call and meeting media.</strong> Audio and video during a call or meeting are transmitted between participants to run the call. This media is <strong>not recorded or stored</strong> unless a participant explicitly starts a recording or transcript.</li>
<li><strong>Technical and device data.</strong> A device push token (so we can deliver notifications), and basic operational logs needed to run and secure the service.</li>
</ul>
<h2>2. How we use information</h2>
<ul>
<li>To deliver your messages, calls, and meetings and keep them in sync across your devices.</li>
<li>To send notifications you have enabled (new messages, incoming calls, meeting reminders).</li>
<li>To operate, secure, troubleshoot, and improve the service.</li>
</ul>
<p>We do <strong>not</strong> sell your personal information, and we do <strong>not</strong> use it for advertising.</p>
<h2>3. Live meeting transcripts (on-device)</h2>
<div class="note">Speech-to-text for live meeting transcripts runs <strong>on your device</strong> using Apple's on-device speech recognition. The audio is <strong>not sent to Apple or to us</strong> for that purpose — only the resulting text is added to the meeting transcript, and only when you choose to turn transcription on.</div>
<h2>4. Notifications</h2>
<p>To notify you when the app is in the background or closed, we send push notifications through Apple Push Notification service (iOS) and Google Firebase Cloud Messaging (Android). We keep sensitive content out of notification text where practical.</p>
<h2>5. How information is shared</h2>
<p>We share information only with service providers that help us run Biz Connect, and only as needed to provide the service:</p>
<ul>
<li><strong>Hosting</strong> — our application servers and database.</li>
<li><strong>Real-time media (LiveKit)</strong> — carries call and meeting audio/video between participants.</li>
<li><strong>Push delivery (Apple, Google)</strong> — delivers notifications to your device.</li>
</ul>
<p>We may also disclose information if required by law or to protect the rights, safety, and security of our users and service.</p>
<h2>6. Data retention</h2>
<p>Messages and content are retained so your conversations remain available to you and your organization. Recordings and transcripts are kept until deleted. You or your organization's administrator can delete content; when an account is removed, its access ends.</p>
<h2>7. Security</h2>
<p>Data is encrypted in transit using TLS, and calls use encrypted real-time transport. No method of transmission or storage is perfectly secure, but we work to protect your information with appropriate technical and organizational measures.</p>
<h2>8. Your rights and choices</h2>
<p>You can request access to, correction of, or deletion of your personal data. Because accounts are managed by your organization, some requests are handled through your organization's administrator. Depending on where you live (for example, the EU/EEA under the GDPR), you may have additional rights, including the right to object to or restrict processing and to lodge a complaint with a supervisory authority. To exercise any right, contact us using the details below.</p>
<h2>9. Children</h2>
<p>Biz Connect is a workplace tool intended for business use and is not directed to children.</p>
<h2>10. International processing</h2>
<p>Your information may be processed in the country where our servers and service providers operate. Where required, we put appropriate safeguards in place for cross-border transfers.</p>
<h2>11. Changes to this policy</h2>
<p>We may update this policy from time to time. Material changes will be reflected by updating the "Last updated" date above.</p>
<h2>12. Contact us</h2>
<p>Questions or requests about privacy: <a href="mailto:support@bizgaze.com">support@bizgaze.com</a>.</p>
</main>
<div class="foot">© 2026 BizGaze. Biz Connect. All rights reserved.</div>
</body>
</html>
+27 -5
View File
@@ -106,6 +106,10 @@ let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
let SHARER_NAME='Customer';
try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){}
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
// iOS app: WKWebView can't getDisplayMedia. Ask the top frame (home.html) if it can publish the screen
// natively (ReplayKit -> LiveKit). If so, we run this session over LiveKit instead of P2P.
let NATIVE_IOS=false, RS_ROOM=null;
try{ if(window.parent && window.parent!==window){ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='bzc-native'){ NATIVE_IOS=!!d.ok; } }); window.parent.postMessage({type:'bzc-native-ping'}, location.origin); } }catch(_){}
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
@@ -138,6 +142,7 @@ ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
case 'recording': recNotice(m.on); if(m.on) startCustTranscription(); else stopCustTranscription(); break;
case 'rs-chat': if(m.msg) addChat({from:'other', name:m.msg.name||'Agent', text:m.msg.text}); break; // chat over the socket in LiveKit mode (no P2P data channel)
case 'session-ended': endShareSession('Your support agent ended the session. Tap below for a new code if you still need help.'); break;
case 'error': setStatus(m.message,''); break;
}};
@@ -169,6 +174,7 @@ function showConsent(m){
// getDisplayMedia unless it is called from a user gesture, so this must not run
// after a server round-trip. getDisplayMedia is called first to keep the gesture.
async function beginCapture(){
if(NATIVE_IOS){ return true; } // iOS: the app captures via ReplayKit on start-stream (no getDisplayMedia in WKWebView)
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ return false; }
// Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the
@@ -178,6 +184,18 @@ async function beginCapture(){
return true;
}
async function startStreaming(){
// iOS: publish the screen NATIVELY over LiveKit (WKWebView can't getDisplayMedia). Tell the app to start the
// ReplayKit broadcast into a room derived from this session, and tell the agent to view over LiveKit.
if(NATIVE_IOS){
RS_ROOM='rs'+String(sessionId||'').replace(/[^A-Za-z0-9]/g,'').slice(0,60);
try{ window.parent.postMessage({type:'rs-native-share', room:RS_ROOM}, location.origin); }catch(_){}
try{ ws.send(JSON.stringify({type:'rs-livekit', sessionId, room:RS_ROOM})); }catch(_){}
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
window.onbeforeunload=function(){ if(!sessionOver){ return 'Leaving this page will end your screen sharing session.'; } };
buildBar();
return;
}
// If the Allow tap already captured the screen (mobile path), reuse it.
if(!localStream){
await ensureIce();
@@ -320,6 +338,7 @@ function recNotice(on){
} else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
}
function endShareSession(msgText){
if(NATIVE_IOS){ try{ window.parent.postMessage({type:'rs-native-stop'}, location.origin); }catch(_){} } // stop the native ReplayKit broadcast + LiveKit
try{ rcStopControl(); }catch(_){} // release remote control when the session ends
sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
removeSessionUI();
@@ -330,7 +349,7 @@ function endShareSession(msgText){
var card=document.querySelector('.panelside .card');
if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; }
}
function teardown(){try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
function teardown(){if(NATIVE_IOS){try{window.parent.postMessage({type:'rs-native-stop'},location.origin);}catch(_){}}try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
let chatOpen=false;
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
@@ -349,10 +368,10 @@ function buildBar(){
const rcb=_btn('rcBtn',I('monitor'),'Remote control is OFF','#6b7280');
const chat=_btn('chatBtn',I('chat'),'Chat','#475569');
const end=_btn('endBtn2',I('callEnd'),'End','#dc2626');
bar.appendChild(mic);bar.appendChild(rcb);bar.appendChild(chat);bar.appendChild(end);
if(!NATIVE_IOS){ bar.appendChild(mic); bar.appendChild(rcb); } // iOS: two-way voice is a follow-up; remote control is impossible on iOS
bar.appendChild(chat);bar.appendChild(end);
document.body.appendChild(bar);
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); };
updateRcBtn();
if(!NATIVE_IOS){ rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); }; updateRcBtn(); }
makeBarDraggable(bar,'bzc_sharebar_pos'); // new #4: let the customer move the bar off their content
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(on?'mic':'micOff')+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
mic.onclick=async()=>{
@@ -391,7 +410,10 @@ let __ac=null;
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:SHARER_NAME,text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;
if(NATIVE_IOS){ try{ ws.send(JSON.stringify({type:'rs-chat',sessionId,msg:{name:SHARER_NAME,text:t}})); }catch(_){} }
else if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:SHARER_NAME,text:t}));}
addChat({from:'__self',name:'You',text:t});i.value='';}
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Support — Biz Connect</title>
<style>
:root{--blue:#1F3B73;--blue-d:#16294f;--brand:#FFC708;--ink:#1f2430;--muted:#5b6472;--line:#e6e9ef;--bg:#f6f8fb;}
*{box-sizing:border-box}
body{margin:0;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--ink);line-height:1.65;}
.top{background:linear-gradient(180deg,#20396f,#16294f);color:#fff;padding:2.2rem 1.2rem;}
.wrap{max-width:760px;margin:0 auto;padding:0 1.2rem;}
.brand{display:flex;align-items:center;gap:.6rem;font-weight:700;font-size:1.25rem;}
.brand svg{width:34px;height:34px;flex:0 0 auto}
.brand .b{color:#fff}.brand .c{color:var(--brand)}
h1{font-size:1.5rem;margin:1rem 0 .2rem;color:#fff;}
.top .sub{color:#c9d4ec;font-size:.95rem;margin:0;}
main{max-width:760px;margin:0 auto;padding:1.8rem 1.2rem 3rem;}
h2{font-size:1.12rem;color:var(--blue);margin:1.8rem 0 .5rem;}
p,li{color:#333a45;}
a{color:var(--blue);}
.card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:1.1rem 1.2rem;margin:1rem 0;box-shadow:0 4px 14px rgba(20,30,60,.05);}
.cta{display:inline-block;background:var(--blue);color:#fff;text-decoration:none;font-weight:600;padding:.7rem 1.1rem;border-radius:10px;margin-top:.4rem;}
.faq b{display:block;color:var(--ink);margin-top:.8rem;}
.foot{max-width:760px;margin:0 auto;padding:1rem 1.2rem 3rem;color:var(--muted);font-size:.85rem;border-top:1px solid var(--line);}
ul{padding-left:1.2rem}
</style>
</head>
<body>
<div class="top">
<div class="wrap">
<div class="brand">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle cx="50" cy="50" r="34" fill="none" stroke="#fff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/><circle cx="66" cy="50" r="8.6" fill="#FFC708"/></svg>
<span><span class="b">Biz</span> <span class="c">Connect</span></span>
</div>
<h1>Support</h1>
<p class="sub">Help with team chat, calls, and meetings on Biz Connect.</p>
</div>
</div>
<main>
<div class="card">
<h2 style="margin-top:0">Contact us</h2>
<p>Need help, found a bug, or have a question? Email our team and we'll get back to you.</p>
<a class="cta" href="mailto:support@bizgaze.com">Email support@bizgaze.com</a>
</div>
<h2>About Biz Connect</h2>
<p>Biz Connect keeps your team connected with chat, voice and video calls, and meetings in one place. It's for organizations that use the BizGaze platform — sign in with your BizGaze account to get started.</p>
<h2>Frequently asked</h2>
<div class="faq">
<b>How do I sign in?</b>
<p>Open the app and sign in with your BizGaze account. Accounts are created by your organization's administrator; if you can't sign in, contact your admin or email us above.</p>
<b>How do I start a call or meeting?</b>
<p>Open a conversation and tap the call button, or use the Meetings tab to start instantly or join a scheduled meeting by its code.</p>
<b>How do live transcripts work?</b>
<p>In a meeting, turn on the live transcript. Speech-to-text runs on your device; only the resulting text is saved to the transcript.</p>
<b>How do I report a message or block someone?</b>
<p>Press and hold (or use the ⋮ menu on) any message to Report it or Block the sender. You can also block a contact from their profile, and manage blocked users from your profile menu.</p>
<b>How do I delete my account or data?</b>
<p>Accounts are managed by your organization. To delete your account or data, contact your organization's administrator or email <a href="mailto:support@bizgaze.com">support@bizgaze.com</a>.</p>
</div>
<h2>Privacy</h2>
<p>Read how we handle your data in our <a href="/privacy">Privacy Policy</a>.</p>
</main>
<div class="foot">© 2026 BizGaze. Biz Connect. All rights reserved.</div>
</body>
</html>
+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 };
+132 -3
View File
@@ -30,7 +30,14 @@ if (webpush && PUBLIC && PRIVATE) {
// ---------------- FCM (Android), HTTP v1 ----------------
let fcmSA = null; // { client_email, private_key, project_id }
(function loadFcm() {
const raw = process.env.FCM_SERVICE_ACCOUNT;
// FCM_SERVICE_ACCOUNT = inline JSON or a file path. FCM_SERVICE_ACCOUNT_B64 = base64 of the JSON — the
// preferred way to put the service-account key in .env, since it's a single env-safe token (no quotes,
// spaces, or newlines to break env_file/compose interpolation).
let raw = process.env.FCM_SERVICE_ACCOUNT || '';
if (!raw && process.env.FCM_SERVICE_ACCOUNT_B64) {
try { raw = Buffer.from(process.env.FCM_SERVICE_ACCOUNT_B64, 'base64').toString('utf8'); }
catch (e) { console.warn('[push] FCM_SERVICE_ACCOUNT_B64 decode failed:', e.message); }
}
if (!raw) return;
try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); }
catch (e) { console.warn('[push] FCM service account unreadable:', e.message); }
@@ -62,6 +69,19 @@ async function sendFcm(token, payload) {
return { ok: res.ok, dead: res.status === 404 || res.status === 410 }; // UNREGISTERED tokens
}
// A high-priority, DATA-ONLY FCM message — no `notification` block, so Android delivers it to the app's
// FirebaseMessagingService.onMessageReceived() in ANY app state (foreground/background/killed) instead of just
// parking it in the tray. That's what lets the native-call plugin ring an incoming call when the app is closed
// (the Android equivalent of an iOS VoIP push). `android.priority:"high"` bypasses Doze batching.
async function sendFcmData(token, data) {
const at = await fcmAccessToken();
const msg = { message: { token, data: strData(data || {}), android: { priority: 'high' } } };
const res = await fetch('https://fcm.googleapis.com/v1/projects/' + fcmSA.project_id + '/messages:send', {
method: 'POST', headers: { Authorization: 'Bearer ' + at, 'Content-Type': 'application/json' }, body: JSON.stringify(msg),
});
return { ok: res.ok, dead: res.status === 404 || res.status === 410 };
}
// ---------------- APNs (iOS), token-based over HTTP/2 ----------------
let apnsKey = null, apnsCfg = null;
(function loadApns() {
@@ -88,7 +108,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 +121,109 @@ 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 = []; }
// Ring EVERY native device the user has — not just the first. (A user signed in on both an iPhone and an
// Android phone must ring on both; the old early-return after the iOS branch meant the Android invite was
// never sent, so a swiped-away Android phone never rang while the iPhone did.)
let handledNative = false;
// iOS CallKit via VoIP push.
const voip = toks.filter((t) => t.platform === 'ios-voip');
if (voip.length && apnsCfg && process.env.CALLKIT_ENABLED === '1') {
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 (_) {}
}
handledNative = true;
}
// Android native ring: a high-priority DATA message to the user's 'android' tokens so the native-call plugin
// rings even when the app is closed (BzCallMessagingService → full-screen incoming UI). Gated on ANDROID_CALLS.
const android = toks.filter((t) => t.platform === 'android');
if (android.length && fcmSA && process.env.ANDROID_CALLS === '1') {
const cd = {
bzcall: '1', type: 'invite',
callUUID: data.callUUID || '', room: data.room || '', kind: data.kind || 'dm',
callerId: data.callerId || '', callerName: data.callerName || 'Incoming call',
groupId: data.groupId || '', groupName: data.groupName || '', hasVideo: data.hasVideo ? '1' : '',
};
for (const t of android) {
try { const r = await sendFcmData(t.token, cd); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
}
handledNative = true;
}
// Native devices got the call-wake above; the banner is only a fallback for web push + non-native devices
// (sending it too would double-notify the native devices).
if (handledNative) return 'native';
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 (!callUUID) return;
let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
// iOS: cancel the CallKit ring via a VoIP push.
if (apnsCfg && process.env.CALLKIT_ENABLED === '1') {
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 (_) {}
}
}
// Android: cancel the native ring via a high-priority data message (BzCallMessagingService dismisses it).
if (fcmSA && process.env.ANDROID_CALLS === '1') {
for (const t of toks.filter((t) => t.platform === 'android')) {
try { const r = await sendFcmData(t.token, { bzcall: '1', 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);
@@ -106,6 +231,10 @@ console.log(enabled.length ? '[push] enabled: ' + enabled.join(', ') : '[push] d
function isEnabled() { return webReady; } // Web Push specifically (drives /api/push/vapid)
function publicKey() { return webReady ? PUBLIC : ''; }
// Whether Android FCM is configured on the server. The Android app must NOT call PushNotifications.register()
// unless Firebase is set up (client google-services.json + this) — otherwise it throws "Default FirebaseApp is
// not initialized", an uncaught NATIVE crash. The web uses this flag to gate Android push registration.
function fcmReady() { return !!fcmSA; }
// Fire-and-forget to every channel the user has: Web Push subscriptions + native device
// tokens. Dead endpoints/tokens are pruned. Never throws.
@@ -134,4 +263,4 @@ async function sendToUser(userId, payload) {
}
}
module.exports = { isEnabled, publicKey, sendToUser };
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel, fcmReady };
+3 -1
View File
@@ -2,6 +2,7 @@
// group members, and invited participants. Runs on a 60s tick; marks each meeting reminded.
const R = require('./repos');
const CHAT = require('./chat');
const PUSH = require('./push'); // native/web background push so a CLOSED app still gets the reminder
async function tick() {
try {
@@ -13,7 +14,8 @@ async function tick() {
invited.forEach((id) => recipients.add(id));
if (s.group_id) { try { (await R.conversations.members(s.group_id)).forEach((m) => recipients.add(m)); } catch (_) {} }
const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } };
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} });
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} }); // open tab
recipients.forEach((uid) => { try { PUSH.sendToUser(uid, { title: 'Meeting starting soon', body: (s.title || 'Your meeting') + ' starts in ~10 minutes', kind: 'meeting', id: s.room_code, tag: 'meet:' + s.room_code }); } catch (_) {} }); // closed app (iOS APNs etc.)
await R.scheduledMeetings.markReminded(s.id);
}
} catch (_) { /* never let the timer die */ }
+72 -7
View File
@@ -38,7 +38,10 @@ const users = {
byBizgazeId: (bizId) => (bizId ? db.prepare('SELECT * FROM users WHERE bizgaze_user_id=?').get(String(bizId)) : undefined),
setBizgazeId: (id, bizId) => db.prepare('UPDATE users SET bizgaze_user_id=? WHERE id=?').run(bizId != null ? String(bizId) : null, id),
listByTenant: (tenantId) =>
db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId),
// last_seen + status MUST be selected: the contacts/conversations DTOs read x.last_seen / x.status. Omitting
// them made every list payload carry lastSeen:null (and status:'active'), so a fresh load showed a bare
// "Offline" with no time — last-seen only appeared via live presence events (which read the full row).
db.prepare('SELECT id,email,name,role,active,avatar_url,created_at,last_seen,status FROM users WHERE team_id=?').all(tenantId),
inTenant: (id, tenantId) =>
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
create: async ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
@@ -51,6 +54,8 @@ const users = {
enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id),
setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id),
setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id),
// Workspace admins (for routing UGC reports to a moderator).
adminsOf: async (tenantId) => (await db.prepare("SELECT id FROM users WHERE team_id=? AND role='admin'").all(tenantId)).map((r) => r.id),
setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id),
setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id),
setAvatar: (id, url) => db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(url || null, id),
@@ -113,6 +118,7 @@ const authSessions = {
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,?,?,?)')
.run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl),
markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token),
touch: (token, ttl) => db.prepare('UPDATE sessions_auth SET expires_at=? WHERE token=?').run(now() + ttl, token), // slide the expiry forward on activity
deleteByToken: (token) => db.prepare('DELETE FROM sessions_auth WHERE token=?').run(token),
deleteByUser: (userId) => db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(userId),
};
@@ -205,6 +211,35 @@ const messages = {
editBody: (id, body) => db.prepare('UPDATE messages SET body=?, edited_at=? WHERE id=?').run(body, now(), id),
// Delete-for-everyone: clear the content but keep the row (renders as a placeholder).
markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id),
// #18 Delete-for-me: hide a message from ONE user's view (the row + everyone else are untouched).
hideForUser: (messageId, userId) => db.prepare('INSERT INTO message_hidden (message_id,user_id,hidden_at) VALUES (?,?,?) ON CONFLICT(message_id,user_id) DO NOTHING').run(messageId, userId, now()),
// "Delete chat" (self-only): hide the ENTIRE DM thread with one peer from a single user's view. Bulk-inserts
// message_hidden rows for every message of the pair, reusing the exact filters the thread + conversation list
// already apply (they skip message_hidden), so the chat disappears for this user while the peer keeps their
// copy. New messages afterwards start a fresh thread (they aren't hidden) — WhatsApp-style.
hideThreadForUser: (teamId, userId, peerId) => db.prepare(
`INSERT INTO message_hidden (message_id,user_id,hidden_at)
SELECT id, ?, ? FROM messages
WHERE team_id=? AND conversation_id IS NULL
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
ON CONFLICT (message_id,user_id) DO NOTHING`
).run(userId, now(), teamId, userId, peerId, peerId, userId),
// "Delete chat" (self-only) for a GROUP conversation: hide every message from one member's view (they stay a
// member; new messages arrive as a fresh thread). The peer/other members are untouched.
hideConversationForUser: (conversationId, userId) => db.prepare(
`INSERT INTO message_hidden (message_id,user_id,hidden_at)
SELECT id, ?, ? FROM messages WHERE conversation_id=?
ON CONFLICT (message_id,user_id) DO NOTHING`
).run(userId, now(), conversationId),
hiddenForUser: async (userId) => (await db.prepare('SELECT message_id FROM message_hidden WHERE user_id=?').all(userId)).map((r) => r.message_id),
// Last message in a group that THIS user hasn't hidden (so a "delete for me" on the last message
// rolls the sidebar preview back to the previous one, instead of showing what they just removed).
lastInConversationForUser: (conversationId, userId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?) ORDER BY created_at DESC LIMIT 1').get(conversationId, userId),
// #13 Pin a message: set/clear pinned_at + pinned_by.
setPinned: (id, pinnedAt, pinnedBy) => db.prepare('UPDATE messages SET pinned_at=?, pinned_by=? WHERE id=?').run(pinnedAt, pinnedBy, id),
// Pinned messages in a group / DM (newest pin first, deleted excluded).
pinnedInConversation: (conversationId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? AND pinned_at IS NOT NULL AND deleted=0 ORDER BY pinned_at DESC').all(conversationId),
pinnedInDm: (teamId, a, b) => db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) AND pinned_at IS NOT NULL AND deleted=0 ORDER BY pinned_at DESC').all(teamId, a, b, b, a),
// Shared media/files in a conversation (group) or DM — newest first.
attachmentsForConversation: (teamId, conversationId) => db.prepare(`SELECT a.id, a.name, a.mime, a.size, m.created_at FROM messages m JOIN attachments a ON a.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id=? AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, conversationId),
attachmentsForDm: (teamId, a, b) => db.prepare(`SELECT at.id, at.name, at.mime, at.size, m.created_at FROM messages m JOIN attachments at ON at.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?)) AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, a, b, b, a),
@@ -215,12 +250,20 @@ const messages = {
// and silently dropped everything newer once a thread passed 300 — so new messages "disappeared".
// The `before` cursor is added CONDITIONALLY (not as `? IS NULL OR …`): an all-NULL param has no type
// for Postgres to infer. The subquery also needs an alias (`t`) — Postgres requires it. Both portable.
// `a` is the VIEWER (u.id). Exclude the viewer's "deleted for me" (message_hidden) rows in SQL — not in
// JS afterwards — so the LIMIT counts only VISIBLE messages. Filtering after the LIMIT returned < PAGE rows
// whenever a recent message had been hidden, and the client read that as "no older history" and stopped
// paginating (a chat with a deleted recent message wouldn't scroll back).
// `a` is the VIEWER. Exclude messages from users the viewer has blocked (in SQL, like message_hidden,
// so the LIMIT counts only VISIBLE messages and pagination doesn't stall).
thread: (teamId, a, b, limit = 500, before = null) => {
const cond = before != null ? ' AND created_at < ?' : '';
const args = before != null ? [teamId, a, b, b, a, before, limit] : [teamId, a, b, b, a, limit];
const args = before != null ? [teamId, a, b, b, a, a, a, before, limit] : [teamId, a, b, b, a, a, a, limit];
return db.prepare(`SELECT * FROM (
SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))${cond}
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)
AND sender_id NOT IN (SELECT blocked_id FROM user_blocks WHERE blocker_id=?)${cond}
ORDER BY created_at DESC LIMIT ?
) t ORDER BY created_at ASC`).all(...args);
},
@@ -237,11 +280,14 @@ const messages = {
db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?')
.all(teamId, userId, userId, limit),
// Group conversation helpers.
threadByConversation: (conversationId, limit = 500, before = null) => {
threadByConversation: (conversationId, userId, limit = 500, before = null) => {
const cond = before != null ? ' AND created_at < ?' : '';
const args = before != null ? [conversationId, before, limit] : [conversationId, limit];
const args = before != null ? [conversationId, userId, userId, before, limit] : [conversationId, userId, userId, limit];
return db.prepare(`SELECT * FROM (
SELECT * FROM messages WHERE conversation_id=?${cond} ORDER BY created_at DESC LIMIT ?
SELECT * FROM messages WHERE conversation_id=?
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)
AND sender_id NOT IN (SELECT blocked_id FROM user_blocks WHERE blocker_id=?)${cond}
ORDER BY created_at DESC LIMIT ?
) t ORDER BY created_at ASC`).all(...args);
},
searchConversation: (conversationId, like, limit = 300) =>
@@ -409,4 +455,23 @@ const appInstalls = {
listForTenant: (tenantId) => db.prepare('SELECT * FROM app_installs WHERE tenant_id=? ORDER BY last_seen DESC').all(tenantId),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, callHistory, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls };
// UGC moderation (App Store guideline 1.2).
const reports = {
add: ({ id, teamId, messageId, reporterId, reportedId, reason, snippet }) =>
db.prepare('INSERT INTO message_reports (id,team_id,message_id,reporter_id,reported_id,reason,snippet,created_at,status) VALUES (?,?,?,?,?,?,?,?,?)')
.run(id, teamId, messageId, reporterId, reportedId, reason || null, snippet || null, now(), 'open'),
byId: (id) => db.prepare('SELECT * FROM message_reports WHERE id=?').get(id),
listForTeam: (teamId, limit = 200) => db.prepare('SELECT * FROM message_reports WHERE team_id=? ORDER BY created_at DESC LIMIT ?').all(teamId, limit),
setStatus: (id, status) => db.prepare('UPDATE message_reports SET status=? WHERE id=?').run(status, id),
openCountForTeam: async (teamId) => (await db.prepare("SELECT COUNT(*) AS c FROM message_reports WHERE team_id=? AND status='open'").get(teamId)).c,
};
const blocks = {
add: (blockerId, blockedId, teamId) =>
db.prepare('INSERT INTO user_blocks (blocker_id,blocked_id,team_id,created_at) VALUES (?,?,?,?) ON CONFLICT(blocker_id,blocked_id) DO NOTHING').run(blockerId, blockedId, teamId, now()),
remove: (blockerId, blockedId) => db.prepare('DELETE FROM user_blocks WHERE blocker_id=? AND blocked_id=?').run(blockerId, blockedId),
has: async (blockerId, blockedId) => !!(await db.prepare('SELECT 1 FROM user_blocks WHERE blocker_id=? AND blocked_id=?').get(blockerId, blockedId)),
listFor: async (blockerId) => (await db.prepare('SELECT blocked_id FROM user_blocks WHERE blocker_id=? ORDER BY created_at DESC').all(blockerId)).map((r) => r.blocked_id),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, callHistory, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls, reports, blocks };
+277 -40
View File
@@ -11,7 +11,7 @@ const PUSH = require('./push');
const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__';
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, pinned: !!m.pinned_at, edited_at: m.deleted ? null : (m.edited_at || null), system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
async function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
// Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a
// plain .filter() can't await). Sequential so per-item DB order is deterministic.
@@ -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, ANDROID_CALLS } = 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) {
@@ -280,7 +267,7 @@ route('POST', '/api/login', async (req, res) => {
}
const tok = A.token();
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
const ttl = SESSION_TTL; // long-lived (90d) and slid forward on /api/me — no more 24h overnight logout (remember-me is now moot)
await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
@@ -372,6 +359,17 @@ route('GET', '/api/ice', async (req, res) => {
route('GET', '/api/me', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
// Sliding session: a web (cookie) client hitting /api/me — on app load, focus, or the periodic heartbeat —
// pushes its expiry out to a fresh full window and re-stamps the cookie. So any regular use keeps you logged
// in indefinitely; you only lapse after SESSION_TTL of NO use at all, or by logging out. (Native clients use
// the refresh-token flow, so we only renew here when the request actually carried the sid cookie.)
try {
const tok = parseCookies(req).sid;
if (tok && u._session && u._session.token === tok) {
await R.authSessions.touch(tok, SESSION_TTL);
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`);
}
} catch (_) {}
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' });
});
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
@@ -412,7 +410,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 });
});
@@ -781,9 +780,11 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const favs = new Set(await R.favorites.forUser(u.id));
const inCall = new Set();
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } }
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: skip messages this user "deleted for me"
// DMs
const byOther = new Map();
for (const m of await R.messages.recentFor(u.team_id, u.id)) {
if (hidden.has(m.id)) continue; // #18
const raw = m.sender_id === u.id ? m.recipient_id : m.sender_id;
if (!raw) continue;
// If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries
@@ -798,11 +799,12 @@ route('GET', '/api/messages/conversations', async (req, res) => {
kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null, lastSeen: seen[c.other] || null,
callActive: !!dc, callRoom: dc ? dc.room : null, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'),
last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread,
last_deleted: !!c.last.deleted, // #10: a deleted last message must still read "message deleted", not "No messages yet"
last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message
}; });
// Groups
const groupItems = await Promise.all((await R.conversations.listForUser(u.team_id, u.id)).map(async (g) => {
const last = await R.messages.lastInConversation(g.id);
const last = await R.messages.lastInConversationForUser(g.id, u.id); // #18: last message this user hasn't hidden
const since = await R.conversations.lastReadAt(g.id, u.id);
const members = await R.conversations.members(g.id);
// Group read tick for MY last message: read = every other member has read it, delivered = some
@@ -818,6 +820,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null,
last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at,
last_from_me: last ? last.sender_id === u.id : false, unread: last ? await R.messages.unreadInConversation(g.id, u.id, since) : 0,
last_deleted: !!(last && last.deleted), // #10: deleted last message still reads "message deleted"
last_status: gStatus,
};
}));
@@ -833,9 +836,10 @@ route('GET', '/api/messages/thread', async (req, res) => {
const before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at
const names = await namesFor(u.team_id);
const group = q.get('group');
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: messages this user "deleted for me"
if (group) {
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
const rows = await R.messages.threadByConversation(group, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
const rows = (await R.messages.threadByConversation(group, u.id, 40, before)).filter((m) => !hidden.has(m.id)); // #18 (hidden now excluded in SQL too, so the 40-row page counts only visible messages)
if (!peek && !before) {
await R.conversations.markRead(group, u.id);
const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() };
@@ -854,7 +858,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
const other = await R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread
if (!other) return json(res, 400, { error: 'with or group required' });
if (!await R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' });
const rows = await R.messages.thread(u.team_id, u.id, other, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
const rows = (await R.messages.thread(u.team_id, u.id, other, 40, before)).filter((m) => !hidden.has(m.id)); // #18
if (!peek && !before) { await R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13
const rxBy = groupReactions(await R.reactions.forPair(u.team_id, u.id, other), u.id, names);
return json(res, 200, await Promise.all(rows.map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })));
@@ -964,6 +968,7 @@ route('POST', '/api/calls/dm/start', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' });
const { to } = await readBody(req);
if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
if (await R.blocks.has(to, u.id)) return json(res, 403, { error: 'This user is unavailable.' }); // callee blocked the caller → don't ring
json(res, 200, await CALLS.startDmCall(u, to, u.team_id));
});
@@ -974,15 +979,21 @@ route('POST', '/api/calls/invite', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' });
const { room, userIds } = await readBody(req);
if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' });
const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} }
json(res, 200, { ok: true, invited: ids.length });
const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id) && !(await R.blocks.has(x, u.id))); // skip anyone who blocked the caller
// #15: adding people to a 1:1 call promotes it to a persistent GROUP call (survives leaves + lets an added
// person rejoin after dropping). When that happens promoteDmToGroup's own group-call broadcast already rings
// the invitees in, so we only send the plain call-invite for a call that WASN'T promoted (group/ad-hoc).
try { CALLS.clearLeft(String(room), ids); } catch (_) {} // #New1: an explicit re-invite should ring again, even if they left earlier
let groupId = null;
try { groupId = await CALLS.promoteDmToGroup(String(room), u, ids); } catch (_) {}
if (!groupId) { for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } }
json(res, 200, { ok: true, invited: ids.length, group: groupId || undefined });
});
// 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, androidCall: ANDROID_CALLS, fcm: PUSH.fcmReady() });
});
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
@@ -1028,12 +1039,31 @@ route('POST', '/api/meetings/token', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
const { room } = await readBody(req);
const rm = String(room || '').trim();
const body = await readBody(req);
const rm = String(body.room || '').trim();
if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' });
const token = livekitToken(u.id, u.name || u.email, rm, metadata);
json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email });
// #12 Multi-device: mint the token with identity = the caller's mesh peerId (unique per connection) when it's
// supplied, so the SAME user joining from two devices no longer collides on LiveKit — which allows exactly
// one connection per identity, so with identity=userId the older device was kicked ("audio jumps to whichever
// joined last"). Falls back to the user id when no peerId is passed (e.g. a native OUTGOING token fetched
// before the WebView has joined the mesh; the plugin reconnects with a peerId token once it has one). The
// client got its peerId from `meeting-joined`. Anti-hijack: refuse a peerId that's a DIFFERENT live user's.
let identity = u.id;
const pid = (typeof body.peerId === 'string') ? body.peerId.trim() : '';
if (/^[A-Za-z0-9_-]{4,64}$/.test(pid)) {
let ok = true;
try { const peers = require('./presence').meetingRooms.get(rm); const pr = peers && peers.get(pid); if (pr && pr.uid && pr.uid !== u.id) ok = false; } catch (_) {}
if (ok) identity = pid;
}
// Screen-share publisher (iOS): the WebView already holds the meeting connection under `identity`, and
// LiveKit allows one connection per identity — so the native ReplayKit publisher joins the SAME room under
// a DISTINCT `<identity>-screen` id. The client maps that suffix back onto the sharer's tile. WKWebView has
// no getDisplayMedia, so this native second connection is the only way to screen-share in an SFU meeting.
const screen = body.screen === true || body.screen === 1 || body.screen === '1';
if (screen) identity = identity + '-screen';
const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '', screen });
const token = livekitToken(identity, u.name || u.email, rm, metadata);
json(res, 200, { token, url: LIVEKIT_URL, identity, name: u.name || u.email });
});
// GUEST (no-login) LiveKit token — lets an external person invited by link join a meeting without a
@@ -1041,7 +1071,8 @@ route('POST', '/api/meetings/token', async (req, res) => {
// meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id.
route('POST', '/api/meetings/guest-token', async (req, res) => {
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
const { room, name, identity } = await readBody(req);
const body = await readBody(req);
const { room, name, identity } = body;
const rm = String(room || '').trim();
if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })();
@@ -1061,8 +1092,17 @@ route('POST', '/api/meetings/guest-token', async (req, res) => {
// signaling (meeting-join guestId) — that mapping is how their media attaches to their tile.
const gid = (typeof identity === 'string' && /^guest-[a-z0-9]+$/i.test(identity)) ? identity.slice(0, 64) : ('guest-' + crypto.randomBytes(8).toString('hex'));
const gname = String(name || 'Guest').slice(0, 60);
const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true }));
json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname });
// #12 Multi-device (same as /api/meetings/token): prefer the guest's per-connection mesh peerId as the
// LiveKit identity so two devices don't collide; fall back to the throwaway guest id. Anti-hijack guarded.
let lkid = gid;
const pid = (typeof body.peerId === 'string') ? body.peerId.trim() : '';
if (/^[A-Za-z0-9_-]{4,64}$/.test(pid)) {
let ok = true;
try { const peers = require('./presence').meetingRooms.get(rm); const pr = peers && peers.get(pid); if (pr && pr.uid && pr.uid !== gid) ok = false; } catch (_) {}
if (ok) lkid = pid;
}
const token = livekitToken(lkid, gname, rm, JSON.stringify({ guest: true }));
json(res, 200, { token, url: LIVEKIT_URL, identity: lkid, name: gname });
});
// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
@@ -1074,6 +1114,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);
@@ -1213,6 +1273,13 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
// Invitation notification to each invited participant.
const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } };
for (const pid of invited) { try { CHAT.pushToUser(pid, inviteEvt); } catch (_) {} }
// Background/native push (iOS APNs, Android FCM, web push) so a CLOSED app still gets the invite. The
// CHAT.pushToUser above only reaches an OPEN tab with a live socket — which is exactly why scheduled-
// meeting notices never arrived on iOS (the webview is suspended in the background). Mirror the chat path.
const notifyIds = new Set(invited);
if (groupId) { try { (await R.conversations.members(groupId)).forEach((m) => notifyIds.add(m)); } catch (_) {} }
notifyIds.delete(u.id);
for (const pid of notifyIds) { try { PUSH.sendToUser(pid, { title: (u.name || u.email) + ' scheduled a meeting', body: t + ' · ' + label, kind: 'meeting', id: code, tag: 'meet:' + code }); } catch (_) {} }
// Email invites (#4): the guest join link goes to external invitees, plus any invited Connect users
// who have an email on file. Fire-and-forget — a mail outage never fails scheduling. No-op if SMTP off.
try {
@@ -1267,7 +1334,7 @@ route('GET', '/api/meetings', async (req, res) => {
// Attach recordings/transcripts. A recording is visible to its creator, group members, or people
// who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become
// their own "Past meeting" entry (group calls show the group name).
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, mime: r.mime || '', createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
const canSeeRec = async (r) => {
if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
if (r.created_by === u.id) return true;
@@ -1534,6 +1601,7 @@ route('POST', '/api/messages', async (req, res) => {
const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group';
const pushBody = (u.name || u.email) + ': ' + (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment');
for (const mid of await R.conversations.members(group)) {
if (mid !== u.id && await R.blocks.has(mid, u.id)) continue; // member blocked the sender → deliver nothing to them
try { CHAT.pushToUser(mid, push); } catch (_) {} // includes sender's other tabs
if (mid !== u.id) PUSH.sendToUser(mid, { title: gname, body: pushBody, kind: 'group', id: group, tag: 'group:' + group });
}
@@ -1547,10 +1615,13 @@ route('POST', '/api/messages', async (req, res) => {
await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
try { CHAT.pushToUser(toId, push); } catch (_) {}
// If the recipient has blocked the sender, persist the message but deliver nothing to them (no live
// push, no background notification). The sender's own devices still sync it, so from their side it looks sent.
const blockedByRcpt = (toId !== u.id) && await R.blocks.has(toId, u.id);
if (!blockedByRcpt) try { CHAT.pushToUser(toId, push); } catch (_) {}
if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
if (toId !== u.id) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self, not if blocked.
if (toId !== u.id && !blockedByRcpt) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
json(res, 200, dto);
});
@@ -1600,13 +1671,167 @@ route('POST', '/api/messages/delete', async (req, res) => {
if (!id) return json(res, 400, { error: 'id required' });
const m = await R.messages.byId(id);
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only delete your own messages' });
if (m.sender_id !== u.id && u.role !== 'admin') return json(res, 403, { error: 'you can only delete your own messages' }); // admins can remove reported content (guideline 1.2)
await R.messages.markDeleted(id);
const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null };
if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
json(res, 200, { ok: true });
});
// ── UGC moderation (App Store Review guideline 1.2) ────────────────────────────────────────────────
// Report a message. Stored + surfaced to the workspace admins (who can delete it / act). Internal only.
route('POST', '/api/messages/report', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id, reason } = await readBody(req);
if (!id) return json(res, 400, { error: 'id required' });
const m = await R.messages.byId(id);
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
if (!canSee) return json(res, 403, { error: 'not allowed' });
const snippet = String(m.body || (m.attachment_id ? '[attachment]' : '')).slice(0, 160);
await R.reports.add({ id: A.id(), teamId: u.team_id, messageId: m.id, reporterId: u.id, reportedId: m.sender_id, reason: String(reason || '').slice(0, 200), snippet });
try { for (const aid of await R.users.adminsOf(u.team_id)) { if (aid !== u.id) { try { CHAT.pushToUser(aid, { type: 'report-new' }); } catch (_) {} } } } catch (_) {}
json(res, 200, { ok: true });
});
// Block a user: I stop receiving their messages and calls (one-directional).
route('POST', '/api/users/block', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { userId } = await readBody(req);
const target = await R.users.resolve(userId);
if (!target || target === u.id) return json(res, 400, { error: 'invalid user' });
if (!await R.users.inTenant(target, u.team_id)) return json(res, 404, { error: 'no such user' });
await R.blocks.add(u.id, target, u.team_id);
json(res, 200, { ok: true });
});
route('POST', '/api/users/unblock', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { userId } = await readBody(req);
if (!userId) return json(res, 400, { error: 'userId required' });
await R.blocks.remove(u.id, await R.users.resolve(userId));
json(res, 200, { ok: true });
});
// My block list (ids + names) — powers the "Blocked users" manager and the client-side hide.
route('GET', '/api/users/blocked', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const ids = await R.blocks.listFor(u.id);
const names = await namesFor(u.team_id);
json(res, 200, { ids, users: ids.map((id) => ({ id, name: names[id] || 'Unknown' })) });
});
// Admin: list the workspace's reports + resolve them.
route('GET', '/api/reports', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role !== 'admin') return json(res, 403, { error: 'admins only' });
const names = await namesFor(u.team_id);
const roster = await R.users.listByTenant(u.team_id);
const activeById = {}; roster.forEach((x) => { activeById[x.id] = x.active !== 0; }); // is the reported user still able to log in?
const rows = await R.reports.listForTeam(u.team_id);
json(res, 200, rows.map((r) => ({ id: r.id, messageId: r.message_id, reporter: names[r.reporter_id] || 'Unknown', reported: names[r.reported_id] || 'Unknown', reportedId: r.reported_id, reportedActive: activeById[r.reported_id] !== false, reason: r.reason || '', snippet: r.snippet || '', at: r.created_at, status: r.status })));
});
route('POST', '/api/reports/resolve', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role !== 'admin') return json(res, 403, { error: 'admins only' });
const { id, status } = await readBody(req);
if (!id) return json(res, 400, { error: 'id required' });
const rep = await R.reports.byId(id);
await R.reports.setStatus(id, status === 'open' ? 'open' : 'resolved');
// Close the loop: tell the reporter their report was reviewed (live + background push).
if (rep && status !== 'open' && rep.reporter_id && rep.reporter_id !== u.id) {
try { CHAT.pushToUser(rep.reporter_id, { type: 'report-resolved' }); } catch (_) {}
try { PUSH.sendToUser(rep.reporter_id, { title: 'Report reviewed', body: 'An admin reviewed the message you reported.' }); } catch (_) {}
}
json(res, 200, { ok: true });
});
// #18 Delete-for-me: hide a message from MY view only (any message I can see, mine or not). The row and
// everyone else are untouched. Echoed to my OTHER devices so it disappears there too.
route('POST', '/api/messages/hide', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id } = await readBody(req);
if (!id) return json(res, 400, { error: 'id required' });
const m = await R.messages.byId(id);
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
if (!canSee) return json(res, 403, { error: 'not allowed' });
await R.messages.hideForUser(id, u.id);
try { CHAT.pushToUser(u.id, { type: 'chat-hidden', id, conversation_id: m.conversation_id || null }); } catch (_) {} // my other devices
json(res, 200, { ok: true });
});
// "Delete chat" — SELF-ONLY. Hides the whole conversation from MY view (synced to my other devices); the
// other party keeps their copy entirely. DM via {with:peerId}; group via {group:conversationId} (I stay a
// member — new messages will start a fresh thread). Reuses the per-user "deleted for me" hide mechanism.
route('POST', '/api/messages/clear', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { with: withRaw, group } = await readBody(req);
if (group) {
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
await R.messages.hideConversationForUser(group, u.id);
try { CHAT.pushToUser(u.id, { type: 'chat-cleared', kind: 'group', id: group }); } catch (_) {} // my other devices
return json(res, 200, { ok: true });
}
const peer = await R.users.resolve(withRaw);
if (!peer) return json(res, 400, { error: 'with or group required' });
await R.messages.hideThreadForUser(u.team_id, u.id, peer);
try { CHAT.pushToUser(u.id, { type: 'chat-cleared', kind: 'dm', id: peer }); } catch (_) {} // my other devices
json(res, 200, { ok: true });
});
// #13 Pin / unpin a message for the whole conversation (any participant may pin/unpin). Broadcast so every
// participant's pinned strip updates live.
route('POST', '/api/messages/pin', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id, on } = await readBody(req);
if (!id) return json(res, 400, { error: 'id required' });
const m = await R.messages.byId(id);
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
if (m.deleted) return json(res, 400, { error: 'cannot pin a deleted message' });
const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
if (!canSee) return json(res, 403, { error: 'not allowed' });
const pin = on !== false; // default true
await R.messages.setPinned(id, pin ? now() : null, pin ? u.id : null);
// #13: log every pin/unpin so there's an accountable trail — anyone can unpin anyone's pin, but who did it
// (and, for an unpin, whose pin they removed) is now recorded in the audit log.
try {
const where = m.conversation_id ? ('group ' + m.conversation_id) : ('dm with ' + (m.sender_id === u.id ? m.recipient_id : m.sender_id));
const whose = (!pin && m.pinned_by && m.pinned_by !== u.id) ? (' (originally pinned by ' + m.pinned_by + ')') : '';
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: pin ? 'message.pin' : 'message.unpin', detail: where + ' · message ' + id + whose });
} catch (_) {}
const evt = { type: 'chat-pinned', id, on: pin, by: u.name || u.email, conversation_id: m.conversation_id || null };
if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(m.sender_id, evt); } catch (_) {} }
json(res, 200, { ok: true, pinned: pin });
});
// #13 The pinned messages for a conversation (?with=userId) or group (?group=id), newest pin first.
route('GET', '/api/messages/pinned', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const q = new URLSearchParams(req.url.split('?')[1] || '');
const names = await namesFor(u.team_id);
const group = q.get('group');
let rows;
if (group) {
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
rows = await R.messages.pinnedInConversation(group);
} else {
const other = await R.users.resolve(q.get('with'));
if (!other) return json(res, 400, { error: 'with or group required' });
rows = await R.messages.pinnedInDm(u.team_id, u.id, other);
}
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: don't surface a message you deleted-for-me
json(res, 200, await Promise.all(rows.filter((m) => !hidden.has(m.id)).map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.pinnedBy = names[m.pinned_by] || ''; return d; }))); // #13: who pinned it (shown in the pinned bar)
});
// Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the
// change live to the other side / other tabs (mirrors the delete broadcast).
route('POST', '/api/messages/edit', async (req, res) => {
@@ -1699,6 +1924,18 @@ route('POST', '/api/messages/react', async (req, res) => {
try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {}
try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {}
}
// #3: notify the message OWNER that someone reacted — a native/web push so a CLOSED app is alerted too
// (previously reactions only pushed over the live socket, so a backgrounded owner got nothing). Only when
// the reaction was ADDED (not removed) and by someone other than the owner.
if (added && msg.sender_id && msg.sender_id !== u.id) {
const reactor = u.name || u.email;
if (msg.conversation_id) {
const conv = await R.conversations.byId(msg.conversation_id); const gname = (conv && conv.name) || 'Group';
try { PUSH.sendToUser(msg.sender_id, { title: gname, body: reactor + ' reacted ' + e + ' to your message', kind: 'group', id: msg.conversation_id, tag: 'react:' + messageId, icon: u.avatar_url || undefined }); } catch (_) {}
} else {
try { PUSH.sendToUser(msg.sender_id, { title: reactor, body: 'reacted ' + e + ' to your message', kind: 'dm', id: u.id, tag: 'react:' + messageId, icon: u.avatar_url || undefined }); } catch (_) {}
}
}
json(res, 200, { ok: true, messageId, added, reactions: await reactionsForMessage(messageId, u.id, names) });
});
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env node
// One-time PRODUCTION migration for "BizGaze-only logins".
//
// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login
// change, every user then signs in through BizGaze and is provisioned into the same
// tenant — which restores the admin's "see all sessions" report.
//
// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email
// (i.e. created locally via register/console, not provisioned by a BizGaze login).
//
// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST.
// Dry run : node scripts/migrate-bizgaze-only.js
// Apply : node scripts/migrate-bizgaze-only.js --apply
// Honors DB_PATH (same env var the server uses).
const db = require('../db');
const APPLY = process.argv.includes('--apply');
const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
const ssoEmails = new Set(
db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL")
.all().map((r) => r.e),
);
const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all();
const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase()));
const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase()));
console.log('=== Teams ===');
for (const t of db.prepare('SELECT id,name FROM teams').all()) {
const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c;
console.log(` ${t.id} ${t.name} (${uc} users)`);
}
console.log('\n=== Users ===');
console.log(` total: ${users.length} | BizGaze-provisioned (keep): ${keep.length} | local pre-BizGaze (delete): ${remove.length}`);
console.log('\n KEEP (already BizGaze-provisioned):');
keep.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
console.log('\n DELETE (local / pre-BizGaze):');
remove.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); }
if (!APPLY) {
console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.');
console.log('After deletion, those users sign in via BizGaze and are recreated automatically.');
process.exit(0);
}
const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?');
const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null;
const delUser = db.prepare('DELETE FROM users WHERE id=?');
let deleted = 0;
db.exec('BEGIN');
try {
for (const u of remove) {
delAuth.run(u.id); // clear active sessions (FK) — also logs them out
if (delRefresh) delRefresh.run(u.id);
delUser.run(u.id);
deleted++;
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
console.error('FAILED — rolled back, no changes applied:', e.message);
process.exit(1);
}
console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`);
+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 };
+26 -5
View File
@@ -58,9 +58,9 @@ function finishMeetingJoin(ws, room, peers) {
const hostUserId = roomHost.get(room);
const avatar = ws._meetingAvatar || null;
const isHost = !!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId);
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null })) }));
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null, clientId: p.clientId || null })) }));
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null, clientId: ws._clientId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null, clientId: ws._clientId || null });
noteRoomStat(room, ws, peers.size); // #7: track the high-water participant count for the call log
if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); }
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true }));
@@ -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.
@@ -139,6 +142,7 @@ async function handle(ws, m, req) {
const peerId = A.token(6);
const name = String(m.name || 'Guest').slice(0, 60);
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name;
ws._clientId = (typeof m.clientId === 'string' && m.clientId) ? m.clientId.slice(0, 64) : null; // #12: stable per-device id → dedup a reconnecting device without collapsing a real 2nd device
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
let hostUserId = roomHost.get(room);
if (hostUserId === undefined) { try { const s = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
@@ -369,6 +373,16 @@ async function handle(ws, m, req) {
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
// iOS remote-support over LiveKit: 'rs-livekit' tells the OTHER end this session's media runs over a
// LiveKit room (WKWebView can't getDisplayMedia); 'rs-chat' carries chat since there's no P2P data
// channel in that mode. Relayed between the two ends exactly like offer/answer/transcript.
case 'rs-livekit': case 'rs-chat': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'end-session': {
await endSession(ws.sessionId, m.reason || null);
break;
@@ -403,8 +417,15 @@ async function leaveMeeting(ws) {
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
try { await require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
peers.delete(pid);
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
if (roomToDmCall.has(room)) {
// #New1: remember this user LEFT, so a later socket reconnect doesn't auto-ring them back into a call that's
// still running for the others (replayActiveCalls sends them noRing state instead). Harmless on a call that
// then ends. Only meaningful for a group/promoted call that survives one person leaving.
try { if (leaverId) require('./calls').markLeft(room, leaverId); } catch (_) {}
// 1:1 call: end it for everyone ONLY when fewer than two people would remain. A DM call that had
// extra people ADDED (via "Add people") is effectively a group now — one participant closing their
// app must NOT hang up the call for the rest (#11). We only tear the whole thing down when ≤1 person
// is left (nobody to talk to). With 2+ remaining we fall through to the normal peer-left path below.
if (roomToDmCall.has(room) && peers.size < 2) {
const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean);
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } }
await persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall
+2
View File
@@ -77,6 +77,8 @@ function serveStatic(req, res) {
if (p === '/console' || p === '/dashboard') p = '/dashboard.html';
if (p === '/share') p = '/share.html';
if (p === '/connect') p = '/connect.html';
if (p === '/privacy') p = '/privacy.html'; // App Store Privacy Policy URL (public, no login)
if (p === '/support') p = '/support.html'; // App Store Support URL (public, no login)
const fp = path.join(PUBLIC_DIR, path.normalize(p));
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
// ETag + revalidation: the browser keeps the file cached and we answer repeat loads with a
+12 -8
View File
@@ -8,12 +8,14 @@
//
// Run: node test/db-smoke.js (uses a throwaway temp DB; DB_BACKEND env selects sqlite|pg)
const fs = require('fs');
const os = require('os');
const path = require('path');
const DB = path.join(os.tmpdir(), 'bzc-smoke.db');
process.env.DB_PATH = DB;
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }
// SQLite was retired 2026-08-12 — this suite runs against Postgres now. Point DATABASE_URL at a DISPOSABLE
// test database (NEVER production — the suite creates + mutates rows), e.g.:
// DATABASE_URL=postgres://bizgaze:PW@localhost:5432/bizgaze_test node test/db-smoke.js
process.env.DB_BACKEND = process.env.DB_BACKEND || 'pg';
if (!process.env.DATABASE_URL) {
console.log('SKIP db-smoke: set DATABASE_URL to a disposable Postgres test DB (SQLite retired 2026-08-12).');
process.exit(0);
}
const PORT = 8097;
process.env.PORT = PORT;
@@ -39,8 +41,10 @@ async function get(p, cookie) {
}
(async () => {
await wait(300);
console.log('DB smoke tests (backend=' + (process.env.DB_BACKEND || 'sqlite') + '):');
// Wait for the server to actually be LISTENING — a cold Postgres boot (connect + apply the full schema)
// takes a few seconds, well past the fixed 300ms that was fine for SQLite's instant in-memory init.
for (let i = 0; i < 150; i++) { try { await fetch(BASE + '/'); break; } catch (_) { await wait(200); } }
console.log('DB smoke tests (backend=' + process.env.DB_BACKEND + '):');
// Auth
const reg = await post('/api/register', { email: 'admin@smoke.test', password: 'supersecret', teamName: 'Smoke Co' });
+11 -7
View File
@@ -5,12 +5,14 @@
// (Login currently marks the session MFA-passed directly, so there is no separate
// TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.)
const fs = require('fs');
const os = require('os');
const path = require('path');
const DB = path.join(os.tmpdir(), 'ra-e2e.db');
process.env.DB_PATH = DB;
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }
// SQLite was retired 2026-08-12 — the backend is Postgres now. Point DATABASE_URL at a DISPOSABLE test DB
// (never production — this creates + mutates rows), e.g.:
// DATABASE_URL=postgres://bizgaze:PW@localhost:5432/bizgaze_test node test/e2e.js
process.env.DB_BACKEND = process.env.DB_BACKEND || 'pg';
if (!process.env.DATABASE_URL) {
console.log('SKIP e2e: set DATABASE_URL to a disposable Postgres test DB (SQLite retired 2026-08-12).');
process.exit(0);
}
const PORT = 8099;
process.env.PORT = PORT;
@@ -63,7 +65,9 @@ function nextMsg(ws, type, timeout = 3000) {
}
(async () => {
await wait(300); // let server bind
// Wait for the server to actually be LISTENING — a cold Postgres boot (connect + apply the full schema)
// takes a few seconds, past the fixed 300ms that was fine for SQLite's instant in-memory init.
for (let i = 0; i < 150; i++) { try { await fetch(BASE + '/'); break; } catch (_) { await wait(200); } }
console.log('E2E backend tests:');
// Local receiver to capture outbound webhook deliveries.