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.
- package/CHANGELOG.md +58 -0
- package/LICENSE +21 -21
- package/README.en.md +131 -109
- package/README.md +129 -109
- package/lib/index.js +390 -273
- package/lib/launcher.ps1 +310 -0
- package/lib/notify.ps1 +99 -35
- package/package.json +1 -1
package/lib/launcher.ps1
ADDED
|
@@ -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
|
-
$
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
try
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
$
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
$
|
|
34
|
-
$
|
|
35
|
-
|
|
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"
|