feat: detect new web builds + hard-refresh escape hatch (0.1.17/batch78)
Root cause of "the fix works on mobile but not on desktop/web": nothing was wrong with the code — the desktop app now CLOSES TO TRAY, so it can run for weeks on the page it loaded on day one and never re-fetch after a deploy. There was also no way to force it off a stale page (no menu bar → no reload accelerator). - Server: GET /api/build returns home.html's __BUILD marker. - Client: polls it (boot, on focus/visibility, every 10 min); when the server's build differs from the running one, shows a branded "A new version is available — Refresh" banner. Settings gains an always-available "Refresh app" with the current build shown. - hardReloadApp(): in the browser it unregisters service workers + clears CacheStorage then reloads cache-busted; in the shell it calls the native hard reload. - Desktop: hard-reload IPC (clears the session HTTP cache + reloadIgnoringCache), wired to Ctrl+R (reload), Ctrl+Shift+R / F5 (hard reload), and a "Refresh app (get latest)" tray item. Previously there was literally no way to clear the cache from the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,17 @@ function avatarToTempPng(src) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Hard refresh -------------------------------------------------------------------------------
|
||||||
|
// The app closes to TRAY, so it can run for weeks on the page it first loaded and never see a new web
|
||||||
|
// deploy. This clears the shell's HTTP cache and reloads ignoring cache, so a stale UI is always
|
||||||
|
// recoverable. Reachable from: the in-app "Refresh" banner / Settings, Ctrl+R (reload),
|
||||||
|
// Ctrl+Shift+R or F5 (hard reload), and the tray menu.
|
||||||
|
async function hardReloadWin() {
|
||||||
|
try { await session.fromPartition('persist:bizconnect').clearCache(); } catch (_) {}
|
||||||
|
try { if (win && !win.isDestroyed()) win.webContents.reloadIgnoringCache(); } catch (_) {}
|
||||||
|
}
|
||||||
|
ipcMain.handle('hard-reload', async () => { await hardReloadWin(); return true; });
|
||||||
|
|
||||||
// ---- Remote control: OS input injection for a screen the local user is SHARING ----
|
// ---- Remote control: OS input injection for a screen the local user is SHARING ----
|
||||||
// The renderer (share flow) forwards a viewer's mouse/keyboard events here for injection. Injection is
|
// The renderer (share flow) forwards a viewer's mouse/keyboard events here for injection. Injection is
|
||||||
// HARD-GATED behind an explicit consent flag (rcArmed): nothing is injected until the local user clicks
|
// HARD-GATED behind an explicit consent flag (rcArmed): nothing is injected until the local user clicks
|
||||||
@@ -207,6 +218,7 @@ function createTray() {
|
|||||||
const showApp = () => { if (!win) return createWindow(); if (win.isMinimized()) win.restore(); win.show(); win.focus(); };
|
const showApp = () => { if (!win) return createWindow(); if (win.isMinimized()) win.restore(); win.show(); win.focus(); };
|
||||||
tray.setContextMenu(Menu.buildFromTemplate([
|
tray.setContextMenu(Menu.buildFromTemplate([
|
||||||
{ label: 'Open Biz Connect', click: showApp },
|
{ label: 'Open Biz Connect', click: showApp },
|
||||||
|
{ label: 'Refresh app (get latest)', click: () => { showApp(); hardReloadWin(); } },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'Quit', click: () => { isQuitting = true; app.quit(); } },
|
{ label: 'Quit', click: () => { isQuitting = true; app.quit(); } },
|
||||||
]));
|
]));
|
||||||
@@ -257,6 +269,19 @@ function createWindow() {
|
|||||||
win.once('ready-to-show', reveal);
|
win.once('ready-to-show', reveal);
|
||||||
setTimeout(reveal, 12000);
|
setTimeout(reveal, 12000);
|
||||||
|
|
||||||
|
// The menu bar is hidden, so the usual reload accelerators don't exist — wire them by hand. Without
|
||||||
|
// these there was literally no way to force the app off a stale page.
|
||||||
|
win.webContents.on('before-input-event', (e, input) => {
|
||||||
|
if (input.type !== 'keyDown') return;
|
||||||
|
const k = String(input.key || '').toLowerCase();
|
||||||
|
const mod = input.control || input.meta;
|
||||||
|
if ((mod && k === 'r') || k === 'f5') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (input.shift || k === 'f5') hardReloadWin(); // hard: clear cache + reload
|
||||||
|
else { try { win.webContents.reload(); } catch (_) {} } // plain reload
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Close = hide to tray (keep running for notifications). First time, tell the user where it went.
|
// Close = hide to tray (keep running for notifications). First time, tell the user where it went.
|
||||||
let toldTray = false;
|
let toldTray = false;
|
||||||
win.on('close', (e) => {
|
win.on('close', (e) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "biz-connect-desktop",
|
"name": "biz-connect-desktop",
|
||||||
"version": "0.1.16",
|
"version": "0.1.17",
|
||||||
"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",
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({
|
|||||||
rcAvailable: () => { try { return !!ipcRenderer.sendSync('rc-available'); } catch (_) { return false; } },
|
rcAvailable: () => { try { return !!ipcRenderer.sendSync('rc-available'); } catch (_) { return false; } },
|
||||||
rcArm: (on) => { try { ipcRenderer.send('rc-arm', !!on); } catch (_) {} },
|
rcArm: (on) => { try { ipcRenderer.send('rc-arm', !!on); } catch (_) {} },
|
||||||
rcInput: (evt) => { try { ipcRenderer.send('rc-input', evt); } catch (_) {} },
|
rcInput: (evt) => { try { ipcRenderer.send('rc-input', evt); } catch (_) {} },
|
||||||
|
// Force the newest web build: clears the shell's HTTP cache and reloads ignoring cache. The app closes
|
||||||
|
// to tray and can run for weeks, so without this it would keep serving the page it first loaded.
|
||||||
|
hardReload: () => { try { return ipcRenderer.invoke('hard-reload'); } catch (_) { return Promise.resolve(false); } },
|
||||||
// Manual "Check for updates" from Settings. Resolves {status:'available'|'current'|'dev'|'error', version?}.
|
// Manual "Check for updates" from Settings. Resolves {status:'available'|'current'|'dev'|'error', version?}.
|
||||||
// On 'available' the shell downloads in the background and prompts to restart when ready.
|
// On 'available' the shell downloads in the background and prompts to restart when ready.
|
||||||
checkForUpdates: () => ipcRenderer.invoke('check-updates'),
|
checkForUpdates: () => ipcRenderer.invoke('check-updates'),
|
||||||
|
|||||||
+36
-1
@@ -1002,7 +1002,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=6"></script>
|
<script src="/icons.js?v=6"></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-14-batch77';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-14-batch78';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>
|
||||||
@@ -1225,10 +1225,13 @@ function openSettings(){
|
|||||||
+sw('setDm','Direct message notifications', notifOn('dm'))
|
+sw('setDm','Direct message notifications', notifOn('dm'))
|
||||||
+'<label class="gi-setting"><span>Notifications <span class="perm-state '+perm+'">'+permLabel+'</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages & calls even when this tab isn’t open</div></span>'+(granted?'':'<button class="btn sm" id="setPerm">'+(perm==='denied'?'Allow':'Enable')+'</button>')+'</label>'
|
+'<label class="gi-setting"><span>Notifications <span class="perm-state '+perm+'">'+permLabel+'</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages & calls even when this tab isn’t open</div></span>'+(granted?'':'<button class="btn sm" id="setPerm">'+(perm==='denied'?'Allow':'Enable')+'</button>')+'</label>'
|
||||||
+(window.bizConnectNative?('<label class="gi-setting"><span>Biz Connect for Desktop<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem" id="setVer">Version '+pEsc(window.bizConnectNative.version||'—')+(_pendingUpdate?('<span class="set-ver-i" title="Update available">i</span> '+(_pendingUpdate.phase==='ready'?'Update ready':'Update '+pEsc(_pendingUpdate.version||'')+' available')):'')+'</div></span><button class="btn sm'+(_pendingUpdate?' has-update':'')+'" id="setUpd">'+(_pendingUpdate?(_pendingUpdate.phase==='ready'?'Restart now':'Downloading…'):'Check for updates')+'</button></label>'):'')
|
+(window.bizConnectNative?('<label class="gi-setting"><span>Biz Connect for Desktop<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem" id="setVer">Version '+pEsc(window.bizConnectNative.version||'—')+(_pendingUpdate?('<span class="set-ver-i" title="Update available">i</span> '+(_pendingUpdate.phase==='ready'?'Update ready':'Update '+pEsc(_pendingUpdate.version||'')+' available')):'')+'</div></span><button class="btn sm'+(_pendingUpdate?' has-update':'')+'" id="setUpd">'+(_pendingUpdate?(_pendingUpdate.phase==='ready'?'Restart now':'Downloading…'):'Check for updates')+'</button></label>'):'')
|
||||||
|
// Always-available escape hatch: force the newest web build (clears cache / service worker).
|
||||||
|
+'<label class="gi-setting"><span>App version<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Build '+pEsc(window.__BUILD||'—')+(_newBuild?' <span class="set-ver-i" title="A newer build is available">i</span> update available':'')+'</div></span><button class="btn sm'+(_newBuild?' has-update':'')+'" id="setRefresh">Refresh app</button></label>'
|
||||||
+'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>';
|
+'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>';
|
||||||
document.body.appendChild(ov);
|
document.body.appendChild(ov);
|
||||||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||||||
ov.querySelector('#setClose').onclick=()=>ov.remove();
|
ov.querySelector('#setClose').onclick=()=>ov.remove();
|
||||||
|
{ const rb=ov.querySelector('#setRefresh'); if(rb) rb.onclick=()=>{ ov.remove(); hardReloadApp(); }; }
|
||||||
const updBtn=ov.querySelector('#setUpd'); // #12: desktop version + manual update check
|
const updBtn=ov.querySelector('#setUpd'); // #12: desktop version + manual update check
|
||||||
if(updBtn) updBtn.onclick=async()=>{
|
if(updBtn) updBtn.onclick=async()=>{
|
||||||
if(_pendingUpdate&&_pendingUpdate.phase==='ready'){ try{ window.bizConnectNative.restartToUpdate&&window.bizConnectNative.restartToUpdate(); }catch(_){} return; } // update already downloaded → restart
|
if(_pendingUpdate&&_pendingUpdate.phase==='ready'){ try{ window.bizConnectNative.restartToUpdate&&window.bizConnectNative.restartToUpdate(); }catch(_){} return; } // update already downloaded → restart
|
||||||
@@ -2775,6 +2778,38 @@ function wireUpdateBanner(){
|
|||||||
else if(d.phase==='current'||d.phase==='error'){ if(el){ el.classList.remove('show'); setTimeout(()=>{ if(el&&!el.classList.contains('show')) el.remove(); }, 400); } }
|
else if(d.phase==='current'||d.phase==='error'){ if(el){ el.classList.remove('show'); setTimeout(()=>{ if(el&&!el.classList.contains('show')) el.remove(); }, 400); } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// ---- New-web-build detection ----------------------------------------------------------------
|
||||||
|
// The desktop app closes to TRAY, so it can stay open for weeks and keep running the page it loaded on
|
||||||
|
// day one — a deploy would never reach it (that's why fixes "worked on mobile but not on desktop").
|
||||||
|
// Poll the server's build marker; when it changes, offer a Refresh. Also exposed in Settings.
|
||||||
|
let _newBuild=null;
|
||||||
|
async function checkWebBuild(){
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/build',{cache:'no-store'}); if(!r.ok) return;
|
||||||
|
const d=await r.json();
|
||||||
|
if(d && d.build && window.__BUILD && d.build!==window.__BUILD && d.build!==_newBuild){ _newBuild=d.build; showRefreshBanner(); }
|
||||||
|
}catch(_){}
|
||||||
|
}
|
||||||
|
function showRefreshBanner(){
|
||||||
|
if(document.getElementById('refreshBanner')) return;
|
||||||
|
const el=document.createElement('div'); el.id='refreshBanner'; el.className='upd-banner show ready';
|
||||||
|
el.innerHTML=ic('download',15)+' <span>A new version of Biz Connect is available</span> <button id="rbGo">Refresh</button>';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.querySelector('#rbGo').onclick=()=>hardReloadApp();
|
||||||
|
}
|
||||||
|
// Hard refresh: on desktop this clears the shell's HTTP cache and reloads ignoring cache. In a browser we
|
||||||
|
// also drop any service-worker + CacheStorage entries, then reload — so there's no stale-code dead end.
|
||||||
|
async function hardReloadApp(){
|
||||||
|
if(meetState==='call' && !(await bzConfirm('You are in a call. Refreshing will leave it.', {title:'Refresh now?', okText:'Refresh'}))) return;
|
||||||
|
try{ const n=window.bizConnectNative; if(n && n.hardReload){ n.hardReload(); return; } }catch(_){}
|
||||||
|
try{ if('serviceWorker' in navigator){ const rs=await navigator.serviceWorker.getRegistrations(); await Promise.all(rs.map(r=>r.unregister().catch(()=>{}))); } }catch(_){}
|
||||||
|
try{ if(window.caches && caches.keys){ const ks=await caches.keys(); await Promise.all(ks.map(k=>caches.delete(k).catch(()=>{}))); } }catch(_){}
|
||||||
|
try{ location.replace(location.pathname+'?_r='+Date.now()); }catch(_){ location.reload(); }
|
||||||
|
}
|
||||||
|
setInterval(checkWebBuild, 10*60*1000); // every 10 min
|
||||||
|
window.addEventListener('focus', checkWebBuild); // and whenever the user comes back to the app
|
||||||
|
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden) checkWebBuild(); });
|
||||||
|
setTimeout(checkWebBuild, 4000); // shortly after boot
|
||||||
// Small 'i' badge on the profile button when an update is pending; clicking opens Settings.
|
// Small 'i' badge on the profile button when an update is pending; clicking opens Settings.
|
||||||
function applyUpdateBadge(){
|
function applyUpdateBadge(){
|
||||||
document.querySelectorAll('.upd-dot').forEach(x=>x.remove());
|
document.querySelectorAll('.upd-dot').forEach(x=>x.remove());
|
||||||
|
|||||||
@@ -917,6 +917,17 @@ route('GET', '/api/meetings/config', (req, res) => {
|
|||||||
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' });
|
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The web build currently on the server (home.html's __BUILD marker). Long-running clients poll this and
|
||||||
|
// offer a Refresh when it changes. This matters because the desktop app now CLOSES TO TRAY — it can run
|
||||||
|
// for weeks without ever reloading the page, so it would silently keep serving stale code after a deploy.
|
||||||
|
let APP_BUILD = '';
|
||||||
|
try {
|
||||||
|
const h = fs.readFileSync(path.join(require('./config').PUBLIC_DIR, 'home.html'), 'utf8');
|
||||||
|
const m = /__BUILD='([^']+)'/.exec(h);
|
||||||
|
if (m) APP_BUILD = m[1];
|
||||||
|
} catch (_) {}
|
||||||
|
route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD }));
|
||||||
|
|
||||||
// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
|
// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
|
||||||
// The room-membership/host authorization already happens over the meeting WebSocket; this only
|
// The room-membership/host authorization already happens over the meeting WebSocket; this only
|
||||||
// hands the client a media-plane credential scoped to that room and its own identity.
|
// hands the client a media-plane credential scoped to that room and its own identity.
|
||||||
|
|||||||
Reference in New Issue
Block a user