dsh-notify-windows 0.6.0 → 0.7.3

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.
@@ -0,0 +1,310 @@
1
+ # dsh-notify launcher (Windows PowerShell 5.1).
2
+ # Registered as the "dshnotify" protocol handler. Invoked by the OS as:
3
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "launcher.ps1" "%1"
4
+ # It decodes the target URL from dshnotify://open?u=<encoded> and tries, in order:
5
+ # A) navigate an already-open DSH tab via Chrome DevTools Protocol (WebSocket);
6
+ # B) focus an already-open DSH window via Win32 P/Invoke;
7
+ # C) open the target URL in the default browser (Start-Process).
8
+ # Every failure degrades silently. The click must never do nothing.
9
+ param(
10
+ [Parameter(Mandatory = $true)][string]$ProtocolArgs
11
+ )
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Helpers
15
+ # ---------------------------------------------------------------------------
16
+ function Write-Trace {
17
+ param([string]$Message)
18
+ try {
19
+ $dir = Join-Path $env:TEMP "dsh-notify"
20
+ if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null }
21
+ $line = (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff") + " " + $Message
22
+ Add-Content -Path (Join-Path $dir "launcher.log") -Value $line -Encoding UTF8
23
+ } catch {
24
+ # diagnostics must never break the flow
25
+ }
26
+ }
27
+
28
+ function Send-WebSocketJson {
29
+ # Synchronously sends a JSON string over a Chrome DevTools Protocol WebSocket
30
+ # and waits briefly for an ack frame. Returns $true on successful send.
31
+ param(
32
+ [string]$WsUrl,
33
+ [string]$Json,
34
+ [int]$TimeoutMs = 2000
35
+ )
36
+ try {
37
+ $ws = [System.Net.WebSockets.ClientWebSocket]::new()
38
+ $ct = [System.Threading.CancellationToken]::None
39
+ $connectTask = $ws.ConnectAsync($WsUrl, $ct)
40
+ $connectTask.Wait($TimeoutMs)
41
+ if ($ws.State -ne [System.Net.WebSockets.WebSocketState]::Open) {
42
+ try { $ws.Dispose() } catch {}
43
+ return $false
44
+ }
45
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
46
+ $seg = New-Object System.ArraySegment[byte] (, $bytes)
47
+ $sendTask = $ws.SendAsync($seg, [System.Net.WebSockets.WebMessageType]::Text, $true, $ct)
48
+ $sendTask.Wait($TimeoutMs)
49
+ if (-not $sendTask.IsCompletedSuccessfully) {
50
+ try { $ws.Dispose() } catch {}
51
+ return $false
52
+ }
53
+ # Best-effort ack read so the frame is flushed before we close.
54
+ $buf = New-Object byte[] 4096
55
+ $recvSeg = New-Object System.ArraySegment[byte] (, $buf)
56
+ $recvTask = $ws.ReceiveAsync($recvSeg, $ct)
57
+ if ($recvTask.Wait($TimeoutMs)) {
58
+ $ackBytes = $recvSeg.Array[0..($recvTask.Result.Count - 1)]
59
+ Write-Trace ("[cdp] ack=" + [System.Convert]::ToBase64String($ackBytes))
60
+ }
61
+ try { $ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, "ok", $ct).Wait($TimeoutMs) } catch {}
62
+ try { $ws.Dispose() } catch {}
63
+ return $true
64
+ } catch {
65
+ Write-Trace ("[fail step] Send-WebSocketJson: " + $_.Exception.Message)
66
+ try { if ($ws -ne $null) { $ws.Dispose() } } catch {}
67
+ return $false
68
+ }
69
+ }
70
+
71
+ function Invoke-CdpNavigate {
72
+ # Tries CDP on the standard debug ports plus any port discovered from a
73
+ # running chrome.exe command line. Returns $true if it navigated a page.
74
+ param([string]$TargetUrl, [string]$HostPort)
75
+ $ports = @(9222, 9223, 9229)
76
+ try {
77
+ $procs = Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" -ErrorAction SilentlyContinue
78
+ foreach ($p in $procs) {
79
+ if ($p.CommandLine -match "--remote-debugging-port=(\d+)") {
80
+ $ports += [int]$Matches[1]
81
+ }
82
+ }
83
+ } catch {}
84
+ $ports = $ports | Sort-Object -Unique
85
+
86
+ foreach ($port in $ports) {
87
+ try {
88
+ $ver = $null
89
+ try {
90
+ $ver = Invoke-WebRequest -Uri "http://127.0.0.1:$port/json/version" -TimeoutSec 1 -UseBasicParsing
91
+ } catch {}
92
+ if ($ver -eq $null) { continue }
93
+ $list = Invoke-WebRequest -Uri "http://127.0.0.1:$port/json/list" -TimeoutSec 1 -UseBasicParsing
94
+ $targets = $list.Content | ConvertFrom-Json
95
+ $hit = $null
96
+ foreach ($t in $targets) {
97
+ if ($t.type -eq "page" -and $t.url -match [regex]::Escape($HostPort)) {
98
+ $hit = $t
99
+ break
100
+ }
101
+ }
102
+ if ($hit -eq $null) { continue }
103
+ $wsUrl = "ws://127.0.0.1:$port/devtools/page/$($hit.id)"
104
+ $json = '{"id":1,"method":"Page.navigate","params":{"url":"' + $TargetUrl.Replace('\', '\\').Replace('"', '\"') + '"}}'
105
+ Write-Trace ("[cdp] navigate target id=" + $hit.id + " port=" + $port)
106
+ if (Send-WebSocketJson -WsUrl $wsUrl -Json $json) {
107
+ return $true
108
+ }
109
+ } catch {
110
+ Write-Trace ("[fail step] Invoke-CdpNavigate port=$port : " + $_.Exception.Message)
111
+ }
112
+ }
113
+ return $false
114
+ }
115
+
116
+ # Win32 P/Invoke type compiled once for Step B.
117
+ $win32Source = @'
118
+ using System;
119
+ using System.Collections.Generic;
120
+ using System.Runtime.InteropServices;
121
+ using System.Text;
122
+
123
+ public class Win32WindowFinder {
124
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
125
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
126
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
127
+ [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId);
128
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
129
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
130
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
131
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
132
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
133
+
134
+ public static List<IntPtr> Find(string className, HashSet<int> pids, string titleCue, string[] matchTitles) {
135
+ var result = new List<IntPtr>();
136
+ EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) {
137
+ try {
138
+ if (!IsWindowVisible(hWnd)) return true;
139
+ var cls = new StringBuilder(256);
140
+ GetClassName(hWnd, cls, cls.Capacity);
141
+ if (cls.ToString() != className) return true;
142
+ int pid;
143
+ GetWindowThreadProcessId(hWnd, out pid);
144
+ if (!pids.Contains(pid)) return true;
145
+ var title = new StringBuilder(512);
146
+ GetWindowText(hWnd, title, title.Capacity);
147
+ var t = title.ToString();
148
+ bool titleOk = false;
149
+ foreach (var mt in matchTitles) { if (t.Contains(mt)) { titleOk = true; break; } }
150
+ if (titleOk || t.Contains(titleCue)) result.Add(hWnd);
151
+ } catch {}
152
+ return true;
153
+ }, IntPtr.Zero);
154
+ return result;
155
+ }
156
+
157
+ public static string GetTitle(IntPtr hWnd) {
158
+ var sb = new StringBuilder(512);
159
+ GetWindowText(hWnd, sb, sb.Capacity);
160
+ return sb.ToString();
161
+ }
162
+ }
163
+ '@
164
+
165
+ function Invoke-Win32Focus {
166
+ # Enumerates top-level Chrome windows and focuses the best DSH candidate.
167
+ # Returns $true if a window was focused (navigation is not possible without
168
+ # CDP, so this is focus-only by design).
169
+ param([string]$HostPort, [string[]]$MatchTitles)
170
+ try {
171
+ if (-not ("Win32WindowFinder" -as [type])) {
172
+ Add-Type -TypeDefinition $win32Source -Language CSharp
173
+ }
174
+ $chromePids = New-Object System.Collections.Generic.HashSet[int]
175
+ $procs = Get-Process chrome -ErrorAction SilentlyContinue
176
+ foreach ($p in $procs) { [void]$chromePids.Add($p.Id) }
177
+
178
+ $wins = [Win32WindowFinder]::Find("Chrome_WidgetWin_1", $chromePids, $HostPort, $MatchTitles)
179
+ if ($wins -eq $null -or $wins.Count -eq 0) {
180
+ Write-Trace ("[win32] no candidate window for " + $HostPort)
181
+ return $false
182
+ }
183
+ # Prefer a window whose title contains the host:port cue.
184
+ $best = $null
185
+ foreach ($w in $wins) {
186
+ $title = [Win32WindowFinder]::GetTitle($w)
187
+ if ($title.Contains($HostPort)) { $best = $w; break }
188
+ }
189
+ if ($best -eq $null) { $best = $wins[0] }
190
+
191
+ if ([Win32WindowFinder]::IsIconic($best)) {
192
+ [void][Win32WindowFinder]::ShowWindow($best, 9) # SW_RESTORE
193
+ }
194
+ $ok = [Win32WindowFinder]::SetForegroundWindow($best)
195
+ if (-not $ok) {
196
+ [void][Win32WindowFinder]::ShowWindow($best, 5) # SW_SHOW
197
+ $ok = [Win32WindowFinder]::SetForegroundWindow($best)
198
+ }
199
+ Write-Trace ("[win32] focused hwnd=" + $best.ToString() + " ok=" + $ok)
200
+ return $true
201
+ } catch {
202
+ Write-Trace ("[fail step] Invoke-Win32Focus: " + $_.Exception.Message)
203
+ return $false
204
+ }
205
+ }
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Main
209
+ # ---------------------------------------------------------------------------
210
+ function Main {
211
+ Write-Trace ("[start] args='" + $ProtocolArgs + "'")
212
+
213
+ # Space-separated args arrive as one token, but rejoin defensively.
214
+ $raw = $ProtocolArgs
215
+ if ($args -and $args.Count -gt 0) {
216
+ $raw = (@($ProtocolArgs) + $args) -join " "
217
+ }
218
+
219
+ # Strip the scheme prefix and parse the query.
220
+ $stripped = $raw
221
+ if ($stripped -match "^dshnotify://") {
222
+ $stripped = $stripped.Substring("dshnotify://".Length)
223
+ } elseif ($stripped -match "^dshnotify:") {
224
+ $stripped = $stripped.Substring("dshnotify:".Length)
225
+ }
226
+ $targetUrl = ""
227
+ if ($stripped -match "\?u=") {
228
+ $encoded = $stripped -split "\?u=" | Select-Object -Last 1
229
+ try { $targetUrl = [uri]::UnescapeDataString($encoded) } catch { $targetUrl = $encoded }
230
+ }
231
+ if ([string]::IsNullOrWhiteSpace($targetUrl)) {
232
+ Write-Trace ("[fail] no targetUrl parsed from '" + $raw + "'")
233
+ return $false
234
+ }
235
+
236
+ # Origin info from the decoded URL (best-effort).
237
+ $HostPort = ""
238
+ try {
239
+ $parsed = [Uri]$targetUrl
240
+ $HostPort = "$($parsed.Host):$($parsed.Port)"
241
+ } catch {
242
+ Write-Trace ("[warn] could not parse targetUrl as Uri: " + $targetUrl)
243
+ }
244
+ $MatchTitles = @("DeepSeek Harness")
245
+
246
+ $handled = $false
247
+
248
+ # Step A: CDP navigate (best-effort, silent on any failure).
249
+ try {
250
+ if ($HostPort -ne "") {
251
+ if (Invoke-CdpNavigate -TargetUrl $targetUrl -HostPort $HostPort) {
252
+ Write-Trace ("[ok] Step A CDP navigate succeeded for " + $HostPort)
253
+ $handled = $true
254
+ } else {
255
+ Write-Trace ("[step] Step A skipped/failed")
256
+ }
257
+ }
258
+ } catch {
259
+ Write-Trace ("[fail step] Step A: " + $_.Exception.Message)
260
+ }
261
+
262
+ # Step B: Win32 focus (best-effort, focus-only without CDP navigation).
263
+ if (-not $handled) {
264
+ try {
265
+ if (Invoke-Win32Focus -HostPort $HostPort -MatchTitles $MatchTitles) {
266
+ Write-Trace ("[ok] Step B focused existing DSH window (focus-only)")
267
+ $handled = $true
268
+ } else {
269
+ Write-Trace ("[step] Step B no candidate")
270
+ }
271
+ } catch {
272
+ Write-Trace ("[fail step] Step B: " + $_.Exception.Message)
273
+ }
274
+ }
275
+
276
+ # Step C: fallback open in default browser. Always runs if A and B failed.
277
+ if (-not $handled) {
278
+ try {
279
+ Write-Trace ("[fallback] Start-Process " + $targetUrl)
280
+ Start-Process $targetUrl
281
+ $handled = $true
282
+ } catch {
283
+ Write-Trace ("[fail step] Step C: " + $_.Exception.Message)
284
+ }
285
+ }
286
+
287
+ return $handled
288
+ }
289
+
290
+ $success = $false
291
+ try {
292
+ $success = Main
293
+ } catch {
294
+ Write-Trace ("[fatal] " + $_.Exception.Message)
295
+ # Last resort: try to open the URL directly.
296
+ try {
297
+ $m = $ProtocolArgs -match "u=([^&\s]+)"
298
+ if ($m) {
299
+ $u = [uri]::UnescapeDataString($Matches[1])
300
+ Start-Process $u
301
+ $success = $true
302
+ }
303
+ } catch {}
304
+ }
305
+
306
+ if ($success) {
307
+ exit 0
308
+ } else {
309
+ exit 1
310
+ }
package/lib/notify.ps1 CHANGED
@@ -1,35 +1,99 @@
1
- # dsh-notify toast sender (Windows PowerShell 5.1 required: WinRT projection).
2
- # Parameters are bound by PowerShell from the process argument vector, so this
3
- # file stays ASCII-only and needs no BOM.
4
- param(
5
- [Parameter(Mandatory = $true)][string]$Title,
6
- [Parameter(Mandatory = $true)][string]$Body,
7
- [string]$Aumid = "DeepSeekHarness.Notify",
8
- [string]$AppName = "DeepSeek Harness"
9
- )
10
- $ErrorActionPreference = "Stop"
11
-
12
- # Register the AppUserModelId once under HKCU so Win32 toasts are allowed.
13
- # Best effort: if the write is denied we still try to show the toast below.
14
- $key = "HKCU:\SOFTWARE\Classes\AppUserModelId\$Aumid"
15
- try {
16
- if (-not (Test-Path $key)) {
17
- New-Item -Path $key -Force | Out-Null
18
- New-ItemProperty -Path $key -Name DisplayName -Value $AppName -PropertyType String -Force | Out-Null
19
- }
20
- } catch {
21
- Write-Warning ("AUMID registration failed: " + $_.Exception.Message)
22
- }
23
-
24
- # Project the WinRT toast API (supported natively by .NET Framework).
25
- [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
26
-
27
- $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
28
- $texts = $xml.GetElementsByTagName("text")
29
- $texts.Item(0).AppendChild($xml.CreateTextNode($Title)) | Out-Null
30
- $texts.Item(1).AppendChild($xml.CreateTextNode($Body)) | Out-Null
31
-
32
- $toast = New-Object Windows.UI.Notifications.ToastNotification($xml)
33
- $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($Aumid)
34
- $notifier.Show($toast)
35
- Write-Output "toast shown"
1
+ # dsh-notify toast sender (Windows PowerShell 5.1 required: WinRT projection).
2
+ # Parameters are bound by PowerShell from the process argument vector, so this
3
+ # file stays ASCII-only and needs no BOM.
4
+ param(
5
+ [Parameter(Mandatory = $true)][string]$Title,
6
+ [Parameter(Mandatory = $true)][string]$Body,
7
+ [string]$Aumid = "DeepSeekHarness.Notify",
8
+ [string]$AppName = "DeepSeek Harness",
9
+ [string]$Url = "",
10
+ [int]$LaunchProtocol = 1 # 1 = route via dshnotify:// launcher (prefer existing); 0 = open targetUrl directly
11
+ )
12
+ $ErrorActionPreference = "Stop"
13
+
14
+ # Register the AppUserModelId once under HKCU so Win32 toasts are allowed.
15
+ # Best effort: if the write is denied we still try to show the toast below.
16
+ $key = "HKCU:\SOFTWARE\Classes\AppUserModelId\$Aumid"
17
+ try {
18
+ if (-not (Test-Path $key)) {
19
+ New-Item -Path $key -Force | Out-Null
20
+ New-ItemProperty -Path $key -Name DisplayName -Value $AppName -PropertyType String -Force | Out-Null
21
+ }
22
+ } catch {
23
+ Write-Warning ("AUMID registration failed: " + $_.Exception.Message)
24
+ }
25
+
26
+ # Project the WinRT toast API (supported natively by .NET Framework).
27
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
28
+
29
+ # Optional clickable activation: when $Url is set we make the toast open a URL
30
+ # on click. Best effort - any failure here degrades gracefully (either to a
31
+ # direct protocol activation or to a plain non-clickable toast) and never
32
+ # aborts the Show below.
33
+ $clickable = $false
34
+ $launchValue = ""
35
+ if ($Url -ne "") {
36
+ try {
37
+ if ($LaunchProtocol -eq 1) {
38
+ # Register the dshnotify:// protocol handler idempotently (best effort).
39
+ try {
40
+ $progId = "HKCU:\Software\Classes\dshnotify"
41
+ if (-not (Test-Path $progId)) {
42
+ New-Item -Path $progId -Force | Out-Null
43
+ }
44
+ # Default (unnamed) value names the protocol.
45
+ Set-Item -Path $progId -Value 'dshnotify' -Force
46
+ # "URL Protocol" marker value (presence marks the key as a protocol handler).
47
+ Set-ItemProperty -Path $progId -Name 'URL Protocol' -Value '' -Force
48
+ $cmdKey = "HKCU:\Software\Classes\dshnotify\shell\open\command"
49
+ if (-not (Test-Path $cmdKey)) {
50
+ New-Item -Path $cmdKey -Force | Out-Null
51
+ }
52
+ $launcherPath = Join-Path $PSScriptRoot "launcher.ps1"
53
+ $command = ('"powershell.exe" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $launcherPath + '" "%1"')
54
+ Set-Item -Path $cmdKey -Value $command -Force
55
+ } catch {
56
+ Write-Warning ("dshnotify protocol registration failed, falling back to direct URL: " + $_.Exception.Message)
57
+ # Fall back to direct-URL activation for this toast.
58
+ $LaunchProtocol = 0
59
+ }
60
+ }
61
+
62
+ if ($LaunchProtocol -eq 1) {
63
+ $launchValue = ("dshnotify://open?u=" + [uri]::EscapeDataString($Url))
64
+ } else {
65
+ $launchValue = $Url
66
+ }
67
+ $clickable = $true
68
+ } catch {
69
+ Write-Warning ("Toast activation setup failed, showing plain toast: " + $_.Exception.Message)
70
+ $clickable = $false
71
+ }
72
+ }
73
+
74
+ $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
75
+ $texts = $xml.GetElementsByTagName("text")
76
+ $texts.Item(0).AppendChild($xml.CreateTextNode($Title)) | Out-Null
77
+ $texts.Item(1).AppendChild($xml.CreateTextNode($Body)) | Out-Null
78
+
79
+ if ($clickable) {
80
+ try {
81
+ $root = $xml.DocumentElement
82
+ if ($root.Name -eq "toast") {
83
+ $root.SetAttribute("activationType", "protocol") | Out-Null
84
+ $root.SetAttribute("duration", "long") | Out-Null
85
+ $root.SetAttribute("launch", $launchValue) | Out-Null
86
+ # Optional explicit audio cue.
87
+ $audio = $xml.CreateElement("audio")
88
+ $audio.SetAttribute("src", "ms-winsoundevent:Notification.Default") | Out-Null
89
+ $root.AppendChild($audio) | Out-Null
90
+ }
91
+ } catch {
92
+ Write-Warning ("Failed to attach activation attributes: " + $_.Exception.Message)
93
+ }
94
+ }
95
+
96
+ $toast = New-Object Windows.UI.Notifications.ToastNotification($xml)
97
+ $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($Aumid)
98
+ $notifier.Show($toast)
99
+ Write-Output "toast shown"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-windows",
3
- "version": "0.6.0",
3
+ "version": "0.7.3",
4
4
  "description": "DeepSeek Harness (DSH) 插件:任务完成 / 等待审批 / 等待回答时发送 Windows 系统桌面通知",
5
5
  "repository": {
6
6
  "type": "git",