feat(desktop): real Windows inline reply via SnoreToast -tb + sender/group avatar

- node-notifier's WindowsToaster forwards raw opts to SnoreToast, so inject -tb (reply box)
  + -p (image). Reuses its named-pipe + result parsing (exit 5 = TextEntered). No pwsh needed.
  Logs the raw toast result to userData/toast-debug.log to confirm the reply field on real HW.
- home.html: notifAvatarDataUrl draws the DM sender's pic / group's DP (else colored initials)
  to a round PNG and passes it as the toast image. Reply -> sendReplyTo; click -> open chat.
- dropped powertoast (ESM + needs pwsh 7, absent here). build batch22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 23:26:24 +05:30
parent ffe04e6bff
commit 3f209bf619
4 changed files with 94 additions and 36 deletions
+40 -19
View File
@@ -35,29 +35,50 @@ ipcMain.on('get-install-info', (e) => {
// on the next restart. No-op in dev (unpackaged). // on the next restart. No-op in dev (unpackaged).
let autoUpdater = null; let autoUpdater = null;
try { ({ autoUpdater } = require('electron-updater')); } catch (_) { /* not installed in dev */ } try { ({ autoUpdater } = require('electron-updater')); } catch (_) { /* not installed in dev */ }
// node-notifier bundles SnoreToast, which renders a native Windows toast WITH a reply box // Native Windows toast WITH a reply box, via node-notifier's bundled SnoreToast engine.
// (Electron's own Notification can't do Windows inline reply). Only works in the installed app // node-notifier's API doesn't expose the reply text box, but its WindowsToaster forwards raw
// (needs the AppUserModelID shortcut the NSIS installer registers). // options to SnoreToast — so we inject `-tb` (text box) + `-p` (avatar image) and reuse its
let notifier = null; // named-pipe + result parsing (exit 5 = TextEntered). Installed-app only (needs the
try { notifier = require('node-notifier'); } catch (_) { /* optional */ } // AppUserModelID shortcut the NSIS installer registers). No PowerShell 7 required.
let WindowsToaster = null;
try { WindowsToaster = require('node-notifier').WindowsToaster; } catch (_) { /* optional */ }
// Show a chat notification with an inline reply box. Resolves with the typed reply, an "open" // Write the avatar the renderer drew (a data: URL) to a temp PNG for SnoreToast's -p image.
// intent (toast clicked), or null (dismissed/timeout). The renderer sends the reply / opens chat. function writeTempPng(dataUrl) {
ipcMain.handle('reply-notification', (_e, payload = {}) => new Promise((resolve) => {
if (!notifier) return resolve(null);
let done = false; const finish = (v) => { if (!done) { done = true; resolve(v); } };
try { try {
notifier.notify({ if (!dataUrl || !/^data:image\/png;base64,/.test(dataUrl)) return null;
appID: 'com.bizgaze.connect.desktop', const p = path.join(app.getPath('temp'), 'bizc-toast-' + crypto.randomBytes(4).toString('hex') + '.png');
fs.writeFileSync(p, Buffer.from(dataUrl.split(',')[1], 'base64'));
return p;
} catch (_) { return null; }
}
// Resolves with {text} (replied), {open} (toast clicked), or null (dismissed/timeout).
ipcMain.handle('reply-notification', (_e, payload = {}) => new Promise((resolve) => {
if (!WindowsToaster) return resolve(null);
const img = writeTempPng(payload.avatar);
let done = false;
const finish = (v) => { if (!done) { done = true; if (img) { try { fs.unlinkSync(img); } catch (_) {} } resolve(v); } };
try {
const toaster = new WindowsToaster({ withFallback: false });
toaster.notify({
title: payload.title || 'Biz Connect', title: payload.title || 'Biz Connect',
message: payload.body || '', message: payload.body || ' ',
reply: true, wait: true, timeout: 20, appID: 'com.bizgaze.connect.desktop',
tb: true, // -tb : reply text box
w: true, // -w : wait for the user to reply / click before returning
...(img ? { icon: img } : {}), // mapped to -p (image)
}, (err, response, metadata) => { }, (err, response, metadata) => {
if (err) return finish(null); // Log the raw result once so the exact reply field can be confirmed on a real machine.
const text = (metadata && metadata.activationValue) ? String(metadata.activationValue).trim() : ''; try { fs.appendFileSync(path.join(app.getPath('userData'), 'toast-debug.log'), JSON.stringify({ t: Date.now(), err: err && err.message, response, metadata }) + '\n'); } catch (_) {}
const resp = String(response || '').toLowerCase(); const meta = metadata || {};
if (text && resp !== 'activate') finish({ kind: payload.kind, id: payload.id, text }); const known = ['click', 'activate', 'activated', 'timeout', 'timedout', 'dismissed'];
else if (resp === 'activate' || resp === 'clicked') finish({ kind: payload.kind, id: payload.id, open: true }); const respStr = String(response || '').trim();
let text = String(meta.text || meta.value || meta.reply || '').trim();
if (!text && respStr && !known.includes(respStr.toLowerCase())) text = respStr; // some builds return the reply as the response
const act = String(response || meta.action || meta.activationType || '').toLowerCase();
if (text) finish({ kind: payload.kind, id: payload.id, text });
else if (act.includes('activat') || act === 'click') finish({ kind: payload.kind, id: payload.id, open: true });
else finish(null); else finish(null);
}); });
} catch (_) { finish(null); } } catch (_) { finish(null); }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "biz-connect-desktop", "name": "biz-connect-desktop",
"version": "0.1.1", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "biz-connect-desktop", "name": "biz-connect-desktop",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"node-notifier": "^10.0.1" "node-notifier": "^10.0.1"
+29 -8
View File
@@ -2,7 +2,10 @@
"name": "biz-connect-desktop", "name": "biz-connect-desktop",
"version": "0.1.2", "version": "0.1.2",
"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": { "name": "BizGaze", "email": "support@bizgaze.com" }, "author": {
"name": "BizGaze",
"email": "support@bizgaze.com"
},
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
@@ -19,12 +22,23 @@
"build": { "build": {
"appId": "com.bizgaze.connect.desktop", "appId": "com.bizgaze.connect.desktop",
"productName": "Biz Connect", "productName": "Biz Connect",
"directories": { "buildResources": "build", "output": "dist" }, "directories": {
"asarUnpack": ["**/node_modules/node-notifier/**"], "buildResources": "build",
"publish": [ "output": "dist"
{ "provider": "generic", "url": "https://remote.bizgaze.com/downloads/" } },
"asarUnpack": [
"**/node_modules/node-notifier/**"
], ],
"win": { "target": "nsis", "icon": "build/icon.ico" }, "publish": [
{
"provider": "generic",
"url": "https://remote.bizgaze.com/downloads/"
}
],
"win": {
"target": "nsis",
"icon": "build/icon.ico"
},
"nsis": { "nsis": {
"oneClick": true, "oneClick": true,
"perMachine": false, "perMachine": false,
@@ -33,7 +47,14 @@
"shortcutName": "Biz Connect", "shortcutName": "Biz Connect",
"runAfterFinish": true "runAfterFinish": true
}, },
"mac": { "target": "dmg", "category": "public.app-category.business", "icon": "build/icon.ico" }, "mac": {
"linux": { "target": "AppImage", "category": "Network" } "target": "dmg",
"category": "public.app-category.business",
"icon": "build/icon.ico"
},
"linux": {
"target": "AppImage",
"category": "Network"
}
} }
} }
+20 -4
View File
@@ -731,7 +731,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=4"></script> <script src="/icons.js?v=4"></script>
<script>window.__BUILD='2026-07-01-batch21';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script> <script>window.__BUILD='2026-07-01-batch22';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<div class="loading" id="loading">Loading…</div> <div class="loading" id="loading">Loading…</div>
<header> <header>
@@ -1895,14 +1895,30 @@ async function unsubscribePush(){
// notification click is not an in-page gesture, so an in-place open won't paint until you // notification click is not an in-page gesture, so an in-place open won't paint until you
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first. // tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
function openFromNotif(kind,id){ try{ if(window.bizConnectNative&&window.bizConnectNative.focusApp) window.bizConnectNative.focusApp(); }catch(_){} try{ window.focus(); }catch(_){} location.assign('/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||'')); } function openFromNotif(kind,id){ try{ if(window.bizConnectNative&&window.bizConnectNative.focusApp) window.bizConnectNative.focusApp(); }catch(_){} try{ window.focus(); }catch(_){} location.assign('/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||'')); }
// Draw the conversation's avatar (DM = sender pic, group = group DP; else colored initials) to
// a round PNG data URL for the desktop toast image.
function notifAvatarDataUrl(kind,id,fallbackName){
return new Promise((resolve)=>{
try{
const row=rowFor(kind,id)||{}; const src=row.avatar||''; const nm=row.name||fallbackName||'?';
const s=96, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
const initialsPng=()=>{ try{ g.clearRect(0,0,s,s); g.fillStyle=avColor(nm); g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.fill(); g.fillStyle='#334155'; g.font='bold 40px system-ui,Segoe UI,sans-serif'; g.textAlign='center'; g.textBaseline='middle'; g.fillText(initials(nm), s/2, s/2+2); resolve(c.toDataURL('image/png')); }catch(_){ resolve(null); } };
if(src){ const img=new Image(); img.crossOrigin='anonymous'; let settled=false; const done=(fn)=>{ if(!settled){ settled=true; fn(); } };
img.onload=()=>done(()=>{ try{ g.save(); g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.clip(); g.drawImage(img,0,0,s,s); g.restore(); resolve(c.toDataURL('image/png')); }catch(_){ initialsPng(); } });
img.onerror=()=>done(initialsPng); img.src=src; setTimeout(()=>done(initialsPng), 1500);
} else initialsPng();
}catch(_){ resolve(null); }
});
}
// Reply to a conversation without opening it (used by the notification quick-reply). // Reply to a conversation without opening it (used by the notification quick-reply).
async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} } async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} }
function notify(title, body, kind, id){ function notify(title, body, kind, id){
try{ try{
// Desktop app (Phase D): native Windows toast with an inline reply box. // Desktop app (Phase D): native Windows toast with an inline reply box + sender/group avatar.
if(window.bizConnectNative && window.bizConnectNative.replyNotify){ if(window.bizConnectNative && window.bizConnectNative.replyNotify){
window.bizConnectNative.replyNotify({title, body, kind, id}).then(r=>{ notifAvatarDataUrl(kind, id, title)
if(!r) return; .then(avatar=>window.bizConnectNative.replyNotify({title, body, kind, id, avatar}))
.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
}).catch(()=>{}); }).catch(()=>{});