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:
2026-07-08 15:43:07 +05:30
parent 0976b6f91a
commit 7682e17a53
3 changed files with 47 additions and 26 deletions
+41 -21
View File
@@ -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)