Compare commits

..

78 Commits

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:56:13 +05:30
Sravan 7f54182186 Reapply "Capacitor 8 — Step B: migrate iOS from CocoaPods to Swift Package Manager"
This reverts commit 166bea4314.
2026-08-03 15:52:46 +05:30
65 changed files with 3428 additions and 892 deletions
+4
View File
@@ -50,3 +50,7 @@ Thumbs.db
# Editor # Editor
.vscode/ .vscode/
.idea/ .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. registered BizGaze users.
## Tech stack (intentionally minimal — keep it this way) ## Tech stack (intentionally minimal — keep it this way)
- **Node.js >= 22.5**, single npm dependency: `ws` (WebSocket). - **Node.js >= 22.5**. npm deps: `ws`, `pg`, `redis`, `web-push` (+ optional `nodemailer`).
- **Built-in `node:sqlite`** (no native modules). DB file: `server/data.db`. - **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). - **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 - **No build step, no framework.** Each page is a single self-contained HTML file
with inline `<style>` and `<script>`. Do not introduce React/bundlers. 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 routes.js # HTTP JSON API (/api/*, /sso) -> { "METHOD /path": handler } map
static.js # static file serving + authenticated recording/transcript downloads static.js # static file serving + authenticated recording/transcript downloads
signaling.js # WebSocket signaling (consent + SDP/ICE relay) 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) 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 auth.js # scrypt hashing, token/id generation, TOTP helpers
package.json # { "dependencies": { "ws": "^8.18" }, engines node>=22.5 } package.json # { "dependencies": { "ws": "^8.18" }, engines node>=22.5 }
test/e2e.js # 21-check backend e2e (register->login->session->signaling->audit) test/e2e.js # 21-check backend e2e (register->login->session->signaling->audit)
@@ -48,11 +52,16 @@ server/
transcripts/ # saved transcripts (.txt) [created at runtime] transcripts/ # saved transcripts (.txt) [created at runtime]
``` ```
Architecture/roadmap detail lives in `ARCHITECTURE.md`. Backend SQL must go through 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 ## 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/) # HTTP on :8090 (HTTPS on :8443 only if cert.pem + key.pem exist in server/)
# Env: ALLOW_REGISTRATION=1 opens the first-team registration # Env: ALLOW_REGISTRATION=1 opens the first-team registration
``` ```
+4 -3
View File
@@ -1,5 +1,6 @@
# BizGaze Support — server image # 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 FROM node:24-alpine
# ffmpeg: server-side video poster thumbnails (see static.js /thumbs/<id>). Small on 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 # App source
COPY server/ ./ 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 PORT=8090
ENV DB_PATH=/data/data.db
EXPOSE 8090 EXPOSE 8090
CMD ["node", "server.js"] CMD ["node", "server.js"]
+144 -16
View File
@@ -27,7 +27,7 @@ workflows:
- ios_signing # ← Codemagic variable group holding CERTIFICATE_PRIVATE_KEY (secure). See below. - ios_signing # ← Codemagic variable group holding CERTIFICATE_PRIVATE_KEY (secure). See below.
vars: vars:
BUNDLE_ID: "com.bizgaze.connect" BUNDLE_ID: "com.bizgaze.connect"
XCODE_WORKSPACE: "mobile/ios/App/App.xcworkspace" XCODE_PROJECT: "mobile/ios/App/App.xcodeproj"
XCODE_SCHEME: "App" XCODE_SCHEME: "App"
node: 22 node: 22
xcode: latest xcode: latest
@@ -44,15 +44,33 @@ workflows:
script: | script: |
cd mobile cd mobile
# `cap add ios` scaffolds ios/App; safe to re-run — it no-ops if it already exists. # `cap add ios` scaffolds ios/App; safe to re-run — it no-ops if it already exists.
# Capacitor 8 DEFAULTS to Swift Package Manager, but our whole iOS pipeline (LiveKit Podfile pins, # Capacitor 8 Swift Package Manager: generate an SPM project (no Podfile). LiveKit is pulled via the
# share-extension injection, AppDelegate patches) is CocoaPods-based — so force CocoaPods. # 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 Cocoapods; fi if [ ! -d "ios" ]; then npx cap add ios --packagemanager SPM; fi
npx cap sync ios npx cap sync ios
# Fail LOUDLY if no Podfile was generated (flag ignored / SPM project) rather than fail later with a # Sanity: the Xcode project must exist. Print the iOS dir so the log shows the SPM layout (CapApp-SPM
# confusing error — the CocoaPods pipeline downstream needs ios/App/Podfile to exist. # present, NO Podfile) — if a Podfile appears, the SPM flag didn't take and we'd need to fix it.
test -f ios/App/Podfile || { echo "ERROR: no ios/App/Podfile — 'cap add' did not use CocoaPods"; exit 1; } 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). # 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 - name: Patch Info.plist (App-Review privacy strings) + bundle id
script: | script: |
@@ -66,6 +84,15 @@ workflows:
# generates already contains the new target. # generates already contains the new target.
ruby mobile/scripts/add-share-extension.rb 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 - name: Set up code signing
script: | script: |
# Create the distribution certificate + provisioning profile from the ASC API key and add the # Create the distribution certificate + provisioning profile from the ASC API key and add the
@@ -93,15 +120,17 @@ workflows:
--type IOS_APP_STORE \ --type IOS_APP_STORE \
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \ --certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
--create --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 keychain add-certificates
- name: Install CocoaPods # (No "Install CocoaPods" step under SPM — there is no Podfile. Xcode resolves the Swift packages
script: | # (Capacitor, plugins, LiveKit + its WebRTC/UniFFI/SwiftProtobuf) during the archive below.)
cd mobile/ios/App
# --repo-update refreshes the cached spec repo so it knows LiveKit 2.15.3's transitive deps
# (LiveKitUniFFI 0.0.6 + LiveKitWebRTC 144.7559.11) — they ARE on the trunk but the build box's
# cached repo was stale ("Unable to find a specification for LiveKitUniFFI (= 0.0.6)").
pod install --repo-update
- name: Build the signed IPA - name: Build the signed IPA
script: | script: |
@@ -112,7 +141,7 @@ workflows:
# `xcode-project build-ipa` prints a PRETTIFIED summary and swallows the raw xcodebuild "error:" # `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 # 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. # 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 =======================" 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)" 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 "=================================================================" echo "================================================================="
@@ -136,3 +165,102 @@ workflows:
# submit_to_app_store: false # submit_to_app_store: false
# No email recipients here on purpose — build status is watched on the Codemagic dashboard. Add # 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. # 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.
+4 -5
View File
@@ -10,7 +10,6 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- PORT=8090 - PORT=8090
- DB_PATH=/data/data.db
# Desktop installers + auto-update feed live on the persistent volume so uploaded # 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). # builds survive image rebuilds (a plain image path would be wiped on every deploy).
- DOWNLOADS_DIR=/data/downloads - DOWNLOADS_DIR=/data/downloads
@@ -31,16 +30,16 @@ services:
- path: .env - path: .env
required: false required: false
volumes: volumes:
- bizgaze_support_data:/data # persists data.db across rebuilds - bizgaze_support_data:/data # persists uploads / recordings / transcripts / downloads across rebuilds
networks: networks:
- npm - npm
# Wait for Postgres to be healthy before starting. Only matters when DB_BACKEND=pg (else the app uses # The DB is Postgres (SQLite retired 2026-08-12), so wait for it to be healthy before the app starts —
# the local SQLite file and ignores pg), but it's harmless on SQLite — pg comes up in a second or two. # otherwise the first queries race the DB coming up.
depends_on: depends_on:
bizgazepg: bizgazepg:
condition: service_healthy condition: service_healthy
# PostgreSQL — the app's data store when DB_BACKEND=pg (default is the local SQLite file). Distinct # 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 # 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. # network; the app reaches it as `bizgaze-postgres`. Data on its own named volume.
bizgazepg: bizgazepg:
+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"
}
+19
View File
@@ -125,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`): Also enabled by this change (main app Info.plist, done automatically by `ios-patch.sh`):
- `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` → the **Biz Connect** folder in Files. - `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` → the **Biz Connect** folder in Files.
- `CFBundleURLTypes` scheme **`bizconnect`** → lets the extension bounce back into the app after staging. - `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: 316 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": { "SafeArea": {
"detectViewportFitCoverChanges": true, "detectViewportFitCoverChanges": true,
"initialViewportFitCover": true, "initialViewportFitCover": true,
"offsetForKeyboardInsetBug": false "offsetForKeyboardInsetBug": true,
"statusBarStyle": "DARK",
"navigationBarStyle": "DARK"
},
"SystemBars": {
"insetsHandling": "disable"
}, },
"Keyboard": { "Keyboard": {
"resize": "none" "resize": "none"
@@ -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 {}
+1
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"audio-route": "file:plugins/audio-route", "audio-route": "file:plugins/audio-route",
"file-opener": "file:plugins/file-opener",
"media-library": "file:plugins/media-library", "media-library": "file:plugins/media-library",
"native-call": "file:plugins/native-call", "native-call": "file:plugins/native-call",
"share-inbox": "file:plugins/share-inbox", "share-inbox": "file:plugins/share-inbox",
+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")
]
)
+2 -1
View File
@@ -10,7 +10,8 @@
"files": [ "files": [
"dist/", "dist/",
"ios/", "ios/",
"AudioRoute.podspec" "AudioRoute.podspec",
"Package.swift"
], ],
"capacitor": { "capacitor": {
"ios": { "ios": {
@@ -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"
}
}
@@ -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")
]
)
+2 -1
View File
@@ -10,7 +10,8 @@
"files": [ "files": [
"dist/", "dist/",
"ios/", "ios/",
"MediaLibrary.podspec" "MediaLibrary.podspec",
"Package.swift"
], ],
"capacitor": { "capacitor": {
"ios": { "ios": {
+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")
]
)
@@ -1,9 +1,12 @@
import Foundation import Foundation
import UIKit
import WebKit
import Capacitor import Capacitor
import PushKit import PushKit
import CallKit import CallKit
import AVFoundation import AVFoundation
import LiveKitClient // the CocoaPod is 'LiveKitClient' (no module_name), so the Swift module is LiveKitClient NOT LiveKit 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. // Native calling for Biz Connect (iOS). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall.
// //
@@ -18,14 +21,27 @@ import LiveKitClient // the CocoaPod is 'LiveKitClient' (no module_name), so the
// the token from the WebView via reportOutgoingCall(). The WebView is UI only for native calls (it must // 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). // NOT also join the room LiveKit allows one connection per identity).
@objc(NativeCallPlugin) @objc(NativeCallPlugin)
public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate { public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate, BroadcastManagerDelegate {
public let identifier = "NativeCallPlugin" public let identifier = "NativeCallPlugin"
public let jsName = "NativeCall" public let jsName = "NativeCall"
public let pluginMethods: [CAPPluginMethod] = [ public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "reconnectRoom", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setMuted", 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) CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
] ]
@@ -39,6 +55,17 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
private var endedCalls = Set<UUID>() private var endedCalls = Set<UUID>()
private var room: Room? private var room: Room?
private var activeUUID: UUID? 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() { override public func load() {
let config = CXProviderConfiguration(localizedName: "Biz Connect") let config = CXProviderConfiguration(localizedName: "Biz Connect")
@@ -60,6 +87,41 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
// engine OFF; we configure the session and enable the engine ONLY in didActivate. // engine OFF; we configure the session and enable the engine ONLY in didActivate.
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
try? AudioManager.shared.setEngineAvailability(.none) 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 // MARK: - LiveKit media
@@ -70,7 +132,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
// engine in didActivate can block on first use determining it up front avoids that. // engine in didActivate can block on first use determining it up front avoids that.
AVAudioSession.sharedInstance().requestRecordPermission { _ in } AVAudioSession.sharedInstance().requestRecordPermission { _ in }
let old = room let old = room
let r = 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 room = r
Task { [weak self] in Task { [weak self] in
await old?.disconnect() // drop any previous/stale connection so we never leave DUPLICATE participants await old?.disconnect() // drop any previous/stale connection so we never leave DUPLICATE participants
@@ -90,7 +155,103 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
private func disconnectRoom() { private func disconnectRoom() {
let r = room let r = room
room = nil room = nil
transcriber.stop() // #5: end any live transcription with the call
Task { await r?.disconnect() } 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 // Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected
@@ -157,6 +318,22 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
call.resolve() 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) { @objc func setMuted(_ call: CAPPluginCall) {
let muted = call.getBool("muted") ?? false 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 // Drive mute THROUGH CallKit so the system call screen and the in-app meeting UI stay in sync (the
@@ -171,6 +348,169 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
call.resolve() 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. // End the CallKit call (remote hung up / user ended from the web UI). No callUUID -> end all.
@objc func endCall(_ call: CAPPluginCall) { @objc func endCall(_ call: CAPPluginCall) {
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) { if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) {
@@ -292,6 +632,8 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
Task { [weak self] in Task { [weak self] in
try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted) try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted)
self?.preferSpeaker() // toggling the mic can flip the route back to the earpiece re-assert speaker 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]) notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted])
action.fulfill() action.fulfill()
@@ -319,3 +661,169 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
notifyListeners("audioDeactivated", data: ["ok": true]) 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)
}
}
+2 -1
View File
@@ -10,7 +10,8 @@
"files": [ "files": [
"dist/", "dist/",
"ios/", "ios/",
"NativeCall.podspec" "NativeCall.podspec",
"Package.swift"
], ],
"capacitor": { "capacitor": {
"ios": { "ios": {
+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")
]
)
+2 -1
View File
@@ -10,7 +10,8 @@
"files": [ "files": [
"dist/", "dist/",
"ios/", "ios/",
"ShareInbox.podspec" "ShareInbox.podspec",
"Package.swift"
], ],
"capacitor": { "capacitor": {
"ios": { "ios": {
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}"
+43
View File
@@ -0,0 +1,43 @@
// 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');
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);
+15 -40
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 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 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 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. # Human-readable display name on the home screen.
set_str CFBundleDisplayName "Biz Connect" set_str CFBundleDisplayName "Biz Connect"
@@ -91,6 +92,14 @@ fi
"$PB" -c "Add :aps-environment string production" "$ENT" 2>/dev/null || "$PB" -c "Set :aps-environment production" "$ENT" "$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" 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 # 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. # Connect from asking the "export compliance" question on every single build/TestFlight upload.
"$PB" -c "Add :ITSAppUsesNonExemptEncryption bool false" "$PLIST" 2>/dev/null \ "$PB" -c "Add :ITSAppUsesNonExemptEncryption bool false" "$PLIST" 2>/dev/null \
@@ -123,47 +132,13 @@ if [ -f "$AD" ]; then
node "$(dirname "$0")/inject-push.js" "$AD" || echo " (push forwarding patch skipped — non-fatal)" node "$(dirname "$0")/inject-push.js" "$AD" || echo " (push forwarding patch skipped — non-fatal)"
fi fi
# ── Pin LiveKit to 2.15.3 via its Git tag (stay on CocoaPods; no SPM migration) ───────────────────── # ── LiveKit under SPM (Capacitor 8) ─────────────────────────────────────────────────────────────────
# The CallKit audio-session coordination API (AudioManager.audioSession / setEngineAvailability) exists only # LiveKit is now a proper Swift Package Manager dependency declared in the native-call plugin's Package.swift
# in LiveKit 2.1+, but the LiveKitClient CocoaPod PUBLISHED to trunk caps at 2.0.18 (2.1+ is SPM-only). The # (github.com/livekit/client-sdk-swift, exact 2.15.3) — SPM resolves it + its WebRTC/UniFFI/SwiftProtobuf
# repo still ships a VALID podspec at tag 2.15.3, and its deps (LiveKitWebRTC 144.7559.11 / LiveKitUniFFI # sub-packages at build time. So there is NO Podfile to patch here anymore (the old CocoaPods git-tag pin
# 0.0.6 / SwiftProtobuf) ARE on trunk — so we point the Podfile straight at the git tag. The NativeCall # hack is gone).
# podspec's `LiveKitClient ~> 2.0` is satisfied by 2.15.3 (2.15.3 ∈ [2.0, 3.0)). Idempotent (grep guard).
PODFILE="mobile/ios/App/Podfile"
if [ -f "$PODFILE" ]; then
if grep -q "client-sdk-swift.git" "$PODFILE"; then
echo "Podfile: LiveKitClient git pin already present"
else
ruby -e '
p = "mobile/ios/App/Podfile"
s = File.read(p)
# LiveKit 2.15.3 pulls two binary deps that are SPM-only (NOT on the CocoaPods CDN), so we pin each to
# its own repo podspec (neither has further deps):
# * LiveKitUniFFI 0.0.6 — podspec has a :git source + prepare_command that downloads its xcframework, so :git works.
# * LiveKitWebRTC 144.7559.11 — podspec has an :http source (release-zip), NOT in the git tree, so we
# point at the podspec URL with :podspec (using :git would clone a repo with no xcframework and fail).
# SwiftProtobuf (the only other dep) resolves from the CDN normally.
pin = " pod \x27LiveKitClient\x27, :git => \x27https://github.com/livekit/client-sdk-swift.git\x27, :tag => \x272.15.3\x27\n" +
" pod \x27LiveKitUniFFI\x27, :git => \x27https://github.com/livekit/livekit-uniffi-xcframework.git\x27, :tag => \x270.0.6\x27\n" +
" pod \x27LiveKitWebRTC\x27, :podspec => \x27https://raw.githubusercontent.com/livekit/webrtc-xcframework/144.7559.11/LiveKitWebRTC.podspec\x27\n"
if s =~ /target ["\x27]App["\x27] do\n/
s = s.sub(/target ["\x27]App["\x27] do\n/) { |m| m + pin }
File.write(p, s)
puts "Podfile: pinned LiveKitClient 2.15.3 + LiveKitUniFFI 0.0.6 + LiveKitWebRTC 144.7559.11"
else
STDERR.puts "WARN: could not find \"target App do\" in Podfile — LiveKit pin NOT applied"
exit 1
end
'
fi
grep -n "LiveKitClient" "$PODFILE" || true
# `npx cap sync` already ran `pod install` with the PRE-pin Podfile, leaving a Podfile.lock that pins
# LiveKitClient = 2.0.18 — which conflicts with the git-tag source we just injected ("could not find
# compatible versions … In snapshot (Podfile.lock): LiveKitClient (= 2.0.18)"). Drop the lock so the
# later "Install CocoaPods" step re-resolves cleanly against tag 2.15.3.
rm -f "mobile/ios/App/Podfile.lock" && echo "Removed stale Podfile.lock (cap-sync pinned 2.0.18)"
fi
echo "Info.plist patched:" echo "Info.plist patched:"
"$PB" -c "Print :NSCameraUsageDescription" "$PLIST" "$PB" -c "Print :NSCameraUsageDescription" "$PLIST"
"$PB" -c "Print :NSMicrophoneUsageDescription" "$PLIST" "$PB" -c "Print :NSMicrophoneUsageDescription" "$PLIST"
"$PB" -c "Print :NSSpeechRecognitionUsageDescription" "$PLIST"
+64 -9
View File
@@ -32,21 +32,25 @@ async function meetingContext(room) {
async function finalizeTranscript(room, onlyUserId) { async function finalizeTranscript(room, onlyUserId) {
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; } const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
const buf = transcriptBuffers.get(room) || []; 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) { if (ids.length && buf.length) {
const ctx = await meetingContext(room); 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 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'; const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
for (const uid of ids) { for (const uid of ids) {
let user = null; try { user = await R.users.byId(uid); } catch (_) {} 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'; const id = A.id(); const file = 'm_' + id + '.txt';
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; } try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
// groupId null → private to its creator (see canSeeRec / /mrec auth). // 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 }); 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 if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
} }
@@ -67,7 +71,7 @@ async function startGroupCall(group, teamId, user) {
if (existing) return { room: existing.room, uuid: existing.uuid, 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)); let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map()); meetingRooms.set(room, new Map());
const call = { room, uuid: crypto.randomUUID(), 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. // 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 (_) {} 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 groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
@@ -103,7 +107,7 @@ async function startDmCall(me, otherId, teamId) {
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map()); meetingRooms.set(room, new Map());
const byName = me.name || me.email; const byName = me.name || me.email;
const call = { room, uuid: crypto.randomUUID(), 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. // 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 (_) {} 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 dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
@@ -192,7 +196,9 @@ async function replayActiveCalls(userId, ws) {
for (const [, call] of dmCalls) { for (const [, call] of dmCalls) {
if (call.answered) continue; if (call.answered) continue;
if (call.users.includes(userId) && call.startedBy !== userId) { if (call.users.includes(userId) && call.startedBy !== userId) {
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {} // #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) { for (const [group, call] of groupCalls) {
@@ -200,11 +206,24 @@ async function replayActiveCalls(userId, ws) {
let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {} let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
if (!member) continue; if (!member) continue;
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {} 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 (_) {} } 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. // Called from signaling when any mesh room empties.
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); } async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
@@ -235,4 +254,40 @@ async function declineDmCall(room, byUser) {
return { ok: true }; return { ok: true };
} }
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, 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 };
+6 -1
View File
@@ -70,9 +70,14 @@ async function broadcastPresence(userId) {
if (!userId) return; if (!userId) return;
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a // 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). // bare "Offline" until the next sidebar reload (#2).
const online = isOnline(userId);
let lastSeen = null; let lastSeen = null;
try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {} try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: await effectiveStatus(userId), lastSeen }); // #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 deliverPresenceLocal(userId, payload); // this instance
pubsub.publish('presence', { userId, payload }); // other instances (no-op on memory) pubsub.publish('presence', { userId, payload }); // other instances (no-op on memory)
} }
+4 -1
View File
@@ -70,6 +70,9 @@ module.exports = {
TRANS_DIR, TRANS_DIR,
UPLOADS_DIR, UPLOADS_DIR,
DOWNLOADS_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) 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}, // PostgreSQL backend for the async DB adapter — the ONLY backend (SQLite retired 2026-08-12). Implements
// exec(sql), tx(fn), init() so repos and app code are engine-agnostic. Selected by DB_BACKEND=pg; // prepare(sql).{get,all,run}, exec(sql), tx(fn), init() so repos/app code stay engine-agnostic (the facade
// connection string from DATABASE_URL. // in dbx.js keeps the door open for future backends). Connection string from DATABASE_URL.
const { Pool, types } = require('pg'); const { Pool, types } = require('pg');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+43 -1
View File
@@ -129,7 +129,9 @@ CREATE TABLE IF NOT EXISTS messages (
msg_type TEXT, msg_type TEXT,
deleted SMALLINT NOT NULL DEFAULT 0, deleted SMALLINT NOT NULL DEFAULT 0,
edited_at BIGINT, 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_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); 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 -- 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). -- 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); 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 ( CREATE TABLE IF NOT EXISTS message_reactions (
message_id TEXT NOT NULL, message_id TEXT NOT NULL,
@@ -146,6 +163,31 @@ CREATE TABLE IF NOT EXISTS message_reactions (
PRIMARY KEY (message_id, user_id, emoji) 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 ( CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
team_id TEXT NOT NULL, 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 // Async DB adapter facade. Production runs PostgreSQL. SQLite was RETIRED on 2026-08-12 so there is exactly
// cutover. Every backend implements the same async interface — prepare(sql).{get,all,run}, exec(sql), // ONE schema source of truth (db/schema.pg.sql) — no more dual-maintenance drift between a SQLite migration
// tx(fn), init() — so repos and app code are engine-agnostic. Swapping engines is one backend file, no // list and the PG schema (that gap once made a column land on SQLite only and 500'd every read on prod).
// 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'; // 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); module.exports = require('./db/' + name);
+248
View File
@@ -8,6 +8,8 @@
"name": "bizgaze-support-server", "name": "bizgaze-support-server",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"pg": "^8.13.1",
"redis": "^4.7.0",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
@@ -18,6 +20,65 @@
"nodemailer": "^6.9.14" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -51,6 +112,15 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -77,6 +147,15 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/http_ece": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
@@ -157,6 +236,151 @@
"node": ">=6.0.0" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -183,6 +407,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/web-push": {
"version": "3.6.7", "version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
@@ -220,6 +453,21 @@
"optional": true "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"
} }
} }
} }
+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'), agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus'); topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
let ws,pc,inputChannel,chatChannel,sessionId,me=null; 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'){ async function api(path,body,method='POST'){
const opt={method,headers:{'Content-Type':'application/json'}}; const opt={method,headers:{'Content-Type':'application/json'}};
@@ -191,6 +211,8 @@ function connectWS(){
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans); const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break; 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 '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 '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-denied': renderEnded('The customer declined the request.'); break;
case 'session-ended': { case 'session-ended': {
@@ -213,6 +235,7 @@ function renderWaiting(){
function renderEnded(msg){ function renderEnded(msg){
bzcSession(false); bzcSession(false);
try{ if(lkRoom){ lkRoom.disconnect(); lkRoom=null; } }catch(_){} // tear down the LiveKit view (symmetric disconnect)
try{ stopRecording(); }catch(_){} try{ stopRecording(); }catch(_){}
removeSessionUI(); removeSessionUI();
document.body.classList.remove('has-bar'); 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 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(_){}} 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(_){} 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();});} function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
async function setupPeer(){ async function setupPeer(){
+1055 -140
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"/>', 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"/>', 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"/>', 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"/>', 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"/>', plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>', 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"/>', 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"/>', 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"/>', 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"/>', 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>', 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"/>', 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"/>', 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"/>', 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"/>', 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.ICON = P;
window.ic = function (name, size) { 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'; 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(_){} 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||''); 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(_){} 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;} 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]));} 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 '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 '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 '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 '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; 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 // 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. // after a server round-trip. getDisplayMedia is called first to keep the gesture.
async function beginCapture(){ 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'}); } try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ return false; } catch(err){ return false; }
// Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the // 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; return true;
} }
async function startStreaming(){ 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 the Allow tap already captured the screen (mobile path), reuse it.
if(!localStream){ if(!localStream){
await ensureIce(); await ensureIce();
@@ -320,6 +338,7 @@ function recNotice(on){
} else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); } } else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
} }
function endShareSession(msgText){ 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 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(_){} sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
removeSessionUI(); removeSessionUI();
@@ -330,7 +349,7 @@ function endShareSession(msgText){
var card=document.querySelector('.panelside .card'); 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>'; } 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; 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>'; 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 rcb=_btn('rcBtn',I('monitor'),'Remote control is OFF','#6b7280');
const chat=_btn('chatBtn',I('chat'),'Chat','#475569'); const chat=_btn('chatBtn',I('chat'),'Chat','#475569');
const end=_btn('endBtn2',I('callEnd'),'End','#dc2626'); 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); document.body.appendChild(bar);
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); }; if(!NATIVE_IOS){ rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); }; updateRcBtn(); }
updateRcBtn();
makeBarDraggable(bar,'bzc_sharebar_pos'); // new #4: let the customer move the bar off their content 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';}; 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()=>{ 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 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(_){}} 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(_){} 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 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]));} 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>
+13 -2
View File
@@ -30,7 +30,14 @@ if (webpush && PUBLIC && PRIVATE) {
// ---------------- FCM (Android), HTTP v1 ---------------- // ---------------- FCM (Android), HTTP v1 ----------------
let fcmSA = null; // { client_email, private_key, project_id } let fcmSA = null; // { client_email, private_key, project_id }
(function loadFcm() { (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; if (!raw) return;
try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); } try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); }
catch (e) { console.warn('[push] FCM service account unreadable:', e.message); } catch (e) { console.warn('[push] FCM service account unreadable:', e.message); }
@@ -179,6 +186,10 @@ console.log(enabled.length ? '[push] enabled: ' + enabled.join(', ') : '[push] d
function isEnabled() { return webReady; } // Web Push specifically (drives /api/push/vapid) function isEnabled() { return webReady; } // Web Push specifically (drives /api/push/vapid)
function publicKey() { return webReady ? PUBLIC : ''; } 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 // Fire-and-forget to every channel the user has: Web Push subscriptions + native device
// tokens. Dead endpoints/tokens are pruned. Never throws. // tokens. Dead endpoints/tokens are pruned. Never throws.
@@ -207,4 +218,4 @@ async function sendToUser(userId, payload) {
} }
} }
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel }; 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. // group members, and invited participants. Runs on a 60s tick; marks each meeting reminded.
const R = require('./repos'); const R = require('./repos');
const CHAT = require('./chat'); const CHAT = require('./chat');
const PUSH = require('./push'); // native/web background push so a CLOSED app still gets the reminder
async function tick() { async function tick() {
try { try {
@@ -13,7 +14,8 @@ async function tick() {
invited.forEach((id) => recipients.add(id)); invited.forEach((id) => recipients.add(id));
if (s.group_id) { try { (await R.conversations.members(s.group_id)).forEach((m) => recipients.add(m)); } catch (_) {} } 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 } }; 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); await R.scheduledMeetings.markReminded(s.id);
} }
} catch (_) { /* never let the timer die */ } } 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), 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), setBizgazeId: (id, bizId) => db.prepare('UPDATE users SET bizgaze_user_id=? WHERE id=?').run(bizId != null ? String(bizId) : null, id),
listByTenant: (tenantId) => 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) => inTenant: (id, tenantId) =>
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId), db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
create: async ({ tenantId, email, hash, salt, role, name, mfaSecret }) => { 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), 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), 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), 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), 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), 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), 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 (?,?,?,?,?)') 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), .run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl),
markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token), 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), 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), 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), 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). // 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), 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. // 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), 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), 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". // 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 // 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. // 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) => { thread: (teamId, a, b, limit = 500, before = null) => {
const cond = before != null ? ' AND created_at < ?' : ''; 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 ( return db.prepare(`SELECT * FROM (
SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL 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 ? ORDER BY created_at DESC LIMIT ?
) t ORDER BY created_at ASC`).all(...args); ) 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 ?') 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), .all(teamId, userId, userId, limit),
// Group conversation helpers. // Group conversation helpers.
threadByConversation: (conversationId, limit = 500, before = null) => { threadByConversation: (conversationId, userId, limit = 500, before = null) => {
const cond = before != null ? ' AND created_at < ?' : ''; 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 ( 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); ) t ORDER BY created_at ASC`).all(...args);
}, },
searchConversation: (conversationId, like, limit = 300) => 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), 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 };
+251 -34
View File
@@ -11,7 +11,7 @@ const PUSH = require('./push');
const MSG_MAX = 4000; const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } }; const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__'; 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; } 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 // 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. // plain .filter() can't await). Sequential so per-item DB order is deterministic.
@@ -267,7 +267,7 @@ route('POST', '/api/login', async (req, res) => {
} }
const tok = A.token(); 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 }); await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`); 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' }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
@@ -359,6 +359,17 @@ route('GET', '/api/ice', async (req, res) => {
route('GET', '/api/me', async (req, res) => { route('GET', '/api/me', async (req, res) => {
const u = await currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); 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' }); 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). // Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
@@ -412,18 +423,6 @@ route('POST', '/api/devices/remove', async (req, res) => {
json(res, 200, { ok: true }); json(res, 200, { ok: true });
}); });
// --- Push diagnostics (temporary): the native app reports each step of push setup here so we can see
// WHERE iOS registration fails without a Mac/device console. Best-effort; logs and returns 200. ---
route('POST', '/api/push-debug', async (req, res) => {
try {
const b = await readBody(req);
let uid = 'anon'; try { const u = await currentUser(req); if (u) uid = u.id; } catch (_) {}
const line = (typeof b === 'object' ? JSON.stringify(b) : String(b)).slice(0, 800);
console.log('[push-debug] user=' + uid + ' ' + line);
} catch (_) {}
json(res, 200, { ok: true });
});
// --- App install telemetry: records each install and, once the user signs in, who's using it. --- // --- App install telemetry: records each install and, once the user signs in, who's using it. ---
route('POST', '/api/telemetry/install', async (req, res) => { route('POST', '/api/telemetry/install', async (req, res) => {
const { installId, platform, appVersion, os } = await readBody(req); const { installId, platform, appVersion, os } = await readBody(req);
@@ -781,9 +780,11 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const favs = new Set(await R.favorites.forUser(u.id)); const favs = new Set(await R.favorites.forUser(u.id));
const inCall = new Set(); 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); } } 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 // DMs
const byOther = new Map(); const byOther = new Map();
for (const m of await R.messages.recentFor(u.team_id, u.id)) { 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; const raw = m.sender_id === u.id ? m.recipient_id : m.sender_id;
if (!raw) continue; if (!raw) continue;
// If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries // 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, 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'), 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_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 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 // Groups
const groupItems = await Promise.all((await R.conversations.listForUser(u.team_id, u.id)).map(async (g) => { 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 since = await R.conversations.lastReadAt(g.id, u.id);
const members = await R.conversations.members(g.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 // 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, 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_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_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, 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 before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at
const names = await namesFor(u.team_id); const names = await namesFor(u.team_id);
const group = q.get('group'); 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 (group) {
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this 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) { if (!peek && !before) {
await R.conversations.markRead(group, u.id); await R.conversations.markRead(group, u.id);
const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; 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 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 (!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' }); 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 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); 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; }))); 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' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const { to } = await readBody(req); const { to } = await readBody(req);
if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' }); 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)); 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' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const { room, userIds } = await readBody(req); const { room, userIds } = await readBody(req);
if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' }); 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)); 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
for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } // #15: adding people to a 1:1 call promotes it to a persistent GROUP call (survives leaves + lets an added
json(res, 200, { ok: true, invited: ids.length }); // 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 // 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://…). // false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route('GET', '/api/meetings/config', (req, res) => { route('GET', '/api/meetings/config', (req, res) => {
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED }); json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, fcm: PUSH.fcmReady() });
}); });
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending. // #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); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' }); if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
const { room } = await readBody(req); const body = await readBody(req);
const rm = String(room || '').trim(); const rm = String(body.room || '').trim();
if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' }); if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' }); // #12 Multi-device: mint the token with identity = the caller's mesh peerId (unique per connection) when it's
const token = livekitToken(u.id, u.name || u.email, rm, metadata); // supplied, so the SAME user joining from two devices no longer collides on LiveKit — which allows exactly
json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email }); // 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 // 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. // 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) => { route('POST', '/api/meetings/guest-token', async (req, res) => {
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' }); 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(); const rm = String(room || '').trim();
if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' }); if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })(); 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. // 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 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 gname = String(name || 'Guest').slice(0, 60);
const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true })); // #12 Multi-device (same as /api/meetings/token): prefer the guest's per-connection mesh peerId as the
json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname }); // 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. // Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
@@ -1233,6 +1273,13 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
// Invitation notification to each invited participant. // 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 } }; 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 (_) {} } 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 // 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. // who have an email on file. Fire-and-forget — a mail outage never fails scheduling. No-op if SMTP off.
try { try {
@@ -1287,7 +1334,7 @@ route('GET', '/api/meetings', async (req, res) => {
// Attach recordings/transcripts. A recording is visible to its creator, group members, or people // 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 // 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). // 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) => { const canSeeRec = async (r) => {
if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
if (r.created_by === u.id) return true; if (r.created_by === u.id) return true;
@@ -1554,6 +1601,7 @@ route('POST', '/api/messages', async (req, res) => {
const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group'; 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'); 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)) { 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 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 }); if (mid !== u.id) PUSH.sendToUser(mid, { title: gname, body: pushBody, kind: 'group', id: group, tag: 'group:' + group });
} }
@@ -1567,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 }); 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 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 } }; 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) 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. // Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self, not if blocked.
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 }); 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); json(res, 200, dto);
}); });
@@ -1620,13 +1671,167 @@ route('POST', '/api/messages/delete', async (req, res) => {
if (!id) return json(res, 400, { error: 'id required' }); if (!id) return json(res, 400, { error: 'id required' });
const m = await R.messages.byId(id); const m = await R.messages.byId(id);
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); 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); await R.messages.markDeleted(id);
const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null }; 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 (_) {} } } 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 (_) {} } else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
json(res, 200, { ok: true }); 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 // 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). // change live to the other side / other tabs (mirrors the delete broadcast).
route('POST', '/api/messages/edit', async (req, res) => { route('POST', '/api/messages/edit', async (req, res) => {
@@ -1719,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(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 (_) {} 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) }); 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.`);
+23 -5
View File
@@ -58,9 +58,9 @@ function finishMeetingJoin(ws, room, peers) {
const hostUserId = roomHost.get(room); const hostUserId = roomHost.get(room);
const avatar = ws._meetingAvatar || null; const avatar = ws._meetingAvatar || null;
const isHost = !!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId); 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 })) })); 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 })); } 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 }); 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 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); } 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 })); const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true }));
@@ -142,6 +142,7 @@ async function handle(ws, m, req) {
const peerId = A.token(6); const peerId = A.token(6);
const name = String(m.name || 'Guest').slice(0, 60); const name = String(m.name || 'Guest').slice(0, 60);
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name; 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. // Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
let hostUserId = roomHost.get(room); 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 (_) {} } if (hostUserId === undefined) { try { const s = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
@@ -372,6 +373,16 @@ async function handle(ws, m, req) {
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m)); if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break; 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': { case 'end-session': {
await endSession(ws.sessionId, m.reason || null); await endSession(ws.sessionId, m.reason || null);
break; break;
@@ -406,8 +417,15 @@ async function leaveMeeting(ws) {
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; } if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
try { await require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript try { await require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
peers.delete(pid); peers.delete(pid);
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call). // #New1: remember this user LEFT, so a later socket reconnect doesn't auto-ring them back into a call that's
if (roomToDmCall.has(room)) { // 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); 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; } } 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 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 === '/console' || p === '/dashboard') p = '/dashboard.html';
if (p === '/share') p = '/share.html'; if (p === '/share') p = '/share.html';
if (p === '/connect') p = '/connect.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)); const fp = path.join(PUBLIC_DIR, path.normalize(p));
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' }); 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 // 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) // Run: node test/db-smoke.js (uses a throwaway temp DB; DB_BACKEND env selects sqlite|pg)
const fs = require('fs'); // SQLite was retired 2026-08-12 — this suite runs against Postgres now. Point DATABASE_URL at a DISPOSABLE
const os = require('os'); // test database (NEVER production — the suite creates + mutates rows), e.g.:
const path = require('path'); // DATABASE_URL=postgres://bizgaze:PW@localhost:5432/bizgaze_test node test/db-smoke.js
const DB = path.join(os.tmpdir(), 'bzc-smoke.db'); process.env.DB_BACKEND = process.env.DB_BACKEND || 'pg';
process.env.DB_PATH = DB; if (!process.env.DATABASE_URL) {
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} } 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; const PORT = 8097;
process.env.PORT = PORT; process.env.PORT = PORT;
@@ -39,8 +41,10 @@ async function get(p, cookie) {
} }
(async () => { (async () => {
await wait(300); // Wait for the server to actually be LISTENING — a cold Postgres boot (connect + apply the full schema)
console.log('DB smoke tests (backend=' + (process.env.DB_BACKEND || 'sqlite') + '):'); // 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 // Auth
const reg = await post('/api/register', { email: 'admin@smoke.test', password: 'supersecret', teamName: 'Smoke Co' }); 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 // (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.) // TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.)
const fs = require('fs'); // SQLite was retired 2026-08-12 — the backend is Postgres now. Point DATABASE_URL at a DISPOSABLE test DB
const os = require('os'); // (never production — this creates + mutates rows), e.g.:
const path = require('path'); // DATABASE_URL=postgres://bizgaze:PW@localhost:5432/bizgaze_test node test/e2e.js
const DB = path.join(os.tmpdir(), 'ra-e2e.db'); process.env.DB_BACKEND = process.env.DB_BACKEND || 'pg';
process.env.DB_PATH = DB; if (!process.env.DATABASE_URL) {
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} } console.log('SKIP e2e: set DATABASE_URL to a disposable Postgres test DB (SQLite retired 2026-08-12).');
process.exit(0);
}
const PORT = 8099; const PORT = 8099;
process.env.PORT = PORT; process.env.PORT = PORT;
@@ -63,7 +65,9 @@ function nextMsg(ws, type, timeout = 3000) {
} }
(async () => { (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:'); console.log('E2E backend tests:');
// Local receiver to capture outbound webhook deliveries. // Local receiver to capture outbound webhook deliveries.