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:
+40
-19
@@ -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); }
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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(()=>{});
|
||||||
|
|||||||
Reference in New Issue
Block a user