diff --git a/desktop/main.js b/desktop/main.js index fe364b2..627a8ce 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -91,10 +91,12 @@ const activeNotifs = new Set(); ipcMain.handle('reply-notification', async (_e, payload = {}) => { if (!Notification.isSupported()) return null; // 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. + // browser's instant one). Race it against a cap: use the DP only if it's ready in time. Chat toasts + // stay snappy (700ms). CALL toasts (persistent) ring for ~40s, so we can afford to wait longer + // (2.5s) to actually show the caller's photo — the whole point of a call notification. const img = await Promise.race([ avatarToTempPng(payload.avatar), - new Promise((r) => setTimeout(() => r(null), 600)), + new Promise((r) => setTimeout(() => r(null), payload.persistent ? 2500 : 700)), ]); return await new Promise((resolve) => { let done = false; @@ -219,16 +221,61 @@ const GRANTED = new Set([ '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: 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. + // 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) => { - desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => { - callback(sources.length ? { video: sources[0], audio: 'loopback' } : {}); + pickShareSource().then((source) => { + callback(source ? { video: source, audio: 'loopback' } : {}); // {} = user cancelled → no share }).catch(() => callback({})); - }, { useSystemPicker: true }); + }, { useSystemPicker: false }); // Async grant (getUserMedia, notifications, …) ses.setPermissionRequestHandler((_wc, permission, callback) => callback(GRANTED.has(permission))); // Sync check (some getUserMedia paths query this before requesting) @@ -255,7 +302,10 @@ app.whenReady().then(() => { // 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 })); - const check = () => autoUpdater.checkForUpdatesAndNotify().catch(() => {}); + // 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); } diff --git a/desktop/package.json b/desktop/package.json index 3c0b3d8..d3e8336 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "biz-connect-desktop", - "version": "0.1.7", + "version": "0.1.8", "description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture", "author": { "name": "BizGaze", diff --git a/desktop/picker.html b/desktop/picker.html new file mode 100644 index 0000000..d84df27 --- /dev/null +++ b/desktop/picker.html @@ -0,0 +1,83 @@ + + + + + +Choose what to share + + + +
+
+

Share your screen

+

Choose a screen or a window to share with the call.

+
+
+ + +
+
+ +
+ + + diff --git a/server/public/home.html b/server/public/home.html index e37edd7..b2e68c5 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -840,7 +840,7 @@ - @@ -1749,7 +1749,8 @@ async function startOrJoinGroupCall(group){ } function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'Join':''); } function onGroupCall(d){ - if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; } + if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; + if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active); if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing @@ -1773,7 +1774,8 @@ function onPresence(d){ } } function onDmCall(d){ - if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; } // #4: drop the stuck "in call" status + if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; // #4: drop the stuck "in call" status + if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing @@ -1782,7 +1784,7 @@ function onDmCall(d){ // Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss. // ret: where to land when the call ends (the originating chat), or null for the meetings tab. // sub: a second line under the caller — e.g. the group name for a group call. -function showCallInvite(room, byName, ret, sub){ +function showCallInvite(room, byName, ret, sub, quiet){ if(!room || document.getElementById('ci-'+room)) return; startRing(); const who=byName||'Someone'; @@ -1792,7 +1794,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, {persistent:true}); }catch(_){} // OS notification too (stays until clicked/ended) + if(!quiet){ try{ notify('📞 '+who, (sub?('Group call · '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id, {persistent:true}); }catch(_){} } // OS notification too (skip when merely re-showing on chat open) 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); }; @@ -1973,6 +1975,12 @@ async function openConvo(kind,id){ const ci=document.getElementById('convoInfo'); if(ci) ci.onclick=()=>openGroupInfo(id); const ct=document.getElementById('convoTitle'); if(ct) ct.onclick=()=>{ if(kind==='group') openGroupInfo(id); else { const r=rowFor(kind,id); openSharedItems('dm', id, (r&&r.name)||it.name||''); } }; // name → group: info+media; DM: media/files const cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id)); + // If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat + // is opened from a call notification, the original transient invite popup may already have closed — + // this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification. + if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom)){ + showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true); + } const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput'); if(csb) csb.onclick=()=>{ if(!cshead) return; cshead.style.display='flex'; if(csin){ csin.value=''; setTimeout(()=>csin.focus(),0); } }; if(csin) csin.oninput=()=>runSearch(csin.value);