fix(desktop): custom screen picker, single restart prompt, reliable call Join (0.1.8/batch61)
- Custom branded screen/window picker (picker.html) — useSystemPicker was silently no-op on Win11 and auto-shared the primary display with no choice. - Use checkForUpdates (not ...AndNotify): drops electron-updater's own native "Update ready" toast that duplicated our in-app banner (two restart prompts). - Call notification DP: wait up to 2.5s for the caller photo on persistent (call) toasts instead of 600ms so the DP actually shows. - Re-surface Join/Decline invite when opening a chat with an active incoming call, so a call-notification click always lands on a joinable call. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+59
-9
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||
<title>Choose what to share</title>
|
||||
<style>
|
||||
:root{ --brand:#1F3B73; --bg:#f4f6fb; --card:#fff; --line:#e3e8f2; --muted:#64748b; }
|
||||
*{ box-sizing:border-box; }
|
||||
html,body{ margin:0; height:100%; font-family:'Segoe UI',system-ui,sans-serif; background:var(--bg); color:#0f172a; }
|
||||
.wrap{ display:flex; flex-direction:column; height:100%; }
|
||||
header{ padding:16px 20px 10px; }
|
||||
header h1{ margin:0; font-size:17px; font-weight:700; color:var(--brand); }
|
||||
header p{ margin:3px 0 0; font-size:12.5px; color:var(--muted); }
|
||||
.tabs{ display:flex; gap:6px; padding:8px 20px 0; }
|
||||
.tab{ border:0; background:transparent; font:inherit; font-size:13px; font-weight:600; color:var(--muted); padding:7px 12px; border-radius:8px 8px 0 0; cursor:pointer; }
|
||||
.tab.on{ color:var(--brand); background:var(--card); box-shadow:inset 0 -2px 0 var(--brand); }
|
||||
.grid{ flex:1; overflow:auto; display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:12px; padding:14px 20px; align-content:start; }
|
||||
.src{ background:var(--card); border:1.5px solid var(--line); border-radius:12px; padding:8px; cursor:pointer; text-align:left; font:inherit; transition:border-color .12s, transform .08s; overflow:hidden; }
|
||||
.src:hover{ border-color:var(--brand); transform:translateY(-1px); }
|
||||
.src.sel{ border-color:var(--brand); box-shadow:0 0 0 2px rgba(31,59,115,.18); }
|
||||
.thumb{ width:100%; height:112px; border-radius:8px; background:#0b1220; object-fit:contain; display:block; }
|
||||
.meta{ display:flex; align-items:center; gap:7px; padding:8px 4px 2px; }
|
||||
.appic{ width:18px; height:18px; border-radius:4px; flex:0 0 auto; }
|
||||
.nm{ font-size:12.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.empty{ grid-column:1/-1; text-align:center; color:var(--muted); font-size:13px; padding:40px 0; }
|
||||
footer{ display:flex; justify-content:flex-end; gap:10px; padding:12px 20px; border-top:1px solid var(--line); background:var(--card); }
|
||||
button.act{ font:inherit; font-size:13.5px; font-weight:600; padding:9px 18px; border-radius:9px; cursor:pointer; border:1.5px solid var(--line); background:#fff; color:#334155; }
|
||||
button.act.primary{ background:var(--brand); border-color:var(--brand); color:#fff; }
|
||||
button.act.primary:disabled{ opacity:.45; cursor:default; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>Share your screen</h1>
|
||||
<p>Choose a screen or a window to share with the call.</p>
|
||||
</header>
|
||||
<div class="tabs">
|
||||
<button class="tab on" data-t="screen">Entire screen</button>
|
||||
<button class="tab" data-t="window">Application window</button>
|
||||
</div>
|
||||
<div class="grid" id="grid"></div>
|
||||
<footer>
|
||||
<button class="act" id="cancel">Cancel</button>
|
||||
<button class="act primary" id="share" disabled>Share</button>
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
const { ipcRenderer } = require('electron');
|
||||
let SOURCES = { screen:[], window:[] };
|
||||
let tab='screen', selected=null;
|
||||
|
||||
ipcRenderer.on('picker-sources', (_e, data)=>{ SOURCES=data||{screen:[],window:[]}; render(); });
|
||||
|
||||
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
|
||||
tab=b.dataset.t; selected=null; document.getElementById('share').disabled=true;
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on', x===b));
|
||||
render();
|
||||
});
|
||||
document.getElementById('cancel').onclick=()=>ipcRenderer.send('picker-choose', null);
|
||||
document.getElementById('share').onclick=()=>{ if(selected) ipcRenderer.send('picker-choose', selected); };
|
||||
window.addEventListener('keydown', e=>{ if(e.key==='Escape') ipcRenderer.send('picker-choose', null); });
|
||||
|
||||
function render(){
|
||||
const grid=document.getElementById('grid'); grid.innerHTML='';
|
||||
const list=SOURCES[tab]||[];
|
||||
if(!list.length){ grid.innerHTML='<div class="empty">Nothing available to share here.</div>'; return; }
|
||||
list.forEach(s=>{
|
||||
const card=document.createElement('button'); card.className='src'; card.dataset.id=s.id;
|
||||
const ap = s.appIcon ? '<img class="appic" src="'+s.appIcon+'">' : '';
|
||||
card.innerHTML='<img class="thumb" src="'+s.thumb+'">'
|
||||
+ '<div class="meta">'+ap+'<span class="nm">'+esc(s.name||'Untitled')+'</span></div>';
|
||||
card.onclick=()=>{ selected=s.id; document.getElementById('share').disabled=false;
|
||||
document.querySelectorAll('.src').forEach(x=>x.classList.toggle('sel', x===card)); };
|
||||
card.ondblclick=()=>{ selected=s.id; ipcRenderer.send('picker-choose', selected); };
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+13
-5
@@ -840,7 +840,7 @@
|
||||
<body>
|
||||
<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>window.__BUILD='2026-07-08-batch60';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-08-batch61';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
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||
@@ -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?'<span>Join</span>':''); }
|
||||
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){
|
||||
+'<button class="ci-join">'+ic('video',16)+' Join</button>'
|
||||
+'<button class="ci-decline" title="Decline">'+ic('callEnd',16)+' Decline</button>';
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user