airenburen/dsh-windows-notify ↗★ 1
dsh-windows-notify
DSH 桌面通知插件:对话进入需要你操作的状态(命令/文件操作需要权限、AI 向你提问、回复完成、出错)时弹出 Windows 原生通知,点击通知回到 DSH;同时提供 AI 可调用的 notify_user 工具与 /plugins/dsh-windows-notify/send HTTP 接口。
같은 패키지 이름의 다른 저장소
설치
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(),改完立即生效):
| 字段 | 默认 | 说明 |
|---|---|---|
enabled | true | 状态通知总开关(不影响 AI/脚本的显式通知) |
notifyApproval | true | 要权限时通知 |
notifyQuestion | true | AI 提问时通知 |
notifyTurnEnd | false | 一轮回复结束时通知 |
notifyError | true | 会话出错时通知 |
agentTool | true | 注册 notify_user 工具(改后需重载插件) |
httpApi | true | 开启 /plugins/dsh-windows-notify/* 路由 |
focusOnClick | true | 点击通知回到 DSH |
sound | true | 提示音 |
displaySeconds | 10 | 显示时间限制:到点自动从屏幕与操作中心移除;0 = 不限制 |
sticky | false | 状态通知用系统「提醒」样式常驻(仅当 displaySeconds = 0 时生效) |
decisionButtons | true | 权限通知带「允许 / 拒绝」按钮 |
decisionSeconds | 300 | 按钮可点窗口(也会写进通知的显示时限);0 = 跟随 displaySeconds |
decisionButtonsFor | safe-only | 哪些审批带按钮:safe-only = 高危的 danger-full-access 不发按钮(只提示去界面确认),all = 全都发 |
privilegedModes | ['danger-full-access'] | 哪些权限模式算「高危」(不给通知按钮)。DSH 未来引入新特权档位时把名字加进来。判定只认权限模式本身,理由文本不参与 |
sessionFilter | [](不过滤) | 只对匹配的会话弹状态通知(审批/提问/回合结束/出错)。匹配会话标题或 cwd 末段(大小写不敏感的包含匹配),如 ['dsh-windows-notify', '猫窝']。显式通知(notify_user/HTTP)不受过滤 |
whenFocused | silent | DSH 在前台时怎么办:silent = 只进操作中心(SuppressPopup:不弹横幅、不响铃)、skip = 完全不发(历史仍记录)、notify = 照常弹。只作用于自动状态通知,notify_user/HTTP 的显式通知照常弹 |
language | zh | zh / en |
aumid | com.deepseek.dsh | 通知来源 AppUserModelID(桌面版开始菜单里的 AppID) |
dedupeMs | 4000 | 同 tag 去重窗口 |
maxPerMinute | 20 | 每分钟上限 |
debugLog | false | 打印每条通知的调试日志 |
前后台: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,所以链路是这样接的:
- 审批通知渲染成 `` 里两个按钮:
dsh-notify:allow//dsh-notify:reject/,按钮配色用hint-button-style(允许=Success / 拒绝=Critical), 点通知正文仍然是回到 DSH(dsh://open)。 - 插件把宿主半边注册的
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, 把地址尽早送过来。 - 宿主维护「待回传」表:token 一次性、默认 300 秒(
decisionSeconds)后作废,回传口只接受 本机回环请求。审批监听器把「GUI 里的回答」和「通知按钮的回答」赛跑: 谁先到用谁,另一边随即作废,并把那条通知从操作中心撤掉。 - 只在通道注册成功时才给按钮——不知道自身地址、注册失败、或
decisionButtons=false时都退化成普通通知(否则点下去系统会弹「选择打开方式」)。 - 顺手把 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基线在每次进入 「有待审批」时重新对齐,避免旧事实误伤新卡片。
- A(首选):从
安全性:token 16 位十六进制随机、只能用一次;回传口只认回环地址;即使 token 泄漏,最坏结果 也只是替你答了一次本来就在等你的审批,而它同时仍受 DSH 自己的授权策略约束。
HTTP 接口的统一安全门
所有 /plugins/dsh-windows-notify/* 路由(不只是写入口)都先过两道检查:
- 回环限定——非
127.0.0.1/::1的请求一律 403; - 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。