@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,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
+ }