Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||||
|
|||||||
+21
-8
@@ -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,8 +44,23 @@ 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; 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 "asset generation skipped"
|
||||||
|
|
||||||
@@ -90,10 +105,8 @@ workflows:
|
|||||||
--create
|
--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 +117,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 "================================================================="
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -34,6 +34,48 @@ services:
|
|||||||
- bizgaze_support_data:/data # persists data.db across rebuilds
|
- bizgaze_support_data:/data # persists data.db across rebuilds
|
||||||
networks:
|
networks:
|
||||||
- npm
|
- npm
|
||||||
|
# Wait for Postgres to be healthy before starting. Only matters when DB_BACKEND=pg (else the app uses
|
||||||
|
# the local SQLite file and ignores pg), but it's harmless on SQLite — pg comes up in a second or two.
|
||||||
|
depends_on:
|
||||||
|
bizgazepg:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# PostgreSQL — the app's data store when DB_BACKEND=pg (default is the local SQLite file). Distinct
|
||||||
|
# service/container name so it never collides with the other postgres containers on the shared NPM
|
||||||
|
# network; the app reaches it as `bizgaze-postgres`. Data on its own named volume.
|
||||||
|
bizgazepg:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: bizgaze-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=bizgaze
|
||||||
|
- POSTGRES_DB=bizgaze
|
||||||
|
# Password comes from the same .env as the app (compose interpolates ${...} from the .env in this dir).
|
||||||
|
# NOTE: Postgres only applies POSTGRES_PASSWORD on FIRST init of an empty data volume.
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-bizgaze_local}
|
||||||
|
volumes:
|
||||||
|
- bizgaze_pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- npm
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U bizgaze -d bizgaze"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
# Redis — cross-instance real-time fan-out (chat/presence), used only when PUBSUB_BACKEND=redis. Dormant
|
||||||
|
# by default (behind the 'scale' profile, like livekit), so a normal deploy never starts it and the app
|
||||||
|
# stays single-instance on the in-memory pubsub. To run multiple app instances: start this
|
||||||
|
# (`docker compose --profile scale up -d`), set PUBSUB_BACKEND=redis + REDIS_URL in .env, and put the app
|
||||||
|
# behind a load balancer with sticky sessions for the /ws WebSocket. (Meeting SIGNALING state is still
|
||||||
|
# per-process — cross-instance meetings need sticky routing or further work; chat/presence fan out here.)
|
||||||
|
bizgazeredis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: bizgaze-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["scale"]
|
||||||
|
networks:
|
||||||
|
- npm
|
||||||
|
|
||||||
# LiveKit SFU — meeting media server. Optional: only started/used when the app's .env has
|
# LiveKit SFU — meeting media server. Optional: only started/used when the app's .env has
|
||||||
# LIVEKIT_URL/API_KEY/API_SECRET set (otherwise meetings use the built-in P2P mesh). NPM proxies
|
# LIVEKIT_URL/API_KEY/API_SECRET set (otherwise meetings use the built-in P2P mesh). NPM proxies
|
||||||
@@ -68,3 +110,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bizgaze_support_data:
|
bizgaze_support_data:
|
||||||
|
bizgaze_pg_data:
|
||||||
|
|||||||
+11
-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`
|
||||||
|
|||||||
Binary file not shown.
+14
-13
@@ -12,22 +12,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"audio-route": "file:plugins/audio-route",
|
"audio-route": "file:plugins/audio-route",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,321 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
import PushKit
|
||||||
|
import CallKit
|
||||||
|
import AVFoundation
|
||||||
|
import LiveKit // SPM product name is 'LiveKit' (Package.swift). (The old CocoaPods module was 'LiveKitClient'.)
|
||||||
|
|
||||||
|
// Native calling for Biz Connect (iOS). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall.
|
||||||
|
//
|
||||||
|
// ARCHITECTURE (native media):
|
||||||
|
// * PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices 'ios-voip').
|
||||||
|
// * CallKit: incoming VoIP push -> full-screen system ring (works when force-killed). The CallKit call
|
||||||
|
// stays ACTIVE for the whole call (that's what foregrounds/unlocks the app on answer and keeps the call
|
||||||
|
// alive in the background).
|
||||||
|
// * LiveKit: the call MEDIA runs NATIVELY via the LiveKit iOS SDK — so the mic works with an active CallKit
|
||||||
|
// call (WebKit's WebRTC could not) and audio survives backgrounding. The VoIP payload carries the
|
||||||
|
// LiveKit url+token so we can connect immediately on answer, even from a killed state; outgoing calls get
|
||||||
|
// the token from the WebView via reportOutgoingCall(). The WebView is UI only for native calls (it must
|
||||||
|
// NOT also join the room — LiveKit allows one connection per identity).
|
||||||
|
@objc(NativeCallPlugin)
|
||||||
|
public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate {
|
||||||
|
public let identifier = "NativeCallPlugin"
|
||||||
|
public let jsName = "NativeCall"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
private var pushRegistry: PKPushRegistry?
|
||||||
|
private var provider: CXProvider?
|
||||||
|
private let callController = CXCallController()
|
||||||
|
private var voipToken: String = ""
|
||||||
|
// callUUID -> the call's data (room, kind, callerId, livekitUrl, livekitToken, …).
|
||||||
|
private var calls: [UUID: [String: Any]] = [:]
|
||||||
|
// Calls we've already ended locally — so a LATE cancel push doesn't re-report (which briefly re-rang).
|
||||||
|
private var endedCalls = Set<UUID>()
|
||||||
|
private var room: Room?
|
||||||
|
private var activeUUID: UUID?
|
||||||
|
|
||||||
|
override public func load() {
|
||||||
|
let config = CXProviderConfiguration(localizedName: "Biz Connect")
|
||||||
|
config.supportsVideo = true
|
||||||
|
config.maximumCallGroups = 1
|
||||||
|
config.maximumCallsPerCallGroup = 1
|
||||||
|
config.supportedHandleTypes = [.generic]
|
||||||
|
let p = CXProvider(configuration: config)
|
||||||
|
p.setDelegate(self, queue: nil)
|
||||||
|
provider = p
|
||||||
|
|
||||||
|
let registry = PKPushRegistry(queue: .main)
|
||||||
|
registry.delegate = self
|
||||||
|
registry.desiredPushTypes = [.voIP]
|
||||||
|
pushRegistry = registry
|
||||||
|
// CallKit audio coordination (LiveKit 2.15+, pinned to the git tag in ios-patch.sh). LiveKit's automatic
|
||||||
|
// AVAudioSession config RACES CallKit's own activation → intermittent dead mic / no audio + the output
|
||||||
|
// route only settling once audio flows ("speaker turns on late"). Fix: turn auto-config OFF and keep the
|
||||||
|
// engine OFF; we configure the session and enable the engine ONLY in didActivate.
|
||||||
|
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
|
||||||
|
try? AudioManager.shared.setEngineAvailability(.none)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LiveKit media
|
||||||
|
|
||||||
|
private func connectRoom(url: String, token: String) {
|
||||||
|
guard !url.isEmpty, !token.isEmpty else { return }
|
||||||
|
// Ask for mic permission now (we still connect MUTED). If it stays "undetermined", enabling the audio
|
||||||
|
// engine in didActivate can block on first use — determining it up front avoids that.
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
|
||||||
|
let old = room
|
||||||
|
let r = Room()
|
||||||
|
room = r
|
||||||
|
Task { [weak self] in
|
||||||
|
await old?.disconnect() // drop any previous/stale connection so we never leave DUPLICATE participants
|
||||||
|
do {
|
||||||
|
try await r.connect(url: url, token: token)
|
||||||
|
// House rule: answer MUTED. Not enabling the mic here means nothing is captured/published
|
||||||
|
// until the user taps Mic in the meeting UI (→ setMuted(false) → setMicrophone(true)), which
|
||||||
|
// is also when iOS asks for mic permission. Playback (hearing others) is unaffected.
|
||||||
|
try await r.localParticipant.setMicrophone(enabled: false)
|
||||||
|
self?.notifyListeners("callConnected", data: ["ok": true])
|
||||||
|
} catch {
|
||||||
|
self?.notifyListeners("callError", data: ["error": String(describing: error)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disconnectRoom() {
|
||||||
|
let r = room
|
||||||
|
room = nil
|
||||||
|
Task { await r?.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected
|
||||||
|
// wired/Bluetooth headset (any non-receiver route) still wins. Re-asserted on mute toggles because enabling
|
||||||
|
// the mic can flip the route back to the earpiece.
|
||||||
|
private func preferSpeaker() {
|
||||||
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
if s.currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver }) {
|
||||||
|
try? s.overrideOutputAudioPort(.speaker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JS-callable methods
|
||||||
|
|
||||||
|
@objc func getToken(_ call: CAPPluginCall) { call.resolve(["token": voipToken]) }
|
||||||
|
|
||||||
|
// Outgoing call from the web app: start a CallKit outgoing call AND connect the LiveKit room natively.
|
||||||
|
@objc func reportOutgoingCall(_ call: CAPPluginCall) {
|
||||||
|
guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else {
|
||||||
|
call.reject("callUUID required"); return
|
||||||
|
}
|
||||||
|
let url = call.getString("url") ?? ""
|
||||||
|
let token = call.getString("token") ?? ""
|
||||||
|
let video = call.getBool("hasVideo") ?? false
|
||||||
|
calls[uuid] = ["room": call.getString("room") ?? "", "kind": call.getString("kind") ?? "dm",
|
||||||
|
"livekitUrl": url, "livekitToken": token]
|
||||||
|
activeUUID = uuid
|
||||||
|
let handle = CXHandle(type: .generic, value: call.getString("peerName") ?? "Call")
|
||||||
|
let start = CXStartCallAction(call: uuid, handle: handle)
|
||||||
|
start.isVideo = video
|
||||||
|
callController.request(CXTransaction(action: start)) { [weak self] error in
|
||||||
|
if let error = error { call.reject(error.localizedDescription); return }
|
||||||
|
self?.connectRoom(url: url, token: token)
|
||||||
|
// Start muted (house rule) — also reflect it on the CallKit system call screen.
|
||||||
|
self?.callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: true))) { _ in }
|
||||||
|
self?.provider?.reportOutgoingCall(with: uuid, connectedAt: Date())
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring CallKit from a WEBSOCKET call event (a 2nd path alongside the VoIP push). When the app is open the
|
||||||
|
// WS is connected and reliable, but the VoIP push can be delayed/dropped → "sometimes it doesn't ring".
|
||||||
|
// Deduped by UUID: if the VoIP push already reported this call, this is a no-op (and vice-versa).
|
||||||
|
@objc func reportIncomingCall(_ call: CAPPluginCall) {
|
||||||
|
guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else {
|
||||||
|
call.reject("callUUID required"); return
|
||||||
|
}
|
||||||
|
// Already handled (ended, ringing, or active) → don't re-report (CallKit would reject a dup anyway).
|
||||||
|
if endedCalls.contains(uuid) || calls[uuid] != nil || activeUUID == uuid { call.resolve(); return }
|
||||||
|
let callerName = call.getString("callerName") ?? call.getString("groupName") ?? "Incoming call"
|
||||||
|
let hasVideo = call.getBool("hasVideo") ?? false
|
||||||
|
calls[uuid] = [
|
||||||
|
"callUUID": uuidStr, "room": call.getString("room") ?? "", "kind": call.getString("kind") ?? "dm",
|
||||||
|
"callerId": call.getString("callerId") ?? "", "callerName": callerName,
|
||||||
|
"groupId": call.getString("groupId") ?? "", "groupName": call.getString("groupName") ?? "",
|
||||||
|
"hasVideo": hasVideo, "livekitUrl": call.getString("url") ?? "", "livekitToken": call.getString("token") ?? "",
|
||||||
|
]
|
||||||
|
let update = CXCallUpdate()
|
||||||
|
update.remoteHandle = CXHandle(type: .generic, value: callerName)
|
||||||
|
update.localizedCallerName = callerName
|
||||||
|
update.hasVideo = hasVideo
|
||||||
|
update.supportsHolding = false; update.supportsGrouping = false; update.supportsUngrouping = false
|
||||||
|
provider?.reportNewIncomingCall(with: uuid, update: update) { _ in }
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setMuted(_ call: CAPPluginCall) {
|
||||||
|
let muted = call.getBool("muted") ?? false
|
||||||
|
// Drive mute THROUGH CallKit so the system call screen and the in-app meeting UI stay in sync (the
|
||||||
|
// CXSetMutedCallAction handler does the actual setMicrophone). Fall back to a direct call if somehow
|
||||||
|
// there's no active CallKit call.
|
||||||
|
if let uuid = activeUUID {
|
||||||
|
callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: muted))) { _ in }
|
||||||
|
} else {
|
||||||
|
let r = room
|
||||||
|
Task { try? await r?.localParticipant.setMicrophone(enabled: !muted) }
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// End the CallKit call (remote hung up / user ended from the web UI). No callUUID -> end all.
|
||||||
|
@objc func endCall(_ call: CAPPluginCall) {
|
||||||
|
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) {
|
||||||
|
requestEnd(uuid)
|
||||||
|
} else {
|
||||||
|
for uuid in calls.keys { requestEnd(uuid) }
|
||||||
|
if let a = activeUUID { requestEnd(a) }
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestEnd(_ uuid: UUID) {
|
||||||
|
callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { _ in }
|
||||||
|
calls.removeValue(forKey: uuid)
|
||||||
|
endedCalls.insert(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PushKit (VoIP)
|
||||||
|
|
||||||
|
public func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||||
|
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
|
||||||
|
voipToken = token
|
||||||
|
notifyListeners("voipToken", data: ["token": token])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||||
|
voipToken = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming VoIP push. iOS 13+: we MUST reportNewIncomingCall for EVERY push before completion(), or the
|
||||||
|
// app is terminated.
|
||||||
|
public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||||
|
var dict: [String: Any] = [:]
|
||||||
|
for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } }
|
||||||
|
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID()
|
||||||
|
|
||||||
|
// CANCEL: caller hung up / declined / timed out. iOS requires a reported call per push, but blindly
|
||||||
|
// reporting a NEW incoming call is what caused the "rings back for a second" blip on a call we've
|
||||||
|
// already handled. So:
|
||||||
|
if (dict["type"] as? String) == "cancel" {
|
||||||
|
// iOS 13+ TERMINATES the app if a VoIP push doesn't result in reportNewIncomingCall before
|
||||||
|
// completion() — that's the crash after several calls (my earlier "no-blip" optimization SKIPPED
|
||||||
|
// the report for known/ended calls, which iOS kills for). So ALWAYS report, then immediately end:
|
||||||
|
// * uuid already ringing/active → the report errors harmlessly (NO second ring), reportCall ends it.
|
||||||
|
// * late/duplicate cancel (done) → a brief, unavoidable blip (acceptable; a crash is not).
|
||||||
|
let u = CXCallUpdate()
|
||||||
|
u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call")
|
||||||
|
let wasActive = (activeUUID == uuid)
|
||||||
|
var ev = calls[uuid] ?? [:]; ev["callUUID"] = uuid.uuidString
|
||||||
|
calls.removeValue(forKey: uuid)
|
||||||
|
endedCalls.insert(uuid)
|
||||||
|
if wasActive { disconnectRoom(); activeUUID = nil }
|
||||||
|
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
|
||||||
|
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
||||||
|
if wasActive { self?.notifyListeners("endCall", data: ev) } // active call cancelled → leave the meeting
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let callerName = (dict["callerName"] as? String) ?? (dict["groupName"] as? String) ?? "Incoming call"
|
||||||
|
let hasVideo = (dict["hasVideo"] as? Bool) ?? false
|
||||||
|
calls[uuid] = dict
|
||||||
|
|
||||||
|
let update = CXCallUpdate()
|
||||||
|
update.remoteHandle = CXHandle(type: .generic, value: callerName)
|
||||||
|
update.localizedCallerName = callerName
|
||||||
|
update.hasVideo = hasVideo
|
||||||
|
update.supportsHolding = false
|
||||||
|
update.supportsGrouping = false
|
||||||
|
update.supportsUngrouping = false
|
||||||
|
|
||||||
|
provider?.reportNewIncomingCall(with: uuid, update: update) { _ in completion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CXProviderDelegate
|
||||||
|
|
||||||
|
public func providerDidReset(_ provider: CXProvider) {
|
||||||
|
disconnectRoom()
|
||||||
|
calls.removeAll(); endedCalls.removeAll(); activeUUID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||||
|
let uuid = action.callUUID
|
||||||
|
let data = calls[uuid] ?? [:]
|
||||||
|
activeUUID = uuid
|
||||||
|
action.fulfill()
|
||||||
|
// Keep the CallKit call ACTIVE (foregrounds the app + keeps the call alive). Connect the LiveKit room
|
||||||
|
// NATIVELY so the mic works with the active CallKit call and audio survives backgrounding.
|
||||||
|
connectRoom(url: (data["livekitUrl"] as? String) ?? "", token: (data["livekitToken"] as? String) ?? "")
|
||||||
|
// We answer muted (house rule) — reflect that on the CallKit system call screen (the mic button there
|
||||||
|
// showed unmuted before, even though we were functionally muted).
|
||||||
|
callController.request(CXTransaction(action: CXSetMutedCallAction(call: uuid, muted: true))) { _ in }
|
||||||
|
var ev = data; ev["callUUID"] = uuid.uuidString
|
||||||
|
// Answering from a KILLED/locked state launches the app — the WebView's JS listener may not be attached
|
||||||
|
// yet, so retain the event until it is. Without this the "answered" signal is lost, the server's
|
||||||
|
// unanswered timer fires, and the call is cancelled ~40s after pickup.
|
||||||
|
notifyListeners("answerCall", data: ev, retainUntilConsumed: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||||
|
var data: [String: Any] = calls[action.callUUID] ?? [:]
|
||||||
|
data["callUUID"] = action.callUUID.uuidString
|
||||||
|
disconnectRoom()
|
||||||
|
notifyListeners("endCall", data: data)
|
||||||
|
calls.removeValue(forKey: action.callUUID)
|
||||||
|
endedCalls.insert(action.callUUID)
|
||||||
|
if activeUUID == action.callUUID { activeUUID = nil }
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||||
|
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date())
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||||
|
let r = room
|
||||||
|
Task { [weak self] in
|
||||||
|
try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted)
|
||||||
|
self?.preferSpeaker() // toggling the mic can flip the route back to the earpiece — re-assert speaker
|
||||||
|
}
|
||||||
|
notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted])
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallKit owns the AVAudioSession lifecycle. Since LiveKit auto-config is OFF, configure the session HERE —
|
||||||
|
// when CallKit activates it — and enable LiveKit's audio engine. This is the fix for the intermittent
|
||||||
|
// dead mic / no-audio and late speaker routing. Don't call setActive(true): CallKit already activated it.
|
||||||
|
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||||
|
do {
|
||||||
|
// Route to the LOUDSPEAKER by default (earpiece was the .voiceChat default). .videoChat +
|
||||||
|
// .defaultToSpeaker prefers the speaker while still letting wired/Bluetooth headsets win.
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .videoChat,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
try AudioManager.shared.setEngineAvailability(.default)
|
||||||
|
preferSpeaker() // belt-and-braces: if we still landed on the earpiece, force the speaker
|
||||||
|
let route = audioSession.currentRoute.outputs.first?.portType.rawValue ?? "none"
|
||||||
|
notifyListeners("audioActivated", data: ["ok": true, "route": route])
|
||||||
|
} catch {
|
||||||
|
notifyListeners("audioActivated", data: ["ok": false, "error": String(describing: error)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||||
|
try? AudioManager.shared.setEngineAvailability(.none)
|
||||||
|
notifyListeners("audioDeactivated", data: ["ok": true])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
@@ -40,6 +40,21 @@ set_bool() { "$PB" -c "Add :$1 bool $2" "$PLIST" 2>/dev/null || "$PB" -c "Set :$
|
|||||||
set_bool UIFileSharingEnabled true
|
set_bool 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 +66,31 @@ 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"
|
||||||
|
|
||||||
# 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 +105,30 @@ 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"
|
||||||
|
|||||||
+80
-13
@@ -2,9 +2,12 @@
|
|||||||
// 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();
|
||||||
@@ -61,17 +64,20 @@ async function postSystem(group, teamId, text) {
|
|||||||
|
|
||||||
async 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 };
|
||||||
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
||||||
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
|
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
|
||||||
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
|
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
|
||||||
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {});
|
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {});
|
||||||
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
|
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.
|
||||||
@@ -83,7 +89,9 @@ async function endGroupCallByRoom(room) {
|
|||||||
if (call) {
|
if (call) {
|
||||||
let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
|
let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
|
||||||
if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
||||||
broadcast(group, { type: 'group-call', group, active: false, room });
|
broadcast(group, { type: 'group-call', group, active: false, room, uuid: call.uuid });
|
||||||
|
// Stop any CallKit ring on members' killed/backgrounded devices.
|
||||||
|
try { for (const mid of await R.conversations.members(group)) { if (mid !== call.startedBy) PUSH.sendCallCancel(mid, call.uuid); } } catch (_) {} // not the starter — a cancel to them re-rings their own phone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +99,11 @@ async function endGroupCallByRoom(room) {
|
|||||||
async function startDmCall(me, otherId, teamId) {
|
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 };
|
||||||
// Log to history (both participants) so the call shows under Past meetings with its transcript.
|
// Log to history (both participants) so the call shows under Past meetings with its transcript.
|
||||||
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
|
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
|
||||||
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
|
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
|
||||||
@@ -113,9 +121,13 @@ async function startDmCall(me, otherId, teamId) {
|
|||||||
const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function endDmCallByRoom(room, silent) {
|
async function endDmCallByRoom(room, silent) {
|
||||||
@@ -125,6 +137,11 @@ async function endDmCallByRoom(room, silent) {
|
|||||||
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 { await 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';
|
||||||
@@ -132,7 +149,20 @@ async function endDmCallByRoom(room, silent) {
|
|||||||
const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
|
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,8 +170,41 @@ async 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) {
|
||||||
|
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [group, call] of groupCalls) {
|
||||||
|
if (call.startedBy === userId) continue;
|
||||||
|
let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
|
||||||
|
if (!member) continue;
|
||||||
|
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
|
||||||
|
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Called from signaling when any mesh room empties.
|
// Called from signaling when any mesh room empties.
|
||||||
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
|
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
|
||||||
|
|
||||||
@@ -149,6 +212,10 @@ async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDm
|
|||||||
async 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);
|
||||||
@@ -168,4 +235,4 @@ async function declineDmCall(room, byUser) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, finalizeTranscript, meetingContext, fmtDur, pairKey };
|
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, finalizeTranscript, meetingContext, fmtDur, pairKey };
|
||||||
|
|||||||
+44
-23
@@ -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,44 @@ 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.
|
||||||
|
function deliverPresenceLocal(subjectId, payload) {
|
||||||
|
for (const [uid, set] of chatClients) {
|
||||||
|
if (uid === subjectId) continue;
|
||||||
|
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function broadcastPresence(userId) {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
|
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
|
||||||
// bare "Offline" until the next sidebar reload (#2).
|
// bare "Offline" until the next sidebar reload (#2).
|
||||||
let lastSeen = null;
|
let lastSeen = null;
|
||||||
try { const u = repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
|
try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
|
||||||
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: effectiveStatus(userId), lastSeen });
|
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: await effectiveStatus(userId), lastSeen });
|
||||||
for (const [uid, set] of chatClients) {
|
deliverPresenceLocal(userId, payload); // this instance
|
||||||
if (uid === userId) continue; // no need to tell someone about their own status
|
pubsub.publish('presence', { userId, payload }); // other instances (no-op on memory)
|
||||||
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,6 +64,7 @@ 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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg": "^8.13.1",
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
+142
-26
@@ -2546,7 +2546,7 @@ function refreshGroupRowTick(gid){
|
|||||||
}
|
}
|
||||||
// Shared group call: start it (or join the live one — the server returns the existing room).
|
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||||||
async function startOrJoinGroupCall(group){
|
async function startOrJoinGroupCall(group){
|
||||||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } }
|
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); meetReturn={kind:'group',id:group}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); return; } /* native: CallKit ring + native LiveKit + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); } }
|
||||||
catch(e){ toast(e.message||'Could not start the call'); }
|
catch(e){ toast(e.message||'Could not start the call'); }
|
||||||
}
|
}
|
||||||
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
|
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
|
||||||
@@ -2554,14 +2554,16 @@ function onGroupCall(d){
|
|||||||
if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null;
|
if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null;
|
||||||
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } }
|
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } }
|
||||||
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
|
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
|
||||||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in
|
// Ring members in. On a CallKit device the system rings it (VoIP push) — skip the in-app popup.
|
||||||
if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing
|
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group');
|
||||||
|
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'group', groupId:d.group, callerName:(d.groupName||(it&&it.name)||'Group call') }); // WS path → CallKit
|
||||||
|
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
|
||||||
renderChats(searchVal());
|
renderChats(searchVal());
|
||||||
}
|
}
|
||||||
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
|
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
|
||||||
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
|
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
|
||||||
async function startOrJoinDmCall(otherId){
|
async function startOrJoinDmCall(otherId){
|
||||||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
|
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); startRingback(); return; } /* native: CallKit ring + native LiveKit media + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
|
||||||
catch(e){ toast(e.message||'Could not start the call'); }
|
catch(e){ toast(e.message||'Could not start the call'); }
|
||||||
}
|
}
|
||||||
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
|
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
|
||||||
@@ -2579,8 +2581,11 @@ function onDmCall(d){
|
|||||||
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; // #4: drop the stuck "in call" status
|
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; // #4: drop the stuck "in call" status
|
||||||
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join
|
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join
|
||||||
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
|
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
|
||||||
if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call
|
// Incoming 1:1 call. On a CallKit device the SYSTEM rings it (via the VoIP push) — don't also show the
|
||||||
if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing
|
// in-app popup, and let CallKit answer drive the join. Off CallKit, show the in-app invite as before.
|
||||||
|
if(d.active && d.by && d.by!==ME.id && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with});
|
||||||
|
if(d.active && d.by && d.by!==ME.id && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'dm', callerId:d.with, callerName:d.byName }); // WS path → CallKit (2nd path alongside the VoIP push)
|
||||||
|
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
|
||||||
renderChats(searchVal());
|
renderChats(searchVal());
|
||||||
}
|
}
|
||||||
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
|
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
|
||||||
@@ -3195,7 +3200,7 @@ async function openConvo(kind,id){
|
|||||||
// If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat
|
// If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat
|
||||||
// is opened from a call notification, the original transient invite popup may already have closed —
|
// is opened from a call notification, the original transient invite popup may already have closed —
|
||||||
// this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification.
|
// this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification.
|
||||||
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom)){
|
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom) && !nativeCallOn()){ // CallKit devices ring via the system, not this popup
|
||||||
showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true);
|
showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true);
|
||||||
}
|
}
|
||||||
const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput');
|
const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput');
|
||||||
@@ -3710,22 +3715,28 @@ function reportInstall(){
|
|||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
let _nativeTok=null;
|
let _nativeTok=null;
|
||||||
|
// TEMP push diagnostics: mirror each step to the server so we can see where iOS registration fails
|
||||||
|
// without a Mac/device console. Remove once native push is confirmed working end-to-end.
|
||||||
|
function pdbg(step, extra){ try{ postJSON('/api/v1/push-debug', Object.assign({ step, t: Date.now() }, extra||{})).catch(()=>{}); }catch(_){}
|
||||||
|
try{ console.log('[push]', step, extra||''); }catch(_){} }
|
||||||
async function setupNativePush(){
|
async function setupNativePush(){
|
||||||
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
|
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
|
||||||
if(!PN || (plat!=='ios' && plat!=='android')) return false; // not a mobile native app
|
const C=window.Capacitor;
|
||||||
|
pdbg('native-setup-enter', { plat, hasPlugin: !!PN, isNative: !!(C&&C.isNativePlatform&&C.isNativePlatform()), plugins: (C&&C.Plugins)?Object.keys(C.Plugins).join(','):'' });
|
||||||
|
if(!PN || (plat!=='ios' && plat!=='android')){ pdbg('native-setup-skip', { plat, hasPlugin: !!PN }); return false; } // not a mobile native app
|
||||||
try{
|
try{
|
||||||
PN.addListener('registration', async (t)=>{ const token=t&&t.value; if(!token) return; _nativeTok=token;
|
PN.addListener('registration', async (t)=>{ const token=t&&t.value; pdbg('registration-event', { tokenLen: token?token.length:0 }); if(!token) return; _nativeTok=token;
|
||||||
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; console.log('[push] native device registered'); }
|
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; pdbg('device-post-ok', { plat }); console.log('[push] native device registered'); }
|
||||||
catch(e){ console.warn('[push] device register failed:', e); } });
|
catch(e){ pdbg('device-post-fail', { err:String((e&&e.message)||e) }); console.warn('[push] device register failed:', e); } });
|
||||||
PN.addListener('registrationError', (e)=>console.warn('[push] native registration error:', e));
|
PN.addListener('registrationError', (e)=>{ pdbg('registration-error', { err:(e&&(e.error||e.message))||JSON.stringify(e) }); console.warn('[push] native registration error:', e); });
|
||||||
// Tapping an OS notification opens the relevant chat (payload carries kind+id).
|
// Tapping an OS notification opens the relevant chat (payload carries kind+id).
|
||||||
PN.addListener('pushNotificationActionPerformed', (a)=>{ try{ const d=(a&&a.notification&&a.notification.data)||{}; if(d.id) selectChat(d.kind||'dm', d.id); }catch(_){} });
|
PN.addListener('pushNotificationActionPerformed', (a)=>{ try{ const d=(a&&a.notification&&a.notification.data)||{}; if(d.id) selectChat(d.kind||'dm', d.id); }catch(_){} });
|
||||||
let perm=await PN.checkPermissions();
|
let perm=await PN.checkPermissions(); pdbg('perm-check', { receive: perm&&perm.receive });
|
||||||
if(perm.receive!=='granted') perm=await PN.requestPermissions();
|
if(perm.receive!=='granted'){ perm=await PN.requestPermissions(); pdbg('perm-after-request', { receive: perm&&perm.receive }); }
|
||||||
if(perm.receive!=='granted'){ console.log('[push] native permission not granted'); return true; }
|
if(perm.receive!=='granted'){ pdbg('perm-not-granted', { receive: perm&&perm.receive }); console.log('[push] native permission not granted'); return true; }
|
||||||
await PN.register();
|
await PN.register(); pdbg('register-called');
|
||||||
console.log('[push] native push registered');
|
console.log('[push] native push registered');
|
||||||
}catch(e){ console.warn('[push] native push setup failed:', e); }
|
}catch(e){ pdbg('native-setup-exception', { err:String((e&&e.message)||e) }); console.warn('[push] native push setup failed:', e); }
|
||||||
return true; // handled the native path (skip Web Push regardless of outcome)
|
return true; // handled the native path (skip Web Push regardless of outcome)
|
||||||
}
|
}
|
||||||
async function setupPush(){
|
async function setupPush(){
|
||||||
@@ -3766,6 +3777,90 @@ async function unsubscribePush(){
|
|||||||
pushActive=false; console.log('[push] unsubscribed (logout)');
|
pushActive=false; console.log('[push] unsubscribed (logout)');
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
|
// ---------- Native calling (iOS CallKit + PushKit VoIP) ----------
|
||||||
|
// The native-call plugin rings incoming calls via CallKit (full-screen, works when the app is killed) and
|
||||||
|
// reports its VoIP token; on answer/end it fires events we bridge into the existing meeting join/leave. On
|
||||||
|
// a CallKit device the SYSTEM owns the incoming ring, so we suppress the in-app call-invite popup
|
||||||
|
// (onDmCall / the group invite check nativeCallOn()).
|
||||||
|
let _callkitReady=false;
|
||||||
|
let _nativeAnswered=new Set(); // rooms this device answered natively (so a "call-taken" from the server doesn't end our own call)
|
||||||
|
function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; }
|
||||||
|
function nativeCallOn(){ return _callkitReady; }
|
||||||
|
async function setupNativeCall(){
|
||||||
|
const NC=nativeCallPlugin(); if(!NC) return;
|
||||||
|
// Server kill-switch: only take over calls with CallKit when the server enables it (callkit:true). When
|
||||||
|
// off, calls use the WebView flow (mic works). Flipped on only once native media carries the audio.
|
||||||
|
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
|
||||||
|
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
|
||||||
|
_callkitReady=true;
|
||||||
|
// VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes.
|
||||||
|
NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); });
|
||||||
|
try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){}
|
||||||
|
// Native media: the plugin carries the call over its OWN LiveKit room. The WebView must NOT also join
|
||||||
|
// (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server
|
||||||
|
// the call was answered (native media bypasses our WS, so the server can't learn it otherwise) and clear
|
||||||
|
// the in-app invite. Track answered rooms so end vs decline is signalled correctly.
|
||||||
|
NC.addListener('answerCall', (d)=>{ try{
|
||||||
|
pdbg('nc-answer', { room:(d&&d.room)||'', hasUrl:!!(d&&d.livekitUrl), hasToken:!!(d&&d.livekitToken) });
|
||||||
|
if(!d||!d.room) return;
|
||||||
|
_nativeAnswered.add(d.room);
|
||||||
|
dismissCallInvite(d.room);
|
||||||
|
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
|
||||||
|
// tile, and wires the end-both-sides lifecycle. The plugin already holds the LiveKit media, so this joins
|
||||||
|
// native (no 2nd SFU connection). meetReturn = the chat to land back on when the call ends.
|
||||||
|
const isGroup=(d.kind==='group');
|
||||||
|
meetReturn = isGroup ? { kind:'group', id:d.groupId } : { kind:'dm', id:d.callerId };
|
||||||
|
switchTab('meeting'); enterMeeting(d.room, false, { native:true, uuid:d.callUUID });
|
||||||
|
}catch(e){ pdbg('nc-answer-err', { err:String((e&&e.message)||e) }); } });
|
||||||
|
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
|
||||||
|
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
|
||||||
|
NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} } });
|
||||||
|
NC.addListener('audioActivated', (e)=>{ pdbg('nc-audio', { ok:!!(e&&e.ok), err:(e&&e.error)||'', route:(e&&e.route)||'' }); }); // CallKit audio-session activation + output route (debug audio)
|
||||||
|
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); });
|
||||||
|
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
|
||||||
|
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
|
||||||
|
// Ended on the CallKit system screen / plugin ended on remote hang-up. Leave the meeting window too (guarded
|
||||||
|
// so our own in-app hang-up, which already called the plugin, doesn't loop). Then tell the server.
|
||||||
|
NC.addListener('endCall', (d)=>{ try{
|
||||||
|
const room=d&&d.room; pdbg('nc-end', { room:room||'', answered:_nativeAnswered.has(room) });
|
||||||
|
if(!room) return;
|
||||||
|
dismissCallInvite(room);
|
||||||
|
if(_ncEnding){ _ncEnding=false; }
|
||||||
|
else if(meetState==='call' && meetRoom===room){ leaveMeeting(true); }
|
||||||
|
if(_nativeAnswered.has(room)){ _nativeAnswered.delete(room); postJSON('/api/calls/end',{ room }).catch(()=>{}); }
|
||||||
|
else { postJSON('/api/calls/decline',{ room }).catch(()=>{}); }
|
||||||
|
}catch(_){} });
|
||||||
|
console.log('[callkit] native calling ready');
|
||||||
|
}
|
||||||
|
// Start an OUTGOING native call: fetch a LiveKit join token for the room, then have the plugin start the
|
||||||
|
// CallKit call AND connect the LiveKit room natively (the WebView does NOT join — one connection per identity).
|
||||||
|
async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){
|
||||||
|
const NC=nativeCallPlugin(); if(!NC||!uuid) return;
|
||||||
|
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room }); }catch(_){}
|
||||||
|
pdbg('nc-outgoing', { room, hasUrl:!!(tk&&tk.url), hasToken:!!(tk&&tk.token) });
|
||||||
|
try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo, url:tk.url||'', token:tk.token||'' }); }catch(_){}
|
||||||
|
}
|
||||||
|
// End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit.
|
||||||
|
function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} }
|
||||||
|
// Ring CallKit from a WebSocket call event — a 2nd, reliable path alongside the VoIP push for when the app is
|
||||||
|
// OPEN (the push can be delayed/dropped). Deduped by UUID in the plugin, so double-firing is harmless.
|
||||||
|
async function nativeReportIncoming(o){
|
||||||
|
const NC=nativeCallPlugin(); if(!NC||!o||!o.callUUID||!o.room) return;
|
||||||
|
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room:o.room }); }catch(_){}
|
||||||
|
// .catch: on older builds without this plugin method, Capacitor REJECTS the promise (not a sync throw) — swallow it.
|
||||||
|
try{ const p=NC.reportIncomingCall({ callUUID:o.callUUID, room:o.room, kind:o.kind||'dm', callerId:o.callerId||'', callerName:o.callerName||'Incoming call', groupId:o.groupId||'', groupName:o.groupName||'', hasVideo:false, url:(tk&&tk.url)||'', token:(tk&&tk.token)||'' }); if(p&&p.catch) p.catch(()=>{}); }catch(_){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NATIVE calls use your REAL meeting window: the plugin owns this user's ONE LiveKit connection (media +
|
||||||
|
// CallKit ring/background/lock-screen), and the WebView joins the same mesh room for the UI — so the caller/
|
||||||
|
// callee tiles, roster, mute state and the whole answer/end lifecycle are the normal meeting code. The only
|
||||||
|
// difference vs a web call is meetNative=true → the WebView does NOT open its own SFU media connection (that
|
||||||
|
// would be a 2nd connection for the same identity), and mute/hang-up bridge to the plugin.
|
||||||
|
// Another of the callee's devices answered — stop ringing here (never on the device that answered).
|
||||||
|
function onCallTaken(d){ if(!d||!d.room) return; if(_nativeAnswered.has(d.room)) return; dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); }
|
||||||
|
// The callee picked up (server signal; the mesh peer-join already clears these on the caller — belt & braces).
|
||||||
|
function onCallAnswered(d){ if(d&&d.room && meetRoom===d.room){ stopRingback(); removeWaitingTile(); _dmCallWaiting=null; } }
|
||||||
|
|
||||||
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
|
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
|
||||||
// notification click is not an in-page gesture, so an in-place open won't paint until you
|
// notification click is not an in-page gesture, so an in-place open won't paint until you
|
||||||
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
|
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
|
||||||
@@ -4086,7 +4181,7 @@ function connectChatWs(){
|
|||||||
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
|
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
|
||||||
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||||
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
|
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
|
||||||
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
|
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
|
||||||
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
|
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
@@ -4342,10 +4437,20 @@ function sfuRebuild(pid){
|
|||||||
addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st);
|
addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st);
|
||||||
}
|
}
|
||||||
function sfuAttach(pub, track, participant, _try){
|
function sfuAttach(pub, track, participant, _try){
|
||||||
const pid=peerIdForUid(participant.identity);
|
let pid=peerIdForUid(participant.identity);
|
||||||
if(!pid){ // #9: uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times
|
if(!pid){ // uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times…
|
||||||
if((_try||0)<10){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); }
|
if((_try||0)<6){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); return; }
|
||||||
return;
|
// …then fall back: a NATIVE (CallKit + LiveKit) participant joins LiveKit but NOT our WS mesh, so it has
|
||||||
|
// no mesh peer id. Key its tile + audio by the LiveKit identity so it still shows and is heard. The
|
||||||
|
// outgoing "Ringing…" placeholder is cleared here (its mesh 'answered' signal never fires for native),
|
||||||
|
// and we label the tile from the contact roster so it shows a NAME + DP, not a raw user id.
|
||||||
|
pid='lk:'+participant.identity;
|
||||||
|
removeWaitingTile(); stopRingback();
|
||||||
|
if(!meetNames.get(pid)){
|
||||||
|
const c=(typeof CONTACTS!=='undefined'?CONTACTS:[]).find(x=>x&&x.id===participant.identity);
|
||||||
|
meetNames.set(pid, (c&&c.name) || (_dmCallWaiting&&_dmCallWaiting.name) || participant.name || 'Caller');
|
||||||
|
if(c&&c.avatar) meetAvatars.set(pid, c.avatar);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const p=SFU.peers.get(pid)||{}; SFU.peers.set(pid,p); const mt=track.mediaStreamTrack;
|
const p=SFU.peers.get(pid)||{}; SFU.peers.set(pid,p); const mt=track.mediaStreamTrack;
|
||||||
if(track.kind==='audio') p.audio=mt;
|
if(track.kind==='audio') p.audio=mt;
|
||||||
@@ -4354,7 +4459,7 @@ function sfuAttach(pub, track, participant, _try){
|
|||||||
sfuRebuild(pid); updateShareMode();
|
sfuRebuild(pid); updateShareMode();
|
||||||
}
|
}
|
||||||
function sfuDetach(pub, track, participant){
|
function sfuDetach(pub, track, participant){
|
||||||
const pid=peerIdForUid(participant.identity); if(!pid) return; const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack;
|
let pid=peerIdForUid(participant.identity); if(!pid) pid='lk:'+participant.identity; const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack;
|
||||||
if(p.audio===mt) p.audio=null; else if(p.screen===mt){ p.screen=null; meetSharers.delete(pid); setTileScreen(pid,false); } else if(p.cam===mt) p.cam=null;
|
if(p.audio===mt) p.audio=null; else if(p.screen===mt){ p.screen=null; meetSharers.delete(pid); setTileScreen(pid,false); } else if(p.cam===mt) p.cam=null;
|
||||||
sfuRebuild(pid); updateShareMode();
|
sfuRebuild(pid); updateShareMode();
|
||||||
}
|
}
|
||||||
@@ -5201,9 +5306,11 @@ function bzUnlockAudio(){
|
|||||||
}
|
}
|
||||||
document.addEventListener('touchend', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true}); // fallback: any tap during a call restarts silent remote audio
|
document.addEventListener('touchend', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true}); // fallback: any tap during a call restarts silent remote audio
|
||||||
document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});
|
document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});
|
||||||
async function enterMeeting(code, audioOnly){
|
let meetNative=false, meetNativeUuid=null, _ncEnding=false; // native call: WebView joins the mesh for UI; the plugin owns media/CallKit
|
||||||
|
async function enterMeeting(code, audioOnly, opts){
|
||||||
bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap
|
bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap
|
||||||
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join
|
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join
|
||||||
|
meetNative=!!(opts&&opts.native); meetNativeUuid=(opts&&opts.uuid)||null; // native → skip our own SFU media; mute/end bridge to the plugin
|
||||||
// Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring).
|
// Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring).
|
||||||
// Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces
|
// Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces
|
||||||
// we clear ALL open invites, since you can only be in one call at a time.
|
// we clear ALL open invites, since you can only be in one call at a time.
|
||||||
@@ -5213,7 +5320,7 @@ async function enterMeeting(code, audioOnly){
|
|||||||
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
|
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
|
||||||
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
|
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
|
||||||
meetLocalStream=new MediaStream();
|
meetLocalStream=new MediaStream();
|
||||||
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null;
|
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null; // answer MUTED by default (house rule) — tap Mic to speak
|
||||||
meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false;
|
meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false;
|
||||||
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
|
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
|
||||||
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
|
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
|
||||||
@@ -5237,7 +5344,9 @@ async function onMeetMsg(e){
|
|||||||
if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings
|
if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings
|
||||||
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
|
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
|
||||||
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
|
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
|
||||||
if(SFU.on){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isn’t active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
|
if(SFU.on && !meetNative){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isn’t active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
|
||||||
|
// native: the plugin already holds the LiveKit media (one connection per identity) — we joined the mesh
|
||||||
|
// for the UI only. Media is native; tell peers our mic is live.
|
||||||
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
|
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
|
||||||
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
|
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
|
||||||
refreshMeetPanel(); updateHostControls();
|
refreshMeetPanel(); updateHostControls();
|
||||||
@@ -5288,6 +5397,7 @@ function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b
|
|||||||
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
|
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
|
||||||
async function toggleMic(){
|
async function toggleMic(){
|
||||||
if(!meetLocalStream) return;
|
if(!meetLocalStream) return;
|
||||||
|
if(meetNative){ const next=!meetMic; const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!next }); }catch(_){} } meetMic=next; updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } // native: mute the plugin's mic
|
||||||
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
||||||
const hasTrack=meetLocalStream.getAudioTracks().length>0;
|
const hasTrack=meetLocalStream.getAudioTracks().length>0;
|
||||||
if(!hasTrack){
|
if(!hasTrack){
|
||||||
@@ -5305,6 +5415,7 @@ async function toggleMic(){
|
|||||||
// renegotiates with every peer, so you can always turn video on once a meeting has started.
|
// renegotiates with every peer, so you can always turn video on once a meeting has started.
|
||||||
async function toggleCam(){
|
async function toggleCam(){
|
||||||
if(!meetLocalStream) return;
|
if(!meetLocalStream) return;
|
||||||
|
if(meetNative){ toast('Video isn’t available on native calls yet'); return; } // native path is audio-only for now (video rendering is the next phase)
|
||||||
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
||||||
const hasTrack=meetLocalStream.getVideoTracks().length>0;
|
const hasTrack=meetLocalStream.getVideoTracks().length>0;
|
||||||
if(!hasTrack){
|
if(!hasTrack){
|
||||||
@@ -5328,6 +5439,10 @@ let meetLeaving=false;
|
|||||||
// there wrongly announced "Host handed to <the person who just left>" (bug).
|
// there wrongly announced "Host handed to <the person who just left>" (bug).
|
||||||
function leaveMeeting(forced){
|
function leaveMeeting(forced){
|
||||||
if(meetLeaving) return; meetLeaving=true;
|
if(meetLeaving) return; meetLeaving=true;
|
||||||
|
// Native call: also end the CallKit/plugin call. _ncEnding tells the plugin's endCall listener this leave
|
||||||
|
// originated here, so it doesn't call leaveMeeting again (the plugin ending the call re-fires endCall).
|
||||||
|
if(meetNative && meetNativeUuid){ _ncEnding=true; callkitEnd(meetNativeUuid); }
|
||||||
|
meetNative=false; meetNativeUuid=null;
|
||||||
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: any call exit stops the ring + ringing tile
|
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: any call exit stops the ring + ringing tile
|
||||||
const isDm=!!(meetReturn && meetReturn.kind==='dm');
|
const isDm=!!(meetReturn && meetReturn.kind==='dm');
|
||||||
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
|
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
|
||||||
@@ -5655,6 +5770,7 @@ window.addEventListener('message',(e)=>{
|
|||||||
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
|
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
|
||||||
wireUpdateBanner(); // #3: desktop update-progress banner
|
wireUpdateBanner(); // #3: desktop update-progress banner
|
||||||
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
|
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
|
||||||
|
setupNativeCall(); // iOS CallKit: register VoIP token + bridge answer/end to the meeting join/leave
|
||||||
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
|
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
|
||||||
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
|
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
|
||||||
// Fast-path: when opened from a notification, show the chat immediately (only needs the
|
// Fast-path: when opened from a notification, show the chat immediately (only needs the
|
||||||
|
|||||||
@@ -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 };
|
||||||
+75
-2
@@ -88,7 +88,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 +101,77 @@ function sendApns(token, payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- VoIP push (PushKit → CallKit), iOS ----------------
|
||||||
|
// Wakes the app even when force-killed so it can report an incoming call to CallKit. Uses the SAME
|
||||||
|
// token-based APNs auth (.p8) as alert pushes, but with apns-push-type 'voip' and the '<bundle>.voip'
|
||||||
|
// topic. The payload is arbitrary call data delivered to the app's PushKit handler (no aps alert). The
|
||||||
|
// destination is a PushKit "VoIP token" (distinct from the alert APNs token) that the native plugin
|
||||||
|
// registers as device_tokens.platform = 'ios-voip'.
|
||||||
|
function sendApnsVoip(token, data) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!apnsCfg) return resolve({});
|
||||||
|
let client;
|
||||||
|
try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
|
||||||
|
client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
|
||||||
|
const body = JSON.stringify({ aps: {}, ...(data || {}) });
|
||||||
|
const req = client.request({
|
||||||
|
':method': 'POST', ':path': '/3/device/' + token,
|
||||||
|
authorization: 'bearer ' + apnsToken(),
|
||||||
|
'apns-topic': apnsCfg.bundle + '.voip', 'apns-push-type': 'voip',
|
||||||
|
'apns-priority': '10', 'apns-expiration': '0', // ring now or drop — never store a stale call
|
||||||
|
});
|
||||||
|
let status = 0;
|
||||||
|
req.on('response', (h) => { status = h[':status']; });
|
||||||
|
req.on('data', () => {});
|
||||||
|
req.on('end', () => { try { client.close(); } catch (_) {} resolve({ ok: status >= 200 && status < 300, dead: status === 410 }); });
|
||||||
|
req.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
|
||||||
|
req.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify a user of an INCOMING CALL. Prefers a VoIP push (→ CallKit native ring, works when the app is
|
||||||
|
// killed) if the user has a registered 'ios-voip' token; otherwise falls back to a normal alert push
|
||||||
|
// (banner + ring sound) so Android / web / iOS-without-the-CallKit-build still get notified. So this is
|
||||||
|
// backward-compatible: with no VoIP tokens registered yet, it behaves exactly like the old call push.
|
||||||
|
async function sendCallNotification(userId, data) {
|
||||||
|
// data: { callUUID, room, kind, callerId, callerName, groupId, groupName, title, body, hasVideo }
|
||||||
|
let toks = [];
|
||||||
|
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
||||||
|
const voip = toks.filter((t) => t.platform === 'ios-voip');
|
||||||
|
if (voip.length && apnsCfg && process.env.CALLKIT_ENABLED === '1') { // kill-switch: off → fall through to a banner
|
||||||
|
const payload = {
|
||||||
|
type: 'invite',
|
||||||
|
callUUID: data.callUUID, room: data.room, kind: data.kind,
|
||||||
|
callerId: data.callerId || '', callerName: data.callerName || 'Incoming call',
|
||||||
|
groupId: data.groupId || '', groupName: data.groupName || '', hasVideo: !!data.hasVideo,
|
||||||
|
// LiveKit join credentials so the native plugin can connect the room immediately on answer — even
|
||||||
|
// from a killed state, before the WebView has loaded.
|
||||||
|
livekitUrl: data.livekitUrl || '', livekitToken: data.livekitToken || '',
|
||||||
|
};
|
||||||
|
for (const t of voip) {
|
||||||
|
try { const r = await sendApnsVoip(t.token, payload); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||||
|
}
|
||||||
|
return 'voip';
|
||||||
|
}
|
||||||
|
await sendToUser(userId, {
|
||||||
|
title: data.title, body: data.body, kind: data.kind, id: data.callerId || data.groupId,
|
||||||
|
room: data.room, tag: 'call:' + data.room, data: { kind: data.kind, id: data.callerId || data.groupId, room: data.room, call: 1 },
|
||||||
|
});
|
||||||
|
return 'push';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out). Sent as a
|
||||||
|
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call.
|
||||||
|
// No-op for non-VoIP devices (their ring is a normal notification that just goes away).
|
||||||
|
async function sendCallCancel(userId, callUUID) {
|
||||||
|
if (!apnsCfg || !callUUID || process.env.CALLKIT_ENABLED !== '1') return;
|
||||||
|
let toks = [];
|
||||||
|
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
||||||
|
for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
|
||||||
|
try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- public API ----------------
|
// ---------------- 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);
|
||||||
@@ -134,4 +207,4 @@ async function sendToUser(userId, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { isEnabled, publicKey, sendToUser };
|
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel };
|
||||||
|
|||||||
+39
-19
@@ -116,7 +116,7 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
|
|||||||
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
|
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
|
||||||
const CALLS = require('./calls');
|
const CALLS = require('./calls');
|
||||||
require('./reminders'); // start the 10-minute meeting-reminder loop
|
require('./reminders'); // start the 10-minute meeting-reminder loop
|
||||||
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY } = require('./config');
|
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED } = require('./config');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
|
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
|
||||||
function fetchJSON(url) {
|
function fetchJSON(url) {
|
||||||
@@ -137,22 +137,9 @@ const crypto = require('crypto');
|
|||||||
const MAX_UPLOAD_MB = parseInt(process.env.MAX_UPLOAD_MB, 10) || 1024; // default 1 GB per chat attachment
|
const MAX_UPLOAD_MB = parseInt(process.env.MAX_UPLOAD_MB, 10) || 1024; // default 1 GB per chat attachment
|
||||||
const MAX_FILE_BYTES = MAX_UPLOAD_MB * 1024 * 1024; // NOTE: also raise Nginx Proxy Manager's client_max_body_size to match (default is 1 MB) or large uploads are rejected at the proxy before reaching here.
|
const MAX_FILE_BYTES = MAX_UPLOAD_MB * 1024 * 1024; // NOTE: also raise Nginx Proxy Manager's client_max_body_size to match (default is 1 MB) or large uploads are rejected at the proxy before reaching here.
|
||||||
|
|
||||||
// Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT
|
// LiveKit access-token minting lives in ./livekit (shared with calls.js, which mints a token for the native
|
||||||
// approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+
|
// VoIP call payload). Same hand-rolled HS256 JWT — grants join+publish+subscribe on one room, one identity.
|
||||||
// subscribe on exactly one room, as one identity. Secret stays server-side.
|
const { livekitToken } = require('./livekit');
|
||||||
const _b64u = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
||||||
function livekitToken(identity, name, room, metadata) {
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const header = _b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
||||||
const payload = _b64u(JSON.stringify({
|
|
||||||
iss: LIVEKIT_API_KEY, sub: identity, name: name || identity,
|
|
||||||
nbf: nowSec, exp: nowSec + 6 * 3600, // 6h — long enough for any meeting
|
|
||||||
metadata: metadata || '',
|
|
||||||
video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
|
|
||||||
}));
|
|
||||||
const sig = _b64u(crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(header + '.' + payload).digest());
|
|
||||||
return header + '.' + payload + '.' + sig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue a refresh token (native clients), store only its hash, return the plaintext once.
|
// Issue a refresh token (native clients), store only its hash, return the plaintext once.
|
||||||
async function issueRefreshToken(userId) {
|
async function issueRefreshToken(userId) {
|
||||||
@@ -412,7 +399,8 @@ route('POST', '/api/devices', async (req, res) => {
|
|||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const { platform, token } = await readBody(req);
|
const { platform, token } = await readBody(req);
|
||||||
if (!token || typeof token !== 'string') return json(res, 400, { error: 'token required' });
|
if (!token || typeof token !== 'string') return json(res, 400, { error: 'token required' });
|
||||||
if (platform !== 'ios' && platform !== 'android') return json(res, 400, { error: 'platform must be ios or android' });
|
// 'ios-voip' = a PushKit VoIP token (CallKit wake), stored alongside the normal alert token.
|
||||||
|
if (platform !== 'ios' && platform !== 'android' && platform !== 'ios-voip') return json(res, 400, { error: 'platform must be ios, android or ios-voip' });
|
||||||
try { await R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {}
|
try { await R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {}
|
||||||
json(res, 200, { ok: true });
|
json(res, 200, { ok: true });
|
||||||
});
|
});
|
||||||
@@ -424,6 +412,18 @@ route('POST', '/api/devices/remove', async (req, res) => {
|
|||||||
json(res, 200, { ok: true });
|
json(res, 200, { ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Push diagnostics (temporary): the native app reports each step of push setup here so we can see
|
||||||
|
// WHERE iOS registration fails without a Mac/device console. Best-effort; logs and returns 200. ---
|
||||||
|
route('POST', '/api/push-debug', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const b = await readBody(req);
|
||||||
|
let uid = 'anon'; try { const u = await currentUser(req); if (u) uid = u.id; } catch (_) {}
|
||||||
|
const line = (typeof b === 'object' ? JSON.stringify(b) : String(b)).slice(0, 800);
|
||||||
|
console.log('[push-debug] user=' + uid + ' ' + line);
|
||||||
|
} catch (_) {}
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// --- App install telemetry: records each install and, once the user signs in, who's using it. ---
|
// --- App install telemetry: records each install and, once the user signs in, who's using it. ---
|
||||||
route('POST', '/api/telemetry/install', async (req, res) => {
|
route('POST', '/api/telemetry/install', async (req, res) => {
|
||||||
const { installId, platform, appVersion, os } = await readBody(req);
|
const { installId, platform, appVersion, os } = await readBody(req);
|
||||||
@@ -982,7 +982,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
|
|||||||
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
|
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
|
||||||
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
|
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
|
||||||
route('GET', '/api/meetings/config', (req, res) => {
|
route('GET', '/api/meetings/config', (req, res) => {
|
||||||
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' });
|
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED });
|
||||||
});
|
});
|
||||||
|
|
||||||
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
|
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
|
||||||
@@ -1074,6 +1074,26 @@ route('POST', '/api/calls/decline', async (req, res) => {
|
|||||||
json(res, 200, await CALLS.declineDmCall(String(room), u));
|
json(res, 200, await CALLS.declineDmCall(String(room), u));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Native-call lifecycle. NATIVE (CallKit + LiveKit) calls carry media over LiveKit, NOT our mesh/WS, so
|
||||||
|
// the server can't learn from a room emptying that a native call was answered/ended. The app signals it
|
||||||
|
// explicitly. (WebView/mesh calls keep using the WS room lifecycle — these are additive no-ops there.) ---
|
||||||
|
route('POST', '/api/calls/answered', async (req, res) => {
|
||||||
|
const u = await currentUser(req);
|
||||||
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
|
const { room } = await readBody(req);
|
||||||
|
if (!room) return json(res, 400, { error: 'room required' });
|
||||||
|
try { CALLS.markDmAnswered(String(room), u.id); } catch (_) {}
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
});
|
||||||
|
route('POST', '/api/calls/end', async (req, res) => {
|
||||||
|
const u = await currentUser(req);
|
||||||
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
|
const { room } = await readBody(req);
|
||||||
|
if (!room) return json(res, 400, { error: 'room required' });
|
||||||
|
try { await CALLS.endCallByRoom(String(room)); } catch (_) {}
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Toggle "only admins can add/remove members" (any admin).
|
// Toggle "only admins can add/remove members" (any admin).
|
||||||
route('POST', '/api/groups/admin-only', async (req, res) => {
|
route('POST', '/api/groups/admin-only', async (req, res) => {
|
||||||
const u = await currentUser(req);
|
const u = await currentUser(req);
|
||||||
|
|||||||
+7
-1
@@ -40,6 +40,7 @@ wss.on('connection', onConnection);
|
|||||||
// no-op (the schema is already applied synchronously when db.js is required). Serving only starts once the
|
// no-op (the schema is already applied synchronously when db.js is required). Serving only starts once the
|
||||||
// store is ready, so the first request can never hit a missing table.
|
// store is ready, so the first request can never hit a missing table.
|
||||||
const db = require('./dbx');
|
const db = require('./dbx');
|
||||||
|
const pubsub = require('./pubsub');
|
||||||
|
|
||||||
function startListening() {
|
function startListening() {
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
@@ -72,6 +73,11 @@ function startListening() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.init().then(startListening).catch((e) => { console.error('DB init failed:', (e && e.message) || e); process.exit(1); });
|
// DB schema first, then the pub/sub layer (redis connects + flushes buffered subscriptions; memory is a
|
||||||
|
// no-op), then serve. A pubsub failure must NOT block booting — degrade to local-only delivery.
|
||||||
|
db.init()
|
||||||
|
.then(() => pubsub.init().catch((e) => console.error('pubsub init failed (local-only delivery):', (e && e.message) || e)))
|
||||||
|
.then(startListening)
|
||||||
|
.catch((e) => { console.error('DB init failed:', (e && e.message) || e); process.exit(1); });
|
||||||
|
|
||||||
module.exports = { server };
|
module.exports = { server };
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ async function handle(ws, m, req) {
|
|||||||
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user