Screen-share (send): startScreenShare requests the MediaProjection grant via
startActivityForResult, then CallMedia.setScreenShareEnabled(true,
ScreenCaptureParams(data)) publishes it over the native LiveKit room.
stopScreenShare unpublishes. Both emit screenShareState so the web button
reflects reality (incl. a declined grant). LiveKit's SDK manifest already
provides the mediaProjection foreground service + FOREGROUND_SERVICE_MEDIA_
PROJECTION (merged into the app), so no manifest change is needed.
Received shared screen: implemented setTileZoom (was a no-op) — the web
captures pinch/pan under hole-punch and forwards {scale,tx,ty}; CallVideo
applies it as a renderer transform (scale about centre + translate ×density),
scale<=1 clears it. Mirrors the iOS applyZoom contract. Screen tiles already
render aspect-fit.
APK-only: the web bridge already calls these methods and received-screen
rendering is gated on ANDROID_NATIVE_VIDEO (already on) — no server/web change.
All LiveKit 2.29.2 APIs (setScreenShareEnabled, ScreenCaptureParams) verified
against source. Clarity-when-zoomed (adaptive-stream) is a follow-up if needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A missing brace in a home.html inline <script> caused an app-wide login
outage (the whole block failed to parse, so boot never ran). Nothing in the
deploy path checked HTML syntax. Add tools/check-syntax.js: parses (never
runs) every inline <script> in server/public/*.html with vm and exits non-zero
on any syntax error, reporting the file + line.
Wired in two places:
- Dockerfile RUN after COPY server/ ./ — a syntax error now FAILS the build,
so it can't deploy.
- redeploy.sh pre-flight — fast local feedback before triggering the remote
build.
Verified: passes on current pages (16 blocks / 9 files); fails with the exact
location when a brace is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production outage: after login the app hung forever on the splash. Chrome
showed "Uncaught SyntaxError: Unexpected token ')' at home:4398". The
callConnected listener's arrow body `()=>{ ... }` was missing its closing
brace (brace balance +1 on the line), so the whole inline <script> block
failed to parse — every boot function (loadSidebar, the boot IIFE) was never
defined, so the splash never hid. The login screen still rendered because it
lives in an earlier script block; only this block died.
The bug rode in with the Increment-2a callConnected wiring and was masked for
a while by service-worker caching (users' "quit and reopen fixed it"); once
the broken home.html was served fresh everywhere it became a total login hang,
on web and desktop alike. Verified all inline blocks parse after the fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production outage: logins (and eventually all requests) hung forever. Root
cause: the pg Pool was max:10 with NO connectionTimeoutMillis, so once all 10
connections were busy/stuck, every new query waited indefinitely for a free
client — the "login spinner that never ends". The reject path stayed fast
(one brief query); the success path needs several writes and blocked on
acquiring a connection. Server-side only (reproduced in a desktop browser).
Add fail-fast + self-healing pool config (all env-tunable):
- connectionTimeoutMillis (10s): callers error instead of hanging on a
saturated pool.
- statement_timeout (60s): kill a runaway query so it can't pin a connection.
- idle_in_transaction_session_timeout (60s): reclaim a client left idle in a
hung transaction (the classic slow pool leak).
- max 10 -> 20 for headroom; pool 'error' handler so a broken idle client
can't crash the process.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the iOS native-video path on Android, gated by ANDROID_NATIVE_VIDEO.
- CallVideo (new): hole-punch (transparent WebView over a black parent with a
container behind) + per-tile LiveKit TextureViewRenderer, positioned from the
web's CSS-px tile rects scaled by display density. TextureView (not Surface)
so it composites behind the transparent WebView. Local camera mirrored;
camera = aspect-fill, screen = aspect-fit; stale renderers released.
- CallMedia: setCameraEnabled / switchCamera (LocalVideoTrack.switchCamera) +
cameraTrack/screenTrack lookup (by LiveKit identity = mesh peerId) +
initRenderer. All LiveKit 2.29.2 APIs verified against source.
- NativeCallPlugin: setCamera / switchCamera / setHolePunch / syncVideoTiles get
real bodies (+ CAMERA runtime-permission prompt, video teardown on endCall).
Screen-share (MediaProjection), tile zoom and #5 transcript stay no-ops.
Web needs no change: the shared NativeCall bridge already drives these for any
meetNative call; bzNativeVideoOn() enables hole-punch + the tile poll on Android
once the flag is on. Flip ANDROID_NATIVE_VIDEO=1 after a 2b APK is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On an Android native call the WebView turned on hole-punch (transparent
meeting grid, so native video can show BEHIND the WebView) whenever the
plugin exposed setHolePunch — but on the 2a audio-only build there is no
native video behind it, so the transparent grid revealed the white Android
window (reported: white meeting background; tiles stayed visible as they're
opaque web elements). iOS never showed this because CallKit covers the view.
Gate hole-punch on native video actually existing: new ANDROID_NATIVE_VIDEO
server flag (mirrors ANDROID_NATIVE_MEDIA, default off) exposed via
/api/meetings/config. Web: bzNativeVideoOn() — iOS always, Android only when
the flag is on (Increment 2b APK). While off, hole-punch stays off and the
grid keeps its dark background; the native-tile poll doesn't run. iOS
unchanged. Flip the flag on once the 2b native-video APK is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JitPack fix worked; two new failures surfaced, both fixed here:
1) checkDebugDuplicateClasses: LiveKit 2.29.2 already BUNDLES a fork
(com.github.davidliu:audioswitch) carrying the same com.twilio.audioswitch
classes at runtime, so our explicit `implementation com.twilio:audioswitch`
put both on the runtime classpath. Switch it to `compileOnly` — we get the
AudioDevice types at compile time, runtime uses only LiveKit's bundled fork.
(Also clears the "namespace used in multiple modules" merge warning.)
2) CallMedia.kt: `Unresolved reference: AudioOptions`. In 2.29.2 AudioOptions
is declared in package io.livekit.android (inside LiveKitOverrides.kt), not
io.livekit.android.audio. Corrected the import. Verified AudioSwitchHandler's
API (registerAudioDeviceChangeListener / availableAudioDevices /
selectedAudioDevice / selectDevice) against the v2.29.2 source.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codemagic build failed at :app:checkDebugAarMetadata — "Could not find
com.github.davidliu:audioswitch". LiveKit Android 2.29.2 pulls in that
JitPack-only fork (commit-hash version), and Gradle resolves the whole
:app transitive graph against the APP module's (+ allprojects) repos, not
the native-call plugin's. The plugin-level jitpack repo was therefore
ignored. android-patch.js now injects `maven { url 'https://jitpack.io' }`
into the generated root build.gradle's allprojects{} (and settings.gradle
dependencyResolutionManagement{} if a newer template uses it). Idempotent,
build-tolerant. TECHSTACK rev 3 note updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LiveKit Android 2.29.2 + audioswitch + Kotlin in the native-call plugin, the
microphone foreground service, the ANDROID_NATIVE_MEDIA flag, and minSdk 24.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces WebView call media on Android with a native LiveKit connection (iOS
parity), so call audio survives backgrounding and the output can be switched
to Speaker/Bluetooth/Earpiece — the #3 (missing speaker control) and #4
(background audio) items.
Gated behind a new, default-off server flag ANDROID_NATIVE_MEDIA (independent
of ANDROID_CALLS, which only governs the native ring). Flag off = today's
WebView media, video included; flag on = native audio (video is Increment 2b,
so enable it for VOICE testing first). No behavior change until flipped.
Native (mobile/plugins/native-call/android):
- build.gradle: add kotlin-android + io.livekit:livekit-android 2.29.2 (+ twilio
audioswitch, coroutines) + JitPack repo; minSdk 24 (LiveKit floor).
- CallMedia.kt: LiveKit Room wrapper — connect/mic/disconnect + AudioSwitchHandler
routing (auto + selectDevice) and route-change events.
- CallForegroundService.java: microphone-type foreground service for background
audio (no-ops without RECORD_AUDIO so it never crashes the call).
- NativeCallPlugin.java: real bodies for reportOutgoingCall/reconnectRoom/setMuted
/endCall + new setAudioRoute/getAudioRoute; fires callConnected/callError/audioRoute.
- AndroidManifest: FG service + FOREGROUND_SERVICE(_MICROPHONE)/RECORD_AUDIO/
MODIFY_AUDIO_SETTINGS/BLUETOOTH_CONNECT.
- android-patch.js: bump the generated app minSdk to 24.
Server:
- config.js/routes.js: ANDROID_NATIVE_MEDIA → /api/meetings/config.androidNativeMedia.
Web (home.html):
- Android flips to the native-media path when androidNativeMedia is set; the
existing outgoing/answer/mic/end code then drives the plugin unchanged.
- Speaker button wired for Android native: cycles speaker/earpiece/bluetooth via
NC.setAudioRoute and reflects the real output from the audioRoute event.
Video stays no-op on the native path (Increment 2b).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A user signed in on two devices (iPhone + Android) rang on both. When they
answered on Android, the iPhone kept ringing: markDmAnswered only emitted a
WS 'call-taken', which never reaches a backgrounded/killed device ringing on
CallKit (no live socket).
Now markDmAnswered also pushes a native cancel to the answerer's OTHER
devices. The device that actually answered is skipped via exceptToken (its own
call-push token) — critical on iOS, where a cancel would end the just-answered
CallKit call; Android's cancel branch never tears down a live call anyway.
- push.sendCallCancel(userId, uuid, { exceptToken, reason }) — skips the
answering device's token; existing 2-arg callers unchanged.
- calls.markDmAnswered(room, userId, exceptToken) — fires the cancel.
- POST /api/calls/answered now takes deviceToken.
- home.html: the native answerCall handler POSTs /api/calls/answered with this
device's own token (ios-voip or fcm), so native/LiveKit calls (which bypass
the mesh) are marked answered and siblings stop ringing.
Server + web only — no native build needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The incoming ring is driven by our own Ringer (we aren't a telecom
ConnectionService yet), so the OS doesn't route volume keys to silence
it. Capture them two ways that together cover every state:
- Foreground/locked: IncomingCallActivity.dispatchKeyEvent consumes
VOLUME_UP/DOWN/MUTE and silences.
- Unlocked/backgrounded heads-up (no activity): a MediaSession with a
remote VolumeProvider so the framework delivers volume presses to us.
Silence stops audio + vibration but keeps the call ringing visually
(notification + full-screen stay) so answer/decline still work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full stack: plain Node (http/https + ws, custom router, no framework),
PostgreSQL 16 (SQLite retired), vanilla-JS web SPA, Capacitor 8 mobile with
custom native-call/audio/media/share plugins, two Electron clients, LiveKit
SFU + WebRTC mesh, Web Push/FCM/APNs, Docker + Nginx Proxy Manager, Codemagic
CI. Includes a maintenance rule + changelog so it's kept current.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On an unlocked Android 14 phone the OS shows our call as a heads-up and does
NOT launch the full-screen IncomingCallActivity (full-screen-intent is
restricted for non-dialer apps while unlocked), so the ring — which lived in
the activity — was silent when unlocked (it rang only when locked, where the
activity launches).
Move the ring to a call-scoped Ringer (looping ringtone + vibration) started
in CallStore.showIncoming and stopped in CallStore.cancel (which every
answer/decline/cancel/end path already calls). The notification channel stays
silent, so there's still a single ring source. IncomingCallActivity no longer
rings — it's just UI — and no longer stops the ring on destroy (backgrounding
the call screen keeps ringing like a real call until answer/decline/timeout).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sendCallNotification returned after the iOS VoIP branch, so a user signed in
on both an iPhone (ios-voip token) and an Android phone only got the call on
iOS — the Android invite was never sent, so a swiped-away Android phone never
rang (confirmed on-device: only type=cancel reached Android, never
type=invite, since sendCallCancel does fan out to all platforms). Now send to
iOS VoIP AND Android FCM, and only fall back to the banner when no native
device was waked. Server-only; no app rebuild.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-device (OnePlus, build 5cb3d0c): new ring UI confirmed; backgrounded rang
twice (channel sound + activity ringtone, and both wake paths raised it);
battery dialog deemed unprofessional (WhatsApp/Teams don't prompt it).
- Double-ring: notification channel is now SILENT (new id bzc_incoming_calls_v2,
no sound/vibration, onlyAlertOnce) so IncomingCallActivity is the single ring
source (looping ringtone + vibrator). showIncoming dedups atomically
(if(!active.add(uuid)) return) so the FCM push and WebSocket paths can't both
ring the same call.
- Battery dialog removed: dropped requestCallReliability (web call + plugin
method) and REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission.
- Logging: Log.i(bzcall, ...) across the wake chain to diagnose swiped-away
(OnePlus force-kills on swipe and blocks FCM cold-start).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses on-device feedback from Increment 1:
- #2 Professional incoming screen: IncomingCallActivity redesigned — brand-
blue backdrop, circular avatar with the caller's initial, name + call
type, and two round icon buttons (red hang-up = Decline, green phone =
Accept) instead of the full-screen colored buttons. New vector drawables
ic_call_answer / ic_call_decline (also used on the notification actions).
- #1 Killed-state ring: the missed-call notification arriving while killed
proves FCM reaches the app, so the ring (high-priority DATA) is being
held by OEM battery management (OnePlus/Oppo Doze) until the call is over.
Added REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + a requestCallReliability()
plugin method that prompts the "allow background activity" dialog once;
the web calls it on Android call setup. (OEM auto-launch is a manual
toggle with no API — guide the user.)
#3 (speaker/Bluetooth) and #4 (background audio) ride Increment 2 (native
LiveKit Android media + foreground service) — kept separate so the LiveKit
dependency can't block these UI/reliability fixes (the iOS coupling lesson).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of Android Phase C (full LiveKit parity), mirroring how iOS
started: native full-screen incoming call UI that wakes the app from
closed, with call MEDIA still in the WebView (foreground). Native LiveKit
media + a foreground service for true background audio are Increment 2.
Native (new Android side of the native-call plugin, Java to match the
proven official-plugin module layout):
- NativeCallPlugin: registered as window.Capacitor.Plugins.NativeCall
(same jsName as iOS, so the shared web bridge drives both). getToken
(FCM token), reportIncomingCall (ring), reportOutgoingCall, endCall,
setMuted; video/screen/hole-punch/transcript methods are safe no-ops
until later increments so the shared bridge never rejects.
- BzCallMessagingService: extends @capacitor/push-notifications'
MessagingService — handles high-priority call-wake DATA messages
(data.bzcall==1) to ring when closed, forwards all other push via super.
- IncomingCallActivity: full-screen call screen over the lock screen
(showWhenLocked/turnScreenOn), Accept/Decline, looping ringtone.
- CallStore: incoming-call notification (channel, full-screen-intent,
actions) + UUID dedup so the FCM and WebSocket paths don't double-ring.
- Plugin manifest: BzCallMessagingService + IncomingCallActivity + call
perms (USE_FULL_SCREEN_INTENT/VIBRATE/WAKE_LOCK).
- android-patch.js: removes Capacitor's MessagingService in the APP
manifest (highest merge priority) so ours is the sole MESSAGING_EVENT
receiver; ensures the tools namespace. Idempotent.
- package.json: capacitor.android.src wired.
Server (dormant until ANDROID_CALLS=1):
- push.js sendFcmData: high-priority data-only FCM (rings when closed).
sendCallNotification rings 'android' tokens via it; sendCallCancel too.
- config.js ANDROID_CALLS flag; /api/meetings/config exposes androidCall.
Web (home.html) — platform-aware, iOS/web behavior byte-for-byte unchanged:
- setupNativeCall: Android gated on cfg.androidCall, sets _callWebViewMedia
(native ring, WebView media); iOS keeps CallKit native-media path.
- answerCall + both outgoing sites branch on _callWebViewMedia so Android
joins the meeting normally while the callee still rings via the server.
Safe to deploy now: dormant until ANDROID_CALLS=1 AND an APK carrying the
plugin is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device logs showed Android's interactive-widget=resizes-content already shrinks
the layout viewport to above the keyboard (innerHeight 800->472 for a 328px
keyboard), yet the app also lifted the composer by --kb=328 → a keyboard-height
white gap. Now gate the manual --kb lift to iOS only (WKWebView doesn't resize);
on Android keep --kb=0 and just toggle kb-open so --sab collapses and the composer
sits flush to the keyboard. Also removes the temporary kbdiag logging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
@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>
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>
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>
#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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
#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>
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>
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>
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>
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>
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>
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>
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>
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>
#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>
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>
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>
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>
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>
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>
#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>
#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>
#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>
#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>
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>
#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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Move off CocoaPods (sunsetting; trunk goes read-only Dec 2026) to SPM, the Cap 8
default. This also ends the LiveKit git-pin hack — LiveKit becomes a real SPM dep.
- Each local plugin gets a Package.swift (swift-tools 5.9, iOS 15, capacitor-swift-pm
from 8.0.0, source path ios/Sources/<Plugin>). native-call additionally declares
LiveKit: .package(client-sdk-swift, exact 2.15.3) + product "LiveKit".
- native-call Swift: import LiveKitClient -> import LiveKit (SPM product name).
- codemagic.yaml: `cap add ios --packagemanager SPM`; removed the "Install CocoaPods"
(pod install) step; XCODE_WORKSPACE -> XCODE_PROJECT (App.xcodeproj); build-ipa
--workspace -> --project. Xcode resolves the Swift packages during archive.
- ios-patch.sh: removed the Podfile LiveKit git-pin injection + Podfile.lock deletion
(no Podfile under SPM). Info.plist/entitlements/AppDelegate/notif.wav patches stay.
- add-share-extension.rb is SPM-safe (operates on App.xcodeproj, no workspace/Pods refs).
Authored blind (no local Xcode) — expect build iterations on the first SPM build
(cap-add SPM layout, CapApp-SPM plugin wiring, LiveKit SPM resolution, signing).
Fully revertible: git revert -> back to Cap 8 + CocoaPods (working).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>