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).
|
||||
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); }
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
+29
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user