@game25011/dsh-notify-toast 0.0.0-stage → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * dsh-notify-toast — Host-side Windows native toast manager.
3
+ *
4
+ * A small PowerShell/C# helper shows a real Windows toast with a bound
5
+ * <progress> bar, updates it while the notification is displayed, and posts the click
6
+ * activation back to a per-Host loopback callback. The Host owns the callback
7
+ * token and never trusts arbitrary callback URLs from the client.
8
+ */
9
+ import { spawn } from 'node:child_process'
10
+ import { createServer } from 'node:http'
11
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
12
+ import { basename, join } from 'node:path'
13
+
14
+ const KINDS = new Set(['question', 'approval', 'complete', 'test'])
15
+ const GROUP = 'dsh-notify-toast'
16
+ const MAX_BODY = 4096
17
+ const SUBMIT_TIMEOUT_MS = 12000
18
+ const WARM_TIMEOUT_MS = 20000
19
+ const MAX_ACTIVE_TOASTS = 8
20
+ const DURATION_MIN_MS = 3000
21
+ const DURATION_MAX_MS = 30000
22
+
23
+ function clampDuration(value) {
24
+ const number = Number(value)
25
+ if (!Number.isFinite(number)) return 8000
26
+ return Math.max(DURATION_MIN_MS, Math.min(DURATION_MAX_MS, Math.round(number)))
27
+ }
28
+
29
+ function jsonResponse(res, status, value) {
30
+ try {
31
+ const body = Buffer.from(JSON.stringify(value), 'utf8')
32
+ res.writeHead(status, {
33
+ 'content-type': 'application/json; charset=utf-8',
34
+ 'content-length': String(body.length),
35
+ 'cache-control': 'no-store',
36
+ })
37
+ res.end(body)
38
+ } catch {
39
+ try { res.destroy() } catch { /* ignore */ }
40
+ }
41
+ }
42
+
43
+ function sameToken(provided, expected) {
44
+ if (typeof provided !== 'string' || provided.length !== expected.length) return false
45
+ try {
46
+ return timingSafeEqual(Buffer.from(provided, 'utf8'), Buffer.from(expected, 'utf8'))
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {object} options
54
+ * @param {string} options.scriptPath absolute path to native-toast.ps1
55
+ * @param {(sessionId: string, notificationId: string) => void} options.onActivate
56
+ * @param {(message: string, error?: unknown) => void} [options.logWarn]
57
+ * @param {string} [options.powershellPath]
58
+ * @param {string} [options.aumid]
59
+ */
60
+ export function createNativeToastManager(options) {
61
+ const {
62
+ scriptPath,
63
+ onActivate,
64
+ logWarn = () => {},
65
+ powershellPath = join(process.env.WINDIR || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
66
+ aumid = 'electron.app.' + basename(process.execPath || 'DeepSeek Harness.exe', '.exe'),
67
+ spawnProcess = spawn,
68
+ } = options || {}
69
+
70
+ let disposed = false
71
+ let server = null
72
+ let pendingServer = null
73
+ let serverReady = null
74
+ let callbackUrl = null
75
+ let warmPromise = null
76
+ let warmChild = null
77
+ let serverStartupReject = null
78
+ const token = randomBytes(32).toString('hex')
79
+ const active = new Map()
80
+
81
+ function readJsonBody(req) {
82
+ return new Promise((resolve, reject) => {
83
+ let size = 0
84
+ const chunks = []
85
+ req.on('data', (chunk) => {
86
+ size += chunk.length
87
+ if (size > MAX_BODY) {
88
+ reject(new Error('activation body too large'))
89
+ try { req.destroy() } catch { /* ignore */ }
90
+ return
91
+ }
92
+ chunks.push(chunk)
93
+ })
94
+ req.on('end', () => {
95
+ try {
96
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
97
+ } catch (error) {
98
+ reject(error)
99
+ }
100
+ })
101
+ req.on('error', reject)
102
+ })
103
+ }
104
+
105
+ async function handleActivation(req, res) {
106
+ if (req.method !== 'POST' || req.url !== '/activate') {
107
+ jsonResponse(res, 404, { ok: false })
108
+ return
109
+ }
110
+ let body
111
+ try {
112
+ body = await readJsonBody(req)
113
+ } catch {
114
+ jsonResponse(res, 400, { ok: false })
115
+ return
116
+ }
117
+ if (!body || !sameToken(body.token, token)) {
118
+ jsonResponse(res, 403, { ok: false })
119
+ return
120
+ }
121
+ const notificationId = typeof body.notificationId === 'string' ? body.notificationId : ''
122
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
123
+ let record = null
124
+ for (const candidate of active.values()) {
125
+ if (candidate.notificationId === notificationId) { record = candidate; break }
126
+ }
127
+ if (!record || record.activated) {
128
+ jsonResponse(res, 200, { ok: true, ignored: true })
129
+ return
130
+ }
131
+ record.activated = true
132
+ try {
133
+ onActivate(sessionId, notificationId)
134
+ } catch (error) {
135
+ logWarn('native toast activation callback failed', error)
136
+ }
137
+ jsonResponse(res, 200, { ok: true })
138
+ }
139
+
140
+ function ensureServer() {
141
+ if (serverReady) return serverReady
142
+ serverReady = new Promise((resolve, reject) => {
143
+ serverStartupReject = reject
144
+ const candidate = createServer((req, res) => {
145
+ handleActivation(req, res).catch((error) => {
146
+ logWarn('native toast activation server error', error)
147
+ jsonResponse(res, 500, { ok: false })
148
+ })
149
+ })
150
+ pendingServer = candidate
151
+ candidate.on('error', (error) => {
152
+ pendingServer = null
153
+ serverStartupReject = null
154
+ serverReady = null
155
+ reject(error)
156
+ })
157
+ candidate.listen(0, '127.0.0.1', () => {
158
+ pendingServer = null
159
+ if (disposed) {
160
+ try { candidate.close() } catch { /* ignore */ }
161
+ serverReady = null
162
+ serverStartupReject = null
163
+ reject(new Error('disposed during callback server startup'))
164
+ return
165
+ }
166
+ server = candidate
167
+ serverStartupReject = null
168
+ const address = candidate.address()
169
+ if (!address || typeof address === 'string') {
170
+ candidate.close()
171
+ serverReady = null
172
+ reject(new Error('activation server has no TCP address'))
173
+ return
174
+ }
175
+ callbackUrl = 'http://127.0.0.1:' + address.port + '/activate'
176
+ resolve()
177
+ })
178
+ })
179
+ return serverReady
180
+ }
181
+
182
+ function killRecord(record) {
183
+ if (!record) return
184
+ try { record.child.kill() } catch { /* ignore */ }
185
+ }
186
+
187
+ /**
188
+ * Compile/load the C# helper without showing a toast. The helper cache is
189
+ * source-hash keyed; the first real notification should not pay the cold
190
+ * csc cost. Failures are non-fatal and leave normal delivery/fallback intact.
191
+ */
192
+ function warm() {
193
+ if (disposed) return Promise.resolve(false)
194
+ if (warmPromise) return warmPromise
195
+ warmPromise = new Promise((resolve) => {
196
+ let child
197
+ try {
198
+ child = spawnProcess(
199
+ powershellPath,
200
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-WarmOnly'],
201
+ { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
202
+ )
203
+ } catch {
204
+ resolve(false)
205
+ return
206
+ }
207
+ warmChild = child
208
+ let done = false
209
+ let output = ''
210
+ let stderr = ''
211
+ const finish = (value) => {
212
+ if (done) return
213
+ done = true
214
+ clearTimeout(timer)
215
+ if (warmChild === child) warmChild = null
216
+ try { child.kill() } catch { /* ignore */ }
217
+ resolve(value)
218
+ }
219
+ const timer = setTimeout(() => {
220
+ try { child.kill() } catch { /* ignore */ }
221
+ finish(false)
222
+ }, WARM_TIMEOUT_MS)
223
+ child.stdout.on('data', (chunk) => {
224
+ output += String(chunk)
225
+ if (output.includes('warm')) finish(true)
226
+ })
227
+ child.stderr.on('data', (chunk) => {
228
+ stderr += String(chunk)
229
+ })
230
+ child.on('error', () => finish(false))
231
+ child.on('exit', () => {
232
+ if (output.includes('warm')) finish(true)
233
+ else {
234
+ if (stderr) logWarn('native toast prewarm failed: ' + stderr.slice(0, 800))
235
+ finish(false)
236
+ }
237
+ })
238
+ })
239
+ return warmPromise
240
+ }
241
+
242
+ async function show(input) {
243
+ if (disposed) return { ok: false, reason: 'disposed' }
244
+ const kind = input && typeof input.kind === 'string' ? input.kind : ''
245
+ if (!KINDS.has(kind)) return { ok: false, reason: 'invalid-kind' }
246
+ const title = input && typeof input.title === 'string' ? input.title.slice(0, 256) : ''
247
+ const body = input && typeof input.body === 'string' ? input.body.slice(0, 2048) : ''
248
+ const sessionId = input && typeof input.sessionId === 'string' ? input.sessionId.slice(0, 256) : ''
249
+ const durationMs = clampDuration(input && input.durationMs)
250
+ const progressLabel = input && typeof input.progressLabel === 'string' ? input.progressLabel.slice(0, 32) : ''
251
+ // Warm in the background; the helper itself still handles a cache miss.
252
+ void warm()
253
+ let ready
254
+ try { ready = await ensureServer() } catch (error) { ready = error }
255
+ if (ready instanceof Error || disposed) return { ok: false, reason: 'callback-server-unavailable' }
256
+ if (active.size >= MAX_ACTIVE_TOASTS) {
257
+ const oldest = active.values().next().value
258
+ if (oldest) {
259
+ killRecord(oldest)
260
+ active.delete(oldest.notificationId)
261
+ }
262
+ }
263
+ const notificationId = kind + '-' + Date.now().toString(36) + '-' + randomBytes(4).toString('hex')
264
+ const tag = 'dsh-notify-' + kind + '-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex')
265
+ const payload = {
266
+ aumid,
267
+ tag,
268
+ group: GROUP,
269
+ title,
270
+ body,
271
+ progressLabel,
272
+ sessionId,
273
+ notificationId,
274
+ callbackUrl,
275
+ token,
276
+ durationMs,
277
+ }
278
+ let child
279
+ try {
280
+ child = spawnProcess(
281
+ powershellPath,
282
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-FromStdin'],
283
+ { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] },
284
+ )
285
+ } catch (error) {
286
+ return { ok: false, reason: 'spawn-failed', error: String(error && error.message ? error.message : error) }
287
+ }
288
+ const record = { child, notificationId, kind, activated: false }
289
+ active.set(notificationId, record)
290
+ child.stdin.on('error', () => {
291
+ // The helper may exit while the payload is being written.
292
+ })
293
+ let stderr = ''
294
+ child.stderr.on('data', (chunk) => {
295
+ stderr += String(chunk).slice(0, 1000)
296
+ })
297
+ child.on('error', () => {
298
+ if (active.get(notificationId) === record) active.delete(notificationId)
299
+ })
300
+ child.on('exit', () => {
301
+ if (active.get(notificationId) === record) active.delete(notificationId)
302
+ })
303
+ const submitted = await new Promise((resolve) => {
304
+ let done = false
305
+ let output = ''
306
+ const finish = (value) => {
307
+ if (done) return
308
+ done = true
309
+ resolve(value)
310
+ }
311
+ const timer = setTimeout(() => {
312
+ killRecord(record)
313
+ finish(false)
314
+ }, SUBMIT_TIMEOUT_MS)
315
+ child.stdout.on('data', (chunk) => {
316
+ output += String(chunk)
317
+ if (output.includes('submitted')) {
318
+ clearTimeout(timer)
319
+ finish(true)
320
+ }
321
+ })
322
+ child.on('exit', (code) => {
323
+ clearTimeout(timer)
324
+ if (!output.includes('submitted')) {
325
+ if (stderr) logWarn('native toast helper failed: ' + stderr)
326
+ finish(false)
327
+ }
328
+ })
329
+ child.on('error', () => {
330
+ clearTimeout(timer)
331
+ finish(false)
332
+ })
333
+ try {
334
+ child.stdin.end(Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'))
335
+ } catch {
336
+ clearTimeout(timer)
337
+ finish(false)
338
+ }
339
+ })
340
+ if (!submitted) return { ok: false, reason: 'helper-failed' }
341
+ return { ok: true, id: notificationId, durationMs }
342
+ }
343
+
344
+ function dispose() {
345
+ if (disposed) return
346
+ disposed = true
347
+ if (warmChild) {
348
+ try { warmChild.kill() } catch { /* ignore */ }
349
+ warmChild = null
350
+ }
351
+ for (const record of active.values()) killRecord(record)
352
+ active.clear()
353
+ if (pendingServer) {
354
+ const pending = pendingServer
355
+ pendingServer = null
356
+ try { pending.close() } catch { /* ignore */ }
357
+ if (serverStartupReject) {
358
+ const rejectStartup = serverStartupReject
359
+ serverStartupReject = null
360
+ try { rejectStartup(new Error('disposed during callback server startup')) } catch { /* ignore */ }
361
+ }
362
+ }
363
+ if (server) {
364
+ try { server.close() } catch { /* ignore */ }
365
+ server = null
366
+ }
367
+ serverReady = null
368
+ callbackUrl = null
369
+ }
370
+
371
+ return { show, warm, ensureServer, dispose }
372
+ }
@@ -0,0 +1,72 @@
1
+ param(
2
+ [switch]$FromStdin,
3
+ [switch]$WarmOnly
4
+ )
5
+
6
+ $ErrorActionPreference = 'Stop'
7
+ $source = Join-Path $PSScriptRoot 'native-toast-helper.cs'
8
+ if (-not (Test-Path $source)) { throw "helper source missing: $source" }
9
+ $hash = (Get-FileHash -Algorithm SHA256 -Path $source).Hash.ToLowerInvariant()
10
+ $cacheRoot = Join-Path $env:LOCALAPPDATA 'dsh-notify-toast\native-cache'
11
+ $dll = Join-Path $cacheRoot ($hash + '.dll')
12
+
13
+ function New-HelperDll {
14
+ New-Item -ItemType Directory -Path $cacheRoot -Force | Out-Null
15
+ $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
16
+ if (-not (Test-Path $csc)) { throw "csc not found: $csc" }
17
+ $winmdRoot = Join-Path $env:WINDIR 'System32\WinMetadata'
18
+ $refs = @(
19
+ (Join-Path $winmdRoot 'Windows.Foundation.winmd'),
20
+ (Join-Path $winmdRoot 'Windows.UI.winmd'),
21
+ (Join-Path $winmdRoot 'Windows.Data.winmd'),
22
+ (Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\System.Runtime.WindowsRuntime.dll'),
23
+ (Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\System.Runtime.InteropServices.WindowsRuntime.dll'),
24
+ (Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\System.Runtime.dll')
25
+ )
26
+ foreach ($ref in $refs) { if (-not (Test-Path $ref)) { throw "reference missing: $ref" } }
27
+ $temp = $dll + '.tmp-' + [guid]::NewGuid().ToString('N')
28
+ $cscArgs = @('/nologo', '/target:library', ('/out:' + $temp))
29
+ foreach ($ref in $refs) { $cscArgs += ('/r:' + $ref) }
30
+ $cscArgs += $source
31
+ $cscOutput = & $csc @cscArgs 2>&1 | Out-String
32
+ if ($LASTEXITCODE -ne 0 -or -not (Test-Path $temp)) {
33
+ Remove-Item -Force -ErrorAction SilentlyContinue $temp
34
+ throw ('native-toast helper compilation failed: ' + $cscOutput)
35
+ }
36
+ Move-Item -Force -Path $temp -Destination $dll
37
+ return $dll
38
+ }
39
+
40
+ if (-not (Test-Path $dll)) { $dll = New-HelperDll }
41
+ try {
42
+ Add-Type -Path $dll
43
+ } catch {
44
+ # A torn or stale cache must not block a real notification. Rebuild once.
45
+ Remove-Item -Force -ErrorAction SilentlyContinue $dll
46
+ $dll = New-HelperDll
47
+ Add-Type -Path $dll
48
+ }
49
+
50
+ if ($WarmOnly) {
51
+ Write-Output 'warm'
52
+ exit 0
53
+ }
54
+
55
+ $payloadBase64 = if ($FromStdin) { [Console]::In.ReadToEnd().Trim() } else { '' }
56
+ if ([string]::IsNullOrWhiteSpace($payloadBase64)) { throw 'native-toast payload is missing' }
57
+ $payloadJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payloadBase64))
58
+ $payload = $payloadJson | ConvertFrom-Json
59
+
60
+ [DshNativeToast]::Run(
61
+ [string]$payload.aumid,
62
+ [string]$payload.tag,
63
+ [string]$payload.group,
64
+ [string]$payload.title,
65
+ [string]$payload.body,
66
+ [string]$payload.progressLabel,
67
+ [string]$payload.sessionId,
68
+ [string]$payload.notificationId,
69
+ [string]$payload.callbackUrl,
70
+ [string]$payload.token,
71
+ [int]$payload.durationMs
72
+ )
@@ -0,0 +1,126 @@
1
+ using System;
2
+ using System.Drawing;
3
+ using System.Drawing.Drawing2D;
4
+ using System.Drawing.Text;
5
+ using System.Runtime.InteropServices;
6
+
7
+ // Minimal ITaskbarList3 surface. Only SetOverlayIcon is used here, but the
8
+ // vtable order must match the Windows SDK exactly.
9
+ [ComImport, Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
10
+ internal interface ITaskbarList3
11
+ {
12
+ void HrInit();
13
+ void AddTab(IntPtr hwnd);
14
+ void DeleteTab(IntPtr hwnd);
15
+ void ActivateTab(IntPtr hwnd);
16
+ void SetActiveAlt(IntPtr hwnd);
17
+ void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen);
18
+ void SetProgressValue(IntPtr hwnd, ulong ullCompleted, ulong ullTotal);
19
+ void SetProgressState(IntPtr hwnd, int tbpFlags);
20
+ void RegisterTab(IntPtr hwndTab, IntPtr hwndMDI);
21
+ void UnregisterTab(IntPtr hwndTab);
22
+ void SetTabOrder(IntPtr hwndTab, IntPtr hwndInsertBefore);
23
+ void SetTabActive(IntPtr hwndTab, IntPtr hwndMDI, uint dwReserved);
24
+ void ThumbBarAddButtons(IntPtr hwnd, uint cButtons, IntPtr pButtons);
25
+ void ThumbBarUpdateButtons(IntPtr hwnd, uint cButtons, IntPtr pButtons);
26
+ void ThumbBarSetImageList(IntPtr hwnd, IntPtr himl);
27
+ void SetOverlayIcon(IntPtr hwnd, IntPtr hIcon, [MarshalAs(UnmanagedType.LPWStr)] string pszDescription);
28
+ void SetThumbnailTooltip(IntPtr hwnd, [MarshalAs(UnmanagedType.LPWStr)] string pszTip);
29
+ void SetThumbnailClip(IntPtr hwnd, IntPtr prcClip);
30
+ }
31
+
32
+ [ComImport, Guid("56FDF344-FD6D-11d0-958A-006097C9A090")]
33
+ internal class CTaskbarList
34
+ {
35
+ }
36
+
37
+ /// <summary>
38
+ /// One-shot taskbar overlay badge writer. The process owns the HICON only
39
+ /// until it exits; Windows keeps the rendered overlay after that, which keeps
40
+ /// the helper tiny and avoids a long-lived process.
41
+ /// </summary>
42
+ public static class DshTaskbarBadge
43
+ {
44
+ public static void Set(IntPtr hwnd, int count)
45
+ {
46
+ if (hwnd == IntPtr.Zero) return;
47
+
48
+ var taskbar = (ITaskbarList3)new CTaskbarList();
49
+ taskbar.HrInit();
50
+
51
+ if (count <= 0)
52
+ {
53
+ taskbar.SetOverlayIcon(hwnd, IntPtr.Zero, string.Empty);
54
+ return;
55
+ }
56
+
57
+ int shown = count > 3 ? 3 : count;
58
+ bool overflow = count > 3;
59
+
60
+ using (var bitmap = new Bitmap(32, 32))
61
+ using (var graphics = Graphics.FromImage(bitmap))
62
+ {
63
+ graphics.SmoothingMode = SmoothingMode.AntiAlias;
64
+ graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
65
+ graphics.Clear(Color.Transparent);
66
+
67
+ // 1-3: a graphite count disc with a thin Fluent-style blue rim.
68
+ // This follows the neutral/accent direction of GitHub Primer's
69
+ // badge tokens and Fluent UI's filled badge, instead of the harsh
70
+ // saturated red block. 4+: a small red dot with a white halo, so
71
+ // the "more than three" state remains an unread indicator without
72
+ // asking Windows to render a two-digit number in 16 px.
73
+ int size = overflow ? 16 : 30;
74
+ int offset = (32 - size) / 2;
75
+ Color fillColor = overflow
76
+ ? Color.FromArgb(255, 196, 49, 75) // #C4314B, muted GitHub danger
77
+ : Color.FromArgb(245, 31, 35, 40); // #1F2328, Primer neutral emphasis
78
+ using (var fill = new SolidBrush(fillColor))
79
+ {
80
+ graphics.FillEllipse(fill, offset, offset, size, size);
81
+ }
82
+
83
+ if (overflow)
84
+ {
85
+ using (var halo = new Pen(Color.FromArgb(235, 255, 255, 255), 2f))
86
+ {
87
+ graphics.DrawEllipse(halo, offset, offset, size, size);
88
+ }
89
+ }
90
+ else
91
+ {
92
+ // Brand-blue rim: recognizable without the urgency of red.
93
+ using (var rim = new Pen(Color.FromArgb(255, 47, 129, 247), 2f))
94
+ {
95
+ graphics.DrawEllipse(rim, 1, 1, 30, 30);
96
+ }
97
+ // Segoe UI Black + a 20 px glyph. Center the actual glyph path,
98
+ // not DrawString's padded layout box: this keeps "1", "2" and
99
+ // "3" optically centered and maximises their footprint in the
100
+ // 30 px disc after Windows scales it to the 16 px overlay.
101
+ using (var font = new Font("Segoe UI Black", 20f, FontStyle.Regular, GraphicsUnit.Pixel))
102
+ using (var textBrush = new SolidBrush(Color.White))
103
+ using (var format = new StringFormat(StringFormat.GenericTypographic))
104
+ using (var path = new GraphicsPath())
105
+ {
106
+ path.AddString(shown.ToString(), font.FontFamily, (int)font.Style, font.Size, PointF.Empty, format);
107
+ RectangleF bounds = path.GetBounds();
108
+ float dx = (32f - bounds.Width) / 2f - bounds.X;
109
+ float dy = (32f - bounds.Height) / 2f - bounds.Y;
110
+ using (var matrix = new Matrix())
111
+ {
112
+ matrix.Translate(dx, dy);
113
+ path.Transform(matrix);
114
+ }
115
+ graphics.FillPath(textBrush, path);
116
+ }
117
+ }
118
+
119
+ // Do not destroy this HICON: the taskbar retains the overlay after
120
+ // this one-shot helper exits. Windows reclaims the icon with the
121
+ // helper's process resources.
122
+ string description = overflow ? "More than 3 completed sessions" : (count == 1 ? "1 completed session" : count + " completed sessions");
123
+ taskbar.SetOverlayIcon(hwnd, bitmap.GetHicon(), description);
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,43 @@
1
+ using System;
2
+
3
+ /// <summary>
4
+ /// Standalone entry point for the one-shot taskbar overlay writer.
5
+ /// Running a precompiled small executable avoids starting Windows PowerShell
6
+ /// (and loading its ~50 MB runtime) for every badge count change.
7
+ /// </summary>
8
+ public static class Program
9
+ {
10
+ [STAThread]
11
+ public static int Main(string[] args)
12
+ {
13
+ long hwnd = 0;
14
+ int count = 0;
15
+
16
+ for (int i = 0; i < args.Length; i++)
17
+ {
18
+ string arg = args[i] ?? string.Empty;
19
+ if (string.Equals(arg, "-WarmOnly", StringComparison.OrdinalIgnoreCase)) return 0;
20
+ if (string.Equals(arg, "-Hwnd", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
21
+ {
22
+ long.TryParse(args[++i], out hwnd);
23
+ continue;
24
+ }
25
+ if (string.Equals(arg, "-Count", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
26
+ {
27
+ int.TryParse(args[++i], out count);
28
+ continue;
29
+ }
30
+ }
31
+
32
+ if (hwnd == 0) return 2;
33
+ try
34
+ {
35
+ DshTaskbarBadge.Set(new IntPtr(hwnd), count);
36
+ return 0;
37
+ }
38
+ catch
39
+ {
40
+ return 1;
41
+ }
42
+ }
43
+ }