dsh-deferred-boot 0.3.1 → 0.3.2

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/lib/client.js CHANGED
@@ -1,593 +1,627 @@
1
- // Client half of dsh-deferred-boot.
2
- //
3
- // It renders the plugin's form in the settings item slot of the plugin
4
- // manager, lists the installed plugins with a startup cost grade, and
5
- // reads/writes the `deferred-boot` settings namespace through ctx.configForms.
6
- // The host half (src/index.js) declares the four fields as volatile so this
7
- // form is the only place they are edited.
8
- window.__ModuleLoader__.load({
9
- id: 'dsh-deferred-boot',
10
- factory: (require) => {
11
- var module = { exports: {} }
12
- var exports = module.exports
13
- const React = require('react')
14
- const h = React.createElement
15
-
16
- const styles = {
17
- root: { display: 'flex', flexDirection: 'column', gap: '6px', fontSize: 13, lineHeight: 1.5 },
18
- intro: { margin: '0 0 10px', color: 'var(--dsh-text-2, #c8c8c8)' },
19
- row: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '6px 0' },
20
- rowMain: { display: 'flex', alignItems: 'center', gap: 12 },
21
- label: { width: 156, flex: '0 0 156px', color: 'var(--dsh-text-2, #c8c8c8)' },
22
- input: {
23
- flex: '0 1 240px',
24
- minWidth: 120,
25
- padding: '4px 8px',
26
- borderRadius: 6,
27
- border: '1px solid var(--dsh-border, #3a3a3a)',
28
- background: 'var(--dsh-surface, #1f1f1f)',
29
- color: 'inherit',
30
- font: 'inherit',
31
- },
32
- hint: { margin: '0 0 0 168px', fontSize: 12, lineHeight: 1.5, color: 'var(--dsh-text-3, #8b8b8b)' },
33
- note: { margin: '0 0 0 168px', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
34
- textarea: {
35
- width: '100%',
36
- minHeight: 96,
37
- padding: '6px 8px',
38
- borderRadius: 6,
39
- border: '1px solid var(--dsh-border, #3a3a3a)',
40
- background: 'var(--dsh-surface, #1f1f1f)',
41
- color: 'inherit',
42
- font: 'inherit',
43
- resize: 'vertical',
44
- },
45
- actions: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 },
46
- button: {
47
- padding: '4px 14px',
48
- borderRadius: 6,
49
- border: '1px solid var(--dsh-accent, #4d6bfe)',
50
- background: 'var(--dsh-accent, #4d6bfe)',
51
- color: '#fff',
52
- cursor: 'pointer',
53
- font: 'inherit',
54
- },
55
- status: { fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
56
- list: { margin: '4px 0 0', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 2 },
57
- item: { display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0' },
58
- itemName: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
59
- itemMeta: { flex: '0 0 auto', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
60
- badge: {
61
- flex: '0 0 auto',
62
- padding: '0 6px',
63
- borderRadius: 4,
64
- fontSize: 11,
65
- lineHeight: '18px',
66
- border: '1px solid var(--dsh-border, #3a3a3a)',
67
- color: 'var(--dsh-text-2, #c8c8c8)',
68
- },
69
- badgeHeavy: { borderColor: '#a33', color: '#e88' },
70
- badgeMedium: { borderColor: '#a73', color: '#ec8' },
71
- badgeLight: { borderColor: '#3a7', color: '#8d8' },
72
- footer: {
73
- display: 'flex',
74
- justifyContent: 'space-between',
75
- alignItems: 'center',
76
- marginTop: 10,
77
- paddingTop: 8,
78
- borderTop: '1px solid var(--dsh-border, #3a3a3a)',
79
- fontSize: 12,
80
- color: 'var(--dsh-text-3, #8b8b8b)',
81
- },
82
- estimate: { color: 'var(--dsh-text-2, #c8c8c8)' },
83
- groupTitle: { margin: '12px 0 2px', fontSize: 12, color: 'var(--dsh-text-2, #c8c8c8)' },
84
- linkButton: {
85
- background: 'transparent',
86
- border: 0,
87
- padding: 0,
88
- color: 'var(--dsh-accent, #4d6bfe)',
89
- font: 'inherit',
90
- fontSize: 12,
91
- cursor: 'pointer',
92
- },
93
- card: {
94
- border: '1px solid var(--dsh-border, #3a3a3a)',
95
- borderRadius: 10,
96
- padding: '12px 14px',
97
- marginBottom: 10,
98
- background: 'var(--dsh-surface-2, #1c1c1c)',
99
- },
100
- cardTitle: { margin: '0 0 10px', fontSize: 13, fontWeight: 600, color: 'var(--dsh-text-1, #e8e8e8)' },
101
- }
102
-
103
- const ZH = {
104
- intro: '把指定的插件推迟到主界面就绪之后再挂载,一次空闲切片只挂一个,避免插件成批初始化把首屏拖住。',
105
- 'field.idleDelay': '就绪后延迟 (ms)',
106
- 'field.disposeTimeout': 'dispose 超时 (ms)',
107
- 'field.readyFallbackMs': '就绪兜底超时 (ms)',
108
- 'field.deferredPlugins': '延迟挂载的插件',
109
- 'plugins.label': '延迟挂载的插件(JSON 数组,每项 { id, name, strategy, config })',
110
- 'plugins.hint': 'strategy: idle(就绪后自动挂载)/ manual(调用 deferredBoot.ensure(id) 才挂载)/ inline-lazy / disabled。',
111
- 'hint.idleDelay': '主界面就绪后等待多久才开始第一次空闲调度(毫秒)。调大更晚开始、首屏更稳;调小更早挂载目标插件。',
112
- 'hint.disposeTimeout': '卸载或热重载目标插件时最多等待多久(毫秒)。超时视为已卸载并继续,防止卸载卡住调度。',
113
- 'hint.readyFallbackMs': '等不到“主界面已就绪”信号时的兜底超时(毫秒)。超时后按已就绪处理,继续延迟挂载。',
114
- 'hint.plugins': '每项一个目标插件:id 为 loader 条目 id,name 为模块名,strategy 决定挂载方式,config 传给目标插件。',
115
- 'catalog.title': '可放缓启动的插件',
116
- 'catalog.hint': '勾选后这些插件会在主界面就绪后逐个挂载;评级按启动开销经验估算,仅供参考。',
117
- 'catalog.loading': '正在读取已安装插件…',
118
- 'catalog.failed': '读取插件列表失败(需要新版 DSH 客户端)。',
119
- 'catalog.empty': '没有可勾选的插件。',
120
- 'catalog.core': '核心',
121
- 'catalog.self': '本插件',
122
- 'catalog.running': '运行中',
123
- 'catalog.disabled': '已禁用',
124
- 'catalog.unknown': '未运行',
125
- 'grade.light': '较轻',
126
- 'grade.medium': '中等',
127
- 'grade.heavy': '严重',
128
- 'advanced': '高级:直接编辑 deferredPlugins(JSON)',
129
- 'estimate': '预计本次启动额外等待 ≈ {seconds} 秒(估算)',
130
- save: '保存',
131
- saving: '保存中…',
132
- saved: '已保存,下一次操作起生效。',
133
- conflict: '配置已被其他窗口修改,已重新载入,请再保存一次。',
134
- failed: '保存失败,请查看 Host 日志。',
135
- badJson: '延迟插件不是合法 JSON,请修正后再保存。',
136
- notArray: '延迟插件必须是 JSON 数组。',
137
- unavailable: '配置服务暂不可用(需要新版 DSH 客户端)。',
138
- note: '配置写入当前 profile 的 Cordis patch,无需手动编辑 cordis.patch.yml。',
139
- 'section.hint': '主界面就绪后再逐个挂载插件,避免成批初始化拖住首屏。',
140
- 'section.open': '配置',
141
- 'section.close': '收起',
142
- 'panel.timing': '启动时机',
143
- 'group.third': '第三方插件',
144
- 'group.official': '官方插件',
145
- 'group.expand': '展开官方插件',
146
- 'group.collapse': '收起官方插件',
147
- }
148
-
149
- const EN = {
150
- intro: 'Mount the listed plugins after the main UI is ready, one idling slice at a time, so a batch of heavy plugins cannot hold up the first screen.',
151
- 'field.idleDelay': 'Delay after ready (ms)',
152
- 'field.disposeTimeout': 'Dispose timeout (ms)',
153
- 'field.readyFallbackMs': 'Ready fallback (ms)',
154
- 'field.deferredPlugins': 'Deferred plugins',
155
- 'plugins.label': 'Deferred plugins (JSON array, each { id, name, strategy, config })',
156
- 'plugins.hint': 'strategy: idle (mount after ready) / manual (wait for deferredBoot.ensure(id)) / inline-lazy / disabled.',
157
- 'hint.idleDelay': 'How long to wait after the main UI is ready before the first idle slice (ms). Larger waits longer for a steadier first paint; smaller mounts targets sooner.',
158
- 'hint.disposeTimeout': 'Maximum time to wait while unloading or hot-reloading a target (ms). On timeout the target is treated as unmounted and scheduling continues.',
159
- 'hint.readyFallbackMs': 'Fallback timeout for “main UI ready” (ms). When no ready signal arrives, mounting starts after this delay.',
160
- 'hint.plugins': 'One entry per deferred plugin: id is the loader entry id, name is the module name, strategy controls how it mounts, config is passed to the target.',
161
- 'catalog.title': 'Plugins that can start later',
162
- 'catalog.hint': 'Checked plugins mount one by one after the main UI is ready. Grades are rough startup-cost estimates for reference only.',
163
- 'catalog.loading': 'Reading installed plugins…',
164
- 'catalog.failed': 'Could not read the installed plugin list (a newer DSH client is required).',
165
- 'catalog.empty': 'No plugins to choose from.',
166
- 'catalog.core': 'core',
167
- 'catalog.self': 'this plugin',
168
- 'catalog.running': 'running',
169
- 'catalog.disabled': 'disabled',
170
- 'catalog.unknown': 'not running',
171
- 'grade.light': 'light',
172
- 'grade.medium': 'medium',
173
- 'grade.heavy': 'heavy',
174
- 'advanced': 'Advanced: edit deferredPlugins (JSON) directly',
175
- 'estimate': 'Estimated extra wait this start ≈ {seconds}s (rough)',
176
- save: 'Save',
177
- saving: 'Saving…',
178
- saved: 'Saved; takes effect from the next operation.',
179
- conflict: 'The settings changed in another window. Reloaded — please save again.',
180
- failed: 'Save failed; check the host log.',
181
- badJson: 'Deferred plugins is not valid JSON.',
182
- notArray: 'Deferred plugins must be a JSON array.',
183
- unavailable: 'The config form service is unavailable (a newer DSH client is required).',
184
- note: 'The change is written to the current profile Cordis patch; cordis.patch.yml stays untouched.',
185
- 'section.hint': 'Mount plugins one by one after the main UI is ready so a batch of initializers cannot hold up the first screen.',
186
- 'section.open': 'Configure',
187
- 'section.close': 'Collapse',
188
- 'panel.timing': 'Startup timing',
189
- 'group.third': 'Third-party plugins',
190
- 'group.official': 'Official plugins',
191
- 'group.expand': 'Show official plugins',
192
- 'group.collapse': 'Hide official plugins',
193
- }
194
-
195
- // Startup-cost grades are rough estimates used only for ordering and the
196
- // bottom-right figure. They do not claim to be measurements.
197
- const COST = { light: 200, medium: 600, heavy: 1800 }
198
- const HEAVY = /billion-context|dshmarket|univer|office-toolkit|solidworks|mech-workbench|tabbit/
199
- const MEDIUM = /dsh-pilot|whale|token-usage|plugin-mgr|dsh-unlag/
200
-
201
- function gradeOf(name) {
202
- const text = String(name || '')
203
- if (HEAVY.test(text)) return 'heavy'
204
- if (MEDIUM.test(text)) return 'medium'
205
- return 'light'
206
- }
207
-
208
- function Row(props) {
209
- return h(
210
- 'div',
211
- { style: styles.row },
212
- h('div', { style: styles.rowMain }, h('span', { style: styles.label }, props.label), props.children),
213
- props.hint ? h('p', { style: styles.hint }, props.hint) : null,
214
- )
215
- }
216
-
217
- function useSnapshot(form) {
218
- // A settings controller can refuse `getSnapshot()` before its describe
219
- // round-trip lands. That throw used to unmount the whole slot subtree
220
- // (the form "flashed" and vanished), so every read is guarded.
221
- const read = () => {
222
- try {
223
- if (!form || typeof form.getSnapshot !== 'function') return null
224
- const value = form.getSnapshot()
225
- return value && typeof value === 'object' ? value : null
226
- } catch {
227
- return null
228
- }
229
- }
230
- const [snapshot, setSnapshot] = React.useState(read)
231
- React.useEffect(() => {
232
- if (!form || typeof form.subscribe !== 'function') return undefined
233
- const sync = () => {
234
- try {
235
- setSnapshot(form.getSnapshot())
236
- } catch {}
237
- }
238
- sync()
239
- let off = null
240
- try {
241
- off = form.subscribe(sync)
242
- } catch {}
243
- return () => {
244
- try {
245
- if (typeof off === 'function') off()
246
- } catch {}
247
- }
248
- }, [form])
249
- return snapshot
250
- }
251
-
252
- function toPlugins(snapshot) {
253
- const value = (snapshot && snapshot.value) || {}
254
- return Array.isArray(value.deferredPlugins) ? value.deferredPlugins : []
255
- }
256
-
257
- function toDraft(snapshot) {
258
- const value = (snapshot && snapshot.value) || {}
259
- return {
260
- idleDelay: String(value.idleDelay ?? 1200),
261
- disposeTimeout: String(value.disposeTimeout ?? 10000),
262
- readyFallbackMs: String(value.readyFallbackMs ?? 5000),
263
- plugins: JSON.stringify(toPlugins(snapshot), null, 2),
264
- }
265
- }
266
-
267
- function badgeStyle(grade) {
268
- if (grade === 'heavy') return Object.assign({}, styles.badge, styles.badgeHeavy)
269
- if (grade === 'medium') return Object.assign({}, styles.badge, styles.badgeMedium)
270
- return Object.assign({}, styles.badge, styles.badgeLight)
271
- }
272
-
273
- // The slot system does not guarantee that a form component receives the
274
- // plugin ctx, so keep the injected instance reachable from apply().
275
- let pluginCtx = null
276
-
277
- // Reading a service off a Cordis context is gated by `inject` and throws on
278
- // the strict proxy when the service is not injected for this component, so
279
- // every access goes through try/catch: a throw here would unmount the whole
280
- // slot subtree (the form "flashes" and disappears).
281
- function configFormsOf(props) {
282
- try {
283
- const forms = props && props.ctx && props.ctx.configForms
284
- if (forms && typeof forms.get === 'function') return forms
285
- } catch {}
286
- try {
287
- const forms = pluginCtx && pluginCtx.configForms
288
- if (forms && typeof forms.get === 'function') return forms
289
- } catch {}
290
- return null
291
- }
292
-
293
- // The slot renders the full form directly: opening the settings card (or
294
- // the plugin page) lands on the form itself, without an extra collapsed
295
- // row. The form lives in its own component so it mounts hook-safely.
296
- function DeferredBootForm(props) {
297
- const t = typeof props?.t === 'function'
298
- ? props.t
299
- : (key, params) => {
300
- let text = ZH[key] ?? key
301
- if (params && params.seconds !== undefined) text = text.replace('{seconds}', params.seconds)
302
- return text
303
- }
304
- const forms = configFormsOf(props)
305
- if (!forms) return h('p', { style: styles.intro }, t('unavailable'))
306
- let form = null
307
- for (const namespace of ['deferred-boot', 'include:deferred-boot']) {
308
- try {
309
- const candidate = forms.get(namespace)
310
- if (candidate && typeof candidate.getSnapshot === 'function') {
311
- form = candidate
312
- break
313
- }
314
- } catch {}
315
- }
316
- if (!form || typeof form.getSnapshot !== 'function') return h('p', { style: styles.intro }, t('unavailable'))
317
- const snapshot = useSnapshot(form)
318
- const [draft, setDraft] = React.useState(() => toDraft(snapshot))
319
- const [status, setStatus] = React.useState('')
320
- const [catalog, setCatalog] = React.useState({ status: 'loading', rows: [] })
321
- const [officialOpen, setOfficialOpen] = React.useState(false)
322
-
323
- React.useEffect(() => {
324
- setDraft(toDraft(snapshot))
325
- }, [snapshot])
326
-
327
- // Read the installed plugin rows through the owner-side remote API. The
328
- // owner page is always allowed, so no remoteAccess gate is needed.
329
- React.useEffect(() => {
330
- const ctx = pluginCtx || (props && props.ctx)
331
- let alive = true
332
- let timer = null
333
- const fail = (message) => {
334
- if (!alive) return
335
- setCatalog({ status: 'failed', rows: [], error: message || '' })
336
- }
337
- if (!ctx) {
338
- fail('no context')
339
- return () => {
340
- alive = false
341
- }
342
- }
343
- // `remote` may be mounted slightly after the page loads, so retry a
344
- // few times before giving up. Cordis 4 contexts are strict proxies:
345
- // reading an unknown service throws, so guard every touch.
346
- const attempt = (remaining) => {
347
- let api = null
348
- try {
349
- api = (typeof ctx.get === 'function' ? ctx.get('remote.pluginManager') : null)
350
- || (ctx.remote && ctx.remote.pluginManager)
351
- } catch (error) {
352
- fail(error && error.message ? error.message : 'remote unavailable')
353
- return
354
- }
355
- if (!api || typeof api.listPlugins !== 'function') {
356
- fail('pluginManager/listPlugins unavailable')
357
- return
358
- }
359
- Promise.resolve()
360
- .then(() => api.listPlugins())
361
- .then((answer) => {
362
- if (!alive) return
363
- if (!answer || !answer.ok) {
364
- if (remaining > 0) {
365
- timer = setTimeout(() => attempt(remaining - 1), 1200)
366
- return
367
- }
368
- fail(answer && answer.error ? (answer.error.code || answer.error.message) : 'listPlugins refused')
369
- return
370
- }
371
- const rows = (Array.isArray(answer.value) ? answer.value : [])
372
- .filter((row) => row && typeof row.moduleName === 'string')
373
- .map((row) => ({
374
- id: row.entryId ?? row.moduleName,
375
- name: row.moduleName,
376
- enabled: row.enabled !== false,
377
- phase: row.fiberPhase ?? null,
378
- }))
379
- setCatalog({ status: 'ready', rows })
380
- })
381
- .catch((error) => {
382
- if (!alive) return
383
- if (remaining > 0) {
384
- timer = setTimeout(() => attempt(remaining - 1), 1200)
385
- return
386
- }
387
- fail(error && error.message ? error.message : 'listPlugins failed')
388
- })
389
- }
390
- attempt(2)
391
- return () => {
392
- alive = false
393
- if (timer) clearTimeout(timer)
394
- }
395
- }, [props && props.ctx])
396
-
397
- const set = (key) => (event) => setDraft((prev) => ({ ...prev, [key]: event.target.value }))
398
-
399
- const plugins = React.useMemo(() => {
400
- try {
401
- const parsed = JSON.parse(draft.plugins)
402
- return Array.isArray(parsed) ? parsed : []
403
- } catch {
404
- return []
405
- }
406
- }, [draft.plugins])
407
-
408
- const selected = React.useMemo(() => {
409
- const map = new Map()
410
- for (const item of plugins) {
411
- if (item && typeof item.name === 'string') map.set(item.name, item)
412
- }
413
- return map
414
- }, [plugins])
415
-
416
- const toggle = (row, checked) => {
417
- let current = []
418
- try {
419
- const parsed = JSON.parse(draft.plugins)
420
- current = Array.isArray(parsed) ? parsed : []
421
- } catch {}
422
- const list = current.slice()
423
- const index = list.findIndex((item) => item && item.name === row.name)
424
- if (checked && index < 0) {
425
- list.push({ id: row.id, name: row.name, strategy: 'idle' })
426
- } else if (!checked && index >= 0) {
427
- list.splice(index, 1)
428
- }
429
- setDraft((prev) => ({ ...prev, plugins: JSON.stringify(list, null, 2) }))
430
- }
431
-
432
- const estimateMs = React.useMemo(() => {
433
- let total = Number(draft.idleDelay) || 0
434
- for (const name of selected.keys()) total += COST[gradeOf(name)] || 0
435
- return total
436
- }, [selected, draft.idleDelay])
437
-
438
- const save = async () => {
439
- let plugins
440
- try {
441
- plugins = JSON.parse(draft.plugins)
442
- } catch {
443
- setStatus(t('badJson'))
444
- return
445
- }
446
- if (!Array.isArray(plugins)) {
447
- setStatus(t('notArray'))
448
- return
449
- }
450
- setStatus(t('saving'))
451
- try {
452
- const base = snapshot && snapshot.revision
453
- const result = await form.mutate(
454
- [
455
- { op: 'set', path: ['idleDelay'], value: Number(draft.idleDelay) || 0 },
456
- { op: 'set', path: ['disposeTimeout'], value: Number(draft.disposeTimeout) || 0 },
457
- { op: 'set', path: ['readyFallbackMs'], value: Number(draft.readyFallbackMs) || 0 },
458
- { op: 'set', path: ['deferredPlugins'], value: plugins },
459
- ],
460
- base,
461
- )
462
- if (result === false) setStatus(t('conflict'))
463
- else setStatus(t('saved'))
464
- } catch {
465
- setStatus(t('failed'))
466
- }
467
- }
468
-
469
- const rows = catalog.rows
470
- const groups = React.useMemo(() => {
471
- const official = []
472
- const third = []
473
- for (const row of rows) {
474
- if (row.name.indexOf('@deepseek-ai/') === 0) official.push(row)
475
- else third.push(row)
476
- }
477
- return { official, third }
478
- }, [rows])
479
-
480
- const renderRow = (row) => {
481
- const isSelf = row.name === 'dsh-deferred-boot'
482
- const isCore = row.name.indexOf('@deepseek-ai/') === 0
483
- const locked = isSelf || isCore
484
- const grade = gradeOf(row.name)
485
- const checked = selected.has(row.name)
486
- const stateText = row.phase === 'failed' ? t('catalog.unknown') : row.enabled ? t('catalog.running') : t('catalog.disabled')
487
- return h('li', { key: row.id, style: styles.item },
488
- h('input', {
489
- type: 'checkbox',
490
- checked,
491
- disabled: locked,
492
- onChange: (event) => { if (!locked) toggle(row, event.target.checked) },
493
- }),
494
- h('span', { style: styles.itemName, title: row.name }, row.name),
495
- h('span', { style: Object.assign({}, badgeStyle(grade), { width: 56, flex: '0 0 56px', textAlign: 'center' }) }, t('grade.' + grade)),
496
- h('span', { style: { width: 56, flex: '0 0 56px', textAlign: 'right', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, isSelf ? t('catalog.self') : isCore ? t('catalog.core') : stateText),
497
- h('span', { style: { width: 72, flex: '0 0 72px', textAlign: 'right', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)', fontVariantNumeric: 'tabular-nums' } }, '~' + COST[grade] + 'ms'),
498
- )
499
- }
500
-
501
- return h(
502
- 'div',
503
- { style: styles.root },
504
- h('p', { style: styles.intro }, t('intro')),
505
- h('section', { style: styles.card },
506
- h('h3', { style: styles.cardTitle }, t('panel.timing')),
507
- h(Row, {
508
- label: t('field.idleDelay'),
509
- hint: t('hint.idleDelay'),
510
- }, h('input', { style: styles.input, type: 'number', value: draft.idleDelay, onChange: set('idleDelay') })),
511
- h(Row, {
512
- label: t('field.disposeTimeout'),
513
- hint: t('hint.disposeTimeout'),
514
- }, h('input', { style: styles.input, type: 'number', value: draft.disposeTimeout, onChange: set('disposeTimeout') })),
515
- h(Row, {
516
- label: t('field.readyFallbackMs'),
517
- hint: t('hint.readyFallbackMs'),
518
- }, h('input', { style: styles.input, type: 'number', value: draft.readyFallbackMs, onChange: set('readyFallbackMs') })),
519
- ),
520
- h('section', { style: styles.card },
521
- h('h3', { style: styles.cardTitle }, t('catalog.title')),
522
- h('p', { style: { margin: '0 0 8px', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('catalog.hint')),
523
- catalog.status === 'loading'
524
- ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.loading'))
525
- : catalog.status === 'failed'
526
- ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.failed') + (catalog.error ? '(' + catalog.error + ')' : ''))
527
- : rows.length === 0
528
- ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.empty'))
529
- : h('div', null,
530
- h('p', { style: styles.groupTitle }, t('group.third') + '(' + groups.third.length + ')'),
531
- groups.third.length === 0 ? null : h('ul', { style: styles.list }, groups.third.map(renderRow)),
532
- h('button', { type: 'button', style: styles.linkButton, onClick: () => setOfficialOpen((value) => !value) },
533
- (officialOpen ? t('group.collapse') : t('group.expand')) + '(' + groups.official.length + ')'),
534
- officialOpen ? h('ul', { style: styles.list }, groups.official.map(renderRow)) : null,
535
- ),
536
- ),
537
-
538
- h('details', { style: { marginTop: 10 } },
539
- h('summary', { style: { cursor: 'pointer', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('advanced')),
540
- h('textarea', { style: styles.textarea, value: draft.plugins, onChange: set('plugins') }),
541
- ),
542
-
543
- h('div', { style: styles.footer },
544
- h('span', { style: styles.status }, status || t('note')),
545
- h('span', { style: styles.estimate }, t('estimate', { seconds: (estimateMs / 1000).toFixed(1) })),
546
- ),
547
- h('div', { style: styles.actions },
548
- h('button', { style: styles.button, type: 'button', onClick: save }, t('save')),
549
- typeof props?.onClose === 'function'
550
- ? h('button', { type: 'button', style: styles.linkButton, onClick: props.onClose }, t('section.close'))
551
- : null,
552
- ),
553
- )
554
- }
555
-
556
- // The settings item is rendered twice by the manager: once as the card
557
- // summary in the installed list and once as the page body after opening
558
- // it. `view` tells them apart, so the long form only mounts on the page.
559
- function DeferredBootItem(props) {
560
- const t = typeof props?.t === 'function'
561
- ? props.t
562
- : (key) => ZH[key] ?? key
563
- if (props && props.view === 'summary') {
564
- return h('span', { style: { fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('section.hint'))
565
- }
566
- return h(DeferredBootForm, props)
567
- }
568
-
569
- function apply(ctx) {
570
- pluginCtx = ctx
571
- try {
572
- if (ctx.locale && typeof ctx.locale.register === 'function') {
573
- ctx.effect(() => ctx.locale.register('deferredBoot', 'zh', ZH), 'dsh-deferred-boot: zh dictionary')
574
- ctx.effect(() => ctx.locale.register('deferredBoot', 'en', EN), 'dsh-deferred-boot: en dictionary')
575
- }
576
- } catch (error) {
577
- // A locale failure must never prevent the configuration form from mounting.
578
- console.warn('dsh-deferred-boot: locale registration failed', error)
579
- }
580
- ctx.effect(
581
- () =>
582
- ctx.slots.inject('plugins.item', () =>
583
- ctx.slots.register({ name: 'plugins.item', id: 'dsh-deferred-boot', order: 40, label: '延迟启动' }, DeferredBootItem),
584
- ),
585
- 'dsh-deferred-boot: settings item',
586
- )
587
- }
588
-
589
- exports.apply = apply
590
- exports.inject = ['slots', 'configForms', 'remote']
591
- return module.exports
592
- },
593
- })
1
+ // Client half of dsh-deferred-boot.
2
+ //
3
+ // It renders the plugin's form in the settings item slot of the plugin
4
+ // manager, lists the installed plugins with a startup cost grade, and
5
+ // reads/writes the `deferred-boot` settings namespace through ctx.configForms.
6
+ // The host half (src/index.js) declares the four fields as volatile so this
7
+ // form is the only place they are edited.
8
+ window.__ModuleLoader__.load({
9
+ id: 'dsh-deferred-boot',
10
+ factory: (require) => {
11
+ var module = { exports: {} }
12
+ var exports = module.exports
13
+ const React = require('react')
14
+ const h = React.createElement
15
+
16
+ const styles = {
17
+ root: { display: 'flex', flexDirection: 'column', gap: '6px', fontSize: 13, lineHeight: 1.5 },
18
+ intro: { margin: '0 0 10px', color: 'var(--dsh-text-2, #c8c8c8)' },
19
+ row: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '6px 0' },
20
+ rowMain: { display: 'flex', alignItems: 'center', gap: 12 },
21
+ label: { width: 156, flex: '0 0 156px', color: 'var(--dsh-text-2, #c8c8c8)' },
22
+ input: {
23
+ flex: '0 1 240px',
24
+ minWidth: 120,
25
+ padding: '4px 8px',
26
+ borderRadius: 6,
27
+ border: '1px solid var(--dsh-border, #3a3a3a)',
28
+ background: 'var(--dsh-surface, #1f1f1f)',
29
+ color: 'inherit',
30
+ font: 'inherit',
31
+ },
32
+ hint: { margin: '0 0 0 168px', fontSize: 12, lineHeight: 1.5, color: 'var(--dsh-text-3, #8b8b8b)' },
33
+ note: { margin: '0 0 0 168px', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
34
+ textarea: {
35
+ width: '100%',
36
+ minHeight: 96,
37
+ padding: '6px 8px',
38
+ borderRadius: 6,
39
+ border: '1px solid var(--dsh-border, #3a3a3a)',
40
+ background: 'var(--dsh-surface, #1f1f1f)',
41
+ color: 'inherit',
42
+ font: 'inherit',
43
+ resize: 'vertical',
44
+ },
45
+ actions: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 },
46
+ button: {
47
+ padding: '4px 14px',
48
+ borderRadius: 6,
49
+ border: '1px solid var(--dsh-accent, #4d6bfe)',
50
+ background: 'var(--dsh-accent, #4d6bfe)',
51
+ color: '#fff',
52
+ cursor: 'pointer',
53
+ font: 'inherit',
54
+ },
55
+ status: { fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
56
+ list: { margin: '4px 0 0', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 2 },
57
+ item: { display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0' },
58
+ itemName: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
59
+ itemMeta: { flex: '0 0 auto', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
60
+ badge: {
61
+ flex: '0 0 auto',
62
+ padding: '0 6px',
63
+ borderRadius: 4,
64
+ fontSize: 11,
65
+ lineHeight: '18px',
66
+ border: '1px solid var(--dsh-border, #3a3a3a)',
67
+ color: 'var(--dsh-text-2, #c8c8c8)',
68
+ },
69
+ badgeHeavy: { borderColor: '#a33', color: '#e88' },
70
+ badgeMedium: { borderColor: '#a73', color: '#ec8' },
71
+ badgeLight: { borderColor: '#3a7', color: '#8d8' },
72
+ footer: {
73
+ display: 'flex',
74
+ justifyContent: 'space-between',
75
+ alignItems: 'center',
76
+ marginTop: 10,
77
+ paddingTop: 8,
78
+ borderTop: '1px solid var(--dsh-border, #3a3a3a)',
79
+ fontSize: 12,
80
+ color: 'var(--dsh-text-3, #8b8b8b)',
81
+ },
82
+ estimate: { color: 'var(--dsh-text-2, #c8c8c8)' },
83
+ groupTitle: { margin: '12px 0 2px', fontSize: 12, color: 'var(--dsh-text-2, #c8c8c8)' },
84
+ linkButton: {
85
+ background: 'transparent',
86
+ border: 0,
87
+ padding: 0,
88
+ color: 'var(--dsh-accent, #4d6bfe)',
89
+ font: 'inherit',
90
+ fontSize: 12,
91
+ cursor: 'pointer',
92
+ },
93
+ card: {
94
+ border: '1px solid var(--dsh-border, #3a3a3a)',
95
+ borderRadius: 10,
96
+ padding: '12px 14px',
97
+ marginBottom: 10,
98
+ background: 'var(--dsh-surface-2, #1c1c1c)',
99
+ },
100
+ cardTitle: { margin: '0 0 10px', fontSize: 13, fontWeight: 600, color: 'var(--dsh-text-1, #e8e8e8)' },
101
+ }
102
+
103
+ const ZH = {
104
+ intro: '把指定的插件推迟到主界面就绪之后再挂载,一次空闲切片只挂一个,避免插件成批初始化把首屏拖住。',
105
+ 'field.idleDelay': '就绪后延迟 (ms)',
106
+ 'field.disposeTimeout': 'dispose 超时 (ms)',
107
+ 'field.readyFallbackMs': '就绪兜底超时 (ms)',
108
+ 'field.deferredPlugins': '延迟挂载的插件',
109
+ 'plugins.label': '延迟挂载的插件(JSON 数组,每项 { id, name, strategy, config })',
110
+ 'plugins.hint': 'strategy: idle(就绪后自动挂载)/ manual(调用 deferredBoot.ensure(id) 才挂载)/ inline-lazy / disabled。',
111
+ 'hint.idleDelay': '主界面就绪后等待多久才开始第一次空闲调度(毫秒)。调大更晚开始、首屏更稳;调小更早挂载目标插件。',
112
+ 'hint.disposeTimeout': '卸载或热重载目标插件时最多等待多久(毫秒)。超时视为已卸载并继续,防止卸载卡住调度。',
113
+ 'hint.readyFallbackMs': '等不到“主界面已就绪”信号时的兜底超时(毫秒)。超时后按已就绪处理,继续延迟挂载。',
114
+ 'hint.plugins': '每项一个目标插件:id 为 loader 条目 id,name 为模块名,strategy 决定挂载方式,config 传给目标插件。',
115
+ 'catalog.title': '可放缓启动的插件',
116
+ 'catalog.hint': '勾选后这些插件会在主界面就绪后逐个挂载;评级按启动开销经验估算,仅供参考。',
117
+ 'catalog.loading': '正在读取已安装插件…',
118
+ 'catalog.failed': '读取插件列表失败(需要新版 DSH 客户端)。',
119
+ 'catalog.empty': '没有可勾选的插件。',
120
+ 'catalog.core': '核心',
121
+ 'catalog.self': '本插件',
122
+ 'catalog.running': '运行中',
123
+ 'catalog.disabled': '已禁用',
124
+ 'catalog.unknown': '未运行',
125
+ 'grade.light': '较轻',
126
+ 'grade.medium': '中等',
127
+ 'grade.heavy': '严重',
128
+ 'advanced': '高级:直接编辑 deferredPlugins(JSON)',
129
+ 'estimate': '预计本次启动额外等待 ≈ {seconds} 秒(估算)',
130
+ save: '保存',
131
+ saving: '保存中…',
132
+ saved: '已保存,下一次操作起生效。',
133
+ conflict: '配置已被其他窗口修改,已重新载入,请再保存一次。',
134
+ failed: '保存失败,请查看 Host 日志。',
135
+ badJson: '延迟插件不是合法 JSON,请修正后再保存。',
136
+ notArray: '延迟插件必须是 JSON 数组。',
137
+ unavailable: '配置服务暂不可用(需要新版 DSH 客户端)。',
138
+ note: '配置写入当前 profile 的 Cordis patch,无需手动编辑 cordis.patch.yml。',
139
+ 'section.hint': '主界面就绪后再逐个挂载插件,避免成批初始化拖住首屏。',
140
+ 'section.open': '配置',
141
+ 'section.close': '收起',
142
+ 'panel.timing': '启动时机',
143
+ 'group.third': '第三方插件',
144
+ 'group.official': '官方插件',
145
+ 'group.expand': '展开官方插件',
146
+ 'group.collapse': '收起官方插件',
147
+ }
148
+
149
+ const EN = {
150
+ intro: 'Mount the listed plugins after the main UI is ready, one idling slice at a time, so a batch of heavy plugins cannot hold up the first screen.',
151
+ 'field.idleDelay': 'Delay after ready (ms)',
152
+ 'field.disposeTimeout': 'Dispose timeout (ms)',
153
+ 'field.readyFallbackMs': 'Ready fallback (ms)',
154
+ 'field.deferredPlugins': 'Deferred plugins',
155
+ 'plugins.label': 'Deferred plugins (JSON array, each { id, name, strategy, config })',
156
+ 'plugins.hint': 'strategy: idle (mount after ready) / manual (wait for deferredBoot.ensure(id)) / inline-lazy / disabled.',
157
+ 'hint.idleDelay': 'How long to wait after the main UI is ready before the first idle slice (ms). Larger waits longer for a steadier first paint; smaller mounts targets sooner.',
158
+ 'hint.disposeTimeout': 'Maximum time to wait while unloading or hot-reloading a target (ms). On timeout the target is treated as unmounted and scheduling continues.',
159
+ 'hint.readyFallbackMs': 'Fallback timeout for “main UI ready” (ms). When no ready signal arrives, mounting starts after this delay.',
160
+ 'hint.plugins': 'One entry per deferred plugin: id is the loader entry id, name is the module name, strategy controls how it mounts, config is passed to the target.',
161
+ 'catalog.title': 'Plugins that can start later',
162
+ 'catalog.hint': 'Checked plugins mount one by one after the main UI is ready. Grades are rough startup-cost estimates for reference only.',
163
+ 'catalog.loading': 'Reading installed plugins…',
164
+ 'catalog.failed': 'Could not read the installed plugin list (a newer DSH client is required).',
165
+ 'catalog.empty': 'No plugins to choose from.',
166
+ 'catalog.core': 'core',
167
+ 'catalog.self': 'this plugin',
168
+ 'catalog.running': 'running',
169
+ 'catalog.disabled': 'disabled',
170
+ 'catalog.unknown': 'not running',
171
+ 'grade.light': 'light',
172
+ 'grade.medium': 'medium',
173
+ 'grade.heavy': 'heavy',
174
+ 'advanced': 'Advanced: edit deferredPlugins (JSON) directly',
175
+ 'estimate': 'Estimated extra wait this start ≈ {seconds}s (rough)',
176
+ save: 'Save',
177
+ saving: 'Saving…',
178
+ saved: 'Saved; takes effect from the next operation.',
179
+ conflict: 'The settings changed in another window. Reloaded — please save again.',
180
+ failed: 'Save failed; check the host log.',
181
+ badJson: 'Deferred plugins is not valid JSON.',
182
+ notArray: 'Deferred plugins must be a JSON array.',
183
+ unavailable: 'The config form service is unavailable (a newer DSH client is required).',
184
+ note: 'The change is written to the current profile Cordis patch; cordis.patch.yml stays untouched.',
185
+ 'section.hint': 'Mount plugins one by one after the main UI is ready so a batch of initializers cannot hold up the first screen.',
186
+ 'section.open': 'Configure',
187
+ 'section.close': 'Collapse',
188
+ 'panel.timing': 'Startup timing',
189
+ 'group.third': 'Third-party plugins',
190
+ 'group.official': 'Official plugins',
191
+ 'group.expand': 'Show official plugins',
192
+ 'group.collapse': 'Hide official plugins',
193
+ }
194
+
195
+ // Startup-cost grades are rough estimates used only for ordering and the
196
+ // bottom-right figure. They do not claim to be measurements.
197
+ const COST = { light: 200, medium: 600, heavy: 1800 }
198
+ const HEAVY = /billion-context|dshmarket|univer|office-toolkit|solidworks|mech-workbench|tabbit/
199
+ const MEDIUM = /dsh-pilot|whale|token-usage|plugin-mgr|dsh-unlag/
200
+
201
+ function gradeOf(name) {
202
+ const text = String(name || '')
203
+ if (HEAVY.test(text)) return 'heavy'
204
+ if (MEDIUM.test(text)) return 'medium'
205
+ return 'light'
206
+ }
207
+
208
+ function Row(props) {
209
+ return h(
210
+ 'div',
211
+ { style: styles.row },
212
+ h('div', { style: styles.rowMain }, h('span', { style: styles.label }, props.label), props.children),
213
+ props.hint ? h('p', { style: styles.hint }, props.hint) : null,
214
+ )
215
+ }
216
+
217
+ function useSnapshot(form) {
218
+ // A settings controller can refuse `getSnapshot()` before its describe
219
+ // round-trip lands. That throw used to unmount the whole slot subtree
220
+ // (the form "flashed" and vanished), so every read is guarded.
221
+ const read = () => {
222
+ try {
223
+ if (!form || typeof form.getSnapshot !== 'function') return null
224
+ const value = form.getSnapshot()
225
+ return value && typeof value === 'object' ? value : null
226
+ } catch {
227
+ return null
228
+ }
229
+ }
230
+ const [snapshot, setSnapshot] = React.useState(read)
231
+ React.useEffect(() => {
232
+ if (!form || typeof form.subscribe !== 'function') return undefined
233
+ const sync = () => {
234
+ try {
235
+ setSnapshot(form.getSnapshot())
236
+ } catch {}
237
+ }
238
+ sync()
239
+ let off = null
240
+ try {
241
+ off = form.subscribe(sync)
242
+ } catch {}
243
+ return () => {
244
+ try {
245
+ if (typeof off === 'function') off()
246
+ } catch {}
247
+ }
248
+ }, [form])
249
+ return snapshot
250
+ }
251
+
252
+ function toPlugins(snapshot) {
253
+ const value = (snapshot && snapshot.value) || {}
254
+ return Array.isArray(value.deferredPlugins) ? value.deferredPlugins : []
255
+ }
256
+
257
+ function toDraft(snapshot) {
258
+ const value = (snapshot && snapshot.value) || {}
259
+ return {
260
+ idleDelay: String(value.idleDelay ?? 1200),
261
+ disposeTimeout: String(value.disposeTimeout ?? 10000),
262
+ readyFallbackMs: String(value.readyFallbackMs ?? 5000),
263
+ plugins: JSON.stringify(toPlugins(snapshot), null, 2),
264
+ }
265
+ }
266
+
267
+ function badgeStyle(grade) {
268
+ if (grade === 'heavy') return Object.assign({}, styles.badge, styles.badgeHeavy)
269
+ if (grade === 'medium') return Object.assign({}, styles.badge, styles.badgeMedium)
270
+ return Object.assign({}, styles.badge, styles.badgeLight)
271
+ }
272
+
273
+ // `listPlugins` reports each entry under its full loader id, e.g.
274
+ // `include:bili-native`. The scheduler hands that id straight to
275
+ // `loader.create`, which builds a *root* entry — and a root entry called
276
+ // `include:bili-native` collides with the very entry the bundle patch
277
+ // already inserted inside the `include` group. DSH treats a duplicate
278
+ // entry id as a hard error, so only the short name may be stored.
279
+ function shortEntryId(raw) {
280
+ const text = String(raw == null ? '' : raw)
281
+ const cut = text.lastIndexOf(':')
282
+ return cut < 0 ? text : text.slice(cut + 1)
283
+ }
284
+
285
+ // Normalise whatever is in the config: strip the group prefix, drop loader
286
+ // namespace nodes (`cordis:include` is a group, not a plugin, and creating
287
+ // it always fails), and de-duplicate by module name.
288
+ function normalizeDeferred(list) {
289
+ const out = []
290
+ const seen = new Set()
291
+ for (const item of Array.isArray(list) ? list : []) {
292
+ if (!item || typeof item.name !== 'string') continue
293
+ if (item.name.indexOf('cordis:') === 0) continue
294
+ if (seen.has(item.name)) continue
295
+ seen.add(item.name)
296
+ const next = Object.assign({}, item)
297
+ next.id = shortEntryId(next.id || item.name)
298
+ out.push(next)
299
+ }
300
+ return out
301
+ }
302
+
303
+ // The slot system does not guarantee that a form component receives the
304
+ // plugin ctx, so keep the injected instance reachable from apply().
305
+ let pluginCtx = null
306
+
307
+ // Reading a service off a Cordis context is gated by `inject` and throws on
308
+ // the strict proxy when the service is not injected for this component, so
309
+ // every access goes through try/catch: a throw here would unmount the whole
310
+ // slot subtree (the form "flashes" and disappears).
311
+ function configFormsOf(props) {
312
+ try {
313
+ const forms = props && props.ctx && props.ctx.configForms
314
+ if (forms && typeof forms.get === 'function') return forms
315
+ } catch {}
316
+ try {
317
+ const forms = pluginCtx && pluginCtx.configForms
318
+ if (forms && typeof forms.get === 'function') return forms
319
+ } catch {}
320
+ return null
321
+ }
322
+
323
+ // The slot renders the full form directly: opening the settings card (or
324
+ // the plugin page) lands on the form itself, without an extra collapsed
325
+ // row. The form lives in its own component so it mounts hook-safely.
326
+ function DeferredBootForm(props) {
327
+ const t = typeof props?.t === 'function'
328
+ ? props.t
329
+ : (key, params) => {
330
+ let text = ZH[key] ?? key
331
+ if (params && params.seconds !== undefined) text = text.replace('{seconds}', params.seconds)
332
+ return text
333
+ }
334
+ const forms = configFormsOf(props)
335
+ if (!forms) return h('p', { style: styles.intro }, t('unavailable'))
336
+ let form = null
337
+ for (const namespace of ['deferred-boot', 'include:deferred-boot']) {
338
+ try {
339
+ const candidate = forms.get(namespace)
340
+ if (candidate && typeof candidate.getSnapshot === 'function') {
341
+ form = candidate
342
+ break
343
+ }
344
+ } catch {}
345
+ }
346
+ if (!form || typeof form.getSnapshot !== 'function') return h('p', { style: styles.intro }, t('unavailable'))
347
+ const snapshot = useSnapshot(form)
348
+ const [draft, setDraft] = React.useState(() => toDraft(snapshot))
349
+ const [status, setStatus] = React.useState('')
350
+ const [catalog, setCatalog] = React.useState({ status: 'loading', rows: [] })
351
+ const [officialOpen, setOfficialOpen] = React.useState(false)
352
+
353
+ React.useEffect(() => {
354
+ setDraft(toDraft(snapshot))
355
+ }, [snapshot])
356
+
357
+ // Read the installed plugin rows through the owner-side remote API. The
358
+ // owner page is always allowed, so no remoteAccess gate is needed.
359
+ React.useEffect(() => {
360
+ const ctx = pluginCtx || (props && props.ctx)
361
+ let alive = true
362
+ let timer = null
363
+ const fail = (message) => {
364
+ if (!alive) return
365
+ setCatalog({ status: 'failed', rows: [], error: message || '' })
366
+ }
367
+ if (!ctx) {
368
+ fail('no context')
369
+ return () => {
370
+ alive = false
371
+ }
372
+ }
373
+ // `remote` may be mounted slightly after the page loads, so retry a
374
+ // few times before giving up. Cordis 4 contexts are strict proxies:
375
+ // reading an unknown service throws, so guard every touch.
376
+ const attempt = (remaining) => {
377
+ let api = null
378
+ try {
379
+ api = (typeof ctx.get === 'function' ? ctx.get('remote.pluginManager') : null)
380
+ || (ctx.remote && ctx.remote.pluginManager)
381
+ } catch (error) {
382
+ fail(error && error.message ? error.message : 'remote unavailable')
383
+ return
384
+ }
385
+ if (!api || typeof api.listPlugins !== 'function') {
386
+ fail('pluginManager/listPlugins unavailable')
387
+ return
388
+ }
389
+ Promise.resolve()
390
+ .then(() => api.listPlugins())
391
+ .then((answer) => {
392
+ if (!alive) return
393
+ if (!answer || !answer.ok) {
394
+ if (remaining > 0) {
395
+ timer = setTimeout(() => attempt(remaining - 1), 1200)
396
+ return
397
+ }
398
+ fail(answer && answer.error ? (answer.error.code || answer.error.message) : 'listPlugins refused')
399
+ return
400
+ }
401
+ const rows = (Array.isArray(answer.value) ? answer.value : [])
402
+ .filter((row) => row && typeof row.moduleName === 'string')
403
+ // `cordis:*` are loader namespace/group nodes, not plugins.
404
+ .filter((row) => row.moduleName.indexOf('cordis:') !== 0)
405
+ .map((row) => ({
406
+ id: shortEntryId(row.entryId ?? row.moduleName),
407
+ name: row.moduleName,
408
+ enabled: row.enabled !== false,
409
+ phase: row.fiberPhase ?? null,
410
+ }))
411
+ setCatalog({ status: 'ready', rows })
412
+ })
413
+ .catch((error) => {
414
+ if (!alive) return
415
+ if (remaining > 0) {
416
+ timer = setTimeout(() => attempt(remaining - 1), 1200)
417
+ return
418
+ }
419
+ fail(error && error.message ? error.message : 'listPlugins failed')
420
+ })
421
+ }
422
+ attempt(2)
423
+ return () => {
424
+ alive = false
425
+ if (timer) clearTimeout(timer)
426
+ }
427
+ }, [props && props.ctx])
428
+
429
+ const set = (key) => (event) => setDraft((prev) => ({ ...prev, [key]: event.target.value }))
430
+
431
+ const plugins = React.useMemo(() => {
432
+ try {
433
+ const parsed = JSON.parse(draft.plugins)
434
+ return Array.isArray(parsed) ? parsed : []
435
+ } catch {
436
+ return []
437
+ }
438
+ }, [draft.plugins])
439
+
440
+ const selected = React.useMemo(() => {
441
+ const map = new Map()
442
+ for (const item of plugins) {
443
+ if (item && typeof item.name === 'string') map.set(item.name, item)
444
+ }
445
+ return map
446
+ }, [plugins])
447
+
448
+ const toggle = (row, checked) => {
449
+ let current = []
450
+ try {
451
+ const parsed = JSON.parse(draft.plugins)
452
+ current = Array.isArray(parsed) ? parsed : []
453
+ } catch {}
454
+ const list = current.slice()
455
+ const index = list.findIndex((item) => item && item.name === row.name)
456
+ if (checked && index < 0) {
457
+ list.push({ id: shortEntryId(row.id), name: row.name, strategy: 'idle' })
458
+ } else if (!checked && index >= 0) {
459
+ list.splice(index, 1)
460
+ }
461
+ setDraft((prev) => ({ ...prev, plugins: JSON.stringify(list, null, 2) }))
462
+ }
463
+
464
+ const estimateMs = React.useMemo(() => {
465
+ let total = Number(draft.idleDelay) || 0
466
+ for (const name of selected.keys()) total += COST[gradeOf(name)] || 0
467
+ return total
468
+ }, [selected, draft.idleDelay])
469
+
470
+ const save = async () => {
471
+ let plugins
472
+ try {
473
+ plugins = JSON.parse(draft.plugins)
474
+ } catch {
475
+ setStatus(t('badJson'))
476
+ return
477
+ }
478
+ if (!Array.isArray(plugins)) {
479
+ setStatus(t('notArray'))
480
+ return
481
+ }
482
+ plugins = normalizeDeferred(plugins)
483
+ setDraft((prev) => ({ ...prev, plugins: JSON.stringify(plugins, null, 2) }))
484
+ setStatus(t('saving'))
485
+ try {
486
+ const base = snapshot && snapshot.revision
487
+ const result = await form.mutate(
488
+ [
489
+ { op: 'set', path: ['idleDelay'], value: Number(draft.idleDelay) || 0 },
490
+ { op: 'set', path: ['disposeTimeout'], value: Number(draft.disposeTimeout) || 0 },
491
+ { op: 'set', path: ['readyFallbackMs'], value: Number(draft.readyFallbackMs) || 0 },
492
+ { op: 'set', path: ['deferredPlugins'], value: plugins },
493
+ ],
494
+ base,
495
+ )
496
+ if (result === false) setStatus(t('conflict'))
497
+ else setStatus(t('saved'))
498
+ } catch {
499
+ setStatus(t('failed'))
500
+ }
501
+ }
502
+
503
+ const rows = catalog.rows
504
+ const groups = React.useMemo(() => {
505
+ const official = []
506
+ const third = []
507
+ for (const row of rows) {
508
+ if (row.name.indexOf('@deepseek-ai/') === 0) official.push(row)
509
+ else third.push(row)
510
+ }
511
+ return { official, third }
512
+ }, [rows])
513
+
514
+ const renderRow = (row) => {
515
+ const isSelf = row.name === 'dsh-deferred-boot'
516
+ const isCore = row.name.indexOf('@deepseek-ai/') === 0
517
+ const locked = isSelf || isCore
518
+ const grade = gradeOf(row.name)
519
+ const checked = selected.has(row.name)
520
+ const stateText = row.phase === 'failed' ? t('catalog.unknown') : row.enabled ? t('catalog.running') : t('catalog.disabled')
521
+ return h('li', { key: row.id, style: styles.item },
522
+ h('input', {
523
+ type: 'checkbox',
524
+ checked,
525
+ disabled: locked,
526
+ onChange: (event) => { if (!locked) toggle(row, event.target.checked) },
527
+ }),
528
+ h('span', { style: styles.itemName, title: row.name }, row.name),
529
+ h('span', { style: Object.assign({}, badgeStyle(grade), { width: 56, flex: '0 0 56px', textAlign: 'center' }) }, t('grade.' + grade)),
530
+ h('span', { style: { width: 56, flex: '0 0 56px', textAlign: 'right', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, isSelf ? t('catalog.self') : isCore ? t('catalog.core') : stateText),
531
+ h('span', { style: { width: 72, flex: '0 0 72px', textAlign: 'right', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)', fontVariantNumeric: 'tabular-nums' } }, '~' + COST[grade] + 'ms'),
532
+ )
533
+ }
534
+
535
+ return h(
536
+ 'div',
537
+ { style: styles.root },
538
+ h('p', { style: styles.intro }, t('intro')),
539
+ h('section', { style: styles.card },
540
+ h('h3', { style: styles.cardTitle }, t('panel.timing')),
541
+ h(Row, {
542
+ label: t('field.idleDelay'),
543
+ hint: t('hint.idleDelay'),
544
+ }, h('input', { style: styles.input, type: 'number', value: draft.idleDelay, onChange: set('idleDelay') })),
545
+ h(Row, {
546
+ label: t('field.disposeTimeout'),
547
+ hint: t('hint.disposeTimeout'),
548
+ }, h('input', { style: styles.input, type: 'number', value: draft.disposeTimeout, onChange: set('disposeTimeout') })),
549
+ h(Row, {
550
+ label: t('field.readyFallbackMs'),
551
+ hint: t('hint.readyFallbackMs'),
552
+ }, h('input', { style: styles.input, type: 'number', value: draft.readyFallbackMs, onChange: set('readyFallbackMs') })),
553
+ ),
554
+ h('section', { style: styles.card },
555
+ h('h3', { style: styles.cardTitle }, t('catalog.title')),
556
+ h('p', { style: { margin: '0 0 8px', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('catalog.hint')),
557
+ catalog.status === 'loading'
558
+ ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.loading'))
559
+ : catalog.status === 'failed'
560
+ ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.failed') + (catalog.error ? '(' + catalog.error + ')' : ''))
561
+ : rows.length === 0
562
+ ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.empty'))
563
+ : h('div', null,
564
+ h('p', { style: styles.groupTitle }, t('group.third') + '(' + groups.third.length + ')'),
565
+ groups.third.length === 0 ? null : h('ul', { style: styles.list }, groups.third.map(renderRow)),
566
+ h('button', { type: 'button', style: styles.linkButton, onClick: () => setOfficialOpen((value) => !value) },
567
+ (officialOpen ? t('group.collapse') : t('group.expand')) + '(' + groups.official.length + ')'),
568
+ officialOpen ? h('ul', { style: styles.list }, groups.official.map(renderRow)) : null,
569
+ ),
570
+ ),
571
+
572
+ h('details', { style: { marginTop: 10 } },
573
+ h('summary', { style: { cursor: 'pointer', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('advanced')),
574
+ h('textarea', { style: styles.textarea, value: draft.plugins, onChange: set('plugins') }),
575
+ ),
576
+
577
+ h('div', { style: styles.footer },
578
+ h('span', { style: styles.status }, status || t('note')),
579
+ h('span', { style: styles.estimate }, t('estimate', { seconds: (estimateMs / 1000).toFixed(1) })),
580
+ ),
581
+ h('div', { style: styles.actions },
582
+ h('button', { style: styles.button, type: 'button', onClick: save }, t('save')),
583
+ typeof props?.onClose === 'function'
584
+ ? h('button', { type: 'button', style: styles.linkButton, onClick: props.onClose }, t('section.close'))
585
+ : null,
586
+ ),
587
+ )
588
+ }
589
+
590
+ // The settings item is rendered twice by the manager: once as the card
591
+ // summary in the installed list and once as the page body after opening
592
+ // it. `view` tells them apart, so the long form only mounts on the page.
593
+ function DeferredBootItem(props) {
594
+ const t = typeof props?.t === 'function'
595
+ ? props.t
596
+ : (key) => ZH[key] ?? key
597
+ if (props && props.view === 'summary') {
598
+ return h('span', { style: { fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('section.hint'))
599
+ }
600
+ return h(DeferredBootForm, props)
601
+ }
602
+
603
+ function apply(ctx) {
604
+ pluginCtx = ctx
605
+ try {
606
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
607
+ ctx.effect(() => ctx.locale.register('deferredBoot', 'zh', ZH), 'dsh-deferred-boot: zh dictionary')
608
+ ctx.effect(() => ctx.locale.register('deferredBoot', 'en', EN), 'dsh-deferred-boot: en dictionary')
609
+ }
610
+ } catch (error) {
611
+ // A locale failure must never prevent the configuration form from mounting.
612
+ console.warn('dsh-deferred-boot: locale registration failed', error)
613
+ }
614
+ ctx.effect(
615
+ () =>
616
+ ctx.slots.inject('plugins.item', () =>
617
+ ctx.slots.register({ name: 'plugins.item', id: 'dsh-deferred-boot', order: 40, label: '延迟启动' }, DeferredBootItem),
618
+ ),
619
+ 'dsh-deferred-boot: settings item',
620
+ )
621
+ }
622
+
623
+ exports.apply = apply
624
+ exports.inject = ['slots', 'configForms', 'remote']
625
+ return module.exports
626
+ },
627
+ })