dsh-reload-buttons 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-reload-buttons contributors
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.en.md ADDED
@@ -0,0 +1,83 @@
1
+ # dsh-reload-buttons
2
+
3
+ <img src="assets/icon-180.png" width="96" align="right" alt="dsh-reload-buttons">
4
+
5
+ [中文](README.md) | **English**
6
+
7
+ Two icon buttons in the top-left corner of the DSH header: **reload the UI** and **restart the app**. No more closing the whole application by hand after changing a plugin or a config.
8
+
9
+ ## What each button does
10
+
11
+ | Button | Desktop app | `dsh web` |
12
+ |---|---|---|
13
+ | Reload | Manifest still valid → full page reload; manifest stale → re-align client plugins in place against the host's current graph, no reload, no closing the app | Same |
14
+ | Restart | Close the shell (the Electron main process) → the kernel shuts down cleanly when its IPC disconnects → a detached script relaunches the exe, retrying once if it does not stay up | End the kernel process; bring-up is up to your process manager |
15
+
16
+ ### Why reload has two paths
17
+
18
+ On the desktop app, a page reload uses the client-plugin manifest **snapshotted when the kernel started** — the main process only knows that copy. After a client plugin changes, the bundle URLs in that snapshot 404, so a full reload ends in a blank page plus a recovery dialog.
19
+
20
+ The button therefore probes every bundle in the snapshot first: if all of them still resolve, it reloads; if not, it re-aligns the client plugin entries in place — it takes the current graph from the first frame of the host's `/plugins/events` and calls `entries.sync`, without any prompt and without closing the app (only a failed re-sync tells you to use Restart). A full teardown still goes through Restart.
21
+
22
+ ### Why restart closes the shell first
23
+
24
+ The desktop app treats any kernel exit as a fatal failure: if the kernel exits on its own, the main process shows the "app is unusable" dialog. The other direction works — once the Electron main process is gone, the kernel's IPC disconnect triggers its own graceful shutdown, and nothing is left to show a dialog; the detached script then relaunches the exe.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ dsh plugin --profile desktop add github:taoziliyouchong/dsh-reload-buttons
30
+ ```
31
+
32
+ Once published to npm, this also works:
33
+
34
+ ```sh
35
+ dsh plugin --profile desktop add dsh-reload-buttons
36
+ ```
37
+
38
+ Restart DSH afterwards. Replace `desktop` with your own profile name if you use another one.
39
+
40
+ Requirements: the DSH desktop app (the restart button needs the shell to close and relaunch). On `dsh web` the reload button works and restart just ends the kernel process. Node follows DSH; no extra runtime is needed.
41
+
42
+ Compatible with DSH 0.2.1-rc.5 (tested); 0.3 and later are not guaranteed.
43
+
44
+ ## Where the buttons live
45
+
46
+ They are mounted at `conversation.header.leading` — the left-most slot of the conversation header, a `single` slot currently owned by this plugin. Registering the same slot twice at the same priority throws, so this plugin cannot be installed alongside another one that claims that exact slot. To move them, see the comment at the top of `src/entry.jsx` (`conversation.session.header.utilities` is a `list` slot).
47
+
48
+ ## How it works
49
+
50
+ - The host half registers `POST /dsh-reload-buttons/api/restart`: loopback addresses only (`127.*`, `::1`, `::ffff:127.*`) and POST only. On a DSH reachable from other machines, the restart call is rejected with 403.
51
+ - On the desktop app the restart is handed to `assets/restart-app.mjs` (detached spawn): wait 1.2 s so the HTTP response reaches the browser → close the Electron main process → wait for the port to be released (kill the kernel if it is not, after 12 s) → launch the exe and watch its new process pid (retry once if it does not stay up for 8 s).
52
+ - The desktop app uses a different port on every start, so liveness is checked by process pid, never by port.
53
+ - The restart log is written to `$DSH_HOME/logs/dsh-reload-buttons-restart.log`.
54
+ - The reload button is purely client-side; it needs no host support.
55
+ - No network access, nothing uploaded.
56
+
57
+ ## Known limits
58
+
59
+ - Non-desktop installs have no shell to close: restart only ends the kernel process, and bringing it back depends on how you start it.
60
+ - The window closes during a restart and comes back a few seconds later. If a recovery dialog with a "Restart" button shows up at the same time, using it works too.
61
+ - Local only: requests from anything other than loopback are not handled.
62
+
63
+ ## Development
64
+
65
+ The source has two halves:
66
+
67
+ - `index.js` — host side (route, hand-off, shutdown)
68
+ - `src/entry.jsx` — browser side (the two buttons, the reload probes, the in-place re-sync)
69
+
70
+ Editing `src/entry.jsx` requires rebuilding. `lib/client.js` is a build artifact committed to the repository; without it the UI does not load:
71
+
72
+ ```sh
73
+ npm install
74
+ npm run build:client # regenerate lib/client.js
75
+ npm run check:client # verify the committed artifact matches the source
76
+ npm run smoke # structure / registration / authorization / artifact
77
+ ```
78
+
79
+ Build rules: keep `react` and `react/jsx-runtime` external (the DSH client module table provides them); the `id` inside the `window.__ModuleLoader__.load({ id, factory })` envelope must equal the package name.
80
+
81
+ ## License
82
+
83
+ MIT
package/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # dsh-reload-buttons
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <img src="assets/icon-180.png" width="96" align="right" alt="dsh-reload-buttons">
4
+
5
+ **中文** | [English](README.en.md)
6
+
7
+ 在 DSH 界面左上角放两个图标按钮:**刷新界面**、**重启应用**。改完插件或配置不用再手动关掉整个应用。
8
+
9
+ ## 两个按钮各自做什么
10
+
11
+ | 按钮 | 桌面端 | `dsh web` |
12
+ |---|---|---|
13
+ | 刷新 | 清单没过期 → 整页重载;清单过期 → 就地按宿主当前图对齐客户端插件,不重载、不关应用 | 同左 |
14
+ | 重启 | 关掉界面壳(Electron 主进程)→ 内核随 IPC 断开优雅收尾 → 独立脚本把 exe 拉起来,起不来再试一次 | 结束内核进程,重开交给你的启动方式 |
15
+
16
+ ### 刷新为什么分两路
17
+
18
+ 桌面端重载页面用的是**内核启动那一刻**拍的客户端插件清单快照(主进程只认那一份)。改过客户端插件后,快照里的 bundle 地址已经 404,整页重载就是白屏加恢复框。
19
+
20
+ 按钮的做法:先探一遍快照里的 bundle 是否都还取得到。全都取得到就真重载;取不到就改为就地重同步——从宿主 `/plugins/events` 首帧取当前图,用 `entries.sync` 对齐客户端插件条目,成功时不提示、不关应用(重同步本身失败才会提示改用「重启」)。彻底重来仍走「重启」。
21
+
22
+ ### 重启为什么先关界面壳
23
+
24
+ 桌面端把内核退出一律当致命故障:内核自己 exit,主进程会弹「应用无法使用」。反过来,Electron 主进程一消失,内核的 IPC 断开会触发它自己的优雅收尾,此时也没有进程能弹框;随后独立脚本把 exe 重新拉起。
25
+
26
+ ## 安装
27
+
28
+ ```sh
29
+ dsh plugin --profile desktop add github:taoziliyouchong/dsh-reload-buttons
30
+ ```
31
+
32
+ 发布到 npm 之后也可以:
33
+
34
+ ```sh
35
+ dsh plugin --profile desktop add dsh-reload-buttons
36
+ ```
37
+
38
+ 装完重启 DSH。用别的 profile 就把 `desktop` 换成你的 profile 名。
39
+
40
+ 要求:DSH 桌面端(重启按钮要关壳重开)。`dsh web` 下刷新可用,重启等于结束内核进程。Node 版本跟随 DSH,不需要额外运行时。
41
+
42
+ 兼容:DSH 0.2.1-rc.5 实测通过;0.3 起不保证。
43
+
44
+ ## 界面位置
45
+
46
+ 按钮挂在会话头部最左的 `conversation.header.leading`——一个 single 槽,当前由本插件占用。同优先级二次注册会抛错,不能和同样占这个座位的插件一起装。要换位置见 `src/entry.jsx` 顶部注释(`conversation.session.header.utilities` 是 list 槽)。
47
+
48
+ ## 机制
49
+
50
+ - 宿主半边注册一条 `POST /dsh-reload-buttons/api/restart`:只接受回环地址(`127.*`、`::1`、`::ffff:127.*`),只接受 POST。远程访问的 DSH 上点重启会被 403 拒绝。
51
+ - 桌面端把重启交棒给 `assets/restart-app.mjs`(detached spawn):等 1.2 秒让 HTTP 响应交回浏览器 → 关掉 Electron 主进程 → 等端口释放(12 秒不放就结束内核)→ 拉起 exe 并按新进程 pid 探活(8 秒不稳就再试一次)。
52
+ - 桌面端每次启动端口都不同,探活只看进程 pid,不看端口。
53
+ - 重启日志写在 `$DSH_HOME/logs/dsh-reload-buttons-restart.log`。
54
+ - 刷新按钮是纯浏览器侧行为,不需要宿主。
55
+ - 不联网、不上传任何内容。
56
+
57
+ ## 已知限制
58
+
59
+ - 非桌面端没有界面壳可关,重启按钮只结束进程,重开取决于你的启动方式。
60
+ - 重启时界面会关掉,几秒后自动回来;若同时弹出带「重启」按钮的恢复框,点它也可以。
61
+ - 只对单机有效:回环地址以外的请求不处理。
62
+
63
+ ## 开发
64
+
65
+ 源码分两半:
66
+
67
+ - `index.js` —— 宿主半边(路由、交棒、退出)
68
+ - `src/entry.jsx` —— 浏览器半边(两个按钮、刷新自检、就地重同步)
69
+
70
+ 改过 `src/entry.jsx` 必须重新打包。`lib/client.js` 是提交进仓库的构建产物,缺了它界面不会加载:
71
+
72
+ ```sh
73
+ npm install
74
+ npm run build:client # 重新生成 lib/client.js
75
+ npm run check:client # 比对新产物与已提交产物是否一致
76
+ npm run smoke # 结构 / 注册 / 鉴权 / 产物 四关
77
+ ```
78
+
79
+ 打包要求:`react` 与 `react/jsx-runtime` 保持 external(由 DSH 客户端模块表提供);产物外壳 `window.__ModuleLoader__.load({ id, factory })` 里的 `id` 必须等于包名。
80
+
81
+ ## 许可
82
+
83
+ MIT
Binary file
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 一键重启的「接手脚本」——被宿主进程 detached 起来,独立于 dsh 活着。
4
+ *
5
+ * 背景:桌面端把内核(宿主)退出一律当致命故障(main.js 里 child.once('close') 无条件 fail),
6
+ * 所以内核自己退出必然弹「应用无法使用」那张框,插件侧绕不过去。
7
+ * 但反方向可行:**先关掉界面壳(Electron 主进程)**,内核会收到 IPC 断开并走自己的优雅收尾
8
+ * (dsh-desktop-host/lib/index.js 里 process.once('disconnect', () => stop())),
9
+ * 主进程一死也没有任何东西会弹框;随后本脚本把 exe 重新拉起来。
10
+ *
11
+ * 参数:node restart-app.mjs <主进程PID> <exe路径> <宿主PID> <端口> <日志文件>
12
+ */
13
+
14
+ import { spawn } from 'node:child_process'
15
+ import { connect } from 'node:net'
16
+ import { appendFileSync } from 'node:fs'
17
+
18
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
19
+ const [parentPid, exePath, hostPid, portArg, logPath] = process.argv.slice(2)
20
+ const port = Number(portArg || 19387)
21
+
22
+ function log(line) {
23
+ try {
24
+ appendFileSync(logPath, `${new Date().toISOString()} ${line}\n`)
25
+ } catch {
26
+ /* 日志写不进去不影响重启 */
27
+ }
28
+ }
29
+
30
+ function portOpen() {
31
+ return new Promise((resolve) => {
32
+ const socket = connect({ host: '127.0.0.1', port })
33
+ const done = (value) => {
34
+ socket.destroy()
35
+ resolve(value)
36
+ }
37
+ socket.once('connect', () => done(true))
38
+ socket.once('error', () => done(false))
39
+ setTimeout(() => done(false), 800)
40
+ })
41
+ }
42
+
43
+ function kill(pid) {
44
+ if (!Number.isInteger(pid) || pid <= 0) return
45
+ try {
46
+ process.kill(pid)
47
+ } catch (error) {
48
+ log(`kill ${pid} failed: ${error.message}`)
49
+ }
50
+ }
51
+
52
+ log(`start parent=${parentPid} host=${hostPid} exe=${exePath}`)
53
+
54
+ // 1) 等宿主把 HTTP 响应交回浏览器(否则按钮会看到连接被掐断)
55
+ await sleep(1200)
56
+
57
+ // 2) 关掉界面壳。主进程一没有,内核的 IPC 断开会自动走优雅收尾,也没有东西能弹恢复框。
58
+ kill(Number(parentPid))
59
+
60
+ // 3) 等端口释放(内核收尾 + 退出);12 秒还不放就补一刀内核,避免新实例撞端口起不来
61
+ let released = false
62
+ for (let i = 0; i < 24; i += 1) {
63
+ await sleep(500)
64
+ if (!(await portOpen())) {
65
+ released = true
66
+ break
67
+ }
68
+ }
69
+ if (!released) {
70
+ log('port still busy after 12s, killing host')
71
+ kill(Number(hostPid))
72
+ for (let i = 0; i < 12; i += 1) {
73
+ await sleep(500)
74
+ if (!(await portOpen())) {
75
+ released = true
76
+ break
77
+ }
78
+ }
79
+ }
80
+ log(`port ${released ? 'released' : 'STILL BUSY'}`)
81
+
82
+ // 4) 重新拉起应用
83
+ if (!exePath) {
84
+ log('no exe path, stop')
85
+ process.exit(1)
86
+ }
87
+ /**
88
+ * 拉起应用。**必须把 ELECTRON_RUN_AS_NODE 从环境里摘掉**:
89
+ * 本脚本自己就是靠这个变量跑在 Electron 的 Node 模式下的,直接继承下去,
90
+ * 被拉起的 exe 也会以 Node 模式启动(没有脚本参数 → 立刻退出),
91
+ * 症状是「应用关掉了、但一直没回来」。
92
+ */
93
+ function launchApp(label) {
94
+ const env = { ...process.env }
95
+ delete env.ELECTRON_RUN_AS_NODE
96
+ const child = spawn(exePath, [], { detached: true, stdio: 'ignore', windowsHide: false, env })
97
+ child.unref()
98
+ log(`${label} spawned exe pid=${String(child.pid)}`)
99
+ return child
100
+ }
101
+
102
+ /** 进程还在不在。桌面端每次启动都是**新端口**,用旧端口探活只会得到假失败。 */
103
+ function alive(pid) {
104
+ if (!Number.isInteger(pid) || pid <= 0) return false
105
+ try {
106
+ process.kill(pid, 0)
107
+ return true
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+
113
+ /** 等进程稳定活着:先等 2.5 秒(撞单实例锁的实例这时已经退了),再等到总时限复查一次。 */
114
+ async function waitAlive(pid, ms) {
115
+ const early = Math.min(2500, ms)
116
+ await sleep(early)
117
+ if (!alive(pid)) return false
118
+ await sleep(Math.max(0, ms - early))
119
+ return alive(pid)
120
+ }
121
+
122
+ const first = launchApp('first launch')
123
+
124
+ // 5) 起来了吗?起不来就再试一次(第二次失败只能让用户手动开)
125
+ if (await waitAlive(first.pid, 8000)) {
126
+ log(`ok: app is back (pid ${first.pid} alive)`)
127
+ process.exit(0)
128
+ }
129
+ log('first launch did not stay alive, retry once')
130
+ const retry = launchApp('retry')
131
+ await sleep(8000)
132
+ log(alive(retry.pid) ? `ok after retry (pid ${retry.pid} alive)` : 'FAILED: app did not come back')
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-reload-buttons
3
+ name: dsh-reload-buttons
package/index.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 重启与刷新:宿主侧提供一条「重启应用 / 结束内核」的路由。
3
+ * 刷新按钮是纯浏览器侧的事(重载页面即重拉客户端产物),不需要宿主。
4
+ *
5
+ * 落点:客户端半边把两个图标按钮挂进 `conversation.header.leading`(网页界面左上角空位)。
6
+ * 安全:只接受回环请求;POST 才受理。
7
+ *
8
+ * 桌面端为什么要「先关界面壳」而不是自己退出:
9
+ * 桌面端把内核退出一律当致命故障(main.js 里 child.once('close') 无条件 fail → 弹恢复框),
10
+ * 内核自己 exit 就必然弹那张框。反过来,Electron 主进程一消失,
11
+ * 内核的 IPC 断开会触发它自己的优雅收尾(dsh-desktop-host 里 process.once('disconnect')),
12
+ * 且没有任何进程会弹框;随后由 assets/restart-app.mjs 把 exe 重新拉起。
13
+ * 非桌面端(dsh web 等)没有这一层,直接走内核自己的 shutdown。
14
+ */
15
+
16
+ import { spawn } from 'node:child_process'
17
+ import { fileURLToPath } from 'node:url'
18
+ import { mkdirSync } from 'node:fs'
19
+ import { homedir, tmpdir } from 'node:os'
20
+ import { dirname, join } from 'node:path'
21
+
22
+ /** Cordis 插件名。 */
23
+ export const name = 'dsh-reload-buttons'
24
+ /** 需要的服务(路由在 apply 期靠 ctx.inject 等 webServer 到位)。 */
25
+ export const inject = []
26
+
27
+ const ROUTE = '/dsh-reload-buttons/api/restart'
28
+ const WORKER = fileURLToPath(new URL('./assets/restart-app.mjs', import.meta.url))
29
+
30
+ /** 桌面端=宿主跑在 Electron 的 Node 模式里。 */
31
+ function isDesktop() {
32
+ return typeof process.versions?.electron === 'string' && process.versions.electron !== ''
33
+ }
34
+
35
+ function logPath() {
36
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
37
+ const dir = join(home, 'logs')
38
+ try {
39
+ mkdirSync(dir, { recursive: true })
40
+ } catch {
41
+ return join(tmpdir(), 'dsh-reload-buttons-restart.log')
42
+ }
43
+ return join(dir, 'dsh-reload-buttons-restart.log')
44
+ }
45
+
46
+ /** 内核自己的 shutdown(宿主 index.js 监听的就是这条消息),跑完进程自然退出。 */
47
+ function shutdownAndExit() {
48
+ let exited = false
49
+ const hardExit = () => {
50
+ if (exited) return
51
+ exited = true
52
+ process.exit(0)
53
+ }
54
+ try {
55
+ process.emit('message', { type: 'shutdown' }, null)
56
+ } catch {
57
+ hardExit()
58
+ return
59
+ }
60
+ setTimeout(hardExit, 3000)
61
+ }
62
+
63
+ /**
64
+ * 桌面端:把重启交给独立脚本(先关界面壳,再拉起 exe)。
65
+ * 本进程不退出——界面壳一走,宿主会收到 IPC 断开并自行优雅收尾。
66
+ */
67
+ function handOffRestart(port) {
68
+ const exe = process.execPath
69
+ const parentPid = process.ppid
70
+ const child = spawn(process.execPath, [
71
+ WORKER,
72
+ String(parentPid),
73
+ exe,
74
+ String(process.pid),
75
+ String(port ?? ''),
76
+ logPath(),
77
+ ], {
78
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
79
+ detached: true,
80
+ stdio: 'ignore',
81
+ windowsHide: true,
82
+ })
83
+ child.unref()
84
+ return child.pid !== undefined
85
+ }
86
+
87
+ function isLocal(req) {
88
+ const addr = req.socket && req.socket.remoteAddress
89
+ return typeof addr === 'string' && /^(::1|127\.|::ffff:127\.)/.test(addr)
90
+ }
91
+
92
+ function sendJson(res, code, value) {
93
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
94
+ res.end(JSON.stringify(value))
95
+ }
96
+
97
+ /** 注册重启路由。 */
98
+ export function apply(ctx) {
99
+ const mount = (hostCtx) => {
100
+ const webServer = hostCtx
101
+ && (hostCtx.webServer || (typeof hostCtx.get === 'function' ? hostCtx.get('webServer') : undefined))
102
+ if (!webServer || typeof webServer.register !== 'function') return
103
+ ctx.effect(() => webServer.register({
104
+ kind: 'exact',
105
+ path: ROUTE,
106
+ handler: (req, res) => {
107
+ if (!isLocal(req)) return sendJson(res, 403, { ok: false, error: '只接受本机请求' })
108
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: '只支持 POST' })
109
+ if (isDesktop()) {
110
+ let handedOff = false
111
+ try {
112
+ handedOff = handOffRestart(webServer.port)
113
+ } catch {
114
+ handedOff = false
115
+ }
116
+ sendJson(res, 200, { ok: true, mode: handedOff ? 'relaunch' : 'exit' })
117
+ // 脚本没起来就别干等:退回内核自己退出(会弹恢复框,但至少不会卡死)
118
+ if (!handedOff) setTimeout(shutdownAndExit, 400)
119
+ return
120
+ }
121
+ sendJson(res, 200, { ok: true, mode: 'exit' })
122
+ setTimeout(shutdownAndExit, 400)
123
+ },
124
+ }), 'dsh-reload-buttons: restart route')
125
+ }
126
+ // apply 期直接取 ctx.get('webServer') 往往还没就绪,优先放进 inject 回调等它到位;
127
+ // 桩 ctx(冒烟、测试)没有 inject,退回直接取(拿不到就什么也不做,不影响加载)。
128
+ if (typeof ctx.inject === 'function') ctx.inject(['webServer'], mount)
129
+ else mount(ctx)
130
+ }
package/invariant.js ADDED
@@ -0,0 +1,10 @@
1
+ const PACKAGE_NAME = 'dsh-reload-buttons'
2
+
3
+ /** Cordis companion plugin name. */
4
+ export const name = 'dsh-reload-buttons-invariant'
5
+ /** Service required before the companion can reserve package ownership. */
6
+ export const inject = ['invariants']
7
+
8
+ const install = () => {}
9
+
10
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
package/lib/client.js ADDED
@@ -0,0 +1,270 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-reload-buttons",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ var __defProp = Object.defineProperty;
8
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
9
+ var __getOwnPropNames = Object.getOwnPropertyNames;
10
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
24
+
25
+ // src/entry.jsx
26
+ var entry_exports = {};
27
+ __export(entry_exports, {
28
+ apply: () => apply,
29
+ inject: () => inject
30
+ });
31
+ module.exports = __toCommonJS(entry_exports);
32
+ var import_react = require("react");
33
+ var import_jsx_runtime = require("react/jsx-runtime");
34
+ var ROUTE = "/dsh-reload-buttons/api/restart";
35
+ function api(path) {
36
+ const relative = String(path).replace(/^\/+/, "");
37
+ if (typeof document === "undefined") return "/" + relative;
38
+ return new URL(relative, document.baseURI).pathname;
39
+ }
40
+ var wrap = { display: "inline-flex", alignItems: "center", gap: "2px" };
41
+ var btn = {
42
+ display: "inline-flex",
43
+ alignItems: "center",
44
+ justifyContent: "center",
45
+ width: "24px",
46
+ height: "24px",
47
+ padding: 0,
48
+ border: 0,
49
+ borderRadius: "6px",
50
+ background: "transparent",
51
+ color: "inherit",
52
+ cursor: "pointer",
53
+ opacity: 0.65
54
+ };
55
+ function IconRefresh() {
56
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
57
+ "svg",
58
+ {
59
+ width: "14",
60
+ height: "14",
61
+ viewBox: "0 0 24 24",
62
+ fill: "none",
63
+ stroke: "currentColor",
64
+ strokeWidth: "2",
65
+ strokeLinecap: "round",
66
+ strokeLinejoin: "round",
67
+ "aria-hidden": "true",
68
+ children: [
69
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M21 12a9 9 0 1 1-2.64-6.36" }),
70
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M21 3v6h-6" })
71
+ ]
72
+ }
73
+ );
74
+ }
75
+ function IconPower() {
76
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
77
+ "svg",
78
+ {
79
+ width: "14",
80
+ height: "14",
81
+ viewBox: "0 0 24 24",
82
+ fill: "none",
83
+ stroke: "currentColor",
84
+ strokeWidth: "2",
85
+ strokeLinecap: "round",
86
+ strokeLinejoin: "round",
87
+ "aria-hidden": "true",
88
+ children: [
89
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M12 3v9" }),
90
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M18.36 6.64a9 9 0 1 1-12.72 0" })
91
+ ]
92
+ }
93
+ );
94
+ }
95
+ async function probeBootBundles() {
96
+ const boot = globalThis.__DSH_BOOT__;
97
+ const batches = boot && Array.isArray(boot.batches) ? boot.batches : [];
98
+ const urls = batches.map((batch) => batch && typeof batch.url === "string" ? batch.url : "").filter((url) => url !== "");
99
+ if (urls.length === 0) return { ok: true, total: 0, bad: [] };
100
+ const results = await Promise.all(urls.map(async (url) => {
101
+ try {
102
+ const res = await fetch(new URL(url, document.baseURI), { cache: "no-store" });
103
+ return { url, ok: res.ok, status: res.status };
104
+ } catch {
105
+ return { url, ok: false, status: 0 };
106
+ }
107
+ }));
108
+ return { ok: results.every((r) => r.ok), total: results.length, bad: results.filter((r) => !r.ok) };
109
+ }
110
+ async function requestRestart() {
111
+ try {
112
+ await fetch(api(ROUTE), { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
113
+ } catch (e) {
114
+ }
115
+ }
116
+ function overlay(text, ms) {
117
+ if (typeof document === "undefined" || document.body === null) return () => {
118
+ };
119
+ const box = document.createElement("div");
120
+ box.textContent = text;
121
+ box.style.position = "fixed";
122
+ box.style.top = "16px";
123
+ box.style.left = "50%";
124
+ box.style.transform = "translateX(-50%)";
125
+ box.style.zIndex = "2147483647";
126
+ box.style.padding = "8px 16px";
127
+ box.style.borderRadius = "8px";
128
+ box.style.background = "rgba(24, 24, 27, 0.88)";
129
+ box.style.color = "#fff";
130
+ box.style.fontSize = "13px";
131
+ box.style.lineHeight = "18px";
132
+ box.style.pointerEvents = "none";
133
+ box.style.fontFamily = "inherit";
134
+ document.body.appendChild(box);
135
+ const remove = () => {
136
+ box.remove();
137
+ };
138
+ if (ms) setTimeout(remove, ms);
139
+ return remove;
140
+ }
141
+ var REFRESH_FLAG = "dsh-reload-buttons:refreshed";
142
+ function announceRefreshed() {
143
+ let stamp = 0;
144
+ try {
145
+ stamp = Number(sessionStorage.getItem(REFRESH_FLAG) ?? 0);
146
+ sessionStorage.removeItem(REFRESH_FLAG);
147
+ } catch {
148
+ return;
149
+ }
150
+ if (!stamp || Date.now() - stamp > 2e4) return;
151
+ overlay("\u754C\u9762\u5DF2\u5237\u65B0", 1800);
152
+ }
153
+ var modulesRef;
154
+ function moduleSystem() {
155
+ return modulesRef;
156
+ }
157
+ function freshGraph() {
158
+ return new Promise((resolve) => {
159
+ let source;
160
+ let timer;
161
+ const finish = (value) => {
162
+ clearTimeout(timer);
163
+ try {
164
+ source?.close();
165
+ } catch {
166
+ }
167
+ resolve(value);
168
+ };
169
+ try {
170
+ source = new EventSource(new URL("plugins/events", document.baseURI));
171
+ } catch {
172
+ resolve(null);
173
+ return;
174
+ }
175
+ timer = setTimeout(() => finish(null), 3e3);
176
+ source.addEventListener("message", (event) => {
177
+ try {
178
+ const frame = JSON.parse(event.data);
179
+ if (frame && frame.type === "graph" && frame.graph) finish(frame.graph);
180
+ } catch {
181
+ }
182
+ });
183
+ });
184
+ }
185
+ async function resyncPlugins() {
186
+ const modules = moduleSystem();
187
+ const entries = modules && modules.entries;
188
+ if (!entries || typeof entries.sync !== "function") return false;
189
+ const graph = await freshGraph();
190
+ if (!graph) return false;
191
+ await entries.sync(graph);
192
+ return true;
193
+ }
194
+ function HeaderButtons() {
195
+ const [restarting, setRestarting] = (0, import_react.useState)(false);
196
+ const [checking, setChecking] = (0, import_react.useState)(false);
197
+ async function reload() {
198
+ if (checking) return;
199
+ setChecking(true);
200
+ let probe = { ok: true, total: 0, bad: [] };
201
+ try {
202
+ probe = await probeBootBundles();
203
+ } catch {
204
+ probe = { ok: true, total: 0, bad: [] };
205
+ }
206
+ if (probe.ok) {
207
+ try {
208
+ sessionStorage.setItem(REFRESH_FLAG, String(Date.now()));
209
+ } catch {
210
+ }
211
+ overlay("\u6B63\u5728\u5237\u65B0\u2026");
212
+ await new Promise((resolve) => {
213
+ setTimeout(resolve, 180);
214
+ });
215
+ window.location.reload();
216
+ return;
217
+ }
218
+ const hide = overlay("\u6B63\u5728\u5237\u65B0\u2026");
219
+ let resynced = false;
220
+ try {
221
+ resynced = await resyncPlugins();
222
+ } catch {
223
+ resynced = false;
224
+ }
225
+ hide();
226
+ overlay(resynced ? "\u754C\u9762\u5DF2\u5237\u65B0" : "\u5237\u65B0\u5931\u8D25\uFF1A\u6E05\u5355\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u7528\u300C\u91CD\u542F\u300D", 2200);
227
+ setChecking(false);
228
+ }
229
+ async function restart() {
230
+ if (restarting) return;
231
+ if (!window.confirm("\u91CD\u542F\u5E94\u7528\uFF1F\u754C\u9762\u4F1A\u5173\u6389\uFF0C\u51E0\u79D2\u540E\u81EA\u52A8\u91CD\u5F00\uFF1B\u82E5\u5F39\u51FA\u300C\u5E94\u7528\u65E0\u6CD5\u4F7F\u7528\u300D\u7A97\u53E3\uFF0C\u70B9\u5176\u4E2D\u7684\u300C\u91CD\u542F\u300D\u5373\u53EF\u3002")) return;
232
+ setRestarting(true);
233
+ await requestRestart();
234
+ }
235
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: wrap, children: [
236
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
237
+ "button",
238
+ {
239
+ type: "button",
240
+ style: Object.assign({}, btn, checking ? { opacity: 0.35, cursor: "default" } : null),
241
+ title: "\u5237\u65B0\u754C\u9762",
242
+ "aria-label": "\u5237\u65B0\u754C\u9762",
243
+ onClick: () => void reload(),
244
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(IconRefresh, {})
245
+ }
246
+ ),
247
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
248
+ "button",
249
+ {
250
+ type: "button",
251
+ style: Object.assign({}, btn, restarting ? { opacity: 0.35, cursor: "default" } : null),
252
+ title: "\u91CD\u542F\u5E94\u7528\uFF08\u5173\u6389\u754C\u9762\uFF0C\u51E0\u79D2\u540E\u81EA\u52A8\u91CD\u5F00\uFF09",
253
+ "aria-label": "\u91CD\u542F\u5E94\u7528",
254
+ onClick: () => void restart(),
255
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(IconPower, {})
256
+ }
257
+ )
258
+ ] });
259
+ }
260
+ var inject = ["slots", "modules"];
261
+ var SLOT = "conversation.header.leading";
262
+ function apply(ctx) {
263
+ modulesRef = ctx.modules;
264
+ ctx.slots.inject(SLOT, () => ctx.slots.register({ name: SLOT }, HeaderButtons));
265
+ if (typeof setTimeout === "function") setTimeout(announceRefreshed, 600);
266
+ }
267
+
268
+ return module.exports;
269
+ }
270
+ });
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "dsh-reload-buttons",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "在界面左上角放两个按钮:刷新界面与重启应用——刷新按需整页重载或就地重同步插件,不给白屏;重启在桌面端关掉界面壳后自动重开,在 dsh web 下结束内核交给你的守护进程。",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./invariant": "./invariant.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "invariant.js",
16
+ "cordis.patch.yml",
17
+ "src",
18
+ "lib/client.js",
19
+ "assets",
20
+ "scripts",
21
+ "README.md",
22
+ "README.en.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "node": "^22.19.0 || >=24.0.0",
27
+ "dsh": ">=0.2.1-rc.5 <0.3.0-0"
28
+ },
29
+ "dsh": {
30
+ "client": {
31
+ "platform": "web",
32
+ "inject": []
33
+ },
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ }
37
+ },
38
+ "keywords": [
39
+ "deepseek-harness",
40
+ "dsh",
41
+ "dsh-plugin",
42
+ "reload",
43
+ "refresh",
44
+ "restart",
45
+ "ui"
46
+ ],
47
+ "scripts": {
48
+ "build:client": "node scripts/build-client.mjs",
49
+ "check:client": "node scripts/build-client.mjs --check",
50
+ "smoke": "node scripts/smoke.mjs",
51
+ "check": "node --check index.js && node --check invariant.js && node --check assets/restart-app.mjs"
52
+ },
53
+ "devDependencies": {
54
+ "esbuild": "^0.28.1"
55
+ },
56
+ "license": "MIT",
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "https://github.com/taoziliyouchong/dsh-reload-buttons"
60
+ },
61
+ "homepage": "https://github.com/taoziliyouchong/dsh-reload-buttons#readme",
62
+ "bugs": {
63
+ "url": "https://github.com/taoziliyouchong/dsh-reload-buttons/issues"
64
+ }
65
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 构建浏览器半边:src/entry.jsx → lib/client.js
4
+ *
5
+ * 客户端模块表按「入口名 == 包名」索引,所以产物必须套上 DSH 的模块外壳:
6
+ * window.__ModuleLoader__.load({ id: <package.json 的 name>, factory })
7
+ * 外壳里的 id 与包名对不上,界面就静默不加载。
8
+ *
9
+ * 用法:
10
+ * node scripts/build-client.mjs 重新生成 lib/client.js
11
+ * node scripts/build-client.mjs --check 只比对新产物与已提交产物,不一致即退出码 1
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
15
+ import { dirname, join, resolve } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { build } from 'esbuild'
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
20
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
21
+ const entry = join(root, 'src', 'entry.jsx')
22
+ const out = join(root, 'lib', 'client.js')
23
+ const check = process.argv.includes('--check')
24
+
25
+ if (!existsSync(entry)) {
26
+ console.error('找不到浏览器半边源码:' + entry)
27
+ process.exit(2)
28
+ }
29
+
30
+ const bundled = await build({
31
+ entryPoints: [entry],
32
+ bundle: true,
33
+ format: 'cjs',
34
+ platform: 'browser',
35
+ jsx: 'automatic',
36
+ target: ['chrome120'],
37
+ // react 由 DSH 客户端模块表提供,不能打进产物
38
+ external: ['react', 'react/jsx-runtime'],
39
+ write: false,
40
+ logLevel: 'warning',
41
+ legalComments: 'none',
42
+ })
43
+
44
+ const code = bundled.outputFiles[0].text.replace(/\s*\/\/# sourceMappingURL=.*$/m, '')
45
+ const envelope = 'window.__ModuleLoader__.load({\n'
46
+ + '\tid: ' + JSON.stringify(pkg.name) + ',\n'
47
+ + '\tfactory: (require) => {\n'
48
+ + '\t\tvar module = { exports: {} };\n'
49
+ + '\t\tvar exports = module.exports;\n'
50
+ + '\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: "Module" });\n'
51
+ + code
52
+ + '\n\t\treturn module.exports;\n'
53
+ + '\t}\n'
54
+ + '});\n'
55
+
56
+ // 语法自检:只解析不执行(new Function 不跑函数体)
57
+ try {
58
+ // eslint-disable-next-line no-new-func
59
+ new Function(envelope)
60
+ } catch (error) {
61
+ console.error('产物语法检查失败:' + error.message)
62
+ process.exit(1)
63
+ }
64
+
65
+ if (check) {
66
+ if (!existsSync(out)) {
67
+ console.error('缺少已提交的产物:' + out + '(先跑 node scripts/build-client.mjs)')
68
+ process.exit(1)
69
+ }
70
+ if (readFileSync(out, 'utf8') !== envelope) {
71
+ console.error('lib/client.js 与 src/entry.jsx 不一致:先跑 node scripts/build-client.mjs 再提交')
72
+ process.exit(1)
73
+ }
74
+ console.log('ok lib/client.js 与源码一致(' + Buffer.byteLength(envelope, 'utf8') + ' 字节)')
75
+ process.exit(0)
76
+ }
77
+
78
+ mkdirSync(dirname(out), { recursive: true })
79
+ writeFileSync(out, envelope, 'utf8')
80
+ console.log('ok 已写出 ' + out + '(' + Buffer.byteLength(envelope, 'utf8') + ' 字节,id=' + pkg.name + ')')
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 本机冒烟:不起应用、不发网络请求、不改任何东西。
4
+ *
5
+ * 四关:
6
+ * 1. 包结构 —— files 声明的必需文件都在(含已提交的 lib/client.js)
7
+ * 2. 宿主半边 —— 桩 ctx 跑 apply,确认注册了一条路由、且路由路径与包名一致
8
+ * 3. 路由鉴权 —— 非回环请求 403、非 POST 405
9
+ * 4. 客户端产物 —— 外壳 id == 包名,且带上了源码里的路由与刷新标记
10
+ *
11
+ * 用法:node scripts/smoke.mjs (任一条失败退出码 1)
12
+ */
13
+
14
+ import { readFileSync, existsSync } from 'node:fs'
15
+ import { dirname, join, resolve } from 'node:path'
16
+ import { fileURLToPath, pathToFileURL } from 'node:url'
17
+
18
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
19
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
20
+ const problems = []
21
+
22
+ const ok = (label, extra = '') => console.log('ok ' + label + (extra ? ' ' + extra : ''))
23
+ const fail = (label, why) => {
24
+ problems.push(label + ': ' + why)
25
+ console.log('FAIL ' + label + ' ' + why)
26
+ }
27
+
28
+ /** 1. 包结构 */
29
+ const REQUIRED = [
30
+ 'index.js',
31
+ 'invariant.js',
32
+ 'cordis.patch.yml',
33
+ 'assets/restart-app.mjs',
34
+ 'src/entry.jsx',
35
+ 'lib/client.js',
36
+ 'README.md',
37
+ 'README.en.md',
38
+ 'LICENSE',
39
+ ]
40
+ const missing = REQUIRED.filter((rel) => !existsSync(join(root, rel)))
41
+ if (missing.length > 0) fail('结构', '缺 ' + missing.join('、'))
42
+ else ok('结构', REQUIRED.length + ' 个必需文件齐全')
43
+
44
+ /** 2. 宿主半边:桩 ctx 跑 apply */
45
+ const mod = await import(pathToFileURL(join(root, 'index.js')).href)
46
+ if (mod.name !== pkg.name) fail('宿主半边', 'index.js 导出的 name 是 ' + mod.name + ',不等于包名 ' + pkg.name)
47
+ else ok('宿主半边', "name = " + mod.name)
48
+
49
+ const routes = []
50
+ const effects = []
51
+ const webServer = {
52
+ port: 0,
53
+ register(route) {
54
+ routes.push(route)
55
+ return () => {}
56
+ },
57
+ }
58
+ // inject 回调收到的是宿主 ctx(webServer 挂在它上面),不是服务本身
59
+ const hostCtx = { webServer }
60
+ const ctx = {
61
+ inject(names, fn) {
62
+ if (!Array.isArray(names) || !names.includes('webServer')) throw new Error('宿主半边要求注入 webServer')
63
+ fn(hostCtx)
64
+ },
65
+ effect(fn) {
66
+ effects.push(typeof fn === 'function' ? fn() : fn)
67
+ },
68
+ }
69
+
70
+ try {
71
+ mod.apply(ctx)
72
+ } catch (error) {
73
+ fail('宿主半边', 'apply 抛错:' + error.message)
74
+ }
75
+
76
+ const ROUTE = '/' + pkg.name + '/api/restart'
77
+ if (routes.length !== 1) fail('宿主半边', '应注册 1 条路由,实际 ' + routes.length)
78
+ else if (routes[0].path !== ROUTE) fail('宿主半边', '路由路径是 ' + routes[0].path + ',应为 ' + ROUTE)
79
+ else if (routes[0].kind !== 'exact') fail('宿主半边', '路由 kind 是 ' + routes[0].kind + ',应为 exact')
80
+ else if (effects.length !== 1) fail('宿主半边', '应登记 1 个 effect,实际 ' + effects.length)
81
+ else ok('宿主半边', 'POST ' + ROUTE + ' 已注册(kind=exact)')
82
+
83
+ /** 3. 路由鉴权 */
84
+ function call(handler, req) {
85
+ const res = {
86
+ status: 0,
87
+ headers: null,
88
+ body: '',
89
+ writeHead(code, headers) {
90
+ res.status = code
91
+ res.headers = headers
92
+ },
93
+ end(body) {
94
+ res.body = String(body ?? '')
95
+ },
96
+ }
97
+ handler(req, res)
98
+ return res
99
+ }
100
+
101
+ if (routes.length === 1) {
102
+ const handler = routes[0].handler
103
+ const cases = [
104
+ { label: '非回环地址被拒', req: { socket: { remoteAddress: '10.1.2.3' }, method: 'POST' }, want: 403 },
105
+ { label: 'GET 被拒', req: { socket: { remoteAddress: '127.0.0.1' }, method: 'GET' }, want: 405 },
106
+ { label: '::1 本机照常走 POST 分支', req: { socket: { remoteAddress: '::1' }, method: 'GET' }, want: 405 },
107
+ { label: 'IPv4 映射地址被认作本机', req: { socket: { remoteAddress: '::ffff:127.0.0.1' }, method: 'GET' }, want: 405 },
108
+ ]
109
+ let passed = 0
110
+ for (const item of cases) {
111
+ let res
112
+ try {
113
+ res = call(handler, item.req)
114
+ } catch (error) {
115
+ fail('路由鉴权', item.label + ':handler 抛错 ' + error.message)
116
+ continue
117
+ }
118
+ if (res.status !== item.want) fail('路由鉴权', item.label + ':返回 ' + res.status + ',应为 ' + item.want)
119
+ else passed += 1
120
+ }
121
+ if (passed === cases.length) ok('路由鉴权', passed + ' 条全部符合')
122
+ }
123
+
124
+ /** 4. 客户端产物 */
125
+ const client = readFileSync(join(root, 'lib', 'client.js'), 'utf8')
126
+ const idMatch = /^\s*id:\s*"([^"]+)"/m.exec(client)
127
+ if (!idMatch) fail('客户端产物', '外壳里找不到 id')
128
+ else if (idMatch[1] !== pkg.name) fail('客户端产物', '外壳 id 是 ' + idMatch[1] + ',应等于包名 ' + pkg.name)
129
+ else if (!client.includes(ROUTE)) fail('客户端产物', '产物里没有路由 ' + ROUTE + '(源码改了但没重新构建)')
130
+ else if (!client.includes(pkg.name + ':refreshed')) fail('客户端产物', '产物里没有刷新标记(源码改了但没重新构建)')
131
+ else ok('客户端产物', 'id=' + pkg.name + ',' + Buffer.byteLength(client, 'utf8') + ' 字节')
132
+
133
+ console.log('')
134
+ if (problems.length > 0) {
135
+ console.error('冒烟失败 ' + problems.length + ' 项')
136
+ process.exit(1)
137
+ }
138
+ console.log('冒烟通过')
package/src/entry.jsx ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * 重启与刷新:会话头部最左两个图标按钮(浏览器半边源码)。
3
+ *
4
+ * 落点 `conversation.header.leading`——single 槽,当前由本插件占用(注册者显示为客户端运行时名)。
5
+ * 如果哪天这个座位被别的插件先占住(single 槽同优先级二次注册会抛错),
6
+ * 换到 `conversation.session.header.utilities`(list 槽,自带 id 追加即可)。
7
+ *
8
+ * 刷新=重载页面;重载会用内核启动时拍的清单快照,改了客户端插件后快照会过期(404 → 白屏 +
9
+ * 恢复框)。所以刷新前先探一遍清单资源:全都取得到就 reload;取不到就说明必须重启,直接走重启,
10
+ * 不再弹一句「先点重启」把人堵住。
11
+ * 重启=POST 宿主路由 → 桌面端交 assets/restart-app.mjs 关壳再拉起。
12
+ *
13
+ * 打包:npm run build:client(scripts/build-client.mjs);产物外壳 id 必须等于包名。
14
+ */
15
+ import { useState } from 'react'
16
+
17
+ const ROUTE = '/dsh-reload-buttons/api/restart'
18
+
19
+ /** 把路由拼成当前页面挂载点下的路径(锚 document.baseURI,兼容子路径部署)。 */
20
+ function api(path) {
21
+ const relative = String(path).replace(/^\/+/, '')
22
+ if (typeof document === 'undefined') return '/' + relative
23
+ return new URL(relative, document.baseURI).pathname
24
+ }
25
+
26
+ const wrap = { display: 'inline-flex', alignItems: 'center', gap: '2px' }
27
+ const btn = {
28
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
29
+ width: '24px', height: '24px', padding: 0, border: 0, borderRadius: '6px',
30
+ background: 'transparent', color: 'inherit', cursor: 'pointer', opacity: 0.65,
31
+ }
32
+
33
+ function IconRefresh() {
34
+ return (
35
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
36
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
37
+ <path d="M21 12a9 9 0 1 1-2.64-6.36" />
38
+ <path d="M21 3v6h-6" />
39
+ </svg>
40
+ )
41
+ }
42
+
43
+ function IconPower() {
44
+ return (
45
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
46
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
47
+ <path d="M12 3v9" />
48
+ <path d="M18.36 6.64a9 9 0 1 1-12.72 0" />
49
+ </svg>
50
+ )
51
+ }
52
+
53
+ /**
54
+ * 刷新前自检:启动快照里的 bundle 是否都还取得到。
55
+ *
56
+ * 桌面端把客户端插件清单在**内核启动那一刻**拍一次快照(主进程 `dshDesktopBoot.ready()` 返回的
57
+ * injections 就是它),之后改插件产物快照不会跟着变;这时刷新会请求到已下线的旧 rev(404),
58
+ * web boot 整批失败 → 白屏 + 恢复对话框。所以刷之前先把快照里的组合脚本取一遍,取不到就不刷。
59
+ */
60
+ async function probeBootBundles() {
61
+ const boot = globalThis.__DSH_BOOT__
62
+ const batches = boot && Array.isArray(boot.batches) ? boot.batches : []
63
+ const urls = batches
64
+ .map((batch) => (batch && typeof batch.url === 'string' ? batch.url : ''))
65
+ .filter((url) => url !== '')
66
+ if (urls.length === 0) return { ok: true, total: 0, bad: [] }
67
+ const results = await Promise.all(urls.map(async (url) => {
68
+ try {
69
+ const res = await fetch(new URL(url, document.baseURI), { cache: 'no-store' })
70
+ return { url, ok: res.ok, status: res.status }
71
+ } catch {
72
+ return { url, ok: false, status: 0 }
73
+ }
74
+ }))
75
+ return { ok: results.every((r) => r.ok), total: results.length, bad: results.filter((r) => !r.ok) }
76
+ }
77
+
78
+ /** 请求宿主重启。界面随壳一起关掉,所以连接断掉是预期,不当错误。 */
79
+ async function requestRestart() {
80
+ try {
81
+ await fetch(api(ROUTE), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
82
+ } catch (e) { /* 界面壳被关掉时连接会断,属预期 */ }
83
+ }
84
+
85
+ /** 顶部浮层:先提示「正在刷新…」,重载回来后再给一句「界面已刷新」,让人知道这一下确实生效了。 */
86
+ function overlay(text, ms) {
87
+ if (typeof document === 'undefined' || document.body === null) return () => {}
88
+ const box = document.createElement('div')
89
+ box.textContent = text
90
+ box.style.position = 'fixed'
91
+ box.style.top = '16px'
92
+ box.style.left = '50%'
93
+ box.style.transform = 'translateX(-50%)'
94
+ box.style.zIndex = '2147483647'
95
+ box.style.padding = '8px 16px'
96
+ box.style.borderRadius = '8px'
97
+ box.style.background = 'rgba(24, 24, 27, 0.88)'
98
+ box.style.color = '#fff'
99
+ box.style.fontSize = '13px'
100
+ box.style.lineHeight = '18px'
101
+ box.style.pointerEvents = 'none'
102
+ box.style.fontFamily = 'inherit'
103
+ document.body.appendChild(box)
104
+ const remove = () => { box.remove() }
105
+ if (ms) setTimeout(remove, ms)
106
+ return remove
107
+ }
108
+
109
+ /** 刷新标记:重载前写下,重载后本插件加载时读到它就报一句「已刷新」。 */
110
+ const REFRESH_FLAG = 'dsh-reload-buttons:refreshed'
111
+
112
+ /** 重载回来后确认一句(20 秒内有效,避免误报)。 */
113
+ function announceRefreshed() {
114
+ let stamp = 0
115
+ try {
116
+ stamp = Number(sessionStorage.getItem(REFRESH_FLAG) ?? 0)
117
+ sessionStorage.removeItem(REFRESH_FLAG)
118
+ } catch {
119
+ return
120
+ }
121
+ if (!stamp || Date.now() - stamp > 20000) return
122
+ overlay('界面已刷新', 1800)
123
+ }
124
+
125
+ /** 客户端模块系统(就地重载插件条目用);apply 时取,声明成依赖保证一定在。 */
126
+ let modulesRef
127
+
128
+ /** 客户端模块系统句柄。 */
129
+ function moduleSystem() {
130
+ return modulesRef
131
+ }
132
+
133
+ /** 从宿主 SSE 抓当前启动图(一连接就推全量图)。 */
134
+ function freshGraph() {
135
+ return new Promise((resolve) => {
136
+ let source
137
+ let timer
138
+ const finish = (value) => {
139
+ clearTimeout(timer)
140
+ try { source?.close() } catch { /* 已关就算了 */ }
141
+ resolve(value)
142
+ }
143
+ try {
144
+ source = new EventSource(new URL('plugins/events', document.baseURI))
145
+ } catch {
146
+ resolve(null)
147
+ return
148
+ }
149
+ timer = setTimeout(() => finish(null), 3000)
150
+ source.addEventListener('message', (event) => {
151
+ try {
152
+ const frame = JSON.parse(event.data)
153
+ if (frame && frame.type === 'graph' && frame.graph) finish(frame.graph)
154
+ } catch { /* 坏帧忽略,等超时 */ }
155
+ })
156
+ })
157
+ }
158
+
159
+ /** 清单过期时:按宿主当前图就地对齐插件条目——不重载整页、不关应用。 */
160
+ async function resyncPlugins() {
161
+ const modules = moduleSystem()
162
+ const entries = modules && modules.entries
163
+ if (!entries || typeof entries.sync !== 'function') return false
164
+ const graph = await freshGraph()
165
+ if (!graph) return false
166
+ await entries.sync(graph)
167
+ return true
168
+ }
169
+
170
+ /** 左上一行两个按钮。 */
171
+ function HeaderButtons() {
172
+ const [restarting, setRestarting] = useState(false)
173
+ const [checking, setChecking] = useState(false)
174
+
175
+ async function reload() {
176
+ if (checking) return
177
+ setChecking(true)
178
+ let probe = { ok: true, total: 0, bad: [] }
179
+ try {
180
+ probe = await probeBootBundles()
181
+ } catch {
182
+ probe = { ok: true, total: 0, bad: [] }
183
+ }
184
+ if (probe.ok) {
185
+ try { sessionStorage.setItem(REFRESH_FLAG, String(Date.now())) } catch { /* 隐私模式忽略 */ }
186
+ overlay('正在刷新…')
187
+ // 留一帧让浮层看得见,再整页重载
188
+ await new Promise((resolve) => { setTimeout(resolve, 180) })
189
+ window.location.reload()
190
+ return
191
+ }
192
+ // 清单过期:整页重载会白屏(旧 rev 已 404)。改为就地重载插件,同行样给可见反馈。
193
+ const hide = overlay('正在刷新…')
194
+ let resynced = false
195
+ try {
196
+ resynced = await resyncPlugins()
197
+ } catch {
198
+ resynced = false
199
+ }
200
+ hide()
201
+ overlay(resynced ? '界面已刷新' : '刷新失败:清单已过期,请用「重启」', 2200)
202
+ setChecking(false)
203
+ }
204
+
205
+ async function restart() {
206
+ if (restarting) return
207
+ if (!window.confirm('重启应用?界面会关掉,几秒后自动重开;若弹出「应用无法使用」窗口,点其中的「重启」即可。')) return
208
+ setRestarting(true)
209
+ await requestRestart()
210
+ }
211
+
212
+ return (
213
+ <span style={wrap}>
214
+ <button
215
+ type="button"
216
+ style={Object.assign({}, btn, checking ? { opacity: 0.35, cursor: 'default' } : null)}
217
+ title="刷新界面"
218
+ aria-label="刷新界面"
219
+ onClick={() => void reload()}
220
+ >
221
+ <IconRefresh />
222
+ </button>
223
+ <button
224
+ type="button"
225
+ style={Object.assign({}, btn, restarting ? { opacity: 0.35, cursor: 'default' } : null)}
226
+ title="重启应用(关掉界面,几秒后自动重开)"
227
+ aria-label="重启应用"
228
+ onClick={() => void restart()}
229
+ >
230
+ <IconPower />
231
+ </button>
232
+ </span>
233
+ )
234
+ }
235
+
236
+ /** 需要的客户端服务。 */
237
+ export const inject = ['slots', 'modules']
238
+
239
+ /** 左上角全局面包屑前的空位(single 槽)。 */
240
+ const SLOT = 'conversation.header.leading'
241
+
242
+ /** 把两个按钮挂进左上角。 */
243
+ export function apply(ctx) {
244
+ modulesRef = ctx.modules
245
+ ctx.slots.inject(SLOT, () => ctx.slots.register({ name: SLOT }, HeaderButtons))
246
+ // 如果是「刷新」刚把页面重载回来的,晚一点给一句「界面已刷新」
247
+ if (typeof setTimeout === 'function') setTimeout(announceRefreshed, 600)
248
+ }