dsh-status-bar-plugin 0.2.5 → 0.2.7

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 wfz2006
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wfz2006
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,100 +1,141 @@
1
- # dsh-status-bar
2
-
3
- DSH bundle for **DSH Status Bar** — a floating glass status bar for DeepSeek Harness.
4
- The npm name and the bundle row id are `dsh-status-bar-plugin`. npm already hosts an unrelated `dsh-statusbar`, so the registry rejects the shorter name as too similar.
5
-
6
- ## What it does
7
-
8
- The plugin mounts one host row in DSH. At boot it waits for the local web server,
9
- asks the `connection` service for the authenticated host URL
10
- (`connection.authenticatedUrl(origin)`), and starts the desktop app with that link:
11
-
12
- ```text
13
- electron <app dir> --dsh-link=<authenticated url> --launcher-pid=<host pid>
14
- ```
15
-
16
- The companion connects with that link on launch, so nothing has to be pasted. The
17
- link travels only in the process arguments and is never logged — the plugin prints
18
- `[dsh-status-bar] Glass desktop companion launched (authenticated link redacted)`.
19
- The app rejects non-loopback links, credentials, hashes and sub-paths before it
20
- touches the network. `--launcher-pid` makes the companion exit when the DSH
21
- process it belongs to disappears; disposing the plugin kills the spawned tree.
22
-
23
- If the companion is already running, the second launch hits its single-instance
24
- lock: the existing window is focused and re-connected with the new link.
25
-
26
- ## Install
27
-
28
- From npm, from the plugin market (once listed), or straight from the release
29
- tarball:
30
-
31
- ```sh
32
- # npm: ~870 kB, fetches the 155 MB runtime once on first launch
33
- dsh plugin --profile web add dsh-status-bar-plugin
34
-
35
- # release tarball: 155 MB, self-contained, nothing downloaded later
36
- dsh plugin --profile web add https://github.com/wfz2006/dsh-status-bar/releases/latest/download/dsh-status-bar.tgz
37
- ```
38
-
39
- Both end up as the same companion: the built desktop app plus an Electron
40
- runtime. The tarball carries them; the npm package reads the download URL from its
41
- own manifest (`dsh.statusBar.runtime`), fetches that tarball the first time DSH
42
- starts it — progress goes to the host log, roughly
43
- `[dsh-status-bar] downloading the Glass runtime: 40% of 155 MB` — and unpacks it
44
- into a per-version cache: `%LOCALAPPDATA%\dsh-status-bar\runtime-<version>` on
45
- Windows, `~/.cache/dsh-status-bar/runtime-<version>` elsewhere. Later launches
46
- reuse that cache, so the download happens once per version.
47
-
48
- > Renamed in 0.2.3. The old package id was `dsh-glass-plugin`; it still resolves
49
- > to the same app, but new installs should use `dsh-status-bar-plugin`.
50
-
51
- ### From a checkout (development)
52
-
53
- The desktop profile can link the package instead of installing it:
54
-
55
- ```jsonc
56
- // <profile>/package.json
57
- "dependencies": { "dsh-status-bar-plugin": "link:D:/codexwork/dsh/dsh-glass-spec/packages/dsh-status-bar" },
58
- "dsh": { "profile": { "bundles": [/* ... */ "dsh-status-bar-plugin"] } }
59
- ```
60
-
61
- In a checkout the plugin uses `apps/desktop/out` as the app directory and the
62
- workspace `electron` package, so run `pnpm build` first. Point a junction or
63
- symlink at `node_modules/dsh-status-bar-plugin` if pnpm did not run, then restart DSH.
64
-
65
- ## Configuration
66
-
67
- The bundle patch sets `autoLaunch: true`. To keep the plugin installed but stop it
68
- from launching the companion, override the row config in the profile patch:
69
-
70
- ```yaml
71
- - id: dsh-status-bar-plugin
72
- config:
73
- autoLaunch: false
74
- ```
75
-
76
- Other overrides, all optional:
77
-
78
- | key | meaning |
79
- |:--|:--|
80
- | `appDir` | directory holding `out/main/index.js` |
81
- | `electronBinary` | path to an Electron executable |
82
- | `runtimeDir` | where the downloaded runtime is cached |
83
- | `downloadUrl` | override the runtime tarball URL |
84
- | `autoReload` | relaunch the companion when the desktop bundle changes (default `true`) |
85
-
86
- The same settings are available through the environment:
87
- `DSH_STATUS_BAR_APP`, `DSH_STATUS_BAR_ELECTRON`, `DSH_STATUS_BAR_RUNTIME`,
88
- `DSH_STATUS_BAR_RUNTIME_URL`. The pre-0.2.3 names (`DSH_GLASS_*`) still work as
89
- fallbacks.
90
-
91
- ## Requirements
92
-
93
- - Windows 11 x64 (the glass refraction reads the screen behind the window).
94
- - The desktop app is a separate Electron process; it needs a GPU-backed Electron
95
- runtime, which the release tarball provides.
96
-
97
- ## License
98
-
99
- MIT. The bundled Electron runtime keeps its own license files
100
- (`electron/dist/LICENSE`, `LICENSES.chromium.html`).
1
+ # dsh-status-bar
2
+
3
+ DSH bundle for **DSH Status Bar** — a floating glass status bar for DeepSeek Harness.
4
+ The npm name and the bundle row id are `dsh-status-bar-plugin`. npm already hosts an unrelated `dsh-statusbar`, so the registry rejects the shorter name as too similar.
5
+
6
+ ## What it does
7
+
8
+ The plugin mounts one host row in DSH. At boot it waits for the local web server,
9
+ asks the `connection` service for the authenticated host URL
10
+ (`connection.authenticatedUrl(origin)`), and starts the desktop app with that link:
11
+
12
+ ```text
13
+ electron <app dir> --dsh-link=<authenticated url> --launcher-pid=<host pid>
14
+ ```
15
+
16
+ The companion connects with that link on launch, so nothing has to be pasted. The
17
+ link travels only in the process arguments and is never logged — the plugin prints
18
+ `[dsh-status-bar] Glass desktop companion launched (authenticated link redacted)`.
19
+ The app rejects non-loopback links, credentials, hashes and sub-paths before it
20
+ touches the network. `--launcher-pid` makes the companion exit when the DSH
21
+ process it belongs to disappears; disposing the plugin kills the spawned tree.
22
+
23
+ If the companion is already running, the second launch hits its single-instance
24
+ lock: the existing window is focused and re-connected with the new link.
25
+
26
+ ## Reopening a hidden or closed companion
27
+
28
+ Hiding the window keeps the process alive; `Ctrl+Shift+G` toggles it back. After
29
+ the user quits the companion, the plugin registers the DSH slash command
30
+ `/status-bar`. Invoking it in any session asks DSH for a fresh authenticated link
31
+ and either shows the existing instance through the single-instance hand-off or
32
+ starts a new one. The command is registered even when `autoLaunch: false`, so a
33
+ manually disabled companion can still be opened without restarting DSH. It never
34
+ stores or replays an old token.
35
+
36
+ ## Install
37
+
38
+ From npm, from the plugin market (once listed), or straight from the release
39
+ tarball:
40
+
41
+ ```sh
42
+ # npm: ~870 kB, fetches the 155 MB runtime once on first launch
43
+ dsh plugin --profile web add dsh-status-bar-plugin
44
+
45
+ # release tarball: 155 MB, self-contained, nothing downloaded later
46
+ dsh plugin --profile web add https://github.com/wfz2006/dsh-status-bar/releases/latest/download/dsh-status-bar.tgz
47
+ ```
48
+
49
+ Both end up as the same companion: the built desktop app plus an Electron
50
+ runtime. The tarball carries them; the npm package reads the download URL from its
51
+ own manifest (`dsh.statusBar.runtime`), fetches that tarball the first time DSH
52
+ starts it, and unpacks it into a per-version cache:
53
+ `%LOCALAPPDATA%\dsh-status-bar\runtime-<version>` on
54
+ Windows, `~/.cache/dsh-status-bar/runtime-<version>` elsewhere. Later launches
55
+ reuse that cache, so the download happens once per version.
56
+
57
+ On Windows, a small native preparation window shows download progress before
58
+ Electron is available. A failed download shows **Retry** and **Try later**;
59
+ retry continues without restarting DSH. Cancel stops the preparation and keeps
60
+ the verifiable partial for the next launch. A valid cache opens the companion
61
+ directly, without flashing the preparation window.
62
+
63
+ The downloader prefers system `curl` (and falls back to Node if unavailable),
64
+ checks stalled connections, and makes up to three attempts. Partial files live
65
+ beside the cache as `runtime-<version>.tgz.part`; Range resume requires an ETag or
66
+ Last-Modified validator. If the server ignores Range or no validator exists,
67
+ the file is downloaded afresh rather than concatenated incorrectly. Corrupt
68
+ archives are discarded; extracted content must match the plugin version before
69
+ it replaces a cache. A per-version lock prevents concurrent hosts from writing
70
+ the same partial/cache. Plugin disposal aborts preparation and prevents late launch.
71
+
72
+ The default GitHub URL is pinned to `releases/download/v<plugin-version>/...`,
73
+ even for older manifests advertising `latest`. An explicit download URL override
74
+ is respected; its archive must still match the installed plugin version. No
75
+ authenticated DSH link is requested until the runtime is ready, and progress
76
+ files contain no Host token or Cookie. Non-Windows environments retain log output;
77
+ the native progress UI is Windows-only.
78
+
79
+ > Renamed in 0.2.3. The old package id was `dsh-glass-plugin`; it still resolves
80
+ > to the same app, but new installs should use `dsh-status-bar-plugin`.
81
+
82
+ ### From a checkout (development)
83
+
84
+ The desktop profile can link the package instead of installing it:
85
+
86
+ ```jsonc
87
+ // <profile>/package.json
88
+ "dependencies": { "dsh-status-bar-plugin": "link:D:/codexwork/dsh/dsh-glass-spec/packages/dsh-status-bar" },
89
+ "dsh": { "profile": { "bundles": [/* ... */ "dsh-status-bar-plugin"] } }
90
+ ```
91
+
92
+ In a checkout the plugin uses `apps/desktop/out` as the app directory and the
93
+ workspace `electron` package, so run `pnpm build` first. Point a junction or
94
+ symlink at `node_modules/dsh-status-bar-plugin` if pnpm did not run, then restart DSH.
95
+
96
+ ## Configuration
97
+
98
+ The bundle patch sets `autoLaunch: true`. To keep the plugin installed but stop it
99
+ from launching the companion at boot, override the row config in the profile
100
+ patch; `/status-bar` remains available for manual launches:
101
+
102
+ ```yaml
103
+ - id: dsh-status-bar-plugin
104
+ config:
105
+ autoLaunch: false
106
+ ```
107
+
108
+ Other overrides, all optional:
109
+
110
+ | key | meaning |
111
+ |:--|:--|
112
+ | `appDir` | directory holding `out/main/index.js` |
113
+ | `electronBinary` | path to an Electron executable |
114
+ | `runtimeDir` | where the downloaded runtime is cached |
115
+ | `downloadUrl` | override the runtime tarball URL |
116
+ | `autoReload` | relaunch the companion when the desktop bundle changes (default `true`) |
117
+ | `showStartupUI` | show native Windows preparation/error feedback (default `true`); disable only for intentionally headless use |
118
+
119
+ The same settings are available through the environment:
120
+ `DSH_STATUS_BAR_APP`, `DSH_STATUS_BAR_ELECTRON`, `DSH_STATUS_BAR_RUNTIME`,
121
+ `DSH_STATUS_BAR_RUNTIME_URL`. The pre-0.2.3 names (`DSH_GLASS_*`) still work as
122
+ fallbacks.
123
+
124
+ ## Requirements
125
+
126
+ - Windows 11 x64 (the glass refraction reads the screen behind the window).
127
+ - The desktop app is a separate Electron process; it needs a GPU-backed Electron
128
+ runtime, which the release tarball provides.
129
+
130
+ ## License
131
+
132
+ MIT. The bundled Electron runtime keeps its own license files
133
+ (`electron/dist/LICENSE`, `LICENSES.chromium.html`).
134
+
135
+ ## Development verification
136
+
137
+ From the repository root, run `pnpm test` and `pnpm test:plugin-startup` after
138
+ `pnpm build`. The latter uses a temporary thin-plugin install, local HTTP/Host,
139
+ native WPF feedback and the actual workspace Electron through the documented
140
+ `electronBinary` override. It does not reinstall or enable the production plugin.
141
+ See [first-run recovery](../../docs/first-run-recovery.md) for evidence and limits.
package/cordis.patch.yml CHANGED
@@ -1,12 +1,12 @@
1
- # dsh-status-bar-plugin bundle patch
2
- #
3
- # This is the `dsh.bundle.patch` layer of the local bundle package. When the
4
- # package is declared in a profile's dependencies, adding it to
5
- # `dsh.profile.bundles` mounts the row below at boot. The row resolves
6
- # `dsh-status-bar-plugin` from the profile's node_modules, where the `link:`
7
- # dependency points back at this directory.
8
- - insert:
9
- - id: dsh-status-bar-plugin
10
- name: 'dsh-status-bar-plugin'
11
- config:
12
- autoLaunch: true
1
+ # dsh-status-bar-plugin bundle patch
2
+ #
3
+ # This is the `dsh.bundle.patch` layer of the local bundle package. When the
4
+ # package is declared in a profile's dependencies, adding it to
5
+ # `dsh.profile.bundles` mounts the row below at boot. The row resolves
6
+ # `dsh-status-bar-plugin` from the profile's node_modules, where the `link:`
7
+ # dependency points back at this directory.
8
+ - insert:
9
+ - id: dsh-status-bar-plugin
10
+ name: 'dsh-status-bar-plugin'
11
+ config:
12
+ autoLaunch: true
@@ -0,0 +1,178 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createWriteStream, existsSync, readFileSync, writeFileSync, statSync, rmSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { get as httpGet } from 'node:http';
5
+ import { get as httpsGet } from 'node:https';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { join } from 'node:path';
8
+
9
+ export function abortError() { return Object.assign(new Error('已取消准备,下次启动可继续下载。'), { name: 'AbortError' }); }
10
+ export function checkAbort(signal) { if (signal?.aborted) throw abortError(); }
11
+ export function delay(ms, signal) {
12
+ checkAbort(signal);
13
+ return new Promise((resolve, reject) => {
14
+ const abort = () => { clearTimeout(timer); reject(abortError()); };
15
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
16
+ signal?.addEventListener('abort', abort, { once: true });
17
+ });
18
+ }
19
+ const size = path => { try { return statSync(path).size; } catch { return 0; } };
20
+ const json = path => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; } };
21
+ function reset(part) { for (const path of [part, `${part}.json`]) rmSync(path, { force: true }); }
22
+ const TLS_CODES = new Set([35, 51, 60]);
23
+ function tlsHint(code) {
24
+ if (!TLS_CODES.has(code)) return undefined;
25
+ return `与下载服务器建立安全连接失败(curl ${code}),通常是系统时间、代理或证书设置导致。`;
26
+ }
27
+ function validator(headers) {
28
+ const tag = headers.etag;
29
+ return typeof tag === 'string' && !tag.startsWith('W/') ? tag : headers['last-modified'];
30
+ }
31
+ function finalHeaders(file) {
32
+ try {
33
+ const blocks = readFileSync(file, 'utf8').split(/\r?\n\r?\n/).filter(block => /^HTTP\//.test(block));
34
+ const last = blocks.at(-1); if (!last) return {};
35
+ const lines = last.split(/\r?\n/); const status = Number(lines.shift()?.split(' ')[1]);
36
+ return { status, headers: Object.fromEntries(lines.filter(line => line.includes(':')).map(line => { const i = line.indexOf(':'); return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()]; })) };
37
+ } catch { return {}; }
38
+ }
39
+ function metadata(headers, source) {
40
+ const range = String(headers['content-range'] ?? '').match(/^bytes \d+-\d+\/(\d+)$/);
41
+ return { source, validator: validator(headers), total: range ? Number(range[1]) : Number(headers['content-length']) || 0 };
42
+ }
43
+ /** Schannel refuses the handshake when it cannot reach a revocation endpoint
44
+ * (`CRYPT_E_NO_REVOCATION_CHECK`, curl 35), which is the norm on locked-down
45
+ * networks. Best-effort keeps the check when it works and tolerates the failure.
46
+ * Curl older than 7.70 does not know the flag and exits 2; retry without it. */
47
+ export function curlArgs(url, part, headersFile, previous, options, quirks = {}) {
48
+ const schannel = quirks.schannel ?? process.platform === 'win32';
49
+ const args = ['--silent', '--show-error', '--location', '--fail', '--connect-timeout', '15', '--max-time', String(options.timeoutMs / 1000),
50
+ '--speed-limit', '1', '--speed-time', String(Math.max(1, Math.ceil(options.idleMs / 1000))), '--proto-redir', '=https,http',
51
+ '--dump-header', headersFile, '--output', part];
52
+ if (schannel && quirks.revoke !== false) args.push('--ssl-revoke-best-effort');
53
+ if (new URL(url).protocol === 'https:') args.push('--proto', '=https', '--proto-redir', '=https');
54
+ if (previous.validator) args.push('--header', `If-Range: ${previous.validator}`);
55
+ const offset = size(part);
56
+ if (offset) args.push('--continue-at', String(offset));
57
+ args.push('--url', url);
58
+ return args;
59
+ }
60
+ function curlAttempt(url, part, previous, options) {
61
+ const headersFile = `${part}.headers`;
62
+ rmSync(headersFile, { force: true });
63
+ const offset = size(part);
64
+ const program = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'curl.exe') : 'curl';
65
+ const args = curlArgs(url, part, headersFile, previous, options, { revoke: options.revoke });
66
+ return new Promise((resolve, reject) => {
67
+ let child;
68
+ const progress = () => {
69
+ const result = finalHeaders(headersFile);
70
+ if (result.status === 200 || result.status === 206) {
71
+ const next = metadata(result.headers, options.source);
72
+ writeFileSync(`${part}.json`, JSON.stringify(next));
73
+ options.onProgress({ received: size(part), total: next.total });
74
+ }
75
+ };
76
+ const abort = () => child?.kill();
77
+ try { child = spawn(program, args, { windowsHide: true, stdio: 'ignore' }); }
78
+ catch (error) { reject(error); return; }
79
+ const timer = setInterval(() => { try { progress(); } catch (error) { child.kill(); reject(error); } }, 250); timer.unref();
80
+ const exiting = () => child.kill(); process.once('exit', exiting);
81
+ options.signal?.addEventListener('abort', abort, { once: true });
82
+ child.once('error', error => { clearInterval(timer); process.removeListener('exit', exiting); options.signal?.removeEventListener('abort', abort); reject(error); });
83
+ child.once('close', code => {
84
+ clearInterval(timer); options.signal?.removeEventListener('abort', abort);
85
+ process.removeListener('exit', exiting);
86
+ try {
87
+ progress(); checkAbort(options.signal);
88
+ const response = finalHeaders(headersFile);
89
+ if (code === 0) { resolve(); return; }
90
+ const completed = response.status === 416 && String(response.headers?.['content-range']).match(/^bytes \*\/(\d+)$/);
91
+ if (completed && Number(completed[1]) === offset && previous.total === offset) { resolve(); return; }
92
+ if (code === 33 || response.status === 416) reset(part);
93
+ const failure = new Error(response.status >= 400 ? `下载服务返回 HTTP ${response.status}。` : tlsHint(code) ?? `下载连接中断或超时(curl ${code ?? 'stopped'})。`);
94
+ if (typeof code === 'number') failure.curlCode = code;
95
+ reject(failure);
96
+ } catch (error) { reject(error); }
97
+ finally { rmSync(headersFile, { force: true }); }
98
+ });
99
+ if (options.signal?.aborted) abort();
100
+ });
101
+ }
102
+ async function nodeAttempt(url, part, previous, options, redirects = 5) {
103
+ checkAbort(options.signal);
104
+ const offset = size(part); let request; let timeout;
105
+ try {
106
+ const response = await new Promise((resolve, reject) => {
107
+ const headers = offset ? { Range: `bytes=${offset}-`, 'If-Range': previous.validator } : {};
108
+ request = (new URL(url).protocol === 'https:' ? httpsGet : httpGet)(url, { headers, signal: options.signal }, resolve);
109
+ request.once('error', reject);
110
+ request.setTimeout(options.idleMs, () => request.destroy(new Error('下载连接长时间没有数据。')));
111
+ timeout = setTimeout(() => request.destroy(new Error('下载超时。')), options.timeoutMs); timeout.unref();
112
+ });
113
+ const status = response.statusCode ?? 0;
114
+ if (status >= 300 && status < 400 && response.headers.location) {
115
+ response.resume();
116
+ const next = new URL(response.headers.location, url);
117
+ if (redirects <= 0 || new URL(url).protocol === 'https:' && next.protocol !== 'https:') throw new Error('下载重定向无效。');
118
+ return await nodeAttempt(next.href, part, previous, options, redirects - 1);
119
+ }
120
+ const complete = status === 416 && String(response.headers['content-range']).match(/^bytes \*\/(\d+)$/);
121
+ if (complete && Number(complete[1]) === offset && previous.total === offset) { response.resume(); return; }
122
+ if (status !== 200 && status !== 206) { response.resume(); if (status === 416) reset(part); throw new Error(`下载服务返回 HTTP ${status}。`); }
123
+ const range = String(response.headers['content-range'] ?? '').match(/^bytes (\d+)-(\d+)\/(\d+)$/);
124
+ if (status === 206 && (!offset || !range || Number(range[1]) !== offset || Number(range[2]) >= Number(range[3]))) {
125
+ response.destroy(); reset(part); throw new Error('续传范围不匹配,下一次重新下载。');
126
+ }
127
+ const next = metadata(response.headers, options.source);
128
+ writeFileSync(`${part}.json`, JSON.stringify(next));
129
+ let received = status === 206 ? offset : 0;
130
+ const report = () => { try { options.onProgress({ received, total: next.total }); } catch (error) { request.destroy(error); } };
131
+ response.on('data', chunk => { received += chunk.length; report(); });
132
+ const progress = setInterval(report, 250); progress.unref();
133
+ try { await pipeline(response, createWriteStream(part, { flags: status === 206 ? 'a' : 'w' }), { signal: options.signal }); }
134
+ finally { clearInterval(progress); }
135
+ if (next.total && size(part) !== next.total) throw new Error('下载文件不完整。');
136
+ } finally { clearTimeout(timeout); }
137
+ }
138
+
139
+ /** The partial file and its entity validator survive network errors/restarts.
140
+ * Curl uses the working Windows network path; Node is only the missing-curl fallback. */
141
+ export async function downloadArchive(url, part, { signal, onProgress = () => {}, attempts = 3, idleMs = 30000, timeoutMs = 300000, backend = 'auto' } = {}) {
142
+ const parsed = new URL(url);
143
+ if (parsed.username || parsed.password || !['https:', 'http:'].includes(parsed.protocol) || parsed.protocol === 'http:' && !['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname)) throw new Error('运行时地址必须使用 HTTPS。');
144
+ const source = createHash('sha256').update(url).digest('hex');
145
+ let useNode = backend === 'node'; let revoke = true; let lastError;
146
+ for (let attempt = 1; attempt <= attempts; attempt++) {
147
+ checkAbort(signal);
148
+ const previous = json(`${part}.json`);
149
+ if (previous.source !== source || !previous.validator) reset(part);
150
+ if (existsSync(part) && previous.source === source && previous.total > 0 && size(part) === previous.total) return;
151
+ onProgress({ phase: 'downloading', received: size(part), total: previous.total || 0, attempt, message: '正在下载桌面运行时,完成后自动打开悬浮窗。' });
152
+ let reported = 0;
153
+ const options = { source, signal, idleMs, timeoutMs, revoke, onProgress: progress => {
154
+ const now = Date.now(); if (now - reported < 250 && progress.received !== progress.total) return;
155
+ reported = now; onProgress({ phase: 'downloading', attempt, ...progress });
156
+ } };
157
+ try {
158
+ if (!useNode) {
159
+ try { await curlAttempt(url, part, previous, options); }
160
+ catch (error) { if (!['ENOENT', 'EACCES', 'EPERM'].includes(error.code)) throw error; useNode = true; await nodeAttempt(url, part, previous, options); }
161
+ } else await nodeAttempt(url, part, previous, options);
162
+ checkAbort(signal); return;
163
+ } catch (error) {
164
+ checkAbort(signal); lastError = error;
165
+ // An old curl without --ssl-revoke-best-effort exits 2; retry the same
166
+ // attempt without the flag instead of failing the whole download.
167
+ if (!useNode && revoke && error.curlCode === 2) { revoke = false; attempt--; continue; }
168
+ // A TLS failure that survives the flag is worth one Node attempt, since
169
+ // Node validates against its own trust store instead of Schannel's.
170
+ if (!useNode && TLS_CODES.has(error.curlCode)) { useNode = true; attempt--; continue; }
171
+ if (attempt < attempts) {
172
+ onProgress({ phase: 'retrying', message: `下载中断,${attempt} 秒后重试;可验证的已下载部分会保留。`, attempt });
173
+ await delay(attempt * 1000, signal);
174
+ }
175
+ }
176
+ }
177
+ throw new Error(`${lastError?.message ?? '运行时下载失败。'} 已保留可续传的数据,可点击重试。`);
178
+ }
package/lib/index.js CHANGED
@@ -1,350 +1,165 @@
1
- // DSH bundle entry for the Glass companion.
2
- //
3
- // The desktop app is its own Electron process. This host plugin starts it once
4
- // the local web server is up, handing over the authenticated URL through
5
- // `--dsh-link` so Glass connects without the user pasting anything. When Glass
6
- // is already running, its single-instance lock focuses the existing window and
7
- // re-connects it with the new link.
8
- //
9
- // Three install shapes have to work:
10
- //
11
- // - the release tarball, which ships the built app in `app/` and an Electron
12
- // runtime in `electron/dist/` (self-contained, nothing to build);
13
- // - the npm package, which ships only this plugin and fetches the same
14
- // tarball once into a cache directory on first launch;
15
- // - a checkout of the repository, where both live outside this package
16
- // (`apps/desktop/out` and the workspace `electron` dependency).
17
- //
18
- // The child also receives `--launcher-pid`; Glass exits when that host process
19
- // disappears, and disposal kills the spawned tree so the companion follows DSH.
20
- import { spawn, spawnSync } from 'node:child_process';
21
- import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
22
- import { get as httpsGet } from 'node:https';
23
- import { createRequire } from 'node:module';
24
- import { homedir, tmpdir } from 'node:os';
25
- import { dirname, join } from 'node:path';
1
+ // DSH starts the companion only after its runtime and authenticated Host are
2
+ // ready. Bootstrap work is cancellable and never replays a saved Host token.
3
+ import { spawn } from 'node:child_process';
4
+ import { statSync } from 'node:fs';
5
+ import { join } from 'node:path';
26
6
  import { fileURLToPath } from 'node:url';
7
+ import { ensureRuntime, resolveInstalled, resolveAvailable } from './runtime.js';
8
+ import { createStartupStatus } from './startup-status.js';
27
9
 
28
10
  export const name = 'dsh-status-bar-plugin';
29
11
  export const inject = ['connection', 'webServer'];
30
-
31
- /** Package root: `lib/index.js` -> the directory that holds `package.json`. */
32
12
  const pluginRoot = fileURLToPath(new URL('../', import.meta.url));
33
-
34
- /** Where the light npm package writes the runtime it downloads on first run. */
35
- const DEFAULT_RUNTIME_URL = 'https://github.com/wfz2006/dsh-status-bar/releases/latest/download/dsh-status-bar.tgz';
36
-
37
- /** The download URL the published manifest advertises, if any. */
38
- function manifestRuntimeUrl() {
39
- try {
40
- const manifest = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
41
- const url = manifest?.dsh?.statusBar?.runtime;
42
- return typeof url === 'string' && url.length > 0 ? url : undefined;
43
- } catch {
44
- return undefined;
45
- }
46
- }
47
-
48
- /** Electron's binary sits at these relative paths inside a `dist/` directory. */
49
- function electronExecutables() {
50
- if (process.platform === 'win32') return ['electron.exe'];
51
- if (process.platform === 'darwin') return [join('Electron.app', 'Contents', 'MacOS', 'Electron')];
52
- return ['electron'];
13
+ function stamp(appDir) { try { return statSync(join(appDir, 'out', 'main', 'index.js')).mtimeMs; } catch { return 0; } }
14
+ function killChild(child) {
15
+ if (!child || child.exitCode !== null || child.pid === undefined) return;
16
+ if (process.platform === 'win32') {
17
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
18
+ killer.on('error', () => { try { child.kill(); } catch { /* Already gone. */ } });
19
+ } else { try { child.kill(); } catch { /* Already gone. */ } }
53
20
  }
54
-
55
- function firstFile(candidates) {
56
- for (const candidate of candidates) {
57
- if (typeof candidate === 'string' && candidate.length > 0 && existsSync(candidate)) return candidate;
21
+ function launchEnv() { const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; return env; }
22
+ export function apply(ctx, config = {}) {
23
+ let child; let timer; let watcher; let controller; let feedback;
24
+ let attempts = 0; let bundleStamp = 0; let preparing = false; let spawning = false;
25
+ let disposed = false; let paused = false; let failed = false;
26
+ let target = resolveAvailable(config, pluginRoot);
27
+ function stopClock() { clearInterval(timer); timer = undefined; }
28
+ function closeStatus() { feedback?.close(); feedback = undefined; }
29
+ function update(state) {
30
+ if (disposed || paused) return;
31
+ feedback ??= createStartupStatus(config, { retry, cancel }); feedback.update(state);
58
32
  }
59
- return undefined;
60
- }
61
-
62
- function hasApp(candidate) {
63
- return typeof candidate === 'string' && candidate.length > 0 && existsSync(join(candidate, 'out', 'main', 'index.js'));
64
- }
65
-
66
- function distBinary(distDir) {
67
- return firstFile(electronExecutables().map(name => join(distDir, name)));
68
- }
69
-
70
- function packageVersion() {
71
- try {
72
- const manifest = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
73
- return typeof manifest.version === 'string' ? manifest.version : '0.0.0';
74
- } catch {
75
- return '0.0.0';
33
+ function fail(message) {
34
+ if (disposed || paused) return;
35
+ failed = true; stopClock();
36
+ update({ phase: 'failed', message, received: 0, total: 0 });
76
37
  }
77
- }
78
-
79
- /**
80
- * The app directory holds `out/main/index.js`. The packaged copy wins, then an
81
- * explicit override, then the checkout layout used while developing.
82
- */
83
- function resolveAppDir(config) {
84
- return firstFile([
85
- config.appDir,
86
- process.env.DSH_STATUS_BAR_APP,
87
- process.env.DSH_GLASS_APP,
88
- join(pluginRoot, 'app'),
89
- join(pluginRoot, '..', '..', 'apps', 'desktop'),
90
- ].filter(hasApp));
91
- }
92
-
93
- function resolveElectron(config, appDir) {
94
- const explicit = firstFile([config.electronBinary, process.env.DSH_STATUS_BAR_ELECTRON, process.env.DSH_GLASS_ELECTRON].filter(value => typeof value === 'string'));
95
- if (explicit) return explicit;
96
-
97
- // Release tarball: the runtime travels with the package.
98
- const bundled = distBinary(join(pluginRoot, 'electron', 'dist'));
99
- if (bundled) return bundled;
100
-
101
- // Source install: use the `electron` package installed next to the app (the
102
- // workspace checkout) or next to this package (a registry dependency).
103
- for (const base of [appDir, pluginRoot]) {
104
- if (!base) continue;
105
- try {
106
- const manifest = createRequire(join(base, 'package.json')).resolve('electron/package.json');
107
- const binary = distBinary(join(dirname(manifest), 'dist'));
108
- if (binary) return binary;
109
- } catch {
110
- // Not installed this way; try the next base.
111
- }
38
+ function cancel() {
39
+ if (disposed || paused) return;
40
+ paused = true; controller?.abort(); stopClock(); closeStatus();
112
41
  }
113
- return undefined;
114
- }
115
-
116
- /** A layout that already has both halves of the companion. */
117
- function resolveInstalled(config) {
118
- const appDir = resolveAppDir(config);
119
- if (!appDir) return undefined;
120
- const electron = resolveElectron(config, appDir);
121
- return electron ? { appDir, electron } : undefined;
122
- }
123
-
124
- function runtimeRoot(config) {
125
- const base = config.runtimeDir
126
- ?? process.env.DSH_STATUS_BAR_RUNTIME
127
- ?? process.env.DSH_GLASS_RUNTIME
128
- ?? (process.platform === 'win32'
129
- ? join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'dsh-status-bar')
130
- : join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'dsh-status-bar'));
131
- return join(base, `runtime-${packageVersion()}`);
132
- }
133
-
134
- function describeError(error) {
135
- return error instanceof Error ? error.message : String(error);
136
- }
137
-
138
- /** Follows redirects; GitHub's release download resolves to a CDN host. */
139
- function download(url, destination, { redirects = 5 } = {}) {
140
- return new Promise((resolve, reject) => {
141
- const request = httpsGet(url, (response) => {
142
- const status = response.statusCode ?? 0;
143
- if (status >= 300 && status < 400 && response.headers.location) {
144
- response.resume();
145
- if (redirects <= 0) {
146
- reject(new Error(`too many redirects while downloading ${url}`));
147
- return;
148
- }
149
- const next = new URL(response.headers.location, url).href;
150
- download(next, destination, { redirects: redirects - 1 }).then(resolve, reject);
151
- return;
152
- }
153
- if (status !== 200) {
154
- response.resume();
155
- reject(new Error(`download failed with HTTP ${status}: ${url}`));
156
- return;
157
- }
158
- const total = Number(response.headers['content-length'] ?? 0);
159
- let received = 0;
160
- let reported = 0;
161
- const file = createWriteStream(destination);
162
- response.on('data', (chunk) => {
163
- received += chunk.length;
164
- if (total > 0) {
165
- const percent = Math.floor((received / total) * 100);
166
- if (percent >= reported + 10) {
167
- reported = percent - (percent % 10);
168
- console.info(`[dsh-status-bar] downloading the Glass runtime: ${reported}% of ${Math.round(total / (1024 * 1024))} MB`);
169
- }
170
- }
171
- });
172
- response.pipe(file);
173
- file.on('finish', () => file.close(() => resolve(received)));
174
- file.on('error', (error) => {
175
- rmSync(destination, { force: true });
176
- reject(error);
42
+ function retry() {
43
+ if (disposed || preparing || spawning || child) return;
44
+ failed = paused = false; attempts = 0;
45
+ target = resolveAvailable(config, pluginRoot) ?? target;
46
+ if (target) startClock(); else prepare();
47
+ }
48
+ function prepare() {
49
+ if (preparing || target || disposed || paused || failed) return;
50
+ preparing = true; const current = new AbortController(); controller = current;
51
+ update({ phase: 'downloading', received: 0, total: 0, message: '首次启动需要准备桌面运行时,完成后自动打开悬浮窗。' });
52
+ void ensureRuntime(config, pluginRoot, { signal: current.signal, onProgress: update }).then(next => {
53
+ if (disposed || paused || current.signal.aborted || controller !== current) return;
54
+ target = resolveInstalled({ ...config, appDir: next.appDir }, pluginRoot) ?? next; bundleStamp = stamp(target.appDir);
55
+ update({ phase: 'launching', message: '运行时已就绪,正在连接 DSH 并打开悬浮窗。' });
56
+ startClock();
57
+ }, error => {
58
+ if (!current.signal.aborted && !disposed && !paused) fail(error instanceof Error ? error.message : '运行时准备失败,请点击重试。');
59
+ }).finally(() => { if (controller === current) { preparing = false; controller = undefined; } });
60
+ }
61
+ function startClock() {
62
+ if (timer || disposed || paused || failed) return;
63
+ timer = setInterval(tryLaunch, 250);
64
+ }
65
+ function spawnTracked(link) {
66
+ spawning = true; stopClock();
67
+ let spawned;
68
+ try {
69
+ spawned = spawn(target.electron, [target.appDir, `--dsh-link=${link}`, `--launcher-pid=${process.pid}`], {
70
+ cwd: target.appDir, detached: true, stdio: 'ignore', env: launchEnv(),
177
71
  });
72
+ } catch { spawning = false; fail('无法启动桌面运行时,请检查系统权限后重试。'); return; }
73
+ child = spawned;
74
+ spawned.once('error', error => {
75
+ if (child === spawned) child = undefined;
76
+ spawning = false;
77
+ const code = /^[A-Z0-9_]+$/.test(error.code ?? '') ? error.code : 'unknown';
78
+ fail(`桌面运行时未能启动(${code}),请检查运行时文件与权限后重试。`);
178
79
  });
179
- request.on('error', (error) => {
180
- rmSync(destination, { force: true });
181
- reject(error);
80
+ spawned.once('spawn', () => {
81
+ spawning = false;
82
+ if (disposed || paused) { killChild(spawned); return; }
83
+ bundleStamp = stamp(target.appDir); closeStatus();
84
+ console.info('[dsh-status-bar] Glass desktop companion launched (authenticated link redacted)');
182
85
  });
183
- request.setTimeout(15 * 60 * 1000, () => request.destroy(new Error(`timed out downloading ${url}`)));
184
- });
185
- }
186
-
187
- /** `tar` ships with Windows 10+ (bsdtar), macOS and every mainstream Linux. */
188
- function extract(tarball, directory) {
189
- const result = spawnSync('tar', ['-xzf', tarball, '-C', directory], { stdio: 'ignore', windowsHide: true });
190
- if (result.status !== 0) {
191
- throw new Error(`could not unpack ${tarball} (tar exited with ${result.status ?? result.signal}); extract it manually to ${directory}`);
86
+ spawned.once('exit', (code, signal) => {
87
+ if (child !== spawned) return;
88
+ child = undefined; spawning = false;
89
+ if (!disposed && !paused && (code !== 0 || signal)) fail('悬浮窗进程意外退出。点击重试可重新启动;若仍失败,请重新安装运行时。');
90
+ });
91
+ spawned.unref();
192
92
  }
193
- }
194
-
195
- /**
196
- * Downloads and unpacks the release tarball into the per-version cache, then
197
- * returns the same `{ appDir, electron }` shape as a bundled install.
198
- */
199
- async function fetchRuntime(config) {
200
- const url = config.downloadUrl ?? process.env.DSH_STATUS_BAR_RUNTIME_URL ?? process.env.DSH_GLASS_RUNTIME_URL ?? manifestRuntimeUrl() ?? DEFAULT_RUNTIME_URL;
201
- const root = runtimeRoot(config);
202
- const cached = { appDir: join(root, 'app'), electron: distBinary(join(root, 'electron', 'dist')) };
203
- if (hasApp(cached.appDir) && cached.electron) return cached;
204
-
205
- const staging = `${root}.staging`;
206
- const tarball = join(tmpdir(), `dsh-status-bar-${process.pid}-${Date.now()}.tgz`);
207
- rmSync(staging, { recursive: true, force: true });
208
- mkdirSync(staging, { recursive: true });
209
- console.info(`[dsh-status-bar] first run: fetching the ~155 MB Glass runtime from ${url}`);
210
- try {
211
- const bytes = await download(url, tarball);
212
- console.info(`[dsh-status-bar] unpacking the Glass runtime (${Math.round(bytes / (1024 * 1024))} MB)`);
213
- extract(tarball, staging);
214
- const unpacked = join(staging, 'package');
215
- if (!hasApp(join(unpacked, 'app')) || !distBinary(join(unpacked, 'electron', 'dist'))) {
216
- throw new Error('the downloaded archive does not contain app/ and electron/dist/');
217
- }
218
- rmSync(root, { recursive: true, force: true });
219
- mkdirSync(root, { recursive: true });
220
- renameSync(join(unpacked, 'app'), join(root, 'app'));
221
- renameSync(join(unpacked, 'electron'), join(root, 'electron'));
222
- } finally {
223
- rmSync(tarball, { force: true });
224
- rmSync(staging, { recursive: true, force: true });
93
+ function tryLaunch() {
94
+ if (disposed || paused || failed || spawning || child) return;
95
+ if (!target) { prepare(); return; }
96
+ if (++attempts > 600) { fail('DSH 本地服务尚未就绪,悬浮窗未启动。待 DSH 加载完成后点击重试。'); return; }
97
+ let link;
98
+ try { link = ctx.connection.authenticatedUrl(ctx.webServer.getOrigin()); } catch { return; }
99
+ if (typeof link !== 'string' || !/^https?:\/\//.test(link)) return;
100
+ spawnTracked(link);
225
101
  }
226
-
227
- const electron = distBinary(join(root, 'electron', 'dist'));
228
- if (!electron) throw new Error(`the runtime in ${root} has no Electron executable`);
229
- return { appDir: join(root, 'app'), electron };
230
- }
231
-
232
- function killChild(child) {
233
- if (!child || child.exitCode !== null || child.pid === undefined) return;
234
- try { child.kill(); } catch { /* already gone */ }
235
- if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
236
- }
237
-
238
- function bundleMtimeOf(appDir) {
239
- try { return statSync(join(appDir, 'out', 'main', 'index.js')).mtimeMs; } catch { return 0; }
240
- }
241
-
242
- export function apply(ctx, config = {}) {
243
- if (config.autoLaunch === false) return;
244
- let child;
245
- let attempts = 0;
246
- let timer;
247
- let watcher;
248
- let bundleStamp = 0;
249
- let target = resolveInstalled(config);
250
- let preparing = false;
251
- let fatal;
252
-
253
- const stopClock = () => {
254
- if (timer) clearInterval(timer);
255
- timer = undefined;
256
- };
257
-
258
- const startClock = () => {
259
- if (!timer) timer = setInterval(tryLaunch, 250);
260
- };
261
-
262
- const useTarget = (next) => {
263
- target = next;
264
- bundleStamp = bundleMtimeOf(next.appDir);
265
- startClock();
266
- };
267
-
268
- const prepare = () => {
269
- if (preparing || target || fatal) return;
270
- preparing = true;
271
- fetchRuntime(config).then((next) => {
272
- preparing = false;
273
- console.info(`[dsh-status-bar] Glass runtime ready at ${next.appDir}`);
274
- useTarget(next);
275
- }, (error) => {
276
- preparing = false;
277
- fatal = `[dsh-status-bar] could not prepare the Glass runtime: ${describeError(error)}`;
278
- console.warn(fatal);
279
- stopClock();
102
+ function wake(link) {
103
+ return new Promise((resolve, reject) => {
104
+ let spawned;
105
+ try {
106
+ spawned = spawn(target.electron, [target.appDir, `--dsh-link=${link}`, `--launcher-pid=${process.pid}`], {
107
+ cwd: target.appDir, detached: true, stdio: 'ignore', env: launchEnv(),
108
+ });
109
+ } catch (error) { reject(error); return; }
110
+ spawned.once('spawn', () => resolve());
111
+ spawned.once('error', reject);
112
+ spawned.unref();
280
113
  });
281
- };
282
-
283
- const tryLaunch = () => {
284
- if (fatal) return;
114
+ }
115
+ // The command is the recovery entry for a hidden window and for a companion
116
+ // the user quit earlier. A running instance receives the second-instance
117
+ // hand-off and shows itself; a missing one is launched with a fresh link.
118
+ async function showCompanion() {
119
+ if (disposed) return { kind: 'error', text: '插件已停用,请重启 DSH。' };
120
+ failed = paused = false; attempts = 0;
285
121
  if (!target) {
286
- // The npm build has no app yet: fetch it once, then keep waiting for the
287
- // web server in the background instead of counting down against it.
288
- prepare();
289
- return;
122
+ if (!preparing) prepare();
123
+ return { kind: 'success', text: '正在准备桌面运行时,完成后会自动打开悬浮窗。' };
290
124
  }
291
- attempts += 1;
292
- if (attempts > 600) {
293
- stopClock();
294
- console.warn('[dsh-status-bar] no web server origin after 150s; the Glass companion was not started');
295
- return;
296
- }
297
- let origin;
298
- try { origin = ctx.webServer.getOrigin(); } catch { return; }
299
125
  let link;
300
- try { link = ctx.connection.authenticatedUrl(origin); } catch { return; }
301
- if (!link || !/^https?:\/\//.test(link)) return;
302
-
303
- const env = { ...process.env };
304
- delete env.ELECTRON_RUN_AS_NODE;
305
- const spawned = spawn(target.electron, [target.appDir, `--dsh-link=${link}`, `--launcher-pid=${process.pid}`], {
306
- cwd: target.appDir,
307
- detached: true,
308
- stdio: 'ignore',
309
- env,
310
- });
311
- child = spawned;
312
- spawned.on('exit', () => { if (child === spawned) child = undefined; });
313
- spawned.unref();
314
- bundleStamp = bundleMtimeOf(target.appDir);
315
- stopClock();
316
- console.info('[dsh-status-bar] Glass desktop companion launched (authenticated link redacted)');
317
- };
318
-
319
- if (typeof ctx.effect === 'function') {
320
- ctx.effect(() => () => {
321
- stopClock();
322
- if (watcher) clearInterval(watcher);
323
- watcher = undefined;
324
- killChild(child);
325
- child = undefined;
326
- });
126
+ try { link = ctx.connection.authenticatedUrl(ctx.webServer.getOrigin()); } catch { link = undefined; }
127
+ if (typeof link !== 'string' || !/^https?:\/\//.test(link)) return { kind: 'error', text: 'DSH 本地服务尚未就绪,请稍后再试。' };
128
+ if (child) {
129
+ try { await wake(link); return { kind: 'success', text: '已请求显示悬浮窗。' }; }
130
+ catch { return { kind: 'error', text: '无法显示悬浮窗,请稍后重试。' }; }
131
+ }
132
+ if (spawning) return { kind: 'success', text: '正在打开悬浮窗。' };
133
+ startClock(); tryLaunch();
134
+ return { kind: 'success', text: '正在打开悬浮窗。' };
327
135
  }
328
-
329
- if (target) {
330
- startClock();
331
- } else {
332
- prepare();
136
+ function registerCommand() {
137
+ if (typeof ctx.inject !== 'function') return;
138
+ ctx.inject(['commands'], commandCtx => {
139
+ const register = () => commandCtx.commands.register({
140
+ name: 'status-bar',
141
+ description: '显示或重新启动 DSH Status Bar 悬浮窗',
142
+ recordInput: false,
143
+ handler: showCompanion,
144
+ });
145
+ if (typeof commandCtx.effect === 'function') commandCtx.effect(register);
146
+ else if (typeof ctx.effect === 'function') ctx.effect(register);
147
+ else register();
148
+ });
333
149
  }
334
-
150
+ registerCommand();
151
+ if (config.autoLaunch === false) return;
152
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => {
153
+ disposed = true; controller?.abort(); stopClock(); clearInterval(watcher); watcher = undefined;
154
+ closeStatus(); killChild(child); child = undefined;
155
+ });
156
+ if (target) startClock(); else prepare();
335
157
  if (config.autoReload !== false) {
336
- // Rebuild-and-reload loop: when the desktop bundle changes, relaunch the
337
- // running companion with the current authenticated link. A user-initiated
338
- // quit leaves `child` empty, so it is never resurrected by a build.
339
158
  watcher = setInterval(() => {
340
- if (!target) return;
341
- const stamp = bundleMtimeOf(target.appDir);
342
- if (!child || !stamp || bundleStamp === 0 || stamp === bundleStamp) return;
343
- bundleStamp = stamp;
344
- console.info('[dsh-status-bar] desktop bundle changed; relaunching the Glass companion');
345
- killChild(child);
346
- child = undefined;
347
- startClock();
348
- }, 1500);
159
+ if (!target || !child || spawning || disposed || paused) return;
160
+ const nextStamp = stamp(target.appDir);
161
+ if (!nextStamp || !bundleStamp || nextStamp === bundleStamp) return;
162
+ bundleStamp = nextStamp; const previous = child; child = undefined; killChild(previous); attempts = 0; startClock();
163
+ }, 1500); watcher.unref();
349
164
  }
350
165
  }
package/lib/runtime.js ADDED
@@ -0,0 +1,132 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, openSync, closeSync, writeFileSync, statSync, readdirSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve, basename } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { abortError, checkAbort, delay, downloadArchive } from './download.js';
8
+
9
+ const DEFAULT_RUNTIME_URL = 'https://github.com/wfz2006/dsh-status-bar/releases/latest/download/dsh-status-bar.tgz';
10
+ const json = path => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; } };
11
+ const firstFile = values => values.find(value => { try { return typeof value === 'string' && statSync(value).isFile(); } catch { return false; } });
12
+ const hasApp = path => typeof path === 'string' && !!firstFile([join(path, 'out', 'main', 'index.js')]);
13
+ export function packageVersion(root) {
14
+ const version = json(join(root, 'package.json')).version;
15
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)) throw new Error('插件版本信息无效,请重新安装插件。');
16
+ return version;
17
+ }
18
+ export function cacheBase(config) {
19
+ return resolve(config.runtimeDir ?? process.env.DSH_STATUS_BAR_RUNTIME ?? process.env.DSH_GLASS_RUNTIME ??
20
+ (process.platform === 'win32' ? join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'dsh-status-bar') : join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'dsh-status-bar')));
21
+ }
22
+ export function runtimeUrl(config, root) {
23
+ const explicit = config.downloadUrl ?? process.env.DSH_STATUS_BAR_RUNTIME_URL ?? process.env.DSH_GLASS_RUNTIME_URL;
24
+ const advertised = json(join(root, 'package.json')).dsh?.statusBar?.runtime;
25
+ const url = explicit ?? advertised ?? DEFAULT_RUNTIME_URL;
26
+ return !explicit && url === DEFAULT_RUNTIME_URL ? `https://github.com/wfz2006/dsh-status-bar/releases/download/v${packageVersion(root)}/dsh-status-bar.tgz` : url;
27
+ }
28
+ function binary(dist) {
29
+ const name = process.platform === 'win32' ? 'electron.exe' : process.platform === 'darwin' ? join('Electron.app', 'Contents', 'MacOS', 'Electron') : 'electron';
30
+ return firstFile([join(dist, name)]);
31
+ }
32
+ export function resolveInstalled(config, root) {
33
+ const appDir = [config.appDir, process.env.DSH_STATUS_BAR_APP, process.env.DSH_GLASS_APP, join(root, 'app'), join(root, '..', '..', 'apps', 'desktop')].find(hasApp);
34
+ if (!appDir) return;
35
+ let electron = firstFile([config.electronBinary, process.env.DSH_STATUS_BAR_ELECTRON, process.env.DSH_GLASS_ELECTRON]) ?? binary(join(root, 'electron', 'dist'));
36
+ for (const base of [appDir, root]) {
37
+ if (electron) break;
38
+ try { electron = binary(join(dirname(createRequire(join(base, 'package.json')).resolve('electron/package.json')), 'dist')); } catch { /* Try the other install shape. */ }
39
+ }
40
+ return electron ? { appDir, electron } : undefined;
41
+ }
42
+ function cachedRuntime(root, version) {
43
+ const appDir = join(root, 'app'); const electron = binary(join(root, 'electron', 'dist'));
44
+ return hasApp(appDir) && electron && json(join(appDir, 'package.json')).version === version ? { appDir, electron } : undefined;
45
+ }
46
+ export function resolveAvailable(config, root) {
47
+ const direct = resolveInstalled(config, root); if (direct) return direct;
48
+ const version = packageVersion(root);
49
+ const cached = cachedRuntime(join(cacheBase(config), `runtime-${version}`), version);
50
+ return cached ? resolveInstalled({ ...config, appDir: cached.appDir }, root) ?? cached : undefined;
51
+ }
52
+ async function lock(path, signal, onProgress) {
53
+ const owner = randomUUID();
54
+ for (;;) {
55
+ checkAbort(signal);
56
+ try {
57
+ const fd = openSync(path, 'wx');
58
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid, owner })); } finally { closeSync(fd); }
59
+ return () => { if (json(path).owner === owner) rmSync(path, { force: true }); };
60
+ } catch (error) {
61
+ if (error.code !== 'EEXIST') throw error;
62
+ const current = json(path); let alive = true;
63
+ if (Number.isSafeInteger(current.pid) && current.pid > 0) {
64
+ try { process.kill(current.pid, 0); } catch (error) { alive = error.code !== 'ESRCH'; }
65
+ } else { try { alive = Date.now() - statSync(path).mtimeMs < 10000; } catch { continue; } }
66
+ if (!alive) { rmSync(path, { force: true }); continue; }
67
+ onProgress({ phase: 'waiting', message: '另一个 DSH 窗口正在准备同版本运行时,完成后会自动继续。' });
68
+ await delay(500, signal);
69
+ }
70
+ }
71
+ }
72
+ function run(program, args, signal, capture = false) {
73
+ checkAbort(signal);
74
+ return new Promise((resolvePromise, reject) => {
75
+ const child = spawn(program, args, { stdio: ['ignore', capture ? 'pipe' : 'ignore', 'ignore'], windowsHide: true });
76
+ let output = ''; let excessive = false;
77
+ child.stdout?.on('data', chunk => { output += chunk.toString(); if (output.length > 16 * 1024 * 1024) { excessive = true; child.kill(); } });
78
+ const abort = () => child.kill(); signal?.addEventListener('abort', abort, { once: true });
79
+ child.once('error', error => { signal?.removeEventListener('abort', abort); reject(error); });
80
+ child.once('close', code => {
81
+ signal?.removeEventListener('abort', abort);
82
+ if (signal?.aborted) reject(abortError());
83
+ else if (code !== 0 || excessive) reject(new Error('运行时压缩包无法解压,请重试或安装自包含版本。'));
84
+ else resolvePromise(output);
85
+ });
86
+ if (signal?.aborted) abort();
87
+ });
88
+ }
89
+ function removeOwned(path, root) {
90
+ if (dirname(path) !== dirname(root) || !basename(path).startsWith(`${basename(root)}.`)) throw new Error('缓存临时路径无效。');
91
+ rmSync(path, { recursive: true, force: true });
92
+ }
93
+ export async function ensureRuntime(config, pluginRoot, { signal, onProgress = () => {} } = {}) {
94
+ const version = packageVersion(pluginRoot); const base = cacheBase(config); const root = join(base, `runtime-${version}`);
95
+ const cached = cachedRuntime(root, version); if (cached) return cached;
96
+ mkdirSync(base, { recursive: true });
97
+ const release = await lock(`${root}.lock`, signal, onProgress);
98
+ const part = `${root}.tgz.part`; const staging = `${root}.staging-${randomUUID()}`; const previous = `${root}.previous-${randomUUID()}`;
99
+ try {
100
+ const installedByPeer = cachedRuntime(root, version); if (installedByPeer) return installedByPeer;
101
+ // The exclusive lock proves that no other preparer owns these generated
102
+ // staging folders. An abrupt Host exit must not leave one unpack per retry.
103
+ for (const name of readdirSync(base)) if (name.startsWith(`${basename(root)}.staging-`) && /^[0-9a-f-]{36}$/.test(name.slice(`${basename(root)}.staging-`.length))) removeOwned(join(base, name), root);
104
+ await downloadArchive(runtimeUrl(config, pluginRoot), part, { signal, onProgress }); checkAbort(signal);
105
+ onProgress({ phase: 'unpacking', message: '下载完成,正在校验并解压运行时。' });
106
+ let entries;
107
+ try { entries = await run('tar', ['-tvzf', part], signal, true); }
108
+ catch (error) { if (error.name !== 'AbortError') { rmSync(part, { force: true }); rmSync(`${part}.json`, { force: true }); } throw error; }
109
+ // Reject links and traversal before extraction; only this package subtree is expected.
110
+ for (const line of entries.split(/\r?\n/).filter(Boolean)) {
111
+ const entry = line.match(/\b(package\/.*)$/)?.[1];
112
+ if (!['-', 'd'].includes(line[0]) || !entry || entry.includes('\\') || entry.split('/').some(part => part === '..' || part.includes(':'))) {
113
+ rmSync(part, { force: true }); rmSync(`${part}.json`, { force: true });
114
+ throw new Error('运行时压缩包包含不支持的条目。');
115
+ }
116
+ }
117
+ mkdirSync(staging);
118
+ await run('tar', ['-xzf', part, '-C', staging, '--no-same-owner', '--no-same-permissions'], signal);
119
+ const unpacked = join(staging, 'package');
120
+ if (json(join(unpacked, 'package.json')).version !== version || !cachedRuntime(unpacked, version)) {
121
+ rmSync(part, { force: true }); rmSync(`${part}.json`, { force: true });
122
+ throw new Error(`下载内容与插件 ${version} 不匹配,未覆盖现有缓存。请重试或安装对应版本的自包含包。`);
123
+ }
124
+ checkAbort(signal);
125
+ if (existsSync(root)) renameSync(root, previous);
126
+ try { renameSync(unpacked, root); }
127
+ catch (error) { if (existsSync(previous)) renameSync(previous, root); throw error; }
128
+ removeOwned(previous, root);
129
+ rmSync(part, { force: true }); rmSync(`${part}.json`, { force: true });
130
+ return cachedRuntime(root, version);
131
+ } finally { try { removeOwned(staging, root); } finally { release(); } }
132
+ }
@@ -0,0 +1,51 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { cacheBase } from './runtime.js';
7
+
8
+ /** Bootstrap feedback cannot depend on the Electron runtime being downloaded.
9
+ * The native window reads only these non-sensitive progress/command files. */
10
+ export function createStartupStatus(config, { retry, cancel }) {
11
+ const id = randomUUID(); const base = cacheBase(config);
12
+ const statePath = join(base, `startup-${id}.json`); const actionPath = join(base, `startup-${id}.action`);
13
+ let panel; let timer; let closed = false; let state = {}; let lastLog = '';
14
+ const remove = () => { for (const path of [statePath, actionPath]) try { rmSync(path, { force: true }); } catch { /* A closing native reader may briefly hold the file. */ } };
15
+ function open() {
16
+ if (timer || closed) return;
17
+ mkdirSync(base, { recursive: true });
18
+ timer = setInterval(() => {
19
+ let action;
20
+ try { action = readFileSync(actionPath, 'utf8').trim(); rmSync(actionPath, { force: true }); } catch { return; }
21
+ if (action === 'retry') retry(); else if (action === 'cancel') cancel();
22
+ }, 250); timer.unref();
23
+ if (process.platform !== 'win32' || config.showStartupUI === false) return;
24
+ panel = spawn(join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
25
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-STA', '-File', fileURLToPath(new URL('./startup-window.ps1', import.meta.url)),
26
+ '-StatePath', statePath, '-ActionPath', actionPath, '-OwnerPid', String(process.pid)],
27
+ { stdio: 'ignore', windowsHide: true });
28
+ panel.once('error', () => { console.warn('[dsh-status-bar] 无法打开准备进度窗口,请查看插件日志。'); panel = undefined; });
29
+ panel.once('exit', () => { panel = undefined; if (!closed) cancel(); else remove(); });
30
+ panel.unref();
31
+ }
32
+ return {
33
+ update(patch) {
34
+ if (closed) return;
35
+ state = { ...state, ...patch };
36
+ // The reader retries transient sharing/partial-write errors on its next tick.
37
+ try { open(); writeFileSync(statePath, JSON.stringify(state)); }
38
+ catch { console.warn('[dsh-status-bar] 无法写入准备状态,请检查运行时缓存目录权限。'); }
39
+ const log = `${state.phase}:${Math.floor((state.total ? state.received / state.total : 0) * 10)}`;
40
+ if (log !== lastLog) {
41
+ lastLog = log;
42
+ (state.phase === 'failed' ? console.warn : console.info)(`[dsh-status-bar] ${state.message ?? state.phase}${state.total ? ` ${Math.min(100, Math.floor(state.received / state.total * 100))}%` : ''}`);
43
+ }
44
+ },
45
+ close() {
46
+ if (closed) return; closed = true; clearInterval(timer); timer = undefined;
47
+ if (panel) { panel.kill(); panel = undefined; }
48
+ remove();
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,75 @@
1
+ param([string]$StatePath, [string]$ActionPath, [int]$OwnerPid)
2
+
3
+ Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase
4
+
5
+ function New-StartupWindow {
6
+ param([string]$StatePath, [string]$ActionPath, [int]$OwnerPid)
7
+ [xml]$markup = @'
8
+ <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
9
+ Title="DSH Status Bar · 准备悬浮窗" Width="456" Height="262"
10
+ ResizeMode="NoResize" WindowStartupLocation="CenterScreen" Background="#F7F9FC"
11
+ FontFamily="Microsoft YaHei UI" FontSize="13" ShowInTaskbar="True">
12
+ <Grid Margin="24,20,24,18">
13
+ <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
14
+ <TextBlock Name="Heading" Text="正在准备悬浮窗" FontSize="20" FontWeight="SemiBold" Foreground="#14263E"/>
15
+ <TextBlock Grid.Row="1" Name="Message" Text="首次启动需要下载桌面运行时。完成后会自动打开。" TextWrapping="Wrap" Foreground="#44566D" Margin="0,12,0,14"/>
16
+ <ProgressBar Grid.Row="2" Name="Progress" Height="6" Minimum="0" Maximum="100" Foreground="#426DDD" Background="#E2E8F0" BorderThickness="0" IsIndeterminate="True"/>
17
+ <TextBlock Grid.Row="3" Name="Detail" Text="连接中…" Foreground="#52657C" Margin="0,10,0,8" TextWrapping="Wrap"/>
18
+ <StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
19
+ <Button Name="Retry" Content="重试" Width="90" Height="30" Margin="0,0,10,0" Visibility="Collapsed" Background="#E6EDFF" Foreground="#244BB5"/>
20
+ <Button Name="Cancel" Content="取消" Width="90" Height="30" Background="#EDF0F5" Foreground="#283C54"/>
21
+ </StackPanel>
22
+ </Grid>
23
+ </Window>
24
+ '@
25
+ $reader = New-Object System.Xml.XmlNodeReader $markup
26
+ $window = [Windows.Markup.XamlReader]::Load($reader)
27
+ $model = @{ Window=$window; Retry=$window.FindName('Retry'); Cancel=$window.FindName('Cancel'); Finished=$false; CommandSent=$false }
28
+ $sendAction = {
29
+ param([string]$action)
30
+ [IO.File]::WriteAllText($ActionPath, $action, (New-Object Text.UTF8Encoding $false))
31
+ $model.CommandSent = $true
32
+ }.GetNewClosure()
33
+ $retryClick = {
34
+ & $sendAction 'retry'
35
+ $model.Retry.IsEnabled = $false
36
+ $window.FindName('Message').Text = '正在重新连接;可验证的已下载部分会继续使用。'
37
+ }.GetNewClosure()
38
+ $model.Retry.Add_Click($retryClick)
39
+ $cancelClick = { & $sendAction 'cancel'; $window.Close() }.GetNewClosure()
40
+ $model.Cancel.Add_Click($cancelClick)
41
+ $timer = New-Object Windows.Threading.DispatcherTimer
42
+ $timer.Interval = [TimeSpan]::FromMilliseconds(300)
43
+ $tick = {
44
+ try { $null = Get-Process -Id $OwnerPid -ErrorAction Stop }
45
+ catch { $model.Finished=$true; $window.Close(); return }
46
+ try {
47
+ $state = [IO.File]::ReadAllText($StatePath) | ConvertFrom-Json -ErrorAction Stop
48
+ $failed = $state.phase -eq 'failed'
49
+ $window.FindName('Heading').Text = $(if($failed){'悬浮窗暂未启动'}else{'正在准备悬浮窗'})
50
+ $window.FindName('Message').Text = [string]$state.message
51
+ $model.Retry.Visibility = $(if($failed){'Visible'}else{'Collapsed'})
52
+ $model.Retry.IsEnabled = $failed
53
+ $model.Cancel.Content = $(if($failed){'稍后再试'}else{'取消'})
54
+ $bar = $window.FindName('Progress')
55
+ $bar.IsIndeterminate = -not $failed -and ($state.phase -ne 'downloading' -or -not $state.total)
56
+ if($state.total){ $bar.Value=[Math]::Min(100, 100 * [double]$state.received / [double]$state.total) }
57
+ $window.FindName('Detail').Text = $(if($failed){'点击重试即可继续,无需重启 DSH。'}elseif($state.phase -eq 'unpacking'){'正在校验和解压,请稍候。'}elseif($state.total){'{0:N1} / {1:N1} MB · 仅本版本首次需要' -f ($state.received/1MB),($state.total/1MB)}else{'请保持 DSH 运行;可随时取消,下次继续。'})
58
+ } catch { # A writer can briefly hold the file. Retry on the next tick.
59
+ }
60
+ }.GetNewClosure()
61
+ $timer.Add_Tick($tick)
62
+ $closing = {
63
+ $timer.Stop()
64
+ if(-not $model.Finished -and -not $model.CommandSent){ & $sendAction 'cancel' }
65
+ }.GetNewClosure()
66
+ $window.Add_Closed($closing)
67
+ $timer.Start()
68
+ $model.Tick=$tick
69
+ return $model
70
+ }
71
+
72
+ if($MyInvocation.InvocationName -ne '.') {
73
+ $ui = New-StartupWindow -StatePath $StatePath -ActionPath $ActionPath -OwnerPid $OwnerPid
74
+ $null = $ui.Window.ShowDialog()
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-status-bar-plugin",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Floating glass status bar for DeepSeek Harness: a frameless always-on-top window that shows session progress, pending approvals and questions.",
5
5
  "license": "MIT",
6
6
  "author": "wfz2006",
package/screenshots.json CHANGED
@@ -1,7 +1,7 @@
1
- [
2
- "assets/capsule.png",
3
- "assets/peek.png",
4
- "assets/expanded.png",
5
- "assets/approval.png",
6
- "assets/settings.png"
7
- ]
1
+ [
2
+ "assets/capsule.png",
3
+ "assets/peek.png",
4
+ "assets/expanded.png",
5
+ "assets/approval.png",
6
+ "assets/settings.png"
7
+ ]