dsh-status-bar-plugin 0.2.5 → 0.2.6
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 +21 -21
- package/README.md +141 -100
- package/cordis.patch.yml +12 -12
- package/lib/download.js +153 -0
- package/lib/index.js +141 -326
- package/lib/runtime.js +132 -0
- package/lib/startup-status.js +51 -0
- package/lib/startup-window.ps1 +75 -0
- package/package.json +1 -1
- package/screenshots.json +7 -7
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
|
-
##
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
function validator(headers) {
|
|
23
|
+
const tag = headers.etag;
|
|
24
|
+
return typeof tag === 'string' && !tag.startsWith('W/') ? tag : headers['last-modified'];
|
|
25
|
+
}
|
|
26
|
+
function finalHeaders(file) {
|
|
27
|
+
try {
|
|
28
|
+
const blocks = readFileSync(file, 'utf8').split(/\r?\n\r?\n/).filter(block => /^HTTP\//.test(block));
|
|
29
|
+
const last = blocks.at(-1); if (!last) return {};
|
|
30
|
+
const lines = last.split(/\r?\n/); const status = Number(lines.shift()?.split(' ')[1]);
|
|
31
|
+
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()]; })) };
|
|
32
|
+
} catch { return {}; }
|
|
33
|
+
}
|
|
34
|
+
function metadata(headers, source) {
|
|
35
|
+
const range = String(headers['content-range'] ?? '').match(/^bytes \d+-\d+\/(\d+)$/);
|
|
36
|
+
return { source, validator: validator(headers), total: range ? Number(range[1]) : Number(headers['content-length']) || 0 };
|
|
37
|
+
}
|
|
38
|
+
function curlAttempt(url, part, previous, options) {
|
|
39
|
+
const headersFile = `${part}.headers`;
|
|
40
|
+
rmSync(headersFile, { force: true });
|
|
41
|
+
const offset = size(part);
|
|
42
|
+
const program = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'curl.exe') : 'curl';
|
|
43
|
+
const args = ['--silent', '--show-error', '--location', '--fail', '--connect-timeout', '15', '--max-time', String(options.timeoutMs / 1000),
|
|
44
|
+
'--speed-limit', '1', '--speed-time', String(Math.max(1, Math.ceil(options.idleMs / 1000))), '--proto-redir', '=https,http',
|
|
45
|
+
'--dump-header', headersFile, '--output', part];
|
|
46
|
+
if (new URL(url).protocol === 'https:') args.push('--proto', '=https', '--proto-redir', '=https');
|
|
47
|
+
if (offset) args.push('--continue-at', String(offset), '--header', `If-Range: ${previous.validator}`);
|
|
48
|
+
args.push('--url', url);
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let child;
|
|
51
|
+
const progress = () => {
|
|
52
|
+
const result = finalHeaders(headersFile);
|
|
53
|
+
if (result.status === 200 || result.status === 206) {
|
|
54
|
+
const next = metadata(result.headers, options.source);
|
|
55
|
+
writeFileSync(`${part}.json`, JSON.stringify(next));
|
|
56
|
+
options.onProgress({ received: size(part), total: next.total });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const abort = () => child?.kill();
|
|
60
|
+
try { child = spawn(program, args, { windowsHide: true, stdio: 'ignore' }); }
|
|
61
|
+
catch (error) { reject(error); return; }
|
|
62
|
+
const timer = setInterval(() => { try { progress(); } catch (error) { child.kill(); reject(error); } }, 250); timer.unref();
|
|
63
|
+
const exiting = () => child.kill(); process.once('exit', exiting);
|
|
64
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
65
|
+
child.once('error', error => { clearInterval(timer); process.removeListener('exit', exiting); options.signal?.removeEventListener('abort', abort); reject(error); });
|
|
66
|
+
child.once('close', code => {
|
|
67
|
+
clearInterval(timer); options.signal?.removeEventListener('abort', abort);
|
|
68
|
+
process.removeListener('exit', exiting);
|
|
69
|
+
try {
|
|
70
|
+
progress(); checkAbort(options.signal);
|
|
71
|
+
const response = finalHeaders(headersFile);
|
|
72
|
+
if (code === 0) { resolve(); return; }
|
|
73
|
+
const completed = response.status === 416 && String(response.headers?.['content-range']).match(/^bytes \*\/(\d+)$/);
|
|
74
|
+
if (completed && Number(completed[1]) === offset && previous.total === offset) { resolve(); return; }
|
|
75
|
+
if (code === 33 || response.status === 416) reset(part);
|
|
76
|
+
reject(new Error(response.status >= 400 ? `下载服务返回 HTTP ${response.status}。` : `下载连接中断或超时(curl ${code ?? 'stopped'})。`));
|
|
77
|
+
} catch (error) { reject(error); }
|
|
78
|
+
finally { rmSync(headersFile, { force: true }); }
|
|
79
|
+
});
|
|
80
|
+
if (options.signal?.aborted) abort();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async function nodeAttempt(url, part, previous, options, redirects = 5) {
|
|
84
|
+
checkAbort(options.signal);
|
|
85
|
+
const offset = size(part); let request; let timeout;
|
|
86
|
+
try {
|
|
87
|
+
const response = await new Promise((resolve, reject) => {
|
|
88
|
+
const headers = offset ? { Range: `bytes=${offset}-`, 'If-Range': previous.validator } : {};
|
|
89
|
+
request = (new URL(url).protocol === 'https:' ? httpsGet : httpGet)(url, { headers, signal: options.signal }, resolve);
|
|
90
|
+
request.once('error', reject);
|
|
91
|
+
request.setTimeout(options.idleMs, () => request.destroy(new Error('下载连接长时间没有数据。')));
|
|
92
|
+
timeout = setTimeout(() => request.destroy(new Error('下载超时。')), options.timeoutMs); timeout.unref();
|
|
93
|
+
});
|
|
94
|
+
const status = response.statusCode ?? 0;
|
|
95
|
+
if (status >= 300 && status < 400 && response.headers.location) {
|
|
96
|
+
response.resume();
|
|
97
|
+
const next = new URL(response.headers.location, url);
|
|
98
|
+
if (redirects <= 0 || new URL(url).protocol === 'https:' && next.protocol !== 'https:') throw new Error('下载重定向无效。');
|
|
99
|
+
return await nodeAttempt(next.href, part, previous, options, redirects - 1);
|
|
100
|
+
}
|
|
101
|
+
const complete = status === 416 && String(response.headers['content-range']).match(/^bytes \*\/(\d+)$/);
|
|
102
|
+
if (complete && Number(complete[1]) === offset && previous.total === offset) { response.resume(); return; }
|
|
103
|
+
if (status !== 200 && status !== 206) { response.resume(); if (status === 416) reset(part); throw new Error(`下载服务返回 HTTP ${status}。`); }
|
|
104
|
+
const range = String(response.headers['content-range'] ?? '').match(/^bytes (\d+)-(\d+)\/(\d+)$/);
|
|
105
|
+
if (status === 206 && (!offset || !range || Number(range[1]) !== offset || Number(range[2]) >= Number(range[3]))) {
|
|
106
|
+
response.destroy(); reset(part); throw new Error('续传范围不匹配,下一次重新下载。');
|
|
107
|
+
}
|
|
108
|
+
const next = metadata(response.headers, options.source);
|
|
109
|
+
writeFileSync(`${part}.json`, JSON.stringify(next));
|
|
110
|
+
let received = status === 206 ? offset : 0;
|
|
111
|
+
const report = () => { try { options.onProgress({ received, total: next.total }); } catch (error) { request.destroy(error); } };
|
|
112
|
+
response.on('data', chunk => { received += chunk.length; report(); });
|
|
113
|
+
const progress = setInterval(report, 250); progress.unref();
|
|
114
|
+
try { await pipeline(response, createWriteStream(part, { flags: status === 206 ? 'a' : 'w' }), { signal: options.signal }); }
|
|
115
|
+
finally { clearInterval(progress); }
|
|
116
|
+
if (next.total && size(part) !== next.total) throw new Error('下载文件不完整。');
|
|
117
|
+
} finally { clearTimeout(timeout); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The partial file and its entity validator survive network errors/restarts.
|
|
121
|
+
* Curl uses the working Windows network path; Node is only the missing-curl fallback. */
|
|
122
|
+
export async function downloadArchive(url, part, { signal, onProgress = () => {}, attempts = 3, idleMs = 30000, timeoutMs = 300000, backend = 'auto' } = {}) {
|
|
123
|
+
const parsed = new URL(url);
|
|
124
|
+
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。');
|
|
125
|
+
const source = createHash('sha256').update(url).digest('hex');
|
|
126
|
+
let useNode = backend === 'node'; let lastError;
|
|
127
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
128
|
+
checkAbort(signal);
|
|
129
|
+
const previous = json(`${part}.json`);
|
|
130
|
+
if (previous.source !== source || !previous.validator) reset(part);
|
|
131
|
+
if (existsSync(part) && previous.source === source && previous.total > 0 && size(part) === previous.total) return;
|
|
132
|
+
onProgress({ phase: 'downloading', received: size(part), total: previous.total || 0, attempt, message: '正在下载桌面运行时,完成后自动打开悬浮窗。' });
|
|
133
|
+
let reported = 0;
|
|
134
|
+
const options = { source, signal, idleMs, timeoutMs, onProgress: progress => {
|
|
135
|
+
const now = Date.now(); if (now - reported < 250 && progress.received !== progress.total) return;
|
|
136
|
+
reported = now; onProgress({ phase: 'downloading', attempt, ...progress });
|
|
137
|
+
} };
|
|
138
|
+
try {
|
|
139
|
+
if (!useNode) {
|
|
140
|
+
try { await curlAttempt(url, part, previous, options); }
|
|
141
|
+
catch (error) { if (!['ENOENT', 'EACCES', 'EPERM'].includes(error.code)) throw error; useNode = true; await nodeAttempt(url, part, previous, options); }
|
|
142
|
+
} else await nodeAttempt(url, part, previous, options);
|
|
143
|
+
checkAbort(signal); return;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
checkAbort(signal); lastError = error;
|
|
146
|
+
if (attempt < attempts) {
|
|
147
|
+
onProgress({ phase: 'retrying', message: `下载中断,${attempt} 秒后重试;可验证的已下载部分会保留。`, attempt });
|
|
148
|
+
await delay(attempt * 1000, signal);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`${lastError?.message ?? '运行时下载失败。'} 已保留可续传的数据,可点击重试。`);
|
|
153
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,350 +1,165 @@
|
|
|
1
|
-
// DSH
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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(
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
342
|
-
if (!
|
|
343
|
-
bundleStamp =
|
|
344
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.6",
|
|
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
|
+
]
|