dsh-unlag 0.0.0-stage → 1.7.3
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 -0
- package/README.md +52 -2
- package/client.js +407 -0
- package/cordis.patch.yml +17 -0
- package/icon.svg +5 -0
- package/index.js +514 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +49 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 regrevia
|
|
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,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-unlag(解除卡顿)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
一个按钮,把「改完插件要重启才生效」变成点一下,顺便治好用久了的卡顿。
|
|
4
|
+
|
|
5
|
+
窗口右上角那个按钮只做两件事:把改过的插件代码原地换掉、把界面重建一次。全程不重启进程,所以会话、正在跑的轮次、排队的消息、没发出的输入都还在。
|
|
6
|
+
|
|
7
|
+
## 为什么有用
|
|
8
|
+
|
|
9
|
+
- 开发插件:改完 `.js` 点一下就生效,不用重启 App,也不用刷新页面。
|
|
10
|
+
- 装新插件:装完点一下让它进来,接着用。
|
|
11
|
+
- 界面用久了发卡:点一下重建,滚动位置和草稿都保留。
|
|
12
|
+
|
|
13
|
+
以前这三种都得重启 App,现在都不用。有会话正在跑的时候也能点:**马上换,不等空闲**——轮次不会被打断;某一步正好调到被换掉的插件时会报错重来,会话继续。
|
|
14
|
+
|
|
15
|
+
## 装
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
dsh plugin install dsh-unlag
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
装完重载一次 App 让它进来,之后所有更新都走按钮。
|
|
22
|
+
|
|
23
|
+
## 用
|
|
24
|
+
|
|
25
|
+
点右上角按钮即可。设置里两行决定它做什么:
|
|
26
|
+
|
|
27
|
+
- **按下刷新时换掉插件代码**(默认开)
|
|
28
|
+
- **按下刷新时重建界面**(默认开)
|
|
29
|
+
|
|
30
|
+
都开=先换代码再重建;只开一个=另一项完全不动(只换代码时页面不刷新,渲染状态和滚动位置全保留);都不开=点击无反应。
|
|
31
|
+
|
|
32
|
+
## 不会丢什么
|
|
33
|
+
|
|
34
|
+
- 轮次、子代理、终端进程:在宿主侧,不受影响。
|
|
35
|
+
- 排队消息、引导队列、输入草稿和附件:重建前会先提交焦点里的内容。
|
|
36
|
+
- 有未保存改动或还开着弹窗时:重建被拦下并提示,不硬刷。
|
|
37
|
+
- 它自己从不重启或退出 App;缺启动模块时宁可放弃重建并提示,也不冒把 App 带下去的风险。
|
|
38
|
+
|
|
39
|
+
## 管不到的地方
|
|
40
|
+
|
|
41
|
+
独立进程和进程级的东西:宠物窗口自己的配置、DSH 自身升级、原生依赖变化。
|
|
42
|
+
|
|
43
|
+
## 排错
|
|
44
|
+
|
|
45
|
+
- 日志:`~/.dsh/unlag-reload.log`,每次换代码/跳过/被拦各一行含原因。
|
|
46
|
+
- 状态:`GET /dsh-unlag/state` → `{"plugins":true,"page":true}` 是两个开关的当前值。
|
|
47
|
+
|
|
48
|
+
## 测试
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
node test/button.test.mjs # 开关、接口、忙时立刻换、无宿主不崩
|
|
52
|
+
node test/safety.test.mjs # 崩坏路径与未处理拒绝
|
|
53
|
+
```
|
package/client.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-unlag',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
const zh = /^zh/i.test(navigator.language || '');
|
|
8
|
+
const COPY = zh
|
|
9
|
+
? { label: '解除卡顿', hint: '卡顿时用:换掉插件代码并重建界面;运行中的任务、排队的消息保留', blocked: '暂时无法重建界面,稍后再试', unsaved: '有未保存的改动,先保存再重试', dialog: '先关闭打开的窗口再重载', queued: '已排队:任务跑完自动重载' } // ui-copy-ignore
|
|
10
|
+
: { label: 'Unlag', hint: 'For a sluggish interface: replaces plugin code and rebuilds the page; running turns and queued messages are kept', blocked: 'Cannot rebuild the page right now; try again', unsaved: 'Unsaved changes; save them, then retry', dialog: 'Close the open window first', queued: 'Queued; it reloads once the turn ends' };
|
|
11
|
+
|
|
12
|
+
// Desktop shell only: the macOS and Windows desktop build owns the page and the running
|
|
13
|
+
// app, so this is where the button belongs. A browser tab can reload freely on its own.
|
|
14
|
+
const desktop = /Electron\//.test(navigator.userAgent || '');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Announced before the interface is rebuilt. Any plugin holding state that a rebuild would
|
|
18
|
+
* drop can listen, save synchronously, or add a promise to `detail.pending`; calling
|
|
19
|
+
* `preventDefault()` keeps the interface as it is. Nothing here is required to know about
|
|
20
|
+
* this plugin, and no plugin is required to answer.
|
|
21
|
+
*/
|
|
22
|
+
const PREPARE_EVENT = 'dsh-unlag:before';
|
|
23
|
+
|
|
24
|
+
/** Set while a rebuild is pending, so a repeated click cannot start a second one. */
|
|
25
|
+
const STAMP_KEY = 'dsh-unlag:at'; // ui-copy-ignore
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a press does is decided by the plugin's settings row, read from the running app, so
|
|
29
|
+
* neither half assumes the other one is wanted. An app that does not answer leaves the press
|
|
30
|
+
* doing what it did before: rebuild the interface.
|
|
31
|
+
*/
|
|
32
|
+
const HOST_BASE = '/dsh-unlag'; // ui-copy-ignore
|
|
33
|
+
|
|
34
|
+
async function readSettings() {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin' });
|
|
37
|
+
if (!response.ok) return null;
|
|
38
|
+
const data = await response.json();
|
|
39
|
+
if (data === null || typeof data !== 'object') return null;
|
|
40
|
+
return { plugins: data.plugins !== false, page: data.page !== false };
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Leave one line per press outcome in the log the app keeps. Without it a press that stopped
|
|
48
|
+
* before rebuilding looks exactly like one that never happened, and the page itself is gone by
|
|
49
|
+
* the time anyone reads it.
|
|
50
|
+
*/
|
|
51
|
+
function report(event, gate) {
|
|
52
|
+
const query = gate === undefined ? `event=${event}` : `event=${event}&gate=${encodeURIComponent(gate)}`;
|
|
53
|
+
try {
|
|
54
|
+
fetch(`${HOST_BASE}/report?${query}`, {
|
|
55
|
+
method: 'POST', // ui-copy-ignore
|
|
56
|
+
credentials: 'same-origin',
|
|
57
|
+
headers: { 'x-dsh-unlag': '1' }, // ui-copy-ignore
|
|
58
|
+
keepalive: true, // ui-copy-ignore
|
|
59
|
+
}).catch(() => {});
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Ask the running app to replace changed plugins now; a running turn is reported, never cut. */
|
|
64
|
+
async function replacePlugins() {
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(`${HOST_BASE}/reload`, {
|
|
67
|
+
method: 'POST', // ui-copy-ignore
|
|
68
|
+
credentials: 'same-origin',
|
|
69
|
+
headers: { 'content-type': 'application/json', 'x-dsh-unlag': '1' }, // ui-copy-ignore
|
|
70
|
+
body: '{}', // ui-copy-ignore
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) return 'failed';
|
|
73
|
+
const data = await response.json();
|
|
74
|
+
return typeof data?.status === 'string' ? data.status : 'failed';
|
|
75
|
+
} catch {
|
|
76
|
+
return 'failed';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The plugin ids one startup bundle covers, read from its `??<id>/client.js,…` list. */
|
|
81
|
+
function bundledIds(url) {
|
|
82
|
+
const match = /\?\?([^&]+)/.exec(url);
|
|
83
|
+
if (match === null) return [];
|
|
84
|
+
return match[1].split(',').map((part) => decodeURIComponent(part).replace(/\/client\.js.*$/u, '')).filter((id) => id !== '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read every bundle the next boot would load and prove each listed plugin is inside. */
|
|
88
|
+
async function bundlesCoverStartup(graph) {
|
|
89
|
+
if (graph === null || typeof graph !== 'object') return false;
|
|
90
|
+
const entries = Array.isArray(graph.entries) ? graph.entries : [];
|
|
91
|
+
const batches = Array.isArray(graph.batches) ? graph.batches : [];
|
|
92
|
+
if (entries.length === 0 || batches.length === 0) return false;
|
|
93
|
+
const covered = new Set();
|
|
94
|
+
const sources = [];
|
|
95
|
+
for (const batch of batches) {
|
|
96
|
+
const url = typeof batch?.url === 'string' ? batch.url : '';
|
|
97
|
+
if (url === '') continue;
|
|
98
|
+
const listed = Array.isArray(batch.entries) && batch.entries.every((id) => typeof id === 'string') ? batch.entries : [];
|
|
99
|
+
const ids = listed.length > 0 ? listed : bundledIds(url);
|
|
100
|
+
if (ids.length === 0) return false;
|
|
101
|
+
for (const id of ids) covered.add(id);
|
|
102
|
+
sources.push({ url: new URL(url, location.href), ids });
|
|
103
|
+
}
|
|
104
|
+
if (sources.length === 0) return false;
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
const id = typeof entry === 'string' ? entry : entry?.id;
|
|
107
|
+
if (typeof id !== 'string' || !covered.has(id)) return false;
|
|
108
|
+
}
|
|
109
|
+
for (const source of sources) {
|
|
110
|
+
try {
|
|
111
|
+
const response = await fetch(source.url.toString(), { cache: 'no-store', credentials: 'same-origin' });
|
|
112
|
+
if (!response.ok) return false;
|
|
113
|
+
const body = await response.text();
|
|
114
|
+
if (body.length === 0) return false;
|
|
115
|
+
for (const id of source.ids) {
|
|
116
|
+
if (!body.includes(id)) return false;
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The first balanced JSON object after `__DSH_BOOT__` in a served document. */
|
|
126
|
+
function graphFromHtml(html) {
|
|
127
|
+
const at = html.indexOf('__DSH_BOOT__'); // ui-copy-ignore
|
|
128
|
+
if (at < 0) return null;
|
|
129
|
+
const start = html.indexOf('{', at);
|
|
130
|
+
if (start < 0) return null;
|
|
131
|
+
let depth = 0;
|
|
132
|
+
let quoted = false;
|
|
133
|
+
let escaped = false;
|
|
134
|
+
for (let index = start; index < html.length; index += 1) {
|
|
135
|
+
const ch = html[index];
|
|
136
|
+
if (quoted) {
|
|
137
|
+
if (escaped) escaped = false;
|
|
138
|
+
else if (ch === '\\') escaped = true;
|
|
139
|
+
else if (ch === '"') quoted = false;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (ch === '"') quoted = true;
|
|
143
|
+
else if (ch === '{') depth += 1;
|
|
144
|
+
else if (ch === '}') {
|
|
145
|
+
depth -= 1;
|
|
146
|
+
if (depth === 0) {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(html.slice(start, index + 1));
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The startup list a rebuild would actually get. This page's own list is only true of the
|
|
160
|
+
* page already running: a plugin swapped since then is listed there under its old module,
|
|
161
|
+
* so asking it first would refuse a rebuild that is in fact safe.
|
|
162
|
+
*/
|
|
163
|
+
/**
|
|
164
|
+
* The startup list the next document would get, read from the running app. It renders that
|
|
165
|
+
* list for every index response, so this is the same row the next boot reads, described by
|
|
166
|
+
* the side that serves the bundles rather than by whatever this document happens to hold.
|
|
167
|
+
*/
|
|
168
|
+
async function hostBootGraph() {
|
|
169
|
+
try {
|
|
170
|
+
const response = await fetch(`${HOST_BASE}/graph`, { cache: 'no-store', credentials: 'same-origin' });
|
|
171
|
+
if (!response.ok) return null;
|
|
172
|
+
const data = await response.json();
|
|
173
|
+
const graph = data?.graph;
|
|
174
|
+
return graph !== null && typeof graph === 'object' ? graph : null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function servedBootGraph() {
|
|
181
|
+
const fromHost = await hostBootGraph();
|
|
182
|
+
if (fromHost !== null) return fromHost;
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetch(location.href, { cache: 'no-store', credentials: 'same-origin' });
|
|
185
|
+
if (!response.ok) return null;
|
|
186
|
+
return graphFromHtml(await response.text());
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether a rebuild can re-run the interface at all. A boot activates one module per plugin
|
|
194
|
+
* in the startup list and stops the desktop app when any of them is missing, so a list row
|
|
195
|
+
* whose module is absent from the bundles it points at would end the app and every running
|
|
196
|
+
* turn along with it. Reading the bundles back proves each row still resolves before the
|
|
197
|
+
* interface is rebuilt; one repeat covers a bundle swapped while the app was re-composing.
|
|
198
|
+
* @returns true when every listed plugin's module is present in the served bundles.
|
|
199
|
+
*/
|
|
200
|
+
async function startupLooksServable() {
|
|
201
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
202
|
+
const graph = (await servedBootGraph()) ?? window.__DSH_BOOT__;
|
|
203
|
+
if (await bundlesCoverStartup(graph)) return true;
|
|
204
|
+
if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 300)); // ui-copy-ignore
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether the other plugins are ready for the rebuild: their saves run first, and a refusal
|
|
211
|
+
* from any of them stops the rebuild instead of discarding work they still hold.
|
|
212
|
+
*/
|
|
213
|
+
async function otherPluginsAreReady() {
|
|
214
|
+
const detail = { pending: [] };
|
|
215
|
+
const event = new CustomEvent(PREPARE_EVENT, { cancelable: true, detail });
|
|
216
|
+
window.dispatchEvent(event);
|
|
217
|
+
if (event.defaultPrevented) return false;
|
|
218
|
+
const pending = Array.isArray(detail.pending) ? detail.pending.filter((item) => item !== null && typeof item?.then === 'function') : [];
|
|
219
|
+
if (pending.length === 0) return true;
|
|
220
|
+
await Promise.race([
|
|
221
|
+
Promise.allSettled(pending),
|
|
222
|
+
new Promise((resolve) => setTimeout(resolve, 1200)), // ui-copy-ignore
|
|
223
|
+
]);
|
|
224
|
+
return event.defaultPrevented === false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Commit whatever the focused field holds. A field keeps the last keystrokes in its own
|
|
229
|
+
* state until the next write, so blurring it and yielding one frame leaves the draft in the
|
|
230
|
+
* session's stored input before the document goes away.
|
|
231
|
+
*/
|
|
232
|
+
async function commitFocusedField() {
|
|
233
|
+
const active = document.activeElement;
|
|
234
|
+
if (active !== null && typeof active.blur === 'function') active.blur();
|
|
235
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Re-enter the interface on a fresh document, keeping the current view in the address. */
|
|
239
|
+
function reloadFreshPage() {
|
|
240
|
+
const graph = window.__DSH_BOOT__;
|
|
241
|
+
const stamp = typeof graph?.rev === 'string' && graph.rev !== '' ? graph.rev : String(Date.now());
|
|
242
|
+
const target = new URL(location.href);
|
|
243
|
+
target.searchParams.set('interface', stamp);
|
|
244
|
+
window.location.replace(target.toString());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A window the user still has open would go away with the rebuild. */
|
|
248
|
+
function screenHasOpenWindow() {
|
|
249
|
+
return document.querySelector('[aria-modal="true"], dialog[open]') !== null; // ui-copy-ignore
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Whether anything still in the page would hold a navigation back. Asking the question without
|
|
254
|
+
* navigating runs the very listeners a rebuild would run, so work that is not committed yet is
|
|
255
|
+
* caught here rather than behind the leave-page prompt a navigation would raise.
|
|
256
|
+
* @returns true when a listener refuses to leave the page.
|
|
257
|
+
*/
|
|
258
|
+
function stillHeldBack() {
|
|
259
|
+
const probe = new Event('beforeunload', { cancelable: true }); // ui-copy-ignore
|
|
260
|
+
window.dispatchEvent(probe);
|
|
261
|
+
return probe.defaultPrevented === true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The automatic judgement: every reason a rebuild would take something away is checked here,
|
|
266
|
+
* in the order that costs the user least. A null answer means the interface can go.
|
|
267
|
+
* @returns the copy key of the reason to stop, or null when a rebuild is safe.
|
|
268
|
+
*/
|
|
269
|
+
async function prepareInterface() {
|
|
270
|
+
if (!(await startupLooksServable())) return 'blocked';
|
|
271
|
+
if (screenHasOpenWindow()) return 'dialog';
|
|
272
|
+
await commitFocusedField();
|
|
273
|
+
const ready = await otherPluginsAreReady();
|
|
274
|
+
if (!ready || stillHeldBack()) return 'unsaved';
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const WRAP_STYLE = { position: 'relative', display: 'inline-flex', alignItems: 'center' };
|
|
279
|
+
|
|
280
|
+
const BUTTON_STYLE = {
|
|
281
|
+
display: 'inline-flex', // ui-copy-ignore
|
|
282
|
+
alignItems: 'center',
|
|
283
|
+
justifyContent: 'center',
|
|
284
|
+
width: 28, // ui-copy-ignore
|
|
285
|
+
height: 28, // ui-copy-ignore
|
|
286
|
+
padding: 0, // ui-copy-ignore
|
|
287
|
+
border: 'none', // ui-copy-ignore
|
|
288
|
+
borderRadius: 6,
|
|
289
|
+
background: 'transparent',
|
|
290
|
+
color: 'inherit', // ui-copy-ignore
|
|
291
|
+
cursor: 'pointer', // ui-copy-ignore
|
|
292
|
+
opacity: 0.75, // ui-copy-ignore
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const PILL_STYLE = {
|
|
296
|
+
position: 'absolute', // ui-copy-ignore
|
|
297
|
+
top: 30, // ui-copy-ignore
|
|
298
|
+
right: 0, // ui-copy-ignore
|
|
299
|
+
padding: '4px 8px', // ui-copy-ignore
|
|
300
|
+
borderRadius: 6,
|
|
301
|
+
border: '1px solid var(--dsh-color-border, rgba(127,127,127,0.28))',
|
|
302
|
+
background: 'var(--dsh-color-surface, Canvas)',
|
|
303
|
+
color: 'var(--dsh-color-text-secondary, inherit)',
|
|
304
|
+
fontSize: 12, // ui-copy-ignore
|
|
305
|
+
whiteSpace: 'nowrap', // ui-copy-ignore
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
function ReloadButton() {
|
|
309
|
+
const [hover, setHover] = React.useState(false);
|
|
310
|
+
const [pending, setPending] = React.useState(false);
|
|
311
|
+
const [notice, setNotice] = React.useState(null);
|
|
312
|
+
|
|
313
|
+
React.useEffect(() => {
|
|
314
|
+
if (notice === null) return undefined;
|
|
315
|
+
const timer = setTimeout(() => setNotice(null), 8000);
|
|
316
|
+
return () => clearTimeout(timer);
|
|
317
|
+
}, [notice]);
|
|
318
|
+
|
|
319
|
+
const style = pending
|
|
320
|
+
? { ...BUTTON_STYLE, opacity: 0.4 }
|
|
321
|
+
: hover
|
|
322
|
+
? { ...BUTTON_STYLE, opacity: 1, background: 'var(--dsh-color-surface-hover, rgba(127,127,127,0.14))' }
|
|
323
|
+
: BUTTON_STYLE;
|
|
324
|
+
|
|
325
|
+
const onClick = async () => {
|
|
326
|
+
if (pending) return;
|
|
327
|
+
const previous = Number(window.sessionStorage.getItem(STAMP_KEY) ?? 0);
|
|
328
|
+
if (Number.isFinite(previous) && Date.now() - previous < 3000) return; // ui-copy-ignore
|
|
329
|
+
setPending(true);
|
|
330
|
+
setNotice(null);
|
|
331
|
+
const stop = (key) => {
|
|
332
|
+
setPending(false);
|
|
333
|
+
setNotice(COPY[key]);
|
|
334
|
+
};
|
|
335
|
+
const settings = await readSettings();
|
|
336
|
+
const replace = settings === null ? false : settings.plugins;
|
|
337
|
+
const rebuild = settings === null ? true : settings.page;
|
|
338
|
+
// Both switches off: a press does nothing at all.
|
|
339
|
+
if (!replace && !rebuild) {
|
|
340
|
+
setPending(false);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Replace first: a plugin that only changed the interface is then covered by the rebuild.
|
|
344
|
+
let pluginNote = null;
|
|
345
|
+
if (replace) {
|
|
346
|
+
const status = await replacePlugins();
|
|
347
|
+
if (status === 'queued') pluginNote = 'queued';
|
|
348
|
+
else if (status !== 'done' && status !== 'off') pluginNote = 'blocked';
|
|
349
|
+
}
|
|
350
|
+
if (!rebuild) {
|
|
351
|
+
setPending(false);
|
|
352
|
+
if (pluginNote !== null) setNotice(COPY[pluginNote]);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
window.sessionStorage.setItem(STAMP_KEY, String(Date.now()));
|
|
356
|
+
const reason = await prepareInterface();
|
|
357
|
+
if (reason !== null) {
|
|
358
|
+
report('held', reason);
|
|
359
|
+
return stop(reason);
|
|
360
|
+
}
|
|
361
|
+
report('rebuild');
|
|
362
|
+
reloadFreshPage();
|
|
363
|
+
setTimeout(() => setPending(false), 4000); // ui-copy-ignore
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return h('span', { style: WRAP_STYLE },
|
|
367
|
+
h('button', {
|
|
368
|
+
type: 'button', // ui-copy-ignore
|
|
369
|
+
title: notice ?? COPY.hint, // ui-copy-ignore
|
|
370
|
+
'aria-label': COPY.label,
|
|
371
|
+
style,
|
|
372
|
+
onMouseEnter: () => setHover(true),
|
|
373
|
+
onMouseLeave: () => setHover(false),
|
|
374
|
+
onClick,
|
|
375
|
+
}, h('svg', {
|
|
376
|
+
viewBox: '0 0 16 16', // ui-copy-ignore
|
|
377
|
+
width: 16, // ui-copy-ignore
|
|
378
|
+
height: 16, // ui-copy-ignore
|
|
379
|
+
fill: 'none', // ui-copy-ignore
|
|
380
|
+
stroke: 'currentColor', // ui-copy-ignore
|
|
381
|
+
strokeWidth: 1.4,
|
|
382
|
+
strokeLinecap: 'round',
|
|
383
|
+
strokeLinejoin: 'round',
|
|
384
|
+
'aria-hidden': true,
|
|
385
|
+
},
|
|
386
|
+
h('path', { d: 'M13 8a5 5 0 1 1-1.9-3.94' }),
|
|
387
|
+
h('path', { d: 'M13 2.7v3.6H9.4' }))),
|
|
388
|
+
notice === null ? null : h('span', { role: 'status', 'aria-live': 'polite', style: PILL_STYLE }, notice));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
inject: ['slots'], // ui-copy-ignore
|
|
393
|
+
apply(ctx) {
|
|
394
|
+
if (!desktop) return;
|
|
395
|
+
// Leave a line in the log per rebuilt interface: it is the one place that shows which
|
|
396
|
+
// plugin code a running page actually loaded.
|
|
397
|
+
report('mounted');
|
|
398
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
|
399
|
+
name: 'conversation.session.header.utilities',
|
|
400
|
+
id: 'dsh-unlag', // ui-copy-ignore
|
|
401
|
+
order: 29, // ui-copy-ignore
|
|
402
|
+
label: () => COPY.label, // ui-copy-ignore
|
|
403
|
+
}, ReloadButton));
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
},
|
|
407
|
+
});
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
- insert:
|
|
2
|
+
- id: dsh-unlag
|
|
3
|
+
name: 'dsh-unlag'
|
|
4
|
+
|
|
5
|
+
# Turn on the shipped module watcher, which the desktop launcher leaves off.
|
|
6
|
+
# `base` resolves against the plugin's own location, and an installed plugin
|
|
7
|
+
# lives inside app.asar, which the watcher cannot stat — hence the working
|
|
8
|
+
# directory, the one directory that always exists on the machine that boots it.
|
|
9
|
+
# `root` stays empty here: the profile's own modules are watched by the watcher
|
|
10
|
+
# installed-plugin path, and this entry only needs to start. To also watch
|
|
11
|
+
# source checkouts, add their absolute paths to `root` in your own profile
|
|
12
|
+
# patch layer.
|
|
13
|
+
- id: hmr
|
|
14
|
+
disabled: false
|
|
15
|
+
config:
|
|
16
|
+
base: !!js 'process.cwd()'
|
|
17
|
+
root: []
|
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<circle cx="32" cy="32" r="28" fill="#1f6feb"/>
|
|
3
|
+
<path d="M46 32a14 14 0 1 1-5.3-10.95" fill="none" stroke="#fff" stroke-width="4.5" stroke-linecap="round"/>
|
|
4
|
+
<path d="M46 15v9.5h-9.5" fill="none" stroke="#fff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, statSync, watch } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One header button, three ways new code reaches the running app:
|
|
7
|
+
*
|
|
8
|
+
* - Plugins (this half): a saved file below a watched development root replaces the affected
|
|
9
|
+
* plugin in place. The page is not rebuilt, so drafts, queued messages and running turns stay.
|
|
10
|
+
* - Interface (client half): the header button rebuilds the page once and keeps the session.
|
|
11
|
+
* - Both at once: the button asks this half to replace the changed plugins first, then the client
|
|
12
|
+
* half rebuilds the interface, so one press leaves neither half on old code. Either half can be
|
|
13
|
+
* switched off in the plugin's settings row; with both off a press does nothing.
|
|
14
|
+
*
|
|
15
|
+
* This half is written so that no path here can end the app: every callback body is inside a
|
|
16
|
+
* `try`, every failure is written to a log and dropped, and the reload itself waits for idle
|
|
17
|
+
* agents and refuses to re-enter. A plugin that cannot start is worse than a plugin that does
|
|
18
|
+
* nothing, because the desktop shell treats a failed startup entry as fatal.
|
|
19
|
+
*/
|
|
20
|
+
export const name = 'dsh-unlag'
|
|
21
|
+
/**
|
|
22
|
+
* Cordis has no optional injection: every key of an object declaration is read as a required
|
|
23
|
+
* service name, so `{ optional: [...] }` waits forever for a service called "optional". Declare
|
|
24
|
+
* only the web server, which any interface that can show the button has, and look the reload
|
|
25
|
+
* service up per use instead so a disabled reloader costs the plugin half, not the whole entry.
|
|
26
|
+
*/
|
|
27
|
+
export const inject = ['webServer']
|
|
28
|
+
|
|
29
|
+
const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
|
|
30
|
+
/** Tests point this at a scratch file, so a test run leaves the live log readable. */
|
|
31
|
+
const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
|
|
32
|
+
/** Survives a module replacement, so a reload cannot leave a second watcher behind. */
|
|
33
|
+
const WATCHER_KEY = Symbol.for('dsh-unlag.watchers')
|
|
34
|
+
/** The same, for the HTTP entry points the header button calls. */
|
|
35
|
+
const ROUTES_KEY = Symbol.for('dsh-unlag.routes')
|
|
36
|
+
const BASE_PATH = '/dsh-unlag'
|
|
37
|
+
/** A same-origin page sets this itself; a cross-site form post cannot, and fails the preflight. */
|
|
38
|
+
const BUTTON_HEADER = 'x-dsh-unlag'
|
|
39
|
+
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs'])
|
|
40
|
+
/** Build output and dependency trees change without the source a developer edits changing. */
|
|
41
|
+
const IGNORED_PATH = /(^|\/)(node_modules|\.git|\.cache|dist|build|coverage)([./]|$)/u
|
|
42
|
+
const DEFAULT_ROOTS = [join(homedir(), '.dsh', 'plugins')]
|
|
43
|
+
/** A turn that never goes idle must not turn this into an endless poll. */
|
|
44
|
+
const MAX_DEFERRALS = 40
|
|
45
|
+
const MIN_INTERVAL_MS = 1500
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The two rows this plugin shows in Settings, in the schema dialect the settings form reads.
|
|
49
|
+
* Both default on: a press then leaves nothing on old code.
|
|
50
|
+
*/
|
|
51
|
+
const SCHEMA = {
|
|
52
|
+
uid: 6, // ui-copy-ignore
|
|
53
|
+
refs: {
|
|
54
|
+
2: { type: 'boolean', meta: { default: true, description: '按下刷新时换掉插件代码' } }, // ui-copy-ignore
|
|
55
|
+
5: { type: 'boolean', meta: { default: true, description: '按下刷新时重建界面' } }, // ui-copy-ignore
|
|
56
|
+
6: { type: 'object', meta: { default: {} }, dict: { reloadPlugins: 2, reloadInterface: 5 } }, // ui-copy-ignore
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The same rows as plain nodes, which is the shape the settings form projects values through. */
|
|
61
|
+
const FIELDS = {
|
|
62
|
+
reloadPlugins: { type: 'boolean', meta: { default: true, description: '按下刷新时换掉插件代码' } },
|
|
63
|
+
reloadInterface: { type: 'boolean', meta: { default: true, description: '按下刷新时重建界面' } },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Exported as the row's Config so Settings can show the two switches. Validation never throws:
|
|
68
|
+
* an unreadable value falls back to the default instead of failing the startup entry, because a
|
|
69
|
+
* rejected config here would take the whole app down at boot.
|
|
70
|
+
*/
|
|
71
|
+
export const Config = {
|
|
72
|
+
...SCHEMA,
|
|
73
|
+
type: 'object', // ui-copy-ignore
|
|
74
|
+
dict: FIELDS, // ui-copy-ignore
|
|
75
|
+
meta: { default: {} }, // ui-copy-ignore
|
|
76
|
+
toJSON() {
|
|
77
|
+
return SCHEMA
|
|
78
|
+
},
|
|
79
|
+
'~standard': {
|
|
80
|
+
version: 1, // ui-copy-ignore
|
|
81
|
+
vendor: 'dsh-unlag', // ui-copy-ignore
|
|
82
|
+
validate(value) {
|
|
83
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
84
|
+
return { value: { ...source, ...settingsOf(source) } }
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The two switches, defaulted on, from any config shape. */
|
|
90
|
+
function settingsOf(config) {
|
|
91
|
+
return {
|
|
92
|
+
reloadPlugins: config?.reloadPlugins !== false,
|
|
93
|
+
reloadInterface: config?.reloadInterface !== false,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function log(line) {
|
|
98
|
+
try {
|
|
99
|
+
appendFileSync(logTarget(), `${new Date().toISOString()} ${line}\n`)
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function closeAll(closer) {
|
|
104
|
+
if (typeof closer === 'function') {
|
|
105
|
+
try {
|
|
106
|
+
closer()
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Development roots the profile links by path; a packaged dependency is not watched. */
|
|
112
|
+
function linkedRoots(baseDir) {
|
|
113
|
+
const roots = []
|
|
114
|
+
try {
|
|
115
|
+
const manifest = JSON.parse(readFileSync(join(baseDir, 'package.json'), 'utf8'))
|
|
116
|
+
const deps = { ...manifest.dependencies, ...manifest.devDependencies }
|
|
117
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
118
|
+
if (typeof spec !== 'string' || !/^(link|file):/u.test(spec)) continue
|
|
119
|
+
const path = resolve(baseDir, spec.replace(/^(link|file):/u, ''))
|
|
120
|
+
if (existsSync(path)) roots.push(path)
|
|
121
|
+
else log(`skip ${name}: ${path} is missing`)
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
log(`cannot read the profile manifest: ${error?.message ?? error}`)
|
|
125
|
+
}
|
|
126
|
+
return roots
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether a watched path is worth a reload: source only, and never this log. */
|
|
130
|
+
function worthReloading(filename) {
|
|
131
|
+
const name = typeof filename === 'string' ? filename : ''
|
|
132
|
+
if (name === '') return false
|
|
133
|
+
if (!CODE_EXTENSIONS.has(name.slice(name.lastIndexOf('.')))) return false
|
|
134
|
+
return !IGNORED_PATH.test(name)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The plugin half. One controller serves both callers: a saved file (debounced) and the header
|
|
139
|
+
* button (immediate). A swap never waits for turns, never interrupts them, and never re-enters.
|
|
140
|
+
*/
|
|
141
|
+
function pluginReloader(ctx, config) {
|
|
142
|
+
/** HMR can start after this half, so ask per use instead of once at load. */
|
|
143
|
+
const hmrService = () => {
|
|
144
|
+
const hmr = ctx.get('hmr')
|
|
145
|
+
if (hmr === null || typeof hmr?.reconcileProfile !== 'function' || typeof hmr?.runExclusive !== 'function') return null
|
|
146
|
+
return hmr
|
|
147
|
+
}
|
|
148
|
+
const available = () => hmrService() !== null
|
|
149
|
+
/** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
|
|
150
|
+
const idleOnly = config?.idleOnly === true
|
|
151
|
+
const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
|
|
152
|
+
|
|
153
|
+
let timer = null
|
|
154
|
+
let running = false
|
|
155
|
+
let again = false
|
|
156
|
+
let deferrals = 0
|
|
157
|
+
let lastRun = 0
|
|
158
|
+
|
|
159
|
+
/** Resolve true when the HMR queue is empty, i.e. no transaction is really running right now. */
|
|
160
|
+
const queueIsIdle = async (hmr) => {
|
|
161
|
+
const queue = hmr?.operations
|
|
162
|
+
if (typeof queue?.then !== 'function') return true
|
|
163
|
+
let settled = false
|
|
164
|
+
Promise.resolve(queue).then(
|
|
165
|
+
() => (settled = true),
|
|
166
|
+
() => (settled = true),
|
|
167
|
+
)
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
169
|
+
return settled
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const turnsRunning = () => {
|
|
173
|
+
try {
|
|
174
|
+
const agents = ctx.get('agents')
|
|
175
|
+
const list = typeof agents?.list === 'function' ? agents.list() : []
|
|
176
|
+
return Array.isArray(list) && list.some((agent) => agent?.status !== 'idle')
|
|
177
|
+
} catch {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const busy = () => idleOnly && turnsRunning()
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Ask the HMR service to recompose; it throws rather than half-applying, so a catch is enough.
|
|
185
|
+
* Another reload may already hold the transaction (a save landing next to a session's own swap),
|
|
186
|
+
* so a nested refusal is retried instead of being reported as a reload that failed.
|
|
187
|
+
*/
|
|
188
|
+
const reload = async (hmr) => {
|
|
189
|
+
let result = null
|
|
190
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
191
|
+
try {
|
|
192
|
+
result = await hmr.runExclusive(() => hmr.reconcileProfile([]))
|
|
193
|
+
break
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const message = String(error?.message ?? error)
|
|
196
|
+
if (message.includes('nested') === false) throw error
|
|
197
|
+
if (await queueIsIdle(hmr)) {
|
|
198
|
+
// The refusal came from a finished transaction whose context this callback inherited
|
|
199
|
+
// (a watcher created while a reload was restarting this plugin). With the queue empty
|
|
200
|
+
// there is nothing to serialize against, so recompose directly.
|
|
201
|
+
log('reload ran outside the queue: the caller inherited a finished transaction')
|
|
202
|
+
result = await hmr.reconcileProfile([])
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
if (attempt >= 9) throw error
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const warnings = Array.isArray(result?.warnings) ? result.warnings.length : 0
|
|
210
|
+
const pending = Array.isArray(result?.pending) ? result.pending.length : 0
|
|
211
|
+
const suffix = typeof result?.restartReason === 'string' ? ` restart=${result.restartReason}` : ''
|
|
212
|
+
log(`plugins reloaded (warnings=${warnings} pending=${pending}${suffix})`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const flush = async () => {
|
|
216
|
+
timer = null
|
|
217
|
+
if (running) {
|
|
218
|
+
again = true
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
if (busy()) {
|
|
223
|
+
deferrals += 1
|
|
224
|
+
if (deferrals > MAX_DEFERRALS) {
|
|
225
|
+
deferrals = 0
|
|
226
|
+
log('reloading while turns are still running; they keep running')
|
|
227
|
+
} else {
|
|
228
|
+
timer = setTimeout(() => void flush(), 500)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const wait = lastRun + MIN_INTERVAL_MS - Date.now()
|
|
233
|
+
if (wait > 0) {
|
|
234
|
+
timer = setTimeout(() => void flush(), wait)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
const hmr = hmrService()
|
|
238
|
+
if (hmr === null) return
|
|
239
|
+
deferrals = 0
|
|
240
|
+
lastRun = Date.now()
|
|
241
|
+
running = true
|
|
242
|
+
await reload(hmr)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
log(`reload failed, the previous code keeps running: ${error?.message ?? error}`)
|
|
245
|
+
} finally {
|
|
246
|
+
running = false
|
|
247
|
+
if (again) {
|
|
248
|
+
again = false
|
|
249
|
+
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const schedule = (filename) => {
|
|
255
|
+
try {
|
|
256
|
+
if (!worthReloading(filename)) return
|
|
257
|
+
if (timer !== null) clearTimeout(timer)
|
|
258
|
+
timer = setTimeout(() => void flush(), debounce)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
log(`ignored a watch event: ${error?.message ?? error}`)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Opt-in (`idleOnly`) quiet-moment mode: wait for a gap, and after a short grace swap anyway,
|
|
266
|
+
* because waiting forever would leave busy machines with a dead button.
|
|
267
|
+
*/
|
|
268
|
+
const WAIT_MAX_MS = Number.isFinite(config?.busyWaitMs) ? Math.max(0, config.busyWaitMs) : 60_000
|
|
269
|
+
let queued = null
|
|
270
|
+
const queueReload = () => {
|
|
271
|
+
if (queued !== null) return 'queued'
|
|
272
|
+
const started = Date.now()
|
|
273
|
+
const tick = () => {
|
|
274
|
+
queued = null
|
|
275
|
+
if (running) {
|
|
276
|
+
queued = setTimeout(tick, 500)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (busy() && Date.now() - started < WAIT_MAX_MS) {
|
|
280
|
+
queued = setTimeout(tick, 1000)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (busy()) log('reloading while turns are still running; they keep running')
|
|
284
|
+
void reloadNow(true)
|
|
285
|
+
}
|
|
286
|
+
queued = setTimeout(tick, 1000)
|
|
287
|
+
return 'queued'
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The button path: run now and report what happened, so the page can tell the user why the
|
|
292
|
+
* press did not replace anything instead of looking broken.
|
|
293
|
+
* @returns 'done' | 'queued' | 'unavailable' | 'failed'
|
|
294
|
+
*/
|
|
295
|
+
const reloadNow = async (force = false) => {
|
|
296
|
+
const hmr = hmrService()
|
|
297
|
+
if (hmr === null) return 'unavailable'
|
|
298
|
+
if (turnsRunning()) log('reloading while turns are still running; they keep running')
|
|
299
|
+
if (running || (force === false && busy())) return queueReload()
|
|
300
|
+
const wait = lastRun + MIN_INTERVAL_MS - Date.now()
|
|
301
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
|
|
302
|
+
if (running || (force === false && busy())) return queueReload()
|
|
303
|
+
if (timer !== null) {
|
|
304
|
+
clearTimeout(timer)
|
|
305
|
+
timer = null
|
|
306
|
+
}
|
|
307
|
+
running = true
|
|
308
|
+
deferrals = 0
|
|
309
|
+
lastRun = Date.now()
|
|
310
|
+
try {
|
|
311
|
+
await reload(hmr)
|
|
312
|
+
return 'done'
|
|
313
|
+
} catch (error) {
|
|
314
|
+
log(`reload failed, the previous code keeps running: ${error?.message ?? error}`)
|
|
315
|
+
return 'failed'
|
|
316
|
+
} finally {
|
|
317
|
+
running = false
|
|
318
|
+
again = false
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Watch the development roots; a missing or unreadable root is logged, never fatal. */
|
|
323
|
+
const startWatching = () => {
|
|
324
|
+
const hmr = hmrService()
|
|
325
|
+
if (hmr === null) return null
|
|
326
|
+
const configured = Array.isArray(config?.roots) ? config.roots.filter((root) => typeof root === 'string') : []
|
|
327
|
+
const roots = [...new Set([...configured, ...linkedRoots(hmr.baseDir ?? process.cwd()), ...DEFAULT_ROOTS])]
|
|
328
|
+
const closers = []
|
|
329
|
+
let watching = 0
|
|
330
|
+
for (const root of roots) {
|
|
331
|
+
try {
|
|
332
|
+
if (statSync(root).isDirectory() === false) continue
|
|
333
|
+
const closer = watch(root, { recursive: true }, (_event, filename) => schedule(filename))
|
|
334
|
+
closer.on?.('error', (error) => log(`watch ${root} stopped: ${String(error?.message ?? error)}`))
|
|
335
|
+
closers.push(() => closer.close())
|
|
336
|
+
watching += 1
|
|
337
|
+
} catch (error) {
|
|
338
|
+
log(`cannot watch ${root}: ${error?.message ?? error}`)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
log(`watching ${watching} package dir(s)`)
|
|
342
|
+
return () => {
|
|
343
|
+
if (timer !== null) clearTimeout(timer)
|
|
344
|
+
timer = null
|
|
345
|
+
for (const close of closers) closeAll(close)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const stop = () => {
|
|
350
|
+
if (queued !== null) clearTimeout(queued)
|
|
351
|
+
queued = null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { reloadNow, startWatching, available, stop }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Write one JSON answer; a response already gone must not throw into the caller. */
|
|
358
|
+
function sendJson(res, status, payload) {
|
|
359
|
+
try {
|
|
360
|
+
if (res.writableEnded === true || res.destroyed === true) return
|
|
361
|
+
const body = JSON.stringify(payload)
|
|
362
|
+
res.writeHead(status, {
|
|
363
|
+
'content-type': 'application/json; charset=utf-8',
|
|
364
|
+
'cache-control': 'no-store',
|
|
365
|
+
'content-length': Buffer.byteLength(body),
|
|
366
|
+
})
|
|
367
|
+
res.end(body)
|
|
368
|
+
} catch {}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The startup list the server would hand the next document, read from the live index injection
|
|
373
|
+
* table. The page's own copy describes the page already running: a plugin swapped since then
|
|
374
|
+
* leaves it naming a module graph that no longer resolves, which is exactly the list a rebuild
|
|
375
|
+
* must not trust.
|
|
376
|
+
*/
|
|
377
|
+
function liveBootGraph(server) {
|
|
378
|
+
try {
|
|
379
|
+
if (typeof server.collectIndexInjections !== 'function') return null
|
|
380
|
+
const rows = server.collectIndexInjections()
|
|
381
|
+
if (!Array.isArray(rows)) return null
|
|
382
|
+
for (const row of rows) {
|
|
383
|
+
if (row?.kind !== 'global') continue
|
|
384
|
+
let value = row.value
|
|
385
|
+
if (typeof value === 'string') {
|
|
386
|
+
try {
|
|
387
|
+
value = JSON.parse(value)
|
|
388
|
+
} catch {
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (value === null || typeof value !== 'object') continue
|
|
393
|
+
if (Array.isArray(value.entries) && Array.isArray(value.batches)) return value
|
|
394
|
+
}
|
|
395
|
+
return null
|
|
396
|
+
} catch (error) {
|
|
397
|
+
log(`cannot read the startup list: ${error?.message ?? error}`)
|
|
398
|
+
return null
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The entry points the header button calls. Each answers with the current switches so the page
|
|
404
|
+
* never guesses: with both off it does nothing, and a turn still running is reported rather than
|
|
405
|
+
* cut. The press outcome is written to the log, because a press that stopped before rebuilding
|
|
406
|
+
* leaves no other trace once the page goes on running.
|
|
407
|
+
*/
|
|
408
|
+
function serveButton(ctx, controller, config) {
|
|
409
|
+
const server = ctx.get('webServer')
|
|
410
|
+
if (server === undefined || server === null || typeof server.register !== 'function') {
|
|
411
|
+
log('no web server: the header button cannot ask for a reload')
|
|
412
|
+
return null
|
|
413
|
+
}
|
|
414
|
+
const settings = settingsOf(config)
|
|
415
|
+
const handlers = [
|
|
416
|
+
[
|
|
417
|
+
`${BASE_PATH}/state`,
|
|
418
|
+
(_req, res) => {
|
|
419
|
+
sendJson(res, 200, { plugins: settings.reloadPlugins, page: settings.reloadInterface })
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
[
|
|
423
|
+
`${BASE_PATH}/graph`,
|
|
424
|
+
(_req, res) => {
|
|
425
|
+
sendJson(res, 200, { graph: liveBootGraph(server) })
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
[
|
|
429
|
+
`${BASE_PATH}/report`,
|
|
430
|
+
(req, res) => {
|
|
431
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
432
|
+
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
433
|
+
let query = ''
|
|
434
|
+
try {
|
|
435
|
+
query = new URL(String(req?.url ?? ''), 'http://x').searchParams.toString()
|
|
436
|
+
} catch {}
|
|
437
|
+
const params = new URLSearchParams(query)
|
|
438
|
+
const event = params.get('event') ?? ''
|
|
439
|
+
const gate = params.get('gate') ?? ''
|
|
440
|
+
if (event === 'mounted') log('interface rebuilt; the page runs the plugins as served now')
|
|
441
|
+
else if (event === 'rebuild') log('rebuild allowed: the page re-enters on the served modules')
|
|
442
|
+
else if (event === 'held') log(`rebuild held back: ${gate === '' ? 'unknown' : gate}`)
|
|
443
|
+
sendJson(res, 200, { status: 'done' })
|
|
444
|
+
},
|
|
445
|
+
],
|
|
446
|
+
[
|
|
447
|
+
`${BASE_PATH}/reload`,
|
|
448
|
+
async (req, res) => {
|
|
449
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
450
|
+
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
451
|
+
const header = req?.headers?.[BUTTON_HEADER]
|
|
452
|
+
if (typeof header !== 'string' || header === '') return sendJson(res, 403, { status: 'forbidden' })
|
|
453
|
+
if (!settings.reloadPlugins) return sendJson(res, 200, { status: 'off' })
|
|
454
|
+
const status = await controller.reloadNow()
|
|
455
|
+
sendJson(res, 200, { status })
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
]
|
|
459
|
+
const disposers = []
|
|
460
|
+
for (const [path, handler] of handlers) {
|
|
461
|
+
try {
|
|
462
|
+
const dispose = server.register({ kind: 'exact', path, handler })
|
|
463
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
464
|
+
} catch (error) {
|
|
465
|
+
log(`cannot answer ${path}: ${error?.message ?? error}`)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return () => {
|
|
469
|
+
for (const dispose of disposers) closeAll(dispose)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function apply(ctx, config) {
|
|
474
|
+
try {
|
|
475
|
+
closeAll(globalThis[WATCHER_KEY])
|
|
476
|
+
closeAll(globalThis[ROUTES_KEY])
|
|
477
|
+
globalThis[WATCHER_KEY] = null
|
|
478
|
+
globalThis[ROUTES_KEY] = null
|
|
479
|
+
|
|
480
|
+
const controller = pluginReloader(ctx, config ?? {})
|
|
481
|
+
/** Watching needs HMR; start as soon as it exists, drop the watcher when it goes away. */
|
|
482
|
+
const syncWatching = () => {
|
|
483
|
+
if (config?.plugins === 'off') return
|
|
484
|
+
if (controller.available() === false) {
|
|
485
|
+
closeAll(globalThis[WATCHER_KEY])
|
|
486
|
+
globalThis[WATCHER_KEY] = null
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
if (globalThis[WATCHER_KEY] !== null) return
|
|
490
|
+
globalThis[WATCHER_KEY] = controller.startWatching()
|
|
491
|
+
}
|
|
492
|
+
if (config?.plugins === 'off') log('file watching is off')
|
|
493
|
+
else {
|
|
494
|
+
syncWatching()
|
|
495
|
+
if (globalThis[WATCHER_KEY] === null) log('plugin reload is unavailable: the hmr entry is not running')
|
|
496
|
+
}
|
|
497
|
+
if (typeof ctx.on === 'function') {
|
|
498
|
+
ctx.on('internal/service', (service) => {
|
|
499
|
+
if (service === 'hmr') syncWatching()
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
globalThis[ROUTES_KEY] = serveButton(ctx, controller, config ?? {})
|
|
503
|
+
|
|
504
|
+
ctx.on('dispose', () => {
|
|
505
|
+
controller.stop()
|
|
506
|
+
closeAll(globalThis[WATCHER_KEY])
|
|
507
|
+
closeAll(globalThis[ROUTES_KEY])
|
|
508
|
+
globalThis[WATCHER_KEY] = null
|
|
509
|
+
globalThis[ROUTES_KEY] = null
|
|
510
|
+
})
|
|
511
|
+
} catch (error) {
|
|
512
|
+
log(`plugin reload did not start: ${error?.message ?? error}`)
|
|
513
|
+
}
|
|
514
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.7.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Clear a sluggish DeepSeek Harness workbench without restarting: rebuild the interface and swap plugin code in place, keeping running turns, queued messages and unsent drafts.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./locale/*.json": "./locale/*.json",
|
|
11
|
+
"./icon": "./icon.svg"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"client.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"locale/*.json",
|
|
18
|
+
"README.md",
|
|
19
|
+
"icon.svg"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"dsh-plugin",
|
|
23
|
+
"deepseek-harness",
|
|
24
|
+
"cordis",
|
|
25
|
+
"hot-reload",
|
|
26
|
+
"hmr"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/regrevia/dsh-unlag.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/regrevia/dsh-unlag#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/regrevia/dsh-unlag/issues"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"dsh": ">=0.2.0-rc.1"
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web",
|
|
45
|
+
"immediately": true,
|
|
46
|
+
"inject": [
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|