Compare commits

...

239 Commits

Author SHA1 Message Date
Sravan 5f342b0b4e fix(db): portable thread queries for Postgres (conditional cursor + subquery alias)
The DM/group thread queries used `(? IS NULL OR created_at < ?)` — an all-NULL
param Postgres can't type ('could not determine data type of parameter') — and an
unaliased FROM-subquery (Postgres requires an alias). Both rewritten to add the
`created_at < ?` clause only when a cursor is given, and alias the subquery `t`.
Portable; sqlite db-smoke still 22/22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:32:42 +05:30
Sravan e67a783bdc feat(db): Postgres backend + dialect-portable queries + data migration (Phase 5)
- db/pg.js: the pg backend (prepare/exec/tx/init) — ?→$N translation, BIGINT parsed
  as Number (matches sqlite; else expires_at<Date.now() compares string<number),
  transactions on one pooled client, init() applies schema.pg.sql. Same interface as
  db/sqlite.js, so repos are unchanged.
- repos.js: the ~7 SQLite-only queries rewritten to run on BOTH engines —
  audit.add @named→positional; email lookups COLLATE NOCASE→LOWER()=LOWER();
  INSERT OR IGNORE→ON CONFLICT DO NOTHING (addMember/poll vote/favorite);
  mergeInto's UPDATE OR IGNORE→UPDATE…WHERE NOT EXISTS/NOT IN and INSERT OR
  REPLACE→ON CONFLICT DO UPDATE. Re-validated on sqlite: db-smoke still 22/22.
- server.js: boot now `await db.init()` before listening (pg creates tables; sqlite
  no-op), so the first request can't hit a missing table.
- db/migrate-sqlite-to-pg.js: one-shot row copy in FK order (bulk insert, TRUNCATE
  first so re-runnable). audit_log id left to PG's identity.
- package.json: add pg ^8.13.1.

Next: validate DB_BACKEND=pg smoke against a real Postgres on the server, then merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:28:04 +05:30
Sravan 3250530596 feat(db): complete async call-site conversion — Phase 3 done, validated on SQLite
The full sync→async conversion is complete and green on the SQLite backend. Every
DB call across the app now awaits the async adapter, so the identical code runs on
Postgres at cutover.

Converted (this commit finishes Phase 3):
- session.js: currentUser/apiKeyFromReq async → 63 route awaits + WS + static.
- routes.js: all ~250 R.* awaited; DTO helpers (namesFor, avatarsFor, buildMsgDTO,
  buildPollDTO, reactionsForMessage, postSystemMessage, pushGroupUpdate,
  issueRefreshToken, provisionFromBizgaze) made async; every `.map(x=>buildDTO(x))`
  restructured to `await Promise.all(...map(async...))` preserving order; `.filter`
  predicates that hit the DB moved to an `asyncFilter` helper; chained
  `R.x.y(...).length/.map/.filter` wrapped as `(await R.x.y(...)).method`; stream
  upload handlers (recording/transcript/attachment) made async.
- calls.js / signaling.js: all call/meeting fns async; leaveMeeting AWAITS
  persistCallHistory + finalizeTranscript BEFORE endCallByRoom (ordering matters —
  fire-and-forget would race the map teardown); WS handle()/cleanup() async with
  .catch guards.
- static.js: authAttachment(Raw) async (the .some carrier check became a loop),
  handleGet async; server.js dispatch catches handler rejections → 500 not a hang.
- media.js backfill, push.js, reminders.js, webhooks.js await their repo calls.

Validation on DB_BACKEND=sqlite: db-smoke 22/22; legacy e2e 80 checks pass with zero
FAILs (throws only at a PRE-EXISTING WS lobby-drift assertion, unrelated). Every
server file `node --check` clean.

Still on the branch — master untouched. Next: Phase 5 (pg backend + ~7 dialect
queries + data migration + Docker Postgres + cutover), then merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:06:27 +05:30
Sravan 2460c0f9eb wip(db): async call-site conversion in progress (Phase 3) — DO NOT MERGE yet
On the db-migration branch only; master stays clean + deployable. Foundation
(adapter, pg schema, smoke harness) is already on master and safe.

Done:
- repos.js fully async (Phase 2, validated: node --check clean, no missed transforms).
- session.js currentUser/apiKeyFromReq async.
- Mechanical `await` prefix applied across routes/static/calls/signaling/reminders/
  webhooks/push.

Remaining (does NOT compile yet — deterministic to finish):
1. Async cascade: helper fns that now contain `await` must be marked async and their
   callers awaited. node --check points to each (namesFor, authAttachmentRaw/
   authAttachment in static, the WS handlers in calls/signaling, reminders/webhooks
   loops).
2. DTO builders are the real work: namesFor, avatarsFor, buildPollDTO, buildMsgDTO,
   recDTO all became async — every `.map(x => buildMsgDTO(...))` etc. must become
   `await Promise.all(arr.map(async x => ...))`.
3. Chained calls `R.x.y(...).map/.length/.includes` → `(await R.x.y(...)).method`.
4. Then: node --check all green → node test/db-smoke.js green → e2e → merge to master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:26:10 +05:30
Sravan dbd209ac2b feat(db): async DB adapter with swappable sqlite backend (Phase 1)
server/dbx.js selects a backend by DB_BACKEND (default sqlite; pg added at cutover).
server/db/sqlite.js wraps the synchronous node:sqlite instance in the async
interface repos will call — prepare(sql).{get,all,run}, exec(sql), tx(fn), init().
Results come back as resolved Promises so identical repo code runs on synchronous
SQLite (dev/test) and asynchronous Postgres (prod).

tx() gives multi-statement atomicity that stays correct on both engines (sqlite is
single-connection; the pg backend will run it on one pooled client) — needed for the
account-merge transaction in repos.

Verified: get/all/run/tx all work end-to-end; confirmed no code reads
.changes/.lastInsertRowid, so the repo conversion is purely sync->Promise. Unwired —
nothing requires dbx.js yet; prod path untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:10:53 +05:30
Sravan e482cb5bb2 test(db): focused DB smoke harness for the migration (22 checks, green on SQLite)
Covers the DB-backed HTTP paths the async repo conversion touches — auth, users,
messages, attachments, conversations/groups, reactions, mentions, edit/delete,
polls, scheduled meetings (paginated), favorites, audit — asserting current API
shapes. Runs to completion with a pass/fail count and honours DB_BACKEND so it
doubles as the sqlite-vs-pg parity check at cutover. No WS/signaling (in-memory,
not the DB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:08:29 +05:30
Sravan 1709a331d1 feat(db): Postgres target schema (Phase 1 of the migration)
server/db/schema.pg.sql — the full Postgres DDL, every column defined up front (no
ALTER-ordering fragility). SQLite→PG type mapping documented in-file (epoch-ms
INTEGER→BIGINT, 0/1 flags→SMALLINT kept numeric so app code is unchanged, sizes→
BIGINT, audit rowid→GENERATED IDENTITY). Mirrors the three existing FKs and adds a
new idx_messages_attachment (the /files auth scan we cached earlier becomes a keyed
lookup).

Validated against a throwaway Postgres 16: loads with no errors, 24 tables + 44
indexes created. Unwired — nothing uses it yet; the SQLite path is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:46:04 +05:30
Sravan b2c4b10e41 fix(db): guest_emails/lobby ALTERs ran before scheduled_meetings existed + e2e drift
Migration prep. Two real fixes surfaced while building a regression harness:

1. db.js: the `guest_emails` and `lobby` ALTER TABLEs sat at lines 241/244, BEFORE
   scheduled_meetings is CREATEd (line 299). On a FRESH database the ALTER fails
   (no table yet), is swallowed by the try/catch, and the columns are never added —
   so a brand-new deploy is missing them and scheduling with guests crashes. Prod
   escaped it only by incremental deploy history. Moved both ALTERs to after the
   CREATE. (The upcoming Postgres schema defines every column up front, so this
   whole class of ordering bug goes away there.)

2. test/e2e.js: /api/meetings returns paginated `{list, pastTotal, page, pageSize}`
   now, not a bare array — updated three `.data.find` → `.data.list.find`.

No prod behaviour change (prod already has the columns; ALTERs are idempotent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:43:46 +05:30
Sravan ee95f594c6 style(share-ext): clean SF-Symbol X + paper-plane instead of heavy pill/circle
The top-bar buttons looked heavy against the navy bar (thick grey X-circle, bold
"Send" pill). Swapped for light SF Symbols on the navy bar: a thin `xmark` for
cancel and a `paperplane.fill` for send (semibold, enables when ≥1 chat is picked).
Icon-only send matches the Teams reference — the radio checks already show what's
selected, so the "(N)" count text is dropped. Native-only — needs a build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:08:38 +05:30
Sravan 854bc86a6e feat(share-ext): fix double "Send to", Teams-style layout, clearer branding
Two things from testing (the in-sheet picker with avatars + multi-select works):

1. Double "Send to": after the extension sent, opening the app ALSO popped the web
   "Send to" modal for the same file. Cause: the extension wrote a safety-net
   manifest up front, which the app then picked up. Now the manifest represents an
   UNSENT share only — written solely when the extension can't send (no token) or a
   send fails. A successful in-sheet send clears the staged files and leaves nothing,
   so the app never re-offers it. Cancel also clears staged files (no orphans).

2. Layout aligned to the Teams reference:
   - Preview strip of thumbnails for what's being shared (image → the image, video →
     first frame via AVAssetImageGenerator, else a doc icon).
   - Radio selectors on the right — an always-visible empty circle that fills to a
     navy check when selected (clearer multi-select than an appear-on-select tick).
   - "Recent chats" section header; subtitle under each name (Direct message /
     Group · N members).
   - Clearer branding: bold white "Share to Biz Connect" on the navy bar.

Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:25:15 +05:30
Sravan 4d446a5425 feat(share-ext): avatars, multi-select, and Biz Connect branding in the picker
Three follow-ups on the in-sheet picker:
- Profile photos: rows now show the real avatar (fetched from the conversations
  API's `avatar` field with the bearer token, or a data-URL decoded inline),
  rendered as a circle; coloured initials as the fallback — matching the app.
- Multi-select: tap toggles a checkmark instead of sending immediately; a "Send (N)"
  button in the nav bar sends to every selected chat. Each file is uploaded ONCE and
  its attachment id reused across all targets (the server allows the uploader to
  reattach the same id), so multi-send doesn't re-upload.
- Branding: navy (#1F3B73) navigation bar with a white "Biz Connect" prompt over the
  "Send to…" title and white controls.

Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:00:01 +05:30
Sravan d664dde798 feat(share): send from inside the share sheet — the Teams-style in-extension picker
Replaces the wrong approach (stage + try to bounce into the app, which iOS blocks)
with the one Teams/WhatsApp actually use: the picker and the send happen INSIDE the
share extension, so there's no app-open at all. Tap Share → Biz Connect → pick a
chat → it uploads and sends, right there in the sheet.

How the extension can send without the app: it's a separate process that can't see
the web app's HttpOnly cookie, so:
- server: GET /api/share/token mints a bearer token for the logged-in user.
- web: on every launch the app fetches that token and hands it to the extension via
  the App Group (ShareInbox.setAuth writes token+base to the shared UserDefaults).
- extension: reads the token and calls the SAME API the native client uses —
  GET /api/messages/conversations to list chats, POST /api/messages/upload for each
  file, POST /api/messages to send. Native UITableView picker with search.

Robustness: it still stages the files + writes a manifest first, so if there's no
token yet (user never signed in) or the send fails, the file isn't lost — the app
collects it on next open, exactly as before. On success the manifest is cleared so
the app doesn't re-offer it.

Server + web are live now; the token endpoint is harmless until a build ships the
extension. NEEDS A NEW iOS BUILD for the picker itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:33:23 +05:30
Sravan 2ec0a0c0cd feat(share-ext): confirmation card instead of a confusing blank flash
A Share Extension opening its host app is unsupported on modern iOS (restricted
~iOS 14), so the programmatic bizconnect://share open is silently blocked and the
user just saw a blank flash back to Photos — looking broken even though the files
staged fine.

The extension now shows a small native card after staging: "✓ Ready to send — Open
Biz Connect to choose a chat", with an "Open Biz Connect" button (user-initiated
open has the best chance of working) and a Done button. It still attempts the
auto-open first. Either way the app collects the staged files when next opened, so
the manual path that already works is unchanged — this just removes the "did it
even work?" confusion.

Renamed the local `staged` array to `collected` to free `staged` for the state
flag. Balance + selectors checked. NEEDS A NEW iOS BUILD (native change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:24:15 +05:30
Sravan 0c3487fdb0 polish(share): picker avatars + target name + pre-warm video poster
Follow-ups from testing the share flow (which now works end-to-end):
- Send-to picker showed only initials — now shows the real profile photo when the
  chat has one (matching the sidebar/forward avatars), coloured initials otherwise.
- During send it only said "Uploading 60%" with no idea WHO to — now the header and
  progress name the target ("Sending to Manasa Rapolu · 60%").
- After sending, a video bubble sat blank (just a timestamp) for a second or two
  while the poster generated on first view. media.js now warms the poster thumbnail
  at UPLOAD (temp-then-rename), and the on-demand /thumbs handler also writes via a
  temp, so the two can't serve a half-written JPEG. The bubble shows its poster
  right away.

Web + server only — live on deploy. Does NOT address the share extension failing to
auto-open the app (an iOS limitation, handled next in the native build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:21:02 +05:30
Sravan 4836b1e197 fix(share): read the shared file via native Filesystem, not fetch (remote-origin)
Sharing a photo into the app: the "Send to…" picker appeared and staging worked,
but picking a chat said "no file found". Cause: the app loads its UI from the
REMOTE origin (remote.bizgaze.com), and I read the staged bytes with
fetch(convertFileSrc(uri)). Capacitor's local `_capacitor_file_` serving isn't on
the remote origin, so that fetch is CORS-blocked / hits the remote server → 404.

Read through the native bridge instead: Filesystem.readFile returns base64 in
native code, never touching the webview network stack, so it reads the App Group
file the app has entitlement access to. Falls back to it.path, then to the old
webview path for local-asset builds. Web-only fix — no rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:08:58 +05:30
Sravan d5658eeb9e ci: surface the real xcodebuild error on archive failure
The archive keeps failing with only "Failed to archive" and exit 65 — no reason
shown. Cause: `xcode-project build-ipa` prints a prettified summary and swallows
xcodebuild's raw "error:" lines, which only survive in the /tmp/xcodebuild_logs
artifact. On failure we now grep that log for the signing/entitlement/compile
error and print it inline, so the next red build states WHY instead of just 65.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:40:01 +05:30
Sravan 59310419ba fix(ci): share-extension injector looked one dir too high for the xcodeproj
Codemagic log: "xcodeproj not found at /Users/builder/clone/ios/App/App.xcodeproj"
— missing the `mobile/` segment. The script lives in mobile/scripts, so __dir__ is
mobile/scripts and `File.expand_path('../..', __dir__)` resolved to the REPO ROOT,
not mobile/. The project is at mobile/ios/App/App.xcodeproj. Changed to '..' so ROOT
= mobile, which fixes PROJECT, SRC_DIR, APP_DIR and the entitlements path together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:28:20 +05:30
Sravan 0a2c7376b9 feat(share): "Biz Connect" in the iOS share sheet — share a photo/file into a chat
Adds the reverse direction: share FROM Photos/Files/Safari INTO a Biz Connect
conversation. An app can only appear in the iOS share sheet as an app-extension
target, so this is real native work, not a web change.

Pieces:
- mobile/ios-share/ShareViewController.swift: a UI-less Share Extension. It stages
  the shared items into the App Group container and opens bizconnect://share. It
  deliberately does NOT reimplement the chat picker — that lives in the app, which
  already has the chat list, search and upload progress. Appends to the manifest
  (never overwrites), so sharing twice before opening the app loses nothing.
- mobile/scripts/add-share-extension.rb: injects the extension target into the
  Capacitor-generated Xcode project on every CI build (Codemagic checks out fresh),
  using the xcodeproj gem that ships with CocoaPods. Embeds it, sets the bundle id
  <app>.share, and MERGES the App Group into the app's entitlements rather than
  clobbering them (push's aps-environment must survive). Idempotent.
- mobile/plugins/share-inbox: getPending()/clear() to read that manifest — the App
  Group container isn't one of Filesystem's known directories, so it needs a bridge.
- home.html: on bizconnect://share (and every resume, and cold-launch), read the
  inbox and show a "Send to…" picker over the chat list; chosen files run the SAME
  upload + /api/messages send as an in-app attachment. Reuses convertFileSrc to read
  the staged bytes with no base64 marshalling.
- ios-patch.sh registers the bizconnect URL scheme; codemagic.yaml fetches a profile
  for the .share bundle id too.

One-time manual gate (CI cannot toggle App capabilities): the App Group
group.com.bizgaze.connect must be created and enabled on both App IDs in the Apple
portal — documented in mobile/IOS_SETUP.md. Without it the two processes can't see
each other's files and sharing silently no-ops; everything else still works.

Validated cross-file: pod-name/jsName/method wiring for all three plugins, App
Group id identical in all 4 files, URL scheme consistent across extension/plist/web,
entitlement-merge preserves push. Needs a new iOS build (new targets + plugins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:58:08 +05:30
Sravan 00ea140280 feat(photos): downloaded media also lands in a "Connect" album in Photos
The Files folder is the app's own copy — it powers offline playback and Manage
storage — but Files is not where anyone looks for photos and videos. The Photos app
is, and only PhotoKit can write there; @capacitor/filesystem cannot, because an
app sandbox and the photo library are separate stores. So this adds a small native
plugin, mirroring the existing audio-route one.

- mobile/plugins/media-library: saveToAlbum({path, album, kind}) finds or creates
  the album and adds the asset. Uses addResource(with:fileURL:), which is uniform
  for photo and video and non-optional, unlike creationRequestForAssetFrom*, which
  can silently no-op.
- Requests .readWrite, NOT .addOnly: addOnly can add an asset but cannot look up or
  create an ALBUM, which is the whole point here. Both photo-library usage strings
  are already set by ios-patch.sh.
- If the album can't be resolved (e.g. "limited" access), the asset is still saved
  to the camera roll — landing somewhere beats failing outright. A racing create
  from two simultaneous downloads re-looks-up instead of erroring.
- Podspec named MediaLibrary.podspec with s.name = 'MediaLibrary' to match
  PascalCase of the package name — the same trap that broke the AudioRoute build.
  Checked by a script: pod name, jsName and declared-vs-implemented methods.

Entirely best-effort from the web side: a denied permission or an older app build
never fails a download that is already safe in the app folder. Added a Settings
toggle since this does keep a second copy of the file.

Needs a new iOS build — new native plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:39:27 +05:30
Sravan 82fa8e04eb fix(video): one centred control — download → % → play, as specified
The download button was a small corner overlay, which landed on top of the native
control bar's speaker icon. That was not the design asked for either: it should be
a single control in the CENTRE that turns into a play button once downloaded.

The tile is now the masked poster plus one centred button:
  not downloaded → download icon  (the element has no src at all, so nothing is
                                   fetched until it is tapped)
  downloading    → live % inside that same button, in place
  downloaded     → play icon; tapping plays the LOCAL file

The native control bar is switched on only when playback starts, so there is
nothing for the control to collide with. Progress reports into the button rather
than the floating chip, so a video download no longer shows two indicators.

Also gives the tile a min-height so the thread doesn't jump while the poster loads.
Web/PWA is untouched — it has nowhere to download to, so it keeps streaming with
native controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:57:07 +05:30
Sravan cb81dd6106 feat(storage): Biz Connect folder in Files, manage storage, videos save once
Three connected pieces, so downloads stop being write-only.

1. A folder the user can actually find. ios-patch.sh now sets UIFileSharingEnabled
   and LSSupportsOpeningDocumentsInPlace, and downloads go to the app's Documents
   folder in typed subfolders. iOS shows it as:
       Files -> On My iPhone -> Biz Connect -> Images | Videos | Files
   Previously everything was written to CACHE (private, and iOS purges it whenever
   it likes) and pushed straight at the share sheet, so nothing was ever really
   "kept" by the app.

2. Manage storage (Settings -> Storage). Lists what this device has downloaded,
   grouped by type with per-group and total sizes; each row can be shared to the OS
   sheet (this is where "Save to Photos" now lives) or deleted. Plus Delete all.
   Deleting removes ONLY the local copy — the attachment stays on the server, so
   anything deleted can be downloaded again from the chat.

3. A downloaded video never downloads twice. Images and files have their own
   download link, but a video's tile IS the player, so it had no control at all and
   re-streamed on every play. It now carries a download button; once saved, the
   button becomes a tick and the tile plays from the local file — no network.

The index is treated as a cache of the filesystem, never as truth, because the user
can delete these from the Files app behind our back: every listing re-stats and
forgets what is gone, the library is reconciled at startup, and a local file that
has vanished by play time falls straight back to streaming instead of showing a
broken player.

Unit-checked the path allocator: collisions between different attachments with the
same filename resolve to "name (2)", re-downloading the SAME attachment reuses its
path, and path traversal / illegal characters are neutralised.

Note: the folder and the save location need a new iOS build to take effect. The web
side degrades cleanly — none of this UI appears outside the native app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:29:29 +05:30
Sravan cc8f7d1eb8 feat(download): real % progress, and stream to disk instead of buffering
Uploads showed a bar + %; downloads showed nothing. On the native app that gap is
worse than on web, because there is no browser download UI behind it — the app
fetches the bytes itself, so a large video looked like a frozen tap.

- Adds the same chip an upload uses (name, bar, %) driven by Content-Length.
  Falls back to an indeterminate "…" when the server sends no length.
- Streams the response and appends to the file in 3-byte-aligned blocks rather
  than holding blob + base64 simultaneously. The old path peaked around 250 MB of
  memory for a 75 MB video, which is enough to get a WebView killed on a phone.
  Verified byte-exact against empty / 1 B / 2 B (base64 padding edges) / ragged
  chunk sizes / 27 MB, reassembling identical bytes every time.
- Older WebViews without streams keep the previous one-shot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:18:50 +05:30
Sravan 310f37f29b fix(video): centre spinner never appeared on the one stall users actually see
Reported: "the loading buffer is not the spinner, it still loads at the left of
the timer." Correct — and it was our bug, not a cosmetic preference.

The spinner was driven by a hand-picked event list (waiting/seeking/stalled). But
a cold start from preload="none" NEVER fires `waiting`: it runs
loadstart -> loadedmetadata -> loadeddata -> canplay -> playing straight through.
Since the bandwidth fix, that cold start is the only stall left — so the spinner
sat out the exact moment it existed for, leaving just the OS control bar's own
small indicator where the play button sits, i.e. left of the timer.

Now the spinner is derived from the element's real state rather than guessed from
events: busy = seeking || (!paused && !ended && readyState < HAVE_FUTURE_DATA),
recomputed on every relevant media event. Simulated against the real event
sequences before shipping — cold start, mid-stream stall and seek all spin;
paused/ended/idle never do.

Also dim the frame to 72% brightness while buffering so the spinner reads
instantly against a bright poster, and give it a dark backing disc.

Note: the small indicator inside the native control bar belongs to the OS's own
video controls and cannot be suppressed while we use them. Ours is now the loud,
central one; removing the OS indicator entirely would mean custom controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:48:11 +05:30
Sravan 8a5409987c perf(video): stream a capped, faststart rendition instead of the raw upload
THE ANSWER to "why does an already-downloaded video still buffer?" — it was never
the download, and it was not the server. Probing the real uploads on the box:

  d0e49e58…  1920x1080  19.4 Mbps   75 MB / 31 s
  ad929d0b…  1920x1080  19.0 Mbps   27 MB / 11 s
  9f4e0865…   720x1584   3.6 Mbps   14 MB / 31 s

To play a 19 Mbps file the client has to SUSTAIN a 19 Mbps download for the whole
clip. No mobile link does, so the <video> buffer drains every few seconds: buffers,
plays, buffers, plays. Server-side disk read was instant and load was 1.7 on 20
cores throughout — the bottleneck is the media itself, not the delivery path.

Second, independent defect: phone MP4s store `moov` AFTER `mdat` (verified on two
uploads), so the player must fetch the file's tail before it can start at all.

Fix — keep the original bytes untouched (that is what the download button serves,
full quality) and build <id>.web.mp4 beside it: longest side capped at 1280,
~2.5 Mbps ceiling, +faststart. Measured on the 19 Mbps file:

  27.3 MB @ 19.0 Mbps  ->  2.55 MB @ 1.78 Mbps   (10.7x less bandwidth)
  transcode took 2.4 s for an 11.5 s clip

- server/media.js (new): probe, decide, 2-at-a-time background queue. Already
  light + correctly sized + faststart => no rendition at all. Light but wrong atom
  order => remux -c copy (seconds, no re-encode). Otherwise re-encode. A rendition
  that lands bigger than the original is discarded. MP4 box-walker for the
  faststart test is unit-checked against known fast/slow files, both directions.
- /stream/<id> serves the rendition, falling back to the original while it is still
  transcoding, so a video is never unplayable. /files/<id> is unchanged and still
  serves the pristine original for download.
- Renditions are queued at upload, and backfilled 15 s after boot for the videos
  that predate this. Range serving is now one shared helper for both routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:49:04 +05:30
Sravan 2127397408 perf(video): cache attachment auth so Range playback stops stuttering; strict on-demand
Root cause of a downloaded/streaming video buffering repeatedly: every /files Range request
(a playing video fires dozens) re-ran the full attachment authorization, which scans the
messages table by attachment_id (un-indexed) — a per-chunk table scan = stutter. Now the
auth decision is cached per user+attachment for 60s (module-level, bounded), so range
requests after the first are ~free.

Also: preload='none' (nothing about a video downloads until the user taps play — only the
small poster loads), per 'no auto-download'. And the buffering spinner no longer hides on
canplay/loadeddata (they fire mid-buffer), so it reliably spins whenever it's buffering.
build batch159.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:27:37 +05:30
Sravan b7cd6df625 feat(video): clear center buffering spinner over in-chat videos
The native controls' buffering indicator sits by the timer and is easy to miss. Wrap the
<video> and overlay a centered spinner shown while it buffers (waiting/seeking/stalled ->
show; playing/canplay/seeked/loadeddata -> hide). Media events don't bubble, so the
listeners run in the capture phase; the overlay is pointer-events:none so it never blocks
the native controls. build batch158.
2026-07-22 22:16:35 +05:30
Sravan fd15628393 feat(video): server-generated poster thumbnails + HTTP Range streaming
- Dockerfile: add ffmpeg (Alpine).
- static.js: new /thumbs/<id> — ffmpeg extracts the first frame (0.5s), caches it next to
  the file, serves as the video poster (cosmetic; 404s gracefully if ffmpeg unavailable).
- static.js: /files now supports HTTP Range (206 Partial Content) + Accept-Ranges, which
  iOS requires to stream/seek video reliably (fixes the buffer-before-play / multi-tap);
  media (image/video/audio) now served inline, other files still download. Shared
  attachment auth refactored into one helper used by /files and /thumbs.
- home.html: video poster points at /thumbs/<id>. build batch157.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:09:32 +05:30
Sravan 92d41e6324 revert swipe-back to instant release; simplify in-chat video to native <video controls>
Swipe-back: the finger-following version is dropped (parked for the future) per request —
back to the reliable release-triggered swipe (rightward edge release runs bzcBack's slide).

Video: the custom download->play overlay caused layout 'dancing' on load and flaky
multi-tap playback. Replaced with a plain native <video controls playsinline preload=
metadata> (poster via #t=0.1) at a fixed box size — poster + OS play button, plays inline
on one tap, streams once and is cached (no re-downloads). build batch156.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:57:19 +05:30
Sravan 85a1b9d2ca fix(mobile): pull-refresh loader below the Dynamic Island; in-chat video player
Bug 1: .ptr-ind used top:8px so the pull-to-refresh spinner sat under the notch/Dynamic
Island. Now top:calc(var(--sat)+8px) clears the safe-area inset.

Bug 2: video attachments rendered as a plain download link that re-downloaded on every
tap. Server now sends isVideo/isAudio on message attachments; videos render as an in-chat
player — masked poster with a DOWNLOAD button that loads the file ONCE (preload=none ->
load on tap), then becomes a PLAY button; playing hands off to native inline controls, so
no repeat downloads. build batch155.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:48:09 +05:30
Sravan 3cc6ff4e54 feat(mobile): finger-following swipe-back (iOS-style), reusing the working close layout
The conversation pane now tracks your finger from the left edge and reveals the chat
list behind it, completing the back past ~35% width or springing back otherwise. Reuses
the existing body.chat-dragging layout (already defined, identical to chat-closing that
showWelcome uses): content z-index:2 at translateX(0), list .chatcol absolute behind at
z-index:0 — so the pane starts at the correct on-screen origin (the earlier attempt's
'one screen-width off' bug was a different setup). Vertical drags still scroll; popup/
search edge-release still closes via bzcBack. build batch154.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:22:58 +05:30
Sravan 200624bd24 chore: remove all temporary debug instrumentation
Investigations are closed, so strip the probes: bzDbg + __perfProbe definitions, the
older/render/slide/pwaFocus call sites, and the server-side /api/dbg (MDBG) sink. Kept
the functional code around each probe (renderThread's innerHTML build, the older-page
re-anchor, the slide fade). No behavior change. build batch153.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:46:54 +05:30
Sravan 2f368643ad fix(ios audio): stop dropping call audio on tap — never reconfigure session mid-call
Root cause of 'tapping stops the Bluetooth audio': bzUnlockAudio fires on EVERY tap
during a call and called bzApplyRoute -> setSpeaker(false) -> configureDevice, whose
setCategory+setActive(true) reconfigures the AVAudioSession mid-call and interrupts
WebKit's audio unit, dropping the call audio. Fix: bzApplyRoute on iOS no longer touches
the session at all (only wires the route-icon listener); iOS keeps auto-routing. build batch152.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:30:35 +05:30
Sravan 5cee67e974 feat(ios audio): final UX — auto-route + live indicator (no impossible toggle)
Telemetry confirmed the ceiling: a single speaker override holds ~1s over active BT then
WebKit reverts. So stop trying to control the route on iOS — always use the default port
(iOS auto-routes: BT/wired if connected, else loudspeaker), and make the button a live
INDICATOR of the real output; tapping shows a toast (connect/disconnect a headset to
change). Removes the temp nroute/sptap probes. Pure web change; plugin v1.1.2 already
supports it. build batch151.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:57:08 +05:30
Sravan 44526e1ee9 fix(ios audio): stop the speaker<->BT oscillation; single override, no re-force
v1.1.1's route observer re-forced speaker on every change, and WebKit re-added Bluetooth
each time -> the audio flapped speaker<->BT many times/sec (telemetry: dozens of route
flips from 3 taps), which read as 'sound doesn't switch'. WKWebView won't let the app
hold the built-in speaker over an active BT device. So: one override per tap, observer
only REPORTS the output (no fighting). Also stop dimming the iOS button (it's a live
output indicator, not on/off; the dim read as 'disabled' on BT). Probe now carries the
native marker so we can confirm the binary. plugin v1.1.2-stable, web batch150.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:54:42 +05:30
Sravan cfbf950173 fix(ios audio): force speaker over connected Bluetooth by dropping BT category options
overrideOutputAudioPort(.speaker) alone can't beat a connected BT headset (BT is higher
priority), so 'Speaker' snapped back to BT. Now setSpeaker(true) sets category options
[.defaultToSpeaker] (no allowBluetooth) so BT isn't an eligible output and the speaker
wins; setSpeaker(false) restores [.allowBluetooth,.allowBluetoothA2DP] and uses the
default port (routes to the headset). Observer re-holds speaker if a BT connect steals it.
Adds a TEMP web probe (nroute/sptap) to verify from telemetry. plugin v1.1.1, web batch149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:01:06 +05:30
Sravan c3b0ef560d feat(ios audio): report real output route so the toggle icon is correct
BT audio already routes on iOS, but the web UI can't see it (iOS hides audio outputs
from enumerateDevices), so the icon was stuck on speaker. Plugin v1.1.0 now exposes the
active output: getRoute() + a 'routeChange' event ('speaker'|'bluetooth'|'wired'|
'receiver'|'airplay'). Web subscribes and drives the icon/label from the real route
(bluetooth/headphones/speaker), and the iOS toggle becomes a 2-state Speaker <-> Device
cycle (JS can't enumerate outputs there). Also strips the earpiece-investigation debug
logging from the plugin. Native needs one Codemagic build; web is live (batch148).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:43:07 +05:30
Sravan e9b9d3c121 feat(ios calls): drop unreachable earpiece from route toggle; Speaker <-> BT/headset
Proven via device telemetry: inside a WKWebView, WebKit owns the WebRTC audio unit and
forces the loudspeaker; overrideOutputAudioPort(.none) is a no-op (route settles on
Speaker 1.2s later), so the built-in earpiece cannot be selected. Present only what
actually works on iOS: Speaker, and Bluetooth/wired headset when connected. bzApplyRoute
now maps only 'speaker' to the loudspeaker override; bt/headset use the default port.
Coerce any stale 'earpiece' pref to speaker on iOS. Also strips the route debug telemetry.
Other platforms keep the earpiece option. build batch147.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:26:13 +05:30
Sravan 0282181ca6 diag(ios audio): settled-route probe (+0.4s/+1.2s) + live mode in route log
1.0.3 telemetry showed mode already == .voiceChat when earpiece still landed on
Speaker, and setSpeaker read the route synchronously (stale) right after the override.
So the mode re-pin alone may be insufficient and the sync read is unreliable.

1.0.5: keeps the .voiceChat re-pin, but stamps live mode into every route-change log
line and re-reads the SETTLED port+mode at +0.4s and +1.2s after each toggle (reported
in the next toggle's trail). This definitively answers whether WebKit flips to .videoChat
and where the route truly settles. native marker 1.0.5-settle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:44:38 +05:30
Sravan b54ba10c69 fix(ios audio): pin .voiceChat before override(.none) so earpiece is reachable
Root cause (WebKit source MediaSessionManagerCocoa.mm + Apple DTS, confirmed by
telemetry): WKWebView's WebRTC re-pins the session to mode .videoChat while capture
is active, and .videoChat auto-implies .defaultToSpeaker. So override(.none) reverts
to the mode default = LOUDSPEAKER, and .none alone can never reach the earpiece once
WebKit flips the mode. Our first override won only because .voiceChat was still active.

Fix: for earpiece, setMode(.voiceChat) (its default route IS the receiver) before
override(.none) in both setSpeaker and the debounced route-change re-assert. Add an
accessory guard so a connected BT/wired headset isn't yanked to the built-in receiver.
Reconcile the launch patch: drop .defaultToSpeaker from inject-audio.js so it stops
contradicting the plugin. native marker 1.0.4-mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:31:14 +05:30
Sravan ce49e8e30d fix(ios audio): re-assert earpiece after WebKit re-forces speaker (debounced)
Telemetry proof: overrideOutputAudioPort(.none) routes to Receiver once, then every
later toggle latches to built-in Speaker despite opts=36 (no .defaultToSpeaker) —
WebKit's WebRTC engine re-forces the loudspeaker after our override. The prior build
ignored .override-reason route changes and never corrected it.

Now: react to ALL route changes, debounced 0.25s, and re-assert the chosen port only
on a genuine mismatch (self-terminating, capped at 6/toggle to avoid thrash). setSpeaker
returns a reason->port route-change trail so the log shows whether WebKit is one-shot
or persistent. native marker 1.0.3-reassert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:20:29 +05:30
Sravan 36d51ba40d diag(ios audio): setSpeaker returns actual output port + category/mode + native marker
Telemetry only showed 'override didn't throw', and the web __BUILD tag can't tell
native binaries apart, so we couldn't see WHERE iOS actually routed the audio or
which plugin build ran. setSpeaker now resolves with the real currentRoute output
port (Receiver/Speaker/Bluetooth), the live AVAudioSession category/mode/options,
and a native-build marker (1.0.2-diag) so the route log is unambiguous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:22:01 +05:30
Sravan 1920345ecd fix(ios audio): route earpiece reliably; stop fighting WebKit's audio session
Speaker worked but the earpiece was silent, and call audio flickered on speaker
then dropped. Causes: (1) the category set .defaultToSpeaker, so overrideOutputAudioPort(.none)
fell back to the loudspeaker instead of the receiver; (2) setSpeaker re-ran
setCategory+setActive on every toggle mid-call, tearing down the audio unit WebKit's
WebRTC engine was using and silencing the earpiece route.

Fix: drop .defaultToSpeaker (drive the port explicitly), make setSpeaker flip ONLY
overrideOutputAudioPort, and observe routeChangeNotification to re-assert the chosen
route when WebKit reconfigures the session at call start / device change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:10:09 +05:30
Sravan 8f33df5c3d fix(ios): rename podspec to AudioRoute.podspec so cap sync/pod install resolves it
Capacitor derives the pod name from the npm package name (audio-route -> AudioRoute)
and writes 'pod AudioRoute, :path => ../../plugins/audio-route' into the generated
Podfile. CocoaPods then requires a file literally named AudioRoute.podspec whose
s.name is 'AudioRoute'. The old AudioRoutePlugin.podspec (s.name AudioRoutePlugin)
caused the Codemagic build to fail with 'No podspec found for AudioRoute'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:15:56 +05:30
Sravan 804c218236 docs: preserve coturn/TURN setup notes (rescued from the removed prod-fix worktree) 2026-07-20 17:55:30 +05:30
Sravan 46b5ae16a4 speaker: force-add audio-route plugin dist stubs (global dist/ ignore would drop them from a fresh clone) 2026-07-20 17:07:01 +05:30
Sravan c6f236ecf9 speaker: make AudioRoute a real Capacitor plugin PACKAGE so it registers (device log: has:0)
Device telemetry proved the app-embedded AudioRoute class never registered (not in
Capacitor.Plugins) — appending a CAPBridgedPlugin to AppDelegate.swift gets stripped/undiscovered
in release builds. The plugins that DO register (Share, Camera, Filesystem) are all npm packages
wired by cap sync. So AudioRoute is now a local plugin package (mobile/plugins/audio-route,
file: dep in mobile/package.json) with a podspec + CAPBridgedPlugin Swift — cap sync adds its pod
and Capacitor registers it like the others. load() sets the launch speaker default; setSpeaker({on})
overrides the output port. inject-audio.js no longer injects the plugin class (would duplicate);
it keeps only the AppDelegate launch default as a fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:06:16 +05:30
Sravan a574816eaa debug: instrument bzApplyRoute to see if native AudioRoute plugin registered + setSpeaker result (batch146) 2026-07-20 16:43:39 +05:30
Sravan 5960efb612 uploads: support up to 1 GB attachments, streamed to disk (was 25 MB, buffered in memory) (batch145)
- Server streams the upload body straight to /data/uploads (a .part temp file, atomic rename on
  success), backpressure-aware, so a 1 GB file never buffers in RAM. MAX_UPLOAD_MB env (default
  1024 = 1 GB) controls the cap; error message reflects it.
- Client size guard raised 25 MB -> 1 GB.
- docker-compose documents MAX_UPLOAD_MB and the required Nginx Proxy Manager client_max_body_size.
NOTE: the actual bottleneck for the user's 9.7 MB reject is almost certainly NPM's client_max_body_size
(nginx default 1 MB) — that must be raised in the NPM admin; the app change alone can't lift it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:06:19 +05:30
Sravan 90d49d29d0 speaker: native AudioRoute plugin for real earpiece<->speaker toggle on iOS (batch144)
- ios-patch.sh now injects an AudioRoutePlugin (CAPBridgedPlugin, Capacitor 7 auto-registers it)
  into AppDelegate.swift with setSpeaker({on}) -> AVAudioSession.overrideOutputAudioPort. Tolerant/
  build-safe: if it doesn't register, the web call just no-ops (can't crash or fail the build).
- web: nativeAudioRoute()/bzApplyRoute() drive the plugin; toggleSpeakerphone + the on-join/on-tap
  unlock now actually switch the route on iOS (setSinkId can't). canRouteAudio() shows the toggle
  when the native plugin is present. Dormant until the next Codemagic build ships the plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:31:25 +05:30
Sravan 9c2ba847b0 meeting: unlock iOS media playback on Join so remote call audio isn't silent until you mute/unmute (batch143) 2026-07-20 00:08:25 +05:30
Sravan 4e78448743 mobile: revert finger-following swipe-back to the working release-based version (interactive one had an undiagnosable-blind layout offset) (batch142) 2026-07-20 00:01:33 +05:30
Sravan fed0e07e3e mobile: swipe-back easier completion threshold (25%) + readback telemetry to confirm conversation actually translates (batch141) 2026-07-19 23:41:13 +05:30
Sravan 9c731f087e mobile: iOS PWA keyboard — pin app to visual viewport + undo webview scroll so header stays; swipe-back telemetry (batch140) 2026-07-19 23:17:13 +05:30
Sravan 578f3a2921 mobile: interactive-widget=resizes-content so the keyboard resizes the layout instead of panning the viewport (header no longer pushed off) (batch139) 2026-07-19 23:00:43 +05:30
Sravan f178e271c8 debug: capture PWA composer-focus viewport state (scale/scroll/font) to diagnose the zoom (batch138) 2026-07-19 22:53:28 +05:30
Sravan 8aa2532666 mobile: interactive finger-following swipe-back to go from a conversation to the list (batch137)
Drag the open conversation rightward from the left edge and it follows the finger 1:1 with the
list parallaxing in underneath; release past ~35% (or a quick flick) completes the pop, else it
snaps back. Fixes the old stuck-pane bug: once a clear horizontal drag is detected we
preventDefault (passive:false) to CLAIM the gesture so iOS/scroll can't steal it and fire
touchcancel; touchcancel always resolves to a clean state. showWelcome(skipAnim) does the state
swap without re-animating. Works in native app, PWA and mobile browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:50:10 +05:30
Sravan 40789b06cf mobile: composer font-size 16px UNCONDITIONALLY to stop iOS/PWA focus-zoom on any viewport (batch136) 2026-07-19 18:57:45 +05:30
Sravan 134cb8999d ios: default in-call audio to the loudspeaker (AVAudioSession) at build time
Calls were routing to the quiet earpiece. ios-patch.sh now runs a Node helper (Node 20 is
already in the build env) that injects an AVAudioSession .playAndRecord/.voiceChat category with
.defaultToSpeaker + Bluetooth into the generated AppDelegate. Tolerant: exits 0 and no-ops if the
template differs, so it can never fail the Codemagic build. Verified locally against the Cap 7
AppDelegate template — injects correctly and is idempotent. First pass; if WebRTC re-grabs the
session mid-call on device, a follow-up plugin will re-assert .overrideOutputAudioPort(.speaker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:52 +05:30
Sravan f2b965bd3d desktop v0.1.20 + web: notify on download complete (was silent) (batch135)
Suppressing the save dialog made downloads completely silent — "nothing happened" even though
the file saved to Downloads. Now on download 'done': (1) tell the web UI → branded toast
"Saved X to your Downloads folder" when the window is focused; (2) native OS notification
(click → reveal in Explorer) when the app is minimized/in the tray, so it's never double-noticed;
(3) a failure notice. preload exposes onDownloadDone; home.html shows the toast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:03:24 +05:30
Sravan 30eaf37773 desktop: v0.1.19 — reliable auto-update for close-to-tray users
The app closes to tray, so it kept running on the old version and only checked on a fresh launch.
Now: (1) check for updates on EVERY window show (X-to-tray then reopen, taskbar, relaunch via
second-instance) via win.on('show'), throttled 1/10min; (2) explicit autoInstallOnAppQuit=true so
a downloaded update installs on the next real quit / PC restart even if the user never clicks
"Restart now"; (3) native notification when an update finishes downloading while hidden in the
tray. The existing 6-hour background timer is unchanged and still runs regardless of window state.
Also carries the 0.1.18 downloads-to-Downloads-folder change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:38:01 +05:30
Sravan 3f44e2b285 desktop: v0.1.18 — downloads save to Downloads folder with no dialog 2026-07-19 11:42:38 +05:30
Sravan d098ba468e docs: revert stray whitespace in IOS_SETUP.md 2026-07-19 11:36:42 +05:30
Sravan 9f3e8b83c7 mobile: hide iOS keyboard accessory bar + composer-anchored attach menu (batch134)
- Accessory bar: call Keyboard.setAccessoryBarVisible({isVisible:false}) on iOS in the native
  IIFE, hiding the grey chevrons+Done strip above the keyboard. The plugin is already bundled
  in the current TestFlight build, so this takes effect on a web deploy — no rebuild.
- Attach: tapping the paperclip now opens a composer-anchored Photos/Camera/Document menu
  (like the emoji/mention popups) instead of firing the generic mid-screen OS chooser as the
  first thing. Each option opens a type-scoped picker (image/*, capture). Web fix, all clients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:35:59 +05:30
Sravan a7f954ae44 mobile: Enter=newline on mobile, hide preview zoom buttons; desktop: downloads to Downloads folder (batch133)
- Mobile: the Return key now inserts a newline instead of sending (send via the button, like
  every native chat app). Desktop keeps Enter=send / Shift+Enter=newline.
- Image preview: hide the on-screen +/- zoom buttons on mobile (pinch-to-zoom covers it).
- Desktop (Electron): a will-download handler saves straight to the OS Downloads folder with no
  "where to save?" dialog, de-duping the name if it exists. NOTE: desktop code only — NOT
  published to the update feed (needs an explicit desktop rebuild/publish).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 10:38:36 +05:30
Sravan c32584df54 mobile: fix zoom + notch on the non-home pages (dashboard/index/connect/share/host)
The mobile viewport fixes only ever went into home.html. The other standalone pages still had
the old viewport, so in the native app they auto-zoom on input focus and (dashboard/host) run
under the notch/Dynamic Island. Bring every page's viewport to match home.html
(maximum-scale=1, user-scalable=no, viewport-fit=cover) and add safe-area top padding to the
dashboard header and the host body/indicator so nothing sits under the island now that the
viewport is cover. index/connect/share already pad for safe-area; they only needed maximum-scale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 08:39:50 +05:30
Sravan 3a7a026533 mobile PWA: fix keyboard zoom (16px inputs) + composer lift via VisualViewport (batch132)
iOS home-screen PWAs IGNORE viewport maximum-scale (Apple disabled it for accessibility), so
the maximum-scale=1 that stops auto-zoom in the Capacitor WebView does nothing in the PWA — it
still zooms on input focus. Real cross-platform fix: make every focusable text field 16px on
mobile (the composer was .92rem). autoGrow's empty-guard keeps it one line.

Also the PWA has no Capacitor Keyboard plugin, so the composer never lifted above the keyboard
("not the same keyboard"). Added a VisualViewport-based lift for non-native clients (browser +
PWA); native still uses the plugin. >100px threshold ignores the Safari toolbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 08:31:32 +05:30
Sravan 150a074578 mobile: stop the open-pin schedule the instant the user scrolls (batch131)
Bug: for ~3.4s after opening a chat, the "keep newest in view as images load" pin schedule
would yank you back to the bottom if you tried to scroll up for older history — so scrolling
up snapped back to latest, and only worked once the schedule expired (~5s). Fix: the user's
first scroll gesture now sets _openScrolled, which cancels the pending pin timers and short-
circuits _pinNewest (so the late image-load pins stop too). loadOlder/anchor then works
immediately, no yank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 08:21:34 +05:30
Sravan 2a60c01268 mobile: apply network content DURING the fade, not at landing (batch130)
Device log showed the slide motion itself is now clean 60fps (frames 0-15 all ~16ms in every
sample); the only remaining spike was at frame ~16 — AFTER the slide lands — from applying the
network thread result + pin at that instant (the "adjusts after it lands" feel). Since the open
is a compositor opacity/transform fade, main-thread work during it doesn't stall the animation
and is masked by the low opacity. So apply the reconcile/render + pins immediately when the
fetch returns (mid-fade) instead of deferring to slide-end. By the time the pane is fully opaque
the content is already settled. Removed the now-obsolete appendBubble mid-slide deferral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 07:32:21 +05:30
Sravan 9bb74cffd2 mobile: real slide-in + fade on open, list column untouched = zero reflow (batch129)
The reflow that caused the end-of-slide hitch was never the content slide itself — it was
SHOWING the list column during the slide and hiding it (display:flex->none) at the end. So
keep .chatcol display:none the whole time (untouched) and animate ONLY .content: it pushes in
from translateX(12%)->0 with an opacity fade, on its already-promoted GPU layer. Real slide
motion, and nothing to reflow when it ends. (This also explains the batch126 breakage: that
made .chatcol position:absolute, disturbing the touch/layout target — not touched here.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 06:57:48 +05:30
Sravan 9c703d6e64 mobile: chat open = instant + quick opacity fade (no slide) (batch128)
User chose the fade over chasing the WebView slide-teardown reflow. Open now: the cache-
rendered conversation is placed instantly and fades opacity 0->1 over 140ms. No transform,
no off-screen pane, no list-column display toggle -> nothing to reflow at the end, so the
~40ms end-of-slide settle cannot occur. Same afterOpenSlide queue flushes network reconcile/
pins/appends at fade-end. Desktop unaffected (fade is mobile-only via __freshOpen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 06:51:38 +05:30
Sravan 9eb4a99679 mobile: revert chatcol absolute-behind — it broke chat opening (batch127)
The list-column position:absolute change stopped conversations from opening. Revert to the
known-good display:none. Keep the other batch124-125 fixes (content promoted, reconcile,
reopen-at-latest, download interceptor). Slide back to the batch125 behaviour (opens fine,
minor end hitch) while I find a safe way to remove the reflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 06:35:45 +05:30
Sravan 82d0154313 mobile: kill the real slide hitch — stop reflowing the list column at slide-end (batch126)
Device log proved the residual 30-78ms end-of-slide hitch is NOT the content-layer demote
(content stays promoted) and NOT the re-render (reconcile, longtasks:[]). It's the chat-list
column flipping display:flex->none at transitionend, which reflows that whole subtree. Keep
the list RENDERED behind the conversation (position:absolute; z-index:0; covered by the opaque
content pane) instead of toggling display. Teardown now only clears transforms = no reflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:04:40 +05:30
Sravan ea04556596 mobile: definitive slide fix (zero teardown) + real native download + reconcile (batch125)
Slide: the residual ~40ms end-of-slide hitch (down from 128ms in b124) was the layer
DEMOTE + the deferred full renderThread at transitionend. Two fixes:
- Keep .content promoted for the WHOLE time a chat is open (will-change lives on
  body.chat-open, not on the animating class). The open transition now only changes the
  transform VALUE 100%->0; when it ends there is nothing to tear down -> no demote raster.
- Slide-end no longer rebuilds all 40 nodes: reconcileOpen() appends only the 0-2 genuinely
  new tail messages (full renderThread only if the page structure diverged).

Download: routing to Safari failed auth (no login cookie). Real fix: fetch the file in the
WebView (cookie present) and hand the bytes to the OS save/share sheet via Filesystem+Share
(added to mobile deps; ships next TestFlight build). Until then, images fall back to the iOS
long-press "Save to Photos" instead of breaking the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:48:34 +05:30
Sravan 91b3e5c522 mobile: kill slide end-hitch (compositor teardown), fix reopen-at-latest + native download hang (batch124)
Slide: the 40-128ms hitch was a compositor RE-RASTER at transitionend, not JS
(longtasks:[], renderMs 2-6ms in the device log). Removed contain:layout paint /
isolation:isolate from the .content slide layer and the filter:brightness parallax
on .chatcol (both force a full re-raster on teardown); defer the layer demotion two
frames past transitionend so it lands off the motion path. Also replaced the fragile
length+lastId slide-end skip (it almost never held once a live message grew the cache,
so the full innerHTML rebuild kept running at slide-end) with an exact per-message diff.

Reopen-at-latest: loadOlder wrote the GROWN thread (100+ msgs) into THREAD_CACHE, so
re-opening re-rendered all of it and stranded you mid-history as images shifted the
(pre-load) bottom past the 1200px pin guard. Open now renders the latest PAGE only;
added _forcePinOpen to glue to the newest through late image loads until the user scrolls.

Download hang: <a download> navigated the whole WKWebView away to the raw file (no back,
app frozen). Native-only capture-phase interceptor opens downloads in the system browser.
Lightbox close/download buttons: solid dark chips so they're visible over bright images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:31:57 +05:30
Sravan 433703cfeb perf(mobile): skip redundant end-of-slide re-render (unchanged content) + load-older before rubber-band
Device log: slide is 60fps EXCEPT one ~60-130ms hitch at the END = the deferred renderThread rebuilding
the (identical) DOM at slide end. Skip it when the network result matches the cache render (signature).
Load-older: fired at scrollTop<120 = during the rubber-band over-scroll past the top (beforeScrollTop
was -289), so the prepend+anchor jerked momentum. Trigger earlier (<700, skip negative scrollTop) so it
loads while still scrolling and never interrupts the bounce.
Build marker -> 2026-07-19-batch123.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:10:27 +05:30
Sravan a4600f6b5f fix(mobile): close the debug <script> block (batch122 left it open, breaking the keyboard block)
The perf-diagnostics script tag was never closed, so it merged with the following native/keyboard
<script> and threw 'Unexpected token <', disabling the keyboard/safe-area init. Add the missing </script>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:02:48 +05:30
Sravan d02d8976d3 diag(mobile): instrument slide frame-timing + render cost + load-older scroll to find the real jank
Re-add /api/dbg + a frame-gap/long-task probe over the chat-open slide, the innerHTML render time, and
before/after scroll for load-older — to measure on-device where the jank actually is (main-thread block
vs compositor) instead of guessing. Temporary; removed once fixed.
Build marker -> 2026-07-19-batch122.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:00:28 +05:30
Sravan 529bc26cb5 perf(mobile): compositor 'render-then-arm' chat-open slide + robust load-older anchor; remove diagnostics
Slide (workflow-verified): the jump was the main thread building the message DOM + scroll writes DURING
the 300ms animation. Now: render off-screen first, then arm a transform-only slide on a promoted GPU
layer (translate3d/contain:layout paint) with all innerHTML/scrollTop work deferred to transitionend
(afterOpenSlide queue). armOpenSlide runs after the sync render; appendBubble defers live writes mid-slide.
Load-older jump: anchor on the specific oldest-loaded message element (not scrollHeight math, which broke
when prepended images loaded and shifted content) and re-anchor as those images load.
Removed all /api/dbg diagnostics (client reporter + server route + spike/diag probes).
Build marker -> 2026-07-18-batch121.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:49:38 +05:30
Sravan f3c3e08be4 perf(mobile): paginate threads to 40 (was 500) — kills the heavy-DOM jank at the source
User's insight: the jank/jumps come from rendering ALL ~500 messages on open. Load only the latest 40
(server + client PAGE), and the existing loadOlder() pages in older history on scroll-up with a scroll
anchor (no jump). Whole-thread SEARCH is a separate endpoint, unaffected. Also #1: keyboard-show only
pins to newest when already near the bottom, so replying to an OLD message no longer yanks to latest.
Build marker -> 2026-07-18-batch120.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:31:05 +05:30
Sravan 2bd8b3c8c1 fix(mobile): image pager direction + end-arrows; reset --kb on composer blur
- Pager: force the staged (opposite-edge) frame to PAINT (void offsetWidth) before animating in, so the
  incoming image enters from the correct side (was appearing from the exit side). Hide prev-arrow at the
  first image and next-arrow at the last.
- #1: keyboard closed after reply-cancel but the --kb lift stayed (empty gap). Add keyboardDidHide reset +
  a composer-blur fallback that drops --kb when focus leaves the composer.
Build marker -> 2026-07-18-batch119.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:11:05 +05:30
Sravan da3d44cbb7 fix(mobile): clamp reply-quote (balloon), clip swipe-reply overflow, animated image pager
- Reply quote: was showing the FULL quoted body untruncated -> DIV.quote rendered 1571px, ballooning the
  bubble + leaving the chat not-at-bottom. Clamp to 2 lines (-webkit-line-clamp) + collapse newlines.
- Swipe-to-reply: translateX(+72px) on a right-edge bubble pushed past the viewport -> a horizontal scroll
  bar. .convo-msgs overflow-x:clip stops it.
- Image pager (#8): replace instant src swap with a real slide — current image slides out, next slides in
  from the opposite side; clamp at first/last (no wrap -> no blank slide past the end).
Build marker -> 2026-07-18-batch118.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:32:10 +05:30
Sravan 1f5b4bd42c fix(mobile): stop bubble/composer balloon + transition pop (shared root cause, workflow-verified)
Parallel root-cause analysis: during body.chat-opening the pane is momentarily ~0 width; word-break:
break-word / overflow-wrap:anywhere let bubbles collapse to 1-GLYPH min-content, so pre-wrap text wraps
into hundreds of lines (5684px 'balloon'); same inflates the empty/draft textarea (autoGrow->140=4 lines);
and translateX(100%) of a ~0-width pane = no travel = the 'jump'. The tall box hid in anonymous line
boxes (renderMsgBody emits bare text nodes), which is why the probe only saw the 84px timestamp span.
Fixes (all @media max-width:760px / chat-opening scoped, desktop untouched):
 1. .bubble: word-break:normal + overflow-wrap:break-word (min-content = longest word, never collapses)
 2. mobile bubble group: overflow-wrap:anywhere -> break-word
 3. .msg-link: word-break:break-word -> overflow-wrap:break-word (URL-only msgs can't reopen the hole)
 4. body.chat-opening .content: width:100vw -> definite width, no collapse frame, real slide travel
Build marker -> 2026-07-18-batch117.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 18:53:38 +05:30
Sravan 898d389dfe diag(mobile): probe composer measurements + transition animation + tall-bubble content
tallImg was no-img, so the balloon is a TEXT bubble stretching (even in a 17-msg chat). Add: composer/
textarea height+scrollHeight+rows+value+fontSize+lineHeight (why ~4 lines), whether chat-opening class +
.content animationName are applied (why transition jumps), and the tall bubble's text + tallest child.
Build marker -> 2026-07-18-batch116.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:59:58 +05:30
Sravan 7f7ff8ba1f fix(mobile): cap in-bubble image height (kills the stretch balloon) + sharper spike diagnostic
Spike log showed bubbles ballooning to 3576-5684px (an image loading at natural ~2778px height before
constraint). att-img is capped 240 but some image path wasn't — added .convo-msgs img/.bubble img
max-height:340px as a blanket cap so no message can stretch the thread. Enhanced spike report to name
the tall bubble's img (natural size, rendered height, computed max-height) to confirm.
Build marker -> 2026-07-18-batch115.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:34:31 +05:30
Sravan 676612a12b fix(mobile): disable zoom via viewport meta (not font-size) + reveal msgs during slide
- Revert font-size:16px !important (it made the composer ~4 lines tall). Disable iOS zoom with
  viewport maximum-scale=1,user-scalable=no instead -> composer keeps its size, AND double-tap-to-zoom
  (in-chat search arrows #3) is gone. touch-action:manipulation on controls as belt-and-suspenders.
  Custom lightbox pinch still works (it's transform-based, not browser zoom).
- Transition: show the newest message immediately (was hidden until images loaded -> empty pane slid in
  then popped = the 'jumping' feel). Pinning still prevents dance.
Build marker -> 2026-07-18-batch114.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:27:54 +05:30
Sravan 78ea36f7d0 polish(mobile): native-style parallax chat-open transition; diagnose scrollH balloon
- Transition: open now slides the whole .content pane in over the list with a subtle parallax + iOS
  easing (was an instant/panel-only slide). Close/back already parallax.
- scrollH blip: added a spike watcher that reports the tallest element (class + img src tail) when the
  thread's per-message height balloons, so we can see what stretches then settles.
Build marker -> 2026-07-18-batch113.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:13:39 +05:30
Sravan 2cadf90bc8 fix(mobile): lift composer by Capacitor Keyboard plugin height (resize:none, vv doesn't reflect kbd)
Device log (post-zoom-fix): keyboard open leaves clientHeight AND visualViewport at 926 -> vv gives 0 ->
composer covered. On resize:none the plugin's keyboardHeight is the only signal; now that zoom is gone
it's clean, so --kb = keyboardHeight lifts the composer flush, and keyboardWillShow at animation start +
the CSS bottom-transition makes it slide up WITH the keyboard (smooth). Config pinned to resize:none.
Build marker -> 2026-07-18-batch112.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:46:00 +05:30
Sravan 9f7561bf8b diag+fix(mobile): capture keyboard-open composer position; replay open slide transition
- Debug now reports curKb/compBottom/kbTop/coveredBy on keyboard events so I can see WHY the composer is
  covered (lift coming out 0) from the server log.
- Transition: force the bzConvoIn slide to replay on every chat open (CSS animation only auto-plays on a
  class change, so chat->chat looked instant). Restart via reflow + inline animation.
Build marker -> 2026-07-18-batch111.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:23:22 +05:30
Sravan bf8df593a1 fix(mobile): force input font-size 16px with !important (was overridden by .composer-row .92rem)
Device log confirmed zoom persisted (vvW:394,vvTop:74) because .composer-row textarea{font-size:.92rem}
out-specified the generic rule, keeping the composer <16px -> iOS still zoomed on focus. !important
forces 16px on all fields so the zoom never fires. (#7 latest + budge already verified fixed.)
Build marker -> 2026-07-18-batch110.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:58:41 +05:30
Sravan 0e38bd9229 fix(mobile): stop iOS input auto-zoom (font-size 16px) = real keyboard fix; robust pin-to-newest
Device log revealed the truth:
- Keyboard: focusing the <16px composer made iOS ZOOM the page (vvW 428->394, vvTop->74), scrambling
  every viewport/keyboard calc. Set all form fields to 16px on mobile -> no zoom -> keyboard math holds.
- Latest messages: on a 500-msg chat atBottom was false because late images grow the thread after the
  initial scroll. Now re-pin on a schedule + on each image load while near the bottom.
- Budge already gone (device reported ovf:false).
Build marker -> 2026-07-18-batch109.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:17:20 +05:30
Sravan f9d6472f77 chore(mobile): add /api/dbg diagnostics sink + client reporter (build, overflow, viewport, scroll)
Temporary: the app POSTs its real runtime state to the server log so device-only bugs (budge source,
keyboard viewport numbers, chat-open scroll state) can be diagnosed from docker logs instead of
screenshots. Removed once mobile issues settle.
Build marker -> 2026-07-18-batch108.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:18:58 +05:30
Sravan 720b1f0c40 fix(mobile): revert keyboard to live VisualViewport tracking (no timer), undo budge; add unit test
- Keyboard: the 60ms timer in batch106 sampled the viewport MID-animation (clientHeight/visualViewport
  temporarily inconsistent) and over-lifted -> gap. Reverted to reading clientHeight-visualViewport LIVE
  on every vv resize, which is correct and self-adapting (unit test: residual=46 flush on the device).
- Budge: reverted the file <input> back to display:none (the position:fixed;left:50% hack overflowed
  the viewport horizontally).
- Config back to resize:native to match the installed build (works, no rebuild).
- Added test/keyboard-lift.test.js (5/5 pass) verifying the lift math against real device numbers.
Build marker -> 2026-07-18-batch107.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:22:45 +05:30
Sravan 6e3cf95deb fix(mobile): mode-adaptive smooth keyboard + force-latest-scroll + bottom-anchored file input
- Keyboard: auto-detect resize mode. resize:none -> lift full keyboard height at willShow so the CSS
  transition slides the composer up WITH the keyboard (smooth, no iOS resize lag). resize:native ->
  residual only. Config set to resize:none for the smooth path (needs build); web stays correct on the
  current resize:native build meanwhile.
- #7: force scroll-to-bottom at 0/120/320/600ms on open so a chat always opens on the newest message.
- File picker: anchor the file <input> to a fixed bottom spot (was display:none -> iOS dropped the menu
  mid-screen); now it comes up as a bottom sheet even as the composer moves.
Build marker -> 2026-07-18-batch106.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 08:32:23 +05:30
Sravan c775ca2740 fix(mobile): lock in device-independent keyboard (VisualViewport residual), remove debug
Debug confirmed resize:native shrinks the WebView by the keyboard height, so the composer only needs the
residual lift = clientHeight - visualViewport.height (46px on the test device — the prediction-bar sliver).
Tracked live via VisualViewport so it follows the keyboard. Zero per-device constants. Removed debug bar;
reverted Keyboard config to resize:native (which the logic relies on).
Build marker -> 2026-07-18-batch105.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 08:11:39 +05:30
Sravan 8867d0abfc fix(mobile): keyboard via VisualViewport (device-independent) + temp debug readout
Replace the hardcoded factor with the real overlap = clientHeight - visualViewport.height (works on any
screen). Adds a temporary on-screen debug line to confirm which signal the device reports, so the final
logic needs no per-device tuning. Debug removed once confirmed.
Build marker -> 2026-07-18-batch104.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:25:10 +05:30
Sravan c5489d7ad6 fix(mobile): scale keyboard lift by measured 0.52 (plugin over-reports ~2x)
Measured from device screenshot: composer at ~678pt from bottom vs 351pt keyboard = 2x overshoot.
Scale the reported height by 0.52 so the composer sits flush on the keyboard. Single tunable const.
Build marker -> 2026-07-18-batch103.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:19:49 +05:30
Sravan 77a3ad455f fix(mobile): normalize keyboard height device-px -> points (was overshooting ~3x on 3x screens)
Root cause: the Keyboard plugin reports keyboardHeight in device pixels on this build; CSS needs points,
so the raw value overshot by the devicePixelRatio. Divide by DPR when the raw value exceeds the screen's
point height. Web-only.
Build marker -> 2026-07-18-batch102.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:13:57 +05:30
Sravan 233db2c140 fix(mobile): keyboard lift via VisualViewport (measured gap, exact) not plugin height
The Keyboard plugin's reported height overshot. With resize:none the WebView stays full-size, so
window.innerHeight - visualViewport.height is the EXACT keyboard overlap -> composer lands on the
keyboard. Web-only, works on the resize:none build already installed.
Build marker -> 2026-07-18-batch101.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:05:12 +05:30
Sravan 357f0168ba fix(mobile): keyboard resize:none + web-tunable JS lift (deterministic, no plugin fight)
resize:native gave contradictory results (covered vs header) because the safe-area plugin fights it.
Set resize:none so the native layer does nothing, and lift the composer purely in JS by keyboardHeight
* KB_LIFT. Once resize:none is built, the lift is web-tunable with zero further builds.
Build marker -> 2026-07-18-batch100.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:55:17 +05:30
Sravan 9bee2c635c fix(mobile): remove redundant --kb lift; keyboard uses resize:native alone (was double-lifting to header)
Isolated test showed 'too high' = resize:native already lifts the composer, so the JS --kb added a
second lift up to the header. Removed it. resize:native is the single mover now.
Build marker -> 2026-07-18-batch99.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:50:18 +05:30
Sravan f55af18e90 fix(mobile): deterministic keyboard lift via --kb (single mover, no rebuild needed)
batch92 proved resize:native is a no-op with the safe-area plugin, and batch97 turned off the
safe-area keyboard offset, so re-adding the JS --kb lift makes it the ONLY thing moving the composer
= it sits exactly on the keyboard. Works on the current installed build (web-only).
Build marker -> 2026-07-18-batch98.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:45:06 +05:30
Sravan cc2a76450f fix(mobile): native keyboard (remove JS hack), open at latest + no double-render dance, splash cfg
- #3 keyboard: the overshoot proved resize:native DOES lift the composer; my --kb JS was a second lift.
  Removed the JS hack entirely; keyboard is now purely @capacitor/keyboard resize:native. Config: turn
  OFF safe-area offsetForKeyboardInsetBug so only one mechanism moves the view (needs the build).
- #7: gate the open-time hide/reveal with _convoRevealed so the cache->network double render doesn't
  flash/dance; on mobile open at the LATEST message (skip auto-scroll to first-unread).
- #6: add SplashScreen config (bg #16294F, no spinner) — asset gen already produces the logo.
Build marker -> 2026-07-18-batch97.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:17:58 +05:30
Sravan 763b2996d1 fix(mobile): revert app-breaking interactive edge-back (iOS touchcancel left UI stuck)
The finger-follow edge gesture starting at the very left edge collided with iOS's own edge-swipe,
which fires touchcancel instead of touchend, so cleanup never ran and .content stayed transformed +
chat-dragging stuck on top -> bottom nav hidden and all clicks blocked. Back to the safe release-based
swipe (still gets showWelcome's parallax slide). Keyboard --kb inset, #7 no-dance, #8 image swipe kept.
Build marker -> 2026-07-17-batch96.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:58:42 +05:30
Sravan 3393e44691 fix(mobile): JS-driven keyboard inset, interactive edge-back, finger-follow image swipe, no-dance
- #3 keyboard (was B: chat didn't move, keyboard covered composer): native resize wasn't moving our
  fixed layout, so drive it from JS — the Keyboard plugin reports its height, we expose --kb and lift the
  composer to sit exactly on the keyboard; keyboard config -> resize:none so native won't double-adjust.
- #4: the left-edge back is now INTERACTIVE — the conversation follows the finger with the list revealed
  underneath (parallax), commit past 1/3 width else spring back (like Teams). Falls back to release-based
  bzcBack for popups/search.
- #8: the image now follows the finger sideways (and down) during the swipe, not just on release.
- #7: hold the thread hidden until its images load (max 700ms) then reveal at the bottom — no dance.
Build marker -> 2026-07-17-batch95.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:11:54 +05:30
Sravan f2e2e8b08a fix(mobile): parallax slide-back, swipe images left/right, glue thread while images load
- #4: on back, reveal the chat list UNDERNEATH with a parallax slide-in while the conversation slides
  off to the right (slide the whole .content, list forced visible beneath) — matches the Teams push/pop.
- #8: swipe left/right in the image viewer flips to the prev/next image (not just swipe-down to close).
- #7: re-pin to the bottom as each image finishes loading (when near bottom), so late images don't
  reflow/'dance' the thread.
Build marker -> 2026-07-17-batch94.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:07:42 +05:30
Sravan e29810ea2a fix(mobile): un-clip popups, swipe-to-reply, swipe-down-close image, no msg dance, slide-back
Addresses reported iOS observations (web-side, no rebuild):
- #1: the overflow-x:hidden I added to stop sideways-scroll was clipping the profile/bell popup menus;
  now only the chat-list scroller is clipped and long content wraps instead of widening the page.
- #9: swipe a message bubble right to reply (WhatsApp/Teams style).
- #8: swipe an opened image down to dismiss the lightbox.
- #7: hide the thread for one frame on open so messages don't visibly scroll/'dance' into place.
- #4: slide the outgoing conversation off-screen on back (inline transform, reliable across WebKit).
Build marker -> 2026-07-17-batch93.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:34:15 +05:30
Sravan 13b0f910ab ci(ios): set CFBundleVersion to Codemagic BUILD_NUMBER so each upload is unique
App Store Connect rejected the upload: 'The bundle version must be higher than the
previously uploaded version: 1'. Capacitor defaults CFBundleVersion to 1, so every
build collided with the first upload. Patch it to the monotonic BUILD_NUMBER.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:09:51 +05:30
Sravan 2ce7ec72fb fix(ios): PERMANENT native safe-area + keyboard via Capacitor 7 + safe-area plugin
Device screenshots proved the WebView reports env(safe-area-inset-*)=0, so no web/CSS/JS trick can
move content off the Dynamic Island — it must be fixed natively. Upgrade the Capacitor shell to v7 and
add @capacitor-community/safe-area (populates env() with real per-device insets, edge-to-edge) and
@capacitor/keyboard (resize:native so the composer sits directly on the keyboard, like Teams).

- mobile: Capacitor 6 -> 7 for all plugins; add safe-area + keyboard; SafeArea/Keyboard config.
- codemagic: npm ci -> npm install (dependency set changed); drop stale Cap6 lockfile.
- web: remove the temporary StatusBar/34px hacks (env() now works); keep var(--sat)/var(--sab) padding.
- share/connect: back link now respects env(safe-area-inset-top/left).
- add native-style slide in/out (push/pop) animation when opening/closing a chat on mobile.
Build marker -> 2026-07-17-batch92.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:36:14 +05:30
Sravan a609bfd2e0 fix(ios): status-bar inset via StatusBar plugin, hide nav in open chat, bulletproof h-scroll
Root cause from device screenshots: the iOS Capacitor WebView reports env(safe-area-inset-*)=0,
so CSS padding did nothing and content slid under the Dynamic Island (every page: home/share/
connect/login) and the bottom nav was clipped.
- Use the already-installed StatusBar plugin: setOverlaysWebView(false) pushes content below the
  status bar natively (no rebuild). Applied on all entry pages via a small native-chrome snippet.
- Hard-code the bottom home-indicator inset (--sab:34px on html.native-ios) since env() can't give it.
- #2 keyboard: hide the bottom tab bar inside an open conversation so the composer sits directly on
  the keyboard (was sandwiched between composer and keyboard).
- #3 h-scroll: clip every scroll surface + overflow-wrap:anywhere so long content can't widen the page.
Build marker -> 2026-07-17-batch91.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:19:37 +05:30
Sravan 95e13a707e fix(ios/mobile): safe-area insets, no keyboard auto-open, no h-scroll, swipe-back, hide desktop-download
Reported on the iOS TestFlight build:
- #1/#5 notch/home-indicator: apply top safe-area inset per surface (chat list column +
  content panels) instead of on the whole shell, so headers sit below the Dynamic Island
  and nothing is cut off top/bottom.
- #2: opening a chat no longer auto-focuses the composer (was popping the phone keyboard
  and shoving the layout up); tapping the box still focuses normally.
- #3: overflow-x:hidden + max-width:100vw guards so pages can't pan left/right.
- #4: left-edge swipe-right now triggers the existing bzcBack() (close popup/search/open
  chat) — a native-feeling back gesture with no native code.
- #6: hide the 'Download app' (Windows) button when running inside the Capacitor app.
Build marker -> 2026-07-17-batch90.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:44:12 +05:30
Sravan 2e4e0210b0 ci(ios): internal-testing-only publishing + declare encryption exemption
The IPA now builds, signs, uploads and processes on App Store Connect. Set
submit_to_testflight=false so the build stays green via internal testing; external
beta review (which needs Test Information + a demo login) is opt-in later. Also set
ITSAppUsesNonExemptEncryption=false in Info.plist so ASC stops prompting for export
compliance on every upload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:02:28 +05:30
Sravan 1ffc69469b ci(ios): pass a fixed CERTIFICATE_PRIVATE_KEY so the distribution cert is reusable
fetch-signing-files --create alone mints a throwaway distribution certificate whose
private key dies with the build VM; the next build then finds a cert it has no key
for and fails 'Cannot save Signing Certificates without certificate private key'.
Supplying our own fixed private key (secure var in the ios_signing group) makes the
cert reproducible and reusable across builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:47:28 +05:30
Sravan 84315ccfb4 ci(ios): run xcode-project use-profiles after pod install, not before
The archive failed with 'App requires a provisioning profile' because use-profiles
ran inside the signing step before pod install created the workspace, so the
profile never bound to the App target. Move it to just before build-ipa, matching
Codemagic's Capacitor recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:39:01 +05:30
Sravan 054b0bb70f ci(ios): remove ios_signing auto-fetch block so first build creates its own signing profile
The environment.ios_signing block makes Codemagic fetch an EXISTING profile at
build init and fails with 'No matching profiles found' on a brand-new app. The
'Set up code signing' script already creates the cert+profile via
fetch-signing-files --create, so the block was both redundant and blocking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:30:11 +05:30
Sravan c18e55b5ec ci: remove hard-coded personal email from codemagic.yaml (watch builds on the dashboard) 2026-07-16 18:09:26 +05:30
Sravan 42e0122504 docs(ci): how to connect Codemagic to self-hosted Gitea over SSH (expose port + read-only deploy key) 2026-07-16 15:46:22 +05:30
Sravan 6b5ca687e4 docs(ios): track ReplayKit screen-broadcast + native audio-routing as phase-2 follow-ups
iOS WebViews don't support getDisplayMedia, so sharing the device screen into a meeting needs a
native ReplayKit Broadcast Upload Extension (+ App Groups) — viewing a shared screen works today.
Also note native audio routing (AVAudioSession) as a phase-2 plugin. Adjusted the App Review note
so we don't advertise iOS screen-sharing before that extension ships.
2026-07-16 14:47:28 +05:30
Sravan 3b09702593 docs(ios): add App ID registration step (fields + Push Notifications capability) 2026-07-16 14:34:46 +05:30
Sravan bc909cb0c0 build(ios): Codemagic pipeline + runbook for App Store (no Mac needed)
The iOS app is a Capacitor shell over the live web UI — the web/server side is already fully
Capacitor-ready (nativePlatform() detects Capacitor; setupNativePush registers APNs tokens via
/api/v1/devices; the APNs sender is built into server/push.js, config-gated). So this adds only
the build/sign/upload path:

- codemagic.yaml: macOS-cloud workflow that generates the iOS project, patches Info.plist,
  generates icons/splash, signs via an App Store Connect API key (automatic signing), archives,
  and uploads to TestFlight. No Mac required.
- mobile/scripts/ios-patch.sh: adds the App-Review privacy usage strings (camera/mic/photos) +
  display name to the generated Info.plist.
- mobile/IOS_SETUP.md: click-by-click runbook — ASC app record, API key, Codemagic integration,
  first build, APNs key → server .env, and the public-submission checklist.

Bundle id com.bizgaze.connect. No secrets committed — Apple keys live in Codemagic + server .env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:45:10 +05:30
Sravan c8f383cbcb fix(gif): send a GIF as the active reply; show GIF (not URL) in reply bar/quote (batch89)
sendGif ignored replyTarget, so picking a GIF while replying sent a plain message instead of a
reply. It now carries replyTo and clears the reply, like sendMessage. Reply bar + quote show
'GIF' instead of the raw media URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:54:31 +05:30
Sravan a172214c44 fix(gif): fixed-height grid rows so GIFs stop stacking over each other (batch88)
aspect-ratio:1 on the grid cells collapsed to ~0 height in the emoji-pop flex column, so the
images overflowed and overlapped. Use grid-auto-rows + a fixed cell height instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:46:05 +05:30
Sravan 02075fbd47 feat(chat): GIF picker via server-proxied GIPHY (#5) (batch87)
- Server: GET /api/gifs proxies GIPHY search/trending. The API key is read from the server
  env only (config.GIPHY_API_KEY, from the gitignored .env) and NEVER reaches the browser;
  the picker is hidden when it isn't configured.
- Emoji picker gains a GIF tab (separated from the emoji categories) with a search box + a 2-col
  grid, "Powered by GIPHY" attribution. Clicking a GIF sends it immediately.
- GIFs are HOTLINKED to GIPHY's CDN (their terms require this — no re-hosting): the message body
  is the GIF url, and a body that is a lone GIF url renders inline as the animated GIF (reusing
  the image/lightbox path). Sidebar previews + notifications show "🎞️ GIF", not the raw url.

Key is NOT in git — set as GIPHY_API_KEY in the server .env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:30:44 +05:30
Sravan c3dc47a94c fix(meetings): Past shows only scheduled + instant meetings; ad-hoc calls hidden w/o recording (batch86)
Revised rule per user: a call is not a meeting. Past meetings now = real SCHEDULED meetings +
INSTANT meetings (Start-a-meeting, logged in call_history as adhoc). Ad-hoc CALLS — 1:1 direct,
group calls, and a 1:1 that a 3rd person joined — are shown ONLY if they produced a
recording/transcript. Dropped the earlier ">2 participants shows it" exception.
Also stop rendering a bare "Host: —" on logged call cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:58:48 +05:30
Sravan f708cc2f12 fix(meetings): hide empty 1:1 direct calls; date from/to validation + field alignment (batch85)
#3 The 136 "Direct Call" cards: calls.js writes a scheduled_meetings row for EVERY call
   ("Direct Call" / "Group call"), which is separate from the call_history table. Those rows
   came through the scheduled-meetings path and my earlier filter never touched them. Now an
   auto call-history row is shown in Past ONLY if it produced a recording/transcript OR the
   call ever held >2 people (peak from the call log); plain 1:1s with neither are dropped.
   Removed the now-redundant callRows synthesis. Recordings stay attached (rows WITH a
   recording are always kept).
#4 Date range: from ≤ to enforced by disabling out-of-range days in each picker (can't pick a
   from after to, or a to before from). Filter controls share one height/baseline so the
   calendar icon, preset dropdown and date fields align cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:19:12 +05:30
Sravan fad8a52da4 fix: last-seen backfill, report-style date filter, hover overflow (batch84)
#1 Last seen still read "Offline": last_seen is a NEW column, so every existing user was NULL
   until they happened to reconnect. Backfill it from the newest message each user sent (best
   evidence we already have), and also stamp it on message send — not just on connect/disconnect
   — so it stays fresh if a socket never closes cleanly.
#3 Past-meetings filter is a report filter now: presets (Today, Yesterday, Last 7/30 days, This
   month, Year to date, All time) plus a Custom range that uses the SAME branded calendar popup
   as the scheduler. The raw <input type=date> looked foreign and behaved differently per browser.
#4 Hover action row overflowed off-panel on SHORT received messages: it's wider than the bubble
   and was anchored to the bubble's right edge, so it ran off the left. Received bubbles now
   anchor it from the left (growing into the empty space); own messages keep the right anchor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:02:59 +05:30
Sravan 6fa261b68f feat(meetings): call log, past pagination + date filter; last-seen exact time (batch83)
#7 The past list was hard-capped at 12 (a .slice(0,12) in the client) and there was NO record
   of how many people were ever in a finished call — so the "only calls with >2 people" rule
   was impossible to apply. Added a call_history table: signaling tracks the HIGH-WATER
   participant count per room and logs the call when the room tears down (scheduled meetings
   are skipped — they already have their own row).
   Past meetings now follow the rules asked for:
     • a plain 1:1 direct call is NOT listed — unless it produced a recording/transcript
       (those already surface as recording entries);
     • a call that ever held MORE than 2 people IS listed (e.g. a 1:1 a third person joined),
       showing its participant count and duration;
     • entries are visible only to people who were actually in the call (or the group).
   Server-side pagination (10/page) + a from/to date filter; nothing is double-listed.

#2 Last seen now shows the exact time/date, WhatsApp-style — "last seen today at 1:36 PM",
   "last seen yesterday at 10:15 AM", "last seen 14/07/2026 at 9:00 AM" — instead of "10
   minutes ago".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:42:53 +05:30
Sravan 860a7bd6cf feat(chat): emoji speed, action rework, copy, phone links, image zoom, upload %, profile card, last seen (batch82)
#6 Emoji were slow because Twemoji swapped EVERY emoji for an <img> fetched individually
   from a CDN — opening the picker fired hundreds of image requests. Now uses the OS's own
   colour emoji font: instant, zero network. twemojify() kept as a no-op.
#4 Message hover row reworked: three one-tap reactions (Like/Laugh/Surprised) + the emoji
   picker + (own) Edit. Reply / Forward / Copy / Delete moved behind a ⋮ menu. Added Copy.
   The row now sits FULLY above the bubble (was top:-14px, overlapping the first text line).
#9 Phone numbers linkify to tel: — mobile gets the OS "call this number?" prompt; desktop has
   no dialer so it offers to copy. Regex kept conservative (10–15 digits, needs +/grouping) so
   it won't grab amounts, dates or 6-digit meeting codes.
#3 Image preview zooms: wheel + pinch + double-click + ± buttons, drag to pan, keys (+/-/0),
   cursor-anchored. Arrows hide while zoomed so panning isn't hijacked.
#8 Upload progress: fetch() can't report upload progress at all, so a large file just said
   "uploading…". Switched to XHR (upload.onprogress) → real bar + %, and cancel aborts in flight.
#1 Clicking a sender in a group opens a mini profile card (photo, presence, last seen) with a
   Message button that opens the 1:1 (and a view-photo button).
#2 Last seen: new users.last_seen column, stamped on connect and when the last socket drops;
   carried on the presence broadcast, so an offline contact reads "Last seen 10 minutes ago"
   instead of a bare "Offline".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:52 +05:30
Sravan 667b4c69d5 perf(update-check): throttle + single-flight the build check (batch81)
The check itself is cheap (one ~35-byte response, served from a variable the server reads
once at startup), but it was hooked to BOTH `focus` and `visibilitychange` with no throttle —
and a single alt-tab back into the app fires both, so every refocus cost two redundant
requests. Now: one check per minute at most, never overlapping itself, and it stops checking
entirely once a new build is known (the retry loop takes over).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:14:50 +05:30
Sravan c1f01e796a fix: DP missing in 1:1 for un-messaged contacts; make web updates fully silent (batch80)
DP bug — real cause found (client-side, not the DB):
loadSidebar builds a DM row for every contact you haven't messaged yet, but it copied only
{name, online} from the contact and DROPPED `avatar` (and status/email). So an un-messaged
contact always rendered initials in the 1:1, while the SAME person showed their photo in a
group (which reads /api/groups/members). Carry the whole contact through.
Kept a server-side safety net: avatarsFor() now indexes known photos under person-id, email
AND name, so a duplicate row missing a photo can match on any of them (the previous single
composite key missed twins with different emails).

Web updates are now completely silent: no banner, no toast. A web build is an implementation
detail — surfacing it makes users reason about "web build vs app version", which is exactly
the confusion to avoid. New code simply applies itself as soon as it's safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:06:28 +05:30
Sravan 2aeeb0a096 fix: auto-apply new web builds; DP missing in DM but shown in group (batch79)
Auto-update (no manual step, no version confusion):
- New code now applies ITSELF. The client polls /api/build and, as soon as it's SAFE,
  silently hard-reloads onto the new build. Safe = not in a call, no live screen session,
  no dialog open, nothing half-typed; if the user is busy we wait and apply the moment
  they're free. A brief "Updated to the latest version" toast confirms it.
- Removed the "web build" row from Settings: users must never have to reason about an app
  version vs a web build. The only version surfaced is the desktop app's (auto-updater).

DP bug: a contact showed their photo in a GROUP but fell back to initials in the 1:1.
Two causes, both handled:
- Duplicate rows for one person (signed in by email once and by mobile another time before
  the bizgaze_user_id merge landed) — only one row carries the DP, and the group happened to
  reference the row WITH the photo. avatarsFor() now keys rows by stable person identity
  (bizgaze person id → email → name) so a photo-less row borrows its twin's photo. Applied to
  contacts, conversations, group members and group info.
- A DM whose counterparty was merged away is now keyed by the SURVIVING account, so the row
  carries that account's name/photo/presence (and split threads collapse into one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:34:35 +05:30
Sravan a0017f2036 feat: detect new web builds + hard-refresh escape hatch (0.1.17/batch78)
Root cause of "the fix works on mobile but not on desktop/web": nothing was wrong with
the code — the desktop app now CLOSES TO TRAY, so it can run for weeks on the page it
loaded on day one and never re-fetch after a deploy. There was also no way to force it
off a stale page (no menu bar → no reload accelerator).

- Server: GET /api/build returns home.html's __BUILD marker.
- Client: polls it (boot, on focus/visibility, every 10 min); when the server's build
  differs from the running one, shows a branded "A new version is available — Refresh"
  banner. Settings gains an always-available "Refresh app" with the current build shown.
- hardReloadApp(): in the browser it unregisters service workers + clears CacheStorage
  then reloads cache-busted; in the shell it calls the native hard reload.
- Desktop: hard-reload IPC (clears the session HTTP cache + reloadIgnoringCache), wired to
  Ctrl+R (reload), Ctrl+Shift+R / F5 (hard reload), and a "Refresh app (get latest)" tray
  item. Previously there was literally no way to clear the cache from the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:38:55 +05:30
Sravan 5a138f5bc4 fix(meetings/mobile): PTR, ringing tile, responsive bar, ⋮ toggle, add-people search (batch77)
#6 Pull-to-refresh genuinely broken: the indicator lived INSIDE the scroller (so it
   scrolled away unseen) and only became visible after ~96px while the trigger fired at
   70 — and preventDefault was deferred until the pull passed 10px, by which point
   iOS/Chrome had already claimed the gesture. Indicator now mounts on the list's
   non-scrolling parent with the branded loader, the gesture is claimed immediately, and
   the trigger is reachable. Works on Android + iOS.
#4 ⋮ More now TOGGLES (it used to remove then instantly rebuild, so it never closed).
#3 "Ringing…" is centred over the tile with the avatar/name dimmed behind it.
#2 Meeting bar fits the screen on mobile: one row of controls sized to the viewport,
   room code on its own line, safe-area padding.
#5 Search box in the in-call Add-people tab.
#1 Speaker control: setSinkId (the only web API for audio output) is NOT implemented in
   Android Chrome or iOS Safari — the OS owns the route there, and iOS forces loudspeaker
   whenever a mic track is live. Rather than ship a button that silently does nothing, it
   now only renders where output switching actually works. Real speaker/earpiece/Bluetooth
   switching on phones needs the native (Capacitor) audio plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:35:01 +05:30
Sravan e8c0b1889f fix: off-state colors, mobile audio route, iOS rail/PTR, in-place join, draggable bars (0.1.16/batch76)
Regressions I introduced, now fixed:
#8d Mic/Camera OFF went DIM — my new `.meet-ic.off` rule overrode the original RED.
    Scoped the lite style to the speaker button only; mic/cam OFF are red again.
#7  Guest "left the meeting" was unstyled (dark text on blue, no card): the card CSS was
    scoped to .guest-prejoin only. Shared with .guest-left. Rejoin no longer dead-ends —
    if the room is gone we say "This meeting has ended" and hide the button.

Reported:
#8d Speaker button no longer shows on desktop (devices already live under the mic ▾).
#8c/#8e Mobile speaker button now CYCLES Speaker → Earpiece → Bluetooth (icon follows the
    route); dropped the redundant "Audio devices" entry from the ⋮ menu.
#8a iOS gap under the bottom rail: the page rubber-band-bounced, exposing background beneath
    the fixed bar. overscroll-behavior:none + fixed body pins it.
#8b Pull-to-refresh now fires on iOS too (overscroll-behavior:contain on the lists so Safari's
    rubber band stops swallowing the gesture; scrollTop>2 tolerance for momentum).
#5  A meeting link no longer opens a whole new window: same-origin urls navigate the main
    window in the shell, and a link clicked in chat joins the meeting IN PLACE.

New observations:
1. The viewer's mic now starts MUTED on a screen session (and can actually be un/muted).
2. iOS lightbox close/download buttons moved below the Dynamic Island (safe-area insets).
3. Meeting chat: recipient picker moved to the BOTTOM next to the input; a private message
   auto-targets your reply back to that person; private vs everyone are visibly different
   (brand amber vs blue/neutral) and the compose area tints in private mode.
4. Meeting bar + screen-session bars are DRAGGABLE (position remembered) so they stop
   covering the shared screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:24:36 +05:30
Sravan 48b6a4050a fix: SFU participants, duplicate peers, RC without login, devices, mobile bar (batch75)
#4 Participants list only ever showed "you": in SFU mode meetMakePeer drew a tile but
   NEVER added to meetPeers, so the roster (and the meeting-chat "To" list) was empty.
#5 Same person as several tiles: each tab/reload/reconnect gets a new peerId, leaving
   ghost peers. One uid = one tile now (dropDupPeers keeps the newest).
#1 Remote control needed a login: /share opened top-level (the no-login "Share my
   screen") has the preload bridge RIGHT THERE, but we only postMessage'd to a parent
   frame that doesn't exist — so control never armed. Use the native bridge directly.
#2 Sharer's session bar moved to the shared Lucide icon set (was old inline SVGs).
#3/#6 Device menu: collapse Windows' "Default -"/"Communications -" triplicates into one
   clean list (single "System default" + each real device once), and actually re-apply the
   saved mic/speaker to the room on connect so a picked headset/Bluetooth is used.
#7 Guest "left the meeting" screen rebranded to match the new pre-join.
#9 Tapping a member in group info opens the private conversation with them.
#8a iOS bottom rail: border-box + height incl. the home-indicator inset, px env() fallbacks
   (unitless 0 breaks calc() in Safari), promoted layer so it stops drifting.
#8c/#8e Mobile meeting bar: Mic / Camera / Speaker / End only; screen, record, transcript,
   chat, participants move behind a ⋮ More menu. No mic device dropdown on mobile.
#8d Speaker button reflects the real route: speaker-on / speaker-off (dimmed) / bluetooth
   when a headset is in use; re-detected on devicechange.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:39:05 +05:30
Sravan 94b8fac32f fix: guest pre-join, post-admit lobby, audio device menu, RC keyboard (0.1.15/batch74)
1. Guest pre-join redesigned: brand backdrop, live camera preview, mic/cam toggles
   applied on entry, initials avatar, name required, "host may admit you" note.
2. Post-admit bug: the guest stayed on "Waiting for the host…" forever — the lobby
   screen had replaced the call UI and meeting-joined never re-rendered it. Now it
   rebuilds the call on admission (_inLobby).
3. Audio devices: dropped the standalone headphones button. The MIC now has a ▾ caret
   opening one Teams-style menu with Speaker + Microphone sections (radio-selected);
   speaker uses setSinkId/LiveKit switchActiveDevice, mic switches the live input.
   Mobile gets a speakerphone toggle that prefers a connected BT/headset when off.
4. Remote-control keyboard:
   - Injector now maps the PHYSICAL key (KeyboardEvent.code) instead of the character,
     so Shift+1 types "!" etc. Character mapping was why typing "performed differently".
   - Keys reach the sharer ONLY while control is ENGAGED (window focused AND you clicked
     their screen). Minimised/unfocused/chat typing stays local. Esc or clicking away
     releases; modifiers are released on disengage so nothing sticks.
   - Explicit control icons: viewer gets a Control ON/OFF button (green when engaged) +
     an on-screen hint; the SHARER gets a control icon beside mic/chat to allow/stop
     access at a glance, synced with the consent dialog and banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:18:01 +05:30
Sravan 82fa4a24d3 feat(meetings): in-meeting chat — Everyone or direct-to-participant, brand-styled (batch73)
Adds an ephemeral chat inside meetings (relayed over the meeting signaling socket,
not persisted). A chat button in the meeting bar (with an unread badge) opens a
brand-styled panel: pick "Everyone" or a specific participant (private), send with
Enter/Send. Direct messages are marked private on both sides. Works for guests too.
Chat state resets when you leave the call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:08:24 +05:30
Sravan d0f9355553 feat: close-to-tray, meeting lobby, speaker select, link expiry, mobile RC touch (0.1.14/batch72)
General #1/#2 (closed-app notifications): the desktop app now CLOSES TO TRAY instead
of quitting, keeping its chat WebSocket alive so calls/messages still notify. Tray
icon + menu (Open / Quit), single-instance lock, first-close hint.

Guest #4 (lobby/admit): meetings can require the host to admit guests joining by link.
Setting on the schedule form ("Guests must be admitted by the host", default on) +
ad-hoc default. Guests wait on a "waiting to be let in" screen; the host gets an
Admit/Deny prompt; auto-cleanup on leave. Logged-in members always join directly.

Guest #5 (speaker): headphones/speaker output picker in the meeting (setSinkId),
remembered and applied to every tile.

Guest #3 (link expiry): guest link/token dies ~2h after a scheduled meeting's end
(HTTP 410) with a clear message; live-room links expire when the room empties.

RC #4 (mobile): touch→mouse mapping so a phone/tablet viewer can control (tap=click,
drag=move). Uses the same letterbox-correct coordinate mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:44:58 +05:30
Sravan bd488b3286 fix(rc+guest): coordinate/keyboard/UI for remote control; guest media + panel fixes (batch71)
Remote control (connect.html viewer):
- Coordinate offset fixed: map clicks to the actual video CONTENT rect (object-fit
  letterbox-aware), not the element rect — cursor now lands where you click.
- Keyboard: capture at document level while a session is live (the <video> lost
  focus on bar clicks → keys did nothing). Skips the chat input.
- Control bar moved to bottom-right with tiny modern icons; video fills the viewport.
- Smoother cursor (mousemove ~60/s).

Guest meetings:
- Guest mic inaudible + guest invisible in the participant list + outsider screen
  share not showing (#1/#8/#11): root cause was the SFU media→tile map keyed on
  identity==uid, but guests had a random LiveKit identity and a null signaling uid.
  Guests now carry ONE stable id across signaling (meeting-join guestId) and the
  LiveKit token identity, so their media attaches and they appear to everyone.
- Guests can't add participants (#9) and don't see the transcript button (#12).
- Search box in the schedule participant list (#10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:28:37 +05:30
Sravan e562099344 feat(remote-control): viewer can control a desktop sharer's screen, with consent (0.1.13/batch70)
Fixes the core "viewer can't control the sharer even on desktop" gap. Three root
causes addressed:
- share.html DISCARDED every input-channel message (onmessage=()=>{}). It now
  parses the viewer's mouse/keyboard events and forwards them to the desktop shell.
- The main desktop app had NO OS injector (it lived only in the separate agent).
  Ported the nut-js injector (agent/input/inject.js) into desktop/input, wired an
  inject IPC + injectInput bridge, HARD-gated behind a consent flag (rcArmed).
- /share runs in an iframe (no direct bridge access) → it postMessages input to
  the top frame (home.html), which relays to the native bridge.

Consent + safety: the sharer sees an Allow/Deny prompt the first time the agent
interacts; while active a persistent "your screen is being controlled — Stop"
banner; instant revoke; auto-release on session end/teardown. Browser sharers stay
view-only (no OS injection possible). nut-js is an optionalDependency (N-API, ABI-
stable across Electron) — degrades to no-op if the native module is unavailable.
Windows-first; maps to the primary display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:30:39 +05:30
Sravan 7bc40d8397 feat(meetings): email invites + add external participants by email + share link (batch69)
#4: scheduled meetings can now invite people who aren't on Connect.
- SMTP config (config.js, env-gated: SMTP_HOST/PORT/USER/PASS/FROM/SECURE,
  PUBLIC_BASE_URL) + a small nodemailer wrapper (mailer.js) with a branded
  meeting-invite template carrying the guest join link. No-op until SMTP is set.
- /api/meetings/schedule + /update accept participantEmails; external emails are
  persisted (scheduled_meetings.guest_emails migration) and emailed the guest
  link (plus any invited Connect users with an email on file). Fire-and-forget —
  a mail outage never fails scheduling.
- Meetings list DTO returns `link`; schedule form gains an "Invite by email"
  chip input; each scheduled-meeting card gets a "Copy link" action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:02:45 +05:30
Sravan 466c0d5d70 feat(meetings): guest join by link — external people can join without a Connect account (batch68)
- Server: POST /api/meetings/guest-token issues an unauthenticated LiveKit token
  for a room that is currently live or a valid scheduled meeting (throwaway guest
  identity). Mesh signaling already tolerates anonymous peers.
- Client: opening /home?meet=CODE while signed out runs a lightweight GUEST mode —
  name prompt, then straight into the meeting with the full meeting client and the
  chat/sidebar chrome hidden. Signed-in users who open the link auto-join.
- sfuConnect uses the guest token for guests. Guests get a friendly "left the
  meeting / rejoin" screen (no chat to fall back to).
- Meeting "Add people" panel gains a "Copy invite link" (copyMeetingLink) that
  copies the guest link — foundation for the emailed invites in #4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:44:28 +05:30
Sravan 9b86def921 feat(search): only surface Connect users; clear "not on Connect"/"not found" (batch67)
Contact search no longer lists BizGaze directory people who aren't on Connect as
addable rows (they can't be messaged). It shows only people actually on Connect;
if a search matches nobody on Connect it shows either "Not on Connect" (they exist
on BizGaze but haven't signed in) or "Contact not found".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:36:18 +05:30
Sravan aa4bb2902d fix: group toast icon, left-click spell suggestions, scroll no-yank (0.1.12/batch66)
1. Group message toast had no icon: the generated group icon is a data: URL, but
   the 0.1.11 handler only used ALREADY-cached icons and skipped it. data: URLs
   are synchronous — use them immediately, so every group toast shows an icon.
2. Spell suggestions on a LEFT click: clicking a red-squiggled word in the message
   box now pops the native suggestions menu (renderer asks the shell to synthesize
   a right-click at that point → real dictionary suggestions). No right-click.
3. Scroll felt "stuck" because an incoming message yanked you to the bottom even
   when you'd scrolled up to read history (worse now that bg messages arrive live).
   appendBubble now keeps your position unless you were at the bottom / it's your
   own message, and surfaces the "jump to latest" control instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:57:02 +05:30
Sravan 1dfb6b16cb fix: unstick scrolling, group toast icon, seen separator, autocorrect (batch65)
1. Scroll felt "stuck": the seen-by card had TWO nested scroll containers
   (.modal overflow-y:auto + inner .seen-scroll) fighting each other — card is
   now overflow:hidden so only the inner list scrolls. Global scrollbar widened
   6→11px, dropped the inset border, added a min thumb length so it's easy to
   grab and drags smoothly everywhere.
2. Group message toast now carries a generated group icon (initials on a brand
   disc) when the group has no photo — was iconless.
3. Seen / Not-seen split by a visual divider in the message-info popup.
4. Auto-correct: curated common-typo dictionary applied as you type (on a word
   boundary) — corrections happen automatically, no right-click. Web/desktop/mobile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:56:18 +05:30
Sravan adafc8c960 fix: seen-by layout, group-name notif, teams-style badge, excel paste, spellcheck (0.1.11/batch64)
1. Message-info popup: ONE scroll region for the whole list with sticky
   Seen / Not-seen headers (was a separate scrollbar per section), realigned.
2. Group message notifications now title = GROUP name, body = "Sender: text".
3. Unread taskbar badge: solid red rounded-square (Teams-style) + white outline
   instead of the gradient disc.
4. Pasting Excel/Sheets cells no longer uploads a screenshot — when the clipboard
   carries real text, the text is pasted; image-only clipboards still upload.
5. Spell check in the message box (red squiggles) with a right-click menu of
   corrections + add-to-dictionary; spellcheck enabled on the textarea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:25:37 +05:30
Sravan 70a776fa6c fix: real-time bg messages, join clears invite, richer seen-by, nicer badge (0.1.10/batch63)
1. Notifications/messages lagged when the desktop window was backgrounded —
   Electron throttles hidden renderers by default, stalling the chat WS's
   onmessage + timers. Set backgroundThrottling:false → real-time in the tray.
   (Transport is WebSocket; no Firebase needed.)
2. Joining a call via the header Join button left the Join/Decline invite popup
   on screen — enterMeeting now dismisses the invite (and stops the ring).
3. Seen-by is now "Message info": real contact DPs, a "Not seen yet" list for
   groups, and dark lettering (white-on-pastel initials were invisible).
4. App-icon unread badge: red→pink gradient pill with a white ring + shadow,
   drawn at 2x, instead of a flat red disc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:51:14 +05:30
Sravan 62a5f750af fix(desktop): notification click no longer reloads to a dead page (0.1.9/batch62)
Root cause of the persistent "notification opens a dead page / no Join" bug:
openFromNotif gated the in-place open on `window.ME`, but ME is declared with
`let` — which never creates a global property — so window.ME was ALWAYS
undefined and every click hit the full-page-reload fallback. Use bare `ME`.

Also:
- Notifications fire instantly: never block on the DP download. Use the photo
  only if already cached; warm the cache in the background + pre-warm all
  contact DPs on chat load (precache-avatars IPC). Removes the ~2.5s lag.
- Wire call Join/Decline handlers BEFORE firing the OS notification so Join is
  live the instant the invite popup appears (was dead until the toast settled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:00:56 +05:30
Sravan 6ed0fa0ea0 fix(desktop): custom screen picker, single restart prompt, reliable call Join (0.1.8/batch61)
- Custom branded screen/window picker (picker.html) — useSystemPicker was
  silently no-op on Win11 and auto-shared the primary display with no choice.
- Use checkForUpdates (not ...AndNotify): drops electron-updater's own native
  "Update ready" toast that duplicated our in-app banner (two restart prompts).
- Call notification DP: wait up to 2.5s for the caller photo on persistent
  (call) toasts instead of 600ms so the DP actually shows.
- Re-surface Join/Decline invite when opening a chat with an active incoming
  call, so a call-notification click always lands on a joinable call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:38:50 +05:30
Sravan 4e2ccb5d60 fix: seen-by popup (#2), info-view DP preview (#5), notification DP cache + no Close btn, branded update flow (0.1.7)
- #2: 'Seen by' opens an on-screen popup listing readers (was a flash toast).
- #5: the DM contact-info view now shows the DP; clicking it previews full-size.
- Desktop notifications: cache DPs per-sender (fast AND with photo after the first);
  removed timeoutType:'never' which added an unwanted 'Close' button.
- Update flow: dropped the unbranded native restart dialog — the branded web banner
  handles Restart. Banner text clearer ('Downloading update…'), plus an update
  indicator that cascades profile 'i' badge → Settings → version line, with the
  Settings button becoming 'Restart now' when the update is downloaded. desktop 0.1.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:56:39 +05:30
Sravan 49718e5d37 feat: hyperlinks, group seen-by for all, profile-pic preview, in-call invite fix + desktop update progress (0.1.6)
- #1: URLs in chat are now clickable links (open externally on desktop).
- #2: group 'Seen by X, Y +N more' now shows under the LATEST message to EVERYONE
  (server computes reads for all messages; click shows the names).
- #5: tapping the conversation-header avatar previews the DP/group photo full-size.
- #4: in-call invite lists only people you've messaged, excludes those already in the
  call/invited, and the Invite button is sticky.
- #3 (0.1.6 shell): auto-updater emits checking/available/downloading/ready/error to
  the web UI, which shows a progress banner (+ Restart button) so updates aren't silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:58:09 +05:30
Sravan 7682e17a53 fix(desktop 0.1.6): fast + persistent native notifications + screen picker
- Desktop notifications were slow (~15s), vanished in ~1s, and clicks did nothing:
  * don't block the toast on the avatar download (race a 600ms cap) → shows instantly
  * keep a strong reference to each Notification (Electron GC'd them → premature close
    + dead click)
  * call invites use timeoutType:'never' + a 45s window so they stay until clicked/ended;
    web marks call notifications persistent.
- #9: enable the OS screen/window PICKER (useSystemPicker) so users choose what to share
  (a single window avoids the whole-screen mirror); falls back to primary display.
- desktop 0.1.5 -> 0.1.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:43:07 +05:30
Sravan 0976b6f91a feat(dashboard): profile pic (More#3), sticky top nav (More#5), App-installs search+pagination (More#4)
- #3: profile avatar now shows the photo (avatarUrl) not just initials.
- #5: header is position:sticky so the brand + profile stay visible while scrolling.
- #4: App installs table gets a search box (user/platform/version/OS) and 10-per-page
  pagination with prev/next + count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:28:12 +05:30
Sravan 3e811af3d6 fix(calls): screen shows with camera OFF (#9), screen-share button highlight, unanswered caller vs callee text
- #9 REAL cause: the tile was forced to avatar whenever the peer's CAMERA was off
  (addTile + meeting-peer-state), so a screen-share only appeared if the camera was
  also on. Now camera-off only shows the avatar when the peer is NOT sharing a screen.
- #9: screen-share button is highlighted (blue) while active, like mic/cam/record.
- Unanswered call text is viewer-relative: the caller sees 'Call not answered', the
  callee sees 'Missed call'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:24:17 +05:30
Sravan 096683385a fix: chat-open scroll regression, SFU screen reliability, unanswered calls, selected-chat border
- REGRESSION (More#1): removed overflow-anchor:none (it stopped the view from staying
  at the bottom as images settle) + renderThread re-asserts scroll-to-bottom after late
  content, unless a 'New messages' divider is shown → chats open at the latest message.
- #9: dynacast off — a screen-share layer was being paused unless a camera track was
  also flowing (blank / only-with-camera / slow). Now every published track flows.
- More#7/#9: 1:1 calls track 'answered'; unanswered calls auto-end after ~40s (caller
  no longer stuck ringing) and post 'Missed call' instead of a duration; answered calls
  show duration from the answer time. meeting-ended reason 'unanswered' → 'No answer'.
- More#6: selected chat now has a thin yellow (brand) boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:20:19 +05:30
Sravan f6962a0b4a fix: screen-share mirror (#9), sidebar tick sync (#1), desktop notification reload (#3)
- #9: sharing your whole screen no longer creates a 'hall of mirrors' — your own
  screen is NOT shown back to you (you see your camera/avatar + a 'You're sharing'
  badge); other participants still see your screen. Your own tile is never the local
  stage.
- #1: opening a DM now syncs the sidebar tick to the thread's true delivered/read
  state, so it can't show a single tick while the conversation shows double.
- #3: nativePlatform() now also detects older desktop builds that expose
  bizConnectNative but not __NATIVE__. Those were wrongly subscribing to Web Push, so
  the service worker's openWindow RELOADED the page on notification click (losing the
  header DP). Desktop now uses only the native toast → opens in-place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:55:30 +05:30
Sravan 1114cd4fda fix(calls): callee tile while ringing (#7), multi-device decline no longer kills active call (#8), SFU screen-attach retry (#9)
- #7: outgoing 1:1 call shows the person you're calling (DP + name + 'Ringing…')
  instead of only your own tile; removed when they answer or the call ends.
- #8: declineDmCall now ignores the decline if that user has ALREADY accepted on
  another device (they're in the room) — declining the ringing invite on a 2nd
  device no longer tears down the active call.
- #9: sfuAttach retries (bounded) when the uid→peerId map lags the LiveKit track,
  so a shared screen isn't silently dropped due to a race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:18:14 +05:30
Sravan f52d54a093 fix(chat): forward polish + scrollbar + image preview + media tabs
- #4: forward modal buttons now branded (bzc-* un-scoped from .bz-confirm); target
  rows get real checkboxes + border highlight; selected MESSAGES highlight in green
  with a filled tick (distinct from blue bubbles), checkbox on every message.
- #5: forwarded messages show an italic 'Forwarded from <origin>' label; new fwd_from
  column preserves the true origin across re-forwards.
- #6: image-preview arrows are smaller (32px) chevron icons, clear of the image
  (image max 82vw); icons.js?v=5.
- #10: image preview now opens ABOVE the media modal (z 9900) and Esc/close returns
  to the media view instead of closing everything.
- #11: Media/Links/Docs active tab underline is brand-blue, not green.
- #2: chat-list + conversation scrollbars 6px, stepper arrows hidden; pagination adds
  a cooldown + overflow-anchor:none so scrolling up no longer sticks/jumps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:11:02 +05:30
Sravan 346361da8a feat(chat): dismiss a chat's notifications on other devices once seen (#13)
When you open/read a conversation, the server now also pushes a notif-clear to your
OWN other sockets. Each device tags notifications by conversation (kind:id), so on
notif-clear it closes the matching page Notification + any Service-Worker (Web Push)
notifications, and drops matching activity-center entries so the bell badge stays in
sync. (Desktop Electron native toasts are transient/auto-expire; the web+PWA surface
is covered.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:20:18 +05:30
Sravan ff436704ec feat(desktop 0.1.5): show version + Check for updates in Settings (#12)
- Settings now shows 'Biz Connect for Desktop · Version x.y.z' with a Check for
  updates button (desktop only, feature-detected).
- preload exposes checkForUpdates(); main.js adds the check-updates IPC (returns
  available/current/dev/error) and, when a build finishes downloading, shows a
  Restart now / Later dialog instead of only the silent on-next-launch install.
- desktop version bumped 0.1.4 -> 0.1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:12:21 +05:30
Sravan a9b3533f7a feat(chat): forward messages with multi-select (#1)
- Message action pill gains a Forward button; tapping it enters selection mode
  (tap bubbles to multi-select, footer bar shows count + Forward/Cancel, Esc exits).
- Forward picker lists EXISTING conversations only (DMs + groups from the sidebar),
  searchable, multi-target. POST /api/messages/forward copies body+attachment into
  each target (authorized as participant/member), live-pushed like a normal send.
- /files auth now accepts ANY message carrying an attachment (allByAttachment), so
  forwarded images stay viewable for the new recipients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:09:02 +05:30
Sravan 3a976d58ab feat(chat): reply-jump (#8), image prev/next nav (#3), multi-file send (#5)
- #8: reply quotes are now clickable — jump to the original message (paging older
  history in if needed), then flash it. reply DTO carries the target's timestamp.
- #3: image lightbox now has ← / → arrows + keyboard nav to flip through all images
  in the conversation.
- #5: the composer queues MULTIPLE files (file input is multiple; paste still works);
  each is shown as a removable chip and sent as its own message (first carries the
  typed text as caption), in order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:02:15 +05:30
Sravan c672f7b65f fix: notification-open in-place (#2/#7/#19) + polish (#9 scrollbar, #16 call scrollbar, #17 ringback)
- #2/#7/#19: openFromNotif opened chats via a full-page reload (location.assign),
  which was slow AND dropped live state — the sidebar hadn't reloaded so the header
  showed 'Conversation' with no DP, and an incoming call's Join/invite popup was lost.
  Now it opens the chat IN-PLACE (selectChat) since the app is already running;
  full-page nav only as a fallback when the app isn't initialised.
- #9: slimmer 6px scrollbar; explicitly hide all webkit stepper-arrow variants.
- #16: 1:1/small call no longer shows a stray scrollbar — meet-grid centers tiles
  and clips overflow (call UI fits the viewport).
- #17: caller now hears a gentle ringback while waiting; stops on answer, on any
  call exit, and auto-stops after 45s so it never rings forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:54:28 +05:30
Sravan 16065315a7 fix(chat): pull-to-refresh at top loads older history, no longer resets to bottom
Pull-down-at-top called reloadThread (reload newest 500 + scroll to bottom), so
reaching the oldest message yanked the view to the newest — jarring, especially on a
single page. Now the pull gesture calls loadOlder: it pages in older history keeping
scroll position, or no-ops when there's nothing older. Never jumps to the bottom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:44:32 +05:30
Sravan eb79823fd2 feat(chat): older-history pagination + full-thread server-side search
Older messages (beyond the newest 500) now load as you scroll to the top — the
thread endpoint takes a ?before=<created_at> cursor, the client prepends the older
page and preserves scroll position (renderThread keepScroll). _hasMoreOlder stops
paging when a short page returns.

Search now covers the ENTIRE thread, not just the loaded window: new
/api/messages/search (DM + group, LIKE with escaped wildcards) returns all matching
message ids; the client debounces the query, and jumping to a hit older than the
loaded window pages history back (ensureLoadedBack) until the match is in view, then
highlights + flashes it. Cap raised to 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:21:14 +05:30
Sravan 4fd323fff4 fix(chat): thread query returned OLDEST 300, hiding new messages past the cap
THE disappearing-messages root cause (found via THREAD.length=300 vs 351 on server).
messages.thread and threadByConversation did 'ORDER BY created_at ASC LIMIT 300' —
the oldest 300. Once a DM/group passed 300 messages, every newer message was silently
dropped from the fetch, so anything sent after that point 'disappeared' (persisted
server-side, never returned to the client). Now: inner 'ORDER BY created_at DESC LIMIT'
takes the NEWEST N, outer ASC presents them oldest-first. Cap raised 300 -> 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:08:38 +05:30
Sravan 507489ec55 fix(chat): stop blanking a conversation (and wiping its cache) on a failed thread fetch
THE disappearing-messages root cause. openConvo did: let msgs=[]; try{ msgs=fetch()
}catch{}; THREAD = Array.isArray(msgs)?msgs:[]. On a flaky desktop network a failed
fetch left msgs=[] (still an array) → THREAD=[] AND THREAD_CACHE.set(ckey,[]) — so
reopening a chat blanked it AND overwrote the cache, making messages vanish and stay
gone even though they persist server-side (confirmed: 982 DMs, zero dangling ids).

Now msgs stays null on a failed/non-OK fetch; we only replace THREAD/cache on a real
array response, and otherwise keep the cached render instead of blanking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:47:58 +05:30
Sravan 12be747609 fix(chat): resolve merged-away user ids so DMs to merged contacts don't vanish
Root cause of 'messages to some contacts disappear (gone after reopen)': the #2
account merge deletes the merged-away user row. Any lingering reference to that old
id — a cached contact, an in-flight DM — then saved against a dead recipient / 404'd
on thread fetch, so messages silently vanished.

- New user_aliases table records old_id -> survivor on every merge (mergeInto).
- users.resolve(id) follows the redirect.
- DM send (recipient), thread fetch (with), and read now resolve() the peer id, so a
  stale id transparently routes to the surviving account.

Fixes future merges fully. Contacts merged BEFORE this (no alias recorded) may need a
one-off data check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:23:49 +05:30
Sravan 1128f9811a fix: persist chat uploads on /data volume (broken images) + prevent duplicate chat sockets
- Broken images: UPLOADS_DIR/REC_DIR/TRANS_DIR were server/<dir> INSIDE the image,
  so every deploy.sh rebuild wiped uploaded files — old images 404'd ('broken
  image') though their DB rows survived. Make them env-overridable and point prod
  at /data/uploads|recordings|transcripts (persistent volume), matching DB/downloads.
  NOTE: files already lost to prior rebuilds can't be recovered; new uploads persist.
- Duplicate notifications: harden connectChatWs — close/detach any prior socket
  before opening a new one and keep a single pending reconnect timer, so a flaky
  reconnect can't leave two live sockets delivering every event/notification twice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:09:24 +05:30
Sravan 7d284213d1 fix(chat): non-destructive reconnect resync — stop messages disappearing after send
batch42's resync-on-reconnect reopened the conversation via openConvo, which
replaces THREAD wholesale. On the desktop app (flaky WS), a reload racing with a
just-sent message could momentarily blank the chat ('messages disappear after
sending', seen on the Manasa chat). resync now MERGES: adds messages missed while
disconnected + refreshes read/delivered/seen/edited/deleted flags, but never
removes messages already on screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:41:12 +05:30
Sravan 37c8929c5e fix(sfu): upgrade LiveKit server v1.7 -> v1.9 to match client SDK 2.20
The bundled livekit-client@2.20 uses signaling protocol 17 and the /rtc/v1 path,
which the v1.7 server didn't implement (404). The client fell back to the legacy
path, leaving the track publisher in a bad state so mic/cam publishing failed with
'InvalidAccessError: The sender was not created by this peer connection' — surfaced
to users as a misleading 'permission required' toast. v1.9 supports protocol 17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:33:11 +05:30
Sravan 496eba3c17 fix(chat): batch 1 desktop fixes — resync-on-reconnect, branded confirm, esc, typing-in-panel, media errors
- #10/#18/#7: on WebSocket RE-connect, re-pull sidebar + re-open the current
  thread so the desktop/mobile apps recover missed messages/ticks without a
  refresh (the old onopen only re-sent chat-hello).
- #11: branded bzConfirm() dialog replaces window.confirm for delete (the OS/
  Electron default dialog looked off-brand on desktop).
- #4: global Esc no longer closes the conversation while an image preview is
  open — the preview closes first, the conversation on a second Esc.
- #6: live 'typing…' now also shows inside the conversation panel (animated),
  not only the header/sidebar.
- #15 (diagnostic): mic/cam failures now report the REAL cause (permission vs
  no-device vs in-use) with desktop-specific guidance, and log the raw error,
  instead of always saying 'permission required'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:46:22 +05:30
Sravan 472170784b fix(livekit): single UDP media port + pin node_ip for the NAT'd server
The prod box is behind NAT (private 192.168.88.61 behind public 118.95.33.89), so
LiveKit auto-IP-detection would pick the wrong (outbound) address. Pin
rtc.node_ip=118.95.33.89 and collapse media to one UDP port (50000) + TCP 7881 to
minimize the upstream gateway port-forward the network team must add. Docs updated
with the exact forward table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:50:24 +05:30
Sravan 10e393a31f chore(compose): gate livekit behind 'sfu' profile so it stays dormant until enabled
A normal deploy no longer starts the livekit container (it would crashloop with
empty keys before provisioning). Enable with 'docker compose --profile sfu up -d'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:23:09 +05:30
Sravan 47b6f475e6 docs(deploy): LiveKit SFU provisioning — phase 3 (ops runbook)
Step-by-step to enable meetings SFU: generate key/secret, .env vars, DNS record,
NPM proxy host for wss signaling, VPS firewall for UDP media + TCP fallback, deploy,
verify. Includes the one-line rollback to mesh (remove the LIVEKIT_* vars).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:21:44 +05:30
Sravan 6118d59c5c feat(meetings): LiveKit SFU — phase 2 (client media plane, behind the flag)
Meeting media flows through LiveKit when the server reports sfu:true; otherwise the
P2P mesh is unchanged. Reuses the ENTIRE existing meeting UI + WS control plane
(join/host/mute/screen/recording/transcript) and only swaps the transport:
- vendored livekit-client 2.20 UMD (lazy-loaded; no build step).
- sfuInit() reads /api/meetings/config at boot; sfuConnect() joins the LiveKit room
  with the minted token after the WS meeting-join.
- remote tracks map back to the WS peerId via uid (LiveKit identity = app user id);
  per-peer stream prefers screen over camera, driving the existing sharing/stage UI.
- toggleMic/Cam/Screen publish via LiveKit; local tracks reflected into meetLocalStream
  so tiles, active-speaker meter, canvas recording and transcript keep working.
- meetMakePeer/peer-joined/peer-left/leaveMeeting branch on SFU.on; mesh path intact.

Needs a running LiveKit server + live test (phase 3 ops) to exercise end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:20:59 +05:30
Sravan f1dbcd0f86 feat(meetings): LiveKit SFU — phase 1 (server plumbing, config-gated)
Adds the server side of scaling meetings past the ~5-peer mesh:
- config.js: LIVEKIT_URL/API_KEY/API_SECRET + LIVEKIT_ENABLED flag. All optional;
  when unset the app keeps the built-in P2P mesh (fully additive, like push).
- routes.js: GET /api/meetings/config (tells the client sfu on/off + wss url) and
  POST /api/meetings/token (mints a per-user, per-room LiveKit join token — hand-rolled
  HS256 JWT like the FCM/APNs tokens, no new dependency; secret stays server-side).
- docker-compose.yml: optional livekit service (single-node, no Redis), keys injected
  via LIVEKIT_KEYS from the same .env; media over published UDP 50000-50100 + TCP 7881,
  signaling proxied by NPM.
- livekit.yaml + .env.example documented.

Client (mesh->LiveKit media swap, behind the flag) lands in phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:11:37 +05:30
Sravan 30e354d58f feat(chat): per-message group read receipts (seen-by + read-by-all ticks)
Thread: every group message I send now carries a receipt tick — sent (1 grey)
-> seen-by-some (2 grey) -> seen-by-all (2 yellow) — computed from the per-message
seenBy the server already returns and kept live by onGroupRead. Tap the tick to see
exactly who ('Seen by X, Y') with an 'N of M' tooltip. Replaces the old last-message-
only 'Seen by' line with a universal, tappable per-message receipt.

Sidebar: the group row tick now reflects real read state (read/delivered/sent) via
memberReads vs member count, instead of a hardcoded 'sent' — and refreshes live when
the open group is read by all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:57:08 +05:30
Sravan 3f791cb120 feat(chat): live typing indicators for DMs and groups
Ephemeral over the chat WebSocket (no DB). Composer emits chat-typing on/off
(throttled 2.5s, auto-stop after 4s idle / on send / on leaving the chat).
Server relays to the DM peer or fans out to group members (membership-checked).
Receiver shows 'typing…' in the conversation header subtitle and the sidebar
row preview (brand-blue italic), with per-sender auto-expiry so a dropped 'off'
can't stick. Group shows names ('Alice is typing…', 'N people are typing…').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:17:03 +05:30
Sravan 820e7a08fc add publish-desktop.sh — one-command desktop installer publish from laptop
Mirrors redeploy.sh conventions (pinned host key, DEPLOY_PASS/deploy.secret/
prompt password resolution, plink discovery). Uploads the three electron-builder
artifacts via pscp to a temp dir, then docker cp's them into the app container's
/data/downloads (volume-path-independent), and verifies the public feed serves
the new version. Keeps older versions; only latest.yml is overwritten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:04:22 +05:30
Sravan 609427747a docs(deploy): document scp-to-named-volume as the desktop release publish step
/data is a named Docker volume, not a host bind-mount, so the prior
scp root@host:/data/downloads/ path was wrong. Document the real host
path (/var/lib/docker/volumes/bizgaze_support_data/_data/downloads) plus
the docker cp alternative, and note latest.yml is the only overwritten file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:43:44 +05:30
Sravan a1887064eb feat(chat/notif): single hover action-pill; sender DP in desktop+mobile notifications
- Message actions (reply/react/edit/delete) consolidated into ONE hover pill anchored to the
  bubble's top-right, overlapping it so on short messages the icons no longer float off into
  empty space and vanish before you can click.
- Desktop toast + mobile(FCM)/web-background push now show the sender's real DP:
  * renderer passes the DP URL through; desktop shell downloads it for the toast icon
    (canvas-drawing an external DP tainted it → initials). Desktop bumped to 0.1.4.
  * DM push payload carries icon=sender avatar; sw.js already uses it (web background),
    sendFcm sets notification.image (Android).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:01:32 +05:30
avinash ab48642cf6 Merge branch 'master' of https://code.bizgaze.com/Sravan/BizGaze_Remote 2026-07-02 17:58:23 +05:30
avinash b0473dee7d Merge branch 'master' of https://code.bizgaze.com/Sravan/BizGaze_Remote 2026-07-02 17:58:14 +05:30
avinash 5eb8f436c4 fix(redeploy): add host key verification to SSH command 2026-07-02 17:57:55 +05:30
Sravan 51e206279b feat(chat): edit message (#4)
Sender can edit their own text messages: a pencil action on the bubble loads the text into the
composer in an 'Editing' mode; saving updates the body, marks it 'edited', and pushes the change
live to the other side/tabs (chat-edited, mirroring delete). Adds messages.edited_at + editBody().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:45:20 +05:30
Sravan 9bc109528e fix(brand/ux): real brand C on header, yellow read-tick, sign-in spinner, notif DP
- #5: use the ACTUAL brand C (extracted transparent white C + gold dot from the master
  app-icon) on the blue headers instead of a hand-drawn mark; login card keeps the app icon.
- #3: read-receipt double tick on my own (blue) bubble is now brand yellow (was blue-on-blue,
  invisible).
- #2: Sign-in button shows a spinner + 'Signing in…' on submit.
- #1: web notification uses the sender/group DP URL directly as the icon (drawing an external
  DP to canvas tainted it → silently fell back to initials).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:39:38 +05:30
Sravan 7674c456f2 fix(brand): header shows a plain white C + gold dot on the blue bar
The header is already blue, so the app-icon-in-a-white-pill looked like blue→white→blue→white
nesting. Use a transparent mark (mark-light.svg: white C + gold dot) placed directly on the blue
header (home/index/connect/dashboard), dropping the white background + padding. share.html keeps
the full app icon (it's a light card, not the blue bar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:26:22 +05:30
Sravan 952242f62b fix(brand/ux): real Biz Connect logo, branded loading screen, phone login, no blank gap
- Replace the leftover 'Your Virtual CXO' logo.png with the app icon (C mark) in every
  header (home/index/connect/dashboard) + the app icon on share.html; delete logo.png.
- Loading screen now shows the orbit mark + 'Biz Connect' wordmark + 'Loading…' (was bare).
- Keep the branded splash up until the chat list finishes loading — no blank/stuck gap
  after login (previously hidden before loadSidebar()).
- Login accepts email OR phone (field was type=email, which rejected phone numbers and so
  blocked the mobile-login that triggers the account merge). Label → 'Email or phone'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:16:08 +05:30
Sravan d515a562a0 fix(chat): stop echo downgrading delivered/read tick (#6); set self-chat apart (#3)
- #6: the server echoes your own sent message back over the socket; onChatMessage reset
  last_status='sent', overwriting a delivered/read that arrived first → the chat-list tick
  flipped back to single. Track last_msg_id and never downgrade the same message's tick.
- #3: pin the 'You' note-to-self chat in its own slot — tinted row + 'Note to self' tag +
  a divider separating it from the conversation list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:17:30 +05:30
Sravan 515411fc83 ops: persist desktop download feed on /data volume + IT release guide
- docker-compose: DOWNLOADS_DIR=/data/downloads so uploaded installers/latest.yml
  survive image rebuilds (deploy.sh) instead of being wiped.
- DEPLOY.md: step-by-step for publishing a desktop release (build → upload the
  3 feed files → verify) so the Download button + auto-update go live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:46:33 +05:30
Sravan dc1915bb43 feat: live presence, delivery-tick sync, brand rollout, call fixes, desktop 0.1.3
Live presence (fixes stale in-call/status until refresh — impossible in apps):
- server broadcasts a user's status over the chat socket on connect/disconnect, call
  join/leave, and status change (chat.js broadcastPresence; signaling + routes hooks).
- client onPresence() updates the contact dot + open-chat header live.

Chat delivery ticks (#6): chat-list row now mirrors the thread (delivered→double grey,
read→blue) via a new 'with' field on the delivered relay + onChatRead/onChatDelivered.

Call fixes: no bogus 'host handed over' when a 1:1 call ends (leaveMeeting forced);
branded call-connecting + chat-thread loaders; header subtitle tracks live call state.

Notifications: web notify + sw.js use sender/group DP + brand icon (not old wordmark);
desktop shell drops Web Push so only the single native toast fires (#5).

Brand: master icon/splash/loaders wired everywhere (PWA/favicon/apple-touch/.ico),
branded login (blue + gold CTA), branded toasts (BZToast) on all pages, Electron splash.

Desktop: dev auto-targets localhost (packaged→prod); version 0.1.3 with new multi-size
icon; dropped unused node-notifier; removed home-mockup.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:43:02 +05:30
Sravan a152005b71 feat(brand): roll out master app icon, splash & branded loaders everywhere
From brand-assets/ masters (C + orbiting-dot on brand blue):
- Regenerated PWA icons (192/512/maskable), apple-touch (180), favicons
  (32/16 + root favicon.ico) from the 1024 master; bumped manifest to v3.
- Rebuilt desktop/build/icon.ico as a proper multi-size ICO (16..256,
  PNG-compressed) to fix the tiny/blurry taskbar icon.
- Updated mobile masters (resources/icon.png 1024, splash.png/splash-dark.png
  2732) for capacitor-assets to regenerate native icons/splash.
- Wired the animated branded orbit loader into the app boot 'Loading…' screen;
  added favicon/theme-color links to index + home heads.
- logo.png (horizontal wordmark) left untouched — masters have no wordmark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:41:48 +05:30
Sravan 04db586dc3 feat(auth): merge mobile+email logins into one account via BizGaze person-id (#2)
Provisioning now keys the Biz Connect account on bz.bizgazeUserId (the same
value whether the person signs in with email or mobile) instead of the typed
identifier, so both logins resolve to a single contact. Legacy rows get the
person-id stamped on next login; an existing duplicate created under the same
identifier is folded in via a transactional users.mergeInto() that reassigns
all messages/memberships/reactions/votes/favorites/ownership to the survivor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:35:29 +05:30
Sravan 88e897cc9c feat(chat): modern emojis via Twemoji (#5)
Loads @twemoji/api (jsDelivr) and renders emojis as Twemoji images in messages, the emoji
picker, and reactions (twemojify at each render point). Picker inserts via data-emoji so it
survives the <img> swap. Falls back to plain Unicode if the CDN is unavailable. build batch25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:23:26 +05:30
Sravan 462a167438 feat(chat): self-chat 'You' — note-to-self (#4)
Pinned 'You' chat at the top of the list (always available); messaging yourself works
(to===me), with no self push/echo notification and a 'Message yourself' header. Self is
filtered from the normal contacts and shows no status dot. build batch24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:05:32 +05:30
Sravan 15350c691b feat(chat): delivery ticks on the last message in the chat list (#3)
Conversations DTO now returns last_status (sent/delivered/read for DMs; sent for groups) for
my last message. The list row shows the matching tick (single/double, blue when read) and a
red 'Draft:' indicator when there's unsent text. Live 'sent' on send/receive; upgrades to
delivered/read on refresh. e2e 119.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:59:53 +05:30
Sravan a33ed27ffe feat(chat): per-chat drafts + fix emoji picker closing on tab switch
- Unsent text is saved per conversation (survives switching chats and reloads) and restored
  when you reopen the chat; cleared on send. (#1)
- Emoji picker: stopPropagation on tab/grid clicks so switching category no longer closes it
  (the re-render was detaching the clicked node -> outside-click handler fired). (#6)
build batch23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:55:14 +05:30
Sravan 2b27889e07 fix(desktop): use Electron's native Notification (reliable click-to-open)
node-notifier/SnoreToast click callback never fired without the crashing wait mode. Switch the
chat toast to Electron's built-in Notification: shows avatar + message and its 'click' event
reliably raises the app + opens the chat. No SnoreToast, no external tools.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:16:35 +05:30
Sravan f8978ff796 fix(desktop): drop crashing SnoreToast reply path — clean click-to-open toast
The bundled SnoreToast crashes (0xC0000409) handling a text reply, and that crash triggered
a second (fallback) toast. Removed the direct-SnoreToast reply path entirely; the chat toast
now reliably shows avatar + message via node-notifier and opens the chat on click. Inline
text reply needs a different toast engine (deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:58:26 +05:30
Sravan 0b58d33117 fix(desktop): drop -w — SnoreToast waits by default; -w made it fail (exit -1)
Testing showed SnoreToast blocks/waits for the toast interaction when given -pipeName; the
-w flag actually fails on this build (exit -1) which forced the reply-less fallback. Without
-w it waits, captures the typed reply, and writes it to our pipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:43:40 +05:30
Sravan 0f5e5bf00a fix(desktop): spawn SnoreToast from app.asar.unpacked (was ENOENT inside asar)
snoreExe returned the app.asar path (fs.existsSync lies about asar paths), so spawn failed
with ENOENT and always fell back to the reply-less WindowsToaster. Map to app.asar.unpacked
unconditionally so the real SnoreToast binary (with -w) runs and captures the reply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:28:43 +05:30
Sravan c0f7926210 fix(desktop): direct SnoreToast -w to capture reply + WindowsToaster fallback
Root cause: node-notifier's toaster whitelist has no -w, so SnoreToast never waits and the
reply is lost. Now drive SnoreToast directly with -w + our own pipe (correct args, no
-application which had broken the toast). If the binary is missing or fails to show, fall
back to node-notifier's WindowsToaster so a toast always appears. Logs code+raw for diagnosis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:06:01 +05:30
Sravan 1abf6855d8 fix(desktop): drive SnoreToast directly with own named pipe + raw reply logging
node-notifier returned an empty result for text replies. Now spawn the bundled SnoreToast
with -tb -w -pipeName against our own pipe, read the raw UTF-16LE result, and parse the
reply (keeps spaces). Logs the raw pipe string to userData/toast-debug.log to pin the
exact reply field on real hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:44:08 +05:30
Sravan 3f209bf619 feat(desktop): real Windows inline reply via SnoreToast -tb + sender/group avatar
- node-notifier's WindowsToaster forwards raw opts to SnoreToast, so inject -tb (reply box)
  + -p (image). Reuses its named-pipe + result parsing (exit 5 = TextEntered). No pwsh needed.
  Logs the raw toast result to userData/toast-debug.log to confirm the reply field on real HW.
- home.html: notifAvatarDataUrl draws the DM sender's pic / group's DP (else colored initials)
  to a round PNG and passes it as the toast image. Reply -> sendReplyTo; click -> open chat.
- dropped powertoast (ESM + needs pwsh 7, absent here). build batch22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:26:24 +05:30
Sravan ffe04e6bff fix(desktop): asarUnpack node-notifier so SnoreToast can launch from the packaged app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:03:48 +05:30
Sravan 21ae3e1aa5 feat(desktop): Phase D — inline-reply notifications (Windows toast reply box)
Reply to a chat straight from the OS notification, no app switch:
- node-notifier (bundles SnoreToast) renders a native Windows toast with a reply box —
  Electron's own Notification can't do Windows inline reply.
- main.js reply-notification handler resolves {text}|{open}|null; preload exposes replyNotify.
- home.html notify() routes chat toasts through it on desktop: a typed reply -> sendReplyTo()
  POSTs to /api/messages without opening the app; a click opens the chat. Web/PWA path unchanged.
- Works only in the installed app (needs the installer's AppUserModelID). desktop 0.1.2, build batch21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:36:02 +05:30
Sravan 160a66f934 fix(landing): Windows logo on the Download button (signals it's the Windows app)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:27:31 +05:30
Sravan 9ff2ef5d48 feat(dashboard): "App installs" view + compact top-right Download button
- dashboard.html: admin "App installs" card — table of user · platform · version · OS ·
  first/last seen, from GET /api/v1/admin/installs (loadInstalls).
- index.html: replaced the long inline link with a compact white "Download app" button in
  the blue top header (top-right); hidden when already inside the desktop app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:16:38 +05:30
Sravan 899770ed02 feat: Windows download link + self-served update feed + install tracking
Downloads/updates:
- config: DOWNLOADS_DIR (override to a mounted volume in prod).
- static.js: serves /downloads/* (installer, latest.yml, .blockmap) with range support for
  resumable + differential auto-updates; /download/windows redirects to the current .exe
  (stable link). Landing page gets a "Download the Windows desktop app" button (hidden in-app).

Install tracking (who installed the app):
- db app_installs + repos.appInstalls (upsert by install_id, fills in the user on sign-in).
- POST /api/v1/telemetry/install (records install + user once authenticated);
  GET /api/v1/admin/installs (admin: list installs with user/version/os/last-seen).
- desktop main.js: stable per-install id in userData, exposed via preload
  (bizConnectNative.installId/version/os); home.html reportInstall() posts it after login.
- e2e: +2 checks (telemetry recorded, admin sees it). 119/119. build batch20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:28:53 +05:30
Sravan e5c94ebf6d fix: login submits on Enter (real <form>) + desktop notifications show "Biz Connect"
- home.html: login is now a real <form> with a submit button, so Enter (or click) both
  submit natively — replaces the ad-hoc keydown handler. (build batch19; header stays removed)
- desktop/main.js: app.setAppUserModelId('com.bizgaze.connect.desktop') so Windows resolves
  the installed shortcut and toasts read "Biz Connect" instead of "electron.app.BizConnect".
- desktop version → 0.1.1; installer rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:19:01 +05:30
Sravan e84e5eb241 feat(desktop): open the landing page (pre-login), not /home directly
The desktop app was hardcoded to /home, so it jumped straight to the login form and
skipped the 'before login' landing (no-login 'Share my screen' + sign-in). Now loads / —
same entry as the website; it redirects logged-in users to /home.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:55:49 +05:30
Sravan 07286899c5 chore(desktop): one-click installer (no scope/folder prompts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:37:58 +05:30
Sravan c605304c67 chore(desktop): add author field (cleaner electron-builder output)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:21:54 +05:30
Sravan 1500d42cd2 feat(desktop): packaging + self-hosted auto-update (electron-builder/updater)
- package.json build config: NSIS installer, app icon, generic publish provider →
  https://remote.bizgaze.com/downloads/ (self-hosted update feed).
- main.js: electron-updater checks the feed on launch + every 6h, downloads in the
  background, installs on restart. Active only in packaged builds.
- build/icon.ico app icon; PACKAGING.md documents build/release/signing.
- Key design: web/UI changes reach installed apps instantly (they load the live server);
  only native shell changes need an auto-updated build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:03:18 +05:30
Sravan 2404538270 fix(ui): modern thin scrollbars app-wide (remove classic up/down arrows)
Only the chat LIST had styled scrollbars; the chat message area and every other scroll
region fell back to the OS scrollbar, which in the Electron/Windows shell shows classic
up/down stepper arrows. Added a global ::-webkit-scrollbar style (thin, rounded thumb,
scrollbar-button hidden) + scrollbar-width:thin to home.html, share.html, connect.html,
dashboard.html. (home build batch18)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:46:40 +05:30
Sravan 1ca0b836e1 feat(desktop): unread-chat count badge on the taskbar icon
Mirrors the rail's unread-chat count onto the Windows taskbar icon (overlay) and the
macOS/Linux dock badge. updateRailUnread() draws a small red count badge on a canvas
and hands it to the shell via bizConnectNative.setUnread(count, dataUrl); main sets it
with win.setOverlayIcon + app.setBadgeCount. Clears at 0, shows 99+ past 99. No-op in a
browser/mobile. (home.html build batch17)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:28:15 +05:30
Sravan 5a98ae4d34 feat(desktop): media/notification permissions + notification raises the app
- desktop/main.js: grant media (camera/mic), display-capture, notifications, clipboard,
  fullscreen, pointerLock for the app origin (Electron denies these by default, which
  silently broke meetings' camera/mic). Adds setPermissionRequestHandler +
  setPermissionCheckHandler on the app session.
- Clicking a notification now raises + focuses the window: preload exposes
  bizConnectNative.focusApp(), main handles 'focus-window' IPC, and the web notify()
  onclick calls it when running in the desktop shell. (home.html build batch16)
- CLIENTS.md: Phase D — inline-reply notifications (Windows Toast RemoteInput /
  Android RemoteInput / iOS UNTextInputNotificationAction) queued right after packaging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:16:40 +05:30
Sravan 7a2ab3fc8d fix(agent): remote-control input now works (data channel + nut-js API)
Two latent bugs that broke input control on any setup:
- Data channel was created by the viewer (the answerer), so the agent's offer had
  no SCTP m-line and the channel never opened -> no input reached the agent. The
  agent (offerer) now creates the 'input' channel; the viewer receives it.
- inject.js used nut.screen.getResolution() which doesn't exist in this nut-js;
  switched to screen.width()/height() with per-session caching.

Verified end-to-end locally: screen streams + mouse injection moves the remote cursor.
Also commits desktop/ + mobile/ package-lock.json from client installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:47:09 +05:30
Sravan 73b40a5d9f feat(mobile): Android prep — icons/splash assets, permissions, FCM setup
- resources/: 1024 icon.png + 2732 splash.png/splash-dark.png generated from the
  PWA icon + brand blue; wired @capacitor/assets (npm run assets) + splash-screen plugin.
- ANDROID_SETUP.md: end-to-end guide (SDK setup, cap add android, manifest permissions,
  Firebase/google-services.json + Gradle, run, Play AAB build) for package com.bizgaze.connect.
- android-permissions.xml: paste-ready POST_NOTIFICATIONS + camera/mic/WebRTC perms.
- mobile/README links the guide; setup adds `npm run assets`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:18:11 +05:30
Sravan 7ae0cacf74 feat(push): wire Capacitor native push into the web UI
home.html: in a Capacitor app shell, setupPush() now uses the native FCM/APNs path
instead of Web Push — requests permission, registers, POSTs the OS device token to
/api/v1/devices, deep-links on notification tap (selectChat), and unregisters the
token on logout. Web Notification prompts are suppressed on native. Fully inert in a
normal browser (Web Push unchanged). build batch15.

CLIENTS.md Phase B push items checked off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:56:59 +05:30
Sravan 4c75db2029 feat(push): native device-token registration + FCM/APNs senders
- /api/v1/devices (register) + /api/v1/devices/remove — auth-required, validates
  platform (ios|android), upserts by token; e2e covers register/validation/auth/remove.
- db device_tokens table + deviceTokens repo.
- push.js: FCM HTTP v1 (Android) and APNs token-based over HTTP/2 (iOS) folded into
  the single push.sendToUser path alongside Web Push; each transport independently
  config-gated and a silent no-op without creds. Dead tokens pruned on 404/410.
- docs: CLIENTS.md Phase B updated; DEPLOY.md env table adds FCM/APNs vars.

e2e 117/117.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:23:10 +05:30
Sravan 593a4677b6 feat(clients): scaffold mobile (Capacitor) + desktop (Electron) shells
Plan + decisions in CLIENTS.md (parallel mobile+desktop; desktop = technician
client + existing remote-control agent host; mobile = Capacitor wrap).

- desktop/: Electron technician client — loads the live Connect UI, native
  screen capture via setDisplayMediaRequestHandler, persisted session, external
  links to browser; electron-builder config for Win/Mac/Linux installers.
- mobile/: Capacitor project — server.url loads Connect UI, push/camera/status-bar
  plugins declared, www splash fallback; iOS/Android added via `cap add`.
- Reuses the existing /api/v1 + Bearer auth backend; no web-code changes.
- .gitignore: ignore generated mobile/android, mobile/ios platform dirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:49:41 +05:30
Sravan f517c153c1 docs(deploy): add operational guardrails + env/verification checklist for IT
Single-instance requirement, ALLOW_LOCAL_LOGIN-off, server-side directory token,
no-store HTML, Node>=22.5/web-push, required env vars (SSO/VAPID/TURN), and the
window.__BUILD per-release verification step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:06:08 +05:30
Sravan 06f0b08a18 feat(chat): rich shared-media view, status selector, drag-drop upload + fixes
Chat / shared media:
- Media/Docs/Links: clean underline tabs (green active), audio & video now
  classified as Media and rendered as tiles (download + headphone/play +
  duration) instead of broken-image glyphs; image thumbnails -> lightbox
- Drag-and-drop a file/video/image onto a conversation to send it
- Fix: removed #chatPanel{position:relative} override that collapsed the
  conversation pane (messages spilled into a clipped right-edge strip)
- "Media, links & docs" row cleaned up (no folder/placeholder icon); media
  popup keeps the back arrow, drops the redundant close button

Presence / status:
- Single current-status row with an arrow that expands Available/Away/On leave
- On leave = circle with minus, In a call = solid red indicators
- Fix: selected-status tick now follows the chosen option

Icons: added headphones + play; bumped icons.js cache-bust to v4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:01:15 +05:30
Sravan e9e5c7f406 fix(pwa): white icon tile for contrast + cache-bust icon URLs (v2)
Logo was dark-on-blue (low contrast); now centered on a white tile like the
header treatment. Icon URLs versioned (?v=2) so browsers/installs fetch the new
ones. Build marker -> pwa2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:02:11 +05:30
Sravan a427be9b6f fix(cache): send Cache-Control: no-store on all JSON/404 responses
Prevents a 404 (e.g. /manifest.json fetched before deploy) from being cached on
a device and persisting after the file exists — the cause of the manifest 404
on mobile but not desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:58:23 +05:30
Sravan b576ed372a feat(pwa): installable app (Add to Home Screen) for Android + iOS
- manifest.json (standalone display, theme color, maskable icons 192/512).
- generated square icons + apple-touch-icon (180) from the logo.
- apple-mobile-web-app + theme-color meta in home.html.
- sw.js gets a no-op fetch handler so it meets installability criteria (still
  no caching). static.js serves .json/.webmanifest with correct MIME.
- Installing as a PWA also unlocks Web Push on iOS (Apple requires Add to Home Screen).
Build marker -> pwa1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:13:01 +05:30
Sravan f4a23ae805 fix(cache): serve HTML with no-store so deploys reach browsers without a hard refresh
Browsers were serving a cached old home.html on normal reloads (only incognito/
hard-refresh got the new one). HTML now sends Cache-Control: no-store; versioned
assets keep ETag revalidation. Bumps build marker to push4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:30:17 +05:30
Sravan f7ddb2e7ae fix(push): wait for active SW before subscribe + log every step (subscribe was failing silently)
subscribePush() swallowed all errors, so if pushManager.subscribe() failed
(e.g. called before the service worker was active) nobody ever subscribed and
there was no trace. Now: await serviceWorker.ready before subscribing, and
console.log/warn each step so the real failure is visible. Server send path
verified independently (web-push builds valid VAPID requests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:57:03 +05:30
Sravan 5edb3fa241 fix(chat): dedup sent message in sendMessage too (WS echo can beat the POST response)
The server echoes the sender's own message over WS before returning the HTTP
response, so onChatMessage could append it before sendMessage's await resolved,
then sendMessage appended again -> double. Both append paths now dedup by id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:39:42 +05:30
Sravan 88d7657364 chore: add build marker (window.__BUILD) to home.html for deploy verification
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:38:35 +05:30
Sravan 1272b81cee feat(push): Web Push notifications for backgrounded/closed/mobile tabs
Page-level Notifications can't fire when a tab is frozen/closed (and never on
mobile), which is why recipients on another tab/app got nothing. Adds a
notification-only service worker (sw.js, no caching) + Web Push:

- push.js: optional web-push wrapper (no-op unless web-push installed AND
  VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured).
- push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead).
- /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a
  Web Push to recipients.
- Client registers /sw.js, subscribes when permission granted; hidden-tab popups
  are left to push to avoid double-notifying (pushActive flag); SW suppresses the
  OS popup when a tab is visible. Removes the old code that unregistered SWs.

Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:58:49 +05:30
Sravan d50d4bde47 fix(icons): proper end-call (hang-up) glyph + cache-bust icons.js (v3)
- callEnd is now a rotated-handset hang-up icon (was a phone-off placeholder).
- All pages reference /icons.js?v=3 so browsers/proxies fetch the corrected
  file instead of a stale cached copy (fixes 'old end icon' + icons not
  appearing until a re-render when an old/404 icons.js was cached).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:47:24 +05:30
Sravan 1f4516d69b fix(chat): dedup own echoed message so sent messages don't show twice
Server echoes your own message back over WS (multi-tab/device sync) and
sendMessage already appended it optimistically; onChatMessage now skips the
append if the id is already in the thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:06:00 +05:30
Sravan fcd6a60baa fix(prod): add missing public/icons.js (was untracked -> 404 in prod)
icons.js was never committed (untracked, lost from disk), so every page
404'd /icons.js and stalled at Loading. Restored from commit e05a788 and
added 16 icons referenced by current code but absent in that snapshot
(bell, bold, italic, strikethrough, code, list, listOrdered, type, crown,
checkCheck, calendarX, calendarClock, fileText, record, callEnd, settings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:41:44 +05:30
Sravan bda63b6f0a Merge origin/master (TURN/coturn + BizGaze-only login) into feature tree
Resolved conflicts in routes.js and share.html: kept the dev tree's superset
(ALLOW_LOCAL_LOGIN dev escape, avatar sync, richer login errors) which already
includes the incoming production BizGaze-only behavior; took the more descriptive
incoming comments. Restored 5 untracked modules (chat, calls, directory,
reminders, webhooks) that were missing from disk — required by routes/signaling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:27:59 +05:30
Sravan 27355cec76 BizGaze Connect: chat, meetings, recordings, mobile, directory + UI fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:15:29 +05:30
Sravan 0a739ee2fd Merge branch 'hotfix/bizgaze-only-login' of Sravan/BizGaze_Remote into master 2026-06-16 09:21:35 +00:00
Sravan caba3b3a21 Merge branch 'hotfix/bizgaze-only-login' of Sravan/BizGaze_Remote into master 2026-06-16 05:24:28 +00:00
120 changed files with 20223 additions and 725 deletions
+8
View File
@@ -21,3 +21,11 @@ TURN_CREDENTIAL=
# 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=
+10
View File
@@ -29,9 +29,19 @@ 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) # Runtime media (created at startup by config.js)
server/recordings/ server/recordings/
server/transcripts/ server/transcripts/
server/uploads/
server/downloads/
# OS files # OS files
.DS_Store .DS_Store
+13 -4
View File
@@ -133,10 +133,19 @@ viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input
- [x] **`Authorization: Bearer <token>`** accepted in `currentUser()` across HTTP + WS, alongside the - [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. 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). WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback).
- [ ] **Refresh tokens** (short access token + long refresh) so native apps stay signed in safely. - [x] **Refresh tokens** `/api/v1/auth/refresh` exchanges a long-lived (90d) refresh token for a
- [ ] **API keys** table + middleware (scoped per *tenant*, hashed at rest). fresh access token, with **rotation** (old token revoked on use) + replay rejection. Stored as a
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile. SHA-256 hash (`refresh_tokens` table). Login returns one; logout/deactivate/reset/delete revoke them.
- [ ] **OIDC/JWT** SSO; per-tenant **webhook subscriptions** with retries. 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) ### Phase 3 — Licensing + scale (last, per priority)
- [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id` - [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id`
+6 -2
View File
@@ -111,8 +111,12 @@ First registered user becomes admin; registration then closes (unless ALLOW_REGI
the dev team for: shared secret, token format (JWT preferred), SSO start URL, the dev team for: shared secret, token format (JWT preferred), SSO start URL,
signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.) signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.)
2. **New post-login home (NEXT TASK)** — see below. 2. **New post-login home (NEXT TASK)** — see below.
3. **Persistent chat** (Slack-style 1:1 + group messaging between registered users) — large new system. 3. **Persistent chat** — 1:1 messaging is BUILT (messages table, `/api/v1/messages/*`, live delivery
4. **Meetings** (multi-party video) — large new system (needs SFU or mesh). 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. 5. **Downloadable Android app** — the only way to support phone screen-sharing.
## NEXT TASK: new post-login home (start with a mockup) ## NEXT TASK: new post-login home (start with a mockup)
+128
View File
@@ -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.
+164
View File
@@ -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
+3
View File
@@ -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
+10 -2
View File
@@ -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 };
+7 -7
View File
@@ -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) => {
+122
View File
@@ -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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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">&times;</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.
+24
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+5
View File
@@ -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

+5
View File
@@ -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

+6
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+130
View File
@@ -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.
+52
View File
@@ -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).
+30
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+144
View File
@@ -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 };
+539
View File
@@ -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(); });
+5676
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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"
}
}
}
+83
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
</script>
</body>
</html>
+48
View File
@@ -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'),
}));
+26
View File
@@ -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>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+39
View File
@@ -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
+87
View File
@@ -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.
+23
View File
@@ -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
+110
View File
@@ -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)
+29
View File
@@ -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.
+117
View File
@@ -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"**.
- ~1015 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.
+43
View File
@@ -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.
+30
View File
@@ -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"
}
}
}
+12
View File
@@ -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>
+50
View File
@@ -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>
+628
View File
@@ -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("Couldnt 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("Couldnt 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("Couldnt 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()
}
}
+33
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
export {};
+3
View File
@@ -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 {};
+2
View File
@@ -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])
}
}
+26
View File
@@ -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
}
}
+26
View File
@@ -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)
}
}
+26
View File
@@ -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"
}
}
+21
View File
@@ -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.
+19
View File
@@ -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" />
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+116
View File
@@ -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}"
+38
View File
@@ -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);
+76
View File
@@ -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"
+25
View File
@@ -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>
+100
View File
@@ -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)."
+5 -1
View File
@@ -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 $*"
+3 -1
View File
@@ -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,
}; };
+15
View File
@@ -9,6 +9,20 @@
function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; } function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; }
const isEnabled = () => !!loginUrl(); 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) { async function validateLogin(username, password) {
const url = loginUrl(); const url = loginUrl();
if (!url) return { ok: false, configured: false }; if (!url) return { ok: false, configured: false };
@@ -30,6 +44,7 @@ async function validateLogin(username, password) {
return { return {
ok: true, configured: true, ok: true, configured: true,
name: s.name || null, name: s.name || null,
avatarUrl: photoUrlFrom(s),
isAdmin: !!s.isAdmin, isAdmin: !!s.isAdmin,
tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id
bizgazeUserId: s.userId != null ? String(s.userId) : null, bizgazeUserId: s.userId != null ? String(s.userId) : null,
+171
View File
@@ -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 };
+64
View File
@@ -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 };
+53 -3
View File
@@ -3,16 +3,66 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const PUBLIC_DIR = path.join(__dirname, 'public'); const PUBLIC_DIR = path.join(__dirname, 'public');
const REC_DIR = path.join(__dirname, 'recordings'); // Uploaded chat files, recordings and transcripts MUST live on the persistent volume (like the DB
const TRANS_DIR = path.join(__dirname, 'transcripts'); // 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(REC_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(TRANS_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 = { module.exports = {
PORT: process.env.PORT || 8090, PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443, HTTPS_PORT: process.env.HTTPS_PORT || 8443,
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, PUBLIC_DIR,
REC_DIR, REC_DIR,
TRANS_DIR, TRANS_DIR,
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h auto-logout 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)
}; };
+323
View File
@@ -81,4 +81,327 @@ CREATE TABLE IF NOT EXISTS sessions_log (
try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ } 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 */ } 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;
+56
View File
@@ -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); });
+59
View File
@@ -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 };
+304
View File
@@ -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)
);
+50
View File
@@ -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 };
+6
View File
@@ -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);
+57
View File
@@ -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 };
+3 -1
View File
@@ -2,7 +2,9 @@
const now = () => Date.now(); const now = () => Date.now();
const json = (res, code, body) => { const json = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' }); // 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)); res.end(JSON.stringify(body));
}; };
+82
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 };
+209
View File
@@ -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 };
+192 -4
View File
@@ -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": {
+14 -4
View File
@@ -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"
}
} }
+9
View File
@@ -4,4 +4,13 @@ module.exports = {
onlineAgents: new Map(), // machineId -> { ws, machine } onlineAgents: new Map(), // machineId -> { ws, machine }
liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user } liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user }
pendingShares: new Map(), // code -> { sharerWs, sessionId } (no-install ad-hoc shares) 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)
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+18
View File
@@ -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}}
+29
View File
@@ -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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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">&times;</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);} };
})();
+158 -26
View File
@@ -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)}
@@ -43,7 +63,7 @@
.pwwrap input{padding-right:2.7rem;} .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{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);} .eye:hover{color:var(--blue);}
#homeLink{position:fixed;top:14px;left:16px;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);} #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{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{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;} .formerr.show::before{content:"⚠";font-size:1rem;}
@@ -53,12 +73,13 @@
html.embed #homeLink{display:none!important;} html.embed #homeLink{display:none!important;}
html.embed #video{height:100vh!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> <script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
<a href="/home" id="homeLink">&#8592; Home</a> <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>
@@ -153,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}));
} }
@@ -161,9 +182,12 @@ 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;
@@ -191,6 +215,8 @@ function renderEnded(msg){
bzcSession(false); bzcSession(false);
try{ stopRecording(); }catch(_){} 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';
@@ -207,7 +233,7 @@ 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>';
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_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>'; 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; let mediaRecorder=null, recChunks=[], recCtx=null;
@@ -246,7 +272,7 @@ function stopTranscription(){ recogActive=false; if(recog){ try{recog.stop();}ca
function buildTranscriptText(){ function buildTranscriptText(){
const lines=transcriptLines.slice().sort((a,b)=>a.t-b.t); const lines=transcriptLines.slice().sort((a,b)=>a.t-b.t);
const pad=(n)=>String(n).padStart(2,'0'); const pad=(n)=>String(n).padStart(2,'0');
const head='BizGaze Support — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n'; 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'); 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'; return head+(body||'(no speech captured)')+'\n';
} }
@@ -296,20 +322,42 @@ function stopRecording(){
showRecTimer(false); showRecTimer(false);
try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){} 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.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;} 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'; } { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
bzcSession(true); 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 rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); }); const mic=_btn('micBtn',I('micOff'),'Unmute','#6b7280'); // starts MUTED (new #1)
const end=_btn('endBtn2',SVG_END,'End','#dc2626'); const ctrl=_btn('ctrlBtn',I('monitor'),'Control OFF — click their screen to take control','#6b7280');
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end); 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(); }; 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(_){} };
@@ -321,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">&#10005;</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">&#10005;</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;
@@ -330,8 +378,9 @@ 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(_){}}
@@ -355,18 +404,101 @@ async function setupPeer(){
else if(s==='connected'){ clearTimeout(pc._dt); } }; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));} function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
+165 -16
View File
@@ -2,13 +2,26 @@
<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 Connect — Dashboard</title> <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> <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; } :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;} *{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;}
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;} 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;} .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);} .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;} .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;}
@@ -28,6 +41,11 @@
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);} 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{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
.pill.on{background:#ecfdf3;color:#15803d;} .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;} .hidden{display:none;}
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;} .tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;} .tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
@@ -55,7 +73,12 @@
.profile{position:relative} .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{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:hover{background:rgba(255,255,255,.24)}
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem} .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{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.open{display:block}
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6} .profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
@@ -64,11 +87,13 @@
.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}
.ic{display:inline-block;vertical-align:middle}
</style> </style>
<script src="/icons.js?v=3"></script>
</head> </head>
<body> <body>
<header> <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 class="y">Connect</span> <span class="tag">· Dashboard</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 class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
<div class="row" id="hdrRight"></div> <div class="row" id="hdrRight"></div>
</header> </header>
<main id="app"></main> <main id="app"></main>
@@ -82,9 +107,8 @@ function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;',
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();} function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
function profileHTML(u){ function profileHTML(u){
const display=u.name||u.email; const display=u.name||u.email;
return '<div class="profile"><button class="pbtn" id="pbtn">' return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
+ '<span class="pav">'+pEsc(initials(display))+'</span>' + '<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>'
+ pEsc(display)+' <span style="font-size:.65rem">&#9662;</span></button>'
+ '<div class="pmenu" id="pmenu">' + '<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>' + '<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 href="/home">Home</a>'
@@ -128,7 +152,7 @@ async function authView() {
</div> </div>
${regOpen ? `<div id="regForm" class="hidden"> ${regOpen ? `<div id="regForm" class="hidden">
<span class="lbl">Team name</span> <span class="lbl">Team name</span>
<input id="rg_team" placeholder="e.g. BizGaze Support"> <input id="rg_team" placeholder="e.g. Acme Inc">
<span class="lbl">Email</span> <span class="lbl">Email</span>
<input id="rg_email" placeholder="you@bizgaze.com" type="email"> <input id="rg_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span> <span class="lbl">Password</span>
@@ -188,20 +212,145 @@ async function dashboard(me) {
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></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> <div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
<button id="fApply">Apply</button> <button id="fApply">Apply</button>
<button id="fExcel" class="mini" style="padding:.6rem .9rem"> Excel</button> <button id="fExcel" class="mini" style="padding:.6rem .9rem">${ic('download',15)} Excel</button>
<button id="fPdf" class="mini" style="padding:.6rem .9rem"> PDF</button> <button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
</div> </div>
${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''} ${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
<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 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> <div id="repPager" class="pager"></div>
<p id="repSummary" class="muted" style="margin-top:.6rem"></p> <p id="repSummary" class="muted" style="margin-top:.6rem"></p>
</div>`); </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('fApply').onclick = loadReport;
document.getElementById('fExcel').onclick = exportExcel; document.getElementById('fExcel').onclick = exportExcel;
document.getElementById('fPdf').onclick = exportPdf; document.getElementById('fPdf').onclick = exportPdf;
if (IS_ADMIN) await populateAgentFilter(); if (IS_ADMIN) await populateAgentFilter();
await loadReport(); 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; const PER_PAGE = 5;
function pagerHTML(page, pages, total, fn){ function pagerHTML(page, pages, total, fn){
@@ -241,8 +390,8 @@ function reportRowHTML(r){
<td>${esc(r.ticket || 'Direct session')}</td> <td>${esc(r.ticket || 'Direct session')}</td>
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td> <td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
<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> Video</a>` : '', 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> Text</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> ].join('') || '<span class="muted"></span>'}</td>
</tr>`; </tr>`;
} }
@@ -326,7 +475,7 @@ function exportPdf() {
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' + 'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' + 'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
'</style></head><body>' + '</style></head><body>' +
'<h1>BizGaze Connect — Connection report</h1>' + '<h1>Biz Connect — Connection report</h1>' +
'<div class="meta">' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' + '<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>' + '<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('') + 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('') +
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

-277
View File
@@ -1,277 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Connect — Home</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;}
html,body{height:100%;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;overflow:hidden;}
/* ---- Top bar (matches console.html) ---- */
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;}
.brandrow{display:flex;align-items:center;gap:.6rem;cursor:pointer;}
.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;}
/* ---- Profile dropdown (from console.html) ---- */
.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 .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
.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}
/* ---- Shell ---- */
.shell{flex:1 1 auto;display:flex;min-height:0;}
/* ---- Sidebar ---- */
.sidebar{width:320px;flex:0 0 320px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
.side-head{padding:1rem 1rem .75rem;border-bottom:1px solid var(--line);}
.side-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem;}
.side-title h2{font-size:.95rem;margin:0;color:var(--blue);}
.newchat{width:30px;height:30px;border-radius:9px;border:none;background:var(--blue-soft);color:var(--blue);font-size:1.2rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;}
.newchat:hover{background:#dbe6fb;}
.search{position:relative;}
.search svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
.search input{width:100%;padding:.55rem .7rem .55rem 2.1rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.9rem;}
.search input:focus{outline:none;border-color:var(--brand);}
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;}
.chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
.chat-row:hover{background:#f3f6fb;}
.chat-row.active{background:var(--blue-soft);}
.chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.92rem;position:relative;}
.avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;}
.avatar .dot.on{background:var(--green);}
.chat-main{flex:1 1 auto;min-width:0;}
.chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
.chat-name{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;}
.chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
.chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
.chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
.chat-row.unread .chat-name{font-weight:700;}
.badge{flex:0 0 auto;background:var(--blue);color:#fff;font-size:.7rem;font-weight:700;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;}
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
/* ---- Main content ---- */
.content{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;}
.tabs{display:flex;gap:.4rem;padding:1rem 1.5rem 0;border-bottom:1px solid var(--line);background:var(--card);}
.tabs button{background:transparent;color:var(--muted);font-weight:600;font-size:.92rem;border:none;border-bottom:3px solid transparent;padding:.6rem .9rem .8rem;cursor:pointer;display:flex;align-items:center;gap:.45rem;border-radius:8px 8px 0 0;}
.tabs button:hover{color:var(--blue);background:#f6f8fb;}
.tabs button.active{color:var(--blue);border-bottom-color:var(--brand);}
.panel-wrap{flex:1 1 auto;overflow-y:auto;padding:2rem 1.5rem;display:flex;}
.panel{display:none;margin:auto;width:100%;max-width:560px;}
.panel.active{display:block;}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2.2rem;box-shadow:0 6px 18px rgba(20,30,60,.05);text-align:center;}
.feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
.feat-icon.blue{background:var(--blue-soft);color:var(--blue);}
.feat-icon.yellow{background:#fff6d8;color:var(--brand-d);}
.card h1{font-size:1.45rem;margin:0 0 .5rem;color:var(--blue);}
.card p{color:var(--muted);font-size:.95rem;line-height:1.55;margin:0 auto 1.6rem;max-width:400px;}
.btn{display:inline-flex;align-items:center;gap:.5rem;text-decoration:none;padding:.8rem 1.6rem;background:var(--brand);color:var(--ink);border:none;border-radius:11px;font-weight:700;font-size:.95rem;cursor:pointer;}
.btn:hover{background:var(--brand-d);}
.pill-soon{display:inline-block;background:#fff6d8;color:var(--brand-d);font-size:.74rem;font-weight:700;padding:.25rem .7rem;border-radius:99px;letter-spacing:.03em;margin-bottom:1.2rem;}
.hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
@media (max-width:760px){
.sidebar{width:108px;flex:0 0 108px;}
.side-title h2,.search,.chat-main{display:none;}
.chat-row{justify-content:center;}
.side-head{padding:.8rem .5rem;}
}
</style>
</head>
<body>
<header>
<div class="brandrow" id="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 class="y">Connect</span> <span class="tag">· Home</span></div>
</div>
<div id="hdrRight"></div>
</header>
<div class="shell">
<!-- ---------- Sidebar ---------- -->
<aside class="sidebar">
<div class="side-head">
<div class="side-title">
<h2>Chats</h2>
<button class="newchat" title="New chat" aria-label="New chat">+</button>
</div>
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
</div>
</div>
<div class="chatlist" id="chatlist"></div>
</aside>
<!-- ---------- Main ---------- -->
<section class="content">
<div class="tabs">
<button data-tab="meeting" class="active">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
Meeting
</button>
<button data-tab="share">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Share Screen
</button>
<button data-tab="connect">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
Connect Screen
</button>
</div>
<div class="panel-wrap">
<!-- Meeting -->
<div class="panel active" data-panel="meeting">
<div class="card">
<div class="feat-icon yellow">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
</div>
<span class="pill-soon">COMING SOON</span>
<h1>Meetings are on the way</h1>
<p>Soon you'll be able to host multi-party video meetings with your BizGaze team and customers — right here, no install needed. We're putting on the finishing touches.</p>
<button class="btn" id="notifyBtn">🔔 Notify me when it's ready</button>
<div class="hint">In the meantime, use <b>Share Screen</b> or <b>Connect Screen</b> to start a session.</div>
</div>
</div>
<!-- Share Screen -->
<div class="panel" data-panel="share">
<div class="card">
<div class="feat-icon blue">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<h1>Share your screen</h1>
<p>Let a teammate or customer see your screen instantly. You'll get a 6-digit code to share — they enter it to connect. No download, works right in the browser.</p>
<a class="btn" href="/share">Start sharing →</a>
<div class="hint">Desktop browsers only — phones can't share their screen yet.</div>
</div>
</div>
<!-- Connect Screen -->
<div class="panel" data-panel="connect">
<div class="card">
<div class="feat-icon blue">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
</div>
<h1>Connect to a screen</h1>
<p>Helping someone out? Enter the 6-digit code they give you to view their screen and provide live support — with two-way voice and chat built in.</p>
<a class="btn" href="/connect">Open connect page →</a>
<div class="hint">The other person taps <b>Allow</b> before you can see anything.</div>
</div>
</div>
</div>
</section>
</div>
<script>
// ---------- Helpers (reused patterns from console.html) ----------
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function initials(name){return name.trim().split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();}
// Stable avatar color from a name
const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d'];
function avColor(name){let h=0;for(const c of name)h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
// Profile dropdown (mirrors profileHTML()/wireProfile() from console.html)
const SAMPLE_USER={name:'Sravan Mareddy',email:'sravanm@bizgaze.com',role:'admin'};
function profileHTML(u){
return '<div class="profile"><button class="pbtn" id="pbtn">'
+ '<span class="pav">'+pEsc(initials(u.name))+'</span>'
+ pEsc(u.name)+' <span style="font-size:.65rem">&#9662;</span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(u.name)+'</div><div class="e">'+pEsc(u.email)+' · '+pEsc(u.role)+'</div></div>'
+ '<a href="/console">Console / Dashboard</a>'
+ '<a href="#">Settings</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=(e)=>{e.preventDefault();alert('Mockup — logout would sign you out and return to /.');};
}
document.getElementById('hdrRight').innerHTML=profileHTML(SAMPLE_USER);
wireProfile();
document.getElementById('brandrow').onclick=()=>{location.href='/';};
// ---------- Mock chat data ----------
const CHATS=[
{name:'Anwi Systems', msg:"Perfect, the screen share worked great. Thanks!", time:'9:42 AM', unread:0, online:true, active:true},
{name:'Priya Sharma', msg:"Can you connect to my screen at 3pm?", time:'9:15 AM', unread:2, online:true},
{name:'GAPL Group', msg:"You: I've shared the 6-digit code with you", time:'Yesterday', unread:0, online:false},
{name:'Battery Doctors', msg:"The invoice module is throwing an error again", time:'Yesterday', unread:5, online:true},
{name:'Ramesh Marketing', msg:"You: Let me know once you're at your desk", time:'Mon', unread:0, online:false},
{name:'STC Support', msg:"Typing…", time:'Mon', unread:1, online:true},
{name:'Samruddhi Traders',msg:"Thanks for the help earlier 👍", time:'Sun', unread:0, online:false},
{name:'DMS 3.0 Team', msg:"You: Closing the ticket, all resolved", time:'Fri', unread:0, online:false},
];
const listEl=document.getElementById('chatlist');
function chatRowHTML(c,i){
const cls=['chat-row'];
if(c.active)cls.push('active');
if(c.unread>0)cls.push('unread');
return '<div class="'+cls.join(' ')+'" data-i="'+i+'">'
+ '<div class="avatar" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))
+ '<span class="dot'+(c.online?' on':'')+'"></span></div>'
+ '<div class="chat-main">'
+ '<div class="chat-top"><span class="chat-name">'+pEsc(c.name)+'</span><span class="chat-time">'+pEsc(c.time)+'</span></div>'
+ '<div class="chat-bottom"><span class="chat-prev">'+pEsc(c.msg)+'</span>'
+ (c.unread>0?'<span class="badge">'+c.unread+'</span>':'')+'</div>'
+ '</div></div>';
}
function renderChats(filter){
const q=(filter||'').trim().toLowerCase();
const rows=CHATS.map((c,i)=>({c,i})).filter(({c})=>!q||c.name.toLowerCase().includes(q)||c.msg.toLowerCase().includes(q));
listEl.innerHTML = rows.length
? rows.map(({c,i})=>chatRowHTML(c,i)).join('')
: '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
listEl.querySelectorAll('.chat-row').forEach(row=>{
row.onclick=()=>{
CHATS.forEach(c=>c.active=false);
CHATS[+row.dataset.i].active=true;
CHATS[+row.dataset.i].unread=0;
renderChats(document.getElementById('chatSearch').value);
};
});
}
renderChats('');
document.getElementById('chatSearch').addEventListener('input',e=>renderChats(e.target.value));
// ---------- Tab switching ----------
const tabBtns=document.querySelectorAll('.tabs button');
const panels=document.querySelectorAll('.panel');
tabBtns.forEach(btn=>{
btn.onclick=()=>{
const tab=btn.dataset.tab;
tabBtns.forEach(b=>b.classList.toggle('active',b===btn));
panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
};
});
// Mockup-only stubs
document.querySelector('.newchat').onclick=()=>alert('Mockup — “New chat” would open the contact picker.');
document.getElementById('notifyBtn').onclick=()=>alert("Thanks! We'll let you know when Meetings launches.");
</script>
</body>
</html>
+5405 -142
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));} function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+79
View File
@@ -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>';
};
})();
+36 -22
View File
@@ -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,9 +25,9 @@
.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(--blue);color:#fff;text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(31,59,115,.28);transition:transform .12s,box-shadow .12s,background .12s;} .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(31,59,115,.34);background:var(--blue-d);} .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(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.9rem;} .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{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);} .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;}
@@ -41,29 +49,35 @@
.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"></div>
</header> </header>
<div class="wrap"> <div class="wrap">
<div class="inner"> <div class="inner">
<h1>Welcome to BizGaze Connect</h1> <h1>Welcome to Biz Connect</h1>
<div class="sub">Chat, meetings and secure remote support — for the BizGaze ecosystem.</div> <div class="sub">Chat, meetings and secure remote support — for the BizGaze ecosystem.</div>
<!-- Stub SSO: routes to staff login for now; swap href to /sso once BizGaze SSO is wired. --> <!-- Customer path FIRST (no account needed): share your screen for support. -->
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a> <div class="divider">Need support? — no account needed</div>
<div class="divider">need support? no account required</div>
<div class="choices" style="max-width:400px;margin:0 auto"> <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>
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a BizGaze support agent — no login, no download.</p></div> <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>
</a> </a>
</div> </div>
<div class="foot">🔒 Screen sharing only starts after you approve 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>
@@ -73,13 +87,13 @@ function profileHTML(name){return '<div class="profile"><button class="pbtn" id=
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(); // No "download the desktop app" link when we're already inside a native shell — the Electron desktop
document.getElementById('authArea').innerHTML=profileHTML(me.name||me.email);wireProfile(); // app (window.__NATIVE__) OR the iOS/Android Capacitor app (window.Capacitor.isNativePlatform()).
// Already signed in: swap the login CTA for an "enter app" CTA. if(window.__NATIVE__ || (window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform())){var _dl=document.getElementById('dlWin');if(_dl)_dl.style.display='none';}
const b=document.getElementById('ssoBtn'); if(b){ b.innerHTML='Open BizGaze Connect &rarr;'; b.href='/home'; } // Already signed in -> skip this landing entirely and go straight to the app (no redundant
const h=document.querySelector('.inner h1'); if(h){ const fn=String(me.name||'').trim().split(/\s+/)[0]; h.textContent='Welcome back'+(fn?', '+fn:'')+'!'; } // "Open Biz Connect" page). This landing only shows when logged out.
const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen'; (async function(){try{const r=await fetch('/api/me');if(r.ok){ location.replace('/home'); return; }}catch(_){}})();
}}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>
@@ -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

+5
View File
@@ -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

+6
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More