dsh-workspace-plus 0.6.0 → 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/README.md +107 -572
- package/cordis.patch.yml +33 -1
- package/lib/client.js +448 -11
- package/lib/index.js +63 -6
- package/lib/notice/click-host.cs +515 -0
- package/lib/notice/index.js +114 -20
- package/lib/notice/launcher.js +455 -0
- package/lib/notice/win32.js +26 -2
- package/lib/sessionlog/icon.svg +19 -0
- package/lib/sessionlog/index.js +636 -0
- package/lib/sessionlog/locale/en.json +6 -0
- package/lib/sessionlog/locale/zh.json +6 -0
- package/lib/sessionlog/package.json +6 -0
- package/lib/sessionlog/redact.js +142 -0
- package/lib/sessionlog/settings.js +436 -0
- package/lib/sessionlog/wire.js +235 -0
- package/package.json +11 -3
|
@@ -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
|
+
}
|