diff --git a/desktop/main.js b/desktop/main.js index caaafbc..14a1e0f 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -75,28 +75,47 @@ function avatarToTempPng(src) { }); } +// 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; - const img = await avatarToTempPng(payload.avatar); + // Do NOT block the toast on the avatar download (that made desktop notifications lag ~15s vs the + // browser's instant one). Race it against a short cap: use the DP only if it's ready fast. + const img = await Promise.race([ + avatarToTempPng(payload.avatar), + new Promise((r) => setTimeout(() => r(null), 600)), + ]); return await new Promise((resolve) => { - let done = false; - const finish = (v) => { if (!done) { done = true; if (img) { try { fs.unlinkSync(img); } catch (_) {} } resolve(v); } }; - try { - const n = new Notification({ - title: payload.title || 'Biz Connect', - body: payload.body || '', - icon: img ? nativeImage.createFromPath(img) : undefined, - silent: false, - }); - 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)); - n.show(); - setTimeout(() => finish(null), 30000); // don't leave the promise pending forever - } catch (_) { finish(null); } + let done = false; + let n; + const finish = (v) => { + if (done) return; done = true; + if (n) { activeNotifs.delete(n); } + if (img) { try { fs.unlinkSync(img); } catch (_) {} } + resolve(v); + }; + try { + n = new Notification({ + title: payload.title || 'Biz Connect', + body: payload.body || '', + icon: img ? nativeImage.createFromPath(img) : undefined, + silent: false, + // A call invite stays on screen until clicked/ended; a chat toast uses the default timeout. + timeoutType: payload.persistent ? 'never' : 'default', + }); + 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 (don't force-close) + n.show(); + // Safety timeout so the promise never leaks. Calls get the full ring window; chats shorter. + setTimeout(() => { try { if (n) n.close(); } catch (_) {} finish(null); }, payload.persistent ? 45000 : 25000); + } catch (_) { finish(null); } }); }); @@ -195,13 +214,14 @@ const GRANTED = new Set([ function configureSession() { const ses = session.fromPartition('persist:bizconnect'); - // getDisplayMedia needs an explicit source. Default to the primary display + loopback audio. - // A production build can swap this for a source-picker window. + // getDisplayMedia: prefer the OS's native screen/window PICKER (Windows 11 / macOS) so the user + // chooses what to share (and can pick a single window, avoiding the whole-screen mirror). If the + // system picker isn't available, this handler falls back to auto-selecting the primary display. ses.setDisplayMediaRequestHandler((request, callback) => { desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => { callback(sources.length ? { video: sources[0], audio: 'loopback' } : {}); }).catch(() => callback({})); - }, { useSystemPicker: false }); + }, { useSystemPicker: true }); // Async grant (getUserMedia, notifications, โฆ) ses.setPermissionRequestHandler((_wc, permission, callback) => callback(GRANTED.has(permission))); // Sync check (some getUserMedia paths query this before requesting) diff --git a/desktop/package.json b/desktop/package.json index 6b12b58..bdee320 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "biz-connect-desktop", - "version": "0.1.5", + "version": "0.1.6", "description": "Biz Connect technician desktop client โ loads the Connect web UI with native screen capture", "author": { "name": "BizGaze", diff --git a/server/public/home.html b/server/public/home.html index d7f36ed..0e901c2 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -827,7 +827,7 @@
- @@ -1764,7 +1764,7 @@ function showCallInvite(room, byName, ret, sub){ +'' +''; document.body.appendChild(el); - try{ notify('๐ '+who, (sub?('Group call ยท '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id); }catch(_){} // OS notification too + try{ notify('๐ '+who, (sub?('Group call ยท '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id, {persistent:true}); }catch(_){} // OS notification too (stays until clicked/ended) let closed=false; const close=()=>{ if(closed) return; closed=true; try{ el.remove(); }catch(_){} stopRing(); }; el.querySelector('.ci-join').onclick=()=>{ close(); meetReturn=ret||null; switchTab('meeting'); enterMeeting(room); }; @@ -2344,13 +2344,14 @@ function onNotifClear(d){ // Drop matching activity-center entries so the bell badge stays in sync. try{ if(Array.isArray(NOTIFS)){ const before=NOTIFS.length; NOTIFS=NOTIFS.filter(x=>!(x.link&&x.link.kind===d.kind&&x.link.id===d.id)); if(NOTIFS.length!==before){ saveNotifs&&saveNotifs(); updateBellBadge&&updateBellBadge(); } } }catch(_){} } -function notify(title, body, kind, id){ +function notify(title, body, kind, id, opts){ + const persistent=!!(opts&&opts.persistent); try{ // Desktop app: native toast with the sender/group DP. Pass the DP URL directly โ the shell // downloads it for the icon (drawing an external DP to a canvas here tainted it โ initials). if(window.bizConnectNative && window.bizConnectNative.replyNotify){ const _av=(rowFor(kind,id)||{}).avatar||null; - Promise.resolve(window.bizConnectNative.replyNotify({title, body, kind, id, avatar:_av})) + Promise.resolve(window.bizConnectNative.replyNotify({title, body, kind, id, avatar:_av, persistent})) .then(r=>{ if(!r) return; if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast else if(r.open) openFromNotif(kind, id); // clicked the toast