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>
This commit is contained in:
+41
-21
@@ -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.
|
// Resolves {open} when the toast is clicked (renderer then opens that chat), else null.
|
||||||
ipcMain.handle('reply-notification', async (_e, payload = {}) => {
|
ipcMain.handle('reply-notification', async (_e, payload = {}) => {
|
||||||
if (!Notification.isSupported()) return null;
|
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) => {
|
return await new Promise((resolve) => {
|
||||||
let done = false;
|
let done = false;
|
||||||
const finish = (v) => { if (!done) { done = true; if (img) { try { fs.unlinkSync(img); } catch (_) {} } resolve(v); } };
|
let n;
|
||||||
try {
|
const finish = (v) => {
|
||||||
const n = new Notification({
|
if (done) return; done = true;
|
||||||
title: payload.title || 'Biz Connect',
|
if (n) { activeNotifs.delete(n); }
|
||||||
body: payload.body || '',
|
if (img) { try { fs.unlinkSync(img); } catch (_) {} }
|
||||||
icon: img ? nativeImage.createFromPath(img) : undefined,
|
resolve(v);
|
||||||
silent: false,
|
};
|
||||||
});
|
try {
|
||||||
n.on('click', () => {
|
n = new Notification({
|
||||||
if (win) { if (win.isMinimized()) win.restore(); win.show(); win.focus(); } // raise the app
|
title: payload.title || 'Biz Connect',
|
||||||
finish({ kind: payload.kind, id: payload.id, open: true });
|
body: payload.body || '',
|
||||||
});
|
icon: img ? nativeImage.createFromPath(img) : undefined,
|
||||||
n.on('close', () => finish(null));
|
silent: false,
|
||||||
n.show();
|
// A call invite stays on screen until clicked/ended; a chat toast uses the default timeout.
|
||||||
setTimeout(() => finish(null), 30000); // don't leave the promise pending forever
|
timeoutType: payload.persistent ? 'never' : 'default',
|
||||||
} catch (_) { finish(null); }
|
});
|
||||||
|
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() {
|
function configureSession() {
|
||||||
const ses = session.fromPartition('persist:bizconnect');
|
const ses = session.fromPartition('persist:bizconnect');
|
||||||
// getDisplayMedia needs an explicit source. Default to the primary display + loopback audio.
|
// getDisplayMedia: prefer the OS's native screen/window PICKER (Windows 11 / macOS) so the user
|
||||||
// A production build can swap this for a source-picker window.
|
// 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) => {
|
ses.setDisplayMediaRequestHandler((request, callback) => {
|
||||||
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
|
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
|
||||||
callback(sources.length ? { video: sources[0], audio: 'loopback' } : {});
|
callback(sources.length ? { video: sources[0], audio: 'loopback' } : {});
|
||||||
}).catch(() => callback({}));
|
}).catch(() => callback({}));
|
||||||
}, { useSystemPicker: false });
|
}, { useSystemPicker: true });
|
||||||
// Async grant (getUserMedia, notifications, …)
|
// Async grant (getUserMedia, notifications, …)
|
||||||
ses.setPermissionRequestHandler((_wc, permission, callback) => callback(GRANTED.has(permission)));
|
ses.setPermissionRequestHandler((_wc, permission, callback) => callback(GRANTED.has(permission)));
|
||||||
// Sync check (some getUserMedia paths query this before requesting)
|
// Sync check (some getUserMedia paths query this before requesting)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "biz-connect-desktop",
|
"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",
|
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "BizGaze",
|
"name": "BizGaze",
|
||||||
|
|||||||
@@ -827,7 +827,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=5"></script>
|
<script src="/icons.js?v=5"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||||
<script>window.__BUILD='2026-07-08-batch57';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-08-batch58';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||||
// (emojis stay as plain Unicode). (#5)
|
// (emojis stay as plain Unicode). (#5)
|
||||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||||
@@ -1764,7 +1764,7 @@ function showCallInvite(room, byName, ret, sub){
|
|||||||
+'<button class="ci-join">'+ic('video',16)+' Join</button>'
|
+'<button class="ci-join">'+ic('video',16)+' Join</button>'
|
||||||
+'<button class="ci-decline" title="Decline">'+ic('callEnd',16)+' Decline</button>';
|
+'<button class="ci-decline" title="Decline">'+ic('callEnd',16)+' Decline</button>';
|
||||||
document.body.appendChild(el);
|
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;
|
let closed=false;
|
||||||
const close=()=>{ if(closed) return; closed=true; try{ el.remove(); }catch(_){} stopRing(); };
|
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); };
|
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.
|
// 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(_){}
|
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{
|
try{
|
||||||
// Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell
|
// 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).
|
// downloads it for the icon (drawing an external DP to a canvas here tainted it → initials).
|
||||||
if(window.bizConnectNative && window.bizConnectNative.replyNotify){
|
if(window.bizConnectNative && window.bizConnectNative.replyNotify){
|
||||||
const _av=(rowFor(kind,id)||{}).avatar||null;
|
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;
|
.then(r=>{ if(!r) return;
|
||||||
if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast
|
if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast
|
||||||
else if(r.open) openFromNotif(kind, id); // clicked the toast
|
else if(r.open) openFromNotif(kind, id); // clicked the toast
|
||||||
|
|||||||
Reference in New Issue
Block a user