From 3f209bf6192760bc2fc265b174b46d3f49944a0f Mon Sep 17 00:00:00 2001 From: sravan Date: Wed, 1 Jul 2026 23:26:24 +0530 Subject: [PATCH] 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 --- desktop/main.js | 59 ++++++++++++++++++++++++++------------- desktop/package-lock.json | 4 +-- desktop/package.json | 37 ++++++++++++++++++------ server/public/home.html | 30 +++++++++++++++----- 4 files changed, 94 insertions(+), 36 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index 100fb58..5f401a8 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -35,29 +35,50 @@ ipcMain.on('get-install-info', (e) => { // on the next restart. No-op in dev (unpackaged). let autoUpdater = null; try { ({ autoUpdater } = require('electron-updater')); } catch (_) { /* not installed in dev */ } -// node-notifier bundles SnoreToast, which renders a native Windows toast WITH a reply box -// (Electron's own Notification can't do Windows inline reply). Only works in the installed app -// (needs the AppUserModelID shortcut the NSIS installer registers). -let notifier = null; -try { notifier = require('node-notifier'); } catch (_) { /* optional */ } +// Native Windows toast WITH a reply box, via node-notifier's bundled SnoreToast engine. +// node-notifier's API doesn't expose the reply text box, but its WindowsToaster forwards raw +// options to SnoreToast — so we inject `-tb` (text box) + `-p` (avatar image) and reuse its +// named-pipe + result parsing (exit 5 = TextEntered). Installed-app only (needs the +// 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" -// intent (toast clicked), or null (dismissed/timeout). The renderer sends the reply / opens chat. -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); } }; +// Write the avatar the renderer drew (a data: URL) to a temp PNG for SnoreToast's -p image. +function writeTempPng(dataUrl) { try { - notifier.notify({ - appID: 'com.bizgaze.connect.desktop', + if (!dataUrl || !/^data:image\/png;base64,/.test(dataUrl)) return null; + 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', - message: payload.body || '', - reply: true, wait: true, timeout: 20, + message: payload.body || ' ', + 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) => { - if (err) return finish(null); - const text = (metadata && metadata.activationValue) ? String(metadata.activationValue).trim() : ''; - const resp = String(response || '').toLowerCase(); - if (text && resp !== 'activate') finish({ kind: payload.kind, id: payload.id, text }); - else if (resp === 'activate' || resp === 'clicked') finish({ kind: payload.kind, id: payload.id, open: true }); + // Log the raw result once so the exact reply field can be confirmed on a real machine. + 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 meta = metadata || {}; + const known = ['click', 'activate', 'activated', 'timeout', 'timedout', 'dismissed']; + 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); }); } catch (_) { finish(null); } diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 8879ddb..a052ecc 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "biz-connect-desktop", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "biz-connect-desktop", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "electron-updater": "^6.3.9", "node-notifier": "^10.0.1" diff --git a/desktop/package.json b/desktop/package.json index 8c4c3ab..334b9c8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -2,7 +2,10 @@ "name": "biz-connect-desktop", "version": "0.1.2", "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", "scripts": { "start": "electron .", @@ -19,12 +22,23 @@ "build": { "appId": "com.bizgaze.connect.desktop", "productName": "Biz Connect", - "directories": { "buildResources": "build", "output": "dist" }, - "asarUnpack": ["**/node_modules/node-notifier/**"], - "publish": [ - { "provider": "generic", "url": "https://remote.bizgaze.com/downloads/" } + "directories": { + "buildResources": "build", + "output": "dist" + }, + "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": { "oneClick": true, "perMachine": false, @@ -33,7 +47,14 @@ "shortcutName": "Biz Connect", "runAfterFinish": true }, - "mac": { "target": "dmg", "category": "public.app-category.business", "icon": "build/icon.ico" }, - "linux": { "target": "AppImage", "category": "Network" } + "mac": { + "target": "dmg", + "category": "public.app-category.business", + "icon": "build/icon.ico" + }, + "linux": { + "target": "AppImage", + "category": "Network" + } } } diff --git a/server/public/home.html b/server/public/home.html index dca48bd..f6a1699 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -731,7 +731,7 @@ - +
Loading…
@@ -1895,17 +1895,33 @@ async function unsubscribePush(){ // 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. 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). 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){ 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){ - window.bizConnectNative.replyNotify({title, body, kind, id}).then(r=>{ - if(!r) return; - if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast - else if(r.open) openFromNotif(kind, id); // clicked the toast - }).catch(()=>{}); + notifAvatarDataUrl(kind, id, title) + .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 + else if(r.open) openFromNotif(kind, id); // clicked the toast + }).catch(()=>{}); return; } // Web / PWA: standard Notification.