diff --git a/desktop/main.js b/desktop/main.js
index 72b4f4e..394c635 100644
--- a/desktop/main.js
+++ b/desktop/main.js
@@ -8,7 +8,7 @@
// - 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, ipcMain, nativeImage, Notification } = require('electron');
+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');
@@ -190,6 +190,30 @@ const SERVER_URL = (process.env.SERVER_URL || (app.isPackaged ? 'https://remote.
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 },
+ { 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.
@@ -233,6 +257,18 @@ function createWindow() {
win.once('ready-to-show', reveal);
setTimeout(reveal, 12000);
+ // 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 + '/');
@@ -376,12 +412,22 @@ function configureSession() {
} 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(); });
+ 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 /
@@ -405,4 +451,6 @@ app.whenReady().then(() => {
}
});
-app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
+// 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(); });
diff --git a/desktop/package.json b/desktop/package.json
index 8791d25..f04de2b 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "biz-connect-desktop",
- "version": "0.1.13",
+ "version": "0.1.14",
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
"author": {
"name": "BizGaze",
diff --git a/desktop/tray.ico b/desktop/tray.ico
new file mode 100644
index 0000000..61ab853
Binary files /dev/null and b/desktop/tray.ico differ
diff --git a/server/db.js b/server/db.js
index 48961c5..96a1ac5 100644
--- a/server/db.js
+++ b/server/db.js
@@ -228,6 +228,9 @@ try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e)
// 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.
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 */ }
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
diff --git a/server/public/connect.html b/server/public/connect.html
index 34805d1..a69a3d4 100644
--- a/server/public/connect.html
+++ b/server/public/connect.html
@@ -409,6 +409,11 @@ video.addEventListener('contextmenu',e=>e.preventDefault());
function rcTyping(){ const a=document.activeElement; return a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA'); }
document.addEventListener('keydown',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keydown',key:e.key,code:e.code}); });
document.addEventListener('keyup',e=>{ if(!video||video.style.display!=='block'||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'}));};
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
diff --git a/server/public/home.html b/server/public/home.html
index 46903ae..f85529d 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -627,6 +627,16 @@
.convo-call span{font-size:.84rem;}
.call-on{display:inline-flex;align-items:center;gap:.3rem;color:#15803d;font-weight:600;}
.call-invite{position:fixed;right:18px;bottom:18px;z-index:6000;display:flex;align-items:center;gap:.7rem;background:#fff;border:1px solid var(--line);border-left:4px solid #15803d;border-radius:14px;padding:.7rem .9rem;box-shadow:0 12px 30px rgba(20,30,60,.25);max-width:340px;}
+ /* #4 lobby request stacks above call invites, tinted blue; multiple stack upward */
+ .call-invite.lobby-req{border-left-color:var(--blue);bottom:auto;top:18px;}
+ .call-invite.lobby-req .ci-ico{background:var(--blue-soft);color:var(--blue);}
+ /* #5 speaker output menu */
+ .spk-menu{position:fixed;z-index:9700;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 34px rgba(20,30,60,.28);padding:.35rem;min-width:220px;max-width:300px;}
+ .spk-menu .spk-h{display:flex;align-items:center;gap:.35rem;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);padding:.35rem .5rem;}
+ .spk-menu .spk-opt{display:block;width:100%;text-align:left;border:none;background:transparent;font:inherit;font-size:.86rem;color:var(--ink);padding:.5rem .6rem;border-radius:8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+ .spk-menu .spk-opt:hover{background:var(--blue-soft);}
+ .spk-menu .spk-opt.on{color:var(--blue);font-weight:600;}
+ .spk-menu .spk-empty{font-size:.8rem;color:var(--muted);padding:.5rem .6rem;line-height:1.4;}
.call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;}
.call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
.call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
@@ -884,7 +894,7 @@