dsh-x-core 0.1.26-rc.2 → 0.1.26-rc.4
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/package.json +1 -1
- package/perf/plugin-profile.mjs +150 -10
- package/public/index.html +257 -39
- package/public/launcher.css +23 -0
- package/server.js +346 -13
package/package.json
CHANGED
package/perf/plugin-profile.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* loader 钩子锚点匹配不到时(dsh 升级改了实现)没有 entry 时间线,但采样仍然有效。
|
|
11
11
|
*/
|
|
12
12
|
import { Session } from 'node:inspector/promises'
|
|
13
|
+
import { registerHooks } from 'node:module'
|
|
13
14
|
import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
14
15
|
import { join } from 'node:path'
|
|
15
16
|
|
|
@@ -38,6 +39,48 @@ let startedAt = 0
|
|
|
38
39
|
let timer = null
|
|
39
40
|
let cap = null
|
|
40
41
|
let stopped = false
|
|
42
|
+
/** 同步 loader 钩子:启动期解析/读取每个模块花的时间,按「谁触发」和「读的是谁」记。 */
|
|
43
|
+
let moduleHooks = null
|
|
44
|
+
const resolveCalls = []
|
|
45
|
+
const loadCalls = []
|
|
46
|
+
/** 启动期模块数不设死上限,但真有异常多的情况也不让数组无限长。 */
|
|
47
|
+
const MODULE_CALL_LIMIT = 100_000
|
|
48
|
+
|
|
49
|
+
function installModuleTiming() {
|
|
50
|
+
if (moduleHooks) return
|
|
51
|
+
try {
|
|
52
|
+
moduleHooks = registerHooks({
|
|
53
|
+
resolve(specifier, context, nextResolve) {
|
|
54
|
+
const started = performance.now()
|
|
55
|
+
const result = nextResolve(specifier, context)
|
|
56
|
+
if (resolveCalls.length < MODULE_CALL_LIMIT) {
|
|
57
|
+
resolveCalls.push({
|
|
58
|
+
parent: context.parentURL || '',
|
|
59
|
+
specifier: String(specifier),
|
|
60
|
+
url: String(result?.url || ''),
|
|
61
|
+
us: (performance.now() - started) * 1000,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
65
|
+
},
|
|
66
|
+
load(url, context, nextLoad) {
|
|
67
|
+
const started = performance.now()
|
|
68
|
+
const result = nextLoad(url, context)
|
|
69
|
+
if (loadCalls.length < MODULE_CALL_LIMIT) {
|
|
70
|
+
loadCalls.push({ url: String(url || ''), us: (performance.now() - started) * 1000 })
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
} catch {
|
|
76
|
+
moduleHooks = null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stopModuleTiming() {
|
|
81
|
+
try { moduleHooks?.deregister() } catch { /* ignore */ }
|
|
82
|
+
moduleHooks = null
|
|
83
|
+
}
|
|
41
84
|
|
|
42
85
|
function packageOf(url, names) {
|
|
43
86
|
const normalized = String(url || '').replace(/\\/g, '/')
|
|
@@ -53,11 +96,20 @@ function packageOf(url, names) {
|
|
|
53
96
|
return '(其他)'
|
|
54
97
|
}
|
|
55
98
|
|
|
56
|
-
function summarize(cpuProfile, heapProfile) {
|
|
57
|
-
const names = new Set(entries.map((row) => row.name).filter((name) => name && !name.startsWith('cordis:')))
|
|
99
|
+
export function summarize(cpuProfile, heapProfile, timing = { resolveCalls, loadCalls }, pluginNames = null) {
|
|
100
|
+
const names = new Set(pluginNames ?? entries.map((row) => row.name).filter((name) => name && !name.startsWith('cordis:')))
|
|
58
101
|
const nodes = new Map((cpuProfile.nodes || []).map((node) => [node.id, node]))
|
|
102
|
+
// 没有 URL 的帧(open/readFileUtf8/spawn/GC…)往上找最近的 JS 调用方,
|
|
103
|
+
// 才能回答「这个插件在启动时到底占了多久」;找不到调用方的才记成未归属。
|
|
104
|
+
const parentOf = new Map()
|
|
105
|
+
for (const node of cpuProfile.nodes || []) {
|
|
106
|
+
// V8 的 children 里存的是节点 id(不是节点对象),按 id 建反向表。
|
|
107
|
+
for (const child of node.children || []) parentOf.set(child, node)
|
|
108
|
+
}
|
|
59
109
|
const cpuUs = new Map()
|
|
110
|
+
const nativeByPackage = new Map()
|
|
60
111
|
const nativeUs = new Map()
|
|
112
|
+
let nativeUnattributedUs = 0
|
|
61
113
|
let totalUs = 0
|
|
62
114
|
let idleUs = 0
|
|
63
115
|
const samples = cpuProfile.samples || []
|
|
@@ -70,9 +122,18 @@ function summarize(cpuProfile, heapProfile) {
|
|
|
70
122
|
if (fn === '(idle)' || fn === '(root)') { idleUs += delta; continue }
|
|
71
123
|
const url = node?.callFrame?.url || ''
|
|
72
124
|
if (!url) {
|
|
73
|
-
// 没有 URL 的帧:GC、V8
|
|
125
|
+
// 没有 URL 的帧:GC、V8 编译/内置函数。按调用方归属,同时保留系统调用分类。
|
|
74
126
|
const key = fn || '(匿名原生)'
|
|
75
127
|
nativeUs.set(key, (nativeUs.get(key) || 0) + delta)
|
|
128
|
+
let owner = parentOf.get(node?.id)
|
|
129
|
+
let ownerPkg = ''
|
|
130
|
+
while (owner) {
|
|
131
|
+
const ownerUrl = owner.callFrame?.url
|
|
132
|
+
if (ownerUrl) { ownerPkg = packageOf(ownerUrl, names); break }
|
|
133
|
+
owner = parentOf.get(owner.id)
|
|
134
|
+
}
|
|
135
|
+
if (ownerPkg) nativeByPackage.set(ownerPkg, (nativeByPackage.get(ownerPkg) || 0) + delta)
|
|
136
|
+
else nativeUnattributedUs += delta
|
|
76
137
|
continue
|
|
77
138
|
}
|
|
78
139
|
const pkg = packageOf(url, names)
|
|
@@ -93,12 +154,85 @@ function summarize(cpuProfile, heapProfile) {
|
|
|
93
154
|
}
|
|
94
155
|
walk(heapProfile?.head)
|
|
95
156
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
157
|
+
// 模块加载:resolve 的耗时记在「谁触发」上,load 记在「读的是谁」上;
|
|
158
|
+
// resolve 同时给出 import 边,最后沿依赖图把整棵子树的加载时间归到插件。
|
|
159
|
+
const moduleUs = new Map()
|
|
160
|
+
const moduleEdges = new Map()
|
|
161
|
+
const moduleRow = (name) => {
|
|
162
|
+
let row = moduleUs.get(name)
|
|
163
|
+
if (!row) { row = { resolveUs: 0, loadUs: 0 }; moduleUs.set(name, row) }
|
|
164
|
+
return row
|
|
165
|
+
}
|
|
166
|
+
let moduleTotalUs = 0
|
|
167
|
+
for (const call of timing.resolveCalls || []) {
|
|
168
|
+
moduleTotalUs += call.us
|
|
169
|
+
if (!call.parent) continue
|
|
170
|
+
const parent = packageOf(call.parent, names)
|
|
171
|
+
if (parent === '(Node 内部)' || parent === '(原生/GC)') continue
|
|
172
|
+
moduleRow(parent).resolveUs += call.us
|
|
173
|
+
const target = /^node:/.test(call.specifier) ? '(Node 内部)' : packageOf(call.url, names)
|
|
174
|
+
if (target === parent || target === '(Node 内部)' || target === '(原生/GC)') continue
|
|
175
|
+
if (!moduleEdges.has(parent)) moduleEdges.set(parent, new Set())
|
|
176
|
+
moduleEdges.get(parent).add(target)
|
|
177
|
+
}
|
|
178
|
+
for (const call of timing.loadCalls || []) {
|
|
179
|
+
moduleTotalUs += call.us
|
|
180
|
+
const pkg = packageOf(call.url, names)
|
|
181
|
+
if (pkg === '(原生/GC)') continue
|
|
182
|
+
moduleRow(pkg).loadUs += call.us
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 每个包从哪些插件可达;共用依赖按引用它的插件数均摊,和空间构成的口径一致。
|
|
186
|
+
const moduleOwners = new Map()
|
|
187
|
+
for (const plugin of names) {
|
|
188
|
+
const seen = new Set([plugin])
|
|
189
|
+
const queue = [plugin]
|
|
190
|
+
while (queue.length) {
|
|
191
|
+
const pkg = queue.shift()
|
|
192
|
+
for (const next of moduleEdges.get(pkg) || []) {
|
|
193
|
+
if (seen.has(next)) continue
|
|
194
|
+
seen.add(next)
|
|
195
|
+
queue.push(next)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const pkg of seen) {
|
|
199
|
+
if (!moduleOwners.has(pkg)) moduleOwners.set(pkg, new Set())
|
|
200
|
+
moduleOwners.get(pkg).add(plugin)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const plugins = [...names].map((name) => {
|
|
205
|
+
let loadUs = 0
|
|
206
|
+
let runUs = 0
|
|
207
|
+
for (const [pkg, owners] of moduleOwners) {
|
|
208
|
+
if (!owners.has(name)) continue
|
|
209
|
+
const share = 1 / owners.size
|
|
210
|
+
const module = moduleUs.get(pkg)
|
|
211
|
+
if (module) loadUs += (module.resolveUs + module.loadUs) * share
|
|
212
|
+
runUs += ((cpuUs.get(pkg) || 0) + (nativeByPackage.get(pkg) || 0)) * share
|
|
213
|
+
}
|
|
214
|
+
const loadMs = Math.round(loadUs / 1000)
|
|
215
|
+
const runMs = Math.round(runUs / 1000)
|
|
216
|
+
return { name, loadMs, runMs, totalMs: loadMs + runMs }
|
|
217
|
+
}).sort((left, right) => right.totalMs - left.totalMs || right.runMs - left.runMs)
|
|
218
|
+
|
|
219
|
+
const packages = [...new Set([...cpuUs.keys(), ...nativeByPackage.keys(), ...allocBytes.keys(), ...moduleUs.keys()])].map((name) => {
|
|
220
|
+
const cpuMs = Math.round((cpuUs.get(name) || 0) / 1000)
|
|
221
|
+
const nativeMs = Math.round((nativeByPackage.get(name) || 0) / 1000)
|
|
222
|
+
const module = moduleUs.get(name)
|
|
223
|
+
const loadMs = Math.round((module?.loadUs || 0) / 1000)
|
|
224
|
+
const resolveMs = Math.round((module?.resolveUs || 0) / 1000)
|
|
225
|
+
return {
|
|
226
|
+
name,
|
|
227
|
+
cpuMs,
|
|
228
|
+
nativeMs,
|
|
229
|
+
loadMs,
|
|
230
|
+
resolveMs,
|
|
231
|
+
totalMs: cpuMs + nativeMs + loadMs + resolveMs,
|
|
232
|
+
allocKB: Math.round((allocBytes.get(name) || 0) / 1024),
|
|
233
|
+
plugin: names.has(name),
|
|
234
|
+
}
|
|
235
|
+
}).sort((a, b) => b.totalMs - a.totalMs || b.cpuMs - a.cpuMs)
|
|
102
236
|
|
|
103
237
|
return {
|
|
104
238
|
version: REPORT_VERSION,
|
|
@@ -109,6 +243,9 @@ function summarize(cpuProfile, heapProfile) {
|
|
|
109
243
|
idleMs: Math.round(idleUs / 1000),
|
|
110
244
|
allocMB: +(totalAlloc / 1048576).toFixed(1),
|
|
111
245
|
packages,
|
|
246
|
+
plugins,
|
|
247
|
+
moduleLoadMs: Math.round(moduleTotalUs / 1000),
|
|
248
|
+
nativeUnattributedMs: Math.round(nativeUnattributedUs / 1000),
|
|
112
249
|
native: [...nativeUs.entries()]
|
|
113
250
|
.map(([name, us]) => ({ name, cpuMs: Math.round(us / 1000) }))
|
|
114
251
|
.sort((a, b) => b.cpuMs - a.cpuMs)
|
|
@@ -132,11 +269,12 @@ async function stop() {
|
|
|
132
269
|
stopped = true
|
|
133
270
|
if (timer !== null) { clearTimeout(timer); timer = null }
|
|
134
271
|
if (cap !== null) { clearTimeout(cap); cap = null }
|
|
272
|
+
stopModuleTiming()
|
|
135
273
|
if (!session) return
|
|
136
274
|
try {
|
|
137
275
|
const cpu = await session.post('Profiler.stop')
|
|
138
276
|
// 堆分配采样已撤:账本只看 CPU;内存用启动结算时读一次 RSS。
|
|
139
|
-
writeReport(summarize(cpu.profile, null))
|
|
277
|
+
writeReport(summarize(cpu.profile, null, { resolveCalls, loadCalls }))
|
|
140
278
|
} catch { /* 采样失败不能影响运行 */ }
|
|
141
279
|
try { session.disconnect() } catch { /* ignore */ }
|
|
142
280
|
}
|
|
@@ -180,6 +318,8 @@ async function start() {
|
|
|
180
318
|
|
|
181
319
|
export function installPluginProfile() {
|
|
182
320
|
if (!DIR) return
|
|
321
|
+
// 模块加载计时要抢在 dsh 的入口模块之前装上(--import 阶段)。
|
|
322
|
+
installModuleTiming()
|
|
183
323
|
globalThis[KEY] = {
|
|
184
324
|
enter(entry) {
|
|
185
325
|
if (!entry || pending.has(entry)) return
|
package/public/index.html
CHANGED
|
@@ -895,7 +895,7 @@
|
|
|
895
895
|
</form>
|
|
896
896
|
<p class="hint" id="versionManageHint" role="status" aria-live="polite"></p>
|
|
897
897
|
<div class="progress" id="versionManageProgress" hidden><div class="progress-bar" id="versionManageProgressBar"></div></div>
|
|
898
|
-
<h3 class="version-manage-heading" data-i18n="已安装">已安装</h3>
|
|
898
|
+
<div class="version-manage-heading-row"><h3 class="version-manage-heading" data-i18n="已安装">已安装</h3><span class="version-manage-total" id="versionManageTotal"></span></div>
|
|
899
899
|
<div class="version-manage-list" id="versionManageList" role="list"></div>
|
|
900
900
|
</div>
|
|
901
901
|
</div>
|
|
@@ -904,16 +904,26 @@
|
|
|
904
904
|
<div class="dialog-heading"><div><h2 class="ask-title" id="launchLogTitle" data-i18n="启动项日志">启动项日志</h2><p class="dialog-subtitle" id="launchLogSubtitle"></p></div><button class="ghost icon-btn dialog-close" id="launchLogClose" type="button" title="关闭" aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6"/></svg></button></div>
|
|
905
905
|
<div class="dialog-body">
|
|
906
906
|
<div class="launch-log-tools" id="launchLogTools" hidden><span class="hint" id="launchLogHint"></span></div>
|
|
907
|
+
<div class="launch-log-tabs" id="launchLogTabs" role="tablist" aria-label="启动项日志视图" data-i18n-aria-label="启动项日志视图">
|
|
908
|
+
<button class="launch-log-tab" type="button" role="tab" data-log-tab="raw" aria-selected="true" data-i18n="原始日志">原始日志</button>
|
|
909
|
+
<button class="launch-log-tab" type="button" role="tab" data-log-tab="time" aria-selected="false" data-i18n="耗时构成" hidden>耗时构成</button>
|
|
910
|
+
<button class="launch-log-tab" type="button" role="tab" data-log-tab="space" aria-selected="false" data-i18n="空间构成" hidden>空间构成</button>
|
|
911
|
+
<button class="launch-log-tab" type="button" role="tab" data-log-tab="diagnosis" aria-selected="false" data-i18n="插件诊断" hidden>插件诊断</button>
|
|
912
|
+
</div>
|
|
907
913
|
<div class="launch-log-loading" id="launchLogLoading" hidden><span class="spin"></span><span data-i18n="正在读取日志…">正在读取日志…</span></div>
|
|
908
|
-
<section class="launch-log-card" id="launchLogDiagnosis" hidden>
|
|
914
|
+
<section class="launch-log-card" id="launchLogDiagnosis" data-log-panel="diagnosis" hidden>
|
|
909
915
|
<div class="launch-log-card-head"><h3 data-i18n="插件诊断">插件诊断</h3><span class="launch-log-head-meta"><span id="launchLogDiagnosisMeta"></span></span></div>
|
|
910
916
|
<div class="launch-log-block" id="launchLogDiagnosisRows"></div>
|
|
911
917
|
</section>
|
|
912
|
-
<section class="launch-log-card" id="launchLogVisual" hidden>
|
|
918
|
+
<section class="launch-log-card" id="launchLogVisual" data-log-panel="time" hidden>
|
|
913
919
|
<div class="launch-log-card-head"><h3 data-i18n="耗时构成">耗时构成</h3><span class="launch-log-head-meta"><span id="launchLogWindow"></span><span id="launchLogRss" hidden></span></span></div>
|
|
914
920
|
<div class="launch-log-block" id="launchLogPlugins"></div>
|
|
915
921
|
</section>
|
|
916
|
-
<section class="launch-log-card" id="
|
|
922
|
+
<section class="launch-log-card" id="launchLogSpace" data-log-panel="space" hidden>
|
|
923
|
+
<div class="launch-log-card-head"><h3 data-i18n="空间构成">空间构成</h3><span class="launch-log-head-meta"><span id="launchLogSpaceMeta"></span></span></div>
|
|
924
|
+
<div class="launch-log-block" id="launchLogSpaceBody"></div>
|
|
925
|
+
</section>
|
|
926
|
+
<section class="launch-log-card" id="launchLogCard" data-log-panel="raw">
|
|
917
927
|
<div class="launch-log-card-head"><h3 data-i18n="原始日志">原始日志</h3><span class="launch-log-actions"><button class="ghost" type="button" id="launchLogCopy" data-i18n="复制">复制</button><button class="ghost" type="button" id="launchLogExport" data-i18n="导出">导出</button><button class="ghost" type="button" id="launchLogClear" data-i18n="清空">清空</button></span></div>
|
|
918
928
|
<pre class="launch-log-text" id="launchLogText"></pre>
|
|
919
929
|
</section>
|
|
@@ -1191,6 +1201,8 @@
|
|
|
1191
1201
|
"这个 profile 已存在,依赖与层栈会并进去": "this profile exists — dependencies and layers are merged in",
|
|
1192
1202
|
"安装": "Install",
|
|
1193
1203
|
"已安装": "Installed",
|
|
1204
|
+
"共 {size}": "{size} total",
|
|
1205
|
+
"正在计算占用…": "Calculating disk usage…",
|
|
1194
1206
|
"不看这个了": "Dismiss",
|
|
1195
1207
|
"正在读社区整合包列表…": "Reading the community modpack list…",
|
|
1196
1208
|
"市场里现在有 {n} 个整合包。": "The market has {n} modpack(s) right now.",
|
|
@@ -1530,6 +1542,7 @@
|
|
|
1530
1542
|
"启动耗时 {seconds}s": "Started in {seconds}s",
|
|
1531
1543
|
"启动明细": "Startup details",
|
|
1532
1544
|
"启动项日志": "Launch log",
|
|
1545
|
+
"启动项日志视图": "Launch log view",
|
|
1533
1546
|
"查看 {name} 的日志": "View the log for {name}",
|
|
1534
1547
|
"正在读取日志…": "Reading log…",
|
|
1535
1548
|
"这个启动项还没有日志。启动一次后,它自己的输出会出现在这里。": "This launch entry has no log yet. Start it once and its own output will show up here.",
|
|
@@ -1557,8 +1570,31 @@
|
|
|
1557
1570
|
"主线程 {pct}% 在跑;插件合计只占 {plugin}%,其余主要是模块解析与文件扫描。": "Main thread busy {pct}%; plugins only {plugin}%, the rest is module resolution and file scanning.",
|
|
1558
1571
|
"原始日志": "Raw log",
|
|
1559
1572
|
"耗时构成": "Time breakdown",
|
|
1573
|
+
"空间构成": "Disk usage",
|
|
1574
|
+
"运行时": "Runtime",
|
|
1575
|
+
"插件本体": "Plugin packages",
|
|
1576
|
+
"插件依赖": "Plugin dependencies",
|
|
1577
|
+
"运行时依赖": "Runtime dependencies",
|
|
1578
|
+
"配置与数据": "Config & data",
|
|
1579
|
+
"未归类": "Unaccounted",
|
|
1580
|
+
"合计 {size}": "{size} total",
|
|
1581
|
+
"最占空间的 5 项": "Top 5 by size",
|
|
1582
|
+
"各插件占用(含它带进来的依赖)": "Disk usage by plugin (including its dependencies)",
|
|
1583
|
+
"其余 {n} 个插件合计 {size}": "Other {n} plugins: {size} total",
|
|
1584
|
+
"插件按它引用到的依赖计入(共用依赖按引用它的插件均摊);合计是版本目录 + 这个环境目录的逻辑大小,pnpm 共享 store 不重复计入。": "Plugins are counted with the dependencies they pull in (shared dependencies are split across the plugins that use them); the total is the logical size of the version directory plus this environment directory, and the shared pnpm store is not counted twice.",
|
|
1560
1585
|
"窗口 {window} · CPU {cpu}": "Window {window} · CPU {cpu}",
|
|
1561
1586
|
"最耗时的 5 项": "Top 5 by time",
|
|
1587
|
+
"最耗时的 5 项(不含 Node 内部)": "Top 5 by time (excluding Node internals)",
|
|
1588
|
+
"各插件耗时(含依赖加载与运行)": "Time by plugin (including dependency loading and execution)",
|
|
1589
|
+
"插件(加载+运行)": "Plugins (load + run)",
|
|
1590
|
+
"运行时与其它": "Runtime & other",
|
|
1591
|
+
"加载 {load} · 运行 {run}": "load {load} · run {run}",
|
|
1592
|
+
"加载 {load} · 解析 {resolve} · JS {cpu} · 原生 {native}": "load {load} · resolve {resolve} · JS {cpu} · native {native}",
|
|
1593
|
+
"原生与 GC 明细": "Native & GC breakdown",
|
|
1594
|
+
"原生/GC 已按最近的 JS 调用方归到包;这里只按系统调用/GC 类型汇总。": "Native/GC time is attributed to the nearest JS caller's package; this lists it by syscall/GC type only.",
|
|
1595
|
+
"插件合计 {plugin}%(含它带进来的依赖);其余为运行时启动、文件扫描与未归属。": "Plugins total {plugin}% (including the dependencies they pull in); the rest is runtime startup, file scanning and unattributed time.",
|
|
1596
|
+
"未归属原生/GC": "Unattributed native/GC",
|
|
1597
|
+
"JS {cpu} · 原生 {native}": "JS {cpu} · native {native}",
|
|
1562
1598
|
"插件合计只占 {plugin}%;其余为模块解析、文件扫描与 Node 启动。": "Plugins account for only {plugin}%; the rest is module resolution, file scanning and Node startup.",
|
|
1563
1599
|
"清空这个启动项的日志?": "Clear this launch entry's log?",
|
|
1564
1600
|
"日志已清空。": "Log cleared.",
|
|
@@ -2179,8 +2215,19 @@
|
|
|
2179
2215
|
const launchLogCardEl = document.getElementById('launchLogCard')
|
|
2180
2216
|
const launchLogVisualEl = document.getElementById('launchLogVisual')
|
|
2181
2217
|
const launchLogPluginsEl = document.getElementById('launchLogPlugins')
|
|
2218
|
+
const launchLogSpaceEl = document.getElementById('launchLogSpace')
|
|
2219
|
+
const launchLogSpaceMetaEl = document.getElementById('launchLogSpaceMeta')
|
|
2220
|
+
const launchLogSpaceBodyEl = document.getElementById('launchLogSpaceBody')
|
|
2182
2221
|
const launchLogDiagnosisEl = document.getElementById('launchLogDiagnosis')
|
|
2183
2222
|
const launchLogDiagnosisMetaEl = document.getElementById('launchLogDiagnosisMeta')
|
|
2223
|
+
const launchLogTabsEl = document.getElementById('launchLogTabs')
|
|
2224
|
+
const launchLogTabButtons = [...document.querySelectorAll('.launch-log-tab[data-log-tab]')]
|
|
2225
|
+
const launchLogPanels = {
|
|
2226
|
+
raw: launchLogCardEl,
|
|
2227
|
+
time: launchLogVisualEl,
|
|
2228
|
+
space: launchLogSpaceEl,
|
|
2229
|
+
diagnosis: launchLogDiagnosisEl,
|
|
2230
|
+
}
|
|
2184
2231
|
const launchLogDiagnosisRowsEl = document.getElementById('launchLogDiagnosisRows')
|
|
2185
2232
|
const manageVersionsEl = document.getElementById('launchManageVersions')
|
|
2186
2233
|
const versionInstallSelectEl = document.getElementById('versionInstallSelect')
|
|
@@ -5126,6 +5173,10 @@
|
|
|
5126
5173
|
let launchLogTimer = null
|
|
5127
5174
|
let launchLogQuery = 0
|
|
5128
5175
|
let launchLogReportKey = ''
|
|
5176
|
+
let launchLogStorageKey = ''
|
|
5177
|
+
// 日志弹窗的页签:原始日志常驻;耗时/空间/诊断有数据才出现
|
|
5178
|
+
let launchLogTab = 'raw'
|
|
5179
|
+
const launchLogAvailable = { raw: true, time: false, space: false, diagnosis: false }
|
|
5129
5180
|
// 耗时账本按「这一次运行」缓存:同一次运行反复打开直接复用,重启后 runAt 变了才重取。
|
|
5130
5181
|
const launchReportCache = new Map()
|
|
5131
5182
|
// 日志正文按启动项缓存:再次打开先显示上次看到的全文,后台刷新再替换,不用空白等一次请求。
|
|
@@ -5141,8 +5192,24 @@
|
|
|
5141
5192
|
return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${Math.round(value)}ms`
|
|
5142
5193
|
}
|
|
5143
5194
|
|
|
5195
|
+
/** 只显示当前页签对应的卡片;没有内容的页签直接收起来。 */
|
|
5196
|
+
function paintLaunchLogTabs() {
|
|
5197
|
+
if (!launchLogAvailable[launchLogTab]) launchLogTab = 'raw'
|
|
5198
|
+
for (const button of launchLogTabButtons) {
|
|
5199
|
+
const key = button.dataset.logTab
|
|
5200
|
+
button.hidden = !launchLogAvailable[key]
|
|
5201
|
+
button.setAttribute('aria-selected', String(key === launchLogTab))
|
|
5202
|
+
button.tabIndex = key === launchLogTab ? 0 : -1
|
|
5203
|
+
}
|
|
5204
|
+
for (const [key, panel] of Object.entries(launchLogPanels)) {
|
|
5205
|
+
panel.hidden = !(launchLogAvailable[key] && key === launchLogTab)
|
|
5206
|
+
}
|
|
5207
|
+
launchLogTabsEl.hidden = false
|
|
5208
|
+
}
|
|
5209
|
+
|
|
5144
5210
|
function syncLaunchLogVisual() {
|
|
5145
|
-
|
|
5211
|
+
launchLogAvailable.time = !launchLogPluginsEl.hidden && launchLogPluginsEl.innerHTML.trim().length > 0
|
|
5212
|
+
paintLaunchLogTabs()
|
|
5146
5213
|
}
|
|
5147
5214
|
|
|
5148
5215
|
/** 诊断里的问题行:同一个包只列一次,标签沿用诊断面板的叫法。 */
|
|
@@ -5189,6 +5256,8 @@
|
|
|
5189
5256
|
function renderLaunchLogDiagnosis(warning) {
|
|
5190
5257
|
const rows = warning ? diagnosisIssueRows(warning.diagnosis) : []
|
|
5191
5258
|
launchLogDiagnosisEl.hidden = !rows.length
|
|
5259
|
+
launchLogAvailable.diagnosis = rows.length > 0
|
|
5260
|
+
paintLaunchLogTabs()
|
|
5192
5261
|
launchLogDiagnosisMetaEl.textContent = rows.length ? [warning.profile, warning.version].filter(Boolean).join(' · ') : ''
|
|
5193
5262
|
launchLogDiagnosisRowsEl.replaceChildren(...rows.map((row) => {
|
|
5194
5263
|
const line = document.createElement('div')
|
|
@@ -5205,50 +5274,79 @@
|
|
|
5205
5274
|
}))
|
|
5206
5275
|
}
|
|
5207
5276
|
|
|
5208
|
-
/** 启动 CPU
|
|
5277
|
+
/** 启动 CPU 账本:插件按「依赖加载 + 运行」归并,剩余是运行时与未归属原生/GC。 */
|
|
5209
5278
|
function renderLaunchReport(report) {
|
|
5210
5279
|
const packages = Array.isArray(report?.packages) ? report.packages : []
|
|
5211
5280
|
const native = Array.isArray(report?.native) ? report.native : []
|
|
5212
|
-
const
|
|
5213
|
-
const
|
|
5214
|
-
|
|
5215
|
-
const
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5281
|
+
const totalOf = (row) => Number(row.totalMs ?? row.cpuMs) || 0
|
|
5282
|
+
const sumOf = (rows, pick) => rows.reduce((sum, row) => sum + (pick(row) ? totalOf(row) : 0), 0)
|
|
5283
|
+
// 新报告带 plugins:加载耗时已经沿依赖图归并,还带运行耗时;旧报告退回按包算。
|
|
5284
|
+
const pluginList = Array.isArray(report?.plugins)
|
|
5285
|
+
? report.plugins.map((row) => ({
|
|
5286
|
+
name: String(row.name || ''),
|
|
5287
|
+
loadMs: Number(row.loadMs) || 0,
|
|
5288
|
+
runMs: Number(row.runMs) || 0,
|
|
5289
|
+
totalMs: Number(row.totalMs) || (Number(row.loadMs) || 0) + (Number(row.runMs) || 0),
|
|
5290
|
+
})).filter((row) => row.name && row.totalMs >= 1).sort((left, right) => right.totalMs - left.totalMs)
|
|
5291
|
+
: null
|
|
5292
|
+
const pluginMs = pluginList ? pluginList.reduce((sum, row) => sum + row.totalMs, 0) : sumOf(packages, (row) => row.plugin)
|
|
5293
|
+
// 新报告把原生/GC 按调用方归到包,只把找不到调用方的部分单列;旧报告没有归属,全部单列。
|
|
5294
|
+
const unattributed = Number(report?.nativeUnattributedMs)
|
|
5295
|
+
const nativeMs = Number.isFinite(unattributed) ? unattributed : native.reduce((sum, row) => sum + (Number(row.cpuMs) || 0), 0)
|
|
5296
|
+
const cpu = Math.max(1, Number(report.cpuMs) || 0)
|
|
5297
|
+
const runtimeMs = Math.max(0, cpu - pluginMs - nativeMs)
|
|
5298
|
+
const ledger = pluginMs + runtimeMs + nativeMs
|
|
5299
|
+
// 没有采样(连包和原生调用都没有)时没有账本可画,别只显示一条 100% 的运行时。
|
|
5300
|
+
if (ledger < 1 || (!packages.length && !native.length && pluginMs < 1)) {
|
|
5219
5301
|
launchLogPluginsEl.hidden = true
|
|
5220
5302
|
launchLogPluginsEl.innerHTML = ''
|
|
5221
5303
|
syncLaunchLogVisual()
|
|
5222
5304
|
return
|
|
5223
5305
|
}
|
|
5224
|
-
const
|
|
5225
|
-
const percent = (ms) => Math.round(ms /
|
|
5306
|
+
const total = Math.max(1, ledger)
|
|
5307
|
+
const percent = (ms) => Math.round(ms / total * 100)
|
|
5226
5308
|
const segments = [
|
|
5227
|
-
{ key: 'plugin', label: t('
|
|
5228
|
-
{ key: 'node', label: t('
|
|
5229
|
-
{ key: '
|
|
5230
|
-
{ key: 'native', label: t('原生与 GC'), ms: nativeMs },
|
|
5231
|
-
{ key: 'rest', label: t('未归类'), ms: Math.max(0, cpu - ledger) },
|
|
5309
|
+
{ key: 'plugin', label: t('插件(加载+运行)'), ms: pluginMs },
|
|
5310
|
+
{ key: 'node', label: t('运行时与其它'), ms: runtimeMs },
|
|
5311
|
+
{ key: 'native', label: t('未归属原生/GC'), ms: nativeMs },
|
|
5232
5312
|
].filter((segment) => segment.ms >= 1)
|
|
5233
|
-
const
|
|
5234
|
-
const
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5313
|
+
const splitTitle = (row) => t('JS {cpu} · 原生 {native}', { cpu: formatProfileMs(Number(row.cpuMs) || 0), native: formatProfileMs(Number(row.nativeMs) || 0) })
|
|
5314
|
+
const packageTitle = (row) => row.resolveMs === undefined
|
|
5315
|
+
? splitTitle(row)
|
|
5316
|
+
: t('加载 {load} · 解析 {resolve} · JS {cpu} · 原生 {native}', {
|
|
5317
|
+
load: formatProfileMs(Number(row.loadMs) || 0),
|
|
5318
|
+
resolve: formatProfileMs(Number(row.resolveMs) || 0),
|
|
5319
|
+
cpu: formatProfileMs(Number(row.cpuMs) || 0),
|
|
5320
|
+
native: formatProfileMs(Number(row.nativeMs) || 0),
|
|
5321
|
+
})
|
|
5322
|
+
const pluginTitle = (row) => pluginList
|
|
5323
|
+
? t('加载 {load} · 运行 {run}', { load: formatProfileMs(row.loadMs), run: formatProfileMs(row.runMs) })
|
|
5324
|
+
: splitTitle(row)
|
|
5325
|
+
const pluginValue = (row) => pluginList ? row.totalMs : totalOf(row)
|
|
5326
|
+
const pluginRows = pluginList
|
|
5327
|
+
? pluginList.slice(0, 5)
|
|
5328
|
+
: packages.filter((row) => row.plugin && totalOf(row) >= 1).sort((left, right) => totalOf(right) - totalOf(left)).slice(0, 5)
|
|
5329
|
+
const pluginMax = Math.max(...pluginRows.map(pluginValue), 1)
|
|
5330
|
+
// Node 内部的采样大多是模块加载/文件扫描,已经体现在插件的加载耗时和运行时里,单列会重复加总。
|
|
5331
|
+
const rows = packages
|
|
5332
|
+
.filter((row) => row.name !== '(Node 内部)' && row.name !== '(启动器钩子)' && totalOf(row) >= 1)
|
|
5333
|
+
.sort((left, right) => totalOf(right) - totalOf(left))
|
|
5334
|
+
.slice(0, 5)
|
|
5335
|
+
const max = Math.max(...rows.map((row) => totalOf(row)), 1)
|
|
5336
|
+
const nativeRows = native.filter((row) => Number(row.cpuMs) >= 1).sort((left, right) => right.cpuMs - left.cpuMs).slice(0, 6)
|
|
5337
|
+
const nativeMax = Math.max(...nativeRows.map((row) => Number(row.cpuMs) || 0), 1)
|
|
5242
5338
|
launchLogWindowEl.textContent = t('窗口 {window} · CPU {cpu}', { window: formatProfileMs(report.windowMs), cpu: formatProfileMs(report.cpuMs) })
|
|
5243
5339
|
launchLogPluginsEl.innerHTML = `
|
|
5244
|
-
<div class="launch-log-ledger" role="img" aria-label="${escapeHtml(segments.map((segment) => `${segment.label} ${formatProfileMs(segment.ms)}`).join(','))}">${segments.map((segment) => `<span class="ledger-${segment.key}" style="flex:${Math.max(segment.ms,
|
|
5340
|
+
<div class="launch-log-ledger" role="img" aria-label="${escapeHtml(segments.map((segment) => `${segment.label} ${formatProfileMs(segment.ms)}`).join(','))}">${segments.map((segment) => `<span class="ledger-${segment.key}" style="flex:${Math.max(segment.ms, total * 0.004)} 1 0" title="${escapeHtml(`${segment.label} ${formatProfileMs(segment.ms)}`)}"><span>${percent(segment.ms)}%</span></span>`).join('')}</div>
|
|
5245
5341
|
<div class="launch-log-legend">${segments.map((segment) => `<span><i class="ledger-${segment.key}"></i>${escapeHtml(segment.label)} <b>${escapeHtml(formatProfileMs(segment.ms))}</b></span>`).join('')}</div>
|
|
5246
|
-
|
|
5247
|
-
<div class="launch-log-bars">${
|
|
5248
|
-
|
|
5249
|
-
<
|
|
5250
|
-
|
|
5251
|
-
|
|
5342
|
+
${pluginRows.length ? `<div class="launch-log-subhead">${escapeHtml(t('各插件耗时(含依赖加载与运行)'))}</div>
|
|
5343
|
+
<div class="launch-log-bars">${pluginRows.map((row) => `<div class="launch-log-bar-row is-plugin"><span class="name" title="${escapeHtml(`${row.name} · ${pluginTitle(row)}`)}">${escapeHtml(row.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, pluginValue(row) / pluginMax * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatProfileMs(pluginValue(row)))}</span></div>`).join('')}</div>` : ''}
|
|
5344
|
+
<div class="launch-log-subhead">${escapeHtml(t('最耗时的 5 项(不含 Node 内部)'))}</div>
|
|
5345
|
+
<div class="launch-log-bars">${rows.map((row) => `<div class="launch-log-bar-row${row.plugin ? ' is-plugin' : ''}"><span class="name" title="${escapeHtml(`${row.name} · ${packageTitle(row)}`)}">${escapeHtml(row.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, totalOf(row) / max * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatProfileMs(totalOf(row)))}</span></div>`).join('')}</div>
|
|
5346
|
+
${nativeRows.length ? `<div class="launch-log-subhead">${escapeHtml(t('原生与 GC 明细'))}</div>
|
|
5347
|
+
<div class="launch-log-bars">${nativeRows.map((row) => `<div class="launch-log-bar-row"><span class="name" title="${escapeHtml(row.name)}">${escapeHtml(row.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, (Number(row.cpuMs) || 0) / nativeMax * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatProfileMs(row.cpuMs))}</span></div>`).join('')}</div>` : ''}
|
|
5348
|
+
${nativeRows.length ? `<p class="hint launch-log-note">${escapeHtml(t('原生/GC 已按最近的 JS 调用方归到包;这里只按系统调用/GC 类型汇总。'))}</p>` : ''}
|
|
5349
|
+
<p class="hint launch-log-note">${escapeHtml(t('插件合计 {plugin}%(含它带进来的依赖);其余为运行时启动、文件扫描与未归属。', { plugin: percent(pluginMs) }))}</p>`
|
|
5252
5350
|
launchLogPluginsEl.hidden = false
|
|
5253
5351
|
syncLaunchLogVisual()
|
|
5254
5352
|
}
|
|
@@ -5279,6 +5377,63 @@
|
|
|
5279
5377
|
}
|
|
5280
5378
|
}
|
|
5281
5379
|
|
|
5380
|
+
/** 启动项的空间构成:版本目录 + 这个 profile 目录,按插件 / 运行时 / 其它依赖 / 配置与数据分。 */
|
|
5381
|
+
function renderStorageBreakdown(data) {
|
|
5382
|
+
const labels = {
|
|
5383
|
+
plugin: t('插件本体'), 'plugin-deps': t('插件依赖'), runtime: t('运行时'),
|
|
5384
|
+
'runtime-deps': t('运行时依赖'), data: t('配置与数据'), rest: t('未归类'),
|
|
5385
|
+
}
|
|
5386
|
+
const segments = (data?.segments || [])
|
|
5387
|
+
.map((segment) => ({ ...segment, label: labels[segment.key] || segment.key }))
|
|
5388
|
+
.filter((segment) => Number(segment.bytes) >= 1)
|
|
5389
|
+
const total = Math.max(1, Number(data?.total) || 0)
|
|
5390
|
+
const percent = (bytes) => Math.round(Number(bytes) / total * 100)
|
|
5391
|
+
const items = (data?.items || []).filter((item) => Number(item.bytes) >= 1)
|
|
5392
|
+
const max = Math.max(...items.map((item) => Number(item.bytes) || 0), 1)
|
|
5393
|
+
const plugins = (data?.plugins || []).filter((plugin) => Number(plugin.bytes) >= 1)
|
|
5394
|
+
const pluginMax = Math.max(...plugins.map((plugin) => Number(plugin.bytes) || 0), 1)
|
|
5395
|
+
const restPlugins = plugins.slice(5)
|
|
5396
|
+
launchLogSpaceMetaEl.textContent = t('合计 {size}', { size: formatBytes(data.total) })
|
|
5397
|
+
launchLogSpaceBodyEl.innerHTML = `
|
|
5398
|
+
<div class="launch-log-ledger" role="img" aria-label="${escapeHtml(segments.map((segment) => `${segment.label} ${formatBytes(segment.bytes)}`).join(','))}">${segments.map((segment) => `<span class="ledger-${segment.key}" style="flex:${Math.max(segment.bytes, total * 0.004)} 1 0" title="${escapeHtml(`${segment.label} ${formatBytes(segment.bytes)}`)}"><span>${percent(segment.bytes)}%</span></span>`).join('')}</div>
|
|
5399
|
+
<div class="launch-log-legend">${segments.map((segment) => `<span><i class="ledger-${segment.key}"></i>${escapeHtml(segment.label)} <b>${escapeHtml(formatBytes(segment.bytes))}</b></span>`).join('')}</div>
|
|
5400
|
+
${plugins.length ? `<div class="launch-log-subhead">${escapeHtml(t('各插件占用(含它带进来的依赖)'))}</div>
|
|
5401
|
+
<div class="launch-log-bars">${plugins.slice(0, 5).map((plugin) => `<div class="launch-log-bar-row is-plugin"><span class="name" title="${escapeHtml(plugin.name)}">${escapeHtml(plugin.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, (Number(plugin.bytes) || 0) / pluginMax * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatBytes(plugin.bytes))}</span></div>`).join('')}</div>
|
|
5402
|
+
${restPlugins.length ? `<p class="hint launch-log-note">${escapeHtml(t('其余 {n} 个插件合计 {size}', { n: restPlugins.length, size: formatBytes(restPlugins.reduce((sum, plugin) => sum + Number(plugin.bytes || 0), 0)) }))}</p>` : ''}` : ''}
|
|
5403
|
+
<div class="launch-log-subhead">${escapeHtml(t('最占空间的 5 项'))}</div>
|
|
5404
|
+
<div class="launch-log-bars">${items.map((item) => `<div class="launch-log-bar-row${item.plugin ? ' is-plugin' : ''}"><span class="name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, (Number(item.bytes) || 0) / max * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatBytes(item.bytes))}</span></div>`).join('')}</div>
|
|
5405
|
+
<p class="hint launch-log-note">${escapeHtml(t('插件按它引用到的依赖计入(共用依赖按引用它的插件均摊);合计是版本目录 + 这个环境目录的逻辑大小,pnpm 共享 store 不重复计入。'))}</p>`
|
|
5406
|
+
launchLogAvailable.space = true
|
|
5407
|
+
paintLaunchLogTabs()
|
|
5408
|
+
}
|
|
5409
|
+
|
|
5410
|
+
async function loadStorageBreakdown(entry, version) {
|
|
5411
|
+
if (!entry || !version) return
|
|
5412
|
+
const profile = entry.profile || 'web'
|
|
5413
|
+
const key = `${entry.id}\0${version}\0${profile}`
|
|
5414
|
+
launchLogStorageKey = key
|
|
5415
|
+
launchLogSpaceEl.hidden = false
|
|
5416
|
+
launchLogSpaceMetaEl.textContent = t('正在计算占用…')
|
|
5417
|
+
launchLogSpaceBodyEl.innerHTML = ''
|
|
5418
|
+
try {
|
|
5419
|
+
const data = await getJson(
|
|
5420
|
+
`/api/storage/breakdown?version=${encodeURIComponent(version)}&profile=${encodeURIComponent(profile)}`,
|
|
5421
|
+
{ launchId: entry.id },
|
|
5422
|
+
)
|
|
5423
|
+
if (launchLogStorageKey !== key) return
|
|
5424
|
+
if (!Array.isArray(data?.segments) || !data.segments.length) throw new Error('empty')
|
|
5425
|
+
renderStorageBreakdown(data)
|
|
5426
|
+
} catch {
|
|
5427
|
+
if (launchLogStorageKey !== key) return
|
|
5428
|
+
// 算不出来就收起这块,不占着位置
|
|
5429
|
+
launchLogSpaceEl.hidden = true
|
|
5430
|
+
launchLogSpaceMetaEl.textContent = ''
|
|
5431
|
+
launchLogSpaceBodyEl.innerHTML = ''
|
|
5432
|
+
launchLogAvailable.space = false
|
|
5433
|
+
paintLaunchLogTabs()
|
|
5434
|
+
}
|
|
5435
|
+
}
|
|
5436
|
+
|
|
5282
5437
|
/** 提示行只在有话说时出现,避免顶部留一条空白。 */
|
|
5283
5438
|
function setLaunchLogHint(text) {
|
|
5284
5439
|
launchLogHintEl.textContent = text || ''
|
|
@@ -5322,7 +5477,6 @@
|
|
|
5322
5477
|
launchLogTextEl.textContent = simplifyLogText(data.text)
|
|
5323
5478
|
launchLogTextCache.set(entry.id, launchLogTextEl.textContent)
|
|
5324
5479
|
if (launchLogTextCache.size > 8) launchLogTextCache.delete(launchLogTextCache.keys().next().value)
|
|
5325
|
-
launchLogCardEl.hidden = !data.text
|
|
5326
5480
|
setLaunchLogHint(data.text
|
|
5327
5481
|
? (data.clipped ? t('只显示最后 120 KB。') : '')
|
|
5328
5482
|
: t('这个启动项还没有日志。启动一次后,它自己的输出会出现在这里。'))
|
|
@@ -5359,16 +5513,24 @@
|
|
|
5359
5513
|
launchLogRssEl.textContent = ''
|
|
5360
5514
|
launchLogRssEl.title = ''
|
|
5361
5515
|
launchLogWindowEl.textContent = ''
|
|
5362
|
-
launchLogCardEl.hidden = !cachedText
|
|
5363
5516
|
launchLogReportKey = ''
|
|
5364
5517
|
launchLogPluginsEl.hidden = true
|
|
5365
5518
|
launchLogPluginsEl.innerHTML = ''
|
|
5519
|
+
launchLogStorageKey = ''
|
|
5520
|
+
launchLogSpaceEl.hidden = true
|
|
5521
|
+
launchLogSpaceMetaEl.textContent = ''
|
|
5522
|
+
launchLogSpaceBodyEl.innerHTML = ''
|
|
5523
|
+
launchLogTab = 'raw'
|
|
5524
|
+
launchLogAvailable.time = false
|
|
5525
|
+
launchLogAvailable.space = false
|
|
5526
|
+
launchLogAvailable.diagnosis = false
|
|
5366
5527
|
renderLaunchLogDiagnosis(null)
|
|
5367
5528
|
syncLaunchLogVisual()
|
|
5368
5529
|
await showDialog(launchLogDialogEl)
|
|
5369
5530
|
launchLogTextEl.scrollTop = launchLogTextEl.scrollHeight
|
|
5370
5531
|
const data = await refreshLaunchLog()
|
|
5371
5532
|
if (version) await loadLaunchReport(version, entry.profile || 'web', data?.runs?.[0]?.at || '')
|
|
5533
|
+
if (version) void loadStorageBreakdown(entry, version)
|
|
5372
5534
|
launchLogLoadingEl.hidden = true
|
|
5373
5535
|
clearInterval(launchLogTimer)
|
|
5374
5536
|
launchLogTimer = setInterval(() => {
|
|
@@ -5378,6 +5540,7 @@
|
|
|
5378
5540
|
|
|
5379
5541
|
function closeLaunchLog() {
|
|
5380
5542
|
launchLogEntry = null
|
|
5543
|
+
launchLogStorageKey = ''
|
|
5381
5544
|
launchLogQuery += 1
|
|
5382
5545
|
launchLogLoadingEl.hidden = true
|
|
5383
5546
|
clearInterval(launchLogTimer)
|
|
@@ -5717,6 +5880,10 @@
|
|
|
5717
5880
|
let versionManageOrigin = null
|
|
5718
5881
|
let versionManageRevision = 0
|
|
5719
5882
|
let versionManageMarkup = ''
|
|
5883
|
+
// 版本目录大小:打开版本管理时按需向服务端要(服务端有缓存),不跟着状态推送反复扫盘
|
|
5884
|
+
let versionSizes = null
|
|
5885
|
+
let versionSizesTotal = 0
|
|
5886
|
+
let versionSizesLoading = false
|
|
5720
5887
|
let versionInstallOptions = ''
|
|
5721
5888
|
|
|
5722
5889
|
function openVersionManager() {
|
|
@@ -5732,6 +5899,7 @@
|
|
|
5732
5899
|
}
|
|
5733
5900
|
versionManageDialogEl.classList.toggle('is-child-dialog', versionManageReturnToEditor)
|
|
5734
5901
|
paintVersionManager()
|
|
5902
|
+
void loadVersionSizes()
|
|
5735
5903
|
showDialog(versionManageDialogEl)
|
|
5736
5904
|
document.getElementById('versionManageClose').focus()
|
|
5737
5905
|
// 打开就顺手静默刷新一次远程列表(服务端 60 秒内走缓存,不会每次都打网络):
|
|
@@ -5831,6 +5999,7 @@
|
|
|
5831
5999
|
try {
|
|
5832
6000
|
await post('/api/install', { version })
|
|
5833
6001
|
applyState(await getJson('/api/state'))
|
|
6002
|
+
void loadVersionSizes()
|
|
5834
6003
|
} catch (error) { notify(error.message) }
|
|
5835
6004
|
finally { action = ''; render() }
|
|
5836
6005
|
}
|
|
@@ -5843,13 +6012,45 @@
|
|
|
5843
6012
|
try {
|
|
5844
6013
|
await post('/api/versions/optimize', { version })
|
|
5845
6014
|
applyState(await getJson('/api/state'))
|
|
6015
|
+
void loadVersionSizes()
|
|
5846
6016
|
notify(t('依赖优化完成'), t('优化依赖'))
|
|
5847
6017
|
} catch (error) { notify(error.message) }
|
|
5848
6018
|
finally { action = ''; render() }
|
|
5849
6019
|
}
|
|
5850
6020
|
|
|
6021
|
+
/** 目录大小的显示单位:B / KB / MB / GB。 */
|
|
6022
|
+
function formatBytes(bytes) {
|
|
6023
|
+
const value = Number(bytes) || 0
|
|
6024
|
+
if (value < 1024) return `${value} B`
|
|
6025
|
+
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
|
|
6026
|
+
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`
|
|
6027
|
+
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
6028
|
+
}
|
|
6029
|
+
|
|
6030
|
+
/** 打开版本管理时算一次每个版本的磁盘占用;服务端有 5 分钟缓存。 */
|
|
6031
|
+
async function loadVersionSizes() {
|
|
6032
|
+
if (typeof versionManageDialogEl === 'undefined' || versionSizesLoading) return
|
|
6033
|
+
versionSizesLoading = true
|
|
6034
|
+
if (!versionManageDialogEl.hidden) paintVersionManager()
|
|
6035
|
+
try {
|
|
6036
|
+
const data = await getJson('/api/versions/sizes')
|
|
6037
|
+
versionSizes = data?.versions && typeof data.versions === 'object' ? data.versions : null
|
|
6038
|
+
versionSizesTotal = Number(data?.total) || 0
|
|
6039
|
+
} catch { /* 算不出来不影响版本管理 */ }
|
|
6040
|
+
finally {
|
|
6041
|
+
versionSizesLoading = false
|
|
6042
|
+
if (!versionManageDialogEl.hidden) paintVersionManager()
|
|
6043
|
+
}
|
|
6044
|
+
}
|
|
6045
|
+
|
|
5851
6046
|
function paintVersionManager() {
|
|
5852
6047
|
const installed = [...installedList()].sort((a, b) => compareVersion(b, a))
|
|
6048
|
+
const totalEl = document.getElementById('versionManageTotal')
|
|
6049
|
+
if (totalEl) {
|
|
6050
|
+
totalEl.textContent = versionSizesLoading && !versionSizes
|
|
6051
|
+
? t('正在计算占用…')
|
|
6052
|
+
: versionSizesTotal ? t('共 {size}', { size: formatBytes(versionSizesTotal) }) : ''
|
|
6053
|
+
}
|
|
5853
6054
|
const latest = latestRemoteVersion()
|
|
5854
6055
|
const available = [...new Set([latest, ...allVersionList()])].filter((version) => version && !installed.includes(version))
|
|
5855
6056
|
.sort((a, b) => compareVersion(b, a))
|
|
@@ -5885,7 +6086,8 @@
|
|
|
5885
6086
|
: t('正在运行 · {profile}', { profile: live.profile || t('运行中') }) : t('未运行')
|
|
5886
6087
|
const label = t('卸载版本')
|
|
5887
6088
|
const optimize = t('优化依赖')
|
|
5888
|
-
|
|
6089
|
+
const size = versionSizes?.[version] ? formatBytes(versionSizes[version]) : ''
|
|
6090
|
+
return `<div class="version-manage-row" role="listitem"><div class="version-manage-info"><strong>${escapeHtml(version)}</strong>${size ? `<span class="version-manage-size">${escapeHtml(size)}</span>` : ''}${live ? `<span title="${escapeHtml(status)}">${escapeHtml(status)}</span>` : ''}</div><button class="ghost version-optimize" type="button" data-optimize-version="${escapeHtml(version)}" title="${escapeHtml(t('整理重复依赖,减少内存和磁盘占用'))}" ${busy || live ? 'disabled' : ''}>${escapeHtml(optimize)}</button><button class="ghost icon-btn version-remove" type="button" data-remove-version="${escapeHtml(version)}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}" ${busy || live ? 'disabled' : ''}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/></svg></button></div>`
|
|
5889
6091
|
}).join('') || `<p class="hint version-manage-empty">${escapeHtml(t('尚未安装任何版本'))}</p>`
|
|
5890
6092
|
// 状态推送和安装进度频繁变化,保留相同的行以免键盘焦点被重建打断。
|
|
5891
6093
|
if (versionManageMarkup !== markup) {
|
|
@@ -6050,6 +6252,7 @@
|
|
|
6050
6252
|
try {
|
|
6051
6253
|
await post('/api/uninstall', { version })
|
|
6052
6254
|
applyState(await getJson('/api/state'))
|
|
6255
|
+
void loadVersionSizes()
|
|
6053
6256
|
}
|
|
6054
6257
|
catch (error) { notify(error.message) }
|
|
6055
6258
|
finally { action = ''; render() }
|
|
@@ -9371,6 +9574,21 @@
|
|
|
9371
9574
|
trapPackDialog(packExportDialog, closeExportDialog)
|
|
9372
9575
|
trapPackDialog(pluginAddDialogEl, closePluginAdd)
|
|
9373
9576
|
document.getElementById('launchLogClose').onclick = () => closeLaunchLog()
|
|
9577
|
+
for (const button of launchLogTabButtons) {
|
|
9578
|
+
button.onclick = () => { launchLogTab = button.dataset.logTab; paintLaunchLogTabs() }
|
|
9579
|
+
}
|
|
9580
|
+
launchLogTabsEl.onkeydown = (event) => {
|
|
9581
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
9582
|
+
const visible = launchLogTabButtons.filter((button) => !button.hidden)
|
|
9583
|
+
const index = visible.indexOf(document.activeElement)
|
|
9584
|
+
if (index < 0) return
|
|
9585
|
+
event.preventDefault()
|
|
9586
|
+
const step = event.key === 'ArrowRight' ? 1 : visible.length - 1
|
|
9587
|
+
const next = visible[(index + step) % visible.length]
|
|
9588
|
+
launchLogTab = next.dataset.logTab
|
|
9589
|
+
paintLaunchLogTabs()
|
|
9590
|
+
next.focus()
|
|
9591
|
+
}
|
|
9374
9592
|
launchLogCopyEl.onclick = () => { void copyLaunchLog() }
|
|
9375
9593
|
launchLogExportEl.onclick = () => { void exportLaunchLog() }
|
|
9376
9594
|
launchLogClearEl.onclick = () => { void clearLaunchLog() }
|
package/public/launcher.css
CHANGED
|
@@ -1429,6 +1429,14 @@ section[data-category="advanced"] .set-group + .set-label + .set-group { margin-
|
|
|
1429
1429
|
.launch-log-loading[hidden] { display: none; }
|
|
1430
1430
|
.launch-log-loading .spin { display: block; width: 16px; height: 16px; }
|
|
1431
1431
|
.launch-log-tools .hint { margin: 0; }
|
|
1432
|
+
/* 日志弹窗的页签:原始日志常驻,耗时/空间/诊断有数据才出现 */
|
|
1433
|
+
.launch-log-tabs { display: flex; gap: 2px; padding: 4px; margin-bottom: 10px; border-radius: 12px; background: var(--accent-soft); }
|
|
1434
|
+
.launch-log-tabs[hidden] { display: none; }
|
|
1435
|
+
.launch-log-tab { flex: 1 1 0; padding: 7px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-family: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer; transition: color 160ms ease, background 160ms ease; }
|
|
1436
|
+
.launch-log-tab[hidden] { display: none; }
|
|
1437
|
+
.launch-log-tab:hover { color: var(--text); }
|
|
1438
|
+
.launch-log-tab[aria-selected="true"] { background: var(--card); color: var(--accent); font-weight: 600; box-shadow: 0 2px 8px rgba(22, 35, 61, .1); }
|
|
1439
|
+
.launch-log-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
1432
1440
|
.launch-log-tools .hint:empty { display: none; }
|
|
1433
1441
|
.launch-log-meta { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; background: rgba(127, 127, 127, .1); color: var(--text); font-size: 12px; }
|
|
1434
1442
|
.launch-log-card { border: 1px solid var(--line); border-radius: 14px; padding: 14px; background: rgba(127, 127, 127, .035); }
|
|
@@ -1456,8 +1464,19 @@ section[data-category="advanced"] .set-group + .set-label + .set-group { margin-
|
|
|
1456
1464
|
.ledger-other { background: #b6bcc8; }
|
|
1457
1465
|
.ledger-native { background: #d99a2b; }
|
|
1458
1466
|
.ledger-rest { background: #dfe3ea; }
|
|
1467
|
+
/* 空间构成:运行时 / 插件依赖 / 运行时依赖 / 配置与数据 */
|
|
1468
|
+
.ledger-runtime { background: #6f7684; }
|
|
1469
|
+
.ledger-plugin-deps { background: #8ea4ff; }
|
|
1470
|
+
.ledger-runtime-deps { background: #e0b45a; }
|
|
1471
|
+
.ledger-data { background: #3fa27a; }
|
|
1459
1472
|
.launch-log-ledger > .ledger-rest { color: #4a5160; }
|
|
1460
1473
|
.launch-log-bars { display: flex; flex-direction: column; gap: 6px; }
|
|
1474
|
+
/* 空间构成 / 耗时构成:标题和合计固定,内容多时在卡片内部滚动,别把弹窗撑长 */
|
|
1475
|
+
#launchLogSpace .launch-log-block, #launchLogVisual .launch-log-block { max-height: min(46vh, 400px); overflow-y: auto; padding-right: 8px; scrollbar-width: thin; scrollbar-color: #8ea4ff transparent; }
|
|
1476
|
+
#launchLogSpace .launch-log-block::-webkit-scrollbar, #launchLogVisual .launch-log-block::-webkit-scrollbar { width: 10px; }
|
|
1477
|
+
#launchLogSpace .launch-log-block::-webkit-scrollbar-track, #launchLogVisual .launch-log-block::-webkit-scrollbar-track { background: rgba(127, 127, 127, .08); border-radius: 999px; }
|
|
1478
|
+
#launchLogSpace .launch-log-block::-webkit-scrollbar-thumb, #launchLogVisual .launch-log-block::-webkit-scrollbar-thumb { background: #8ea4ff; border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
|
1479
|
+
#launchLogSpace .launch-log-block::-webkit-scrollbar-thumb:hover, #launchLogVisual .launch-log-block::-webkit-scrollbar-thumb:hover { background: #6f8cff; background-clip: content-box; }
|
|
1461
1480
|
.launch-log-bar-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) 56px; gap: 10px; align-items: center; font-size: 12px; }
|
|
1462
1481
|
.launch-log-bar-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
|
1463
1482
|
.launch-log-bar-row .track { height: 7px; border-radius: 999px; background: rgba(127, 127, 127, .12); overflow: hidden; }
|
|
@@ -2407,6 +2426,10 @@ html .set-row:focus-within { background: transparent; box-shadow: none; }
|
|
|
2407
2426
|
#versionManageHint { margin: 0; min-height: 0; font-size: 12px; }
|
|
2408
2427
|
#versionManageHint:empty { display: none; }
|
|
2409
2428
|
.version-manage-heading { margin: 6px 0 0; font-size: 12px; font-weight: 500; color: var(--muted); }
|
|
2429
|
+
/* 「已安装」右边放整体的磁盘占用;每行左边再标各自的大小 */
|
|
2430
|
+
.version-manage-heading-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-top: 6px; }
|
|
2431
|
+
.version-manage-heading-row .version-manage-heading { margin-top: 0; }
|
|
2432
|
+
.version-manage-total { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
2410
2433
|
.version-optimize { flex: 0 0 auto; padding: 7px 10px; font-size: 12px; white-space: nowrap; }
|
|
2411
2434
|
.version-manage-row .version-remove svg { width: 18px; height: 18px; }
|
|
2412
2435
|
.version-manage-row .version-manage-info span { max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
package/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from 'node:crypto'
|
|
|
4
4
|
import { createServer } from 'node:http'
|
|
5
5
|
import { createServer as createNetServer } from 'node:net'
|
|
6
6
|
import { createInterface } from 'node:readline'
|
|
7
|
-
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
8
8
|
import { accessSync, chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { homedir, tmpdir } from 'node:os'
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
@@ -199,7 +199,8 @@ let WEB_BIND = resolveWebBind()
|
|
|
199
199
|
// 手工钉死端口的实例(键 `版本@profile` → 端口):没钉的组合每次启动由系统挑一个。
|
|
200
200
|
// 和 WEB_BIND 一样是「改完立刻生效」的内存副本,启动时与保存设置时各刷新一次。
|
|
201
201
|
let INSTANCE_PORTS = safeInstancePorts(loadSettingsSync().instancePorts)
|
|
202
|
-
|
|
202
|
+
// 额外目录的端口表在 defaultHome() 定义之后再填(要判断哪些目录还活着,见 #90)。
|
|
203
|
+
let ENVIRONMENT_PORTS = {}
|
|
203
204
|
// 界面语言(zh / en):settings.json 为准;lang.txt 只为尚未选择语言的用户提供首次默认值。
|
|
204
205
|
const INSTALL_LANG = join(ROOT, 'lang.txt')
|
|
205
206
|
let LANG = safeLang(loadSettingsSync().lang) || installLang() || 'zh'
|
|
@@ -213,6 +214,21 @@ let HIDE_BIG_FISH = loadSettingsSync().hideBigFish === true
|
|
|
213
214
|
let DSH_HOME_DIR = safeDshHome(loadSettingsSync().dshHome)
|
|
214
215
|
const launchScope = new AsyncLocalStorage()
|
|
215
216
|
const defaultHome = () => DSH_HOME_DIR || defaultDshHome()
|
|
217
|
+
/**
|
|
218
|
+
* 固定端口只认「还登记过、目录也还在」的环境。旧 DSH_HOME 被删掉/换盘后,
|
|
219
|
+
* environmentPorts 里那条钉子会把端口永久占住(#90),这里直接把它当不存在。
|
|
220
|
+
*/
|
|
221
|
+
function liveEnvironmentPorts(stored) {
|
|
222
|
+
const environments = safeEnvironmentPorts(stored?.environmentPorts)
|
|
223
|
+
const known = [
|
|
224
|
+
defaultHome(),
|
|
225
|
+
...safeDshHomes(stored?.dshHomes),
|
|
226
|
+
...safeLaunchPresets(stored?.launchPresets).map((entry) => entry.dshHome).filter(Boolean),
|
|
227
|
+
]
|
|
228
|
+
return Object.fromEntries(Object.entries(environments).filter(([dir]) =>
|
|
229
|
+
existsSync(dir) && known.some((item) => fileManager.samePath(dir, item))))
|
|
230
|
+
}
|
|
231
|
+
ENVIRONMENT_PORTS = liveEnvironmentPorts(loadSettingsSync())
|
|
216
232
|
const profileKey = (profile) => `${homeDir()}\0${profile}`
|
|
217
233
|
// 启动器更新的下载源:direct(默认直连 GitHub)/ mirror(国内加速,直连失败时走前缀镜像)
|
|
218
234
|
let UPDATE_SOURCE = updateSourceForDownload(loadSettingsSync().downloadSource)
|
|
@@ -525,6 +541,252 @@ function scanInstalled() {
|
|
|
525
541
|
return found
|
|
526
542
|
}
|
|
527
543
|
|
|
544
|
+
/** 小并发跑异步任务,避免一次 stat 几万个文件把句柄打满。 */
|
|
545
|
+
async function mapLimit(items, limit, task) {
|
|
546
|
+
let index = 0
|
|
547
|
+
const workers = Array.from({ length: Math.min(limit, items.length || 1) }, async () => {
|
|
548
|
+
while (index < items.length) {
|
|
549
|
+
const item = items[index]
|
|
550
|
+
index += 1
|
|
551
|
+
await task(item)
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
await Promise.all(workers)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* 目录的逻辑占用。符号链接跳过:pnpm 的 node_modules 里大量是链接,顺着走会把共享 store
|
|
559
|
+
* 里的同一份包重复算进每个版本。
|
|
560
|
+
*/
|
|
561
|
+
async function directoryBytes(dir) {
|
|
562
|
+
let entries
|
|
563
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return 0 }
|
|
564
|
+
const files = []
|
|
565
|
+
const dirs = []
|
|
566
|
+
for (const entry of entries) {
|
|
567
|
+
if (entry.isSymbolicLink()) continue
|
|
568
|
+
if (entry.isDirectory()) dirs.push(entry.name)
|
|
569
|
+
else if (entry.isFile()) files.push(entry.name)
|
|
570
|
+
}
|
|
571
|
+
// 并发累加不能写 `total += await ...`:两个 worker 会读到同一个旧值,后写的把先写的覆盖掉
|
|
572
|
+
const sizes = []
|
|
573
|
+
await mapLimit(files, 32, async (name) => {
|
|
574
|
+
try { sizes.push((await stat(join(dir, name))).size) } catch { /* 文件刚好被删 */ }
|
|
575
|
+
})
|
|
576
|
+
await mapLimit(dirs, 8, async (name) => { sizes.push(await directoryBytes(join(dir, name))) })
|
|
577
|
+
return sizes.reduce((total, bytes) => total + bytes, 0)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const VERSION_SIZE_TTL_MS = 5 * 60_000
|
|
581
|
+
let versionSizesCache = { at: 0, data: null }
|
|
582
|
+
|
|
583
|
+
/** .pnpm 目录名 → 包名:`pkg@1.2.3`、`@scope+pkg@1.2.3(peer@x)`。 */
|
|
584
|
+
function pnpmPackageName(entry) {
|
|
585
|
+
const raw = String(entry).replace(/\(.*$/, '')
|
|
586
|
+
const at = raw.lastIndexOf('@')
|
|
587
|
+
return (at > 0 ? raw.slice(0, at) : raw).replace(/\+/g, '/')
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** 一个 node_modules 里各包的大小;pnpm 的 .pnpm 虚拟目录按包名合并,符号链接跳过。 */
|
|
591
|
+
async function packageSizesIn(nodeModules) {
|
|
592
|
+
const sizes = new Map()
|
|
593
|
+
// 目录名和 package.json 的 name 不一定一样(平台二进制 wrapper 常见),按真实包名归并,
|
|
594
|
+
// 这样依赖图(按 package.json 的 name 建)和大小才能对上。
|
|
595
|
+
const nameOf = (dir, fallback) => {
|
|
596
|
+
try { return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name || fallback } catch { return fallback }
|
|
597
|
+
}
|
|
598
|
+
const add = async (dir, fallback) => {
|
|
599
|
+
const name = nameOf(dir, fallback)
|
|
600
|
+
sizes.set(name, (sizes.get(name) || 0) + await directoryBytes(dir))
|
|
601
|
+
}
|
|
602
|
+
const entries = await readdir(nodeModules, { withFileTypes: true }).catch(() => [])
|
|
603
|
+
for (const entry of entries) {
|
|
604
|
+
if (entry.isSymbolicLink()) continue
|
|
605
|
+
if (entry.name === '.pnpm') {
|
|
606
|
+
const virtual = await readdir(join(nodeModules, '.pnpm'), { withFileTypes: true }).catch(() => [])
|
|
607
|
+
for (const item of virtual) {
|
|
608
|
+
if (item.isSymbolicLink() || !item.isDirectory()) continue
|
|
609
|
+
const name = pnpmPackageName(item.name)
|
|
610
|
+
if (!name) continue
|
|
611
|
+
sizes.set(name, (sizes.get(name) || 0) + await directoryBytes(join(nodeModules, '.pnpm', item.name)))
|
|
612
|
+
}
|
|
613
|
+
continue
|
|
614
|
+
}
|
|
615
|
+
if (entry.name.startsWith('.') || !entry.isDirectory()) continue
|
|
616
|
+
if (entry.name.startsWith('@')) {
|
|
617
|
+
const scoped = await readdir(join(nodeModules, entry.name), { withFileTypes: true }).catch(() => [])
|
|
618
|
+
for (const sub of scoped) {
|
|
619
|
+
if (sub.isSymbolicLink() || !sub.isDirectory()) continue
|
|
620
|
+
await add(join(nodeModules, entry.name, sub.name), `${entry.name}/${sub.name}`)
|
|
621
|
+
}
|
|
622
|
+
continue
|
|
623
|
+
}
|
|
624
|
+
await add(join(nodeModules, entry.name), entry.name)
|
|
625
|
+
}
|
|
626
|
+
return sizes
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const STORAGE_BREAKDOWN_TTL_MS = 5 * 60_000
|
|
630
|
+
const storageBreakdownCache = new Map()
|
|
631
|
+
|
|
632
|
+
/** 包名 → 它声明的依赖名。用来把传递依赖归到引用它的插件头上。 */
|
|
633
|
+
async function packageGraph(nodeModules) {
|
|
634
|
+
const graph = new Map()
|
|
635
|
+
const add = async (dir) => {
|
|
636
|
+
try {
|
|
637
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
638
|
+
if (!pkg?.name) return
|
|
639
|
+
const deps = new Set([
|
|
640
|
+
...(pkg.dependencies ? Object.keys(pkg.dependencies) : []),
|
|
641
|
+
...(pkg.optionalDependencies ? Object.keys(pkg.optionalDependencies) : []),
|
|
642
|
+
])
|
|
643
|
+
graph.set(pkg.name, deps)
|
|
644
|
+
} catch { /* 没有 package.json 的残缺目录 */ }
|
|
645
|
+
}
|
|
646
|
+
const entries = await readdir(nodeModules, { withFileTypes: true }).catch(() => [])
|
|
647
|
+
for (const entry of entries) {
|
|
648
|
+
if (entry.isSymbolicLink() || !entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
649
|
+
if (entry.name.startsWith('@')) {
|
|
650
|
+
const scoped = await readdir(join(nodeModules, entry.name), { withFileTypes: true }).catch(() => [])
|
|
651
|
+
for (const sub of scoped) {
|
|
652
|
+
if (sub.isSymbolicLink() || !sub.isDirectory()) continue
|
|
653
|
+
await add(join(nodeModules, entry.name, sub.name))
|
|
654
|
+
}
|
|
655
|
+
continue
|
|
656
|
+
}
|
|
657
|
+
await add(join(nodeModules, entry.name))
|
|
658
|
+
}
|
|
659
|
+
// pnpm 隔离布局:.pnpm/<pkg>@<ver>/node_modules/<pkg>
|
|
660
|
+
const virtual = await readdir(join(nodeModules, '.pnpm'), { withFileTypes: true }).catch(() => [])
|
|
661
|
+
for (const item of virtual) {
|
|
662
|
+
if (item.isSymbolicLink() || !item.isDirectory()) continue
|
|
663
|
+
const inner = await readdir(join(nodeModules, '.pnpm', item.name, 'node_modules'), { withFileTypes: true }).catch(() => [])
|
|
664
|
+
for (const sub of inner) {
|
|
665
|
+
if (sub.isSymbolicLink() || !sub.isDirectory()) continue
|
|
666
|
+
if (sub.name.startsWith('@')) {
|
|
667
|
+
const scoped = await readdir(join(nodeModules, '.pnpm', item.name, 'node_modules', sub.name), { withFileTypes: true }).catch(() => [])
|
|
668
|
+
for (const child of scoped) {
|
|
669
|
+
if (child.isSymbolicLink() || !child.isDirectory()) continue
|
|
670
|
+
await add(join(nodeModules, '.pnpm', item.name, 'node_modules', sub.name, child.name))
|
|
671
|
+
}
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
674
|
+
await add(join(nodeModules, '.pnpm', item.name, 'node_modules', sub.name))
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return graph
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** 每个直接插件能到达哪些包;共用包按引用它的插件数量均摊。 */
|
|
681
|
+
async function attributePlugins(nodeModules, direct, sizes) {
|
|
682
|
+
const graph = await packageGraph(nodeModules)
|
|
683
|
+
const parents = new Map()
|
|
684
|
+
for (const start of direct) {
|
|
685
|
+
const seen = new Set([start])
|
|
686
|
+
const queue = [start]
|
|
687
|
+
while (queue.length) {
|
|
688
|
+
const current = queue.shift()
|
|
689
|
+
for (const next of graph.get(current) || []) {
|
|
690
|
+
if (seen.has(next)) continue
|
|
691
|
+
seen.add(next)
|
|
692
|
+
queue.push(next)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
for (const name of seen) {
|
|
696
|
+
if (name === start) continue
|
|
697
|
+
const owners = parents.get(name) || new Set()
|
|
698
|
+
owners.add(start)
|
|
699
|
+
parents.set(name, owners)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const totals = new Map()
|
|
703
|
+
for (const name of direct) totals.set(name, sizes.get(name) || 0)
|
|
704
|
+
let depsBytes = 0
|
|
705
|
+
for (const [name, owners] of parents) {
|
|
706
|
+
if (direct.includes(name)) continue
|
|
707
|
+
const size = sizes.get(name) || 0
|
|
708
|
+
if (!size) continue
|
|
709
|
+
depsBytes += size
|
|
710
|
+
const share = size / owners.size
|
|
711
|
+
for (const owner of owners) totals.set(owner, (totals.get(owner) || 0) + share)
|
|
712
|
+
}
|
|
713
|
+
const plugins = [...totals.entries()]
|
|
714
|
+
.map(([name, bytes]) => ({ name, bytes: Math.round(bytes) }))
|
|
715
|
+
.filter((plugin) => plugin.bytes >= 1)
|
|
716
|
+
.sort((left, right) => right.bytes - left.bytes)
|
|
717
|
+
return { plugins, depsBytes }
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* 一个启动项(版本 + profile)的空间构成:版本目录 + 这个 profile 目录的逻辑大小。
|
|
722
|
+
* 插件只算 profile 的直接依赖;pnpm 共享 store 不在这些目录里,不重复计入。
|
|
723
|
+
*/
|
|
724
|
+
async function storageBreakdown(version, profile) {
|
|
725
|
+
const key = `${homeDir()}\0${version}\0${profile}`
|
|
726
|
+
const cached = storageBreakdownCache.get(key)
|
|
727
|
+
if (cached && Date.now() - cached.at < STORAGE_BREAKDOWN_TTL_MS) return cached.data
|
|
728
|
+
const versionPath = versionDir(version)
|
|
729
|
+
const profilePath = join(homeDir(), 'profiles', safeProfile(profile))
|
|
730
|
+
const profileNodeModules = join(profilePath, 'node_modules')
|
|
731
|
+
const [versionBytes, profileBytes, profileNodeModulesBytes, versionPackages, profilePackages] = await Promise.all([
|
|
732
|
+
directoryBytes(versionPath),
|
|
733
|
+
directoryBytes(profilePath),
|
|
734
|
+
directoryBytes(profileNodeModules),
|
|
735
|
+
packageSizesIn(join(versionPath, 'node_modules')),
|
|
736
|
+
packageSizesIn(profileNodeModules),
|
|
737
|
+
])
|
|
738
|
+
const sum = (map) => [...map.values()].reduce((totalBytes, bytes) => totalBytes + bytes, 0)
|
|
739
|
+
const direct = new Set(Object.keys(profileDependencies(profilePath)))
|
|
740
|
+
const { plugins, depsBytes: pluginDepsBytes } = await attributePlugins(profileNodeModules, [...direct], profilePackages)
|
|
741
|
+
let pluginOwnBytes = 0
|
|
742
|
+
for (const name of direct) pluginOwnBytes += profilePackages.get(name) || 0
|
|
743
|
+
let runtimeBytes = 0
|
|
744
|
+
let runtimeDepsBytes = 0
|
|
745
|
+
for (const [name, bytes] of versionPackages) {
|
|
746
|
+
if (name.startsWith('@deepseek-ai/')) runtimeBytes += bytes
|
|
747
|
+
else runtimeDepsBytes += bytes
|
|
748
|
+
}
|
|
749
|
+
const dataBytes = Math.max(0, profileBytes - profileNodeModulesBytes)
|
|
750
|
+
const total = versionBytes + profileBytes
|
|
751
|
+
// 到不了的 profile 包(残留/未声明)和版本目录非 node_modules 的部分都算「未归类」
|
|
752
|
+
const restBytes = Math.max(0, total - pluginOwnBytes - pluginDepsBytes - runtimeBytes - runtimeDepsBytes - dataBytes)
|
|
753
|
+
const segments = [
|
|
754
|
+
{ key: 'plugin', bytes: pluginOwnBytes },
|
|
755
|
+
{ key: 'plugin-deps', bytes: pluginDepsBytes },
|
|
756
|
+
{ key: 'runtime', bytes: runtimeBytes },
|
|
757
|
+
{ key: 'runtime-deps', bytes: runtimeDepsBytes },
|
|
758
|
+
{ key: 'data', bytes: dataBytes },
|
|
759
|
+
{ key: 'rest', bytes: restBytes },
|
|
760
|
+
].filter((segment) => segment.bytes >= 1)
|
|
761
|
+
const combined = new Map()
|
|
762
|
+
for (const [name, bytes] of versionPackages) combined.set(name, { name, bytes, plugin: false })
|
|
763
|
+
for (const [name, bytes] of profilePackages) {
|
|
764
|
+
const item = combined.get(name) || { name, bytes: 0, plugin: false }
|
|
765
|
+
item.bytes += bytes
|
|
766
|
+
item.plugin = item.plugin || direct.has(name)
|
|
767
|
+
combined.set(name, item)
|
|
768
|
+
}
|
|
769
|
+
const items = [...combined.values()].sort((left, right) => right.bytes - left.bytes).slice(0, 5)
|
|
770
|
+
const data = { version, profile, total, segments, plugins, items }
|
|
771
|
+
storageBreakdownCache.set(key, { at: Date.now(), data })
|
|
772
|
+
return data
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function invalidateStorageBreakdown() {
|
|
776
|
+
storageBreakdownCache.clear()
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** 已装版本各自占多少(只算 DATA/versions 下受管版本;系统全局装的不归启动器管)。 */
|
|
780
|
+
async function versionSizes() {
|
|
781
|
+
if (versionSizesCache.data && Date.now() - versionSizesCache.at < VERSION_SIZE_TTL_MS) return versionSizesCache.data
|
|
782
|
+
const versions = scanInstalled().filter(isManaged)
|
|
783
|
+
const sizes = {}
|
|
784
|
+
for (const version of versions) sizes[version] = await directoryBytes(versionDir(version))
|
|
785
|
+
const total = Object.values(sizes).reduce((sum, bytes) => sum + bytes, 0)
|
|
786
|
+
versionSizesCache = { at: Date.now(), data: { versions: sizes, total } }
|
|
787
|
+
return versionSizesCache.data
|
|
788
|
+
}
|
|
789
|
+
|
|
528
790
|
function listedVersions(config) {
|
|
529
791
|
const onDisk = new Set(scanInstalled())
|
|
530
792
|
const fromConfig = (config.versions || [])
|
|
@@ -605,6 +867,8 @@ async function pruneVersions(config) {
|
|
|
605
867
|
}
|
|
606
868
|
}
|
|
607
869
|
if (removed.length) {
|
|
870
|
+
versionSizesCache.at = 0
|
|
871
|
+
invalidateStorageBreakdown()
|
|
608
872
|
config.versions = listedVersions(config)
|
|
609
873
|
await saveConfig(config)
|
|
610
874
|
for (const version of removed) await dropInstancePorts(version)
|
|
@@ -1543,7 +1807,7 @@ async function setInstancePortNow(version, profile, port) {
|
|
|
1543
1807
|
? { environmentPorts: { ...ENVIRONMENT_PORTS, [home]: next } }
|
|
1544
1808
|
: { instancePorts: next })
|
|
1545
1809
|
INSTANCE_PORTS = safeInstancePorts(saved.instancePorts)
|
|
1546
|
-
ENVIRONMENT_PORTS =
|
|
1810
|
+
ENVIRONMENT_PORTS = liveEnvironmentPorts(saved)
|
|
1547
1811
|
}
|
|
1548
1812
|
const text = String(port ?? '').trim()
|
|
1549
1813
|
if (!text || Number(text) === 0) {
|
|
@@ -1842,10 +2106,40 @@ async function removeLaunchPresetNow(id) {
|
|
|
1842
2106
|
const stored = await loadSettings()
|
|
1843
2107
|
if (installing || packInstalling) throw new Error('正在安装,稍后再编辑启动项')
|
|
1844
2108
|
const presets = safeLaunchPresets(stored.launchPresets)
|
|
2109
|
+
const entry = presets.find((item) => item.id === id)
|
|
1845
2110
|
const next = presets.filter((item) => item.id !== id)
|
|
1846
|
-
if (
|
|
1847
|
-
//
|
|
1848
|
-
|
|
2111
|
+
if (!entry) throw new Error('这个启动项已不存在,请刷新后重试')
|
|
2112
|
+
// 启动项是端口钉子的唯一入口:它删掉、又没有别的启动项用同一个版本@profile 时,
|
|
2113
|
+
// 钉子一起清掉,否则端口会被一个已经不存在的启动项永久占住(#90)。
|
|
2114
|
+
const home = entry.dshHome || defaultHome()
|
|
2115
|
+
const profile = entry.profile || 'web'
|
|
2116
|
+
const keys = new Set([entry.version !== 'auto' ? entry.version : '', entry.usedVersion, entry.prevVersion]
|
|
2117
|
+
.filter(Boolean).map((version) => `${version}@${profile}`))
|
|
2118
|
+
for (const other of next) {
|
|
2119
|
+
if (!fileManager.samePath(other.dshHome || defaultHome(), home)) continue
|
|
2120
|
+
for (const version of [other.version !== 'auto' ? other.version : '', other.usedVersion, other.prevVersion]) {
|
|
2121
|
+
if (version) keys.delete(`${version}@${other.profile || 'web'}`)
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
const patch = { launchPresets: next }
|
|
2125
|
+
if (keys.size) {
|
|
2126
|
+
if (fileManager.samePath(home, defaultHome())) {
|
|
2127
|
+
const ports = { ...safeInstancePorts(stored.instancePorts) }
|
|
2128
|
+
for (const key of keys) delete ports[key]
|
|
2129
|
+
patch.instancePorts = ports
|
|
2130
|
+
} else {
|
|
2131
|
+
const environments = safeEnvironmentPorts(stored.environmentPorts)
|
|
2132
|
+
const dir = Object.keys(environments).find((item) => fileManager.samePath(item, home))
|
|
2133
|
+
if (dir) {
|
|
2134
|
+
const ports = { ...environments[dir] }
|
|
2135
|
+
for (const key of keys) delete ports[key]
|
|
2136
|
+
patch.environmentPorts = { ...environments, [dir]: ports }
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
const saved = await saveSettings(patch)
|
|
2141
|
+
INSTANCE_PORTS = safeInstancePorts(saved.instancePorts)
|
|
2142
|
+
ENVIRONMENT_PORTS = liveEnvironmentPorts(saved)
|
|
1849
2143
|
dropLaunchLog(id)
|
|
1850
2144
|
await emitState()
|
|
1851
2145
|
return { ok: true }
|
|
@@ -1891,7 +2185,7 @@ async function dropInstancePorts(version) {
|
|
|
1891
2185
|
&& Object.entries(environments).every(([home, ports]) => Object.keys(ports).length === Object.keys(ENVIRONMENT_PORTS[home]).length)) return
|
|
1892
2186
|
const saved = await saveSettings({ instancePorts: kept, environmentPorts: environments })
|
|
1893
2187
|
INSTANCE_PORTS = safeInstancePorts(saved.instancePorts)
|
|
1894
|
-
ENVIRONMENT_PORTS =
|
|
2188
|
+
ENVIRONMENT_PORTS = liveEnvironmentPorts(saved)
|
|
1895
2189
|
}
|
|
1896
2190
|
|
|
1897
2191
|
async function saveManagerSettings(body) {
|
|
@@ -2040,7 +2334,7 @@ async function saveManagerSettings(body) {
|
|
|
2040
2334
|
EXTRA_ARGS = composeExtraArgs(stored.args)
|
|
2041
2335
|
WEB_BIND = safeWebBind(stored.webBind)
|
|
2042
2336
|
INSTANCE_PORTS = safeInstancePorts(stored.instancePorts)
|
|
2043
|
-
ENVIRONMENT_PORTS =
|
|
2337
|
+
ENVIRONMENT_PORTS = liveEnvironmentPorts(stored)
|
|
2044
2338
|
if (safeLang(stored.lang)) LANG = safeLang(stored.lang)
|
|
2045
2339
|
THEME = safeTheme(stored.theme)
|
|
2046
2340
|
PANEL_TRANSPARENCY = safePanelTransparency(stored.panelTransparency)
|
|
@@ -2951,7 +3245,7 @@ function autoPortCandidates(stored, version, profile) {
|
|
|
2951
3245
|
/** 这个端口这次真的能拿来复用吗(不能的话调用方会换一个新的)。 */
|
|
2952
3246
|
async function autoPortUsable(port, stored) {
|
|
2953
3247
|
// 自动端口不能抢手工指定给其他实例的端口;真实监听也要检查(含别的程序)。
|
|
2954
|
-
const reserved = [stored.instancePorts, ...Object.values(stored
|
|
3248
|
+
const reserved = [stored.instancePorts, ...Object.values(liveEnvironmentPorts(stored))]
|
|
2955
3249
|
.some((ports) => Object.values(ports || {}).includes(port))
|
|
2956
3250
|
|| safeLaunchPresets(stored.launchPresets).some((entry) => entry.port === port)
|
|
2957
3251
|
// 检查监听会让出执行权;不同目录的并发启动不能同时复用同一个历史端口。
|
|
@@ -3964,6 +4258,7 @@ async function addPlugin(version, spec, { profile = PROFILE_NAME } = {}) {
|
|
|
3964
4258
|
pluginBusy = false
|
|
3965
4259
|
pluginProgressName = ''
|
|
3966
4260
|
pluginProgressProfile = ''
|
|
4261
|
+
invalidateStorageBreakdown()
|
|
3967
4262
|
emitPluginProgress(null)
|
|
3968
4263
|
}
|
|
3969
4264
|
}
|
|
@@ -4006,6 +4301,7 @@ async function removePlugin(version, name, { profile = PROFILE_NAME } = {}) {
|
|
|
4006
4301
|
pluginBusy = false
|
|
4007
4302
|
pluginProgressName = ''
|
|
4008
4303
|
pluginProgressProfile = ''
|
|
4304
|
+
invalidateStorageBreakdown()
|
|
4009
4305
|
emitPluginProgress(null)
|
|
4010
4306
|
}
|
|
4011
4307
|
}
|
|
@@ -4224,6 +4520,7 @@ async function runProfileInstall(version, options = {}) {
|
|
|
4224
4520
|
} finally {
|
|
4225
4521
|
pluginProgressName = ''
|
|
4226
4522
|
pluginProgressProfile = ''
|
|
4523
|
+
invalidateStorageBreakdown()
|
|
4227
4524
|
emitPluginProgress(null)
|
|
4228
4525
|
}
|
|
4229
4526
|
}
|
|
@@ -5742,7 +6039,7 @@ async function renameProfile(profile, nextName) {
|
|
|
5742
6039
|
autoPorts: Object.fromEntries(Object.entries(safeEnvironmentPorts(current.autoPorts)).map(([dir, value]) => [dir, sameHome(dir) ? ports(value) : value])),
|
|
5743
6040
|
}))
|
|
5744
6041
|
INSTANCE_PORTS = safeInstancePorts(saved.instancePorts)
|
|
5745
|
-
ENVIRONMENT_PORTS =
|
|
6042
|
+
ENVIRONMENT_PORTS = liveEnvironmentPorts(saved)
|
|
5746
6043
|
if (sameHome(defaultHome())) {
|
|
5747
6044
|
PROFILE_NAME = saved.profile
|
|
5748
6045
|
}
|
|
@@ -6780,11 +7077,16 @@ async function handleApi(req, res, url) {
|
|
|
6780
7077
|
}
|
|
6781
7078
|
if (req.method === 'POST' && url.pathname === '/api/install') {
|
|
6782
7079
|
await install(body.version)
|
|
7080
|
+
versionSizesCache.at = 0
|
|
7081
|
+
invalidateStorageBreakdown()
|
|
6783
7082
|
send(res, 200, { ok: true })
|
|
6784
7083
|
return
|
|
6785
7084
|
}
|
|
6786
7085
|
if (req.method === 'POST' && url.pathname === '/api/versions/optimize') {
|
|
6787
|
-
|
|
7086
|
+
const result = await optimizeVersion(body.version)
|
|
7087
|
+
versionSizesCache.at = 0
|
|
7088
|
+
invalidateStorageBreakdown()
|
|
7089
|
+
send(res, 200, result)
|
|
6788
7090
|
return
|
|
6789
7091
|
}
|
|
6790
7092
|
if (req.method === 'POST' && url.pathname === '/api/start') {
|
|
@@ -6820,8 +7122,16 @@ async function handleApi(req, res, url) {
|
|
|
6820
7122
|
// 启动项仍在用的目录不能悄悄消失,也不能为了移除列表而删掉启动项。
|
|
6821
7123
|
if (removing && safeLaunchPresets(current.launchPresets).some((entry) => entry.dshHome && same(entry.dshHome, home))) throw new Error('启动项正在使用此目录,请先修改或删除对应启动项')
|
|
6822
7124
|
const homes = safeDshHomes(current.dshHomes).filter((item) => !same(item, home))
|
|
6823
|
-
return {
|
|
7125
|
+
return {
|
|
7126
|
+
dshHomes: removing ? homes : [...homes, home],
|
|
7127
|
+
// 目录都不在了,钉在它上面的端口也没意义;留着会占住端口(#90)。
|
|
7128
|
+
...(removing ? {
|
|
7129
|
+
environmentPorts: Object.fromEntries(Object.entries(safeEnvironmentPorts(current.environmentPorts))
|
|
7130
|
+
.filter(([dir]) => !same(dir, home))),
|
|
7131
|
+
} : {}),
|
|
7132
|
+
}
|
|
6824
7133
|
})
|
|
7134
|
+
if (removing) ENVIRONMENT_PORTS = liveEnvironmentPorts(stored)
|
|
6825
7135
|
send(res, 200, { dshHomes: safeDshHomes(stored.dshHomes) })
|
|
6826
7136
|
await emitState()
|
|
6827
7137
|
return
|
|
@@ -6907,9 +7217,25 @@ async function handleApi(req, res, url) {
|
|
|
6907
7217
|
}
|
|
6908
7218
|
if (req.method === 'POST' && url.pathname === '/api/uninstall') {
|
|
6909
7219
|
await uninstall(body.version)
|
|
7220
|
+
versionSizesCache.at = 0
|
|
7221
|
+
invalidateStorageBreakdown()
|
|
6910
7222
|
send(res, 200, { ok: true })
|
|
6911
7223
|
return
|
|
6912
7224
|
}
|
|
7225
|
+
if (req.method === 'GET' && url.pathname === '/api/versions/sizes') {
|
|
7226
|
+
try { send(res, 200, await versionSizes()) }
|
|
7227
|
+
catch (error) { send(res, 500, { error: error instanceof Error ? error.message : String(error) }) }
|
|
7228
|
+
return
|
|
7229
|
+
}
|
|
7230
|
+
if (req.method === 'GET' && url.pathname === '/api/storage/breakdown') {
|
|
7231
|
+
try {
|
|
7232
|
+
const version = safeVersion(url.searchParams.get('version') || '')
|
|
7233
|
+
const profile = safeProfile(url.searchParams.get('profile') || PROFILE_NAME)
|
|
7234
|
+
if (!existsSync(versionDir(version))) throw new Error(`${version} 没有安装在启动器的版本目录里`)
|
|
7235
|
+
send(res, 200, await storageBreakdown(version, profile))
|
|
7236
|
+
} catch (error) { send(res, 400, { error: error instanceof Error ? error.message : String(error) }) }
|
|
7237
|
+
return
|
|
7238
|
+
}
|
|
6913
7239
|
if (req.method === 'POST' && url.pathname === '/api/settings') {
|
|
6914
7240
|
send(res, 200, await saveManagerSettings(body))
|
|
6915
7241
|
return
|
|
@@ -7045,6 +7371,7 @@ async function handleApi(req, res, url) {
|
|
|
7045
7371
|
}
|
|
7046
7372
|
} finally {
|
|
7047
7373
|
pluginBusy = false
|
|
7374
|
+
invalidateStorageBreakdown()
|
|
7048
7375
|
}
|
|
7049
7376
|
pushLog(`桌面增强:${profile} 卸载完成,成功 ${removed.length} 个${failed.length ? `,失败 ${failed.length} 个` : ''}(重启 dsh 后生效)`)
|
|
7050
7377
|
send(res, 200, { ok: failed.length === 0, removed, failed, desktopEnhance: desktopEnhanceSummary(profile), ...listPlugins(profileDirOf(profile)), profile })
|
|
@@ -7784,7 +8111,13 @@ export async function startServer() {
|
|
|
7784
8111
|
}
|
|
7785
8112
|
// 钉死端口的实例表:启动、重启都读它(页面改这张表走 /api/instance-port)
|
|
7786
8113
|
INSTANCE_PORTS = safeInstancePorts(stored.instancePorts)
|
|
7787
|
-
|
|
8114
|
+
// 旧环境留下的端口钉子:内存里不算它,文件里也顺手清掉,不然重装也不会消失(#90)。
|
|
8115
|
+
const storedEnvironments = safeEnvironmentPorts(stored.environmentPorts)
|
|
8116
|
+
ENVIRONMENT_PORTS = liveEnvironmentPorts(stored)
|
|
8117
|
+
if (Object.keys(ENVIRONMENT_PORTS).length !== Object.keys(storedEnvironments).length) {
|
|
8118
|
+
await saveSettings({ environmentPorts: ENVIRONMENT_PORTS }).catch(() => {})
|
|
8119
|
+
pushLog('已清理旧环境留下的固定端口记录')
|
|
8120
|
+
}
|
|
7788
8121
|
// 用户在设置页的选择要跨重启与升级保留,安装语言只初始化缺失的设置。
|
|
7789
8122
|
const storedLang = safeLang(stored.lang)
|
|
7790
8123
|
const fromInstall = storedLang ? '' : installLang()
|