返回

Electron Windows Toast通知: 添加按钮并响应交互

windows

在 Electron 应用中实现 Windows Toast 通知交互按钮

咱们在用 Electron 开发 Windows 应用时,常常需要用到系统原生的 Toast 通知。单纯显示个消息挺简单,用 Electron 自带的 Notification API 就能搞定。要是想用 toastXml来自定义通知样式,比如不让通知在操作中心堆积,也能做到。

就像下面这样:

const { Notification, app } = require('electron');
const path = require('path');

const toastXmlString = `
<toast>
  <audio silent="true" />
  <visual>
        <binding template="ToastImageAndText01">
            <image id="1" src="${path.join(__dirname, 'icon.png')}" alt="img"/>
            <text id="1">${app.getName()} \n你好,世界!</text>
            <text placement="attribution">这条小字在底部</text>
        </binding>  
    </visual>
</toast>
`;

function showBasicToast() {
  let ENotification = new Notification({ toastXml: toastXmlString });
  ENotification.show();
  // 演示用,2.5秒后自动关闭,实际应用中看需求
  setTimeout(() => { ENotification.close(); }, 2500); 
}

// 在 app ready 后调用
// app.whenReady().then(showBasicToast); 

这段代码能显示一个带图标、标题、内容和署名的基础 Toast 通知,而且用 toastXml 的好处是,默认情况下新通知会替换掉同一个应用发出的旧通知(如果 ID 没特殊处理),看起来清爽些。

但是,问题来了:如果我想在通知上加几个按钮,比如“同意”、“拒绝”或者“稍后处理”,用户点了之后应用能知道点的是哪个,并且执行相应的操作,这该怎么弄呢?单纯的 toastXml 似乎只管显示,怎么把用户的点击行为传回我们的 Electron 主进程呢?

为什么会这样?

主要是因为 Electron 的 Notification API 设计目标是跨平台,它提供的是各平台通知功能的交集 。像 Windows Toast 这种带有复杂交互(按钮、输入框等)的功能,属于 Windows 平台的特性

虽然 Electron 允许通过 toastXml 参数直接使用 Windows 的原生 Toast XML 结构,但如何把 XML 里定义的 actions(操作按钮)跟 Electron 的事件机制联系起来,官方文档示例确实不多,需要咱们自己摸索一下 Windows 的 Toast 规范和 Electron 的事件处理。

简单说,你需要两步:

  1. toastXml 里正确定义这些按钮。
  2. 在 Electron 代码里监听特定事件来接收按钮点击的信号。

搞定它:添加并响应 Toast 操作

好消息是,Electron 确实提供了处理这种交互的能力,主要是通过 Notification 实例触发的 action 事件。

第一步:改造你的 toastXml

要在 Toast 通知上添加按钮,你需要在 <toast> 根节点下,紧随 <visual> 之后(或者有时在 <audio> 之后,位置需符合 Toast Schema),添加一个 <actions> 节点。

<actions> 节点里面可以包含一个或多个 <action> 子节点,每个 <action> 代表一个按钮。关键属性有:

  • content: 按钮上显示的文字。
  • arguments: 非常重要 ,这是你自定义的一个字符串,当用户点击这个按钮时,这个字符串会作为信息传递给你的应用程序。你可以用它来区分点的是哪个按钮,或者传递一些上下文数据。
  • activationType: 定义点击按钮后如何激活你的应用。常见的值有:
    • foreground: 激活应用并把窗口带到前台。
    • background: 在后台激活应用(应用进程启动或唤醒),但不打扰用户当前窗口。适用于后台任务。
    • protocol: 通过注册的协议(例如 myapp://)启动应用。需要额外配置,用起来要小心安全问题。

我们来修改之前的 toastXmlString,加入两个按钮,“确认”和“取消”:

const { Notification, app } = require('electron');
const path = require('path');

// 假设你的应用名叫 'MyApp'
const appName = app.getName() || '我的应用'; 
const iconPath = path.join(__dirname, 'icon.png'); // 确保路径正确且图片存在

// 注意:XML 中的 src 路径需要是绝对路径或者 Windows 能理解的格式
// file:/// 协议通常是可靠的
const iconSrc = `file:///${iconPath.replace(/\\/g, '/')}`; 

const toastXmlWithActions = `
<toast launch="app-defined-string"> 
  <visual>
    <binding template="ToastGeneric"> 
      <image placement="appLogoOverride" hint-crop="circle" src="${iconSrc}" alt="logo"/>
      <text>${appName}</text>
      <text>这是一个带有操作按钮的通知示例。请选择:</text>
    </binding>
  </visual>
  <actions>
    <action
        content="确认"
        arguments="confirmAction" 
        activationType="foreground"/> 
        
    <action
        content="取消"
        arguments="cancelAction"
        activationType="background"/> 
        
    </actions>
  <audio silent="true" /> 
</toast>
`;

// 注意: 
// 1. `launch` 属性在 `<toast>` 根节点上,定义了点击通知主体(非按钮部分)时的行为参数。
// 2. 模板改用了更通用的 `ToastGeneric`,可以更好地容纳按钮。
// 3. `image` 的 `placement="appLogoOverride"` 让它显示在标题旁边。
// 4. `arguments` 分别设为 "confirmAction" 和 "cancelAction" 用于区分。
// 5. “确认”按钮使用 foreground 激活,“取消”使用 background。

第二步:监听 action 事件

定义好了带按钮的 XML,接下来就是在 Electron 里显示这个通知,并且监听按钮点击

Notification 对象在用户点击了你在 <actions> 里定义的某个按钮时,会触发一个名为 action 的事件。

这个事件的回调函数会收到两个参数:

  1. event: 标准的事件对象。
  2. index: 用户点击的按钮在 <actions> 节点下的索引 (从 0 开始)。

所以,如果用户点了第一个按钮(“确认”),index 就是 0;点了第二个按钮(“取消”),index 就是 1

我们可以根据这个 index 来判断用户意图,并执行对应逻辑。如果你需要获取当时在 XML 里定义的 arguments 字符串,理论上你需要自己解析 toastXmlWithActions,找到对应 index<action> 节点的 arguments 属性值。不过,在多数情况下,仅仅知道点击的是第几个按钮(即 index)就足够执行相应的分支逻辑了。

看代码:

// ...接上面的代码...

function showInteractiveToast() {
  // 确保 app ID 正确设置,这对于 Toast 通知很重要
  if (process.platform === 'win32') {
    app.setAppUserModelId(app.name); // 或者你的自定义 AppUserModelId
  }
  
  // 解析 XML 中的 actions 以便之后根据 index 获取 arguments
  // 注意:这是一个简化的解析示例,实际应用可能需要更健壮的 XML 解析库
  const actionsInXml = [];
  const actionMatches = toastXmlWithActions.matchAll(/<action\s+.*?arguments="([^"]*?)"/gs);
  for (const match of actionMatches) {
      actionsInXml.push({ arguments: match[1] });
  }

  let ENotification = new Notification({ toastXml: toastXmlWithActions });

  ENotification.on('show', () => {
    console.log('通知已显示');
  });

  ENotification.on('click', (event) => {
    console.log('通知主体被点击');
    // 这里可以处理点击通知本体(非按钮区域)的逻辑
    // event 里可能没有额外信息表明是哪个部分被点击
    // 通常是激活应用窗口
  });

  // 这就是关键:监听按钮点击
  ENotification.on('action', (event, index) => {
    console.log(`按钮被点击了!索引:${index}`);
    
    const clickedActionInfo = actionsInXml[index]; // 获取对应按钮的信息
    if (clickedActionInfo) {
      const actionArgument = clickedActionInfo.arguments;
      console.log(`对应的 argument 是: ${actionArgument}`);

      // 根据 argument 执行不同操作
      if (actionArgument === 'confirmAction') {
        console.log('执行确认操作...');
        // 比如:打开某个窗口、发送网络请求等
        // mainWindow?.show(); // 如果希望确认后显示主窗口
      } else if (actionArgument === 'cancelAction') {
        console.log('执行取消操作...');
        // 比如:记录日志、默默完成某个清理任务
      } else {
        console.log('未知的 action argument:', actionArgument);
      }
    } else {
       console.error('无法根据索引找到对应的 action 信息: ', index);
    }

    // 点击按钮后,通知通常会自动消失,不需要手动 close
    // ENotification.close(); 
  });

  ENotification.on('close', (event) => {
    console.log('通知已关闭');
  });
  
  ENotification.on('failed', (event, error) => {
      console.error('显示通知失败:', error);
  });

  ENotification.show();
}

// 同样,在 app ready 后调用
// app.whenReady().then(showInteractiveToast);

一个更完整的例子

把上面的代码整合一下,放在主进程 (main.js 或类似文件) 里:

const { app, BrowserWindow, Notification } = require('electron');
const path = require('path');

let mainWindow;

function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js') // 示例性的
    }
  });
  mainWindow.loadFile('index.html'); // 你的 UI 页面
  mainWindow.on('closed', () => mainWindow = null);
}

// 设置 AppUserModelId (仅 Windows)
if (process.platform === 'win32') {
  // 非常重要:确保这里的 ID 和你的快捷方式或安装程序设置的一致
  // 通常用 'com.squirrel.YourAppName.YourAppName' 或类似格式
  app.setAppUserModelId(`com.electron.${app.getName().toLowerCase()}`); 
}

app.whenReady().then(() => {
  createWindow();

  // 应用启动后,延迟几秒显示一个带按钮的通知作为演示
  setTimeout(showInteractiveToast, 5000); 
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});


// ---- 通知相关代码 ----

function showInteractiveToast() {
  // 防止在非 Windows 平台执行 ToastXML 特定逻辑
  if (process.platform !== 'win32') {
    console.log('ToastXML with actions is only supported on Windows.');
    // 可以考虑显示一个普通的 Electron Notification 作为回退
    new Notification({ title: '通知', body: '这是一个操作通知' }).show();
    return;
  }

  const appName = app.getName() || '我的应用';
  const iconPath = path.join(__dirname, 'assets', 'icon.png'); // 推荐把资源放进 assets 目录
  const iconSrc = `file:///${iconPath.replace(/\\/g, '/')}`;

  const toastXmlWithActions = `
  <toast launch="app-defined-string"> 
    <visual>
      <binding template="ToastGeneric"> 
        <image placement="appLogoOverride" hint-crop="circle" src="${iconSrc}" alt="logo"/>
        <text>${appName}</text>
        <text>这是一个带有操作按钮的通知示例。请选择:</text>
      </binding>
    </visual>
    <actions>
      <action
          content="确认"
          arguments="confirmAction" 
          activationType="foreground"/> 
          
      <action
          content="取消"
          arguments="cancelAction"
          activationType="background"/> 
          
      </actions>
    <audio silent="true" /> 
  </toast>
  `;

  // 解析 XML 中的 actions (基础版解析)
  const actionsInXml = [];
  const actionMatches = toastXmlWithActions.matchAll(/<action\s+.*?arguments="([^"]*?)"/gs);
  for (const match of actionMatches) {
      actionsInXml.push({ arguments: match[1] });
  }

  let ENotification = new Notification({ toastXml: toastXmlWithActions });

  ENotification.on('action', (event, index) => {
    console.log(`按钮被点击了!索引:${index}`);
    
    const clickedActionInfo = actionsInXml[index];
    if (clickedActionInfo) {
        const actionArgument = clickedActionInfo.arguments;
        console.log(`对应的 argument 是: ${actionArgument}`);

        // 在这里根据 actionArgument 执行你的应用逻辑
        handleToastAction(actionArgument);

    } else {
        console.error('无法根据索引找到对应的 action 信息: ', index);
    }
  });

  ENotification.on('click', () => {
    console.log('通知主体被点击,通常是激活窗口。');
    // 例如,确保主窗口可见并获得焦点
    if (mainWindow) {
        if (mainWindow.isMinimized()) mainWindow.restore();
        mainWindow.focus();
    }
  });
  
  ENotification.on('failed', (event, error) => {
    console.error('显示通知失败:', error);
    // 可能的原因:XML 格式错误、AppUserModelId 未设置或错误、资源路径问题等
  });

  ENotification.show();
}

function handleToastAction(argument) {
  // 这是处理按钮点击逻辑的函数
  console.log(`处理 Action: ${argument}`);
  if (argument === 'confirmAction') {
    // 可能需要通知渲染进程更新 UI,或者直接在主进程执行操作
    if (mainWindow) {
      mainWindow.webContents.send('toast-action', { action: 'confirm' });
      // 同时也可以让主窗口获得焦点
       if (mainWindow.isMinimized()) mainWindow.restore();
       mainWindow.focus();
    }
    console.log('确认操作逻辑已触发');
  } else if (argument === 'cancelAction') {
    // 执行后台任务,比如记录日志
    console.log('取消操作逻辑已触发(后台)');
    // 可能不需要打扰用户界面
  }
}

进阶技巧与注意事项

  1. 区分 clickaction 事件 :要搞清楚,用户点击通知的主体部分(非按钮区域)会触发 click 事件,而点击 <actions> 里定义的按钮则触发 action 事件。它们的用途和传递的信息不同。通常 click 用来激活应用窗口,action 用来执行具体操作。

  2. activationType 的选择

    • foreground:最常用,适合需要用户立即看到结果或进行后续交互的操作。
    • background:适合不需要界面反馈、可以在后台默默完成的任务,比如“标记为已读”、“静音”等。这能减少对用户的打扰。
    • protocol:使用场景相对特殊,比如点击按钮打开一个特定格式的链接(myapp://dosomething?id=123)。需要你的应用注册并能处理这个协议。用这个要特别注意安全,恶意构造的协议链接可能有风险。
  3. 动态生成 argumentsarguments 字符串不一定是固定的。你可以根据通知相关的上下文动态生成它。例如,如果通知是关于某个特定项目的,你可以把项目 ID 包含进去:arguments="viewProject-${projectId}"。这样,在 action 事件处理器里,解析这个字符串就能知道具体要操作哪个项目了。

    // 动态生成 arguments 示例
    const itemId = 'item-abc-123';
    const dynamicArgs = `processItem-${itemId}`; 
    const dynamicAction = `<action content="处理它" arguments="${dynamicArgs}" activationType="foreground"/>`;
    // 然后把 dynamicAction 插入到你的 toastXml 模板中
    
  4. 错误处理 :监听 failed 事件很重要。它可以帮你诊断为什么通知没显示出来,常见原因包括 XML 格式错误、图片路径不正确、或者 Windows 上的通知设置被禁用了等等。

  5. AppUserModelId (AUMID) :在 Windows 上,Toast 通知的许多行为(包括能否正确响应点击、是否堆叠等)都和应用程序的 AppUserModelId 紧密相关。务必使用 app.setAppUserModelId() 设置一个唯一的 ID,并且最好与你的安装包或快捷方式设置的 ID 保持一致。开发时用 com.electron.yourappname 常常可行,但发布时建议用更规范的 ID。

安全第一

  • 谨慎使用 activationType="protocol" :如果你的应用注册了自定义协议,要确保对传入的 URL 参数做了严格的校验和清理,防止潜在的命令注入或其他攻击。
  • 验证 arguments 内容 :即使是 foregroundbackground 类型,如果你在 arguments 里传递了数据(比如 ID),在处理 action 事件时,也应该验证这些数据的有效性,不要盲目信任。比如,检查 ID 格式是否正确,查询数据库确认该 ID 对应的资源确实存在且用户有权限操作等。

通过这种方式,你就能在 Electron 应用中充分利用 Windows Toast 通知的交互能力,为用户提供更原生、更便捷的操作体验了。记住关键:构造好 toastXml<actions> 部分,并正确监听 Notification 实例的 action 事件获取用户点击的按钮索引。