dsh-mini 0.1.3 → 0.1.5

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 CHANGED
@@ -105,7 +105,20 @@ PNG → base64 → `postMessage` 给页面 → 走 Lexical 粘贴事件塞进输
105
105
  数据目录固定是 `%LOCALAPPDATA%\dsh-hud\`(`config.ini`、`window.txt`、日志、WebView2 用户数据),
106
106
  改名前后没动过,装到别的机器上也是这个路径、按用户隔离。
107
107
 
108
- ## 安装(本机已做)
108
+ ## 安装
109
+
110
+ ### A. 从 npm 装(别人用这个)
111
+
112
+ ```bash
113
+ cd ~/.dsh/profiles/desktop
114
+ npm install dsh-mini
115
+ ```
116
+
117
+ 然后在同一个 `package.json` 的 `dsh.profile.bundles` 末尾加一项 `"dsh-mini"`,重启 DSH(或 `plugin_manager set_bundle` 关一次再开一次)。
118
+
119
+ 装完不需要额外配置:插件会在 DSH 启动 2.5 秒后自己拉起常驻服务(划词药丸 + 全局热键 + 托盘),小窗由标题栏的「弹出此窗口」按需打开。**前提是机器上已有 WebView2 Runtime**(见上面「系统要求」)。
120
+
121
+ ### B. 本机 link(改代码时用)
109
122
 
110
123
  profile 走 `link:` 依赖,和 `tju-plugin` 一样:
111
124
 
@@ -164,14 +177,17 @@ cd app
164
177
  在**任何程序**里用鼠标选中一段文字,松开后光标右下方会弹出蓝色小药丸「问大肥鱼」;点它,
165
178
  小窗自动弹到最前,选中的内容以引用块(每行 `> ` 前缀)插进输入框,直接接着提问即可。
166
179
 
167
- * 实现在 `app/SelectionWatcher.cs`:全局鼠标钩子(90ms 轮询)→ 松开时若拖动距离 ≥6 逻辑像素、
180
+ * 实现在 `app/SelectionWatcher.cs`:全局鼠标轮询(90ms)→ 松开时若拖动距离 ≥6 逻辑像素、
168
181
  且不在小窗/药丸自己身上 → 后台线程等 130ms(双击选词等 320ms)→ 校验仍在前台 → 取选区。
169
- * Windows 没有「给我当前选区」的 API,所以取选区是:备份剪贴板 → 发一次 `Ctrl+C` →
170
- 轮询 `GetClipboardSequenceNumber()` 变化(最多 900ms)→ 读文本 → **还原剪贴板**。
171
- 用序列号而不是「文本变了没」,重复选同一段也能识别。
172
- * 三处保护:终端窗口(console/cascadia/mintty/putty)**不发** `Ctrl+C`(会变 SIGINT);
173
- 资源管理器/桌面/任务栏(Progman/WorkerW/CabinetWClass…)直接跳过(否则会真的复制文件、
174
- 污染 `Win+V` 剪贴板历史);用户已切窗就不发。
182
+ * **取选区走系统辅助功能(UIA `TextPattern`),不碰键盘也不碰剪贴板**:拿到文本的同时还能拿到
183
+ 选区最后一行/一个字的矩形,药丸才能精确贴在它右下角。绝大多数现代程序(Notepad、Chrome/Edge、
184
+ Word、WPS、VS Code、DSH 自己)都支持。
185
+ * 只有用户显式打开设置里的**兼容模式**,UIA 读不到时才补一次读剪贴板:先给焦点控件发 `WM_COPY`
186
+ (不合成按键),仍不行才真的发一次 `Ctrl+C`。**任何情况下都不再做「备份 → 复制 → 还原」**——
187
+ 之前那套会把你自己的 `Ctrl+C` 覆盖掉,这正是"选中后复制不上"的元凶。
188
+ * 保护:按住 Ctrl/Alt/Shift 时的拖动直接跳过(那是复制/扩选,不是重新选词);终端
189
+ (console/cascadia/mintty/putty)不发按键;资源管理器/桌面/任务栏(Progman/WorkerW/CabinetWClass…)
190
+ 一律跳过(否则会真的复制文件、污染 `Win+V` 历史);用户按着修饰键期间完全不介入。
175
191
  * 药丸 9 秒无操作自动消失;托盘菜单「划词问大肥鱼」可随时关掉,状态存在
176
192
  `%LOCALAPPDATA%\dsh-hud\ask.txt`。
177
193
  * 因此**标题条 ✕ 只是收进托盘**(不是退出)——watcher 活在这个进程里,退出了就没法划词。
@@ -209,6 +225,8 @@ DSH小窗.exe --service --url <tokenized> # Mutex Local\DshHudService,单
209
225
  主窗「设置 → DSH 小窗」:
210
226
 
211
227
  * 截图自动复制到剪贴板(默认开,整屏/框选都复制);
228
+ * 兼容模式:读不到选区时模拟 `Ctrl+C`(**默认关**。关着时只用系统辅助功能读选区,
229
+ 完全不碰剪贴板、不发按键;打开后老式控件才读得到,代价是可能和自己按的 `Ctrl+C` 抢剪贴板);
212
230
  * 全屏截图快捷键、框选截图快捷键(失焦即保存)。
213
231
 
214
232
  配置落在 `%LOCALAPPDATA%\dsh-hud\config.ini`(`key=value`,C# 壳、宿主插件、客户端三边都读),
package/app/HudWindow.cs CHANGED
@@ -228,6 +228,7 @@ namespace DshHud
228
228
 
229
229
  config = HudConfig.Load();
230
230
  configStamp = HudConfig.Stamp();
231
+ SelectionReader.ClipboardFallback = config.TextFallback;
231
232
  }
232
233
 
233
234
  private Size Logical(int w, int h)
@@ -567,7 +568,9 @@ namespace DshHud
567
568
  {
568
569
  configStamp = stamp;
569
570
  config = HudConfig.Load();
570
- Log("config reloaded copyShot=" + config.CopyShot);
571
+ SelectionReader.ClipboardFallback = config.TextFallback;
572
+ Log("config reloaded copyShot=" + config.CopyShot +
573
+ " textFallback=" + (config.TextFallback ? "1" : "0"));
571
574
  }
572
575
 
573
576
  string quote = HudConfig.QuotePath;
@@ -52,15 +52,92 @@ namespace DshHud
52
52
 
53
53
  internal const uint GA_ROOT = 2;
54
54
  internal const int VK_LBUTTON = 0x01;
55
- internal const byte VK_CONTROL = 0x11;
55
+ internal const int VK_SHIFT = 0x10;
56
+ internal const int VK_CONTROL = 0x11;
57
+ internal const int VK_MENU = 0x12;
56
58
  internal const byte VK_C = 0x43;
57
59
  internal const uint KEYEVENTF_KEYUP = 0x0002;
58
60
 
61
+ [StructLayout(LayoutKind.Sequential)]
62
+ internal struct GUITHREADINFO
63
+ {
64
+ public int cbSize;
65
+ public uint flags;
66
+ public IntPtr hwndActive;
67
+ public IntPtr hwndFocus;
68
+ public IntPtr hwndCapture;
69
+ public IntPtr hwndMenuOwner;
70
+ public IntPtr hwndMoveSize;
71
+ public IntPtr hwndCaret;
72
+ public RECT rcCaret;
73
+ }
74
+
75
+ [StructLayout(LayoutKind.Sequential)]
76
+ internal struct RECT
77
+ {
78
+ public int left;
79
+ public int top;
80
+ public int right;
81
+ public int bottom;
82
+ }
83
+
84
+ [DllImport("user32.dll")]
85
+ internal static extern bool GetGUIThreadInfo(uint idThread, ref GUITHREADINFO info);
86
+
87
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
88
+ internal static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, IntPtr wParam,
89
+ IntPtr lParam, uint flags, uint timeout, out IntPtr result);
90
+
91
+ internal const uint WM_COPY = 0x0301;
92
+ internal const uint SMTO_ABORTIFHUNG = 0x0002;
93
+
59
94
  internal static bool IsMouseDown()
60
95
  {
61
96
  return (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0;
62
97
  }
63
98
 
99
+ internal static bool IsKeyDown(int vk)
100
+ {
101
+ return (GetAsyncKeyState(vk) & 0x8000) != 0;
102
+ }
103
+
104
+ /// <summary>
105
+ /// True while the user holds Ctrl/Alt/Shift himself. Every probe is skipped then:
106
+ /// injecting our own Ctrl+C on top of a real one is what made the user's copies
107
+ /// land empty, and it can also cancel a drag-copy in Explorer.
108
+ /// </summary>
109
+ internal static bool IsModifierDown()
110
+ {
111
+ return IsKeyDown(VK_CONTROL) || IsKeyDown(VK_MENU) || IsKeyDown(VK_SHIFT);
112
+ }
113
+
114
+ /// <summary>
115
+ /// The control that currently has the keyboard focus. WM_COPY has to be delivered to
116
+ /// the edit control, not the top-level window.
117
+ /// </summary>
118
+ internal static IntPtr FocusedControl()
119
+ {
120
+ try
121
+ {
122
+ IntPtr fg = GetForegroundWindow();
123
+ if (fg == IntPtr.Zero) return IntPtr.Zero;
124
+ uint pid;
125
+ uint tid = GetWindowThreadProcessId(fg, out pid);
126
+ GUITHREADINFO gti = new GUITHREADINFO();
127
+ gti.cbSize = Marshal.SizeOf(typeof(GUITHREADINFO));
128
+ if (GetGUIThreadInfo(tid, ref gti))
129
+ {
130
+ if (gti.hwndFocus != IntPtr.Zero) return gti.hwndFocus;
131
+ if (gti.hwndActive != IntPtr.Zero) return gti.hwndActive;
132
+ }
133
+ return fg;
134
+ }
135
+ catch
136
+ {
137
+ return IntPtr.Zero;
138
+ }
139
+ }
140
+
64
141
  /// <summary>
65
142
  /// Terminals map Ctrl+C to an interrupt rather than to "copy", so the clipboard
66
143
  /// fallback must never fire over one of them.
@@ -187,6 +264,28 @@ namespace DshHud
187
264
  return false;
188
265
  }
189
266
 
267
+ /// <summary>
268
+ /// Ask the focused control to copy through WM_COPY. This is the polite fallback:
269
+ /// no keystrokes are synthesised, so a real Ctrl+C pressed by the user is never
270
+ /// raced or cancelled, and nothing is written back over the clipboard.
271
+ /// </summary>
272
+ internal static bool SendCopyMessage()
273
+ {
274
+ try
275
+ {
276
+ IntPtr target = FocusedControl();
277
+ if (target == IntPtr.Zero) return false;
278
+ IntPtr result;
279
+ SendMessageTimeout(target, WM_COPY, IntPtr.Zero, IntPtr.Zero,
280
+ SMTO_ABORTIFHUNG, 300, out result);
281
+ return true;
282
+ }
283
+ catch
284
+ {
285
+ return false;
286
+ }
287
+ }
288
+
190
289
  internal static void SendCopy()
191
290
  {
192
291
  keybd_event(VK_CONTROL, 0, 0, UIntPtr.Zero);
@@ -218,6 +317,14 @@ namespace DshHud
218
317
 
219
318
  internal static class SelectionReader
220
319
  {
320
+ /// <summary>
321
+ /// Off by default. UIA answers for every modern text surface without touching the
322
+ /// keyboard or the clipboard; the copy-based path is only needed by legacy controls,
323
+ /// and it necessarily competes with the user's own Ctrl+C. Users can enable it from
324
+ /// the settings page.
325
+ /// </summary>
326
+ internal static bool ClipboardFallback;
327
+
221
328
  /// <summary>
222
329
  /// Read the current selection. UIA first: it is passive (no keystrokes, no clipboard
223
330
  /// churn) and it also tells us where the selection ends, which is what the pill
@@ -275,7 +382,7 @@ namespace DshHud
275
382
  {
276
383
  }
277
384
 
278
- hit.Text = Read();
385
+ if (ClipboardFallback) hit.Text = Read();
279
386
  return hit;
280
387
  }
281
388
 
@@ -338,16 +445,23 @@ namespace DshHud
338
445
 
339
446
  private static string ReadOnStaThread()
340
447
  {
341
- IDataObject backup = null;
342
- try
343
- {
344
- backup = Clipboard.GetDataObject();
345
- }
346
- catch
347
- {
348
- }
448
+ // No clipboard backup/restore any more. Restoring was the actual damage: while
449
+ // we waited for our own copy to land, the user's real Ctrl+C could complete
450
+ // first, and our restore then threw that copy away ("复制不上"). Now the worst
451
+ // case is that the selection text stays on the clipboard — which is exactly
452
+ // what the user just asked for anyway.
453
+ if (ShellWin.IsModifierDown()) return "";
349
454
 
350
455
  uint seq = ShellWin.GetClipboardSequenceNumber();
456
+ bool asked = ShellWin.SendCopyMessage();
457
+ if (!asked) return "";
458
+
459
+ string text = WaitForClipboardText(seq, 320);
460
+ if (!string.IsNullOrEmpty(text)) return Finish(text);
461
+
462
+ // WM_COPY is ignored by a few legacy controls; only then fall back to a real
463
+ // key chord, and only when the user has opted in.
464
+ if (!ClipboardFallback || ShellWin.IsModifierDown()) return "";
351
465
  try
352
466
  {
353
467
  ShellWin.SendCopy();
@@ -356,32 +470,33 @@ namespace DshHud
356
470
  {
357
471
  return "";
358
472
  }
473
+ text = WaitForClipboardText(seq, 700);
474
+ return string.IsNullOrEmpty(text) ? "" : Finish(text);
475
+ }
359
476
 
360
- string text = null;
477
+ /// <summary>Wait until the clipboard sequence number moves, then read its text.</summary>
478
+ private static string WaitForClipboardText(uint seq, int budgetMs)
479
+ {
361
480
  int waited = 0;
362
- while (waited < 900)
481
+ while (waited < budgetMs)
363
482
  {
364
- Thread.Sleep(30);
365
- waited += 30;
483
+ Thread.Sleep(25);
484
+ waited += 25;
366
485
  if (ShellWin.GetClipboardSequenceNumber() == seq) continue;
367
486
  try
368
487
  {
369
- if (Clipboard.ContainsText()) text = Clipboard.GetText();
488
+ if (Clipboard.ContainsText()) return Clipboard.GetText();
370
489
  }
371
490
  catch
372
491
  {
373
492
  }
374
- break;
375
- }
376
-
377
- try
378
- {
379
- if (backup != null) Clipboard.SetDataObject(backup, false);
380
- }
381
- catch
382
- {
493
+ return "";
383
494
  }
495
+ return "";
496
+ }
384
497
 
498
+ private static string Finish(string text)
499
+ {
385
500
  if (string.IsNullOrEmpty(text)) return "";
386
501
  text = text.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
387
502
  if (text.Length == 0) return "";
@@ -748,6 +863,9 @@ namespace DshHud
748
863
  private void Kick(Point pt, int delay)
749
864
  {
750
865
  if (!enabled) { Log("skip: disabled"); return; }
866
+ // A drag with Ctrl/Shift/Alt held is a copy or an extension, not a fresh
867
+ // selection: probing it races the user's own shortcut.
868
+ if (ShellWin.IsModifierDown()) { Log("skip: modifier down"); return; }
751
869
 
752
870
  if (ShellWin.IsShellSurfaceAt(pt)) { Log("skip: shell surface"); return; }
753
871
  int id = ++probeId;
package/app/Service.cs CHANGED
@@ -1,4 +1,4 @@
1
- using System;
1
+ using System;
2
2
  using System.Diagnostics;
3
3
  using System.Drawing;
4
4
  using System.IO;
@@ -19,6 +19,7 @@ namespace DshHud
19
19
  internal bool Ask;
20
20
  internal string HotkeyFull;
21
21
  internal string HotkeyRegion;
22
+ internal bool TextFallback;
22
23
 
23
24
  internal HudConfig()
24
25
  {
@@ -26,6 +27,7 @@ namespace DshHud
26
27
  Ask = true;
27
28
  HotkeyFull = "Ctrl+Shift+1";
28
29
  HotkeyRegion = "Ctrl+Shift+2";
30
+ TextFallback = false;
29
31
  }
30
32
 
31
33
  internal static string Dir
@@ -61,6 +63,7 @@ namespace DshHud
61
63
  else if (key == "ask") cfg.Ask = val != "0";
62
64
  else if (key == "hotkeyFull") cfg.HotkeyFull = val;
63
65
  else if (key == "hotkeyRegion") cfg.HotkeyRegion = val;
66
+ else if (key == "textFallback") cfg.TextFallback = val != "0";
64
67
  }
65
68
  }
66
69
  catch
@@ -79,6 +82,7 @@ namespace DshHud
79
82
  sb.AppendLine("ask=" + (Ask ? "1" : "0"));
80
83
  sb.AppendLine("hotkeyFull=" + HotkeyFull);
81
84
  sb.AppendLine("hotkeyRegion=" + HotkeyRegion);
85
+ sb.AppendLine("textFallback=" + (TextFallback ? "1" : "0"));
82
86
  File.WriteAllText(ConfigPath, sb.ToString(), Encoding.UTF8);
83
87
  }
84
88
  catch
@@ -250,6 +254,7 @@ namespace DshHud
250
254
  watcher = new SelectionWatcher(host);
251
255
  watcher.QuoteRequested += DeliverQuote;
252
256
  watcher.Enabled = config.Ask;
257
+ SelectionReader.ClipboardFallback = config.TextFallback;
253
258
 
254
259
  tray = new NotifyIcon();
255
260
  tray.Icon = SystemIcons.Application;
@@ -313,9 +318,11 @@ namespace DshHud
313
318
  config = HudConfig.Load();
314
319
  watcher.Enabled = config.Ask;
315
320
  askItem.Checked = config.Ask;
321
+ SelectionReader.ClipboardFallback = config.TextFallback;
316
322
  host.Bind(config);
317
323
  SelectionWatcher.Log("config reloaded: hotkeyFull=" + config.HotkeyFull +
318
- " hotkeyRegion=" + config.HotkeyRegion);
324
+ " hotkeyRegion=" + config.HotkeyRegion +
325
+ " textFallback=" + (config.TextFallback ? "1" : "0"));
319
326
  }
320
327
 
321
328
  private void OnHotkey(string kind)
package/lib/client.js CHANGED
@@ -551,6 +551,8 @@ window.__ModuleLoader__.load({
551
551
  react.createElement('h3', { style: { fontSize: '14px', margin: '0 0 4px' } }, '小窗与截图'),
552
552
  react.createElement('p', { style: hintStyle }, '设置对原生小窗和后台划词服务同时生效,保存后约 3 秒内应用。'),
553
553
  checkbox('copyShot', '截图自动复制到剪贴板', '全屏、框选两种截图都会复制'),
554
+ checkbox('textFallback', '兼容模式:读不到选区时模拟 Ctrl+C',
555
+ '默认关闭。关闭时只走系统辅助功能读选区,完全不碰剪贴板、不发按键;打开后老式控件才读得到,但可能和你自己的 Ctrl+C 抢剪贴板。'),
554
556
  hotkey('hotkeyFull', '全屏截图快捷键'),
555
557
  hotkey('hotkeyRegion', '框选截图快捷键'),
556
558
  react.createElement('p', { style: hintStyle }, '写法:Ctrl+Shift+1、Ctrl+Shift+S、Alt+PrintScreen、F8 等。组合里必须带 Ctrl/Alt/Shift/Win 之一。'),
package/lib/index.js CHANGED
@@ -202,7 +202,7 @@ function advertise(ctx) {
202
202
 
203
203
  const CONFIG_PATH = join(WORK_DIR, 'config.ini')
204
204
 
205
- const CONFIG_DEFAULTS = { copyShot: true, ask: true, hotkeyFull: 'Ctrl+Shift+1', hotkeyRegion: 'Ctrl+Shift+2' }
205
+ const CONFIG_DEFAULTS = { copyShot: true, ask: true, textFallback: false, hotkeyFull: 'Ctrl+Shift+1', hotkeyRegion: 'Ctrl+Shift+2' }
206
206
 
207
207
  /**
208
208
  * Settings live in a flat `key=value` file because the window shell (C# 5) and this
@@ -219,7 +219,7 @@ function readConfig() {
219
219
  if (eq <= 0) continue
220
220
  const key = line.slice(0, eq).trim()
221
221
  const value = line.slice(eq + 1).trim()
222
- if (key === 'copyShot' || key === 'ask') config[key] = value !== '0'
222
+ if (key === 'copyShot' || key === 'ask' || key === 'textFallback') config[key] = value !== '0'
223
223
  else if (key === 'hotkeyFull' || key === 'hotkeyRegion') config[key] = value
224
224
  }
225
225
  } catch {
@@ -235,6 +235,7 @@ function writeConfig(config) {
235
235
  `copyShot=${config.copyShot ? '1' : '0'}`,
236
236
  `ask=${config.ask ? '1' : '0'}`,
237
237
  `hotkeyFull=${config.hotkeyFull}`,
238
+ `textFallback=${config.textFallback ? '1' : '0'}`,
238
239
  `hotkeyRegion=${config.hotkeyRegion}`,
239
240
  ]
240
241
  writeFileSync(CONFIG_PATH, lines.join('\n') + '\n', 'utf8')
@@ -326,12 +327,13 @@ export function apply(ctx, config = {}) {
326
327
  let body
327
328
  try { body = await readJson(req) } catch (error) { sendJson(res, 400, { error: error.message }); return }
328
329
  const next = readConfig()
329
- for (const key of ['copyShot', 'ask', 'hotkeyFull', 'hotkeyRegion']) {
330
+ for (const key of ['copyShot', 'ask', 'textFallback', 'hotkeyFull', 'hotkeyRegion']) {
330
331
  if (Object.prototype.hasOwnProperty.call(body, key)) next[key] = body[key]
331
332
  }
332
333
  // Normalize before writing: the shell matches these strings exactly when it
333
334
  // registers the global hotkeys.
334
335
  next.copyShot = Boolean(next.copyShot)
336
+ next.textFallback = Boolean(next.textFallback)
335
337
  next.ask = Boolean(next.ask)
336
338
  next.hotkeyFull = String(next.hotkeyFull ?? '').trim() || CONFIG_DEFAULTS.hotkeyFull
337
339
  next.hotkeyRegion = String(next.hotkeyRegion ?? '').trim() || CONFIG_DEFAULTS.hotkeyRegion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mini",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "DSH 常驻小窗插件:把当前会话一键弹出为独立置顶小窗(WebView2 原生窗口),含整屏/框选截图、划词「问大肥鱼」、全局快捷键与截图自动复制",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",