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).
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); }