@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.
- package/AI-DISCLOSURE.md +29 -0
- package/CHANGELOG.md +87 -0
- package/LICENSE +22 -0
- package/README.en.md +290 -0
- package/README.md +308 -3
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/assets/sounds/bubble.ogg +0 -0
- package/cordis.patch.yml +5 -0
- package/docs/INTERFACE.md +180 -0
- package/docs/RELEASING.md +282 -0
- package/docs/TEST-REPORT.md +183 -0
- package/docs/assets/native-toast.png +0 -0
- package/dsh-plugin.json +14 -0
- package/icon.svg +17 -0
- package/lib/all-done.js +126 -0
- package/lib/client.js +3486 -0
- package/lib/config.js +420 -0
- package/lib/goal-summary.js +438 -0
- package/lib/index.js +1411 -0
- package/lib/native-focus.js +350 -0
- package/lib/native-toast-helper.cs +206 -0
- package/lib/native-toast.js +372 -0
- package/lib/native-toast.ps1 +72 -0
- package/lib/taskbar-badge-helper.cs +126 -0
- package/lib/taskbar-badge-main.cs +43 -0
- package/lib/taskbar-badge.js +347 -0
- package/lib/taskbar-badge.ps1 +59 -0
- package/lib/text.js +174 -0
- package/locale/en.json +17 -0
- package/locale/zh.json +17 -0
- package/package.json +76 -6
|
@@ -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
|
+
}
|