dsh-deferred-boot 0.3.0 → 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/CHANGELOG.md +103 -56
- package/README.md +1 -1
- package/docs/design.md +12 -5
- package/lib/client.js +627 -228
- package/market/index.json +2 -2
- package/package.json +71 -71
- package/src/index.js +65 -3
- package/src/scheduler.js +89 -25
package/lib/client.js
CHANGED
|
@@ -1,228 +1,627 @@
|
|
|
1
|
-
// Client half of dsh-deferred-boot.
|
|
2
|
-
//
|
|
3
|
-
// It renders the plugin's form
|
|
4
|
-
//
|
|
5
|
-
// namespace through ctx.configForms.
|
|
6
|
-
// four fields as volatile so this
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
var
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
'
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
'
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
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
|
+
})
|