Compare commits
127 Commits
dbd209ac2b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ece8e1061 | |||
| 03de639e1a | |||
| 133208ae3f | |||
| 703cf1fcf0 | |||
| 9d5af66a0e | |||
| ab6d8161ee | |||
| c013536e2b | |||
| ba75dc8daa | |||
| a4726dd10c | |||
| b03a32b10d | |||
| e39828ed56 | |||
| 35ce641046 | |||
| 1efb2e4314 | |||
| 677d418ed0 | |||
| 07c728bf6a | |||
| 948eae249f | |||
| 88a6b6a21e | |||
| 620039a2ff | |||
| 224a2800c5 | |||
| ba51a3c5d7 | |||
| 1a089c0349 | |||
| eb685b42ca | |||
| 467a8b9c6e | |||
| 14eb99edaf | |||
| 2948e0cfcc | |||
| a9d95ed8c6 | |||
| 18edc5dbfb | |||
| 928119725e | |||
| 10b2251efe | |||
| e4d361f298 | |||
| 7e3a94b04c | |||
| 343724df0f | |||
| 3f41ca857a | |||
| 87a76c09f3 | |||
| e46ac1e7cc | |||
| 68ff3a878b | |||
| c322448774 | |||
| 354e65acdf | |||
| 785eeb7fbc | |||
| 25b0b39c49 | |||
| 6ee424db16 | |||
| c3c4178227 | |||
| a677675b8f | |||
| eaea1ccc3f | |||
| 971a6fdf22 | |||
| ad48829337 | |||
| f3b6e67c19 | |||
| d0863351d2 | |||
| 9017d2ff25 | |||
| c1b67d38d3 | |||
| 276c5e0929 | |||
| ecc8be3dba | |||
| e359271157 | |||
| 9e80aee1c6 | |||
| a0f9ab10f2 | |||
| 100a092ff9 | |||
| 0cee72e73c | |||
| c24d5d828a | |||
| d9ea104b73 | |||
| c79d78485b | |||
| bdd0058c5c | |||
| f971908d80 | |||
| f2efff394b | |||
| 5a140e45a1 | |||
| b35c95a5be | |||
| a561852067 | |||
| 27c582bceb | |||
| aed3d22675 | |||
| e6d3d2d66a | |||
| a5e2ed8a9d | |||
| 34bdd8ea16 | |||
| 4415225407 | |||
| 149e32b8d8 | |||
| f27b7af9e4 | |||
| 2c1e1c7ca5 | |||
| b2c2acbbc7 | |||
| b68ba94d2a | |||
| 7f54182186 | |||
| 166bea4314 | |||
| 92f1f8f1a3 | |||
| 9d0fd93d7e | |||
| 29106370fd | |||
| fafc480904 | |||
| f4dbbde558 | |||
| 8c737372de | |||
| 21430bd590 | |||
| 46cc2610b9 | |||
| bcd5699e2a | |||
| 2c97f45245 | |||
| a92cdd69f8 | |||
| c3de5ac8e3 | |||
| 455d16abcc | |||
| 155e89c6c0 | |||
| c472ee2618 | |||
| 36edc6de12 | |||
| 199c32a319 | |||
| c2b339e284 | |||
| 11f72b2592 | |||
| 82c6c9ce0e | |||
| 3d35a9ece7 | |||
| 2d3ab3dbd3 | |||
| 3ac5b6e7bd | |||
| e60e80e4bd | |||
| 3e8aaad68c | |||
| aae663ccea | |||
| c4ffe2a4e9 | |||
| e09d17f1a7 | |||
| 9dc253143e | |||
| 6096b62368 | |||
| e7c3231c50 | |||
| 3e21aa30d7 | |||
| cd7ab74eed | |||
| f63aba0ed1 | |||
| fd2eb42e25 | |||
| 37e58f6087 | |||
| 5a1ce5fdba | |||
| a0b3936799 | |||
| 1920258fd6 | |||
| c6523a8461 | |||
| 83b445e32d | |||
| 381c4ddfee | |||
| 63f2c588da | |||
| 363c4a539f | |||
| 5f342b0b4e | |||
| e67a783bdc | |||
| 3250530596 | |||
| 2460c0f9eb |
@@ -29,3 +29,16 @@ TURN_CREDENTIAL=
|
|||||||
# LIVEKIT_URL=wss://livekit.bizgaze.com
|
# LIVEKIT_URL=wss://livekit.bizgaze.com
|
||||||
# LIVEKIT_API_KEY=
|
# LIVEKIT_API_KEY=
|
||||||
# LIVEKIT_API_SECRET=
|
# LIVEKIT_API_SECRET=
|
||||||
|
|
||||||
|
# Optional: PostgreSQL data store. Leave unset to use the built-in SQLite file (DB_PATH). To move to
|
||||||
|
# Postgres: set POSTGRES_PASSWORD (used by the bizgaze-postgres container AND the URL below), then set
|
||||||
|
# DATABASE_URL + DB_BACKEND=pg after running db/migrate-sqlite-to-pg.js. See db/schema.pg.sql.
|
||||||
|
# POSTGRES_PASSWORD=
|
||||||
|
# DATABASE_URL=postgres://bizgaze:PASSWORD@bizgaze-postgres:5432/bizgaze
|
||||||
|
# DB_BACKEND=pg
|
||||||
|
|
||||||
|
# Optional: cross-instance real-time (chat/presence) fan-out via Redis, for running MULTIPLE app instances.
|
||||||
|
# Leave unset for single-instance (in-memory pub/sub, the default). To scale out: start the redis service
|
||||||
|
# (`docker compose --profile scale up -d`), then set both below + a sticky load balancer for /ws.
|
||||||
|
# PUBSUB_BACKEND=redis
|
||||||
|
# REDIS_URL=redis://bizgaze-redis:6379
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ 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: 20
|
node: 22
|
||||||
xcode: latest
|
xcode: latest
|
||||||
cocoapods: default
|
cocoapods: default
|
||||||
scripts:
|
scripts:
|
||||||
@@ -44,10 +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.
|
||||||
if [ ! -d "ios" ]; then npx cap add ios; fi
|
# Capacitor 8 Swift Package Manager: generate an SPM project (no Podfile). LiveKit is pulled via the
|
||||||
|
# native-call plugin's Package.swift; `cap sync` wires all local plugins into the CapApp-SPM package.
|
||||||
|
if [ ! -d "ios" ]; then npx cap add ios --packagemanager SPM; fi
|
||||||
npx cap sync ios
|
npx cap sync ios
|
||||||
|
# Sanity: the Xcode project must exist. Print the iOS dir so the log shows the SPM layout (CapApp-SPM
|
||||||
|
# present, NO Podfile) — if a Podfile appears, the SPM flag didn't take and we'd need to fix it.
|
||||||
|
test -d ios/App/App.xcodeproj || { echo "ERROR: iOS Xcode project not generated"; ls -la ios/App || true; exit 1; }
|
||||||
|
echo "iOS project layout:"; ls -la ios/App
|
||||||
|
# ── Diagnose local-plugin SPM wiring (the regression: our file: plugins didn't function at runtime) ──
|
||||||
|
echo "=== local plugins in node_modules — symlink vs copy, and is Package.swift present? ==="
|
||||||
|
for p in native-call media-library audio-route share-inbox file-opener; do
|
||||||
|
echo "-- $p --"; ls -ld "node_modules/$p" 2>/dev/null || echo " (dir missing)"
|
||||||
|
( ls "node_modules/$p/Package.swift" >/dev/null 2>&1 && echo " Package.swift PRESENT" ) || echo " Package.swift MISSING"
|
||||||
|
done
|
||||||
|
echo "=== CapApp-SPM Package.swift — are our local plugins + LiveKit wired in? ==="
|
||||||
|
CAPSPM=$(find ios -path "*CapApp-SPM*Package.swift" 2>/dev/null | head -1)
|
||||||
|
if [ -n "$CAPSPM" ]; then echo "found: $CAPSPM"; cat "$CAPSPM"; else echo " CapApp-SPM/Package.swift NOT FOUND"; find ios -name Package.swift 2>/dev/null; fi
|
||||||
# App icon + splash from resources/icon.png & resources/splash*.png (1024x1024 icon, 2732² splash).
|
# 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: |
|
||||||
@@ -61,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
|
||||||
@@ -88,12 +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
|
|
||||||
pod install
|
|
||||||
|
|
||||||
- name: Build the signed IPA
|
- name: Build the signed IPA
|
||||||
script: |
|
script: |
|
||||||
@@ -104,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 "================================================================="
|
||||||
@@ -128,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.
|
||||||
|
|||||||
@@ -84,6 +84,24 @@ verify() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Recreating the app container can give it a NEW docker network IP. Nginx Proxy Manager caches the app's
|
||||||
|
# upstream IP at config-load, so it would keep hitting the OLD IP ("Connection refused" → site down) until
|
||||||
|
# it re-resolves. Reload NPM's nginx so it picks up the current IP. Non-fatal: warn (don't fail) if NPM
|
||||||
|
# isn't found — some environments run a different reverse proxy.
|
||||||
|
reload_proxy() {
|
||||||
|
local npm
|
||||||
|
npm="$(docker ps --format '{{.Names}}' | grep -iE 'nginx.?proxy.?manager.*app' | head -n1 || true)"
|
||||||
|
if [ -n "$npm" ] && docker exec "$npm" nginx -t >/dev/null 2>&1; then
|
||||||
|
if docker exec "$npm" nginx -s reload >/dev/null 2>&1; then
|
||||||
|
ok "Reloaded $npm (re-resolved app upstream IP)."
|
||||||
|
else
|
||||||
|
warn "Could not reload $npm — if the site 502s, run: docker exec $npm nginx -s reload"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Reverse proxy (NPM) not auto-detected; if the site fails after deploy, reload it so it re-resolves the app IP."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# --- rollback mode ---
|
# --- rollback mode ---
|
||||||
if [ "$ROLLBACK" -eq 1 ]; then
|
if [ "$ROLLBACK" -eq 1 ]; then
|
||||||
latest="$(ls -1t "$BK_DIR"/*.tgz 2>/dev/null | head -n1 || true)"
|
latest="$(ls -1t "$BK_DIR"/*.tgz 2>/dev/null | head -n1 || true)"
|
||||||
@@ -91,7 +109,7 @@ if [ "$ROLLBACK" -eq 1 ]; then
|
|||||||
log "Rolling back from: $latest"
|
log "Rolling back from: $latest"
|
||||||
snapshot # snapshot the (broken) current state first
|
snapshot # snapshot the (broken) current state first
|
||||||
tar -xzf "$latest" -C "$APP_DIR"
|
tar -xzf "$latest" -C "$APP_DIR"
|
||||||
rebuild; verify
|
rebuild; verify; reload_proxy
|
||||||
ok "Rolled back to $latest"
|
ok "Rolled back to $latest"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -106,5 +124,6 @@ if [ "$DO_PULL" -eq 1 ]; then
|
|||||||
fi
|
fi
|
||||||
rebuild
|
rebuild
|
||||||
verify
|
verify
|
||||||
|
reload_proxy
|
||||||
ok "Deploy complete. Backups kept: $KEEP_BACKUPS (in $BK_DIR)."
|
ok "Deploy complete. Backups kept: $KEEP_BACKUPS (in $BK_DIR)."
|
||||||
echo " Rollback with: ./deploy.sh --rollback"
|
echo " Rollback with: ./deploy.sh --rollback"
|
||||||
|
|||||||
@@ -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,7 +30,49 @@ 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:
|
||||||
|
- npm
|
||||||
|
# The DB is Postgres (SQLite retired 2026-08-12), so wait for it to be healthy before the app starts —
|
||||||
|
# otherwise the first queries race the DB coming up.
|
||||||
|
depends_on:
|
||||||
|
bizgazepg:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# PostgreSQL — the app's data store (DB_BACKEND=pg; the only backend since SQLite was retired). Distinct
|
||||||
|
# service/container name so it never collides with the other postgres containers on the shared NPM
|
||||||
|
# network; the app reaches it as `bizgaze-postgres`. Data on its own named volume.
|
||||||
|
bizgazepg:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: bizgaze-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=bizgaze
|
||||||
|
- POSTGRES_DB=bizgaze
|
||||||
|
# Password comes from the same .env as the app (compose interpolates ${...} from the .env in this dir).
|
||||||
|
# NOTE: Postgres only applies POSTGRES_PASSWORD on FIRST init of an empty data volume.
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-bizgaze_local}
|
||||||
|
volumes:
|
||||||
|
- bizgaze_pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- npm
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U bizgaze -d bizgaze"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
# Redis — cross-instance real-time fan-out (chat/presence), used only when PUBSUB_BACKEND=redis. Dormant
|
||||||
|
# by default (behind the 'scale' profile, like livekit), so a normal deploy never starts it and the app
|
||||||
|
# stays single-instance on the in-memory pubsub. To run multiple app instances: start this
|
||||||
|
# (`docker compose --profile scale up -d`), set PUBSUB_BACKEND=redis + REDIS_URL in .env, and put the app
|
||||||
|
# behind a load balancer with sticky sessions for the /ws WebSocket. (Meeting SIGNALING state is still
|
||||||
|
# per-process — cross-instance meetings need sticky routing or further work; chat/presence fan out here.)
|
||||||
|
bizgazeredis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: bizgaze-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["scale"]
|
||||||
networks:
|
networks:
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
@@ -68,3 +109,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bizgaze_support_data:
|
bizgaze_support_data:
|
||||||
|
bizgaze_pg_data:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -40,7 +40,17 @@ On the **Register an App ID** page, only three fields matter — leave everythin
|
|||||||
- Add yourself under **TestFlight → Internal Testing** to install via the TestFlight app on your iPhone.
|
- Add yourself under **TestFlight → Internal Testing** to install via the TestFlight app on your iPhone.
|
||||||
|
|
||||||
## Step 5 — Push notifications (APNs) — do this once, then tell me
|
## Step 5 — Push notifications (APNs) — do this once, then tell me
|
||||||
So the app gets **calls/messages while it's closed**:
|
So the app gets **calls/messages while it's closed**. Two independent pieces must BOTH be in place:
|
||||||
|
|
||||||
|
> **A. App ID capability (client side).** The App ID `com.bizgaze.connect` must have **Push Notifications**
|
||||||
|
> enabled (Step 0 ticks it). The build now injects the `aps-environment` entitlement automatically
|
||||||
|
> (`ios-patch.sh`), so the app can obtain an APNs token — but if the App ID lacks the Push capability, the
|
||||||
|
> archive fails code-signing on `aps-environment`. If a build errors on that after you enable the
|
||||||
|
> capability, delete the app's provisioning profile in App Store Connect so the next Codemagic run
|
||||||
|
> regenerates one that includes push.
|
||||||
|
>
|
||||||
|
> **B. APNs key (server side).** Do the two steps below so the server can actually SEND to that token.
|
||||||
|
|
||||||
1. developer.apple.com → **Keys → +** → enable **Apple Push Notifications service (APNs)** → download the
|
1. developer.apple.com → **Keys → +** → enable **Apple Push Notifications service (APNs)** → download the
|
||||||
**`.p8`**. Note its **Key ID** and your **Team ID** (top-right of the developer portal).
|
**`.p8`**. Note its **Key ID** and your **Team ID** (top-right of the developer portal).
|
||||||
2. **Send me**: the `.p8` contents, the **Key ID**, and the **Team ID**. I set these in the server `.env`
|
2. **Send me**: the `.p8` contents, the **Key ID**, and the **Team ID**. I set these in the server `.env`
|
||||||
@@ -115,3 +125,22 @@ to the Files folder, the Photos "Connect" album, Manage storage — works withou
|
|||||||
Also enabled by this change (main app Info.plist, done automatically by `ios-patch.sh`):
|
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.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 252 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 {}
|
||||||
@@ -11,23 +11,25 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
"share-inbox": "file:plugins/share-inbox",
|
"share-inbox": "file:plugins/share-inbox",
|
||||||
"@capacitor-community/safe-area": "^7.0.0",
|
"@capacitor-community/safe-area": "^8.0.0",
|
||||||
"@capacitor/android": "^7.0.0",
|
"@capacitor/android": "^8.0.0",
|
||||||
"@capacitor/app": "^7.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/camera": "^7.0.0",
|
"@capacitor/camera": "^8.0.0",
|
||||||
"@capacitor/core": "^7.0.0",
|
"@capacitor/core": "^8.0.0",
|
||||||
"@capacitor/filesystem": "^7.0.0",
|
"@capacitor/filesystem": "^8.0.0",
|
||||||
"@capacitor/ios": "^7.0.0",
|
"@capacitor/ios": "^8.0.0",
|
||||||
"@capacitor/keyboard": "^7.0.0",
|
"@capacitor/keyboard": "^8.0.0",
|
||||||
"@capacitor/share": "^7.0.0",
|
"@capacitor/share": "^8.0.0",
|
||||||
"@capacitor/push-notifications": "^7.0.0",
|
"@capacitor/push-notifications": "^8.0.0",
|
||||||
"@capacitor/splash-screen": "^7.0.0",
|
"@capacitor/splash-screen": "^8.0.0",
|
||||||
"@capacitor/status-bar": "^7.0.0"
|
"@capacitor/status-bar": "^8.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@capacitor/cli": "^7.0.0"
|
"@capacitor/cli": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Pod::Spec.new do |s|
|
|||||||
s.author = 'BizGaze'
|
s.author = 'BizGaze'
|
||||||
s.source = { :git => 'https://bizgaze.com/audio-route.git', :tag => s.version.to_s }
|
s.source = { :git => 'https://bizgaze.com/audio-route.git', :tag => s.version.to_s }
|
||||||
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
s.ios.deployment_target = '14.0'
|
s.ios.deployment_target = '15.0'
|
||||||
s.dependency 'Capacitor'
|
s.dependency 'Capacitor'
|
||||||
s.swift_version = '5.1'
|
s.swift_version = '5.1'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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")
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
"ios/",
|
"ios/",
|
||||||
"AudioRoute.podspec"
|
"AudioRoute.podspec",
|
||||||
|
"Package.swift"
|
||||||
],
|
],
|
||||||
"capacitor": {
|
"capacitor": {
|
||||||
"ios": {
|
"ios": {
|
||||||
@@ -18,9 +19,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/core": "^7.0.0"
|
"@capacitor/core": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@capacitor/core": "^7.0.0"
|
"@capacitor/core": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
# NOTE: the pod name MUST be 'FileOpener' (PascalCase of the npm package name 'file-opener').
|
||||||
|
# `cap sync` writes `pod 'FileOpener', :path => '../../plugins/file-opener'` into the generated Podfile,
|
||||||
|
# and CocoaPods then looks for a file literally named FileOpener.podspec whose s.name is 'FileOpener'.
|
||||||
|
# Any other name → "No podspec found for `FileOpener`" and pod install fails. (Same trap that broke the
|
||||||
|
# AudioRoute build — see mobile/plugins/audio-route/AudioRoute.podspec.)
|
||||||
|
s.name = 'FileOpener'
|
||||||
|
s.version = package['version']
|
||||||
|
s.summary = package['description']
|
||||||
|
s.license = package['license']
|
||||||
|
s.homepage = 'https://bizgaze.com'
|
||||||
|
s.author = 'BizGaze'
|
||||||
|
s.source = { :git => 'https://bizgaze.com/file-opener.git', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
|
s.ios.deployment_target = '15.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "file-opener",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Preview a downloaded file with native iOS Quick Look for Biz Connect",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"author": "BizGaze",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/",
|
||||||
|
"FileOpener.podspec",
|
||||||
|
"Package.swift"
|
||||||
|
],
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/core": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ Pod::Spec.new do |s|
|
|||||||
s.author = 'BizGaze'
|
s.author = 'BizGaze'
|
||||||
s.source = { :git => 'https://bizgaze.com/media-library.git', :tag => s.version.to_s }
|
s.source = { :git => 'https://bizgaze.com/media-library.git', :tag => s.version.to_s }
|
||||||
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
s.ios.deployment_target = '14.0'
|
s.ios.deployment_target = '15.0'
|
||||||
s.dependency 'Capacitor'
|
s.dependency 'Capacitor'
|
||||||
s.swift_version = '5.1'
|
s.swift_version = '5.1'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "MediaLibrary",
|
||||||
|
platforms: [.iOS(.v15)],
|
||||||
|
products: [
|
||||||
|
.library(name: "MediaLibrary", targets: ["MediaLibraryPlugin"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "MediaLibraryPlugin",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||||
|
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||||
|
],
|
||||||
|
path: "ios/Sources/MediaLibraryPlugin")
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
"ios/",
|
"ios/",
|
||||||
"MediaLibrary.podspec"
|
"MediaLibrary.podspec",
|
||||||
|
"Package.swift"
|
||||||
],
|
],
|
||||||
"capacitor": {
|
"capacitor": {
|
||||||
"ios": {
|
"ios": {
|
||||||
@@ -18,9 +19,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/core": "^7.0.0"
|
"@capacitor/core": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@capacitor/core": "^7.0.0"
|
"@capacitor/core": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
# NOTE: the pod name MUST be 'NativeCall' (PascalCase of the npm package name 'native-call').
|
||||||
|
# Capacitor's `cap sync` writes `pod 'NativeCall', :path => '../../plugins/native-call'` into the
|
||||||
|
# generated Podfile, and CocoaPods then looks for a file literally named NativeCall.podspec whose
|
||||||
|
# s.name is 'NativeCall'. Any other name → "No podspec found for `NativeCall`" and pod install fails.
|
||||||
|
s.name = 'NativeCall'
|
||||||
|
s.version = package['version']
|
||||||
|
s.summary = package['description']
|
||||||
|
s.license = package['license']
|
||||||
|
s.homepage = 'https://bizgaze.com'
|
||||||
|
s.author = 'BizGaze'
|
||||||
|
s.source = { :git => 'https://bizgaze.com/native-call.git', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
|
s.ios.deployment_target = '15.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
# LiveKit iOS SDK — carries the call media NATIVELY so audio survives backgrounding AND coordinates with
|
||||||
|
# CallKit's audio session (auto-config OFF via AudioManager.shared.audioSession.isAutomaticConfigurationEnabled
|
||||||
|
# + setEngineAvailability in CXProvider didActivate/didDeactivate), which the WebView's WebRTC could not do.
|
||||||
|
# NOTE ON VERSION: this constraint stays '~> 2.0', but the actual version is pinned to 2.15.3 by a git-tag
|
||||||
|
# `pod 'LiveKitClient', :git => ...` line that ios-patch.sh injects into the generated Podfile — because the
|
||||||
|
# CallKit audio API needs 2.1+, which LiveKit publishes SPM-only (the CocoaPods TRUNK caps at 2.0.18, but the
|
||||||
|
# repo still ships a valid podspec at tag 2.15.3, and its deps are on trunk). 2.15.3 satisfies '~> 2.0'.
|
||||||
|
s.dependency 'LiveKitClient', '~> 2.0'
|
||||||
|
# CallKit + PushKit + AVFoundation are system frameworks (no external pod).
|
||||||
|
s.frameworks = 'CallKit', 'PushKit', 'AVFoundation'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
// SPM manifest for the native-call Capacitor plugin (Capacitor 8 uses SPM). LiveKit is declared here as a
|
||||||
|
// REAL SPM dependency — LiveKit 2.1+ is SPM-native, so this replaces the CocoaPods git-tag pin hack entirely.
|
||||||
|
// SPM resolves LiveKit + its LiveKitWebRTC / LiveKitUniFFI / SwiftProtobuf sub-packages directly.
|
||||||
|
let package = Package(
|
||||||
|
name: "NativeCall",
|
||||||
|
platforms: [.iOS(.v15)],
|
||||||
|
products: [
|
||||||
|
.library(name: "NativeCall", targets: ["NativeCallPlugin"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
|
||||||
|
.package(url: "https://github.com/livekit/client-sdk-swift.git", exact: "2.15.3")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "NativeCallPlugin",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||||
|
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||||
|
.product(name: "LiveKit", package: "client-sdk-swift")
|
||||||
|
],
|
||||||
|
path: "ios/Sources/NativeCallPlugin")
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import Capacitor
|
||||||
|
import PushKit
|
||||||
|
import CallKit
|
||||||
|
import AVFoundation
|
||||||
|
import Speech // #5 native transcript: SFSpeechRecognizer (iOS on-device speech-to-text; WKWebView has no Web Speech API)
|
||||||
|
import LiveKit // SPM product name is 'LiveKit' (Package.swift). (The old CocoaPods module was 'LiveKitClient'.)
|
||||||
|
|
||||||
|
// Native calling for Biz Connect (iOS). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall.
|
||||||
|
//
|
||||||
|
// ARCHITECTURE (native media):
|
||||||
|
// * PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices 'ios-voip').
|
||||||
|
// * CallKit: incoming VoIP push -> full-screen system ring (works when force-killed). The CallKit call
|
||||||
|
// stays ACTIVE for the whole call (that's what foregrounds/unlocks the app on answer and keeps the call
|
||||||
|
// alive in the background).
|
||||||
|
// * LiveKit: the call MEDIA runs NATIVELY via the LiveKit iOS SDK — so the mic works with an active CallKit
|
||||||
|
// call (WebKit's WebRTC could not) and audio survives backgrounding. The VoIP payload carries the
|
||||||
|
// LiveKit url+token so we can connect immediately on answer, even from a killed state; outgoing calls get
|
||||||
|
// the token from the WebView via reportOutgoingCall(). The WebView is UI only for native calls (it must
|
||||||
|
// NOT also join the room — LiveKit allows one connection per identity).
|
||||||
|
@objc(NativeCallPlugin)
|
||||||
|
public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate, BroadcastManagerDelegate {
|
||||||
|
public let identifier = "NativeCallPlugin"
|
||||||
|
public let jsName = "NativeCall"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "reconnectRoom", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setCamera", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "startScreenShare", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "stopScreenShare", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "startMeetingScreenShare", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "stopMeetingScreenShare", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setHolePunch", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setTileZoom", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "startTranscription", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "stopTranscription", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "feedAudio", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
private var pushRegistry: PKPushRegistry?
|
||||||
|
private var provider: CXProvider?
|
||||||
|
private let callController = CXCallController()
|
||||||
|
private var voipToken: String = ""
|
||||||
|
// callUUID -> the call's data (room, kind, callerId, livekitUrl, livekitToken, …).
|
||||||
|
private var calls: [UUID: [String: Any]] = [:]
|
||||||
|
// Calls we've already ended locally — so a LATE cancel push doesn't re-report (which briefly re-rang).
|
||||||
|
private var endedCalls = Set<UUID>()
|
||||||
|
private var room: Room?
|
||||||
|
private var activeUUID: UUID?
|
||||||
|
// Native video: one native video view per visible participant (key "__local" or the remote user id),
|
||||||
|
// drawn over the WebView and positioned to match the web meeting tiles (see syncVideoTiles). UIKit views —
|
||||||
|
// only ever touched on the main thread. TileVideoView adds pinch-zoom/pan for shared screens.
|
||||||
|
private var tileViews: [String: TileVideoView] = [:]
|
||||||
|
// Hole-punch: draw the native video BEHIND a transparent WebView so all web UI (bar, menus, panels) floats
|
||||||
|
// on top. Saved so we can restore the WebView when the call ends.
|
||||||
|
private var holePunchOn = false
|
||||||
|
private var savedVCBg: UIColor? // the WebView parent's original background, restored when the call ends
|
||||||
|
// #5 native transcript: transcribes THIS device's mic (WKWebView has no Web Speech API). Fed by a LiveKit
|
||||||
|
// AudioRenderer on the local mic track, so it reuses the call's already-open mic (no 2nd audio engine).
|
||||||
|
private let transcriber = SpeechTranscriber()
|
||||||
|
|
||||||
|
override public func load() {
|
||||||
|
let config = CXProviderConfiguration(localizedName: "Biz Connect")
|
||||||
|
config.supportsVideo = true
|
||||||
|
config.maximumCallGroups = 1
|
||||||
|
config.maximumCallsPerCallGroup = 1
|
||||||
|
config.supportedHandleTypes = [.generic]
|
||||||
|
let p = CXProvider(configuration: config)
|
||||||
|
p.setDelegate(self, queue: nil)
|
||||||
|
provider = p
|
||||||
|
|
||||||
|
let registry = PKPushRegistry(queue: .main)
|
||||||
|
registry.delegate = self
|
||||||
|
registry.desiredPushTypes = [.voIP]
|
||||||
|
pushRegistry = registry
|
||||||
|
// CallKit audio coordination (LiveKit 2.15+, pinned to the git tag in ios-patch.sh). LiveKit's automatic
|
||||||
|
// AVAudioSession config RACES CallKit's own activation → intermittent dead mic / no audio + the output
|
||||||
|
// route only settling once audio flows ("speaker turns on late"). Fix: turn auto-config OFF and keep the
|
||||||
|
// engine OFF; we configure the session and enable the engine ONLY in didActivate.
|
||||||
|
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
|
||||||
|
try? AudioManager.shared.setEngineAvailability(.none)
|
||||||
|
|
||||||
|
// Re-assert the LOUDSPEAKER whenever iOS routes call audio back to the quiet earpiece. The LiveKit
|
||||||
|
// audio engine starting up right after CallKit activates the session flips the route to the built-in
|
||||||
|
// receiver — that's the "sound is on the earpiece until I tap something" bug (tapping mic/cam re-ran
|
||||||
|
// preferSpeaker and fixed it). Listening for route changes makes that self-healing.
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteChanged(_:)),
|
||||||
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
|
|
||||||
|
// Screen sharing (ReplayKit broadcast extension). LiveKit tells us when a broadcast starts/stops and
|
||||||
|
// (with shouldPublishTrack=true, the default) auto-publishes/unpublishes the screen-share track.
|
||||||
|
BroadcastManager.shared.delegate = self
|
||||||
|
|
||||||
|
// #5 native transcript: each finalized speech segment → the web (meeting-transcript over the WS), which
|
||||||
|
// merges it into the shared meeting transcript (same path as desktop's Web Speech API).
|
||||||
|
transcriber.onFinal = { [weak self] text in self?.notifyListeners("transcript", data: ["text": text]) }
|
||||||
|
|
||||||
|
// Make the WebView transparent at STARTUP. WKWebView can IGNORE isOpaque=false when it's flipped after
|
||||||
|
// the page has already rendered — the likely reason the hole-punch showed no video. Doing it once, up
|
||||||
|
// front, makes the transparent-meeting areas actually reveal the native video behind the WebView. The
|
||||||
|
// web body is opaque, so the app looks normal outside a call.
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.makeWebViewTransparent() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeWebViewTransparent() {
|
||||||
|
guard let web = bridge?.webView else { return }
|
||||||
|
web.isOpaque = false
|
||||||
|
web.backgroundColor = .clear
|
||||||
|
web.scrollView.isOpaque = false // the scrollView being opaque can occlude native content behind the WebView
|
||||||
|
web.scrollView.backgroundColor = .clear
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func audioRouteChanged(_ note: Notification) {
|
||||||
|
guard room != nil else { return } // only steer the route during an active native call
|
||||||
|
// Let the engine's own route change settle first, then override if we landed on the earpiece.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.preferSpeaker() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LiveKit media
|
||||||
|
|
||||||
|
private func connectRoom(url: String, token: String) {
|
||||||
|
guard !url.isEmpty, !token.isEmpty else { return }
|
||||||
|
// Ask for mic permission now (we still connect MUTED). If it stays "undetermined", enabling the audio
|
||||||
|
// engine in didActivate can block on first use — determining it up front avoids that.
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
|
||||||
|
let old = room
|
||||||
|
// Route screen-share through the ReplayKit broadcast extension (so the user can share their screen
|
||||||
|
// even when the app is backgrounded, and it captures the whole phone, not just the WebView).
|
||||||
|
let opts = RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true))
|
||||||
|
let r = Room(roomOptions: opts)
|
||||||
|
room = r
|
||||||
|
Task { [weak self] in
|
||||||
|
await old?.disconnect() // drop any previous/stale connection so we never leave DUPLICATE participants
|
||||||
|
do {
|
||||||
|
try await r.connect(url: url, token: token)
|
||||||
|
// House rule: answer MUTED. Not enabling the mic here means nothing is captured/published
|
||||||
|
// until the user taps Mic in the meeting UI (→ setMuted(false) → setMicrophone(true)), which
|
||||||
|
// is also when iOS asks for mic permission. Playback (hearing others) is unaffected.
|
||||||
|
try await r.localParticipant.setMicrophone(enabled: false)
|
||||||
|
self?.notifyListeners("callConnected", data: ["ok": true])
|
||||||
|
} catch {
|
||||||
|
self?.notifyListeners("callError", data: ["error": String(describing: error)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disconnectRoom() {
|
||||||
|
let r = room
|
||||||
|
room = nil
|
||||||
|
transcriber.stop() // #5: end any live transcription with the call
|
||||||
|
Task { await r?.disconnect() }
|
||||||
|
removeAllTileViews()
|
||||||
|
DispatchQueue.main.async { [weak self] in guard let self = self, let web = self.bridge?.webView else { return }; self.applyHolePunchRestore(web) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Native video (tile rendering, Increment 2b)
|
||||||
|
//
|
||||||
|
// The WebView owns the meeting UI (grid, roster, controls) but — for a native call — has NO LiveKit
|
||||||
|
// connection, so it can't render any video. The video lives only in the plugin's LiveKit connection.
|
||||||
|
// So we draw native VideoViews on top of the WebView, positioned to match each web tile: the web reports
|
||||||
|
// each tile's on-screen rect + the participant's user id (syncVideoTiles), and we place/size a VideoView
|
||||||
|
// for whichever participants currently have a live camera track. Views are subviews of the WKWebView, so
|
||||||
|
// their frames use the SAME coordinate space as getBoundingClientRect (CSS px == points, both
|
||||||
|
// viewport-relative) and stay aligned as the page scrolls.
|
||||||
|
|
||||||
|
// Find the live (unmuted, subscribed) camera track for a user id — nil when the camera is off, so the web
|
||||||
|
// tile's avatar shows through instead.
|
||||||
|
private func cameraTrack(forUid uid: String, isLocal: Bool) -> VideoTrack? {
|
||||||
|
guard let room = room else { return nil }
|
||||||
|
let pubs: [TrackPublication]
|
||||||
|
if isLocal {
|
||||||
|
pubs = room.localParticipant.videoTracks
|
||||||
|
} else {
|
||||||
|
guard let p = room.remoteParticipants[Participant.Identity(from: uid)] else { return nil }
|
||||||
|
pubs = p.videoTracks
|
||||||
|
}
|
||||||
|
guard let pub = pubs.first(where: { $0.source == .camera && !$0.isMuted && $0.track != nil }) else { return nil }
|
||||||
|
return pub.track as? VideoTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remote screen-share track for a user id — nil if they aren't sharing (or it isn't subscribed yet).
|
||||||
|
// (Local screen-share isn't supported on iOS — no ReplayKit broadcast extension — so this is remote-only.)
|
||||||
|
private func screenTrack(forUid uid: String) -> VideoTrack? {
|
||||||
|
guard let room = room, let p = room.remoteParticipants[Participant.Identity(from: uid)] else { return nil }
|
||||||
|
guard let pub = p.videoTracks.first(where: { $0.source == .screenShareVideo && !$0.isMuted && $0.track != nil }) else { return nil }
|
||||||
|
return pub.track as? VideoTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tileKey(uid: String, isLocal: Bool) -> String { isLocal ? "__local" : uid }
|
||||||
|
|
||||||
|
private func removeAllTileViews() {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
for (_, vv) in self.tileViews { vv.removeFromSuperview() }
|
||||||
|
self.tileViews.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a tile view BEHIND the whole WebView (in its superview). WKWebView does NOT composite native
|
||||||
|
// subviews placed under its scrollView through transparent web content, so the video must sit behind the
|
||||||
|
// (transparent) WebView itself; the web UI then paints on top. No native overlays — the web tile draws them.
|
||||||
|
private func makeTileView(host: WKWebView, key: String) -> TileVideoView {
|
||||||
|
let v = TileVideoView(frame: .zero)
|
||||||
|
if let sup = host.superview { sup.insertSubview(v, belowSubview: host) } else { host.addSubview(v) }
|
||||||
|
tileViews[key] = v
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn hole-punch on/off. Transparency is already set at startup (makeWebViewTransparent); here we just add
|
||||||
|
// a BLACK backing to the WebView's parent (the layer directly behind the video tiles) during the call, and
|
||||||
|
// remove it + the tiles afterwards.
|
||||||
|
@objc func setHolePunch(_ call: CAPPluginCall) {
|
||||||
|
let on = call.getBool("on") ?? false
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self, let web = self.bridge?.webView else { call.resolve(); return }
|
||||||
|
self.makeWebViewTransparent() // belt-and-braces
|
||||||
|
let parent = web.superview
|
||||||
|
if on {
|
||||||
|
if !self.holePunchOn { self.savedVCBg = parent?.backgroundColor }
|
||||||
|
parent?.backgroundColor = .black
|
||||||
|
self.holePunchOn = true
|
||||||
|
} else {
|
||||||
|
self.applyHolePunchRestore(web)
|
||||||
|
for (_, vv) in self.tileViews { vv.removeFromSuperview() }
|
||||||
|
self.tileViews.removeAll()
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the black backing (leave the WebView transparent — the web body is opaque, so it looks normal).
|
||||||
|
private func applyHolePunchRestore(_ web: WKWebView) {
|
||||||
|
guard holePunchOn else { return }
|
||||||
|
web.superview?.backgroundColor = savedVCBg
|
||||||
|
holePunchOn = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web-forwarded zoom: touches land on the WebView (on top), so the web captures pinch/pan on the shared
|
||||||
|
// screen and forwards the transform here; we apply it to that tile's inner video.
|
||||||
|
@objc func setTileZoom(_ call: CAPPluginCall) {
|
||||||
|
let key = tileKey(uid: call.getString("uid") ?? "", isLocal: call.getBool("local") ?? false)
|
||||||
|
let scale = CGFloat(call.getDouble("scale") ?? 1)
|
||||||
|
let tx = CGFloat(call.getDouble("tx") ?? 0)
|
||||||
|
let ty = CGFloat(call.getDouble("ty") ?? 0)
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.tileViews[key]?.applyZoom(scale: scale, tx: tx, ty: ty) }
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected
|
||||||
|
// wired/Bluetooth headset (any non-receiver route) still wins. Re-asserted on mute toggles because enabling
|
||||||
|
// the mic can flip the route back to the earpiece.
|
||||||
|
private func preferSpeaker() {
|
||||||
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
if s.currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver }) {
|
||||||
|
try? s.overrideOutputAudioPort(.speaker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JS-callable methods
|
||||||
|
|
||||||
|
@objc func getToken(_ call: CAPPluginCall) { call.resolve(["token": voipToken]) }
|
||||||
|
|
||||||
|
// Outgoing call from the web app: start a CallKit outgoing call AND connect the LiveKit room natively.
|
||||||
|
@objc func reportOutgoingCall(_ call: CAPPluginCall) {
|
||||||
|
guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else {
|
||||||
|
call.reject("callUUID required"); return
|
||||||
|
}
|
||||||
|
let url = call.getString("url") ?? ""
|
||||||
|
let token = call.getString("token") ?? ""
|
||||||
|
let video = call.getBool("hasVideo") ?? false
|
||||||
|
calls[uuid] = ["room": call.getString("room") ?? "", "kind": call.getString("kind") ?? "dm",
|
||||||
|
"livekitUrl": url, "livekitToken": token]
|
||||||
|
activeUUID = uuid
|
||||||
|
let handle = CXHandle(type: .generic, value: call.getString("peerName") ?? "Call")
|
||||||
|
let start = CXStartCallAction(call: uuid, handle: handle)
|
||||||
|
start.isVideo = video
|
||||||
|
callController.request(CXTransaction(action: start)) { [weak self] error in
|
||||||
|
if let error = error { call.reject(error.localizedDescription); return }
|
||||||
|
self?.connectRoom(url: url, token: token)
|
||||||
|
// Start muted (house rule) — also reflect it on the CallKit system call screen.
|
||||||
|
self?.callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: true))) { _ in }
|
||||||
|
self?.provider?.reportOutgoingCall(with: uuid, connectedAt: Date())
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring CallKit from a WEBSOCKET call event (a 2nd path alongside the VoIP push). When the app is open the
|
||||||
|
// WS is connected and reliable, but the VoIP push can be delayed/dropped → "sometimes it doesn't ring".
|
||||||
|
// Deduped by UUID: if the VoIP push already reported this call, this is a no-op (and vice-versa).
|
||||||
|
@objc func reportIncomingCall(_ call: CAPPluginCall) {
|
||||||
|
guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else {
|
||||||
|
call.reject("callUUID required"); return
|
||||||
|
}
|
||||||
|
// Already handled (ended, ringing, or active) → don't re-report (CallKit would reject a dup anyway).
|
||||||
|
if endedCalls.contains(uuid) || calls[uuid] != nil || activeUUID == uuid { call.resolve(); return }
|
||||||
|
let callerName = call.getString("callerName") ?? call.getString("groupName") ?? "Incoming call"
|
||||||
|
let hasVideo = call.getBool("hasVideo") ?? false
|
||||||
|
calls[uuid] = [
|
||||||
|
"callUUID": uuidStr, "room": call.getString("room") ?? "", "kind": call.getString("kind") ?? "dm",
|
||||||
|
"callerId": call.getString("callerId") ?? "", "callerName": callerName,
|
||||||
|
"groupId": call.getString("groupId") ?? "", "groupName": call.getString("groupName") ?? "",
|
||||||
|
"hasVideo": hasVideo, "livekitUrl": call.getString("url") ?? "", "livekitToken": call.getString("token") ?? "",
|
||||||
|
]
|
||||||
|
let update = CXCallUpdate()
|
||||||
|
update.remoteHandle = CXHandle(type: .generic, value: callerName)
|
||||||
|
update.localizedCallerName = callerName
|
||||||
|
update.hasVideo = hasVideo
|
||||||
|
update.supportsHolding = false; update.supportsGrouping = false; update.supportsUngrouping = false
|
||||||
|
provider?.reportNewIncomingCall(with: uuid, update: update) { _ in }
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12 Multi-device: swap the media connection to a token whose identity = this device's mesh peerId (unique
|
||||||
|
// per connection). The web calls this once the native WebView has joined the mesh and has a peerId. On
|
||||||
|
// answer we connected INSTANTLY with the push token (identity=userId) for zero-latency audio; this re-homes
|
||||||
|
// the media onto the unique peerId identity so two devices of the same user are distinct LiveKit
|
||||||
|
// participants (LiveKit allows one connection per identity — otherwise the older device is kicked and
|
||||||
|
// "audio jumps to whichever joined last"). connectRoom disconnects the old room first; the CallKit call and
|
||||||
|
// its audio session stay active, so audio just re-attaches. callConnected fires on success → web re-applies
|
||||||
|
// mic/cam. Guarded to only run during an active call.
|
||||||
|
@objc func reconnectRoom(_ call: CAPPluginCall) {
|
||||||
|
let url = call.getString("url") ?? ""
|
||||||
|
let token = call.getString("token") ?? ""
|
||||||
|
guard room != nil, !url.isEmpty, !token.isEmpty else { call.resolve(); return }
|
||||||
|
connectRoom(url: url, token: token)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setMuted(_ call: CAPPluginCall) {
|
||||||
|
let muted = call.getBool("muted") ?? false
|
||||||
|
// Drive mute THROUGH CallKit so the system call screen and the in-app meeting UI stay in sync (the
|
||||||
|
// CXSetMutedCallAction handler does the actual setMicrophone). Fall back to a direct call if somehow
|
||||||
|
// there's no active CallKit call.
|
||||||
|
if let uuid = activeUUID {
|
||||||
|
callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: muted))) { _ in }
|
||||||
|
} else {
|
||||||
|
let r = room
|
||||||
|
Task { try? await r?.localParticipant.setMicrophone(enabled: !muted) }
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable/disable the local camera. Publishing it makes this user's video appear for everyone else (their
|
||||||
|
// web/desktop clients render it via their own SFU subscription); locally it's drawn on the __local tile by
|
||||||
|
// syncVideoTiles (the web triggers a sync right after this resolves). Front camera only for now.
|
||||||
|
@objc func setCamera(_ call: CAPPluginCall) {
|
||||||
|
guard let r = room else { call.reject("no active call"); return }
|
||||||
|
let on = call.getBool("on") ?? false
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await r.localParticipant.setCamera(
|
||||||
|
enabled: on,
|
||||||
|
captureOptions: CameraCaptureOptions(position: .front))
|
||||||
|
call.resolve(["on": on])
|
||||||
|
} catch {
|
||||||
|
call.reject("camera failed: \(String(describing: error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip the local camera between front and back.
|
||||||
|
@objc func switchCamera(_ call: CAPPluginCall) {
|
||||||
|
guard let r = room else { call.reject("no active call"); return }
|
||||||
|
let pub = r.localParticipant.videoTracks.first(where: { $0.source == .camera })
|
||||||
|
guard let track = pub?.track as? LocalVideoTrack, let cam = track.capturer as? CameraCapturer else {
|
||||||
|
call.reject("camera not active"); return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
do { _ = try await cam.switchCameraPosition(); call.resolve() }
|
||||||
|
catch { call.reject("switch failed: \(String(describing: error))") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen sharing from iOS: show the system broadcast picker. When the user starts the broadcast, the
|
||||||
|
// extension streams the screen to us over IPC and LiveKit publishes it (BroadcastManager.shouldPublishTrack
|
||||||
|
// defaults true). broadcastManager(didChangeState:) fires screenShareState back to the web either way.
|
||||||
|
@objc func startScreenShare(_ call: CAPPluginCall) {
|
||||||
|
guard room != nil else { call.reject("no active call"); return }
|
||||||
|
DispatchQueue.main.async { BroadcastManager.shared.requestActivation() } // presents RPSystemBroadcastPickerView
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func stopScreenShare(_ call: CAPPluginCall) {
|
||||||
|
BroadcastManager.shared.requestStop()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen share into a SCHEDULED / code-joined SFU meeting. The WebView already holds the meeting
|
||||||
|
// connection (identity = peerId) and WKWebView has no getDisplayMedia — so we open a SECOND, screen-ONLY
|
||||||
|
// native LiveKit connection under a distinct `<peerId>-screen` identity and publish the ReplayKit
|
||||||
|
// broadcast into the SAME room. The WebView maps that identity back onto the sharer's tile.
|
||||||
|
@objc func startMeetingScreenShare(_ call: CAPPluginCall) {
|
||||||
|
let url = call.getString("url") ?? ""
|
||||||
|
let token = call.getString("token") ?? ""
|
||||||
|
guard !url.isEmpty, !token.isEmpty else { call.reject("url/token required"); return }
|
||||||
|
connectScreenRoom(url: url, token: token)
|
||||||
|
DispatchQueue.main.async { BroadcastManager.shared.requestActivation() } // system broadcast picker
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func stopMeetingScreenShare(_ call: CAPPluginCall) {
|
||||||
|
BroadcastManager.shared.requestStop()
|
||||||
|
let r = room; room = nil
|
||||||
|
Task { await r?.disconnect() } // drop the screen-only connection (no call/tile/transcriber side effects)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A screen-ONLY LiveKit connection (mic off, no camera) used purely to publish the ReplayKit broadcast
|
||||||
|
// into an SFU meeting. Unlike connectRoom() it enables no mic and fires no callConnected — this is not a call.
|
||||||
|
private func connectScreenRoom(url: String, token: String) {
|
||||||
|
guard !url.isEmpty, !token.isEmpty else { return }
|
||||||
|
let old = room
|
||||||
|
let opts = RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true))
|
||||||
|
let r = Room(roomOptions: opts)
|
||||||
|
room = r
|
||||||
|
Task { [weak self] in
|
||||||
|
await old?.disconnect() // never leave a duplicate connection
|
||||||
|
do { try await r.connect(url: url, token: token) }
|
||||||
|
catch { self?.notifyListeners("screenShareState", data: ["sharing": false, "error": String(describing: error)]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - #5 Live transcript (native SFSpeechRecognizer)
|
||||||
|
|
||||||
|
// The current published local mic track — the source we tap for transcription. nil until the user has
|
||||||
|
// unmuted at least once (the mic is published on unmute), or between reconnects.
|
||||||
|
private func localAudioTrack() -> LocalAudioTrack? {
|
||||||
|
return room?.localParticipant.audioTracks.first?.track as? LocalAudioTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transcribing this device's mic (WKWebView has no Web Speech API). Two audio sources:
|
||||||
|
// * NATIVE CALL (default): the plugin owns the LiveKit room, so we tap the local mic track with a LiveKit
|
||||||
|
// AudioRenderer (reuses the call's open mic — reliable, no 2nd capturer). Re-attaches on unmute.
|
||||||
|
// * SCHEDULED/WEB MEETING ({external:true}): the WebView owns the mic (the plugin has no room), so the web
|
||||||
|
// reads its own mic PCM via Web Audio and pushes it here with feedAudio() — no second mic capture on iOS
|
||||||
|
// (two input units would fight and yield silence).
|
||||||
|
@objc func startTranscription(_ call: CAPPluginCall) {
|
||||||
|
if call.getBool("external") == true {
|
||||||
|
transcriber.startExternal()
|
||||||
|
} else {
|
||||||
|
transcriber.start(track: localAudioTrack())
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func stopTranscription(_ call: CAPPluginCall) {
|
||||||
|
transcriber.stop()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web-forwarded mic PCM for the {external:true} path (scheduled/web meetings). `pcm` = base64 little-endian
|
||||||
|
// Int16 mono at `rate` Hz (the web downsamples to 16 kHz). Fed straight into the recognizer.
|
||||||
|
@objc func feedAudio(_ call: CAPPluginCall) {
|
||||||
|
guard let b64 = call.getString("pcm"), let data = Data(base64Encoded: b64) else { call.resolve(); return }
|
||||||
|
let rate = call.getDouble("rate") ?? 16000
|
||||||
|
transcriber.appendPCM(int16: data, sampleRate: rate)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BroadcastManagerDelegate
|
||||||
|
|
||||||
|
public func broadcastManager(didChangeState isBroadcasting: Bool) {
|
||||||
|
notifyListeners("screenShareState", data: ["sharing": isBroadcasting])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position native video views to match the web meeting tiles. `tiles` = [{uid, local, x, y, w, h}] in
|
||||||
|
// CSS px (== points; getBoundingClientRect coords). We create/move a VideoView for each participant that
|
||||||
|
// has a live camera track, and remove views for tiles that are gone or whose camera is off (so the web
|
||||||
|
// avatar shows). Called on a short poll by the web while a native call is on screen, plus on demand.
|
||||||
|
@objc func syncVideoTiles(_ call: CAPPluginCall) {
|
||||||
|
let tiles = (call.getArray("tiles") as? [[String: Any]]) ?? []
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { call.resolve(); return }
|
||||||
|
guard let host = self.bridge?.webView else { call.resolve(); return }
|
||||||
|
var wanted = Set<String>()
|
||||||
|
for t in tiles {
|
||||||
|
guard let uid = t["uid"] as? String, !uid.isEmpty else { continue }
|
||||||
|
let isLocal = (t["local"] as? Bool) ?? false
|
||||||
|
func num(_ k: String) -> CGFloat { CGFloat((t[k] as? NSNumber)?.doubleValue ?? 0) }
|
||||||
|
let x = num("x"), y = num("y"), w = num("w"), h = num("h")
|
||||||
|
if w < 2 || h < 2 { continue }
|
||||||
|
let key = self.tileKey(uid: uid, isLocal: isLocal)
|
||||||
|
// A tile flagged `screen` is a sharer's stage tile → show their screen-share track (fit, so it
|
||||||
|
// isn't cropped); otherwise the camera (fill). Either is nil when off → web avatar shows.
|
||||||
|
let wantScreen = (t["screen"] as? Bool) ?? false
|
||||||
|
guard let track = wantScreen ? self.screenTrack(forUid: uid)
|
||||||
|
: self.cameraTrack(forUid: uid, isLocal: isLocal) else { continue }
|
||||||
|
wanted.insert(key)
|
||||||
|
let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key)
|
||||||
|
if vv.superview !== host.superview, let sup = host.superview { sup.insertSubview(vv, belowSubview: host) } // keep BEHIND the transparent WebView
|
||||||
|
vv.layoutMode = wantScreen ? .fit : .fill
|
||||||
|
if vv.track !== track { vv.track = track }
|
||||||
|
// The container tracks the tile rect. getBoundingClientRect is in the WebView's coordinate space;
|
||||||
|
// convert it into the superview (where the tiles live) — robust to any WebView offset/inset. The
|
||||||
|
// zoom transform (web-forwarded via setTileZoom) lives on the INNER video, so this never fights it.
|
||||||
|
vv.frame = host.convert(CGRect(x: x, y: y, width: w, height: h), to: host.superview)
|
||||||
|
}
|
||||||
|
// Drop views for participants no longer present / camera turned off.
|
||||||
|
for (key, vv) in self.tileViews where !wanted.contains(key) {
|
||||||
|
vv.removeFromSuperview(); self.tileViews.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End the CallKit call (remote hung up / user ended from the web UI). No callUUID -> end all.
|
||||||
|
@objc func endCall(_ call: CAPPluginCall) {
|
||||||
|
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) {
|
||||||
|
requestEnd(uuid)
|
||||||
|
} else {
|
||||||
|
for uuid in calls.keys { requestEnd(uuid) }
|
||||||
|
if let a = activeUUID { requestEnd(a) }
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestEnd(_ uuid: UUID) {
|
||||||
|
callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { _ in }
|
||||||
|
calls.removeValue(forKey: uuid)
|
||||||
|
endedCalls.insert(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PushKit (VoIP)
|
||||||
|
|
||||||
|
public func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||||
|
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
|
||||||
|
voipToken = token
|
||||||
|
notifyListeners("voipToken", data: ["token": token])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||||
|
voipToken = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming VoIP push. iOS 13+: we MUST reportNewIncomingCall for EVERY push before completion(), or the
|
||||||
|
// app is terminated.
|
||||||
|
public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||||
|
var dict: [String: Any] = [:]
|
||||||
|
for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } }
|
||||||
|
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID()
|
||||||
|
|
||||||
|
// CANCEL: caller hung up / declined / timed out. iOS requires a reported call per push, but blindly
|
||||||
|
// reporting a NEW incoming call is what caused the "rings back for a second" blip on a call we've
|
||||||
|
// already handled. So:
|
||||||
|
if (dict["type"] as? String) == "cancel" {
|
||||||
|
// iOS 13+ TERMINATES the app if a VoIP push doesn't result in reportNewIncomingCall before
|
||||||
|
// completion() — that's the crash after several calls (my earlier "no-blip" optimization SKIPPED
|
||||||
|
// the report for known/ended calls, which iOS kills for). So ALWAYS report, then immediately end:
|
||||||
|
// * uuid already ringing/active → the report errors harmlessly (NO second ring), reportCall ends it.
|
||||||
|
// * late/duplicate cancel (done) → a brief, unavoidable blip (acceptable; a crash is not).
|
||||||
|
let u = CXCallUpdate()
|
||||||
|
u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call")
|
||||||
|
let wasActive = (activeUUID == uuid)
|
||||||
|
var ev = calls[uuid] ?? [:]; ev["callUUID"] = uuid.uuidString
|
||||||
|
calls.removeValue(forKey: uuid)
|
||||||
|
endedCalls.insert(uuid)
|
||||||
|
if wasActive { disconnectRoom(); activeUUID = nil }
|
||||||
|
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
|
||||||
|
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
||||||
|
if wasActive { self?.notifyListeners("endCall", data: ev) } // active call cancelled → leave the meeting
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let callerName = (dict["callerName"] as? String) ?? (dict["groupName"] as? String) ?? "Incoming call"
|
||||||
|
let hasVideo = (dict["hasVideo"] as? Bool) ?? false
|
||||||
|
calls[uuid] = dict
|
||||||
|
|
||||||
|
let update = CXCallUpdate()
|
||||||
|
update.remoteHandle = CXHandle(type: .generic, value: callerName)
|
||||||
|
update.localizedCallerName = callerName
|
||||||
|
update.hasVideo = hasVideo
|
||||||
|
update.supportsHolding = false
|
||||||
|
update.supportsGrouping = false
|
||||||
|
update.supportsUngrouping = false
|
||||||
|
|
||||||
|
provider?.reportNewIncomingCall(with: uuid, update: update) { _ in completion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CXProviderDelegate
|
||||||
|
|
||||||
|
public func providerDidReset(_ provider: CXProvider) {
|
||||||
|
disconnectRoom()
|
||||||
|
calls.removeAll(); endedCalls.removeAll(); activeUUID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||||
|
let uuid = action.callUUID
|
||||||
|
let data = calls[uuid] ?? [:]
|
||||||
|
activeUUID = uuid
|
||||||
|
action.fulfill()
|
||||||
|
// Keep the CallKit call ACTIVE (foregrounds the app + keeps the call alive). Connect the LiveKit room
|
||||||
|
// NATIVELY so the mic works with the active CallKit call and audio survives backgrounding.
|
||||||
|
connectRoom(url: (data["livekitUrl"] as? String) ?? "", token: (data["livekitToken"] as? String) ?? "")
|
||||||
|
// We answer muted (house rule) — reflect that on the CallKit system call screen (the mic button there
|
||||||
|
// showed unmuted before, even though we were functionally muted).
|
||||||
|
callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: true))) { _ in }
|
||||||
|
var ev = data; ev["callUUID"] = uuid.uuidString
|
||||||
|
// Answering from a KILLED/locked state launches the app — the WebView's JS listener may not be attached
|
||||||
|
// yet, so retain the event until it is. Without this the "answered" signal is lost, the server's
|
||||||
|
// unanswered timer fires, and the call is cancelled ~40s after pickup.
|
||||||
|
notifyListeners("answerCall", data: ev, retainUntilConsumed: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||||
|
var data: [String: Any] = calls[action.callUUID] ?? [:]
|
||||||
|
data["callUUID"] = action.callUUID.uuidString
|
||||||
|
disconnectRoom()
|
||||||
|
notifyListeners("endCall", data: data)
|
||||||
|
calls.removeValue(forKey: action.callUUID)
|
||||||
|
endedCalls.insert(action.callUUID)
|
||||||
|
if activeUUID == action.callUUID { activeUUID = nil }
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||||
|
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date())
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||||
|
let r = room
|
||||||
|
Task { [weak self] in
|
||||||
|
try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted)
|
||||||
|
self?.preferSpeaker() // toggling the mic can flip the route back to the earpiece — re-assert speaker
|
||||||
|
// #5: unmuting publishes the mic track — (re)attach the transcriber's renderer to it if transcript is on.
|
||||||
|
if !action.isMuted { self?.transcriber.refresh(track: self?.localAudioTrack()) }
|
||||||
|
}
|
||||||
|
notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted])
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallKit owns the AVAudioSession lifecycle. Since LiveKit auto-config is OFF, configure the session HERE —
|
||||||
|
// when CallKit activates it — and enable LiveKit's audio engine. This is the fix for the intermittent
|
||||||
|
// dead mic / no-audio and late speaker routing. Don't call setActive(true): CallKit already activated it.
|
||||||
|
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||||
|
do {
|
||||||
|
// Route to the LOUDSPEAKER by default (earpiece was the .voiceChat default). .videoChat +
|
||||||
|
// .defaultToSpeaker prefers the speaker while still letting wired/Bluetooth headsets win.
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .videoChat,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
try AudioManager.shared.setEngineAvailability(.default)
|
||||||
|
preferSpeaker() // belt-and-braces: if we still landed on the earpiece, force the speaker
|
||||||
|
let route = audioSession.currentRoute.outputs.first?.portType.rawValue ?? "none"
|
||||||
|
notifyListeners("audioActivated", data: ["ok": true, "route": route])
|
||||||
|
} catch {
|
||||||
|
notifyListeners("audioActivated", data: ["ok": false, "error": String(describing: error)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||||
|
try? AudioManager.shared.setEngineAvailability(.none)
|
||||||
|
notifyListeners("audioDeactivated", data: ["ok": true])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tile view = a container holding a LiveKit VideoView. We CANNOT subclass VideoView (it's `public`, not
|
||||||
|
// `open`, so subclassing outside its module is illegal), so we compose. Under hole-punch the video sits BEHIND
|
||||||
|
// the (transparent) WebView, so touches never reach it — pinch-zoom is captured by the web and forwarded via
|
||||||
|
// applyZoom(). The container stays frame-synced to the web tile rect; the zoom transform lives on the inner
|
||||||
|
// video, so the two never fight.
|
||||||
|
final class TileVideoView: UIView {
|
||||||
|
let video = VideoView()
|
||||||
|
|
||||||
|
// Forward the two properties the plugin sets so call sites read like a VideoView.
|
||||||
|
var track: VideoTrack? { get { video.track } set { video.track = newValue } }
|
||||||
|
var layoutMode: VideoView.LayoutMode { get { video.layoutMode } set { video.layoutMode = newValue } }
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .clear // the web tile draws its own frame; gaps show the WebView's black bg
|
||||||
|
clipsToBounds = true
|
||||||
|
layer.cornerRadius = 12 // match .meet-tile's border-radius so corners don't poke past the web border
|
||||||
|
video.layoutMode = .fill
|
||||||
|
addSubview(video)
|
||||||
|
}
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
// Position via bounds+center (not frame) so it coexists with the zoom transform.
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
video.bounds = CGRect(origin: .zero, size: bounds.size)
|
||||||
|
video.center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web-forwarded zoom (scale + translation in points). scale<=1 clears the transform.
|
||||||
|
func applyZoom(scale: CGFloat, tx: CGFloat, ty: CGFloat) {
|
||||||
|
if scale <= 1.001 { if !video.transform.isIdentity { video.transform = .identity } }
|
||||||
|
else { video.transform = CGAffineTransform(translationX: tx, y: ty).scaledBy(x: scale, y: scale) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #5 Live transcript on iOS. WKWebView has no Web Speech API, so an iOS participant's speech was never captured
|
||||||
|
// into the meeting transcript (desktop Chrome/Edge already works). This transcribes THIS device's mic with
|
||||||
|
// SFSpeechRecognizer, fed by a LiveKit `AudioRenderer` attached to the local mic track — so it reuses the call's
|
||||||
|
// already-open capture (no second AVAudioEngine fighting WebRTC for the audio session). Each finished utterance
|
||||||
|
// fires `onFinal`; the plugin relays it to the web, which sends it over the meeting WS exactly like the desktop
|
||||||
|
// path. Segments are cut on a short silence gap (and on the recognizer's own isFinal), and the request is
|
||||||
|
// restarted per segment so text flows continuously and stays within the recognizer's limits. On-device
|
||||||
|
// recognition is used when available (offline, continuous, no ~1-minute cap). All state is main-confined except
|
||||||
|
// the locked `request` that the audio-thread renderer appends to.
|
||||||
|
final class SpeechTranscriber: NSObject, AudioRenderer, @unchecked Sendable {
|
||||||
|
var onFinal: ((String) -> Void)?
|
||||||
|
|
||||||
|
private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var task: SFSpeechRecognitionTask?
|
||||||
|
private weak var track: LocalAudioTrack?
|
||||||
|
private var running = false
|
||||||
|
private var latest = ""
|
||||||
|
private var lastEmitted = ""
|
||||||
|
private var silenceTimer: DispatchWorkItem?
|
||||||
|
|
||||||
|
// Begin transcription tapping a LiveKit local mic track (native calls). Idempotent; asks for Speech
|
||||||
|
// authorization once. Safe to call before the mic exists — `refresh` re-attaches when it publishes (unmute).
|
||||||
|
func start(track: LocalAudioTrack?) { begin { self.attach(track) } }
|
||||||
|
|
||||||
|
// Begin transcription in EXTERNAL mode (scheduled/web meetings): no LiveKit track to tap — PCM arrives via
|
||||||
|
// appendPCM() from the web. Same recognition pipeline.
|
||||||
|
func startExternal() { begin { } }
|
||||||
|
|
||||||
|
private func begin(_ afterStart: @escaping () -> Void) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if self.running { afterStart(); return }
|
||||||
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard status == .authorized, !self.running else { return }
|
||||||
|
self.running = true
|
||||||
|
self.beginRequest()
|
||||||
|
afterStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web-forwarded PCM (external mode): little-endian Int16 mono → Float32 buffer → recognizer.
|
||||||
|
func appendPCM(int16 data: Data, sampleRate: Double) {
|
||||||
|
let count = data.count / 2
|
||||||
|
guard count > 0,
|
||||||
|
let fmt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false),
|
||||||
|
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: AVAudioFrameCount(count)) else { return }
|
||||||
|
buf.frameLength = AVAudioFrameCount(count)
|
||||||
|
guard let dst = buf.floatChannelData?[0] else { return }
|
||||||
|
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
|
||||||
|
let src = raw.bindMemory(to: Int16.self)
|
||||||
|
for i in 0..<count { dst[i] = Float(src[i]) / 32768.0 }
|
||||||
|
}
|
||||||
|
lock.lock(); let r = request; lock.unlock()
|
||||||
|
r?.append(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Re)attach to the current local mic track — called on start and whenever the mic (re)publishes on unmute.
|
||||||
|
func refresh(track: LocalAudioTrack?) { DispatchQueue.main.async { [weak self] in self?.attach(track) } }
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.running = false
|
||||||
|
self.silenceTimer?.cancel(); self.silenceTimer = nil
|
||||||
|
self.track?.remove(audioRenderer: self); self.track = nil
|
||||||
|
self.lock.lock(); let r = self.request; self.request = nil; self.lock.unlock()
|
||||||
|
r?.endAudio(); self.task?.cancel(); self.task = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attach(_ t: LocalAudioTrack?) {
|
||||||
|
guard running, let t = t, track !== t else { return }
|
||||||
|
track?.remove(audioRenderer: self)
|
||||||
|
t.add(audioRenderer: self)
|
||||||
|
track = t
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginRequest() {
|
||||||
|
guard let recognizer = recognizer, recognizer.isAvailable else { return }
|
||||||
|
let req = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
req.shouldReportPartialResults = true // stream results; we only EMIT a segment on silence / isFinal
|
||||||
|
if recognizer.supportsOnDeviceRecognition { req.requiresOnDeviceRecognition = true }
|
||||||
|
lock.lock(); request = req; lock.unlock()
|
||||||
|
latest = ""; lastEmitted = ""
|
||||||
|
task = recognizer.recognitionTask(with: req) { [weak self] result, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let result = result {
|
||||||
|
let text = result.bestTranscription.formattedString
|
||||||
|
let isFinal = result.isFinal
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard self.running else { return }
|
||||||
|
self.latest = text
|
||||||
|
if isFinal { self.flushAndRestart() } else { self.armSilenceTimer() }
|
||||||
|
}
|
||||||
|
} else if error != nil {
|
||||||
|
DispatchQueue.main.async { if self.running { self.flushAndRestart() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short pause = end of an utterance → emit it and start a fresh request for the next one.
|
||||||
|
private func armSilenceTimer() {
|
||||||
|
silenceTimer?.cancel()
|
||||||
|
let work = DispatchWorkItem { [weak self] in guard let self = self, self.running else { return }; self.flushAndRestart() }
|
||||||
|
silenceTimer = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4, execute: work)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flushAndRestart() {
|
||||||
|
silenceTimer?.cancel(); silenceTimer = nil
|
||||||
|
let text = latest.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !text.isEmpty && text != lastEmitted { lastEmitted = text; onFinal?(text) }
|
||||||
|
guard running else { return }
|
||||||
|
lock.lock(); let r = request; request = nil; lock.unlock()
|
||||||
|
r?.endAudio(); task?.cancel(); task = nil
|
||||||
|
beginRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: AudioRenderer — receives the local mic PCM from LiveKit (audio thread).
|
||||||
|
func render(pcmBuffer: AVAudioPCMBuffer) {
|
||||||
|
lock.lock(); let r = request; lock.unlock()
|
||||||
|
r?.append(pcmBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "native-call",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Native CallKit + PushKit VoIP calling for Biz Connect (iOS)",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"author": "BizGaze",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/",
|
||||||
|
"NativeCall.podspec",
|
||||||
|
"Package.swift"
|
||||||
|
],
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/core": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ShareInbox",
|
||||||
|
platforms: [.iOS(.v15)],
|
||||||
|
products: [
|
||||||
|
.library(name: "ShareInbox", targets: ["ShareInboxPlugin"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "ShareInboxPlugin",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||||
|
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||||
|
],
|
||||||
|
path: "ios/Sources/ShareInboxPlugin")
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
|
|||||||
s.author = 'BizGaze'
|
s.author = 'BizGaze'
|
||||||
s.source = { :git => 'https://bizgaze.com/share-inbox.git', :tag => s.version.to_s }
|
s.source = { :git => 'https://bizgaze.com/share-inbox.git', :tag => s.version.to_s }
|
||||||
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
s.ios.deployment_target = '14.0'
|
s.ios.deployment_target = '15.0'
|
||||||
s.dependency 'Capacitor'
|
s.dependency 'Capacitor'
|
||||||
s.swift_version = '5.1'
|
s.swift_version = '5.1'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
"ios/",
|
"ios/",
|
||||||
"ShareInbox.podspec"
|
"ShareInbox.podspec",
|
||||||
|
"Package.swift"
|
||||||
],
|
],
|
||||||
"capacitor": {
|
"capacitor": {
|
||||||
"ios": {
|
"ios": {
|
||||||
@@ -18,9 +19,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/core": "^7.0.0"
|
"@capacitor/core": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@capacitor/core": "^7.0.0"
|
"@capacitor/core": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 113 KiB |
@@ -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}"
|
||||||
@@ -66,7 +66,7 @@ puts "App entitlements: app-group ensured (kept #{(app_ent.keys - ['com.apple.se
|
|||||||
# ── Create the extension target ──────────────────────────────────────────────────────────────────────
|
# ── Create the extension target ──────────────────────────────────────────────────────────────────────
|
||||||
# deployment_target can be nil when it's only set at the project level — fall back so we never create a
|
# deployment_target can be nil when it's only set at the project level — fall back so we never create a
|
||||||
# target with an empty minimum-OS (which Xcode then flags).
|
# target with an empty minimum-OS (which Xcode then flags).
|
||||||
deployment = app.deployment_target || project.build_configurations.first&.build_settings&.[]('IPHONEOS_DEPLOYMENT_TARGET') || '14.0'
|
deployment = app.deployment_target || project.build_configurations.first&.build_settings&.[]('IPHONEOS_DEPLOYMENT_TARGET') || '15.0'
|
||||||
ext = project.new_target(
|
ext = project.new_target(
|
||||||
:app_extension, EXT_NAME, :ios,
|
:app_extension, EXT_NAME, :ios,
|
||||||
deployment, project.products_group, :swift
|
deployment, project.products_group, :swift
|
||||||
@@ -112,5 +112,25 @@ appex = ext.product_reference
|
|||||||
build_file = embed.add_file_reference(appex)
|
build_file = embed.add_file_reference(appex)
|
||||||
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
|
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
|
||||||
|
|
||||||
|
# ── Bundle the custom notification sound (notif.wav) into the App target ─────────────────────────────
|
||||||
|
# ios-patch.sh copied it to App/App/notif.wav. Add it as a resource so it ships in the bundle root where
|
||||||
|
# APNs can find it (the server sends sound:'notif.wav'). Tolerant: never fail the build over the sound.
|
||||||
|
begin
|
||||||
|
snd_path = File.join(APP_DIR, 'App', 'notif.wav')
|
||||||
|
if File.exist?(snd_path)
|
||||||
|
grp = project.main_group.find_subpath('App', false) || project.main_group
|
||||||
|
already = app.resources_build_phase.files.any? { |bf| bf.file_ref && bf.file_ref.respond_to?(:display_name) && bf.file_ref.display_name == 'notif.wav' }
|
||||||
|
unless already
|
||||||
|
snd_ref = grp.new_reference(snd_path)
|
||||||
|
app.resources_build_phase.add_file_reference(snd_ref)
|
||||||
|
end
|
||||||
|
puts "Notification sound bundled into App resources: notif.wav"
|
||||||
|
else
|
||||||
|
puts " (notif.wav not in project — sound not bundled)"
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
puts " (notif.wav bundling skipped: #{e.message})"
|
||||||
|
end
|
||||||
|
|
||||||
project.save
|
project.save
|
||||||
puts "Share Extension injected: #{EXT_NAME} (#{EXT_BUNDLE}) embedded in #{APP_TARGET}"
|
puts "Share Extension injected: #{EXT_NAME} (#{EXT_BUNDLE}) embedded in #{APP_TARGET}"
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Inject the APNs registration-forwarding methods into the Capacitor iOS AppDelegate.
|
||||||
|
// Run on Codemagic (macOS) from ios-patch.sh:
|
||||||
|
// node mobile/scripts/inject-push.js mobile/ios/App/App/AppDelegate.swift
|
||||||
|
//
|
||||||
|
// WHY: Capacitor 7's default AppDelegate.swift template does NOT implement
|
||||||
|
// application(_:didRegisterForRemoteNotificationsWithDeviceToken:) / ...didFailToRegister...
|
||||||
|
// so when @capacitor/push-notifications calls registerForRemoteNotifications(), iOS DOES obtain the
|
||||||
|
// APNs token but the AppDelegate never posts .capacitorDidRegisterForRemoteNotifications — so the plugin
|
||||||
|
// never delivers the token to JS. register() "succeeds", yet NEITHER the `registration` nor the
|
||||||
|
// `registrationError` event ever fires (proven via server-side push telemetry: register-called logged,
|
||||||
|
// no token, no error). These two methods forward the token / error to Capacitor. The plugin listens for
|
||||||
|
// the notifications defined in @capacitor/ios CAPNotifications.swift; `import Capacitor` (already in the
|
||||||
|
// AppDelegate) exposes the Notification.Name values.
|
||||||
|
//
|
||||||
|
// TOLERANT: exits 0 and no-ops if anything is off, so it can NEVER fail the build. Idempotent (keyed on
|
||||||
|
// the bzcPushForward marker), so re-runs never duplicate the methods.
|
||||||
|
const fs = require('fs');
|
||||||
|
const p = process.argv[2];
|
||||||
|
if (!p || !fs.existsSync(p)) { console.log(' (AppDelegate.swift not found — push forwarding patch skipped)'); process.exit(0); }
|
||||||
|
try {
|
||||||
|
let s = fs.readFileSync(p, 'utf8');
|
||||||
|
if (s.includes('bzcPushForward') || s.includes('capacitorDidRegisterForRemoteNotifications')) {
|
||||||
|
console.log(' push forwarding already patched'); process.exit(0);
|
||||||
|
}
|
||||||
|
const methods = [
|
||||||
|
'',
|
||||||
|
' // bzcPushForward: forward APNs device-token registration to Capacitor. The Capacitor 7 AppDelegate',
|
||||||
|
' // template omits these, so @capacitor/push-notifications never receives the token without them.',
|
||||||
|
' func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {',
|
||||||
|
' NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
' func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {',
|
||||||
|
' NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
// Insert the methods just before the final closing brace of the file (which closes the AppDelegate class).
|
||||||
|
const orig = s;
|
||||||
|
s = s.replace(/\}\s*$/, methods + '}\n');
|
||||||
|
if (s !== orig && s.includes('bzcPushForward')) {
|
||||||
|
fs.writeFileSync(p, s);
|
||||||
|
console.log(' APNs registration forwarding methods injected into AppDelegate');
|
||||||
|
} else {
|
||||||
|
console.log(' (AppDelegate closing brace not matched — push forwarding patch skipped)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' (push forwarding patch error, skipped: ' + (e && e.message) + ')');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
@@ -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"
|
||||||
@@ -40,6 +41,21 @@ set_bool() { "$PB" -c "Add :$1 bool $2" "$PLIST" 2>/dev/null || "$PB" -c "Set :$
|
|||||||
set_bool UIFileSharingEnabled true
|
set_bool UIFileSharingEnabled true
|
||||||
set_bool LSSupportsOpeningDocumentsInPlace true
|
set_bool LSSupportsOpeningDocumentsInPlace true
|
||||||
|
|
||||||
|
# ── Keep CALL AUDIO alive when the app is BACKGROUNDED (minimised / screen locked) ──────────────────
|
||||||
|
# Without the 'audio' background mode, iOS suspends the WebView a few seconds after it backgrounds, which
|
||||||
|
# freezes the WebRTC mic + audio pipeline — so participants can't hear each other once the app is minimised
|
||||||
|
# or the phone locks. Declaring background audio keeps the audio session (and the app) running so a voice
|
||||||
|
# call continues in the background. (Video RENDERING still pauses while backgrounded — unavoidable in a
|
||||||
|
# WebView — but audio keeps flowing, which is what matters for a call.) Idempotent: rebuild the array each run.
|
||||||
|
# 'audio' keeps the call audio session alive when backgrounded; 'voip' is REQUIRED for PushKit to deliver
|
||||||
|
# VoIP pushes (CallKit incoming-call wake). Both are legitimate for a calling app and accepted by review
|
||||||
|
# because the app uses CallKit.
|
||||||
|
"$PB" -c "Delete :UIBackgroundModes" "$PLIST" 2>/dev/null || true
|
||||||
|
"$PB" -c "Add :UIBackgroundModes array" "$PLIST"
|
||||||
|
"$PB" -c "Add :UIBackgroundModes:0 string audio" "$PLIST"
|
||||||
|
"$PB" -c "Add :UIBackgroundModes:1 string voip" "$PLIST"
|
||||||
|
echo "UIBackgroundModes: audio, voip (call audio + CallKit VoIP wake)"
|
||||||
|
|
||||||
# ── Custom URL scheme so the Share Extension can bounce the user back into the app ──────────────────
|
# ── Custom URL scheme so the Share Extension can bounce the user back into the app ──────────────────
|
||||||
# The extension stages the shared files into the App Group, then opens bizconnect://share; the app reads
|
# The extension stages the shared files into the App Group, then opens bizconnect://share; the app reads
|
||||||
# the staged files and shows "Send to…". Registering the scheme is what makes that openURL succeed.
|
# the staged files and shows "Send to…". Registering the scheme is what makes that openURL succeed.
|
||||||
@@ -51,6 +67,39 @@ if ! "$PB" -c "Print :CFBundleURLTypes" "$PLIST" >/dev/null 2>&1; then
|
|||||||
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string bizconnect" "$PLIST"
|
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string bizconnect" "$PLIST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Push Notifications entitlement (aps-environment) ────────────────────────────────────────────────
|
||||||
|
# The @capacitor/push-notifications plugin does NOT add the Push Notifications capability to the generated
|
||||||
|
# Xcode project — in Xcode that's a manual "Signing & Capabilities → + Push Notifications" click, which
|
||||||
|
# never happens on a fresh CI checkout. Without the aps-environment entitlement, PushNotifications.register()
|
||||||
|
# fails on device ("no valid 'aps-environment' entitlement string found") and NO APNs token is ever
|
||||||
|
# obtained, so device_tokens stays empty and the server has nothing to push to. Create the entitlements
|
||||||
|
# file with aps-environment HERE; add-share-extension.rb (runs after this) MERGES the App Group into the
|
||||||
|
# same file, preserving this key. REQUIRES: the App ID com.bizgaze.connect must have the Push Notifications
|
||||||
|
# capability enabled in the Apple Developer portal, so the fetched provisioning profile carries
|
||||||
|
# aps-environment — otherwise the archive fails code-signing. "production" is correct for App Store +
|
||||||
|
# TestFlight (pair it with APNS_PRODUCTION=1 on the server).
|
||||||
|
ENT="mobile/ios/App/App/App.entitlements"
|
||||||
|
if [ ! -f "$ENT" ]; then
|
||||||
|
cat > "$ENT" <<'PLIST'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
fi
|
||||||
|
"$PB" -c "Add :aps-environment string production" "$ENT" 2>/dev/null || "$PB" -c "Set :aps-environment production" "$ENT"
|
||||||
|
echo "Entitlements: aps-environment=production ensured in $ENT"
|
||||||
|
|
||||||
|
# ── Screen sharing from iOS (ReplayKit broadcast extension + LiveKit) ───────────────────────────────
|
||||||
|
# LiveKit's BroadcastManager finds the broadcast upload extension + the shared App Group via these two keys
|
||||||
|
# (BroadcastBundleInfo reads RTCScreenSharingExtension / RTCAppGroupIdentifier). They match the extension
|
||||||
|
# added by add-broadcast-extension.rb (bundle id <app>.broadcast) and the App Group used by the share
|
||||||
|
# extension. Set explicitly so there's no reliance on the default-derivation.
|
||||||
|
set_str RTCScreenSharingExtension "com.bizgaze.connect.broadcast"
|
||||||
|
set_str RTCAppGroupIdentifier "group.com.bizgaze.connect"
|
||||||
|
|
||||||
# We use only standard encryption (HTTPS/TLS), which is exempt — declaring this up front stops App Store
|
# 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 \
|
||||||
@@ -65,12 +114,31 @@ fi
|
|||||||
# the speaker (headphones/Bluetooth still win when connected). The helper is tolerant and exits 0 even if
|
# the speaker (headphones/Bluetooth still win when connected). The helper is tolerant and exits 0 even if
|
||||||
# the template differs, so it NEVER fails the build. First-pass fix — if WebRTC re-grabs the session
|
# the template differs, so it NEVER fails the build. First-pass fix — if WebRTC re-grabs the session
|
||||||
# mid-call on device, we follow up with a plugin that re-asserts .overrideOutputAudioPort(.speaker).
|
# mid-call on device, we follow up with a plugin that re-asserts .overrideOutputAudioPort(.speaker).
|
||||||
|
# ── Bundle the custom notification sound so chat/call pushes have an explicit tone ──────────────────
|
||||||
|
# APNs plays the sound named in the push payload (the server sends sound:'notif.wav'). The file must be a
|
||||||
|
# resource in the app bundle ROOT; copy it in here — add-share-extension.rb then adds it to the App
|
||||||
|
# target's "Copy Bundle Resources". iOS accepts a PCM .wav (<30s) for a notification sound.
|
||||||
|
SND_SRC="mobile/ios-assets/notif.wav"
|
||||||
|
SND_DST="mobile/ios/App/App/notif.wav"
|
||||||
|
if [ -f "$SND_SRC" ]; then cp "$SND_SRC" "$SND_DST" && echo "Copied notification sound -> $SND_DST"; else echo " (notif.wav source missing — sound not bundled)"; fi
|
||||||
|
|
||||||
AD="mobile/ios/App/App/AppDelegate.swift"
|
AD="mobile/ios/App/App/AppDelegate.swift"
|
||||||
if [ -f "$AD" ]; then
|
if [ -f "$AD" ]; then
|
||||||
echo "Patching AppDelegate audio session"
|
echo "Patching AppDelegate audio session"
|
||||||
node "$(dirname "$0")/inject-audio.js" "$AD" || echo " (AVAudioSession patch skipped — non-fatal)"
|
node "$(dirname "$0")/inject-audio.js" "$AD" || echo " (AVAudioSession patch skipped — non-fatal)"
|
||||||
|
# Capacitor 7's AppDelegate template omits the APNs registration callbacks, so the push-notifications
|
||||||
|
# plugin never receives the device token (register() fires no event at all). Inject the forwarding methods.
|
||||||
|
echo "Patching AppDelegate APNs registration forwarding"
|
||||||
|
node "$(dirname "$0")/inject-push.js" "$AD" || echo " (push forwarding patch skipped — non-fatal)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── LiveKit under SPM (Capacitor 8) ─────────────────────────────────────────────────────────────────
|
||||||
|
# LiveKit is now a proper Swift Package Manager dependency declared in the native-call plugin's Package.swift
|
||||||
|
# (github.com/livekit/client-sdk-swift, exact 2.15.3) — SPM resolves it + its WebRTC/UniFFI/SwiftProtobuf
|
||||||
|
# sub-packages at build time. So there is NO Podfile to patch here anymore (the old CocoaPods git-tag pin
|
||||||
|
# hack is gone).
|
||||||
|
|
||||||
echo "Info.plist patched:"
|
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"
|
||||||
|
|||||||
@@ -2,23 +2,26 @@
|
|||||||
// ends (with a duration line in the chat) when the last participant's mesh room empties.
|
// ends (with a duration line in the chat) when the last participant's mesh room empties.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
const R = require('./repos');
|
const R = require('./repos');
|
||||||
const A = require('./auth');
|
const A = require('./auth');
|
||||||
const CHAT = require('./chat');
|
const CHAT = require('./chat');
|
||||||
|
const PUSH = require('./push'); // native push (APNs/FCM/WebPush) so a CLOSED app is notified of calls
|
||||||
|
const LK = require('./livekit'); // mint the callee's LiveKit join token for the native VoIP call payload
|
||||||
const { TRANS_DIR } = require('./config');
|
const { TRANS_DIR } = require('./config');
|
||||||
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
|
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
|
||||||
const now = () => Date.now();
|
const now = () => Date.now();
|
||||||
const pairKey = (a, b) => [a, b].sort().join('|');
|
const pairKey = (a, b) => [a, b].sort().join('|');
|
||||||
|
|
||||||
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
|
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
|
||||||
function meetingContext(room) {
|
async function meetingContext(room) {
|
||||||
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
|
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
|
||||||
try {
|
try {
|
||||||
const sched = R.scheduledMeetings.byCode(room);
|
const sched = await R.scheduledMeetings.byCode(room);
|
||||||
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
|
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
|
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
|
||||||
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
|
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = await R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
|
||||||
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
|
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -26,78 +29,87 @@ function meetingContext(room) {
|
|||||||
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
|
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
|
||||||
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
|
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
|
||||||
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
|
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
|
||||||
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 = 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 = 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).
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
|
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
|
||||||
|
|
||||||
function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
|
async function broadcast(group, evt) { try { for (const mid of await R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
|
||||||
|
|
||||||
// Post a centered activity line into the group (system sender → no ping on clients).
|
// Post a centered activity line into the group (system sender → no ping on clients).
|
||||||
function postSystem(group, teamId, text) {
|
async function postSystem(group, teamId, text) {
|
||||||
const id = A.id();
|
const id = A.id();
|
||||||
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
|
await R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
|
||||||
const m = R.messages.byId(id);
|
const m = await R.messages.byId(id);
|
||||||
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
|
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function startGroupCall(group, teamId, user) {
|
async function startGroupCall(group, teamId, user) {
|
||||||
const existing = groupCalls.get(group);
|
const existing = groupCalls.get(group);
|
||||||
if (existing) return { room: existing.room, active: true, already: true };
|
if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
|
||||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||||
meetingRooms.set(room, new Map());
|
meetingRooms.set(room, new Map());
|
||||||
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email, left: new Set() };
|
||||||
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
||||||
try { const hid = A.id(); 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
|
||||||
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call');
|
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {});
|
||||||
let gName = 'Group'; try { const g = 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 (_) {}
|
||||||
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
|
broadcast(group, { type: 'group-call', group, active: true, room, uuid: call.uuid, by: user.id, startedByName: call.startedByName, groupName: gName });
|
||||||
return { room, active: true };
|
// Notify the OTHER members so a closed app is alerted to the group call — VoIP/CallKit if available,
|
||||||
|
// else a banner. broadcast() above only reaches connected sockets. Best-effort; never throws.
|
||||||
|
try { for (const mid of await R.conversations.members(group)) { if (mid !== user.id) PUSH.sendCallNotification(mid, { callUUID: call.uuid, room, kind: 'group', groupId: group, groupName: gName, callerId: user.id, callerName: call.startedByName, title: gName, body: '📞 ' + call.startedByName + ' started a group call', hasVideo: true, livekitUrl: LK.LIVEKIT_URL, livekitToken: LK.livekitToken(mid, null, room) }); } } catch (_) {}
|
||||||
|
return { room, uuid: call.uuid, active: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called from signaling when a mesh room empties — ends the group call if this room was one.
|
// Called from signaling when a mesh room empties — ends the group call if this room was one.
|
||||||
function endGroupCallByRoom(room) {
|
async function endGroupCallByRoom(room) {
|
||||||
const group = roomToGroupCall.get(room);
|
const group = roomToGroupCall.get(room);
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
const call = groupCalls.get(group);
|
const call = groupCalls.get(group);
|
||||||
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
|
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
|
||||||
if (call) {
|
if (call) {
|
||||||
let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {}
|
let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
|
||||||
if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
||||||
broadcast(group, { type: 'group-call', group, active: false, room });
|
broadcast(group, { type: 'group-call', group, active: false, room, uuid: call.uuid });
|
||||||
|
// Stop any CallKit ring on members' killed/backgrounded devices.
|
||||||
|
try { for (const mid of await R.conversations.members(group)) { if (mid !== call.startedBy) PUSH.sendCallCancel(mid, call.uuid); } } catch (_) {} // not the starter — a cancel to them re-rings their own phone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
|
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
|
||||||
function startDmCall(me, otherId, teamId) {
|
async function startDmCall(me, otherId, teamId) {
|
||||||
const key = pairKey(me.id, otherId);
|
const key = pairKey(me.id, otherId);
|
||||||
const existing = dmCalls.get(key);
|
const existing = dmCalls.get(key);
|
||||||
if (existing) return { room: existing.room, active: true, already: true };
|
if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
|
||||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
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, 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(); 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
|
||||||
// #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed —
|
// #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed —
|
||||||
// so the caller isn't stuck "ringing" forever.
|
// so the caller isn't stuck "ringing" forever.
|
||||||
@@ -109,30 +121,52 @@ function startDmCall(me, otherId, teamId) {
|
|||||||
}, 40000);
|
}, 40000);
|
||||||
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
|
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
|
||||||
const mid = A.id();
|
const mid = A.id();
|
||||||
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
|
await R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
|
||||||
const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
|
const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
|
||||||
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
|
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||||
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
|
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||||
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
|
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, uuid: call.uuid, with: me.id, by: me.id, byName }); } catch (_) {}
|
||||||
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
|
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, uuid: call.uuid, with: otherId, by: me.id, byName }); } catch (_) {}
|
||||||
return { room, active: true };
|
// Notify the callee so a CLOSED app still rings — VoIP/CallKit if the device registered a VoIP token,
|
||||||
|
// else a banner (the CHAT.pushToUser events above only reach a connected socket). Best-effort. On
|
||||||
|
// reconnect the callee's app also re-shows the invite (replayActiveCalls), so answering works either way.
|
||||||
|
try { PUSH.sendCallNotification(otherId, { callUUID: call.uuid, room, kind: 'dm', callerId: me.id, callerName: byName, title: byName, body: '📞 Incoming call', hasVideo: false, livekitUrl: LK.LIVEKIT_URL, livekitToken: LK.livekitToken(otherId, null, room) }); } catch (_) {}
|
||||||
|
return { room, uuid: call.uuid, active: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function endDmCallByRoom(room, silent) {
|
async function endDmCallByRoom(room, silent) {
|
||||||
const key = roomToDmCall.get(room); if (!key) return;
|
const key = roomToDmCall.get(room); if (!key) return;
|
||||||
const call = dmCalls.get(key);
|
const call = dmCalls.get(key);
|
||||||
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
|
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
|
||||||
if (!call) return;
|
if (!call) return;
|
||||||
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
|
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
|
||||||
if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
|
if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
|
||||||
|
// A native side that ends the call (POST /api/calls/end) may have a dropped WebSocket, so the OTHER party
|
||||||
|
// can be left sitting in the mesh meeting "still in the call". Close their window. Idempotent: when this
|
||||||
|
// runs from the mesh emptying normally, the room is already gone → no-op (so it never double-ends).
|
||||||
|
const stuck = meetingRooms.get(room);
|
||||||
|
if (stuck) { for (const [, p] of stuck) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
|
||||||
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
|
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
|
||||||
if (!silent) try {
|
if (!silent) try {
|
||||||
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
|
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
|
||||||
R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
|
await R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
|
||||||
const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
|
const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
|
||||||
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
|
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
|
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, uuid: call.uuid, with: call.users[1 - i], room }); } catch (_) {} });
|
||||||
|
// Stop the CallKit ring on a device that's still RINGING (unanswered) — a killed/asleep callee has no WS
|
||||||
|
// to receive the dm-call above. For an ANSWERED call both sides are awake (the WS event ends it), and a
|
||||||
|
// cancel push would re-ring the device that just hung up — so only cancel when it was NOT answered.
|
||||||
|
// Cancel the ring ONLY on the CALLEE's device(s) — never the caller (startedBy). The caller is placing the
|
||||||
|
// call, not ringing, so a cancel push to them made their OWN phone re-ring after they hung up an unanswered
|
||||||
|
// outgoing call (the plugin must reportNewIncomingCall for every VoIP push → a phantom ring on the caller).
|
||||||
|
if (!call.answered) { const callee = call.users.find((u) => u !== call.startedBy); if (callee) { try { PUSH.sendCallCancel(callee, call.uuid); } catch (_) {} } }
|
||||||
|
// Missed-call banner to the callee (a plain notification, like a phone's missed call) when the call ended
|
||||||
|
// UNANSWERED — timeout or the caller hung up before pickup. Skipped on decline (silent): they chose to.
|
||||||
|
if (!silent && !call.answered) {
|
||||||
|
const callee = call.users.find((u) => u !== call.startedBy);
|
||||||
|
if (callee) { try { PUSH.sendToUser(callee, { title: call.startedByName || 'Missed call', body: '📞 Missed call', kind: 'dm', id: call.startedBy, tag: 'missed:' + room, data: { kind: 'dm', id: call.startedBy } }); } catch (_) {} }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
|
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
|
||||||
@@ -140,15 +174,67 @@ function endDmCallByRoom(room, silent) {
|
|||||||
function markDmAnswered(room, userId) {
|
function markDmAnswered(room, userId) {
|
||||||
const key = roomToDmCall.get(room); if (!key) return;
|
const key = roomToDmCall.get(room); if (!key) return;
|
||||||
const call = dmCalls.get(key); if (!call) return;
|
const call = dmCalls.get(key); if (!call) return;
|
||||||
if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } }
|
if (userId && userId !== call.startedBy && !call.answered) {
|
||||||
|
call.answered = true; call.answeredAt = now();
|
||||||
|
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; }
|
||||||
|
// Native calls carry media over LiveKit, not our mesh — so the callee's OTHER devices never learn the
|
||||||
|
// call was picked up here and keep ringing forever (and dismissing that stale ring as a "decline" would
|
||||||
|
// tear down THIS live call). Tell them it was taken (dismiss the ring, no teardown), and flip the
|
||||||
|
// caller's UI from "ringing" to "connected".
|
||||||
|
try { CHAT.pushToUser(userId, { type: 'call-taken', room, uuid: call.uuid }); } catch (_) {}
|
||||||
|
try { CHAT.pushToUser(call.startedBy, { type: 'call-answered', room, uuid: call.uuid, by: userId }); } catch (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// When a user's chat socket (re)connects, re-send any call they're currently being rung into. The
|
||||||
|
// original dm-call / group-call events fire ONCE at call start, so an app that was closed then misses
|
||||||
|
// them. This makes the call PUSH actionable: tapping the banner opens the app, the socket connects, and
|
||||||
|
// the invite re-appears so they can answer (while the caller is still within the ring window). Sends only
|
||||||
|
// to the freshly-connected socket. Best-effort; never throws.
|
||||||
|
async function replayActiveCalls(userId, ws) {
|
||||||
|
if (!userId || !ws || ws.readyState !== 1) return;
|
||||||
|
try {
|
||||||
|
for (const [, call] of dmCalls) {
|
||||||
|
if (call.answered) continue;
|
||||||
|
if (call.users.includes(userId) && call.startedBy !== userId) {
|
||||||
|
// #New1: if this user already LEFT the call, don't ring them back in (noRing) — just refresh the "Join" state.
|
||||||
|
const noRing = !!(call.left && call.left.has(userId));
|
||||||
|
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName, noRing })); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [group, call] of groupCalls) {
|
||||||
|
if (call.startedBy === userId) continue;
|
||||||
|
let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
|
||||||
|
if (!member) continue;
|
||||||
|
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
|
||||||
|
const noRing = !!(call.left && call.left.has(userId)); // #New1: left already → refresh Join, don't re-ring
|
||||||
|
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName, noRing })); } catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #New1: a participant who EXPLICITLY leaves an active (still-running) call must not be auto-rung back into
|
||||||
|
// it. We remember who left per call; replayActiveCalls (on their next socket reconnect) then sends the call
|
||||||
|
// state with noRing:true so their client refreshes the passive "Join" affordance without ringing / popping
|
||||||
|
// CallKit again. Before this, every reconnect (constant on mobile) re-rang the leaver until the call ended.
|
||||||
|
function callForRoom(room) {
|
||||||
|
const gid = roomToGroupCall.get(room); if (gid) { const c = groupCalls.get(gid); if (c) return c; }
|
||||||
|
const key = roomToDmCall.get(room); if (key) { const c = dmCalls.get(key); if (c) return c; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function markLeft(room, userId) { if (!userId) return; const c = callForRoom(room); if (c) { if (!c.left) c.left = new Set(); c.left.add(userId); } }
|
||||||
|
function clearLeft(room, ids) { const c = callForRoom(room); if (c && c.left) { for (const id of (ids || [])) c.left.delete(id); } } // an explicit re-invite should ring again
|
||||||
|
|
||||||
// Called from signaling when any mesh room empties.
|
// Called from signaling when any mesh room empties.
|
||||||
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); }
|
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
|
||||||
|
|
||||||
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
|
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
|
||||||
function declineDmCall(room, byUser) {
|
async function declineDmCall(room, byUser) {
|
||||||
const key = roomToDmCall.get(room); if (!key) return { ok: false };
|
const key = roomToDmCall.get(room); if (!key) return { ok: false };
|
||||||
const call = dmCalls.get(key); if (!call) return { ok: false };
|
const call = dmCalls.get(key); if (!call) return { ok: false };
|
||||||
|
// Already ANSWERED (e.g. a native CallKit pickup on this user's other device, which bypasses our mesh so
|
||||||
|
// the check below can't see it): a "decline" here is just the stale ring on a second device — dismiss it,
|
||||||
|
// do NOT tear down the live call.
|
||||||
|
if (call.answered && byUser.id !== call.startedBy) return { ok: true, alreadyAnswered: true };
|
||||||
// #8: if this user has ALREADY accepted on another device (they're in the room), this is just the
|
// #8: if this user has ALREADY accepted on another device (they're in the room), this is just the
|
||||||
// ringing invite on a second device — dismiss it silently, do NOT tear down the active call.
|
// ringing invite on a second device — dismiss it silently, do NOT tear down the active call.
|
||||||
const inRoom = meetingRooms.get(room);
|
const inRoom = meetingRooms.get(room);
|
||||||
@@ -156,8 +242,8 @@ function declineDmCall(room, byUser) {
|
|||||||
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
|
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
|
||||||
try {
|
try {
|
||||||
const mid = A.id();
|
const mid = A.id();
|
||||||
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
|
await R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
|
||||||
const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
|
const mm = await R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
|
||||||
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
|
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
|
||||||
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
|
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -168,4 +254,40 @@ function declineDmCall(room, byUser) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, finalizeTranscript, meetingContext, fmtDur, pairKey };
|
// #15: adding a 3rd person to a 1:1 (DM) call turns it into a real, PERSISTENT group call. Two wins:
|
||||||
|
// (1) the call now survives anyone leaving (a group call only ends when the room empties), and
|
||||||
|
// (2) an added person who drops can rejoin — they're a group member, so the group's active-call banner
|
||||||
|
// (replayActiveCalls / group-call) reappears for them.
|
||||||
|
// The SAME room/uuid/startedAt are kept, so live media + the transcript continue uninterrupted; we just move
|
||||||
|
// the room's bookkeeping from dmCalls → groupCalls and create the backing group conversation. Returns the new
|
||||||
|
// group id, or null when `room` isn't a DM call (a group/ad-hoc call needs no promotion — caller invites as usual).
|
||||||
|
async function promoteDmToGroup(room, inviter, inviteeIds) {
|
||||||
|
const key = roomToDmCall.get(room); if (!key) return null;
|
||||||
|
const call = dmCalls.get(key); if (!call) return null;
|
||||||
|
const teamId = call.teamId || (inviter && inviter.team_id);
|
||||||
|
const memberIds = [...new Set([...(call.users || []), ...(inviteeIds || [])])].filter(Boolean);
|
||||||
|
if (memberIds.length < 3) return null; // nothing new actually added → stay a 1:1
|
||||||
|
// Friendly name from participant first-names (e.g. "Ravi, Sara, Alex"), capped so it doesn't run long.
|
||||||
|
const names = [];
|
||||||
|
for (const uid of memberIds) { let usr = null; try { usr = await R.users.byId(uid); } catch (_) {} if (usr) names.push(((usr.name || usr.email || '').trim().split(/\s+/)[0]) || usr.email || 'Someone'); }
|
||||||
|
const groupName = (names.slice(0, 4).join(', ') + (names.length > 4 ? ' +' + (names.length - 4) : '')) || 'Group call';
|
||||||
|
const owner = call.startedBy || (inviter && inviter.id);
|
||||||
|
const gid = A.id();
|
||||||
|
await R.conversations.create({ id: gid, teamId, name: groupName, createdBy: owner });
|
||||||
|
for (const uid of memberIds) { try { await R.conversations.addMember(gid, uid, uid === owner); } catch (_) {} }
|
||||||
|
// Migrate the LIVE call: DM → group (same room/uuid/history so media, transcript and Past-meetings all continue).
|
||||||
|
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; }
|
||||||
|
dmCalls.delete(key); roomToDmCall.delete(room);
|
||||||
|
groupCalls.set(gid, { room, uuid: call.uuid, startedAt: call.startedAt, startedBy: owner, startedByName: call.startedByName, teamId, historyId: call.historyId, left: new Set() });
|
||||||
|
roomToGroupCall.set(room, gid);
|
||||||
|
postSystem(gid, teamId, '📞 ' + (call.startedByName || 'Someone') + ' turned this into a group call').catch(() => {});
|
||||||
|
// Tell every member's client: refresh the sidebar (the new group appears) and mark the call active (banner
|
||||||
|
// + ring the people not already in the room). Those already in the call ignore the ring (same room).
|
||||||
|
for (const uid of memberIds) {
|
||||||
|
try { CHAT.pushToUser(uid, { type: 'group-update', group: gid }); } catch (_) {}
|
||||||
|
try { CHAT.pushToUser(uid, { type: 'group-call', group: gid, active: true, room, uuid: call.uuid, by: owner, startedByName: call.startedByName, groupName }); } catch (_) {}
|
||||||
|
}
|
||||||
|
return gid;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, promoteDmToGroup, markLeft, clearLeft, finalizeTranscript, meetingContext, fmtDur, pairKey };
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
|
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends `chat-hello`;
|
||||||
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
|
// signaling.js registers the socket here. Messages are persisted over HTTP (routes.js) and pushed live to
|
||||||
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
|
// the recipient's sockets via pushToUser().
|
||||||
|
//
|
||||||
|
// MULTI-INSTANCE: local delivery (to sockets on THIS process) is unchanged. Every push is ALSO published
|
||||||
|
// via the swappable pubsub layer so, when >1 instance runs, a recipient connected to another instance
|
||||||
|
// still gets it. With the default in-memory pubsub (single instance) publish is a no-op, so this is
|
||||||
|
// behaviourally identical to before — no hot-path cost.
|
||||||
const { chatClients, meetingRooms } = require('./presence');
|
const { chatClients, meetingRooms } = require('./presence');
|
||||||
|
const pubsub = require('./pubsub');
|
||||||
let _repos = null; const repos = () => (_repos || (_repos = require('./repos'))); // lazy: avoid a require cycle
|
let _repos = null; const repos = () => (_repos || (_repos = require('./repos'))); // lazy: avoid a require cycle
|
||||||
|
|
||||||
|
// Deliver an already-built or plain object to a user's sockets on THIS instance.
|
||||||
|
function deliverLocal(userId, obj) {
|
||||||
|
const s = chatClients.get(userId);
|
||||||
|
if (!s) return;
|
||||||
|
const data = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
||||||
|
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
|
||||||
|
}
|
||||||
|
|
||||||
function register(userId, ws) {
|
function register(userId, ws) {
|
||||||
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
|
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
|
||||||
chatClients.get(userId).add(ws);
|
chatClients.get(userId).add(ws);
|
||||||
ws._chatUserId = userId;
|
ws._chatUserId = userId;
|
||||||
try { repos().users.touchSeen(userId); } catch (_) {} // "last seen" (#2)
|
repos().users.touchSeen(userId).catch(() => {}); // "last seen" (#2) — fire-and-forget UPDATE
|
||||||
}
|
}
|
||||||
|
|
||||||
function unregister(ws) {
|
function unregister(ws) {
|
||||||
@@ -18,7 +32,7 @@ function unregister(ws) {
|
|||||||
if (set) {
|
if (set) {
|
||||||
set.delete(ws);
|
set.delete(ws);
|
||||||
// Their LAST socket went away → stamp when they were last here, so contacts can show "last seen …".
|
// Their LAST socket went away → stamp when they were last here, so contacts can show "last seen …".
|
||||||
if (!set.size) { chatClients.delete(id); try { repos().users.touchSeen(id); } catch (_) {} }
|
if (!set.size) { chatClients.delete(id); repos().users.touchSeen(id).catch(() => {}); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,37 +42,49 @@ function isOnline(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushToUser(userId, obj) {
|
function pushToUser(userId, obj) {
|
||||||
const s = chatClients.get(userId);
|
deliverLocal(userId, obj); // sockets on this instance
|
||||||
if (!s) return;
|
pubsub.publish('u:' + userId, obj); // other instances (no-op on the memory backend)
|
||||||
const data = JSON.stringify(obj);
|
|
||||||
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Live presence -------------------------------------------------------------------------
|
// --- Live presence -------------------------------------------------------------------------
|
||||||
// A user's effective status (online / in-a-call / away / …) changes with no HTTP round-trip:
|
// A user's effective status (online / in-a-call / away / …) changes with no HTTP round-trip: they connect
|
||||||
// they connect or disconnect a socket, or join/leave a call. Without pushing that change, OTHER
|
// or disconnect a socket, or join/leave a call. Without pushing that change, OTHER users only see it after
|
||||||
// users only see it after a full page reload — impossible in the desktop/mobile apps. So whenever
|
// a full page reload — impossible in the desktop/mobile apps. So whenever it changes we broadcast the
|
||||||
// it changes we broadcast the user's fresh status to everyone else's sockets, and the client
|
// user's fresh status to everyone else's sockets, and the client updates that contact's dot/subtitle.
|
||||||
// updates that contact's dot/subtitle in place.
|
|
||||||
function isInCall(userId) {
|
function isInCall(userId) {
|
||||||
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId === userId) return true; } }
|
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId === userId) return true; } }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function effectiveStatus(userId) {
|
async function effectiveStatus(userId) {
|
||||||
if (isInCall(userId)) return 'incall'; // derived (overrides the stored status)
|
if (isInCall(userId)) return 'incall'; // derived (overrides the stored status)
|
||||||
try { const u = repos().users.byId(userId); return (u && u.status) || 'active'; } catch (_) { return 'active'; }
|
try { const u = await repos().users.byId(userId); return (u && u.status) || 'active'; } catch (_) { return 'active'; }
|
||||||
}
|
}
|
||||||
function broadcastPresence(userId) {
|
// Send a presence payload to every local socket EXCEPT the subject's own.
|
||||||
if (!userId) return;
|
function deliverPresenceLocal(subjectId, payload) {
|
||||||
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
|
|
||||||
// bare "Offline" until the next sidebar reload (#2).
|
|
||||||
let lastSeen = null;
|
|
||||||
try { const u = repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
|
|
||||||
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: effectiveStatus(userId), lastSeen });
|
|
||||||
for (const [uid, set] of chatClients) {
|
for (const [uid, set] of chatClients) {
|
||||||
if (uid === userId) continue; // no need to tell someone about their own status
|
if (uid === subjectId) continue;
|
||||||
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
|
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function broadcastPresence(userId) {
|
||||||
|
if (!userId) return;
|
||||||
|
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
|
||||||
|
// bare "Offline" until the next sidebar reload (#2).
|
||||||
|
const online = isOnline(userId);
|
||||||
|
let lastSeen = null;
|
||||||
|
try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
|
||||||
|
// #8: never broadcast a NULL last-seen for someone who's offline — touchSeen() is fire-and-forget on
|
||||||
|
// disconnect, so the DB write can lag this broadcast and the client would flap to a bare "Offline". They're
|
||||||
|
// leaving now, so "just now" is accurate.
|
||||||
|
if (!lastSeen && !online) lastSeen = Date.now();
|
||||||
|
const payload = JSON.stringify({ type: 'presence', userId, online, status: await effectiveStatus(userId), lastSeen });
|
||||||
|
deliverPresenceLocal(userId, payload); // this instance
|
||||||
|
pubsub.publish('presence', { userId, payload }); // other instances (no-op on memory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-instance inbound: deliver events published by OTHER instances to our local sockets. On the memory
|
||||||
|
// backend these never fire; on redis they carry the fan-out. (Buffered until pubsub.init() connects.)
|
||||||
|
pubsub.subscribe('u:*', (channel, obj) => deliverLocal(channel.slice(2), obj));
|
||||||
|
pubsub.subscribe('presence', (channel, msg) => { if (msg && msg.payload) deliverPresenceLocal(msg.userId, msg.payload); });
|
||||||
|
|
||||||
module.exports = { register, unregister, isOnline, pushToUser, broadcastPresence };
|
module.exports = { register, unregister, isOnline, pushToUser, broadcastPresence };
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://remote.bizgaze.
|
|||||||
// calls our /api/gifs proxy. GIF picker is hidden when this isn't configured.
|
// calls our /api/gifs proxy. GIF picker is hidden when this isn't configured.
|
||||||
const GIPHY_API_KEY = process.env.GIPHY_API_KEY || '';
|
const GIPHY_API_KEY = process.env.GIPHY_API_KEY || '';
|
||||||
|
|
||||||
|
// Native CallKit / PushKit VoIP calling (iOS). Config-gated so it can be flipped WITHOUT an app rebuild:
|
||||||
|
// OFF (default) → calls use the WebView flow (works today); ON → iOS rings via CallKit + a VoIP push.
|
||||||
|
// Only turn ON once native LiveKit media carries the call audio — a CallKit call reserves the mic, so the
|
||||||
|
// WebView's WebRTC can't capture it (mic dead). Set CALLKIT_ENABLED=1 in the server .env to enable.
|
||||||
|
const CALLKIT_ENABLED = process.env.CALLKIT_ENABLED === '1';
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
PORT: process.env.PORT || 8090,
|
PORT: process.env.PORT || 8090,
|
||||||
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
|
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
|
||||||
@@ -58,11 +64,15 @@ module.exports = {
|
|||||||
SMTP_ENABLED,
|
SMTP_ENABLED,
|
||||||
PUBLIC_BASE_URL,
|
PUBLIC_BASE_URL,
|
||||||
GIPHY_API_KEY,
|
GIPHY_API_KEY,
|
||||||
|
CALLKIT_ENABLED,
|
||||||
PUBLIC_DIR,
|
PUBLIC_DIR,
|
||||||
REC_DIR,
|
REC_DIR,
|
||||||
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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// PostgreSQL backend for the async DB adapter — the ONLY backend (SQLite retired 2026-08-12). Implements
|
||||||
|
// prepare(sql).{get,all,run}, exec(sql), tx(fn), init() so repos/app code stay engine-agnostic (the facade
|
||||||
|
// in dbx.js keeps the door open for future backends). Connection string from DATABASE_URL.
|
||||||
|
const { Pool, types } = require('pg');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// BIGINT (int8, OID 20) defaults to STRING in node-postgres to avoid precision loss. Every BIGINT here is
|
||||||
|
// an epoch-ms timestamp or a byte size — all far below Number.MAX_SAFE_INTEGER — so parse them as numbers
|
||||||
|
// to match the SQLite backend. Otherwise `expires_at < Date.now()` would compare a string to a number.
|
||||||
|
types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10)));
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
|
||||||
|
|
||||||
|
// Repos use '?' placeholders (SQLite style); Postgres wants $1,$2,… — replace positionally. Safe because
|
||||||
|
// no literal '?' appears inside any SQL string literal in this codebase.
|
||||||
|
function toPg(sql) { let i = 0; return sql.replace(/\?/g, () => '$' + (++i)); }
|
||||||
|
|
||||||
|
function prepare(sql) {
|
||||||
|
const q = toPg(sql);
|
||||||
|
return {
|
||||||
|
get: (...p) => pool.query(q, p).then((r) => r.rows[0]),
|
||||||
|
all: (...p) => pool.query(q, p).then((r) => r.rows),
|
||||||
|
run: (...p) => pool.query(q, p).then((r) => ({ changes: r.rowCount, lastInsertRowid: undefined })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(sql) { return pool.query(sql).then(() => {}); }
|
||||||
|
|
||||||
|
// Transaction on ONE pooled client (a pool would scatter BEGIN/COMMIT across connections). Same runner
|
||||||
|
// shape the sqlite backend's tx() exposes, so repos.mergeInto is identical on both engines.
|
||||||
|
async function tx(fn) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const t = {
|
||||||
|
run: (sql, ...p) => client.query(toPg(sql), p).then((r) => ({ changes: r.rowCount })),
|
||||||
|
get: (sql, ...p) => client.query(toPg(sql), p).then((r) => r.rows[0]),
|
||||||
|
all: (sql, ...p) => client.query(toPg(sql), p).then((r) => r.rows),
|
||||||
|
};
|
||||||
|
const out = await fn(t);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
try { await client.query('ROLLBACK'); } catch (_) {}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the schema (all CREATE ... IF NOT EXISTS — idempotent). Multi-statement, no params, so it runs via
|
||||||
|
// the simple-query protocol in one call. MUST be awaited before serving (server.js boot).
|
||||||
|
async function init() {
|
||||||
|
const sql = fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8');
|
||||||
|
await pool.query(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { prepare, exec, tx, init, name: 'pg', _pool: pool };
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// LiveKit helpers shared by routes.js (browser meeting/call join) and calls.js (native VoIP call payload).
|
||||||
|
// Mint an access token (HS256 JWT signed with the API secret) — hand-rolled, same approach as push.js's
|
||||||
|
// JWTs, so there's no SDK dependency. Grants join+publish+subscribe on exactly one room, as one identity.
|
||||||
|
// The secret stays server-side.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED } = require('./config');
|
||||||
|
|
||||||
|
const _b64u = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||||
|
|
||||||
|
function livekitToken(identity, name, room, metadata) {
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const header = _b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||||
|
const payload = _b64u(JSON.stringify({
|
||||||
|
iss: LIVEKIT_API_KEY, sub: identity, name: name || identity,
|
||||||
|
nbf: nowSec, exp: nowSec + 6 * 3600, // 6h — long enough for any meeting/call
|
||||||
|
metadata: metadata || '',
|
||||||
|
video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
|
||||||
|
}));
|
||||||
|
const sig = _b64u(crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(header + '.' + payload).digest());
|
||||||
|
return header + '.' + payload + '.' + sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { livekitToken, LIVEKIT_URL, LIVEKIT_ENABLED };
|
||||||
@@ -193,9 +193,9 @@ function dropDerived(id) {
|
|||||||
// persist on the data volume, so on a normal restart this finds nothing to do and costs one query.
|
// persist on the data volume, so on a normal restart this finds nothing to do and costs one query.
|
||||||
// Deliberately delayed and rate-limited by the same 2-at-a-time queue — boot must not stall on it.
|
// Deliberately delayed and rate-limited by the same 2-at-a-time queue — boot must not stall on it.
|
||||||
function backfill() {
|
function backfill() {
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
let rows = [];
|
let rows = [];
|
||||||
try { rows = require('./repos').attachments.allVideos(); } catch (e) { return; }
|
try { rows = await require('./repos').attachments.allVideos(); } catch (e) { return; }
|
||||||
let queued = 0;
|
let queued = 0;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (hasWebRendition(r.id)) continue;
|
if (hasWebRendition(r.id)) continue;
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"node": ">=22.5.0"
|
"node": ">=22.5.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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(){
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Swappable pub/sub for cross-instance real-time fan-out. The app delivers to its OWN WebSocket clients
|
||||||
|
// locally (chat.js) exactly as before; this layer only carries a copy to OTHER app instances so a message
|
||||||
|
// POSTed on instance A reaches a recipient whose socket lives on instance B.
|
||||||
|
//
|
||||||
|
// Backend chosen by PUBSUB_BACKEND (default 'memory'). 'memory' = single instance: publish is a no-op and
|
||||||
|
// subscriptions never fire, so behaviour is identical to before this layer existed — zero hot-path cost.
|
||||||
|
// 'redis' fans out via Redis. The interface (publish/subscribe/init) is deliberately tiny so Redis is one
|
||||||
|
// swappable file — a Postgres LISTEN/NOTIFY or NATS backend could drop in the same way. Never hardwired.
|
||||||
|
const name = process.env.PUBSUB_BACKEND || 'memory';
|
||||||
|
module.exports = require('./pubsub/' + name);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Single-instance pub/sub backend. There are no OTHER app instances, so a cross-instance publish has
|
||||||
|
// nowhere to go (no-op) and remote subscriptions never fire. All real-time delivery happens locally in
|
||||||
|
// chat.js — this is exactly the pre-pubsub behaviour, at zero cost. The default backend.
|
||||||
|
module.exports = {
|
||||||
|
name: 'memory',
|
||||||
|
init: () => Promise.resolve(),
|
||||||
|
publish: () => {}, // no other instance to reach
|
||||||
|
subscribe: () => {}, // nothing remote will ever arrive
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Redis pub/sub backend — enables running MULTIPLE app instances. Each instance publishes every local
|
||||||
|
// real-time event; every other instance receives it and delivers to ITS local sockets. Selected by
|
||||||
|
// PUBSUB_BACKEND=redis; connection from REDIS_URL (default redis://bizgaze-redis:6379).
|
||||||
|
//
|
||||||
|
// Self-echo guard: Redis delivers a publish to ALL subscribers including the publisher, but the publishing
|
||||||
|
// instance ALREADY delivered locally — so every message is tagged with this instance's id and ignored on
|
||||||
|
// the way back in. Subscriptions made before connect are buffered and flushed in init().
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const INSTANCE = crypto.randomBytes(8).toString('hex');
|
||||||
|
|
||||||
|
let pub = null, sub = null;
|
||||||
|
const pending = []; // [pattern, handler] queued before connect
|
||||||
|
|
||||||
|
async function doSubscribe(pattern, handler) {
|
||||||
|
const onMessage = (message, channel) => {
|
||||||
|
let m; try { m = JSON.parse(message); } catch { return; }
|
||||||
|
if (m.i === INSTANCE) return; // our own publish — already delivered locally
|
||||||
|
try { handler(channel, m.d); } catch (_) {}
|
||||||
|
};
|
||||||
|
if (pattern.includes('*')) await sub.pSubscribe(pattern, onMessage);
|
||||||
|
else await sub.subscribe(pattern, onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const { createClient } = require('redis');
|
||||||
|
const url = process.env.REDIS_URL || 'redis://bizgaze-redis:6379';
|
||||||
|
pub = createClient({ url });
|
||||||
|
sub = pub.duplicate();
|
||||||
|
pub.on('error', () => {}); sub.on('error', () => {}); // never let a redis blip crash the app
|
||||||
|
await pub.connect();
|
||||||
|
await sub.connect();
|
||||||
|
for (const [pattern, handler] of pending) { try { await doSubscribe(pattern, handler); } catch (_) {} }
|
||||||
|
pending.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(channel, data) {
|
||||||
|
if (!pub) return;
|
||||||
|
pub.publish(channel, JSON.stringify({ i: INSTANCE, d: data })).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(pattern, handler) {
|
||||||
|
if (!sub) { pending.push([pattern, handler]); return; } // buffer until init() connects
|
||||||
|
doSubscribe(pattern, handler).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { name: 'redis', init, publish, subscribe };
|
||||||
@@ -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); }
|
||||||
@@ -88,7 +95,9 @@ function sendApns(token, payload) {
|
|||||||
let client;
|
let client;
|
||||||
try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
|
try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
|
||||||
client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
|
client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
|
||||||
const body = JSON.stringify({ aps: { alert: { title: payload.title || 'Biz Connect', body: payload.body || '' }, sound: 'default' }, ...(payload.data || {}) });
|
// Custom bundled sound (notif.wav, shipped in the app via ios-patch.sh + add-share-extension.rb) gives
|
||||||
|
// chat/call notifications an explicit, distinctive tone. A call site may override via payload.sound.
|
||||||
|
const body = JSON.stringify({ aps: { alert: { title: payload.title || 'Biz Connect', body: payload.body || '' }, sound: payload.sound || 'notif.wav' }, ...(payload.data || {}) });
|
||||||
const req = client.request({ ':method': 'POST', ':path': '/3/device/' + token, authorization: 'bearer ' + apnsToken(), 'apns-topic': apnsCfg.bundle, 'apns-push-type': 'alert' });
|
const req = client.request({ ':method': 'POST', ':path': '/3/device/' + token, authorization: 'bearer ' + apnsToken(), 'apns-topic': apnsCfg.bundle, 'apns-push-type': 'alert' });
|
||||||
let status = 0;
|
let status = 0;
|
||||||
req.on('response', (h) => { status = h[':status']; });
|
req.on('response', (h) => { status = h[':status']; });
|
||||||
@@ -99,6 +108,77 @@ function sendApns(token, payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- VoIP push (PushKit → CallKit), iOS ----------------
|
||||||
|
// Wakes the app even when force-killed so it can report an incoming call to CallKit. Uses the SAME
|
||||||
|
// token-based APNs auth (.p8) as alert pushes, but with apns-push-type 'voip' and the '<bundle>.voip'
|
||||||
|
// topic. The payload is arbitrary call data delivered to the app's PushKit handler (no aps alert). The
|
||||||
|
// destination is a PushKit "VoIP token" (distinct from the alert APNs token) that the native plugin
|
||||||
|
// registers as device_tokens.platform = 'ios-voip'.
|
||||||
|
function sendApnsVoip(token, data) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!apnsCfg) return resolve({});
|
||||||
|
let client;
|
||||||
|
try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
|
||||||
|
client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
|
||||||
|
const body = JSON.stringify({ aps: {}, ...(data || {}) });
|
||||||
|
const req = client.request({
|
||||||
|
':method': 'POST', ':path': '/3/device/' + token,
|
||||||
|
authorization: 'bearer ' + apnsToken(),
|
||||||
|
'apns-topic': apnsCfg.bundle + '.voip', 'apns-push-type': 'voip',
|
||||||
|
'apns-priority': '10', 'apns-expiration': '0', // ring now or drop — never store a stale call
|
||||||
|
});
|
||||||
|
let status = 0;
|
||||||
|
req.on('response', (h) => { status = h[':status']; });
|
||||||
|
req.on('data', () => {});
|
||||||
|
req.on('end', () => { try { client.close(); } catch (_) {} resolve({ ok: status >= 200 && status < 300, dead: status === 410 }); });
|
||||||
|
req.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
|
||||||
|
req.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify a user of an INCOMING CALL. Prefers a VoIP push (→ CallKit native ring, works when the app is
|
||||||
|
// killed) if the user has a registered 'ios-voip' token; otherwise falls back to a normal alert push
|
||||||
|
// (banner + ring sound) so Android / web / iOS-without-the-CallKit-build still get notified. So this is
|
||||||
|
// backward-compatible: with no VoIP tokens registered yet, it behaves exactly like the old call push.
|
||||||
|
async function sendCallNotification(userId, data) {
|
||||||
|
// data: { callUUID, room, kind, callerId, callerName, groupId, groupName, title, body, hasVideo }
|
||||||
|
let toks = [];
|
||||||
|
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
||||||
|
const voip = toks.filter((t) => t.platform === 'ios-voip');
|
||||||
|
if (voip.length && apnsCfg && process.env.CALLKIT_ENABLED === '1') { // kill-switch: off → fall through to a banner
|
||||||
|
const payload = {
|
||||||
|
type: 'invite',
|
||||||
|
callUUID: data.callUUID, room: data.room, kind: data.kind,
|
||||||
|
callerId: data.callerId || '', callerName: data.callerName || 'Incoming call',
|
||||||
|
groupId: data.groupId || '', groupName: data.groupName || '', hasVideo: !!data.hasVideo,
|
||||||
|
// LiveKit join credentials so the native plugin can connect the room immediately on answer — even
|
||||||
|
// from a killed state, before the WebView has loaded.
|
||||||
|
livekitUrl: data.livekitUrl || '', livekitToken: data.livekitToken || '',
|
||||||
|
};
|
||||||
|
for (const t of voip) {
|
||||||
|
try { const r = await sendApnsVoip(t.token, payload); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||||
|
}
|
||||||
|
return 'voip';
|
||||||
|
}
|
||||||
|
await sendToUser(userId, {
|
||||||
|
title: data.title, body: data.body, kind: data.kind, id: data.callerId || data.groupId,
|
||||||
|
room: data.room, tag: 'call:' + data.room, data: { kind: data.kind, id: data.callerId || data.groupId, room: data.room, call: 1 },
|
||||||
|
});
|
||||||
|
return 'push';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out). Sent as a
|
||||||
|
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call.
|
||||||
|
// No-op for non-VoIP devices (their ring is a normal notification that just goes away).
|
||||||
|
async function sendCallCancel(userId, callUUID) {
|
||||||
|
if (!apnsCfg || !callUUID || process.env.CALLKIT_ENABLED !== '1') return;
|
||||||
|
let toks = [];
|
||||||
|
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
||||||
|
for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
|
||||||
|
try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- public API ----------------
|
// ---------------- public API ----------------
|
||||||
const nativeReady = !!(fcmSA || apnsCfg);
|
const nativeReady = !!(fcmSA || apnsCfg);
|
||||||
const enabled = [webReady && 'WebPush', fcmSA && 'FCM', apnsCfg && 'APNs'].filter(Boolean);
|
const enabled = [webReady && 'WebPush', fcmSA && 'FCM', apnsCfg && 'APNs'].filter(Boolean);
|
||||||
@@ -106,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.
|
||||||
@@ -114,24 +198,24 @@ async function sendToUser(userId, payload) {
|
|||||||
const data = JSON.stringify(payload || {});
|
const data = JSON.stringify(payload || {});
|
||||||
if (webReady) {
|
if (webReady) {
|
||||||
let subs = [];
|
let subs = [];
|
||||||
try { subs = R.pushSubs.byUser(userId); } catch (_) { subs = []; }
|
try { subs = await R.pushSubs.byUser(userId); } catch (_) { subs = []; }
|
||||||
for (const s of subs) {
|
for (const s of subs) {
|
||||||
try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); }
|
try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); }
|
||||||
catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } }
|
catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { await R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (nativeReady) {
|
if (nativeReady) {
|
||||||
let toks = [];
|
let toks = [];
|
||||||
try { toks = R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
||||||
for (const t of toks) {
|
for (const t of toks) {
|
||||||
try {
|
try {
|
||||||
let r = null;
|
let r = null;
|
||||||
if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload);
|
if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload);
|
||||||
else if (t.platform === 'ios' && apnsCfg) r = await sendApns(t.token, payload);
|
else if (t.platform === 'ios' && apnsCfg) r = await sendApns(t.token, payload);
|
||||||
if (r && r.dead) { try { R.deviceTokens.removeByToken(t.token); } catch (_) {} }
|
if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} }
|
||||||
} catch (_) { /* best-effort */ }
|
} catch (_) { /* best-effort */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { isEnabled, publicKey, sendToUser };
|
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel, fcmReady };
|
||||||
|
|||||||
@@ -2,19 +2,21 @@
|
|||||||
// 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
|
||||||
|
|
||||||
function tick() {
|
async function tick() {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min
|
const due = await R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min
|
||||||
for (const s of due) {
|
for (const s of due) {
|
||||||
const recipients = new Set([s.created_by]);
|
const recipients = new Set([s.created_by]);
|
||||||
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
|
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
|
||||||
invited.forEach((id) => recipients.add(id));
|
invited.forEach((id) => recipients.add(id));
|
||||||
if (s.group_id) { try { 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
|
||||||
R.scheduledMeetings.markReminded(s.id);
|
recipients.forEach((uid) => { try { PUSH.sendToUser(uid, { title: 'Meeting starting soon', body: (s.title || 'Your meeting') + ' starts in ~10 minutes', kind: 'meeting', id: s.room_code, tag: 'meet:' + s.room_code }); } catch (_) {} }); // closed app (iOS APNs etc.)
|
||||||
|
await R.scheduledMeetings.markReminded(s.id);
|
||||||
}
|
}
|
||||||
} catch (_) { /* never let the timer die */ }
|
} catch (_) { /* never let the timer die */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,38 +5,48 @@
|
|||||||
// TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`).
|
// TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`).
|
||||||
// Repo signatures take `tenantId` so that when the tenant is later elevated to a
|
// Repo signatures take `tenantId` so that when the tenant is later elevated to a
|
||||||
// first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged.
|
// first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged.
|
||||||
const db = require('./db');
|
//
|
||||||
|
// ASYNC: queries go through the db adapter (dbx.js), so every method returns a Promise — the same repo
|
||||||
|
// code runs on synchronous SQLite and asynchronous Postgres. Callers MUST await. Methods that only
|
||||||
|
// `return db.prepare(...).get/all/run(...)` already yield the adapter's Promise; methods that transform a
|
||||||
|
// result (.map, !!, .c, or run multiple statements) are written async/await so they don't operate on a
|
||||||
|
// bare Promise.
|
||||||
|
const db = require('./dbx');
|
||||||
const A = require('./auth');
|
const A = require('./auth');
|
||||||
const now = () => Date.now();
|
const now = () => Date.now();
|
||||||
|
|
||||||
const teams = {
|
const teams = {
|
||||||
first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(),
|
first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(),
|
||||||
byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id),
|
byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id),
|
||||||
create: (name) => {
|
create: async (name) => {
|
||||||
const id = A.id();
|
const id = A.id();
|
||||||
db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now());
|
await db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now());
|
||||||
return db.prepare('SELECT * FROM teams WHERE id=?').get(id);
|
return db.prepare('SELECT * FROM teams WHERE id=?').get(id);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const users = {
|
const users = {
|
||||||
anyExists: () => !!db.prepare('SELECT 1 FROM users LIMIT 1').get(),
|
anyExists: async () => !!(await db.prepare('SELECT 1 FROM users LIMIT 1').get()),
|
||||||
byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id),
|
byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id),
|
||||||
// Follow a merge redirect: a merged-away id resolves to the surviving account, else returns id
|
// Follow a merge redirect: a merged-away id resolves to the surviving account, else returns id
|
||||||
// unchanged. Use for any user id that arrived from the client (DM recipient, thread peer).
|
// unchanged. Use for any user id that arrived from the client (DM recipient, thread peer).
|
||||||
resolve: (id) => { if (!id) return id; const a = db.prepare('SELECT user_id FROM user_aliases WHERE old_id=?').get(id); return a ? a.user_id : id; },
|
resolve: async (id) => { if (!id) return id; const a = await db.prepare('SELECT user_id FROM user_aliases WHERE old_id=?').get(id); return a ? a.user_id : id; },
|
||||||
byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email),
|
// LOWER()=LOWER() is case-insensitive on both engines (SQLite has no portable COLLATE NOCASE in Postgres).
|
||||||
emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email),
|
byEmail: (email) => db.prepare('SELECT * FROM users WHERE LOWER(email)=LOWER(?)').get(email),
|
||||||
|
emailExists: async (email) => !!(await db.prepare('SELECT 1 FROM users WHERE LOWER(email)=LOWER(?)').get(email)),
|
||||||
// Match on the stable BizGaze person-id so email- and mobile-logins resolve to one account (#2).
|
// Match on the stable BizGaze person-id so email- and mobile-logins resolve to one account (#2).
|
||||||
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: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
|
create: async ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
|
||||||
const id = A.id();
|
const id = A.id();
|
||||||
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
|
await db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,0,?)`)
|
VALUES (?,?,?,?,?,?,?,?,0,?)`)
|
||||||
.run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now());
|
.run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now());
|
||||||
return id;
|
return id;
|
||||||
@@ -44,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),
|
||||||
@@ -57,47 +69,46 @@ const users = {
|
|||||||
// BizGaze person (#2). Runs in a single transaction so a failure leaves the data untouched.
|
// BizGaze person (#2). Runs in a single transaction so a failure leaves the data untouched.
|
||||||
// For composite-key tables we UPDATE OR IGNORE (move what won't collide) then DELETE the rest
|
// For composite-key tables we UPDATE OR IGNORE (move what won't collide) then DELETE the rest
|
||||||
// (the survivor already has that membership/reaction/vote).
|
// (the survivor already has that membership/reaction/vote).
|
||||||
mergeInto: (fromId, intoId) => {
|
mergeInto: async (fromId, intoId) => {
|
||||||
if (!fromId || !intoId || fromId === intoId) return;
|
if (!fromId || !intoId || fromId === intoId) return;
|
||||||
const run = (sql, ...a) => db.prepare(sql).run(...a);
|
// db.tx() gives one atomic unit on both engines (sqlite single-connection; pg one pooled client).
|
||||||
db.exec('BEGIN');
|
return db.tx(async (t) => {
|
||||||
try {
|
const run = (sql, ...a) => t.run(sql, ...a);
|
||||||
run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId);
|
await run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId);
|
||||||
run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId);
|
await run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId);
|
||||||
run('UPDATE OR IGNORE message_reactions SET user_id=? WHERE user_id=?', intoId, fromId);
|
// Composite-key tables: MOVE only the rows that won't collide with the survivor's existing rows
|
||||||
run('DELETE FROM message_reactions WHERE user_id=?', fromId);
|
// (NOT EXISTS / NOT IN — portable across SQLite & Postgres, replacing SQLite-only UPDATE OR IGNORE),
|
||||||
run('UPDATE OR IGNORE conversation_members SET user_id=? WHERE user_id=?', intoId, fromId);
|
// then DELETE whatever remains (the survivor already had that membership/reaction/vote).
|
||||||
run('DELETE FROM conversation_members WHERE user_id=?', fromId);
|
await run('UPDATE message_reactions SET user_id=? WHERE user_id=? AND NOT EXISTS (SELECT 1 FROM message_reactions x WHERE x.message_id=message_reactions.message_id AND x.emoji=message_reactions.emoji AND x.user_id=?)', intoId, fromId, intoId);
|
||||||
run('UPDATE OR IGNORE poll_votes SET user_id=? WHERE user_id=?', intoId, fromId);
|
await run('DELETE FROM message_reactions WHERE user_id=?', fromId);
|
||||||
run('DELETE FROM poll_votes WHERE user_id=?', fromId);
|
await run('UPDATE conversation_members SET user_id=? WHERE user_id=? AND conversation_id NOT IN (SELECT conversation_id FROM conversation_members WHERE user_id=?)', intoId, fromId, intoId);
|
||||||
run('UPDATE OR IGNORE favorites SET user_id=? WHERE user_id=?', intoId, fromId);
|
await run('DELETE FROM conversation_members WHERE user_id=?', fromId);
|
||||||
run('DELETE FROM favorites WHERE user_id=?', fromId);
|
await run('UPDATE poll_votes SET user_id=? WHERE user_id=? AND NOT EXISTS (SELECT 1 FROM poll_votes x WHERE x.poll_id=poll_votes.poll_id AND x.option_idx=poll_votes.option_idx AND x.user_id=?)', intoId, fromId, intoId);
|
||||||
|
await run('DELETE FROM poll_votes WHERE user_id=?', fromId);
|
||||||
|
await run('UPDATE favorites SET user_id=? WHERE user_id=? AND target NOT IN (SELECT target FROM favorites WHERE user_id=?)', intoId, fromId, intoId);
|
||||||
|
await run('DELETE FROM favorites WHERE user_id=?', fromId);
|
||||||
// A DM favourite pointing AT the merged-away user should now point at the survivor.
|
// A DM favourite pointing AT the merged-away user should now point at the survivor.
|
||||||
run("UPDATE OR IGNORE favorites SET target='dm:'||? WHERE target='dm:'||?", intoId, fromId);
|
await run("UPDATE favorites SET target='dm:'||? WHERE target='dm:'||? AND NOT EXISTS (SELECT 1 FROM favorites x WHERE x.user_id=favorites.user_id AND x.target='dm:'||?)", intoId, fromId, intoId);
|
||||||
run("DELETE FROM favorites WHERE target='dm:'||?", fromId);
|
await run("DELETE FROM favorites WHERE target='dm:'||?", fromId);
|
||||||
run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId);
|
await run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId);
|
||||||
run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId);
|
await run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId);
|
||||||
run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId);
|
await run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId);
|
||||||
run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId);
|
await run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId);
|
||||||
run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId);
|
await run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId);
|
||||||
run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId);
|
await run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId);
|
||||||
run('UPDATE OR IGNORE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId);
|
// device_tokens: changing user_id can't violate its PK (id) or UNIQUE (token), so a plain UPDATE.
|
||||||
run('DELETE FROM device_tokens WHERE user_id=?', fromId);
|
await run('UPDATE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId);
|
||||||
run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, fromId);
|
await run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, fromId);
|
||||||
// Drop the merged-away account's auth so a stale token can't resurrect the empty row.
|
// Drop the merged-away account's auth so a stale token can't resurrect the empty row.
|
||||||
run('DELETE FROM sessions_auth WHERE user_id=?', fromId);
|
await run('DELETE FROM sessions_auth WHERE user_id=?', fromId);
|
||||||
run('DELETE FROM refresh_tokens WHERE user_id=?', fromId);
|
await run('DELETE FROM refresh_tokens WHERE user_id=?', fromId);
|
||||||
// Record old_id -> survivor so lingering references (cached contacts, in-flight DMs) resolve
|
// Record old_id -> survivor so lingering references (cached contacts, in-flight DMs) resolve
|
||||||
// instead of hitting the deleted row (which made messages to merged contacts vanish).
|
// instead of hitting the deleted row (which made messages to merged contacts vanish).
|
||||||
const _iu = db.prepare('SELECT team_id FROM users WHERE id=?').get(intoId);
|
const _iu = await t.get('SELECT team_id FROM users WHERE id=?', intoId);
|
||||||
run('INSERT OR REPLACE INTO user_aliases (old_id,user_id,team_id,created_at) VALUES (?,?,?,?)', fromId, intoId, (_iu && _iu.team_id) || null, now());
|
await run('INSERT INTO user_aliases (old_id,user_id,team_id,created_at) VALUES (?,?,?,?) ON CONFLICT(old_id) DO UPDATE SET user_id=excluded.user_id, team_id=excluded.team_id, created_at=excluded.created_at', fromId, intoId, (_iu && _iu.team_id) || null, now());
|
||||||
run('UPDATE user_aliases SET user_id=? WHERE user_id=?', intoId, fromId); // re-chain earlier aliases to the new survivor
|
await run('UPDATE user_aliases SET user_id=? WHERE user_id=?', intoId, fromId); // re-chain earlier aliases to the new survivor
|
||||||
run('DELETE FROM users WHERE id=?', fromId);
|
await run('DELETE FROM users WHERE id=?', fromId);
|
||||||
db.exec('COMMIT');
|
});
|
||||||
} catch (e) {
|
|
||||||
db.exec('ROLLBACK');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,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),
|
||||||
};
|
};
|
||||||
@@ -116,9 +128,9 @@ const machines = {
|
|||||||
inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId),
|
inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId),
|
||||||
listByTenant: (tenantId) =>
|
listByTenant: (tenantId) =>
|
||||||
db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId),
|
db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId),
|
||||||
create: ({ tenantId, name, enrollToken, unattended }) => {
|
create: async ({ tenantId, name, enrollToken, unattended }) => {
|
||||||
const id = A.id();
|
const id = A.id();
|
||||||
db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
|
await db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
|
||||||
.run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now());
|
.run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now());
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
@@ -126,14 +138,11 @@ const machines = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const audit = {
|
const audit = {
|
||||||
|
// Positional params (not @named) so the same SQL runs on SQLite and Postgres.
|
||||||
add: (e) =>
|
add: (e) =>
|
||||||
db.prepare(`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
|
db.prepare(`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
|
||||||
VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`)
|
VALUES (?,?,?,?,?,?,?,?)`)
|
||||||
.run({
|
.run(e.team_id, e.user_id || null, e.user_email || null, e.machine_id || null, e.machine_name || null, e.action, e.detail || null, now()),
|
||||||
team_id: e.team_id, user_id: e.user_id || null, user_email: e.user_email || null,
|
|
||||||
machine_id: e.machine_id || null, machine_name: e.machine_name || null,
|
|
||||||
action: e.action, detail: e.detail || null, at: now(),
|
|
||||||
}),
|
|
||||||
listByTenant: (tenantId) =>
|
listByTenant: (tenantId) =>
|
||||||
db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(tenantId),
|
db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(tenantId),
|
||||||
};
|
};
|
||||||
@@ -202,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),
|
||||||
@@ -210,13 +248,25 @@ const messages = {
|
|||||||
// Full 1:1 (DM) thread between two users (both directions). Take the NEWEST `limit` messages
|
// Full 1:1 (DM) thread between two users (both directions). Take the NEWEST `limit` messages
|
||||||
// (inner DESC), then present them oldest-first. The old plain "ASC LIMIT" returned the OLDEST 300
|
// (inner DESC), then present them oldest-first. The old plain "ASC LIMIT" returned the OLDEST 300
|
||||||
// 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".
|
||||||
thread: (teamId, a, b, limit = 500, before = null) =>
|
// The `before` cursor is added CONDITIONALLY (not as `? IS NULL OR …`): an all-NULL param has no type
|
||||||
db.prepare(`SELECT * FROM (
|
// for Postgres to infer. The subquery also needs an alias (`t`) — Postgres requires it. Both portable.
|
||||||
|
// `a` is the VIEWER (u.id). Exclude the viewer's "deleted for me" (message_hidden) rows in SQL — not in
|
||||||
|
// JS afterwards — so the LIMIT counts only VISIBLE messages. Filtering after the LIMIT returned < PAGE rows
|
||||||
|
// whenever a recent message had been hidden, and the client read that as "no older history" and stopped
|
||||||
|
// paginating (a chat with a deleted recent message wouldn't scroll back).
|
||||||
|
// `a` is the VIEWER. Exclude messages from users the viewer has blocked (in SQL, like message_hidden,
|
||||||
|
// so the LIMIT counts only VISIBLE messages and pagination doesn't stall).
|
||||||
|
thread: (teamId, a, b, limit = 500, before = null) => {
|
||||||
|
const cond = before != null ? ' AND created_at < ?' : '';
|
||||||
|
const args = before != null ? [teamId, a, b, b, a, a, a, before, limit] : [teamId, a, b, b, a, a, a, limit];
|
||||||
|
return db.prepare(`SELECT * FROM (
|
||||||
SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
|
SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
|
||||||
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
|
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
|
||||||
AND (? IS NULL OR created_at < ?)
|
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 ?
|
||||||
) ORDER BY created_at ASC`).all(teamId, a, b, b, a, before, before, limit),
|
) t ORDER BY created_at ASC`).all(...args);
|
||||||
|
},
|
||||||
// Full-thread search (ALL messages, not just the loaded window). `like` is the escaped LIKE pattern.
|
// Full-thread search (ALL messages, not just the loaded window). `like` is the escaped LIKE pattern.
|
||||||
searchThread: (teamId, a, b, like, limit = 300) =>
|
searchThread: (teamId, a, b, like, limit = 300) =>
|
||||||
db.prepare(`SELECT id, created_at, sender_id FROM messages WHERE team_id=? AND conversation_id IS NULL
|
db.prepare(`SELECT id, created_at, sender_id FROM messages WHERE team_id=? AND conversation_id IS NULL
|
||||||
@@ -230,27 +280,33 @@ 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) => {
|
||||||
db.prepare(`SELECT * FROM (
|
const cond = before != null ? ' AND created_at < ?' : '';
|
||||||
SELECT * FROM messages WHERE conversation_id=? AND (? IS NULL OR created_at < ?) ORDER BY created_at DESC LIMIT ?
|
const args = before != null ? [conversationId, userId, userId, before, limit] : [conversationId, userId, userId, limit];
|
||||||
) ORDER BY created_at ASC`).all(conversationId, before, before, limit),
|
return db.prepare(`SELECT * FROM (
|
||||||
|
SELECT * FROM messages WHERE conversation_id=?
|
||||||
|
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)
|
||||||
|
AND sender_id NOT IN (SELECT blocked_id FROM user_blocks WHERE blocker_id=?)${cond}
|
||||||
|
ORDER BY created_at DESC LIMIT ?
|
||||||
|
) t ORDER BY created_at ASC`).all(...args);
|
||||||
|
},
|
||||||
searchConversation: (conversationId, like, limit = 300) =>
|
searchConversation: (conversationId, like, limit = 300) =>
|
||||||
db.prepare(`SELECT id, created_at, sender_id FROM messages WHERE conversation_id=? AND deleted=0
|
db.prepare(`SELECT id, created_at, sender_id FROM messages WHERE conversation_id=? AND deleted=0
|
||||||
AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(conversationId, like, limit),
|
AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(conversationId, like, limit),
|
||||||
lastInConversation: (conversationId) =>
|
lastInConversation: (conversationId) =>
|
||||||
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
|
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
|
||||||
unreadInConversation: (conversationId, userId, since) =>
|
unreadInConversation: async (conversationId, userId, since) =>
|
||||||
db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c,
|
(await db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since)).c,
|
||||||
};
|
};
|
||||||
|
|
||||||
const reactions = {
|
const reactions = {
|
||||||
// Toggle with ONE reaction per user per message: picking an emoji replaces any prior
|
// Toggle with ONE reaction per user per message: picking an emoji replaces any prior
|
||||||
// reaction by that user; picking the same one again removes it. Returns true if added.
|
// reaction by that user; picking the same one again removes it. Returns true if added.
|
||||||
toggle: (messageId, userId, emoji) => {
|
toggle: async (messageId, userId, emoji) => {
|
||||||
const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji);
|
const had = await db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji);
|
||||||
db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
|
await db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
|
||||||
if (had) return false;
|
if (had) return false;
|
||||||
db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now());
|
await db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now());
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId),
|
forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId),
|
||||||
@@ -270,17 +326,17 @@ const conversations = {
|
|||||||
db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()),
|
db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()),
|
||||||
byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id),
|
byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id),
|
||||||
addMember: (conversationId, userId, admin) =>
|
addMember: (conversationId, userId, admin) =>
|
||||||
db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0),
|
db.prepare('INSERT INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?) ON CONFLICT(conversation_id,user_id) DO NOTHING').run(conversationId, userId, 0, now(), admin ? 1 : 0),
|
||||||
members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id),
|
members: async (conversationId) => (await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId)).map((r) => r.user_id),
|
||||||
isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId),
|
isMember: async (conversationId, userId) => !!(await db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId)),
|
||||||
// ---- Group admins (multiple allowed) ----
|
// ---- Group admins (multiple allowed) ----
|
||||||
isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId),
|
isAdmin: async (conversationId, userId) => !!(await db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId)),
|
||||||
admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id),
|
admins: async (conversationId) => (await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId)).map((r) => r.user_id),
|
||||||
setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId),
|
setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId),
|
||||||
oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; },
|
oldestMember: async (conversationId) => { const r = await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; },
|
||||||
listForUser: (teamId, userId) =>
|
listForUser: (teamId, userId) =>
|
||||||
db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId),
|
db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId),
|
||||||
lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; },
|
lastReadAt: async (conversationId, userId) => { const r = await db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; },
|
||||||
memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId),
|
memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId),
|
||||||
setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id),
|
setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id),
|
||||||
markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId),
|
markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId),
|
||||||
@@ -288,7 +344,7 @@ const conversations = {
|
|||||||
setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id),
|
setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id),
|
||||||
byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId),
|
byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId),
|
||||||
removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId),
|
removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId),
|
||||||
remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); },
|
remove: async (id) => { await db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); await db.prepare('DELETE FROM conversations WHERE id=?').run(id); },
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachments = {
|
const attachments = {
|
||||||
@@ -352,8 +408,8 @@ const polls = {
|
|||||||
|
|
||||||
const pollVotes = {
|
const pollVotes = {
|
||||||
forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId),
|
forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId),
|
||||||
hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx),
|
hasVoted: async (pollId, userId, idx) => !!(await db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx)),
|
||||||
add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()),
|
add: (pollId, userId, idx) => db.prepare('INSERT INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?) ON CONFLICT(poll_id,user_id,option_idx) DO NOTHING').run(pollId, userId, idx, now()),
|
||||||
remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx),
|
remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx),
|
||||||
clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId),
|
clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId),
|
||||||
};
|
};
|
||||||
@@ -369,9 +425,9 @@ const pushSubs = {
|
|||||||
|
|
||||||
const favorites = {
|
const favorites = {
|
||||||
set: (userId, target, on) => on
|
set: (userId, target, on) => on
|
||||||
? db.prepare('INSERT OR IGNORE INTO favorites (user_id,target,created_at) VALUES (?,?,?)').run(userId, target, now())
|
? db.prepare('INSERT INTO favorites (user_id,target,created_at) VALUES (?,?,?) ON CONFLICT(user_id,target) DO NOTHING').run(userId, target, now())
|
||||||
: db.prepare('DELETE FROM favorites WHERE user_id=? AND target=?').run(userId, target),
|
: db.prepare('DELETE FROM favorites WHERE user_id=? AND target=?').run(userId, target),
|
||||||
forUser: (userId) => db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId).map((r) => r.target),
|
forUser: async (userId) => (await db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId)).map((r) => r.target),
|
||||||
};
|
};
|
||||||
|
|
||||||
const deviceTokens = {
|
const deviceTokens = {
|
||||||
@@ -399,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 };
|
||||||
|
|||||||
@@ -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,43 +23,61 @@ const { onConnection } = require('./signaling');
|
|||||||
// ---------- HTTP request dispatch ----------
|
// ---------- HTTP request dispatch ----------
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const key = `${req.method} ${req.url.split('?')[0]}`;
|
const key = `${req.method} ${req.url.split('?')[0]}`;
|
||||||
if (routes[key]) return routes[key](req, res);
|
// Route/static handlers are async now (DB adapter). Catch any rejection so a handler error becomes a
|
||||||
if (req.method === 'GET') return handleGet(req, res); // downloads + static
|
// 500 instead of a hung request + unhandled promise rejection.
|
||||||
json(res, 404, { error: 'not found' });
|
let p;
|
||||||
|
if (routes[key]) p = routes[key](req, res);
|
||||||
|
else if (req.method === 'GET') p = handleGet(req, res); // downloads + static
|
||||||
|
else return json(res, 404, { error: 'not found' });
|
||||||
|
if (p && typeof p.catch === 'function') p.catch((e) => { try { console.error('handler error', key, e && e.message); json(res, 500, { error: 'server error' }); } catch (_) {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- WebSocket signaling ----------
|
// ---------- WebSocket signaling ----------
|
||||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
wss.on('connection', onConnection);
|
wss.on('connection', onConnection);
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
// Apply the DB schema BEFORE serving. For Postgres this creates the tables (async); for SQLite it's a
|
||||||
console.log(`HTTP on http://localhost:${PORT}`);
|
// no-op (the schema is already applied synchronously when db.js is required). Serving only starts once the
|
||||||
try { require('./media').backfill(); } catch (e) {} // streaming renditions for older video uploads
|
// store is ready, so the first request can never hit a missing table.
|
||||||
});
|
const db = require('./dbx');
|
||||||
|
const pubsub = require('./pubsub');
|
||||||
|
|
||||||
// HTTPS — required so other devices can share their screen (browsers block
|
function startListening() {
|
||||||
// screen capture on non-secure origins). Uses cert.pem/key.pem if present.
|
server.listen(PORT, () => {
|
||||||
let httpsServer = null;
|
console.log(`HTTP on http://localhost:${PORT} (db=${db.name})`);
|
||||||
try {
|
try { require('./media').backfill(); } catch (e) {} // streaming renditions for older video uploads
|
||||||
const certPath = path.join(__dirname, 'cert.pem');
|
});
|
||||||
const keyPath = path.join(__dirname, 'key.pem');
|
|
||||||
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
// HTTPS — required so other devices can share their screen (browsers block
|
||||||
httpsServer = https.createServer(
|
// screen capture on non-secure origins). Uses cert.pem/key.pem if present.
|
||||||
{ cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) },
|
try {
|
||||||
(req, res) => server.emit('request', req, res)
|
const certPath = path.join(__dirname, 'cert.pem');
|
||||||
);
|
const keyPath = path.join(__dirname, 'key.pem');
|
||||||
const wssSecure = new WebSocketServer({ server: httpsServer, path: '/ws' });
|
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||||
wssSecure.on('connection', onConnection);
|
const httpsServer = https.createServer(
|
||||||
httpsServer.listen(HTTPS_PORT, () => {
|
{ cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) },
|
||||||
console.log(`HTTPS on https://localhost:${HTTPS_PORT} (use this address from other devices)`);
|
(req, res) => server.emit('request', req, res)
|
||||||
console.log(` End user shares screen: https://<this-pc-ip>:${HTTPS_PORT}/share`);
|
);
|
||||||
console.log(` Technician connects: https://<this-pc-ip>:${HTTPS_PORT}/connect`);
|
const wssSecure = new WebSocketServer({ server: httpsServer, path: '/ws' });
|
||||||
});
|
wssSecure.on('connection', onConnection);
|
||||||
} else {
|
httpsServer.listen(HTTPS_PORT, () => {
|
||||||
console.log('(No cert.pem/key.pem found — HTTPS disabled. Other devices can view but not share their screen.)');
|
console.log(`HTTPS on https://localhost:${HTTPS_PORT} (use this address from other devices)`);
|
||||||
|
console.log(` End user shares screen: https://<this-pc-ip>:${HTTPS_PORT}/share`);
|
||||||
|
console.log(` Technician connects: https://<this-pc-ip>:${HTTPS_PORT}/connect`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('(No cert.pem/key.pem found — HTTPS disabled. Other devices can view but not share their screen.)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('HTTPS failed to start:', e.message);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.log('HTTPS failed to start:', e.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DB schema first, then the pub/sub layer (redis connects + flushes buffered subscriptions; memory is a
|
||||||
|
// no-op), then serve. A pubsub failure must NOT block booting — degrade to local-only delivery.
|
||||||
|
db.init()
|
||||||
|
.then(() => pubsub.init().catch((e) => console.error('pubsub init failed (local-only delivery):', (e && e.message) || e)))
|
||||||
|
.then(startListening)
|
||||||
|
.catch((e) => { console.error('DB init failed:', (e && e.message) || e); process.exit(1); });
|
||||||
|
|
||||||
module.exports = { server };
|
module.exports = { server };
|
||||||
|
|||||||
@@ -25,27 +25,28 @@ function tokenFromReq(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the logged-in user from the request. Returns user row (with mfa state) or null.
|
// Resolve the logged-in user from the request. Returns user row (with mfa state) or null.
|
||||||
function currentUser(req, { requireMfa = true } = {}) {
|
// Async: the repos it reads go through the DB adapter, so every caller must `await currentUser(...)`.
|
||||||
|
async function currentUser(req, { requireMfa = true } = {}) {
|
||||||
const tok = tokenFromReq(req);
|
const tok = tokenFromReq(req);
|
||||||
if (!tok) return null;
|
if (!tok) return null;
|
||||||
const s = R.authSessions.byToken(tok);
|
const s = await R.authSessions.byToken(tok);
|
||||||
if (!s || s.expires_at < now()) return null;
|
if (!s || s.expires_at < now()) return null;
|
||||||
if (requireMfa && !s.mfa_passed) return null;
|
if (requireMfa && !s.mfa_passed) return null;
|
||||||
const u = R.users.byId(s.user_id);
|
const u = await R.users.byId(s.user_id);
|
||||||
if (!u || u.active === 0) return null;
|
if (!u || u.active === 0) return null;
|
||||||
return { ...u, _session: s };
|
return { ...u, _session: s };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`.
|
// Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`.
|
||||||
// Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed.
|
// Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed.
|
||||||
function apiKeyFromReq(req) {
|
async function apiKeyFromReq(req) {
|
||||||
let raw = req.headers && req.headers['x-api-key'];
|
let raw = req.headers && req.headers['x-api-key'];
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
const h = req.headers && (req.headers.authorization || req.headers.Authorization);
|
const h = req.headers && (req.headers.authorization || req.headers.Authorization);
|
||||||
if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim();
|
if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim();
|
||||||
}
|
}
|
||||||
if (!raw || !/^bzc_/.test(raw)) return null;
|
if (!raw || !/^bzc_/.test(raw)) return null;
|
||||||
const row = R.apiKeys.byHash(A.hashToken(raw));
|
const row = await R.apiKeys.byHash(A.hashToken(raw));
|
||||||
if (!row || row.revoked) return null;
|
if (!row || row.revoked) return null;
|
||||||
return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name };
|
return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,15 +26,15 @@ function noteRoomStat(room, ws, size) {
|
|||||||
if (ws._meetingUserId) st.uids.add(ws._meetingUserId);
|
if (ws._meetingUserId) st.uids.add(ws._meetingUserId);
|
||||||
}
|
}
|
||||||
// Called as a room is torn down. Scheduled meetings already have their own row, so they're skipped.
|
// Called as a room is torn down. Scheduled meetings already have their own row, so they're skipped.
|
||||||
function persistCallHistory(room) {
|
async function persistCallHistory(room) {
|
||||||
const st = roomStats.get(room);
|
const st = roomStats.get(room);
|
||||||
roomStats.delete(room);
|
roomStats.delete(room);
|
||||||
if (!st || !st.teamId || st.peak < 1) return;
|
if (!st || !st.teamId || st.peak < 1) return;
|
||||||
try {
|
try {
|
||||||
if (R.scheduledMeetings.byCode(room)) return; // the scheduled meeting row already represents this
|
if (await R.scheduledMeetings.byCode(room)) return; // the scheduled meeting row already represents this
|
||||||
const gid = roomToGroupCall.get(room) || null;
|
const gid = roomToGroupCall.get(room) || null;
|
||||||
const isDm = roomToDmCall.has(room);
|
const isDm = roomToDmCall.has(room);
|
||||||
R.callHistory.create({
|
await R.callHistory.create({
|
||||||
id: A.id(), teamId: st.teamId, room, groupId: gid,
|
id: A.id(), teamId: st.teamId, room, groupId: gid,
|
||||||
kind: isDm ? 'dm' : (gid ? 'group' : 'adhoc'),
|
kind: isDm ? 'dm' : (gid ? 'group' : 'adhoc'),
|
||||||
title: isDm ? 'Direct call' : (gid ? null : 'Meeting'),
|
title: isDm ? 'Direct call' : (gid ? null : 'Meeting'),
|
||||||
@@ -46,9 +46,9 @@ function persistCallHistory(room) {
|
|||||||
// A room requires host approval for GUESTS when explicitly set (ad-hoc) or by the scheduled meeting's
|
// A room requires host approval for GUESTS when explicitly set (ad-hoc) or by the scheduled meeting's
|
||||||
// `lobby` flag. Default: require approval (the "people with the link join directly" concern) unless the
|
// `lobby` flag. Default: require approval (the "people with the link join directly" concern) unless the
|
||||||
// organizer chose "join directly". Logged-in tenant users are never held — only guests.
|
// organizer chose "join directly". Logged-in tenant users are never held — only guests.
|
||||||
function meetingRoomRequiresApproval(room) {
|
async function meetingRoomRequiresApproval(room) {
|
||||||
if (roomLobby.has(room)) return !!roomLobby.get(room);
|
if (roomLobby.has(room)) return !!roomLobby.get(room);
|
||||||
try { const s = R.scheduledMeetings.byCode(room); if (s) return s.lobby !== 0; } catch (_) {}
|
try { const s = await R.scheduledMeetings.byCode(room); if (s) return s.lobby !== 0; } catch (_) {}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Actually add a newcomer to the room: tell them who's here, tell others they arrived, and (if they're
|
// Actually add a newcomer to the room: tell them who's here, tell others they arrived, and (if they're
|
||||||
@@ -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 }));
|
||||||
@@ -77,30 +77,33 @@ function onConnection(ws, req) {
|
|||||||
}, 25000);
|
}, 25000);
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
let m; try { m = JSON.parse(raw); } catch { return; }
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
||||||
handle(ws, m, req);
|
handle(ws, m, req).catch(() => {}); // handle is async now; never let a rejection go unhandled
|
||||||
});
|
});
|
||||||
ws.on('close', () => { clearInterval(hb); cleanup(ws); });
|
ws.on('close', () => { clearInterval(hb); cleanup(ws).catch(() => {}); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handle(ws, m, req) {
|
async function handle(ws, m, req) {
|
||||||
switch (m.type) {
|
switch (m.type) {
|
||||||
// --- Logged-in user registers this socket for live chat delivery ---
|
// --- Logged-in user registers this socket for live chat delivery ---
|
||||||
case 'chat-hello': {
|
case 'chat-hello': {
|
||||||
const u = currentUser(req); // identity from the cookie/Bearer on the WS upgrade
|
const u = await currentUser(req); // identity from the cookie/Bearer on the WS upgrade
|
||||||
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
|
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
|
||||||
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
|
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
|
||||||
CHAT.register(u.id, ws);
|
CHAT.register(u.id, ws);
|
||||||
ws.send(JSON.stringify({ type: 'chat-ready' }));
|
ws.send(JSON.stringify({ type: 'chat-ready' }));
|
||||||
CHAT.broadcastPresence(u.id); // tell contacts this user just came online
|
CHAT.broadcastPresence(u.id); // tell contacts this user just came online
|
||||||
|
// Re-ring any call this user is currently being called into (missed while their app was closed) —
|
||||||
|
// makes the call push actionable: opening the app resurfaces the invite so they can answer.
|
||||||
|
try { require('./calls').replayActiveCalls(u.id, ws); } catch (_) {}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
|
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
|
||||||
case 'chat-delivered': {
|
case 'chat-delivered': {
|
||||||
if (!ws._chatUserId || !m.id) break;
|
if (!ws._chatUserId || !m.id) break;
|
||||||
const msg = R.messages.byId(m.id);
|
const msg = await R.messages.byId(m.id);
|
||||||
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
|
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
|
||||||
if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack
|
if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack
|
||||||
if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} }
|
if (!msg.delivered_at) { await R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} }
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Live "is typing…" — ephemeral, never persisted. Relay to the DM peer, or fan out to
|
// Live "is typing…" — ephemeral, never persisted. Relay to the DM peer, or fan out to
|
||||||
@@ -108,9 +111,9 @@ function handle(ws, m, req) {
|
|||||||
case 'chat-typing': {
|
case 'chat-typing': {
|
||||||
const uid = ws._chatUserId; if (!uid) break;
|
const uid = ws._chatUserId; if (!uid) break;
|
||||||
const on = !!m.on;
|
const on = !!m.on;
|
||||||
let name = ''; try { const u = R.users.byId(uid); name = (u && u.name) || ''; } catch (_) {}
|
let name = ''; try { const u = await R.users.byId(uid); name = (u && u.name) || ''; } catch (_) {}
|
||||||
if (m.group) {
|
if (m.group) {
|
||||||
let members; try { if (!R.conversations.isMember(m.group, uid)) break; members = R.conversations.members(m.group); } catch (_) { break; }
|
let members; try { if (!await R.conversations.isMember(m.group, uid)) break; members = await R.conversations.members(m.group); } catch (_) { break; }
|
||||||
for (const mid of members) { if (mid !== uid) { try { CHAT.pushToUser(mid, { type: 'chat-typing', group: m.group, from: uid, name, on }); } catch (_) {} } }
|
for (const mid of members) { if (mid !== uid) { try { CHAT.pushToUser(mid, { type: 'chat-typing', group: m.group, from: uid, name, on }); } catch (_) {} } }
|
||||||
} else if (m.to) {
|
} else if (m.to) {
|
||||||
try { CHAT.pushToUser(m.to, { type: 'chat-typing', from: uid, name, on }); } catch (_) {}
|
try { CHAT.pushToUser(m.to, { type: 'chat-typing', from: uid, name, on }); } catch (_) {}
|
||||||
@@ -121,7 +124,7 @@ function handle(ws, m, req) {
|
|||||||
case 'meeting-create': {
|
case 'meeting-create': {
|
||||||
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
|
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
|
||||||
meetingRooms.set(code, new Map());
|
meetingRooms.set(code, new Map());
|
||||||
const cu = currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host
|
const cu = await currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host
|
||||||
// Lobby preference for guests joining this ad-hoc room by link (default: require approval).
|
// Lobby preference for guests joining this ad-hoc room by link (default: require approval).
|
||||||
roomLobby.set(code, m.lobby === false ? false : true);
|
roomLobby.set(code, m.lobby === false ? false : true);
|
||||||
ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
|
ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
|
||||||
@@ -132,17 +135,18 @@ function handle(ws, m, req) {
|
|||||||
let peers = meetingRooms.get(room);
|
let peers = meetingRooms.get(room);
|
||||||
// A scheduled meeting's room is created lazily on first join (its code lives in the DB).
|
// A scheduled meeting's room is created lazily on first join (its code lives in the DB).
|
||||||
if (!peers) {
|
if (!peers) {
|
||||||
const sched = R.scheduledMeetings.byCode(room);
|
const sched = await R.scheduledMeetings.byCode(room);
|
||||||
if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); }
|
if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); }
|
||||||
}
|
}
|
||||||
if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' }));
|
if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' }));
|
||||||
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 = 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 (_) {} }
|
||||||
const ju = currentUser(req);
|
const ju = await currentUser(req);
|
||||||
// Identity used to map LiveKit media → this tile (peerIdForUid). Logged-in users use their user id
|
// Identity used to map LiveKit media → this tile (peerIdForUid). Logged-in users use their user id
|
||||||
// (their LiveKit token identity is the same). GUESTS have no session, so they pass a stable client
|
// (their LiveKit token identity is the same). GUESTS have no session, so they pass a stable client
|
||||||
// guest id here that ALSO becomes their LiveKit token identity — otherwise their media never maps
|
// guest id here that ALSO becomes their LiveKit token identity — otherwise their media never maps
|
||||||
@@ -154,7 +158,7 @@ function handle(ws, m, req) {
|
|||||||
if (ju) ws._meetingTeamId = ju.team_id; // #7: which tenant owns this call log
|
if (ju) ws._meetingTeamId = ju.team_id; // #7: which tenant owns this call log
|
||||||
// LOBBY (#4): a GUEST (no session) waits for the host to admit them when the room requires approval.
|
// LOBBY (#4): a GUEST (no session) waits for the host to admit them when the room requires approval.
|
||||||
// Logged-in tenant members always join directly.
|
// Logged-in tenant members always join directly.
|
||||||
if (!ju && meetingRoomRequiresApproval(room)) {
|
if (!ju && await meetingRoomRequiresApproval(room)) {
|
||||||
let pend = lobbyPending.get(room); if (!pend) { pend = new Map(); lobbyPending.set(room, pend); }
|
let pend = lobbyPending.get(room); if (!pend) { pend = new Map(); lobbyPending.set(room, pend); }
|
||||||
pend.set(peerId, ws); ws._lobbyRoom = room;
|
pend.set(peerId, ws); ws._lobbyRoom = room;
|
||||||
ws.send(JSON.stringify({ type: 'meeting-lobby-wait' }));
|
ws.send(JSON.stringify({ type: 'meeting-lobby-wait' }));
|
||||||
@@ -259,25 +263,25 @@ function handle(ws, m, req) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'meeting-leave': {
|
case 'meeting-leave': {
|
||||||
leaveMeeting(ws);
|
await leaveMeeting(ws);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// --- Agent comes online ---
|
// --- Agent comes online ---
|
||||||
case 'agent-hello': {
|
case 'agent-hello': {
|
||||||
const machine = R.machines.byEnrollToken(m.enrollToken);
|
const machine = await R.machines.byEnrollToken(m.enrollToken);
|
||||||
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
|
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
|
||||||
ws.kind = 'agent'; ws.machineId = machine.id;
|
ws.kind = 'agent'; ws.machineId = machine.id;
|
||||||
onlineAgents.set(machine.id, { ws, machine });
|
onlineAgents.set(machine.id, { ws, machine });
|
||||||
R.machines.touch(machine.id);
|
await R.machines.touch(machine.id);
|
||||||
ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
|
ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// --- Technician requests control of a machine ---
|
// --- Technician requests control of a machine ---
|
||||||
case 'viewer-connect': {
|
case 'viewer-connect': {
|
||||||
const u = currentUser(req); // cookie sent on WS upgrade
|
const u = await currentUser(req); // cookie sent on WS upgrade
|
||||||
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
|
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
|
||||||
const agent = onlineAgents.get(m.machineId);
|
const agent = onlineAgents.get(m.machineId);
|
||||||
const machine = R.machines.inTenant(m.machineId, u.team_id);
|
const machine = await R.machines.inTenant(m.machineId, u.team_id);
|
||||||
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
|
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
|
||||||
if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
|
if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
|
||||||
if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
|
if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
|
||||||
@@ -301,7 +305,7 @@ function handle(ws, m, req) {
|
|||||||
if (m.granted) {
|
if (m.granted) {
|
||||||
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
|
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
|
||||||
try {
|
try {
|
||||||
R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null });
|
await R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null });
|
||||||
} catch (e) { /* duplicate consent */ }
|
} catch (e) { /* duplicate consent */ }
|
||||||
try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {}
|
try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {}
|
||||||
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
|
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
|
||||||
@@ -325,7 +329,7 @@ function handle(ws, m, req) {
|
|||||||
}
|
}
|
||||||
// --- Logged-in agent enters the code (+ ticket) to connect ---
|
// --- Logged-in agent enters the code (+ ticket) to connect ---
|
||||||
case 'code-connect': {
|
case 'code-connect': {
|
||||||
const agent = currentUser(req); // identity from the agent's authenticated session
|
const agent = await currentUser(req); // identity from the agent's authenticated session
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
|
return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
|
||||||
}
|
}
|
||||||
@@ -369,19 +373,29 @@ 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': {
|
||||||
endSession(ws.sessionId, m.reason || null);
|
await endSession(ws.sessionId, m.reason || null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function endSession(sessionId, reason) {
|
async function endSession(sessionId, reason) {
|
||||||
const sess = liveSessions.get(sessionId);
|
const sess = liveSessions.get(sessionId);
|
||||||
if (!sess) return;
|
if (!sess) return;
|
||||||
try { R.sessionsLog.end(sessionId); } catch (e) {}
|
try { await R.sessionsLog.end(sessionId); } catch (e) {}
|
||||||
try {
|
try {
|
||||||
const row = R.sessionsLog.byId(sessionId);
|
const row = await R.sessionsLog.byId(sessionId);
|
||||||
if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email,
|
if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email,
|
||||||
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
|
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
|
||||||
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
|
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
|
||||||
@@ -393,7 +407,7 @@ function endSession(sessionId, reason) {
|
|||||||
liveSessions.delete(sessionId);
|
liveSessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function leaveMeeting(ws) {
|
async function leaveMeeting(ws) {
|
||||||
const room = ws._meetingRoom;
|
const room = ws._meetingRoom;
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const peers = meetingRooms.get(room);
|
const peers = meetingRooms.get(room);
|
||||||
@@ -401,34 +415,41 @@ function leaveMeeting(ws) {
|
|||||||
const pid = ws._peerId;
|
const pid = ws._peerId;
|
||||||
const leaverId = ws._meetingUserId;
|
const leaverId = ws._meetingUserId;
|
||||||
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
|
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
|
||||||
try { 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; } }
|
||||||
persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall
|
await persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall
|
||||||
meetingRooms.delete(room);
|
meetingRooms.delete(room);
|
||||||
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
|
try { await require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
|
||||||
roomHost.delete(room);
|
roomHost.delete(room);
|
||||||
try { require('./calls').endCallByRoom(room); } catch (_) {}
|
try { await require('./calls').endCallByRoom(room); } catch (_) {}
|
||||||
if (leaverId) CHAT.broadcastPresence(leaverId);
|
if (leaverId) CHAT.broadcastPresence(leaverId);
|
||||||
others.forEach((uid) => CHAT.broadcastPresence(uid)); // both parties are now out of the call → live update
|
others.forEach((uid) => CHAT.broadcastPresence(uid)); // both parties are now out of the call → live update
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
|
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
|
||||||
if (peers.size === 0) {
|
if (peers.size === 0) {
|
||||||
persistCallHistory(room); // #7: log the finished call (before the room maps are cleared)
|
await persistCallHistory(room); // #7: log the finished call (before the room maps are cleared)
|
||||||
meetingRooms.delete(room);
|
meetingRooms.delete(room);
|
||||||
lobbyPending.delete(room); roomLobby.delete(room); // room gone → drop its lobby state
|
lobbyPending.delete(room); roomLobby.delete(room); // room gone → drop its lobby state
|
||||||
try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps
|
try { await require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps
|
||||||
roomHost.delete(room);
|
roomHost.delete(room);
|
||||||
try { require('./calls').endCallByRoom(room); } catch (_) {}
|
try { await require('./calls').endCallByRoom(room); } catch (_) {}
|
||||||
}
|
}
|
||||||
if (leaverId) CHAT.broadcastPresence(leaverId); // this user left the call → update contacts live
|
if (leaverId) CHAT.broadcastPresence(leaverId); // this user left the call → update contacts live
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup(ws) {
|
async function cleanup(ws) {
|
||||||
const goneUserId = ws._chatUserId; // capture before unregister so we can announce the change
|
const goneUserId = ws._chatUserId; // capture before unregister so we can announce the change
|
||||||
// A guest waiting in the lobby dropped → remove their pending request and tell the host to clear it.
|
// A guest waiting in the lobby dropped → remove their pending request and tell the host to clear it.
|
||||||
if (ws._lobbyRoom) {
|
if (ws._lobbyRoom) {
|
||||||
@@ -438,14 +459,14 @@ function cleanup(ws) {
|
|||||||
ws._lobbyRoom = null;
|
ws._lobbyRoom = null;
|
||||||
}
|
}
|
||||||
CHAT.unregister(ws);
|
CHAT.unregister(ws);
|
||||||
leaveMeeting(ws);
|
await leaveMeeting(ws);
|
||||||
if (goneUserId) CHAT.broadcastPresence(goneUserId); // now reflects offline / no-longer-in-call
|
if (goneUserId) CHAT.broadcastPresence(goneUserId); // now reflects offline / no-longer-in-call
|
||||||
|
|
||||||
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
|
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
|
||||||
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
|
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
|
||||||
if (ws.sessionId) {
|
if (ws.sessionId) {
|
||||||
for (const [sid, sess] of liveSessions) {
|
for (const [sid, sess] of liveSessions) {
|
||||||
if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
|
if (sess.agentWs === ws || sess.viewerWs === ws) await endSession(sid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,27 +12,32 @@ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css
|
|||||||
|
|
||||||
// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
|
// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
|
||||||
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
|
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
|
||||||
function authAttachmentRaw(id, u) {
|
async function authAttachmentRaw(id, u) {
|
||||||
const a = R.attachments.byId(id);
|
const a = await R.attachments.byId(id);
|
||||||
if (!a || a.team_id !== u.team_id) return null;
|
if (!a || a.team_id !== u.team_id) return null;
|
||||||
const avatarGroup = R.conversations.byAvatar(id);
|
const avatarGroup = await R.conversations.byAvatar(id);
|
||||||
const carriers = R.messages.allByAttachment(id);
|
const carriers = await R.messages.allByAttachment(id);
|
||||||
const ok = a.uploader_id === u.id
|
let ok = a.uploader_id === u.id || (avatarGroup && await R.conversations.isMember(avatarGroup.id, u.id));
|
||||||
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|
if (!ok) {
|
||||||
|| carriers.some((msg) => msg.conversation_id
|
// A .some() predicate can't await, so walk the carriers explicitly.
|
||||||
? R.conversations.isMember(msg.conversation_id, u.id)
|
for (const msg of carriers) {
|
||||||
: (msg.sender_id === u.id || msg.recipient_id === u.id));
|
const carried = msg.conversation_id
|
||||||
|
? await R.conversations.isMember(msg.conversation_id, u.id)
|
||||||
|
: (msg.sender_id === u.id || msg.recipient_id === u.id);
|
||||||
|
if (carried) { ok = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
return ok ? a : null;
|
return ok ? a : null;
|
||||||
}
|
}
|
||||||
// Video playback fires MANY /files Range requests, and authAttachmentRaw scans messages by attachment_id
|
// Video playback fires MANY /files Range requests, and authAttachmentRaw scans messages by attachment_id
|
||||||
// (un-indexed) each time — that per-chunk scan is what made playback stutter ("buffers and plays…"). Cache the
|
// each time — that per-chunk scan is what made playback stutter ("buffers and plays…"). Cache the resolved
|
||||||
// decision per user+attachment for 60s so range requests after the first are ~free. Bounded to keep memory flat.
|
// decision per user+attachment for 60s so range requests after the first are ~free. Bounded to keep memory flat.
|
||||||
const _attAuth = new Map();
|
const _attAuth = new Map();
|
||||||
function authAttachment(id, u) {
|
async function authAttachment(id, u) {
|
||||||
const key = u.id + ':' + id, now = Date.now();
|
const key = u.id + ':' + id, now = Date.now();
|
||||||
const hit = _attAuth.get(key);
|
const hit = _attAuth.get(key);
|
||||||
if (hit && hit.exp > now) return hit.a;
|
if (hit && hit.exp > now) return hit.a;
|
||||||
const a = authAttachmentRaw(id, u);
|
const a = await authAttachmentRaw(id, u);
|
||||||
if (_attAuth.size > 4000) _attAuth.clear();
|
if (_attAuth.size > 4000) _attAuth.clear();
|
||||||
_attAuth.set(key, { a, exp: now + 60000 });
|
_attAuth.set(key, { a, exp: now + 60000 });
|
||||||
return a;
|
return a;
|
||||||
@@ -72,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
|
||||||
@@ -100,7 +107,7 @@ function serveStatic(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET fallback: authenticated transcript/recording downloads, else static files.
|
// GET fallback: authenticated transcript/recording downloads, else static files.
|
||||||
function handleGet(req, res) {
|
async function handleGet(req, res) {
|
||||||
const pathOnly = req.url.split('?')[0];
|
const pathOnly = req.url.split('?')[0];
|
||||||
// Stable "latest Windows installer" link (used by the site's Download button). Reads the
|
// Stable "latest Windows installer" link (used by the site's Download button). Reads the
|
||||||
// electron-updater manifest and redirects to the current versioned .exe.
|
// electron-updater manifest and redirects to the current versioned .exe.
|
||||||
@@ -141,11 +148,11 @@ function handleGet(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pathOnly.startsWith('/transcripts/')) {
|
if (pathOnly.startsWith('/transcripts/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const name = path.basename(decodeURIComponent(pathOnly));
|
const name = path.basename(decodeURIComponent(pathOnly));
|
||||||
const sid = name.replace(/\.txt$/i, '');
|
const sid = name.replace(/\.txt$/i, '');
|
||||||
const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
|
const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
|
||||||
if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
|
if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
|
||||||
const fp = path.join(TRANS_DIR, row.transcript);
|
const fp = path.join(TRANS_DIR, row.transcript);
|
||||||
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
|
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||||
@@ -158,11 +165,11 @@ function handleGet(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pathOnly.startsWith('/recordings/')) {
|
if (pathOnly.startsWith('/recordings/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const name = path.basename(decodeURIComponent(pathOnly));
|
const name = path.basename(decodeURIComponent(pathOnly));
|
||||||
const sid = name.replace(/\.(webm|mp4)$/i, '');
|
const sid = name.replace(/\.(webm|mp4)$/i, '');
|
||||||
const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
|
const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
|
||||||
if (!row || !row.recording) return json(res, 404, { error: 'not found' });
|
if (!row || !row.recording) return json(res, 404, { error: 'not found' });
|
||||||
const fp = path.join(REC_DIR, row.recording);
|
const fp = path.join(REC_DIR, row.recording);
|
||||||
if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
|
if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||||
@@ -179,16 +186,16 @@ function handleGet(req, res) {
|
|||||||
// Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
|
// Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
|
||||||
// who can see the scheduled meeting it belongs to.
|
// who can see the scheduled meeting it belongs to.
|
||||||
if (pathOnly.startsWith('/mrec/')) {
|
if (pathOnly.startsWith('/mrec/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
const id = path.basename(decodeURIComponent(pathOnly));
|
||||||
const r = R.recordings.byId(id);
|
const r = await R.recordings.byId(id);
|
||||||
if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
|
if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
|
||||||
let allowed = r.created_by === u.id;
|
let allowed = r.created_by === u.id;
|
||||||
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
|
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
|
||||||
else {
|
else {
|
||||||
if (!allowed && r.group_id) allowed = R.conversations.isMember(r.group_id, u.id);
|
if (!allowed && r.group_id) allowed = await R.conversations.isMember(r.group_id, u.id);
|
||||||
if (!allowed && r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
|
if (!allowed && r.meeting_id) { const s = await R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
|
||||||
}
|
}
|
||||||
if (!allowed) return json(res, 403, { error: 'forbidden' });
|
if (!allowed) return json(res, 403, { error: 'forbidden' });
|
||||||
const isVideo = r.kind === 'video';
|
const isVideo = r.kind === 'video';
|
||||||
@@ -208,10 +215,10 @@ function handleGet(req, res) {
|
|||||||
// Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg
|
// Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg
|
||||||
// is missing or fails we 404 and the <video> just falls back to its own (black) poster.
|
// is missing or fails we 404 and the <video> just falls back to its own (black) poster.
|
||||||
if (pathOnly.startsWith('/thumbs/')) {
|
if (pathOnly.startsWith('/thumbs/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
const id = path.basename(decodeURIComponent(pathOnly));
|
||||||
const a = authAttachment(id, u);
|
const a = await authAttachment(id, u);
|
||||||
if (!a) return json(res, 404, { error: 'not found' });
|
if (!a) return json(res, 404, { error: 'not found' });
|
||||||
if (!/^video\//.test(a.mime || '')) return json(res, 404, { error: 'not a video' });
|
if (!/^video\//.test(a.mime || '')) return json(res, 404, { error: 'not a video' });
|
||||||
const src = path.join(UPLOADS_DIR, id);
|
const src = path.join(UPLOADS_DIR, id);
|
||||||
@@ -236,10 +243,10 @@ function handleGet(req, res) {
|
|||||||
// back to the original bytes while that is still transcoding, so a video is never unplayable. The
|
// back to the original bytes while that is still transcoding, so a video is never unplayable. The
|
||||||
// download button keeps pointing at /files, which always serves the untouched original.
|
// download button keeps pointing at /files, which always serves the untouched original.
|
||||||
if (pathOnly.startsWith('/stream/')) {
|
if (pathOnly.startsWith('/stream/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
const id = path.basename(decodeURIComponent(pathOnly));
|
||||||
const a = authAttachment(id, u);
|
const a = await authAttachment(id, u);
|
||||||
if (!a) return json(res, 404, { error: 'not found' });
|
if (!a) return json(res, 404, { error: 'not found' });
|
||||||
media.ensureWebRendition(id, a.mime); // idempotent — also backfills pre-existing uploads
|
media.ensureWebRendition(id, a.mime); // idempotent — also backfills pre-existing uploads
|
||||||
const ready = media.hasWebRendition(id);
|
const ready = media.hasWebRendition(id);
|
||||||
@@ -253,10 +260,10 @@ function handleGet(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pathOnly.startsWith('/files/')) {
|
if (pathOnly.startsWith('/files/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
const id = path.basename(decodeURIComponent(pathOnly));
|
||||||
const a = authAttachment(id, u);
|
const a = await authAttachment(id, u);
|
||||||
if (!a) return json(res, 404, { error: 'not found' });
|
if (!a) return json(res, 404, { error: 'not found' });
|
||||||
const fp = path.join(UPLOADS_DIR, id);
|
const fp = path.join(UPLOADS_DIR, id);
|
||||||
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
|
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ function deliver(url, secret, body, onDone) {
|
|||||||
go();
|
go();
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(event, tenantId, payload) {
|
async function emit(event, tenantId, payload) {
|
||||||
const body = JSON.stringify({ event, ...payload });
|
const body = JSON.stringify({ event, ...payload });
|
||||||
// Per-tenant subscriptions
|
// Per-tenant subscriptions
|
||||||
try {
|
try {
|
||||||
for (const h of R.webhooks.activeForTenant(tenantId)) {
|
for (const h of await R.webhooks.activeForTenant(tenantId)) {
|
||||||
const subs = String(h.events || '').split(',').map((s) => s.trim());
|
const subs = String(h.events || '').split(',').map((s) => s.trim());
|
||||||
if (subs.includes('*') || subs.includes(event)) {
|
if (subs.includes('*') || subs.includes(event)) {
|
||||||
deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
|
deliver(h.url, h.secret, body, async (r) => { try { await R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||