dsh-workspace-plus 0.6.1 → 0.7.1

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.
@@ -31,11 +31,36 @@
31
31
  * is the one place the plugin leaves the host process:
32
32
  *
33
33
  * 1. the toast carries a `dsh-notice:open?s=<session>` protocol activation;
34
- * 2. the plugin registers that protocol under HKCU, pointing at a small
35
- * PowerShell launcher that calls this plugin's own loopback route;
36
- * 3. the route records the session, brings the desktop window forward, and
37
- * the browser half — which polls the plugin's route — selects that
38
- * conversation through the Harness's own `uiWorkspace.openSession`.
34
+ * 2. the plugin registers that protocol under HKCU, pointing at the best
35
+ * click launcher this machine can actually run. `launcher.js` owns that
36
+ * choice, in this order:
37
+ * - `helper` — a small compiled C# window (`click-host.cs`) that calls
38
+ * this plugin's own loopback route and then restores and focuses the
39
+ * Harness window itself, in the same process. It exists because every
40
+ * PowerShell path below is worse at exactly that job: a console
41
+ * flashes on each click, the foreground lock routinely refuses a focus
42
+ * request from a freshly started child process, and a window resting in
43
+ * the tray is not reachable from a script at all. The helper is also
44
+ * cheap — no interpreter startup, no second Electron instance per
45
+ * click, which is what the old `dsh://open` route cost.
46
+ * - `windowless-powershell` — `powershell.exe` started such that no
47
+ * console window can appear, for a machine that cannot build the
48
+ * helper.
49
+ * - `powershell` — the plain `-WindowStyle Hidden` invocation of
50
+ * `click.ps1`, the last resort that still needs a working PowerShell.
51
+ * 3. the route records the session and answers 200. With either PowerShell
52
+ * launcher it then still asks the desktop application (`dsh://open`) to
53
+ * bring its window forward; with the helper it does NOT, because the
54
+ * helper has already done it — and spawning a second Electron instance per
55
+ * click is the slow, unreliable path this replaced. The browser half —
56
+ * which polls the plugin's route — then selects that conversation through
57
+ * the Harness's own `uiWorkspace.openSession`.
58
+ *
59
+ * When `focusOnClick` is false the click only selects the conversation and never
60
+ * raises the window: the launcher is selected with `focus: false` (so the helper
61
+ * is registered without its restore-and-focus step) and the route below skips the
62
+ * `dsh://open` fallback. A click then changes what the window shows, not whether
63
+ * it is on screen.
39
64
  *
40
65
  * Step 3 is why the browser half exists at all: the Host cannot select a
41
66
  * conversation in a page's UI, so it publishes the intent and the page applies
@@ -58,6 +83,7 @@ import {
58
83
  isInScope,
59
84
  openUrl,
60
85
  } from './format.js'
86
+ import { selectLauncher } from './launcher.js'
61
87
  import {
62
88
  KIND_LABELS,
63
89
  KINDS,
@@ -72,8 +98,6 @@ import { ToastDaemon, toastTag } from './toast.js'
72
98
  import {
73
99
  focusDesktopWindow,
74
100
  isSupported,
75
- powershellPath,
76
- protocolCommand,
77
101
  readProtocolCommand,
78
102
  registerProtocol,
79
103
  unregisterProtocol,
@@ -94,7 +118,15 @@ export const PENDING_PATH = '/dsh-notice/api/pending'
94
118
  /** Directory holding this plugin's shipped assets. */
95
119
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
96
120
 
97
- /** PowerShell launcher the protocol registration points at. */
121
+ /**
122
+ * PowerShell launcher the protocol registration falls back to.
123
+ *
124
+ * `selectLauncher` builds the two PowerShell command lines around this script
125
+ * and the host never invokes it; it stays exported because the path is part of
126
+ * the plugin's shipped surface (and of the dsh-notice contract this sub-feature
127
+ * was ported from), and because a registration written by an older version still
128
+ * points here until it is retired.
129
+ */
98
130
  export const CLICK_SCRIPT = join(MODULE_DIR, 'click.ps1')
99
131
 
100
132
  /** Largest accepted settings request body. */
@@ -263,6 +295,18 @@ export function apply(ctx, config) {
263
295
  /** Scheme that command was written under, so a change can retire it. */
264
296
  let registeredScheme = null
265
297
 
298
+ /**
299
+ * Which click launcher the registered command belongs to, or null.
300
+ *
301
+ * The command line alone cannot answer this, and the answer matters to the
302
+ * open route: the compiled helper focuses the window itself after answering,
303
+ * while the PowerShell paths leave that to the host. Kept beside the command
304
+ * so every place that clears one clears all three — a stale launcher kind would
305
+ * make the host skip (or repeat) the focus step for a command that no longer
306
+ * exists.
307
+ */
308
+ let registeredLauncher = null
309
+
266
310
  /** The last conversation the plugin announced, used by the test action. */
267
311
  let lastSessionId = null
268
312
 
@@ -560,6 +604,15 @@ export function apply(ctx, config) {
560
604
  /**
561
605
  * One synchronous-ish pass over the registry. Callers use `syncProtocol`.
562
606
  *
607
+ * The click command is not built here any more. Which launcher works is a
608
+ * property of the machine (is there a C# compiler for the helper, is there a
609
+ * PowerShell, does the window need restoring) rather than of the settings, so
610
+ * `selectLauncher` owns that decision and the host only compares and writes the
611
+ * command line it returns. It is asked on every pass — that is what keeps the
612
+ * registration idempotent, since the command compared is always the freshly
613
+ * selected one — and it caches the helper it builds, so only the first pass of
614
+ * a host process ever pays the couple of seconds that build costs.
615
+ *
563
616
  * @returns the outcome for the settings page.
564
617
  */
565
618
  const syncProtocolNow = async () => {
@@ -573,6 +626,7 @@ export function apply(ctx, config) {
573
626
  const previousCommand = registeredCommand
574
627
  registeredScheme = null
575
628
  registeredCommand = null
629
+ registeredLauncher = null
576
630
  const stored = await readProtocolCommand(previous)
577
631
  if (previousCommand === null || stored === previousCommand) await unregisterProtocol(previous)
578
632
  }
@@ -584,6 +638,7 @@ export function apply(ctx, config) {
584
638
  await unregisterProtocol(scheme)
585
639
  registeredCommand = null
586
640
  registeredScheme = null
641
+ registeredLauncher = null
587
642
  }
588
643
  return { ok: true, message: '已关闭协议注册:点击通知只会把 Harness 窗口切到前台' }
589
644
  }
@@ -591,22 +646,41 @@ export function apply(ctx, config) {
591
646
  if (port === null) return { ok: false, message: '尚未获知 Harness 监听端口,稍后重试' }
592
647
  await retireOldScheme()
593
648
 
594
- const command = protocolCommand({
595
- powershell: powershellPath(),
596
- script: CLICK_SCRIPT,
597
- scheme,
598
- port,
599
- token,
600
- })
649
+ // `focusOnClick` is passed into the selection rather than applied later: the
650
+ // helper embeds its own focus step, so the two choices produce different
651
+ // command lines and flipping the switch correctly causes one re-registration.
652
+ let selected
653
+ try {
654
+ selected = await selectLauncher({ scheme, port, token, focus: settings.focusOnClick === true })
655
+ } catch (error) {
656
+ // By contract this never happens, and by policy it must not matter: a
657
+ // launcher that cannot be prepared means clicks degrade to `dsh://open`
658
+ // (see `activationFor`), which is exactly the failure this should produce.
659
+ // Plugin load is not allowed to fail over a notification detail.
660
+ const message = String((error && error.message) || error)
661
+ ctx.logger?.warn?.(`[dsh-workspace-plus/notice] 选择点击启动器失败:${message}`)
662
+ return { ok: false, message: `无法准备点击启动器:${message}` }
663
+ }
664
+ const command = typeof selected?.command === 'string' ? selected.command : ''
665
+ if (selected?.ok !== true || command === '') {
666
+ // An old registration under this same scheme is deliberately left alone:
667
+ // a stale key still swallows a click quietly, whereas a deleted one makes
668
+ // Windows pop "how do you want to open this link" on every click.
669
+ return { ok: false, message: String(selected?.message ?? '没有可用的点击启动器') }
670
+ }
671
+ const launcher = typeof selected.kind === 'string' ? selected.kind : null
672
+
601
673
  const stored = await readProtocolCommand(scheme)
602
674
  if (stored === command) {
603
675
  registeredCommand = command
604
676
  registeredScheme = scheme
677
+ registeredLauncher = launcher
605
678
  return { ok: true, message: `${scheme}: 已注册` }
606
679
  }
607
680
  const result = await registerProtocol({ scheme, command })
608
681
  registeredCommand = result.ok ? command : null
609
682
  registeredScheme = result.ok ? scheme : null
683
+ registeredLauncher = result.ok ? launcher : null
610
684
  return result
611
685
  }
612
686
 
@@ -626,10 +700,14 @@ export function apply(ctx, config) {
626
700
  * notification would then reach a route that rejects it.
627
701
  *
628
702
  * Every run is serialized behind the previous one. The bookkeeping
629
- * (`registeredCommand`/`registeredScheme`) is assigned after `await`s, and the
630
- * four call sites genuinely race — a page reporting its origin while the user
631
- * flips a switch would otherwise be able to leave the variables describing a
632
- * registry state that no longer exists, or an orphaned key behind.
703
+ * (`registeredCommand`/`registeredScheme`/`registeredLauncher`) is assigned
704
+ * after `await`s, and the four call sites genuinely race — a page reporting its
705
+ * origin while the user flips a switch would otherwise be able to leave the
706
+ * variables describing a registry state that no longer exists, or an orphaned
707
+ * key behind. The serialization has a second, visible effect: the first pass
708
+ * may spend a couple of seconds building the click helper, and every later
709
+ * caller waits for that pass instead of starting a competing build, while the
710
+ * plugin itself keeps loading (this chain is never awaited at load).
633
711
  *
634
712
  * @returns the outcome for the settings page.
635
713
  */
@@ -754,6 +832,12 @@ export function apply(ctx, config) {
754
832
  scheme: settings.scheme,
755
833
  registeredScheme,
756
834
  registered: registeredCommand !== null,
835
+ // Which click path the registered command runs, so the settings page can
836
+ // say what a click will actually do on this machine: the compiled helper
837
+ // restores and focuses the window itself, the PowerShell fallbacks need
838
+ // the host to ask the desktop application to. Null whenever nothing of
839
+ // ours is registered.
840
+ launcher: registeredLauncher,
757
841
  port: listenPort(),
758
842
  },
759
843
  platformSupported: isSupported(),
@@ -820,6 +904,7 @@ export function apply(ctx, config) {
820
904
  const result = await unregisterProtocol(current().scheme)
821
905
  registeredCommand = null
822
906
  registeredScheme = null
907
+ registeredLauncher = null
823
908
  sendJson(res, 200, { ...snapshot(), ok: true, message: result.message })
824
909
  return
825
910
  }
@@ -888,7 +973,15 @@ export function apply(ctx, config) {
888
973
  const settings = current()
889
974
  res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
890
975
  res.end('ok')
891
- if (settings.focusOnClick === true) void focusDesktopWindow().catch(() => {})
976
+ // The compiled helper focuses the window itself, immediately after this
977
+ // route has answered: it restores a minimized or tray-hidden window and
978
+ // takes the foreground from the same process that just made the request.
979
+ // Asking the desktop application to focus as well would spawn a second
980
+ // Electron instance on every click — slow, and the `dsh://open`
981
+ // activation is unreliable exactly when the first instance is busy. The
982
+ // PowerShell launchers cannot do any of that from their own process, so
983
+ // they keep the fallback.
984
+ if (settings.focusOnClick === true && registeredLauncher !== 'helper') void focusDesktopWindow().catch(() => {})
892
985
  } catch (error) {
893
986
  ctx.logger?.warn?.(`[dsh-workspace-plus/notice] ${String((error && error.message) || error)}`)
894
987
  res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
@@ -925,6 +1018,7 @@ export function apply(ctx, config) {
925
1018
  const scheme = registeredScheme
926
1019
  registeredCommand = null
927
1020
  registeredScheme = null
1021
+ registeredLauncher = null
928
1022
  if (owned === null || scheme === null) return
929
1023
  void readProtocolCommand(scheme)
930
1024
  .then((stored) => {