@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,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify-toast — best-effort Windows window raise for the Host half.
|
|
3
|
+
*
|
|
4
|
+
* Contract: docs/INTERFACE.md §2.6. The DSH desktop shell runs this plugin in
|
|
5
|
+
* an Electron-as-Node Host process, so the window belongs to the parent
|
|
6
|
+
* Electron main process (`process.ppid`). koffi is not a plugin dependency; we
|
|
7
|
+
* load the native binding that ships inside the installed app:
|
|
8
|
+
*
|
|
9
|
+
* resources/app.asar.unpacked/dsh/node_modules/@koromix/koffi-win32-x64/
|
|
10
|
+
* win32_x64/koffi.node
|
|
11
|
+
*
|
|
12
|
+
* Nothing here may throw: every entry point catches and falls back to a
|
|
13
|
+
* boolean. `focusDesktopWindow()` is synchronous (the user32 calls are cheap)
|
|
14
|
+
* and safe to call from an RPC request.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync } from 'node:fs'
|
|
18
|
+
import { createRequire } from 'node:module'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
/** Native binding locations relative to a resources root (most specific first). */
|
|
22
|
+
const BINDING_RELATIVES = [
|
|
23
|
+
['app.asar.unpacked', 'dsh', 'node_modules', '@koromix', 'koffi-win32-x64', 'win32_x64', 'koffi.node'],
|
|
24
|
+
[
|
|
25
|
+
'app.asar.unpacked',
|
|
26
|
+
'dsh',
|
|
27
|
+
'node_modules',
|
|
28
|
+
'@deepseek-ai',
|
|
29
|
+
'dsh-desktop-host',
|
|
30
|
+
'node_modules',
|
|
31
|
+
'@koromix',
|
|
32
|
+
'koffi-win32-x64',
|
|
33
|
+
'win32_x64',
|
|
34
|
+
'koffi.node',
|
|
35
|
+
],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/** SW_RESTORE — un-minimize without changing the window size. */
|
|
39
|
+
const SW_RESTORE = 9
|
|
40
|
+
/** Virtual key code for ALT; the key tap satisfies SetForegroundWindow's focus rules. */
|
|
41
|
+
const VK_MENU = 0x12
|
|
42
|
+
const KEYEVENTF_KEYUP = 0x0002
|
|
43
|
+
|
|
44
|
+
/** Cached loaded API, or null when native focus is unavailable. */
|
|
45
|
+
let api = null
|
|
46
|
+
/** Whether loading was attempted (success or failure). Cached to avoid retries. */
|
|
47
|
+
let loadAttempted = false
|
|
48
|
+
/** Last load failure, exposed through nativeFocusInfo() for diagnostics. */
|
|
49
|
+
let loadError = null
|
|
50
|
+
/** Path of the loaded native binding. */
|
|
51
|
+
let bindingPath = null
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Candidate resources roots for the running app.
|
|
55
|
+
* @returns {string[]} existing-looking roots (no I/O besides the env/execPath reads).
|
|
56
|
+
*/
|
|
57
|
+
function resourcesRoots() {
|
|
58
|
+
const roots = []
|
|
59
|
+
try {
|
|
60
|
+
if (typeof process.resourcesPath === 'string' && process.resourcesPath) roots.push(process.resourcesPath)
|
|
61
|
+
} catch {
|
|
62
|
+
/* not Electron */
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
roots.push(join(dirname(process.execPath), 'resources'))
|
|
66
|
+
} catch {
|
|
67
|
+
/* no execPath */
|
|
68
|
+
}
|
|
69
|
+
// A dev/checkout run of the desktop app may keep the unpacked tree here.
|
|
70
|
+
try {
|
|
71
|
+
const localAppData = process.env.LOCALAPPDATA
|
|
72
|
+
if (localAppData) roots.push(join(localAppData, 'Programs', 'DeepSeek Harness', 'resources'))
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
return roots
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Locate the packaged koffi binding.
|
|
81
|
+
* @returns {string|null} absolute path or null.
|
|
82
|
+
*/
|
|
83
|
+
function resolveBindingPath() {
|
|
84
|
+
for (const root of resourcesRoots()) {
|
|
85
|
+
for (const relative of BINDING_RELATIVES) {
|
|
86
|
+
try {
|
|
87
|
+
const candidate = join(root, ...relative)
|
|
88
|
+
if (existsSync(candidate)) return candidate
|
|
89
|
+
} catch {
|
|
90
|
+
/* ignore a malformed candidate */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Load koffi and declare the user32/kernel32 functions once.
|
|
99
|
+
* @returns {object|null} the function table, or null when unavailable.
|
|
100
|
+
*/
|
|
101
|
+
function loadNativeApi() {
|
|
102
|
+
if (loadAttempted) return api
|
|
103
|
+
loadAttempted = true
|
|
104
|
+
if (process.platform !== 'win32') {
|
|
105
|
+
loadError = 'native focus is only implemented on win32'
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
bindingPath = resolveBindingPath()
|
|
110
|
+
if (!bindingPath) {
|
|
111
|
+
loadError = 'koffi native binding not found under the app resources'
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
// koffi's package directory is the parent of win32_x64; createRequire only
|
|
115
|
+
// uses its dirname as a resolution base, so any filename in that dir works.
|
|
116
|
+
const requireNative = createRequire(join(dirname(bindingPath), 'index.cjs'))
|
|
117
|
+
const koffi = requireNative(bindingPath)
|
|
118
|
+
if (!koffi || typeof koffi.load !== 'function' || typeof koffi.proto !== 'function') {
|
|
119
|
+
loadError = 'koffi module has an unexpected shape'
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
const user32 = koffi.load('user32.dll')
|
|
123
|
+
const kernel32 = koffi.load('kernel32.dll')
|
|
124
|
+
|
|
125
|
+
// koffi 3 requires the callback prototype to be declared up front and the
|
|
126
|
+
// function signature to reference it as `WNDENUMPROC *`.
|
|
127
|
+
koffi.proto('bool __stdcall WNDENUMPROC(void *hWnd, intptr_t lParam)')
|
|
128
|
+
api = {
|
|
129
|
+
koffi,
|
|
130
|
+
EnumWindows: user32.func('bool __stdcall EnumWindows(WNDENUMPROC *cb, intptr_t param)'),
|
|
131
|
+
IsWindowVisible: user32.func('bool __stdcall IsWindowVisible(void *h)'),
|
|
132
|
+
IsIconic: user32.func('int __stdcall IsIconic(void *h)'),
|
|
133
|
+
GetWindowTextLengthW: user32.func('int __stdcall GetWindowTextLengthW(void *h)'),
|
|
134
|
+
GetWindowTextW: user32.func('int __stdcall GetWindowTextW(void *h, _Out_ uint16 *buf, int max)'),
|
|
135
|
+
GetClassNameW: user32.func('int __stdcall GetClassNameW(void *h, _Out_ uint16 *buf, int max)'),
|
|
136
|
+
GetWindowThreadProcessId: user32.func('uint32 __stdcall GetWindowThreadProcessId(void *h, _Out_ uint32 *pid)'),
|
|
137
|
+
ShowWindow: user32.func('bool __stdcall ShowWindow(void *h, int cmd)'),
|
|
138
|
+
BringWindowToTop: user32.func('bool __stdcall BringWindowToTop(void *h)'),
|
|
139
|
+
SetForegroundWindow: user32.func('bool __stdcall SetForegroundWindow(void *h)'),
|
|
140
|
+
GetForegroundWindow: user32.func('void * __stdcall GetForegroundWindow()'),
|
|
141
|
+
AttachThreadInput: user32.func('bool __stdcall AttachThreadInput(uint32 attach, uint32 attachTo, bool attachFlag)'),
|
|
142
|
+
keybd_event: user32.func('void __stdcall keybd_event(uint8 vk, uint8 scan, uint32 flags, uintptr_t extra)'),
|
|
143
|
+
GetCurrentThreadId: kernel32.func('uint32 __stdcall GetCurrentThreadId()'),
|
|
144
|
+
}
|
|
145
|
+
loadError = null
|
|
146
|
+
return api
|
|
147
|
+
} catch (error) {
|
|
148
|
+
loadError = error instanceof Error ? error.message : String(error)
|
|
149
|
+
api = null
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Read a window's title (empty string when it has none). */
|
|
155
|
+
function windowTitle(functions, handle) {
|
|
156
|
+
try {
|
|
157
|
+
const length = functions.GetWindowTextLengthW(handle)
|
|
158
|
+
if (length <= 0) return ''
|
|
159
|
+
const buffer = new Uint16Array(length + 1)
|
|
160
|
+
functions.GetWindowTextW(handle, buffer, length + 1)
|
|
161
|
+
return Buffer.from(buffer.buffer, 0, (length + 1) * 2)
|
|
162
|
+
.toString('utf16le')
|
|
163
|
+
.replace(/\0.*$/s, '')
|
|
164
|
+
} catch {
|
|
165
|
+
return ''
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Read a window's class name (empty string on failure). */
|
|
170
|
+
function windowClass(functions, handle) {
|
|
171
|
+
try {
|
|
172
|
+
const buffer = new Uint16Array(256)
|
|
173
|
+
const written = functions.GetClassNameW(handle, buffer, 256)
|
|
174
|
+
if (!written) return ''
|
|
175
|
+
return Buffer.from(buffer.buffer, 0, written * 2).toString('utf16le').replace(/\0.*$/s, '')
|
|
176
|
+
} catch {
|
|
177
|
+
return ''
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Windows that belong to Electron's chrome but are never the app window. A
|
|
183
|
+
* PID match alone is not enough: the main process also owns visible top-level
|
|
184
|
+
* windows such as `OleDdeWndClass` / `DDE Server Window`, `MSCTFIME UI` and
|
|
185
|
+
* `Default IME`, and raising one of those leaves the real window where it was.
|
|
186
|
+
*/
|
|
187
|
+
const NON_APP_CLASSES = new Set(['OleDdeWndClass', 'MSCTFIME UI', 'IME', 'Default IME', 'DDE Server Window'])
|
|
188
|
+
|
|
189
|
+
/** Window class of the actual Electron browser window. */
|
|
190
|
+
const APP_WINDOW_CLASS = /^Chrome_WidgetWin/i
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* List candidate main windows with a score; the highest score is tried first.
|
|
194
|
+
* Score: app title > browser-window class > parent-process PID; non-app window
|
|
195
|
+
* classes are filtered out entirely.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} functions native function table.
|
|
198
|
+
* @returns {Array<{handle: any, title: string, pid: number, cls: string, score: number}>}
|
|
199
|
+
*/
|
|
200
|
+
function listDesktopWindows(functions) {
|
|
201
|
+
const ownPid = Number(process.ppid)
|
|
202
|
+
const found = []
|
|
203
|
+
try {
|
|
204
|
+
functions.EnumWindows((handle) => {
|
|
205
|
+
try {
|
|
206
|
+
if (!functions.IsWindowVisible(handle)) return true
|
|
207
|
+
const cls = windowClass(functions, handle)
|
|
208
|
+
if (NON_APP_CLASSES.has(cls)) return true
|
|
209
|
+
const title = windowTitle(functions, handle)
|
|
210
|
+
if (!title) return true
|
|
211
|
+
const pidBuffer = new Uint32Array(1)
|
|
212
|
+
functions.GetWindowThreadProcessId(handle, pidBuffer)
|
|
213
|
+
const pid = pidBuffer[0]
|
|
214
|
+
const titleMatch = /deepseek/i.test(title)
|
|
215
|
+
const classMatch = APP_WINDOW_CLASS.test(cls)
|
|
216
|
+
if (!titleMatch && !classMatch) return true
|
|
217
|
+
let score = 0
|
|
218
|
+
if (titleMatch) score += 100
|
|
219
|
+
if (classMatch) score += 50
|
|
220
|
+
if (Number.isInteger(ownPid) && ownPid > 0 && pid === ownPid) score += 20
|
|
221
|
+
found.push({ handle, title, pid, cls, score })
|
|
222
|
+
return true
|
|
223
|
+
} catch {
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
226
|
+
}, 0)
|
|
227
|
+
} catch {
|
|
228
|
+
/* enumeration failed: return whatever was captured */
|
|
229
|
+
}
|
|
230
|
+
found.sort((left, right) => right.score - left.score)
|
|
231
|
+
return found
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Window candidates, highest score first (diagnostics/tests). */
|
|
235
|
+
export function listDesktopWindowCandidates() {
|
|
236
|
+
const functions = loadNativeApi()
|
|
237
|
+
if (!functions) return []
|
|
238
|
+
return listDesktopWindows(functions).map((entry) => ({ ...entry, handle: String(entry.handle) }))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Attach to the foreground thread, raise the window, then detach. */
|
|
242
|
+
function activateWindow(functions, handle) {
|
|
243
|
+
const foreground = functions.GetForegroundWindow()
|
|
244
|
+
let foregroundThread = 0
|
|
245
|
+
try {
|
|
246
|
+
if (foreground !== null && foreground !== undefined) {
|
|
247
|
+
foregroundThread = functions.GetWindowThreadProcessId(foreground, new Uint32Array(1))
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
foregroundThread = 0
|
|
251
|
+
}
|
|
252
|
+
const ownThread = functions.GetCurrentThreadId()
|
|
253
|
+
let attached = false
|
|
254
|
+
try {
|
|
255
|
+
if (foregroundThread !== 0 && foregroundThread !== ownThread) {
|
|
256
|
+
attached = Boolean(functions.AttachThreadInput(foregroundThread, ownThread, true))
|
|
257
|
+
}
|
|
258
|
+
functions.SetForegroundWindow(handle)
|
|
259
|
+
functions.BringWindowToTop(handle)
|
|
260
|
+
} catch {
|
|
261
|
+
/* best effort */
|
|
262
|
+
} finally {
|
|
263
|
+
if (attached) {
|
|
264
|
+
try {
|
|
265
|
+
functions.AttachThreadInput(foregroundThread, ownThread, false)
|
|
266
|
+
} catch {
|
|
267
|
+
/* best effort */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Whether the native window-raise path can work in this process (win32 + the
|
|
275
|
+
* bundled koffi binding loads). Never throws; loading is attempted once.
|
|
276
|
+
*
|
|
277
|
+
* @returns {boolean} true when `focusDesktopWindow()` can attempt a raise.
|
|
278
|
+
*/
|
|
279
|
+
export function nativeFocusAvailable() {
|
|
280
|
+
try {
|
|
281
|
+
return loadNativeApi() !== null
|
|
282
|
+
} catch {
|
|
283
|
+
return false
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Best-effort: restore, raise and foreground the DSH desktop window.
|
|
289
|
+
*
|
|
290
|
+
* @returns {boolean} true only when the target window ended up foreground.
|
|
291
|
+
* Never throws; every failure (non-win32, missing binding, no window, denied
|
|
292
|
+
* SetForegroundWindow) returns false so callers can fall back to
|
|
293
|
+
* `window.focus()` on the page.
|
|
294
|
+
*/
|
|
295
|
+
export function focusDesktopWindow() {
|
|
296
|
+
try {
|
|
297
|
+
const functions = loadNativeApi()
|
|
298
|
+
if (!functions) return false
|
|
299
|
+
const candidates = listDesktopWindows(functions)
|
|
300
|
+
if (candidates.length === 0) return false
|
|
301
|
+
for (const target of candidates) {
|
|
302
|
+
const handle = target.handle
|
|
303
|
+
try {
|
|
304
|
+
functions.ShowWindow(handle, SW_RESTORE)
|
|
305
|
+
functions.BringWindowToTop(handle)
|
|
306
|
+
// A harmless ALT tap: Windows grants foreground rights to processes that
|
|
307
|
+
// received the last input event, which is what SetForegroundWindow needs.
|
|
308
|
+
functions.keybd_event(VK_MENU, 0, 0, 0)
|
|
309
|
+
functions.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0)
|
|
310
|
+
} catch {
|
|
311
|
+
/* still try the activation below */
|
|
312
|
+
}
|
|
313
|
+
activateWindow(functions, handle)
|
|
314
|
+
if (String(functions.GetForegroundWindow()) === String(handle)) return true
|
|
315
|
+
// One retry: the first activation can lose the race with a just-focused
|
|
316
|
+
// foreground window (for example the page that received the click).
|
|
317
|
+
activateWindow(functions, handle)
|
|
318
|
+
if (String(functions.GetForegroundWindow()) === String(handle)) return true
|
|
319
|
+
}
|
|
320
|
+
return false
|
|
321
|
+
} catch {
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Diagnostics for the RPC health/probe output (not part of the client contract).
|
|
328
|
+
*
|
|
329
|
+
* @returns {{available: boolean, modulePath: string|null, error: string|null, platform: string}}
|
|
330
|
+
*/
|
|
331
|
+
export function nativeFocusInfo() {
|
|
332
|
+
try {
|
|
333
|
+
const available = nativeFocusAvailable()
|
|
334
|
+
return {
|
|
335
|
+
available,
|
|
336
|
+
modulePath: bindingPath,
|
|
337
|
+
error: available ? null : loadError,
|
|
338
|
+
platform: process.platform,
|
|
339
|
+
candidates: available ? listDesktopWindowCandidates() : [],
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return {
|
|
343
|
+
available: false,
|
|
344
|
+
modulePath: null,
|
|
345
|
+
error: error instanceof Error ? error.message : String(error),
|
|
346
|
+
platform: 'unknown',
|
|
347
|
+
candidates: [],
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Globalization;
|
|
3
|
+
using System.Net;
|
|
4
|
+
using System.Threading;
|
|
5
|
+
using Windows.Data.Xml.Dom;
|
|
6
|
+
using Windows.Foundation;
|
|
7
|
+
using Windows.UI.Notifications;
|
|
8
|
+
|
|
9
|
+
public static class DshNativeToast
|
|
10
|
+
{
|
|
11
|
+
private const int ProgressTickMs = 100;
|
|
12
|
+
private const int RetentionGraceMs = 5000;
|
|
13
|
+
private const int RetractionGraceMs = 300;
|
|
14
|
+
private const int ActivationGraceMs = 750;
|
|
15
|
+
|
|
16
|
+
public static void Run(
|
|
17
|
+
string aumid,
|
|
18
|
+
string tag,
|
|
19
|
+
string group,
|
|
20
|
+
string title,
|
|
21
|
+
string body,
|
|
22
|
+
string progressLabel,
|
|
23
|
+
string sessionId,
|
|
24
|
+
string notificationId,
|
|
25
|
+
string callbackUrl,
|
|
26
|
+
string token,
|
|
27
|
+
int durationMs)
|
|
28
|
+
{
|
|
29
|
+
var xml = BuildXml(title, body, progressLabel);
|
|
30
|
+
var document = new XmlDocument();
|
|
31
|
+
document.LoadXml(xml);
|
|
32
|
+
var toast = new ToastNotification(document);
|
|
33
|
+
toast.Tag = tag;
|
|
34
|
+
toast.Group = group;
|
|
35
|
+
|
|
36
|
+
var data = new NotificationData();
|
|
37
|
+
data.SequenceNumber = 1;
|
|
38
|
+
data.Values["progressValue"] = "1";
|
|
39
|
+
// Bind the initial data before Show so the first frame has the bound
|
|
40
|
+
// progress value instead of waiting for the first Update().
|
|
41
|
+
toast.Data = data;
|
|
42
|
+
var safeDuration = Math.Max(1000, durationMs);
|
|
43
|
+
// Expiration is a notification-center retention deadline, not a
|
|
44
|
+
// promise about the banner animation. Keep it a little beyond the
|
|
45
|
+
// progress window so the shell can finish its own transition.
|
|
46
|
+
toast.ExpirationTime = DateTimeOffset.Now.AddMilliseconds(safeDuration + RetentionGraceMs);
|
|
47
|
+
|
|
48
|
+
var stop = new ManualResetEventSlim(false);
|
|
49
|
+
var activationStarted = new ManualResetEventSlim(false);
|
|
50
|
+
var activationFinished = new ManualResetEventSlim(false);
|
|
51
|
+
|
|
52
|
+
if (!string.IsNullOrEmpty(callbackUrl))
|
|
53
|
+
{
|
|
54
|
+
var handler = new TypedEventHandler<ToastNotification, object>(delegate
|
|
55
|
+
{
|
|
56
|
+
activationStarted.Set();
|
|
57
|
+
stop.Set();
|
|
58
|
+
try
|
|
59
|
+
{
|
|
60
|
+
PostActivation(callbackUrl, token, sessionId, notificationId);
|
|
61
|
+
}
|
|
62
|
+
finally
|
|
63
|
+
{
|
|
64
|
+
activationFinished.Set();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
AddHandler(toast, "Activated", handler);
|
|
68
|
+
}
|
|
69
|
+
AddHandler(toast, "Dismissed", new TypedEventHandler<ToastNotification, ToastDismissedEventArgs>(delegate
|
|
70
|
+
{
|
|
71
|
+
stop.Set();
|
|
72
|
+
}));
|
|
73
|
+
AddHandler(toast, "Failed", new TypedEventHandler<ToastNotification, ToastFailedEventArgs>(delegate
|
|
74
|
+
{
|
|
75
|
+
stop.Set();
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
var notifier = ToastNotificationManager.CreateToastNotifier(aumid);
|
|
79
|
+
notifier.Show(toast);
|
|
80
|
+
Console.Out.WriteLine("submitted");
|
|
81
|
+
Console.Out.Flush();
|
|
82
|
+
|
|
83
|
+
var started = DateTime.UtcNow;
|
|
84
|
+
uint sequence = 2;
|
|
85
|
+
int updateFailures = 0;
|
|
86
|
+
while (!stop.IsSet)
|
|
87
|
+
{
|
|
88
|
+
var elapsed = (int)(DateTime.UtcNow - started).TotalMilliseconds;
|
|
89
|
+
var remaining = Math.Max(0, safeDuration - elapsed);
|
|
90
|
+
if (remaining <= 0) break;
|
|
91
|
+
data.SequenceNumber = sequence++;
|
|
92
|
+
data.Values["progressValue"] = ((double)remaining / safeDuration).ToString("0.###", CultureInfo.InvariantCulture);
|
|
93
|
+
try
|
|
94
|
+
{
|
|
95
|
+
notifier.Update(data, tag, group);
|
|
96
|
+
updateFailures = 0;
|
|
97
|
+
}
|
|
98
|
+
catch
|
|
99
|
+
{
|
|
100
|
+
updateFailures++;
|
|
101
|
+
if (updateFailures >= 3) break;
|
|
102
|
+
}
|
|
103
|
+
if (stop.Wait(ProgressTickMs)) break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ExpirationTime alone does not remove a visible banner on this Windows
|
|
107
|
+
// build (observed: a 3 s toast remained after the 4 s expiration while
|
|
108
|
+
// the helper had already exited). Let the bar finish, then hide the
|
|
109
|
+
// banner after a short grace. Hide() is the only supported way to
|
|
110
|
+
// retract it; do not call it while an activation callback is in flight.
|
|
111
|
+
if (!activationStarted.IsSet)
|
|
112
|
+
{
|
|
113
|
+
Thread.Sleep(RetractionGraceMs);
|
|
114
|
+
try { notifier.Hide(toast); } catch { /* best effort */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Keep the process alive for notification-center click callbacks until
|
|
118
|
+
// the notification expires. ExpirationTime still bounds retention.
|
|
119
|
+
if (!activationStarted.IsSet)
|
|
120
|
+
{
|
|
121
|
+
var expiration = toast.ExpirationTime;
|
|
122
|
+
var remainingRetentionMs = expiration.HasValue
|
|
123
|
+
? (expiration.Value - DateTimeOffset.Now).TotalMilliseconds
|
|
124
|
+
: 0;
|
|
125
|
+
if (remainingRetentionMs > 0)
|
|
126
|
+
{
|
|
127
|
+
var waitMs = (int)Math.Min(remainingRetentionMs, durationMs + RetentionGraceMs + 1000.0);
|
|
128
|
+
activationStarted.Wait(TimeSpan.FromMilliseconds(Math.Max(0, waitMs)));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (activationStarted.IsSet)
|
|
132
|
+
{
|
|
133
|
+
activationFinished.Wait(ActivationGraceMs);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private static void AddHandler<TArgs>(ToastNotification toast, string eventName, TypedEventHandler<ToastNotification, TArgs> handler)
|
|
138
|
+
{
|
|
139
|
+
try
|
|
140
|
+
{
|
|
141
|
+
var eventInfo = typeof(ToastNotification).GetEvent(eventName);
|
|
142
|
+
if (eventInfo == null) return;
|
|
143
|
+
eventInfo.GetAddMethod().Invoke(toast, new object[] { handler });
|
|
144
|
+
}
|
|
145
|
+
catch
|
|
146
|
+
{
|
|
147
|
+
// A missing event is not fatal; the process still exits after its
|
|
148
|
+
// bounded grace period.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private static string BuildXml(string title, string body, string progressLabel)
|
|
153
|
+
{
|
|
154
|
+
var document = new XmlDocument();
|
|
155
|
+
var toast = document.CreateElement("toast");
|
|
156
|
+
toast.SetAttribute("duration", "long");
|
|
157
|
+
var visual = document.CreateElement("visual");
|
|
158
|
+
var binding = document.CreateElement("binding");
|
|
159
|
+
binding.SetAttribute("template", "ToastGeneric");
|
|
160
|
+
var titleNode = document.CreateElement("text");
|
|
161
|
+
titleNode.InnerText = title ?? "";
|
|
162
|
+
binding.AppendChild(titleNode);
|
|
163
|
+
var bodyNode = document.CreateElement("text");
|
|
164
|
+
bodyNode.InnerText = body ?? "";
|
|
165
|
+
binding.AppendChild(bodyNode);
|
|
166
|
+
var progress = document.CreateElement("progress");
|
|
167
|
+
progress.SetAttribute("value", "{progressValue}");
|
|
168
|
+
// Keep the native bar only. Windows needs non-empty title/status on a
|
|
169
|
+
// ToastGeneric progress element; use spaces, and a zero-width override
|
|
170
|
+
// to hide the automatic percentage text.
|
|
171
|
+
progress.SetAttribute("title", " ");
|
|
172
|
+
progress.SetAttribute("status", string.IsNullOrWhiteSpace(progressLabel) ? "Auto close" : progressLabel);
|
|
173
|
+
progress.SetAttribute("valueStringOverride", "");
|
|
174
|
+
binding.AppendChild(progress);
|
|
175
|
+
visual.AppendChild(binding);
|
|
176
|
+
toast.AppendChild(visual);
|
|
177
|
+
var audio = document.CreateElement("audio");
|
|
178
|
+
audio.SetAttribute("silent", "true");
|
|
179
|
+
toast.AppendChild(audio);
|
|
180
|
+
document.AppendChild(toast);
|
|
181
|
+
return document.GetXml();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private static void PostActivation(string callbackUrl, string token, string sessionId, string notificationId)
|
|
185
|
+
{
|
|
186
|
+
try
|
|
187
|
+
{
|
|
188
|
+
var payload = "{\"token\":\"" + Escape(token) + "\",\"sessionId\":\"" + Escape(sessionId) + "\",\"notificationId\":\"" + Escape(notificationId) + "\"}";
|
|
189
|
+
using (var client = new WebClient())
|
|
190
|
+
{
|
|
191
|
+
client.Proxy = null;
|
|
192
|
+
client.Headers[HttpRequestHeader.ContentType] = "application/json";
|
|
193
|
+
client.UploadString(callbackUrl, "POST", payload);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch
|
|
197
|
+
{
|
|
198
|
+
// Click callback is best-effort; the Host still sees the native toast.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private static string Escape(string value)
|
|
203
|
+
{
|
|
204
|
+
return (value ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"");
|
|
205
|
+
}
|
|
206
|
+
}
|