dsh-x-core 0.1.21 → 0.1.23

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.
Files changed (50) hide show
  1. package/assets/dsh-preview.png +0 -0
  2. package/assets/dsh.ico +0 -0
  3. package/assets/icon.ico +0 -0
  4. package/assets/tray.ico +0 -0
  5. package/assets/tray.png +0 -0
  6. package/core-npm.js +201 -183
  7. package/desktop-plugins.js +86 -0
  8. package/desktop.js +200 -0
  9. package/filemanager-base.js +7 -8
  10. package/filemanager-linux.js +124 -0
  11. package/filemanager.js +4 -7
  12. package/native-notify.js +74 -0
  13. package/node/node +2 -1
  14. package/package.json +1 -1
  15. package/packs/dsh-x-workbench/README.md +61 -0
  16. package/packs/dsh-x-workbench/dspack.json +4 -0
  17. package/packs/dsh-x-workbench/home/skills/agent-browser/LICENSE-APACHE-2.0.txt +202 -0
  18. package/packs/dsh-x-workbench/home/skills/agent-browser/NOTICE.md +6 -0
  19. package/packs/dsh-x-workbench/home/skills/agent-browser/SKILL.md +697 -0
  20. package/packs/dsh-x-workbench/home/skills/agent-browser/references/authentication.md +313 -0
  21. package/packs/dsh-x-workbench/home/skills/agent-browser/references/commands.md +300 -0
  22. package/packs/dsh-x-workbench/home/skills/agent-browser/references/profiling.md +120 -0
  23. package/packs/dsh-x-workbench/home/skills/agent-browser/references/proxy-support.md +194 -0
  24. package/packs/dsh-x-workbench/home/skills/agent-browser/references/session-management.md +201 -0
  25. package/packs/dsh-x-workbench/home/skills/agent-browser/references/snapshot-refs.md +229 -0
  26. package/packs/dsh-x-workbench/home/skills/agent-browser/references/video-recording.md +173 -0
  27. package/packs/dsh-x-workbench/home/skills/agent-browser/templates/authenticated-session.sh +105 -0
  28. package/packs/dsh-x-workbench/home/skills/agent-browser/templates/capture-workflow.sh +69 -0
  29. package/packs/dsh-x-workbench/home/skills/agent-browser/templates/form-automation.sh +62 -0
  30. package/packs/dsh-x-workbench/home/skills/electron/LICENSE-APACHE-2.0.txt +202 -0
  31. package/packs/dsh-x-workbench/home/skills/electron/NOTICE.md +6 -0
  32. package/packs/dsh-x-workbench/home/skills/electron/SKILL.md +243 -0
  33. package/packs/dsh-x-workbench/manifest.json +35 -0
  34. package/perf/patch-hooks.mjs +14 -1
  35. package/perf/plugin-profile.mjs +204 -0
  36. package/perf/register.mjs +4 -0
  37. package/platform.js +2 -1
  38. package/public/booting.html +475 -0
  39. package/public/favicon.ico +0 -0
  40. package/public/favicon.png +0 -0
  41. package/public/icon.png +0 -0
  42. package/public/index.html +9277 -8173
  43. package/public/internal-tabs.js +45 -41
  44. package/public/launcher.css +285 -98
  45. package/public/mascot.js +1 -1
  46. package/public/theme.css +33 -6
  47. package/public/water.js +1 -1
  48. package/registry.js +38 -3
  49. package/server.js +1060 -119
  50. package/settings.js +78 -1
@@ -0,0 +1,475 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN" data-lang="__APP_LANG__" data-theme="__APP_THEME__">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>DSH-X</title>
7
+ <link rel="icon" href="/favicon.ico?v=dsh" sizes="any" />
8
+ <link rel="icon" type="image/png" href="/favicon.png?v=dsh" />
9
+ <script>
10
+ // 配色跟管理页一致:显式 light/dark 说了算,system(以及将来新增的未知值)跟系统走。
11
+ // 这段必须在样式之前跑,否则深色下会先闪一帧白底。
12
+ (() => {
13
+ const root = document.documentElement
14
+ const systemDark = matchMedia('(prefers-color-scheme: dark)')
15
+ const apply = () => {
16
+ const theme = root.dataset.theme
17
+ root.dataset.colorScheme = theme === 'light' || theme === 'dark' ? theme : (systemDark.matches ? 'dark' : 'light')
18
+ }
19
+ apply()
20
+ systemDark.addEventListener('change', apply)
21
+ })()
22
+ </script>
23
+ <style>
24
+ /* 这张页由管理服务直接吐出来(/booting),不引 launcher.css:启动那一刻的目标是
25
+ 立刻画出一张能看的页,不该再等第二个请求把几十 KB 的主样式表拉下来。 */
26
+ :root {
27
+ color-scheme: light;
28
+ --bg: #eceff5;
29
+ --card: #ffffff;
30
+ --line: #d8dde8;
31
+ --text: #1b2434;
32
+ --muted: #667386;
33
+ --accent: #3b6cff;
34
+ --accent-soft: rgba(59, 108, 255, .12);
35
+ --danger: #d5484d;
36
+ --track: #e3e7f0;
37
+ }
38
+ html[data-color-scheme="dark"] {
39
+ color-scheme: dark;
40
+ --bg: #15161a;
41
+ --card: #22232a;
42
+ --line: #3a3c45;
43
+ --text: #eceef3;
44
+ --muted: #a3a9b6;
45
+ --accent: #ececee;
46
+ --accent-soft: rgba(236, 236, 238, .14);
47
+ --danger: #f2777a;
48
+ --track: #35373f;
49
+ }
50
+ * { box-sizing: border-box; }
51
+ html, body { height: 100%; }
52
+ body {
53
+ margin: 0;
54
+ display: grid;
55
+ place-items: center;
56
+ padding: 28px;
57
+ background: radial-gradient(ellipse at 50% 0%, var(--accent-soft), transparent 62%), var(--bg);
58
+ color: var(--text);
59
+ font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
60
+ -webkit-font-smoothing: antialiased;
61
+ }
62
+ .card {
63
+ width: min(440px, 100%);
64
+ display: grid;
65
+ gap: 14px;
66
+ padding: 30px 30px 24px;
67
+ border-radius: 18px;
68
+ background: var(--card);
69
+ border: 1px solid var(--line);
70
+ box-shadow: 0 22px 60px rgba(15, 20, 35, .16);
71
+ }
72
+ .brand { display: flex; align-items: center; gap: 9px; font-size: 11.5px; letter-spacing: .12em; color: var(--muted); text-transform: uppercase; }
73
+ .brand-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
74
+ .head { display: flex; align-items: center; gap: 12px; }
75
+ .spinner {
76
+ flex: none;
77
+ width: 26px; height: 26px;
78
+ border-radius: 50%;
79
+ border: 2.5px solid var(--track);
80
+ border-top-color: var(--accent);
81
+ animation: spin .9s linear infinite;
82
+ }
83
+ @keyframes spin { to { transform: rotate(360deg); } }
84
+ h1 { margin: 0; font-size: 16.5px; font-weight: 650; letter-spacing: .01em; }
85
+ .meta { margin: 0; color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
86
+ .bar { position: relative; height: 4px; border-radius: 999px; background: var(--track); overflow: hidden; }
87
+ .bar > span { position: absolute; inset: 0 auto 0 0; width: 0; border-radius: inherit; background: var(--accent); transition: width .35s ease; }
88
+ /* 不确定进度:一小段带渐隐尾巴的亮条扫过去。一整块方块匀速平移像「卡住的进度条」,
89
+ 有尾巴、有快慢变化才像还在干活(也诚实地表示这段没有真实百分比)。 */
90
+ .bar.indeterminate > span {
91
+ width: 46%;
92
+ background: linear-gradient(90deg, transparent, var(--accent) 52%, var(--accent));
93
+ animation: slide 1.7s cubic-bezier(.45, .05, .55, .95) infinite;
94
+ }
95
+ @keyframes slide { 0% { transform: translateX(-105%); } 100% { transform: translateX(225%); } }
96
+ .phase { margin: 0; min-height: 20px; font-size: 13px; font-variant-numeric: tabular-nums; }
97
+ .hint { margin: 0; font-size: 12px; color: var(--muted); }
98
+ /* 阶段点:服务端只给粗粒度阶段,这里把「启动进程 → 加载插件 → 校验依赖 → 打开页面」
99
+ 画成一条能看见在走的线,比一根假进度条诚实。 */
100
+ .steps { position: relative; display: flex; align-items: center; gap: 0; margin: 2px 0 0; padding: 0; list-style: none; }
101
+ .steps li { position: relative; flex: 1; padding-top: 16px; font-size: 11.5px; color: var(--muted); text-align: center; transition: color .3s ease; }
102
+ .steps li::before {
103
+ content: ""; position: absolute; top: 4px; left: 50%; width: 8px; height: 8px; margin-left: -4px;
104
+ border-radius: 50%; background: var(--track); transition: background .25s ease, box-shadow .25s ease;
105
+ }
106
+ /* 连接线从左边长过去:done 的那段用 background-size 过渡,比整段突然变色顺 */
107
+ .steps li + li::after {
108
+ content: ""; position: absolute; top: 7px; right: 50%; width: 100%; height: 2px; border-radius: 1px;
109
+ background: linear-gradient(var(--accent), var(--accent)) left center / 0% 100% no-repeat, var(--track);
110
+ transition: background-size .4s cubic-bezier(.2, .8, .2, 1);
111
+ }
112
+ .steps li[data-state="done"] + li::after { background-size: 100% 100%, auto; }
113
+ .steps li[data-state="done"] { color: var(--text); }
114
+ .steps li[data-state="done"]::before { background: var(--accent); }
115
+ .steps li[data-state="next"] { color: var(--text); }
116
+ .steps li[data-state="next"]::before { background: var(--muted); }
117
+ /* 走到哪一格:一颗同色的光标停在当前那一步上,换阶段时是「滑过去」而不是原地跳到下一格。
118
+ 呼吸只改光晕(不碰 transform,免得和滑动的 transition 打架),两端都回到原值、不过冲。 */
119
+ .step-cursor {
120
+ position: absolute; top: 4px; width: 8px; height: 8px; margin-left: -4px;
121
+ border-radius: 50%; background: var(--accent);
122
+ left: calc((var(--step-index, 0) + .5) * 25%); /* 4 步等宽,圆心落在每格中间 */
123
+ transition: left .38s cubic-bezier(.2, .8, .2, 1), opacity .22s ease;
124
+ animation: cursor-pulse 1.5s ease-in-out infinite;
125
+ pointer-events: none;
126
+ }
127
+ .steps[data-index="-1"] .step-cursor { opacity: 0; animation: none; }
128
+ @keyframes cursor-pulse {
129
+ 0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
130
+ 50% { box-shadow: 0 0 0 6px var(--accent-soft); }
131
+ }
132
+ .steps[hidden] { display: none; }
133
+ .actions { display: flex; gap: 9px; flex-wrap: wrap; }
134
+ .actions[hidden], .hint[hidden] { display: none; }
135
+ button {
136
+ border: 1px solid var(--line); border-radius: 9px; padding: 8px 16px;
137
+ background: transparent; color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer;
138
+ transition: background .15s ease, border-color .15s ease;
139
+ }
140
+ button:hover { background: var(--accent-soft); }
141
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--card); }
142
+ html[data-color-scheme="dark"] button.primary { color: #1b1c21; }
143
+ .error { display: grid; gap: 8px; }
144
+ .error[hidden] { display: none; }
145
+ .error-title { margin: 0; font-size: 14.5px; font-weight: 650; color: var(--danger); }
146
+ .error-text { margin: 0; font-size: 12.5px; color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 30vh; overflow: auto; }
147
+ /* 进场和出现都轻一点:卡片抬 10px 淡入,报错/按钮只淡入 */
148
+ @media (prefers-reduced-motion: no-preference) {
149
+ .card { animation: card-in .32s cubic-bezier(.2, .8, .2, 1) both; }
150
+ .error:not([hidden]), .actions:not([hidden]) { animation: fade-in .22s ease-out both; }
151
+ }
152
+ @keyframes card-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
153
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
154
+ @media (prefers-reduced-motion: reduce) {
155
+ .spinner { animation: none; }
156
+ .bar.indeterminate > span { animation: none; width: 100%; }
157
+ .step-cursor { animation: none; transition: none; }
158
+ .steps li + li::after { transition: none; }
159
+ }
160
+ </style>
161
+ </head>
162
+ <body>
163
+ <main class="card" role="status" aria-live="polite">
164
+ <div class="brand"><span class="brand-dot" aria-hidden="true"></span>DSH-X</div>
165
+ <div class="head">
166
+ <div class="spinner" id="spinner" aria-hidden="true"></div>
167
+ <h1 id="title"></h1>
168
+ </div>
169
+ <p class="meta" id="meta"></p>
170
+ <div class="bar indeterminate" id="bar" aria-hidden="true"><span id="barFill"></span></div>
171
+ <p class="phase" id="phase"></p>
172
+ <ol class="steps" id="steps">
173
+ <span class="step-cursor" aria-hidden="true"></span>
174
+ <li data-step="launching"></li>
175
+ <li data-step="loading"></li>
176
+ <li data-step="verifying"></li>
177
+ <li data-step="ready"></li>
178
+ </ol>
179
+ <p class="hint" id="hint"></p>
180
+ <div class="error" id="error" hidden>
181
+ <p class="error-title" id="errorTitle"></p>
182
+ <p class="error-text" id="errorText"></p>
183
+ </div>
184
+ <div class="actions" id="actions" hidden>
185
+ <button type="button" class="primary" id="manager"></button>
186
+ <button type="button" id="retry"></button>
187
+ </div>
188
+ </main>
189
+ <script>
190
+ (() => {
191
+ const LANG = document.documentElement.dataset.lang === 'en' ? 'en' : 'zh'
192
+ const STRINGS = {
193
+ zh: {
194
+ 'booting': '正在启动 {version}',
195
+ 'bootingAuto': '正在启动 dsh',
196
+ 'preparing': '准备启动…',
197
+ 'phase': '{profile} · {phase} · {elapsed}s',
198
+ 'phaseNoProfile': '{phase} · {elapsed}s',
199
+ 'elapsedOnly': '已用 {elapsed}s',
200
+ 'installing': '正在准备依赖',
201
+ 'stepLaunching': '启动进程',
202
+ 'stepLoading': '加载插件',
203
+ 'stepVerifying': '校验依赖',
204
+ 'stepReady': '打开页面',
205
+ 'phLaunching': '正在启动 dsh',
206
+ 'phLoading': '正在加载插件',
207
+ 'phVerifying': '正在校验依赖',
208
+ 'phDownload': '正在下载依赖 {done}/{total}',
209
+ 'phDownloadPct': '正在下载依赖 {pct}%',
210
+ 'phResolve': '正在解析依赖',
211
+ 'phOptimize': '正在优化依赖',
212
+ 'phUnpack': '正在解包 {done}/{total}',
213
+ 'hintOpen': '启动完成后这个页面会自动打开 dsh',
214
+ 'hintSlow': '首次启动要下载依赖,通常会久一点;也可以回管理页看实时日志',
215
+ 'hintOffline': '与管理页的连接断了一下,正在重试…',
216
+ 'opening': '启动完成,正在打开…',
217
+ 'failedTitle': '启动失败',
218
+ 'failedFallback': '实例没能起来,回到管理页可以看到完整诊断。',
219
+ 'toManager': '回到管理页',
220
+ 'retry': '重新打开这张页',
221
+ },
222
+ en: {
223
+ 'booting': 'Starting {version}',
224
+ 'bootingAuto': 'Starting dsh',
225
+ 'preparing': 'Preparing…',
226
+ 'phase': '{profile} · {phase} · {elapsed}s',
227
+ 'phaseNoProfile': '{phase} · {elapsed}s',
228
+ 'elapsedOnly': '{elapsed}s elapsed',
229
+ 'installing': 'Preparing dependencies',
230
+ 'stepLaunching': 'Process',
231
+ 'stepLoading': 'Plugins',
232
+ 'stepVerifying': 'Dependencies',
233
+ 'stepReady': 'Open page',
234
+ 'phLaunching': 'Starting dsh',
235
+ 'phLoading': 'Loading plugins',
236
+ 'phVerifying': 'Verifying dependencies',
237
+ 'phDownload': 'Downloading dependencies {done}/{total}',
238
+ 'phDownloadPct': 'Downloading dependencies {pct}%',
239
+ 'phResolve': 'Resolving dependencies',
240
+ 'phOptimize': 'Optimizing dependencies',
241
+ 'phUnpack': 'Unpacking {done}/{total}',
242
+ 'hintOpen': 'This page opens dsh automatically once it is ready',
243
+ 'hintSlow': 'The first start downloads dependencies and takes longer; the launcher page shows the live log',
244
+ 'hintOffline': 'Lost the launcher for a moment, retrying…',
245
+ 'opening': 'Ready — opening…',
246
+ 'failedTitle': 'Start failed',
247
+ 'failedFallback': 'The instance did not come up. The launcher page has the full diagnosis.',
248
+ 'toManager': 'Back to launcher',
249
+ 'retry': 'Reload this page',
250
+ },
251
+ }
252
+ const DICT = STRINGS[LANG]
253
+ const t = (key, vars) => String(DICT[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => (vars && name in vars ? String(vars[name]) : `{${name}}`))
254
+ document.title = t('bootingAuto')
255
+
256
+ const els = {
257
+ spinner: document.getElementById('spinner'),
258
+ title: document.getElementById('title'),
259
+ meta: document.getElementById('meta'),
260
+ bar: document.getElementById('bar'),
261
+ barFill: document.getElementById('barFill'),
262
+ phase: document.getElementById('phase'),
263
+ steps: document.getElementById('steps'),
264
+ hint: document.getElementById('hint'),
265
+ error: document.getElementById('error'),
266
+ errorTitle: document.getElementById('errorTitle'),
267
+ errorText: document.getElementById('errorText'),
268
+ actions: document.getElementById('actions'),
269
+ manager: document.getElementById('manager'),
270
+ retry: document.getElementById('retry'),
271
+ }
272
+ els.steps.querySelectorAll('li').forEach((item) => { item.textContent = t(`step${item.dataset.step[0].toUpperCase()}${item.dataset.step.slice(1)}`) })
273
+ els.manager.textContent = t('toManager')
274
+ els.retry.textContent = t('retry')
275
+ els.manager.onclick = () => { location.href = '/' }
276
+ els.retry.onclick = () => location.reload()
277
+
278
+ const params = new URLSearchParams(location.search)
279
+ const version = params.get('version') || ''
280
+ const profile = params.get('profile') || ''
281
+ const home = params.get('home') || ''
282
+ const samePath = (left, right) => String(left || '').toLowerCase() === String(right || '').toLowerCase()
283
+ // 页面自己记的起点:实例还没进列表(首次启动要先装依赖)时,秒表从这里走。
284
+ const pageStart = Date.now()
285
+ let snapshot = null
286
+ let opened = false
287
+ let failed = false
288
+ let offline = false
289
+
290
+ els.title.textContent = version ? t('booting', { version }) : t('bootingAuto')
291
+ els.meta.textContent = [profile, version].filter(Boolean).join(' · ')
292
+
293
+ const waitStartedAt = pageStart
294
+ const PHASE_TEXT = {
295
+ launching: 'phLaunching',
296
+ loading: 'phLoading',
297
+ verifying: 'phVerifying',
298
+ }
299
+ const STEPS = ['launching', 'loading', 'verifying', 'ready']
300
+
301
+ /** 按版本 + profile(+ 目录)找本次要等的那个实例;同一个版本可能还跑着别的 profile。 */
302
+ function findInstance() {
303
+ const rows = Array.isArray(snapshot?.instances) ? snapshot.instances : []
304
+ return rows.find((row) => row.version === version
305
+ && (!profile || row.profile === profile)
306
+ && (!home || samePath(row.home, home))) || null
307
+ }
308
+
309
+ /** 这次启动的失败记录:只认等待开始之后发生的,否则会把上一次的旧错误挂在这一次头上。 */
310
+ function findFailure() {
311
+ const failure = snapshot?.failure
312
+ if (!failure) return null
313
+ if (failure.version !== version) return null
314
+ if (profile && failure.profile !== profile) return null
315
+ if (home && !samePath(failure.home, home)) return null
316
+ return Number(failure.at) >= waitStartedAt - 2000 ? failure : null
317
+ }
318
+
319
+ function paintSteps(current) {
320
+ const index = STEPS.indexOf(current)
321
+ els.steps.hidden = false
322
+ // 光标滑到第几格(CSS 里 translateX 用这个数算位置;-1 = 还没到第一步,先藏起来)
323
+ els.steps.dataset.index = String(index)
324
+ els.steps.style.setProperty('--step-index', String(Math.max(0, index)))
325
+ for (const item of els.steps.querySelectorAll('li')) {
326
+ const at = STEPS.indexOf(item.dataset.step)
327
+ // 当前这一步已经点亮(它代表「正在做」),下一格只是提示「还差它」。
328
+ item.dataset.state = index < 0 ? '' : at <= index ? 'done' : at === index + 1 ? 'next' : ''
329
+ }
330
+ }
331
+
332
+ /** 阶段文案:换字的时候淡入一下,别硬切(同一句不重复播)。 */
333
+ function setPhase(text) {
334
+ if (els.phase.textContent === text) return
335
+ els.phase.textContent = text
336
+ if (document.documentElement.dataset.reduceMotion === 'true') return
337
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) return
338
+ els.phase.animate?.([{ opacity: 0, transform: 'translateY(2px)' }, { opacity: 1, transform: 'none' }], { duration: 180, easing: 'ease-out' })
339
+ }
340
+
341
+ function setBar(current) {
342
+ const total = Number(current?.total) || 0
343
+ const done = Number(current?.done) || 0
344
+ if (total > 0) {
345
+ els.bar.classList.remove('indeterminate')
346
+ els.barFill.style.width = `${Math.max(2, Math.min(100, Math.round((done / total) * 100)))}%`
347
+ return false
348
+ }
349
+ els.bar.classList.add('indeterminate')
350
+ els.barFill.style.width = ''
351
+ return true
352
+ }
353
+
354
+ function showFailure(failure) {
355
+ failed = true
356
+ els.spinner.hidden = true
357
+ els.bar.hidden = true
358
+ els.steps.hidden = true
359
+ els.title.textContent = t('failedTitle')
360
+ els.phase.textContent = ''
361
+ els.error.hidden = false
362
+ els.errorTitle.textContent = t('failedTitle')
363
+ els.errorText.textContent = failure?.message || t('failedFallback')
364
+ els.hint.hidden = true
365
+ els.actions.hidden = false
366
+ els.manager.classList.add('primary')
367
+ document.title = t('failedTitle')
368
+ }
369
+
370
+ function openDsh(url) {
371
+ if (opened || !url) return
372
+ opened = true
373
+ els.title.textContent = t('opening')
374
+ els.phase.textContent = ''
375
+ els.spinner.hidden = true
376
+ els.bar.classList.remove('indeterminate')
377
+ els.barFill.style.width = '100%'
378
+ paintSteps('ready')
379
+ // replace 而不是 assign:dsh 起来后按后退不该回到这张过渡页。
380
+ location.replace(url)
381
+ }
382
+
383
+ function render() {
384
+ if (failed || opened) return
385
+ const live = findInstance()
386
+ if (live?.status === 'running' && live.url) {
387
+ openDsh(live.url)
388
+ return
389
+ }
390
+ if (live?.status === 'starting') {
391
+ const phase = live.startupPhase || 'launching'
392
+ setBar(null)
393
+ paintSteps(phase)
394
+ els.hint.hidden = false
395
+ els.hint.textContent = t('hintOpen')
396
+ setPhase(`${t(PHASE_TEXT[phase] || 'phLaunching')}`)
397
+ els.meta.textContent = [profile, elapsedText(live.startedAt)].filter(Boolean).join(' · ')
398
+ return
399
+ }
400
+ // 还没进实例列表:首次启动要先把版本/依赖装好,这一段用安装进度说话。
401
+ const progress = snapshot?.progress
402
+ if (progress) {
403
+ const determinate = !setBar(progress)
404
+ els.steps.hidden = true
405
+ els.title.textContent = t('installing')
406
+ let text = t('phResolve')
407
+ if (progress.phase === 'download') {
408
+ text = determinate && Number(progress.total) > 0
409
+ ? t('phDownload', { done: progress.done || 0, total: progress.total })
410
+ : t('phDownloadPct', { pct: Number(progress.done) || 0 })
411
+ } else if (progress.phase === 'optimize') text = t('phOptimize')
412
+ else if (progress.phase === 'unpack') text = t('phUnpack', { done: progress.done || 0, total: progress.total || 0 })
413
+ setPhase(text)
414
+ els.meta.textContent = [profile, version].filter(Boolean).join(' · ')
415
+ els.hint.hidden = false
416
+ els.hint.textContent = t('hintSlow')
417
+ return
418
+ }
419
+ // 页面比启动请求先打开:这段时间什么都不用报,只说在准备。
420
+ setBar(null)
421
+ paintSteps('launching')
422
+ setPhase(t('preparing'))
423
+ els.hint.hidden = false
424
+ els.hint.textContent = t('hintOpen')
425
+ const failure = findFailure()
426
+ if (failure) showFailure(failure)
427
+ }
428
+
429
+ function elapsedText(startedAt) {
430
+ const started = Number(startedAt) || pageStart
431
+ const seconds = Math.max(0, Math.round((Date.now() - started) / 1000))
432
+ return t('elapsedOnly', { elapsed: seconds })
433
+ }
434
+
435
+ // 秒表自己走:状态只在事件点推送,等状态推过来再画秒数会一格一格地跳。
436
+ setInterval(() => {
437
+ if (failed || opened) return
438
+ const live = findInstance()
439
+ const row = live?.status === 'starting' ? live : null
440
+ const meta = row ? [profile, elapsedText(row.startedAt)].filter(Boolean).join(' · ')
441
+ : [profile, elapsedText(pageStart)].filter(Boolean).join(' · ')
442
+ if (els.meta.textContent !== meta) els.meta.textContent = meta
443
+ if (!row && !snapshot?.progress) setPhase(t('preparing'))
444
+ }, 250)
445
+
446
+ async function poll() {
447
+ if (failed || opened) return
448
+ try {
449
+ const response = await fetch('/api/state', { cache: 'no-store' })
450
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
451
+ snapshot = await response.json()
452
+ if (offline) {
453
+ offline = false
454
+ els.hint.textContent = t('hintOpen')
455
+ }
456
+ render()
457
+ } catch {
458
+ if (!offline) {
459
+ offline = true
460
+ els.hint.hidden = false
461
+ els.hint.textContent = t('hintOffline')
462
+ }
463
+ }
464
+ }
465
+
466
+ // 800ms 一次:服务端的启动阶段是在日志行上推断的,不会每次都推状态,
467
+ // 轮询比 SSE 更稳地跟住「启动进程 → 加载插件 → 校验依赖」这三级。
468
+ const timer = setInterval(poll, 800)
469
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) void poll() })
470
+ window.addEventListener('pagehide', () => clearInterval(timer), { once: true })
471
+ void poll()
472
+ })()
473
+ </script>
474
+ </body>
475
+ </html>
Binary file
Binary file
package/public/icon.png CHANGED
Binary file