dsh-workspace-plus 0.6.1 → 0.7.0

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/lib/index.js CHANGED
@@ -12,7 +12,9 @@
12
12
  * agent sees the mapping without spending a tool call
13
13
  * 3. a `workspace_dirs` Tool the agent uses to inspect or edit the mapping
14
14
  * when the user asks in natural language
15
- * 4. a small JSON HTTP route the browser half uses to manage the same store
15
+ * 4. a small JSON HTTP route the browser half uses to manage the same store —
16
+ * every request must pass the Host's `connection.admit` check, and the
17
+ * route refuses (503) when that service is unavailable
16
18
  *
17
19
  * The whole point is routing by label instead of by guesswork: the injected
18
20
  * table gives absolute paths, so a similar-looking directory name elsewhere on
@@ -362,6 +364,55 @@ export function validateLabel(label) {
362
364
  return { ok: true, label: text }
363
365
  }
364
366
 
367
+ /**
368
+ * Run the Host's browser trust check for one plugin request.
369
+ *
370
+ * `connection.admit(req)` is the only authority a plugin route has: the Host
371
+ * fence plus the GUI session cookie. This route both publishes the mapping and
372
+ * lets the caller rewrite it, and whatever it stores is rendered into every
373
+ * request's model context — so it fails closed when that authority is absent
374
+ * instead of falling through to "admitted":
375
+ *
376
+ * - no `connection` service mounted, or a service without `admit` — the host
377
+ * composition has no auth layer (or its API drifted): refuse with 503;
378
+ * - `admit` throwing, rejecting, or failing to answer: the decision is
379
+ * unavailable, so refuse with 503;
380
+ * - a rejection from `admit`: pass its 401 through, everything else is 403.
381
+ *
382
+ * The result is awaited, so a synchronous or an asynchronous `admit` is honored
383
+ * the same way — but only an answer that actually carries no rejection counts
384
+ * as an admission.
385
+ *
386
+ * The notice sub-feature deliberately grants a loopback exemption. This route
387
+ * does not: an unauthenticated writer could otherwise plant arbitrary text in
388
+ * the agent's prompt through a directory note.
389
+ *
390
+ * @param connection - the resolved `connection` service, or undefined.
391
+ * @param req - incoming request.
392
+ * @returns a promise of `{ ok: true }` or `{ ok: false, status, message }`.
393
+ */
394
+ export async function checkAdmission(connection, req) {
395
+ const unavailable = (message) => ({ ok: false, status: 503, message })
396
+ if (connection === undefined || connection === null) {
397
+ return unavailable('host authentication service is not mounted; refusing to answer')
398
+ }
399
+ if (typeof connection.admit !== 'function') {
400
+ return unavailable('host authentication service has no admit(); refusing to answer')
401
+ }
402
+ let admission
403
+ try {
404
+ admission = await connection.admit(req)
405
+ } catch (error) {
406
+ return unavailable(`host authentication service failed: ${String(error?.message ?? error)}`)
407
+ }
408
+ const rejection = admission !== null && typeof admission === 'object' ? admission.rejection : undefined
409
+ if (rejection !== undefined) {
410
+ const status = rejection === 401 ? 401 : 403
411
+ return { ok: false, status, message: status === 401 ? 'unauthorized' : 'forbidden' }
412
+ }
413
+ return { ok: true }
414
+ }
415
+
365
416
  /**
366
417
  * Apply one store operation. Every branch validates first and returns a
367
418
  * discriminated result, so both the Tool and the HTTP route share one behavior
@@ -709,11 +760,17 @@ export function apply(ctx) {
709
760
  kind: 'exact',
710
761
  path: API_PATH,
711
762
  handler: async (req, res) => {
712
- const connection = ctx.get('connection')
713
- const admission = connection?.admit?.(req)
714
- if (admission !== undefined && 'rejection' in admission) {
715
- res.writeHead(admission.rejection)
716
- res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
763
+ // Fail closed. A request that cannot be admitted by the Host's
764
+ // connection service gets no answer at all, read or write: the
765
+ // mapping this route returns — and the notes it stores — become
766
+ // model context, so an unauthenticated caller must never reach it.
767
+ const admission = await checkAdmission(ctx.get('connection'), req)
768
+ if (!admission.ok) {
769
+ res.writeHead(admission.status, {
770
+ 'content-type': 'application/json; charset=utf-8',
771
+ 'cache-control': 'no-store',
772
+ })
773
+ res.end(JSON.stringify({ ok: false, message: admission.message }))
717
774
  return
718
775
  }
719
776
  const send = (status, payload) => {
@@ -0,0 +1,515 @@
1
+ // dsh-notice click host: one windowless activation of a toast click.
2
+ //
3
+ // Windows resolves a protocol activation to the command line this plugin writes
4
+ // under HKCU\Software\Classes\<scheme>\shell\open\command, and that command used
5
+ // to run powershell.exe -WindowStyle Hidden. On a machine where Windows Terminal
6
+ // is the default terminal host, -WindowStyle Hidden hides the PowerShell window
7
+ // but NOT the terminal host Windows spawns for it, so every click flashed a full
8
+ // Windows Terminal window for two to four seconds. A GUI-subsystem executable has
9
+ // no console at all, so there is nothing to flash in the first place.
10
+ //
11
+ // The work is deliberately split into two phases:
12
+ //
13
+ // 1. HTTP FIRST. The activated URL is forwarded to the running host's loopback
14
+ // route. The host owns the protocol bookkeeping; this helper is only a
15
+ // courier and never interprets the URL.
16
+ // 2. FOCUS SECOND. Showing and foregrounding the desktop window makes the page
17
+ // fire its focus event, and the page answers that event by polling for the
18
+ // intent phase 1 just recorded. Doing this in the other order would race:
19
+ // the page could poll before the intent exists and open nothing.
20
+ //
21
+ // A click must never do nothing, so every way of failing ends in either a focus
22
+ // attempt or the application's own dsh://open activation.
23
+ //
24
+ // Build (no project file and no NuGet; the .NET Framework 4.0 compiler is enough,
25
+ // which is why this file stays C# 5 compatible and pure ASCII):
26
+ //
27
+ // csc.exe /nologo /target:winexe /optimize+ /out:click-host.exe click-host.cs
28
+ //
29
+ // Usage:
30
+ //
31
+ // click-host.exe <port> <token> <focus:0|1> "<activated-url>"
32
+ // click-host.exe --selftest (exit code 0; used by the build step)
33
+ //
34
+ // Exit codes: 0 for every normal activation (the fallbacks are part of success),
35
+ // 2 for a malformed command line.
36
+
37
+ using System;
38
+ using System.Collections.Generic;
39
+ using System.Diagnostics;
40
+ using System.IO;
41
+ using System.Net;
42
+ using System.Runtime.InteropServices;
43
+ using System.Text;
44
+ using System.Threading;
45
+
46
+ internal static class ClickHost
47
+ {
48
+ // --- window constants ----------------------------------------------------
49
+ //
50
+ // The only window-state change this helper is allowed to make is restoring a
51
+ // minimized window: SW_RESTORE returns it to the state it had before it was
52
+ // minimized, which may well be maximized.
53
+ //
54
+ // Deliberately absent: SW_SHOWNORMAL (1) and SW_SHOW (5). Both were measured
55
+ // live to un-maximize this Chromium window when called on an already-visible
56
+ // window: SW_SHOWNORMAL forces normal size, and even SW_SHOW (documented as
57
+ // "shows the window in its current state") reset a maximized window from
58
+ // 1550x878 to 1295x800 in the compiled helper while the same call from
59
+ // PowerShell P/Invoke did not. A click must never touch the placement of a
60
+ // window the user can already see, so any ShowWindow call other than
61
+ // SW_RESTORE for an iconic window is forbidden. A hidden window is made
62
+ // visible with SetWindowPos(SWP_SHOWWINDOW) instead, which sets WS_VISIBLE
63
+ // without going through ShowWindow's placement logic.
64
+ private const int SW_RESTORE = 9;
65
+
66
+ private const uint SWP_NOSIZE = 0x0001;
67
+ private const uint SWP_NOMOVE = 0x0002;
68
+ private const uint SWP_SHOWWINDOW = 0x0040;
69
+
70
+ /// <summary>Process name the desktop application ships under.</summary>
71
+ private const string TargetProcessName = "DeepSeek Harness";
72
+
73
+ /// <summary>
74
+ /// Chromium's top-level window class, the one that carries the actual UI.
75
+ /// </summary>
76
+ private const string TargetWindowClass = "Chrome_WidgetWin_1";
77
+
78
+ // --- time budget ---------------------------------------------------------
79
+ //
80
+ // A click is a UI gesture: waiting seconds for it is already a failure. The
81
+ // whole activation is therefore bounded, and every wait below draws from the
82
+ // same remaining budget rather than adding its own timeout.
83
+
84
+ private const int TotalBudgetMs = 4000;
85
+ private const int HttpTimeoutMs = 2500;
86
+ private const int ForegroundWaitMs = 800;
87
+ private const int ShowFallbackWaitMs = 1500;
88
+
89
+ // --- win32 ---------------------------------------------------------------
90
+
91
+ private delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
92
+
93
+ [DllImport("user32.dll")]
94
+ private static extern bool EnumWindows(EnumProc callback, IntPtr lParam);
95
+
96
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
97
+ private static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
98
+
99
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
100
+ private static extern int GetClassName(IntPtr hWnd, StringBuilder text, int count);
101
+
102
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
103
+ private static extern int GetWindowTextLength(IntPtr hWnd);
104
+
105
+ [DllImport("user32.dll")]
106
+ private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
107
+
108
+ [DllImport("user32.dll")]
109
+ private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr ignored);
110
+
111
+ [DllImport("user32.dll")]
112
+ private static extern bool IsWindowVisible(IntPtr hWnd);
113
+
114
+ [DllImport("user32.dll")]
115
+ private static extern bool IsIconic(IntPtr hWnd);
116
+
117
+ [DllImport("user32.dll")]
118
+ private static extern bool ShowWindow(IntPtr hWnd, int command);
119
+
120
+ [DllImport("user32.dll")]
121
+ private static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
122
+
123
+ [DllImport("user32.dll")]
124
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
125
+
126
+ [DllImport("user32.dll")]
127
+ private static extern bool BringWindowToTop(IntPtr hWnd);
128
+
129
+ [DllImport("user32.dll")]
130
+ private static extern bool AttachThreadInput(uint attach, uint attachTo, bool attachFlag);
131
+
132
+ [DllImport("user32.dll")]
133
+ private static extern IntPtr GetForegroundWindow();
134
+
135
+ [DllImport("kernel32.dll")]
136
+ private static extern uint GetCurrentThreadId();
137
+
138
+ /// <summary>Diagnostic log target; empty unless DSH_NOTICE_CLICK_LOG is set.</summary>
139
+ private static string logPath = "";
140
+
141
+ /// <summary>Activation-relative clock used to keep the whole click bounded.</summary>
142
+ private static readonly Stopwatch Clock = Stopwatch.StartNew();
143
+
144
+ /// <summary>
145
+ /// Entry point.
146
+ /// </summary>
147
+ /// <param name="args">port, token, focus flag and the activated URL.</param>
148
+ /// <returns>0 for a normal activation, 2 for a malformed command line.</returns>
149
+ public static int Main(string[] args)
150
+ {
151
+ // The build step runs the freshly compiled binary once to prove it starts
152
+ // and exits cleanly. There is nothing to activate in that mode, and it
153
+ // must not need a host or a window.
154
+ if (args.Length == 1 && string.Equals(args[0], "--selftest", StringComparison.Ordinal)) return 0;
155
+
156
+ logPath = Environment.GetEnvironmentVariable("DSH_NOTICE_CLICK_LOG") ?? "";
157
+
158
+ int port;
159
+ if (args.Length < 4 || !int.TryParse(args[0], out port) || port < 1 || port > 65535) return 2;
160
+
161
+ string token = args[1];
162
+ bool focus = string.Equals(args[2], "1", StringComparison.Ordinal);
163
+ string url = args[3];
164
+ Log("start focus=" + (focus ? "1" : "0") + " port=" + port);
165
+
166
+ // Phase 1: hand the activated URL to the host. The host owns the token
167
+ // check and the session bookkeeping; this process never interprets it.
168
+ bool delivered = Deliver(port, token, url);
169
+ Log("http " + (delivered ? "ok" : "fail"));
170
+
171
+ if (!focus)
172
+ {
173
+ // A "record only" activation (focus=0) has nothing else to do. It
174
+ // still exits 0 when the HTTP request failed: the click has already
175
+ // been recorded as best it can be, and nothing visible is expected.
176
+ Log("done");
177
+ return 0;
178
+ }
179
+
180
+ // Phase 2: show and foreground the desktop window.
181
+ IntPtr hwnd = FindWindow();
182
+ bool focused = false;
183
+ if (hwnd != IntPtr.Zero)
184
+ {
185
+ focused = ForceForeground(hwnd);
186
+ Log("focus " + (focused ? "ok" : "fail"));
187
+ }
188
+ else
189
+ {
190
+ Log("window not found");
191
+ }
192
+
193
+ // Never do nothing. When the host did not answer, or no window could be
194
+ // found at all, ask the application itself through its own protocol: its
195
+ // single-instance path shows the primary window and focuses it.
196
+ if (!delivered || hwnd == IntPtr.Zero)
197
+ {
198
+ Log("fallback dsh://open");
199
+ ShellOpen("dsh://open");
200
+ }
201
+
202
+ Log("done");
203
+ return 0;
204
+ }
205
+
206
+ /// <summary>
207
+ /// Forward the activated URL to the host's loopback route.
208
+ /// </summary>
209
+ /// <remarks>
210
+ /// Both the token and the URL are escaped with Uri.EscapeDataString: the URL
211
+ /// carries its own query string, and an unescaped ampersand would end the
212
+ /// outer query value and silently change what the host parses.
213
+ ///
214
+ /// The proxy is disabled outright. A machine-wide proxy must never be asked
215
+ /// to resolve 127.0.0.1, and an unreachable proxy would delay a click for
216
+ /// as long as its connect timeout.
217
+ ///
218
+ /// Any HTTP answer counts as delivery: a 403 for a stale token or a 400 for a
219
+ /// malformed URL still proves the host is alive and listening, which is what
220
+ /// decides whether the dsh://open fallback below is needed.
221
+ /// </remarks>
222
+ /// <param name="port">Loopback port the host listens on.</param>
223
+ /// <param name="token">Per-process secret the host issued.</param>
224
+ /// <param name="url">Activated protocol URL, forwarded verbatim.</param>
225
+ /// <returns>true when the host answered.</returns>
226
+ private static bool Deliver(int port, string token, string url)
227
+ {
228
+ try
229
+ {
230
+ string endpoint = "http://127.0.0.1:" + port + "/dsh-notice/api/open?token=" +
231
+ Uri.EscapeDataString(token) + "&url=" + Uri.EscapeDataString(url);
232
+
233
+ HttpWebRequest request = (HttpWebRequest)WebRequest.Create(endpoint);
234
+ request.Proxy = null;
235
+ request.Method = "GET";
236
+ request.KeepAlive = false;
237
+ request.Timeout = HttpTimeoutMs;
238
+ request.ReadWriteTimeout = HttpTimeoutMs;
239
+
240
+ try
241
+ {
242
+ using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
243
+ {
244
+ // The body is an ack, not data this helper needs. Disposing
245
+ // the response is what releases the connection.
246
+ return true;
247
+ }
248
+ }
249
+ catch (WebException error)
250
+ {
251
+ if (error.Response != null)
252
+ {
253
+ error.Response.Close();
254
+ return true;
255
+ }
256
+ Log("http error " + error.Message);
257
+ return false;
258
+ }
259
+ }
260
+ catch (Exception error)
261
+ {
262
+ // A helper that throws here would leave the click half done, so every
263
+ // failure is reported as "not delivered" rather than propagated.
264
+ Log("http exception " + error.Message);
265
+ return false;
266
+ }
267
+ }
268
+
269
+ /// <summary>
270
+ /// The desktop application's primary window, or IntPtr.Zero.
271
+ /// </summary>
272
+ /// <remarks>
273
+ /// Several DeepSeek Harness windows exist at once: Chromium's hidden GPU and
274
+ /// message windows, the IME windows, and the real top-level window. The
275
+ /// filter accepts only a titled window of the target process on a plausible
276
+ /// class, and scores the survivors so the visible Chrome_WidgetWin_1 window
277
+ /// with the longest title wins even when a hidden sibling is enumerated too.
278
+ /// </remarks>
279
+ /// <returns>The best matching window handle.</returns>
280
+ private static IntPtr FindWindow()
281
+ {
282
+ IntPtr best = IntPtr.Zero;
283
+ int bestScore = int.MinValue;
284
+ Dictionary<uint, bool> processCache = new Dictionary<uint, bool>();
285
+
286
+ EnumWindows(delegate(IntPtr hwnd, IntPtr lParam)
287
+ {
288
+ if (GetWindowTextLength(hwnd) <= 0) return true;
289
+
290
+ string className = ClassOf(hwnd);
291
+ if (className == "IME" || className == "MSCTFIME UI" || className == "Base_PowerMessageWindow") return true;
292
+ // Chromium creates Chrome_WidgetWin_0 for message-only windows; it is
293
+ // never the window the user means.
294
+ if (className.StartsWith("Chrome_WidgetWin_0", StringComparison.Ordinal)) return true;
295
+
296
+ string title = TextOf(hwnd);
297
+ if (title.Length == 0 || title == "Default IME" || title == "MSCTFIME UI") return true;
298
+
299
+ uint processId;
300
+ GetWindowThreadProcessId(hwnd, out processId);
301
+ if (!IsTargetProcess(processId, processCache)) return true;
302
+
303
+ int score = (IsWindowVisible(hwnd) ? 1000 : 0) +
304
+ (className == TargetWindowClass ? 500 : 0) +
305
+ Math.Min(title.Length, 300);
306
+ if (score > bestScore)
307
+ {
308
+ bestScore = score;
309
+ best = hwnd;
310
+ }
311
+ return true;
312
+ }, IntPtr.Zero);
313
+
314
+ return best;
315
+ }
316
+
317
+ /// <summary>
318
+ /// Whether one process id belongs to the desktop application.
319
+ /// </summary>
320
+ /// <remarks>
321
+ /// The answer is cached per enumeration because the process name is more
322
+ /// expensive to fetch than the window data, and one process usually owns
323
+ /// several of the windows being examined.
324
+ /// </remarks>
325
+ private static bool IsTargetProcess(uint processId, Dictionary<uint, bool> cache)
326
+ {
327
+ bool cached;
328
+ if (cache.TryGetValue(processId, out cached)) return cached;
329
+
330
+ bool match = false;
331
+ try
332
+ {
333
+ match = string.Equals(Process.GetProcessById((int)processId).ProcessName, TargetProcessName, StringComparison.Ordinal);
334
+ }
335
+ catch
336
+ {
337
+ // The process ended while its windows were being enumerated.
338
+ match = false;
339
+ }
340
+ cache[processId] = match;
341
+ return match;
342
+ }
343
+
344
+ /// <summary>Read one window's class name.</summary>
345
+ private static string ClassOf(IntPtr hwnd)
346
+ {
347
+ StringBuilder text = new StringBuilder(256);
348
+ GetClassName(hwnd, text, text.Capacity);
349
+ return text.ToString();
350
+ }
351
+
352
+ /// <summary>Read one window's title.</summary>
353
+ private static string TextOf(IntPtr hwnd)
354
+ {
355
+ int length = GetWindowTextLength(hwnd);
356
+ if (length <= 0) return "";
357
+ StringBuilder text = new StringBuilder(length + 2);
358
+ GetWindowText(hwnd, text, text.Capacity);
359
+ return text.ToString();
360
+ }
361
+
362
+ /// <summary>
363
+ /// Show the window only when it is minimized or hidden, then focus it.
364
+ /// </summary>
365
+ /// <remarks>
366
+ /// A click must never change the placement of a window the user can already
367
+ /// see. The only ShowWindow call allowed here is SW_RESTORE, and only for an
368
+ /// iconic window: it returns the window to the state it had before it was
369
+ /// minimized (maximized included). Calling SW_SHOW - which is documented as
370
+ /// merely making a window visible - on an already-visible Chromium window was
371
+ /// measured live to un-maximize it (1550x878 -> 1295x800), so a visible window
372
+ /// is left completely untouched and only the foreground calls below raise it.
373
+ ///
374
+ /// A window closed to the tray has WS_VISIBLE cleared, which ShowWindow must
375
+ /// not be asked to fix for the same reason. SetWindowPos with SWP_SHOWWINDOW
376
+ /// sets the visibility bit while leaving WS_MAXIMIZE and the window rectangle
377
+ /// alone, so a maximized window comes back maximized; only if it is also
378
+ /// iconic is it restored. Chromium then re-hides a window whose visibility the
379
+ /// application believes is false, so if the window is still invisible after a
380
+ /// short settle, the application is asked to show it through its own
381
+ /// dsh://open activation and given a moment to comply.
382
+ ///
383
+ /// Foregrounding is a separate problem. Windows only lets the current
384
+ /// foreground process (or one that shares its input queue) call
385
+ /// SetForegroundWindow successfully, so this thread attaches to the foreground
386
+ /// thread first. The attachment is always undone, even on the failure path.
387
+ /// </remarks>
388
+ /// <param name="hwnd">The window to raise.</param>
389
+ /// <returns>true when the window ended up in the foreground.</returns>
390
+ private static bool ForceForeground(IntPtr hwnd)
391
+ {
392
+ // Restore only a minimized window, exactly once. Nothing else may touch a
393
+ // window that is already visible: the click must not resize or re-place it.
394
+ if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
395
+
396
+ if (!IsWindowVisible(hwnd))
397
+ {
398
+ // SWP_SHOWWINDOW is the visibility fix for a tray-hidden window: it
399
+ // sets WS_VISIBLE without ShowWindow's placement logic, so a maximized
400
+ // window stays maximized. A hidden window that is also iconic still
401
+ // needs SW_RESTORE to come back to its pre-minimize state.
402
+ SetWindowPos(hwnd, IntPtr.Zero, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
403
+ if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
404
+
405
+ // The visibility bit and the paint can land one frame apart; asking
406
+ // the application for help immediately would waste the cheap path.
407
+ Stopwatch settle = Stopwatch.StartNew();
408
+ while (settle.ElapsedMilliseconds < 300 && Remaining() > 0 && !IsWindowVisible(hwnd)) Thread.Sleep(20);
409
+ }
410
+
411
+ if (!IsWindowVisible(hwnd) && Remaining() > 0)
412
+ {
413
+ Log("wake dsh://open");
414
+ ShellOpen("dsh://open");
415
+ WaitVisible(hwnd, Math.Min(ShowFallbackWaitMs, Remaining()));
416
+ }
417
+
418
+ // Final settle before the foreground attempt: restore only an iconic
419
+ // window. There is deliberately no else/show call here - a visible window
420
+ // must be left exactly as it is, and BringWindowToTop/SetForegroundWindow
421
+ // below need no show call to raise it.
422
+ if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
423
+
424
+ IntPtr foreground = GetForegroundWindow();
425
+ uint foregroundThread = foreground == IntPtr.Zero ? 0 : GetWindowThreadProcessId(foreground, IntPtr.Zero);
426
+ uint currentThread = GetCurrentThreadId();
427
+ bool attached = foregroundThread != 0 && foregroundThread != currentThread &&
428
+ AttachThreadInput(currentThread, foregroundThread, true);
429
+
430
+ try
431
+ {
432
+ BringWindowToTop(hwnd);
433
+ SetForegroundWindow(hwnd);
434
+
435
+ int limit = Math.Min(ForegroundWaitMs, Remaining());
436
+ Stopwatch wait = Stopwatch.StartNew();
437
+ while (wait.ElapsedMilliseconds < limit)
438
+ {
439
+ if (GetForegroundWindow() == hwnd) return true;
440
+ // The foreground lock can bounce the first call; retrying while
441
+ // still attached is what usually wins on the second attempt.
442
+ if (wait.ElapsedMilliseconds > 200)
443
+ {
444
+ BringWindowToTop(hwnd);
445
+ SetForegroundWindow(hwnd);
446
+ }
447
+ Thread.Sleep(10);
448
+ }
449
+ return GetForegroundWindow() == hwnd;
450
+ }
451
+ finally
452
+ {
453
+ if (attached) AttachThreadInput(currentThread, foregroundThread, false);
454
+ }
455
+ }
456
+
457
+ /// <summary>Wait until a window reports itself visible, within a bound.</summary>
458
+ private static void WaitVisible(IntPtr hwnd, int limitMs)
459
+ {
460
+ Stopwatch wait = Stopwatch.StartNew();
461
+ while (wait.ElapsedMilliseconds < limitMs)
462
+ {
463
+ if (IsWindowVisible(hwnd)) return;
464
+ Thread.Sleep(50);
465
+ }
466
+ }
467
+
468
+ /// <summary>Milliseconds left in the whole activation's budget.</summary>
469
+ private static int Remaining()
470
+ {
471
+ int left = TotalBudgetMs - (int)Clock.ElapsedMilliseconds;
472
+ return left < 0 ? 0 : left;
473
+ }
474
+
475
+ /// <summary>Activate a URL through the shell, as Explorer would.</summary>
476
+ /// <remarks>
477
+ /// Failures are swallowed on purpose: this is the last resort, and there is no
478
+ /// window left to report to. The diagnostic log is the only recipient.
479
+ /// </remarks>
480
+ private static void ShellOpen(string target)
481
+ {
482
+ try
483
+ {
484
+ ProcessStartInfo info = new ProcessStartInfo();
485
+ info.FileName = target;
486
+ info.UseShellExecute = true;
487
+ Process.Start(info);
488
+ }
489
+ catch (Exception error)
490
+ {
491
+ Log("shellopen fail " + error.Message);
492
+ }
493
+ }
494
+
495
+ /// <summary>
496
+ /// Append one timestamped diagnostic line when DSH_NOTICE_CLICK_LOG is set.
497
+ /// </summary>
498
+ /// <remarks>
499
+ /// Diagnostics are opt-in and best effort. A click must behave exactly the
500
+ /// same when the log cannot be written, so every failure is ignored here.
501
+ /// </remarks>
502
+ private static void Log(string message)
503
+ {
504
+ if (logPath.Length == 0) return;
505
+ try
506
+ {
507
+ string line = DateTime.Now.ToString("HH:mm:ss.fff") + " " + message + Environment.NewLine;
508
+ File.AppendAllText(logPath, line, Encoding.UTF8);
509
+ }
510
+ catch
511
+ {
512
+ // Intentionally empty: logging must never change the click outcome.
513
+ }
514
+ }
515
+ }