Compare commits
247 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f342b0b4e | |||
| e67a783bdc | |||
| 3250530596 | |||
| 2460c0f9eb | |||
| dbd209ac2b | |||
| e482cb5bb2 | |||
| 1709a331d1 | |||
| b2c4b10e41 | |||
| ee95f594c6 | |||
| 854bc86a6e | |||
| 4d446a5425 | |||
| d664dde798 | |||
| 2ec0a0c0cd | |||
| 0c3487fdb0 | |||
| 4836b1e197 | |||
| d5658eeb9e | |||
| 59310419ba | |||
| 0a2c7376b9 | |||
| 00ea140280 | |||
| 82fa8e04eb | |||
| cb81dd6106 | |||
| cc8f7d1eb8 | |||
| 310f37f29b | |||
| 8a5409987c | |||
| 2127397408 | |||
| b7cd6df625 | |||
| fd15628393 | |||
| 92d41e6324 | |||
| 85a1b9d2ca | |||
| 3cc6ff4e54 | |||
| 200624bd24 | |||
| 2f368643ad | |||
| 5cee67e974 | |||
| 44526e1ee9 | |||
| cfbf950173 | |||
| c3b0ef560d | |||
| e9b9d3c121 | |||
| 0282181ca6 | |||
| b54ba10c69 | |||
| ce49e8e30d | |||
| 36d51ba40d | |||
| 1920345ecd | |||
| 8f33df5c3d | |||
| 804c218236 | |||
| 46b5ae16a4 | |||
| c6f236ecf9 | |||
| a574816eaa | |||
| 5960efb612 | |||
| 90d49d29d0 | |||
| 9c2ba847b0 | |||
| 4e78448743 | |||
| fed0e07e3e | |||
| 9c731f087e | |||
| 578f3a2921 | |||
| f178e271c8 | |||
| 8aa2532666 | |||
| 40789b06cf | |||
| 134cb8999d | |||
| f2b965bd3d | |||
| 30eaf37773 | |||
| 3f44e2b285 | |||
| d098ba468e | |||
| 9f3e8b83c7 | |||
| a7f954ae44 | |||
| c32584df54 | |||
| 3a7a026533 | |||
| 150a074578 | |||
| 2a60c01268 | |||
| 9bb74cffd2 | |||
| 9c703d6e64 | |||
| 9eb4a99679 | |||
| 82d0154313 | |||
| ea04556596 | |||
| 91b3e5c522 | |||
| 433703cfeb | |||
| a4600f6b5f | |||
| d02d8976d3 | |||
| 529bc26cb5 | |||
| f3c3e08be4 | |||
| 2bd8b3c8c1 | |||
| da3d44cbb7 | |||
| 1f5b4bd42c | |||
| 898d389dfe | |||
| 7f7ff8ba1f | |||
| 676612a12b | |||
| 78ea36f7d0 | |||
| 2cadf90bc8 | |||
| 9f7561bf8b | |||
| bf8df593a1 | |||
| 0e38bd9229 | |||
| f9d6472f77 | |||
| 720b1f0c40 | |||
| 6e3cf95deb | |||
| c775ca2740 | |||
| 8867d0abfc | |||
| c5489d7ad6 | |||
| 77a3ad455f | |||
| 233db2c140 | |||
| 357f0168ba | |||
| 9bee2c635c | |||
| f55af18e90 | |||
| cc2a76450f | |||
| 763b2996d1 | |||
| 3393e44691 | |||
| f2e2e8b08a | |||
| e29810ea2a | |||
| 13b0f910ab | |||
| 2ce7ec72fb | |||
| a609bfd2e0 | |||
| 95e13a707e | |||
| 2e4e0210b0 | |||
| 1ffc69469b | |||
| 84315ccfb4 | |||
| 054b0bb70f | |||
| c18e55b5ec | |||
| 42e0122504 | |||
| 6b5ca687e4 | |||
| 3b09702593 | |||
| bc909cb0c0 | |||
| c8f383cbcb | |||
| a172214c44 | |||
| 02075fbd47 | |||
| c3dc47a94c | |||
| f708cc2f12 | |||
| fad8a52da4 | |||
| 6fa261b68f | |||
| 860a7bd6cf | |||
| 667b4c69d5 | |||
| c1f01e796a | |||
| 2aeeb0a096 | |||
| a0017f2036 | |||
| 5a138f5bc4 | |||
| e8c0b1889f | |||
| 48b6a4050a | |||
| 94b8fac32f | |||
| 82fa4a24d3 | |||
| d0f9355553 | |||
| bd488b3286 | |||
| e562099344 | |||
| 7bc40d8397 | |||
| 466c0d5d70 | |||
| 9b86def921 | |||
| aa4bb2902d | |||
| 1dfb6b16cb | |||
| adafc8c960 | |||
| 70a776fa6c | |||
| 62a5f750af | |||
| 6ed0fa0ea0 | |||
| 4e2ccb5d60 | |||
| 49718e5d37 | |||
| 7682e17a53 | |||
| 0976b6f91a | |||
| 3e811af3d6 | |||
| 096683385a | |||
| f6962a0b4a | |||
| 1114cd4fda | |||
| f52d54a093 | |||
| 346361da8a | |||
| ff436704ec | |||
| a9b3533f7a | |||
| 3a976d58ab | |||
| c672f7b65f | |||
| 16065315a7 | |||
| eb79823fd2 | |||
| 4fd323fff4 | |||
| 507489ec55 | |||
| 12be747609 | |||
| 1128f9811a | |||
| 7d284213d1 | |||
| 37c8929c5e | |||
| 496eba3c17 | |||
| 472170784b | |||
| 10e393a31f | |||
| 47b6f475e6 | |||
| 6118d59c5c | |||
| f1dbcd0f86 | |||
| 30e354d58f | |||
| 3f791cb120 | |||
| 820e7a08fc | |||
| 609427747a | |||
| a1887064eb | |||
| ab48642cf6 | |||
| b0473dee7d | |||
| 5eb8f436c4 | |||
| 51e206279b | |||
| 9bc109528e | |||
| 7674c456f2 | |||
| 952242f62b | |||
| d515a562a0 | |||
| 515411fc83 | |||
| dc1915bb43 | |||
| a152005b71 | |||
| 04db586dc3 | |||
| 88e897cc9c | |||
| 462a167438 | |||
| 15350c691b | |||
| a33ed27ffe | |||
| 2b27889e07 | |||
| f8978ff796 | |||
| 0b58d33117 | |||
| 0f5e5bf00a | |||
| c0f7926210 | |||
| 1abf6855d8 | |||
| 3f209bf619 | |||
| ffe04e6bff | |||
| 21ae3e1aa5 | |||
| 160a66f934 | |||
| 9ff2ef5d48 | |||
| 899770ed02 | |||
| e5c94ebf6d | |||
| e84e5eb241 | |||
| 07286899c5 | |||
| c605304c67 | |||
| 1500d42cd2 | |||
| 2404538270 | |||
| 1ca0b836e1 | |||
| 5a98ae4d34 | |||
| 7a2ab3fc8d | |||
| 73b40a5d9f | |||
| 7ae0cacf74 | |||
| 4c75db2029 | |||
| 593a4677b6 | |||
| f517c153c1 | |||
| 06f0b08a18 | |||
| e9e5c7f406 | |||
| a427be9b6f | |||
| b576ed372a | |||
| f4a23ae805 | |||
| f7ddb2e7ae | |||
| 5edb3fa241 | |||
| 88d7657364 | |||
| 1272b81cee | |||
| d50d4bde47 | |||
| 1f4516d69b | |||
| fcd6a60baa | |||
| bda63b6f0a | |||
| 27355cec76 | |||
| 0a739ee2fd | |||
| 54b74d5db1 | |||
| 6ac280f178 | |||
| caba3b3a21 | |||
| 5448cf0614 | |||
| d045847a59 | |||
| ba8bfc3f46 | |||
| f6ebaa7bfb | |||
| 1d9ffcc3d4 | |||
| 28f616d829 |
@@ -12,8 +12,20 @@ TURN_CREDENTIAL=
|
|||||||
# Optional: open self-registration of the first/any team (1 to enable).
|
# Optional: open self-registration of the first/any team (1 to enable).
|
||||||
# ALLOW_REGISTRATION=1
|
# ALLOW_REGISTRATION=1
|
||||||
|
|
||||||
|
# Optional: BizGaze as the identity provider. When set, /api/login validates
|
||||||
|
# credentials against this endpoint (after a local check) and provisions the user.
|
||||||
|
# BIZGAZE_LOGIN_URL=https://c02.bizgaze.app/Account/ValidateAndLogin
|
||||||
|
|
||||||
# Optional: shared secret for BizGaze SSO + signed webhook delivery.
|
# Optional: shared secret for BizGaze SSO + signed webhook delivery.
|
||||||
# SSO_SECRET=
|
# SSO_SECRET=
|
||||||
|
|
||||||
# Optional: BizGaze webhook endpoint for session events.
|
# Optional: BizGaze webhook endpoint for session events.
|
||||||
# BIZGAZE_WEBHOOK_URL=
|
# BIZGAZE_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# Optional: LiveKit SFU for meetings (scales past the ~5-peer P2P mesh). Set ALL THREE to enable;
|
||||||
|
# leave unset to keep the built-in mesh. The app mints join tokens with the secret (server-side
|
||||||
|
# only); the same key/secret feed the livekit container via LIVEKIT_KEYS in docker-compose.
|
||||||
|
# Generate a key/secret pair: two random strings, e.g. `openssl rand -hex 16` for each.
|
||||||
|
# LIVEKIT_URL=wss://livekit.bizgaze.com
|
||||||
|
# LIVEKIT_API_KEY=
|
||||||
|
# LIVEKIT_API_SECRET=
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ dist/
|
|||||||
build/
|
build/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
|
# Native client generated/build artifacts (Capacitor adds platform dirs; Electron builds to dist)
|
||||||
|
mobile/android/
|
||||||
|
mobile/ios/
|
||||||
|
|
||||||
|
# Keep the desktop app icon (a build RESOURCE), even though the global build/ rule ignores it
|
||||||
|
!desktop/build/
|
||||||
|
!desktop/build/**
|
||||||
|
|
||||||
|
# Runtime media (created at startup by config.js)
|
||||||
|
server/recordings/
|
||||||
|
server/transcripts/
|
||||||
|
server/uploads/
|
||||||
|
server/downloads/
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# BizGaze Connect — Architecture & Roadmap
|
||||||
|
|
||||||
|
This document records the **current** architecture, the **target** architecture, and a
|
||||||
|
**phased migration plan** so that the three strategic goals can be added *additively*
|
||||||
|
rather than as rewrites.
|
||||||
|
|
||||||
|
Strategic goals (see also `CLAUDE.md`):
|
||||||
|
1. **Native Android/iOS apps**
|
||||||
|
2. **Integration with any third-party application**
|
||||||
|
3. **Org-based licensing model** (Zoom-like: organizations buy seats/plans)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current architecture (as of 2026-06)
|
||||||
|
|
||||||
|
```
|
||||||
|
Single Node process (server/server.js, ~640 lines)
|
||||||
|
├── HTTP JSON API (/api/*, cookie-session auth)
|
||||||
|
├── WebSocket signaling (/ws — SDP/ICE relay, consent, share codes)
|
||||||
|
├── Static file serving (public/*.html, single-file pages, no build)
|
||||||
|
└── In-process state liveSessions / onlineAgents / pendingShares (Maps)
|
||||||
|
|
||||||
|
Data: node:sqlite single file (server/data.db)
|
||||||
|
teams, users, sessions_auth, machines, audit_log, sessions_log
|
||||||
|
Media: WebRTC P2P (1:1). STUN + managed TURN. Media never traverses the server.
|
||||||
|
Auth: scrypt passwords, opaque session token in an HttpOnly `sid` cookie. TOTP code exists but
|
||||||
|
login currently marks sessions MFA-passed directly.
|
||||||
|
Integrations: outbound webhook (single env URL, `session.ended`, HMAC-signed);
|
||||||
|
inbound SSO (`/sso`, custom HMAC token).
|
||||||
|
Recordings/transcripts: written to local disk (server/recordings, server/transcripts).
|
||||||
|
```
|
||||||
|
|
||||||
|
### What is already future-proof (keep)
|
||||||
|
- **WebRTC + `/ws` signaling** — standards-based; reused as-is by native apps and an SFU.
|
||||||
|
- **P2P media** — no server media path for 1:1; cheap and private.
|
||||||
|
- **HMAC-signed webhooks** and **audit log** — right primitives, just need to scale out.
|
||||||
|
- **Team-scoped queries** — the seed of multi-tenancy is present.
|
||||||
|
|
||||||
|
### Structural constraints that block the roadmap
|
||||||
|
| # | Constraint | Blocks |
|
||||||
|
|---|-----------|--------|
|
||||||
|
| C1 | Auth is **cookie-only** (`parseCookies(req).sid`); no `Authorization: Bearer`, no API keys | Mobile, Integrations |
|
||||||
|
| C2 | **No API versioning** (`/api/...`) | Mobile (shipped clients pin a contract) |
|
||||||
|
| C3 | **`team` is a thin tenant** `(id,name,created_at)`; app assumes one team | Licensing |
|
||||||
|
| C4 | **Session state in process memory** (Maps) | Horizontal scale, Meetings |
|
||||||
|
| C5 | **SQLite single-writer**, queries inline at ~100 call-sites | Scale, multi-tenant isolation |
|
||||||
|
| C6 | **P2P mesh only** — no SFU | Multi-party meetings (Zoom-like) |
|
||||||
|
| C7 | **Recordings on local disk** | Multi-instance, per-org storage quotas |
|
||||||
|
| C8 | **Monolithic `server.js`** mixes HTTP/WS/static/logic/DB | All (maintainability) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Target architecture (principles)
|
||||||
|
|
||||||
|
1. **Organization is the top-level tenant.** Every row and every request resolves to an
|
||||||
|
`org_id`. Billing, seats, plan, and feature flags hang off the Organization.
|
||||||
|
2. **Data access goes through a repository layer**, never raw SQL in route handlers.
|
||||||
|
This is what makes the SQLite→Postgres migration and strict tenant-scoping feasible.
|
||||||
|
3. **The API is versioned and token-addressable.** `/api/v1`; auth accepted via cookie
|
||||||
|
(web) *or* `Authorization: Bearer` (mobile) *or* scoped API key (integrations).
|
||||||
|
4. **Shared runtime state lives outside the process** (Redis) so the app can run N instances.
|
||||||
|
5. **Multi-party media uses an SFU** (LiveKit/mediasoup); 1:1 may stay P2P.
|
||||||
|
6. **Entitlements are enforced centrally** — one middleware checks plan limits before
|
||||||
|
privileged actions (add seat, start meeting, record, call the API).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── clients ────────────┐
|
||||||
|
│ web (HTML) mobile (native) 3rd-party (API key) │
|
||||||
|
└───────┬───────────┬───────────────┬──────────────┘
|
||||||
|
│ cookie │ Bearer │ API key
|
||||||
|
┌───────▼───────────▼───────────────▼──────────────┐
|
||||||
|
│ API v1 (routes → services → repository) │
|
||||||
|
│ authN (cookie/Bearer/key) · authZ (RBAC) │
|
||||||
|
│ entitlements middleware (plan/seat/feature) │
|
||||||
|
└───────┬───────────────────────┬──────────────────┘
|
||||||
|
│ │
|
||||||
|
┌─────────▼────────┐ ┌─────────▼─────────┐
|
||||||
|
│ Repository layer │ │ Signaling (ws) │──── Redis (shared state,
|
||||||
|
│ (SQLite→Postgres)│ │ + SFU for meetings│ pub/sub across instances)
|
||||||
|
└─────────┬────────┘ └───────────────────┘
|
||||||
|
│
|
||||||
|
Postgres · Object storage (recordings) · usage-metering
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goal-by-goal requirements
|
||||||
|
|
||||||
|
### Goal 1 — Native Android/iOS
|
||||||
|
- **Bearer-token auth** (C1) + refresh tokens; device registration.
|
||||||
|
- **`/api/v1`** (C2) — stable, documented contract.
|
||||||
|
- **Push notifications** (APNs/FCM) for incoming sessions/calls (mobile can't hold a background WS).
|
||||||
|
- Reuse WebRTC/`/ws` via `react-native-webrtc`/native SDKs; native screen capture
|
||||||
|
(ReplayKit / MediaProjection) for the phone-can't-share gap.
|
||||||
|
|
||||||
|
### Goal 2 — Third-party integration
|
||||||
|
- **Scoped API keys / OAuth2 client-credentials** per org (C1).
|
||||||
|
- **Webhook subscriptions** per org: multiple endpoints, event types, signed payloads, retries.
|
||||||
|
- **OIDC/JWT SSO** to replace the custom HMAC `/sso`.
|
||||||
|
- Optional: embeddable JS widget / SDK.
|
||||||
|
|
||||||
|
### Goal 3 — Org licensing (Zoom-like)
|
||||||
|
- **Organization** entity (C3): `plan`, `seats`, `status`, `trial_ends_at`, feature flags.
|
||||||
|
- **Entitlements + metering**: tables for plan limits and usage (minutes, sessions, storage);
|
||||||
|
central enforcement middleware.
|
||||||
|
- **SFU** for multi-party meetings (C6); per-org concurrent-meeting / minute caps.
|
||||||
|
- **Redis shared state** (C4) for multi-instance; **Postgres** (C5); **object storage** (C7).
|
||||||
|
- Billing provider integration (e.g. Stripe) driving subscription state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phased plan
|
||||||
|
|
||||||
|
> **Priority (set by the user 2026-06-11): mobile + integration first; licensing last.**
|
||||||
|
> Principle unchanged: do the shared groundwork first, so later work is additive. Because
|
||||||
|
> licensing is last, the full **Organization** entity moves to Phase 3 with it — Phase 1 keeps
|
||||||
|
> only a *tenant-id abstraction* (mapping to today's `team_id`) so Phase-2 auth/keys don't need
|
||||||
|
> reworking when the tenant is later elevated to a full Organization.
|
||||||
|
|
||||||
|
### Phase 1 — Foundations (structural, no behavior change) ✅ DONE (2026-06-11)
|
||||||
|
- [x] Extracted a **data-access layer** (`repos.js`) — all SQL moved out of `server.js`.
|
||||||
|
- [x] **Modularized** `server.js` → `config / lib / session / presence / routes / static / signaling`
|
||||||
|
(plus `repos` data layer and `bizgaze` service). `server.js` is now a thin entry point.
|
||||||
|
- [x] Standardized a **tenant id** in the data layer (repo params named `tenantId`, == `team_id` today);
|
||||||
|
every query is tenant-scoped. *No Organization entity / plan-seats yet — that's Phase 3.*
|
||||||
|
- Verified behavior-preserving by `test/e2e.js` (21/21) before and after.
|
||||||
|
|
||||||
|
### Phase 2 — API + access ← **PRIORITY** (mobile + desktop + integrations)
|
||||||
|
Target clients: web (cookie), native **Android/iOS**, a native **Windows desktop app where the
|
||||||
|
viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input injection like
|
||||||
|
`agent/`), and third-party systems (API keys). All authenticate through this one access layer.
|
||||||
|
- [x] **`/api/v1`** — every `/api/*` route aliased under `/api/v1/*` (routes.js); web keeps unversioned paths.
|
||||||
|
- [x] **`Authorization: Bearer <token>`** accepted in `currentUser()` across HTTP + WS, alongside the
|
||||||
|
cookie (session.js `tokenFromReq`); `/api/login` now also returns the `token` for native clients.
|
||||||
|
WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback).
|
||||||
|
- [x] **Refresh tokens** — `/api/v1/auth/refresh` exchanges a long-lived (90d) refresh token for a
|
||||||
|
fresh access token, with **rotation** (old token revoked on use) + replay rejection. Stored as a
|
||||||
|
SHA-256 hash (`refresh_tokens` table). Login returns one; logout/deactivate/reset/delete revoke them.
|
||||||
|
Web/cookie path unchanged.
|
||||||
|
- [x] **API keys** — admin-managed (`POST/GET /api/v1/keys`, `POST /api/v1/keys/revoke`), per-tenant,
|
||||||
|
scoped (`report:read`, `audit:read`), `bzc_`-prefixed, SHA-256 hashed at rest, shown once. Accepted via
|
||||||
|
`X-API-Key` or `Authorization: Bearer bzc_…` on `/api/v1/report` + `/api/v1/audit` with scope enforcement.
|
||||||
|
- [x] **Per-tenant webhook subscriptions** — admin-managed (`/api/v1/webhooks` create/list/delete +
|
||||||
|
`/webhooks/events`), each with its own signing secret; events `session.started`/`session.ended`
|
||||||
|
delivered HMAC-SHA256-signed (`X-BizGaze-Signature`) with in-memory retries. Legacy global
|
||||||
|
`BIZGAZE_WEBHOOK_URL` still works. (webhooks.js + signaling emits.)
|
||||||
|
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile — needs Apple/Google creds to test.
|
||||||
|
- [ ] **OIDC/JWT** SSO — needs an identity provider to test against.
|
||||||
|
|
||||||
|
### Phase 3 — Licensing + scale (last, per priority)
|
||||||
|
- [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id`
|
||||||
|
as alias/FK); add `plan`, `seats`, `status`, `features` columns.
|
||||||
|
- [ ] **Entitlements** module + central enforcement; **usage metering** (minutes/sessions/storage).
|
||||||
|
- [ ] **SFU** (LiveKit/mediasoup) for multi-party meetings; keep 1:1 P2P.
|
||||||
|
- [ ] **Redis** for `liveSessions/onlineAgents/pendingShares` + cross-instance pub/sub.
|
||||||
|
- [ ] **Postgres** migration (enabled by the Phase-1 data-access layer); **object storage** (S3) for recordings.
|
||||||
|
- [ ] Billing provider + subscription lifecycle webhooks.
|
||||||
|
|
||||||
|
### Explicitly NOT yet
|
||||||
|
- Don't add an SFU or Postgres before the data-access layer exists — you'd rework them.
|
||||||
|
- Don't build the full Organization/plan model before Phase 2 ships — but *do* keep the tenant-id
|
||||||
|
abstraction consistent from Phase 1 so the elevation is additive.
|
||||||
|
- Don't shard or add microservices; a well-modularized monolith + Redis + Postgres scales far enough.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Key decisions to confirm (when we reach them)
|
||||||
|
- **Auth tokens:** opaque (DB-backed, easy revoke) vs JWT (stateless, harder revoke). *Lean: opaque
|
||||||
|
access + refresh, since `sessions_auth` already works that way.*
|
||||||
|
- **SFU:** LiveKit (batteries-included, good mobile SDKs) vs mediasoup (lower-level, more control).
|
||||||
|
- **DB:** Postgres (recommended) — keep the repository layer DB-agnostic until the cutover.
|
||||||
|
- **Billing:** Stripe vs BizGaze's own billing (depends on how Connect sits inside the BizGaze suite).
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# BizGaze Connect — project brief
|
||||||
|
|
||||||
|
Place this file at the repo root (`remote-access-app/CLAUDE.md`). Claude Code reads
|
||||||
|
it automatically each session.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
**BizGaze Connect** — a no-install, browser-based remote support / screen-sharing
|
||||||
|
tool for the BizGaze ecosystem. A customer opens a page, gets a 6-digit code; a
|
||||||
|
signed-in BizGaze agent enters the code, the customer taps Allow, and the agent
|
||||||
|
sees the customer's screen with two-way voice + chat. Live at **remote.bizgaze.com**.
|
||||||
|
Roadmap: grow into a communication platform (meetings + persistent chat) for
|
||||||
|
registered BizGaze users.
|
||||||
|
|
||||||
|
## Tech stack (intentionally minimal — keep it this way)
|
||||||
|
- **Node.js >= 22.5**, single npm dependency: `ws` (WebSocket).
|
||||||
|
- **Built-in `node:sqlite`** (no native modules). DB file: `server/data.db`.
|
||||||
|
- **WebRTC** peer-to-peer for media (screen video + voice + data channels).
|
||||||
|
- **No build step, no framework.** Each page is a single self-contained HTML file
|
||||||
|
with inline `<style>` and `<script>`. Do not introduce React/bundlers.
|
||||||
|
- Auth: scrypt password hashing, HttpOnly session cookie. (SSO migration in progress.)
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
server.js # thin entry: HTTP dispatch + WS attach + listeners (HTTP/HTTPS)
|
||||||
|
config.js # env + filesystem paths (PORT, dirs, SESSION_TTL)
|
||||||
|
lib.js # HTTP helpers: json / readBody / parseCookies / now
|
||||||
|
session.js # currentUser (cookie -> user) + audit()
|
||||||
|
presence.js # shared in-memory live state (onlineAgents/liveSessions/pendingShares)
|
||||||
|
routes.js # HTTP JSON API (/api/*, /sso) -> { "METHOD /path": handler } map
|
||||||
|
static.js # static file serving + authenticated recording/transcript downloads
|
||||||
|
signaling.js # WebSocket signaling (consent + SDP/ICE relay)
|
||||||
|
repos.js # data-access layer — ALL SQL lives here (tenant-scoped)
|
||||||
|
bizgaze.js # BizGaze identity provider (validate login, env-gated)
|
||||||
|
db.js # node:sqlite schema + idempotent migrations
|
||||||
|
auth.js # scrypt hashing, token/id generation, TOTP helpers
|
||||||
|
package.json # { "dependencies": { "ws": "^8.18" }, engines node>=22.5 }
|
||||||
|
test/e2e.js # 21-check backend e2e (register->login->session->signaling->audit)
|
||||||
|
public/
|
||||||
|
index.html # public landing (Log in with BizGaze / share without login)
|
||||||
|
home.html # post-login shell: chat rail + Share/Connect (iframe) + Meeting (/home)
|
||||||
|
dashboard.html# login + role-scoped session report (/dashboard, replaces /console)
|
||||||
|
connect.html # agent: enter code, view screen, control bar (/connect)
|
||||||
|
share.html # customer: get code, share screen (/share)
|
||||||
|
home-mockup.html # locked design reference for home
|
||||||
|
logo.png
|
||||||
|
recordings/ # saved session recordings (.webm) [created at runtime]
|
||||||
|
transcripts/ # saved transcripts (.txt) [created at runtime]
|
||||||
|
```
|
||||||
|
Architecture/roadmap detail lives in `ARCHITECTURE.md`. Backend SQL must go through
|
||||||
|
`repos.js` (never inline in routes/signaling). Run `node test/e2e.js` after backend edits.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
```
|
||||||
|
cd server && npm install && node server.js
|
||||||
|
# HTTP on :8090 (HTTPS on :8443 only if cert.pem + key.pem exist in server/)
|
||||||
|
# Env: ALLOW_REGISTRATION=1 opens the first-team registration
|
||||||
|
```
|
||||||
|
First registered user becomes admin; registration then closes (unless ALLOW_REGISTRATION=1).
|
||||||
|
|
||||||
|
## Key HTTP routes (server.js)
|
||||||
|
- `POST /api/register|login|logout`, `GET /api/me`, `GET/POST /api/users`,
|
||||||
|
`POST /api/users/manage`, `GET /api/setup-state`, `GET /api/report`
|
||||||
|
- `GET /api/ice` — returns STUN, plus managed TURN **only for mobile clients**
|
||||||
|
(TURN creds come from env: `TURN_URLS`, `TURN_USERNAME`, `TURN_CREDENTIAL`)
|
||||||
|
- `POST /api/recording?sessionId=` / `POST /api/transcript?sessionId=` — uploads
|
||||||
|
- `GET /recordings/<sid>.webm` / `GET /transcripts/<sid>.txt` — authed downloads (streamed w/ Content-Length)
|
||||||
|
- `GET /sso?token=` — SSO entry (HMAC today; JWT migration planned)
|
||||||
|
- Page routes: `/`, `/console`, `/connect`, `/share`
|
||||||
|
|
||||||
|
## WebSocket signaling (`/ws`)
|
||||||
|
`liveSessions` map (sessionId -> {agentWs, viewerWs, ...}). Message cases:
|
||||||
|
`agent-hello`, `viewer-connect`, `consent`, `share-create`, `code-connect`,
|
||||||
|
`offer`/`answer`/`ice-candidate` (relayed peer-to-peer), `recording`, `transcript`,
|
||||||
|
`end-session`. Keepalive ping every 25s. Media never traverses the server.
|
||||||
|
|
||||||
|
## Current features (all working on desktop)
|
||||||
|
- Code-based no-install screen share (customer shares, agent views).
|
||||||
|
- Two-way voice; in-session chat (logged-in sharer's name shown).
|
||||||
|
- **Session recording**: agent presses Record; mixes customer screen + both voices;
|
||||||
|
uploads `.webm`; downloadable from the report. Customer sees a "being recorded"
|
||||||
|
banner + live timer.
|
||||||
|
- **Auto-transcript**: each side runs Web Speech API on its own mic; lines stream to
|
||||||
|
the agent; combined `.txt` (voices + chat) uploaded; downloadable from the report.
|
||||||
|
- **Session report**: filter by agent/date, CSV + PDF export, pagination (5/page),
|
||||||
|
agent search, recording/transcript download links.
|
||||||
|
- Agent management (admin invites, roles admin/technician/viewer), remember-me,
|
||||||
|
case-insensitive email, password show/hide, session-end webhook to BizGaze.
|
||||||
|
|
||||||
|
## Hard constraints (do not try to "fix" these)
|
||||||
|
- **Mobile browsers CANNOT share their screen.** Android Chrome and iOS Safari do
|
||||||
|
not expose `getDisplayMedia` screen capture to web pages. Only a native app can
|
||||||
|
capture a phone screen. The share page detects mobile and shows a clear message.
|
||||||
|
(Desktop screen share works fully.)
|
||||||
|
- Screen capture requires a user gesture → `getDisplayMedia` is called directly from
|
||||||
|
the customer's "Allow" tap (see share.html `beginCapture`).
|
||||||
|
- Recording/transcript use browser MediaRecorder + Web Speech API → Chrome/Edge only.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
- `remote.bizgaze.com`, Linux, Docker, behind a reverse proxy.
|
||||||
|
- Proxy MUST: upgrade `/ws` with long timeouts; allow large bodies on
|
||||||
|
`/api/recording`; not buffer `/recordings/` downloads. (See IT-HANDOFF-PROXY.md.)
|
||||||
|
- Env vars: `TURN_URLS`, `TURN_USERNAME`, `TURN_CREDENTIAL` (Metered TURN),
|
||||||
|
`SSO_SECRET`, `BIZGAZE_WEBHOOK_URL`, `BIZGAZE_LOGIN_URL` (identity provider for `/api/login`),
|
||||||
|
`ALLOW_REGISTRATION`, `DB_PATH`, `PORT`, `HTTPS_PORT`.
|
||||||
|
|
||||||
|
## In progress / roadmap
|
||||||
|
1. **SSO with BizGaze** (active): BizGaze becomes the identity provider. It issues a
|
||||||
|
signed token; `/sso` verifies it and creates a local session. Supports both
|
||||||
|
"from inside BizGaze" and a "Log in with BizGaze" button at our URL. Waiting on
|
||||||
|
the dev team for: shared secret, token format (JWT preferred), SSO start URL,
|
||||||
|
signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.)
|
||||||
|
2. **New post-login home (NEXT TASK)** — see below.
|
||||||
|
3. **Persistent chat** — 1:1 messaging is BUILT (messages table, `/api/v1/messages/*`, live delivery
|
||||||
|
over `/ws` via `chat-hello`/`chat-message`, wired into home.html). Group chat is the remaining part.
|
||||||
|
4. **Meetings** (multi-party video) — **mesh (P2P) MVP BUILT**: in-memory rooms + signaling
|
||||||
|
(`meeting-create/join/signal/leave` in signaling.js), video-grid UI in home.html's Meeting tab
|
||||||
|
(start/join by 6-digit code, mic/cam toggles, leave). Good for small groups; **SFU upgrade** is
|
||||||
|
the next step for larger rooms.
|
||||||
|
5. **Downloadable Android app** — the only way to support phone screen-sharing.
|
||||||
|
|
||||||
|
## NEXT TASK: new post-login home (start with a mockup)
|
||||||
|
After login, replace the current dashboard with a BizGaze Connect "home":
|
||||||
|
- **Left sidebar (Slack-style):** list of recent chats/contacts with avatar,
|
||||||
|
name, last-message preview, unread badge. (Mock data first — no chat backend yet.)
|
||||||
|
- **Main area with tabs:** **Meeting** (placeholder "coming soon"), **Share Screen**
|
||||||
|
(links to the existing share flow), **Connect Screen** (existing agent connect flow).
|
||||||
|
- Top bar: BizGaze Connect wordmark (brand blue #1F3B73 / yellow #FFC708, logo.png),
|
||||||
|
profile dropdown (existing pattern in the HTML).
|
||||||
|
- Build a **standalone static mockup first** (e.g. `public/home-mockup.html`) to lock
|
||||||
|
the layout, then wire the real tabs/sidebar. Keep the single-file, no-framework style.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- Brand: blue `#1F3B73`, yellow `#FFC708`, logo at `/logo.png`.
|
||||||
|
- Single-file HTML pages; reuse the existing `profileHTML()`/`wireProfile()` and
|
||||||
|
brand patterns already in console.html/connect.html.
|
||||||
|
- Always `node --check` extracted inline scripts after edits; test against a local
|
||||||
|
`node server.js` before committing.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Biz Connect — Mobile & Desktop clients
|
||||||
|
|
||||||
|
Native clients for Biz Connect. **The web app is the single source of truth for the UI**;
|
||||||
|
each native client is a thin shell that loads that UI and adds the capabilities a browser
|
||||||
|
can't provide (background push, native screen capture, OS input injection, store presence).
|
||||||
|
|
||||||
|
Decisions (set by the user 2026-06-30):
|
||||||
|
- **Build mobile and desktop in parallel.**
|
||||||
|
- **Desktop = both pieces:** the remote-control **host** (existing `agent/`) *and* a
|
||||||
|
**technician desktop client** (`desktop/` — Connect in a window).
|
||||||
|
- **Mobile = Capacitor wrap** of the existing web UI (one codebase), not a native rewrite.
|
||||||
|
|
||||||
|
Backend is already client-ready (see [ARCHITECTURE.md](ARCHITECTURE.md) Phase 2): `/api/v1`,
|
||||||
|
`Authorization: Bearer` + refresh tokens, API keys, per-tenant webhooks. The one remaining
|
||||||
|
backend gap is **APNs/FCM push** for native mobile (needs Apple/Google credentials).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Dir | Client | Tech | What it adds over the browser |
|
||||||
|
|-----|--------|------|-------------------------------|
|
||||||
|
| `mobile/` | iOS + Android app | **Capacitor** loading the Connect UI | Native push (FCM/APNs), camera/mic perms, store distribution, screen capture (ReplayKit / MediaProjection) |
|
||||||
|
| `desktop/` | Technician client | **Electron** loading the Connect UI | Native full-screen capture for screen-share; windowed app; later: tray, auto-update |
|
||||||
|
| `agent/` | Remote-control **host** (customer) | **Electron + nut-js** *(exists, v0.2.0)* | Screen capture + **OS input injection** so a technician can control the machine |
|
||||||
|
|
||||||
|
All three authenticate through the same `/api/v1` access layer (Bearer token for mobile/desktop,
|
||||||
|
the existing consent/enroll flow for the agent).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why "wrap the web UI" (not rewrite)
|
||||||
|
|
||||||
|
The Connect UI is already an installable PWA built from server-rendered single-file HTML.
|
||||||
|
Pointing a native webview at the server origin means **every relative `/api` and `/ws` URL
|
||||||
|
keeps working unchanged** — zero web-code changes — while native plugins augment it through
|
||||||
|
the JS bridge. One codebase, three shells.
|
||||||
|
|
||||||
|
Trade-off: a server-URL shell needs network at launch (fine for a remote-support tool) and
|
||||||
|
some stores scrutinise "just a website". We mitigate by shipping real native capabilities
|
||||||
|
(push, screen capture, deep links), and can later switch to **bundled** web assets + an
|
||||||
|
absolute API base if offline-launch or store policy requires it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phased plan
|
||||||
|
|
||||||
|
### Phase A — Shells that load the live app ← start here
|
||||||
|
- [ ] `desktop/` Electron client: `loadURL(server)`, `setDisplayMediaRequestHandler` so
|
||||||
|
screen-share works natively, external links → browser, persisted session.
|
||||||
|
- [ ] `mobile/` Capacitor project: `server.url` → Connect, app icons/splash, status bar.
|
||||||
|
- [ ] Inject `window.__NATIVE__ = 'desktop' | 'ios' | 'android'` so the web UI can adapt
|
||||||
|
(e.g. hide the PWA "install" prompt, enable native push instead of Web Push).
|
||||||
|
|
||||||
|
### Phase B — Native capabilities
|
||||||
|
- [x] **Push backend:** device-token registration (`POST /api/v1/devices`,
|
||||||
|
`/api/v1/devices/remove`) + native senders folded into the single `push.sendToUser`
|
||||||
|
path — **FCM v1** (Android) and **APNs** token-based over HTTP/2 (iOS), each
|
||||||
|
config-gated and a no-op until creds are set. Web Push (VAPID) unchanged. Dead
|
||||||
|
tokens are pruned on 404/410/UNREGISTERED. (db `device_tokens`, repo `deviceTokens`,
|
||||||
|
`push.js`.) *Mobile app still needs the Capacitor push plugin wired + FCM/APNs creds
|
||||||
|
to deliver end-to-end.*
|
||||||
|
- [x] **Capacitor push plugin wired** in the web UI (`setupNativePush` in home.html):
|
||||||
|
inside the app it requests permission, registers, and `POST`s the FCM/APNs token to
|
||||||
|
`/api/v1/devices`; notification taps deep-link via `selectChat`; logout unregisters the
|
||||||
|
token. Web Push is skipped when running natively. Inert in a normal browser. *Activates
|
||||||
|
once the Capacitor Android/iOS app is built and FCM/APNs creds are set.*
|
||||||
|
- [ ] **Mobile screen capture** for "Share Screen" from a phone (ReplayKit / MediaProjection plugin).
|
||||||
|
- [ ] **Deep links / universal links** so a session/meeting link opens the app.
|
||||||
|
|
||||||
|
### Phase C — Packaging & distribution *(needs external accounts)*
|
||||||
|
- [ ] Desktop installers via **electron-builder** (Win NSIS + Mac dmg); **code-signing**
|
||||||
|
(Win EV cert, Apple Developer ID + notarization).
|
||||||
|
- [ ] Mobile store builds: **Apple Developer** ($99/yr) + **Google Play** ($25 once);
|
||||||
|
signing keys; store listings & privacy disclosures.
|
||||||
|
- [ ] Agent host installer (signed) for customers.
|
||||||
|
- [ ] Auto-update channels.
|
||||||
|
|
||||||
|
### Phase D — Inline-reply notifications (Teams-style) *(right AFTER packaging)*
|
||||||
|
Reply to a chat directly from the OS notification, without opening the app. Cross-platform:
|
||||||
|
- [x] **Desktop (Windows):** native Windows toast with a **reply box** via **node-notifier**
|
||||||
|
(bundles SnoreToast). main.js `reply-notification` handler → preload `replyNotify` →
|
||||||
|
home.html `notify()` routes chat toasts through it: a typed reply calls `sendReplyTo`
|
||||||
|
(POST /api/messages) without opening the app; clicking opens the chat. Uses the installer's
|
||||||
|
AppUserModelID, so it only works in the **installed** app. *Needs a live test in the
|
||||||
|
installed app (can't drive a real Windows toast from CI).*
|
||||||
|
- **Android:** notification action with **`RemoteInput`** (direct reply) on the FCM message.
|
||||||
|
- **iOS:** **`UNTextInputNotificationAction`** on the APNs notification category.
|
||||||
|
All three hand the typed text to the same send path. Depends on Phase B push + Phase C packaging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
### Desktop (technician client)
|
||||||
|
```bash
|
||||||
|
cd desktop
|
||||||
|
npm install
|
||||||
|
SERVER_URL=https://remote.bizgaze.com npm start # or http://localhost:8090 in dev
|
||||||
|
npm run dist # build installers (needs electron-builder + certs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile (Capacitor)
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
npm install
|
||||||
|
npx cap add android # needs Android Studio + SDK
|
||||||
|
npx cap add ios # needs macOS + Xcode
|
||||||
|
npx cap sync
|
||||||
|
npx cap open android # build/run from Android Studio
|
||||||
|
npx cap open ios # build/run from Xcode
|
||||||
|
```
|
||||||
|
Set the server origin in `capacitor.config.json` (`server.url`).
|
||||||
|
|
||||||
|
### Agent host (existing)
|
||||||
|
```bash
|
||||||
|
cd agent
|
||||||
|
npm install
|
||||||
|
SERVER_URL=https://remote.bizgaze.com AGENT_ENROLL_TOKEN=<token> npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's gated on you (external, can't be done from code alone)
|
||||||
|
- **Apple Developer** + **Google Play** accounts (mobile store builds & push).
|
||||||
|
- **Code-signing certificates** (Windows EV, Apple Developer ID) for trusted installers.
|
||||||
|
- **FCM/APNs credentials** for native push.
|
||||||
|
Everything else — the shells, the device/push backend, native plugin wiring — is built here.
|
||||||
@@ -21,6 +21,52 @@ Server facts:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Operational guardrails (read before every deploy)
|
||||||
|
|
||||||
|
These are correctness/security invariants, not preferences. Breaking one degrades
|
||||||
|
or breaks the app even if the container starts fine.
|
||||||
|
|
||||||
|
- **Single instance only.** Chat, presence, and meeting (WebRTC) signaling use an
|
||||||
|
**in-process** registry. Do **not** scale to multiple replicas or place several
|
||||||
|
instances behind a round-robin load balancer — users on different processes
|
||||||
|
can't see each other's messages/calls. One container, one process.
|
||||||
|
- **`ALLOW_LOCAL_LOGIN` must NOT be set in production.** It's a dev-only escape
|
||||||
|
hatch that bypasses BizGaze SSO and the local-password lockout. Production logs
|
||||||
|
in via BizGaze only.
|
||||||
|
- **`BIZGAZE_DIRECTORY_TOKEN` is server-side only** — it's used by the server to
|
||||||
|
proxy directory lookups and must never be exposed to the browser/client.
|
||||||
|
- **HTML is served `Cache-Control: no-store` by design** so new builds land
|
||||||
|
immediately. Do not add an HTTP/CDN cache layer that caches `.html`. Static JS
|
||||||
|
(`icons.js`) is cache-busted with a `?v=` query, currently `?v=4`.
|
||||||
|
- **Node ≥ 22.5** (the image uses `node:24-alpine`) — required for the built-in
|
||||||
|
`node:sqlite` that `db.js` relies on. `deploy.sh` rebuilds the image, so
|
||||||
|
`npm install` (incl. `web-push`) happens automatically; no manual install.
|
||||||
|
- **No DB migration is required** for routine UI/chat releases. The `data.db`
|
||||||
|
volume persists across rebuilds; schema changes (when present) auto-apply on boot.
|
||||||
|
|
||||||
|
### Env vars to confirm in `.env`
|
||||||
|
`.env` lives only on the server (never in git) and must contain, beyond the TURN
|
||||||
|
secrets already documented:
|
||||||
|
|
||||||
|
| Group | Vars | Needed for |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| Login / SSO | `BIZGAZE_LOGIN_URL`, `BIZGAZE_DIRECTORY_URL`, `BIZGAZE_DIRECTORY_TOKEN`, `SSO_SECRET` | BizGaze sign-in + directory search |
|
||||||
|
| Web Push | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | Background push for browsers / installed PWA |
|
||||||
|
| Native push — Android | `FCM_SERVICE_ACCOUNT` (path to / inline Firebase service-account JSON) | FCM push to the Android app |
|
||||||
|
| Native push — iOS | `APNS_KEY` (path/inline `.p8`), `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, `APNS_PRODUCTION=1` | APNs push to the iOS app |
|
||||||
|
| Calls | `TURN_URLS` / `TURN_SECRET` (or `TURN_USERNAME`+`TURN_CREDENTIAL`) | Audio/video across NATs & mobile networks |
|
||||||
|
|
||||||
|
If the VAPID keys are missing, push silently no-ops (the app still runs). Push on
|
||||||
|
iOS additionally requires the user to **Add to Home Screen** (iOS 16.4+) — an
|
||||||
|
end-user step, not ops.
|
||||||
|
|
||||||
|
### Per-release verification
|
||||||
|
After deploy, open the app and check the browser console logs the expected build,
|
||||||
|
e.g. `Biz Connect build 2026-06-30-batch14`. That confirms the new HTML is being
|
||||||
|
served (not a stale cache).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## One-time bootstrap (server → git clone)
|
## One-time bootstrap (server → git clone)
|
||||||
|
|
||||||
Run **once** to convert the existing folder into a git checkout without losing the
|
Run **once** to convert the existing folder into a git checkout without losing the
|
||||||
@@ -84,6 +130,124 @@ ssh -p 61 root@118.95.33.89 'cd /opt/bizgaze-support && ./deploy.sh'
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Desktop app releases (make the installer live + auto-update)
|
||||||
|
|
||||||
|
Web/UI changes reach the desktop app instantly (it loads the live site). Only a change to the
|
||||||
|
**native shell** (`desktop/`) needs a new installer. Publishing one both powers the site's
|
||||||
|
"Download for Windows" button and pushes an auto-update to already-installed apps.
|
||||||
|
|
||||||
|
The installer feed is served from `DOWNLOADS_DIR`, which docker-compose now points at
|
||||||
|
`/data/downloads` (the persistent volume) so uploads survive `deploy.sh` rebuilds. **First time
|
||||||
|
only**, redeploy once after pulling so the container picks up `DOWNLOADS_DIR`, then create the dir:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 61 root@118.95.33.89 'docker exec bizgaze-support mkdir -p /data/downloads'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Each desktop release:**
|
||||||
|
|
||||||
|
1. Build on a Windows machine (needs `desktop/build/icon.ico`; bump `desktop/package.json`
|
||||||
|
`version` first):
|
||||||
|
```bash
|
||||||
|
cd desktop && npm install && npm run dist
|
||||||
|
```
|
||||||
|
Output in `desktop/dist/`: `Biz Connect Setup <ver>.exe`, `….exe.blockmap`, `latest.yml`.
|
||||||
|
2. Upload those **three** files into the container's `/data/downloads` (all three are required —
|
||||||
|
`latest.yml` is the update manifest, `.blockmap` enables differential updates). `/data` is a
|
||||||
|
**named Docker volume** (`bizgaze_support_data`), not a host bind-mount, so its real host path
|
||||||
|
is `/var/lib/docker/volumes/bizgaze_support_data/_data`. scp straight into it — one step, no
|
||||||
|
restart needed (the server serves the folder live):
|
||||||
|
```powershell
|
||||||
|
# from the repo's desktop\dist folder on the Windows build machine
|
||||||
|
scp -P 61 "Biz Connect Setup <ver>.exe" "Biz Connect Setup <ver>.exe.blockmap" latest.yml `
|
||||||
|
root@118.95.33.89:/var/lib/docker/volumes/bizgaze_support_data/_data/downloads/
|
||||||
|
```
|
||||||
|
Alternatively, scp to `/tmp` on the server and `docker cp` in (avoids touching the volume path):
|
||||||
|
```bash
|
||||||
|
docker cp "/tmp/Biz Connect Setup <ver>.exe" bizgaze-support:/data/downloads/
|
||||||
|
docker cp "/tmp/Biz Connect Setup <ver>.exe.blockmap" bizgaze-support:/data/downloads/
|
||||||
|
docker cp "/tmp/latest.yml" bizgaze-support:/data/downloads/
|
||||||
|
```
|
||||||
|
Keep both versions' `.exe`/`.blockmap` on the server (older blockmaps let installed apps pull
|
||||||
|
deltas); only `latest.yml` is overwritten — there must be exactly one, pointing at the newest.
|
||||||
|
3. Verify:
|
||||||
|
```bash
|
||||||
|
curl -I https://remote.bizgaze.com/download/windows # 302 → the new .exe
|
||||||
|
curl https://remote.bizgaze.com/downloads/latest.yml # shows version <ver>
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed apps check the feed on launch and every 6h, download in the background, and update on
|
||||||
|
next restart. Keep the `.exe` + `.blockmap` that `latest.yml` references on the server; older
|
||||||
|
versions can be pruned. Note: the installer is **not code-signed**, so Windows SmartScreen shows
|
||||||
|
an "unknown publisher" warning — supply an EV/OV code-signing cert to remove it (see
|
||||||
|
`desktop/PACKAGING.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meetings SFU (LiveKit) — optional, scales meetings past ~5 people
|
||||||
|
|
||||||
|
By default meetings use a **P2P mesh** (each person sends video to every other person), which
|
||||||
|
degrades past ~5 participants. Enabling **LiveKit** routes media through an SFU so each person
|
||||||
|
uploads once — rooms scale to 20-50+. It's **fully optional and config-gated**: until you set the
|
||||||
|
three `LIVEKIT_*` vars, the app keeps using the mesh, unchanged. The `livekit` service is already
|
||||||
|
in `docker-compose.yml`; these steps turn it on.
|
||||||
|
|
||||||
|
**1. Generate an API key + secret** (any two random strings; keep them secret):
|
||||||
|
```bash
|
||||||
|
echo "LIVEKIT_API_KEY=$(openssl rand -hex 8)"
|
||||||
|
echo "LIVEKIT_API_SECRET=$(openssl rand -hex 24)"
|
||||||
|
```
|
||||||
|
Add those two lines to the server's `.env`, plus the public signaling URL:
|
||||||
|
```
|
||||||
|
LIVEKIT_URL=wss://livekit.bizgaze.com
|
||||||
|
LIVEKIT_API_KEY=<from above>
|
||||||
|
LIVEKIT_API_SECRET=<from above>
|
||||||
|
```
|
||||||
|
The app mints join tokens with the secret (server-side only); the same key/secret reach the
|
||||||
|
`livekit` container via `LIVEKIT_KEYS` (docker-compose reads them from this same `.env`).
|
||||||
|
|
||||||
|
**2. DNS**: point `livekit.bizgaze.com` (A record) at the server — `118.95.33.89`.
|
||||||
|
|
||||||
|
**3. NPM proxy host** for the signaling WebSocket (LiveKit media does NOT go through NPM):
|
||||||
|
- Domain `livekit.bizgaze.com` → **Forward to** `livekit:7880` (scheme `http`).
|
||||||
|
- **Websockets Support: ON**. Request an SSL cert (Let's Encrypt) + Force SSL.
|
||||||
|
- NPM reaches `livekit:7880` by container name — both are on `nginx_proxy_manager_default`.
|
||||||
|
|
||||||
|
**4. Media ports — NAT port-forward (REQUIRED here).** This box sits **behind NAT**: its only
|
||||||
|
interface is a private `192.168.88.61`; the public `118.95.33.89` (DNS) is mapped by an upstream
|
||||||
|
gateway. WebRTC media can't traverse NPM (L7), so the gateway/router must forward the media ports
|
||||||
|
to the box. To keep the ask minimal, LiveKit is configured for **one** UDP port + one TCP fallback:
|
||||||
|
|
||||||
|
Ask whoever controls the network/gateway to forward, from `118.95.33.89` → `192.168.88.61`:
|
||||||
|
| Port | Proto | Purpose |
|
||||||
|
|------|-------|---------|
|
||||||
|
| 50000 | UDP | WebRTC media (all participants mux over this one port) |
|
||||||
|
| 7881 | TCP | WebRTC-over-TCP fallback (restrictive client networks) |
|
||||||
|
|
||||||
|
`livekit.yaml` already pins `rtc.node_ip: 118.95.33.89` (auto-detect would pick the wrong outbound
|
||||||
|
IP behind this NAT). The host's local `ufw` is inactive, so no host-firewall change is needed — the
|
||||||
|
only requirement is the upstream port-forward above. Until it exists, signaling connects but media
|
||||||
|
won't flow (participants see each other's tiles but no video/audio).
|
||||||
|
|
||||||
|
**5. Deploy** (the livekit service is behind a `sfu` compose profile, so it stays dormant on a
|
||||||
|
normal deploy — start it explicitly):
|
||||||
|
```bash
|
||||||
|
cd /opt/bizgaze-support && ./deploy.sh # rebuilds/starts the app as usual
|
||||||
|
docker compose --profile sfu up -d # additionally starts the livekit container
|
||||||
|
docker compose ps # expect both bizgaze-support AND bizgaze-livekit "Up"
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Verify**:
|
||||||
|
```bash
|
||||||
|
curl https://remote.bizgaze.com/api/meetings/config # expect {"sfu":true,"url":"wss://livekit.bizgaze.com"}
|
||||||
|
curl -I https://livekit.bizgaze.com # 200/426 (WS endpoint reachable via NPM+TLS)
|
||||||
|
docker logs bizgaze-livekit --tail 30 # "starting LiveKit server", no key errors
|
||||||
|
```
|
||||||
|
Then start a meeting in the app and confirm 3+ participants see each other. To roll back to mesh,
|
||||||
|
just remove the `LIVEKIT_*` vars from `.env` and redeploy — no code change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
# Node 24 ships node:sqlite as a stable built-in (no flag), which db.js relies on.
|
# Node 24 ships node:sqlite as a stable built-in (no flag), which db.js relies on.
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
# ffmpeg: server-side video poster thumbnails (see static.js /thumbs/<id>). Small on Alpine.
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app/server
|
WORKDIR /app/server
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,17 @@ function mapKey(key, code) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the screen size (this nut-js exposes screen.width()/height(), not getResolution()).
|
||||||
|
// Recomputed once per session (cleared in releaseAll) so a resolution change is picked up.
|
||||||
|
let _screen = null;
|
||||||
|
async function screenSize() {
|
||||||
|
if (!_screen) _screen = { w: await nut.screen.width(), h: await nut.screen.height() };
|
||||||
|
return _screen;
|
||||||
|
}
|
||||||
async function moveTo(xNorm, yNorm) {
|
async function moveTo(xNorm, yNorm) {
|
||||||
if (!nut) return;
|
if (!nut) return;
|
||||||
const { width, height } = await nut.screen.getResolution();
|
const { w, h } = await screenSize();
|
||||||
await nut.mouse.setPosition(new nut.Point(Math.round(xNorm * width), Math.round(yNorm * height)));
|
await nut.mouse.setPosition(new nut.Point(Math.round(xNorm * w), Math.round(yNorm * h)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonEnum(b) {
|
function buttonEnum(b) {
|
||||||
@@ -98,6 +105,7 @@ async function releaseAll() {
|
|||||||
if (!nut) { pressed.clear(); return; }
|
if (!nut) { pressed.clear(); return; }
|
||||||
for (const k of pressed) { try { await nut.keyboard.releaseKey(k); } catch {} }
|
for (const k of pressed) { try { await nut.keyboard.releaseKey(k); } catch {} }
|
||||||
pressed.clear();
|
pressed.clear();
|
||||||
|
_screen = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { inject, releaseAll, available, mapKey };
|
module.exports = { inject, releaseAll, available, mapKey };
|
||||||
|
|||||||
@@ -104,13 +104,13 @@ async function startStreaming() {
|
|||||||
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||||
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
|
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
|
||||||
|
|
||||||
// Viewer creates the input data channel; we receive it here.
|
// The agent is the OFFERER, so it must create the input data channel — otherwise the
|
||||||
pc.ondatachannel = (ev) => {
|
// SCTP m-line is absent from the offer and the channel never negotiates (viewer's stays
|
||||||
const ch = ev.channel;
|
// closed, so no input arrives). The viewer receives this channel via ondatachannel.
|
||||||
ch.onmessage = (msg) => {
|
const inputCh = pc.createDataChannel('input', { ordered: true });
|
||||||
let evt; try { evt = JSON.parse(msg.data); } catch { return; }
|
inputCh.onmessage = (msg) => {
|
||||||
window.agent.injectInput(evt); // -> main process -> OS injection
|
let evt; try { evt = JSON.parse(msg.data); } catch { return; }
|
||||||
};
|
window.agent.injectInput(evt); // -> main process -> OS injection
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onicecandidate = (ev) => {
|
pc.onicecandidate = (ev) => {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# TASK FOR CLAUDE (VS Code): Apply the Biz Connect brand — icons, splash, loaders, toasts
|
||||||
|
|
||||||
|
Read this whole file, then execute it. Work CONSERVATIVELY — only ADD / restyle, do NOT
|
||||||
|
refactor unrelated code. The app is live in production. Find the right spots in the CURRENT
|
||||||
|
code yourself (don't assume old names). Summarise all changes and STOP for my review before
|
||||||
|
committing anything.
|
||||||
|
|
||||||
|
Repo root: C:\BizGaze_Support\remote-access-app
|
||||||
|
Master brand assets are already placed in: brand-assets\
|
||||||
|
- app-icon-1024.png (1024 full-bleed app icon master)
|
||||||
|
- splash-2732-dark.png (2732 splash, brand blue — primary)
|
||||||
|
- splash-2732-light.png (2732 splash, white — optional light theme)
|
||||||
|
- loader-ring.svg (default spinner, light backgrounds)
|
||||||
|
- loader-orbit.svg (branded spinner, light backgrounds)
|
||||||
|
- loader-orbit-dark.svg (branded spinner, dark/overlay backgrounds)
|
||||||
|
Brand colours: blue #1F3B73, blue-dark #16294F, yellow #FFC708, gold #E0AC00.
|
||||||
|
|
||||||
|
Note: brand-assets/ is only the SOURCE. Put the GENERATED / used copies where each target
|
||||||
|
needs them — web assets under server/public/ (served over HTTP); native icons/splash into the
|
||||||
|
platform build folders (Electron / Android / iOS) as applicable.
|
||||||
|
|
||||||
|
## 1. App icons (from brand-assets/app-icon-1024.png)
|
||||||
|
Generate and place:
|
||||||
|
- Windows .ico (multi-size 16/32/48/256) — fixes the tiny/blurry taskbar icon.
|
||||||
|
- Favicon: server/public/favicon.ico + a 32px PNG; add to each page <head>:
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png">
|
||||||
|
- PWA icons 192, 512, and a maskable 512; reference them in the web manifest, and set the
|
||||||
|
manifest "background_color" and "theme_color" to "#1F3B73".
|
||||||
|
- Native mobile icons (Android mipmap set + adaptive, iOS AppIcon set) into their folders.
|
||||||
|
|
||||||
|
## 2. Splash (from brand-assets/splash-2732-dark.png)
|
||||||
|
- Web/PWA loading + Electron splash: brand-blue background #1F3B73 with the logo centred
|
||||||
|
(use the 2732 image, or reuse the app's splash.html if present).
|
||||||
|
- Native launch screens: use the 2732 image (Android 12 SplashScreen background #1F3B73 + the
|
||||||
|
app icon; iOS LaunchScreen background #1F3B73 + centred logo). Keep native launch STATIC.
|
||||||
|
- Use splash-2732-light.png only if a light theme is supported.
|
||||||
|
|
||||||
|
## 3. Loaders (from brand-assets/loader-*.svg)
|
||||||
|
Copy the three SVGs into server/public/ (e.g. server/public/loaders/). Wire them:
|
||||||
|
- Everyday "Loading…" and in-app spinners -> loader-ring.svg
|
||||||
|
- The hero "Connecting…" moment (session connect) -> loader-orbit.svg (light) / loader-orbit-dark.svg (on dark)
|
||||||
|
Show one centered in the middle of the screen/panel while loading; hide when done. Always pair
|
||||||
|
show with hide on BOTH the success and error paths so it can never get stuck.
|
||||||
|
|
||||||
|
## 4. Branded notification toasts
|
||||||
|
Create server/public/bizconnect-toast.css with EXACTLY:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Biz Connect — branded notification toast. BZToast.success('…') / .error / .message / .info */
|
||||||
|
.bzt-wrap{position:fixed;top:16px;right:16px;z-index:2147483600;display:flex;flex-direction:column;gap:10px;max-width:min(380px,92vw)}
|
||||||
|
@supports(top:env(safe-area-inset-top)){.bzt-wrap{top:calc(16px + env(safe-area-inset-top));right:calc(16px + env(safe-area-inset-right))}}
|
||||||
|
.bzt{display:flex;align-items:flex-start;gap:12px;background:#fff;color:#1f2430;border-radius:14px;padding:12px 14px;
|
||||||
|
box-shadow:0 12px 30px rgba(16,26,53,.20);border-left:5px solid #1F3B73;
|
||||||
|
transform:translateX(120%);opacity:0;transition:transform .3s cubic-bezier(.2,.7,.2,1),opacity .3s}
|
||||||
|
.bzt.bzt-in{transform:translateX(0);opacity:1}
|
||||||
|
.bzt.success{border-left-color:#16a34a}.bzt.error{border-left-color:#b91c1c}
|
||||||
|
.bzt-badge{flex:none;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#1F3B73}
|
||||||
|
.bzt.success .bzt-badge{background:#16a34a}.bzt.error .bzt-badge{background:#b91c1c}
|
||||||
|
.bzt-badge svg{width:20px;height:20px}
|
||||||
|
.bzt-body{flex:1;min-width:0;padding-top:1px}
|
||||||
|
.bzt-title{font:700 13.5px/1.3 'Segoe UI',system-ui,sans-serif;color:#1F3B73;margin:0 0 1px}
|
||||||
|
.bzt.success .bzt-title{color:#15803d}.bzt.error .bzt-title{color:#b91c1c}
|
||||||
|
.bzt-msg{font:500 13px/1.4 'Segoe UI',system-ui,sans-serif;color:#3a4152;overflow-wrap:anywhere}
|
||||||
|
.bzt-x{flex:none;background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:18px;line-height:1;padding:2px 4px}
|
||||||
|
.bzt-x:hover{color:#1f2430}
|
||||||
|
@media(prefers-reduced-motion:reduce){.bzt{transition:opacity .2s}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create server/public/bizconnect-toast.js with EXACTLY:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/* Biz Connect toast API. Requires bizconnect-toast.css.
|
||||||
|
BZToast.success('Saved'); BZToast.error('Connection lost'); BZToast.message('Hi', {title:'Ravi'}); */
|
||||||
|
window.BZToast=(function(){
|
||||||
|
var wrap=null;
|
||||||
|
var ICON={
|
||||||
|
message:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||||
|
info:'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="2.2"/><path d="M12 11v5M12 8h.01" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>',
|
||||||
|
success:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||||
|
error:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
|
||||||
|
};
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||||
|
function ensure(){ if(wrap) return wrap; wrap=document.createElement('div'); wrap.className='bzt-wrap'; document.body.appendChild(wrap); return wrap; }
|
||||||
|
function show(message,opts){
|
||||||
|
opts=opts||{}; var type=opts.type||'message'; var w=ensure();
|
||||||
|
var t=document.createElement('div'); t.className='bzt '+type;
|
||||||
|
t.innerHTML='<div class="bzt-badge">'+(ICON[type]||ICON.message)+'</div><div class="bzt-body">'
|
||||||
|
+(opts.title?'<div class="bzt-title">'+esc(opts.title)+'</div>':'')
|
||||||
|
+'<div class="bzt-msg">'+esc(message)+'</div></div><button class="bzt-x" aria-label="Dismiss">×</button>';
|
||||||
|
w.appendChild(t); requestAnimationFrame(function(){ t.classList.add('bzt-in'); });
|
||||||
|
var dur=(opts.duration==null?4000:opts.duration), timer;
|
||||||
|
function close(){ t.classList.remove('bzt-in'); setTimeout(function(){ if(t.parentNode) t.parentNode.removeChild(t); },320); clearTimeout(timer); }
|
||||||
|
t.querySelector('.bzt-x').onclick=close; if(dur>0) timer=setTimeout(close,dur); return close;
|
||||||
|
}
|
||||||
|
return { show:show,
|
||||||
|
message:function(m,o){o=o||{};o.type='message';return show(m,o);},
|
||||||
|
success:function(m,o){o=o||{};o.type='success';return show(m,o);},
|
||||||
|
error:function(m,o){o=o||{};o.type='error';return show(m,o);},
|
||||||
|
info:function(m,o){o=o||{};o.type='info';return show(m,o);} };
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, on every page that shows notifications (connect, share, home, dashboard, console),
|
||||||
|
include both files near the top BEFORE the page's own inline <script>:
|
||||||
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
|
(Load the JS via src only — never inline.)
|
||||||
|
|
||||||
|
Now REPLACE the existing toast / notification pop-ups with the branded API (keep the same
|
||||||
|
triggers and text, just route them through BZToast so they look on-brand):
|
||||||
|
- Incoming chat message -> BZToast.message(text, {title: senderName})
|
||||||
|
- A success (e.g. recording/transcript saved, agent added) -> BZToast.success('…')
|
||||||
|
- An error (connection lost, upload failed, invalid code) -> BZToast.error('…')
|
||||||
|
- Neutral info -> BZToast.info('…')
|
||||||
|
Remove the old inline toast markup/styles it replaces. Toasts appear top-right and auto-dismiss.
|
||||||
|
|
||||||
|
## 5. Verify (do not commit until I review)
|
||||||
|
- node --check any JS you extract/touch; confirm every page still loads.
|
||||||
|
- Confirm: taskbar/favicon icon is crisp, splash shows on launch, a loader appears centered
|
||||||
|
during connect/report, and notifications now use the branded toast.
|
||||||
|
- List exactly which files changed, then STOP for my review.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Biz Connect — master assets for VS Claude
|
||||||
|
|
||||||
|
Three masters, exactly as requested. Regenerate/wire everything from these.
|
||||||
|
|
||||||
|
## 1. App icon → app-icon-1024.png
|
||||||
|
- 1024×1024, full-bleed solid brand-blue square (no baked-in rounded corners), C mark
|
||||||
|
centred with safe padding. Use as the single source to regenerate:
|
||||||
|
Windows `.ico` (multi-size — fixes the tiny/blurry taskbar icon), PWA icons
|
||||||
|
(192, 512, maskable), apple-touch-icon, favicon, and the Android/iOS mobile icons.
|
||||||
|
|
||||||
|
## 2. Splash → splash-2732-dark.png (primary) · splash-2732-light.png (optional)
|
||||||
|
- 2732×2732. Dark = brand blue (use this everywhere by default). Light = white background
|
||||||
|
variant if you support a light theme. Use for the desktop/web launch and the mobile
|
||||||
|
native splash. (Native launch screens should stay static — no animation.)
|
||||||
|
|
||||||
|
## 3. Loader → animated SVG (pick per context)
|
||||||
|
- loader-ring.svg default everyday spinner, for LIGHT backgrounds (blue track + yellow arc)
|
||||||
|
- loader-orbit.svg branded spinner (the C with a circling dot), LIGHT backgrounds
|
||||||
|
- loader-orbit-dark.svg same branded spinner for DARK/overlay backgrounds (white C)
|
||||||
|
Recommended: use loader-ring.svg as the standard "Loading…" and in-app spinner; use
|
||||||
|
loader-orbit(-dark).svg for the hero "Connecting…" moment. SVG is animated, tiny, scalable.
|
||||||
|
|
||||||
|
## Brand
|
||||||
|
Blue #1F3B73 · Blue-dark #16294F · Yellow #FFC708 · (deeper gold #E0AC00 for "Connect" on white)
|
||||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="64" height="64" role="img" aria-label="Loading">
|
||||||
|
<circle cx="50" cy="50" r="34" fill="none" stroke="#ffffff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/>
|
||||||
|
<g><circle cx="84" cy="50" r="7" fill="#FFC708"/>
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.1s" repeatCount="indefinite"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="64" height="64" role="img" aria-label="Loading">
|
||||||
|
<circle cx="50" cy="50" r="34" fill="none" stroke="#1F3B73" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/>
|
||||||
|
<g><circle cx="84" cy="50" r="7" fill="#FFC708"/>
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.1s" repeatCount="indefinite"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50" height="50" role="img" aria-label="Loading">
|
||||||
|
<circle cx="25" cy="25" r="20" fill="none" stroke="#1F3B73" stroke-opacity="0.16" stroke-width="5"/>
|
||||||
|
<path fill="none" stroke="#FFC708" stroke-width="5" stroke-linecap="round" d="M25 5 A20 20 0 0 1 45 25">
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.8s" repeatCount="indefinite"/>
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,130 @@
|
|||||||
|
# Codemagic CI/CD — builds the Biz Connect iOS app (Capacitor shell over the live web UI) and uploads it
|
||||||
|
# to TestFlight / App Store Connect. No Mac needed: this runs on Codemagic's macOS cloud instances.
|
||||||
|
#
|
||||||
|
# The app is a thin Capacitor wrapper that loads https://remote.bizgaze.com, so there's no bundled web
|
||||||
|
# code to build here — we generate the iOS project, patch its privacy strings, sign, archive and upload.
|
||||||
|
#
|
||||||
|
# ── One-time setup (see mobile/IOS_SETUP.md for the click-by-click) ────────────────────────────────
|
||||||
|
# 1. App Store Connect: create the app with bundle id com.bizgaze.connect
|
||||||
|
# 2. Codemagic → Teams/Integrations → App Store Connect: add your ASC API key (issuer id, key id, .p8).
|
||||||
|
# Name the integration exactly: BizGaze App Store Connect
|
||||||
|
# 3. That's it — automatic code signing fetches/creates the distribution cert + profile from that key.
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
ios-testflight:
|
||||||
|
name: Biz Connect iOS → TestFlight
|
||||||
|
max_build_duration: 60
|
||||||
|
instance_type: mac_mini_m2
|
||||||
|
integrations:
|
||||||
|
app_store_connect: BizGaze App Store Connect # ← must match the integration name you create
|
||||||
|
environment:
|
||||||
|
# NOTE: we deliberately do NOT use an `ios_signing:` block here. That block makes Codemagic
|
||||||
|
# try to *fetch an existing* provisioning profile at build startup — it never creates one — so on
|
||||||
|
# a brand-new app it fails init with "No matching profiles found …". Instead the "Set up code
|
||||||
|
# signing" script below runs `fetch-signing-files … --create`, which creates the distribution
|
||||||
|
# certificate + profile on first run, then `xcode-project use-profiles` wires them into the project.
|
||||||
|
groups:
|
||||||
|
- ios_signing # ← Codemagic variable group holding CERTIFICATE_PRIVATE_KEY (secure). See below.
|
||||||
|
vars:
|
||||||
|
BUNDLE_ID: "com.bizgaze.connect"
|
||||||
|
XCODE_WORKSPACE: "mobile/ios/App/App.xcworkspace"
|
||||||
|
XCODE_SCHEME: "App"
|
||||||
|
node: 20
|
||||||
|
xcode: latest
|
||||||
|
cocoapods: default
|
||||||
|
scripts:
|
||||||
|
- name: Install JS dependencies
|
||||||
|
script: |
|
||||||
|
cd mobile
|
||||||
|
# npm install (not ci): the dependency set changed to Capacitor 7 + the safe-area/keyboard
|
||||||
|
# plugins, so we let npm resolve a fresh tree rather than require a pre-synced lockfile.
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Generate the iOS project (Capacitor)
|
||||||
|
script: |
|
||||||
|
cd mobile
|
||||||
|
# `cap add ios` scaffolds ios/App; safe to re-run — it no-ops if it already exists.
|
||||||
|
if [ ! -d "ios" ]; then npx cap add ios; fi
|
||||||
|
npx cap sync ios
|
||||||
|
# App icon + splash from resources/icon.png & resources/splash*.png (1024x1024 icon, 2732² splash).
|
||||||
|
npx capacitor-assets generate --ios || echo "asset generation skipped"
|
||||||
|
|
||||||
|
- name: Patch Info.plist (App-Review privacy strings) + bundle id
|
||||||
|
script: |
|
||||||
|
bash mobile/scripts/ios-patch.sh
|
||||||
|
|
||||||
|
- name: Add the Share Extension target
|
||||||
|
script: |
|
||||||
|
# Inject the second target (Biz Connect in the iOS share sheet) into the freshly-generated
|
||||||
|
# Xcode project. Uses the `xcodeproj` gem that ships with CocoaPods, so no extra install.
|
||||||
|
# Runs BEFORE pod install: the extension uses no pods, and this way the workspace that pods
|
||||||
|
# generates already contains the new target.
|
||||||
|
ruby mobile/scripts/add-share-extension.rb
|
||||||
|
|
||||||
|
- name: Set up code signing
|
||||||
|
script: |
|
||||||
|
# Create the distribution certificate + provisioning profile from the ASC API key and add the
|
||||||
|
# cert to the keychain. NOTE: `xcode-project use-profiles` is intentionally NOT here — it must
|
||||||
|
# run AFTER `pod install` generates the workspace, otherwise it fails to wire the profile into
|
||||||
|
# the App target and the archive dies with "App requires a provisioning profile".
|
||||||
|
#
|
||||||
|
# --certificate-key is REQUIRED for reusable signing: without it, --create makes a throwaway
|
||||||
|
# distribution cert whose private key dies with the build machine, so the next build finds a
|
||||||
|
# cert it has no key for ("Cannot save Signing Certificates without certificate private key").
|
||||||
|
# By passing our own fixed private key (CERTIFICATE_PRIVATE_KEY, a secure var in the
|
||||||
|
# `ios_signing` group), the cert is created once from that key and reused by every build.
|
||||||
|
#
|
||||||
|
# TWO bundle ids now need signing: the app AND the share extension (<app>.share). Each gets its
|
||||||
|
# own App Store profile. The App Group capability (group.com.bizgaze.connect) must be enabled on
|
||||||
|
# BOTH App IDs in the Apple Developer portal — see mobile/IOS_SETUP.md. fetch-signing-files
|
||||||
|
# registers a missing bundle id and creates its profile, but does NOT toggle the App Group
|
||||||
|
# capability, so that stays a one-time manual step.
|
||||||
|
keychain initialize
|
||||||
|
app-store-connect fetch-signing-files "$BUNDLE_ID" \
|
||||||
|
--type IOS_APP_STORE \
|
||||||
|
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
|
||||||
|
--create
|
||||||
|
app-store-connect fetch-signing-files "${BUNDLE_ID}.share" \
|
||||||
|
--type IOS_APP_STORE \
|
||||||
|
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
|
||||||
|
--create
|
||||||
|
keychain add-certificates
|
||||||
|
|
||||||
|
- name: Install CocoaPods
|
||||||
|
script: |
|
||||||
|
cd mobile/ios/App
|
||||||
|
pod install
|
||||||
|
|
||||||
|
- name: Build the signed IPA
|
||||||
|
script: |
|
||||||
|
# Apply the fetched provisioning profile(s) to the Xcode project NOW that the workspace exists,
|
||||||
|
# then archive. use-profiles scans for **/*.xcodeproj under the repo root and sets manual
|
||||||
|
# signing (team + profile specifier) on the matching App target.
|
||||||
|
xcode-project use-profiles
|
||||||
|
# `xcode-project build-ipa` prints a PRETTIFIED summary and swallows the raw xcodebuild "error:"
|
||||||
|
# lines — a failed archive shows only "Failed to archive" with no reason. On failure, surface the
|
||||||
|
# actual errors from the raw log so we don't have to dig through the artifact.
|
||||||
|
if ! xcode-project build-ipa --workspace "$XCODE_WORKSPACE" --scheme "$XCODE_SCHEME"; then
|
||||||
|
echo "======================= xcodebuild errors ======================="
|
||||||
|
grep -h -E "error:|errSec|Provisioning profile|entitlement|Code ?Sign|does not (support|contain)|requires a provisioning|No profile|No signing|doesn't (include|match)|Command .* failed" /tmp/xcodebuild_logs/*.log 2>/dev/null | grep -vi "warning:" | tail -60 || echo "(no matching lines — open the xcodebuild_logs artifact)"
|
||||||
|
echo "================================================================="
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
artifacts:
|
||||||
|
- build/ios/ipa/*.ipa
|
||||||
|
- /tmp/xcodebuild_logs/*.log
|
||||||
|
publishing:
|
||||||
|
app_store_connect:
|
||||||
|
auth: integration
|
||||||
|
# Upload the build to App Store Connect. It is immediately usable for INTERNAL TestFlight testing
|
||||||
|
# (no Apple review). We keep external-beta submission OFF for now: submit_to_testflight=true would
|
||||||
|
# push the build to EXTERNAL beta review, which requires the Test Information (feedback email +
|
||||||
|
# beta review contact + a demo login, since our app needs sign-in) to be filled in first, and
|
||||||
|
# fails the build until then. Flip to true (and add `beta_groups:` + fill Test Information at
|
||||||
|
# App Store Connect → TestFlight → Test Information) when you want outside testers.
|
||||||
|
submit_to_testflight: false
|
||||||
|
# Flip this to true (and add a `submit_to_app_store` group with reviewer notes) once you're ready
|
||||||
|
# to push a build to public App Store review instead of only TestFlight.
|
||||||
|
# submit_to_app_store: false
|
||||||
|
# No email recipients here on purpose — build status is watched on the Codemagic dashboard. Add
|
||||||
|
# per-user notifications in the Codemagic UI (or a `publishing.email` block) later if you want them.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Self-hosted TURN (coturn) for BizGaze Connect
|
||||||
|
|
||||||
|
Why: customers behind symmetric NAT / corporate firewalls / VPNs can't form a direct
|
||||||
|
WebRTC path, so screen share blanks out and disconnects. A TURN relay fixes it. We host
|
||||||
|
our own coturn on a VM we already own — flat cost, no per-GB billing.
|
||||||
|
|
||||||
|
## 1. VM prerequisites
|
||||||
|
- A VM with a **public IP** (your data-center VM is fine).
|
||||||
|
- A DNS A record, e.g. `turn.yourdomain.com` -> that public IP.
|
||||||
|
- A TLS cert for that name (Let's Encrypt): `certbot certonly --standalone -d turn.yourdomain.com`
|
||||||
|
|
||||||
|
## 2. Open firewall ports (on the VM and any edge firewall)
|
||||||
|
- `3478/udp` and `3478/tcp` (STUN/TURN)
|
||||||
|
- `5349/tcp` (TURN over TLS) — and `443/tcp` if you enable alt-tls
|
||||||
|
- `49152-65535/udp` (relay range)
|
||||||
|
|
||||||
|
## 3. Configure
|
||||||
|
Edit `turnserver.conf`:
|
||||||
|
- `external-ip=` your VM's public IP
|
||||||
|
- `static-auth-secret=` a long random string (e.g. `openssl rand -hex 32`)
|
||||||
|
- `realm=` your domain
|
||||||
|
- `cert=` / `pkey=` paths to your Let's Encrypt cert
|
||||||
|
|
||||||
|
## 4. Run
|
||||||
|
```
|
||||||
|
docker compose up -d # uses docker-compose.yml here
|
||||||
|
# or native: apt install coturn; copy this file to /etc/turnserver.conf; enable in /etc/default/coturn; systemctl enable --now coturn
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Point the app at it (production env)
|
||||||
|
```
|
||||||
|
TURN_URLS=turn:turn.yourdomain.com:3478,turn:turn.yourdomain.com:3478?transport=tcp,turns:turn.yourdomain.com:5349?transport=tcp
|
||||||
|
TURN_SECRET=<the same static-auth-secret from turnserver.conf>
|
||||||
|
TURN_TTL=86400
|
||||||
|
```
|
||||||
|
The app's `/api/ice` mints short-lived credentials from `TURN_SECRET` automatically — no
|
||||||
|
permanent password is exposed, and outsiders can't reuse your relay. Restart the app.
|
||||||
|
|
||||||
|
## 6. Verify
|
||||||
|
- `GET https://<app>/api/ice` should return a `turn:`/`turns:` entry with a username + credential.
|
||||||
|
- Test page: https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
|
||||||
|
Add your `turns:turn.yourdomain.com:5349?transport=tcp` with the username/credential from
|
||||||
|
`/api/ice`; you should see a candidate of type **relay**. If you do, restrictive networks
|
||||||
|
are covered.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Run coturn on your VM: docker compose up -d
|
||||||
|
# host networking is required so the UDP relay port range works without per-port mapping.
|
||||||
|
services:
|
||||||
|
coturn:
|
||||||
|
image: coturn/coturn:latest
|
||||||
|
container_name: coturn
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro # TLS cert for turns:
|
||||||
|
command: ["-c", "/etc/coturn/turnserver.conf"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# coturn config for BizGaze Connect self-hosted TURN.
|
||||||
|
# Put this on your VM (public IP) and run via Docker (see docker-compose.yml) or
|
||||||
|
# native coturn (apt install coturn). Replace every CHANGE_ME / placeholder.
|
||||||
|
|
||||||
|
# --- listening ---
|
||||||
|
listening-port=3478
|
||||||
|
tls-listening-port=5349
|
||||||
|
# If this VM has a spare 443, also exposing TURNS on 443 gives the best traversal
|
||||||
|
# through strict corporate firewalls (uncomment + ensure nothing else uses 443):
|
||||||
|
# alt-tls-listening-port=443
|
||||||
|
|
||||||
|
# Public address clients reach. If the VM has a 1:1 NAT, use external-ip=PUBLIC/PRIVATE.
|
||||||
|
external-ip=CHANGE_ME_PUBLIC_IP
|
||||||
|
|
||||||
|
# Relay port range (open these UDP ports in the firewall too).
|
||||||
|
min-port=49152
|
||||||
|
max-port=65535
|
||||||
|
|
||||||
|
# --- auth: time-limited shared-secret credentials (matches the app's TURN_SECRET) ---
|
||||||
|
use-auth-secret
|
||||||
|
static-auth-secret=CHANGE_ME_LONG_RANDOM_SECRET
|
||||||
|
realm=connect.yourdomain.com
|
||||||
|
|
||||||
|
# --- TLS (needed for turns: on 5349/443). Use a real cert for turn.yourdomain.com ---
|
||||||
|
cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem
|
||||||
|
pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem
|
||||||
|
|
||||||
|
# --- hardening ---
|
||||||
|
fingerprint
|
||||||
|
no-cli
|
||||||
|
no-multicast-peers
|
||||||
|
no-tcp-relay
|
||||||
|
# Block relaying to private/internal ranges (prevents your relay being used to reach
|
||||||
|
# your own LAN / cloud metadata — important SSRF protection):
|
||||||
|
denied-peer-ip=0.0.0.0-0.255.255.255
|
||||||
|
denied-peer-ip=10.0.0.0-10.255.255.255
|
||||||
|
denied-peer-ip=100.64.0.0-100.127.255.255
|
||||||
|
denied-peer-ip=169.254.0.0-169.254.255.255
|
||||||
|
denied-peer-ip=172.16.0.0-172.31.255.255
|
||||||
|
denied-peer-ip=192.168.0.0-192.168.255.255
|
||||||
|
denied-peer-ip=::1
|
||||||
|
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||||
|
# Optional: cap per-session bandwidth (bytes/sec) to protect the VM, e.g. 700000 = ~5.6 Mbps
|
||||||
|
# bps-capacity=0
|
||||||
|
# total-quota=100
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Biz Connect Desktop — packaging & updates
|
||||||
|
|
||||||
|
## How updates reach installed apps (two kinds)
|
||||||
|
|
||||||
|
1. **Web / UI / feature / bug-fix changes → instant, no app update.**
|
||||||
|
The app loads the live UI from `https://remote.bizgaze.com`. Deploy the server
|
||||||
|
(`./deploy.sh`) and every installed desktop app has it on next open/reload. This is ~95%
|
||||||
|
of all changes.
|
||||||
|
2. **Native shell changes (`main.js` / `preload.js`) → auto-update.**
|
||||||
|
Baked into the `.exe`. Shipped via **electron-updater** against a **self-hosted feed** on
|
||||||
|
`https://remote.bizgaze.com/downloads/`. On launch (and every 6h) the app checks
|
||||||
|
`latest.yml`, downloads a newer version in the background, and installs on next restart.
|
||||||
|
|
||||||
|
## Build an installer
|
||||||
|
```bash
|
||||||
|
cd desktop
|
||||||
|
npm install
|
||||||
|
npm run dist # electron-builder → dist/ (Win: NSIS .exe + latest.yml + .blockmap)
|
||||||
|
```
|
||||||
|
Output in `desktop/dist/`:
|
||||||
|
- `Biz Connect Setup <version>.exe` — the installer
|
||||||
|
- `latest.yml` — the update manifest electron-updater reads
|
||||||
|
- `*.blockmap` — enables delta downloads
|
||||||
|
|
||||||
|
A PACKAGED build points at production (`main.js` defaults `SERVER_URL` to
|
||||||
|
`https://remote.bizgaze.com` when `app.isPackaged`); running from source in dev defaults to
|
||||||
|
`http://localhost:8090`. `SERVER_URL` overrides either.
|
||||||
|
|
||||||
|
## Publish a release (self-hosted feed)
|
||||||
|
1. Bump `version` in `desktop/package.json` (semver — electron-updater compares this).
|
||||||
|
2. `npm run dist`.
|
||||||
|
3. Upload **all** of `dist/` (the `.exe`, `latest.yml`, `.blockmap`) to whatever the server
|
||||||
|
serves at `https://remote.bizgaze.com/downloads/`.
|
||||||
|
- Behind Nginx Proxy Manager: point `/downloads/` at a static folder, or add a static
|
||||||
|
route in the app. The files are large binaries — host them on disk/volume, **not** git.
|
||||||
|
4. Installed apps pick it up within 6h (or on next launch).
|
||||||
|
|
||||||
|
> First release: users install the `.exe` manually (download link on your site). Every
|
||||||
|
> release after that updates automatically.
|
||||||
|
|
||||||
|
## Code signing (add when the cert is ready)
|
||||||
|
Unsigned installers work but trip Windows SmartScreen ("More info → Run anyway"). To sign:
|
||||||
|
- **Azure Trusted Signing** (recommended): set `win.azureSignOptions` (or use the
|
||||||
|
`@electron/windows-sign` path) with the Trusted Signing account/endpoint. Cloud, no token.
|
||||||
|
- **EV/OV cert (.pfx or token)**: set env `CSC_LINK` (path to .pfx) + `CSC_KEY_PASSWORD`,
|
||||||
|
or configure a hardware-token signing tool. electron-builder signs automatically.
|
||||||
|
Once signing is on, auto-updates are silent (no SmartScreen).
|
||||||
|
|
||||||
|
## App identity
|
||||||
|
`appId` = `com.bizgaze.connect.desktop`; the NSIS installer registers this as the
|
||||||
|
AppUserModelID and creates a Start-menu shortcut — which is also the prerequisite for the
|
||||||
|
**Phase D inline-reply Windows Toast notifications** (see ../CLIENTS.md).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Biz Connect — Desktop client
|
||||||
|
|
||||||
|
Electron shell that loads the live Connect web UI and adds native screen capture. See the
|
||||||
|
overall plan in [../CLIENTS.md](../CLIENTS.md).
|
||||||
|
|
||||||
|
## Run (dev = local testing)
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start # dev auto-targets http://localhost:8090 (your local server)
|
||||||
|
SERVER_URL=https://remote.bizgaze.com npm start # …or point dev at production to compare
|
||||||
|
```
|
||||||
|
`npm start` IS the local desktop test — no separate "local" installer needed. In dev (unpackaged)
|
||||||
|
the shell defaults to the local server; a PACKAGED installer defaults to production. `SERVER_URL`
|
||||||
|
overrides either. (Bash/Git-Bash syntax above; in PowerShell: `$env:SERVER_URL='…'; npm start`.)
|
||||||
|
|
||||||
|
## Build installers
|
||||||
|
```bash
|
||||||
|
npm run dist # electron-builder → Win NSIS / Mac dmg / Linux AppImage
|
||||||
|
```
|
||||||
|
Signed, trusted installers need certificates:
|
||||||
|
- **Windows:** an EV (or OV) code-signing certificate.
|
||||||
|
- **macOS:** Apple Developer ID cert + notarization (`CSC_LINK`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The window loads `${SERVER_URL}/home`; relative `/api` and `/ws` URLs work because the
|
||||||
|
origin is the server itself — no web-code changes.
|
||||||
|
- `setDisplayMediaRequestHandler` in `main.js` is what makes "Share Screen" work in Electron;
|
||||||
|
it currently defaults to the primary display. Swap in a source-picker for production.
|
||||||
|
- The session is persisted (`persist:bizconnect`) so login survives restarts.
|
||||||
|
- `window.__NATIVE__ === 'desktop'` is exposed for the web UI to feature-detect.
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,144 @@
|
|||||||
|
// OS input injection layer.
|
||||||
|
//
|
||||||
|
// Cross-platform mouse/keyboard control via @nut-tree-fork/nut-js (optional
|
||||||
|
// native dependency). If nut-js isn't installed (e.g. CI, or a sandbox without
|
||||||
|
// a display), this module degrades to a logging no-op so the rest of the agent
|
||||||
|
// still runs and can be tested. On Windows, nut-js drives the Win32 SendInput
|
||||||
|
// API under the hood — the same mechanism TeamViewer/AnyDesk use.
|
||||||
|
|
||||||
|
let nut = null;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
nut = require('@nut-tree-fork/nut-js');
|
||||||
|
nut.mouse.config.autoDelayMs = 0;
|
||||||
|
nut.keyboard.config.autoDelayMs = 0;
|
||||||
|
} catch {
|
||||||
|
nut = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = !!nut;
|
||||||
|
|
||||||
|
// Map the PHYSICAL key (KeyboardEvent.code) to a nut-js Key. This is the correct way to drive a remote
|
||||||
|
// keyboard: press the same physical key the viewer pressed and let the remote OS apply its own modifier
|
||||||
|
// state. Mapping by CHARACTER (mapKey below) broke shifted keys — e.g. Shift+1 typed "1" instead of "!"
|
||||||
|
// and symbols came out wrong ("keyboard performs differently on the sharer's device").
|
||||||
|
const CODE_MAP = {
|
||||||
|
Backspace: 'Backspace', Tab: 'Tab', Enter: 'Enter', NumpadEnter: 'Enter', Escape: 'Escape', Space: 'Space',
|
||||||
|
ShiftLeft: 'LeftShift', ShiftRight: 'RightShift',
|
||||||
|
ControlLeft: 'LeftControl', ControlRight: 'RightControl',
|
||||||
|
AltLeft: 'LeftAlt', AltRight: 'RightAlt',
|
||||||
|
MetaLeft: 'LeftSuper', MetaRight: 'RightSuper',
|
||||||
|
CapsLock: 'CapsLock',
|
||||||
|
PageUp: 'PageUp', PageDown: 'PageDown', End: 'End', Home: 'Home',
|
||||||
|
ArrowLeft: 'Left', ArrowUp: 'Up', ArrowRight: 'Right', ArrowDown: 'Down',
|
||||||
|
Insert: 'Insert', Delete: 'Delete',
|
||||||
|
Minus: 'Minus', Equal: 'Equal', BracketLeft: 'LeftBracket', BracketRight: 'RightBracket',
|
||||||
|
Backslash: 'Backslash', Semicolon: 'Semicolon', Quote: 'Quote', Backquote: 'Grave',
|
||||||
|
Comma: 'Comma', Period: 'Period', Slash: 'Slash',
|
||||||
|
NumpadAdd: 'Add', NumpadSubtract: 'Subtract', NumpadMultiply: 'Multiply', NumpadDivide: 'Divide', NumpadDecimal: 'Decimal',
|
||||||
|
};
|
||||||
|
function mapCode(code) {
|
||||||
|
if (!nut || !code) return null;
|
||||||
|
const K = nut.Key;
|
||||||
|
const named = CODE_MAP[code];
|
||||||
|
if (named && K[named] !== undefined) return [K[named]];
|
||||||
|
let m;
|
||||||
|
if ((m = /^Key([A-Z])$/.exec(code)) && K[m[1]] !== undefined) return [K[m[1]]];
|
||||||
|
if ((m = /^Digit([0-9])$/.exec(code)) && K['Num' + m[1]] !== undefined) return [K['Num' + m[1]]];
|
||||||
|
if ((m = /^Numpad([0-9])$/.exec(code)) && K['NumPad' + m[1]] !== undefined) return [K['NumPad' + m[1]]];
|
||||||
|
if ((m = /^(F\d{1,2})$/.exec(code)) && K[m[1]] !== undefined) return [K[m[1]]];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map browser KeyboardEvent.key values to nut-js Key enum names. (Fallback when there's no usable code.)
|
||||||
|
function mapKey(key, code) {
|
||||||
|
if (!nut) return null;
|
||||||
|
const K = nut.Key;
|
||||||
|
const direct = {
|
||||||
|
'Enter': K.Enter, 'Backspace': K.Backspace, 'Tab': K.Tab, 'Escape': K.Escape,
|
||||||
|
' ': K.Space, 'ArrowLeft': K.Left, 'ArrowRight': K.Right, 'ArrowUp': K.Up, 'ArrowDown': K.Down,
|
||||||
|
'Home': K.Home, 'End': K.End, 'PageUp': K.PageUp, 'PageDown': K.PageDown, 'Delete': K.Delete,
|
||||||
|
'Control': K.LeftControl, 'Shift': K.LeftShift, 'Alt': K.LeftAlt, 'Meta': K.LeftSuper,
|
||||||
|
'CapsLock': K.CapsLock,
|
||||||
|
};
|
||||||
|
if (direct[key] !== undefined) return [direct[key]];
|
||||||
|
if (/^F\d{1,2}$/.test(key) && K[key] !== undefined) return [K[key]];
|
||||||
|
if (key && key.length === 1) {
|
||||||
|
const upper = key.toUpperCase();
|
||||||
|
if (/[A-Z]/.test(upper) && K[upper] !== undefined) return [K[upper]];
|
||||||
|
if (/[0-9]/.test(key) && K['Num' + key] !== undefined) return [K['Num' + key]];
|
||||||
|
// Fall back to typing the literal character (handles symbols/shifted chars)
|
||||||
|
return { type: key };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the screen size (this nut-js exposes screen.width()/height(), not getResolution()).
|
||||||
|
// Recomputed once per session (cleared in releaseAll) so a resolution change is picked up.
|
||||||
|
let _screen = null;
|
||||||
|
async function screenSize() {
|
||||||
|
if (!_screen) _screen = { w: await nut.screen.width(), h: await nut.screen.height() };
|
||||||
|
return _screen;
|
||||||
|
}
|
||||||
|
async function moveTo(xNorm, yNorm) {
|
||||||
|
if (!nut) return;
|
||||||
|
const { w, h } = await screenSize();
|
||||||
|
await nut.mouse.setPosition(new nut.Point(Math.round(xNorm * w), Math.round(yNorm * h)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonEnum(b) {
|
||||||
|
if (!nut) return null;
|
||||||
|
return b === 2 ? nut.Button.RIGHT : b === 1 ? nut.Button.MIDDLE : nut.Button.LEFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pressed = new Set();
|
||||||
|
|
||||||
|
// Inject a single normalized input event coming from the viewer.
|
||||||
|
async function inject(evt) {
|
||||||
|
if (!nut) {
|
||||||
|
if (evt.kind !== 'mousemove') console.log('[input:noop]', JSON.stringify(evt));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
switch (evt.kind) {
|
||||||
|
case 'mousemove':
|
||||||
|
await moveTo(evt.x, evt.y); break;
|
||||||
|
case 'mousedown':
|
||||||
|
await moveTo(evt.x, evt.y); await nut.mouse.pressButton(buttonEnum(evt.button)); break;
|
||||||
|
case 'mouseup':
|
||||||
|
await nut.mouse.releaseButton(buttonEnum(evt.button)); break;
|
||||||
|
case 'dblclick':
|
||||||
|
await moveTo(evt.x, evt.y); await nut.mouse.doubleClick(nut.Button.LEFT); break;
|
||||||
|
case 'scroll':
|
||||||
|
if (evt.dy) await (evt.dy > 0 ? nut.mouse.scrollDown(Math.abs(evt.dy)) : nut.mouse.scrollUp(Math.abs(evt.dy)));
|
||||||
|
if (evt.dx) await (evt.dx > 0 ? nut.mouse.scrollRight(Math.abs(evt.dx)) : nut.mouse.scrollLeft(Math.abs(evt.dx)));
|
||||||
|
break;
|
||||||
|
case 'keydown': {
|
||||||
|
// Prefer the PHYSICAL key so the remote OS applies its own shift/altgr state (correct symbols).
|
||||||
|
const m = mapCode(evt.code) || mapKey(evt.key, evt.code);
|
||||||
|
if (!m) break;
|
||||||
|
if (m.type) { await nut.keyboard.type(m.type); break; } // last-resort: type the literal character
|
||||||
|
await nut.keyboard.pressKey(...m); m.forEach((k) => pressed.add(k));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'keyup': {
|
||||||
|
const m = mapCode(evt.code) || mapKey(evt.key, evt.code);
|
||||||
|
if (!m || m.type) break;
|
||||||
|
await nut.keyboard.releaseKey(...m); m.forEach((k) => pressed.delete(k));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[input] inject error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: release any stuck modifier keys when a session ends.
|
||||||
|
async function releaseAll() {
|
||||||
|
if (!nut) { pressed.clear(); return; }
|
||||||
|
for (const k of pressed) { try { await nut.keyboard.releaseKey(k); } catch {} }
|
||||||
|
pressed.clear();
|
||||||
|
_screen = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { inject, releaseAll, available, mapKey };
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
// Biz Connect — technician desktop client (Electron main process).
|
||||||
|
//
|
||||||
|
// This is a thin shell: it loads the live Connect web UI from the server origin, so every
|
||||||
|
// relative /api and /ws URL in the web app keeps working unchanged. What it adds over a
|
||||||
|
// browser tab:
|
||||||
|
// - native full-screen capture for "Share Screen" (setDisplayMediaRequestHandler)
|
||||||
|
// - a real desktop window (no browser chrome), persisted login session
|
||||||
|
// - external links open in the user's browser, not inside the app
|
||||||
|
//
|
||||||
|
// Server origin is configurable so the same build works against prod or a dev server.
|
||||||
|
const { app, BrowserWindow, session, desktopCapturer, shell, Menu, MenuItem, Tray, ipcMain, nativeImage, Notification } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// A stable per-install id (persisted in userData), so the server can count installs and
|
||||||
|
// associate them with the user who signs in. Created once, then reused across launches.
|
||||||
|
function getInstallId() {
|
||||||
|
try {
|
||||||
|
const p = path.join(app.getPath('userData'), 'install-id');
|
||||||
|
if (fs.existsSync(p)) { const v = fs.readFileSync(p, 'utf8').trim(); if (v) return v; }
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
fs.writeFileSync(p, id);
|
||||||
|
return id;
|
||||||
|
} catch (_) { return 'unknown'; }
|
||||||
|
}
|
||||||
|
// The renderer (web app) reads this synchronously to report telemetry after login.
|
||||||
|
ipcMain.on('get-install-info', (e) => {
|
||||||
|
e.returnValue = { installId: getInstallId(), appVersion: app.getVersion(), os: process.platform + ' ' + os.release() };
|
||||||
|
});
|
||||||
|
// Auto-update: only NATIVE shell changes (this .exe) need this — all web/UI changes arrive
|
||||||
|
// live from the server. Checks the self-hosted feed (publish config in package.json →
|
||||||
|
// https://remote.bizgaze.com/downloads/latest.yml), downloads in the background, and installs
|
||||||
|
// on the next restart. No-op in dev (unpackaged).
|
||||||
|
let autoUpdater = null;
|
||||||
|
try { ({ autoUpdater } = require('electron-updater')); } catch (_) { /* not installed in dev */ }
|
||||||
|
let _lastUpdateCheck = 0;
|
||||||
|
// Background update check — called on launch, on a timer, AND whenever the window is revealed from the tray
|
||||||
|
// (so re-opening the app actually looks for a new version instead of waiting up to 6h). Throttled so rapid
|
||||||
|
// tray open/close doesn't spam the feed. Combined with autoInstallOnAppQuit=true (set in whenReady), a
|
||||||
|
// downloaded update installs the next time the app truly quits — e.g. a normal PC restart — so close-to-tray
|
||||||
|
// users get updated even if they never pick "Quit" from the tray icon.
|
||||||
|
function bgUpdateCheck() {
|
||||||
|
if (!app.isPackaged || !autoUpdater) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - _lastUpdateCheck < 10 * 60 * 1000) return; // at most once / 10 min
|
||||||
|
_lastUpdateCheck = now;
|
||||||
|
autoUpdater.checkForUpdates().catch(() => {});
|
||||||
|
}
|
||||||
|
// #12: manual "Check for updates" from Settings. Returns the current status; the background updater
|
||||||
|
// (configured in app.whenReady) downloads and prompts to restart when a build is ready.
|
||||||
|
ipcMain.handle('check-updates', async () => {
|
||||||
|
const current = app.getVersion();
|
||||||
|
if (!app.isPackaged || !autoUpdater) return { status: 'dev', current };
|
||||||
|
try {
|
||||||
|
const r = await autoUpdater.checkForUpdates();
|
||||||
|
const v = r && r.updateInfo && r.updateInfo.version;
|
||||||
|
return (v && v !== current) ? { status: 'available', version: v, current } : { status: 'current', current };
|
||||||
|
} catch (e) { return { status: 'error', message: String((e && e.message) || e), current }; }
|
||||||
|
});
|
||||||
|
// #3: restart-and-install, triggered from the web update banner's "Restart" button.
|
||||||
|
ipcMain.handle('restart-to-update', () => { try { if (autoUpdater) autoUpdater.quitAndInstall(); } catch (_) {} });
|
||||||
|
// Chat toast: sender/group avatar + message; clicking it raises the app and opens that chat.
|
||||||
|
// Uses Electron's OWN Notification (native, no SnoreToast) whose 'click' event fires reliably.
|
||||||
|
const APP_ID = 'com.bizgaze.connect.desktop';
|
||||||
|
|
||||||
|
// Resolve the sender/group avatar to a local temp PNG for the toast icon. Accepts either a data:
|
||||||
|
// URL (legacy) or an http(s) DP URL, which we download (external photos can't be drawn to a canvas
|
||||||
|
// in the renderer without tainting it, so the renderer now passes the URL straight through).
|
||||||
|
function tmpPngPath() { return path.join(app.getPath('temp'), 'bizc-toast-' + crypto.randomBytes(4).toString('hex') + '.png'); }
|
||||||
|
// Cache downloaded DPs for the session (keyed by URL) so the SAME sender's photo is instant on the
|
||||||
|
// next notification — the first one may still show without a photo if the download is slow, but after
|
||||||
|
// that it's cached. Cached files are NOT deleted after use.
|
||||||
|
const avatarCache = new Map();
|
||||||
|
function avatarToTempPng(src) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
if (!src) return resolve(null);
|
||||||
|
const cached = avatarCache.get(src);
|
||||||
|
if (cached) { try { if (fs.existsSync(cached)) return resolve(cached); } catch (_) {} avatarCache.delete(src); }
|
||||||
|
if (/^data:image\/png;base64,/.test(src)) { const p = tmpPngPath(); fs.writeFileSync(p, Buffer.from(src.split(',')[1], 'base64')); avatarCache.set(src, p); return resolve(p); }
|
||||||
|
if (/^https?:\/\//i.test(src)) {
|
||||||
|
const mod = src.startsWith('https') ? require('https') : require('http');
|
||||||
|
const p = tmpPngPath(); const file = fs.createWriteStream(p);
|
||||||
|
const req = mod.get(src, (res) => {
|
||||||
|
if (res.statusCode !== 200) { res.resume(); file.close(() => { try { fs.unlinkSync(p); } catch (_) {} }); return resolve(null); }
|
||||||
|
res.pipe(file); file.on('finish', () => file.close(() => { avatarCache.set(src, p); resolve(p); }));
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve(null));
|
||||||
|
req.setTimeout(4000, () => { try { req.destroy(); } catch (_) {} resolve(null); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(null);
|
||||||
|
} catch (_) { resolve(null); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hard refresh -------------------------------------------------------------------------------
|
||||||
|
// The app closes to TRAY, so it can run for weeks on the page it first loaded and never see a new web
|
||||||
|
// deploy. This clears the shell's HTTP cache and reloads ignoring cache, so a stale UI is always
|
||||||
|
// recoverable. Reachable from: the in-app "Refresh" banner / Settings, Ctrl+R (reload),
|
||||||
|
// Ctrl+Shift+R or F5 (hard reload), and the tray menu.
|
||||||
|
async function hardReloadWin() {
|
||||||
|
try { await session.fromPartition('persist:bizconnect').clearCache(); } catch (_) {}
|
||||||
|
try { if (win && !win.isDestroyed()) win.webContents.reloadIgnoringCache(); } catch (_) {}
|
||||||
|
}
|
||||||
|
ipcMain.handle('hard-reload', async () => { await hardReloadWin(); return true; });
|
||||||
|
|
||||||
|
// ---- Remote control: OS input injection for a screen the local user is SHARING ----
|
||||||
|
// The renderer (share flow) forwards a viewer's mouse/keyboard events here for injection. Injection is
|
||||||
|
// HARD-GATED behind an explicit consent flag (rcArmed): nothing is injected until the local user clicks
|
||||||
|
// "Allow control", and it stops the instant they revoke or the session ends. nut-js is optional — if the
|
||||||
|
// native module isn't present it degrades to a no-op (no crash), so control simply won't take effect.
|
||||||
|
let injector = null;
|
||||||
|
try { injector = require('./input/inject'); } catch (_) { injector = null; }
|
||||||
|
let rcArmed = false;
|
||||||
|
// The renderer arms/disarms control (mirrors the on-screen consent banner). Disarming releases any
|
||||||
|
// stuck keys immediately.
|
||||||
|
ipcMain.on('rc-arm', (_e, on) => { rcArmed = !!on; if (!rcArmed && injector && injector.releaseAll) { try { injector.releaseAll(); } catch (_) {} } });
|
||||||
|
ipcMain.on('rc-input', (_e, evt) => { if (rcArmed && injector && injector.inject && evt) { try { injector.inject(evt); } catch (_) {} } });
|
||||||
|
// Whether OS injection is even possible on this machine (native module loaded). The renderer uses this
|
||||||
|
// to show "control needs the desktop app" vs an actual Allow prompt.
|
||||||
|
ipcMain.on('rc-available', (e) => { e.returnValue = !!(injector && injector.available); });
|
||||||
|
|
||||||
|
// Pre-warm the DP cache for the renderer's contacts (called after chats load), so the FIRST
|
||||||
|
// notification from anyone already has their photo — no per-toast download wait.
|
||||||
|
ipcMain.handle('precache-avatars', async (_e, urls = []) => {
|
||||||
|
try { for (const u of (Array.isArray(urls) ? urls : []).slice(0, 100)) { try { await avatarToTempPng(u); } catch (_) {} } } catch (_) {}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep STRONG references to live notifications. Electron/Windows garbage-collects a Notification
|
||||||
|
// with no reference, which closed the toast within ~1s and made clicks do nothing.
|
||||||
|
const activeNotifs = new Set();
|
||||||
|
|
||||||
|
// Resolves {open} when the toast is clicked (renderer then opens that chat), else null.
|
||||||
|
ipcMain.handle('reply-notification', async (_e, payload = {}) => {
|
||||||
|
if (!Notification.isSupported()) return null;
|
||||||
|
// Fire the toast IMMEDIATELY — NEVER block on a download (waiting made notifications lag; a late
|
||||||
|
// call/chat alert is worse than one without a photo). Use the DP only if it's ALREADY cached
|
||||||
|
// (instant). If not, kick off a background fetch so the SAME sender's NEXT notification has it.
|
||||||
|
// Contacts are also pre-warmed on load (precache-avatars), so the photo is usually already cached.
|
||||||
|
let img = null;
|
||||||
|
try {
|
||||||
|
const src = payload.avatar;
|
||||||
|
if (src && /^data:image\//i.test(src)) {
|
||||||
|
img = await avatarToTempPng(src); // generated icon (initials/group) — synchronous, always use immediately
|
||||||
|
} else if (src) {
|
||||||
|
const c = avatarCache.get(src);
|
||||||
|
if (c && fs.existsSync(c)) img = c; // http DP already warmed (contacts pre-cached on load) → instant
|
||||||
|
else avatarToTempPng(src).catch(() => {}); // not cached yet → fire now, warm for next time
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let n;
|
||||||
|
const finish = (v) => {
|
||||||
|
if (done) return; done = true;
|
||||||
|
if (n) { activeNotifs.delete(n); }
|
||||||
|
resolve(v); // note: img is cached, not deleted
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// No timeoutType:'never' — on Windows that added an unwanted "Close" action button. Windows'
|
||||||
|
// default toast behavior + our strong reference keep it visible long enough; the in-app call
|
||||||
|
// popup provides the persistent Join/Decline for calls.
|
||||||
|
n = new Notification({
|
||||||
|
title: payload.title || 'Biz Connect',
|
||||||
|
body: payload.body || '',
|
||||||
|
icon: img ? nativeImage.createFromPath(img) : undefined,
|
||||||
|
silent: false,
|
||||||
|
});
|
||||||
|
activeNotifs.add(n); // strong ref → toast isn't collected; click stays live
|
||||||
|
n.on('click', () => {
|
||||||
|
if (win) { if (win.isMinimized()) win.restore(); win.show(); win.focus(); } // raise the app
|
||||||
|
finish({ kind: payload.kind, id: payload.id, open: true });
|
||||||
|
});
|
||||||
|
n.on('close', () => finish(null)); // user/system dismissed it → no action
|
||||||
|
n.show();
|
||||||
|
setTimeout(() => finish(null), payload.persistent ? 45000 : 25000); // don't leak the promise
|
||||||
|
} catch (_) { finish(null); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Windows attributes notifications to the AppUserModelID. Without setting it, toasts read
|
||||||
|
// "electron.app.<name>"; setting it to the installer's appId makes Windows resolve the
|
||||||
|
// installed "Biz Connect" shortcut, so notifications show "Biz Connect".
|
||||||
|
app.setAppUserModelId('com.bizgaze.connect.desktop');
|
||||||
|
|
||||||
|
// Renderer asks (via preload) to raise the window — e.g. when an OS notification is clicked.
|
||||||
|
ipcMain.on('focus-window', () => {
|
||||||
|
if (!win) return;
|
||||||
|
if (win.isMinimized()) win.restore();
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unread badge on the taskbar icon. The renderer computes the count (chats with unread) and
|
||||||
|
// draws the badge image (it has a canvas); Windows shows it via an overlay icon, macOS/Linux
|
||||||
|
// via the dock badge count.
|
||||||
|
ipcMain.on('set-unread', (_e, { count, dataUrl } = {}) => {
|
||||||
|
try {
|
||||||
|
if (typeof app.setBadgeCount === 'function') app.setBadgeCount(count || 0); // macOS/Linux dock
|
||||||
|
if (!win) return;
|
||||||
|
const overlay = (count > 0 && dataUrl) ? nativeImage.createFromDataURL(dataUrl) : null;
|
||||||
|
win.setOverlayIcon(overlay, count > 0 ? (count + ' unread chats') : ''); // Windows taskbar
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server origin: a PACKAGED build (the installer) points at production; running from source in dev
|
||||||
|
// (`npm start`, unpackaged) defaults to the local server so you can test the shell against localhost
|
||||||
|
// with no flags or separate "local" build. SERVER_URL always overrides (e.g. point dev at prod).
|
||||||
|
const SERVER_URL = (process.env.SERVER_URL || (app.isPackaged ? 'https://remote.bizgaze.com' : 'http://localhost:8090')).replace(/\/+$/, '');
|
||||||
|
|
||||||
|
let win;
|
||||||
|
let splash;
|
||||||
|
let tray = null;
|
||||||
|
let isQuitting = false; // true only during a real Quit (tray menu / before-quit) — otherwise close = hide to tray
|
||||||
|
|
||||||
|
// Close-to-tray: closing the window HIDES it instead of quitting, so the app keeps running in the
|
||||||
|
// background with its chat WebSocket alive. That's what lets call/message notifications still fire when
|
||||||
|
// the window is "closed" (General #1/#2) — a fully-quit Electron app gets no push. The tray icon + menu
|
||||||
|
// bring it back or quit for real.
|
||||||
|
function createTray() {
|
||||||
|
if (tray) return;
|
||||||
|
try {
|
||||||
|
let img = nativeImage.createFromPath(path.join(__dirname, 'tray.ico'));
|
||||||
|
if (img.isEmpty()) img = nativeImage.createFromPath(path.join(process.resourcesPath || __dirname, 'tray.ico'));
|
||||||
|
tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img);
|
||||||
|
tray.setToolTip('Biz Connect');
|
||||||
|
const showApp = () => { if (!win) return createWindow(); if (win.isMinimized()) win.restore(); win.show(); win.focus(); };
|
||||||
|
tray.setContextMenu(Menu.buildFromTemplate([
|
||||||
|
{ label: 'Open Biz Connect', click: showApp },
|
||||||
|
{ label: 'Refresh app (get latest)', click: () => { showApp(); hardReloadWin(); } },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Quit', click: () => { isQuitting = true; app.quit(); } },
|
||||||
|
]));
|
||||||
|
tray.on('click', showApp); // single-click (Windows)
|
||||||
|
tray.on('double-click', showApp);
|
||||||
|
} catch (_) { tray = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tiny brand-blue splash (splash.html) shown while the web UI loads, so launch feels instant
|
||||||
|
// and on-brand instead of a blank window. Closed as soon as the main window is ready to show.
|
||||||
|
function createSplash() {
|
||||||
|
splash = new BrowserWindow({
|
||||||
|
width: 440, height: 440, frame: false, resizable: false, center: true,
|
||||||
|
backgroundColor: '#1F3B73', skipTaskbar: true, alwaysOnTop: true, show: true,
|
||||||
|
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
||||||
|
});
|
||||||
|
splash.loadFile(path.join(__dirname, 'splash.html'));
|
||||||
|
splash.on('closed', () => { splash = null; });
|
||||||
|
}
|
||||||
|
function closeSplash() { if (splash) { try { splash.close(); } catch (_) {} splash = null; } }
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 880,
|
||||||
|
minHeight: 600,
|
||||||
|
title: 'Biz Connect',
|
||||||
|
backgroundColor: '#1F3B73',
|
||||||
|
show: false, // reveal only once the page is ready — the splash covers the gap
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
// Persist cookies/localStorage so the technician stays logged in between launches.
|
||||||
|
partition: 'persist:bizconnect',
|
||||||
|
// Keep the renderer at full speed when the window is in the BACKGROUND/minimized. Electron
|
||||||
|
// throttles hidden windows by default, which stalled the chat WebSocket's onmessage + timers —
|
||||||
|
// so incoming messages and their notifications only landed when you refocused the app (the
|
||||||
|
// "notifications slow / messages don't update live" report). Off = real-time even in the tray.
|
||||||
|
backgroundThrottling: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reveal the main window when its first paint is ready, and retire the splash. A fallback
|
||||||
|
// timer guarantees we never get stuck on the splash if the load stalls.
|
||||||
|
const reveal = () => { closeSplash(); if (win && !win.isVisible()) { win.show(); win.focus(); } };
|
||||||
|
win.once('ready-to-show', reveal);
|
||||||
|
setTimeout(reveal, 12000);
|
||||||
|
// Every time the window is shown — X-to-tray then reopened, taskbar click, relaunch (second-instance),
|
||||||
|
// tray icon — check for a new version. This is the catch-all that covers ALL reveal paths (closing the
|
||||||
|
// window with the X and opening it again included), not just minimize/restore. Throttled to 1/10 min.
|
||||||
|
win.on('show', () => bgUpdateCheck());
|
||||||
|
|
||||||
|
// The menu bar is hidden, so the usual reload accelerators don't exist — wire them by hand. Without
|
||||||
|
// these there was literally no way to force the app off a stale page.
|
||||||
|
win.webContents.on('before-input-event', (e, input) => {
|
||||||
|
if (input.type !== 'keyDown') return;
|
||||||
|
const k = String(input.key || '').toLowerCase();
|
||||||
|
const mod = input.control || input.meta;
|
||||||
|
if ((mod && k === 'r') || k === 'f5') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (input.shift || k === 'f5') hardReloadWin(); // hard: clear cache + reload
|
||||||
|
else { try { win.webContents.reload(); } catch (_) {} } // plain reload
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close = hide to tray (keep running for notifications). First time, tell the user where it went.
|
||||||
|
let toldTray = false;
|
||||||
|
win.on('close', (e) => {
|
||||||
|
if (isQuitting) return; // real quit → let it close
|
||||||
|
e.preventDefault();
|
||||||
|
win.hide();
|
||||||
|
if (!toldTray && Notification.isSupported()) {
|
||||||
|
toldTray = true;
|
||||||
|
try { const n = new Notification({ title: 'Biz Connect is still running', body: 'It stays in the system tray so you keep getting calls & messages. Quit from the tray icon.' }); n.show(); } catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open the landing page (same entry as the website): the "before login" screen with the
|
||||||
|
// no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home.
|
||||||
|
win.loadURL(SERVER_URL + '/');
|
||||||
|
|
||||||
|
// Links: EXTERNAL ones go to the system browser. OUR OWN urls (e.g. a meeting invite link clicked in
|
||||||
|
// chat) must NOT spawn a second app window (#5) — navigate the main window instead.
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (!url.startsWith(SERVER_URL)) { shell.openExternal(url); return { action: 'deny' }; }
|
||||||
|
try { if (win && !win.isDestroyed()) { if (win.isMinimized()) win.restore(); win.show(); win.focus(); win.loadURL(url); } } catch (_) {}
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spell-check menu. Two ways in:
|
||||||
|
// - RIGHT-click: full editing menu (suggestions + cut/copy/paste), the standard desktop behavior.
|
||||||
|
// - LEFT-click on a misspelled word: the renderer asks us (spell-suggest) to synthesize a
|
||||||
|
// right-click at that point, so Chromium hands us the real dictionary suggestions — then we show
|
||||||
|
// a SUGGESTIONS-ONLY menu. This gives the user corrections on a plain left click.
|
||||||
|
win.webContents.on('context-menu', (_e, params) => {
|
||||||
|
const fromLeftClick = spellClickPending; spellClickPending = false;
|
||||||
|
if (fromLeftClick) {
|
||||||
|
if (!params.misspelledWord) return; // clicked a correctly-spelled word → no menu, don't disturb typing
|
||||||
|
const menu = new Menu();
|
||||||
|
for (const s of (params.dictionarySuggestions || [])) menu.append(new MenuItem({ label: s, click: () => win.webContents.replaceMisspelling(s) }));
|
||||||
|
if (!menu.items.length) menu.append(new MenuItem({ label: 'No suggestions', enabled: false }));
|
||||||
|
menu.append(new MenuItem({ type: 'separator' }));
|
||||||
|
menu.append(new MenuItem({ label: 'Add to dictionary', click: () => win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) }));
|
||||||
|
menu.popup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const menu = new Menu();
|
||||||
|
for (const s of (params.dictionarySuggestions || [])) {
|
||||||
|
menu.append(new MenuItem({ label: s, click: () => win.webContents.replaceMisspelling(s) }));
|
||||||
|
}
|
||||||
|
if (params.misspelledWord) {
|
||||||
|
if (params.dictionarySuggestions && params.dictionarySuggestions.length) menu.append(new MenuItem({ type: 'separator' }));
|
||||||
|
menu.append(new MenuItem({ label: 'Add to dictionary', click: () => win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) }));
|
||||||
|
}
|
||||||
|
if (params.isEditable || params.editFlags.canCopy) {
|
||||||
|
if (menu.items.length) menu.append(new MenuItem({ type: 'separator' }));
|
||||||
|
if (params.editFlags.canCut) menu.append(new MenuItem({ role: 'cut' }));
|
||||||
|
if (params.editFlags.canCopy) menu.append(new MenuItem({ role: 'copy' }));
|
||||||
|
if (params.isEditable) menu.append(new MenuItem({ role: 'paste' }));
|
||||||
|
if (params.isEditable && params.editFlags.canSelectAll) menu.append(new MenuItem({ role: 'selectAll' }));
|
||||||
|
}
|
||||||
|
if (menu.items.length) menu.popup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Set just before we synthesize a right-click from a renderer LEFT-click, so the context-menu handler
|
||||||
|
// knows to show a suggestions-only menu (and to stay silent on correctly-spelled words).
|
||||||
|
let spellClickPending = false;
|
||||||
|
ipcMain.on('spell-suggest', (_e, pos) => {
|
||||||
|
try {
|
||||||
|
if (!win || win.isDestroyed() || !pos) return;
|
||||||
|
const x = Math.round(pos.x), y = Math.round(pos.y);
|
||||||
|
if (!(x >= 0 && y >= 0)) return;
|
||||||
|
spellClickPending = true;
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'right', clickCount: 1 });
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'right', clickCount: 1 });
|
||||||
|
setTimeout(() => { spellClickPending = false; }, 500); // guard: clear if no context-menu fired
|
||||||
|
} catch (_) { spellClickPending = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// The full Connect experience needs several web capabilities that Electron denies by
|
||||||
|
// default. We grant them for our own trusted origin:
|
||||||
|
// - media → camera + mic for meetings/calls (getUserMedia)
|
||||||
|
// - display-capture → "Share my screen" (getDisplayMedia)
|
||||||
|
// - notifications → in-app alerts
|
||||||
|
// - clipboard, fullscreen, pointerLock → chat paste + meeting UX
|
||||||
|
// Without this, meetings silently have no camera/mic and notifications never fire.
|
||||||
|
const GRANTED = new Set([
|
||||||
|
'media', 'display-capture', 'notifications',
|
||||||
|
'clipboard-read', 'clipboard-sanitized-write', 'fullscreen', 'pointerLock',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Custom "Share your screen" picker. Enumerates screens + windows, shows a branded modal grid with
|
||||||
|
// live thumbnails, and resolves to the chosen desktopCapturer source (or null if cancelled). Replaces
|
||||||
|
// the unreliable OS system picker. Only one picker at a time.
|
||||||
|
let pickerWin = null;
|
||||||
|
function pickShareSource() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (v) => { if (settled) return; settled = true; ipcMain.removeListener('picker-choose', onChoose); if (pickerWin && !pickerWin.isDestroyed()) { try { pickerWin.close(); } catch (_) {} } pickerWin = null; resolve(v); };
|
||||||
|
let allSources = [];
|
||||||
|
const onChoose = (_e, id) => {
|
||||||
|
if (!id) return finish(null);
|
||||||
|
finish(allSources.find((s) => s.id === id) || null);
|
||||||
|
};
|
||||||
|
desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 200 }, fetchWindowIcons: true })
|
||||||
|
.then((sources) => {
|
||||||
|
allSources = sources;
|
||||||
|
const payload = { screen: [], window: [] };
|
||||||
|
for (const s of sources) {
|
||||||
|
const bucket = s.id.startsWith('screen:') ? 'screen' : 'window';
|
||||||
|
payload[bucket].push({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || (bucket === 'screen' ? 'Screen' : 'Window'),
|
||||||
|
thumb: s.thumbnail ? s.thumbnail.toDataURL() : '',
|
||||||
|
appIcon: s.appIcon && !s.appIcon.isEmpty() ? s.appIcon.toDataURL() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pickerWin && !pickerWin.isDestroyed()) { try { pickerWin.close(); } catch (_) {} }
|
||||||
|
pickerWin = new BrowserWindow({
|
||||||
|
width: 760, height: 560, parent: win || undefined, modal: !!win, resizable: true,
|
||||||
|
minimizable: false, maximizable: false, title: 'Share your screen', backgroundColor: '#f4f6fb',
|
||||||
|
show: false, autoHideMenuBar: true,
|
||||||
|
webPreferences: { preload: undefined, nodeIntegration: true, contextIsolation: false },
|
||||||
|
});
|
||||||
|
pickerWin.setMenu(null);
|
||||||
|
pickerWin.loadFile(path.join(__dirname, 'picker.html'));
|
||||||
|
pickerWin.once('ready-to-show', () => { pickerWin.show(); pickerWin.webContents.send('picker-sources', payload); });
|
||||||
|
pickerWin.on('closed', () => { if (!settled) finish(null); }); // closed via the X → cancel
|
||||||
|
ipcMain.on('picker-choose', onChoose);
|
||||||
|
})
|
||||||
|
.catch(() => finish(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureSession() {
|
||||||
|
const ses = session.fromPartition('persist:bizconnect');
|
||||||
|
// getDisplayMedia: show OUR OWN branded screen/window picker. The Electron `useSystemPicker`
|
||||||
|
// option silently no-ops on many Windows 11 builds (it needs a specific WebRTC feature) and then
|
||||||
|
// auto-shares the primary display with no choice — which is exactly the "no picker appears" bug.
|
||||||
|
// So we enumerate sources ourselves and pop a picker window (pickShareSource) to let the user
|
||||||
|
// pick a specific screen or window.
|
||||||
|
ses.setDisplayMediaRequestHandler((request, callback) => {
|
||||||
|
pickShareSource().then((source) => {
|
||||||
|
callback(source ? { video: source, audio: 'loopback' } : {}); // {} = user cancelled → no share
|
||||||
|
}).catch(() => callback({}));
|
||||||
|
}, { useSystemPicker: false });
|
||||||
|
// Async grant (getUserMedia, notifications, …)
|
||||||
|
ses.setPermissionRequestHandler((_wc, permission, callback) => callback(GRANTED.has(permission)));
|
||||||
|
// Sync check (some getUserMedia paths query this before requesting)
|
||||||
|
ses.setPermissionCheckHandler((_wc, permission) => GRANTED.has(permission));
|
||||||
|
// Spell check for the message box (red squiggles) with right-click corrections. Uses the OS
|
||||||
|
// dictionaries; en-US by default plus whatever the OS UI language is, so mixed typing still checks.
|
||||||
|
try {
|
||||||
|
ses.setSpellCheckerEnabled(true);
|
||||||
|
const langs = ['en-US'];
|
||||||
|
const sys = (app.getLocale && app.getLocale()) || '';
|
||||||
|
const avail = (ses.availableSpellCheckerLanguages || []);
|
||||||
|
if (sys && sys !== 'en-US' && (!avail.length || avail.includes(sys))) langs.push(sys);
|
||||||
|
ses.setSpellCheckerLanguages(langs);
|
||||||
|
} catch (_) {}
|
||||||
|
// Downloads go STRAIGHT to the OS Downloads folder — no "where do you want to save?" dialog. If a file of
|
||||||
|
// the same name already exists, suffix " (n)" so nothing is overwritten. (item.setSavePath suppresses the
|
||||||
|
// save dialog entirely.)
|
||||||
|
ses.on('will-download', (_e, item) => {
|
||||||
|
try {
|
||||||
|
const dir = app.getPath('downloads');
|
||||||
|
const name = item.getFilename() || 'download';
|
||||||
|
const ext = path.extname(name), base = path.basename(name, ext);
|
||||||
|
let target = path.join(dir, name), n = 1;
|
||||||
|
while (fs.existsSync(target)) target = path.join(dir, `${base} (${n++})${ext}`);
|
||||||
|
item.setSavePath(target);
|
||||||
|
// Since we suppressed the save dialog, the download would otherwise be completely silent. Give feedback
|
||||||
|
// when it finishes: a notification (click → reveal the file in Explorer) so the user knows it saved and
|
||||||
|
// where. Also tell the web UI so it can show its own toast. On failure, say so.
|
||||||
|
item.once('done', (_ev, state) => {
|
||||||
|
try {
|
||||||
|
const ok = state === 'completed';
|
||||||
|
const savedName = ok ? path.basename(target) : (item.getFilename() || 'file');
|
||||||
|
// Always tell the web UI (it shows a branded toast when the window is up).
|
||||||
|
try { if (win && !win.isDestroyed()) win.webContents.send('download-done', { name: savedName, path: ok ? target : '', ok }); } catch (_) {}
|
||||||
|
// If the window is focused, that toast is enough — skip the native notification to avoid a double
|
||||||
|
// notice. If the app is minimized/in the tray, show a native notification instead (click → reveal).
|
||||||
|
const focused = win && !win.isDestroyed() && win.isVisible() && win.isFocused();
|
||||||
|
if (!focused && Notification.isSupported()) {
|
||||||
|
if (ok) { const note = new Notification({ title: 'Download complete', body: savedName + ' — saved to your Downloads folder. Click to show it.' }); note.on('click', () => { try { shell.showItemInFolder(target); } catch (_) {} }); note.show(); }
|
||||||
|
else new Notification({ title: 'Download failed', body: savedName + ' could not be saved.' }).show();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-instance: a tray app must not spawn a second copy. If another launch happens, focus the
|
||||||
|
// existing window (restoring it from the tray) instead.
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => { if (win) { if (win.isMinimized()) win.restore(); win.show(); win.focus(); } });
|
||||||
|
}
|
||||||
|
app.on('before-quit', () => { isQuitting = true; });
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
configureSession();
|
||||||
|
createSplash();
|
||||||
|
createWindow();
|
||||||
|
createTray(); // keep the app reachable while its window is hidden to tray
|
||||||
|
Menu.setApplicationMenu(null); // hide the default menu bar; the web UI is the chrome
|
||||||
|
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); else if (win) { win.show(); win.focus(); } });
|
||||||
|
// Check for shell updates on launch, then every 6 hours. Only in packaged builds.
|
||||||
|
if (app.isPackaged && autoUpdater) {
|
||||||
|
// #3: surface update progress to the web UI so the user can SEE an update is downloading /
|
||||||
|
// installing, instead of it happening silently in the background.
|
||||||
|
const sendUpdate = (data) => { try { if (win && !win.isDestroyed()) win.webContents.send('update-event', data); } catch (_) {} };
|
||||||
|
autoUpdater.on('checking-for-update', () => sendUpdate({ phase: 'checking' }));
|
||||||
|
autoUpdater.on('update-available', (info) => sendUpdate({ phase: 'available', version: info && info.version }));
|
||||||
|
autoUpdater.on('update-not-available', () => sendUpdate({ phase: 'current' }));
|
||||||
|
autoUpdater.on('download-progress', (p) => sendUpdate({ phase: 'downloading', percent: Math.round((p && p.percent) || 0) }));
|
||||||
|
autoUpdater.on('error', () => sendUpdate({ phase: 'error' }));
|
||||||
|
// When an update finishes downloading, tell the web UI so it can show a BRANDED "Update ready —
|
||||||
|
// Restart now" banner (restartToUpdate IPC does the install). No native dialog — that was
|
||||||
|
// unbranded. It still installs on next launch if the user never clicks Restart.
|
||||||
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
sendUpdate({ phase: 'ready', version: info && info.version });
|
||||||
|
// If the window is hidden in the tray, the branded in-app banner isn't visible — nudge with a native
|
||||||
|
// notification so tray users know an update is waiting (it also installs automatically on next quit).
|
||||||
|
try { if ((!win || !win.isVisible()) && Notification.isSupported()) new Notification({ title: 'Biz Connect update ready', body: 'Version ' + ((info && info.version) || '') + ' installs when you restart. Open Biz Connect to restart now.' }).show(); } catch (_) {}
|
||||||
|
});
|
||||||
|
// Install a downloaded update on the next real quit (PC restart / tray-Quit) even if the user never
|
||||||
|
// clicks "Restart now" — so close-to-tray users don't get stuck on an old version. (This is the
|
||||||
|
// electron-updater default; set explicitly to be safe.)
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
// checkForUpdates (NOT ...AndNotify): ...AndNotify pops electron-updater's OWN native "Update ready
|
||||||
|
// — Restart/Later" toast on download, which duplicated our branded in-app banner (the user saw TWO
|
||||||
|
// restart prompts). Plain checkForUpdates still auto-downloads and fires 'update-downloaded'.
|
||||||
|
bgUpdateCheck();
|
||||||
|
setInterval(bgUpdateCheck, 6 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// With close-to-tray the window is HIDDEN, not destroyed, so this normally won't fire while the app is
|
||||||
|
// meant to keep running. Only quit here if we're actually quitting (belt-and-braces).
|
||||||
|
app.on('window-all-closed', () => { if (isQuitting && process.platform !== 'darwin') app.quit(); });
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "biz-connect-desktop",
|
||||||
|
"version": "0.1.20",
|
||||||
|
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
|
||||||
|
"author": {
|
||||||
|
"name": "BizGaze",
|
||||||
|
"email": "support@bizgaze.com"
|
||||||
|
},
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dist": "electron-builder"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-updater": "^6.3.9"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@nut-tree-fork/nut-js": "^4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^31.0.0",
|
||||||
|
"electron-builder": "^24.13.3"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.bizgaze.connect.desktop",
|
||||||
|
"productName": "Biz Connect",
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "build",
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"publish": [
|
||||||
|
{
|
||||||
|
"provider": "generic",
|
||||||
|
"url": "https://remote.bizgaze.com/downloads/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"icon": "build/icon.ico"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": true,
|
||||||
|
"perMachine": false,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "Biz Connect",
|
||||||
|
"runAfterFinish": true
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg",
|
||||||
|
"category": "public.app-category.business",
|
||||||
|
"icon": "build/icon.ico"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"category": "Network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||||
|
<title>Choose what to share</title>
|
||||||
|
<style>
|
||||||
|
:root{ --brand:#1F3B73; --bg:#f4f6fb; --card:#fff; --line:#e3e8f2; --muted:#64748b; }
|
||||||
|
*{ box-sizing:border-box; }
|
||||||
|
html,body{ margin:0; height:100%; font-family:'Segoe UI',system-ui,sans-serif; background:var(--bg); color:#0f172a; }
|
||||||
|
.wrap{ display:flex; flex-direction:column; height:100%; }
|
||||||
|
header{ padding:16px 20px 10px; }
|
||||||
|
header h1{ margin:0; font-size:17px; font-weight:700; color:var(--brand); }
|
||||||
|
header p{ margin:3px 0 0; font-size:12.5px; color:var(--muted); }
|
||||||
|
.tabs{ display:flex; gap:6px; padding:8px 20px 0; }
|
||||||
|
.tab{ border:0; background:transparent; font:inherit; font-size:13px; font-weight:600; color:var(--muted); padding:7px 12px; border-radius:8px 8px 0 0; cursor:pointer; }
|
||||||
|
.tab.on{ color:var(--brand); background:var(--card); box-shadow:inset 0 -2px 0 var(--brand); }
|
||||||
|
.grid{ flex:1; overflow:auto; display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:12px; padding:14px 20px; align-content:start; }
|
||||||
|
.src{ background:var(--card); border:1.5px solid var(--line); border-radius:12px; padding:8px; cursor:pointer; text-align:left; font:inherit; transition:border-color .12s, transform .08s; overflow:hidden; }
|
||||||
|
.src:hover{ border-color:var(--brand); transform:translateY(-1px); }
|
||||||
|
.src.sel{ border-color:var(--brand); box-shadow:0 0 0 2px rgba(31,59,115,.18); }
|
||||||
|
.thumb{ width:100%; height:112px; border-radius:8px; background:#0b1220; object-fit:contain; display:block; }
|
||||||
|
.meta{ display:flex; align-items:center; gap:7px; padding:8px 4px 2px; }
|
||||||
|
.appic{ width:18px; height:18px; border-radius:4px; flex:0 0 auto; }
|
||||||
|
.nm{ font-size:12.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.empty{ grid-column:1/-1; text-align:center; color:var(--muted); font-size:13px; padding:40px 0; }
|
||||||
|
footer{ display:flex; justify-content:flex-end; gap:10px; padding:12px 20px; border-top:1px solid var(--line); background:var(--card); }
|
||||||
|
button.act{ font:inherit; font-size:13.5px; font-weight:600; padding:9px 18px; border-radius:9px; cursor:pointer; border:1.5px solid var(--line); background:#fff; color:#334155; }
|
||||||
|
button.act.primary{ background:var(--brand); border-color:var(--brand); color:#fff; }
|
||||||
|
button.act.primary:disabled{ opacity:.45; cursor:default; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<h1>Share your screen</h1>
|
||||||
|
<p>Choose a screen or a window to share with the call.</p>
|
||||||
|
</header>
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab on" data-t="screen">Entire screen</button>
|
||||||
|
<button class="tab" data-t="window">Application window</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid" id="grid"></div>
|
||||||
|
<footer>
|
||||||
|
<button class="act" id="cancel">Cancel</button>
|
||||||
|
<button class="act primary" id="share" disabled>Share</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const { ipcRenderer } = require('electron');
|
||||||
|
let SOURCES = { screen:[], window:[] };
|
||||||
|
let tab='screen', selected=null;
|
||||||
|
|
||||||
|
ipcRenderer.on('picker-sources', (_e, data)=>{ SOURCES=data||{screen:[],window:[]}; render(); });
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
|
||||||
|
tab=b.dataset.t; selected=null; document.getElementById('share').disabled=true;
|
||||||
|
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on', x===b));
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.getElementById('cancel').onclick=()=>ipcRenderer.send('picker-choose', null);
|
||||||
|
document.getElementById('share').onclick=()=>{ if(selected) ipcRenderer.send('picker-choose', selected); };
|
||||||
|
window.addEventListener('keydown', e=>{ if(e.key==='Escape') ipcRenderer.send('picker-choose', null); });
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
const grid=document.getElementById('grid'); grid.innerHTML='';
|
||||||
|
const list=SOURCES[tab]||[];
|
||||||
|
if(!list.length){ grid.innerHTML='<div class="empty">Nothing available to share here.</div>'; return; }
|
||||||
|
list.forEach(s=>{
|
||||||
|
const card=document.createElement('button'); card.className='src'; card.dataset.id=s.id;
|
||||||
|
const ap = s.appIcon ? '<img class="appic" src="'+s.appIcon+'">' : '';
|
||||||
|
card.innerHTML='<img class="thumb" src="'+s.thumb+'">'
|
||||||
|
+ '<div class="meta">'+ap+'<span class="nm">'+esc(s.name||'Untitled')+'</span></div>';
|
||||||
|
card.onclick=()=>{ selected=s.id; document.getElementById('share').disabled=false;
|
||||||
|
document.querySelectorAll('.src').forEach(x=>x.classList.toggle('sel', x===card)); };
|
||||||
|
card.ondblclick=()=>{ selected=s.id; ipcRenderer.send('picker-choose', selected); };
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Minimal, safe bridge into the web UI. Runs with contextIsolation, so it only exposes a
|
||||||
|
// frozen marker the web app can feature-detect against (e.g. to hide the PWA install prompt
|
||||||
|
// or prefer native push). No Node APIs are exposed to page JS.
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
// Pull the stable install id + version/os from main (synchronous, one-time at preload).
|
||||||
|
let info = { installId: '', appVersion: '', os: '' };
|
||||||
|
try { info = ipcRenderer.sendSync('get-install-info') || info; } catch (_) {}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('__NATIVE__', 'desktop');
|
||||||
|
contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({
|
||||||
|
platform: 'desktop',
|
||||||
|
version: info.appVersion || '0.1.0',
|
||||||
|
installId: info.installId || '',
|
||||||
|
os: info.os || '',
|
||||||
|
// Bring the app window to the foreground (e.g. when a notification is clicked) — the web
|
||||||
|
// Notification's window.focus() can't raise an Electron window; the main process must.
|
||||||
|
focusApp: () => ipcRenderer.send('focus-window'),
|
||||||
|
// Show the unread-chat count as a badge on the taskbar icon. count=number of chats with
|
||||||
|
// unread; dataUrl=a small PNG badge the renderer drew (null to clear).
|
||||||
|
setUnread: (count, dataUrl) => ipcRenderer.send('set-unread', { count, dataUrl }),
|
||||||
|
// Native Windows toast with an inline reply box. Resolves to {text} (replied), {open} (clicked)
|
||||||
|
// or null. Lets the user reply to a chat straight from the notification.
|
||||||
|
replyNotify: (payload) => ipcRenderer.invoke('reply-notification', payload),
|
||||||
|
// Pre-warm the notification DP cache with contact photo URLs (called after chats load) so the first
|
||||||
|
// toast from anyone already has their photo — no per-notification download lag.
|
||||||
|
precacheAvatars: (urls) => { try { return ipcRenderer.invoke('precache-avatars', urls); } catch (_) { return Promise.resolve(false); } },
|
||||||
|
// Ask the shell to show native spelling suggestions for the word at page coords (x,y) — used to bring
|
||||||
|
// up corrections on a LEFT click in the message box (not just right-click).
|
||||||
|
spellSuggestAt: (x, y) => { try { ipcRenderer.send('spell-suggest', { x, y }); } catch (_) {} },
|
||||||
|
// Remote control (screen the local user is sharing): whether OS injection is possible on this machine,
|
||||||
|
// arm/disarm the consent gate, and forward a viewer's input event for injection. Injection only happens
|
||||||
|
// while armed (the user granted control) — see main.js rcArmed.
|
||||||
|
rcAvailable: () => { try { return !!ipcRenderer.sendSync('rc-available'); } catch (_) { return false; } },
|
||||||
|
rcArm: (on) => { try { ipcRenderer.send('rc-arm', !!on); } catch (_) {} },
|
||||||
|
rcInput: (evt) => { try { ipcRenderer.send('rc-input', evt); } catch (_) {} },
|
||||||
|
// Force the newest web build: clears the shell's HTTP cache and reloads ignoring cache. The app closes
|
||||||
|
// to tray and can run for weeks, so without this it would keep serving the page it first loaded.
|
||||||
|
hardReload: () => { try { return ipcRenderer.invoke('hard-reload'); } catch (_) { return Promise.resolve(false); } },
|
||||||
|
// Manual "Check for updates" from Settings. Resolves {status:'available'|'current'|'dev'|'error', version?}.
|
||||||
|
// On 'available' the shell downloads in the background and prompts to restart when ready.
|
||||||
|
checkForUpdates: () => ipcRenderer.invoke('check-updates'),
|
||||||
|
// #3: subscribe to auto-update lifecycle events so the UI can show download progress + "ready".
|
||||||
|
// cb receives {phase:'checking'|'available'|'downloading'|'ready'|'current'|'error', percent?, version?}.
|
||||||
|
onUpdateEvent: (cb) => { try { ipcRenderer.on('update-event', (_e, data) => { try { cb(data); } catch (_) {} }); } catch (_) {} },
|
||||||
|
onDownloadDone: (cb) => { try { ipcRenderer.on('download-done', (_e, data) => { try { cb(data); } catch (_) {} }); } catch (_) {} }, // desktop: a file finished downloading to the Downloads folder → show a toast
|
||||||
|
restartToUpdate: () => ipcRenderer.invoke('restart-to-update'),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;height:100%;overflow:hidden;}
|
||||||
|
body{background:#1F3B73;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;
|
||||||
|
font-family:'Segoe UI',system-ui,sans-serif;-webkit-user-select:none;user-select:none;}
|
||||||
|
/* Branded orbit mark (white C + circling yellow dot) — matches the app icon/loader */
|
||||||
|
.mark{width:96px;height:96px;}
|
||||||
|
.mark .dot{transform-origin:50px 50px;animation:spin 1.1s linear infinite;}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg);}}
|
||||||
|
.name{color:#fff;font-size:20px;font-weight:700;letter-spacing:.3px;}
|
||||||
|
.name b{color:#FFC708;font-weight:700;}
|
||||||
|
.sub{color:rgba(255,255,255,.66);font-size:12.5px;margin-top:-14px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<svg class="mark" viewBox="0 0 100 100" aria-label="Biz Connect">
|
||||||
|
<circle cx="50" cy="50" r="34" fill="none" stroke="#ffffff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/>
|
||||||
|
<g class="dot"><circle cx="84" cy="50" r="7" fill="#FFC708"/></g>
|
||||||
|
</svg>
|
||||||
|
<div class="name">Biz <b>Connect</b></div>
|
||||||
|
<div class="sub">Starting…</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -11,6 +11,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PORT=8090
|
- PORT=8090
|
||||||
- DB_PATH=/data/data.db
|
- DB_PATH=/data/data.db
|
||||||
|
# Desktop installers + auto-update feed live on the persistent volume so uploaded
|
||||||
|
# builds survive image rebuilds (a plain image path would be wiped on every deploy).
|
||||||
|
- DOWNLOADS_DIR=/data/downloads
|
||||||
|
# Chat uploads / recordings / transcripts on the persistent volume too, so they survive image
|
||||||
|
# rebuilds (otherwise old shared images 404 as "broken image" after every deploy).
|
||||||
|
- UPLOADS_DIR=/data/uploads
|
||||||
|
# Max chat-attachment size in MB (default 1024 = 1 GB). The app streams uploads to /data/uploads, so
|
||||||
|
# large files don't buffer in memory. IMPORTANT: also set Nginx Proxy Manager's client_max_body_size
|
||||||
|
# for remote.bizgaze.com to at least this (Advanced tab: `client_max_body_size 1024m;`) or the proxy
|
||||||
|
# rejects big uploads before they reach the app.
|
||||||
|
- MAX_UPLOAD_MB=1024
|
||||||
|
- REC_DIR=/data/recordings
|
||||||
|
- TRANS_DIR=/data/transcripts
|
||||||
# Secrets (TURN credentials, SSO_SECRET, BIZGAZE_WEBHOOK_URL, etc.) live in
|
# Secrets (TURN credentials, SSO_SECRET, BIZGAZE_WEBHOOK_URL, etc.) live in
|
||||||
# a .env file next to this compose file. It is gitignored — never committed.
|
# a .env file next to this compose file. It is gitignored — never committed.
|
||||||
# See .env.example for the expected keys.
|
# See .env.example for the expected keys.
|
||||||
@@ -22,6 +35,32 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# wss://livekit.bizgaze.com -> livekit:7880 (signaling); media flows over the published UDP/TCP
|
||||||
|
# ports below, NOT through NPM. Single-node (no Redis) — consistent with the app's single-instance rule.
|
||||||
|
livekit:
|
||||||
|
# v1.8+ implements the /rtc/v1 signaling path (protocol 17) that the bundled
|
||||||
|
# livekit-client@2.20 uses. On the older v1.7 the client fell back to the legacy path and
|
||||||
|
# track publishing broke (mic/cam wouldn't turn on). Keep this within one minor of the client.
|
||||||
|
image: livekit/livekit-server:v1.9
|
||||||
|
container_name: bizgaze-livekit
|
||||||
|
restart: unless-stopped
|
||||||
|
# Dormant by default: a normal `docker compose up -d` / deploy.sh does NOT start it. Enable SFU
|
||||||
|
# explicitly with `docker compose --profile sfu up -d` after setting the LIVEKIT_* vars (see DEPLOY.md).
|
||||||
|
profiles: ["sfu"]
|
||||||
|
command: --config /etc/livekit.yaml
|
||||||
|
environment:
|
||||||
|
# key: secret, sourced from the same .env as the app so both sign/verify with the same secret.
|
||||||
|
- "LIVEKIT_KEYS=${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
||||||
|
volumes:
|
||||||
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
|
ports:
|
||||||
|
- "7881:7881" # WebRTC over TCP (fallback)
|
||||||
|
- "50000:50000/udp" # single WebRTC media UDP port (must match livekit.yaml rtc.udp_port)
|
||||||
|
networks:
|
||||||
|
- npm
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
npm:
|
npm:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# coturn + app config for TURN (remote.bizgaze.com)
|
||||||
|
|
||||||
|
Status: self-hosted **coturn** is working on **UDP 3478** (verified — a `relay`
|
||||||
|
candidate was returned by the Trickle ICE test). This doc adds **TCP 3478** and
|
||||||
|
optional **TLS 5349** for wider firewall coverage, and points the BizGaze Connect
|
||||||
|
app at coturn.
|
||||||
|
|
||||||
|
TURN makes the WebRTC *connection* work across cellular / strict NATs. It does NOT
|
||||||
|
let a phone share its screen in a browser — that is a separate platform limitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. coturn — turnserver.conf
|
||||||
|
|
||||||
|
Verify the first block (already working) and ADD the TLS block.
|
||||||
|
|
||||||
|
```conf
|
||||||
|
# --- core (already working on UDP 3478) ---
|
||||||
|
listening-port=3478 # serves BOTH UDP and TCP on 3478
|
||||||
|
fingerprint
|
||||||
|
lt-cred-mech
|
||||||
|
realm=remote.bizgaze.com
|
||||||
|
external-ip=118.95.33.89 # coturn server's PUBLIC ip (from the relay result)
|
||||||
|
user=USERNAME:PASSWORD # the TURN username:password
|
||||||
|
|
||||||
|
# --- ADD: TLS on 5349 (turns:) ---
|
||||||
|
tls-listening-port=5349
|
||||||
|
cert=/etc/letsencrypt/live/remote.bizgaze.com/fullchain.pem
|
||||||
|
pkey=/etc/letsencrypt/live/remote.bizgaze.com/privkey.pem
|
||||||
|
|
||||||
|
# --- relay media port range (must be open in the firewall) ---
|
||||||
|
min-port=49152
|
||||||
|
max-port=65535
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- TLS needs a cert for `remote.bizgaze.com`. Nginx Proxy Manager already issues a
|
||||||
|
Let's Encrypt cert for that host — point coturn at those `fullchain.pem` /
|
||||||
|
`privkey.pem` (copy or mount them so coturn can read them).
|
||||||
|
- TCP 3478 alone already widens coverage a lot; TLS/5349 can be added later.
|
||||||
|
|
||||||
|
Restart coturn after editing:
|
||||||
|
```
|
||||||
|
systemctl restart coturn # or: restart the coturn container
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Firewall / cloud security group — open these ports
|
||||||
|
- UDP 3478 (already open — relay works)
|
||||||
|
- TCP 3478 <- add
|
||||||
|
- TCP 5349 <- add (only if doing TLS)
|
||||||
|
- UDP 49152-65535 (relay media range; should already be open)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. App .env (next to docker-compose.yml)
|
||||||
|
|
||||||
|
Point BizGaze Connect at coturn. Without TLS yet:
|
||||||
|
```env
|
||||||
|
TURN_URLS=turn:remote.bizgaze.com:3478,turn:remote.bizgaze.com:3478?transport=tcp
|
||||||
|
TURN_USERNAME=your-coturn-username
|
||||||
|
TURN_CREDENTIAL=your-coturn-password
|
||||||
|
```
|
||||||
|
|
||||||
|
After TLS (5349) is confirmed working, use:
|
||||||
|
```env
|
||||||
|
TURN_URLS=turn:remote.bizgaze.com:3478,turn:remote.bizgaze.com:3478?transport=tcp,turns:remote.bizgaze.com:5349?transport=tcp
|
||||||
|
TURN_USERNAME=your-coturn-username
|
||||||
|
TURN_CREDENTIAL=your-coturn-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload the app:
|
||||||
|
```
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Verify
|
||||||
|
1. Open `https://remote.bizgaze.com/api/ice` — should show the
|
||||||
|
`remote.bizgaze.com` TURN entry with the username.
|
||||||
|
(The app only *sends* TURN to mobile clients by design, but /api/ice still lists it.)
|
||||||
|
2. Trickle ICE test (https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/):
|
||||||
|
- Add `turn:remote.bizgaze.com:3478?transport=tcp` + username + credential → expect a `relay` row.
|
||||||
|
- If TLS is set up, also test `turns:remote.bizgaze.com:5349?transport=tcp`.
|
||||||
|
A `relay` candidate = success. No relay row = TURN not reachable on that transport.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# LiveKit SFU config (non-secret — the API key/secret are injected via the LIVEKIT_KEYS env var
|
||||||
|
# in docker-compose, sourced from .env, so nothing secret lives in git).
|
||||||
|
#
|
||||||
|
# Media plane: LiveKit needs UDP reachable from clients (NPM only proxies the HTTP/WS signaling on
|
||||||
|
# 7880). The UDP range + TCP fallback below are published as HOST ports in docker-compose. On the
|
||||||
|
# VPS, if the server sits behind NAT and can't auto-detect its public IP, set rtc.node_ip to it.
|
||||||
|
port: 7880 # signaling (HTTP/WS) — NPM proxies wss://livekit.bizgaze.com -> here
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881 # WebRTC-over-TCP fallback (restrictive networks)
|
||||||
|
udp_port: 50000 # SINGLE UDP media port (all participants mux over it) — minimizes
|
||||||
|
# the NAT port-forward to one UDP + one TCP port.
|
||||||
|
# This box sits behind NAT (private 192.168.88.61 behind public 118.95.33.89). Auto-detection
|
||||||
|
# would find the wrong (outbound) IP, so pin the inbound public IP clients actually reach.
|
||||||
|
use_external_ip: false
|
||||||
|
node_ip: 118.95.33.89
|
||||||
|
|
||||||
|
# Embedded TURN over TLS on 443 helps clients on locked-down networks. Left off by default because
|
||||||
|
# NPM already owns 443; enable via a dedicated hostname + NPM stream if you need it (see DEPLOY.md).
|
||||||
|
turn:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Biz Connect — Android build & push setup
|
||||||
|
|
||||||
|
Step-by-step to go from this repo to a running Android app with working FCM push.
|
||||||
|
You have **Android Studio (Quail 2026.1.1)** installed — that bundles the Android SDK and a
|
||||||
|
JDK, so no separate Java install is needed.
|
||||||
|
|
||||||
|
> The app is a Capacitor shell that loads the live Connect UI (`server.url` in
|
||||||
|
> `capacitor.config.json`, default `https://remote.bizgaze.com`). The native side only adds
|
||||||
|
> push, camera/mic, status bar, and store packaging. App id / Android package:
|
||||||
|
> **`com.bizgaze.connect`** — this must match the Firebase app you create below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. One-time Android Studio setup
|
||||||
|
1. Launch Android Studio once and let it finish "SDK Components Setup" (downloads the
|
||||||
|
Android SDK + platform-tools).
|
||||||
|
2. **More Actions → SDK Manager** → install **Android SDK Platform 34** (or latest) and
|
||||||
|
**Android SDK Build-Tools**.
|
||||||
|
3. To test on an emulator: **More Actions → Virtual Device Manager** → create a Pixel device
|
||||||
|
(any recent API ≥ 33 so you can test the notification permission prompt). Or enable
|
||||||
|
**USB debugging** on a physical phone and plug it in.
|
||||||
|
|
||||||
|
## 2. Generate the native Android project
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
npm install
|
||||||
|
npm run assets # builds icons/splash from resources/ (already provided)
|
||||||
|
npx cap add android # creates mobile/android/ (gitignored)
|
||||||
|
npx cap sync # copies config + web assets + plugins into the project
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. App permissions
|
||||||
|
Capacitor adds `INTERNET` automatically. Add the rest to
|
||||||
|
`mobile/android/app/src/main/AndroidManifest.xml` (inside `<manifest>`, above `<application>`).
|
||||||
|
A ready-to-paste copy is in [`android-permissions.xml`](android-permissions.xml):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Push (Android 13+ runtime prompt) -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- Voice / video calls + camera from the web UI -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
```
|
||||||
|
> WebRTC in the WebView: Capacitor grants `getUserMedia` to the page when the app holds the
|
||||||
|
> CAMERA/RECORD_AUDIO permissions, so the existing call/screen-share UI works once these are
|
||||||
|
> present and the user accepts the runtime prompts.
|
||||||
|
|
||||||
|
## 4. Firebase / FCM (push)
|
||||||
|
1. [Firebase console](https://console.firebase.google.com) → **Add project** (or reuse one).
|
||||||
|
2. **Add app → Android**. Package name: **`com.bizgaze.connect`**. Register.
|
||||||
|
3. Download **`google-services.json`** → place it in **`mobile/android/app/`**.
|
||||||
|
4. Add the Google Services Gradle plugin (Capacitor 6 template):
|
||||||
|
- `mobile/android/build.gradle` → `buildscript { dependencies { ... } }`:
|
||||||
|
```gradle
|
||||||
|
classpath 'com.google.gms:google-services:4.4.2'
|
||||||
|
```
|
||||||
|
- **bottom** of `mobile/android/app/build.gradle`:
|
||||||
|
```gradle
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
```
|
||||||
|
5. `npx cap sync` again.
|
||||||
|
|
||||||
|
That's the **client** half. The **server** half (already built) needs the matching credential:
|
||||||
|
- Firebase console → **Project settings → Service accounts → Generate new private key** →
|
||||||
|
download the JSON.
|
||||||
|
- On the production server, set **`FCM_SERVICE_ACCOUNT`** to that file's path (or its inline
|
||||||
|
JSON) and restart. See [../DEPLOY.md](../DEPLOY.md). With that set, `push.sendToUser`
|
||||||
|
delivers to Android devices automatically; the app already registers its token via
|
||||||
|
`POST /api/v1/devices` on launch (see `setupNativePush` in `server/public/home.html`).
|
||||||
|
|
||||||
|
## 5. Run it
|
||||||
|
```bash
|
||||||
|
npx cap open android # opens the project in Android Studio → press Run ▶
|
||||||
|
# or headless:
|
||||||
|
npx cap run android
|
||||||
|
```
|
||||||
|
First launch will prompt for notifications (Android 13+); accept it, then check the server log
|
||||||
|
/ DB `device_tokens` shows a row for your user.
|
||||||
|
|
||||||
|
### Testing against a LOCAL dev server (optional)
|
||||||
|
The app points at `https://remote.bizgaze.com` by default. To hit your laptop instead, edit
|
||||||
|
`capacitor.config.json`:
|
||||||
|
```json
|
||||||
|
"server": { "url": "http://<your-LAN-IP>:8090", "cleartext": true, "androidScheme": "https" }
|
||||||
|
```
|
||||||
|
then `npx cap sync`. (`cleartext` is required for plain `http`.) Revert before shipping.
|
||||||
|
|
||||||
|
## 6. Build for the Play Store
|
||||||
|
1. Create an upload keystore (once):
|
||||||
|
```bash
|
||||||
|
keytool -genkey -v -keystore biz-connect.keystore -alias bizconnect -keyalg RSA -keysize 2048 -validity 10000
|
||||||
|
```
|
||||||
|
2. Android Studio → **Build → Generate Signed App Bundle** → AAB → select the keystore.
|
||||||
|
(Or `cd android && ./gradlew bundleRelease`.)
|
||||||
|
3. Upload the `.aab` to **Google Play Console** (one-time $25 developer account). Fill in the
|
||||||
|
store listing, data-safety form (declare camera/mic/notifications), and roll out to
|
||||||
|
internal testing first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Android Studio SDK + an emulator/device ready
|
||||||
|
- [ ] `npm install` → `npm run assets` → `npx cap add android` → `npx cap sync`
|
||||||
|
- [ ] Permissions added to AndroidManifest
|
||||||
|
- [ ] `google-services.json` in `android/app/` + Gradle plugin lines + `cap sync`
|
||||||
|
- [ ] App runs; notification permission accepted; `device_tokens` row appears
|
||||||
|
- [ ] Server `FCM_SERVICE_ACCOUNT` set in prod → end-to-end push works
|
||||||
|
- [ ] Signed AAB built and uploaded to Play (internal testing)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Connecting Codemagic to our self-hosted Gitea (SSH)
|
||||||
|
|
||||||
|
We build the iOS app on Codemagic's macOS cloud (no Mac needed). Codemagic must clone the repo from
|
||||||
|
`code.bizgaze.com`, but that Gitea only exposes **HTTPS (443)** — its **SSH port is not reachable** from
|
||||||
|
the internet, so Codemagic can't connect yet. This is a one-time infra + Codemagic setup.
|
||||||
|
|
||||||
|
## Part 1 — IT: expose Gitea's SSH port (git host `118.95.33.93`)
|
||||||
|
1. **Find Gitea's SSH port.** In `app.ini` → `[server]` → `SSH_PORT` / `SSH_LISTEN_PORT`. Or open a repo
|
||||||
|
in the Gitea web UI → **clone dropdown → SSH** and read the port in the URL, e.g.
|
||||||
|
`ssh://git@code.bizgaze.com:2222/Sravan/BizGaze_Remote.git`.
|
||||||
|
2. **Port-forward a public TCP port → that Gitea SSH port.** Suggested public port: **2222**.
|
||||||
|
- ⚠️ Nginx Proxy Manager proxies HTTP/HTTPS only. SSH needs a **raw TCP forward** at the firewall/router
|
||||||
|
(or an NPM **Stream** rule) — not an HTTP proxy host.
|
||||||
|
3. Confirm reachable, then send the DevOps/AI the **SSH clone URL** (with port) to verify.
|
||||||
|
|
||||||
|
Security: Gitea SSH is **key-only** (no password auth), same model as GitHub's public port 22. Access is
|
||||||
|
further limited to a **read-only deploy key** (below), so a leaked key could only *read* this one repo.
|
||||||
|
|
||||||
|
## Part 2 — Codemagic: connect the repo (once the port is open)
|
||||||
|
1. Codemagic → **Add application → "Other"** (self-hosted / SSH) → paste the SSH clone URL.
|
||||||
|
2. Copy the **SSH public key** Codemagic shows.
|
||||||
|
3. Gitea → this repo → **Settings → Deploy Keys → Add Deploy Key** → paste it, **Enable write access = OFF**.
|
||||||
|
4. Codemagic **Test connection** → it clones and reads [`codemagic.yaml`](../codemagic.yaml).
|
||||||
|
|
||||||
|
Then follow [IOS_SETUP.md](IOS_SETUP.md) for the App Store Connect key + first build.
|
||||||
|
|
||||||
|
## Auto-build on push (optional, later)
|
||||||
|
Manual **Start build** works immediately. Automatic builds on push work natively only for
|
||||||
|
GitHub/GitLab/Bitbucket; for Gitea we'd add a webhook Codemagic can accept — a later nicety, not required.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Biz Connect — iOS App Store setup (Codemagic, no Mac needed)
|
||||||
|
|
||||||
|
The iOS app is a Capacitor shell that loads the live Connect web UI (`https://remote.bizgaze.com`).
|
||||||
|
Building/signing/uploading happens on **Codemagic's macOS cloud** — you never need a Mac.
|
||||||
|
|
||||||
|
Bundle id: **`com.bizgaze.connect`** · CI config: [`codemagic.yaml`](../codemagic.yaml) (repo root).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 0 — Register the App ID (Identifiers → + → App IDs → App)
|
||||||
|
On the **Register an App ID** page, only three fields matter — leave everything else default:
|
||||||
|
- **Platform**: leave as-is (the default `iOS, iPadOS, macOS…` combined App ID is fine).
|
||||||
|
- **Description**: `Biz Connect` (label only; no `@ & * "`).
|
||||||
|
- **Bundle ID**: keep **Explicit** → `com.bizgaze.connect`.
|
||||||
|
- **Capabilities**: tick **Push Notifications** only. Leave all others unchecked. (Camera/mic are NOT
|
||||||
|
here — they're Info.plist runtime strings, added by the build pipeline.)
|
||||||
|
- Continue → Register. *(The App ID Prefix shown is your Team ID — note it for Step 5's APNs.)*
|
||||||
|
|
||||||
|
## Step 1 — App Store Connect: create the app record
|
||||||
|
1. [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → **Apps → +** → **New App**.
|
||||||
|
2. Platform **iOS**, Name **Biz Connect**, primary language, **Bundle ID** = `com.bizgaze.connect`
|
||||||
|
(the App ID you registered in Step 0 now appears in the dropdown).
|
||||||
|
3. SKU: anything unique (e.g. `bizconnect-ios`). Create.
|
||||||
|
|
||||||
|
## Step 2 — App Store Connect API key (for Codemagic to sign + upload)
|
||||||
|
1. App Store Connect → **Users and Access → Integrations → App Store Connect API** → **+**.
|
||||||
|
2. Access **App Manager**. Generate. Note the **Issuer ID** (top of the page) and the key's **Key ID**,
|
||||||
|
and **download the `.p8`** (you can only download it once).
|
||||||
|
|
||||||
|
## Step 3 — Codemagic: connect + add the key
|
||||||
|
1. [codemagic.io](https://codemagic.io) → sign in with the git provider → add this repository.
|
||||||
|
2. **Teams → Integrations → App Store Connect → Connect**, upload the `.p8`, paste the **Issuer ID** and
|
||||||
|
**Key ID**. **Name it exactly `BizGaze App Store Connect`** (the `codemagic.yaml` references that name).
|
||||||
|
3. Codemagic detects `codemagic.yaml`. That's all the signing setup — automatic signing creates the
|
||||||
|
distribution certificate + provisioning profile from this key on the first build.
|
||||||
|
|
||||||
|
## Step 4 — Run the build
|
||||||
|
- Codemagic → the app → **Start new build** → workflow **"Biz Connect iOS → TestFlight"**.
|
||||||
|
- ~10–15 min. On success the build appears in **App Store Connect → TestFlight**.
|
||||||
|
- Add yourself under **TestFlight → Internal Testing** to install via the TestFlight app on your iPhone.
|
||||||
|
|
||||||
|
## Step 5 — Push notifications (APNs) — do this once, then tell me
|
||||||
|
So the app gets **calls/messages while it's closed**:
|
||||||
|
1. developer.apple.com → **Keys → +** → enable **Apple Push Notifications service (APNs)** → download the
|
||||||
|
**`.p8`**. Note its **Key ID** and your **Team ID** (top-right of the developer portal).
|
||||||
|
2. **Send me**: the `.p8` contents, the **Key ID**, and the **Team ID**. I set these in the server `.env`
|
||||||
|
(server-side only, like the LiveKit/Giphy keys):
|
||||||
|
```
|
||||||
|
APNS_KEY=<contents of the .p8>
|
||||||
|
APNS_KEY_ID=<key id>
|
||||||
|
APNS_TEAM_ID=<team id>
|
||||||
|
APNS_BUNDLE_ID=com.bizgaze.connect
|
||||||
|
APNS_PRODUCTION=1
|
||||||
|
```
|
||||||
|
The APNs sender is already built into the server — it's a no-op until these are set.
|
||||||
|
|
||||||
|
## Step 6 — Public App Store submission (when you're ready to leave TestFlight)
|
||||||
|
In App Store Connect, fill the listing: **screenshots** (6.7" + 6.1" iPhone), description, keywords,
|
||||||
|
support URL, and a **Privacy Policy URL** (required). Complete the **App Privacy** questionnaire (we
|
||||||
|
collect account info + usage for chat/calls). Then submit for review (or flip `submit_to_app_store` in
|
||||||
|
`codemagic.yaml`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### App Review note (Guideline 4.2 — "Minimum Functionality")
|
||||||
|
Apple scrutinises apps that look like "just a website". Ours passes because it ships **real native
|
||||||
|
capabilities** — push notifications, camera/microphone for calls, photo sharing. Make sure push (Step 5)
|
||||||
|
is live before the **public** submission, and in the reviewer notes mention the **native video/voice
|
||||||
|
calling + push notifications**. Do **not** advertise "share your screen" as an iOS feature in the store
|
||||||
|
listing yet — see the follow-up below (you can still *view* a screen someone else shares).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known iOS limitations & follow-ups (phase 2 — after TestFlight)
|
||||||
|
|
||||||
|
### 1. Sharing YOUR iOS screen into a meeting → needs a ReplayKit Broadcast Upload Extension
|
||||||
|
- **Why:** the app's screen share uses the web `getDisplayMedia` API, which **iOS WebViews and Safari do
|
||||||
|
not support**. Apple only allows capturing the *device* screen via **ReplayKit**.
|
||||||
|
- **What works today on iOS:** *viewing* a screen another participant shares (it's just incoming video),
|
||||||
|
chat, voice/video calls, camera, photo sharing.
|
||||||
|
- **What's needed to broadcast the iOS screen:** a native **Broadcast Upload Extension** target that
|
||||||
|
captures frames via ReplayKit and feeds them into the LiveKit/WebRTC session, plus the **App Groups**
|
||||||
|
capability (to pass data between the app and the extension). This is native Swift work — NOT part of
|
||||||
|
the Capacitor wrapper — so it's tracked as a separate task, done after the app is on TestFlight.
|
||||||
|
- **Store impact:** don't claim iOS screen-sharing in the listing until this ships, or a reviewer may
|
||||||
|
test it and it will fail.
|
||||||
|
|
||||||
|
### 2. Native mobile audio routing (speaker / earpiece / Bluetooth) — needs a Capacitor audio plugin
|
||||||
|
- Mobile **web** can't switch the audio output route (`setSinkId` is unimplemented on iOS/Android), so the
|
||||||
|
in-meeting speaker/earpiece/Bluetooth control is web-only where it works and hidden where it doesn't.
|
||||||
|
- True routing on iOS needs a small native plugin driving `AVAudioSession`. Phase-2 native task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Share Extension ("Biz Connect" in the iOS share sheet) — one-time Apple portal setup
|
||||||
|
|
||||||
|
The app now has a **Share Extension** target (`com.bizgaze.connect.share`) so users can share a photo /
|
||||||
|
video / file FROM the Photos or Files app INTO a Biz Connect conversation. The Codemagic build injects the
|
||||||
|
target and fetches a profile for it automatically, but two things can ONLY be done once, by hand, in the
|
||||||
|
Apple Developer portal — CI cannot toggle App capabilities:
|
||||||
|
|
||||||
|
1. **Create the App Group** (developer.apple.com → Identifiers → App Groups → +):
|
||||||
|
identifier **`group.com.bizgaze.connect`**.
|
||||||
|
2. **Enable the App Groups capability on BOTH App IDs** and assign them to that group:
|
||||||
|
- `com.bizgaze.connect` (the app)
|
||||||
|
- `com.bizgaze.connect.share` (the extension — create this App ID if the first build hasn't yet;
|
||||||
|
`fetch-signing-files --create` will register it, then edit it to add App Groups)
|
||||||
|
After enabling the capability, the provisioning profiles must be regenerated — the next Codemagic build
|
||||||
|
does that via `fetch-signing-files`, so just re-run it once the capability is on.
|
||||||
|
|
||||||
|
If the App Group isn't set up, the app and the extension can't see each other's files: sharing will appear
|
||||||
|
to do nothing (the extension stages the file, but the app finds an empty inbox). Everything else — download
|
||||||
|
to the Files folder, the Photos "Connect" album, Manage storage — works without it.
|
||||||
|
|
||||||
|
Also enabled by this change (main app Info.plist, done automatically by `ios-patch.sh`):
|
||||||
|
- `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` → the **Biz Connect** folder in Files.
|
||||||
|
- `CFBundleURLTypes` scheme **`bizconnect`** → lets the extension bounce back into the app after staging.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Biz Connect — Mobile app (Capacitor)
|
||||||
|
|
||||||
|
A Capacitor shell that loads the live Connect web UI (`server.url` in
|
||||||
|
`capacitor.config.json`) and adds native push, camera/mic, and store distribution. See the
|
||||||
|
overall plan in [../CLIENTS.md](../CLIENTS.md).
|
||||||
|
|
||||||
|
> **Android:** follow the step-by-step in **[ANDROID_SETUP.md](ANDROID_SETUP.md)** (project
|
||||||
|
> generation, icons/splash, permissions, Firebase/FCM, run, and Play Store build).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Node + `npm install` here.
|
||||||
|
- **Android:** Android Studio + SDK.
|
||||||
|
- **iOS:** macOS + Xcode (+ an Apple Developer account to run on device / ship).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run assets # generate app icons + splash from resources/
|
||||||
|
npx cap add android
|
||||||
|
npx cap add ios # macOS only
|
||||||
|
npx cap sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run / build
|
||||||
|
```bash
|
||||||
|
npx cap open android # build & run from Android Studio
|
||||||
|
npx cap open ios # build & run from Xcode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server origin
|
||||||
|
The app loads `server.url` from `capacitor.config.json` (default
|
||||||
|
`https://remote.bizgaze.com`). For a local device test against a dev server, set it to your
|
||||||
|
machine's LAN URL (and allow cleartext for plain http).
|
||||||
|
|
||||||
|
## Native push (next step)
|
||||||
|
Native push uses the Capacitor Push Notifications plugin (FCM on Android, APNs on iOS) and a
|
||||||
|
server endpoint to register device tokens — tracked in [../CLIENTS.md](../CLIENTS.md) Phase B.
|
||||||
|
This is separate from the existing Web Push (VAPID) the PWA already uses. Needs Google/Apple
|
||||||
|
credentials to test end-to-end.
|
||||||
|
|
||||||
|
## Shipping (gated on accounts)
|
||||||
|
- **Google Play:** one-time $25; upload an AAB; signing key.
|
||||||
|
- **App Store:** Apple Developer $99/yr; archive via Xcode; App Store Connect listing.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.bizgaze.connect",
|
||||||
|
"appName": "Biz Connect",
|
||||||
|
"webDir": "www",
|
||||||
|
"server": {
|
||||||
|
"url": "https://remote.bizgaze.com",
|
||||||
|
"cleartext": false,
|
||||||
|
"androidScheme": "https"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"PushNotifications": {
|
||||||
|
"presentationOptions": ["badge", "sound", "alert"]
|
||||||
|
},
|
||||||
|
"SafeArea": {
|
||||||
|
"detectViewportFitCoverChanges": true,
|
||||||
|
"initialViewportFitCover": true,
|
||||||
|
"offsetForKeyboardInsetBug": false
|
||||||
|
},
|
||||||
|
"Keyboard": {
|
||||||
|
"resize": "none"
|
||||||
|
},
|
||||||
|
"SplashScreen": {
|
||||||
|
"launchShowDuration": 900,
|
||||||
|
"launchAutoHide": true,
|
||||||
|
"backgroundColor": "#16294F",
|
||||||
|
"showSpinner": false,
|
||||||
|
"iosSpinnerStyle": "large"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.bizgaze.connect</string>
|
||||||
|
</array>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>production</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Biz Connect</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<!-- No storyboard: the extension has no UI of its own (see ShareViewController). -->
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<!-- What Biz Connect offers to accept from the share sheet. Without a matching rule here the
|
||||||
|
app simply does not appear for that content type. -->
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||||
|
<integer>20</integer>
|
||||||
|
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||||
|
<integer>20</integer>
|
||||||
|
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NSExtensionActivationSupportsText</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.bizgaze.connect</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
// Share Extension — puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way
|
||||||
|
// Teams/WhatsApp do: pick one or more chats in the sheet, it uploads and sends, no app-open needed.
|
||||||
|
//
|
||||||
|
// How it can send without the app: the app writes a bearer token + API base into the App Group each launch
|
||||||
|
// (via /api/share/token → ShareInbox.setAuth). This extension reads that token and calls the same server
|
||||||
|
// API the native client uses — GET /api/messages/conversations, POST /api/messages/upload, POST
|
||||||
|
// /api/messages.
|
||||||
|
//
|
||||||
|
// Manifest lifecycle (why the app doesn't also pop a "Send to" sheet): the App Group manifest represents an
|
||||||
|
// UNSENT share. We only write it when this extension can't send (no token) or a send fails — so a
|
||||||
|
// successful in-sheet send leaves nothing behind and the app never re-offers it. Cancel/success clear any
|
||||||
|
// staged files too, so the App Group doesn't accumulate orphans.
|
||||||
|
|
||||||
|
struct ShareChat {
|
||||||
|
let kind: String // "dm" | "group"
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let avatar: String?
|
||||||
|
let subtitle: String
|
||||||
|
var key: String { kind + ":" + id }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ShareItem {
|
||||||
|
let name: String
|
||||||
|
let url: URL
|
||||||
|
let mime: String
|
||||||
|
let isText: Bool
|
||||||
|
let text: String
|
||||||
|
var isImage: Bool { mime.hasPrefix("image/") }
|
||||||
|
var isVideo: Bool { mime.hasPrefix("video/") }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
|
||||||
|
private let appGroup = "group.com.bizgaze.connect"
|
||||||
|
private let maxItems = 20
|
||||||
|
private let brandNavy = UIColor(red: 0x1F/255.0, green: 0x3B/255.0, blue: 0x73/255.0, alpha: 1)
|
||||||
|
|
||||||
|
private var token = ""
|
||||||
|
private var apiBase = "https://remote.bizgaze.com"
|
||||||
|
private var items: [ShareItem] = []
|
||||||
|
private var chats: [ShareChat] = []
|
||||||
|
private var filtered: [ShareChat] = []
|
||||||
|
private var selected = Set<String>()
|
||||||
|
private var avatarCache: [String: UIImage] = [:]
|
||||||
|
private var sending = false
|
||||||
|
|
||||||
|
private let table = UITableView(frame: .zero, style: .grouped)
|
||||||
|
private let search = UISearchBar()
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let previewStack = UIStackView()
|
||||||
|
private let previewScroll = UIScrollView()
|
||||||
|
private var sendButton: UIBarButtonItem!
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
readAuth()
|
||||||
|
buildUI()
|
||||||
|
ingest()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readAuth() {
|
||||||
|
if let d = UserDefaults(suiteName: appGroup) {
|
||||||
|
token = d.string(forKey: "bzc_token") ?? ""
|
||||||
|
let b = d.string(forKey: "bzc_base") ?? ""
|
||||||
|
if !b.isEmpty { apiBase = b }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
view.backgroundColor = .systemGroupedBackground
|
||||||
|
|
||||||
|
let nav = UINavigationBar()
|
||||||
|
nav.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithOpaqueBackground()
|
||||||
|
appearance.backgroundColor = brandNavy
|
||||||
|
appearance.titleTextAttributes = [.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 17, weight: .bold)]
|
||||||
|
nav.standardAppearance = appearance
|
||||||
|
nav.scrollEdgeAppearance = appearance
|
||||||
|
nav.tintColor = .white
|
||||||
|
let navItem = UINavigationItem(title: "Share to Biz Connect")
|
||||||
|
// Clean, light SF Symbols instead of a heavy grey X-circle / bold pill (thin xmark + paper-plane).
|
||||||
|
let symCfg = UIImage.SymbolConfiguration(pointSize: 16, weight: .regular)
|
||||||
|
navItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "xmark", withConfiguration: symCfg), style: .plain, target: self, action: #selector(cancelTapped))
|
||||||
|
let sendCfg = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
||||||
|
sendButton = UIBarButtonItem(image: UIImage(systemName: "paperplane.fill", withConfiguration: sendCfg), style: .plain, target: self, action: #selector(sendTapped))
|
||||||
|
sendButton.isEnabled = false
|
||||||
|
navItem.rightBarButtonItem = sendButton
|
||||||
|
nav.setItems([navItem], animated: false)
|
||||||
|
view.addSubview(nav)
|
||||||
|
|
||||||
|
// Preview strip — thumbnails of what's being shared (like Teams' attachment row).
|
||||||
|
previewScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
previewScroll.showsHorizontalScrollIndicator = false
|
||||||
|
previewStack.axis = .horizontal
|
||||||
|
previewStack.spacing = 8
|
||||||
|
previewStack.alignment = .center
|
||||||
|
previewStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
previewScroll.addSubview(previewStack)
|
||||||
|
view.addSubview(previewScroll)
|
||||||
|
|
||||||
|
search.placeholder = "Search for people or groups"
|
||||||
|
search.delegate = self
|
||||||
|
search.searchBarStyle = .minimal
|
||||||
|
search.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(search)
|
||||||
|
|
||||||
|
table.dataSource = self
|
||||||
|
table.delegate = self
|
||||||
|
table.rowHeight = 60
|
||||||
|
table.backgroundColor = .systemGroupedBackground
|
||||||
|
table.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(table)
|
||||||
|
|
||||||
|
statusLabel.font = .systemFont(ofSize: 15)
|
||||||
|
statusLabel.textColor = .secondaryLabel
|
||||||
|
statusLabel.textAlignment = .center
|
||||||
|
statusLabel.numberOfLines = 0
|
||||||
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(statusLabel)
|
||||||
|
|
||||||
|
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
spinner.hidesWhenStopped = true
|
||||||
|
view.addSubview(spinner)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
nav.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
nav.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
|
||||||
|
previewScroll.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 10),
|
||||||
|
previewScroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||||
|
previewScroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||||
|
previewScroll.heightAnchor.constraint(equalToConstant: 56),
|
||||||
|
previewStack.topAnchor.constraint(equalTo: previewScroll.topAnchor),
|
||||||
|
previewStack.bottomAnchor.constraint(equalTo: previewScroll.bottomAnchor),
|
||||||
|
previewStack.leadingAnchor.constraint(equalTo: previewScroll.leadingAnchor),
|
||||||
|
previewStack.trailingAnchor.constraint(equalTo: previewScroll.trailingAnchor),
|
||||||
|
previewStack.heightAnchor.constraint(equalTo: previewScroll.heightAnchor),
|
||||||
|
|
||||||
|
search.topAnchor.constraint(equalTo: previewScroll.bottomAnchor, constant: 8),
|
||||||
|
search.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6),
|
||||||
|
search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6),
|
||||||
|
|
||||||
|
table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2),
|
||||||
|
table.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
table.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
table.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
|
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
|
||||||
|
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
|
||||||
|
spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
spinner.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 14)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setStatus(_ text: String?, busy: Bool = false) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.statusLabel.text = text
|
||||||
|
self.statusLabel.isHidden = (text == nil)
|
||||||
|
if busy { self.spinner.startAnimating() } else { self.spinner.stopAnimating() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSendButton() {
|
||||||
|
// Icon-only paper-plane (like Teams); the radio checks show what's selected. Just enable/disable.
|
||||||
|
sendButton.isEnabled = !selected.isEmpty && !sending
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview thumbnails
|
||||||
|
|
||||||
|
private func buildPreviews() {
|
||||||
|
for f in items where !f.isText {
|
||||||
|
let iv = UIImageView()
|
||||||
|
iv.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
iv.contentMode = .scaleAspectFill
|
||||||
|
iv.clipsToBounds = true
|
||||||
|
iv.layer.cornerRadius = 8
|
||||||
|
iv.backgroundColor = .tertiarySystemFill
|
||||||
|
iv.tintColor = .secondaryLabel
|
||||||
|
iv.widthAnchor.constraint(equalToConstant: 56).isActive = true
|
||||||
|
iv.heightAnchor.constraint(equalToConstant: 56).isActive = true
|
||||||
|
iv.image = UIImage(systemName: f.isVideo ? "video.fill" : "doc.fill")
|
||||||
|
previewStack.addArrangedSubview(iv)
|
||||||
|
thumbnail(for: f) { img in if let img = img { DispatchQueue.main.async { iv.image = img; iv.contentMode = .scaleAspectFill } } }
|
||||||
|
}
|
||||||
|
if items.contains(where: { $0.isText }) {
|
||||||
|
let lbl = UILabel()
|
||||||
|
lbl.text = "🔗 link"
|
||||||
|
lbl.font = .systemFont(ofSize: 13)
|
||||||
|
lbl.textColor = .secondaryLabel
|
||||||
|
previewStack.addArrangedSubview(lbl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thumbnail(for item: ShareItem, completion: @escaping (UIImage?) -> Void) {
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
if item.isImage, let img = UIImage(contentsOfFile: item.url.path) {
|
||||||
|
return completion(img)
|
||||||
|
}
|
||||||
|
if item.isVideo {
|
||||||
|
let asset = AVURLAsset(url: item.url)
|
||||||
|
let gen = AVAssetImageGenerator(asset: asset)
|
||||||
|
gen.appliesPreferredTrackTransform = true
|
||||||
|
gen.maximumSize = CGSize(width: 168, height: 168)
|
||||||
|
if let cg = try? gen.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil) {
|
||||||
|
return completion(UIImage(cgImage: cg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ingest
|
||||||
|
|
||||||
|
private func ingest() {
|
||||||
|
setStatus("Preparing…", busy: true)
|
||||||
|
let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
|
||||||
|
.flatMap { $0.attachments ?? [] }
|
||||||
|
.prefix(maxItems)
|
||||||
|
guard !providers.isEmpty else { return finishFail("Nothing to share.") }
|
||||||
|
|
||||||
|
var collected: [ShareItem] = []
|
||||||
|
let lock = NSLock()
|
||||||
|
let group = DispatchGroup()
|
||||||
|
for provider in providers {
|
||||||
|
group.enter()
|
||||||
|
load(provider) { item in
|
||||||
|
if let item = item { lock.lock(); collected.append(item); lock.unlock() }
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.notify(queue: .main) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.items = collected
|
||||||
|
if collected.isEmpty { return self.finishFail("Couldn’t read the shared file.") }
|
||||||
|
self.buildPreviews()
|
||||||
|
self.loadChats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(_ provider: NSItemProvider, completion: @escaping (ShareItem?) -> Void) {
|
||||||
|
let fileTypes: [UTType] = [.movie, .image, .audio, .pdf, .item]
|
||||||
|
if let type = fileTypes.first(where: { provider.hasItemConformingToTypeIdentifier($0.identifier) }) {
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { [weak self] url, _ in
|
||||||
|
guard let self = self, let url = url else { return completion(nil) }
|
||||||
|
completion(self.stage(url))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
|
||||||
|
if let u = item as? URL { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: u.absoluteString)) }
|
||||||
|
else { completion(nil) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in
|
||||||
|
if let s = item as? String { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: s)) }
|
||||||
|
else { completion(nil) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stage(_ src: URL) -> ShareItem? {
|
||||||
|
guard let dir = sharedDir() else { return nil }
|
||||||
|
let name = src.lastPathComponent.isEmpty ? "shared-file" : src.lastPathComponent
|
||||||
|
let dest = uniqueURL(in: dir, preferred: name)
|
||||||
|
do { try FileManager.default.copyItem(at: src, to: dest) } catch { return nil }
|
||||||
|
return ShareItem(name: dest.lastPathComponent, url: dest, mime: Self.mimeType(for: dest), isText: false, text: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat list
|
||||||
|
|
||||||
|
private func loadChats() {
|
||||||
|
guard !token.isEmpty else {
|
||||||
|
writeManifest(items) // no token → let the app pick these up
|
||||||
|
setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus("Loading your chats…", busy: true)
|
||||||
|
api("GET", "/api/messages/conversations") { [weak self] data, _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let data = data,
|
||||||
|
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
|
||||||
|
self.writeManifest(self.items)
|
||||||
|
self.setStatus("Couldn’t load your chats.\n\nOpen Biz Connect once, then try sharing again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let parsed: [ShareChat] = arr.compactMap { row in
|
||||||
|
guard let kind = row["kind"] as? String, let id = idString(row["id"]), let name = row["name"] as? String else { return nil }
|
||||||
|
let sub = kind == "group" ? "Group" + ((row["members"] as? Int).map { " · \($0) members" } ?? "") : "Direct message"
|
||||||
|
return ShareChat(kind: kind, id: id, name: name, avatar: row["avatar"] as? String, subtitle: sub)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.chats = parsed
|
||||||
|
self.filtered = parsed
|
||||||
|
self.setStatus(parsed.isEmpty ? "No chats yet." : nil)
|
||||||
|
self.table.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Table
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filtered.count }
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
filtered.isEmpty ? nil : "Recent chats"
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "chat") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "chat")
|
||||||
|
let c = filtered[indexPath.row]
|
||||||
|
cell.textLabel?.text = c.name
|
||||||
|
cell.textLabel?.font = .systemFont(ofSize: 16)
|
||||||
|
cell.detailTextLabel?.text = c.subtitle
|
||||||
|
cell.detailTextLabel?.textColor = .secondaryLabel
|
||||||
|
// Radio selector on the right (empty circle → filled navy check), always visible like Teams.
|
||||||
|
let selImg = selected.contains(c.key)
|
||||||
|
? UIImage(systemName: "checkmark.circle.fill")?.withTintColor(brandNavy, renderingMode: .alwaysOriginal)
|
||||||
|
: UIImage(systemName: "circle")?.withTintColor(.systemGray3, renderingMode: .alwaysOriginal)
|
||||||
|
let selView = UIImageView(image: selImg)
|
||||||
|
selView.frame = CGRect(x: 0, y: 0, width: 26, height: 26)
|
||||||
|
cell.accessoryView = selView
|
||||||
|
// Round avatar
|
||||||
|
cell.imageView?.layer.cornerRadius = 20
|
||||||
|
cell.imageView?.layer.masksToBounds = true
|
||||||
|
if let img = avatarCache[c.key] {
|
||||||
|
cell.imageView?.image = img
|
||||||
|
} else {
|
||||||
|
cell.imageView?.image = initialsImage(c.name)
|
||||||
|
loadAvatar(for: c)
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard !sending else { return }
|
||||||
|
let key = filtered[indexPath.row].key
|
||||||
|
if selected.contains(key) { selected.remove(key) } else { selected.insert(key) }
|
||||||
|
tableView.reloadRows(at: [indexPath], with: .none)
|
||||||
|
updateSendButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatars
|
||||||
|
|
||||||
|
private func loadAvatar(for chat: ShareChat) {
|
||||||
|
guard let av = chat.avatar, !av.isEmpty else { return }
|
||||||
|
if av.hasPrefix("data:") {
|
||||||
|
if let comma = av.firstIndex(of: ","),
|
||||||
|
let d = Data(base64Encoded: String(av[av.index(after: comma)...])),
|
||||||
|
let img = Self.circularImage(from: d) {
|
||||||
|
cache(img, for: chat)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let urlStr = av.hasPrefix("/") ? (apiBase + av) : av
|
||||||
|
guard let url = URL(string: urlStr) else { return }
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
if !token.isEmpty { req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") }
|
||||||
|
URLSession.shared.dataTask(with: req) { [weak self] d, _, _ in
|
||||||
|
guard let self = self, let d = d, let img = Self.circularImage(from: d) else { return }
|
||||||
|
self.cache(img, for: chat)
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cache(_ img: UIImage, for chat: ShareChat) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.avatarCache[chat.key] = img
|
||||||
|
if let idx = self.filtered.firstIndex(where: { $0.key == chat.key }) {
|
||||||
|
self.table.reloadRows(at: [IndexPath(row: idx, section: 0)], with: .none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialsImage(_ name: String, size: CGFloat = 40) -> UIImage {
|
||||||
|
let parts = name.split(separator: " ")
|
||||||
|
let initials = parts.prefix(2).compactMap { $0.first }.map { String($0) }.joined().uppercased()
|
||||||
|
let bg = Self.color(for: name)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
|
||||||
|
return renderer.image { _ in
|
||||||
|
bg.setFill()
|
||||||
|
UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).fill()
|
||||||
|
let para = NSMutableParagraphStyle(); para.alignment = .center
|
||||||
|
let attrs: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.systemFont(ofSize: size * 0.4, weight: .semibold),
|
||||||
|
.foregroundColor: UIColor.white,
|
||||||
|
.paragraphStyle: para
|
||||||
|
]
|
||||||
|
let s = (initials.isEmpty ? "?" : initials) as NSString
|
||||||
|
let ts = s.size(withAttributes: attrs)
|
||||||
|
s.draw(in: CGRect(x: 0, y: (size - ts.height) / 2, width: size, height: ts.height), withAttributes: attrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func circularImage(from data: Data, size: CGFloat = 40) -> UIImage? {
|
||||||
|
guard let img = UIImage(data: data) else { return nil }
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
|
||||||
|
return renderer.image { _ in
|
||||||
|
UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).addClip()
|
||||||
|
let scale = max(size / img.size.width, size / img.size.height)
|
||||||
|
let w = img.size.width * scale, h = img.size.height * scale
|
||||||
|
img.draw(in: CGRect(x: (size - w) / 2, y: (size - h) / 2, width: w, height: h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let palette: [UIColor] = [
|
||||||
|
UIColor(red: 0.20, green: 0.45, blue: 0.85, alpha: 1),
|
||||||
|
UIColor(red: 0.85, green: 0.35, blue: 0.45, alpha: 1),
|
||||||
|
UIColor(red: 0.30, green: 0.65, blue: 0.45, alpha: 1),
|
||||||
|
UIColor(red: 0.75, green: 0.55, blue: 0.20, alpha: 1),
|
||||||
|
UIColor(red: 0.50, green: 0.40, blue: 0.75, alpha: 1),
|
||||||
|
UIColor(red: 0.25, green: 0.60, blue: 0.70, alpha: 1)
|
||||||
|
]
|
||||||
|
private static func color(for name: String) -> UIColor {
|
||||||
|
var h: UInt32 = 0
|
||||||
|
for c in name.unicodeScalars { h = h &* 31 &+ c.value }
|
||||||
|
return palette[Int(h % UInt32(palette.count))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send
|
||||||
|
|
||||||
|
@objc private func sendTapped() {
|
||||||
|
guard !sending else { return }
|
||||||
|
let targets = chats.filter { selected.contains($0.key) }
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
sending = true
|
||||||
|
updateSendButton()
|
||||||
|
search.isHidden = true
|
||||||
|
table.isHidden = true
|
||||||
|
previewScroll.isHidden = true
|
||||||
|
let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats"
|
||||||
|
setStatus("Sending to \(label)…", busy: true)
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let files = self.items.filter { !$0.isText }
|
||||||
|
let texts = self.items.filter { $0.isText }.map { $0.text }
|
||||||
|
var ok = true
|
||||||
|
var attIds: [String] = []
|
||||||
|
for (i, f) in files.enumerated() {
|
||||||
|
if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)…", busy: true) }
|
||||||
|
guard let data = try? Data(contentsOf: f.url), let id = self.uploadSync(name: f.name, mime: f.mime, data: data) else { ok = false; break }
|
||||||
|
attIds.append(id)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
sendLoop: for chat in targets {
|
||||||
|
for id in attIds {
|
||||||
|
if !self.sendMessageSync(self.messageBody(chat: chat, attachmentId: id, text: nil)) { ok = false; break sendLoop }
|
||||||
|
}
|
||||||
|
if !texts.isEmpty {
|
||||||
|
if !self.sendMessageSync(self.messageBody(chat: chat, attachmentId: nil, text: texts.joined(separator: "\n"))) { ok = false; break sendLoop }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if ok {
|
||||||
|
self.clearStaged() // sent → leave nothing for the app to re-offer
|
||||||
|
self.setStatus("✓ Sent to \(label)")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
|
||||||
|
} else {
|
||||||
|
self.writeManifest(self.items) // failed → let the app pick these up
|
||||||
|
self.sending = false
|
||||||
|
self.search.isHidden = false
|
||||||
|
self.table.isHidden = false
|
||||||
|
self.previewScroll.isHidden = false
|
||||||
|
self.updateSendButton()
|
||||||
|
self.setStatus("Couldn’t send. Your file is saved — open Biz Connect to send it.")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { self.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func messageBody(chat: ShareChat, attachmentId: String?, text: String?) -> [String: Any] {
|
||||||
|
var b: [String: Any] = [:]
|
||||||
|
if chat.kind == "group" { b["group"] = chat.id } else { b["to"] = chat.id }
|
||||||
|
if let a = attachmentId { b["attachmentId"] = a }
|
||||||
|
if let t = text { b["body"] = t }
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Networking
|
||||||
|
|
||||||
|
private func uploadSync(name: String, mime: String, data: Data) -> String? {
|
||||||
|
guard let url = URL(string: apiBase + "/api/messages/upload") else { return nil }
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
|
||||||
|
req.setValue(mime.isEmpty ? "application/octet-stream" : mime, forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue(name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? name, forHTTPHeaderField: "X-Filename")
|
||||||
|
req.httpBody = data
|
||||||
|
req.timeoutInterval = 300
|
||||||
|
var out: String? = nil
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
URLSession.shared.dataTask(with: req) { d, resp, _ in
|
||||||
|
defer { sem.signal() }
|
||||||
|
guard let d = d, let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||||
|
let obj = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] else { return }
|
||||||
|
out = idString(obj["id"])
|
||||||
|
}.resume()
|
||||||
|
sem.wait()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendMessageSync(_ body: [String: Any]) -> Bool {
|
||||||
|
guard let url = URL(string: apiBase + "/api/messages"),
|
||||||
|
let json = try? JSONSerialization.data(withJSONObject: body) else { return false }
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = json
|
||||||
|
req.timeoutInterval = 60
|
||||||
|
var ok = false
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
URLSession.shared.dataTask(with: req) { _, resp, _ in
|
||||||
|
if let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) { ok = true }
|
||||||
|
sem.signal()
|
||||||
|
}.resume()
|
||||||
|
sem.wait()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
private func api(_ method: String, _ path: String, completion: @escaping (Data?, HTTPURLResponse?) -> Void) {
|
||||||
|
guard let url = URL(string: apiBase + path) else { return completion(nil, nil) }
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = method
|
||||||
|
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
|
||||||
|
req.timeoutInterval = 30
|
||||||
|
URLSession.shared.dataTask(with: req) { d, resp, _ in completion(d, resp as? HTTPURLResponse) }.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App Group storage
|
||||||
|
|
||||||
|
private func sharedDir() -> URL? {
|
||||||
|
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { return nil }
|
||||||
|
let dir = base.appendingPathComponent("Shared", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniqueURL(in dir: URL, preferred: String) -> URL {
|
||||||
|
let ext = (preferred as NSString).pathExtension
|
||||||
|
let stem = (preferred as NSString).deletingPathExtension
|
||||||
|
var candidate = dir.appendingPathComponent(preferred)
|
||||||
|
var i = 2
|
||||||
|
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
let next = ext.isEmpty ? "\(stem) (\(i))" : "\(stem) (\(i)).\(ext)"
|
||||||
|
candidate = dir.appendingPathComponent(next)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written only when this extension can't send — it marks an UNSENT share for the app to pick up.
|
||||||
|
private func writeManifest(_ its: [ShareItem]) {
|
||||||
|
guard let dir = sharedDir() else { return }
|
||||||
|
let records: [[String: Any]] = its.map { it in
|
||||||
|
it.isText ? ["kind": "text", "text": it.text]
|
||||||
|
: ["kind": "file", "name": it.name, "path": it.url.path, "mime": it.mime]
|
||||||
|
}
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: records) {
|
||||||
|
try? data.write(to: dir.appendingPathComponent("manifest.json"), options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the staged files + any manifest, so the App Group doesn't accumulate and the app has nothing
|
||||||
|
// to re-offer after a successful send or a cancel.
|
||||||
|
private func clearStaged() {
|
||||||
|
guard let dir = sharedDir() else { return }
|
||||||
|
let fm = FileManager.default
|
||||||
|
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
|
||||||
|
for e in entries { try? fm.removeItem(at: e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mimeType(for url: URL) -> String {
|
||||||
|
if let t = UTType(filenameExtension: url.pathExtension.lowercased()), let m = t.preferredMIMEType { return m }
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Finish
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
clearStaged() // nothing sent → don't leave orphans or let the app re-offer
|
||||||
|
extensionContext?.cancelRequest(withError: NSError(domain: "share", code: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish() {
|
||||||
|
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishFail(_ msg: String) {
|
||||||
|
setStatus(msg)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { self.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func idString(_ v: Any?) -> String? {
|
||||||
|
if let s = v as? String { return s }
|
||||||
|
if let n = v as? NSNumber { return n.stringValue }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ShareViewController: UISearchBarDelegate {
|
||||||
|
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||||
|
let q = searchText.trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
|
filtered = q.isEmpty ? chats : chats.filter { $0.name.lowercased().contains(q) }
|
||||||
|
table.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "biz-connect-mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Biz Connect mobile app — Capacitor shell loading the Connect web UI",
|
||||||
|
"scripts": {
|
||||||
|
"sync": "cap sync",
|
||||||
|
"assets": "capacitor-assets generate --android",
|
||||||
|
"assets:all": "capacitor-assets generate",
|
||||||
|
"android": "cap open android",
|
||||||
|
"ios": "cap open ios"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"audio-route": "file:plugins/audio-route",
|
||||||
|
"media-library": "file:plugins/media-library",
|
||||||
|
"share-inbox": "file:plugins/share-inbox",
|
||||||
|
"@capacitor-community/safe-area": "^7.0.0",
|
||||||
|
"@capacitor/android": "^7.0.0",
|
||||||
|
"@capacitor/app": "^7.0.0",
|
||||||
|
"@capacitor/camera": "^7.0.0",
|
||||||
|
"@capacitor/core": "^7.0.0",
|
||||||
|
"@capacitor/filesystem": "^7.0.0",
|
||||||
|
"@capacitor/ios": "^7.0.0",
|
||||||
|
"@capacitor/keyboard": "^7.0.0",
|
||||||
|
"@capacitor/share": "^7.0.0",
|
||||||
|
"@capacitor/push-notifications": "^7.0.0",
|
||||||
|
"@capacitor/splash-screen": "^7.0.0",
|
||||||
|
"@capacitor/status-bar": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/assets": "^3.0.5",
|
||||||
|
"@capacitor/cli": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
# NOTE: the pod name MUST be 'AudioRoute' (PascalCase of the npm package name 'audio-route').
|
||||||
|
# Capacitor's `cap sync` writes `pod 'AudioRoute', :path => '../../plugins/audio-route'` into the
|
||||||
|
# generated Podfile, and CocoaPods then looks for a file literally named AudioRoute.podspec whose
|
||||||
|
# s.name is 'AudioRoute'. Any other name → "No podspec found for `AudioRoute`" and pod install fails.
|
||||||
|
s.name = 'AudioRoute'
|
||||||
|
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/audio-route.git', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
|
s.ios.deployment_target = '14.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Native-only plugin — the web app calls window.Capacitor.Plugins.AudioRoute directly (it loads the UI from
|
||||||
|
// a remote URL, so nothing here is bundled). This stub only exists so the npm package resolves.
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
'use strict';
|
||||||
|
// Native-only plugin stub (see dist/esm/index.js).
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
// Call-audio routing for the iOS app. Registered by cap sync as a real Capacitor plugin package, so it shows
|
||||||
|
// up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds).
|
||||||
|
//
|
||||||
|
// KNOWN CEILING (WKWebView): the app does NOT own the AVAudioSession — WebKit's media process does, and it
|
||||||
|
// re-asserts its own category (Bluetooth allowed) on every change. So the earpiece can't be forced, and the
|
||||||
|
// built-in speaker can't be held over an actively-connected Bluetooth device (a re-force just oscillates with
|
||||||
|
// WebKit — proven on device). This plugin therefore does a SINGLE override per user tap and does NOT fight
|
||||||
|
// route changes; it only reports the active output so the web UI can show the correct icon.
|
||||||
|
// * setSpeaker(true) -> try to force the loudspeaker (drops Bluetooth options + overrideOutputAudioPort(.speaker))
|
||||||
|
// * setSpeaker(false) -> "Device": allow BT/wired and use the default port (routes to a connected headset)
|
||||||
|
// * getRoute() + 'routeChange' event -> the active output ('speaker'|'bluetooth'|'wired'|'receiver'|'airplay')
|
||||||
|
@objc(AudioRoutePlugin)
|
||||||
|
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public let identifier = "AudioRoutePlugin"
|
||||||
|
public let jsName = "AudioRoute"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let nativeTag = "1.1.2-stable"
|
||||||
|
private var pending: DispatchWorkItem?
|
||||||
|
|
||||||
|
override public func load() {
|
||||||
|
try? configureDevice() // start in "device" mode (BT/wired allowed); the web forces speaker per call
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
|
||||||
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { NotificationCenter.default.removeObserver(self) }
|
||||||
|
|
||||||
|
private func configureSpeaker() throws {
|
||||||
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
|
||||||
|
try s.setActive(true)
|
||||||
|
try s.overrideOutputAudioPort(.speaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureDevice() throws {
|
||||||
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
try s.setActive(true)
|
||||||
|
try s.overrideOutputAudioPort(.none)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentOutput() -> String {
|
||||||
|
for o in AVAudioSession.sharedInstance().currentRoute.outputs {
|
||||||
|
switch o.portType {
|
||||||
|
case .builtInSpeaker: return "speaker"
|
||||||
|
case .builtInReceiver: return "receiver"
|
||||||
|
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE, .carAudio: return "bluetooth"
|
||||||
|
case .headphones, .headsetMic, .usbAudio: return "wired"
|
||||||
|
case .airPlay: return "airplay"
|
||||||
|
default: continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report the active output to the web UI. Deliberately does NOT re-force any route — fighting WebKit's
|
||||||
|
// re-assertion just oscillates the audio (see ceiling note above).
|
||||||
|
@objc private func routeChanged() {
|
||||||
|
pending?.cancel()
|
||||||
|
let work = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.notifyListeners("routeChange", data: ["output": self.currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
||||||
|
}
|
||||||
|
pending = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
||||||
|
let on = call.getBool("on") ?? true
|
||||||
|
do {
|
||||||
|
if on { try configureSpeaker() } else { try configureDevice() }
|
||||||
|
call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
||||||
|
} catch {
|
||||||
|
call.reject(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func getRoute(_ call: CAPPluginCall) {
|
||||||
|
call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "audio-route",
|
||||||
|
"version": "1.1.2",
|
||||||
|
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"author": "BizGaze",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/",
|
||||||
|
"AudioRoute.podspec"
|
||||||
|
],
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
# NOTE: the pod name MUST be 'MediaLibrary' (PascalCase of the npm package name 'media-library').
|
||||||
|
# `cap sync` writes `pod 'MediaLibrary', :path => '../../plugins/media-library'` into the generated
|
||||||
|
# Podfile, and CocoaPods then looks for a file literally named MediaLibrary.podspec whose s.name is
|
||||||
|
# 'MediaLibrary'. Any other name → "No podspec found for `MediaLibrary`" and pod install fails.
|
||||||
|
# (Same trap that broke the AudioRoute build — see mobile/plugins/audio-route/AudioRoute.podspec.)
|
||||||
|
s.name = 'MediaLibrary'
|
||||||
|
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/media-library.git', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
|
s.ios.deployment_target = '14.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
// Copies a downloaded photo/video into the user's Photos library, inside a named album ("Connect"), the
|
||||||
|
// way WhatsApp puts saved media in a WhatsApp album. Registered by cap sync as a real Capacitor plugin
|
||||||
|
// package, so it appears as window.Capacitor.Plugins.MediaLibrary (an app-embedded class would be
|
||||||
|
// stripped in release builds).
|
||||||
|
//
|
||||||
|
// WHY A PLUGIN AT ALL: the app already saves downloads into its own Documents folder (visible in Files),
|
||||||
|
// but Files is not where people look for photos and videos — the Photos app is, and only PhotoKit can put
|
||||||
|
// something there. @capacitor/filesystem cannot: an app's sandbox and the photo library are separate stores.
|
||||||
|
//
|
||||||
|
// PERMISSION NOTE: .addOnly is enough to add an asset, but NOT to look up or create an ALBUM — that needs
|
||||||
|
// .readWrite. So we request .readWrite, and both NSPhotoLibraryUsageDescription and
|
||||||
|
// NSPhotoLibraryAddUsageDescription must be present (ios-patch.sh sets them).
|
||||||
|
@objc(MediaLibraryPlugin)
|
||||||
|
public class MediaLibraryPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public let identifier = "MediaLibraryPlugin"
|
||||||
|
public let jsName = "MediaLibrary"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "saveToAlbum", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "checkPermission", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - API
|
||||||
|
|
||||||
|
@objc func checkPermission(_ call: CAPPluginCall) {
|
||||||
|
call.resolve(["status": MediaLibraryPlugin.label(PHPhotoLibrary.authorizationStatus(for: .readWrite))])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func saveToAlbum(_ call: CAPPluginCall) {
|
||||||
|
guard let raw = call.getString("path"), !raw.isEmpty else {
|
||||||
|
call.reject("path is required"); return
|
||||||
|
}
|
||||||
|
let album = (call.getString("album") ?? "Connect").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let url = MediaLibraryPlugin.fileURL(from: raw)
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
call.reject("file not found: \(url.path)"); return
|
||||||
|
}
|
||||||
|
// Trust an explicit kind if the web layer passed one (it knows the MIME); otherwise fall back to
|
||||||
|
// the file extension.
|
||||||
|
let isVideo: Bool = {
|
||||||
|
if let kind = call.getString("kind") { return kind == "video" }
|
||||||
|
return MediaLibraryPlugin.videoExtensions.contains(url.pathExtension.lowercased())
|
||||||
|
}()
|
||||||
|
|
||||||
|
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
|
||||||
|
guard status == .authorized || status == .limited else {
|
||||||
|
call.reject("permission denied", MediaLibraryPlugin.label(status)); return
|
||||||
|
}
|
||||||
|
self.album(named: album) { collection in
|
||||||
|
PHPhotoLibrary.shared().performChanges({
|
||||||
|
// addResource(with:fileURL:) works uniformly for photo and video and, unlike the
|
||||||
|
// creationRequestForAssetFrom* helpers, is non-optional — no silent no-op path.
|
||||||
|
let request = PHAssetCreationRequest.forAsset()
|
||||||
|
let options = PHAssetResourceCreationOptions()
|
||||||
|
options.shouldMoveFile = false // keep our own copy in the app folder
|
||||||
|
options.originalFilename = url.lastPathComponent
|
||||||
|
request.addResource(with: isVideo ? .video : .photo, fileURL: url, options: options)
|
||||||
|
// If the album couldn't be resolved (e.g. "limited" access), still save the asset —
|
||||||
|
// landing in the camera roll beats failing outright.
|
||||||
|
if let collection = collection,
|
||||||
|
let placeholder = request.placeholderForCreatedAsset,
|
||||||
|
let add = PHAssetCollectionChangeRequest(for: collection) {
|
||||||
|
add.addAssets([placeholder] as NSArray)
|
||||||
|
}
|
||||||
|
}) { ok, err in
|
||||||
|
if ok {
|
||||||
|
call.resolve(["saved": true, "album": album, "inAlbum": collection != nil])
|
||||||
|
} else {
|
||||||
|
call.reject(err?.localizedDescription ?? "could not save to Photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private static let videoExtensions: Set<String> = ["mp4", "mov", "m4v", "3gp", "avi", "mkv", "webm"]
|
||||||
|
|
||||||
|
private static func label(_ s: PHAuthorizationStatus) -> String {
|
||||||
|
switch s {
|
||||||
|
case .authorized: return "granted"
|
||||||
|
case .limited: return "limited"
|
||||||
|
case .denied: return "denied"
|
||||||
|
case .restricted: return "restricted"
|
||||||
|
case .notDetermined: return "prompt"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts either a file:// URI (what Filesystem.writeFile returns) or a bare absolute path.
|
||||||
|
private static func fileURL(from raw: String) -> URL {
|
||||||
|
if raw.hasPrefix("file://") {
|
||||||
|
if let u = URL(string: raw) { return u }
|
||||||
|
// Un-encoded spaces make URL(string:) fail — percent-encode and retry before giving up.
|
||||||
|
let encoded = raw.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? raw
|
||||||
|
if let u = URL(string: encoded) { return u }
|
||||||
|
}
|
||||||
|
return URL(fileURLWithPath: raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the album by title, creating it the first time. Returns nil if it can't be resolved.
|
||||||
|
private func album(named name: String, completion: @escaping (PHAssetCollection?) -> Void) {
|
||||||
|
if let existing = MediaLibraryPlugin.findAlbum(name) { completion(existing); return }
|
||||||
|
var placeholder: PHObjectPlaceholder?
|
||||||
|
PHPhotoLibrary.shared().performChanges({
|
||||||
|
let req = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: name)
|
||||||
|
placeholder = req.placeholderForCreatedAssetCollection
|
||||||
|
}) { ok, _ in
|
||||||
|
guard ok, let id = placeholder?.localIdentifier else {
|
||||||
|
// A racing create (two downloads at once) means it exists now — look again before failing.
|
||||||
|
completion(MediaLibraryPlugin.findAlbum(name)); return
|
||||||
|
}
|
||||||
|
completion(PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [id], options: nil).firstObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func findAlbum(_ name: String) -> PHAssetCollection? {
|
||||||
|
let opts = PHFetchOptions()
|
||||||
|
opts.predicate = NSPredicate(format: "localizedTitle = %@", name)
|
||||||
|
return PHAssetCollection.fetchAssetCollections(with: .album, subtype: .albumRegular, options: opts).firstObject
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "media-library",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Save downloaded photos & videos into a named Photos album for Biz Connect",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"author": "BizGaze",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/",
|
||||||
|
"MediaLibrary.podspec"
|
||||||
|
],
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
# Pod name MUST be 'ShareInbox' (PascalCase of 'share-inbox') — same rule as the other plugins, or
|
||||||
|
# pod install fails with "No podspec found for `ShareInbox`".
|
||||||
|
s.name = 'ShareInbox'
|
||||||
|
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/share-inbox.git', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
|
s.ios.deployment_target = '14.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
|
||||||
|
// Reads the files the Share Extension staged into the App Group container, so the web app can pick a
|
||||||
|
// conversation and send them. The extension and the app are separate processes; the App Group's shared
|
||||||
|
// container is the only place both can read/write, and it is NOT one of @capacitor/filesystem's known
|
||||||
|
// directories — hence this small bridge.
|
||||||
|
//
|
||||||
|
// getPending() → { items: [ {kind, name, path, uri, mime, size} | {kind:"text", text} ] }
|
||||||
|
// `uri` is a file:// URL the web layer turns into a fetchable source with Capacitor.convertFileSrc,
|
||||||
|
// so the existing upload path can read the bytes without base64 marshalling.
|
||||||
|
// clear() removes the manifest and every staged file, once the app has taken them.
|
||||||
|
@objc(ShareInboxPlugin)
|
||||||
|
public class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public let identifier = "ShareInboxPlugin"
|
||||||
|
public let jsName = "ShareInbox"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "getPending", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setAuth", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
private let appGroup = "group.com.bizgaze.connect"
|
||||||
|
|
||||||
|
// The web app hands the extension a bearer token + API base (via /api/share/token) so the extension can
|
||||||
|
// list chats, upload and send on its own — no app-open needed. Stored in the App Group's shared
|
||||||
|
// UserDefaults, which the extension reads directly. Passing an empty token clears it (e.g. on logout).
|
||||||
|
@objc func setAuth(_ call: CAPPluginCall) {
|
||||||
|
let token = call.getString("token") ?? ""
|
||||||
|
let base = call.getString("base") ?? ""
|
||||||
|
if let d = UserDefaults(suiteName: appGroup) {
|
||||||
|
if token.isEmpty { d.removeObject(forKey: "bzc_token"); d.removeObject(forKey: "bzc_base") }
|
||||||
|
else { d.set(token, forKey: "bzc_token"); d.set(base, forKey: "bzc_base") }
|
||||||
|
}
|
||||||
|
call.resolve(["ok": true])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func getPending(_ call: CAPPluginCall) {
|
||||||
|
guard let dir = sharedDir() else { return call.resolve(["items": []]) }
|
||||||
|
let manifest = dir.appendingPathComponent("manifest.json")
|
||||||
|
guard let data = try? Data(contentsOf: manifest),
|
||||||
|
let records = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
||||||
|
return call.resolve(["items": []])
|
||||||
|
}
|
||||||
|
var items: [[String: Any]] = []
|
||||||
|
for r in records {
|
||||||
|
let kind = r["kind"] as? String ?? "file"
|
||||||
|
if kind == "text" {
|
||||||
|
if let text = r["text"] as? String { items.append(["kind": "text", "text": text]) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A file record is only usable if its staged copy is still on disk.
|
||||||
|
guard let path = r["path"] as? String,
|
||||||
|
FileManager.default.fileExists(atPath: path) else { continue }
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
items.append([
|
||||||
|
"kind": "file",
|
||||||
|
"name": r["name"] as? String ?? url.lastPathComponent,
|
||||||
|
"path": path,
|
||||||
|
"uri": url.absoluteString,
|
||||||
|
"mime": r["mime"] as? String ?? "application/octet-stream",
|
||||||
|
"size": r["size"] as? Int64 ?? (r["size"] as? Int ?? 0)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
call.resolve(["items": items])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func clear(_ call: CAPPluginCall) {
|
||||||
|
if let dir = sharedDir() {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
|
||||||
|
for e in entries { try? fm.removeItem(at: e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sharedDir() -> URL? {
|
||||||
|
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { return nil }
|
||||||
|
return base.appendingPathComponent("Shared", isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "share-inbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Read files handed to Biz Connect from the iOS share sheet (via the App Group)",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"author": "BizGaze",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/",
|
||||||
|
"ShareInbox.podspec"
|
||||||
|
],
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# App icon & splash source images
|
||||||
|
|
||||||
|
`@capacitor/assets` generates every Android (and iOS) icon/splash density from these masters.
|
||||||
|
|
||||||
|
| File | Size | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| `icon.png` | 1024×1024 | App icon (all densities + Android adaptive icon) |
|
||||||
|
| `splash.png` | 2732×2732 | Launch splash (light) |
|
||||||
|
| `splash-dark.png` | 2732×2732 | Launch splash (dark mode) |
|
||||||
|
|
||||||
|
These were generated from the existing PWA icon (`server/public/icon-512.png`) + brand blue
|
||||||
|
`#1F3B73`. To rebrand, replace these three files (keep the sizes) and re-run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
npm run assets # Android only (npm run assets:all for iOS too)
|
||||||
|
npx cap sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Tip: for the sharpest result, drop a true 1024×1024 `icon.png` (and a 2732×2732 `splash.png`)
|
||||||
|
exported from the design source rather than an upscale.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- Paste these into mobile/android/app/src/main/AndroidManifest.xml, inside <manifest> and
|
||||||
|
directly above the <application> element. INTERNET is already added by Capacitor.
|
||||||
|
See ANDROID_SETUP.md §3. -->
|
||||||
|
|
||||||
|
<!-- Push notifications: Android 13 (API 33)+ shows a runtime prompt -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- Voice / video calls + camera, used by the WebRTC features in the web UI -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
|
<!-- Camera is optional hardware (tablets without one can still install) -->
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
|
<!-- Later, for screen-sharing FROM the phone (needs a screen-capture plugin):
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
-->
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
#
|
||||||
|
# Inject the Share Extension target into the Capacitor-generated Xcode project.
|
||||||
|
#
|
||||||
|
# WHY A SCRIPT: `npx cap add ios` scaffolds mobile/ios/App from a template that knows nothing about our
|
||||||
|
# extension, and Codemagic runs on a fresh checkout every time, so the target has to be (re)created on each
|
||||||
|
# build. This uses the `xcodeproj` gem, which ships with CocoaPods (already installed for `pod install`),
|
||||||
|
# so there is no extra dependency to add.
|
||||||
|
#
|
||||||
|
# WHAT IT WIRES:
|
||||||
|
# * a new app-extension target "ShareExtension" (bundle id <app>.share) whose sources are our
|
||||||
|
# ShareViewController.swift + Info.plist, copied in from mobile/ios-share/
|
||||||
|
# * the App Group entitlement on BOTH the App target and the extension (the only storage both processes
|
||||||
|
# can see), via the two .entitlements files
|
||||||
|
# * the extension embedded into the app ("Embed App Extensions" phase) and set as a build dependency
|
||||||
|
#
|
||||||
|
# Idempotent: if the target already exists it is removed and rebuilt, so re-runs never duplicate it.
|
||||||
|
|
||||||
|
require 'xcodeproj'
|
||||||
|
require 'fileutils'
|
||||||
|
|
||||||
|
ROOT = File.expand_path('..', __dir__) # repo/mobile (this script is in mobile/scripts)
|
||||||
|
PROJECT = File.join(ROOT, 'ios', 'App', 'App.xcodeproj')
|
||||||
|
SRC_DIR = File.join(ROOT, 'ios-share') # our checked-in extension sources
|
||||||
|
APP_DIR = File.join(ROOT, 'ios', 'App')
|
||||||
|
EXT_NAME = 'ShareExtension'
|
||||||
|
EXT_DIR = File.join(APP_DIR, EXT_NAME)
|
||||||
|
APP_TARGET = 'App'
|
||||||
|
APP_BUNDLE = ENV.fetch('BUNDLE_ID', 'com.bizgaze.connect')
|
||||||
|
EXT_BUNDLE = "#{APP_BUNDLE}.share"
|
||||||
|
|
||||||
|
abort "xcodeproj not found at #{PROJECT}" unless File.directory?(PROJECT)
|
||||||
|
|
||||||
|
project = Xcodeproj::Project.open(PROJECT)
|
||||||
|
app = project.targets.find { |t| t.name == APP_TARGET }
|
||||||
|
abort "App target not found" unless app
|
||||||
|
|
||||||
|
# ── Clean any previous injection so this is idempotent ──────────────────────────────────────────────
|
||||||
|
project.targets.select { |t| t.name == EXT_NAME }.each do |t|
|
||||||
|
t.build_configuration_list&.build_configurations&.each { |c| c.remove_from_project }
|
||||||
|
t.remove_from_project
|
||||||
|
end
|
||||||
|
if (grp = project.main_group.children.find { |g| g.respond_to?(:display_name) && g.display_name == EXT_NAME })
|
||||||
|
grp.remove_from_project
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Copy our sources into the app project so paths inside the .xcodeproj are stable ──────────────────
|
||||||
|
FileUtils.mkdir_p(EXT_DIR)
|
||||||
|
%w[ShareViewController.swift Info.plist ShareExtension.entitlements].each do |f|
|
||||||
|
FileUtils.cp(File.join(SRC_DIR, f), File.join(EXT_DIR, f))
|
||||||
|
end
|
||||||
|
|
||||||
|
# The App Group entitlement for the MAIN app. MERGE, don't overwrite: the push-notifications plugin may
|
||||||
|
# already have written App/App.entitlements (aps-environment), and clobbering it would break push. We add
|
||||||
|
# the app-group array into whatever is there (or create the file if it's absent).
|
||||||
|
APP_GROUP = 'group.com.bizgaze.connect'
|
||||||
|
app_ent_path = File.join(APP_DIR, 'App', 'App.entitlements')
|
||||||
|
app_ent = File.exist?(app_ent_path) ? (Xcodeproj::Plist.read_from_path(app_ent_path) || {}) : {}
|
||||||
|
groups = app_ent['com.apple.security.application-groups'] || []
|
||||||
|
groups << APP_GROUP unless groups.include?(APP_GROUP)
|
||||||
|
app_ent['com.apple.security.application-groups'] = groups
|
||||||
|
Xcodeproj::Plist.write_to_path(app_ent, app_ent_path)
|
||||||
|
puts "App entitlements: app-group ensured (kept #{(app_ent.keys - ['com.apple.security.application-groups']).join(', ')})"
|
||||||
|
|
||||||
|
# ── Create the extension target ──────────────────────────────────────────────────────────────────────
|
||||||
|
# deployment_target can be nil when it's only set at the project level — fall back so we never create a
|
||||||
|
# target with an empty minimum-OS (which Xcode then flags).
|
||||||
|
deployment = app.deployment_target || project.build_configurations.first&.build_settings&.[]('IPHONEOS_DEPLOYMENT_TARGET') || '14.0'
|
||||||
|
ext = project.new_target(
|
||||||
|
:app_extension, EXT_NAME, :ios,
|
||||||
|
deployment, project.products_group, :swift
|
||||||
|
)
|
||||||
|
|
||||||
|
# Source file + resources
|
||||||
|
group = project.main_group.new_group(EXT_NAME, "#{EXT_NAME}")
|
||||||
|
swift_ref = group.new_reference(File.join(EXT_DIR, 'ShareViewController.swift'))
|
||||||
|
ext.add_file_references([swift_ref])
|
||||||
|
|
||||||
|
# Build settings for every configuration (Debug/Release)
|
||||||
|
ext.build_configurations.each do |cfg|
|
||||||
|
s = cfg.build_settings
|
||||||
|
s['PRODUCT_BUNDLE_IDENTIFIER'] = EXT_BUNDLE
|
||||||
|
s['PRODUCT_NAME'] = '$(TARGET_NAME)'
|
||||||
|
s['INFOPLIST_FILE'] = "#{EXT_NAME}/Info.plist"
|
||||||
|
s['CODE_SIGN_ENTITLEMENTS'] = "#{EXT_NAME}/ShareExtension.entitlements"
|
||||||
|
s['IPHONEOS_DEPLOYMENT_TARGET'] = deployment
|
||||||
|
s['SWIFT_VERSION'] = '5.0'
|
||||||
|
s['TARGETED_DEVICE_FAMILY'] = '1,2'
|
||||||
|
s['GENERATE_INFOPLIST_FILE'] = 'NO'
|
||||||
|
s['SKIP_INSTALL'] = 'YES'
|
||||||
|
s['CODE_SIGN_STYLE'] = 'Manual'
|
||||||
|
s['MARKETING_VERSION'] = '1.0'
|
||||||
|
s['CURRENT_PROJECT_VERSION'] = ENV.fetch('BUILD_NUMBER', '1')
|
||||||
|
s['LD_RUNPATH_SEARCH_PATHS'] = '$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks'
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── App Group entitlement on the MAIN app target too ─────────────────────────────────────────────────
|
||||||
|
app.build_configurations.each do |cfg|
|
||||||
|
cfg.build_settings['CODE_SIGN_ENTITLEMENTS'] = 'App/App.entitlements'
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Embed the extension into the app + depend on it ──────────────────────────────────────────────────
|
||||||
|
app.add_dependency(ext)
|
||||||
|
embed = app.build_phases.find { |p| p.respond_to?(:symbol_dst_subfolder_spec) && p.symbol_dst_subfolder_spec == :plug_ins }
|
||||||
|
embed ||= begin
|
||||||
|
phase = app.new_copy_files_build_phase('Embed App Extensions')
|
||||||
|
phase.symbol_dst_subfolder_spec = :plug_ins
|
||||||
|
phase
|
||||||
|
end
|
||||||
|
appex = ext.product_reference
|
||||||
|
build_file = embed.add_file_reference(appex)
|
||||||
|
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
|
||||||
|
|
||||||
|
project.save
|
||||||
|
puts "Share Extension injected: #{EXT_NAME} (#{EXT_BUNDLE}) embedded in #{APP_TARGET}"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Patch the freshly-generated Capacitor iOS AppDelegate so calls DEFAULT to the loudspeaker at launch
|
||||||
|
// (iOS uses the quiet earpiece otherwise). Run on Codemagic (macOS) from ios-patch.sh:
|
||||||
|
// node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift
|
||||||
|
// TOLERANT: exits 0 and no-ops if the template doesn't match, so it can NEVER fail the build.
|
||||||
|
// NOTE: the earpiece<->speaker TOGGLE is provided by the local Capacitor plugin package mobile/plugins/
|
||||||
|
// audio-route (registered by cap sync, like @capacitor/share). This file only sets the launch default.
|
||||||
|
const fs = require('fs');
|
||||||
|
const p = process.argv[2];
|
||||||
|
if (!p || !fs.existsSync(p)) { console.log(' (AppDelegate.swift not found — audio patch skipped)'); process.exit(0); }
|
||||||
|
try {
|
||||||
|
let s = fs.readFileSync(p, 'utf8');
|
||||||
|
if (s.includes('bzcAudioLaunch')) { console.log(' audio launch baseline already patched'); process.exit(0); }
|
||||||
|
const orig = s;
|
||||||
|
if (!s.includes('import AVFoundation')) {
|
||||||
|
if (s.includes('import Capacitor')) s = s.replace('import Capacitor', 'import Capacitor\nimport AVFoundation');
|
||||||
|
else if (s.includes('import UIKit')) s = s.replace('import UIKit', 'import UIKit\nimport AVFoundation');
|
||||||
|
}
|
||||||
|
// NOTE: NO .defaultToSpeaker here. The earpiece<->speaker toggle plugin (mobile/plugins/audio-route) drives
|
||||||
|
// the output port explicitly and needs .voiceChat's receiver default; .defaultToSpeaker would invert that and
|
||||||
|
// make earpiece unreachable. This is only a launch-time baseline — WebKit re-pins the session per call anyway.
|
||||||
|
const launch = [
|
||||||
|
' // bzcAudioLaunch: baseline voice-call audio session (earpiece-capable; the AudioRoute plugin and',
|
||||||
|
' // the JS meet UI force the speaker per call). Keep in sync with mobile/plugins/audio-route load().',
|
||||||
|
' do {',
|
||||||
|
' let audioSession = AVAudioSession.sharedInstance()',
|
||||||
|
' try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])',
|
||||||
|
' try audioSession.setActive(true)',
|
||||||
|
' } catch { }',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const m = s.match(/func\s+application\([^)]*didFinishLaunchingWithOptions[^)]*\)\s*->\s*Bool\s*\{[^\n]*\n/);
|
||||||
|
if (m) { const idx = m.index + m[0].length; s = s.slice(0, idx) + launch + s.slice(idx); }
|
||||||
|
if (s !== orig && s.includes('bzcAudioLaunch')) { fs.writeFileSync(p, s); console.log(' AVAudioSession launch baseline injected'); }
|
||||||
|
else { console.log(' (AppDelegate pattern not matched — audio patch skipped)'); }
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' (audio patch error, skipped: ' + (e && e.message) + ')');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Patch the freshly-generated Capacitor iOS project (mobile/ios/App) for App Store submission.
|
||||||
|
# Runs on the Codemagic macOS instance after `npx cap add ios`. Idempotent — safe to re-run.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PLIST="mobile/ios/App/App/Info.plist"
|
||||||
|
PB=/usr/libexec/PlistBuddy
|
||||||
|
|
||||||
|
set_str() { # set_str <key> <value> — add the key if missing, else overwrite
|
||||||
|
"$PB" -c "Add :$1 string $2" "$PLIST" 2>/dev/null || "$PB" -c "Set :$1 $2" "$PLIST"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Patching $PLIST"
|
||||||
|
|
||||||
|
# ── Privacy usage strings (Apple REJECTS the build if a used capability has no purpose string) ──
|
||||||
|
set_str NSCameraUsageDescription "Biz Connect uses the camera for video calls and to share photos and your screen."
|
||||||
|
set_str NSMicrophoneUsageDescription "Biz Connect uses the microphone for voice and video calls."
|
||||||
|
set_str NSPhotoLibraryUsageDescription "Biz Connect needs photo access so you can send images in chat."
|
||||||
|
set_str NSPhotoLibraryAddUsageDescription "Biz Connect saves images and recordings you download to your photos."
|
||||||
|
|
||||||
|
# Human-readable display name on the home screen.
|
||||||
|
set_str CFBundleDisplayName "Biz Connect"
|
||||||
|
|
||||||
|
# CFBundleVersion (build number) MUST be unique AND higher than every previous App Store Connect upload,
|
||||||
|
# or publishing fails with "The bundle version must be higher than the previously uploaded version".
|
||||||
|
# Capacitor ships "1" by default, so every build collided. Use Codemagic's monotonically-increasing
|
||||||
|
# BUILD_NUMBER (this is build index 6+, already > the "1" that's on TestFlight). Fall back to an epoch
|
||||||
|
# timestamp if it's somehow unset, which is also strictly increasing.
|
||||||
|
BUILD_NO="${BUILD_NUMBER:-$(date +%s)}"
|
||||||
|
echo "Setting CFBundleVersion = $BUILD_NO"
|
||||||
|
set_str CFBundleVersion "$BUILD_NO"
|
||||||
|
|
||||||
|
# ── Make the app's Documents folder visible in the Files app ───────────────────────────────────────
|
||||||
|
# Downloads are saved to Documents/{Images,Videos,Files}. Without these two keys that folder is private
|
||||||
|
# and the user has no way to reach what they saved. With them, Files shows
|
||||||
|
# Files → Browse → On My iPhone → Biz Connect → Images / Videos / Files
|
||||||
|
# UIFileSharingEnabled exposes the folder; LSSupportsOpeningDocumentsInPlace lets other apps open those
|
||||||
|
# files in place rather than silently working on a copy.
|
||||||
|
set_bool() { "$PB" -c "Add :$1 bool $2" "$PLIST" 2>/dev/null || "$PB" -c "Set :$1 $2" "$PLIST"; }
|
||||||
|
set_bool UIFileSharingEnabled true
|
||||||
|
set_bool LSSupportsOpeningDocumentsInPlace true
|
||||||
|
|
||||||
|
# ── Custom URL scheme so the Share Extension can bounce the user back into the app ──────────────────
|
||||||
|
# The extension stages the shared files into the App Group, then opens bizconnect://share; the app reads
|
||||||
|
# the staged files and shows "Send to…". Registering the scheme is what makes that openURL succeed.
|
||||||
|
if ! "$PB" -c "Print :CFBundleURLTypes" "$PLIST" >/dev/null 2>&1; then
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes array" "$PLIST"
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes:0 dict" "$PLIST"
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLName string com.bizgaze.connect" "$PLIST"
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST"
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string bizconnect" "$PLIST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# We use only standard encryption (HTTPS/TLS), which is exempt — declaring this up front stops App Store
|
||||||
|
# Connect from asking the "export compliance" question on every single build/TestFlight upload.
|
||||||
|
"$PB" -c "Add :ITSAppUsesNonExemptEncryption bool false" "$PLIST" 2>/dev/null \
|
||||||
|
|| "$PB" -c "Set :ITSAppUsesNonExemptEncryption false" "$PLIST"
|
||||||
|
|
||||||
|
# Allow the webview to load our HTTPS origin (we do NOT enable arbitrary cleartext).
|
||||||
|
"$PB" -c "Delete :NSAppTransportSecurity" "$PLIST" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Default in-call audio to the LOUDSPEAKER (AVAudioSession) ──────────────────────────────────────
|
||||||
|
# Without this, iOS routes WebRTC call audio to the quiet EARPIECE. A Node helper (Node 20 is already set
|
||||||
|
# up for this build) injects an AVAudioSession category into the generated AppDelegate so calls default to
|
||||||
|
# the speaker (headphones/Bluetooth still win when connected). The helper is tolerant and exits 0 even if
|
||||||
|
# the template differs, so it NEVER fails the build. First-pass fix — if WebRTC re-grabs the session
|
||||||
|
# mid-call on device, we follow up with a plugin that re-asserts .overrideOutputAudioPort(.speaker).
|
||||||
|
AD="mobile/ios/App/App/AppDelegate.swift"
|
||||||
|
if [ -f "$AD" ]; then
|
||||||
|
echo "Patching AppDelegate audio session"
|
||||||
|
node "$(dirname "$0")/inject-audio.js" "$AD" || echo " (AVAudioSession patch skipped — non-fatal)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Info.plist patched:"
|
||||||
|
"$PB" -c "Print :NSCameraUsageDescription" "$PLIST"
|
||||||
|
"$PB" -c "Print :NSMicrophoneUsageDescription" "$PLIST"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!-- Capacitor requires a webDir with an index. At runtime the app loads the live Connect UI
|
||||||
|
via server.url in capacitor.config.json, so this is only a launch splash / offline
|
||||||
|
fallback. To ship fully-bundled (offline-launch) later, copy ../server/public here and
|
||||||
|
drop server.url. -->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Biz Connect</title>
|
||||||
|
<style>
|
||||||
|
html,body{height:100%;margin:0;background:#0f1830;color:#fff;font-family:system-ui,Segoe UI,Roboto,sans-serif;}
|
||||||
|
.wrap{height:100%;display:grid;place-items:center;text-align:center;padding:2rem;}
|
||||||
|
h1{font-size:1.4rem;margin:.4rem 0;} p{opacity:.7;font-size:.9rem;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<h1>Biz <span style="color:#f5b301">Connect</span></h1>
|
||||||
|
<p>Connecting…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# publish-desktop.sh — publish a desktop installer FROM YOUR LAPTOP (Git Bash).
|
||||||
|
# It uploads the three electron-builder artifacts to the server and drops them into
|
||||||
|
# the app container's DOWNLOADS_DIR (/data/downloads), which powers both the site's
|
||||||
|
# "Download for Windows" button and the auto-update feed for installed apps.
|
||||||
|
#
|
||||||
|
# Uploaded artifacts (from desktop/dist/):
|
||||||
|
# - Biz Connect Setup <ver>.exe the installer
|
||||||
|
# - Biz Connect Setup <ver>.exe.blockmap differential-update map
|
||||||
|
# - latest.yml update manifest (the ONLY file overwritten)
|
||||||
|
#
|
||||||
|
# It does NOT delete older versions — keeping old .exe/.blockmap lets installed apps
|
||||||
|
# pull deltas. Only latest.yml is replaced (there must be exactly one, newest wins).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./publish-desktop.sh # publish the version in desktop/package.json
|
||||||
|
# ./publish-desktop.sh 0.1.4 # publish a specific version
|
||||||
|
#
|
||||||
|
# Password (in priority order), same as redeploy.sh:
|
||||||
|
# 1. $DEPLOY_PASS environment variable
|
||||||
|
# 2. a gitignored `deploy.secret` file next to this script (one line = the pw)
|
||||||
|
# 3. hidden prompt
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST=118.95.33.89
|
||||||
|
PORT=61
|
||||||
|
USER=root
|
||||||
|
CONTAINER=bizgaze-support
|
||||||
|
DEST=/data/downloads
|
||||||
|
# Pinned server host key (SHA256). -batch won't prompt to cache an unknown key, so
|
||||||
|
# we pin it here (same value as redeploy.sh). Override with $DEPLOY_HOSTKEY if the
|
||||||
|
# server is rebuilt.
|
||||||
|
HOSTKEY="${DEPLOY_HOSTKEY:-SHA256:hxfv/hH5aplnM4wOsl+jLjWaXwEeceZ4Uz932/5IoCE}"
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DIST="$DIR/desktop/dist"
|
||||||
|
|
||||||
|
# Locate plink + pscp (PuTTY).
|
||||||
|
PLINK="$(command -v plink 2>/dev/null || true)"; [ -n "$PLINK" ] || PLINK="/c/Program Files/PuTTY/plink"
|
||||||
|
PSCP="$(command -v pscp 2>/dev/null || true)"; [ -n "$PSCP" ] || PSCP="/c/Program Files/PuTTY/pscp"
|
||||||
|
[ -x "$PLINK" ] || { echo "ERROR: plink not found (install PuTTY or add to PATH)"; exit 1; }
|
||||||
|
[ -x "$PSCP" ] || { echo "ERROR: pscp not found (install PuTTY or add to PATH)"; exit 1; }
|
||||||
|
|
||||||
|
# Resolve the version: explicit arg, else desktop/package.json.
|
||||||
|
VER="${1:-}"
|
||||||
|
if [ -z "$VER" ]; then
|
||||||
|
VER="$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' "$DIR/desktop/package.json" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
|
||||||
|
fi
|
||||||
|
[ -n "$VER" ] || { echo "ERROR: could not determine version (pass it as an argument)"; exit 1; }
|
||||||
|
|
||||||
|
EXE="$DIST/Biz Connect Setup $VER.exe"
|
||||||
|
MAP="$DIST/Biz Connect Setup $VER.exe.blockmap"
|
||||||
|
YML="$DIST/latest.yml"
|
||||||
|
for f in "$EXE" "$MAP" "$YML"; do
|
||||||
|
[ -f "$f" ] || { echo "ERROR: missing artifact: $f"; echo " build it first: cd desktop && npm run dist"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# Sanity: latest.yml should reference this version, or installed apps won't update to it.
|
||||||
|
if ! grep -qE "^version:[[:space:]]*$VER([^0-9]|$)" "$YML"; then
|
||||||
|
echo "WARNING: $YML does not declare version $VER — is dist/ from an older build?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve password (same precedence as redeploy.sh).
|
||||||
|
PW="${DEPLOY_PASS:-}"
|
||||||
|
if [ -z "$PW" ] && [ -f "$DIR/deploy.secret" ]; then
|
||||||
|
PW="$(tr -d '\r\n' < "$DIR/deploy.secret")"
|
||||||
|
fi
|
||||||
|
if [ -z "$PW" ]; then
|
||||||
|
read -rsp "Server password for $USER@$HOST: " PW; echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAGE="/tmp/bizc-desktop-$VER"
|
||||||
|
|
||||||
|
echo "==> Publishing Biz Connect $VER to $USER@$HOST:$CONTAINER:$DEST"
|
||||||
|
|
||||||
|
# 1. Stage a clean temp dir on the server.
|
||||||
|
"$PLINK" -ssh -batch -hostkey "$HOSTKEY" -P "$PORT" -pw "$PW" "$USER@$HOST" "rm -rf '$STAGE' && mkdir -p '$STAGE'"
|
||||||
|
|
||||||
|
# 2. Upload the three artifacts into it.
|
||||||
|
echo "--> uploading installer, blockmap, manifest …"
|
||||||
|
"$PSCP" -P "$PORT" -pw "$PW" -hostkey "$HOSTKEY" "$EXE" "$MAP" "$YML" "$USER@$HOST:$STAGE/"
|
||||||
|
|
||||||
|
# 3. Copy them into the container's DOWNLOADS_DIR (volume-path-independent), then clean up.
|
||||||
|
# \$f stays literal so the REMOTE shell iterates/quotes the space-containing names.
|
||||||
|
REMOTE_CMD="set -e; for f in '$STAGE'/*; do docker cp \"\$f\" $CONTAINER:$DEST/; done; echo '--- $DEST now holds ---'; docker exec $CONTAINER ls -la $DEST; rm -rf '$STAGE'"
|
||||||
|
echo "--> installing into container …"
|
||||||
|
"$PLINK" -ssh -batch -hostkey "$HOSTKEY" -P "$PORT" -pw "$PW" "$USER@$HOST" "$REMOTE_CMD"
|
||||||
|
|
||||||
|
# 4. Verify the public feed actually serves this version (GET, not HEAD — the route is GET-only).
|
||||||
|
echo "==> Verifying public feed …"
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
echo -n " latest.yml -> "; curl -s "https://remote.bizgaze.com/downloads/latest.yml" | grep -E '^version:' || echo "??"
|
||||||
|
code="$(curl -s -r 0-1 -o /dev/null -w '%{http_code}' -L "https://remote.bizgaze.com/download/windows")"
|
||||||
|
echo " /download/windows -> HTTP $code (expect 200 or 206)"
|
||||||
|
else
|
||||||
|
echo " (curl not found — skip; check https://remote.bizgaze.com/downloads/latest.yml manually)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Done. Installed 0.x apps will auto-update to $VER on next launch (or within 6h)."
|
||||||
@@ -18,6 +18,10 @@ HOST=118.95.33.89
|
|||||||
PORT=61
|
PORT=61
|
||||||
USER=root
|
USER=root
|
||||||
APPDIR=/opt/bizgaze-support
|
APPDIR=/opt/bizgaze-support
|
||||||
|
# Pinned server host key (SHA256). plink -batch won't prompt to cache an
|
||||||
|
# unknown key, so we pin it here. Verify against the fingerprint plink shows
|
||||||
|
# on first connect. Override with $DEPLOY_HOSTKEY if the server is rebuilt.
|
||||||
|
HOSTKEY="${DEPLOY_HOSTKEY:-SHA256:hxfv/hH5aplnM4wOsl+jLjWaXwEeceZ4Uz932/5IoCE}"
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
@@ -36,4 +40,4 @@ if [ -z "$PW" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Triggering deploy on $USER@$HOST ($APPDIR) …"
|
echo "==> Triggering deploy on $USER@$HOST ($APPDIR) …"
|
||||||
exec "$PLINK" -ssh -batch -P "$PORT" -pw "$PW" "$USER@$HOST" "cd $APPDIR && bash deploy.sh $*"
|
exec "$PLINK" -ssh -batch -hostkey "$HOSTKEY" -P "$PORT" -pw "$PW" "$USER@$HOST" "cd $APPDIR && bash deploy.sh $*"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ function verifyPassword(password, salt, expectedHash) {
|
|||||||
// ---- Random tokens ----
|
// ---- Random tokens ----
|
||||||
const token = (bytes = 24) => crypto.randomBytes(bytes).toString('hex');
|
const token = (bytes = 24) => crypto.randomBytes(bytes).toString('hex');
|
||||||
const id = () => crypto.randomBytes(8).toString('hex');
|
const id = () => crypto.randomBytes(8).toString('hex');
|
||||||
|
// Deterministic hash for storing high-value tokens (e.g. refresh tokens) at rest.
|
||||||
|
const hashToken = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
|
||||||
const numericCode = (digits = 6) =>
|
const numericCode = (digits = 6) =>
|
||||||
String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0');
|
String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0');
|
||||||
|
|
||||||
@@ -70,6 +72,6 @@ function otpauthUrl(secret, email, issuer = 'RemoteAccess') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
hashPassword, verifyPassword, token, id, numericCode,
|
hashPassword, verifyPassword, token, id, hashToken, numericCode,
|
||||||
newMfaSecret, totp, verifyTotp, otpauthUrl,
|
newMfaSecret, totp, verifyTotp, otpauthUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// BizGaze as identity provider.
|
||||||
|
// Validates a username/password against BizGaze's ValidateAndLogin endpoint.
|
||||||
|
// Enabled only when BIZGAZE_LOGIN_URL is set (so tests/local runs stay self-contained).
|
||||||
|
//
|
||||||
|
// Success response shape (observed):
|
||||||
|
// { status: 1, currentSession: { name, userId, tenantId, unibaseId, isAdmin, ... }, message }
|
||||||
|
// Failure: status !== 1, with a `message`.
|
||||||
|
|
||||||
|
function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; }
|
||||||
|
const isEnabled = () => !!loginUrl();
|
||||||
|
|
||||||
|
// Origin of the BizGaze app (e.g. https://c02.bizgaze.app), derived from the login URL.
|
||||||
|
function loginOrigin() { try { return new URL(loginUrl()).origin; } catch { return ''; } }
|
||||||
|
|
||||||
|
// Build an absolute profile-photo URL from the session payload. BizGaze returns a
|
||||||
|
// relative path like "_files/documents/.../x.jpg" plus an asset/app base; we try the
|
||||||
|
// asset host first, then the app host, then the login origin. Absolute URLs pass through.
|
||||||
|
function photoUrlFrom(s) {
|
||||||
|
const raw = s.photoUrl || s.PhotoUrl || s.photo || s.profilePic || s.imageUrl || '';
|
||||||
|
if (!raw || typeof raw !== 'string') return null;
|
||||||
|
if (/^https?:\/\//i.test(raw)) return raw;
|
||||||
|
const base = String(s.assetUrl || s.appUrl || loginOrigin() || '').replace(/\/+$/, '');
|
||||||
|
return base ? base + '/' + raw.replace(/^\/+/, '') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateLogin(username, password) {
|
||||||
|
const url = loginUrl();
|
||||||
|
if (!url) return { ok: false, configured: false };
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ UserName: username, Password: password, UnibaseId: '', RememberMe: false }),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, configured: true, error: 'BizGaze sign-in is unavailable right now' };
|
||||||
|
}
|
||||||
|
let data;
|
||||||
|
try { data = await res.json(); } catch { return { ok: false, configured: true, error: 'Unexpected response from BizGaze' }; }
|
||||||
|
const s = data && data.currentSession;
|
||||||
|
if (data && data.status === 1 && s) {
|
||||||
|
return {
|
||||||
|
ok: true, configured: true,
|
||||||
|
name: s.name || null,
|
||||||
|
avatarUrl: photoUrlFrom(s),
|
||||||
|
isAdmin: !!s.isAdmin,
|
||||||
|
tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id
|
||||||
|
bizgazeUserId: s.userId != null ? String(s.userId) : null,
|
||||||
|
unibaseId: s.unibaseId || null,
|
||||||
|
message: data.message || 'Login Success',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, configured: true, message: (data && data.message) || 'Invalid BizGaze credentials' };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateLogin, isEnabled };
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Shared group calls: one live call per group. Members join without a code; the call
|
||||||
|
// ends (with a duration line in the chat) when the last participant's mesh room empties.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const R = require('./repos');
|
||||||
|
const A = require('./auth');
|
||||||
|
const CHAT = require('./chat');
|
||||||
|
const { TRANS_DIR } = require('./config');
|
||||||
|
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
|
||||||
|
const now = () => Date.now();
|
||||||
|
const pairKey = (a, b) => [a, b].sort().join('|');
|
||||||
|
|
||||||
|
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
|
||||||
|
async function meetingContext(room) {
|
||||||
|
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
|
||||||
|
try {
|
||||||
|
const sched = await R.scheduledMeetings.byCode(room);
|
||||||
|
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
|
||||||
|
} catch (_) {}
|
||||||
|
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
|
||||||
|
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = await R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
|
||||||
|
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
|
||||||
|
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
|
||||||
|
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
|
||||||
|
async function finalizeTranscript(room, onlyUserId) {
|
||||||
|
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
|
||||||
|
const buf = transcriptBuffers.get(room) || [];
|
||||||
|
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
|
||||||
|
if (ids.length && buf.length) {
|
||||||
|
const ctx = await meetingContext(room);
|
||||||
|
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
|
||||||
|
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
|
||||||
|
for (const uid of ids) {
|
||||||
|
let user = null; try { user = await R.users.byId(uid); } catch (_) {}
|
||||||
|
if (!user) { subs.delete(uid); continue; }
|
||||||
|
const id = A.id(); const file = 'm_' + id + '.txt';
|
||||||
|
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
|
||||||
|
// groupId null → private to its creator (see canSeeRec / /mrec auth).
|
||||||
|
await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
|
||||||
|
subs.delete(uid);
|
||||||
|
}
|
||||||
|
} else { ids.forEach((uid) => subs.delete(uid)); }
|
||||||
|
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
|
||||||
|
|
||||||
|
async function broadcast(group, evt) { try { for (const mid of await R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
|
||||||
|
|
||||||
|
// Post a centered activity line into the group (system sender → no ping on clients).
|
||||||
|
async function postSystem(group, teamId, text) {
|
||||||
|
const id = A.id();
|
||||||
|
await R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
|
||||||
|
const m = await R.messages.byId(id);
|
||||||
|
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGroupCall(group, teamId, user) {
|
||||||
|
const existing = groupCalls.get(group);
|
||||||
|
if (existing) return { room: existing.room, active: true, already: true };
|
||||||
|
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||||
|
meetingRooms.set(room, new Map());
|
||||||
|
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
||||||
|
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
||||||
|
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
|
||||||
|
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
|
||||||
|
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {});
|
||||||
|
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
|
||||||
|
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
|
||||||
|
return { room, active: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from signaling when a mesh room empties — ends the group call if this room was one.
|
||||||
|
async function endGroupCallByRoom(room) {
|
||||||
|
const group = roomToGroupCall.get(room);
|
||||||
|
if (!group) return;
|
||||||
|
const call = groupCalls.get(group);
|
||||||
|
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
|
||||||
|
if (call) {
|
||||||
|
let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
|
||||||
|
if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
||||||
|
broadcast(group, { type: 'group-call', group, active: false, room });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
|
||||||
|
async function startDmCall(me, otherId, teamId) {
|
||||||
|
const key = pairKey(me.id, otherId);
|
||||||
|
const existing = dmCalls.get(key);
|
||||||
|
if (existing) return { room: existing.room, active: true, already: true };
|
||||||
|
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||||
|
meetingRooms.set(room, new Map());
|
||||||
|
const byName = me.name || me.email;
|
||||||
|
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
|
||||||
|
// Log to history (both participants) so the call shows under Past meetings with its transcript.
|
||||||
|
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
|
||||||
|
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
|
||||||
|
// #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed —
|
||||||
|
// so the caller isn't stuck "ringing" forever.
|
||||||
|
call.ringTimer = setTimeout(() => {
|
||||||
|
if (call.answered) return;
|
||||||
|
const peers = meetingRooms.get(room);
|
||||||
|
if (peers) { for (const [, p] of peers) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended', reason: 'unanswered' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
|
||||||
|
endDmCallByRoom(room);
|
||||||
|
}, 40000);
|
||||||
|
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
|
||||||
|
const mid = A.id();
|
||||||
|
await R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
|
||||||
|
const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
|
||||||
|
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||||
|
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||||
|
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
|
||||||
|
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
|
||||||
|
return { room, active: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endDmCallByRoom(room, silent) {
|
||||||
|
const key = roomToDmCall.get(room); if (!key) return;
|
||||||
|
const call = dmCalls.get(key);
|
||||||
|
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
|
||||||
|
if (!call) return;
|
||||||
|
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
|
||||||
|
if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
|
||||||
|
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
|
||||||
|
if (!silent) try {
|
||||||
|
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
|
||||||
|
await R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
|
||||||
|
const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
|
||||||
|
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
|
||||||
|
} catch (_) {}
|
||||||
|
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
|
||||||
|
// message shows a real duration (from answer) and the unanswered timeout stands down.
|
||||||
|
function markDmAnswered(room, userId) {
|
||||||
|
const key = roomToDmCall.get(room); if (!key) return;
|
||||||
|
const call = dmCalls.get(key); if (!call) return;
|
||||||
|
if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } }
|
||||||
|
}
|
||||||
|
// Called from signaling when any mesh room empties.
|
||||||
|
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
|
||||||
|
|
||||||
|
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
|
||||||
|
async function declineDmCall(room, byUser) {
|
||||||
|
const key = roomToDmCall.get(room); if (!key) return { ok: false };
|
||||||
|
const call = dmCalls.get(key); if (!call) return { ok: false };
|
||||||
|
// #8: if this user has ALREADY accepted on another device (they're in the room), this is just the
|
||||||
|
// ringing invite on a second device — dismiss it silently, do NOT tear down the active call.
|
||||||
|
const inRoom = meetingRooms.get(room);
|
||||||
|
if (inRoom) { for (const [, p] of inRoom) { if (p.ws && p.ws._meetingUserId === byUser.id) return { ok: true, alreadyJoined: true }; } }
|
||||||
|
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
|
||||||
|
try {
|
||||||
|
const mid = A.id();
|
||||||
|
await R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
|
||||||
|
const mm = await R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
|
||||||
|
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
|
||||||
|
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
|
||||||
|
} catch (_) {}
|
||||||
|
// Drop the caller who's still waiting in the (otherwise empty) mesh room.
|
||||||
|
const peers = meetingRooms.get(room);
|
||||||
|
if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); }
|
||||||
|
endDmCallByRoom(room, true); // silent: we already posted "Call declined"
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, finalizeTranscript, meetingContext, fmtDur, pairKey };
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
|
||||||
|
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
|
||||||
|
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
|
||||||
|
const { chatClients, meetingRooms } = require('./presence');
|
||||||
|
let _repos = null; const repos = () => (_repos || (_repos = require('./repos'))); // lazy: avoid a require cycle
|
||||||
|
|
||||||
|
function register(userId, ws) {
|
||||||
|
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
|
||||||
|
chatClients.get(userId).add(ws);
|
||||||
|
ws._chatUserId = userId;
|
||||||
|
try { repos().users.touchSeen(userId); } catch (_) {} // "last seen" (#2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregister(ws) {
|
||||||
|
const id = ws && ws._chatUserId;
|
||||||
|
if (!id) return;
|
||||||
|
const set = chatClients.get(id);
|
||||||
|
if (set) {
|
||||||
|
set.delete(ws);
|
||||||
|
// Their LAST socket went away → stamp when they were last here, so contacts can show "last seen …".
|
||||||
|
if (!set.size) { chatClients.delete(id); try { repos().users.touchSeen(id); } catch (_) {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnline(userId) {
|
||||||
|
const s = chatClients.get(userId);
|
||||||
|
return !!(s && s.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushToUser(userId, obj) {
|
||||||
|
const s = chatClients.get(userId);
|
||||||
|
if (!s) return;
|
||||||
|
const data = JSON.stringify(obj);
|
||||||
|
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Live presence -------------------------------------------------------------------------
|
||||||
|
// A user's effective status (online / in-a-call / away / …) changes with no HTTP round-trip:
|
||||||
|
// they connect or disconnect a socket, or join/leave a call. Without pushing that change, OTHER
|
||||||
|
// users only see it after a full page reload — impossible in the desktop/mobile apps. So whenever
|
||||||
|
// it changes we broadcast the user's fresh status to everyone else's sockets, and the client
|
||||||
|
// updates that contact's dot/subtitle in place.
|
||||||
|
function isInCall(userId) {
|
||||||
|
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId === userId) return true; } }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function effectiveStatus(userId) {
|
||||||
|
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'; }
|
||||||
|
}
|
||||||
|
function broadcastPresence(userId) {
|
||||||
|
if (!userId) return;
|
||||||
|
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
|
||||||
|
// bare "Offline" until the next sidebar reload (#2).
|
||||||
|
let lastSeen = null;
|
||||||
|
try { const u = repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
|
||||||
|
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: effectiveStatus(userId), lastSeen });
|
||||||
|
for (const [uid, set] of chatClients) {
|
||||||
|
if (uid === userId) continue; // no need to tell someone about their own status
|
||||||
|
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register, unregister, isOnline, pushToUser, broadcastPresence };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Runtime config + filesystem paths. Reads process.env once at startup.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
|
// Uploaded chat files, recordings and transcripts MUST live on the persistent volume (like the DB
|
||||||
|
// and downloads). With the old in-image path, every deploy.sh rebuild wiped them — old images/files
|
||||||
|
// then 404 ("broken image") while their DB rows survive. Overridable so prod points them at /data.
|
||||||
|
const REC_DIR = process.env.REC_DIR || path.join(__dirname, 'recordings');
|
||||||
|
const TRANS_DIR = process.env.TRANS_DIR || path.join(__dirname, 'transcripts');
|
||||||
|
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, 'uploads');
|
||||||
|
// Desktop installers + auto-update feed (latest.yml). Override with DOWNLOADS_DIR to point at a
|
||||||
|
// mounted volume in production; IT drops the electron-builder dist/ output here.
|
||||||
|
const DOWNLOADS_DIR = process.env.DOWNLOADS_DIR || path.join(__dirname, 'downloads');
|
||||||
|
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
|
||||||
|
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
|
||||||
|
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
|
||||||
|
try { fs.mkdirSync(DOWNLOADS_DIR, { recursive: true }); } catch (e) {}
|
||||||
|
|
||||||
|
// LiveKit SFU (scales meetings past the ~5-peer mesh ceiling). Entirely optional and config-gated:
|
||||||
|
// when LIVEKIT_URL/API_KEY/API_SECRET are all set the client uses LiveKit for meeting media; when
|
||||||
|
// they're unset the app falls back to the built-in P2P mesh, unchanged. The API secret is used
|
||||||
|
// ONLY server-side to mint per-user join tokens — it never reaches the browser.
|
||||||
|
const LIVEKIT_URL = process.env.LIVEKIT_URL || ''; // wss://livekit.bizgaze.com
|
||||||
|
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || '';
|
||||||
|
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || '';
|
||||||
|
const LIVEKIT_ENABLED = !!(LIVEKIT_URL && LIVEKIT_API_KEY && LIVEKIT_API_SECRET);
|
||||||
|
|
||||||
|
// SMTP for outbound email (meeting invites to external participants, #4). Entirely optional and
|
||||||
|
// config-gated: email is only sent when SMTP_HOST/USER/PASS are set. Credentials stay server-side.
|
||||||
|
// PUBLIC_BASE_URL is the origin used to build guest meeting links in emails (e.g. https://remote.bizgaze.com).
|
||||||
|
const SMTP_HOST = process.env.SMTP_HOST || '';
|
||||||
|
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
|
||||||
|
const SMTP_SECURE = String(process.env.SMTP_SECURE || '').toLowerCase() === 'true' || SMTP_PORT === 465; // TLS on connect (465) vs STARTTLS
|
||||||
|
const SMTP_USER = process.env.SMTP_USER || '';
|
||||||
|
const SMTP_PASS = process.env.SMTP_PASS || '';
|
||||||
|
const SMTP_FROM = process.env.SMTP_FROM || (SMTP_USER ? ('Biz Connect <' + SMTP_USER + '>') : '');
|
||||||
|
const SMTP_ENABLED = !!(SMTP_HOST && SMTP_USER && SMTP_PASS);
|
||||||
|
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://remote.bizgaze.com').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// GIPHY GIF search (#5). Key is read from the server env only and never sent to the browser — the client
|
||||||
|
// calls our /api/gifs proxy. GIF picker is hidden when this isn't configured.
|
||||||
|
const GIPHY_API_KEY = process.env.GIPHY_API_KEY || '';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PORT: process.env.PORT || 8090,
|
||||||
|
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
|
||||||
|
LIVEKIT_URL,
|
||||||
|
LIVEKIT_API_KEY,
|
||||||
|
LIVEKIT_API_SECRET,
|
||||||
|
LIVEKIT_ENABLED,
|
||||||
|
SMTP_HOST,
|
||||||
|
SMTP_PORT,
|
||||||
|
SMTP_SECURE,
|
||||||
|
SMTP_USER,
|
||||||
|
SMTP_PASS,
|
||||||
|
SMTP_FROM,
|
||||||
|
SMTP_ENABLED,
|
||||||
|
PUBLIC_BASE_URL,
|
||||||
|
GIPHY_API_KEY,
|
||||||
|
PUBLIC_DIR,
|
||||||
|
REC_DIR,
|
||||||
|
TRANS_DIR,
|
||||||
|
UPLOADS_DIR,
|
||||||
|
DOWNLOADS_DIR,
|
||||||
|
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
|
||||||
|
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
|
||||||
|
};
|
||||||
@@ -77,4 +77,331 @@ CREATE TABLE IF NOT EXISTS sessions_log (
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: stored recording filename for a session (null if not recorded)
|
||||||
|
try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ }
|
||||||
|
try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ }
|
||||||
|
|
||||||
|
// Refresh tokens for native (desktop/mobile) clients: long-lived, rotated on use,
|
||||||
|
// stored as a SHA-256 hash so a DB leak doesn't expose usable tokens.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// API keys for third-party / system integrations (machine-to-machine, no human login).
|
||||||
|
// Scoped per tenant; the key is stored as a SHA-256 hash (plaintext shown once at creation).
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
scopes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Outbound webhook subscriptions: per-tenant endpoints that receive signed event
|
||||||
|
// callbacks (session.started / session.ended). Each has its own signing secret.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
secret TEXT NOT NULL,
|
||||||
|
events TEXT NOT NULL DEFAULT '',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_status INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
last_at INTEGER
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Persistent 1:1 chat between users in the same team.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
sender_id TEXT NOT NULL,
|
||||||
|
recipient_id TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
read_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
|
||||||
|
`);
|
||||||
|
// Migration: a message can quote/reply to another message.
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN reply_to TEXT'); } catch (e) { /* exists */ }
|
||||||
|
|
||||||
|
// Emoji reactions on messages (one row per user+message+emoji; toggling adds/removes).
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (message_id, user_id, emoji)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// File attachments for chat messages (file bytes stored on disk at uploads/<id>).
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
uploader_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
mime TEXT,
|
||||||
|
size INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN attachment_id TEXT'); } catch (e) { /* exists */ }
|
||||||
|
|
||||||
|
// Group conversations + membership. (1:1 DMs keep using sender_id/recipient_id directly;
|
||||||
|
// group messages set conversation_id instead, with recipient_id left blank.)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'group',
|
||||||
|
name TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
last_read_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
joined_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (conversation_id, user_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN conversation_id TEXT'); } catch (e) { /* exists */ }
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at)'); } catch (e) {}
|
||||||
|
// Group admins: 1 = this member is an admin (multiple admins allowed). Creator seeded as admin.
|
||||||
|
try { db.exec('ALTER TABLE conversation_members ADD COLUMN admin INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||||
|
try { db.exec('UPDATE conversation_members SET admin=1 WHERE user_id IN (SELECT created_by FROM conversations WHERE conversations.id=conversation_members.conversation_id) AND admin=0'); } catch (e) {}
|
||||||
|
|
||||||
|
// Avatars: a user's profile picture (BizGaze photo URL) and a group's uploaded image
|
||||||
|
// (an attachment id, served via /files/<id> with group-membership auth).
|
||||||
|
try { db.exec('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch (e) { /* exists */ }
|
||||||
|
try { db.exec('ALTER TABLE conversations ADD COLUMN avatar_id TEXT'); } catch (e) { /* exists */ }
|
||||||
|
|
||||||
|
// @mentions on a (group) message: JSON array of mentioned user ids, and/or the literal
|
||||||
|
// "everyone" for @everyone/@all. Used to highlight and notify mentioned members.
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN mentions TEXT'); } catch (e) { /* exists */ }
|
||||||
|
|
||||||
|
// Delivered receipt for DMs (double tick): set when the recipient's client acknowledges.
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN delivered_at INTEGER'); } catch (e) { /* exists */ }
|
||||||
|
// Group setting: when 1, only the creator can add/remove members.
|
||||||
|
try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||||
|
|
||||||
|
// Polls live within a group conversation, attached to a message (the poll's question is
|
||||||
|
// the message body). options is a JSON array of option strings; votes are one row each.
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN poll_id TEXT'); } catch (e) { /* exists */ }
|
||||||
|
// Activity/event lines (e.g. 'call-start','call-end') render as centered system messages.
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* exists */ }
|
||||||
|
// Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment
|
||||||
|
// are cleared and clients render a "This message was deleted" placeholder.
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||||
|
// A message can be edited by its sender; edited_at marks it (shows an "edited" label).
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN edited_at INTEGER'); } catch (e) { /* exists */ }
|
||||||
|
try { db.exec('ALTER TABLE messages ADD COLUMN fwd_from TEXT'); } catch (e) { /* exists — original sender name when a message was forwarded (#5) */ }
|
||||||
|
// User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.)
|
||||||
|
try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ }
|
||||||
|
// BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or
|
||||||
|
// their mobile number, so this — not the typed login identifier — is the stable identity key.
|
||||||
|
// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge).
|
||||||
|
try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ }
|
||||||
|
// #2: when this user was last connected (stamped on connect + on their last socket closing), so contacts
|
||||||
|
// can show "last seen 10 minutes ago" instead of a bare "Offline".
|
||||||
|
try { db.exec('ALTER TABLE users ADD COLUMN last_seen INTEGER'); } catch (e) { /* exists */ }
|
||||||
|
// Backfill: the column is new, so every existing user was NULL and therefore still read as a bare
|
||||||
|
// "Offline" until they happened to reconnect. Seed it from the last message they sent — the best
|
||||||
|
// evidence we already have of when they were last around. Only fills rows that are still NULL.
|
||||||
|
try {
|
||||||
|
db.exec(`UPDATE users SET last_seen = (
|
||||||
|
SELECT MAX(created_at) FROM messages WHERE messages.sender_id = users.id
|
||||||
|
) WHERE last_seen IS NULL AND EXISTS (SELECT 1 FROM messages WHERE messages.sender_id = users.id)`);
|
||||||
|
} catch (e) { /* messages table may not exist yet on a fresh db */ }
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ }
|
||||||
|
// When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so
|
||||||
|
// any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor
|
||||||
|
// instead of hitting a deleted user (which made messages to merged contacts silently vanish).
|
||||||
|
// #7: a log of finished CALLS (ad-hoc / group / 1:1). Scheduled meetings already have their own row, so
|
||||||
|
// they're not duplicated here. `peak` is the most people who were in the room at once — that's what lets
|
||||||
|
// "Past meetings" show a call that grew past 2 people while hiding plain 1:1s.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS call_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
room TEXT NOT NULL,
|
||||||
|
group_id TEXT,
|
||||||
|
kind TEXT,
|
||||||
|
title TEXT,
|
||||||
|
peak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
participants TEXT,
|
||||||
|
uids TEXT,
|
||||||
|
started_at INTEGER NOT NULL,
|
||||||
|
ended_at INTEGER NOT NULL
|
||||||
|
)`);
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_call_history_team ON call_history(team_id, ended_at)'); } catch (e) {}
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_aliases (
|
||||||
|
old_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
team_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS polls (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
message_id TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
options TEXT NOT NULL,
|
||||||
|
multi INTEGER NOT NULL DEFAULT 0,
|
||||||
|
closed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS poll_votes (
|
||||||
|
poll_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
option_idx INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (poll_id, user_id, option_idx)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Scheduled meetings/calls. Each carries a stable room_code so a scheduled call can be
|
||||||
|
// joined later (the live mesh room is created on first join). group_id is optional — a
|
||||||
|
// scheduled meeting may target a specific group conversation or be standalone.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduled_meetings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
group_id TEXT,
|
||||||
|
room_code TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
scheduled_at INTEGER NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
ended_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code);
|
||||||
|
`);
|
||||||
|
// Invited participants (JSON array of user ids) + a one-shot "10-min reminder sent" flag.
|
||||||
|
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN participants TEXT'); } catch (e) { /* exists */ }
|
||||||
|
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN reminded INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||||
|
// Cancelled meetings are kept (shown as "Cancelled"), not deleted.
|
||||||
|
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||||
|
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN duration_mins INTEGER'); } catch (e) { /* exists */ }
|
||||||
|
// Weekly recurrence: JSON array of weekdays (0=Sun..6=Sat), or null for a one-off.
|
||||||
|
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN recurrence TEXT'); } catch (e) { /* exists */ }
|
||||||
|
// External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed
|
||||||
|
// guest join link instead of an in-app invite. (Must come AFTER the CREATE above — on a fresh DB these
|
||||||
|
// ALTERs previously ran before the table existed and were silently lost.)
|
||||||
|
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ }
|
||||||
|
// Lobby (#4): 1 = guests joining by link must be admitted by the host; 0 = they join directly. NULL is
|
||||||
|
// treated as "require approval" (safe default) by the signaling layer.
|
||||||
|
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN lobby INTEGER'); } catch (e) { /* exists */ }
|
||||||
|
|
||||||
|
// Meeting recordings & transcripts. Video bytes live in recordings/m_<id>.webm, transcript text
|
||||||
|
// in transcripts/m_<id>.txt. Tied to a room (and group/scheduled meeting when applicable) so they
|
||||||
|
// surface under "Past meetings". kind = 'video' | 'transcript'.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recordings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
room TEXT,
|
||||||
|
group_id TEXT,
|
||||||
|
meeting_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
file TEXT,
|
||||||
|
mime TEXT,
|
||||||
|
size INTEGER,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
created_by TEXT,
|
||||||
|
created_by_name TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Web Push subscriptions (one per browser/device per user) for background/closed-tab
|
||||||
|
// notifications. endpoint is unique; p256dh+auth are the encryption keys from the browser.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Native device push tokens (FCM for Android, APNs for iOS) registered by the mobile app.
|
||||||
|
// Distinct from push_subscriptions (Web Push): a native token is just an opaque string + platform.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS device_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
tenant_id TEXT,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// App installs (desktop/mobile clients): one row per install, associated with the user once
|
||||||
|
// they sign in. Lets admins see who installed the app, which version, and when it was last used.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_installs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
install_id TEXT NOT NULL UNIQUE,
|
||||||
|
user_id TEXT,
|
||||||
|
user_email TEXT,
|
||||||
|
tenant_id TEXT,
|
||||||
|
platform TEXT,
|
||||||
|
app_version TEXT,
|
||||||
|
os TEXT,
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_installs_tenant ON app_installs(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_installs_user ON app_installs(user_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, target)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// One-shot data migration: copy every row from the SQLite data.db into Postgres. Run ONCE at cutover,
|
||||||
|
// with the app stopped, BEFORE switching DB_BACKEND to pg.
|
||||||
|
//
|
||||||
|
// DB_PATH=/data/data.db DATABASE_URL=postgres://user:pass@host/db node db/migrate-sqlite-to-pg.js
|
||||||
|
//
|
||||||
|
// It applies the Postgres schema first, TRUNCATEs the target tables (so a re-run re-copies cleanly), then
|
||||||
|
// bulk-inserts in FK-dependency order. audit_log.id is a GENERATED identity, so its id is not copied (PG
|
||||||
|
// assigns fresh ones — nothing references audit_log.id). Timestamps/flags are plain integers on both sides.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const SQLITE = process.env.DB_PATH || path.join(__dirname, '..', 'data.db');
|
||||||
|
if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is required'); process.exit(1); }
|
||||||
|
|
||||||
|
const src = new DatabaseSync(SQLITE);
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
|
||||||
|
|
||||||
|
// Parents before children (users→teams, sessions_auth→users, machines→teams); the rest have no FKs.
|
||||||
|
const ORDER = [
|
||||||
|
'teams', 'users', 'machines', 'sessions_auth', 'audit_log', 'sessions_log', 'refresh_tokens',
|
||||||
|
'api_keys', 'webhooks', 'messages', 'message_reactions', 'attachments', 'conversations',
|
||||||
|
'conversation_members', 'call_history', 'user_aliases', 'polls', 'poll_votes', 'scheduled_meetings',
|
||||||
|
'recordings', 'push_subscriptions', 'device_tokens', 'app_installs', 'favorites',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await pool.query(fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8')); // ensure schema exists
|
||||||
|
await pool.query('TRUNCATE ' + ORDER.map((t) => '"' + t + '"').join(', ') + ' RESTART IDENTITY CASCADE');
|
||||||
|
|
||||||
|
const totals = {};
|
||||||
|
for (const table of ORDER) {
|
||||||
|
let rows = [];
|
||||||
|
try { rows = src.prepare('SELECT * FROM ' + table).all(); } catch (e) { totals[table] = 'skip(' + e.message + ')'; continue; }
|
||||||
|
if (!rows.length) { totals[table] = 0; continue; }
|
||||||
|
let cols = Object.keys(rows[0]);
|
||||||
|
if (table === 'audit_log') cols = cols.filter((c) => c !== 'id'); // GENERATED — let PG assign
|
||||||
|
const colList = cols.map((c) => '"' + c + '"').join(',');
|
||||||
|
const CHUNK = 400; // keep param count well under Postgres' 65535 limit even for wide tables
|
||||||
|
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||||
|
const batch = rows.slice(i, i + CHUNK);
|
||||||
|
const values = []; const params = [];
|
||||||
|
batch.forEach((r, ri) => {
|
||||||
|
values.push('(' + cols.map((c, ci) => '$' + (ri * cols.length + ci + 1)).join(',') + ')');
|
||||||
|
cols.forEach((c) => params.push(r[c] === undefined ? null : r[c]));
|
||||||
|
});
|
||||||
|
await pool.query('INSERT INTO "' + table + '" (' + colList + ') VALUES ' + values.join(','), params);
|
||||||
|
}
|
||||||
|
totals[table] = rows.length;
|
||||||
|
}
|
||||||
|
console.log('MIGRATED rows:', JSON.stringify(totals, null, 0));
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error('MIGRATION FAILED:', e && e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// PostgreSQL backend for the async DB adapter. Same interface as db/sqlite.js — prepare(sql).{get,all,run},
|
||||||
|
// exec(sql), tx(fn), init() — so repos and app code are engine-agnostic. Selected by DB_BACKEND=pg;
|
||||||
|
// connection string from DATABASE_URL.
|
||||||
|
const { Pool, types } = require('pg');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// BIGINT (int8, OID 20) defaults to STRING in node-postgres to avoid precision loss. Every BIGINT here is
|
||||||
|
// an epoch-ms timestamp or a byte size — all far below Number.MAX_SAFE_INTEGER — so parse them as numbers
|
||||||
|
// to match the SQLite backend. Otherwise `expires_at < Date.now()` would compare a string to a number.
|
||||||
|
types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10)));
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
|
||||||
|
|
||||||
|
// Repos use '?' placeholders (SQLite style); Postgres wants $1,$2,… — replace positionally. Safe because
|
||||||
|
// no literal '?' appears inside any SQL string literal in this codebase.
|
||||||
|
function toPg(sql) { let i = 0; return sql.replace(/\?/g, () => '$' + (++i)); }
|
||||||
|
|
||||||
|
function prepare(sql) {
|
||||||
|
const q = toPg(sql);
|
||||||
|
return {
|
||||||
|
get: (...p) => pool.query(q, p).then((r) => r.rows[0]),
|
||||||
|
all: (...p) => pool.query(q, p).then((r) => r.rows),
|
||||||
|
run: (...p) => pool.query(q, p).then((r) => ({ changes: r.rowCount, lastInsertRowid: undefined })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(sql) { return pool.query(sql).then(() => {}); }
|
||||||
|
|
||||||
|
// Transaction on ONE pooled client (a pool would scatter BEGIN/COMMIT across connections). Same runner
|
||||||
|
// shape the sqlite backend's tx() exposes, so repos.mergeInto is identical on both engines.
|
||||||
|
async function tx(fn) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const t = {
|
||||||
|
run: (sql, ...p) => client.query(toPg(sql), p).then((r) => ({ changes: r.rowCount })),
|
||||||
|
get: (sql, ...p) => client.query(toPg(sql), p).then((r) => r.rows[0]),
|
||||||
|
all: (sql, ...p) => client.query(toPg(sql), p).then((r) => r.rows),
|
||||||
|
};
|
||||||
|
const out = await fn(t);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
try { await client.query('ROLLBACK'); } catch (_) {}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the schema (all CREATE ... IF NOT EXISTS — idempotent). Multi-statement, no params, so it runs via
|
||||||
|
// the simple-query protocol in one call. MUST be awaited before serving (server.js boot).
|
||||||
|
async function init() {
|
||||||
|
const sql = fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8');
|
||||||
|
await pool.query(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { prepare, exec, tx, init, name: 'pg', _pool: pool };
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
-- PostgreSQL schema for Biz Connect — the target of the SQLite→Postgres migration.
|
||||||
|
--
|
||||||
|
-- Every column is defined up front here (unlike the SQLite db.js, which layers columns via ALTER TABLE
|
||||||
|
-- and hit a real ordering bug). Type mapping from the SQLite schema:
|
||||||
|
-- SQLite INTEGER epoch-ms timestamp -> BIGINT (ms since epoch; JS Number-safe)
|
||||||
|
-- SQLite INTEGER 0/1 boolean flag -> SMALLINT (kept numeric so app code still reads 0/1)
|
||||||
|
-- SQLite INTEGER byte size / duration-> BIGINT (files can exceed INT range)
|
||||||
|
-- SQLite INTEGER small count (peak) -> INTEGER
|
||||||
|
-- SQLite AUTOINCREMENT rowid -> BIGINT GENERATED ALWAYS AS IDENTITY
|
||||||
|
-- TEXT -> TEXT
|
||||||
|
-- Foreign keys mirror the three the SQLite schema enforced (users→teams, sessions_auth→users,
|
||||||
|
-- machines→teams). The data-migration script inserts in dependency order so these hold.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL REFERENCES teams(id),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
pw_hash TEXT NOT NULL,
|
||||||
|
pw_salt TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'technician',
|
||||||
|
mfa_secret TEXT,
|
||||||
|
mfa_enabled SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
active SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
avatar_url TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
bizgaze_user_id TEXT,
|
||||||
|
last_seen BIGINT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions_auth (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
mfa_passed SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS machines (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL REFERENCES teams(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
enroll_token TEXT NOT NULL UNIQUE,
|
||||||
|
unattended SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
last_seen BIGINT,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
user_email TEXT,
|
||||||
|
machine_id TEXT,
|
||||||
|
machine_name TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
agent_email TEXT,
|
||||||
|
agent_name TEXT,
|
||||||
|
ticket TEXT,
|
||||||
|
started_at BIGINT NOT NULL,
|
||||||
|
ended_at BIGINT,
|
||||||
|
recording TEXT,
|
||||||
|
transcript TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
revoked SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
scopes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
last_used_at BIGINT,
|
||||||
|
revoked SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
secret TEXT NOT NULL,
|
||||||
|
events TEXT NOT NULL DEFAULT '',
|
||||||
|
active SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
last_status INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
last_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
sender_id TEXT NOT NULL,
|
||||||
|
recipient_id TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
read_at BIGINT,
|
||||||
|
reply_to TEXT,
|
||||||
|
attachment_id TEXT,
|
||||||
|
conversation_id TEXT,
|
||||||
|
mentions TEXT,
|
||||||
|
delivered_at BIGINT,
|
||||||
|
poll_id TEXT,
|
||||||
|
msg_type TEXT,
|
||||||
|
deleted SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
edited_at BIGINT,
|
||||||
|
fwd_from TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
|
||||||
|
-- New: attachment lookups drove the /files auth scan (see static.js authAttachment). Index it so the
|
||||||
|
-- per-Range playback auth is a keyed lookup, not a table scan (the auth cache stays as a second line).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_attachment ON messages(attachment_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (message_id, user_id, emoji)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
uploader_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
mime TEXT,
|
||||||
|
size BIGINT,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'group',
|
||||||
|
name TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
avatar_id TEXT,
|
||||||
|
admin_only SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
last_read_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
joined_at BIGINT NOT NULL,
|
||||||
|
admin SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (conversation_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS call_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
room TEXT NOT NULL,
|
||||||
|
group_id TEXT,
|
||||||
|
kind TEXT,
|
||||||
|
title TEXT,
|
||||||
|
peak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
participants TEXT,
|
||||||
|
uids TEXT,
|
||||||
|
started_at BIGINT NOT NULL,
|
||||||
|
ended_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_call_history_team ON call_history(team_id, ended_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_aliases (
|
||||||
|
old_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
team_id TEXT,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS polls (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
message_id TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
options TEXT NOT NULL,
|
||||||
|
multi SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
closed SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS poll_votes (
|
||||||
|
poll_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
option_idx INTEGER NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (poll_id, user_id, option_idx)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduled_meetings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
group_id TEXT,
|
||||||
|
room_code TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
scheduled_at BIGINT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
ended_at BIGINT,
|
||||||
|
participants TEXT,
|
||||||
|
reminded SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
cancelled SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
duration_mins INTEGER,
|
||||||
|
recurrence TEXT,
|
||||||
|
guest_emails TEXT,
|
||||||
|
lobby SMALLINT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recordings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
room TEXT,
|
||||||
|
group_id TEXT,
|
||||||
|
meeting_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
file TEXT,
|
||||||
|
mime TEXT,
|
||||||
|
size BIGINT,
|
||||||
|
duration_ms BIGINT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_by_name TEXT,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
tenant_id TEXT,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
last_seen BIGINT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_installs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
install_id TEXT NOT NULL UNIQUE,
|
||||||
|
user_id TEXT,
|
||||||
|
user_email TEXT,
|
||||||
|
tenant_id TEXT,
|
||||||
|
platform TEXT,
|
||||||
|
app_version TEXT,
|
||||||
|
os TEXT,
|
||||||
|
first_seen BIGINT NOT NULL,
|
||||||
|
last_seen BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_installs_tenant ON app_installs(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_installs_user ON app_installs(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, target)
|
||||||
|
);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// SQLite backend for the async DB adapter (dev + tests; also the current prod engine until pg cutover).
|
||||||
|
//
|
||||||
|
// Wraps the synchronous node:sqlite instance (schema applied at load in ../db.js) in the async interface
|
||||||
|
// the repos call. Results are returned via resolved Promises, so the SAME repo code runs unchanged on this
|
||||||
|
// synchronous engine and on asynchronous Postgres — the app never sees the difference.
|
||||||
|
const raw = require('../db'); // DatabaseSync instance with the full schema already applied
|
||||||
|
|
||||||
|
// node:sqlite re-prepares cheaply, but caching by SQL text avoids re-parsing on hot paths.
|
||||||
|
const cache = new Map();
|
||||||
|
function stmt(sql) {
|
||||||
|
let s = cache.get(sql);
|
||||||
|
if (!s) { s = raw.prepare(sql); cache.set(sql, s); }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const num = (v) => (typeof v === 'bigint' ? Number(v) : v);
|
||||||
|
const runResult = (r) => ({ changes: num(r.changes), lastInsertRowid: num(r.lastInsertRowid) });
|
||||||
|
|
||||||
|
function prepare(sql) {
|
||||||
|
return {
|
||||||
|
get: (...p) => Promise.resolve(stmt(sql).get(...p)),
|
||||||
|
all: (...p) => Promise.resolve(stmt(sql).all(...p)),
|
||||||
|
run: (...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(sql) { raw.exec(sql); return Promise.resolve(); }
|
||||||
|
|
||||||
|
// Transaction primitive. SQLite is single-connection, so BEGIN/COMMIT/ROLLBACK on `raw` is safe; the
|
||||||
|
// callback gets a runner with the same async run/get shape. (The pg backend implements this on ONE pooled
|
||||||
|
// client — the reason repos must use tx() rather than bare exec('BEGIN') for multi-statement atomicity.)
|
||||||
|
async function tx(fn) {
|
||||||
|
raw.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
const t = {
|
||||||
|
run: (sql, ...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
|
||||||
|
get: (sql, ...p) => Promise.resolve(stmt(sql).get(...p)),
|
||||||
|
all: (sql, ...p) => Promise.resolve(stmt(sql).all(...p)),
|
||||||
|
};
|
||||||
|
const out = await fn(t);
|
||||||
|
raw.exec('COMMIT');
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
try { raw.exec('ROLLBACK'); } catch (_) {}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() { return Promise.resolve(); } // schema already applied synchronously in ../db.js
|
||||||
|
|
||||||
|
module.exports = { prepare, exec, tx, init, name: 'sqlite', _raw: raw };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Async DB adapter facade. The backend is chosen by DB_BACKEND (default 'sqlite'); 'pg' is added at
|
||||||
|
// cutover. Every backend implements the same async interface — prepare(sql).{get,all,run}, exec(sql),
|
||||||
|
// tx(fn), init() — so repos and app code are engine-agnostic. Swapping engines is one backend file, no
|
||||||
|
// repo changes. (This is the same "never hardwire the engine" principle we'll apply to the pub/sub layer.)
|
||||||
|
const name = process.env.DB_BACKEND || 'sqlite';
|
||||||
|
module.exports = require('./db/' + name);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// BizGaze user-directory search (cross-tenant). The auth token is kept SERVER-SIDE only — the
|
||||||
|
// browser calls /api/directory/search and never sees the token. Configure via env in production:
|
||||||
|
// BIZGAZE_DIRECTORY_URL (base, the search term is appended url-encoded)
|
||||||
|
// BIZGAZE_DIRECTORY_TOKEN (the "stat ..." Authorization header value)
|
||||||
|
const DEFAULT_URL = 'https://app.bizgaze.com/apis/v4/bizgaze/integrations/users_chatsearch/get_usersforchatsearch/searchterm/';
|
||||||
|
const DEFAULT_TOKEN = 'stat 3cd2e190b4db448496ae316b155d2441';
|
||||||
|
|
||||||
|
function baseUrl() { return process.env.BIZGAZE_DIRECTORY_URL || DEFAULT_URL; }
|
||||||
|
function token() { return process.env.BIZGAZE_DIRECTORY_TOKEN || DEFAULT_TOKEN; }
|
||||||
|
function enabled() { return !!(baseUrl() && token()); }
|
||||||
|
|
||||||
|
// Pull a field from an object by any of several case-insensitive key names.
|
||||||
|
function field(o, names) {
|
||||||
|
const keys = Object.keys(o || {});
|
||||||
|
for (const want of names) { for (const k of keys) { if (k.toLowerCase() === want) { const v = o[k]; if (v != null && v !== '') return String(v); } } }
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// BizGaze responses vary (raw array, or wrapped in Result/data, sometimes a JSON string). Normalize.
|
||||||
|
function toArray(data) {
|
||||||
|
let d = data;
|
||||||
|
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { return []; } }
|
||||||
|
if (Array.isArray(d)) return d;
|
||||||
|
if (d && typeof d === 'object') {
|
||||||
|
for (const key of ['Result', 'result', 'data', 'Data', 'records', 'Records', 'items', 'Items']) {
|
||||||
|
if (d[key] != null) { let v = d[key]; if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_) {} } if (Array.isArray(v)) return v; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(o) {
|
||||||
|
return {
|
||||||
|
id: field(o, ['userid', 'id', 'contactid', 'partyid', 'recordid']),
|
||||||
|
name: field(o, ['fullname', 'name', 'displayname', 'username', 'contactname', 'firstname']),
|
||||||
|
email: field(o, ['email', 'emailaddress', 'emailid', 'mail']),
|
||||||
|
phone: field(o, ['mobile', 'mobilenumber', 'phone', 'phonenumber', 'contactno', 'contactnumber']),
|
||||||
|
avatar: field(o, ['photourl', 'photo', 'avatar', 'imageurl', 'profilepic', 'profileimage']),
|
||||||
|
org: field(o, ['organization', 'organisation', 'company', 'tenantname', 'orgname']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(term) {
|
||||||
|
if (!enabled() || !term || term.trim().length < 2) return [];
|
||||||
|
const url = baseUrl() + encodeURIComponent(term.trim());
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const to = setTimeout(() => ctrl.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { headers: { Authorization: token(), Accept: 'application/json' }, signal: ctrl.signal });
|
||||||
|
if (!r.ok) return [];
|
||||||
|
const data = await r.json().catch(() => null);
|
||||||
|
return toArray(data).map(pick).filter((x) => x.name || x.email || x.phone).slice(0, 25);
|
||||||
|
} catch (_) { return []; }
|
||||||
|
finally { clearTimeout(to); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { search, enabled };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Small HTTP helpers shared across the server.
|
||||||
|
const now = () => Date.now();
|
||||||
|
|
||||||
|
const json = (res, code, body) => {
|
||||||
|
// no-store: API/JSON responses (and 404s) must never be cached — a cached 404 for an asset
|
||||||
|
// like /manifest.json would otherwise persist on a device even after the file is deployed.
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let data = '';
|
||||||
|
req.on('data', (c) => (data += c));
|
||||||
|
req.on('end', () => {
|
||||||
|
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(req) {
|
||||||
|
const out = {};
|
||||||
|
(req.headers.cookie || '').split(';').forEach((c) => {
|
||||||
|
const [k, ...v] = c.trim().split('=');
|
||||||
|
if (k) out[k] = decodeURIComponent(v.join('='));
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { now, json, readBody, parseCookies };
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Outbound email (meeting invites to external participants — #4). Config-gated: does nothing unless
|
||||||
|
// SMTP_* env vars are set (SMTP_ENABLED). Credentials come from config (env), never the client.
|
||||||
|
//
|
||||||
|
// Kept intentionally small: one lazily-created nodemailer transport + a couple of helpers. Sending is
|
||||||
|
// fire-and-forget from the caller's perspective (we log failures but never throw into request handlers,
|
||||||
|
// so a mail outage can't break scheduling).
|
||||||
|
const cfg = require('./config');
|
||||||
|
|
||||||
|
let _transport = null;
|
||||||
|
let _nodemailer = null;
|
||||||
|
function transport() {
|
||||||
|
if (!cfg.SMTP_ENABLED) return null;
|
||||||
|
if (_transport) return _transport;
|
||||||
|
try {
|
||||||
|
_nodemailer = _nodemailer || require('nodemailer');
|
||||||
|
_transport = _nodemailer.createTransport({
|
||||||
|
host: cfg.SMTP_HOST,
|
||||||
|
port: cfg.SMTP_PORT,
|
||||||
|
secure: cfg.SMTP_SECURE, // true for 465, false for 587/STARTTLS
|
||||||
|
auth: { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[mailer] transport init failed:', e && e.message);
|
||||||
|
_transport = null;
|
||||||
|
}
|
||||||
|
return _transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEnabled = () => cfg.SMTP_ENABLED;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send one email. Returns a promise that resolves to true/false — never rejects (callers shouldn't
|
||||||
|
// have to try/catch around scheduling). `to` may be a string or an array of addresses.
|
||||||
|
function send({ to, subject, html, text }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const t = transport();
|
||||||
|
if (!t) { console.warn('[mailer] SMTP not configured — skipping email:', subject); return resolve(false); }
|
||||||
|
const list = Array.isArray(to) ? to.filter(Boolean) : [to].filter(Boolean);
|
||||||
|
if (!list.length) return resolve(false);
|
||||||
|
t.sendMail({ from: cfg.SMTP_FROM, to: list.join(', '), subject, text: text || '', html: html || undefined }, (err) => {
|
||||||
|
if (err) { console.warn('[mailer] sendMail failed:', err && err.message); return resolve(false); }
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branded meeting-invite email. `link` is the guest join URL; `when` a human-readable time string.
|
||||||
|
function meetingInviteEmail({ title, when, link, host, description }) {
|
||||||
|
const subject = 'Meeting invite: ' + title;
|
||||||
|
const text = [
|
||||||
|
(host ? host + ' invited you to a meeting.' : 'You have a meeting invite.'),
|
||||||
|
'',
|
||||||
|
'Title: ' + title,
|
||||||
|
when ? ('When: ' + when) : '',
|
||||||
|
description ? ('Details: ' + description) : '',
|
||||||
|
'',
|
||||||
|
'Join: ' + link,
|
||||||
|
'',
|
||||||
|
'No account needed — just open the link and enter your name.',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
const html = `<div style="font-family:'Segoe UI',system-ui,sans-serif;max-width:520px;margin:0 auto;color:#0f172a">
|
||||||
|
<div style="background:#1F3B73;border-radius:14px 14px 0 0;padding:20px 24px;color:#fff">
|
||||||
|
<div style="font-size:18px;font-weight:700">Biz Connect</div>
|
||||||
|
<div style="opacity:.85;font-size:13px;margin-top:2px">Meeting invitation</div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #e3e8f2;border-top:none;border-radius:0 0 14px 14px;padding:22px 24px">
|
||||||
|
<p style="margin:0 0 14px;font-size:14px">${host ? esc(host) + ' invited you to a meeting.' : 'You have a meeting invite.'}</p>
|
||||||
|
<div style="font-size:17px;font-weight:700;margin-bottom:6px">${esc(title)}</div>
|
||||||
|
${when ? `<div style="font-size:14px;color:#475569;margin-bottom:4px">🗓 ${esc(when)}</div>` : ''}
|
||||||
|
${description ? `<div style="font-size:13px;color:#64748b;margin:10px 0 0;line-height:1.5">${esc(description)}</div>` : ''}
|
||||||
|
<a href="${esc(link)}" style="display:inline-block;margin:18px 0 8px;background:#1F3B73;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 22px;border-radius:9px">Join the meeting</a>
|
||||||
|
<div style="font-size:12px;color:#94a3b8;margin-top:8px">No account needed — open the link and enter your name.</div>
|
||||||
|
<div style="font-size:11px;color:#cbd5e1;margin-top:14px;word-break:break-all">${esc(link)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return { subject, text, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { send, meetingInviteEmail, isEnabled };
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
'use strict';
|
||||||
|
//
|
||||||
|
// media.js — web playback renditions for uploaded videos.
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS (measured, not guessed):
|
||||||
|
// Real uploads on this box were probed at 19.4 Mbps and 19.0 Mbps (1080p screen recordings) and
|
||||||
|
// 3.6 Mbps (phone portrait). To play a 19 Mbps file the client must SUSTAIN a 19 Mbps download for
|
||||||
|
// the whole clip; no mobile link does, so the <video> buffer drains every few seconds and you get
|
||||||
|
// the classic "buffers, plays, buffers, plays". Server disk and CPU were idle throughout — the
|
||||||
|
// bottleneck is the media, not the delivery.
|
||||||
|
// Separately, phone MP4s often store `moov` AFTER `mdat` (not faststart), so the player has to
|
||||||
|
// fetch the tail before it can begin at all.
|
||||||
|
//
|
||||||
|
// WHAT WE DO:
|
||||||
|
// Leave the uploaded bytes untouched — that is what the download button serves, at full quality.
|
||||||
|
// Alongside it build <id>.web.mp4: longest side capped at 1280, ~2.5 Mbps ceiling, +faststart.
|
||||||
|
// /stream/<id> prefers that rendition and falls back to the original until it is ready, so a video
|
||||||
|
// is never unplayable while it transcodes.
|
||||||
|
//
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { UPLOADS_DIR } = require('./config');
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 2; // transcoding is never urgent; leave the cores to the app
|
||||||
|
const OK_BITRATE = 2500000; // ≤2.5 Mbps streams fine over mobile
|
||||||
|
const OK_DIMENSION = 1280; // ...provided it isn't oversized as well
|
||||||
|
const PROBE_TIMEOUT = 20000;
|
||||||
|
const XCODE_TIMEOUT = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
let running = 0;
|
||||||
|
const queue = [];
|
||||||
|
const pending = new Set(); // ids queued or transcoding right now
|
||||||
|
const failed = new Set(); // ffmpeg couldn't handle it — don't retry forever
|
||||||
|
|
||||||
|
const webPath = (id) => path.join(UPLOADS_DIR, id + '.web.mp4');
|
||||||
|
|
||||||
|
function hasWebRendition(id) {
|
||||||
|
try { return fs.statSync(webPath(id)).size > 0; } catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the top-level MP4 box headers. If `mdat` comes before `moov` the index lives at the end of
|
||||||
|
// the file and the player must seek there before it can start — that is what +faststart fixes.
|
||||||
|
function isFastStart(fp) {
|
||||||
|
let fd;
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(fp, 'r');
|
||||||
|
const buf = Buffer.alloc(4096);
|
||||||
|
const read = fs.readSync(fd, buf, 0, 4096, 0);
|
||||||
|
let off = 0;
|
||||||
|
while (off + 8 <= read) {
|
||||||
|
let size = buf.readUInt32BE(off);
|
||||||
|
const type = buf.toString('latin1', off + 4, off + 8);
|
||||||
|
if (type === 'moov') return true;
|
||||||
|
if (type === 'mdat') return false;
|
||||||
|
if (size === 1) { // 64-bit largesize follows the header
|
||||||
|
if (off + 16 > read) return true;
|
||||||
|
size = Number(buf.readBigUInt64BE(off + 8));
|
||||||
|
} else if (size === 0) return true; // box runs to EOF
|
||||||
|
if (size < 8) return true; // malformed — leave it alone
|
||||||
|
off += size;
|
||||||
|
}
|
||||||
|
return true; // couldn't tell; assume fine
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function probe(fp, cb) {
|
||||||
|
execFile('ffprobe', ['-v', 'error', '-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=width,height,codec_name',
|
||||||
|
'-show_entries', 'format=bit_rate,duration',
|
||||||
|
'-of', 'json', fp], { timeout: PROBE_TIMEOUT, maxBuffer: 1 << 20 }, (err, stdout) => {
|
||||||
|
if (err) return cb(err);
|
||||||
|
let info;
|
||||||
|
try { info = JSON.parse(stdout); } catch (e) { return cb(e); }
|
||||||
|
const s = (info.streams || [])[0], f = info.format || {};
|
||||||
|
if (!s || !s.width) return cb(new Error('no video stream'));
|
||||||
|
cb(null, {
|
||||||
|
width: +s.width, height: +s.height, codec: s.codec_name || '',
|
||||||
|
bitrate: +f.bit_rate || 0, duration: +f.duration || 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap the LONGEST side at 1280 (so 1920x1080 → 1280x720, and portrait 1080x2340 → 591x1280) while
|
||||||
|
// preserving aspect. force_divisible_by=2 keeps dimensions legal for H.264.
|
||||||
|
const SCALE = 'scale=w=' + OK_DIMENSION + ':h=' + OK_DIMENSION +
|
||||||
|
':force_original_aspect_ratio=decrease:force_divisible_by=2';
|
||||||
|
|
||||||
|
function buildArgs(src, out, meta) {
|
||||||
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
|
const lightEnough = meta.bitrate > 0 && meta.bitrate <= OK_BITRATE && maxDim <= OK_DIMENSION;
|
||||||
|
// Already small enough and only the atom order is wrong → remux, no re-encode. Seconds, not minutes.
|
||||||
|
if (lightEnough && /^(h264|avc1)$/i.test(meta.codec)) {
|
||||||
|
return ['-y', '-i', src, '-c', 'copy', '-movflags', '+faststart', '-f', 'mp4', out];
|
||||||
|
}
|
||||||
|
return ['-y', '-i', src,
|
||||||
|
'-map', '0:v:0', '-map', '0:a:0?', // audio optional — silent clips exist
|
||||||
|
'-vf', SCALE,
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-profile:v', 'high', '-level', '4.0',
|
||||||
|
'-crf', '24', '-maxrate', '2500k', '-bufsize', '5000k',
|
||||||
|
'-g', '48', '-pix_fmt', 'yuv420p', // 2s keyframes: smooth seeking
|
||||||
|
'-c:a', 'aac', '-b:a', '128k', '-ac', '2',
|
||||||
|
'-movflags', '+faststart', '-f', 'mp4', out];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
while (running < MAX_CONCURRENT && queue.length) {
|
||||||
|
const id = queue.shift();
|
||||||
|
running++;
|
||||||
|
transcode(id, () => { running--; pending.delete(id); pump(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcode(id, done) {
|
||||||
|
const src = path.join(UPLOADS_DIR, id);
|
||||||
|
const out = webPath(id);
|
||||||
|
const tmp = out + '.part';
|
||||||
|
if (!src.startsWith(UPLOADS_DIR)) return done();
|
||||||
|
fs.stat(src, (e, srcStat) => {
|
||||||
|
if (e) return done();
|
||||||
|
probe(src, (perr, meta) => {
|
||||||
|
if (perr) { failed.add(id); return done(); }
|
||||||
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
|
// Nothing to gain: already light, already sized, already faststart.
|
||||||
|
if (meta.bitrate > 0 && meta.bitrate <= OK_BITRATE && maxDim <= OK_DIMENSION && isFastStart(src)) {
|
||||||
|
failed.add(id); // "no rendition needed" — same effect: stop reconsidering it
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
execFile('ffmpeg', buildArgs(src, tmp, meta), { timeout: XCODE_TIMEOUT, maxBuffer: 1 << 20 }, (xerr) => {
|
||||||
|
if (xerr) {
|
||||||
|
try { fs.unlinkSync(tmp); } catch (_) {}
|
||||||
|
failed.add(id);
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
let outStat;
|
||||||
|
try { outStat = fs.statSync(tmp); } catch (_) { failed.add(id); return done(); }
|
||||||
|
// A rendition bigger than the original helps nobody — throw it away and stream the original.
|
||||||
|
if (!outStat.size || outStat.size >= srcStat.size) {
|
||||||
|
try { fs.unlinkSync(tmp); } catch (_) {}
|
||||||
|
failed.add(id);
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
try { fs.renameSync(tmp, out); } catch (_) { try { fs.unlinkSync(tmp); } catch (__) {} }
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poster frame, generated to a temp then renamed so a reader never sees a half-written JPEG (the /thumbs
|
||||||
|
// handler and this can both target the same file). Warming it at upload means the chat bubble shows the
|
||||||
|
// poster immediately instead of a blank tile while ffmpeg runs on the first view.
|
||||||
|
function ensureThumb(id) {
|
||||||
|
const thumb = path.join(UPLOADS_DIR, id + '.thumb.jpg');
|
||||||
|
const src = path.join(UPLOADS_DIR, id);
|
||||||
|
if (!src.startsWith(UPLOADS_DIR)) return;
|
||||||
|
if (fs.existsSync(thumb)) return;
|
||||||
|
fs.stat(src, (e) => {
|
||||||
|
if (e) return;
|
||||||
|
const tmp = thumb + '.part';
|
||||||
|
execFile('ffmpeg', ['-y', '-ss', '0.5', '-i', src, '-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4', tmp],
|
||||||
|
{ timeout: 15000 }, (err) => {
|
||||||
|
if (err) { try { fs.unlinkSync(tmp); } catch (_) {} return; }
|
||||||
|
try { fs.renameSync(tmp, thumb); } catch (_) { try { fs.unlinkSync(tmp); } catch (__) {} }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue a freshly uploaded (or first-played) video. Cheap and idempotent: safe to call on every
|
||||||
|
// /stream hit, which is also how pre-existing uploads get backfilled.
|
||||||
|
function ensureWebRendition(id, mime) {
|
||||||
|
if (!/^video\//.test(mime || '')) return;
|
||||||
|
ensureThumb(id); // warm the poster so the bubble isn't blank
|
||||||
|
if (pending.has(id) || failed.has(id) || hasWebRendition(id)) return;
|
||||||
|
pending.add(id);
|
||||||
|
queue.push(id);
|
||||||
|
pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop derived files when the attachment goes away.
|
||||||
|
function dropDerived(id) {
|
||||||
|
for (const p of [webPath(id), webPath(id) + '.part', path.join(UPLOADS_DIR, id + '.thumb.jpg')]) {
|
||||||
|
try { fs.unlinkSync(p); } catch (e) {}
|
||||||
|
}
|
||||||
|
pending.delete(id); failed.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-off catch-up for videos uploaded before this module existed (and after a restore). Renditions
|
||||||
|
// persist on the data volume, so on a normal restart this finds nothing to do and costs one query.
|
||||||
|
// Deliberately delayed and rate-limited by the same 2-at-a-time queue — boot must not stall on it.
|
||||||
|
function backfill() {
|
||||||
|
setTimeout(async () => {
|
||||||
|
let rows = [];
|
||||||
|
try { rows = await require('./repos').attachments.allVideos(); } catch (e) { return; }
|
||||||
|
let queued = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (hasWebRendition(r.id)) continue;
|
||||||
|
try { if (!fs.statSync(path.join(UPLOADS_DIR, r.id)).size) continue; } catch (e) { continue; }
|
||||||
|
ensureWebRendition(r.id, r.mime); queued++;
|
||||||
|
}
|
||||||
|
if (queued) console.log('[media] backfilling streaming renditions for ' + queued + ' video(s)');
|
||||||
|
}, 15000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ensureWebRendition, hasWebRendition, webPath, dropDerived, backfill };
|
||||||
@@ -1,17 +1,205 @@
|
|||||||
{
|
{
|
||||||
"name": "remote-access-server",
|
"name": "bizgaze-support-server",
|
||||||
"version": "0.2.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "remote-access-server",
|
"name": "bizgaze-support-server",
|
||||||
"version": "0.2.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.5.0"
|
"node": ">=22.5.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"nodemailer": "^6.9.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "6.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||||
|
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
|
|||||||
@@ -3,8 +3,18 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration",
|
"description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": { "start": "node server.js" },
|
"scripts": {
|
||||||
"engines": { "node": ">=22.5.0" },
|
"start": "node server.js"
|
||||||
"dependencies": { "ws": "^8.18.0" },
|
},
|
||||||
"optionalDependencies": { "nodemailer": "^6.9.14" }
|
"engines": {
|
||||||
|
"node": ">=22.5.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"nodemailer": "^6.9.14"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// In-memory live state shared between the HTTP routes and the WebSocket signaling layer.
|
||||||
|
// NOTE (roadmap): this is the piece that must move to Redis to run multiple instances.
|
||||||
|
module.exports = {
|
||||||
|
onlineAgents: new Map(), // machineId -> { ws, machine }
|
||||||
|
liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user }
|
||||||
|
pendingShares: new Map(), // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
|
||||||
|
chatClients: new Map(), // userId -> Set<ws> (a user may have several tabs/devices open)
|
||||||
|
meetingRooms: new Map(), // roomCode -> Map(peerId -> { ws, name }) (mesh meetings MVP)
|
||||||
|
groupCalls: new Map(), // groupId -> { room, startedAt, startedBy, startedByName } (shared group calls)
|
||||||
|
roomToGroupCall: new Map(),// roomCode -> groupId (end a group call when its room empties)
|
||||||
|
dmCalls: new Map(), // pairKey "a|b" -> { room, startedAt, startedBy, startedByName, users:[a,b] }
|
||||||
|
roomToDmCall: new Map(), // roomCode -> pairKey (end a 1:1 call when its room empties)
|
||||||
|
roomHost: new Map(), // roomCode -> userId (the meeting creator = host; transferable in-call)
|
||||||
|
transcriptBuffers: new Map(),// roomCode -> [{ t, speaker, text }] (shared full-conversation buffer)
|
||||||
|
transcriptSubs: new Map(), // roomCode -> Set(userId) (who wants a private copy of the transcript)
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
/* Biz Connect — branded notification toast. BZToast.success('…') / .error / .message / .info */
|
||||||
|
.bzt-wrap{position:fixed;top:16px;right:16px;z-index:2147483600;display:flex;flex-direction:column;gap:10px;max-width:min(380px,92vw)}
|
||||||
|
@supports(top:env(safe-area-inset-top)){.bzt-wrap{top:calc(16px + env(safe-area-inset-top));right:calc(16px + env(safe-area-inset-right))}}
|
||||||
|
.bzt{display:flex;align-items:flex-start;gap:12px;background:#fff;color:#1f2430;border-radius:14px;padding:12px 14px;
|
||||||
|
box-shadow:0 12px 30px rgba(16,26,53,.20);border-left:5px solid #1F3B73;
|
||||||
|
transform:translateX(120%);opacity:0;transition:transform .3s cubic-bezier(.2,.7,.2,1),opacity .3s}
|
||||||
|
.bzt.bzt-in{transform:translateX(0);opacity:1}
|
||||||
|
.bzt.success{border-left-color:#16a34a}.bzt.error{border-left-color:#b91c1c}
|
||||||
|
.bzt-badge{flex:none;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#1F3B73}
|
||||||
|
.bzt.success .bzt-badge{background:#16a34a}.bzt.error .bzt-badge{background:#b91c1c}
|
||||||
|
.bzt-badge svg{width:20px;height:20px}
|
||||||
|
.bzt-body{flex:1;min-width:0;padding-top:1px}
|
||||||
|
.bzt-title{font:700 13.5px/1.3 'Segoe UI',system-ui,sans-serif;color:#1F3B73;margin:0 0 1px}
|
||||||
|
.bzt.success .bzt-title{color:#15803d}.bzt.error .bzt-title{color:#b91c1c}
|
||||||
|
.bzt-msg{font:500 13px/1.4 'Segoe UI',system-ui,sans-serif;color:#3a4152;overflow-wrap:anywhere}
|
||||||
|
.bzt-x{flex:none;background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:18px;line-height:1;padding:2px 4px}
|
||||||
|
.bzt-x:hover{color:#1f2430}
|
||||||
|
@media(prefers-reduced-motion:reduce){.bzt{transition:opacity .2s}}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* Biz Connect toast API. Requires bizconnect-toast.css.
|
||||||
|
BZToast.success('Saved'); BZToast.error('Connection lost'); BZToast.message('Hi', {title:'Ravi'}); */
|
||||||
|
window.BZToast=(function(){
|
||||||
|
var wrap=null;
|
||||||
|
var ICON={
|
||||||
|
message:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||||
|
info:'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="2.2"/><path d="M12 11v5M12 8h.01" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>',
|
||||||
|
success:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||||
|
error:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
|
||||||
|
};
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||||
|
function ensure(){ if(wrap) return wrap; wrap=document.createElement('div'); wrap.className='bzt-wrap'; document.body.appendChild(wrap); return wrap; }
|
||||||
|
function show(message,opts){
|
||||||
|
opts=opts||{}; var type=opts.type||'message'; var w=ensure();
|
||||||
|
var t=document.createElement('div'); t.className='bzt '+type;
|
||||||
|
t.innerHTML='<div class="bzt-badge">'+(ICON[type]||ICON.message)+'</div><div class="bzt-body">'
|
||||||
|
+(opts.title?'<div class="bzt-title">'+esc(opts.title)+'</div>':'')
|
||||||
|
+'<div class="bzt-msg">'+esc(message)+'</div></div><button class="bzt-x" aria-label="Dismiss">×</button>';
|
||||||
|
w.appendChild(t); requestAnimationFrame(function(){ t.classList.add('bzt-in'); });
|
||||||
|
var dur=(opts.duration==null?4000:opts.duration), timer;
|
||||||
|
function close(){ t.classList.remove('bzt-in'); setTimeout(function(){ if(t.parentNode) t.parentNode.removeChild(t); },320); clearTimeout(timer); }
|
||||||
|
t.querySelector('.bzt-x').onclick=close; if(dur>0) timer=setTimeout(close,dur); return close;
|
||||||
|
}
|
||||||
|
return { show:show,
|
||||||
|
message:function(m,o){o=o||{};o.type='message';return show(m,o);},
|
||||||
|
success:function(m,o){o=o||{};o.type='success';return show(m,o);},
|
||||||
|
error:function(m,o){o=o||{};o.type='error';return show(m,o);},
|
||||||
|
info:function(m,o){o=o||{};o.type='info';return show(m,o);} };
|
||||||
|
})();
|
||||||
@@ -2,11 +2,24 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<title>BizGaze Support — Agent Console</title>
|
<title>Biz Connect — Agent Console</title>
|
||||||
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
|
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
|
||||||
*{box-sizing:border-box;}
|
*{box-sizing:border-box;}
|
||||||
|
/* Modern thin scrollbars (no classic up/down arrows in the Electron shell) */
|
||||||
|
::-webkit-scrollbar{width:9px;height:9px;}
|
||||||
|
::-webkit-scrollbar-button{display:none!important;width:0;height:0;}
|
||||||
|
::-webkit-scrollbar-track{background:transparent;}
|
||||||
|
::-webkit-scrollbar-thumb{background:#c7d0dd;border-radius:9px;border:2px solid transparent;background-clip:content-box;}
|
||||||
|
::-webkit-scrollbar-thumb:hover{background:#aab6c8;}
|
||||||
|
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
|
||||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
||||||
.topbar{background:var(--blue);padding:.7rem 1.2rem;display:flex;align-items:center;justify-content:space-between;gap:.6rem;}
|
.topbar{background:var(--blue);padding:.7rem 1.2rem;display:flex;align-items:center;justify-content:space-between;gap:.6rem;}
|
||||||
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
||||||
@@ -30,7 +43,14 @@
|
|||||||
.topbar2{background:var(--card);border-bottom:1px solid var(--line);padding:.5rem 1rem;display:none;justify-content:space-between;align-items:center;}
|
.topbar2{background:var(--card);border-bottom:1px solid var(--line);padding:.5rem 1rem;display:none;justify-content:space-between;align-items:center;}
|
||||||
.topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
|
.topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
|
||||||
#endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
|
#endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
|
||||||
#video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
|
#video{width:100vw;height:100vh;background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
|
||||||
|
/* The control bar floats over the bottom-right corner (tiny icons), so the shared screen uses the
|
||||||
|
FULL viewport underneath it. */
|
||||||
|
body.has-bar #video{width:100vw;height:100vh;}
|
||||||
|
body.has-bar{background:#0b1220;}
|
||||||
|
/* Control engaged: a green inset ring makes it obvious your keyboard now drives THEIR machine. */
|
||||||
|
#video.engaged{box-shadow:inset 0 0 0 3px #16a34a;}
|
||||||
|
#ctrlHint{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;background:rgba(15,23,42,.78);color:#fff;font-family:'Segoe UI',system-ui,sans-serif;font-size:.78rem;padding:.4rem .8rem;border-radius:999px;pointer-events:none;}
|
||||||
.profile{position:relative}
|
.profile{position:relative}
|
||||||
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||||||
@@ -39,11 +59,27 @@
|
|||||||
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
||||||
.profile .pmenu a:hover{background:#f1f5f9}
|
.profile .pmenu a:hover{background:#f1f5f9}
|
||||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||||
|
.pwwrap{position:relative;}
|
||||||
|
.pwwrap input{padding-right:2.7rem;}
|
||||||
|
.eye{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;margin:0;}
|
||||||
|
.eye:hover{color:var(--blue);}
|
||||||
|
#homeLink{position:fixed;top:calc(14px + env(safe-area-inset-top,0px));left:calc(16px + env(safe-area-inset-left,0px));z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15);}
|
||||||
|
.formerr{color:#b91c1c;font-weight:600;font-size:.88rem;margin-top:.9rem;min-height:1.1em;text-align:left;}
|
||||||
|
.formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
|
||||||
|
.formerr.show::before{content:"⚠";font-size:1rem;}
|
||||||
|
@keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
|
||||||
|
/* Embedded inside the home shell: hide own chrome (the shell provides it). */
|
||||||
|
html.embed .topbar{display:none!important;}
|
||||||
|
html.embed #homeLink{display:none!important;}
|
||||||
|
html.embed #video{height:100vh!important;}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/icons.js?v=6"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
|
||||||
|
<a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
|
||||||
<div class="topbar" id="topbar">
|
<div class="topbar" id="topbar">
|
||||||
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span></div></div>
|
<div class="brandrow"><img src="/mark-light.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span>Connect</span></div></div>
|
||||||
<div class="agentchip" id="agentChip"></div>
|
<div class="agentchip" id="agentChip"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div>
|
<div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div>
|
||||||
@@ -52,10 +88,14 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||||
|
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
|
||||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
|
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
|
||||||
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
|
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
|
||||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
// When embedded in the home shell, tell the parent when a session is live so the
|
||||||
|
// rail can show a "return here" indicator.
|
||||||
|
function bzcSession(active){try{if(window.parent&&window.parent!==window)window.parent.postMessage({type:'bzc-session',flow:'connect',active:!!active},location.origin);}catch(_){}}
|
||||||
|
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
||||||
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
||||||
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
||||||
makeBrandClickable();
|
makeBrandClickable();
|
||||||
@@ -82,25 +122,30 @@ function onEnter(ids, fn){ ids.forEach(id => { const el=document.getElementById(
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// ---- LOGIN ----
|
// ---- LOGIN ----
|
||||||
|
const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
||||||
|
const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||||
|
function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
|
||||||
function renderLogin(){
|
function renderLogin(){
|
||||||
agentChip.textContent='';
|
agentChip.textContent='';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<h1>Agent sign in</h1>
|
<h1>Sign in</h1>
|
||||||
<div class="sub">Sign in to start a support session. Your name is shown to the customer.</div>
|
<div class="sub">Sign in to start a support session. Your name is shown to the customer.</div>
|
||||||
<span class="lbl">Email</span><input id="email" type="email" placeholder="you@bizgaze.com">
|
<span class="lbl">Email</span><input id="email" type="email" placeholder="you@bizgaze.com">
|
||||||
<span class="lbl">Password</span><input id="pw" type="password" placeholder="password">
|
<span class="lbl">Password</span><div class="pwwrap"><input id="pw" type="password" placeholder="password"><button type="button" class="eye" data-for="pw" aria-label="Show password"></button></div>
|
||||||
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="rememberMe" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
|
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="rememberMe" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
|
||||||
<button class="btn" id="loginBtn" style="width:100%">Sign in</button>
|
<button class="btn" id="loginBtn" style="width:100%">Sign in</button>
|
||||||
<div class="status err" id="err"></div>`;
|
<div class="formerr" id="err"></div>`;
|
||||||
{
|
{
|
||||||
const doSignIn=async()=>{
|
const doSignIn=async()=>{
|
||||||
|
const errEl=document.getElementById('err'); errEl.textContent=''; errEl.classList.remove('show');
|
||||||
try{
|
try{
|
||||||
await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value,remember:(document.getElementById('rememberMe')||{}).checked||false});
|
await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value,remember:(document.getElementById('rememberMe')||{}).checked||false});
|
||||||
me=await api('/api/me',null,'GET'); renderAgent();
|
me=await api('/api/me',null,'GET'); renderAgent();
|
||||||
}catch(e){ document.getElementById('err').textContent=e.message; }
|
}catch(e){ errEl.textContent=/invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message; errEl.classList.add('show'); }
|
||||||
};
|
};
|
||||||
document.getElementById('loginBtn').onclick=doSignIn;
|
document.getElementById('loginBtn').onclick=doSignIn;
|
||||||
onEnter(['email','pw'], doSignIn);
|
onEnter(['email','pw'], doSignIn);
|
||||||
|
wireEyes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +174,7 @@ async function startConnect(){
|
|||||||
const ticket=document.getElementById('ticketInput').value.trim();
|
const ticket=document.getElementById('ticketInput').value.trim();
|
||||||
const code=document.getElementById('codeInput').value.trim();
|
const code=document.getElementById('codeInput').value.trim();
|
||||||
if(!/^\d{6}$/.test(code)){ statusEl.textContent='Please enter the 6-digit code.'; return; }
|
if(!/^\d{6}$/.test(code)){ statusEl.textContent='Please enter the 6-digit code.'; return; }
|
||||||
statusEl.textContent='Connecting…';
|
statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Connecting…';
|
||||||
ws.send(JSON.stringify({type:'code-connect',code,ticket}));
|
ws.send(JSON.stringify({type:'code-connect',code,ticket}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +182,16 @@ function connectWS(){
|
|||||||
ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||||
ws.onmessage=async(e)=>{const m=JSON.parse(e.data);const statusEl=document.getElementById('status');switch(m.type){
|
ws.onmessage=async(e)=>{const m=JSON.parse(e.data);const statusEl=document.getElementById('status');switch(m.type){
|
||||||
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
|
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
|
||||||
case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
|
case 'session-ready': if(statusEl)statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Allowed — connecting…'; break;
|
||||||
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
||||||
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){}
|
// Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer.
|
||||||
|
// Acquire the agent mic MUTED by default — joining a screen session shouldn't open a hot mic on the
|
||||||
|
// customer without the agent choosing to speak (new #1). The Mic button unmutes it.
|
||||||
|
if(!window.__mic){ try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>{ t.enabled=false; pc.addTrack(t,mic); }); try{ setMicBtn(false); }catch(_){} }catch(e){} }
|
||||||
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
|
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
|
||||||
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
|
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
|
||||||
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
|
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
|
||||||
|
case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break;
|
||||||
case 'session-denied': renderEnded('The customer declined the request.'); break;
|
case 'session-denied': renderEnded('The customer declined the request.'); break;
|
||||||
case 'session-ended': {
|
case 'session-ended': {
|
||||||
const msgs={'share-cancelled':'The customer cancelled the screen selection. Ask them to refresh their page for a new code.',
|
const msgs={'share-cancelled':'The customer cancelled the screen selection. Ask them to refresh their page for a new code.',
|
||||||
@@ -163,10 +212,15 @@ function renderWaiting(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEnded(msg){
|
function renderEnded(msg){
|
||||||
|
bzcSession(false);
|
||||||
|
try{ stopRecording(); }catch(_){}
|
||||||
removeSessionUI();
|
removeSessionUI();
|
||||||
|
document.body.classList.remove('has-bar');
|
||||||
|
if(window.__mic){ try{ window.__mic.getTracks().forEach(t=>t.stop()); }catch(_){} window.__mic=null; } // release mic so the tab's recording dot clears
|
||||||
if(pc){ try{pc.close();}catch(e){} pc=null; }
|
if(pc){ try{pc.close();}catch(e){} pc=null; }
|
||||||
video.style.display='none'; bar.classList.remove('show');
|
video.style.display='none'; bar.classList.remove('show');
|
||||||
topbar.style.display='flex'; wrap.style.display='grid';
|
topbar.style.display='flex'; wrap.style.display='grid';
|
||||||
|
{ const hl=document.getElementById('homeLink'); if(hl && !document.documentElement.classList.contains('embed')) hl.style.display=''; }
|
||||||
card.innerHTML=`
|
card.innerHTML=`
|
||||||
<h1>Session ended</h1>
|
<h1>Session ended</h1>
|
||||||
<div class="sub">${esc(msg)}</div>
|
<div class="sub">${esc(msg)}</div>
|
||||||
@@ -179,19 +233,133 @@ let chatOpen=false;
|
|||||||
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||||
const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||||
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||||
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
|
const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></svg>';
|
||||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
const SVG_REC='<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>';
|
||||||
|
const SVG_RECSTOP='<svg viewBox="0 0 24 24" width="15" height="15" fill="#fff"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
|
||||||
|
let mediaRecorder=null, recChunks=[], recCtx=null;
|
||||||
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
let recog=null, recogActive=false, transcriptLines=[];
|
||||||
|
let recTimerInt=null, recStartTs=0;
|
||||||
|
function fmtElapsedA(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
|
||||||
|
function showRecTimer(on){
|
||||||
|
let c=document.getElementById('recTimer');
|
||||||
|
if(on){
|
||||||
|
if(!c){ c=document.createElement('div'); c.id='recTimer';
|
||||||
|
c.style.cssText='display:inline-flex;align-items:center;gap:6px;color:#fff;font-weight:700;font-size:.85rem;background:#dc2626;padding:.5rem .7rem;border-radius:12px';
|
||||||
|
c.innerHTML='<span style="width:9px;height:9px;border-radius:50%;background:#fff;display:inline-block;animation:recpulseA 1.2s infinite"></span><span id="recTimerVal">00:00</span>';
|
||||||
|
const bar=document.getElementById('sessionBar'), rb=document.getElementById('recBtn');
|
||||||
|
if(bar&&rb){ bar.insertBefore(c, rb); } else if(bar){ bar.appendChild(c); }
|
||||||
|
if(!document.getElementById('recPulseStyleA')){const st=document.createElement('style');st.id='recPulseStyleA';st.textContent='@keyframes recpulseA{0%,100%{opacity:1}50%{opacity:.2}}';document.head.appendChild(st);}
|
||||||
|
}
|
||||||
|
recStartTs=Date.now(); clearInterval(recTimerInt);
|
||||||
|
const upd=()=>{ const v=document.getElementById('recTimerVal'); if(v) v.textContent=fmtElapsedA(Date.now()-recStartTs); };
|
||||||
|
upd(); recTimerInt=setInterval(upd,1000);
|
||||||
|
} else { clearInterval(recTimerInt); recTimerInt=null; if(c) c.remove(); }
|
||||||
|
}
|
||||||
|
function addLine(role, name, text, isChat){ transcriptLines.push({ t: Date.now(), role: role, name: name||'', text: text, chat: !!isChat }); }
|
||||||
|
function startTranscription(){
|
||||||
|
transcriptLines=[];
|
||||||
|
if(!SR) return;
|
||||||
|
try{
|
||||||
|
recog=new SR(); recog.continuous=true; recog.interimResults=false; recog.lang='en-US';
|
||||||
|
recog.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ if(e.results[i].isFinal){ const txt=(e.results[i][0].transcript||'').trim(); if(txt) addLine('agent',(me&&(me.name||me.email))||'Agent',txt,false); } } };
|
||||||
|
recog.onerror=()=>{};
|
||||||
|
recog.onend=()=>{ if(recogActive){ try{recog.start();}catch(_){} } };
|
||||||
|
recogActive=true; recog.start();
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function stopTranscription(){ recogActive=false; if(recog){ try{recog.stop();}catch(_){} recog=null; } }
|
||||||
|
function buildTranscriptText(){
|
||||||
|
const lines=transcriptLines.slice().sort((a,b)=>a.t-b.t);
|
||||||
|
const pad=(n)=>String(n).padStart(2,'0');
|
||||||
|
const head='Biz Connect — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n';
|
||||||
|
const body=lines.map(l=>{ const d=new Date(l.t); const ts='['+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds())+']'; const who=(l.role==='agent'?'Agent':'Customer')+(l.name?' ('+l.name+')':'')+(l.chat?' [chat]':''); return ts+' '+who+': '+l.text; }).join('\n');
|
||||||
|
return head+(body||'(no speech captured)')+'\n';
|
||||||
|
}
|
||||||
|
async function uploadTranscript(){
|
||||||
|
if(!transcriptLines.length) return;
|
||||||
|
try{ await fetch('/api/transcript?sessionId='+encodeURIComponent(sessionId),{method:'POST',headers:{'Content-Type':'text/plain'},body:buildTranscriptText()}); }catch(_){}
|
||||||
|
}
|
||||||
|
function recBtnUpdate(on){const b=document.getElementById('recBtn');if(!b)return;b.innerHTML='<span style="display:inline-flex">'+(on?SVG_RECSTOP:SVG_REC)+'</span>';b.title=on?'Stop recording':'Record';b.style.background=on?'#dc2626':'#0ea5e9';}
|
||||||
|
function startRecording(){
|
||||||
|
const remote=video.srcObject;
|
||||||
|
const _vt=remote&&remote.getVideoTracks&&remote.getVideoTracks()[0];
|
||||||
|
if(!_vt||_vt.readyState!=='live'){ alert('No live screen to record. The customer may have disconnected.'); return; }
|
||||||
|
if(pc&&pc.connectionState&&pc.connectionState!=='connected'){ alert('Not connected to the customer right now.'); return; }
|
||||||
|
try{
|
||||||
|
recCtx=new (window.AudioContext||window.webkitAudioContext)();
|
||||||
|
const dest=recCtx.createMediaStreamDestination();
|
||||||
|
if(remote.getAudioTracks().length){ try{recCtx.createMediaStreamSource(new MediaStream(remote.getAudioTracks())).connect(dest);}catch(_){} }
|
||||||
|
if(window.__mic&&window.__mic.getAudioTracks().length){ try{recCtx.createMediaStreamSource(new MediaStream(window.__mic.getAudioTracks())).connect(dest);}catch(_){} }
|
||||||
|
const mixed=new MediaStream();
|
||||||
|
mixed.addTrack(remote.getVideoTracks()[0]);
|
||||||
|
dest.stream.getAudioTracks().forEach(t=>mixed.addTrack(t));
|
||||||
|
// Prefer MP4 (H.264/AAC) — playable by most tools (Windows Media Player, QuickTime,
|
||||||
|
// WhatsApp, etc.). Fall back to WebM only if the browser can't record MP4.
|
||||||
|
const REC_TYPES=['video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4;codecs=h264,aac','video/mp4','video/webm;codecs=vp8,opus','video/webm'];
|
||||||
|
let mime='video/webm'; for(const t of REC_TYPES){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)){ mime=t; break; } }
|
||||||
|
const recExt = mime.indexOf('mp4')!==-1 ? 'mp4' : 'webm';
|
||||||
|
const recBlobType = mime.indexOf('mp4')!==-1 ? 'video/mp4' : 'video/webm';
|
||||||
|
recChunks=[];
|
||||||
|
mediaRecorder=new MediaRecorder(mixed,{mimeType:mime});
|
||||||
|
mediaRecorder.ondataavailable=(e)=>{ if(e.data&&e.data.size) recChunks.push(e.data); };
|
||||||
|
mediaRecorder.onstop=async()=>{
|
||||||
|
try{ const blob=new Blob(recChunks,{type:recBlobType}); if(blob.size) await fetch('/api/recording?sessionId='+encodeURIComponent(sessionId)+'&ext='+recExt,{method:'POST',headers:{'Content-Type':recBlobType},body:blob}); }catch(_){}
|
||||||
|
try{recCtx&&recCtx.close();}catch(_){} recCtx=null;
|
||||||
|
};
|
||||||
|
mediaRecorder.start(1000);
|
||||||
|
startTranscription();
|
||||||
|
recBtnUpdate(true);
|
||||||
|
showRecTimer(true);
|
||||||
|
try{ws.send(JSON.stringify({type:'recording',sessionId,on:true}));}catch(_){}
|
||||||
|
}catch(e){ alert('Recording could not start on this browser.'); }
|
||||||
|
}
|
||||||
|
function stopRecording(){
|
||||||
|
if(mediaRecorder&&mediaRecorder.state!=='inactive'){ try{mediaRecorder.stop();}catch(_){} }
|
||||||
|
stopTranscription();
|
||||||
|
uploadTranscript();
|
||||||
|
recBtnUpdate(false);
|
||||||
|
showRecTimer(false);
|
||||||
|
try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){}
|
||||||
|
}
|
||||||
|
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||||
function buildBar(){
|
function buildBar(){
|
||||||
if(document.getElementById('sessionBar'))return;
|
if(document.getElementById('sessionBar'))return;
|
||||||
|
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
|
||||||
|
bzcSession(true);
|
||||||
const bar=document.createElement('div'); bar.id='sessionBar';
|
const bar=document.createElement('div'); bar.id='sessionBar';
|
||||||
bar.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
|
// Floats over the bottom-right corner with tiny icons, so the shared screen fills the whole viewport.
|
||||||
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
|
bar.style.cssText='position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:row;gap:8px;align-items:center;background:rgba(15,23,42,.72);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);padding:7px 9px;border-radius:14px;box-shadow:0 8px 22px rgba(0,0,0,.35)';
|
||||||
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
|
const I=(n)=>(window.ic?window.ic(n,16):'');
|
||||||
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
|
const mic=_btn('micBtn',I('micOff'),'Unmute','#6b7280'); // starts MUTED (new #1)
|
||||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
|
const ctrl=_btn('ctrlBtn',I('monitor'),'Control OFF — click their screen to take control','#6b7280');
|
||||||
|
const chat=_btn('chatBtn',I('chat'),'Chat','#334155');
|
||||||
|
const rec=_btn('recBtn','<svg viewBox="0 0 24 24" width="15" height="15"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>','Record','#334155');
|
||||||
|
const end=_btn('endBtn2',I('callEnd'),'End','#dc2626');
|
||||||
|
bar.appendChild(mic);bar.appendChild(ctrl);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
|
||||||
document.body.appendChild(bar);
|
document.body.appendChild(bar);
|
||||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
document.body.classList.add('has-bar');
|
||||||
|
// Shrink from the default 48px round to a compact 38px so they read as "tiny icons".
|
||||||
|
[mic,ctrl,chat,rec,end].forEach(b=>{ b.style.width='38px'; b.style.height='38px'; b.style.boxShadow='none'; });
|
||||||
|
ctrl.onclick=()=>setEngaged(!rcEngaged);
|
||||||
|
makeBarDraggable(bar,'bzc_connectbar_pos'); // new #4: the bar hides part of the shared screen → move it
|
||||||
|
// A hint over the screen until they take control, so it's obvious how to start driving.
|
||||||
|
if(!document.getElementById('ctrlHint')){
|
||||||
|
const h=document.createElement('div'); h.id='ctrlHint';
|
||||||
|
h.textContent='Click the screen to take control · Esc to release';
|
||||||
|
document.body.appendChild(h);
|
||||||
|
}
|
||||||
|
mic.onclick=async()=>{
|
||||||
|
// If the mic wasn't acquired yet (e.g. the customer never renegotiated), get it now, then toggle.
|
||||||
|
if(!window.__mic){
|
||||||
|
try{ const s=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=s; s.getAudioTracks().forEach(t=>{ t.enabled=false; if(pc) pc.addTrack(t,s); }); }
|
||||||
|
catch(e){ toast('Microphone permission was blocked.'); return; }
|
||||||
|
}
|
||||||
|
const t=window.__mic.getAudioTracks()[0]; if(!t) return;
|
||||||
|
t.enabled=!t.enabled; setMicBtn(t.enabled);
|
||||||
|
};
|
||||||
chat.onclick=toggleChat;
|
chat.onclick=toggleChat;
|
||||||
|
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
|
||||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
|
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
|
||||||
buildChatPanel();
|
buildChatPanel();
|
||||||
document.addEventListener('pointerdown',ensureAudio,{once:true});
|
document.addEventListener('pointerdown',ensureAudio,{once:true});
|
||||||
@@ -201,7 +369,7 @@ function buildBar(){
|
|||||||
function buildChatPanel(){
|
function buildChatPanel(){
|
||||||
if(document.getElementById('chatPanel'))return;
|
if(document.getElementById('chatPanel'))return;
|
||||||
const p=document.createElement('div'); p.id='chatPanel';
|
const p=document.createElement('div'); p.id='chatPanel';
|
||||||
p.style.cssText='position:fixed;right:18px;bottom:84px;width:300px;max-width:92vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
|
p.style.cssText='position:fixed;right:88px;bottom:18px;width:300px;max-width:80vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
|
||||||
p.innerHTML='<div style="background:#1F3B73;color:#fff;padding:.6rem .85rem;font-weight:600;font-size:.92rem;display:flex;justify-content:space-between;align-items:center">Chat <span id="chatClose" style="cursor:pointer;opacity:.85">✕</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
|
p.innerHTML='<div style="background:#1F3B73;color:#fff;padding:.6rem .85rem;font-weight:600;font-size:.92rem;display:flex;justify-content:space-between;align-items:center">Chat <span id="chatClose" style="cursor:pointer;opacity:.85">✕</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
|
||||||
document.body.appendChild(p);
|
document.body.appendChild(p);
|
||||||
document.getElementById('chatSend').onclick=sendChat;
|
document.getElementById('chatSend').onclick=sendChat;
|
||||||
@@ -210,39 +378,127 @@ function buildChatPanel(){
|
|||||||
}
|
}
|
||||||
function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
|
function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
|
||||||
function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
|
function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
|
||||||
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}toast((msg.name||'Message')+': '+msg.text);try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
|
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}if(window.BZToast)BZToast.message(msg.text,{title:(msg.name||'Message')});try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
|
||||||
function toast(text){let t=document.getElementById('msgToast');if(!t){t=document.createElement('div');t.id='msgToast';t.style.cssText='position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:2147483600;background:#16a34a;color:#fff;padding:.7rem 1.1rem;border-radius:12px;box-shadow:0 10px 26px rgba(0,0,0,.35);font-size:.92rem;font-weight:600;border:2px solid #0c7a36;max-width:82vw;transition:opacity .4s';document.body.appendChild(t);}t.innerHTML='\ud83d\udcac '+text;t.style.opacity='1';clearTimeout(window.__toastT);window.__toastT=setTimeout(()=>{t.style.opacity='0';},2800);}
|
// Branded toast (BZToast, /bizconnect-toast.js). Classify by wording so errors show red.
|
||||||
|
function toast(text){var s=String(text==null?'':text);if(!window.BZToast)return;if(/could ?n.t|cannot|failed|invalid|error|denied|expired|not found|please enter/i.test(s))return BZToast.error(s);return BZToast.message(s);}
|
||||||
let __ac=null;
|
let __ac=null;
|
||||||
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
||||||
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
||||||
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
|
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
|
||||||
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';}
|
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
|
||||||
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
|
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
|
||||||
|
|
||||||
async function setupPeer(){
|
async function setupPeer(){
|
||||||
await ensureIce();
|
await ensureIce();
|
||||||
pc=new RTCPeerConnection(ICE);
|
pc=new RTCPeerConnection(ICE);
|
||||||
inputChannel=pc.createDataChannel('input',{ordered:true});
|
inputChannel=pc.createDataChannel('input',{ordered:true});
|
||||||
pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}}; } };
|
pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{const mm=JSON.parse(e.data);addChat(mm);if(recogActive)addLine('customer',mm.name||'Customer',mm.text,true);}catch(_){}}; } };
|
||||||
pc.ontrack=(ev)=>{
|
pc.ontrack=(ev)=>{
|
||||||
if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; return; }
|
if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; return; }
|
||||||
video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; video.style.display='block'; video.focus(); buildBar();
|
video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; video.style.display='block'; video.focus(); buildBar();
|
||||||
};
|
};
|
||||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||||
|
pc.onconnectionstatechange=()=>{ if(!pc)return; const s=pc.connectionState;
|
||||||
|
if(s==='failed'){ renderEnded('The connection was lost. Ask the customer to refresh their page for a new code.'); }
|
||||||
|
else if(s==='disconnected'){ clearTimeout(pc._dt); pc._dt=setTimeout(()=>{ if(pc&&(pc.connectionState==='disconnected'||pc.connectionState==='failed')) renderEnded('The connection was lost. Ask the customer to refresh their page for a new code.'); },8000); }
|
||||||
|
else if(s==='connected'){ clearTimeout(pc._dt); } };
|
||||||
}
|
}
|
||||||
const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));};
|
const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));};
|
||||||
const rel=(e)=>{const r=video.getBoundingClientRect();return{x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height};};
|
// Map a pointer event to NORMALIZED coords over the actual VIDEO CONTENT, not the <video> element.
|
||||||
|
// object-fit:contain letterboxes the shared screen inside the element (bars top/bottom or sides), so
|
||||||
|
// mapping against the element rect put the cursor off by the bar size (the "must aim lower" bug). We
|
||||||
|
// compute the content rect from the source resolution (videoWidth/Height) and map against that.
|
||||||
|
function contentRect(){
|
||||||
|
const r=video.getBoundingClientRect();
|
||||||
|
const vw=video.videoWidth||16, vh=video.videoHeight||9;
|
||||||
|
const elAR=r.width/r.height, vidAR=vw/vh;
|
||||||
|
let cw, ch, ox=0, oy=0;
|
||||||
|
if(vidAR>elAR){ cw=r.width; ch=r.width/vidAR; oy=(r.height-ch)/2; } // letterbox: bars top & bottom
|
||||||
|
else { ch=r.height; cw=r.height*vidAR; ox=(r.width-cw)/2; } // pillarbox: bars left & right
|
||||||
|
return {left:r.left+ox, top:r.top+oy, width:cw, height:ch};
|
||||||
|
}
|
||||||
|
const rel=(e)=>{ const c=contentRect(); let x=(e.clientX-c.left)/c.width, y=(e.clientY-c.top)/c.height; return {x:Math.max(0,Math.min(1,x)), y:Math.max(0,Math.min(1,y))}; };
|
||||||
let lm=0;
|
let lm=0;
|
||||||
video.addEventListener('mousemove',e=>{const t=performance.now();if(t-lm<30)return;lm=t;send({kind:'mousemove',...rel(e)});});
|
video.addEventListener('mousemove',e=>{const t=performance.now();if(t-lm<16)return;lm=t;send({kind:'mousemove',...rel(e)});}); // ~60/s for a smoother cursor
|
||||||
video.addEventListener('mousedown',e=>{video.focus();send({kind:'mousedown',button:e.button,...rel(e)});});
|
video.addEventListener('mousedown',e=>{video.focus();send({kind:'mousedown',button:e.button,...rel(e)});});
|
||||||
video.addEventListener('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(e)}));
|
video.addEventListener('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(e)}));
|
||||||
video.addEventListener('dblclick',e=>send({kind:'dblclick',...rel(e)}));
|
video.addEventListener('dblclick',e=>send({kind:'dblclick',...rel(e)}));
|
||||||
video.addEventListener('wheel',e=>{e.preventDefault();send({kind:'scroll',dx:e.deltaX,dy:e.deltaY});},{passive:false});
|
video.addEventListener('wheel',e=>{e.preventDefault();send({kind:'scroll',dx:e.deltaX,dy:e.deltaY});},{passive:false});
|
||||||
video.addEventListener('contextmenu',e=>e.preventDefault());
|
video.addEventListener('contextmenu',e=>e.preventDefault());
|
||||||
video.addEventListener('keydown',e=>{e.preventDefault();send({kind:'keydown',key:e.key,code:e.code});});
|
// ---- Keyboard control gating ----
|
||||||
video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.key,code:e.code});});
|
// Keys reach the SHARER only while control is ENGAGED: the window is focused AND you clicked into the
|
||||||
|
// shared screen. Otherwise your typing stays on YOUR machine (so a minimised/unfocused window, or typing
|
||||||
|
// in chat, never leaks keystrokes to the remote desktop). Click the screen (or the Control button) to
|
||||||
|
// take control; click away, press Esc, or leave the window to release it.
|
||||||
|
function rcTyping(){ const a=document.activeElement; return a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA'); }
|
||||||
|
// new #4: drag the floating control bar anywhere — it otherwise sits over part of the shared screen.
|
||||||
|
// Drag from the bar background (not the buttons); position is remembered.
|
||||||
|
function makeBarDraggable(el,key){
|
||||||
|
if(!el||el._drag) return; el._drag=true;
|
||||||
|
let sx=0,sy=0,ox=0,oy=0,dragging=false;
|
||||||
|
const pt=(e)=>e.touches?e.touches[0]:e;
|
||||||
|
const clamp=()=>{ const w=el.offsetWidth,h=el.offsetHeight;
|
||||||
|
let x=parseFloat(el.style.left||'0'), y=parseFloat(el.style.top||'0');
|
||||||
|
x=Math.max(4,Math.min(x,window.innerWidth-w-4)); y=Math.max(4,Math.min(y,window.innerHeight-h-4));
|
||||||
|
el.style.left=x+'px'; el.style.top=y+'px'; };
|
||||||
|
const pin=(r)=>{ el.style.left=r.left+'px'; el.style.top=r.top+'px'; el.style.right='auto'; el.style.bottom='auto'; };
|
||||||
|
try{ const s=JSON.parse(localStorage.getItem(key)||'null'); if(s&&typeof s.x==='number'){ pin({left:s.x,top:s.y}); clamp(); } }catch(_){}
|
||||||
|
const move=(e)=>{ if(!dragging) return; const p=pt(e); el.style.left=(ox+(p.clientX-sx))+'px'; el.style.top=(oy+(p.clientY-sy))+'px'; clamp(); if(e.cancelable) e.preventDefault(); };
|
||||||
|
const up=()=>{ if(!dragging) return; dragging=false; el.style.opacity='';
|
||||||
|
document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
|
||||||
|
document.removeEventListener('touchmove',move); document.removeEventListener('touchend',up);
|
||||||
|
try{ localStorage.setItem(key, JSON.stringify({x:parseFloat(el.style.left), y:parseFloat(el.style.top)})); }catch(_){} };
|
||||||
|
const down=(e)=>{ if(e.target.closest('button,select,input,a')) return;
|
||||||
|
const r=el.getBoundingClientRect(); pin(r);
|
||||||
|
const p=pt(e); sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top; dragging=true; el.style.opacity='.92';
|
||||||
|
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
|
||||||
|
document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up);
|
||||||
|
if(e.cancelable) e.preventDefault(); };
|
||||||
|
el.addEventListener('mousedown',down); el.addEventListener('touchstart',down,{passive:false});
|
||||||
|
el.style.cursor='move'; el.title='Drag to move';
|
||||||
|
}
|
||||||
|
// Mic button state (kept global so the offer handler can set it once the mic is acquired, muted).
|
||||||
|
function setMicBtn(on){
|
||||||
|
const b=document.getElementById('micBtn'); if(!b) return;
|
||||||
|
b.title=on?'Mute':'Unmute';
|
||||||
|
b.innerHTML='<span style="display:inline-flex">'+(window.ic?window.ic(on?'mic':'micOff',16):'')+'</span>';
|
||||||
|
b.style.background=on?'#2563eb':'#6b7280';
|
||||||
|
}
|
||||||
|
let rcEngaged=false;
|
||||||
|
function setEngaged(on){
|
||||||
|
const next=!!on; if(next===rcEngaged) return;
|
||||||
|
rcEngaged=next;
|
||||||
|
if(video) video.classList.toggle('engaged', rcEngaged);
|
||||||
|
const b=document.getElementById('ctrlBtn');
|
||||||
|
if(b){ b.style.background=rcEngaged?'#16a34a':'#6b7280'; b.title=rcEngaged?'Control ON — your mouse & keyboard drive their screen (Esc to release)':'Control OFF — click their screen to take control'; }
|
||||||
|
const hint=document.getElementById('ctrlHint'); if(hint) hint.style.display=rcEngaged?'none':'block';
|
||||||
|
// Releasing control must not leave modifiers stuck down on the remote machine.
|
||||||
|
if(!rcEngaged){ ['ShiftLeft','ControlLeft','AltLeft','MetaLeft'].forEach(code=>send({kind:'keyup',key:code.replace(/Left$/,''),code})); }
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown',(e)=>{
|
||||||
|
if(!video || video.style.display!=='block') return;
|
||||||
|
if(e.target===video){ setEngaged(true); return; } // clicked the shared screen → take control
|
||||||
|
if(e.target.closest && e.target.closest('#sessionBar')) return; // control bar clicks don't release
|
||||||
|
setEngaged(false); // clicked anywhere else → release
|
||||||
|
});
|
||||||
|
window.addEventListener('blur',()=>setEngaged(false)); // window minimised / lost focus → release
|
||||||
|
document.addEventListener('keydown',e=>{
|
||||||
|
if(e.key==='Escape' && rcEngaged){ e.preventDefault(); setEngaged(false); return; }
|
||||||
|
if(!video||video.style.display!=='block'||!rcEngaged||!document.hasFocus()||rcTyping()) return;
|
||||||
|
e.preventDefault(); send({kind:'keydown',key:e.key,code:e.code});
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup',e=>{
|
||||||
|
if(!video||video.style.display!=='block'||!rcEngaged||!document.hasFocus()||rcTyping()) return;
|
||||||
|
e.preventDefault(); send({kind:'keyup',key:e.key,code:e.code});
|
||||||
|
});
|
||||||
|
// Mobile viewer (#4): map touch → mouse so a phone/tablet can control too. Tap = move+click; drag = move.
|
||||||
|
const relT=(t)=>{ const c=contentRect(); return {x:Math.max(0,Math.min(1,(t.clientX-c.left)/c.width)), y:Math.max(0,Math.min(1,(t.clientY-c.top)/c.height))}; };
|
||||||
|
video.addEventListener('touchstart',e=>{ if(!e.touches.length) return; e.preventDefault(); const p=relT(e.touches[0]); send({kind:'mousemove',...p}); send({kind:'mousedown',button:0,...p}); },{passive:false});
|
||||||
|
video.addEventListener('touchmove',e=>{ if(!e.touches.length) return; e.preventDefault(); const t=performance.now(); if(t-lm<16) return; lm=t; send({kind:'mousemove',...relT(e.touches[0])}); },{passive:false});
|
||||||
|
video.addEventListener('touchend',e=>{ e.preventDefault(); const t=e.changedTouches&&e.changedTouches[0]; const p=t?relT(t):null; if(p) send({kind:'mousemove',...p}); send({kind:'mouseup',button:0,...(p||{})}); },{passive:false});
|
||||||
document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));};
|
document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));};
|
||||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
</script>
|
</script>
|
||||||
|
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>BizGaze Support — Staff Console</title>
|
|
||||||
<style>
|
|
||||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
|
|
||||||
*{box-sizing:border-box;}
|
|
||||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
|
||||||
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
|
|
||||||
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
|
||||||
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
|
|
||||||
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span{color:var(--brand);font-weight:600;}
|
|
||||||
.who{color:#dbe4f5;font-size:.85rem;margin-right:.7rem;}
|
|
||||||
main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
|
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
|
|
||||||
h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
|
|
||||||
input,select{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);margin:.25rem 0;font-size:.92rem;}
|
|
||||||
input:focus,select:focus{outline:none;border-color:var(--brand);}
|
|
||||||
button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
|
|
||||||
button:hover{background:var(--brand-d);}
|
|
||||||
button.ghost{background:transparent;color:#dbe4f5;border:1px solid #46598c;font-weight:600;}
|
|
||||||
button.ghost:hover{background:var(--blue-d);}
|
|
||||||
button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
|
|
||||||
button.mini:hover{background:var(--blue-soft);}
|
|
||||||
button.mini.danger{color:var(--red);}
|
|
||||||
.row{display:flex;gap:.5rem;align-items:center;}
|
|
||||||
.muted{color:var(--muted);font-size:.85rem;}
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:.88rem;}
|
|
||||||
th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
|
|
||||||
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
|
|
||||||
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
|
|
||||||
.pill.on{background:#ecfdf3;color:#15803d;} .pill.off{background:#fee2e2;color:var(--red);}
|
|
||||||
.hidden{display:none;}
|
|
||||||
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
|
|
||||||
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
|
|
||||||
.tabs button.active{background:var(--blue);color:#fff;}
|
|
||||||
.quick{display:flex;align-items:center;justify-content:space-between;gap:1rem;background:linear-gradient(120deg,var(--blue),var(--blue-d));color:#fff;border:none;}
|
|
||||||
.quick h2{color:#fff;margin:0 0 .25rem;}
|
|
||||||
.quick p{margin:0;color:#cdd7ee;font-size:.88rem;}
|
|
||||||
.quick a{background:var(--brand);color:var(--ink);text-decoration:none;font-weight:700;padding:.7rem 1.3rem;border-radius:10px;white-space:nowrap;}
|
|
||||||
.quick a:hover{background:var(--brand-d);}
|
|
||||||
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
|
|
||||||
.filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
|
|
||||||
.filters .f{flex:1;min-width:140px;}
|
|
||||||
.filters .lbl{margin:.1rem 0 .15rem;}
|
|
||||||
.profile{position:relative}
|
|
||||||
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
|
||||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
|
||||||
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:190px;overflow:hidden;z-index:5000;display:none}
|
|
||||||
.profile .pmenu.open{display:block}
|
|
||||||
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
|
||||||
.profile .pmenu a:hover{background:#f1f5f9}
|
|
||||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span> <span style="color:#8ea3cf;font-weight:500;font-size:.85rem">· Console</span></div></div>
|
|
||||||
<div class="row" id="hdrRight"></div>
|
|
||||||
</header>
|
|
||||||
<main id="app"></main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
|
||||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
|
||||||
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
|
||||||
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
|
||||||
makeBrandClickable();
|
|
||||||
const app = document.getElementById('app');
|
|
||||||
const hdrRight = document.getElementById('hdrRight');
|
|
||||||
|
|
||||||
async function api(path, body, method = 'POST') {
|
|
||||||
const opt = { method, headers: { 'Content-Type': 'application/json' } };
|
|
||||||
if (body) opt.body = JSON.stringify(body);
|
|
||||||
const r = await fetch(path, opt);
|
|
||||||
const data = await r.json().catch(() => ({}));
|
|
||||||
if (!r.ok) throw new Error(data.error || 'request failed');
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function onEnter(ids, fn){ ids.forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fn(); } }); }); }
|
|
||||||
|
|
||||||
function view(html) { app.innerHTML = html; }
|
|
||||||
|
|
||||||
// ---------- Auth ----------
|
|
||||||
async function authView() {
|
|
||||||
hdrRight.innerHTML = '';
|
|
||||||
let regOpen = false;
|
|
||||||
try { regOpen = (await api('/api/setup-state', null, 'GET')).registrationOpen; } catch {}
|
|
||||||
view(`
|
|
||||||
<div class="card" style="max-width:420px;margin:3rem auto">
|
|
||||||
<div class="tabs">
|
|
||||||
<button id="tabLogin" class="active">Sign in</button>
|
|
||||||
${regOpen ? '<button id="tabReg">Register team</button>' : ''}
|
|
||||||
</div>
|
|
||||||
<div id="loginForm">
|
|
||||||
<span class="lbl">Email</span>
|
|
||||||
<input id="li_email" placeholder="you@bizgaze.com" type="email">
|
|
||||||
<span class="lbl">Password</span>
|
|
||||||
<input id="li_pw" placeholder="password" type="password">
|
|
||||||
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
|
|
||||||
<button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
|
|
||||||
<p id="li_err" class="muted"></p>
|
|
||||||
</div>
|
|
||||||
${regOpen ? `<div id="regForm" class="hidden">
|
|
||||||
<span class="lbl">Team name</span>
|
|
||||||
<input id="rg_team" placeholder="e.g. BizGaze Support">
|
|
||||||
<span class="lbl">Email</span>
|
|
||||||
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
|
|
||||||
<span class="lbl">Password</span>
|
|
||||||
<input id="rg_pw" placeholder="min 8 characters" type="password">
|
|
||||||
<button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
|
|
||||||
<p id="rg_err" class="muted"></p>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>`);
|
|
||||||
document.getElementById('li_btn').onclick = doLogin;
|
|
||||||
onEnter(['li_email','li_pw'], doLogin);
|
|
||||||
if (regOpen) {
|
|
||||||
document.getElementById('tabLogin').onclick = () => toggle(true);
|
|
||||||
document.getElementById('tabReg').onclick = () => toggle(false);
|
|
||||||
document.getElementById('rg_btn').onclick = doRegister;
|
|
||||||
onEnter(['rg_team','rg_email','rg_pw'], doRegister);
|
|
||||||
}
|
|
||||||
function toggle(login) {
|
|
||||||
document.getElementById('loginForm').classList.toggle('hidden', !login);
|
|
||||||
document.getElementById('regForm').classList.toggle('hidden', login);
|
|
||||||
document.getElementById('tabLogin').classList.toggle('active', login);
|
|
||||||
const rt = document.getElementById('tabReg'); if (rt) rt.classList.toggle('active', !login);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doLogin() {
|
|
||||||
try {
|
|
||||||
const rem = document.getElementById('li_remember');
|
|
||||||
await api('/api/login', { email: li_email.value, password: li_pw.value, remember: rem ? rem.checked : false });
|
|
||||||
location.reload();
|
|
||||||
} catch (e) { li_err.textContent = e.message; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doRegister() {
|
|
||||||
try {
|
|
||||||
await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
|
|
||||||
await api('/api/login', { email: rg_email.value, password: rg_pw.value });
|
|
||||||
location.reload();
|
|
||||||
} catch (e) { rg_err.textContent = e.message; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Dashboard ----------
|
|
||||||
let ME = null;
|
|
||||||
async function dashboard(me) {
|
|
||||||
ME = me;
|
|
||||||
hdrRight.innerHTML = profileHTML((me.name||me.email)+' · '+me.role); wireProfile();
|
|
||||||
view(`
|
|
||||||
<div class="card quick">
|
|
||||||
<div><h2>Start a support session</h2><p>Customer gives you their 6-digit code from the share page.</p></div>
|
|
||||||
<a href="/connect">Open connect page →</a>
|
|
||||||
</div>
|
|
||||||
<div class="card" id="agentsCard">
|
|
||||||
<h2>Agents</h2>
|
|
||||||
<table id="agents"><thead><tr><th>Email</th><th>Display name</th><th>Role</th><th>Status</th><th style="width:280px"></th></tr></thead><tbody></tbody></table>
|
|
||||||
<div class="row" style="margin-top:1rem;flex-wrap:wrap">
|
|
||||||
<input id="agEmail" placeholder="agent email" style="max-width:200px">
|
|
||||||
<input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
|
|
||||||
<input id="agPw" placeholder="temporary password" style="max-width:170px">
|
|
||||||
<select id="agRole" style="max-width:140px">
|
|
||||||
<option value="technician">technician</option><option value="admin">admin</option><option value="viewer">view-only</option>
|
|
||||||
</select>
|
|
||||||
<button id="agAdd">Add agent</button>
|
|
||||||
</div>
|
|
||||||
<p id="agOut" class="muted"></p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>Session report</h2>
|
|
||||||
<div class="filters">
|
|
||||||
<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>
|
|
||||||
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
|
|
||||||
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
|
|
||||||
<button id="fApply">Apply</button>
|
|
||||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
|
|
||||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
|
|
||||||
</div>
|
|
||||||
<table id="report"><thead><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr></thead><tbody></tbody></table>
|
|
||||||
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
|
|
||||||
</div>`);
|
|
||||||
|
|
||||||
if (me.role !== 'admin') document.getElementById('agentsCard').style.display = 'none';
|
|
||||||
else {
|
|
||||||
document.getElementById('agAdd').onclick = addAgent;
|
|
||||||
onEnter(['agEmail','agName','agPw'], addAgent);
|
|
||||||
await loadAgents();
|
|
||||||
}
|
|
||||||
document.getElementById('fApply').onclick = loadReport;
|
|
||||||
document.getElementById('fExcel').onclick = exportExcel;
|
|
||||||
document.getElementById('fPdf').onclick = exportPdf;
|
|
||||||
await populateAgentFilter();
|
|
||||||
await loadReport();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addAgent() {
|
|
||||||
try {
|
|
||||||
const r = await api('/api/users', { email: agEmail.value, name: agName.value, password: agPw.value, role: agRole.value });
|
|
||||||
agOut.textContent = `Agent ${r.email} added. Share the email + temporary password — they sign in at /connect.`;
|
|
||||||
agEmail.value = ''; agName.value = ''; agPw.value = '';
|
|
||||||
loadAgents(); populateAgentFilter();
|
|
||||||
} catch (e) { agOut.textContent = e.message; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAgents() {
|
|
||||||
const rows = await api('/api/users', null, 'GET');
|
|
||||||
document.querySelector('#agents tbody').innerHTML = rows.map((u) => `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(u.email)}</td><td>${esc(u.name || '—')}</td><td>${esc(u.role)}</td>
|
|
||||||
<td><span class="pill ${u.active === 0 ? 'off' : 'on'}">${u.active === 0 ? 'deactivated' : 'active'}</span></td>
|
|
||||||
<td>
|
|
||||||
<button class="mini" onclick="resetPw('${u.id}','${esc(u.email)}')">Reset password</button>
|
|
||||||
<button class="mini" onclick="renameAgent('${u.id}','${esc(u.email)}')">Edit name</button>
|
|
||||||
${u.id === ME.id ? '' : (u.active === 0
|
|
||||||
? `<button class="mini" onclick="manage('${u.id}','activate')">Activate</button>`
|
|
||||||
: `<button class="mini danger" onclick="manage('${u.id}','deactivate')">Deactivate</button>`)
|
|
||||||
}
|
|
||||||
${u.id === ME.id ? '' : `<button class="mini danger" onclick="delAgent('${u.id}','${esc(u.email)}')">Delete</button>`}
|
|
||||||
</td>
|
|
||||||
</tr>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.resetPw = async (id, email) => {
|
|
||||||
const pw = prompt(`New password for ${email} (min 8 characters):`);
|
|
||||||
if (!pw) return;
|
|
||||||
try { await api('/api/users/manage', { id, action: 'reset-password', password: pw }); agOut.textContent = `Password reset for ${email}. They were signed out everywhere.`; }
|
|
||||||
catch (e) { agOut.textContent = e.message; }
|
|
||||||
};
|
|
||||||
window.renameAgent = async (id, email) => {
|
|
||||||
const name = prompt(`Display name for ${email} (as in the BizGaze app):`);
|
|
||||||
if (!name) return;
|
|
||||||
try { await api('/api/users/manage', { id, action: 'rename', name }); loadAgents(); }
|
|
||||||
catch (e) { agOut.textContent = e.message; }
|
|
||||||
};
|
|
||||||
window.manage = async (id, action) => {
|
|
||||||
try { await api('/api/users/manage', { id, action }); loadAgents(); }
|
|
||||||
catch (e) { agOut.textContent = e.message; }
|
|
||||||
};
|
|
||||||
window.delAgent = async (id, email) => {
|
|
||||||
if (!confirm(`Delete ${email}? This cannot be undone. (Tip: Deactivate keeps their history.)`)) return;
|
|
||||||
try { await api('/api/users/manage', { id, action: 'delete' }); loadAgents(); populateAgentFilter(); }
|
|
||||||
catch (e) { agOut.textContent = e.message; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- Session report ----------
|
|
||||||
async function populateAgentFilter() {
|
|
||||||
try {
|
|
||||||
const rows = await api('/api/users', null, 'GET');
|
|
||||||
const sel = document.getElementById('fAgent');
|
|
||||||
const cur = sel.value;
|
|
||||||
sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
|
|
||||||
sel.value = cur;
|
|
||||||
} catch { /* non-admins cannot list agents; filter stays "All" */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(ms) {
|
|
||||||
if (ms == null) return '—';
|
|
||||||
const s = Math.round(ms / 1000);
|
|
||||||
if (s < 60) return s + 's';
|
|
||||||
const m = Math.floor(s / 60), r = s % 60;
|
|
||||||
if (m < 60) return m + 'm ' + r + 's';
|
|
||||||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
|
|
||||||
}
|
|
||||||
|
|
||||||
let REPORT_ROWS = [];
|
|
||||||
async function loadReport() {
|
|
||||||
const q = new URLSearchParams();
|
|
||||||
if (fAgent.value) q.set('agent', fAgent.value);
|
|
||||||
if (fFrom.value) q.set('from', fFrom.value);
|
|
||||||
if (fTo.value) q.set('to', fTo.value);
|
|
||||||
const rows = await api('/api/report?' + q.toString(), null, 'GET');
|
|
||||||
REPORT_ROWS = rows;
|
|
||||||
document.querySelector('#report tbody').innerHTML = rows.map((r) => {
|
|
||||||
const d = new Date(r.started_at);
|
|
||||||
const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
|
|
||||||
return `<tr>
|
|
||||||
<td>${d.toLocaleDateString()}</td>
|
|
||||||
<td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
|
|
||||||
<td>${esc(r.agent_name || r.agent_email || '—')}</td>
|
|
||||||
<td>${esc(r.ticket || 'Direct session')}</td>
|
|
||||||
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('') || '<tr><td colspan=5 class="muted">No sessions in this period.</td></tr>';
|
|
||||||
const total = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
|
|
||||||
repSummary.textContent = rows.length ? `${rows.length} session(s) · total time ${fmtDuration(total)}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportData() {
|
|
||||||
return REPORT_ROWS.map((r) => {
|
|
||||||
const d = new Date(r.started_at);
|
|
||||||
return {
|
|
||||||
date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
|
|
||||||
agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
|
|
||||||
spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportExcel() {
|
|
||||||
const rows = reportData();
|
|
||||||
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
|
|
||||||
const head = ['Date','Start time','Agent','Ticket','Time spent'];
|
|
||||||
const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
|
|
||||||
const csv = '\ufeff' + [head, ...rows.map(r => [r.date, r.start, r.agent, r.ticket, r.spent])]
|
|
||||||
.map(line => line.map(csvCell).join(',')).join('\r\n');
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
|
||||||
a.download = 'session-report.csv';
|
|
||||||
a.click(); URL.revokeObjectURL(a.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportPdf() {
|
|
||||||
const rows = reportData();
|
|
||||||
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
|
|
||||||
const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
|
|
||||||
const agentSel = fAgent.value || 'All agents';
|
|
||||||
const w = window.open('', '_blank');
|
|
||||||
w.document.write('<html><head><title>Session report</title><style>' +
|
|
||||||
'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
|
|
||||||
'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
|
|
||||||
'.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
|
|
||||||
'table{width:100%;border-collapse:collapse;font-size:12px}' +
|
|
||||||
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
|
|
||||||
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
|
|
||||||
'</style></head><body>' +
|
|
||||||
'<h1>BizGaze Support — Session report</h1>' +
|
|
||||||
'<div class="meta">Agent: ' + esc(agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
|
|
||||||
'<table><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr>' +
|
|
||||||
rows.map(r => '<tr><td>' + [r.date, r.start, esc(r.agent), esc(r.ticket), r.spent].join('</td><td>') + '</td></tr>').join('') +
|
|
||||||
'</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
|
|
||||||
w.document.close();
|
|
||||||
w.onload = () => { w.print(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
|
||||||
|
|
||||||
// ---------- Boot ----------
|
|
||||||
(async function () {
|
|
||||||
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
|
|
||||||
catch { authView(); }
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>Biz Connect — Dashboard</title>
|
||||||
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
|
<style>
|
||||||
|
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
/* Modern thin scrollbars (no classic up/down arrows in the Electron shell) */
|
||||||
|
::-webkit-scrollbar{width:9px;height:9px;}
|
||||||
|
::-webkit-scrollbar-button{display:none!important;width:0;height:0;}
|
||||||
|
::-webkit-scrollbar-track{background:transparent;}
|
||||||
|
::-webkit-scrollbar-thumb{background:#c7d0dd;border-radius:9px;border:2px solid transparent;background-clip:content-box;}
|
||||||
|
::-webkit-scrollbar-thumb:hover{background:#aab6c8;}
|
||||||
|
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
|
||||||
|
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
||||||
|
header{background:var(--blue);padding:calc(.75rem + env(safe-area-inset-top,0px)) 1.5rem .75rem;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:100;} /* #5: keep the top bar + profile visible while scrolling; pad the top so the bar clears the notch/Dynamic Island in the native app */
|
||||||
|
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
||||||
|
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
|
||||||
|
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;} .brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
|
||||||
|
main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
|
||||||
|
h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
|
||||||
|
input,select{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);margin:.25rem 0;font-size:.92rem;}
|
||||||
|
input:focus,select:focus{outline:none;border-color:var(--brand);}
|
||||||
|
button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
|
||||||
|
button:hover{background:var(--brand-d);}
|
||||||
|
button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
|
||||||
|
button.mini:hover{background:var(--blue-soft);}
|
||||||
|
.row{display:flex;gap:.5rem;align-items:center;}
|
||||||
|
.muted{color:var(--muted);font-size:.85rem;}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:.88rem;}
|
||||||
|
th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
|
||||||
|
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
|
||||||
|
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
|
||||||
|
.pill.on{background:#ecfdf3;color:#15803d;}
|
||||||
|
.pill.off{background:#fee2e2;color:var(--red);}
|
||||||
|
.reveal{margin-top:1rem;background:#f1f7ec;border:1px solid #cfe8bf;border-radius:10px;padding:.8rem 1rem;}
|
||||||
|
.reveal code{flex:1;word-break:break-all;background:#fff;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;font-size:.85rem;}
|
||||||
|
.chk{display:flex;align-items:center;gap:.4rem;font-size:.85rem;}
|
||||||
|
.chk input{width:16px;height:16px;margin:0;accent-color:var(--blue);}
|
||||||
|
.hidden{display:none;}
|
||||||
|
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
|
||||||
|
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
|
||||||
|
.tabs button.active{background:var(--blue);color:#fff;}
|
||||||
|
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
|
||||||
|
.filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
|
||||||
|
.filters .f{flex:1;min-width:140px;}
|
||||||
|
.filters .lbl{margin:.1rem 0 .15rem;}
|
||||||
|
.srch{max-width:320px;margin:.2rem 0 .9rem;}
|
||||||
|
.pager{display:flex;gap:.5rem;align-items:center;justify-content:flex-end;margin-top:.8rem;font-size:.82rem;color:var(--muted);}
|
||||||
|
.pager button{padding:.32rem .7rem;font-size:.8rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
|
||||||
|
.pager button:hover:not(:disabled){background:var(--blue-soft);}
|
||||||
|
.pager button:disabled{opacity:.4;cursor:default;}
|
||||||
|
.stats{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;}
|
||||||
|
.stat{flex:1;min-width:150px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.1rem 1.3rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
|
||||||
|
.stat .v{font-size:1.7rem;font-weight:800;color:var(--blue);line-height:1.1;}
|
||||||
|
.stat .k{font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:.2rem;}
|
||||||
|
.formerr{color:var(--red);font-weight:600;font-size:.88rem;margin:.7rem 0 0;min-height:1em;}
|
||||||
|
.formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
|
||||||
|
.formerr.show::before{content:"⚠";font-size:1rem;}
|
||||||
|
@keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
|
||||||
|
.pwwrap{position:relative;} .pwwrap input{padding-right:2.6rem;}
|
||||||
|
.eye{position:absolute;right:.35rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;}
|
||||||
|
.eye:hover{background:none;color:var(--blue);}
|
||||||
|
.profile{position:relative}
|
||||||
|
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||||||
|
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||||||
|
.profile .pbtn.icon-only{padding:.25rem;gap:0;border-radius:50%;background:transparent;border:none}
|
||||||
|
/* #12: report table scrolls horizontally on small screens instead of overflowing. */
|
||||||
|
.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%;}
|
||||||
|
.table-scroll table{min-width:560px;}
|
||||||
|
.profile .pbtn .pav{position:relative;width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem;overflow:hidden}
|
||||||
|
.profile .pbtn .pav img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||||
|
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
|
||||||
|
.profile .pmenu.open{display:block}
|
||||||
|
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
|
||||||
|
.profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
|
||||||
|
.profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
|
||||||
|
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
||||||
|
.profile .pmenu a:hover{background:#f1f5f9}
|
||||||
|
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||||
|
.ic{display:inline-block;vertical-align:middle}
|
||||||
|
</style>
|
||||||
|
<script src="/icons.js?v=3"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="brandrow"><img src="/mark-light.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
|
||||||
|
<div class="row" id="hdrRight"></div>
|
||||||
|
</header>
|
||||||
|
<main id="app"></main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
||||||
|
const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||||
|
function pwField(id,ph){return '<div class="pwwrap"><input id="'+id+'" type="password" placeholder="'+ph+'"><button type="button" class="eye" data-for="'+id+'" aria-label="Show password"></button></div>';}
|
||||||
|
function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
|
||||||
|
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
|
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
|
||||||
|
function profileHTML(u){
|
||||||
|
const display=u.name||u.email;
|
||||||
|
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
|
||||||
|
+ '<span class="pav">'+pEsc(initials(display))+((u.avatarUrl||u.avatar_url)?'<img src="'+pEsc(u.avatarUrl||u.avatar_url)+'" alt="" onerror="this.remove()">':'')+'</span></button>'
|
||||||
|
+ '<div class="pmenu" id="pmenu">'
|
||||||
|
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
|
||||||
|
+ '<a href="/home">Home</a>'
|
||||||
|
+ '<a class="danger" id="plogout">Logout</a>'
|
||||||
|
+ '</div></div>';
|
||||||
|
}
|
||||||
|
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
const hdrRight = document.getElementById('hdrRight');
|
||||||
|
|
||||||
|
async function api(path, body, method = 'POST') {
|
||||||
|
const opt = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
|
if (body) opt.body = JSON.stringify(body);
|
||||||
|
const r = await fetch(path, opt);
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(data.error || 'request failed');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function onEnter(ids, fn){ ids.forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fn(); } }); }); }
|
||||||
|
function view(html) { app.innerHTML = html; }
|
||||||
|
|
||||||
|
// ---------- Auth (login lives here; on success → home) ----------
|
||||||
|
async function authView() {
|
||||||
|
hdrRight.innerHTML = '';
|
||||||
|
let regOpen = false;
|
||||||
|
try { regOpen = (await api('/api/setup-state', null, 'GET')).registrationOpen; } catch {}
|
||||||
|
view(`
|
||||||
|
<div class="card" style="max-width:420px;margin:3rem auto">
|
||||||
|
${regOpen ? `<div class="tabs">
|
||||||
|
<button id="tabLogin" class="active">Sign in</button>
|
||||||
|
<button id="tabReg">Register team</button>
|
||||||
|
</div>` : ''}
|
||||||
|
<div id="loginForm">
|
||||||
|
<span class="lbl">Email</span>
|
||||||
|
<input id="li_email" placeholder="you@bizgaze.com" type="email">
|
||||||
|
<span class="lbl">Password</span>
|
||||||
|
${pwField("li_pw","password")}
|
||||||
|
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
|
||||||
|
<button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
|
||||||
|
<p id="li_err" class="formerr"></p>
|
||||||
|
</div>
|
||||||
|
${regOpen ? `<div id="regForm" class="hidden">
|
||||||
|
<span class="lbl">Team name</span>
|
||||||
|
<input id="rg_team" placeholder="e.g. Acme Inc">
|
||||||
|
<span class="lbl">Email</span>
|
||||||
|
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
|
||||||
|
<span class="lbl">Password</span>
|
||||||
|
${pwField("rg_pw","min 8 characters")}
|
||||||
|
<button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
|
||||||
|
<p id="rg_err" class="formerr"></p>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>`);
|
||||||
|
document.getElementById('li_btn').onclick = doLogin;
|
||||||
|
wireEyes();
|
||||||
|
onEnter(['li_email','li_pw'], doLogin);
|
||||||
|
if (regOpen) {
|
||||||
|
document.getElementById('tabLogin').onclick = () => toggle(true);
|
||||||
|
document.getElementById('tabReg').onclick = () => toggle(false);
|
||||||
|
document.getElementById('rg_btn').onclick = doRegister;
|
||||||
|
onEnter(['rg_team','rg_email','rg_pw'], doRegister);
|
||||||
|
}
|
||||||
|
function toggle(login) {
|
||||||
|
document.getElementById('loginForm').classList.toggle('hidden', !login);
|
||||||
|
document.getElementById('regForm').classList.toggle('hidden', login);
|
||||||
|
document.getElementById('tabLogin').classList.toggle('active', login);
|
||||||
|
const rt = document.getElementById('tabReg'); if (rt) rt.classList.toggle('active', !login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showErr(id, msg) { const el = document.getElementById(id); el.textContent = msg; el.classList.add('show'); }
|
||||||
|
function clearErr(id) { const el = document.getElementById(id); el.textContent = ''; el.classList.remove('show'); }
|
||||||
|
async function doLogin() {
|
||||||
|
clearErr('li_err');
|
||||||
|
try {
|
||||||
|
const rem = document.getElementById('li_remember');
|
||||||
|
await api('/api/login', { email: li_email.value, password: li_pw.value, remember: rem ? rem.checked : false });
|
||||||
|
location.href = '/home';
|
||||||
|
} catch (e) {
|
||||||
|
showErr('li_err', /invalid credentials/i.test(e.message) ? 'Incorrect email or password. Please try again.' : e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function doRegister() {
|
||||||
|
clearErr('rg_err');
|
||||||
|
try {
|
||||||
|
await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
|
||||||
|
await api('/api/login', { email: rg_email.value, password: rg_pw.value });
|
||||||
|
location.href = '/home';
|
||||||
|
} catch (e) { showErr('rg_err', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dashboard ----------
|
||||||
|
let ME = null, IS_ADMIN = false;
|
||||||
|
async function dashboard(me) {
|
||||||
|
ME = me; IS_ADMIN = (me.role === 'admin');
|
||||||
|
hdrRight.innerHTML = profileHTML(me); wireProfile();
|
||||||
|
view(`
|
||||||
|
<div class="stats" id="stats"></div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>${IS_ADMIN ? 'Connection report — all agents' : 'My connection report'}</h2>
|
||||||
|
<div class="filters">
|
||||||
|
${IS_ADMIN ? '<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>' : ''}
|
||||||
|
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
|
||||||
|
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
|
||||||
|
<button id="fApply">Apply</button>
|
||||||
|
<button id="fExcel" class="mini" style="padding:.6rem .9rem">${ic('download',15)} Excel</button>
|
||||||
|
<button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
|
||||||
|
</div>
|
||||||
|
${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
|
||||||
|
<div class="table-scroll"><table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table></div>
|
||||||
|
<div id="repPager" class="pager"></div>
|
||||||
|
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
|
||||||
|
</div>
|
||||||
|
${IS_ADMIN ? `
|
||||||
|
<div class="card" id="keysCard">
|
||||||
|
<h2>API keys <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— let other systems read your data programmatically</span></h2>
|
||||||
|
<table id="keys"><thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody></tbody></table>
|
||||||
|
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
|
||||||
|
<div><span class="lbl">Name</span><input id="kName" placeholder="e.g. Partner X" style="max-width:200px"></div>
|
||||||
|
<label class="chk"><input type="checkbox" id="kReport" checked> report:read</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="kAudit"> audit:read</label>
|
||||||
|
<button id="kAdd">Generate key</button>
|
||||||
|
</div>
|
||||||
|
<div id="kOut"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="hooksCard">
|
||||||
|
<h2>Webhooks <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— signed event callbacks to your systems</span></h2>
|
||||||
|
<table id="hooks"><thead><tr><th>Endpoint</th><th>Events</th><th>Status</th><th>Last delivery</th><th></th></tr></thead><tbody></tbody></table>
|
||||||
|
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
|
||||||
|
<div style="flex:1;min-width:240px"><span class="lbl">Endpoint URL</span><input id="hUrl" placeholder="https://your-system.example.com/webhook"></div>
|
||||||
|
<label class="chk"><input type="checkbox" id="hStarted" checked> session.started</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="hEnded" checked> session.ended</label>
|
||||||
|
<button id="hAdd">Add webhook</button>
|
||||||
|
</div>
|
||||||
|
<div id="hOut"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="installsCard">
|
||||||
|
<h2>App installs <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— who installed the desktop / mobile app</span></h2>
|
||||||
|
<input id="installSearch" placeholder="Search user, platform, version or OS…" autocomplete="off" style="width:100%;max-width:360px;padding:.5rem .7rem;border:1px solid #e6e9ef;border-radius:9px;margin:.2rem 0 .7rem;font-size:.9rem">
|
||||||
|
<div class="table-scroll"><table id="installs"><thead><tr><th>User</th><th>Platform</th><th>Version</th><th>OS</th><th>First seen</th><th>Last seen</th></tr></thead><tbody></tbody></table></div>
|
||||||
|
<p id="installsEmpty" class="muted" style="margin-top:.6rem;display:none">No installs recorded yet.</p>
|
||||||
|
<div id="installsPager" style="display:none;align-items:center;justify-content:space-between;margin-top:.7rem;gap:.5rem">
|
||||||
|
<span class="muted" id="installsCount" style="font-size:.82rem"></span>
|
||||||
|
<span style="display:flex;gap:.4rem;align-items:center"><button class="mini" id="installsPrev">Prev</button><span id="installsPage" class="muted" style="font-size:.82rem"></span><button class="mini" id="installsNext">Next</button></span>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}`);
|
||||||
|
document.getElementById('fApply').onclick = loadReport;
|
||||||
|
document.getElementById('fExcel').onclick = exportExcel;
|
||||||
|
document.getElementById('fPdf').onclick = exportPdf;
|
||||||
|
if (IS_ADMIN) await populateAgentFilter();
|
||||||
|
await loadReport();
|
||||||
|
if (IS_ADMIN) {
|
||||||
|
document.getElementById('kAdd').onclick = createKey;
|
||||||
|
document.getElementById('hAdd').onclick = createHook;
|
||||||
|
await loadKeys();
|
||||||
|
await loadHooks();
|
||||||
|
await loadInstalls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _installs=[], _installQ='', _installPage=0; const INSTALL_PAGE=10;
|
||||||
|
async function loadInstalls(){
|
||||||
|
try{ _installs=await fetch('/api/v1/admin/installs').then(r=>r.json()); }catch(_){ _installs=[]; }
|
||||||
|
if(!Array.isArray(_installs)) _installs=[];
|
||||||
|
const s=document.getElementById('installSearch');
|
||||||
|
if(s && !s._w){ s._w=1; s.oninput=()=>{ _installQ=s.value.trim().toLowerCase(); _installPage=0; renderInstalls(); }; }
|
||||||
|
_installPage=0; renderInstalls();
|
||||||
|
}
|
||||||
|
function renderInstalls(){
|
||||||
|
const tb=document.querySelector('#installs tbody'); if(!tb) return;
|
||||||
|
const empty=document.getElementById('installsEmpty'), pager=document.getElementById('installsPager');
|
||||||
|
const q=_installQ;
|
||||||
|
const filtered=_installs.filter(r=>!q||[r.user_email,r.platform,r.app_version,r.os].some(v=>String(v||'').toLowerCase().includes(q)));
|
||||||
|
if(!filtered.length){ tb.innerHTML=''; if(empty){ empty.style.display='block'; empty.textContent=q?'No installs match your search.':'No installs recorded yet.'; } if(pager) pager.style.display='none'; return; }
|
||||||
|
if(empty) empty.style.display='none';
|
||||||
|
const pages=Math.ceil(filtered.length/INSTALL_PAGE); if(_installPage>=pages) _installPage=pages-1;
|
||||||
|
const page=filtered.slice(_installPage*INSTALL_PAGE, _installPage*INSTALL_PAGE+INSTALL_PAGE);
|
||||||
|
tb.innerHTML=page.map(r=>`<tr><td>${esc(r.user_email||'—')}</td><td>${esc(r.platform||'—')}</td><td>${esc(r.app_version||'—')}</td><td>${esc(r.os||'—')}</td><td>${fmtTs(r.first_seen)}</td><td>${fmtTs(r.last_seen)}</td></tr>`).join('');
|
||||||
|
if(pager){ pager.style.display=pages>1?'flex':'none';
|
||||||
|
const cnt=document.getElementById('installsCount'); if(cnt) cnt.textContent=filtered.length+' install'+(filtered.length===1?'':'s');
|
||||||
|
const pg=document.getElementById('installsPage'); if(pg) pg.textContent='Page '+(_installPage+1)+' / '+pages;
|
||||||
|
const prev=document.getElementById('installsPrev'), next=document.getElementById('installsNext');
|
||||||
|
if(prev){ prev.disabled=_installPage<=0; prev.onclick=()=>{ if(_installPage>0){ _installPage--; renderInstalls(); } }; }
|
||||||
|
if(next){ next.disabled=_installPage>=pages-1; next.onclick=()=>{ if(_installPage<pages-1){ _installPage++; renderInstalls(); } }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Integrations: API keys + webhooks (admin) ----------
|
||||||
|
function fmtTs(ms){ return ms ? new Date(ms).toLocaleString() : '—'; }
|
||||||
|
function revealBox(label, value, note){
|
||||||
|
return '<div class="reveal"><div class="lbl" style="margin:0 0 .3rem">'+esc(label)+' — copy now</div>'
|
||||||
|
+ '<div style="display:flex;gap:.5rem;align-items:center"><code id="revealVal">'+esc(value)+'</code>'
|
||||||
|
+ '<button class="mini" id="copyReveal">Copy</button></div>'
|
||||||
|
+ '<div class="muted" style="margin-top:.4rem;font-size:.78rem">'+esc(note)+'</div></div>';
|
||||||
|
}
|
||||||
|
function wireCopy(){ const b=document.getElementById('copyReveal'); if(!b)return; b.onclick=async()=>{ try{ await navigator.clipboard.writeText(document.getElementById('revealVal').textContent); }catch(_){} b.textContent='Copied'; setTimeout(()=>{b.textContent='Copy';},1500); }; }
|
||||||
|
|
||||||
|
async function loadKeys(){
|
||||||
|
let rows=[]; try{ rows = await api('/api/keys', null, 'GET'); }catch(e){ return; }
|
||||||
|
document.querySelector('#keys tbody').innerHTML = rows.length ? rows.map(k=>`
|
||||||
|
<tr style="${k.revoked?'opacity:.5':''}">
|
||||||
|
<td>${esc(k.name||'—')}</td>
|
||||||
|
<td class="muted">${esc(k.scopes||'')}</td>
|
||||||
|
<td>${fmtTs(k.created_at)}</td>
|
||||||
|
<td>${fmtTs(k.last_used_at)}</td>
|
||||||
|
<td>${k.revoked?'<span class="pill off">revoked</span>':'<span class="pill on">active</span>'}</td>
|
||||||
|
<td>${k.revoked?'':`<button class="mini danger" onclick="revokeKey('${k.id}')">Revoke</button>`}</td>
|
||||||
|
</tr>`).join('') : '<tr><td colspan=6 class="muted">No API keys yet.</td></tr>';
|
||||||
|
}
|
||||||
|
async function createKey(){
|
||||||
|
const scopes=[]; if(document.getElementById('kReport').checked)scopes.push('report:read'); if(document.getElementById('kAudit').checked)scopes.push('audit:read');
|
||||||
|
if(!scopes.length){ document.getElementById('kOut').innerHTML='<p class="muted">Select at least one scope.</p>'; return; }
|
||||||
|
try{
|
||||||
|
const r = await api('/api/keys', { name: document.getElementById('kName').value, scopes }, 'POST');
|
||||||
|
document.getElementById('kName').value='';
|
||||||
|
document.getElementById('kOut').innerHTML = revealBox('API key', r.key, "Send this to the integrator. It won't be shown again — revoke and re-issue if lost.");
|
||||||
|
wireCopy(); loadKeys();
|
||||||
|
}catch(e){ document.getElementById('kOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
|
||||||
|
}
|
||||||
|
window.revokeKey = async (id)=>{ if(!confirm('Revoke this API key? Integrations using it will stop working.'))return; try{ await api('/api/keys/revoke',{id},'POST'); loadKeys(); }catch(e){} };
|
||||||
|
|
||||||
|
async function loadHooks(){
|
||||||
|
let rows=[]; try{ rows = await api('/api/webhooks', null, 'GET'); }catch(e){ return; }
|
||||||
|
document.querySelector('#hooks tbody').innerHTML = rows.length ? rows.map(h=>`
|
||||||
|
<tr style="${h.active?'':'opacity:.5'}">
|
||||||
|
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis" class="muted">${esc(h.url)}</td>
|
||||||
|
<td class="muted">${esc(h.events||'')}</td>
|
||||||
|
<td>${h.last_status==null?'<span class="muted">—</span>':(h.last_status?'<span class="pill on">ok</span>':'<span class="pill off">failing</span>')}</td>
|
||||||
|
<td>${fmtTs(h.last_at)}${h.last_error?' <span class="muted" title="'+esc(h.last_error)+'">'+ic('alertTriangle',13)+'</span>':''}</td>
|
||||||
|
<td><button class="mini danger" onclick="deleteHook('${h.id}')">Delete</button></td>
|
||||||
|
</tr>`).join('') : '<tr><td colspan=5 class="muted">No webhooks yet.</td></tr>';
|
||||||
|
}
|
||||||
|
async function createHook(){
|
||||||
|
const events=[]; if(document.getElementById('hStarted').checked)events.push('session.started'); if(document.getElementById('hEnded').checked)events.push('session.ended');
|
||||||
|
const url=document.getElementById('hUrl').value.trim();
|
||||||
|
if(!/^https?:\/\//i.test(url)){ document.getElementById('hOut').innerHTML='<p class="muted">Enter a valid http(s) URL.</p>'; return; }
|
||||||
|
if(!events.length){ document.getElementById('hOut').innerHTML='<p class="muted">Select at least one event.</p>'; return; }
|
||||||
|
try{
|
||||||
|
const r = await api('/api/webhooks', { url, events }, 'POST');
|
||||||
|
document.getElementById('hUrl').value='';
|
||||||
|
document.getElementById('hOut').innerHTML = revealBox('Signing secret', r.secret, 'Verify the X-BizGaze-Signature header (HMAC-SHA256 of the body) with this. Shown once.');
|
||||||
|
wireCopy(); loadHooks();
|
||||||
|
}catch(e){ document.getElementById('hOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
|
||||||
|
}
|
||||||
|
window.deleteHook = async (id)=>{ if(!confirm('Delete this webhook?'))return; try{ await api('/api/webhooks/delete',{id},'POST'); loadHooks(); }catch(e){} };
|
||||||
|
|
||||||
|
const PER_PAGE = 5;
|
||||||
|
function pagerHTML(page, pages, total, fn){
|
||||||
|
if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : '';
|
||||||
|
return `<button ${page<=1?'disabled':''} onclick="${fn}(${page-1})">‹ Prev</button>`
|
||||||
|
+ `<span>Page ${page} of ${pages} · ${total} total</span>`
|
||||||
|
+ `<button ${page>=pages?'disabled':''} onclick="${fn}(${page+1})">Next ›</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateAgentFilter() {
|
||||||
|
try {
|
||||||
|
const rows = await api('/api/users', null, 'GET');
|
||||||
|
const sel = document.getElementById('fAgent'); if (!sel) return;
|
||||||
|
const cur = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
|
||||||
|
sel.value = cur;
|
||||||
|
} catch { /* non-admins cannot list agents */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(ms) {
|
||||||
|
if (ms == null) return '—';
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
if (s < 60) return s + 's';
|
||||||
|
const m = Math.floor(s / 60), r = s % 60;
|
||||||
|
if (m < 60) return m + 'm ' + r + 's';
|
||||||
|
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
let REPORT_ROWS = [], reportPage = 1, reportSearch = '';
|
||||||
|
function reportRowHTML(r){
|
||||||
|
const d = new Date(r.started_at);
|
||||||
|
const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
|
||||||
|
return `<tr>
|
||||||
|
<td>${d.toLocaleDateString()}</td>
|
||||||
|
<td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
|
||||||
|
${IS_ADMIN ? `<td>${esc(r.agent_name || r.agent_email || '—')}</td>` : ''}
|
||||||
|
<td>${esc(r.ticket || 'Direct session')}</td>
|
||||||
|
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
|
||||||
|
<td>${[
|
||||||
|
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>${ic('download',14)} Video</a>` : '',
|
||||||
|
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>${ic('download',14)} Text</a>` : ''
|
||||||
|
].join('') || '<span class="muted">—</span>'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
async function loadReport() {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const fa = document.getElementById('fAgent');
|
||||||
|
if (fa && fa.value) q.set('agent', fa.value);
|
||||||
|
if (fFrom.value) q.set('from', fFrom.value);
|
||||||
|
if (fTo.value) q.set('to', fTo.value);
|
||||||
|
REPORT_ROWS = await api('/api/report?' + q.toString(), null, 'GET');
|
||||||
|
reportPage = 1;
|
||||||
|
const s = document.getElementById('repSearch');
|
||||||
|
if (s && !s._w){ s._w = 1; s.addEventListener('input', () => { reportSearch = s.value.trim().toLowerCase(); reportPage = 1; renderReport(); }); }
|
||||||
|
renderStats();
|
||||||
|
renderReport();
|
||||||
|
}
|
||||||
|
window.reportGo = (p) => { reportPage = p; renderReport(); };
|
||||||
|
function filteredRows(){
|
||||||
|
return reportSearch ? REPORT_ROWS.filter(r => ((r.agent_name||'')+' '+(r.agent_email||'')+' '+(r.ticket||'')).toLowerCase().includes(reportSearch)) : REPORT_ROWS;
|
||||||
|
}
|
||||||
|
function renderStats(){
|
||||||
|
const el = document.getElementById('stats'); if (!el) return;
|
||||||
|
const rows = REPORT_ROWS;
|
||||||
|
const total = rows.length;
|
||||||
|
const totalMs = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
|
||||||
|
const recs = rows.filter(r => r.recording).length;
|
||||||
|
const cards = [
|
||||||
|
{ v: total, k: IS_ADMIN ? 'Total sessions' : 'My sessions' },
|
||||||
|
{ v: fmtDuration(totalMs), k: 'Time spent' },
|
||||||
|
{ v: recs, k: 'Recorded' },
|
||||||
|
];
|
||||||
|
el.innerHTML = cards.map(c => `<div class="stat"><div class="v">${esc(String(c.v))}</div><div class="k">${esc(c.k)}</div></div>`).join('');
|
||||||
|
}
|
||||||
|
function renderReport(){
|
||||||
|
const all = filteredRows();
|
||||||
|
const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
|
||||||
|
if (reportPage > pages) reportPage = pages;
|
||||||
|
const slice = all.slice((reportPage-1)*PER_PAGE, (reportPage-1)*PER_PAGE + PER_PAGE);
|
||||||
|
const cols = IS_ADMIN ? 6 : 5;
|
||||||
|
document.querySelector('#report tbody').innerHTML = slice.map(reportRowHTML).join('') || `<tr><td colspan=${cols} class="muted">No sessions match.</td></tr>`;
|
||||||
|
document.getElementById('repPager').innerHTML = pagerHTML(reportPage, pages, all.length, 'reportGo');
|
||||||
|
const total = all.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
|
||||||
|
repSummary.textContent = all.length ? `${all.length} session(s) · total time ${fmtDuration(total)}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportData() {
|
||||||
|
return filteredRows().map((r) => {
|
||||||
|
const d = new Date(r.started_at);
|
||||||
|
return {
|
||||||
|
date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
|
||||||
|
agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
|
||||||
|
spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function exportExcel() {
|
||||||
|
const rows = reportData();
|
||||||
|
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
|
||||||
|
const head = ['Date','Start time'].concat(IS_ADMIN ? ['Agent'] : []).concat(['Ticket','Time spent']);
|
||||||
|
const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
|
||||||
|
const out = '' + [head, ...rows.map(r => [r.date, r.start].concat(IS_ADMIN ? [r.agent] : []).concat([r.ticket, r.spent]))]
|
||||||
|
.map(line => line.map(csvCell).join(',')).join('\r\n');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(new Blob([out], { type: 'text/csv;charset=utf-8' }));
|
||||||
|
a.download = 'connection-report.csv';
|
||||||
|
a.click(); URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
|
function exportPdf() {
|
||||||
|
const rows = reportData();
|
||||||
|
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
|
||||||
|
const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
|
||||||
|
const fa = document.getElementById('fAgent');
|
||||||
|
const agentSel = IS_ADMIN ? (fa && fa.value || 'All agents') : (ME.name || ME.email);
|
||||||
|
const w = window.open('', '_blank');
|
||||||
|
const headCells = ['Date','Start time'].concat(IS_ADMIN ? ['Agent'] : []).concat(['Ticket','Time spent']);
|
||||||
|
w.document.write('<html><head><title>Connection report</title><style>' +
|
||||||
|
'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
|
||||||
|
'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
|
||||||
|
'.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
|
||||||
|
'table{width:100%;border-collapse:collapse;font-size:12px}' +
|
||||||
|
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
|
||||||
|
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
|
||||||
|
'</style></head><body>' +
|
||||||
|
'<h1>Biz Connect — Connection report</h1>' +
|
||||||
|
'<div class="meta">' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
|
||||||
|
'<table><tr>' + headCells.map(h => '<th>' + esc(h) + '</th>').join('') + '</tr>' +
|
||||||
|
rows.map(r => '<tr><td>' + [r.date, r.start].concat(IS_ADMIN ? [esc(r.agent)] : []).concat([esc(r.ticket), r.spent]).join('</td><td>') + '</td></tr>').join('') +
|
||||||
|
'</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
|
||||||
|
w.document.close();
|
||||||
|
w.onload = () => { w.print(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||||
|
|
||||||
|
// ---------- Boot ----------
|
||||||
|
// Login lives on /home — send logged-out visitors there.
|
||||||
|
(async function () {
|
||||||
|
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
|
||||||
|
catch { location.href = '/home'; }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -2,10 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Browser Host — Remote Access</title>
|
<title>Browser Host — Remote Access</title>
|
||||||
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: system-ui, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:1.5rem; }
|
body { font-family: system-ui, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:calc(1.5rem + env(safe-area-inset-top,0px)) 1.5rem calc(1.5rem + env(safe-area-inset-bottom,0px)); } /* clear the notch/home-indicator now that the viewport is cover */
|
||||||
.card { max-width:560px; margin:0 auto; background:#1e293b; border-radius:12px; padding:1.5rem; }
|
.card { max-width:560px; margin:0 auto; background:#1e293b; border-radius:12px; padding:1.5rem; }
|
||||||
h1 { font-size:1.15rem; margin:0 0 1rem; }
|
h1 { font-size:1.15rem; margin:0 0 1rem; }
|
||||||
input { width:100%; padding:.7rem; border-radius:8px; border:1px solid #334155; background:#0f172a; color:#e2e8f0; margin:.3rem 0; }
|
input { width:100%; padding:.7rem; border-radius:8px; border:1px solid #334155; background:#0f172a; color:#e2e8f0; margin:.3rem 0; }
|
||||||
@@ -16,13 +20,14 @@
|
|||||||
.grant { background:#22c55e; color:#052e16; } .deny { background:#ef4444; margin-left:.5rem; }
|
.grant { background:#22c55e; color:#052e16; } .deny { background:#ef4444; margin-left:.5rem; }
|
||||||
.muted { color:#94a3b8; font-size:.82rem; }
|
.muted { color:#94a3b8; font-size:.82rem; }
|
||||||
#log { font-family:monospace; font-size:.72rem; color:#64748b; height:120px; overflow-y:auto; margin-top:1rem; background:#020617; padding:.6rem; border-radius:8px; }
|
#log { font-family:monospace; font-size:.72rem; color:#64748b; height:120px; overflow-y:auto; margin-top:1rem; background:#020617; padding:.6rem; border-radius:8px; }
|
||||||
.indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem; font-size:.85rem; display:none; }
|
.indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem calc(.4rem + env(safe-area-inset-right,0px)) calc(.4rem + env(safe-area-inset-bottom,0px)) calc(.4rem + env(safe-area-inset-left,0px)); font-size:.85rem; display:none; }
|
||||||
.indicator.show { display:block; }
|
.indicator.show { display:block; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/icons.js?v=3"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>🖥️ Browser Host (no install)</h1>
|
<h1><span data-ic="monitor" data-sz="22"></span> Browser Host (no install)</h1>
|
||||||
<p class="muted">Shares this screen with a technician. Paste the enroll token from the console and click Go online.</p>
|
<p class="muted">Shares this screen with a technician. Paste the enroll token from the console and click Go online.</p>
|
||||||
<input id="token" placeholder="enroll token">
|
<input id="token" placeholder="enroll token">
|
||||||
<button id="goBtn">Go online</button>
|
<button id="goBtn">Go online</button>
|
||||||
@@ -98,5 +103,6 @@ function teardown() {
|
|||||||
}
|
}
|
||||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
</script>
|
</script>
|
||||||
|
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,79 @@
|
|||||||
|
// Shared icon set (Lucide — modern line icons). Use ic('name', size) for any UI icon.
|
||||||
|
// Add new icons here so the whole app stays visually consistent.
|
||||||
|
(function () {
|
||||||
|
const P = {
|
||||||
|
chat: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
||||||
|
screenShare: '<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m17 8 5-5"/><path d="M17 3h5v5"/>',
|
||||||
|
wifi: '<path d="M12 20h.01"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 14 0"/><path d="M2 8.8a15 15 0 0 1 20 0"/>',
|
||||||
|
video: '<path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/>',
|
||||||
|
videoOff: '<path d="M10.66 6H14a2 2 0 0 1 2 2v2.34l1 1L22 8v8"/><path d="M16 16a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2l10 10Z"/><line x1="2" x2="22" y1="2" y2="22"/>',
|
||||||
|
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||||||
|
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||||||
|
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||||
|
headphones: '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
|
||||||
|
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
||||||
|
smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>',
|
||||||
|
smilePlus: '<path d="M22 11v1a10 10 0 1 1-9-10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/><path d="M16 5h6"/><path d="M19 2v6"/>',
|
||||||
|
reply: '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||||
|
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||||
|
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||||
|
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||||
|
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
|
||||||
|
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.63l-19 6.5a.5.5 0 0 0-.02.93l7.93 3.18a2 2 0 0 1 1.1 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
|
||||||
|
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||||
|
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||||
|
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||||
|
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||||
|
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||||
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
|
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||||
|
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||||
|
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
|
||||||
|
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||||
|
micOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||||
|
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
||||||
|
cameraOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12"/><path d="M9.5 4h5L17 7h3a2 2 0 0 1 2 2v7.5"/>',
|
||||||
|
phoneOff: '<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="2" x2="22" y1="2" y2="22"/>',
|
||||||
|
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
|
||||||
|
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
||||||
|
pencil: '<path d="M21.17 6.83a2.83 2.83 0 0 0-4-4L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/><path d="m15 5 4 4"/>',
|
||||||
|
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
||||||
|
chevronUp: '<path d="m18 15-6-6-6 6"/>',
|
||||||
|
chevronLeft: '<path d="m15 18-6-6 6-6"/>',
|
||||||
|
chevronRight: '<path d="m9 18 6-6-6-6"/>',
|
||||||
|
star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
|
||||||
|
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||||
|
layoutDashboard:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||||
|
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||||
|
alertTriangle:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||||
|
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||||
|
monitor: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
|
||||||
|
barChart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><rect x="7" y="11" width="3" height="6" rx="1"/><rect x="12" y="7" width="3" height="10" rx="1"/><rect x="17" y="13" width="3" height="4" rx="1"/>',
|
||||||
|
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||||
|
bold: '<path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/>',
|
||||||
|
italic: '<line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/>',
|
||||||
|
strikethrough:'<path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/>',
|
||||||
|
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
||||||
|
list: '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
|
||||||
|
listOrdered: '<line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
|
||||||
|
type: '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/>',
|
||||||
|
crown: '<path d="m2 4 3 12h14l3-12-6 7-4-7-4 7-6-7z"/><path d="M5 20h14"/>',
|
||||||
|
checkCheck: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
|
||||||
|
calendarX: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m14 14-4 4"/><path d="m10 14 4 4"/>',
|
||||||
|
calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>',
|
||||||
|
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
||||||
|
record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>',
|
||||||
|
callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>',
|
||||||
|
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
|
moreVertical:'<circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/>',
|
||||||
|
bluetooth: '<path d="m7 7 10 10-5 5V2l5 5L7 17"/>',
|
||||||
|
speaker: '<path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a9 9 0 0 1 0 14"/>',
|
||||||
|
speakerOff: '<path d="M11 5 6 9H2v6h4l5 4z"/><line x1="22" y1="9" x2="16" y2="15"/><line x1="16" y1="9" x2="22" y2="15"/>',
|
||||||
|
};
|
||||||
|
window.ICON = P;
|
||||||
|
window.ic = function (name, size) {
|
||||||
|
const s = size || 18;
|
||||||
|
return '<svg class="ic" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (P[name] || '') + '</svg>';
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -2,14 +2,22 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<title>BizGaze Support</title>
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --line:#e6e9ef; }
|
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --line:#e6e9ef; }
|
||||||
*{box-sizing:border-box;}
|
*{box-sizing:border-box;}
|
||||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;min-height:100vh;display:flex;flex-direction:column;}
|
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;min-height:100vh;display:flex;flex-direction:column;}
|
||||||
header{background:var(--blue);padding:.85rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
|
header{background:var(--blue);padding:calc(.85rem + env(safe-area-inset-top,0px)) 1.5rem .85rem;display:flex;justify-content:space-between;align-items:center;}
|
||||||
.brandrow{display:flex;align-items:center;gap:.7rem;}
|
.brandrow{display:flex;align-items:center;gap:.7rem;}
|
||||||
|
.hdr-right{display:flex;align-items:center;gap:.9rem;}
|
||||||
|
.dl-btn{display:inline-flex;align-items:center;gap:.4rem;background:#fff;color:var(--blue);padding:.45rem .85rem;border-radius:9px;font-size:.85rem;font-weight:700;text-decoration:none;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.12);}
|
||||||
|
.dl-btn:hover{background:var(--brand);color:var(--blue);}
|
||||||
.brand{font-weight:700;color:#fff;font-size:1.1rem;} .brand span{color:var(--brand);font-weight:600;}
|
.brand{font-weight:700;color:#fff;font-size:1.1rem;} .brand span{color:var(--brand);font-weight:600;}
|
||||||
.signin{color:#dbe4f5;text-decoration:none;font-size:.9rem;border:1px solid #46598c;border-radius:8px;padding:.45rem 1rem;}
|
.signin{color:#dbe4f5;text-decoration:none;font-size:.9rem;border:1px solid #46598c;border-radius:8px;padding:.45rem 1rem;}
|
||||||
.signin:hover{background:var(--blue-d);}
|
.signin:hover{background:var(--blue-d);}
|
||||||
@@ -17,14 +25,19 @@
|
|||||||
.inner{max-width:780px;width:100%;text-align:center;}
|
.inner{max-width:780px;width:100%;text-align:center;}
|
||||||
h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
|
h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
|
||||||
.sub{color:var(--muted);margin-bottom:2.2rem;}
|
.sub{color:var(--muted);margin-bottom:2.2rem;}
|
||||||
|
.ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--brand);color:var(--blue);text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(224,172,0,.32);transition:transform .12s,box-shadow .12s,background .12s;}
|
||||||
|
.ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(224,172,0,.4);background:var(--brand-d);}
|
||||||
|
.ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--blue);color:var(--brand);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
|
||||||
|
.divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;}
|
||||||
|
.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);}
|
||||||
.choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;}
|
.choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;}
|
||||||
.choice{flex:1;min-width:260px;max-width:340px;background:#fff;border:1px solid var(--line);border-radius:18px;padding:2.2rem 1.6rem;text-decoration:none;color:var(--ink);box-shadow:0 10px 30px rgba(20,30,60,.06);transition:transform .12s,box-shadow .12s;}
|
.choice{flex:1;min-width:260px;max-width:360px;background:#fff;border:1px solid var(--line);border-radius:18px;padding:1.8rem 1.6rem;text-decoration:none;color:var(--ink);box-shadow:0 10px 30px rgba(20,30,60,.06);transition:transform .12s,box-shadow .12s,border-color .12s;display:flex;align-items:center;gap:1.1rem;text-align:left;}
|
||||||
.choice:hover{transform:translateY(-3px);box-shadow:0 16px 38px rgba(20,30,60,.12);border-color:var(--brand);}
|
.choice:hover{transform:translateY(-3px);box-shadow:0 16px 38px rgba(20,30,60,.12);border-color:var(--brand);}
|
||||||
.icon{width:66px;height:66px;border-radius:18px;display:grid;place-items:center;margin:0 auto 1.1rem;}
|
.icon{width:56px;height:56px;flex:0 0 56px;border-radius:16px;display:grid;place-items:center;}
|
||||||
.icon.share{background:#FFF7DA;} .icon.connect{background:var(--blue-soft);}
|
.icon.share{background:#FFF7DA;} .icon.connect{background:var(--blue-soft);}
|
||||||
.icon svg{width:34px;height:34px;}
|
.icon svg{width:30px;height:30px;}
|
||||||
.choice h3{margin:.2rem 0 .4rem;color:var(--blue);font-size:1.15rem;}
|
.choice h3{margin:0 0 .25rem;color:var(--blue);font-size:1.1rem;}
|
||||||
.choice p{margin:0;color:var(--muted);font-size:.9rem;line-height:1.5;}
|
.choice p{margin:0;color:var(--muted);font-size:.88rem;line-height:1.45;}
|
||||||
.foot{color:var(--muted);font-size:.82rem;margin-top:2.4rem;}
|
.foot{color:var(--muted);font-size:.82rem;margin-top:2.4rem;}
|
||||||
footer{text-align:center;color:#9aa3b2;font-size:.8rem;padding:1rem;}
|
footer{text-align:center;color:#9aa3b2;font-size:.8rem;padding:1rem;}
|
||||||
.profile{position:relative}
|
.profile{position:relative}
|
||||||
@@ -36,42 +49,51 @@
|
|||||||
.profile .pmenu a:hover{background:#f1f5f9}
|
.profile .pmenu a:hover{background:#f1f5f9}
|
||||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/icons.js?v=3"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<div class="brandrow">
|
<div class="brandrow">
|
||||||
<img src="/logo.png" alt="" style="height:40px;width:auto;max-width:170px;border-radius:8px;object-fit:contain;background:#fff;padding:4px 10px" onerror="this.style.display='none'">
|
<img src="/mark-light.png" alt="" style="height:40px;width:auto;max-width:170px;border-radius:8px;object-fit:contain" onerror="this.style.display='none'">
|
||||||
<div class="brand">BizGaze <span>Support</span></div>
|
<div class="brand">Biz <span>Connect</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-right">
|
||||||
|
<a class="dl-btn" id="dlWin" href="/download/windows" title="Download the Windows desktop app"><svg viewBox="0 0 448 512" width="15" height="15" fill="currentColor" style="flex:0 0 auto"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg> Download app</a>
|
||||||
|
<div id="authArea"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="authArea"><a class="signin" href="/console">Staff sign in</a></div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<h1>How can we help you today?</h1>
|
<h1>Welcome to Biz Connect</h1>
|
||||||
<div class="sub">Secure remote support — no downloads, you stay in control.</div>
|
<div class="sub">Chat, meetings and secure remote support — for the BizGaze ecosystem.</div>
|
||||||
<div class="choices">
|
<!-- Customer path FIRST (no account needed): share your screen for support. -->
|
||||||
|
<div class="divider">Need support? — no account needed</div>
|
||||||
|
<div class="choices" style="max-width:400px;margin:0 auto">
|
||||||
<a class="choice" href="/share">
|
<a class="choice" href="/share">
|
||||||
<div class="icon share"><svg viewBox="0 0 24 24" fill="none" stroke="#BA7515" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
|
<div class="icon share"><svg viewBox="0 0 24 24" fill="none" stroke="#BA7515" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
|
||||||
<h3>Share my screen</h3>
|
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a Biz Connect support agent — no login, no download.</p></div>
|
||||||
<p>You need help. Get a one-time code and show your screen to a BizGaze support agent.</p>
|
|
||||||
</a>
|
|
||||||
<a class="choice" href="/connect">
|
|
||||||
<div class="icon connect"><svg viewBox="0 0 24 24" fill="none" stroke="#1F3B73" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17V7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10"/><path d="M2 21h20"/><path d="m9 9 3 3-3 3"/></svg></div>
|
|
||||||
<h3>Connect to a screen</h3>
|
|
||||||
<p>You're a support agent. Sign in, then enter the customer's code to view their screen.</p>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="foot">🔒 Screen sharing only starts after the customer approves it, and can be stopped anytime.</div>
|
<div class="foot" style="margin:.7rem 0 0"><span data-ic="lock" data-sz="14"></span> Screen sharing only starts after you approve it, and can be stopped anytime.</div>
|
||||||
|
<!-- Team member path BELOW: log in to the full app. Stub SSO -> /home for now. -->
|
||||||
|
<div class="divider" style="margin-top:1.6rem">BizGaze team member?</div>
|
||||||
|
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer>© BizGaze · Remote Support</footer>
|
<footer>© BizGaze · Remote Support</footer>
|
||||||
<script>
|
<script>
|
||||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
||||||
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
||||||
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
||||||
makeBrandClickable();
|
makeBrandClickable();
|
||||||
(async function(){try{const r=await fetch('/api/me');if(r.ok){const me=await r.json();document.getElementById('authArea').innerHTML=profileHTML(me.name||me.email);wireProfile();}}catch(_){}})();
|
// No "download the desktop app" link when we're already inside a native shell — the Electron desktop
|
||||||
|
// app (window.__NATIVE__) OR the iOS/Android Capacitor app (window.Capacitor.isNativePlatform()).
|
||||||
|
if(window.__NATIVE__ || (window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform())){var _dl=document.getElementById('dlWin');if(_dl)_dl.style.display='none';}
|
||||||
|
// Already signed in -> skip this landing entirely and go straight to the app (no redundant
|
||||||
|
// "Open Biz Connect" page). This landing only shows when logged out.
|
||||||
|
(async function(){try{const r=await fetch('/api/me');if(r.ok){ location.replace('/home'); return; }}catch(_){}})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||