airenburen/dsh-windows-notify ↗★ 1

dsh-windows-notify

DSH 桌面通知插件:对话进入需要你操作的状态(命令/文件操作需要权限、AI 向你提问、回复完成、出错)时弹出 Windows 原生通知,点击通知回到 DSH;同时提供 AI 可调用的 notify_user 工具与 /plugins/dsh-windows-notify/send HTTP 接口。

패키지
dsh-windows-notify
호환성
미검증
버전
0.1.0
라이선스
MIT
최근 업데이트
2026. 11. 9.

같은 패키지 이름의 다른 저장소

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:airenburen/dsh-windows-notify

配置

主入口:设置 → 左侧导航「通知」(DSH 原生设置页,order 55,排在「插件市场」后面)。

这是 DSH 自己的槽位机制:客户端半边注册一格 settings.section({ id: 'dsh-windows-notify', order: 55, label })就多一页 —— dsh-market 的「插件市场」页是同一套做法。用自己的 id:复用别人的 id 会把对方那一页顶掉。 这一格不依赖任何第三方插件,better-sidebar 卸了也照样在。

另一处入口:设置 → 侧边栏卡片 → 找到「通知」卡片 → 卡片上的齿轮(DSH-better-sidebar 的设置页)。

better-sidebar 给每个注册的 tab 类型在它的设置页放一张卡片,卡片的齿轮渲染 settings.render;所以插件用 ctx.betterSidebar.registerTab({ ..., hidden: true, settings: { render } }) 把同一份面板 也挂在那里。hidden: true 是编辑器 / diff 内置类型的同一套路:有设置卡片,但不往 DSH 原生 「+」菜单里添一个用不上的侧栏页。

没装 better-sidebar 的部署会自动退回插件行配置页(侧栏「插件」→ 已安装 → dsh-windows-notify → 行上的「配置」)。三处入口共用同一个面板与同一份配置。

面板里的字段(全部 .volatile(),改完立即生效):

字段默认说明
enabledtrue状态通知总开关(不影响 AI/脚本的显式通知)
notifyApprovaltrue要权限时通知
notifyQuestiontrueAI 提问时通知
notifyTurnEndfalse一轮回复结束时通知
notifyErrortrue会话出错时通知
agentTooltrue注册 notify_user 工具(改后需重载插件)
httpApitrue开启 /plugins/dsh-windows-notify/* 路由
focusOnClicktrue点击通知回到 DSH
soundtrue提示音
displaySeconds10显示时间限制:到点自动从屏幕与操作中心移除;0 = 不限制
stickyfalse状态通知用系统「提醒」样式常驻(仅当 displaySeconds = 0 时生效)
decisionButtonstrue权限通知带「允许 / 拒绝」按钮
decisionSeconds300按钮可点窗口(也会写进通知的显示时限);0 = 跟随 displaySeconds
decisionButtonsForsafe-only哪些审批带按钮:safe-only = 高危的 danger-full-access 不发按钮(只提示去界面确认),all = 全都发
privilegedModes['danger-full-access']哪些权限模式算「高危」(不给通知按钮)。DSH 未来引入新特权档位时把名字加进来。判定只认权限模式本身,理由文本不参与
sessionFilter[](不过滤)只对匹配的会话弹状态通知(审批/提问/回合结束/出错)。匹配会话标题或 cwd 末段(大小写不敏感的包含匹配),如 ['dsh-windows-notify', '猫窝']。显式通知(notify_user/HTTP)不受过滤
whenFocusedsilentDSH 在前台时怎么办:silent = 只进操作中心(SuppressPopup:不弹横幅、不响铃)、skip = 完全不发(历史仍记录)、notify = 照常弹。只作用于自动状态通知,notify_user/HTTP 的显式通知照常弹
languagezhzh / en
aumidcom.deepseek.dsh通知来源 AppUserModelID(桌面版开始菜单里的 AppID)
dedupeMs4000同 tag 去重窗口
maxPerMinute20每分钟上限
debugLogfalse打印每条通知的调试日志

前后台:DSH 在前台时不打扰(whenFocused)

宿主是独立 Node 进程(ELECTRON_RUN_AS_NODE),拿不到 Electron 的窗口状态,webServer 也不暴露窗口信息——所以判定来自渲染进程:客户端半边监听 focus/blur/ visibilitychange,每次焦点变化上报一次,平时每 15 秒心跳一次 (POST /plugins/dsh-windows-notify/focus,只接受本机回环)。

「前台」= visibilityState === 'visible' && document.hasFocus();宿主侧 45 秒 (FOCUS_TTL_MS)收不到消息就当作后台——页面关掉、崩溃、换机器都不会把通知永久压住。 多窗口各报各的,取「有一个在前台」。

离开类事件强制报后台:pagehide(页面关闭/跳转)与 visibilitychange → hidden (最小化、锁屏)触发时跳过计算直接上报 focused: false(带 keepalive,页面卸载后 请求仍能发出)——这两类事件触发瞬间 hasFocus() 可能仍返回 true,按计算结果报会把 「已离开」误报成「在前台」。锁屏时页面转入 hidden → 通知恢复弹横幅,符合「人不在电脑前」 的直觉(真机锁屏行为建议升级后手动确认一次)。

命中前台时按 whenFocused 处理:

  • silent(默认):给 toast 设 ToastNotification.SuppressPopup = $true——不弹横幅、 不响铃,但通知照旧进操作中心(它上面的按钮也照旧可用)。既不看横幅、又不会漏,还顺手 消掉了「横幅弹在光标下被误点」的隐患。
  • skip:完全不发,历史里记一条 skipped: "foreground"(侧栏「通知」页能看到)。
  • notify:回到老行为,一律弹。

只有 4 类自动状态通知受这个策略约束;notify_user、HTTP /send、 ctx.windowsNotify.send 这类显式通知永远照常弹(那是「我就是要提醒你」)。 /status 里的 focus 字段(foreground / policy / windows / ttlMs)可以在侧栏 「通知」页直接看到当前判定。

显示时间限制是怎么做到的

Windows 里两件事是分开的:duration 只决定横幅在屏幕上停多久(short ≈ 7 秒 / long ≈ 25 秒), 而 scenario="reminder"(「提醒」样式)根本不自动下屏——这正是之前通知「一直显示」的原因。 真正能把通知撤掉的是 ToastNotification.ExpirationTime:到点后系统把它从屏幕和操作中心一并移除。

所以:

  • displaySeconds > 0(默认 10)→ 给每条 toast 设 ExpirationTime = now + N;同时强制关掉 「提醒」样式(否则它会顶掉时限),N > 25 时用 long 横幅,否则 short。
  • displaySeconds = 0 → 不设 ExpirationTime,此时 sticky 才起作用(状态通知用「提醒」 样式,一直挂在屏幕上等你处理)。
  • 这个上限对所有通知生效,包括 AI 主动调 notify_user(..., sticky=true) 的那条。

node test/windows.mjs --expire 会发两条真通知(一条设 6 秒时限、一条不设),12 秒后回读 操作中心,验证设了时限的那条已被移除、对照那条还在。

「允许 / 拒绝」按钮是怎么把答案传回来的

通知上的按钮只能激活一个 URI,所以链路是这样接的:

  1. 审批通知渲染成 `` 里两个按钮:dsh-notify:allow/ / dsh-notify:reject/,按钮配色用 hint-button-style(允许=Success / 拒绝=Critical), 点通知正文仍然是回到 DSH(dsh://open)。
  2. 插件把宿主半边注册的 dsh-notify: 协议指向一个隐藏 PowerShell 启动器 (%LOCALAPPDATA%\dsh-windows-notify\decision.ps1)——它把 URI 解析成 POST /plugins/dsh-windows-notify/decision?token=…&choice=allow|reject。 这条通道独立于回 DSH 用的 dsh: 协议,所以两边互不影响。 启动器脚本里要写死宿主地址,而这个地址只能从请求上取:webServer 服务不暴露端口, 宿主进程里也没有 DSH_WEB_URL(那是 DSH 注入给 shell 子进程的)。所以插件在每条路由里 记下 req.socket.localPort(服务端监听端口,比 Host 头可靠——桌面端页面的请求是 Electron 主进程转发过来的),客户端半边在页面加载时还会 ping 一次 /hello, 把地址尽早送过来。
  3. 宿主维护「待回传」表:token 一次性、默认 300 秒(decisionSeconds)后作废,回传口只接受 本机回环请求。审批监听器把「GUI 里的回答」和「通知按钮的回答」赛跑: 谁先到用谁,另一边随即作废,并把那条通知从操作中心撤掉。
  4. 只在通道注册成功时才给按钮——不知道自身地址、注册失败、或 decisionButtons=false 时都退化成普通通知(否则点下去系统会弹「选择打开方式」)。
  5. 顺手把 GUI 里那张「等待审批」卡也收掉。那张卡是客户端自己在 approval/request 瀑布里创建的 pending interaction,只有它自己结束(answer/delegate/abort)时 才会消失——宿主返回的答案它看不到,所以之前点完通知按钮,卡片还杵在那儿等人点。 现在宿主每完成一次「从通知按钮结算」就记一条事实 (GET /plugins/dsh-windows-notify/decisions?since=),客户端半边拿它做两件事:
    • A(首选):从 ctx.uiSession.sessionStatus 的快照里找到那个 pending 实例, 直接调它的公开 answer(outcome) → 卡片随它的 finally 一起消失。这次迟到的回答会被 瀑布丢弃(宿主早已落盘),实例自带的 already-settled 守卫也拦住重复结算。
    • B(兜底):拿不到可结算的实例时,按文案在 DOM 里找卡上的「允许一次 / 拒绝」 (Allow once / Reject)按钮点它——不依赖任何内部 API,代价是换文案/换皮会失效。 客户端只在本地真的有待审批时才拉这条增量,空闲时零请求;since 基线在每次进入 「有待审批」时重新对齐,避免旧事实误伤新卡片。

安全性:token 16 位十六进制随机、只能用一次;回传口只认回环地址;即使 token 泄漏,最坏结果 也只是替你答了一次本来就在等你的审批,而它同时仍受 DSH 自己的授权策略约束。

HTTP 接口的统一安全门

所有 /plugins/dsh-windows-notify/* 路由(不只是写入口)都先过两道检查:

  1. 回环限定——非 127.0.0.1/::1 的请求一律 403;
  2. Origin 校验——浏览器发起的跨源请求必带 Origin 头;不是本机来源(http://127.0.0.1:*"、 http://localhost:*)也不是 null(file:// 基)的一律 403。没有 Origin 的调用 (curl、PowerShell、宿主进程内的 Electron 转发)不受影响。

背景:浏览器里任何网页都可以向 http://127.0.0.1: /send 发跨源 POST(简单请求不受 CORS 预检拦截),伪造一条「DSH 需要授权」样式的通知做社工钓鱼。统一安全门把它挡掉。

威胁模型备案:%LOCALAPPDATA%dsh-windows-notify 下的回传脚本(decision.ps1、ps-worker.ps1) 对本机其他进程可写——但本机恶意进程本来就能直接发 toast / 读注册表,本插件不假设 「本机已失陷」场景。

高危审批默认不发按钮(decisionButtonsFor = safe-only):通知上的「允许」太容易误点, 而 danger-full-access 一旦放行就不再受工作区限制。这类审批只弹一条正文带 「高危权限,请到 DSH 界面确认」的普通通知,必须回界面点。低危的(如升级到 workspace-write)照旧带按钮。想恢复「全都给按钮」就把它设成 all。

每次按钮激活都有审计:%LOCALAPPDATA%\dsh-windows-notify\decision.log,一行一次 时间 | 允许/拒绝 | token | 拉起它的父进程 | 父进程命令行。父进程能区分来源—— 真人点通知走的是 Windows 通知平台(svchost.exe -s WpnUserService), 而程序里 Start-Process "dsh-notify:…" 的父进程是 powershell.exe。 (现场验证过:一条没人点的通知挂 10 分钟不会有任何回传,不会自己触发。) node tools/peek-toast.mjs 则用来查看操作中心里那条通知实际带了哪些按钮参数。

node tools/test-decision.mjs 会起一个替身宿主、用真实注册表协议和 Start-Process "dsh-notify:allow/"(就是系统点按钮时做的事)验完整条链路, 跑完恢复原来的协议注册。

node tools/demo-buttons.mjs 是「人点一下」的现场演示:起替身宿主 + 注册真实协议 + 发一条真的带按钮通知,点哪个都会把 token/choice 打在终端里,用来确认 Windows 确实渲染了这两个按钮、点下去也确实回传(演示结束会把协议恢复原样)。

面板顶部还有发送测试通知:点一次就能验证「弹得出来 + 点击能回到 DSH」。

另外注册了一个 hidden 的 tab 类型(正常看不到,只有被显式打开时才出现):它显示通知通道 状态、最近发出的通知(宿主保留最近 50 条,GET /plugins/dsh-windows-notify/recent)和测试按钮。 想让它出现在 DSH 侧栏的「+」菜单里,把 lib/client.js 里的 hidden: true 改成 false 即可。

不想用界面也行:同一份配置等价地存在 profile 的 cordis.patch.yml(entry id windows-notify) 里,直接改文件效果一样——界面写的就是它(经 DSH 的 settings 服务)。

界面靠宿主半边这两个路由读写配置(写入只允许本机回环): GET /plugins/dsh-windows-notify/config、POST /plugins/dsh-windows-notify/config。