2026-06-30 17:49:41 +05:30
// 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.
2026-07-11 14:44:58 +05:30
const { app , BrowserWindow , session , desktopCapturer , shell , Menu , MenuItem , Tray , ipcMain , nativeImage , Notification } = require ( 'electron' );
2026-06-30 17:49:41 +05:30
const path = require ( 'path' );
2026-07-01 21:28:53 +05:30
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 () };
});
2026-07-01 17:03:18 +05:30
// 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 */ }
2026-07-07 16:12:21 +05:30
// #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 }; }
});
2026-07-08 15:58:09 +05:30
// #3: restart-and-install, triggered from the web update banner's "Restart" button.
ipcMain . handle ( 'restart-to-update' , () => { try { if ( autoUpdater ) autoUpdater . quitAndInstall (); } catch ( _ ) {} });
2026-07-02 11:16:35 +05:30
// 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.
2026-07-02 00:06:01 +05:30
const APP_ID = 'com.bizgaze.connect.desktop' ;
2026-07-01 22:36:02 +05:30
2026-07-02 18:01:32 +05:30
// 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' ); }
2026-07-08 16:56:39 +05:30
// 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 ();
2026-07-02 18:01:32 +05:30
function avatarToTempPng ( src ) {
return new Promise (( resolve ) => {
try {
if ( ! src ) return resolve ( null );
2026-07-08 16:56:39 +05:30
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 ); }
2026-07-02 18:01:32 +05:30
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 ); }
2026-07-08 16:56:39 +05:30
res . pipe ( file ); file . on ( 'finish' , () => file . close (() => { avatarCache . set ( src , p ); resolve ( p ); }));
2026-07-02 18:01:32 +05:30
});
req . on ( 'error' , () => resolve ( null ));
2026-07-08 16:56:39 +05:30
req . setTimeout ( 4000 , () => { try { req . destroy (); } catch ( _ ) {} resolve ( null ); });
2026-07-02 18:01:32 +05:30
return ;
}
resolve ( null );
} catch ( _ ) { resolve ( null ); }
});
2026-07-01 23:26:24 +05:30
}
2026-07-02 08:58:26 +05:30
2026-07-14 13:38:55 +05:30
// ---- 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 ; });
2026-07-10 16:30:39 +05:30
// ---- 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 ); });
2026-07-08 18:00:56 +05:30
// 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 ;
});
2026-07-08 15:43:07 +05:30
// 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 ();
2026-07-02 11:16:35 +05:30
// Resolves {open} when the toast is clicked (renderer then opens that chat), else null.
2026-07-02 18:01:32 +05:30
ipcMain . handle ( 'reply-notification' , async ( _e , payload = {}) => {
if ( ! Notification . isSupported ()) return null ;
2026-07-08 18:00:56 +05:30
// 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 {
2026-07-10 14:57:02 +05:30
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
}
2026-07-08 18:00:56 +05:30
} catch ( _ ) {}
2026-07-02 18:01:32 +05:30
return await new Promise (( resolve ) => {
2026-07-08 15:43:07 +05:30
let done = false ;
let n ;
const finish = ( v ) => {
if ( done ) return ; done = true ;
if ( n ) { activeNotifs . delete ( n ); }
2026-07-08 16:56:39 +05:30
resolve ( v ); // note: img is cached, not deleted
2026-07-08 15:43:07 +05:30
};
try {
2026-07-08 16:56:39 +05:30
// 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.
2026-07-08 15:43:07 +05:30
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 });
});
2026-07-08 16:56:39 +05:30
n . on ( 'close' , () => finish ( null )); // user/system dismissed it → no action
2026-07-08 15:43:07 +05:30
n . show ();
2026-07-08 16:56:39 +05:30
setTimeout (() => finish ( null ), payload . persistent ? 45000 : 25000 ); // don't leak the promise
2026-07-08 15:43:07 +05:30
} catch ( _ ) { finish ( null ); }
2026-07-02 18:01:32 +05:30
});
});
2026-06-30 17:49:41 +05:30
2026-07-01 18:19:01 +05:30
// 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' );
2026-07-01 16:16:40 +05:30
// 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 ();
});
2026-07-01 16:28:15 +05:30
// 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 ( _ ) {}
});
2026-07-02 15:43:02 +05:30
// 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 ( /\/+$/ , '' );
2026-06-30 17:49:41 +05:30
let win ;
2026-07-02 15:43:02 +05:30
let splash ;
2026-07-11 14:44:58 +05:30
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 },
2026-07-14 13:38:55 +05:30
{ label : 'Refresh app (get latest)' , click : () => { showApp (); hardReloadWin (); } },
2026-07-11 14:44:58 +05:30
{ type : 'separator' },
{ label : 'Quit' , click : () => { isQuitting = true ; app . quit (); } },
]));
tray . on ( 'click' , showApp ); // single-click (Windows)
tray . on ( 'double-click' , showApp );
} catch ( _ ) { tray = null ; }
}
2026-07-02 15:43:02 +05:30
// 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 ; } }
2026-06-30 17:49:41 +05:30
function createWindow () {
win = new BrowserWindow ({
width : 1200 ,
height : 800 ,
minWidth : 880 ,
minHeight : 600 ,
title : 'Biz Connect' ,
2026-07-02 15:43:02 +05:30
backgroundColor : '#1F3B73' ,
show : false , // reveal only once the page is ready — the splash covers the gap
2026-06-30 17:49:41 +05:30
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' ,
2026-07-10 12:51:14 +05:30
// 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 ,
2026-06-30 17:49:41 +05:30
},
});
2026-07-02 15:43:02 +05:30
// 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 );
2026-07-14 13:38:55 +05:30
// 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
}
});
2026-07-11 14:44:58 +05:30
// 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 ( _ ) {}
}
});
2026-07-01 17:55:49 +05:30
// 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 + '/' );
2026-06-30 17:49:41 +05:30
2026-07-13 22:24:36 +05:30
// 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.
2026-06-30 17:49:41 +05:30
win . webContents . setWindowOpenHandler (({ url }) => {
if ( ! url . startsWith ( SERVER_URL )) { shell . openExternal ( url ); return { action : 'deny' }; }
2026-07-13 22:24:36 +05:30
try { if ( win && ! win . isDestroyed ()) { if ( win . isMinimized ()) win . restore (); win . show (); win . focus (); win . loadURL ( url ); } } catch ( _ ) {}
return { action : 'deny' };
2026-06-30 17:49:41 +05:30
});
2026-07-10 13:25:37 +05:30
2026-07-10 14:57:02 +05:30
// 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.
2026-07-10 13:25:37 +05:30
win . webContents . on ( 'context-menu' , ( _e , params ) => {
2026-07-10 14:57:02 +05:30
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 ;
}
2026-07-10 13:25:37 +05:30
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 ();
});
2026-06-30 17:49:41 +05:30
}
2026-07-10 14:57:02 +05:30
// 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 ; }
});
2026-06-30 17:49:41 +05:30
2026-07-01 16:16:40 +05:30
// 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' ,
]);
2026-07-08 17:38:50 +05:30
// 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 ));
});
}
2026-07-01 16:16:40 +05:30
function configureSession () {
const ses = session . fromPartition ( 'persist:bizconnect' );
2026-07-08 17:38:50 +05:30
// 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.
2026-07-01 16:16:40 +05:30
ses . setDisplayMediaRequestHandler (( request , callback ) => {
2026-07-08 17:38:50 +05:30
pickShareSource (). then (( source ) => {
callback ( source ? { video : source , audio : 'loopback' } : {}); // {} = user cancelled → no share
2026-06-30 17:49:41 +05:30
}). catch (() => callback ({}));
2026-07-08 17:38:50 +05:30
}, { useSystemPicker : false });
2026-07-01 16:16:40 +05:30
// 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 ));
2026-07-10 13:25:37 +05:30
// 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 ( _ ) {}
2026-07-19 10:38:36 +05:30
// 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 );
} catch ( _ ) {}
});
2026-06-30 17:49:41 +05:30
}
2026-07-11 14:44:58 +05:30
// 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 ; });
2026-06-30 17:49:41 +05:30
app . whenReady (). then (() => {
2026-07-01 16:16:40 +05:30
configureSession ();
2026-07-02 15:43:02 +05:30
createSplash ();
2026-06-30 17:49:41 +05:30
createWindow ();
2026-07-11 14:44:58 +05:30
createTray (); // keep the app reachable while its window is hidden to tray
2026-06-30 17:49:41 +05:30
Menu . setApplicationMenu ( null ); // hide the default menu bar; the web UI is the chrome
2026-07-11 14:44:58 +05:30
app . on ( 'activate' , () => { if ( BrowserWindow . getAllWindows (). length === 0 ) createWindow (); else if ( win ) { win . show (); win . focus (); } });
2026-07-01 17:03:18 +05:30
// Check for shell updates on launch, then every 6 hours. Only in packaged builds.
if ( app . isPackaged && autoUpdater ) {
2026-07-08 15:58:09 +05:30
// #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' }));
2026-07-08 16:56:39 +05:30
// 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 }));
2026-07-08 17:38:50 +05:30
// 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 (() => {});
2026-07-01 17:03:18 +05:30
check ();
setInterval ( check , 6 * 60 * 60 * 1000 );
}
2026-06-30 17:49:41 +05:30
});
2026-07-11 14:44:58 +05:30
// 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 (); });