dsh-notify-me 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  All notable changes to **dsh-notify-me** are documented here.
4
4
 
5
+ ## [1.5.2] — 2026-10-04
6
+
7
+ ### 新增
8
+ - **设置页新增「运行环境」**(默认自动识别):按页面协议(`dsh-app://` = 桌面端 DSH Desktop)识别 Web 端 / 桌面端,识别不准时可手动指定「桌面端」或「Web 端」。手动覆盖同时决定两件事:点通知的抬窗通道(桌面端 `dsh://` 深链 / Web 端 `window.focus()`)与快捷裁决按钮的可用性标注;覆盖与自动识别不一致时设置页明示检测结果,强选「桌面端」但页面并非 `dsh-app://` 时另提示可能弹「打开应用」确认框。
9
+
10
+ ### 修复
11
+ - **桌面端「点击通知回到对应对话」真正把窗口带到前台**(issue #9):DSH Desktop 的页面跑在 `dsh-app://app`(Electron),DOM `window.focus()` 碰不到原生窗口,此前点通知只在后台切好对话、窗口不上前台。现在点通知正文时页面发一条隐藏 iframe 导航到 `dsh://open` —— 子框架导航不经过壳的 `will-navigate` / window-open 拦截,URL 交给系统协议注册,应用单实例锁的 `second-instance` 处理走壳自己的 `focusPrimaryWindow()`(restore + show + focus)。窗口隐藏在托盘、被别的窗口压住、最小化都能抬回来;切对话行为不变,浏览器端不受影响。
12
+
13
+ ### 变更
14
+ - **设置页在桌面端明示快捷裁决按钮不可用**:`dsh-app://` 页面无法注册 Service Worker(实测),审批通知的「同意 / 拒绝」按钮渲染不了、自动退回无按钮样式;此前开关本身毫无标注(只有测试按钮的反馈里带一行原始 `bridge` 状态),容易被当成开关坏了。现在设置页在桌面端直接说明这是宿主限制;「测试『审批按钮』」在无桥环境发普通测试通知时也明说按钮渲染不了。
15
+ - **测试按钮的反馈文字移到按钮正下方**:此前落在页面最底部(常在视口外),点了测试看不到任何反馈,像「没反应」;无桥 / 开关关闭时的提示也一并放在同一位置。
16
+ - `window.__dshNotifyMe.debug()` 新增 `desktop`(生效环境是否桌面端)、`raiseChannel`(`dsh-deeplink` / `window-focus`)、`hostEnv`(设置值)与 `hostEnvDetected`(自动识别结果)。
17
+ - 版本号 1.5.1 → 1.5.2;`window.__dshNotifyMe.version` 同步。
18
+ - README(中英)同步:设置清单加「运行环境」,新增「桌面端怎么抬窗的 / How the Desktop window gets raised」一节,「点击通知回到对应对话」「审批通知上直接裁决」两条口径与已知限制(桌面端按钮不可用由「未实测」改为实测结论)。
19
+
20
+ ### 测试
21
+ - 抬窗通道实测:最小 Electron 44 复刻(`setWindowOpenHandler` / `will-navigate` 与桌面壳同款拦截、不装 `setPermissionRequestHandler`)上,非沙箱 iframe 导航 `dsh://open` 抬起真实 DSH Desktop 窗口(restore + 前台约 0.8s),沙箱 iframe 被拒(Electron GHSA-p2rr-rvmm-c5fp 修复生效),`window.focus()` 不抬(与 issue #9 结论一致);`dsh://open` 经系统协议回流四轮实测均可靠抬窗。
22
+ - `smoke/smoke-test.cjs` 新增桌面抬窗通道用例段:桌面端点通知正文发 `dsh://open` 深链且仍切对话、浏览器端绝不发深链、`hostEnv` 手动覆盖双向生效(强 Web 桌面页不发链、强桌面 Web 页发链)。
23
+
24
+ ## [1.5.1] — 2026-10-04
25
+
26
+ ### 修复
27
+ - **「点击通知回到对应对话」在带按钮的审批通知上补齐窗口调度**:这类通知由 Service Worker 弹出,此前 worker 只把点击转告页面,窗口能不能到前台全押在页面消息回调里的 `window.focus()` 上——那段代码不在用户点击的手势上下文里,Windows 前台抢占规则下窗口可能根本抬不起来。现在 worker 在 `notificationclick` 里按规范用 `client.focus()` 调起窗口。
28
+ - **多标签页不再互相抢焦点、一起乱切对话**:worker 的点击转信此前广播给所有同源 DSH 标签页,每个开着的标签页都会聚焦自己并各自切对话。现在一次点击只挑一个窗口(优先当前可见的)调起并切对话,其余窗口只收「不导航」的副本、留在原地;「同意 / 拒绝」按钮照旧只裁决、不调窗口。
29
+ - 「点击通知回到对应对话」关掉后的承诺在持久通知上也成立:show 时把该设置记进通知 `data`,worker 据此不调任何窗口;旧版本弹出的遗留通知没有该字段,维持此前行为。
30
+
31
+ ### 变更
32
+ - 版本号 1.5.0 → 1.5.1;`window.__dshNotifyMe.version` 同步。
33
+ - README(中英)同步:「点击通知回到对应对话」补多窗口口径,「工作原理」补 worker 侧的窗口调度说明。
34
+
35
+ ### 测试
36
+ - `smoke/smoke-test.cjs` 快捷裁决桥用例补两个 window client 的点击路由断言:点正文只调起可见窗口并只让它切对话(其余客户端收 `navigate: false` 不动)、点正文不裁决;点按钮不调任何窗口也不切对话;`autoFocus` 关掉时通知 `data.focus` 记 `false`、点击既不调窗口也不切对话。
37
+
5
38
  ## [1.5.0] — 2026-10-03
6
39
 
7
40
  ### 新增
package/README.en.md CHANGED
@@ -52,12 +52,13 @@ dsh-notify-me decouples **supervision** from **sitting in front of the screen**:
52
52
  Open **Settings → Notify me** (refresh the page once after installing):
53
53
 
54
54
  - **Enable reminders** — master switch; when off, no toasts, no sounds and the tab title is never touched;
55
+ - **Environment** (auto-detect by default) — identifies Web vs Desktop (DSH Desktop, `dsh-app://`) from the page protocol, with a manual override for hosts where detection comes out wrong: on the Desktop a toast click rides the `dsh://` deep link to raise the window and quick-decision buttons are unavailable; on the Web it is `window.focus()` plus the Service Worker bridge. Whenever the override disagrees with detection, the settings page says what detection saw;
55
56
  - **System notifications / Sound / Volume** — toast toggle, WebAudio beep toggle and a volume slider;
56
57
  - **"Needs you" alerts while the page is open** (default on) and **"Reply finished" alerts while the page is open** (default off);
57
58
  - **Stay quiet for the conversation on screen** (default on): a wait inside the conversation you are looking at keeps only the tab marker — no toast or sound landing on the approval card. Backgrounding the page puts that alert back at once, unless you already handled it. Waits in other background sessions are unaffected;
58
59
  - **Ignore subagent sessions** (default on): background subagent child sessions raise neither "needs you" nor "reply finished" — those are steps of the parent conversation's turn, and the parent's own alert already covers them. The subagent session you have open alerts as usual. Turn it off to alert for every session;
59
- - **Click a toast to open that conversation** (default on): clicking brings DSH to the front and switches to the conversation the alert came from; when off, clicking a toast does nothing;
60
- - **Decide approvals from the toast** (default on): approval notifications carry Approve / Reject buttons, and one click settles the request exactly like the Allow once / Reject buttons on the approval card. Clicking the toast body still takes you back to the conversation;
60
+ - **Click a toast to open that conversation** (default on): clicking brings DSH to the front and switches to the conversation the alert came from. With several DSH tabs open, one click raises exactly one window (a visible one first) and switches only that one — the others stay put; when off, clicking a toast does nothing. On the Desktop (DSH Desktop) the click likewise raises the window and switches conversations: page-side `window.focus()` cannot raise a native Electron window, so since 1.5.2 the plugin rides the app's own `dsh://open` deep link instead (a window hidden to the tray comes back too);
61
+ - **Decide approvals from the toast** (default on): approval notifications carry Approve / Reject buttons, and one click settles the request exactly like the Allow once / Reject buttons on the approval card. Clicking the toast body still takes you back to the conversation. **Buttons are unavailable on the Desktop** (`dsh-app://` pages cannot register a Service Worker): approval toasts fall back to their buttonless form there, and the settings page says so;
61
62
  - **Notification language** — follow the interface / 简体中文 / English: controls the language of the alert text and the `🔔 …` title marker;
62
63
  - **Test buttons** — send one "needs you", "reply finished" or "approval buttons" test alert with the current settings (**not** limited by the "while the page is open" toggles; the "needs you" test's title marker clears itself after ~6s).
63
64
 
@@ -143,18 +144,22 @@ The reminder core stays dependency-free and self-contained: notification copy re
143
144
 
144
145
  ### Quick decisions, and how they reach the agent
145
146
 
146
- Action buttons (`actions`) belong to persistent notifications shown through `ServiceWorkerRegistration.showNotification()`; passing `actions` to the `new Notification()` constructor throws a `TypeError`. That is a browser rule no plugin can route around, so the bundle holds two jobs in the same bytes: the page half registers the reminder factory as it always did, and the very same file is registered as a Service Worker whose only task is relaying which button was clicked back to the page. The decision itself happens in the page, through the same `PendingApproval.answer('allowed-once' | 'rejected')` the approval card uses — which is why "Approve" is exactly "Allow once" and nothing broader.
147
+ Action buttons (`actions`) belong to persistent notifications shown through `ServiceWorkerRegistration.showNotification()`; passing `actions` to the `new Notification()` constructor throws a `TypeError`. That is a browser rule no plugin can route around, so the bundle holds two jobs in the same bytes: the page half registers the reminder factory as it always did, and the very same file is registered as a Service Worker whose only task is relaying which button was clicked back to the page. The decision itself happens in the page, through the same `PendingApproval.answer('allowed-once' | 'rejected')` the approval card uses — which is why "Approve" is exactly "Allow once" and nothing broader. The "raise a window and switch its conversation" part of a body click is scheduled on the worker half too: `client.focus()` raises exactly one window (a visible one first) and routes the conversation switch to that window alone, so several open DSH tabs never fight over focus or all switch at once.
147
148
 
148
149
  Every button toast is booked against its interaction key, so it can only decide the request it was raised for. Once the page answers that request or a newer one replaces it, clicking the leftover buttons decides nothing and just closes the notification. Button toasts also skip the auto-dismiss timer: an undecided approval stays in the notification center until you settle it.
149
150
 
151
+ ### How the Desktop window gets raised
152
+
153
+ DSH Desktop is a thin Electron shell and its page lives under `dsh-app://app`: DOM `window.focus()` only touches the browsing-context window, never the native one, and the shell's `will-navigate` / window-open handlers block every ordinary way for the page to fire a `dsh://` URL — so through 1.5.1 a toast click switched the conversation in the background but never brought the window forward. Since 1.5.2 a body click navigates a hidden iframe to `dsh://open`: subframe navigations pass the shell's `will-navigate` interception, the URL reaches the OS protocol registration, and the app's single-instance owner runs its own `focusPrimaryWindow()` (restore + show + focus) — the same path the login completion page uses to bring the client forward. A window hidden to the tray, buried under others, or minimized all come back. Browsers are unaffected and still use `window.focus()` / the worker's `client.focus()`.
154
+
150
155
  ## Known limitations
151
156
 
152
157
  - The page must be open for alerts to fire (background tab / minimized is fine; closing the tab stops it — that's inherent to a browser-layer plugin).
153
158
  - Notifications appear through the browser, so the browser needs "show notifications" permission in the OS notification settings, and notification-center "Do Not Disturb" must not suppress them.
154
159
  - The first sound/toast of each page load needs one user click on the page first (browser autoplay + permission policy).
155
160
  - Toasts are only produced once the notification permission is granted; declining means sound + title marker only.
156
- - Quick-decision buttons need a Service Worker: they work on `http://127.0.0.1` and `https`, not on plain `http://192.168.x.x`-style LAN addresses, and some browsers or desktop hosts never render notification buttons. The `bridge` field of `window.__dshNotifyMe.debug()` says why; toasts then fall back to their buttonless form and everything else keeps working.
157
- - macOS: in the browser, alerts, sounds, the title marker and toast-click navigation carry no OS assumptions and work as on Windows. Two caveats: DSH Desktop (Electron) notifications require the app to be code-signed — unsigned builds simply emit no notification — and whether the official macOS build satisfies that is untested; and notification buttons on macOS / desktop hosts are likewise unrendered in some cases, falling back to the buttonless form above.
161
+ - Quick-decision buttons need a Service Worker: they work on `http://127.0.0.1` and `https`, not on plain `http://192.168.x.x`-style LAN addresses, and some browsers or desktop hosts never render notification buttons. **The Desktop (`dsh-app://`) is tested and cannot register a Service Worker**, so buttons never render there and approval toasts fall back to their buttonless form. The `bridge` field of `window.__dshNotifyMe.debug()` says why; everything else keeps working.
162
+ - macOS: in the browser, alerts, sounds, the title marker and toast-click navigation carry no OS assumptions and work as on Windows. Two caveats: DSH Desktop (Electron) notifications require the app to be code-signed — unsigned builds simply emit no notification — and whether the official macOS build satisfies that is untested; and notification buttons on the macOS desktop host are likewise untested (the Windows desktop host is tested and renders none, see the line above), falling back to the buttonless form above.
158
163
  - Config lives in browser `localStorage`: switching browsers/devices or clearing site data returns to defaults (one-click restore in the Settings page).
159
164
  - On `>= 0.1.6` (including `0.2.0-rc.2`) the "reply finished" toast body carries only the session label, no assistant snippet: host snapshots no longer expose `nodes`. The alert itself still fires.
160
165
  - The title marker is written into `document.title` alongside the host: when the host recomputes the title it can overwrite the marker until the next alert event rewrites it.
package/README.md CHANGED
@@ -52,12 +52,13 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
52
52
  打开 **设置 → 通知提醒**(首次安装后刷新一次页面即可看到),页面提供:
53
53
 
54
54
  - **启用提醒** 主开关:关闭后不再弹系统通知、不播放提示音、也不改标签页标题;
55
+ - **运行环境**(默认自动识别):按页面协议识别 Web 端 / 桌面端(DSH Desktop,`dsh-app://`),识别不准时可手动指定——桌面端点通知走 `dsh://` 深链抬窗、快捷裁决按钮不可用;Web 端走 `window.focus()` 与 Service Worker 桥。手动覆盖与自动识别不一致时,设置页会明示检测结果;
55
56
  - **系统通知 / 提示音 / 音量**:Toast、WebAudio 提示音与音量滑块;
56
57
  - **页面打开时也提醒「需要你」**(默认开)与 **页面打开时也提醒「回复完成」**(默认关);
57
58
  - **当前对话不弹通知**(默认开):等待就发生在你正看着的对话里时只留标题标记,不弹通知也不响音(免得盖住审批卡片);页面切到后台立刻补发,若你已经处理掉就作废。其它后台会话不受影响;
58
59
  - **子代理会话不提醒**(默认开):后台子代理(subagent)子会话的「需要你」与「回复完成」都不提醒——它们是主对话那一轮的步骤,完成由主对话自己的提醒覆盖;你当前打开的那个子代理会话照常提醒;关掉后恢复逐会话提醒;
59
- - **点击通知回到对应对话**(默认开):点通知把 DSH 带到前台并切到提醒所属的对话;关掉后点通知不做任何事;
60
- - **审批通知上直接裁决**(默认开):权限审批的通知上带「同意 / 拒绝」按钮,点一下就按审批卡片的「允许一次 / 拒绝」直接裁决,不用切回页面;点通知正文仍是原来的「切回对应对话」;
60
+ - **点击通知回到对应对话**(默认开):点通知把 DSH 带到前台并切到提醒所属的对话;多个 DSH 标签页同时开着时,一次点击只调起一个窗口(优先当前可见的那个)并只切它的对话,其余标签页不动;关掉后点通知不做任何事。桌面端(DSH Desktop)同样会把窗口带到前台并切对话:页面侧的 `window.focus()` 在 Electron 里抬不动原生窗口,1.5.2 起改走应用自己的 `dsh://open` 深链调窗(窗口隐藏在托盘里也能被抬回来);
61
+ - **审批通知上直接裁决**(默认开):权限审批的通知上带「同意 / 拒绝」按钮,点一下就按审批卡片的「允许一次 / 拒绝」直接裁决,不用切回页面;点通知正文仍是原来的「切回对应对话」。**桌面端暂不支持按钮**(`dsh-app://` 页面无法注册 Service Worker),审批通知自动退回无按钮样式,设置页会明示这一限制;
61
62
  - **通知语言**:跟随界面 / 简体中文 / English——决定通知文字与 `🔔 …` 标题标记使用的语言;
62
63
  - **测试按钮**:用当前设置立即发一条「需要你」「回复完成」或「审批按钮」测试提醒(**不受**上面「页面打开时也提醒」开关限制;「需要你」测试的标题标记约 6 秒后自动消失)。
63
64
 
@@ -139,18 +140,22 @@ window.__dshNotifyMe.decide("approval:3", "allowed-once") // 程序化裁决:
139
140
 
140
141
  ### 快捷裁决怎么走通的
141
142
 
142
- 通知按钮(`actions`)只属于 Service Worker 弹的持久通知,往 `new Notification()` 里传 `actions` 会直接抛 `TypeError`——这是绕不开的浏览器限制。所以 bundle 一份文件干两件事:页面里照常注册提醒工厂,同一份字节再注册成一个只干转信的 Service Worker,把「点了哪个按钮」(同意 / 拒绝 / 点正文)带回页面。真正的裁决发生在页面里,调的是审批卡片自己用的 `PendingApproval.answer('allowed-once' | 'rejected')`,所以「同意」就是「允许一次」,不多放行任何东西。
143
+ 通知按钮(`actions`)只属于 Service Worker 弹的持久通知,往 `new Notification()` 里传 `actions` 会直接抛 `TypeError`——这是绕不开的浏览器限制。所以 bundle 一份文件干两件事:页面里照常注册提醒工厂,同一份字节再注册成一个只干转信的 Service Worker,把「点了哪个按钮」(同意 / 拒绝 / 点正文)带回页面。真正的裁决发生在页面里,调的是审批卡片自己用的 `PendingApproval.answer('allowed-once' | 'rejected')`,所以「同意」就是「允许一次」,不多放行任何东西。点正文的「调窗口 + 切对话」同样归 worker 这半侧调度:它用 `client.focus()` 只调起一个窗口(优先当前可见的),并只让那个窗口切对话——多个 DSH 标签页不会互相抢焦点或一起乱切。
143
144
 
144
145
  每条按钮通知按交互 key 记账,一次只认领一条等待;请求被页面里答掉、或被新请求顶替之后,残留通知上的按钮再点也不会误裁,只是安静地关掉。带按钮的通知不设自动消失——审批没裁决就一直留在通知中心里等你。
145
146
 
147
+ ### 桌面端怎么抬窗的
148
+
149
+ DSH Desktop 是一层 Electron 壳,页面跑在 `dsh-app://app` 下:DOM 的 `window.focus()` 只动浏览器窗口对象、碰不到原生窗口,壳的 `will-navigate` / `window-open` 又把页面里发 `dsh://` 的常规出口全堵上了——所以 1.5.1 及以前点通知只在后台切好对话,窗口不上前台。1.5.2 起点通知正文时页面发一条隐藏 iframe 导航到 `dsh://open`:子框架导航不经过壳的 `will-navigate` 拦截,URL 交给系统协议注册,应用的单实例锁收到第二次启动后走 `focusPrimaryWindow()`(restore + show + focus)——与登录完成页把客户端置前是同一条路。窗口隐藏在托盘、被别的窗口压住、最小化,都能被抬回来。浏览器端不受影响,仍走 `window.focus()` / worker `client.focus()`。
150
+
146
151
  ## 已知限制
147
152
 
148
153
  - 页面必须开着才会提醒(后台标签/最小化可以;关标签页即失效——浏览器层方案固有限制)。
149
154
  - 通知经由浏览器弹出,需允许浏览器通知权限,且勿扰模式不能屏蔽它。
150
155
  - 每次页面加载后第一次出声/弹通知前,需在页面上点击过一次(浏览器自动播放与权限策略)。
151
156
  - 未授权通知权限时只有提示音与标题标记。
152
- - 快捷裁决按钮要 Service Worker:`http://127.0.0.1`、`https` 可用,`http://192.168.x.x` 这类局域网地址不行;浏览器或桌面宿主不渲染通知按钮时,`window.__dshNotifyMe.debug()` 的 `bridge` 字段会说明原因,通知自动退回无按钮样式,其余功能不受影响。
153
- - macOS:浏览器里提醒、提示音、标题标记、点通知跳转都没有平台假设,照常工作;但 DSH Desktop(Electron)的系统通知要求应用已签名,未签名的通知直接发不出去,官方 macOS 版是否满足这一前提未实测;审批按钮在 macOS 与桌面宿主上的渲染同样未实测,不渲染时自动退回无按钮通知。
157
+ - 快捷裁决按钮要 Service Worker:`http://127.0.0.1`、`https` 可用,`http://192.168.x.x` 这类局域网地址不行;**桌面端(`dsh-app://`)已实测无法注册 Service Worker**,按钮渲染不了,审批通知退回无按钮样式。浏览器或桌面宿主不渲染通知按钮时,`window.__dshNotifyMe.debug()` 的 `bridge` 字段会说明原因,其余功能不受影响。
158
+ - macOS:浏览器里提醒、提示音、标题标记、点通知跳转都没有平台假设,照常工作;但 DSH Desktop(Electron)的系统通知要求应用已签名,未签名的通知直接发不出去,官方 macOS 版是否满足这一前提未实测;审批按钮在 macOS 桌面宿主上的渲染同样未实测(Windows 桌面端已实测不渲染,见上条),不渲染时自动退回无按钮通知。
154
159
  - 配置存在浏览器 `localStorage`:换浏览器/设备或清除站点数据后会回到默认值(设置页可一键恢复默认)。
155
160
  - `≥ 0.1.6`(含 `0.2.0-rc.2`)的「回复完成」提醒正文只有会话名,不再附带回复摘要——宿主快照已不再提供 `nodes`;提醒本身照常触发。
156
161
  - 标题标记由宿主与插件共同写 `document.title`:宿主重算标题时标记可能被覆盖,直到下一次提醒事件重新写成。
package/lib/client.js CHANGED
@@ -55,7 +55,8 @@
55
55
  // card uses. Action buttons only exist on persistent notifications, so this
56
56
  // file is dual-context: registered as a Service Worker as well, whose
57
57
  // notificationclick relays the chosen button back to this page (broadcast +
58
- // client postMessage). Where a worker cannot run (insecure context, no
58
+ // client postMessage) and, on a body click, raises exactly one window
59
+ // (client.focus()) and routes the conversation switch to that window alone. Where a worker cannot run (insecure context, no
59
60
  // bundle URL) the plugin degrades to today's plain toast;
60
61
  // - registers a "通知提醒 / Notify me" page into DSH Settings
61
62
  // (settings.section slot) exposing an on/off master switch, per-channel
@@ -103,22 +104,92 @@
103
104
  }
104
105
  } catch (e) {}
105
106
  }
107
+ // One relayed click; `navigate` says whether the receiving page may
108
+ // steer its window (raise it + switch to the alerted conversation).
109
+ function clickMsg(base, navigate) {
110
+ return {
111
+ source: base.source,
112
+ type: base.type,
113
+ action: base.action,
114
+ key: base.key,
115
+ sessionId: base.sessionId,
116
+ navigate: navigate
117
+ };
118
+ }
119
+ // A body click means "take me there", and both halves of that request are
120
+ // this handler's job. The window raise belongs here because only a
121
+ // notificationclick carries the click's user activation — the page-side
122
+ // window.focus() a plain relay triggers afterwards can lose the OS
123
+ // foreground race and leave DSH behind other windows. So exactly one
124
+ // window client is picked (a visible one first), that client alone gets
125
+ // `navigate: true` plus the focus() call, and every other recipient gets
126
+ // `navigate: false` and stays put: several open DSH tabs must not fight
127
+ // over focus or all switch their own conversation at once. Buttons stay
128
+ // hands-off — deciding from the toast must not drag a window up. The
129
+ // `data.focus` flag (the autoFocus setting at show time) keeps the
130
+ // "off = clicking does nothing" promise even though a worker cannot read
131
+ // the page's config; toasts from older versions carry no flag and keep
132
+ // the previous best-effort behavior.
133
+ function deliverClick(base, wantsFocus) {
134
+ var send = function (list) {
135
+ list = list || [];
136
+ var best = null;
137
+ if (wantsFocus) {
138
+ for (var i = 0; i < list.length; i++) {
139
+ var c = list[i];
140
+ if (!c || typeof c.postMessage !== "function") continue;
141
+ if (c.visibilityState === "visible") { best = c; break; }
142
+ if (!best) best = c;
143
+ }
144
+ }
145
+ for (var j = 0; j < list.length; j++) {
146
+ try { list[j].postMessage(clickMsg(base, wantsFocus && list[j] === best)); } catch (e) {}
147
+ }
148
+ try {
149
+ if (typeof g.BroadcastChannel === "function") {
150
+ var ch = new g.BroadcastChannel(BRIDGE_CHANNEL);
151
+ ch.postMessage(clickMsg(base, false));
152
+ try { ch.close(); } catch (e) {}
153
+ }
154
+ } catch (e) {}
155
+ if (best && typeof best.focus === "function") {
156
+ try {
157
+ var fp = best.focus();
158
+ if (fp && typeof fp.then === "function") fp.then(null, function () {});
159
+ } catch (e) {}
160
+ }
161
+ };
162
+ try {
163
+ if (g.clients && typeof g.clients.matchAll === "function") {
164
+ var p = g.clients.matchAll({ type: "window", includeUncontrolled: true });
165
+ if (p && typeof p.then === "function") {
166
+ p.then(send, function () { relay(clickMsg(base, wantsFocus)); });
167
+ return;
168
+ }
169
+ }
170
+ } catch (e) {}
171
+ relay(clickMsg(base, wantsFocus));
172
+ }
106
173
  g.addEventListener("notificationclick", function (event) {
107
174
  try {
108
175
  var n = event.notification;
109
176
  var data = (n && n.data) || {};
110
- var msg = {
177
+ var action = event.action || "";
178
+ var base = {
111
179
  source: "dsh-notify-me",
112
180
  type: "notification",
113
- action: event.action || "",
181
+ action: action,
114
182
  key: data.key || null,
115
183
  sessionId: data.sessionId || null
116
184
  };
117
185
  try { if (n && typeof n.close === "function") n.close(); } catch (e) {}
186
+ var run = action
187
+ ? function () { relay(base); }
188
+ : function () { deliverClick(base, data.focus !== false); };
118
189
  if (typeof event.waitUntil === "function") {
119
- event.waitUntil(Promise.resolve().then(function () { relay(msg); }));
190
+ event.waitUntil(Promise.resolve().then(run));
120
191
  } else {
121
- relay(msg);
192
+ run();
122
193
  }
123
194
  } catch (e) {}
124
195
  });
@@ -145,6 +216,12 @@
145
216
  // language of the notification copy: 'auto' follows the DSH UI locale,
146
217
  // 'zh' and 'en' pin Simplified Chinese / English.
147
218
  language: "auto",
219
+ // Which host environment this page runs in: 'auto' detects from the page
220
+ // protocol (dsh-app:// = the packaged DSH Desktop shell), 'desktop' and
221
+ // 'web' force it. The desktop rides the dsh:// deep-link raise path and
222
+ // cannot carry quick-decision buttons at all; the browser uses
223
+ // window.focus() and the Service Worker bridge.
224
+ hostEnv: "auto",
148
225
  // toast + sound are only useful while the user is elsewhere; the tab
149
226
  // being hidden is the reliable "away" signal.
150
227
  attentionHiddenOnly: false, // needs-input alerts even while the page is visible
@@ -163,7 +240,7 @@
163
240
  toast: true,
164
241
  sound: true,
165
242
  volume: 0.5,
166
- autoFocus: true, // clicking the toast focuses the DSH window
243
+ autoFocus: true, // clicking the toast raises DSH and switches to the alerted conversation
167
244
  // Approval toasts carry 同意/拒绝 buttons and settle the request on one
168
245
  // click (the same answer('allowed-once' | 'rejected') the in-page
169
246
  // approval card sends). Buttons need persistent notifications, served
@@ -289,6 +366,44 @@
289
366
  return "Notification" in window && Notification.permission === "granted";
290
367
  } catch (e) { return false; } // a policy-disabled Notification must not break delivery
291
368
  }
369
+ // True on the packaged desktop shell, where the app page lives under
370
+ // dsh-app://app inside Electron. The desktop needs its own raise path
371
+ // (raiseWindow below) and cannot carry quick-decision buttons at all.
372
+ // Detection is a page-protocol read; the hostEnv setting can override it
373
+ // for hosts where detection comes out wrong.
374
+ function detectedDesktop() {
375
+ try { return location.protocol === "dsh-app:"; } catch (e) { return false; }
376
+ }
377
+ function isDesktopShell() {
378
+ if (config.hostEnv === "desktop") return true;
379
+ if (config.hostEnv === "web") return false;
380
+ return detectedDesktop();
381
+ }
382
+ // Bring the OS window forward. A browser tab answers to window.focus();
383
+ // the Electron shell does not — DOM focus() never touches the native
384
+ // window, so a toast click there left the session switched but the window
385
+ // buried. The desktop rides the app's own dsh://open deep link instead:
386
+ // the URL is fired through a hidden iframe (the shell's will-navigate and
387
+ // window-open handlers intercept top-level navigations and window.open()
388
+ // to non-http(s) URLs, but not subframe navigations), the OS hands it to
389
+ // the protocol registration, and the single-instance owner restores,
390
+ // shows and focuses the window — the same path the login completion page
391
+ // uses to bring DSH forward.
392
+ function raiseWindow() {
393
+ try { window.focus(); } catch (e) {}
394
+ if (!isDesktopShell()) return;
395
+ try {
396
+ var f = document.createElement("iframe");
397
+ f.setAttribute("aria-hidden", "true");
398
+ f.setAttribute("tabindex", "-1");
399
+ f.style.cssText = "position:fixed;left:-10px;top:-10px;width:0;height:0;border:0;visibility:hidden";
400
+ f.src = "dsh://open";
401
+ (document.body || document.documentElement).appendChild(f);
402
+ setTimeout(function () {
403
+ try { if (f.parentNode) f.parentNode.removeChild(f); } catch (e) {}
404
+ }, 4000);
405
+ } catch (e) {}
406
+ }
292
407
  // Bring the window forward and make the alerted session the current one,
293
408
  // so a click on the toast lands on the conversation that needs the user
294
409
  // instead of just the app. Navigation moved with the host API: `sessions`
@@ -298,7 +413,7 @@
298
413
  // cordis service that cannot be resolved must still leave a usable window
299
414
  // in front.
300
415
  function focusSession(sessionId) {
301
- try { window.focus(); } catch (e) {}
416
+ raiseWindow();
302
417
  if (!sessionId) return;
303
418
  try {
304
419
  var st = listStore && listStore.getSnapshot();
@@ -415,6 +530,11 @@
415
530
  if (!msg || msg.source !== "dsh-notify-me" || msg.type !== "notification") return;
416
531
  if (msg.action === "approve") { decidePending(msg.key, "allowed-once"); return; }
417
532
  if (msg.action === "reject") { decidePending(msg.key, "rejected"); return; }
533
+ // The worker picks the one window a click should land on (see the bridge):
534
+ // its `navigate: false` copies keep every other DSH tab from stealing
535
+ // focus or switching its own conversation. A message from before that
536
+ // flag existed carries none and falls back to the old best-effort route.
537
+ if (msg.navigate === false) return;
418
538
  if (config.autoFocus) focusSession(msg.sessionId || null);
419
539
  }
420
540
  // The live interaction wins over the reference captured at alert time (the
@@ -515,7 +635,7 @@
515
635
  // The plugin owns the audio (see showToast's silent note below).
516
636
  silent: true,
517
637
  icon: (location.origin || "") + "/favicon.svg",
518
- data: { key: quick.key, sessionId: sessionId || null, kind: kind, test: !!quick.test },
638
+ data: { key: quick.key, sessionId: sessionId || null, kind: kind, test: !!quick.test, focus: config.autoFocus !== false },
519
639
  actions: [
520
640
  { action: "approve", title: d.approve },
521
641
  { action: "reject", title: d.reject }
@@ -1359,7 +1479,7 @@
1359
1479
  });
1360
1480
  } catch (e) {}
1361
1481
  // keep in sync with package.json "version" on every release
1362
- api.version = "1.5.0";
1482
+ api.version = "1.5.2";
1363
1483
  api.setConfig = function (patch) { return setConfigLocal(patch); };
1364
1484
  api.resetConfig = function () {
1365
1485
  try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
@@ -1399,6 +1519,11 @@
1399
1519
  // quick-decision bridge: 'active' is the only state in which approval
1400
1520
  // toasts carry working 同意/拒绝 buttons.
1401
1521
  bridge: swState,
1522
+ // how a toast click gets the OS window forward on this host
1523
+ hostEnv: config.hostEnv,
1524
+ hostEnvDetected: detectedDesktop() ? "desktop" : "web",
1525
+ desktop: isDesktopShell(),
1526
+ raiseChannel: isDesktopShell() ? "dsh-deeplink" : "window-focus",
1402
1527
  actionKeys: Object.keys(pendingActions),
1403
1528
  permission: ("Notification" in window) ? Notification.permission : "n/a",
1404
1529
  visibility: hidden() ? "hidden" : "visible",
@@ -1426,6 +1551,14 @@
1426
1551
  blurb: "当模型停下来需要你操作(审批 / 方案待确认 / 提问),或回复在后台完成时,用系统通知、提示音和标签页标题提醒你。",
1427
1552
  enable: "启用提醒",
1428
1553
  enableHint: "总开关:关闭后不再弹系统通知、不播放提示音,也不再改动标签页标题。",
1554
+ hostEnv: "运行环境",
1555
+ hostEnvHint: "默认自动识别:页面协议是 dsh-app:// 即桌面端(DSH Desktop),其余为 Web 端。识别不准时手动指定:桌面端点通知借 dsh:// 深链把窗口带到前台、快捷裁决按钮不可用;Web 端走 window.focus() 与 Service Worker 桥。",
1556
+ hostEnvAuto: "自动识别(推荐)",
1557
+ hostEnvDesktop: "桌面端(DSH Desktop)",
1558
+ hostEnvWeb: "Web 端(浏览器)",
1559
+ hostEnvMismatch: "(自动识别结果是「",
1560
+ hostEnvMismatchTail: "」,手动选择会覆盖它)",
1561
+ hostEnvDesktopForcedNote: "(当前页面不是 dsh-app://:点通知会尝试唤起 dsh:// 协议,浏览器可能弹「打开应用」确认框)",
1429
1562
  toast: "系统通知",
1430
1563
  toastHint: "经浏览器弹出系统 Toast(Windows 通知中心);需要已授予通知权限。",
1431
1564
  sound: "提示音",
@@ -1445,6 +1578,9 @@
1445
1578
  quickApprovalNote: "「同意」等价于审批卡片上的「允许一次」,只放行这一次。",
1446
1579
  bridgeOff: "快捷裁决按钮暂不可用(",
1447
1580
  bridgeOffTail: "):审批通知会退回不带按钮的普通样式。",
1581
+ bridgeOffDesktop: "桌面端暂不支持快捷裁决按钮:dsh-app:// 页面无法注册 Service Worker,审批通知只能是不带按钮的普通样式,测试按钮同样只发普通通知。这是宿主限制,不是设置坏了。",
1582
+ testApprovalNoBridge: "已发普通测试通知(快捷裁决按钮需要 Service Worker,当前环境没有,按钮渲染不了)。",
1583
+ testNeedsQuick: "先打开上面的「审批通知上直接裁决」才能测按钮。",
1448
1584
  volume: "音量",
1449
1585
  language: "通知语言",
1450
1586
  langAuto: "跟随界面",
@@ -1468,6 +1604,14 @@
1468
1604
  blurb: "When the agent stops and needs your input (approval / plan review / question) or a reply finishes in the background, get a system notification, a sound, and a tab-title marker.",
1469
1605
  enable: "Enable reminders",
1470
1606
  enableHint: "Master switch: when off, no toasts, no sounds, and the tab title is never modified.",
1607
+ hostEnv: "Environment",
1608
+ hostEnvHint: "Auto-detect by default: a dsh-app:// page is the Desktop (DSH Desktop) shell, anything else is the Web. Override it if detection comes out wrong: on the Desktop a toast click raises the window through the dsh:// deep link and quick-decision buttons are unavailable; on the Web it is window.focus() plus the Service Worker bridge.",
1609
+ hostEnvAuto: "Auto-detect (recommended)",
1610
+ hostEnvDesktop: "Desktop (DSH Desktop)",
1611
+ hostEnvWeb: "Web (browser)",
1612
+ hostEnvMismatch: " (auto-detection sees \"",
1613
+ hostEnvMismatchTail: "\"; the manual choice overrides it)",
1614
+ hostEnvDesktopForcedNote: " (this page is not dsh-app://: a toast click will try to launch the dsh:// protocol and the browser may show an \"open app?\" prompt)",
1471
1615
  toast: "System notifications",
1472
1616
  toastHint: "Native toasts via the browser (Windows notification center); requires notification permission.",
1473
1617
  sound: "Sound",
@@ -1487,6 +1631,9 @@
1487
1631
  quickApprovalNote: "\"Approve\" equals \"Allow once\" on the approval card — it allows that one call only.",
1488
1632
  bridgeOff: "Quick-decision buttons are unavailable right now (",
1489
1633
  bridgeOffTail: "); approval toasts fall back to their plain form.",
1634
+ bridgeOffDesktop: "Quick-decision buttons are unavailable on Desktop: dsh-app:// pages cannot register a Service Worker, so approval toasts fall back to their plain, button-less form and the test button only sends a plain notification. This is a host limitation, not a broken setting.",
1635
+ testApprovalNoBridge: "Plain test notification sent (quick-decision buttons need a Service Worker, which this environment lacks, so they cannot render).",
1636
+ testNeedsQuick: "Turn on \"Decide approvals from the toast\" above to test the buttons.",
1490
1637
  volume: "Volume",
1491
1638
  language: "Notification language",
1492
1639
  langAuto: "Follow the interface",
@@ -1587,19 +1734,45 @@
1587
1734
  testAlert(kind);
1588
1735
  var langName = effectiveLang() === "en" ? t("langEn") : t("langZh");
1589
1736
  var msg = t("sent") + langName;
1590
- if (kind === "approval" && !bridgeActive()) msg += " · " + t("bridgeOff") + swState + t("bridgeOffTail");
1737
+ if (kind === "approval" && !bridgeActive()) {
1738
+ msg += " · " + (isDesktopShell() ? t("testApprovalNoBridge") : t("bridgeOff") + swState + t("bridgeOffTail"));
1739
+ }
1591
1740
  setFeedback(msg);
1592
1741
  }
1593
1742
  var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
1594
1743
  var resolvedLang = effectiveLang();
1595
1744
  var resolvedLangName = resolvedLang === "en" ? t("langEn") : t("langZh");
1745
+ // The environment row states what detection saw whenever the manual
1746
+ // choice disagrees with it — the override exists for hosts where the
1747
+ // protocol read comes out wrong, so the divergence must be visible.
1748
+ var envHint = t("hostEnvHint");
1749
+ if (cfg.hostEnv === "desktop" && !detectedDesktop()) envHint += t("hostEnvDesktopForcedNote");
1750
+ else if (cfg.hostEnv !== "auto" && isDesktopShell() !== detectedDesktop()) {
1751
+ envHint += t("hostEnvMismatch") + (detectedDesktop() ? t("hostEnvDesktop") : t("hostEnvWeb")) + t("hostEnvMismatchTail");
1752
+ }
1596
1753
  return h("div", { className: "dnm-wrap" }, [
1597
1754
  h("p", { className: "dnm-blurb" }, t("blurb")),
1598
1755
  card([
1599
1756
  rowSwitch("enabled", t("enable"), t("enableHint"), cfg.enabled, function (v) {
1600
1757
  patch({ enabled: v });
1601
1758
  if (!v && attentionActive > 0) { attentionActive = 0; applyTitle(); }
1602
- })
1759
+ }),
1760
+ h("div", { className: "dnm-row" }, [
1761
+ h("div", { className: "dnm-rowText" }, [
1762
+ h("div", { className: "dnm-rowLabel" }, t("hostEnv")),
1763
+ h("div", { className: "dnm-rowHint" }, envHint)
1764
+ ]),
1765
+ h("select", {
1766
+ className: "dnm-select",
1767
+ value: cfg.hostEnv,
1768
+ "aria-label": t("hostEnv"),
1769
+ onChange: function (e) { patch({ hostEnv: e.currentTarget.value }); }
1770
+ }, [
1771
+ h("option", { value: "auto" }, t("hostEnvAuto")),
1772
+ h("option", { value: "desktop" }, t("hostEnvDesktop")),
1773
+ h("option", { value: "web" }, t("hostEnvWeb"))
1774
+ ])
1775
+ ])
1603
1776
  ]),
1604
1777
  card([
1605
1778
  rowSwitch("toast", t("toast"), t("toastHint"), cfg.toast, function (v) { patch({ toast: v }); }),
@@ -1657,9 +1830,12 @@
1657
1830
  ])
1658
1831
  ]),
1659
1832
  cfg.quickActions && cfg.toast && !bridgeActive() ? h("div", { className: "dnm-row" }, [
1660
- h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("bridgeOff") + swState + t("bridgeOffTail"))])
1833
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, isDesktopShell() ? t("bridgeOffDesktop") : t("bridgeOff") + swState + t("bridgeOffTail"))])
1661
1834
  ]) : h("div", { className: "dnm-row" }, [
1662
- h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("quickApprovalNote"))])
1835
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, cfg.quickActions ? t("quickApprovalNote") : t("testNeedsQuick"))])
1836
+ ]),
1837
+ h("div", { className: "dnm-row" }, [
1838
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-feedback" }, feedback)])
1663
1839
  ]),
1664
1840
  h("div", { className: "dnm-row" }, [
1665
1841
  h("div", { className: "dnm-rowText" }, [
@@ -1677,8 +1853,7 @@
1677
1853
  !permOK ? h("div", { className: "dnm-row" }, [
1678
1854
  h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("permHint"))])
1679
1855
  ]) : null
1680
- ]),
1681
- h("p", { className: "dnm-feedback" }, feedback)
1856
+ ])
1682
1857
  ]);
1683
1858
  }
1684
1859
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-me",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "DSH 桌面消息提醒插件:模型需要你操作(审批/方案确认/提问的可操作提醒)或回复在后台完成时,系统通知+提示音+标签页标题提醒你;权限审批的通知上可直接点「同意/拒绝」完成裁决;可在设置页开关并切换通知语言。Desktop notification & message alerts for DeepSeek Harness web — toasts, sounds and a tab-title marker when the agent needs your input (approval / plan review / question) or a reply finishes in the background, Approve/Reject buttons on approval toasts to decide right from the notification; toggleable from Settings with zh/en notification language.",
5
5
  "license": "MIT",
6
6
  "type": "module",