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.
- package/README.md +8 -5
- package/cordis.patch.yml +1 -1
- package/lib/client.js +47 -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/package.json +1 -1
package/lib/notice/index.js
CHANGED
|
@@ -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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|
630
|
-
* four call sites genuinely race — a page reporting its
|
|
631
|
-
* flips a switch would otherwise be able to leave the
|
|
632
|
-
* registry state that no longer exists, or an orphaned
|
|
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
|
-
|
|
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) => {
|