Files
BizGaze_Remote/desktop/main.js
T
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

393 lines
22 KiB
JavaScript

// 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, 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 */ }
// #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); }
});
}
// 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;
// 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);
// 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 + '/');
// Open target=_blank / external links in the system browser instead of a new Electron window.
win.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(SERVER_URL)) { shell.openExternal(url); return { action: 'deny' }; }
return { action: 'allow' };
});
// 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 (_) {}
}
app.whenReady().then(() => {
configureSession();
createSplash();
createWindow();
Menu.setApplicationMenu(null); // hide the default menu bar; the web UI is the chrome
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
// 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 }));
// 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'.
const check = () => autoUpdater.checkForUpdates().catch(() => {});
check();
setInterval(check, 6 * 60 * 60 * 1000);
}
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });