dsh-notify-me 1.13.0 → 1.14.0

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,36 @@
2
2
 
3
3
  All notable changes to **dsh-notify-me** are documented here.
4
4
 
5
+ ## [1.14.0] — 2026-10-23
6
+
7
+ ### 新增
8
+
9
+ - **桌面端点通知真的能抬窗了**:插件在宿主侧注册一条 exact 路由 `POST /__dsh-notify-me/focus-window`,页面点通知时 POST 过去,宿主 spawn 一次 `powershell.exe` 跑 `lib/focus-window.ps1`,按 pid 找到 Electron 主窗口后做原生激活,把 `FOCUSED=1|0` 回成 `200` / `202`。此前那条 `dsh://open` 深链在 0.2.1-rc.2 上隔离复测 3/3 无效,1.14.0 起整条删掉。
10
+ - **`debug()` 多一个 `raiseLast`**:`focused` / `not-focused:<原因>` / `http-<状态码>` / `error` / `browser` / `no-fetch`,报障时能分清是「路由没到」「脚本没抬起来」还是「浏览器里本来就只有 `window.focus()`」。`raiseChannel` 现在桌面端是 `host-route`,浏览器端仍是 `window-focus`。
11
+ - **路由自带三条自证**:`GET` 返回 `{ok, route, enabled, desktop, platform}`,装机后可以先用浏览器打开确认路由在不在;非桌面宿主(Web 版、macOS / Linux 桌面、纯 Node)一律 `501 unsupported-host`,不会去 spawn 一个注定失败的进程。
12
+
13
+ ### 移除
14
+
15
+ - **`dsh://open` 隐藏 iframe 通道**:子框架导航绕过 `will-navigate` 那条路依旧在,但应用的单实例让位每次都先于聚焦发生(带参第二实例约 130 ms 后退出码 1,`IsIconic` 不变),留着只是白起一个进程。
16
+
17
+ ### 说明
18
+
19
+ - **为什么走 PowerShell 而不是原生模块**:插件宿主是普通 Node 进程(壳以 `ELECTRON_RUN_AS_NODE=1` 拉起),拿不到 Electron API;带原生依赖还得跟宿主 ABI 对齐。`powershell.exe` 在 Windows 10/11 上必在,脚本用 `Add-Type` 现编译 P/Invoke,零安装。
20
+ - **抬窗顺序是实的,不是拍脑袋**:已在前台就什么都不做;最小化了先 `SW_RESTORE`;然后合成 ALT 抖动给本线程前台权限再 `SetForegroundWindow`;都不行才走 `SW_MINIMIZE` + `SW_RESTORE` 兜底。单独调 `SetForegroundWindow` 被前台锁拒,退到 ALT 抖动那一步就过——这条来自 [discussions #10446 的实测](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19143913),也解释了此前「时灵时不灵」的观感。
21
+ - **等待上限 8 秒、400 毫秒内合并**:脚本自身最坏 0.4 秒,超时按 `FOCUSED=0` 收口并 kill 掉;连点两条通知只跑一次。
22
+ - **只 Windows**:`process.platform === "win32"` 且宿主跑在 Electron 里(`process.versions.electron`)才启用,其余平台一律 `501`,页面的抬窗行为跟以前一样。
23
+
24
+ ### 测试
25
+
26
+ - 冒烟新增 6 个场景:桌面页面断言 POST 到路由、带 `x-dsh-notify-me-focus` 头、点击后切会话、`raiseLast` 为 `focused`、不再插 iframe;浏览器断点零 `fetch`、`raiseLast` 为 `browser`;`hostEnv` 两个方向都能覆盖;`501` 与 `fetch` 抛错时都只记状态、不挡切会话。
27
+ - cordis 冒烟新增路由段:真 `Context` + 假 `webServer`,断言只注册一条 exact 路由、`GET` 探测形状、非桌面 `501`、`DELETE` 405、缺 header 403,以及 `FOCUSED=1` / `FOCUSED=0` 两个桩脚本分别回 `200` / `202`。
28
+ - 真实窗口实测(`powershell.exe` 5.1,同一轮):窗口在前台 ×3、最小化 + 记事本在前台 ×3、可见被盖住 ×3 全部 `FOCUSED=1` 且前台比对一致;记事本最大化遮挡、最小化 + 最大化遮挡、`-ProcessId 0` 自动探测同样成功;脚本内部耗时最坏 434 ms。`pwsh` 7.6 对照一轮行为一致,仅 `Add-Type` 编译慢约 100 ms。不存在的 pid 与存在但无窗口的 pid 都稳定 `FOCUSED=0` 且退出码 0。
29
+
30
+ ### 文档
31
+
32
+ - README 中英重写[桌面端怎么抬窗的](README.md#桌面端怎么抬窗的):删掉 1.5.2 深链那套叙述,换成宿主路由 + 原生激活的链路、实测数据与失败时的回退行为;已知限制里「桌面端不一定上前台」换成「提权窗口 / 虚拟桌面 / 前台被连续抢占时仍可能抬不起来,此时返回 `202` 但对话照常切」。
33
+ - 致谢加上 [@xinming-chenniang](https://github.com/xinming-chenniang):[discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19143913) 里给的五种手法实测表与「宿主自定义路由 + 同源校验 + PowerShell 兜底链」的思路,是这一版落地的直接依据。
34
+
5
35
  ## [1.13.0] — 2026-10-23
6
36
 
7
37
  ### 新增
package/README.en.md CHANGED
@@ -127,7 +127,7 @@ Open **Settings → Notify me**. The page runs as how you get told, when to aler
127
127
  - **When "reply finished" alerts** (default "background only"): three choices. "Background only" is the old rule — alert when you are not watching the page; "quiet for the open one" stays silent only for the conversation on screen while any other conversation still alerts (useful with several open); "always" never suppresses. An old profile's "also alert reply finished while in front" switch migrates to this setting;
128
128
  - **Dismiss the toast automatically** (default "keep while away"): three choices. "Keep while away" only closes a toast a few seconds later while you are at the page, so one that arrived while you were away is still in the notification centre when you come back; "always close" is the old tidy-up; "never close" leaves plain toasts up until you dismiss one. Approvals keep their 12 s rule and are likewise not withdrawn while you are away;
129
129
  - **Ignore subagent sessions** (on): skip background sessions spawned by a subagent — the parent conversation covers them; the one you have open still alerts;
130
- - **Click a toast to open that conversation** (on): the click switches to the conversation that raised it; in a browser it also brings the window forward, while on the Desktop shell it is flaky — sometimes the window comes forward, sometimes only the conversation switches (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)); with several tabs open only one is raised, and off means a click does nothing.
130
+ - **Click a toast to open that conversation** (on): the click switches to the conversation that raised it; in a browser it also brings the window forward, while on the Desktop shell the host route raises the native window (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)) and a failure only leaves the conversation switch in place, with the reason in `debug().raiseLast`; with several tabs open only one is raised, and off means a click does nothing.
131
131
 
132
132
  ### Try it
133
133
 
@@ -168,13 +168,13 @@ Off by default. Supports Discord Webhook / Bark / Gotify / Feishu group bots / S
168
168
 
169
169
  ### Runtime & diagnostics
170
170
 
171
- The last block inside Advanced. The page protocol is detected automatically to tell Web from Desktop (`dsh-app://`); override it by hand if detection is wrong — Desktop clicks fire the `dsh://` deep link (the effect is unreliable — see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)) and carry no quick-decision buttons, while Web uses `window.focus()` plus the Service Worker bridge. When the override disagrees with detection the page says so.
171
+ The last block inside Advanced. The page protocol is detected automatically to tell Web from Desktop (`dsh-app://`); override it by hand if detection is wrong — Desktop clicks go through the host route for native activation (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)) and carry no quick-decision buttons, while Web uses `window.focus()` plus the Service Worker bridge. When the override disagrees with detection the page says so.
172
172
 
173
173
  <details>
174
174
  <summary>Alert language, Desktop window raising, reset</summary>
175
175
 
176
176
  - **Alert language**: follow the UI / Simplified Chinese / English — governs alert copy and the `🔔 …` title marker;
177
- - **How the Desktop window is raised**: `window.focus()` cannot lift a native Electron window, so since 1.5.2 the page drives the app's own `dsh://open` deep link — measured working on DSH Desktop 0.2.0-rc.2, flaky on 0.2.1-rc.2 / Windows (see the next section);
177
+ - **How the Desktop window is raised**: `window.focus()` cannot lift a native Electron window, so since 1.14.0 the page POSTs to the host route `/__dsh-notify-me/focus-window` and the host runs one PowerShell pass to activate the window natively — measured raising it back from both minimized and covered (see the next section);
178
178
  - **Reset settings**: restores every switch and cue setting to the factory defaults (webhook targets and chosen audio files are kept).
179
179
 
180
180
  </details>
@@ -269,7 +269,7 @@ window.__dshNotifyMe.testExternal() // explicit outbound test to v
269
269
 
270
270
  ## How it works
271
271
 
272
- The reminder core watches session state and pending interactions. The page handles approval decisions; Desktop window activation uses the app deep link.
272
+ The reminder core watches session state and pending interactions. The page handles approval decisions; Desktop window activation uses a host route plus native Win32 calls.
273
273
 
274
274
  <details>
275
275
  <summary>Event sources, approval bridge, and Desktop window activation</summary>
@@ -291,17 +291,17 @@ Every button toast is booked against its interaction key, so it can only decide
291
291
 
292
292
  ### How the Desktop window gets raised
293
293
 
294
- 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()`.
294
+ 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` handler blocks the page from firing a `dsh:` URL. From 1.5.2 through 1.13.0 the plugin sidestepped that with a hidden iframe navigating to `dsh://open`, relying on the app's single-instance yield to focus the window second-hand. Isolated retests on 0.2.1-rc.2 failed 3/3, so 1.14.0 removes that path and goes through the plugin's own host route instead.
295
295
 
296
- Verified on the real shell (DSH Desktop 0.2.0-rc.2, three rounds of retesting in issue #9): a toast click raises the window and switches the conversation in one step, stable across sessions and with the window in the background. Timing of the fire is what makes the chain work: the deep link must be launched from a **user-gesture handler** — the notification's `onclick` carries one, which is why the product path works — while gesture-less scripted injection (an iframe appended from a CDP evaluate, say) is silently dropped by Chromium and the external protocol never launches, which looks exactly like the channel being blocked.
296
+ For every non-asset path under `dsh-app://app` the shell forwards the request to the host HTTP server (with an origin check and the host cookie injected), so a page POST to `/__dsh-notify-me/focus-window` is a same-origin request with no CORS involved. The host half (`lib/index.js`) already runs as a child of the Electron main process, and its `process.ppid` is that of the window's browser process. On a toast click the page POSTs, the host spawns one `powershell.exe` running `lib/focus-window.ps1 -ProcessId <ppid>`, the script enumerates the visible, owner-less, non-toolwindow top-level windows of that pid (preferring class `Chrome_WidgetWin_1`, then the largest area from `GetWindowRect`), raises it, compares `GetForegroundWindow()` and prints `FOCUSED=1|0`, and the host answers `200` / `202`.
297
297
 
298
- **Flaky on 0.2.1-rc.2 / Windows** (measured 2026-10-20): the toast click handler still runs (`navigator.userActivation.isActive === true`, `visibilityState` is `hidden`), but whether the window actually comes forward is inconsistent — sometimes it does, sometimes only the conversation switches while the window stays put.
298
+ The raise order is adaptive and comes from the five-method table measured in [discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19143913): already in front means do nothing (the common case, no flicker); minimized means `SW_RESTORE` first; then a synthetic Alt tap (`keybd_event` down/up) grants the calling thread foreground permission before `SetForegroundWindow`; only if all of that fails does it fall back to `SW_MINIMIZE` + `SW_RESTORE`. `SetForegroundWindow` on its own gets refused by the foreground lock — per call, unrelated to `ForegroundLockTimeout`. That also re-reads the old "sometimes it works" behaviour: the times the window did come forward were most likely the OS activating the app on the click itself.
299
299
 
300
- Do not count on the plugin's own path: isolating the `dsh://open` deep link (three ways, three runs each — launching the exe with the argument, OS protocol activation, and a real `WM_SYSCOMMAND` minimize followed by the deep link) failed **every time**. The second instance with the argument exits with code 1 after ~130 ms (without arguments it exits with code 0 after ~260 ms, the normal single-instance yield), and after a real minimize `IsIconic` stays `true`. So the times it does come forward are most likely the OS/Chromium activating the app on the notification click itself, not the deep link.
300
+ Measured with `powershell.exe` 5.1 in one round: window in front ×3, minimized with Notepad in front ×3, visible but covered ×3 — all `FOCUSED=1` with the foreground comparison matching and every `IsIconic` flipped back to `false`; Notepad maximized, minimized plus maximized, and `-ProcessId 0` auto-detection all succeeded too. Script-internal time peaked at 434 ms, process wall time at 782 ms, well inside the 8 s cap; `pwsh` 7.6 behaved identically, only slower by ~100 ms compiling the `Add-Type` helpers (the script avoids PowerShell 7 syntax on purpose). A pid that does not exist and a pid with no visible window both settle on `FOCUSED=0` and `exit 0`.
301
301
 
302
- There is no page-side fallback either: the shell preload only exposes `dshDesktop`, the directory picker and locale, `DESKTOP_IPC` has no focus/raise channel, and the main window's `will-navigate` rejects `dsh:` URLs outright. The repro and code locations are filed upstream in [deepseek-harness discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446).
302
+ The route is a single exact path and says no by default: non-POST answers `405`, a non-Desktop host (Web, macOS / Linux, plain Node) `501`, a missing `x-dsh-notify-me-focus: focus` header `403`. A cross-origin page would have to pass a preflight to send that header and the route allows no CORS at all, so other sites in the browser cannot drive it. A `GET` returns `{ok, route, enabled, desktop, platform}` so you can check the route from a browser after installing. Calls within 400 ms coalesce into one, and a script that has not answered after 8 s is killed and counted as a failure.
303
303
 
304
- </details>
304
+ A few cases still will not raise, and they answer `202` with the conversation switch intact: a target window owned by a higher integrity level (elevated / UAC), a window on another virtual desktop, or a foreground that keeps getting stolen. A tray-hidden window (`SW_HIDE` rather than minimized) is filtered out as well; DSH does not do that today.
305
305
 
306
306
  ## Known limitations
307
307
 
@@ -309,7 +309,7 @@ There is no page-side fallback either: the shell preload only exposes `dshDeskto
309
309
  - 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.
310
310
  - The first sound/toast of each page load needs one user click on the page first (browser autoplay + permission policy).
311
311
  - Toasts are only produced once the notification permission is granted; declining means sound + title marker only.
312
- - On the Desktop shell (DSH Desktop 0.2.1-rc.2 / Windows) a toast click does not reliably bring the window forward: sometimes it does, sometimes only the conversation switches. The plugin's `dsh://open` deep link on its own failed 3/3 in isolation (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)), so when it does come forward it is most likely the OS/Chromium activating the app on the notification click itself ([discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446)).
312
+ - On the Desktop shell (DSH Desktop, Windows) a toast click can still fail to raise the window: a target owned by a higher integrity level (elevated / UAC), a window on another virtual desktop, a foreground that keeps getting stolen, or a window hidden with `SW_HIDE` to the tray all end in `FOCUSED=0` and a `202` from the route, with the conversation switch intact (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)).
313
313
  - System notification banners are drawn on the primary display only: Windows has no setting and Chromium no API to aim one at another monitor, so on a multi-monitor setup the second screen never receives them. Forwarding to your phone with external notifications is the only route across screens (since 1.12.0 the page no longer draws an approval card).
314
314
  - 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** (the shell does not grant the `allowServiceWorkers` privilege), 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.
315
315
  - 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.
@@ -328,6 +328,7 @@ node --check lib\client.js
328
328
  node --check lib\index.js
329
329
  node smoke\smoke-test.cjs # offline state-machine test: three host generations + master switch + zh/en cases + the quick-decision bridge (worker half included) + idle detection and toast self-dismiss
330
330
  node smoke\cordis-host-test.mjs # real-cordis end-to-end (skips when no local DSH installation is found)
331
+ powershell -NoProfile -File lib\focus-window.ps1 -ProcessId 0 # manual raise test, the last line should be FOCUSED=1
331
332
  npm pack --dry-run # preview the published tarball
332
333
  ```
333
334
 
@@ -346,6 +347,7 @@ Contributors (by handle; the link points at the PR / issue they worked on):
346
347
  - [@yuexian7](https://github.com/yuexian7) — [#17](https://github.com/chromoany/dsh-notify-me/issues/17): the ask for alerts on an Android phone — Discord is awkward in mainland China and Bark has no Android app, so a self-hosted relay was the only option; 1.13.0's Feishu / ServerChan / WxPusher targets, retries, and failure log came out of it.
347
348
  - [@shrekcg](https://github.com/shrekcg) — [#16](https://github.com/chromoany/dsh-notify-me/pull/16): counting "in front but walked away" as away (`idleMinutes`, 3 minutes by default), deciding "reply finished" alerts by which conversation finished (`donePolicy`, three choices), the three-way toast self-dismiss policy (`autoDismiss`), with matching smoke cases and old-config migration; when it landed in 1.12.0 the maintainer added the idle-expiry replay for suppressed approvals, removed the leftover in-page approval card, and brought docs and version up to date.
348
349
 
350
+ - [@xinming-chenniang](https://github.com/xinming-chenniang) — [deepseek-harness discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19143913): the five-method Win32 raise comparison (`SetForegroundWindow` alone refused by the foreground lock, accepted after a synthetic Alt tap, minimize→restore working in both scenarios), plus a working proof of the "host-registered same-origin route + PowerShell fallback chain" detour; the direct basis for 1.14.0's host route and `lib/focus-window.ps1`.
349
351
  Attribution policy: external PRs are landed as maintainer commits — the original commit is not reused and no Co-authored-by trailer is added, so GitHub's contributors graph lists the maintainer only and contributors are credited above.
350
352
 
351
353
  ## License
package/README.md CHANGED
@@ -123,7 +123,7 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
123
123
  - **「回复完成」什么时候提醒**(默认「仅后台」):三选一。「仅后台」=原来的规则,不在看页面才提醒;「仅当前对话静默」=屏幕上那条静默,别的对话完成照常提醒,多开对话时有用;「总是提醒」=从不静默。老配置里的「窗口在前台时也提醒『回复完成』」开关自动迁移到这一项;
124
124
  - **通知自动消失**(默认「离开时保留」):三选一。「离开时保留」=只有你在看页面时才几秒后关闭,人不在时收到的那条留在通知中心等你回来;「总是关闭」=旧行为;「从不关闭」=普通通知一直留着,等你点掉。带「同意 / 拒绝」的审批通知仍按 12 秒走,同样不会在人不在时被撤掉;
125
125
  - **子代理会话不提醒**(默认开):跳过后台子代理(subagent)会话——它们是主对话那一轮的步骤,完成由主对话自己的提醒覆盖;你当前打开的那个子代理会话照常提醒;
126
- - **点击通知回到对应对话**(默认开):点通知切到提醒所属的对话;浏览器里同时把窗口带到前台;桌面端不稳定——有时窗口会上前,有时只切对话(见[桌面端怎么抬窗的](#桌面端怎么抬窗的));多个标签页同时开着时只调起一个(优先当前可见的那个);关掉后点通知不做任何事。
126
+ - **点击通知回到对应对话**(默认开):点通知切到提醒所属的对话;浏览器里同时把窗口带到前台;桌面端交给宿主路由做原生激活(见[桌面端怎么抬窗的](#桌面端怎么抬窗的)),抬不起来时只切对话,原因记在 `debug().raiseLast` 里;多个标签页同时开着时只调起一个(优先当前可见的那个);关掉后点通知不做任何事。
127
127
 
128
128
  ### 试一试
129
129
 
@@ -164,13 +164,13 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
164
164
 
165
165
  ### 运行环境与诊断
166
166
 
167
- 在「高级设置」最后一块。默认自动识别当前是 Web 端还是桌面端(DSH Desktop,`dsh-app://`);识别不准时可手动指定——桌面端点通知走 `dsh://` 深链调窗(效果不稳定,见[桌面端怎么抬窗的](#桌面端怎么抬窗的))、通知上不带快捷裁决按钮;Web 端走 `window.focus()` 与 Service Worker 桥。手动覆盖与自动识别不一致时,设置页会明示检测结果。
167
+ 在「高级设置」最后一块。默认自动识别当前是 Web 端还是桌面端(DSH Desktop,`dsh-app://`);识别不准时可手动指定——桌面端点通知走宿主路由做原生抬窗(见[桌面端怎么抬窗的](#桌面端怎么抬窗的))、通知上不带快捷裁决按钮;Web 端走 `window.focus()` 与 Service Worker 桥。手动覆盖与自动识别不一致时,设置页会明示检测结果。
168
168
 
169
169
  <details>
170
170
  <summary>通知语言、桌面端抬窗与恢复默认</summary>
171
171
 
172
172
  - **通知语言**:跟随界面 / 简体中文 / English——决定通知文字与 `🔔 …` 标题标记使用的语言;
173
- - **桌面端怎么抬窗**:页面侧的 `window.focus()` 在 Electron 里抬不动原生窗口,1.5.2 起改走应用自己的 `dsh://open` 深链调窗——在 DSH Desktop 0.2.0-rc.2 上实测有效,0.2.1-rc.2 在 Windows 上不稳定(详见下一节);
173
+ - **桌面端怎么抬窗**:页面侧的 `window.focus()` 在 Electron 里抬不动原生窗口,1.14.0 起由页面 POST 到宿主路由 `/__dsh-notify-me/focus-window`、宿主跑一次 PowerShell 做原生激活——窗口最小化、被别的窗口盖住都实测能抬回来(详见下一节);
174
174
  - **恢复默认设置**:把所有开关与音效设置还原成出厂值(不动外部推送目标和已选的自定义音频文件)。
175
175
 
176
176
  </details>
@@ -264,7 +264,7 @@ window.__dshNotifyMe.testExternal() // 显式外发测试:向有
264
264
 
265
265
  ## 工作原理
266
266
 
267
- 提醒核心监听会话状态与待处理交互;审批裁决由页面侧完成,桌面抬窗使用应用深链。
267
+ 提醒核心监听会话状态与待处理交互;审批裁决由页面侧完成,桌面抬窗走宿主路由加 Win32 原生激活。
268
268
 
269
269
  <details>
270
270
  <summary>展开事件来源、审批桥接与桌面抬窗实现</summary>
@@ -286,23 +286,23 @@ window.__dshNotifyMe.testExternal() // 显式外发测试:向有
286
286
 
287
287
  ### 桌面端怎么抬窗的
288
288
 
289
- 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()`。
289
+ DSH Desktop 是一层 Electron 壳,页面跑在 `dsh-app://app` 下:DOM 的 `window.focus()` 只动浏览器窗口对象、碰不到原生窗口,壳的 `will-navigate` 又把页面里发 `dsh:` 的常规出口堵上了。1.5.2 到 1.13.0 靠一条隐藏 iframe 导航到 `dsh://open` 绕过去,借应用的单实例让位间接聚焦——在 0.2.1-rc.2 上隔离复测 3/3 无效,1.14.0 起整条删掉。
290
290
 
291
- 真实壳已实测(DSH Desktop 0.2.0-rc.2,issue #9 三轮复测):点通知 = 抬窗 + 切会话一步到位,跨会话、窗口在后台时同样稳定。触发时机是这条链路的关键:深链必须在**带用户手势的处理器**里发——通知的 `onclick` 自带手势,所以产品路径通;而无手势的脚本化注入(如 CDP evaluate 手工塞 iframe)会被 Chromium 静默丢弃、外部协议根本不回流,复现时看起来就像「通道被拦死」。
291
+ 现在走插件自己的宿主路由。壳把 `dsh-app://app` 下除静态资源以外的所有路径原样转发给宿主 HTTP server(带 origin 校验、注入宿主 cookie),所以页面对 `/__dsh-notify-me/focus-window` 发 POST 是同源请求,没有 CORS 问题;宿主半(`lib/index.js`)本来就跑在 Electron 主进程的子进程里,`process.ppid` 就是主窗口所属的浏览器进程。点通知时:页面 POST → 宿主起一次 `powershell.exe` 跑 `lib/focus-window.ps1 -ProcessId <ppid>` → 脚本枚举该 pid 下可见、无 owner、非 toolwindow 的顶层窗口(优先类名 `Chrome_WidgetWin_1`,再按 `GetWindowRect` 面积取最大)→ 自适应激活 → 比对 `GetForegroundWindow()` 打印 `FOCUSED=1|0` → 宿主回 `200` / `202`。
292
292
 
293
- **0.2.1-rc.2 在 Windows 上不稳定**(2026-10-20):点通知的回调照常执行(`navigator.userActivation.isActive === true`、`visibilityState` 为 `hidden`),但窗口是否上前台时灵时不灵——有时会,有时只切对话、窗口不动。
293
+ 激活顺序是实的,来自 [discussions #10446 那份五种手法的实测](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19143913):已经在前台就什么都不做(最常见的情况,零闪烁);最小化了先 `SW_RESTORE`;然后合成一次 ALT 按键(`keybd_event` 按下抬起)给本线程前台权限,再 `SetForegroundWindow`;这一串都不行才退到 `SW_MINIMIZE` + `SW_RESTORE` 兜底。单独调 `SetForegroundWindow` 会被前台锁直接拒,按调用算、跟 `ForegroundLockTimeout` 无关;这也重新解释了 1.5.2 那套深链「时灵时不灵」——能抬起来的那些次,多半是系统在通知被点击时自己激活了应用。
294
294
 
295
- 插件这一侧为什么不能指望:把 `dsh://open` 深链单独拿出来复测(三种走法各 3 次:直接带参启动 exe、走系统协议激活、`WM_SYSCOMMAND` 真实最小化后再发),**全部失败**——带参第二实例每次都在约 130 ms 后以退出码 1 结束(不带参数则约 260 ms 退出码 0,属正常单实例让位),最小化窗口的 `IsIconic` 每次都保持 `true`。也就是说,能抬起来的那些次,多半是系统/Chromium 在通知被点击时自己激活了应用窗口,不是插件发的深链起了作用。
295
+ 实测(`powershell.exe` 5.1,同一轮跑完):窗口在前台 ×3、最小化 + 记事本在前台 ×3、可见但被盖住 ×3,全部 `FOCUSED=1` 且前台窗口比对一致,最小化那几个的 `IsIconic` 都从 `true` 变回 `false`;记事本最大化完全遮挡、最小化 + 完全遮挡、`-ProcessId 0` 自动探测同样成功。脚本内部耗时最坏 434 ms,进程启动到退出最坏 782 ms,都在 8 秒上限内;`pwsh` 7.6 对照一轮行为一致,只是 `Add-Type` 编译慢约 100 ms(脚本刻意不用 7 的语法)。不存在的 pid、存在但没有可见窗口的 pid 都稳定 `FOCUSED=0` 并 `exit 0`。
296
296
 
297
- 页面侧也没有替代通道:壳的 preload 只暴露 `dshDesktop`、目录选择、本地化这些,`DESKTOP_IPC` 里没有 focus/raise 类通道;主窗口的 `will-navigate` 对 `dsh:` 一律 `preventDefault()`。复现步骤与代码位置已提交到 [deepseek-harness discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446)。
297
+ 安全上只开一条 exact 路由:非 POST 回 `405`,非桌面宿主(Web 版、macOS / Linux、纯 Node)回 `501`,缺了 `x-dsh-notify-me-focus: focus` 头回 `403`——跨源页面要带这个头得先过 preflight,而路由不放行任何 CORS 头,所以浏览器里别的站点调不动它。`GET` 只回 `{ok, route, enabled, desktop, platform}`,装完可以先用浏览器打开确认路由在不在。400 毫秒内的重复调用合并成一次,脚本超过 8 秒没回话就 kill 掉按失败处理。
298
298
 
299
- </details>
299
+ 抬不起来的场合还剩几种,都会如实回 `202`、对话照常切:目标窗口属于更高完整性级别(提权 / UAC)、窗口在另一个虚拟桌面、前台被别的进程反复抢回去。托盘隐藏(`SW_HIDE` 而非最小化)的窗口也选不到,DSH 当前不会这样。
300
300
 
301
301
  ## 已知限制
302
302
 
303
303
  - 页面必须开着才会提醒(后台标签/最小化可以;关标签页即失效——浏览器层方案固有限制)。
304
304
  - 通知经由浏览器弹出,需允许浏览器通知权限,且勿扰模式不能屏蔽它。
305
- - 桌面端(DSH Desktop 0.2.1-rc.2 / Windows)点通知后窗口不一定上前台:有时会,有时只切对话。插件发的 `dsh://open` 深链单独复测 3/3 无效(详见[桌面端怎么抬窗的](#桌面端怎么抬窗的)),能抬的时候多半是系统/Chromium 在通知激活时自己把窗口带起来了([discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446))。
305
+ - 桌面端(DSH Desktop,Windows)点通知后窗口仍可能抬不起来:目标窗口属于更高完整性级别(提权 / UAC)、窗口在另一个虚拟桌面、前台被别的进程反复抢回去、或窗口被 `SW_HIDE` 隐藏到托盘时,宿主脚本回 `FOCUSED=0`、路由回 `202`,对话照常切(详见[桌面端怎么抬窗的](#桌面端怎么抬窗的))。
306
306
  - 系统通知横幅只画在主屏:Windows 没有设置项、Chromium 没有 API 能把它指到别的显示器(多屏时副屏收不到系统通知)。要跨屏提醒只能靠外部推送转到手机(1.12.0 起页面里不再画审批卡片)。
307
307
  - 每次页面加载后第一次出声/弹通知前,需在页面上点击过一次(浏览器自动播放与权限策略)。
308
308
  - 未授权通知权限时只有提示音与标题标记。
@@ -323,6 +323,7 @@ node --check lib\client.js
323
323
  node --check lib\index.js
324
324
  node smoke\smoke-test.cjs # 离线状态机冒烟测试:三代宿主 + 主开关 + 中英语言用例 + 快捷裁决桥(Service Worker 半侧一并跑)+ 闲置判定与通知自收
325
325
  node smoke\cordis-host-test.mjs # 真 cordis 端到端(找不到本机 DSH 时自动跳过)
326
+ powershell -NoProfile -File lib\focus-window.ps1 -ProcessId 0 # 手动验证抬窗脚本,末行应是 FOCUSED=1
326
327
  npm pack --dry-run # 预览发布包
327
328
  ```
328
329
 
@@ -341,6 +342,7 @@ npm pack --dry-run # 预览发布包
341
342
  - [@yuexian7](https://github.com/yuexian7) — [#17](https://github.com/chromoany/dsh-notify-me/issues/17):安卓端收消息的诉求——Discord 在国内不便、Bark 没有安卓端,只能自己搭后台中转;1.13.0 的飞书 / Server酱 / WxPusher 三种目标、自动重试与失败记录都是据此做的。
342
343
  - [@shrekcg](https://github.com/shrekcg) — [#16](https://github.com/chromoany/dsh-notify-me/pull/16):把「人在前台但离开了」并入离开判定(`idleMinutes`,默认 3 分钟)、「回复完成」按哪个对话完成决定是否提醒(`donePolicy` 三选一)、通知自动消失的三档策略(`autoDismiss`),并带上对应冒烟用例与旧配置迁移;1.12.0 落地时维护者补了闲置到期补发被压下的审批、删掉残留的页内审批卡片、补齐文档与版本。
343
344
 
345
+ - [@xinming-chenniang](https://github.com/xinming-chenniang) — [deepseek-harness discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19143913):五种 Win32 抬窗手法的实测对照(单独 `SetForegroundWindow` 被前台锁拒、合成 ALT 键后通过、最小化→恢复两种场景都有效),以及「宿主注册同源路由 + PowerShell 兜底链」这条绕法的可运行验证;1.14.0 的宿主路由与 `lib/focus-window.ps1` 的直接依据。
344
346
  署名口径:本仓库收编外部 PR 时以维护者提交落地——不沿用原 commit、不挂 Co-authored-by,GitHub 的 contributors 图只显示维护者,贡献者以上面的致谢署名。
345
347
 
346
348
  ## License
package/lib/client.js CHANGED
@@ -498,39 +498,58 @@
498
498
  if (config.hostEnv === "web") return false;
499
499
  return detectedDesktop();
500
500
  }
501
- // Bring the OS window forward. A browser tab answers to window.focus();
502
- // the Electron shell does not — DOM focus() never touches the native
503
- // window, so a toast click there left the session switched but the window
504
- // buried. The desktop rides the app's own dsh://open deep link instead:
505
- // the URL is fired through a hidden iframe (the shell's will-navigate and
506
- // window-open handlers intercept top-level navigations and window.open()
507
- // to non-http(s) URLs, but not subframe navigations), the OS hands it to
508
- // the protocol registration, and the single-instance owner restores,
509
- // shows and focuses the window — the same path the login completion page
510
- // uses to bring DSH forward.
501
+ // Route the host half (lib/index.js) registers to activate the desktop
502
+ // window from the process that owns it.
503
+ var FOCUS_ROUTE = "/__dsh-notify-me/focus-window";
504
+ // A cross-origin page cannot send a custom header without a CORS preflight
505
+ // this route never answers, so the header is also the CSRF gate.
506
+ var FOCUS_HEADERS = { "x-dsh-notify-me-focus": "focus" };
507
+ // What the last raise attempt did, surfaced through debug() so a failing
508
+ // host path can be diagnosed from the settings page instead of by guesswork.
509
+ var raiseState = { channel: "window-focus", last: null };
510
+ // Bring the OS window forward. A browser tab answers to window.focus(); the
511
+ // Electron shell does not — DOM focus() never touches the native window, and
512
+ // Chromium only activates a window the OS already treats as foreground. The
513
+ // shell exposes no focus IPC either (preload's dshDesktop carries none), so
514
+ // the activation has to run in the host process: lib/index.js registers
515
+ // /__dsh-notify-me/focus-window there, and this click posts to it. Every
516
+ // non-static dsh-app://app path is forwarded by the shell to the host web
517
+ // server with the host cookie attached, so a relative same-origin fetch
518
+ // reaches that route exactly as it would from a browser tab.
511
519
  //
512
- // Verified on the real shell (DSH Desktop 0.2.0-rc.2, issue #9): a click
513
- // on the notification raises the window and switches the conversation in
514
- // one step, reproducibly. The fire must sit in a user-gesture context —
515
- // the page-side Notification onclick carries one, and Chromium drops
516
- // external-protocol navigations launched without it. That is also why
517
- // gesture-less injections (CDP evaluate, scripted iframe appends) silently
518
- // produce no protocol launch at all, which read like the channel being
519
- // blocked when the gesture was the only difference.
520
+ // The old path fired the dsh://open deep link through a hidden iframe and
521
+ // leaned on the single-instance owner to restore the window. Measured on
522
+ // DSH Desktop 0.2.1-rc.2 (issue #9): three attempts, three failures — the
523
+ // second instance exited in ~130 ms and the window's IsIconic state never
524
+ // changed — so that channel is gone rather than kept as a silent fallback.
520
525
  function raiseWindow() {
521
526
  try { window.focus(); } catch (e) {}
522
- if (!isDesktopShell()) return;
527
+ if (!isDesktopShell()) { raiseState.channel = "window-focus"; raiseState.last = "browser"; return; }
528
+ raiseState.channel = "host-route";
523
529
  try {
524
- var f = document.createElement("iframe");
525
- f.setAttribute("aria-hidden", "true");
526
- f.setAttribute("tabindex", "-1");
527
- f.style.cssText = "position:fixed;left:-10px;top:-10px;width:0;height:0;border:0;visibility:hidden";
528
- f.src = "dsh://open";
529
- (document.body || document.documentElement).appendChild(f);
530
- setTimeout(function () {
531
- try { if (f.parentNode) f.parentNode.removeChild(f); } catch (e) {}
532
- }, 4000);
533
- } catch (e) {}
530
+ if (typeof fetch !== "function") { raiseState.last = "no-fetch"; return; }
531
+ raiseState.last = "pending";
532
+ fetch(FOCUS_ROUTE, {
533
+ method: "POST",
534
+ headers: FOCUS_HEADERS,
535
+ body: "{}",
536
+ cache: "no-store"
537
+ }).then(function (res) {
538
+ if (!res || !res.ok) { raiseState.last = "http-" + (res ? String(res.status) : "0"); return; }
539
+ if (typeof res.json !== "function") { raiseState.last = "focused"; return; }
540
+ return res.json().then(function (body) {
541
+ raiseState.last = body && body.focused
542
+ ? "focused"
543
+ : "not-focused" + (body && body.detail ? ":" + body.detail : "");
544
+ }, function () {
545
+ raiseState.last = "focused";
546
+ });
547
+ }).catch(function () {
548
+ raiseState.last = "error";
549
+ });
550
+ } catch (e) {
551
+ raiseState.last = "error";
552
+ }
534
553
  }
535
554
  // Bring the window forward and make the alerted session the current one,
536
555
  // so a click on the toast lands on the conversation that needs the user
@@ -2211,7 +2230,7 @@
2211
2230
  });
2212
2231
  } catch (e) {}
2213
2232
  // keep in sync with package.json "version" on every release
2214
- api.version = "1.13.0";
2233
+ api.version = "1.14.0";
2215
2234
  api.setConfig = function (patch) { return setConfigLocal(patch); };
2216
2235
  api.resetConfig = function () {
2217
2236
  try {
@@ -2313,7 +2332,8 @@
2313
2332
  hostEnv: config.hostEnv,
2314
2333
  hostEnvDetected: detectedDesktop() ? "desktop" : "web",
2315
2334
  desktop: isDesktopShell(),
2316
- raiseChannel: isDesktopShell() ? "dsh-deeplink" : "window-focus",
2335
+ raiseChannel: isDesktopShell() ? "host-route" : "window-focus",
2336
+ raiseLast: raiseState.last,
2317
2337
  actionKeys: Object.keys(pendingActions),
2318
2338
  permission: ("Notification" in window) ? Notification.permission : "n/a",
2319
2339
  visibility: hidden() ? "hidden" : "visible",
@@ -0,0 +1,349 @@
1
+ # focus-window.ps1 - bring the DSH (Electron) main window to the foreground on Windows.
2
+ #
3
+ # Contract:
4
+ # powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File focus-window.ps1 -ProcessId 12345
5
+ # * -ProcessId <int>: pid of the window owner (the Electron browser process).
6
+ # * -ProcessId 0 or omitted: auto-detect the Electron shell process by process name
7
+ # or window title containing "DeepSeek".
8
+ # * stdout: optional "DETAIL: ..." lines for troubleshooting, the LAST line is
9
+ # exactly "FOCUSED=1" or "FOCUSED=0".
10
+ # * always exits 0 (no window found / internal error also prints FOCUSED=0 and exits 0).
11
+ #
12
+ # Window selection: pid match, IsWindowVisible, GW_OWNER == 0, no WS_EX_TOOLWINDOW.
13
+ # Candidates are ranked: process-name match, then class Chrome_WidgetWin_1, then
14
+ # the largest window area (GetWindowRect). Never just the first enumer matched.
15
+ #
16
+ # Raise order (adaptive):
17
+ # 1. already foreground -> done (no flicker for the common case)
18
+ # 2. IsIconic -> ShowWindow(SW_RESTORE), then Alt tap + SetForegroundWindow
19
+ # 3. Alt tap + SetForegroundWindow
20
+ # 4. ShowWindow(SW_MINIMIZE) + ShowWindow(SW_RESTORE) + Alt tap + SetForegroundWindow
21
+ # 5. final Alt tap + SetForegroundWindow
22
+ # The Alt tap (keybd_event VK_MENU down/up) gives the calling thread foreground
23
+ # permission so SetForegroundWindow is not refused by the foreground lock rule.
24
+ #
25
+ # ASCII only, UTF-8 without BOM (Windows PowerShell 5.1 safe).
26
+
27
+ param(
28
+ [int]$ProcessId = 0
29
+ )
30
+
31
+ $ErrorActionPreference = 'Continue'
32
+ $script:Stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
33
+ $script:Status = 'FOCUSED=0'
34
+
35
+ # Write one line to stdout. [Console]::Out is used so that nothing from the
36
+ # PowerShell formatting pipeline can leak into the parsed output.
37
+ function Out-Line([string]$Text) {
38
+ try { [Console]::Out.WriteLine($Text) } catch { Write-Output $Text }
39
+ }
40
+
41
+ # Keep all output pure ASCII so the caller can decode it with any code page.
42
+ function To-Ascii([string]$Text) {
43
+ if ([string]::IsNullOrEmpty($Text)) { return '' }
44
+ return [regex]::Replace($Text, '[^\x20-\x7E]', '?')
45
+ }
46
+
47
+ function Short-Text([string]$Text, [int]$Max) {
48
+ $s = To-Ascii $Text
49
+ if ($s.Length -gt $Max) { return $s.Substring(0, $Max) + '...' }
50
+ return $s
51
+ }
52
+
53
+ try {
54
+ # ------------------------------------------------------------------ native
55
+ if (-not ('FocusNative' -as [type])) {
56
+ $cs = @'
57
+ using System;
58
+ using System.Text;
59
+ using System.Collections.Generic;
60
+ using System.Runtime.InteropServices;
61
+
62
+ public class FocusNative
63
+ {
64
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
65
+
66
+ [DllImport("user32.dll")]
67
+ public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
68
+
69
+ [DllImport("user32.dll")]
70
+ public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
71
+
72
+ [DllImport("user32.dll")]
73
+ public static extern bool IsWindowVisible(IntPtr hWnd);
74
+
75
+ [DllImport("user32.dll")]
76
+ public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
77
+
78
+ [DllImport("user32.dll")]
79
+ public static extern bool IsIconic(IntPtr hWnd);
80
+
81
+ [DllImport("user32.dll")]
82
+ public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
83
+
84
+ [DllImport("user32.dll")]
85
+ public static extern bool SetForegroundWindow(IntPtr hWnd);
86
+
87
+ [DllImport("user32.dll")]
88
+ public static extern IntPtr GetForegroundWindow();
89
+
90
+ [DllImport("user32.dll")]
91
+ public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
92
+
93
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
94
+ public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
95
+
96
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
97
+ public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
98
+
99
+ [DllImport("user32.dll")]
100
+ public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
101
+
102
+ [DllImport("user32.dll")]
103
+ public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
104
+
105
+ [StructLayout(LayoutKind.Sequential)]
106
+ public struct RECT
107
+ {
108
+ public int Left;
109
+ public int Top;
110
+ public int Right;
111
+ public int Bottom;
112
+ }
113
+
114
+ public const uint GW_OWNER = 4;
115
+ public const int GWL_EXSTYLE = -20;
116
+ public const int WS_EX_TOOLWINDOW = 0x00000080;
117
+ public const int SW_MINIMIZE = 6;
118
+ public const int SW_RESTORE = 9;
119
+ public const byte VK_MENU = 0x12;
120
+ public const uint KEYEVENTF_KEYUP = 0x0002;
121
+ public const string TARGET_CLASS = "Chrome_WidgetWin_1";
122
+
123
+ public class WinInfo
124
+ {
125
+ public IntPtr Hwnd;
126
+ public int Pid;
127
+ public bool Visible;
128
+ public bool HasOwner;
129
+ public bool Iconic;
130
+ public string ClassName;
131
+ public string Title;
132
+ public int Left;
133
+ public int Top;
134
+ public int Right;
135
+ public int Bottom;
136
+ public long Area;
137
+ public int ExStyle;
138
+ public bool ToolWindow;
139
+ }
140
+
141
+ public static string ClassOf(IntPtr hWnd)
142
+ {
143
+ StringBuilder sb = new StringBuilder(256);
144
+ GetClassName(hWnd, sb, sb.Capacity);
145
+ return sb.ToString();
146
+ }
147
+
148
+ public static string TitleOf(IntPtr hWnd)
149
+ {
150
+ StringBuilder sb = new StringBuilder(512);
151
+ GetWindowText(hWnd, sb, sb.Capacity);
152
+ return sb.ToString();
153
+ }
154
+
155
+ public static long AreaOf(IntPtr hWnd)
156
+ {
157
+ RECT r;
158
+ if (!GetWindowRect(hWnd, out r)) { return -1; }
159
+ long w = (long)r.Right - (long)r.Left;
160
+ long h = (long)r.Bottom - (long)r.Top;
161
+ if (w < 0) { w = -w; }
162
+ if (h < 0) { h = -h; }
163
+ return w * h;
164
+ }
165
+
166
+ public static List<WinInfo> ListTopWindows()
167
+ {
168
+ List<WinInfo> list = new List<WinInfo>();
169
+ EnumWindows(delegate(IntPtr hWnd, IntPtr lParam)
170
+ {
171
+ WinInfo wi = new WinInfo();
172
+ wi.Hwnd = hWnd;
173
+ uint pid;
174
+ GetWindowThreadProcessId(hWnd, out pid);
175
+ wi.Pid = (int)pid;
176
+ wi.Visible = IsWindowVisible(hWnd);
177
+ wi.HasOwner = GetWindow(hWnd, GW_OWNER) != IntPtr.Zero;
178
+ wi.Iconic = IsIconic(hWnd);
179
+ wi.ClassName = ClassOf(hWnd);
180
+ wi.Title = TitleOf(hWnd);
181
+ RECT r;
182
+ if (GetWindowRect(hWnd, out r))
183
+ {
184
+ wi.Left = r.Left;
185
+ wi.Top = r.Top;
186
+ wi.Right = r.Right;
187
+ wi.Bottom = r.Bottom;
188
+ }
189
+ wi.Area = AreaOf(hWnd);
190
+ wi.ExStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
191
+ wi.ToolWindow = (wi.ExStyle & WS_EX_TOOLWINDOW) != 0;
192
+ list.Add(wi);
193
+ return true;
194
+ }, IntPtr.Zero);
195
+ return list;
196
+ }
197
+ }
198
+ '@
199
+ Add-Type -TypeDefinition $cs -Language CSharp -ErrorAction Stop
200
+ }
201
+ Out-Line ("DETAIL: loaded native helpers in " + $script:Stopwatch.ElapsedMilliseconds + " ms")
202
+
203
+ # ------------------------------------------------------------- find window
204
+ $autoMode = ($ProcessId -le 0)
205
+ $allWindows = [FocusNative]::ListTopWindows()
206
+ $autoPids = @{}
207
+ if ($autoMode) {
208
+ foreach ($p in @(Get-Process -ErrorAction SilentlyContinue)) {
209
+ if ($p.ProcessName -match 'DeepSeek|Harness') { $autoPids[[int]$p.Id] = $true }
210
+ }
211
+ $pidList = @($autoPids.Keys | Sort-Object) -join ','
212
+ Out-Line ("DETAIL: auto-detect mode, matched process pids: " + $pidList)
213
+ } else {
214
+ Out-Line ("DETAIL: explicit mode, target pid: " + $ProcessId)
215
+ }
216
+
217
+ $candidates = @()
218
+ foreach ($w in $allWindows) {
219
+ if (-not $w.Visible) { continue }
220
+ if ($w.HasOwner) { continue }
221
+ if ($w.ToolWindow) { continue }
222
+ $titleMatch = ($w.Title -like '*DeepSeek*')
223
+ $isProcMatch = $false
224
+ if ($autoMode) {
225
+ if ($autoPids.ContainsKey([int]$w.Pid)) {
226
+ $isProcMatch = $true
227
+ } elseif (-not $titleMatch) {
228
+ continue
229
+ }
230
+ } else {
231
+ if ([int]$w.Pid -ne $ProcessId) { continue }
232
+ $isProcMatch = $true
233
+ }
234
+ $isClassTarget = ($w.ClassName -eq [FocusNative]::TARGET_CLASS)
235
+ $candidates += [pscustomobject]@{
236
+ Hwnd = $w.Hwnd
237
+ Pid = [int]$w.Pid
238
+ ClassName = $w.ClassName
239
+ Title = $w.Title
240
+ Area = [long]$w.Area
241
+ Iconic = [bool]$w.Iconic
242
+ IsClassTarget = [bool]$isClassTarget
243
+ IsProcMatch = [bool]$isProcMatch
244
+ }
245
+ }
246
+ Out-Line ("DETAIL: candidates=" + $candidates.Count)
247
+
248
+ if ($candidates.Count -eq 0) {
249
+ Out-Line 'DETAIL: no candidate window found'
250
+ } else {
251
+ $sortSpec = @(
252
+ @{ Expression = { [int]$_.IsProcMatch }; Descending = $true }
253
+ @{ Expression = { [int]$_.IsClassTarget }; Descending = $true }
254
+ @{ Expression = { [long]$_.Area }; Descending = $true }
255
+ )
256
+ $ranked = @($candidates | Sort-Object -Property $sortSpec)
257
+ $idx = 0
258
+ foreach ($c in $ranked) {
259
+ $idx++
260
+ Out-Line ("DETAIL: candidate #{0} hwnd=0x{1:X} pid={2} class={3} area={4} iconic={5} procMatch={6} title={7}" -f `
261
+ $idx, $c.Hwnd.ToInt64(), $c.Pid, $c.ClassName, $c.Area, $c.Iconic, $c.IsProcMatch, (Short-Text $c.Title 70))
262
+ }
263
+
264
+ $chosen = $ranked[0]
265
+ $reason = 'largest area among the remaining candidates'
266
+ if ($chosen.IsClassTarget) { $reason = 'preferred class Chrome_WidgetWin_1, largest area among those' }
267
+ if (-not $chosen.IsProcMatch) { $reason = 'title match only (no process-name match), largest area among those' }
268
+ Out-Line ("DETAIL: selected hwnd=0x{0:X} pid={1} class={2} area={3} ({4})" -f `
269
+ $chosen.Hwnd.ToInt64(), $chosen.Pid, $chosen.ClassName, $chosen.Area, $reason)
270
+
271
+ $hwnd = [IntPtr]$chosen.Hwnd
272
+ $fg0 = [FocusNative]::GetForegroundWindow()
273
+ $iconic0 = [FocusNative]::IsIconic($hwnd)
274
+ Out-Line ("DETAIL: initial foreground=0x{0:X} iconic={1}" -f $fg0.ToInt64(), $iconic0)
275
+
276
+ # ---------------------------------------------------------- raise logic
277
+ function Test-Foreground([IntPtr]$Handle, [int]$TimeoutMs) {
278
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
279
+ while ($sw.ElapsedMilliseconds -lt $TimeoutMs) {
280
+ if ([FocusNative]::GetForegroundWindow() -eq $Handle) { return $true }
281
+ Start-Sleep -Milliseconds 25
282
+ }
283
+ return ([FocusNative]::GetForegroundWindow() -eq $Handle)
284
+ }
285
+
286
+ function Invoke-AltTap {
287
+ [FocusNative]::keybd_event([byte][FocusNative]::VK_MENU, [byte]0, [uint32]0, [UIntPtr]::Zero)
288
+ Start-Sleep -Milliseconds 15
289
+ [FocusNative]::keybd_event([byte][FocusNative]::VK_MENU, [byte]0, [uint32][FocusNative]::KEYEVENTF_KEYUP, [UIntPtr]::Zero)
290
+ Start-Sleep -Milliseconds 15
291
+ }
292
+
293
+ function Invoke-AltForeground([IntPtr]$Handle) {
294
+ Invoke-AltTap
295
+ [void][FocusNative]::SetForegroundWindow($Handle)
296
+ }
297
+
298
+ $focused = $false
299
+ if ($fg0 -eq $hwnd) {
300
+ $focused = $true
301
+ Out-Line 'DETAIL: step 1: window is already foreground, nothing to do'
302
+ } else {
303
+ if ($iconic0) {
304
+ Out-Line 'DETAIL: step 2: window is iconic, ShowWindow(SW_RESTORE) + Alt tap + SetForegroundWindow'
305
+ [void][FocusNative]::ShowWindow($hwnd, [FocusNative]::SW_RESTORE)
306
+ Start-Sleep -Milliseconds 50
307
+ Invoke-AltForeground $hwnd
308
+ $focused = Test-Foreground $hwnd 220
309
+ }
310
+ if (-not $focused) {
311
+ Out-Line 'DETAIL: step 3: Alt tap + SetForegroundWindow'
312
+ Invoke-AltForeground $hwnd
313
+ $focused = Test-Foreground $hwnd 170
314
+ }
315
+ if (-not $focused) {
316
+ Out-Line 'DETAIL: step 4: ShowWindow(SW_MINIMIZE) + SW_RESTORE + Alt tap + SetForegroundWindow'
317
+ [void][FocusNative]::ShowWindow($hwnd, [FocusNative]::SW_MINIMIZE)
318
+ Start-Sleep -Milliseconds 70
319
+ Invoke-AltTap
320
+ [void][FocusNative]::ShowWindow($hwnd, [FocusNative]::SW_RESTORE)
321
+ Start-Sleep -Milliseconds 60
322
+ Invoke-AltForeground $hwnd
323
+ $focused = Test-Foreground $hwnd 280
324
+ }
325
+ if (-not $focused) {
326
+ Out-Line 'DETAIL: step 5: final Alt tap + SetForegroundWindow'
327
+ Invoke-AltForeground $hwnd
328
+ $focused = Test-Foreground $hwnd 180
329
+ }
330
+ }
331
+
332
+ $fg1 = [FocusNative]::GetForegroundWindow()
333
+ $iconic1 = [FocusNative]::IsIconic($hwnd)
334
+ Out-Line ("DETAIL: final foreground=0x{0:X} target=0x{1:X} iconic={2}" -f $fg1.ToInt64(), $hwnd.ToInt64(), $iconic1)
335
+ if ($fg1 -eq $hwnd) {
336
+ $focused = $true
337
+ } else {
338
+ $focused = $false
339
+ }
340
+ if ($focused) { $script:Status = 'FOCUSED=1' } else { $script:Status = 'FOCUSED=0' }
341
+ }
342
+ } catch {
343
+ Out-Line ('DETAIL: exception ' + (To-Ascii $_.Exception.Message))
344
+ $script:Status = 'FOCUSED=0'
345
+ }
346
+
347
+ Out-Line ('DETAIL: elapsed_ms=' + $script:Stopwatch.ElapsedMilliseconds)
348
+ Out-Line $script:Status
349
+ exit 0
package/lib/index.js CHANGED
@@ -1,18 +1,292 @@
1
- /**
2
- * dsh-notify-me — host half (no-op).
3
- *
4
- * Everything this plugin does lives in the browser half (lib/client.js):
5
- * desktop notifications and sounds when the agent needs the user's input or a
6
- * reply finishes. This module exists only so the loader entry mounts the
7
- * package, letting dsh-client-modules pick up the dsh.client declaration and
8
- * serve /plugins/dsh-notify-me/client.js to the page.
9
- */
10
-
11
- /** Plugin identity for cordis.yml rows. */
12
- export const name = "dsh-notify-me";
13
-
14
- /**
15
- * Host loader entry: nothing to mount on the server side.
16
- * @param ctx - host cordis context.
17
- */
18
- export function apply() {}
1
+ /**
2
+ * dsh-notify-me — host half.
3
+ *
4
+ * The browser half (lib/client.js) raises the window when a notification is
5
+ * clicked. In a browser tab `window.focus()` is all that exists; inside the
6
+ * desktop shell it is not enough, because the shell exposes no focus IPC and
7
+ * Chromium only honours `window.focus()` for a window the OS already treats as
8
+ * foreground. This module therefore owns one loopback route on the host web
9
+ * server: the notification click posts to it, and the host activates the
10
+ * Electron window through Win32 (lib/focus-window.ps1) from the process that is
11
+ * allowed to do so.
12
+ *
13
+ * The route is reachable from the desktop page because the shell forwards every
14
+ * non-static `dsh-app://app` path verbatim to the host web server, host cookie
15
+ * attached — so a plain same-origin relative `fetch` behaves exactly as it does
16
+ * in a browser tab.
17
+ */
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { existsSync } from "node:fs";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ /** Route that carries "bring the desktop window to the front". */
24
+ export const FOCUS_ROUTE = "/__dsh-notify-me/focus-window";
25
+
26
+ /**
27
+ * Request header proving the caller is page code. A cross-origin page cannot
28
+ * send a custom header without a CORS preflight, which this route never
29
+ * answers, so the header doubles as the CSRF gate.
30
+ */
31
+ const RAISE_HEADER = "x-dsh-notify-me-focus";
32
+
33
+ /** Marker value the page sends in {@link RAISE_HEADER}. */
34
+ const RAISE_HEADER_VALUE = "focus";
35
+
36
+ /** Activation script, resolved next to this module. */
37
+ const SCRIPT_URL = new URL("./focus-window.ps1", import.meta.url);
38
+
39
+ /** An activation that has not answered by then is abandoned; the user moved on. */
40
+ const RAISE_TIMEOUT_MS = 8000;
41
+
42
+ /** Two clicks inside this window share one activation instead of flashing twice. */
43
+ const RAISE_COALESCE_MS = 400;
44
+
45
+ /** Longest script output retained for the diagnostic line. */
46
+ const OUTPUT_LIMIT = 4096;
47
+
48
+ /** Plugin identity for cordis.yml rows. */
49
+ export const name = "dsh-notify-me";
50
+
51
+ /**
52
+ * True when this host process runs on the Electron binary, i.e. inside the
53
+ * desktop shell the window belongs to. `process.versions.electron` is the only
54
+ * trustworthy signal: the shell also exports `ELECTRON_RUN_AS_NODE=1` into the
55
+ * host's environment, and every child of the host inherits it, so keying on the
56
+ * variable reports "desktop" from a plain Node test process.
57
+ * @returns whether native window activation is worth attempting here.
58
+ */
59
+ function isDesktopHost() {
60
+ if (process.platform !== "win32") return false;
61
+ return typeof process.versions.electron === "string";
62
+ }
63
+
64
+ /**
65
+ * Stand-in script for the smoke tests; also the only way to force the desktop
66
+ * path on from a plain Node process.
67
+ * @returns the override path, or `undefined` when unset.
68
+ */
69
+ function scriptOverride() {
70
+ const override = process.env.DSH_NOTIFY_ME_FOCUS_SCRIPT;
71
+ return typeof override === "string" && override.length > 0 ? override : undefined;
72
+ }
73
+
74
+ /**
75
+ * @returns whether a POST to {@link FOCUS_ROUTE} can do anything.
76
+ */
77
+ function focusAvailable() {
78
+ return isDesktopHost() || scriptOverride() !== undefined;
79
+ }
80
+
81
+ /**
82
+ * @returns the PowerShell script that performs the activation.
83
+ */
84
+ function focusScriptPath() {
85
+ return scriptOverride() ?? fileURLToPath(SCRIPT_URL);
86
+ }
87
+
88
+ /**
89
+ * Where `powershell.exe` lives. The shell hands the host a PATH that appends its
90
+ * own launcher directory in front of the inherited one, so a named lookup would
91
+ * normally do; the absolute path just keeps a curated PATH from breaking raising.
92
+ * @returns the interpreter to spawn.
93
+ */
94
+ function powershellPath() {
95
+ const root = process.env.SystemRoot;
96
+ const candidate = `${typeof root === "string" && root.length > 0 ? root : "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
97
+ return existsSync(candidate) ? candidate : "powershell.exe";
98
+ }
99
+
100
+ /**
101
+ * @returns the process whose window is activated: the Electron shell by
102
+ * default (the host is its direct child), or an explicit test stand-in.
103
+ */
104
+ function focusTargetPid() {
105
+ const override = Number.parseInt(process.env.DSH_NOTIFY_ME_FOCUS_PID ?? "", 10);
106
+ return Number.isInteger(override) && override >= 0 ? override : process.ppid;
107
+ }
108
+
109
+ /**
110
+ * Last `FOCUSED=<0|1>` line the script printed.
111
+ * @param output - captured stdout.
112
+ * @returns 1, 0, or `undefined` when the script printed no verdict.
113
+ */
114
+ function lastVerdict(output) {
115
+ const matches = output.match(/^FOCUSED=([01])$/gm);
116
+ if (matches === null || matches.length === 0) return undefined;
117
+ return Number.parseInt(matches[matches.length - 1].slice("FOCUSED=".length), 10);
118
+ }
119
+
120
+ /**
121
+ * Most informative `DETAIL:` / `DETAIL=` line the script printed. The last one
122
+ * is the elapsed-time line, which says nothing a caller cares about, so it is
123
+ * skipped: what remains is either where activation succeeded or why it did not.
124
+ * @param output - captured stdout.
125
+ * @returns the diagnostic text (bounded), or `null`.
126
+ */
127
+ function detailLine(output) {
128
+ const matches = output.match(/^DETAIL[:=][ \t]*(.+)$/gm);
129
+ if (matches === null) return null;
130
+ for (let index = matches.length - 1; index >= 0; index -= 1) {
131
+ const text = matches[index].replace(/^DETAIL[:=][ \t]*/, "").trim();
132
+ if (text.length > 0 && !text.startsWith("elapsed_ms=")) return text.slice(0, 200);
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Run the activation script once.
139
+ * @returns a result carrying `focused` and a short `detail` tag.
140
+ */
141
+ function runFocusScript() {
142
+ return new Promise((resolve) => {
143
+ let output = "";
144
+ let timer;
145
+ let settled = false;
146
+ /** Resolve exactly once and drop the watchdog. */
147
+ const settle = (result) => {
148
+ if (settled) return;
149
+ settled = true;
150
+ if (timer !== undefined) clearTimeout(timer);
151
+ resolve(result);
152
+ };
153
+
154
+ const args = [
155
+ "-NoProfile",
156
+ "-NonInteractive",
157
+ "-ExecutionPolicy",
158
+ "Bypass",
159
+ "-File",
160
+ focusScriptPath(),
161
+ "-ProcessId",
162
+ String(focusTargetPid()),
163
+ ];
164
+
165
+ let child;
166
+ try {
167
+ child = spawn(powershellPath(), args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
168
+ } catch (error) {
169
+ settle({ focused: false, detail: "spawn-failed: " + String(error?.message ?? error) });
170
+ return;
171
+ }
172
+
173
+ timer = setTimeout(() => {
174
+ try {
175
+ child.kill();
176
+ } catch {}
177
+ settle({ focused: false, detail: "timeout" });
178
+ }, RAISE_TIMEOUT_MS);
179
+
180
+ child.stdout.setEncoding("utf8");
181
+ child.stdout.on("data", (chunk) => {
182
+ if (output.length < OUTPUT_LIMIT) output += chunk;
183
+ });
184
+ child.stderr.on("data", () => {});
185
+ child.on("error", (error) => {
186
+ settle({ focused: false, detail: "spawn-error: " + String(error?.message ?? error) });
187
+ });
188
+ child.on("close", (code) => {
189
+ const verdict = lastVerdict(output);
190
+ settle({
191
+ focused: verdict === 1,
192
+ detail: detailLine(output) ?? (verdict === undefined ? "no-verdict" : null),
193
+ exitCode: code ?? null,
194
+ });
195
+ });
196
+ });
197
+ }
198
+
199
+ let pendingRaise = null;
200
+ let lastRaiseAt = 0;
201
+
202
+ /**
203
+ * Bring the desktop window to the front, coalescing rapid clicks.
204
+ * @returns a result carrying `focused` and a short `detail` tag.
205
+ */
206
+ function raiseWindow() {
207
+ if (pendingRaise !== null) return pendingRaise;
208
+ const now = Date.now();
209
+ if (now - lastRaiseAt < RAISE_COALESCE_MS) return Promise.resolve({ focused: false, detail: "coalesced" });
210
+ lastRaiseAt = now;
211
+ pendingRaise = runFocusScript().finally(() => {
212
+ pendingRaise = null;
213
+ });
214
+ return pendingRaise;
215
+ }
216
+
217
+ /**
218
+ * Write a JSON response. Route handlers own the whole response lifecycle.
219
+ * @param res - the server response.
220
+ * @param status - HTTP status to send.
221
+ * @param body - JSON-serialisable payload.
222
+ */
223
+ function respond(res, status, body) {
224
+ const payload = JSON.stringify(body);
225
+ res.writeHead(status, {
226
+ "content-type": "application/json; charset=utf-8",
227
+ "cache-control": "no-store",
228
+ "content-length": Buffer.byteLength(payload),
229
+ });
230
+ res.end(payload);
231
+ }
232
+
233
+ /**
234
+ * Build the route handler. The GET form is a probe the page may use to find out
235
+ * whether activation is available at all; only the POST form touches the OS.
236
+ * @returns the web-server route handler.
237
+ */
238
+ function createHandler() {
239
+ return function handleFocus(req, res) {
240
+ const method = req.method ?? "GET";
241
+ if (method === "GET" || method === "HEAD") {
242
+ respond(res, 200, {
243
+ ok: true,
244
+ route: FOCUS_ROUTE,
245
+ enabled: focusAvailable(),
246
+ desktop: isDesktopHost(),
247
+ platform: process.platform,
248
+ });
249
+ return;
250
+ }
251
+ if (method !== "POST") {
252
+ respond(res, 405, { ok: false, reason: "method-not-allowed" });
253
+ return;
254
+ }
255
+ if (!focusAvailable()) {
256
+ respond(res, 501, { ok: false, reason: "unsupported-host", platform: process.platform });
257
+ return;
258
+ }
259
+ if (req.headers[RAISE_HEADER] !== RAISE_HEADER_VALUE) {
260
+ respond(res, 403, { ok: false, reason: "forbidden" });
261
+ return;
262
+ }
263
+ req.resume();
264
+ raiseWindow().then(
265
+ (result) => {
266
+ respond(res, result.focused ? 200 : 202, {
267
+ ok: true,
268
+ focused: result.focused === true,
269
+ platform: process.platform,
270
+ detail: result.detail ?? null,
271
+ });
272
+ },
273
+ () => {
274
+ respond(res, 202, { ok: true, focused: false, platform: process.platform, detail: "error" });
275
+ },
276
+ );
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Host loader entry: mount the window-activation route when this composition has
282
+ * a web server, and do nothing at all when it does not.
283
+ * @param ctx - host cordis context.
284
+ */
285
+ export function apply(ctx) {
286
+ ctx.inject(["webServer"], (webCtx) => {
287
+ webCtx.effect(
288
+ () => webCtx.webServer.register({ kind: "exact", path: FOCUS_ROUTE, handler: createHandler() }),
289
+ "dsh-notify-me: window focus route",
290
+ );
291
+ });
292
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-me",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "离开 DSH 页面也能收到提醒:需要你操作 / 后台回复完成时发系统通知、标题标记与提示音,支持审批快捷裁决与外部推送,Web 版与桌面版均可用。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "lib/index.js",
15
15
  "lib/client.js",
16
+ "lib/focus-window.ps1",
16
17
  "cordis.patch.yml",
17
18
  "README.md",
18
19
  "README.en.md",