dsh-autorestart 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- package/scripts/watchdog.mjs +432 -79
package/client.js
CHANGED
|
@@ -192,8 +192,8 @@
|
|
|
192
192
|
panelParkedGoal: '会话 {session} · {goal}',
|
|
193
193
|
panelWaiting: '已等待 {duration}',
|
|
194
194
|
panelResume: '重启后本会话会自动继续进度',
|
|
195
|
-
// The long wait: the restart
|
|
196
|
-
//
|
|
195
|
+
// The long wait: the restart is still waiting while a conversation runs on.
|
|
196
|
+
// There is no deadline and nothing expires. The capsule is ONE clipped line: it holds a
|
|
197
197
|
// number and a duration, NEVER a name. Every name goes into the tooltip,
|
|
198
198
|
// where there is room for all of them.
|
|
199
199
|
longWaitPill: ' · 已等 {duration} · {n} 项任务在跑',
|
|
@@ -209,6 +209,14 @@
|
|
|
209
209
|
blockerMore: '…等 {n} 项',
|
|
210
210
|
blockersTitleLine: '占用会话:{blockers}',
|
|
211
211
|
durationSeconds: '{n} 秒',
|
|
212
|
+
// A CANCELLED QUEUED RESTART, IN PLAIN WORDS (user report, 2026-11-03): the request
|
|
213
|
+
// waited over two hours, then a host restart cancelled it and the page said nothing.
|
|
214
|
+
// No internal identifier may reach the screen.
|
|
215
|
+
'settings.restart.cancelledNote': '上次要的重启没有做成:你在 {at} 请求重启,等了 {waited},它一直没有执行;这次宿主重启之后,那个请求已经被取消了。想重启的话,再点一次就行。',
|
|
216
|
+
'restart.waitedSeconds': '几秒',
|
|
217
|
+
'restart.waitedMinutes': '约 {n} 分钟',
|
|
218
|
+
'restart.waitedHours': '约 {n} 小时 {m} 分',
|
|
219
|
+
'restart.waitedHoursExact': '约 {n} 小时',
|
|
212
220
|
durationMinutes: '{n} 分 {m} 秒',
|
|
213
221
|
durationHours: '{n} 小时 {m} 分',
|
|
214
222
|
// The COMPACT duration of the capsule: it can afford "16 分钟", not
|
|
@@ -291,9 +299,13 @@
|
|
|
291
299
|
'settings.restartGrace.value': '{n} 秒',
|
|
292
300
|
'settings.restartGrace.immediate': '不等(立即重启)',
|
|
293
301
|
'settings.backup.label': '重启前备份',
|
|
294
|
-
'settings.backup.
|
|
302
|
+
'settings.backup.details': '详情',
|
|
303
|
+
'settings.backup.section': '备份说明及手动备份操作',
|
|
304
|
+
'settings.backup.hint': '重启前自动存一份,重启后起不来时能退回去。存的是:你的设置(一定);你自己写的、直接链接到源码目录的插件(连文件一起拷);从网上下载装的插件(只记下装过哪些、什么版本,不拷文件,真出事时按清单重装)。',
|
|
295
305
|
'settings.backupPlugins.label': '备份包含已安装插件',
|
|
296
306
|
'settings.backupPlugins.hint': '关闭后只备份 DSH 自身状态,不含 profile 里的插件目录。',
|
|
307
|
+
'settings.restartBanner.title': '这台机器现在还没有重启能力:本插件不会重启,也不会在重启前备份。点一下「帮我配好」,我自动推导并写入;配错了可以撤销。',
|
|
308
|
+
'settings.restartBanner.fix': '帮我配好',
|
|
297
309
|
'settings.groups.supervisor': '开机自启',
|
|
298
310
|
'settings.supervisor.status': '当前由',
|
|
299
311
|
'settings.supervisor.installed': '插件守护',
|
|
@@ -353,6 +365,7 @@
|
|
|
353
365
|
'settings.autostartKind.systemd-system': 'systemd 系统服务',
|
|
354
366
|
'settings.autostartKind.wsl-systemd': 'WSL 里的 systemd',
|
|
355
367
|
'settings.autostartKind.wsl-no-systemd': 'WSL(没有 systemd)',
|
|
368
|
+
'settings.autostartKind.linux-no-systemd': 'Linux(没有 systemd)',
|
|
356
369
|
'settings.autostartKind.container': '容器',
|
|
357
370
|
'settings.autostartKind.desktop': '桌面版自带重启',
|
|
358
371
|
'settings.autostartKind.unknown': '认不出',
|
|
@@ -368,19 +381,102 @@
|
|
|
368
381
|
'meta.description': '让 DeepSeek Harness 重启后接着干活:落盘的检查点、启动时重新武装 goal 或给一条续接提示,以及一次等人闲下来的优雅重启。',
|
|
369
382
|
'text.missing': '(这条文案还没写)',
|
|
370
383
|
'settings.backup.list': '现在备份了什么',
|
|
371
|
-
|
|
384
|
+
// TWO LINES, NOT FIVE (user report, 2026-11-02). One line says how many
|
|
385
|
+
// copies there are and how much disk they take together; the next one says
|
|
386
|
+
// the rule that keeps their number bounded. Everything else about the folder
|
|
387
|
+
// was a restatement of one of those two — or a number written into a
|
|
388
|
+
// sentence, where it went stale the moment the dropdown changed it.
|
|
389
|
+
'settings.backup.count': '当前 {n} 份,实际占用 {size}。',
|
|
390
|
+
// NO "内含:…" LINE (user decision, 2026-11-02): its only job was to point at
|
|
391
|
+
// the per-plugin recovery while the fold was closed, and that entry is gone —
|
|
392
|
+
// a note advertising a deleted feature is worse than no note.
|
|
393
|
+
// MORE THAN ONE PROFILE (2026-11-02): the host cannot tell which profile to
|
|
394
|
+
// back up, so backups and rollbacks stop. Saying nothing would look like
|
|
395
|
+
// "everything is fine".
|
|
396
|
+
'settings.backup.multiProfile': '检测到多个 profile,备份和回滚已暂停:它不知道该备份哪一个。解决办法:启动时指定 DSH_PROFILE,或者只保留一个 profile。',
|
|
372
397
|
'settings.backup.keep': '最多保留 {n} 份,超出自动删最旧的那份。',
|
|
398
|
+
// THE SECOND SENTENCE THE USER SAW TWICE IS GONE (user report, 2026-11-02):
|
|
399
|
+
// "最多保留 2 份" said the same thing as the line above, and the long
|
|
400
|
+
// paragraph about what the folder is for repeated the number a third time.
|
|
401
|
+
// Two lines say everything the page can promise; the dropdown below changes
|
|
402
|
+
// the number, so no sentence carries a frozen copy of it.
|
|
403
|
+
'settings.backup.kindsLocal': '链接安装(自己写的)和本地化过的插件(目录里有 .git)都已完整备份,可以回档。',
|
|
404
|
+
'settings.backup.kindsThirdParty': '第三方插件由插件作者维护,有异常建议重装',
|
|
405
|
+
'settings.backup.limitLabel': '保留份数',
|
|
406
|
+
// THE DISK WARNING SITS NEXT TO THE DROPDOWN (user report, 2026-11-02): it
|
|
407
|
+
// used to be a line BELOW the control, so the warning arrived after the
|
|
408
|
+
// choice was made. Same place in the tree, right of the select.
|
|
409
|
+
'settings.backup.limitHint': '备份会占用磁盘空间,建议尽量选更少的备份数。',
|
|
410
|
+
'settings.backup.limitAsk': '将删除 {remove} 份最旧的备份,删除后无法恢复。当前 {from} 份 → 保留 {to} 份。',
|
|
411
|
+
'settings.backup.limitConfirm': '确认删除并改为保留 {to} 份',
|
|
412
|
+
'settings.backup.limitCancel': '取消',
|
|
413
|
+
'settings.backup.limitDeleted': '已删除 {removed} 份,现在保留 {kept} 份。',
|
|
414
|
+
'settings.backup.limitMore': '已选 {requested} 份,实际保留 {kept} 份。',
|
|
415
|
+
'settings.backup.limitFailed': '没能改成功,份数保持原样。',
|
|
373
416
|
'settings.backup.empty': '还没有备份;每次重启前会自动生成一份。',
|
|
374
417
|
'settings.backup.path': '存放路径',
|
|
375
418
|
'settings.backup.copyPath': '复制路径',
|
|
376
419
|
'settings.backup.copied': '路径已复制',
|
|
377
420
|
'settings.backup.copyFailed': '复制失败,请手动选中上面的路径',
|
|
378
421
|
'settings.backup.time': '备份时间',
|
|
379
|
-
|
|
422
|
+
// THE SIZE OF ONE COPY IS NOT SHOWN, so its label is gone as well (user
|
|
423
|
+
// decision, 2026-11-02): a row is a time and a button, and the disk a copy
|
|
424
|
+
// needs is only stated once for the whole folder (`settings.backup.count`).
|
|
425
|
+
// A key nothing renders is a promise the page does not keep — the same reason
|
|
426
|
+
// the identity keys went.
|
|
380
427
|
'settings.backup.withPlugins': '(含已装插件)',
|
|
381
428
|
'settings.backup.withoutPlugins': '(不含已装插件)',
|
|
382
|
-
|
|
383
|
-
|
|
429
|
+
// THE ONE LINE A COPY GETS WHEN IT IS NOT THE ONE (user decision,
|
|
430
|
+
// 2026-11-02): a copy automatic recovery would never use is still listed,
|
|
431
|
+
// but as a sentence with its time and its size — never as a card with a
|
|
432
|
+
// button next to it. Two wordings, because a seed and a restart snapshot are
|
|
433
|
+
// two different things to promise about.
|
|
434
|
+
'settings.backup.fallback.seed': '另有一份首装备份(只含设置、未验证,仅在前一份不可用时才用),{time},{size}',
|
|
435
|
+
'settings.backup.fallback.other': '另有一份未验证的备份(不会被自动还原,仅在前一份不可用时才用),{time},{size}',
|
|
436
|
+
// THE FOLD (user report, 2026-11-02): fifteen copies used to be fifteen
|
|
437
|
+
// sentences. The first two stay; the rest are behind this one line, and the
|
|
438
|
+
// line says how many and what they are worth.
|
|
439
|
+
'settings.backup.othersSummary': '另有 {n} 份重启前自动拍的备份(未验证,不会自动还原)',
|
|
440
|
+
// THE CERTIFICATE VOCABULARY IS GONE (user decision, 2026-11-02). Two rounds
|
|
441
|
+
// of deletion ended here: first the rendering stopped grading copies, then the
|
|
442
|
+
// labels and now the evidence sentences themselves. Nothing draws them any
|
|
443
|
+
// more — a copy is a time and a button — and a dictionary that still carried
|
|
444
|
+
// them could be picked up again by a later change without anyone noticing that
|
|
445
|
+
// it re-introduces a verdict the plugin cannot support.
|
|
446
|
+
'settings.backup.scopeLabel': '内容范围',
|
|
447
|
+
'settings.backup.scope.config-only': '只含配置',
|
|
448
|
+
'settings.backup.scope.config+plugins': '配置 + 已装插件',
|
|
449
|
+
'settings.backup.scope.config+links': '配置 + 链接插件',
|
|
450
|
+
'settings.backup.scope.slim': '精简份(配置 + 手装插件)',
|
|
451
|
+
'settings.backup.scopeMissing': '(没有记录)',
|
|
452
|
+
'settings.backup.interventionLabel': '这次启动有没有被系统介入',
|
|
453
|
+
'settings.backup.intervention.true': '有(自动救援动过手)',
|
|
454
|
+
'settings.backup.intervention.false': '没有',
|
|
455
|
+
'settings.backup.intervention.unknown': '没有记录',
|
|
456
|
+
// THE MANUAL ROLLBACK (user decision, 2026-10-30): the user may know better
|
|
457
|
+
// than "the newest proven copy", so ANY copy can be restored — but only
|
|
458
|
+
// after a card that says which one, what it replaces, and that a restart
|
|
459
|
+
// follows.
|
|
460
|
+
'settings.backup.rollback': '回退到这一份',
|
|
461
|
+
// THE ONE SENTENCE THE PAGE MAY SAY ABOUT A COPY (user decision, 2026-11-02).
|
|
462
|
+
//
|
|
463
|
+
// It hangs on exactly one copy — the one taken at the most recent startup —
|
|
464
|
+
// and it says only WHAT THAT COPY IS: the state the host last started from.
|
|
465
|
+
// It does not say the copy is good, because the plugin cannot know that: a
|
|
466
|
+
// plugin that fails to load is dropped and the host comes up anyway. Which
|
|
467
|
+
// copy to roll back to is the reader's call, and the sentence ends by saying
|
|
468
|
+
// so. Verbatim — do not reword.
|
|
469
|
+
'settings.backup.startupHint': '这是最近一次启动时的状态。DSH 能启动不代表插件都正常——坏掉的插件会被排除,主机照样起来。要回退到哪一份,请自己判断。',
|
|
470
|
+
'settings.backup.rollbackConfirmTitle': '确定回退到这一份?',
|
|
471
|
+
'settings.backup.rollbackConfirmBody': '会把 {time} 的那份备份还原到现在——**当前状态会被它覆盖**(覆盖前会先自动存一份当前快照,所以这次回退还能再退回来)。',
|
|
472
|
+
'settings.backup.rollbackConfirmRestart': '还原完成后会自动重启一次:回退前已自动存了一份可撤回的现场;回退后的这次重启不会再额外备份。重启期间这个页面会断开,回来后接着用。',
|
|
473
|
+
'settings.backup.rollbackConfirmYes': '确认回退',
|
|
474
|
+
'settings.backup.rollbackConfirmNo': '取消',
|
|
475
|
+
'settings.backup.rollbackBusy': '正在回退…',
|
|
476
|
+
'settings.backup.rollbackDone': '已回退,正在重启…',
|
|
477
|
+
'settings.backup.rollbackFailed': '回退没成功:{message}',
|
|
478
|
+
'settings.groups.report': '遇到问题',
|
|
479
|
+
'settings.groups.rescue': '桌面救援入口',
|
|
384
480
|
'settings.rescue.hint': 'DSH 打不开的时候,用下面这两个脚本救自己(都不用 DSH 跑起来)。',
|
|
385
481
|
'settings.rescue.restoreProfile': '回到上一次好状态',
|
|
386
482
|
'settings.rescue.emergencyDisable': '只把这个插件摘掉',
|
|
@@ -390,12 +486,14 @@
|
|
|
390
486
|
'settings.rescue.copyFailed': '复制失败,请手动选中上面的路径',
|
|
391
487
|
'settings.rescue.reveal': '打开所在文件夹',
|
|
392
488
|
'settings.rescue.which': '怎么选:整个环境坏了(改了配置、装了别的插件、升级崩了)→ 用「回到上一次好状态」;只怀疑这个插件 → 用「只把这个插件摘掉」。',
|
|
393
|
-
'settings.rescue.warning': '
|
|
489
|
+
'settings.rescue.warning': '重要:这个桌面入口和上面两个脚本,都只在你打不开 DSH 的时候用;DSH 正常开着的时候不要点它——用「回到上一次好状态」会把你现在正在用的配置覆盖掉。',
|
|
394
490
|
'settings.rescue.rollbackOk': '自动回滚:可用(重启后起不来会自动回到上一个好状态)',
|
|
395
491
|
'settings.rescue.rollbackBlocked': '自动回滚:**不可用** —— 这台电脑不允许创建计划任务。**会丢失:重启后 DSH 起不来时不会自动恢复**;出问题时请双击「出事了怎么办」里的还原脚本。',
|
|
396
492
|
'settings.rescue.rollbackUnknown': '自动回滚:还不知道(这台机器的能力还没检查过)',
|
|
397
493
|
'settings.rescue.watchdogTier': '看门狗的触发方式:{tier}',
|
|
398
494
|
'settings.rescue.watchdogLast': '上一次看门狗的结果:{result}',
|
|
495
|
+
'settings.rescue.watchdogStrict': '上一次自动救援没有动手:找不到同时满足两个条件的备份——那份备份有过启动记录,而且那次启动没有系统介入过。请手动处理。',
|
|
496
|
+
'settings.rescue.watchdogRestoredFrom': "上一次自动救援还原的这一份:{dir}",
|
|
399
497
|
'settings.rescue.tierScheduledTask': 'Windows 计划任务',
|
|
400
498
|
'settings.rescue.tierSystemdRun': 'systemd 定时器',
|
|
401
499
|
'settings.rescue.tierDetachedProcess': '自建后台进程',
|
|
@@ -403,6 +501,16 @@
|
|
|
403
501
|
'settings.rescue.shortcut': '在桌面放一个快捷方式',
|
|
404
502
|
'settings.rescue.shortcutDone': '已在桌面',
|
|
405
503
|
'settings.rescue.noDesktop': '这台机器上没有桌面目录,所以放不了快捷方式(脚本的路径在上面,可以手动复制过去)。',
|
|
504
|
+
'settings.rescue.noDesktopPlatform': '网页版 / 服务器版没有桌面,所以没有这个一键入口;请用上面两个 .sh 脚本。',
|
|
505
|
+
// PLAN B (user decision, 2026-11-02): a host without a desktop shows the
|
|
506
|
+
// SAME rescue entry as a copyable command, not a dead-end explanation.
|
|
507
|
+
'settings.rescue.commandLabel': '救援入口(命令)',
|
|
508
|
+
'settings.rescue.copyCommand': '复制命令',
|
|
509
|
+
'settings.rescue.commandHint': '复制这条命令执行,效果和桌面上双击那个入口一样。',
|
|
510
|
+
'settings.rescue.commandUnavailable': '这台机器上没有找到可用的救援命令。',
|
|
511
|
+
'settings.rescue.shortcutScope': '它只能把整个环境退回上一次好状态,不能只摘掉某一个插件。',
|
|
512
|
+
'settings.rescue.shortcutBlocked': '系统策略不允许创建快捷方式(比如组策略或权限限制)。把下面的脚本路径复制出来,手动放到桌面:',
|
|
513
|
+
'settings.rescue.shortcutWriteFailed': '快捷方式没写成功:{message}。把下面的脚本路径复制出来,手动放到桌面:',
|
|
406
514
|
'settings.rescue.shortcutFailed': '快捷方式没放成:{message}',
|
|
407
515
|
'settings.rescue.revealFailed': '文件夹没打开:{message}',
|
|
408
516
|
'settings.report.label': '遇到问题了?',
|
|
@@ -478,6 +586,50 @@
|
|
|
478
586
|
'announcement.disabled': '按钮目前关闭,可在设置里打开。',
|
|
479
587
|
'announcement.button': '知道了',
|
|
480
588
|
'announcement.error': '没能记下来:{message}',
|
|
589
|
+
// THE PLUGIN THAT DID NOT LOAD (用户拍板 2026-11-01):宿主照常起来,只是某个
|
|
590
|
+
// 插件这次没激活 —— 把这件事搬到前台,并且只给一个动作:只恢复这一个。
|
|
591
|
+
'pluginFailure.title': "有两件事要你看一下",
|
|
592
|
+
'watchdog.hint': "后面想改主意,可以到「插件」页里找 dsh-autorestart 的设置。",
|
|
593
|
+
'pluginFailure.hint': "这些功能已经记下来了,之后可以在「插件」页里再恢复。",
|
|
594
|
+
'settings.description': "自动重启 DSH、守护插件,出问题能一键恢复。",
|
|
595
|
+
'settings.restore': "恢复",
|
|
596
|
+
// THE TWO SENTENCES THAT REPLACED THE PER-PLUGIN THIRD-PARTY LINE (user
|
|
597
|
+
// correction, 2026-11-02). The old copy said "the backup holds only its name
|
|
598
|
+
// and version", which reads as "there is something in there" — and there is
|
|
599
|
+
// nothing: third-party plugins are NOT backed up. Built-in ones are shipped
|
|
600
|
+
// with DSH, so they need no backup and must not be reinstalled.
|
|
601
|
+
'settings.thirdParty': "这个插件不是你自己开发的,没法从备份恢复,需要重新安装。",
|
|
602
|
+
'settings.version': "版本 {version}",
|
|
603
|
+
'pluginFailure.dismiss': "知道了",
|
|
604
|
+
'pluginFailure.dismissLabel': "关掉这个提示(这次启动不再显示,下次启动会重新出现)",
|
|
605
|
+
'pluginFailure.certainTitle': "这些功能这次没起来",
|
|
606
|
+
'pluginFailure.certainIntro': "上次启动时这些功能还在,这次没有。这是已经发生的事,不是猜测。",
|
|
607
|
+
'pluginFailure.clueTitle': "这些插件跟备份不一样",
|
|
608
|
+
'pluginFailure.clueIntro': "这些插件的文件和最近一次备份不同。可能只是升级过,也可能有别的原因,所以这里只列出差异,不下结论。",
|
|
609
|
+
'pluginFailure.uiBlindSpot': "另外有 {count} 个界面模块这次没出现(它们没有对应到你安装的插件,所以不在这里列出)。",
|
|
610
|
+
'pluginFailure.restoreAll': "全部恢复({count} 个)",
|
|
611
|
+
'pluginFailure.restoreAllDone': "恢复完成:{done} 个成功,{failed} 个没成功。",
|
|
612
|
+
'pluginFailure.close': "关掉这个提示",
|
|
613
|
+
'pluginFailure.linkedHint': "这个插件是你自己写在本地、以链接方式安装的,可以直接恢复到备份里的版本。",
|
|
614
|
+
'pluginFailure.thirdPartyHint': "这个插件是第三方安装的,备份里没有它的文件。要还原就用下面这条命令,它会装回原来的那个版本(不是最新版):",
|
|
615
|
+
'pluginFailure.commandLabel': "复制这条命令",
|
|
616
|
+
'pluginFailure.noVersion': "备份里查不到它的精确版本,只能按插件名重新安装。",
|
|
617
|
+
'pluginFailure.restore': "只恢复 {plugin}",
|
|
618
|
+
'pluginFailure.restoring': "正在恢复 {plugin}…",
|
|
619
|
+
'pluginFailure.restored': "已经把 {plugin} 恢复到备份版本(备份时间 {at})。",
|
|
620
|
+
'pluginFailure.failed': "{plugin} 恢复没有完成:{reason}",
|
|
621
|
+
'pluginFailure.noBackup': "这台机器没有可用备份,只能手动处理。",
|
|
622
|
+
'watchdog.title': "重启失败时,会自动帮你救回来",
|
|
623
|
+
'watchdog.explain': "这个插件自带自动救援:重启之后新版本一直起不来时,它会拿一份它自己认为可用的备份帮你恢复(判据是内容指纹一致、且那次启动起来了——这不代表里面的插件都正常),再把程序重新拉起来。它默认就是开着的。",
|
|
624
|
+
'watchdog.whereOff': "不想要的话,到设置页把「自动救援」的开关关掉就行——关掉之后,新版本起不来就只能手动处理了。",
|
|
625
|
+
'watchdog.gotIt': "知道了",
|
|
626
|
+
'watchdog.noticeSaved': "好的,就这一条提示,以后不会再弹了。",
|
|
627
|
+
'watchdog.warnOff': "关掉之后,万一新版本起不来,就只能手动处理了。",
|
|
628
|
+
'watchdog.keepOn': "开着(推荐)",
|
|
629
|
+
'watchdog.turnOff': "不要,我自己处理",
|
|
630
|
+
'watchdog.savedOn': "已开启:下次重启如果起不来,会自动帮你救回来。",
|
|
631
|
+
'watchdog.savedOff': "已关闭:下次重启如果起不来,不会自动救。",
|
|
632
|
+
'watchdog.saveFailed': "没能保存你的选择,设置没有变。请再点一次,或到设置页里改。",
|
|
481
633
|
// THE COPY OF THE COMPOSER ROW. It is one SHORT line and never a sentence:
|
|
482
634
|
// the long stage sentence was clipped away there (user report,
|
|
483
635
|
// 2026-10-29). While the gate waits the pill keeps its own composed line
|
|
@@ -497,6 +649,12 @@
|
|
|
497
649
|
'restart.overlay.size': '{done} / {total}',
|
|
498
650
|
'restart.overlay.sizeDone': '{done}',
|
|
499
651
|
'restart.overlay.restarting': '正在重启…',
|
|
652
|
+
// THE HONEST CARD (user report, 2026-10-30): a restart that was interrupted
|
|
653
|
+
// by a new conversation said "正在重启…" for 25 s, which was not true. It now
|
|
654
|
+
// says what actually happened — and it may stay, because it IS the truth.
|
|
655
|
+
'restart.overlay.deferred': '有新会话在跑,重启已暂缓',
|
|
656
|
+
'restart.overlay.deferredNote': '等它结束会自动继续 · 已等 {count} 个会话',
|
|
657
|
+
'restart.overlay.deferredNoteZero': '等它结束会自动继续',
|
|
500
658
|
'restart.overlay.back': '已回来',
|
|
501
659
|
}
|
|
502
660
|
|
|
@@ -542,8 +700,8 @@
|
|
|
542
700
|
panelParkedGoal: 'session {session} · {goal}',
|
|
543
701
|
panelWaiting: 'Waiting for {duration}',
|
|
544
702
|
panelResume: 'This session resumes its progress automatically after the restart.',
|
|
545
|
-
// The long wait: the restart
|
|
546
|
-
//
|
|
703
|
+
// The long wait: the restart is still waiting while a conversation runs on.
|
|
704
|
+
// There is no deadline and nothing expires. The capsule is ONE clipped line: it holds a
|
|
547
705
|
// number and a duration, NEVER a name. Every name goes into the tooltip,
|
|
548
706
|
// where there is room for all of them.
|
|
549
707
|
longWaitPill: ' · waiting for {duration} · {n} task(s) running',
|
|
@@ -558,6 +716,11 @@
|
|
|
558
716
|
blockerMore: '… and {n} more',
|
|
559
717
|
blockersTitleLine: 'Blocking sessions: {blockers}',
|
|
560
718
|
durationSeconds: '{n}s',
|
|
719
|
+
'settings.restart.cancelledNote': 'The restart you asked for did not happen: you requested it at {at}, waited {waited}, and it was never carried out; after the host restarted, that request was cancelled. If you still want it, just ask once more.',
|
|
720
|
+
'restart.waitedSeconds': 'a few seconds',
|
|
721
|
+
'restart.waitedMinutes': 'about {n} min',
|
|
722
|
+
'restart.waitedHours': 'about {n}h {m}m',
|
|
723
|
+
'restart.waitedHoursExact': 'about {n}h',
|
|
561
724
|
durationMinutes: '{n}m {m}s',
|
|
562
725
|
durationHours: '{n}h {m}m',
|
|
563
726
|
// The COMPACT duration of the capsule: it can afford "16 min", not
|
|
@@ -640,11 +803,14 @@
|
|
|
640
803
|
'settings.restartGrace.value': '{n} seconds',
|
|
641
804
|
'settings.restartGrace.immediate': 'Do not wait (restart right away)',
|
|
642
805
|
'settings.backup.label': 'Backup before a restart',
|
|
643
|
-
'settings.backup.
|
|
806
|
+
'settings.backup.details': 'Details',
|
|
807
|
+
'settings.backup.section': 'What is backed up, and the manual backup steps',
|
|
808
|
+
'settings.backup.hint': 'A copy is taken before every restart, so a host that cannot boot again is rolled back. What it holds: your settings (always); plugins you wrote and linked from a source directory (copied file by file); plugins downloaded from the internet (only which ones and which version are recorded — never copied; they are reinstalled from that list).',
|
|
644
809
|
'settings.backupPlugins.label': 'Include the installed plugins in the backup',
|
|
645
810
|
'settings.backupPlugins.hint': 'Off: only the state of DSH itself is backed up — the plugin directory of the profile is left out.',
|
|
646
|
-
'settings.
|
|
647
|
-
'settings.
|
|
811
|
+
'settings.restartBanner.title': 'This machine has no restart capability right now: this plugin will not restart, and will not take a backup before a restart. Press "Set it up for me" and I derive and write it — and that can be undone.',
|
|
812
|
+
'settings.restartBanner.fix': 'Set it up for me',
|
|
813
|
+
'settings.groups.supervisor': 'Start at login', 'settings.supervisor.status': 'Started by',
|
|
648
814
|
'settings.supervisor.installed': 'the supervisor of this plugin',
|
|
649
815
|
'settings.supervisor.external': 'your own way',
|
|
650
816
|
'settings.supervisor.unknown': 'nobody knows',
|
|
@@ -702,6 +868,7 @@
|
|
|
702
868
|
'settings.autostartKind.systemd-system': 'systemd system service',
|
|
703
869
|
'settings.autostartKind.wsl-systemd': 'systemd inside WSL',
|
|
704
870
|
'settings.autostartKind.wsl-no-systemd': 'WSL without systemd',
|
|
871
|
+
'settings.autostartKind.linux-no-systemd': 'Linux without systemd',
|
|
705
872
|
'settings.autostartKind.container': 'a container',
|
|
706
873
|
'settings.autostartKind.desktop': 'the desktop app itself',
|
|
707
874
|
'settings.autostartKind.unknown': 'cannot tell',
|
|
@@ -717,19 +884,77 @@
|
|
|
717
884
|
'meta.description': 'Keeps agent work alive across a DeepSeek Harness restart: a durable checkpoint, goal re-arm or a continuation message on boot, and an idle-aware graceful restart the agent can request itself.',
|
|
718
885
|
'text.missing': '(this text is missing yet)',
|
|
719
886
|
'settings.backup.list': 'What is backed up right now',
|
|
720
|
-
'settings.backup.count': '{n} copies right now',
|
|
887
|
+
'settings.backup.count': '{n} copies right now, using {size}.',
|
|
888
|
+
'settings.backup.multiProfile': 'More than one profile was found, so backups and rollbacks are paused: it cannot tell which one to back up. Fix it by starting with DSH_PROFILE set, or by keeping a single profile.',
|
|
721
889
|
'settings.backup.keep': 'At most {n} copies are kept; the oldest one is deleted when there are more.',
|
|
890
|
+
'settings.backup.kindsLocal': 'Both linked plugins (written by hand) and localized ones (their directory has a .git) are in the copy in full, so they can be rolled back.',
|
|
891
|
+
'settings.backup.kindsThirdParty': 'Third-party plugins are maintained by their authors; if something is wrong, reinstall them.',
|
|
892
|
+
'settings.backup.limitLabel': 'Copies kept',
|
|
893
|
+
'settings.backup.limitHint': 'Backups take disk space, so it is best to keep as few copies as possible.',
|
|
894
|
+
'settings.backup.limitAsk': 'This deletes the {remove} oldest copies and cannot be undone. {from} copies now → {to} kept.',
|
|
895
|
+
'settings.backup.limitConfirm': 'Delete and keep {to}',
|
|
896
|
+
'settings.backup.limitCancel': 'Cancel',
|
|
897
|
+
'settings.backup.limitDeleted': 'Deleted {removed} copies; {kept} are kept now.',
|
|
898
|
+
'settings.backup.limitMore': 'You picked {requested}; {kept} are kept.',
|
|
899
|
+
'settings.backup.limitFailed': 'The change did not go through; the copy limit is unchanged.',
|
|
722
900
|
'settings.backup.empty': 'No backup yet; one is written before every restart.',
|
|
723
901
|
'settings.backup.path': 'Where they live',
|
|
724
902
|
'settings.backup.copyPath': 'Copy the path',
|
|
725
903
|
'settings.backup.copied': 'The path is on the clipboard',
|
|
726
904
|
'settings.backup.copyFailed': 'The copy failed — please select the path above by hand',
|
|
727
905
|
'settings.backup.time': 'Backup time',
|
|
728
|
-
|
|
906
|
+
// THE SIZE OF ONE COPY IS NOT SHOWN, so its label is gone as well (user
|
|
907
|
+
// decision, 2026-11-02): a row is a time and a button, and the disk a copy
|
|
908
|
+
// needs is only stated once for the whole folder (`settings.backup.count`).
|
|
909
|
+
// A key nothing renders is a promise the page does not keep — the same reason
|
|
910
|
+
// the identity keys went.
|
|
729
911
|
'settings.backup.withPlugins': '(plugins included)',
|
|
730
912
|
'settings.backup.withoutPlugins': '(plugins excluded)',
|
|
731
|
-
|
|
732
|
-
|
|
913
|
+
// THE ONE LINE A COPY GETS WHEN IT IS NOT THE ONE (user decision,
|
|
914
|
+
// 2026-11-02): listed, but as a sentence with its time and its size — never
|
|
915
|
+
// as a card with a button next to it.
|
|
916
|
+
'settings.backup.fallback.seed': 'There is also a first-install seed copy (settings only, not verified, used only when the copy above is unavailable), {time}, {size}',
|
|
917
|
+
'settings.backup.fallback.other': 'There is also an unverified copy (never restored automatically, used only when the copy above is unavailable), {time}, {size}',
|
|
918
|
+
'settings.backup.othersSummary': '{n} more pre-restart snapshots (unverified, never restored automatically)',
|
|
919
|
+
// THE CERTIFICATE VOCABULARY IS GONE (user decision, 2026-11-02). Two rounds
|
|
920
|
+
// of deletion ended here: first the rendering stopped grading copies, then the
|
|
921
|
+
// labels and now the evidence sentences themselves. Nothing draws them any
|
|
922
|
+
// more — a copy is a time and a button — and a dictionary that still carried
|
|
923
|
+
// them could be picked up again by a later change without anyone noticing that
|
|
924
|
+
// it re-introduces a verdict the plugin cannot support.
|
|
925
|
+
'settings.backup.scopeLabel': 'What it holds',
|
|
926
|
+
'settings.backup.scope.config-only': 'configuration only',
|
|
927
|
+
'settings.backup.scope.config+plugins': 'configuration and installed plugins',
|
|
928
|
+
'settings.backup.scope.config+links': 'configuration and linked plugins',
|
|
929
|
+
'settings.backup.scope.slim': 'slim copy (configuration and by-hand plugins)',
|
|
930
|
+
'settings.backup.scopeMissing': '(not recorded)',
|
|
931
|
+
'settings.backup.interventionLabel': 'Was this boot rescued by the system',
|
|
932
|
+
'settings.backup.intervention.true': 'yes, the watchdog restored a copy',
|
|
933
|
+
'settings.backup.intervention.false': 'no, nothing had to step in',
|
|
934
|
+
'settings.backup.intervention.unknown': 'not recorded',
|
|
935
|
+
// THE MANUAL ROLLBACK (user decision, 2026-10-30): any copy can be restored
|
|
936
|
+
// — but only after a card that says which one, what it replaces, and that
|
|
937
|
+
// a restart follows.
|
|
938
|
+
'settings.backup.rollback': 'Roll back to this copy',
|
|
939
|
+
// THE ONE SENTENCE THE PAGE MAY SAY ABOUT A COPY (user decision, 2026-11-02).
|
|
940
|
+
//
|
|
941
|
+
// It hangs on exactly one copy — the one taken at the most recent startup —
|
|
942
|
+
// and it says only WHAT THAT COPY IS: the state the host last started from.
|
|
943
|
+
// It does not say the copy is good, because the plugin cannot know that: a
|
|
944
|
+
// plugin that fails to load is dropped and the host comes up anyway. Which
|
|
945
|
+
// copy to roll back to is the reader's call, and the sentence ends by saying
|
|
946
|
+
// so. Verbatim — do not reword.
|
|
947
|
+
'settings.backup.startupHint': 'This is the state at the most recent startup. DSH starting does not mean every plugin is healthy — a broken plugin is excluded and the host still boots. Which copy to roll back to is your call.',
|
|
948
|
+
'settings.backup.rollbackConfirmTitle': 'Roll back to this copy?',
|
|
949
|
+
'settings.backup.rollbackConfirmBody': 'The backup from {time} will be restored over the current state — **what you have now is replaced** (a snapshot of it is taken first, so this rollback can be undone).',
|
|
950
|
+
'settings.backup.rollbackConfirmRestart': 'A restart follows automatically: a restorable copy of what was here before the rollback was already saved; this restart takes no further backup. This page disconnects during the restart and works again afterwards.',
|
|
951
|
+
'settings.backup.rollbackConfirmYes': 'Roll back',
|
|
952
|
+
'settings.backup.rollbackConfirmNo': 'Cancel',
|
|
953
|
+
'settings.backup.rollbackBusy': 'Rolling back…',
|
|
954
|
+
'settings.backup.rollbackDone': 'Rolled back — restarting…',
|
|
955
|
+
'settings.backup.rollbackFailed': 'The rollback did not work: {message}',
|
|
956
|
+
'settings.groups.report': 'Something is wrong',
|
|
957
|
+
'settings.groups.rescue': 'Desktop rescue',
|
|
733
958
|
'settings.rescue.hint': 'When DSH does not come up, these two scripts rescue it for you (neither of them needs DSH running).',
|
|
734
959
|
'settings.rescue.restoreProfile': 'Back to the last good state',
|
|
735
960
|
'settings.rescue.emergencyDisable': 'Take this plugin out only',
|
|
@@ -739,12 +964,14 @@
|
|
|
739
964
|
'settings.rescue.copyFailed': 'The copy failed — please select the path above by hand',
|
|
740
965
|
'settings.rescue.reveal': 'Open the folder',
|
|
741
966
|
'settings.rescue.which': 'Which one: the whole environment is broken (a changed configuration, another plugin, a bad upgrade) → "Back to the last good state"; only this plugin is suspect → "Take this plugin out only".',
|
|
742
|
-
'settings.rescue.warning': 'Important:
|
|
967
|
+
'settings.rescue.warning': 'Important: this desktop entry and the two scripts above are for when DSH does not come up. Do not click it while DSH is running — "Back to the last good state" overwrites the configuration you are working with right now.',
|
|
743
968
|
'settings.rescue.rollbackOk': 'Automatic rollback: available (a host that does not come up after a restart goes back to the last good state)',
|
|
744
969
|
'settings.rescue.rollbackBlocked': 'Automatic rollback: **not available** — this computer does not allow creating scheduled tasks. **What you lose: DSH will NOT recover by itself when it does not come up after a restart**; when it breaks, double-click the restore script in "If DSH will not start".',
|
|
745
970
|
'settings.rescue.rollbackUnknown': 'Automatic rollback: not known yet (this machine has not been checked)',
|
|
746
971
|
'settings.rescue.watchdogTier': 'How the watchdog is triggered: {tier}',
|
|
747
972
|
'settings.rescue.watchdogLast': 'The last watchdog run: {result}',
|
|
973
|
+
'settings.rescue.watchdogStrict': 'The last watchdog run did NOT act: no backup satisfies both conditions — it records a startup, and that startup had no system intervention. Please handle it by hand.',
|
|
974
|
+
'settings.rescue.watchdogRestoredFrom': "The copy the last watchdog run restored: {dir}",
|
|
748
975
|
'settings.rescue.tierScheduledTask': 'a Windows scheduled task',
|
|
749
976
|
'settings.rescue.tierSystemdRun': 'a systemd timer',
|
|
750
977
|
'settings.rescue.tierDetachedProcess': 'a detached background process',
|
|
@@ -752,6 +979,14 @@
|
|
|
752
979
|
'settings.rescue.shortcut': 'Put a shortcut on the desktop',
|
|
753
980
|
'settings.rescue.shortcutDone': 'It is on the desktop',
|
|
754
981
|
'settings.rescue.noDesktop': 'This machine has no desktop directory, so no shortcut can be placed (the paths are above — copy them by hand).',
|
|
982
|
+
'settings.rescue.noDesktopPlatform': 'The web / server version has no desktop, so there is no one-click entry here; use the two .sh scripts above.',
|
|
983
|
+
'settings.rescue.commandLabel': 'Rescue entry (command)',
|
|
984
|
+
'settings.rescue.copyCommand': 'Copy the command',
|
|
985
|
+
'settings.rescue.commandHint': 'Copy this command and run it — the same effect as double-clicking the desktop entry.',
|
|
986
|
+
'settings.rescue.commandUnavailable': 'No usable rescue command was found on this machine.',
|
|
987
|
+
'settings.rescue.shortcutScope': 'It can only put the WHOLE environment back to the last good state — it cannot take one plugin out.',
|
|
988
|
+
'settings.rescue.shortcutBlocked': 'A system policy does not allow creating the shortcut (a group policy or a permission limit). Copy the script path below and put it on the desktop by hand:',
|
|
989
|
+
'settings.rescue.shortcutWriteFailed': 'The shortcut was not written: {message}. Copy the script path below and put it on the desktop by hand:',
|
|
755
990
|
'settings.rescue.shortcutFailed': 'The shortcut was not placed: {message}',
|
|
756
991
|
'settings.rescue.revealFailed': 'The folder did not open: {message}',
|
|
757
992
|
'settings.report.label': 'Something wrong?',
|
|
@@ -827,6 +1062,46 @@
|
|
|
827
1062
|
'announcement.disabled': 'The button is currently switched off; you can turn it on in the settings.',
|
|
828
1063
|
'announcement.button': 'Got it',
|
|
829
1064
|
'announcement.error': 'Could not record it: {message}',
|
|
1065
|
+
// THE PLUGIN THAT DID NOT LOAD (user decision 2026-11-01): the host came up,
|
|
1066
|
+
// one plugin simply did not activate this time. Say so, and offer exactly
|
|
1067
|
+
// one action: put THIS one plugin back.
|
|
1068
|
+
'pluginFailure.title': "Two things worth a look",
|
|
1069
|
+
'watchdog.hint': "Changed your mind later? Look for the dsh-autorestart settings on the Plugins page.",
|
|
1070
|
+
'pluginFailure.hint': "These are on record - you can restore them later from the Plugins page.",
|
|
1071
|
+
'settings.description': "Restarts DSH, watches your plugins, and puts back the ones it can.",
|
|
1072
|
+
'settings.restore': "Restore",
|
|
1073
|
+
'settings.thirdParty': "You did not write this plugin yourself, so it cannot be restored from the backup - it has to be reinstalled.",
|
|
1074
|
+
'settings.version': "version {version}",
|
|
1075
|
+
'pluginFailure.dismiss': "Got it",
|
|
1076
|
+
'pluginFailure.dismissLabel': "Hide this for now (it comes back on the next start)",
|
|
1077
|
+
'pluginFailure.certainTitle': "These features did not come up this time",
|
|
1078
|
+
'pluginFailure.certainIntro': "They were there on the previous start and are not here now. That is something that happened, not a guess.",
|
|
1079
|
+
'pluginFailure.clueTitle': "These plugins differ from the backup",
|
|
1080
|
+
'pluginFailure.clueIntro': "The files of these plugins are not what the latest backup holds. They may simply have been upgraded - this block lists a difference, not a conclusion.",
|
|
1081
|
+
'pluginFailure.uiBlindSpot': "Also {count} interface module(s) did not appear this time (they do not map to a plugin you installed, so they are not listed here).",
|
|
1082
|
+
'pluginFailure.restoreAll': "Restore all ({count})",
|
|
1083
|
+
'pluginFailure.restoreAllDone': "Done: {done} restored, {failed} not restored.",
|
|
1084
|
+
'pluginFailure.close': "Dismiss this notice",
|
|
1085
|
+
'pluginFailure.linkedHint': "You wrote this plugin yourself and installed it as a link, so it can be put back from the backup directly.",
|
|
1086
|
+
'pluginFailure.thirdPartyHint': "This plugin was installed from elsewhere and the backup does not hold its files. To put it back, use the command below: it reinstalls the same version (not the latest one):",
|
|
1087
|
+
'pluginFailure.commandLabel': "Copy this command",
|
|
1088
|
+
'pluginFailure.noVersion': "The backup does not record its exact version, so it can only be reinstalled by name.",
|
|
1089
|
+
'pluginFailure.restore': "Restore {plugin} only",
|
|
1090
|
+
'pluginFailure.restoring': "Restoring {plugin}…",
|
|
1091
|
+
'pluginFailure.restored': "{plugin} was put back to the backup version (backup taken {at}).",
|
|
1092
|
+
'pluginFailure.failed': "{plugin} was not restored: {reason}",
|
|
1093
|
+
'pluginFailure.noBackup': "There is no usable backup on this machine; it has to be handled by hand.",
|
|
1094
|
+
'watchdog.title': "If a restart fails, it can rescue you automatically",
|
|
1095
|
+
'watchdog.explain': "This plugin ships with an automatic rescue: when a newly restarted version never comes up, it restores a backup it considers usable (the criterion is that the content fingerprint matched and that boot came up — it does not mean the plugins inside are all fine), then starts the program again. It is on by default.",
|
|
1096
|
+
'watchdog.whereOff': "If you do not want it, turn it off on the settings page — with it off, a new version that does not start has to be handled by hand.",
|
|
1097
|
+
'watchdog.gotIt': "Got it",
|
|
1098
|
+
'watchdog.noticeSaved': "That is the only notice; it will not appear again.",
|
|
1099
|
+
'watchdog.warnOff': "With it off, a new version that does not start has to be handled by hand.",
|
|
1100
|
+
'watchdog.keepOn': "Keep it on (recommended)",
|
|
1101
|
+
'watchdog.turnOff': "No, I will handle it",
|
|
1102
|
+
'watchdog.savedOn': "The watchdog is on: the next restart is protected.",
|
|
1103
|
+
'watchdog.savedOff': "The watchdog is off: the next restart will not roll back on its own.",
|
|
1104
|
+
'watchdog.saveFailed': "Your choice was not saved, so the watchdog setting is unchanged. Please try again, or change it on the settings page.",
|
|
830
1105
|
// THE COPY OF THE COMPOSER ROW (see the Chinese block): one short line,
|
|
831
1106
|
// never a sentence.
|
|
832
1107
|
'restart.stage.snapshotting': 'Backing up…',
|
|
@@ -837,6 +1112,9 @@
|
|
|
837
1112
|
'restart.overlay.size': '{done} / {total}',
|
|
838
1113
|
'restart.overlay.sizeDone': '{done}',
|
|
839
1114
|
'restart.overlay.restarting': 'Restarting…',
|
|
1115
|
+
'restart.overlay.deferred': 'A conversation just started — restart postponed',
|
|
1116
|
+
'restart.overlay.deferredNote': 'It will retry automatically when idle · {count} waiting',
|
|
1117
|
+
'restart.overlay.deferredNoteZero': 'It will retry automatically when idle',
|
|
840
1118
|
'restart.overlay.back': 'Back again',
|
|
841
1119
|
}
|
|
842
1120
|
|
|
@@ -906,20 +1184,49 @@
|
|
|
906
1184
|
* ever sees a key name, in any language.
|
|
907
1185
|
*
|
|
908
1186
|
* @param {Function|null|undefined} t the Host's translator (may be missing)
|
|
909
|
-
* @returns {(key: string) => string}
|
|
1187
|
+
* @returns {(key: string, params?: object) => string}
|
|
910
1188
|
*/
|
|
911
1189
|
function makeTranslate(t) {
|
|
912
|
-
|
|
1190
|
+
/**
|
|
1191
|
+
* `{name}` ANSWERED FROM `params` (user report, 2026-11-02).
|
|
1192
|
+
*
|
|
1193
|
+
* THE BUG THIS FIXES: this translator took ONE argument, so every call that
|
|
1194
|
+
* passed replacement values had them silently dropped and the page printed
|
|
1195
|
+
* the placeholder itself ("看门狗的触发方式:{tier}"). Three call sites did
|
|
1196
|
+
* exactly that; the ones that substitute by hand were never affected.
|
|
1197
|
+
*
|
|
1198
|
+
* THE RULES, each one a decision:
|
|
1199
|
+
* - a placeholder with NO value stays as it is: blanking it would delete
|
|
1200
|
+
* information the sentence was written to carry;
|
|
1201
|
+
* - the value is inserted as literal text, so `$&` / `$1` are characters
|
|
1202
|
+
* and not replace patterns (hence split + join, never `replace`);
|
|
1203
|
+
* - every occurrence is replaced, not only the first;
|
|
1204
|
+
* - a `null` / `undefined` value counts as "no value", never as the word
|
|
1205
|
+
* `null` printed on screen.
|
|
1206
|
+
*/
|
|
1207
|
+
const fillPlaceholders = (text, params) => {
|
|
1208
|
+
if (typeof text !== 'string' || text === '') return text
|
|
1209
|
+
if (params === null || params === undefined || typeof params !== 'object') return text
|
|
1210
|
+
let out = text
|
|
1211
|
+
for (const [name, value] of Object.entries(params)) {
|
|
1212
|
+
if (value === null || value === undefined) continue
|
|
1213
|
+
const placeholder = '{' + name + '}'
|
|
1214
|
+
if (out.includes(placeholder) !== true) continue
|
|
1215
|
+
out = out.split(placeholder).join(String(value))
|
|
1216
|
+
}
|
|
1217
|
+
return out
|
|
1218
|
+
}
|
|
1219
|
+
return (key, params) => {
|
|
913
1220
|
const name = typeof key === 'string' ? key : String(key ?? '')
|
|
914
1221
|
if (typeof t === 'function') {
|
|
915
1222
|
try {
|
|
916
1223
|
const value = t(name)
|
|
917
|
-
if (typeof value === 'string' && value !== '' && value !== name) return value
|
|
1224
|
+
if (typeof value === 'string' && value !== '' && value !== name) return fillPlaceholders(value, params)
|
|
918
1225
|
} catch {
|
|
919
1226
|
// A broken translator must not break the surface.
|
|
920
1227
|
}
|
|
921
1228
|
}
|
|
922
|
-
return dictionaryText(name, currentLanguage())
|
|
1229
|
+
return fillPlaceholders(dictionaryText(name, currentLanguage()), params)
|
|
923
1230
|
}
|
|
924
1231
|
}
|
|
925
1232
|
|
|
@@ -1105,7 +1412,58 @@
|
|
|
1105
1412
|
: fillTemplate(translate('durationHoursMinutesShort'), { n: hours, m: rest })
|
|
1106
1413
|
}
|
|
1107
1414
|
|
|
1415
|
+
/**
|
|
1416
|
+
* A WAIT, SAID THE WAY A PERSON SAYS IT (user report, 2026-11-03).
|
|
1417
|
+
*
|
|
1418
|
+
* Precision is not the point, recognition is: "约 2 小时 21 分" is what makes a
|
|
1419
|
+
* user believe the page looked at THEIR request. Seconds are rounded to words on
|
|
1420
|
+
* purpose — "47 秒" invites a precision nobody needs.
|
|
1421
|
+
*/
|
|
1422
|
+
function humanWait(ms, translate) {
|
|
1423
|
+
const t = makeTranslate(translate)
|
|
1424
|
+
const value = Number(ms)
|
|
1425
|
+
if (Number.isFinite(value) !== true || value < 0) return ''
|
|
1426
|
+
if (value < 60_000) return t('restart.waitedSeconds')
|
|
1427
|
+
const totalMinutes = Math.floor(value / 60_000)
|
|
1428
|
+
if (totalMinutes < 60) return fillTemplate(t('restart.waitedMinutes'), { n: totalMinutes })
|
|
1429
|
+
const hours = Math.floor(totalMinutes / 60)
|
|
1430
|
+
const rest = totalMinutes % 60
|
|
1431
|
+
return rest === 0
|
|
1432
|
+
? fillTemplate(t('restart.waitedHoursExact'), { n: hours })
|
|
1433
|
+
: fillTemplate(t('restart.waitedHours'), { n: hours, m: rest })
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** `2026-11-02T23:04:38+08:00` -> `2026-11-02 23:04:38` (the offset is the host's; no Date parsing). */
|
|
1437
|
+
function humanRequestedAt(raw) {
|
|
1438
|
+
const text = typeof raw === 'string' ? raw.trim() : ''
|
|
1439
|
+
if (text.length < 19) return ''
|
|
1440
|
+
return text.slice(0, 10) + ' ' + text.slice(11, 19)
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* THE ONE SENTENCE ABOUT A REQUEST THAT DIED (user decision, 2026-11-03).
|
|
1445
|
+
*
|
|
1446
|
+
* ONLY the newest request is looked at, and only the outcome "the host came back
|
|
1447
|
+
* and settled it" is spoken about. A stale record must not follow the user around,
|
|
1448
|
+
* and a request that simply completed is not news.
|
|
1449
|
+
*
|
|
1450
|
+
* @returns {{ text: string, at: string, waited: string }|null}
|
|
1451
|
+
*/
|
|
1452
|
+
function cancelledRestartNote(info, translate) {
|
|
1453
|
+
const request = info && typeof info === 'object' && info.restartRequest && typeof info.restartRequest === 'object'
|
|
1454
|
+
? info.restartRequest
|
|
1455
|
+
: null
|
|
1456
|
+
if (request === null) return null
|
|
1457
|
+
if (request.state !== 'cancelled' || request.by !== 'boot-recovery') return null
|
|
1458
|
+
const at = humanRequestedAt(request.requestedAtLocal)
|
|
1459
|
+
const waited = humanWait(request.waitedMs, translate)
|
|
1460
|
+
if (at === '' || waited === '') return null
|
|
1461
|
+
const t = makeTranslate(translate)
|
|
1462
|
+
return { text: fillTemplate(t('settings.restart.cancelledNote'), { at, waited }), at, waited }
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1108
1465
|
/* ------------------------------------------------------------------ *
|
|
1466
|
+
|
|
1109
1467
|
* The blocker lists: ONE rule for every surface that names them.
|
|
1110
1468
|
*
|
|
1111
1469
|
* The defect the real machine showed: the pending-restart bubble grew a
|
|
@@ -1653,8 +2011,8 @@
|
|
|
1653
2011
|
/**
|
|
1654
2012
|
* The note of a LONG wait.
|
|
1655
2013
|
*
|
|
1656
|
-
* The Host says that the pending restart
|
|
1657
|
-
*
|
|
2014
|
+
* The Host says that the pending restart is still waiting while a conversation
|
|
2015
|
+
* runs on (there is no deadline). That is not "a little longer", so
|
|
1658
2016
|
* the bubble says it out loud: how long the restart has been waiting and who
|
|
1659
2017
|
* holds it. The note is empty when there is no long wait.
|
|
1660
2018
|
*
|
|
@@ -1916,7 +2274,7 @@
|
|
|
1916
2274
|
return root + '?title=' + encodeURIComponent(safeTitle) + '&body=' + encodeURIComponent(safeBody);
|
|
1917
2275
|
}
|
|
1918
2276
|
|
|
1919
|
-
|
|
2277
|
+
|
|
1920
2278
|
|
|
1921
2279
|
/**
|
|
1922
2280
|
* Copy a text to the clipboard — with a FALLBACK.
|
|
@@ -2090,6 +2448,117 @@
|
|
|
2090
2448
|
return Math.round(value) + ' B'
|
|
2091
2449
|
}
|
|
2092
2450
|
|
|
2451
|
+
/**
|
|
2452
|
+
* THE GRADING OF COPIES IS GONE (user decision, 2026-11-02).
|
|
2453
|
+
*
|
|
2454
|
+
* The page used to sort every copy into an identity and let only one of them
|
|
2455
|
+
* carry a verdict — a source plus a criterion ("full-checks" AND
|
|
2456
|
+
* "fingerprint-match") had to agree before a row was allowed to look better
|
|
2457
|
+
* than the others. Two things were wrong with that and the user ruled both
|
|
2458
|
+
* out: the verdict was read as "the plugins in it are fine", which a boot
|
|
2459
|
+
* cannot establish (a plugin that fails to load is dropped and the host comes
|
|
2460
|
+
* up anyway), and on a hosted instance nothing ever qualified, so the good tier
|
|
2461
|
+
* stayed empty while every real copy sat in the demoted row.
|
|
2462
|
+
*
|
|
2463
|
+
* So there is no `isSafeBackup`, no identity key and no per-copy label any
|
|
2464
|
+
* more: a row is a time and a button. What replaces the question is one fact
|
|
2465
|
+
* the page can state honestly — WHICH COPY IS THE STATE THE HOST LAST STARTED
|
|
2466
|
+
* FROM — and that is `startupCopyName` below.
|
|
2467
|
+
*/
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* The moment a copy says it was taken, read from the copy's OWN record.
|
|
2471
|
+
*
|
|
2472
|
+
* `takenAt` is the take time under the name the Host records it with; `at` is
|
|
2473
|
+
* the number the same manifest has carried for longer. Either may arrive as a
|
|
2474
|
+
* number of milliseconds or as an ISO string. A copy that says neither cannot
|
|
2475
|
+
* answer the question, and gets no sentence at all — silence beats a guess.
|
|
2476
|
+
*
|
|
2477
|
+
* @param {object|null|undefined} item one backup of the state payload
|
|
2478
|
+
* @returns {number|null}
|
|
2479
|
+
*/
|
|
2480
|
+
function copyTakeTime(item) {
|
|
2481
|
+
if (!item) return null
|
|
2482
|
+
for (const value of [item.takenAt, item.at]) {
|
|
2483
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
2484
|
+
if (typeof value === 'string' && value !== '') {
|
|
2485
|
+
const parsed = Date.parse(value)
|
|
2486
|
+
if (Number.isFinite(parsed)) return parsed
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
return null
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/**
|
|
2493
|
+
* WHICH COPY WAS TAKEN AT THE MOST RECENT STARTUP? (user decision, 2026-11-02)
|
|
2494
|
+
*
|
|
2495
|
+
* The answer is the copy whose own record carries the latest take time: that is
|
|
2496
|
+
* the state the host was started from the last time it came up. It is
|
|
2497
|
+
* deliberately NOT "the first row of the list" — the row order is a rendering
|
|
2498
|
+
* detail, and a page that read its fact from the row order would move the one
|
|
2499
|
+
* sentence onto a different copy the day the order changes (or silently onto
|
|
2500
|
+
* the wrong copy when the list is not sorted).
|
|
2501
|
+
*
|
|
2502
|
+
* Nothing here judges the copy: the sentence this name selects says what the
|
|
2503
|
+
* copy IS, never that it is healthy.
|
|
2504
|
+
*
|
|
2505
|
+
* @param {Array<object>|null|undefined} items the copies as the Host reports them
|
|
2506
|
+
* @returns {string|null} the name of that copy, or null when no copy can say
|
|
2507
|
+
*/
|
|
2508
|
+
function startupCopyName(items) {
|
|
2509
|
+
if (!Array.isArray(items)) return null
|
|
2510
|
+
let best = null
|
|
2511
|
+
for (const item of items) {
|
|
2512
|
+
if (!item || typeof item.name !== 'string' || item.name === '') continue
|
|
2513
|
+
const at = copyTakeTime(item)
|
|
2514
|
+
if (at === null) continue
|
|
2515
|
+
if (best === null || at > best.at) best = { name: item.name, at }
|
|
2516
|
+
}
|
|
2517
|
+
return best === null ? null : best.name
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* THE TWO KEY MAPPERS OF THE CERTIFICATE VOCABULARY ARE GONE (user decision,
|
|
2522
|
+
* 2026-11-02). One named the source that "signed" a copy, the other the
|
|
2523
|
+
* criterion that backed it; both existed only to pick a per-copy sentence out
|
|
2524
|
+
* of the dictionary. No row grades a copy any more, so once the list stopped
|
|
2525
|
+
* rendering identities they had no caller left — deleted rather than kept as a
|
|
2526
|
+
* hook a later change could quietly revive. Their dictionary entries went with
|
|
2527
|
+
* them: the whole "who certified it" family is not in this file any more.
|
|
2528
|
+
*/
|
|
2529
|
+
|
|
2530
|
+
/** Which dictionary entry describes what a copy holds. */
|
|
2531
|
+
function backupScopeKey(contentScope) {
|
|
2532
|
+
if (contentScope === 'config-only') return 'config-only'
|
|
2533
|
+
if (contentScope === 'config+plugins') return 'config+plugins'
|
|
2534
|
+
if (contentScope === 'config+links') return 'config+links'
|
|
2535
|
+
if (contentScope === 'slim') return 'slim'
|
|
2536
|
+
return ''
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/**
|
|
2540
|
+
* Whether this boot was rescued by the system, in three answers.
|
|
2541
|
+
*
|
|
2542
|
+
* `null` is NOT "no": the Host says `null` when nobody recorded anything, and
|
|
2543
|
+
* the two must not be merged — "it did not happen" and "we do not know" lead a
|
|
2544
|
+
* reader to different conclusions.
|
|
2545
|
+
*/
|
|
2546
|
+
function backupInterventionKey(intervention) {
|
|
2547
|
+
if (intervention === true) return 'true'
|
|
2548
|
+
if (intervention === false) return 'false'
|
|
2549
|
+
return 'unknown'
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
/**
|
|
2553
|
+
* The version a copy was proven with, or an empty string when it carries none.
|
|
2554
|
+
*
|
|
2555
|
+
* @param {object|null|undefined} item one backup of the state payload
|
|
2556
|
+
* @returns {string}
|
|
2557
|
+
*/
|
|
2558
|
+
function backupVersionOf(item) {
|
|
2559
|
+
return item && typeof item.version === 'string' && item.version !== '' ? item.version : ''
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2093
2562
|
/**
|
|
2094
2563
|
* The local time of one backup, to the minute.
|
|
2095
2564
|
*
|
|
@@ -2192,6 +2661,134 @@
|
|
|
2192
2661
|
* @param {Function} [translator]
|
|
2193
2662
|
* @returns {string}
|
|
2194
2663
|
*/
|
|
2664
|
+
/**
|
|
2665
|
+
* HOW LONG THE "RESTART POSTPONED" CARD STAYS ON SCREEN (milliseconds).
|
|
2666
|
+
*
|
|
2667
|
+
* THE REPORT (user, 2026-10-30): "after the interruption I still have a lot of
|
|
2668
|
+
* work to do — a card hovering for ever is in the way". The postponed card is an
|
|
2669
|
+
* ANNOUNCEMENT, not a progress bar: it says what happened for four seconds and
|
|
2670
|
+
* then folds away, while the capsule keeps saying "pending restart · waiting for
|
|
2671
|
+
* N sessions". A NEW postponement (a new stage timestamp) announces itself again.
|
|
2672
|
+
*
|
|
2673
|
+
* The stages that really are running ('snapshotting', 'restarting') are not
|
|
2674
|
+
* affected: they stay while the work lasts.
|
|
2675
|
+
*/
|
|
2676
|
+
const RESTART_DEFERRED_CARD_MS = 4000
|
|
2677
|
+
|
|
2678
|
+
/**
|
|
2679
|
+
* HOW LONG A STAGE MAY GO UNMENTIONED BEFORE THE CARD STOPS TRUSTING IT.
|
|
2680
|
+
*
|
|
2681
|
+
* WHY (user report, 2026-10-29): a restart was postponed (a conversation
|
|
2682
|
+
* became active again) right after the copy, and the card kept saying
|
|
2683
|
+
* "DSH 重启前备份中" for ever — the stage on disk had not been brought back to
|
|
2684
|
+
* 'waiting'. The Host is fixed, but the interface must not be able to hang on
|
|
2685
|
+
* a stale stage either: a stage is evidence only while it keeps being
|
|
2686
|
+
* refreshed.
|
|
2687
|
+
*/
|
|
2688
|
+
const RESTART_STAGE_STALE_MS = 15000
|
|
2689
|
+
// While files are being copied the Host refreshes the stage on every progress
|
|
2690
|
+
// tick, so ten silent seconds mean the copy is no longer reporting — either it
|
|
2691
|
+
// is over or nobody is copying any more. A 'restarting' stage has no progress
|
|
2692
|
+
// of its own and only has to stay inside the stale window above.
|
|
2693
|
+
const RESTART_SNAPSHOT_FRESH_MS = 10000
|
|
2694
|
+
|
|
2695
|
+
/**
|
|
2696
|
+
* THE TWO CADENCES OF THE SHARED POLL (milliseconds).
|
|
2697
|
+
*
|
|
2698
|
+
* 2 s is right for a page that merely shows whether a restart is pending: a
|
|
2699
|
+
* pending restart sits there for minutes. While a restart is MOVING, though,
|
|
2700
|
+
* the stage can flip at any moment (a conversation starts, the host postpones
|
|
2701
|
+
* the restart, the card has to change its words) — and 2 s of the wrong word is
|
|
2702
|
+
* exactly what the owner complained about. So the cadence follows the state.
|
|
2703
|
+
*/
|
|
2704
|
+
const POLL_MS = 2000
|
|
2705
|
+
const POLL_FAST_MS = 500
|
|
2706
|
+
|
|
2707
|
+
/**
|
|
2708
|
+
* IS A RESTART MOVING RIGHT NOW?
|
|
2709
|
+
*
|
|
2710
|
+
* 'waiting' counts as well: the restart is requested and the gate may open (or
|
|
2711
|
+
* a conversation may appear) at any moment — that is exactly when the card must
|
|
2712
|
+
* change its words within a second.
|
|
2713
|
+
*
|
|
2714
|
+
* @param {object|null} info the state payload of the Host
|
|
2715
|
+
* @returns {boolean}
|
|
2716
|
+
*/
|
|
2717
|
+
function isRestartMoving(info) {
|
|
2718
|
+
if (!info || typeof info !== 'object') return false
|
|
2719
|
+
if (info.pendingRestart !== true) return false
|
|
2720
|
+
const stage = info.restartPhase
|
|
2721
|
+
const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
|
|
2722
|
+
return phase === 'waiting' || phase === 'snapshotting' || phase === 'restarting'
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
/**
|
|
2726
|
+
* The cadence the CURRENT state asks for.
|
|
2727
|
+
*
|
|
2728
|
+
* @param {object|null} info the state payload of the Host
|
|
2729
|
+
* @returns {number} milliseconds
|
|
2730
|
+
*/
|
|
2731
|
+
function pollIntervalFor(info) {
|
|
2732
|
+
return isRestartMoving(info) ? POLL_FAST_MS : POLL_MS
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
/**
|
|
2736
|
+
* Is the page on screen? A hidden tab has nobody to tell, so it must not spin
|
|
2737
|
+
* (and it asks once more as soon as it is visible again).
|
|
2738
|
+
*
|
|
2739
|
+
* @returns {boolean}
|
|
2740
|
+
*/
|
|
2741
|
+
function isPageVisible() {
|
|
2742
|
+
if (typeof document === 'undefined' || !document) return true
|
|
2743
|
+
if (document.hidden === true) return false
|
|
2744
|
+
if (typeof document.visibilityState === 'string') return document.visibilityState !== 'hidden'
|
|
2745
|
+
return true
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/**
|
|
2749
|
+
* IS THE RESTART POSTPONED BY A CONVERSATION? (the Host says so in `reason`)
|
|
2750
|
+
*
|
|
2751
|
+
* A postponed restart is a stage of 'waiting' with a reason that starts with
|
|
2752
|
+
* "deferred:" (see the Host's deferRestartStage). That state is NOT an activity,
|
|
2753
|
+
* so it is drawn differently: the card names the interruption and promises the
|
|
2754
|
+
* retry, and the word "正在重启" never appears for it.
|
|
2755
|
+
*
|
|
2756
|
+
* @param {object|null} stage the `restartPhase` of the state route
|
|
2757
|
+
* @returns {boolean}
|
|
2758
|
+
*/
|
|
2759
|
+
function isRestartStageDeferred(stage) {
|
|
2760
|
+
if (!stage || typeof stage !== 'object') return false
|
|
2761
|
+
if (stage.phase !== 'waiting') return false
|
|
2762
|
+
return typeof stage.reason === 'string' && stage.reason.indexOf('deferred:') === 0
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
/**
|
|
2766
|
+
* Is the reported stage still something the card may draw?
|
|
2767
|
+
*
|
|
2768
|
+
* The card is an ALARM about something happening right now: 'snapshotting'
|
|
2769
|
+
* (files are being copied) or 'restarting' (the host is going down). A stage
|
|
2770
|
+
* that nobody refreshed for 15 s (10 s for a copy, which refreshes on every
|
|
2771
|
+
* file) describes something that is not happening any more, and drawing it
|
|
2772
|
+
* would leave a card hanging — exactly the bug to avoid. The pill keeps
|
|
2773
|
+
* telling the user the restart is still pending; the card simply stops
|
|
2774
|
+
* claiming progress.
|
|
2775
|
+
*
|
|
2776
|
+
* @param {object|null} stage the `restartPhase` of the state route
|
|
2777
|
+
* @param {number} now
|
|
2778
|
+
* @returns {boolean}
|
|
2779
|
+
*/
|
|
2780
|
+
function isRestartStageLive(stage, now) {
|
|
2781
|
+
const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
|
|
2782
|
+
if (phase !== 'snapshotting' && phase !== 'restarting') return false
|
|
2783
|
+
const at = stage && Number.isFinite(Number(stage.at)) ? Number(stage.at) : null
|
|
2784
|
+
// No timestamp at all: the stage cannot be trusted to describe "now".
|
|
2785
|
+
if (at === null || at <= 0) return false
|
|
2786
|
+
const age = Math.max(0, now - at)
|
|
2787
|
+
if (age > RESTART_STAGE_STALE_MS) return false
|
|
2788
|
+
if (phase === 'restarting') return true
|
|
2789
|
+
return age <= RESTART_SNAPSHOT_FRESH_MS
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2195
2792
|
function restartOverlayTitle(stage, translator) {
|
|
2196
2793
|
const phase = stage && typeof stage === 'object' && typeof stage.phase === 'string' ? stage.phase : ''
|
|
2197
2794
|
if (!isRestartOverlayPhase(phase)) return ''
|
|
@@ -2275,6 +2872,19 @@
|
|
|
2275
2872
|
// The stage of a restart on the two surfaces that have room for it: the
|
|
2276
2873
|
// composer row (one short line) and the centred overlay (the detail).
|
|
2277
2874
|
isRestartOverlayPhase,
|
|
2875
|
+
// Is the reported stage still worth a card? (a stale stage cannot hang it)
|
|
2876
|
+
isRestartStageLive,
|
|
2877
|
+
isRestartStageDeferred,
|
|
2878
|
+
RESTART_DEFERRED_CARD_MS,
|
|
2879
|
+
// The cadence of the shared poll: fast while a restart is moving, and no
|
|
2880
|
+
// polling at all in a hidden tab.
|
|
2881
|
+
isRestartMoving,
|
|
2882
|
+
pollIntervalFor,
|
|
2883
|
+
isPageVisible,
|
|
2884
|
+
POLL_MS,
|
|
2885
|
+
POLL_FAST_MS,
|
|
2886
|
+
RESTART_STAGE_STALE_MS,
|
|
2887
|
+
RESTART_SNAPSHOT_FRESH_MS,
|
|
2278
2888
|
restartPillStageText,
|
|
2279
2889
|
restartOverlayTitle,
|
|
2280
2890
|
// The size with the unit that fits it ("92 KB", "156 MB"): a hosted profile
|
|
@@ -2316,6 +2926,12 @@
|
|
|
2316
2926
|
const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
|
|
2317
2927
|
const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
|
|
2318
2928
|
const DEFAULT_CANCEL_URL = ROUTE_PREFIX + '/cancel'
|
|
2929
|
+
// The route of the MANUAL ROLLBACK (restore ONE copy the user picked).
|
|
2930
|
+
const DEFAULT_ROLLBACK_URL = ROUTE_PREFIX + '/rollback-to'
|
|
2931
|
+
// The route that writes the copy limit (how many backups to keep). It has a
|
|
2932
|
+
// default for the same reason the rollback does: this page must not depend
|
|
2933
|
+
// on an optional field of the state payload to be able to send a write.
|
|
2934
|
+
const DEFAULT_BACKUP_LIMIT_URL = ROUTE_PREFIX + '/backup-limit'
|
|
2319
2935
|
// The route that persists the one-time install choice.
|
|
2320
2936
|
const DEFAULT_CHOICE_URL = ROUTE_PREFIX + '/choice'
|
|
2321
2937
|
// The browser-side record of the one-time install choice. The prompt is
|
|
@@ -2329,7 +2945,8 @@
|
|
|
2329
2945
|
* the fallback for a Host that is older than the supervisor.
|
|
2330
2946
|
*/
|
|
2331
2947
|
const DEFAULT_SUPERVISOR_URL = '/__dsh_autorestart/supervisor'
|
|
2332
|
-
|
|
2948
|
+
// The cadence lives with the state it follows (see POLL_MS/POLL_FAST_MS
|
|
2949
|
+
// above): the shared poll speeds up while a restart is moving.
|
|
2333
2950
|
// The brand accent (blue-purple). The pending-restart marker is a brand
|
|
2334
2951
|
// signal, not a warning: no warning/yellow token is used here.
|
|
2335
2952
|
const BRAND = 'var(--dsw-alias-brand-primary, #5b8def)'
|
|
@@ -2405,11 +3022,15 @@
|
|
|
2405
3022
|
info: null,
|
|
2406
3023
|
listeners: new Set(),
|
|
2407
3024
|
timer: null,
|
|
3025
|
+
// The cadence the current timer was armed with (0 = not armed).
|
|
3026
|
+
interval: 0,
|
|
2408
3027
|
inFlight: false,
|
|
2409
3028
|
controller: null,
|
|
2410
3029
|
// When the pending state was FIRST observed, so the details panel can
|
|
2411
3030
|
// say how long this session has been waiting.
|
|
2412
3031
|
pendingSince: null,
|
|
3032
|
+
// The visibility listener is bound once, on the first subscriber.
|
|
3033
|
+
visibilityBound: false,
|
|
2413
3034
|
}
|
|
2414
3035
|
|
|
2415
3036
|
function notifyListeners() {
|
|
@@ -2442,8 +3063,63 @@
|
|
|
2442
3063
|
}
|
|
2443
3064
|
}
|
|
2444
3065
|
|
|
3066
|
+
/**
|
|
3067
|
+
* (Re-)arm the shared timer with the cadence the state asks for.
|
|
3068
|
+
*
|
|
3069
|
+
* THE CADENCE IS RE-DECIDED AFTER EVERY ANSWER: a restart that starts makes
|
|
3070
|
+
* the next tick 500 ms away, a restart that ends puts it back to 2 s, and a
|
|
3071
|
+
* hidden page is not polled at all (an interval in a background tab only
|
|
3072
|
+
* spends battery — it asks once as soon as it is visible again).
|
|
3073
|
+
*
|
|
3074
|
+
* Re-arming only happens when the cadence really changed, so the ticks stay
|
|
3075
|
+
* evenly spaced and never pile up.
|
|
3076
|
+
*/
|
|
3077
|
+
function armPoll() {
|
|
3078
|
+
if (stateStore.listeners.size === 0) return
|
|
3079
|
+
const wanted = isPageVisible() ? pollIntervalFor(stateStore.info) : 0
|
|
3080
|
+
if (wanted === 0) {
|
|
3081
|
+
if (stateStore.timer !== null) {
|
|
3082
|
+
clearInterval(stateStore.timer)
|
|
3083
|
+
stateStore.timer = null
|
|
3084
|
+
}
|
|
3085
|
+
stateStore.interval = 0
|
|
3086
|
+
return
|
|
3087
|
+
}
|
|
3088
|
+
if (stateStore.timer !== null && stateStore.interval === wanted) return
|
|
3089
|
+
if (stateStore.timer !== null) clearInterval(stateStore.timer)
|
|
3090
|
+
stateStore.interval = wanted
|
|
3091
|
+
stateStore.timer = setInterval(() => {
|
|
3092
|
+
pollOnce().then(armPoll)
|
|
3093
|
+
}, wanted)
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
/**
|
|
3097
|
+
* Ask once more as soon as the page is back on screen.
|
|
3098
|
+
*
|
|
3099
|
+
* A hidden tab stops polling; coming back must not wait for the next tick of
|
|
3100
|
+
* a timer that was stopped, so the visibility event polls immediately and
|
|
3101
|
+
* then re-arms the cadence.
|
|
3102
|
+
*/
|
|
3103
|
+
function bindVisibility() {
|
|
3104
|
+
if (stateStore.visibilityBound) return
|
|
3105
|
+
if (typeof document === 'undefined' || !document || typeof document.addEventListener !== 'function') return
|
|
3106
|
+
stateStore.visibilityBound = true
|
|
3107
|
+
document.addEventListener('visibilitychange', () => {
|
|
3108
|
+
if (stateStore.listeners.size === 0) return
|
|
3109
|
+
if (!isPageVisible()) {
|
|
3110
|
+
armPoll()
|
|
3111
|
+
return
|
|
3112
|
+
}
|
|
3113
|
+
pollOnce().then(armPoll)
|
|
3114
|
+
armPoll()
|
|
3115
|
+
})
|
|
3116
|
+
}
|
|
3117
|
+
|
|
2445
3118
|
async function pollOnce() {
|
|
3119
|
+
// ONE REQUEST AT A TIME: a slow answer must never stack up a queue.
|
|
2446
3120
|
if (stateStore.inFlight) return
|
|
3121
|
+
// Nobody is looking at a hidden tab; the next visible moment asks.
|
|
3122
|
+
if (!isPageVisible()) return
|
|
2447
3123
|
stateStore.inFlight = true
|
|
2448
3124
|
try {
|
|
2449
3125
|
const response = await fetch(HEALTH_URL, {
|
|
@@ -2482,8 +3158,9 @@
|
|
|
2482
3158
|
stateStore.listeners.add(listener)
|
|
2483
3159
|
if (stateStore.listeners.size === 1) {
|
|
2484
3160
|
stateStore.controller = new AbortController()
|
|
2485
|
-
|
|
2486
|
-
|
|
3161
|
+
bindVisibility()
|
|
3162
|
+
pollOnce().then(armPoll)
|
|
3163
|
+
armPoll()
|
|
2487
3164
|
}
|
|
2488
3165
|
return () => {
|
|
2489
3166
|
stateStore.listeners.delete(listener)
|
|
@@ -2492,6 +3169,7 @@
|
|
|
2492
3169
|
clearInterval(stateStore.timer)
|
|
2493
3170
|
stateStore.timer = null
|
|
2494
3171
|
}
|
|
3172
|
+
stateStore.interval = 0
|
|
2495
3173
|
if (stateStore.controller) {
|
|
2496
3174
|
stateStore.controller.abort()
|
|
2497
3175
|
stateStore.controller = null
|
|
@@ -2688,6 +3366,59 @@
|
|
|
2688
3366
|
return DEFAULT_CANCEL_URL
|
|
2689
3367
|
}
|
|
2690
3368
|
|
|
3369
|
+
/**
|
|
3370
|
+
* The route that restores ONE backup the user picked (the manual
|
|
3371
|
+
* rollback): the one the Host sent with the state payload, or the derived
|
|
3372
|
+
* default. It never carries a path — only a backup NAME goes in the body.
|
|
3373
|
+
*/
|
|
3374
|
+
function rollbackUrlFor(info) {
|
|
3375
|
+
const sent = info && typeof info.rollbackUrl === 'string' ? info.rollbackUrl.trim() : ''
|
|
3376
|
+
if (sent.startsWith('/')) return sent
|
|
3377
|
+
return DEFAULT_ROLLBACK_URL
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/**
|
|
3381
|
+
* The route that writes the backup copy limit (user decision, 2026-11-02).
|
|
3382
|
+
*
|
|
3383
|
+
* THE BUG THIS FIXES (user report, 2026-11-02: "2 份改 3 份不生效"): the
|
|
3384
|
+
* Host sends `healthUrl` as a FULL URL (`http://127.0.0.1:<port>/…/health`),
|
|
3385
|
+
* and this function only accepted a value starting with `/` — so it fell
|
|
3386
|
+
* through to `''`, and `submitBackupLimit` returned before it ever called
|
|
3387
|
+
* `fetch`. Nothing was sent, nothing was said, and the number did not move.
|
|
3388
|
+
* The sibling of health is the same path with its last segment replaced,
|
|
3389
|
+
* whether the Host spelled it as a path or as an absolute URL; when it
|
|
3390
|
+
* sends neither, the derived default is used — exactly like the rollback
|
|
3391
|
+
* route, which never had this problem.
|
|
3392
|
+
*/
|
|
3393
|
+
function backupLimitUrlFor(info) {
|
|
3394
|
+
const sent = info && typeof info.backupLimitUrl === 'string' ? info.backupLimitUrl.trim() : ''
|
|
3395
|
+
if (sent.startsWith('/')) return sent
|
|
3396
|
+
const health = info && typeof info.healthUrl === 'string' ? info.healthUrl.trim() : ''
|
|
3397
|
+
const asSibling = health.replace(/\/[^/?]+(\?.*)?$/, '/backup-limit')
|
|
3398
|
+
// An absolute URL keeps only its path: every write in this client is a
|
|
3399
|
+
// same-origin path, and the page's own origin is the instance it talks to.
|
|
3400
|
+
if (/^https?:\/\//i.test(health)) return asSibling.replace(/^https?:\/\/[^/]+/i, '')
|
|
3401
|
+
if (health.startsWith('/')) return asSibling
|
|
3402
|
+
return DEFAULT_BACKUP_LIMIT_URL
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
/**
|
|
3406
|
+
* The two READ-ONLY sources the failure panel merges (2026-11-01):
|
|
3407
|
+
* what the loaded plugins stopped providing, and how each plugin compares
|
|
3408
|
+
* with the newest backup. Both are plain GET routes.
|
|
3409
|
+
*/
|
|
3410
|
+
function pluginWatchUrlFor(info) {
|
|
3411
|
+
const sent = info && typeof info.pluginWatchUrl === 'string' ? info.pluginWatchUrl.trim() : ''
|
|
3412
|
+
if (sent.startsWith('/')) return sent
|
|
3413
|
+
return '/__dsh_autorestart/plugin-watch'
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
function pluginInventoryUrlFor(info) {
|
|
3417
|
+
const sent = info && typeof info.pluginInventoryUrl === 'string' ? info.pluginInventoryUrl.trim() : ''
|
|
3418
|
+
if (sent.startsWith('/')) return sent
|
|
3419
|
+
return '/__dsh_autorestart/plugin-inventory'
|
|
3420
|
+
}
|
|
3421
|
+
|
|
2691
3422
|
/**
|
|
2692
3423
|
* Whether the one-time install prompt may be shown.
|
|
2693
3424
|
*
|
|
@@ -3314,8 +4045,8 @@
|
|
|
3314
4045
|
// waiting for the quiet window — not for a conversation.
|
|
3315
4046
|
const gateReason = info && typeof info.reason === 'string' ? info.reason : ''
|
|
3316
4047
|
|
|
3317
|
-
// The long wait: the Host says that the restart
|
|
3318
|
-
//
|
|
4048
|
+
// The long wait: the Host says that the restart is still waiting while a
|
|
4049
|
+
// conversation runs on. The capsule shows one
|
|
3319
4050
|
// more line about it — how long it has been waiting and how many tasks
|
|
3320
4051
|
// are running.
|
|
3321
4052
|
const longWait = formatLongWaitNoteParts(info, t)
|
|
@@ -3817,13 +4548,58 @@
|
|
|
3817
4548
|
// on right now", the state file alone does not.
|
|
3818
4549
|
const pending = Boolean(info && info.pendingRestart === true)
|
|
3819
4550
|
// THE ONLY TWO STAGES THAT ARE "SOMETHING IS REALLY HAPPENING": the
|
|
3820
|
-
// snapshot is being written, or the host is on its way out
|
|
3821
|
-
|
|
4551
|
+
// snapshot is being written, or the host is on its way out — AND the
|
|
4552
|
+
// stage must still be fresh. A stage nobody refreshed (the restart was
|
|
4553
|
+
// postponed and the Host did not say so) cannot leave a card on screen
|
|
4554
|
+
// for ever; the pill still says the restart is pending.
|
|
4555
|
+
const running = pending && isRestartStageLive(stage, Date.now())
|
|
3822
4556
|
|
|
3823
4557
|
useEffect(() => {
|
|
3824
4558
|
if (running) setSawRunning(true)
|
|
3825
4559
|
}, [running])
|
|
3826
4560
|
|
|
4561
|
+
// THE POSTPONED CARD IS AN ANNOUNCEMENT, NOT A PROGRESS BAR (user report,
|
|
4562
|
+
// 2026-10-30): "after the interruption I still have a lot of work to do — a
|
|
4563
|
+
// card hovering for ever is in the way". It says what happened for four
|
|
4564
|
+
// seconds and then folds away; the capsule keeps saying the restart is
|
|
4565
|
+
// pending. A NEW postponement (a new stage timestamp) announces itself
|
|
4566
|
+
// again. Hooks live up here, with the others, because they must run on
|
|
4567
|
+
// every render — including the renders that draw nothing.
|
|
4568
|
+
const deferredKey = pending && isRestartStageDeferred(stage)
|
|
4569
|
+
? (Number.isFinite(Number(stage.at)) ? Number(stage.at) : 0)
|
|
4570
|
+
: null
|
|
4571
|
+
const [announcedKey, setAnnouncedKey] = useState(null)
|
|
4572
|
+
const [folded, setFolded] = useState(false)
|
|
4573
|
+
|
|
4574
|
+
useEffect(() => {
|
|
4575
|
+
if (deferredKey === null) {
|
|
4576
|
+
if (announcedKey !== null) {
|
|
4577
|
+
setAnnouncedKey(null)
|
|
4578
|
+
setFolded(false)
|
|
4579
|
+
}
|
|
4580
|
+
return undefined
|
|
4581
|
+
}
|
|
4582
|
+
if (announcedKey === deferredKey) return undefined
|
|
4583
|
+
setAnnouncedKey(deferredKey)
|
|
4584
|
+
setFolded(false)
|
|
4585
|
+
return undefined
|
|
4586
|
+
}, [deferredKey, announcedKey])
|
|
4587
|
+
|
|
4588
|
+
// The four seconds of THIS announcement.
|
|
4589
|
+
useEffect(() => {
|
|
4590
|
+
if (announcedKey === null || folded) return undefined
|
|
4591
|
+
const timer = setTimeout(() => setFolded(true), RESTART_DEFERRED_CARD_MS)
|
|
4592
|
+
return () => clearTimeout(timer)
|
|
4593
|
+
}, [announcedKey, folded])
|
|
4594
|
+
|
|
4595
|
+
// A POSTPONED RESTART IS NOT A RUNNING ONE (user report, 2026-10-30): when
|
|
4596
|
+
// the Host says the restart went back to waiting because a conversation
|
|
4597
|
+
// started, the card says exactly that. It is drawn AT ONCE (a fresh
|
|
4598
|
+
// postponement must not wait for a second render) and the hook above
|
|
4599
|
+
// records which postponement has been announced, so its four seconds can
|
|
4600
|
+
// run out; every new stage timestamp resets the clock.
|
|
4601
|
+
const postponed = deferredKey !== null && !folded
|
|
4602
|
+
|
|
3827
4603
|
// "已回来" is a MOMENT, not a state: 1.5s and then the card is gone.
|
|
3828
4604
|
useEffect(() => {
|
|
3829
4605
|
if (phase !== 'back') {
|
|
@@ -3834,8 +4610,9 @@
|
|
|
3834
4610
|
return () => clearTimeout(timer)
|
|
3835
4611
|
}, [phase])
|
|
3836
4612
|
|
|
3837
|
-
// THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS
|
|
3838
|
-
|
|
4613
|
+
// THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS — or while
|
|
4614
|
+
// the restart is honestly postponed.
|
|
4615
|
+
const visible = running || postponed || (phase === 'back' && sawRunning && !backGone)
|
|
3839
4616
|
if (!visible) return null
|
|
3840
4617
|
|
|
3841
4618
|
const progress = stage.progress && typeof stage.progress === 'object' ? stage.progress : null
|
|
@@ -3845,11 +4622,32 @@
|
|
|
3845
4622
|
? Math.max(0, Math.min(100, Math.round(Number(progress.percent))))
|
|
3846
4623
|
: null
|
|
3847
4624
|
|
|
3848
|
-
|
|
4625
|
+
// The postponed card wins over everything else: even a stage that still
|
|
4626
|
+
// says 'restarting' in the same render is downgraded here, so the old word
|
|
4627
|
+
// can never survive a single frame once the Host said "deferred".
|
|
4628
|
+
const deferredCount = postponed ? waitingTaskCount(info, info && Array.isArray(info.blockedBy) ? info.blockedBy : []) : 0
|
|
4629
|
+
const title = postponed
|
|
4630
|
+
? t('restart.overlay.deferred')
|
|
4631
|
+
: restartOverlayTitle(stage, t)
|
|
3849
4632
|
|
|
3850
4633
|
let bar = null
|
|
3851
4634
|
let detail = null
|
|
3852
|
-
if (
|
|
4635
|
+
if (postponed) {
|
|
4636
|
+
// A second, smaller line: what happens next, and how many conversations
|
|
4637
|
+
// are in the way (the same number the capsule shows).
|
|
4638
|
+
detail = h(
|
|
4639
|
+
'p',
|
|
4640
|
+
{ className: 'dsh-autorestart-restart-overlay__size' },
|
|
4641
|
+
h(
|
|
4642
|
+
'span',
|
|
4643
|
+
{ 'data-dsh-autorestart-restart-overlay-deferred': 'true' },
|
|
4644
|
+
deferredCount > 0
|
|
4645
|
+
? fill(t('restart.overlay.deferredNote'), { count: deferredCount })
|
|
4646
|
+
: t('restart.overlay.deferredNoteZero'),
|
|
4647
|
+
),
|
|
4648
|
+
)
|
|
4649
|
+
}
|
|
4650
|
+
if (!postponed && phase === 'snapshotting') {
|
|
3853
4651
|
// The numbers are the ones the copy loop really measured; when there is
|
|
3854
4652
|
// no measurement yet, the card shows no number at all (an invented
|
|
3855
4653
|
// progress bar is worse than none). The unit fits the size, so a 92 KB
|
|
@@ -3894,6 +4692,9 @@
|
|
|
3894
4692
|
'aria-live': 'polite',
|
|
3895
4693
|
'data-dsh-autorestart-restart-overlay': 'true',
|
|
3896
4694
|
'data-dsh-autorestart-restart-overlay-phase': phase,
|
|
4695
|
+
// WHAT THE CARD SAYS, independent of the Host phase: a postponed
|
|
4696
|
+
// restart is drawn from 'waiting'.
|
|
4697
|
+
'data-dsh-autorestart-restart-overlay-card': postponed ? 'deferred' : phase,
|
|
3897
4698
|
},
|
|
3898
4699
|
h(
|
|
3899
4700
|
'p',
|
|
@@ -4906,6 +5707,190 @@
|
|
|
4906
5707
|
' line-height:18px;',
|
|
4907
5708
|
' color:var(--dsw-alias-label-secondary);',
|
|
4908
5709
|
'}',
|
|
5710
|
+
// WHAT a copy is (user decision, 2026-10-30): the identity is the first
|
|
5711
|
+
// thing read on a row, so it sits on its own line and is not muted.
|
|
5712
|
+
'.dsh-autorestart-settings__backup-identity{',
|
|
5713
|
+
' display:flex;',
|
|
5714
|
+
' flex-wrap:wrap;',
|
|
5715
|
+
' gap:8px;',
|
|
5716
|
+
' align-items:baseline;',
|
|
5717
|
+
'}',
|
|
5718
|
+
'.dsh-autorestart-settings__backup-badge{',
|
|
5719
|
+
' font-size:13px;',
|
|
5720
|
+
' line-height:18px;',
|
|
5721
|
+
' font-weight:600;',
|
|
5722
|
+
' color:var(--dsw-alias-label-primary);',
|
|
5723
|
+
'}',
|
|
5724
|
+
'.dsh-autorestart-settings__backup-actions{',
|
|
5725
|
+
' display:flex;',
|
|
5726
|
+
' flex-wrap:wrap;',
|
|
5727
|
+
' gap:8px;',
|
|
5728
|
+
' align-items:baseline;',
|
|
5729
|
+
'}',
|
|
5730
|
+
// EVERY COPY THAT IS NOT THE ONE (user decision, 2026-11-02): one small
|
|
5731
|
+
// line each, clearly quieter than the card above it, with no button.
|
|
5732
|
+
'.dsh-autorestart-settings__backup-other{',
|
|
5733
|
+
' display:flex;',
|
|
5734
|
+
' flex-wrap:wrap;',
|
|
5735
|
+
' gap:6px;',
|
|
5736
|
+
' margin-top:8px;',
|
|
5737
|
+
' align-items:baseline;',
|
|
5738
|
+
'}',
|
|
5739
|
+
// THE PER-PLUGIN RECOVERY: a plain list of one row per plugin, the name
|
|
5740
|
+
// in monospace so a package name is recognisable at a glance.
|
|
5741
|
+
'.dsh-autorestart-settings__plugins{',
|
|
5742
|
+
' display:flex;',
|
|
5743
|
+
' flex-direction:column;',
|
|
5744
|
+
' gap:6px;',
|
|
5745
|
+
' margin:0;',
|
|
5746
|
+
' padding:0;',
|
|
5747
|
+
' list-style:none;',
|
|
5748
|
+
'}',
|
|
5749
|
+
'.dsh-autorestart-settings__plugin-row{',
|
|
5750
|
+
' display:grid;',
|
|
5751
|
+
' grid-template-columns:1fr auto auto;',
|
|
5752
|
+
' align-items:center;',
|
|
5753
|
+
' gap:10px;',
|
|
5754
|
+
' padding:6px 8px;',
|
|
5755
|
+
' border-radius:8px;',
|
|
5756
|
+
' background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,0.04));',
|
|
5757
|
+
'}',
|
|
5758
|
+
'.dsh-autorestart-settings__plugin-name{',
|
|
5759
|
+
' font-family:monospace;',
|
|
5760
|
+
' font-size:12px;',
|
|
5761
|
+
' word-break:break-all;',
|
|
5762
|
+
'}',
|
|
5763
|
+
'.dsh-autorestart-settings__plugin-status{',
|
|
5764
|
+
' font-size:12px;',
|
|
5765
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
5766
|
+
' white-space:nowrap;',
|
|
5767
|
+
'}',
|
|
5768
|
+
'.dsh-autorestart-settings__plugin-hint{',
|
|
5769
|
+
' grid-column:1 / -1;',
|
|
5770
|
+
' font-size:11px;',
|
|
5771
|
+
' line-height:1.4;',
|
|
5772
|
+
' color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));',
|
|
5773
|
+
'}',
|
|
5774
|
+
// A HOST THAT CANNOT RESTART (user decision, 2026-11-02): a permanent
|
|
5775
|
+
// strip at the top of the page. It borrows the error colour of the host
|
|
5776
|
+
// theme, so it reads as a warning in both light and dark themes.
|
|
5777
|
+
'.dsh-autorestart-settings__banner{',
|
|
5778
|
+
' display:flex;',
|
|
5779
|
+
' flex-direction:column;',
|
|
5780
|
+
' gap:8px;',
|
|
5781
|
+
' margin:0 0 12px;',
|
|
5782
|
+
' padding:10px 12px;',
|
|
5783
|
+
' border:1px solid var(--dsw-alias-state-error-border,var(--dsw-alias-border-l1));',
|
|
5784
|
+
' border-left:3px solid var(--dsw-alias-state-error-fg,#c62828);',
|
|
5785
|
+
' border-radius:8px;',
|
|
5786
|
+
' background:var(--dsw-alias-bg-layer-1);',
|
|
5787
|
+
'}',
|
|
5788
|
+
'.dsh-autorestart-settings__banner-text{',
|
|
5789
|
+
' color:var(--dsw-alias-label-primary);',
|
|
5790
|
+
' font-size:13px;',
|
|
5791
|
+
' line-height:19px;',
|
|
5792
|
+
'}',
|
|
5793
|
+
'.dsh-autorestart-settings__banner-actions{display:flex;flex-wrap:wrap;gap:8px;}',
|
|
5794
|
+
'.dsh-autorestart-settings__banner-note,.dsh-autorestart-settings__card-note{',
|
|
5795
|
+
' color:var(--dsw-alias-state-error-fg,var(--dsw-alias-label-secondary));',
|
|
5796
|
+
' font-size:12px;',
|
|
5797
|
+
' line-height:18px;',
|
|
5798
|
+
'}',
|
|
5799
|
+
// THE ONE-TIME CARD: the same shape as the install prompt, so the user
|
|
5800
|
+
// recognises "a question, then it is gone".
|
|
5801
|
+
'.dsh-autorestart-settings__card{',
|
|
5802
|
+
' display:flex;',
|
|
5803
|
+
' flex-direction:column;',
|
|
5804
|
+
' gap:8px;',
|
|
5805
|
+
' margin:0 0 12px;',
|
|
5806
|
+
' padding:14px 16px;',
|
|
5807
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
5808
|
+
' border-radius:12px;',
|
|
5809
|
+
' background:var(--dsw-alias-bg-layer-1);',
|
|
5810
|
+
' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
|
|
5811
|
+
'}',
|
|
5812
|
+
'.dsh-autorestart-settings__card-title{',
|
|
5813
|
+
' margin:0;',
|
|
5814
|
+
' font-size:14px;',
|
|
5815
|
+
' font-weight:600;',
|
|
5816
|
+
' line-height:20px;',
|
|
5817
|
+
' color:var(--dsw-alias-label-primary);',
|
|
5818
|
+
'}',
|
|
5819
|
+
'.dsh-autorestart-settings__card-actions{display:flex;flex-wrap:wrap;gap:8px;}',
|
|
5820
|
+
// THE LINE UNDER THE FOLD TITLE: small, secondary, never a substitute for
|
|
5821
|
+
// the title itself.
|
|
5822
|
+
'.dsh-autorestart-settings__summary-note{',
|
|
5823
|
+
' display:block;',
|
|
5824
|
+
' margin-top:2px;',
|
|
5825
|
+
' font-size:12px;',
|
|
5826
|
+
' line-height:16px;',
|
|
5827
|
+
' font-weight:400;',
|
|
5828
|
+
' color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));',
|
|
5829
|
+
'}',
|
|
5830
|
+
// ONE RESCUE SCRIPT: its name, its real path, and a copy button — the
|
|
5831
|
+
// path is the only thing that helps when the host will not start.
|
|
5832
|
+
'.dsh-autorestart-settings__rescue-path{',
|
|
5833
|
+
' display:flex;',
|
|
5834
|
+
' flex-wrap:wrap;',
|
|
5835
|
+
' gap:8px;',
|
|
5836
|
+
' align-items:baseline;',
|
|
5837
|
+
' padding:6px 0;',
|
|
5838
|
+
'}',
|
|
5839
|
+
// THE DESKTOP ENTRY, DRAWN AS IT WILL LOOK (user report, 2026-11-02):
|
|
5840
|
+
// a tile with DSH on it and the file name next to it, so "what am I
|
|
5841
|
+
// looking for on my desktop?" is answered before the button is pressed.
|
|
5842
|
+
'.dsh-autorestart-settings__shortcut{',
|
|
5843
|
+
' display:flex;',
|
|
5844
|
+
' align-items:center;',
|
|
5845
|
+
' gap:8px;',
|
|
5846
|
+
'}',
|
|
5847
|
+
'.dsh-autorestart-settings__shortcut-icon{',
|
|
5848
|
+
' display:inline-flex;',
|
|
5849
|
+
' align-items:center;',
|
|
5850
|
+
' justify-content:center;',
|
|
5851
|
+
' width:26px;',
|
|
5852
|
+
' height:26px;',
|
|
5853
|
+
' flex:none;',
|
|
5854
|
+
' border-radius:6px;',
|
|
5855
|
+
' background:var(--dsw-alias-brand-primary,#4d6bfe);',
|
|
5856
|
+
' color:#fff;',
|
|
5857
|
+
' font-size:9px;',
|
|
5858
|
+
' font-weight:700;',
|
|
5859
|
+
' letter-spacing:.02em;',
|
|
5860
|
+
'}',
|
|
5861
|
+
'.dsh-autorestart-settings__shortcut-name{',
|
|
5862
|
+
' font-size:12.5px;',
|
|
5863
|
+
' line-height:18px;',
|
|
5864
|
+
' color:var(--dsw-alias-label-primary);',
|
|
5865
|
+
' word-break:break-all;',
|
|
5866
|
+
'}',
|
|
5867
|
+
// THE REAL PICTURE, WHEN THE HOST SENT ONE (2026-11-02): the tile stops
|
|
5868
|
+
// being a stand-in — no brand background, no 26px box — and shows the
|
|
5869
|
+
// bytes the desktop shortcut carries. 16px keeps it on the text baseline.
|
|
5870
|
+
'.dsh-autorestart-settings__shortcut-icon[data-icon-real="true"]{',
|
|
5871
|
+
' background:transparent;',
|
|
5872
|
+
' width:16px;',
|
|
5873
|
+
' height:16px;',
|
|
5874
|
+
' border-radius:2px;',
|
|
5875
|
+
'}',
|
|
5876
|
+
'.dsh-autorestart-settings__shortcut-icon-img{',
|
|
5877
|
+
' display:block;',
|
|
5878
|
+
' width:16px;',
|
|
5879
|
+
' height:16px;',
|
|
5880
|
+
' border-radius:2px;',
|
|
5881
|
+
' image-rendering:auto;',
|
|
5882
|
+
'}',
|
|
5883
|
+
// THE SECOND CONFIRMATION of a manual rollback: a block that stands
|
|
5884
|
+
// apart from the list, because it is a question, not a fact.
|
|
5885
|
+
'.dsh-autorestart-settings__confirm{',
|
|
5886
|
+
' display:flex;',
|
|
5887
|
+
' flex-direction:column;',
|
|
5888
|
+
' gap:6px;',
|
|
5889
|
+
' padding:10px 12px;',
|
|
5890
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
5891
|
+
' border-radius:8px;',
|
|
5892
|
+
' background:var(--dsw-alias-bg-l1);',
|
|
5893
|
+
'}',
|
|
4909
5894
|
// The folded "technical details": the command itself stays out of the
|
|
4910
5895
|
// way until somebody asks for it.
|
|
4911
5896
|
'.dsh-autorestart-settings__details{',
|
|
@@ -5417,9 +6402,110 @@
|
|
|
5417
6402
|
// a fresh page starts closed, so nobody leaves technical text open by
|
|
5418
6403
|
// accident.
|
|
5419
6404
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
5420
|
-
|
|
5421
|
-
//
|
|
5422
|
-
//
|
|
6405
|
+
// The two folds of the BACKUP group (user decision, 2026-11-02): the long
|
|
6406
|
+
// explanation and the per-copy technical rows are collapsed by default,
|
|
6407
|
+
// while every switch stays visible. Both use the neutral label
|
|
6408
|
+
// `settings.backup.details` (added with the user's approval).
|
|
6409
|
+
const [backupDetailsOpen, setBackupDetailsOpen] = useState(false)
|
|
6410
|
+
const [backupListDetailsOpen, setBackupListDetailsOpen] = useState(false)
|
|
6411
|
+
// THE WHOLE BACKUP BLOCK IS ONE FOLD (user decision, 2026-11-02): the
|
|
6412
|
+
// four switches stay visible, everything that only EXPLAINS the backups
|
|
6413
|
+
// (the count, the rules, the copy cards, the path and the per-plugin
|
|
6414
|
+
// recovery) lives behind `settings.backup.section`. Closed by default —
|
|
6415
|
+
// except on a boot the watchdog had to rescue, where the one copy that
|
|
6416
|
+
// matters must not be hidden (see the fold below).
|
|
6417
|
+
const [backupSectionOpen, setBackupSectionOpen] = useState(false)
|
|
6418
|
+
// The RESCUE script paths on Windows: behind 详情, because the desktop
|
|
6419
|
+
// entry is the main door there (audit finding G1, 2026-11-02). Never
|
|
6420
|
+
// folded on the other platforms and never folded when there is no
|
|
6421
|
+
// desktop entry to press.
|
|
6422
|
+
const [rescuePathsOpen, setRescuePathsOpen] = useState(false)
|
|
6423
|
+
// THE ONE-TIME "this host cannot restart" CARD (user decision,
|
|
6424
|
+
// 2026-11-02). `restartCardPending` is the Host's answer ("read once per
|
|
6425
|
+
// host"), `restartCardDone` only hides it in this session right after
|
|
6426
|
+
// the click, and `restartFixNote` carries the reason of a failed
|
|
6427
|
+
// attempt. Nothing is ever written without the click.
|
|
6428
|
+
const [restartCardPending, setRestartCardPending] = useState(false)
|
|
6429
|
+
const [restartCardDone, setRestartCardDone] = useState(false)
|
|
6430
|
+
const [restartFixNote, setRestartFixNote] = useState('')
|
|
6431
|
+
// The command this page just wrote ('' = none). It hides the strip at
|
|
6432
|
+
// once and shows what is configured NOW, instead of waiting for the next
|
|
6433
|
+
// health poll — the user clicked a button and must see the result.
|
|
6434
|
+
const [restartApplied, setRestartApplied] = useState('')
|
|
6435
|
+
// THE MANUAL ROLLBACK (user decision, 2026-10-30). Asking is a STATE,
|
|
6436
|
+
// not a `window.confirm`: the card has to say WHICH copy, that the
|
|
6437
|
+
// current state is replaced, and that a restart follows. `null` = nobody
|
|
6438
|
+
// is asking, and only a confirmation ever calls the route.
|
|
6439
|
+
// THE COPY LIMIT (user decision, 2026-11-02): the number the user picks,
|
|
6440
|
+
// the confirmation that stands between a smaller number and deleted
|
|
6441
|
+
// copies, and what the route reported back afterwards.
|
|
6442
|
+
const [limitBusy, setLimitBusy] = useState(false)
|
|
6443
|
+
const [limitNote, setLimitNote] = useState('')
|
|
6444
|
+
const [limitAsk, setLimitAsk] = useState(null)
|
|
6445
|
+
// The value the select SHOWS: seeded from the payload, replaced by the
|
|
6446
|
+
// answer, so the number on screen is the number in force right after the
|
|
6447
|
+
// write instead of waiting for the next poll.
|
|
6448
|
+
const [limitValue, setLimitValue] = useState(null)
|
|
6449
|
+
|
|
6450
|
+
/**
|
|
6451
|
+
* WRITE THE COPY LIMIT (user decision, 2026-11-02).
|
|
6452
|
+
*
|
|
6453
|
+
* The route answers with the copies it really deleted and with
|
|
6454
|
+
* `keptWillExceedRequest`, so this reports the truth instead of echoing
|
|
6455
|
+
* the number the user asked for. A refusal carries the Host's `reason`
|
|
6456
|
+
* and is shown as-is — rewriting it would hide which value was refused.
|
|
6457
|
+
*/
|
|
6458
|
+
const submitBackupLimit = async (wanted) => {
|
|
6459
|
+
const url = backupLimitUrlFor(info)
|
|
6460
|
+
// A WRITE THAT CANNOT BE ADDRESSED MUST SAY SO (user report,
|
|
6461
|
+
// 2026-11-02): this used to `return` silently, which is exactly how
|
|
6462
|
+
// "2 份改 3 份不生效" looked from the outside — no request, no message,
|
|
6463
|
+
// no change. The default route makes this branch unreachable, and if a
|
|
6464
|
+
// future change makes it reachable again the page says it failed
|
|
6465
|
+
// instead of pretending nothing happened.
|
|
6466
|
+
if (url === '') {
|
|
6467
|
+
setLimitNote(t('settings.backup.limitFailed'))
|
|
6468
|
+
return
|
|
6469
|
+
}
|
|
6470
|
+
setLimitBusy(true)
|
|
6471
|
+
setLimitNote('')
|
|
6472
|
+
try {
|
|
6473
|
+
const response = await fetch(url, {
|
|
6474
|
+
method: 'POST',
|
|
6475
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
6476
|
+
body: JSON.stringify({ maxBackups: wanted }),
|
|
6477
|
+
})
|
|
6478
|
+
const answer = await response.json().catch(() => null)
|
|
6479
|
+
setLimitAsk(null)
|
|
6480
|
+
if (response.ok !== true || answer === null || answer.ok !== true) {
|
|
6481
|
+
setLimitNote(answer && typeof answer.reason === 'string' && answer.reason !== ''
|
|
6482
|
+
? answer.reason
|
|
6483
|
+
: t('settings.backup.limitFailed'))
|
|
6484
|
+
return
|
|
6485
|
+
}
|
|
6486
|
+
const removed = Array.isArray(answer.removed) ? answer.removed.length : 0
|
|
6487
|
+
const kept = Number.isFinite(Number(answer.kept)) ? Number(answer.kept) : wanted
|
|
6488
|
+
setLimitValue(Number.isFinite(Number(answer.maxBackups)) ? Number(answer.maxBackups) : wanted)
|
|
6489
|
+
// BOTH READINGS MOVE TOGETHER (user report, 2026-11-02): the select
|
|
6490
|
+
// shows the confirmed number at once, and this poll brings the two
|
|
6491
|
+
// lines above it to the same value instead of leaving them saying the
|
|
6492
|
+
// old limit until the next 2 s tick.
|
|
6493
|
+
void pollOnce()
|
|
6494
|
+
setLimitNote(answer.keptWillExceedRequest === true
|
|
6495
|
+
? fill(t('settings.backup.limitMore'), { requested: String(wanted), kept: String(kept) })
|
|
6496
|
+
: fill(t('settings.backup.limitDeleted'), { removed: String(removed), kept: String(kept) }))
|
|
6497
|
+
} catch {
|
|
6498
|
+
setLimitNote(t('settings.backup.limitFailed'))
|
|
6499
|
+
} finally {
|
|
6500
|
+
setLimitBusy(false)
|
|
6501
|
+
}
|
|
6502
|
+
}
|
|
6503
|
+
const [rollbackAsk, setRollbackAsk] = useState(null)
|
|
6504
|
+
const [rollbackBusy, setRollbackBusy] = useState(false)
|
|
6505
|
+
const [rollbackNote, setRollbackNote] = useState('')
|
|
6506
|
+
const t = makeTranslate(props && typeof props.t === 'function' ? props.t : translate)
|
|
6507
|
+
// THE RECEIPT OF A SETTINGS CHANGE, on screen right now. It belongs to
|
|
6508
|
+
// THIS panel (it is not a window overlay), so it is read here and
|
|
5423
6509
|
// drawn here.
|
|
5424
6510
|
const saveNotice = useSaveNotice()
|
|
5425
6511
|
|
|
@@ -5506,12 +6592,55 @@
|
|
|
5506
6592
|
const restartInfo = info && typeof info === 'object' && info.restart && typeof info.restart === 'object'
|
|
5507
6593
|
? info.restart
|
|
5508
6594
|
: { mode: '', command: '', written: false, canRevert: false, previous: null }
|
|
6595
|
+
// CAN THIS HOST RESTART AT ALL? (user report, 2026-11-02)
|
|
6596
|
+
//
|
|
6597
|
+
// A hosted `dsh web` with no `command` restart mode is a host this
|
|
6598
|
+
// plugin cannot restart and will not back up before a restart — i.e. it
|
|
6599
|
+
// does nothing for the user. That used to be silent: the page looked
|
|
6600
|
+
// perfectly healthy. Both halves are required (`mode: 'command'` AND a
|
|
6601
|
+
// command), and the desktop app is never told this: it relaunches
|
|
6602
|
+
// itself.
|
|
6603
|
+
const restartModeValue = typeof restartInfo.mode === 'string' ? restartInfo.mode : ''
|
|
6604
|
+
const restartCommandValue = typeof restartInfo.command === 'string' ? restartInfo.command : ''
|
|
6605
|
+
const restartCapable = restartModeValue === 'command' && restartCommandValue.trim() !== ''
|
|
6606
|
+
// Only a Host that TOLD us about its restart settings can be accused of
|
|
6607
|
+
// having none: an older build (or the very first paint, before the state
|
|
6608
|
+
// arrived) must not produce a red strip.
|
|
6609
|
+
const restartKnown = Boolean(info && typeof info === 'object' && info.restart && typeof info.restart === 'object')
|
|
6610
|
+
const restartMissing = restartKnown && hostIsDesktop !== true && !restartCapable && restartApplied === ''
|
|
6611
|
+
// WHICH PROFILE WOULD BE BACKED UP (2026-11-02): the Host resolves it,
|
|
6612
|
+
// and `dir: null` means "more than one profile matched / none did" — so
|
|
6613
|
+
// backups and rollbacks are paused. The browser cannot look at the disk,
|
|
6614
|
+
// so this is only a DISPLAY of the Host's answer; when the Host does not
|
|
6615
|
+
// send it (older builds), nothing is claimed.
|
|
6616
|
+
const profileInfo = (() => {
|
|
6617
|
+
const source = info && typeof info === 'object' ? info.profile : null
|
|
6618
|
+
if (source && typeof source === 'object') return source
|
|
6619
|
+
if (typeof source === 'string') return { dir: source }
|
|
6620
|
+
const flat = info && typeof info === 'object' ? info.profileDir : null
|
|
6621
|
+
if (flat && typeof flat === 'object') return flat
|
|
6622
|
+
if (typeof flat === 'string') return { dir: flat }
|
|
6623
|
+
const fromRestart = info && typeof info === 'object' && info.lastRestart
|
|
6624
|
+
&& typeof info.lastRestart === 'object' && info.lastRestart.snapshot
|
|
6625
|
+
&& typeof info.lastRestart.snapshot === 'object'
|
|
6626
|
+
? info.lastRestart.snapshot.profile
|
|
6627
|
+
: null
|
|
6628
|
+
return fromRestart && typeof fromRestart === 'object' ? fromRestart : null
|
|
6629
|
+
})()
|
|
6630
|
+
const profileUnknown = profileInfo !== null
|
|
6631
|
+
&& (profileInfo.dir === null || profileInfo.dir === undefined || String(profileInfo.dir) === '')
|
|
5509
6632
|
// THE BACKUPS ON DISK: the count, the local time, the size, whether the
|
|
5510
6633
|
// plugins are in it and where it lives. The Host reads the directory.
|
|
5511
6634
|
const backupsInfo = info && typeof info === 'object' && info.backups && typeof info.backups === 'object'
|
|
5512
6635
|
? info.backups
|
|
5513
6636
|
: { dir: '', max: 0, count: 0, items: [] }
|
|
6637
|
+
// WHAT HAPPENED TO THE LAST RESTART REQUEST, computed once (2026-11-03).
|
|
6638
|
+
const cancelledRestart = cancelledRestartNote(info, props && typeof props.t === 'function' ? props.t : translate)
|
|
5514
6639
|
const backupItems = Array.isArray(backupsInfo.items) ? backupsInfo.items : []
|
|
6640
|
+
// WHICH COPY THE ONE SENTENCE BELONGS TO (user decision, 2026-11-02):
|
|
6641
|
+
// resolved ONCE from the copies' own records, so the sentence follows the
|
|
6642
|
+
// copy and not the row it happens to be drawn in.
|
|
6643
|
+
const startupCopy = startupCopyName(backupItems)
|
|
5515
6644
|
// THE RESCUE KIT: the two scripts and where they are, as the Host
|
|
5516
6645
|
// resolved them. The browser never guesses a path.
|
|
5517
6646
|
const rescuePaths = rescuePathsFor(info)
|
|
@@ -5520,6 +6649,54 @@
|
|
|
5520
6649
|
: {}
|
|
5521
6650
|
// Where a desktop shortcut could go ('' = there is no desktop here).
|
|
5522
6651
|
const rescueDesktop = typeof rescueKit.desktopDir === 'string' ? rescueKit.desktopDir.trim() : ''
|
|
6652
|
+
// WHICH PLATFORM the rescue kit was resolved for, so the page can SAY
|
|
6653
|
+
// "there is no desktop on a hosted web / server install" instead of
|
|
6654
|
+
// hiding the whole block (user report, 2026-11-02).
|
|
6655
|
+
const rescuePlatform = typeof rescueKit.platform === 'string' ? rescueKit.platform : ''
|
|
6656
|
+
const rescueIsWindows = rescuePlatform === 'win32'
|
|
6657
|
+
// THE RESCUE ENTRY ON A HOST WITHOUT A DESKTOP (plan B, user decision
|
|
6658
|
+
// 2026-11-02). The desktop build offers a `.lnk` on the desktop; a hosted
|
|
6659
|
+
// web / server host has no desktop, so the same entry is a COPYABLE
|
|
6660
|
+
// COMMAND — the restore script the Host already resolved. '' means the
|
|
6661
|
+
// Host has none, and the page says so instead of inventing a path.
|
|
6662
|
+
const rescueCommand = (() => {
|
|
6663
|
+
const entry = rescuePaths.find((item) => item && item.key === 'restoreProfile')
|
|
6664
|
+
return entry && typeof entry.path === 'string' ? entry.path.trim() : ''
|
|
6665
|
+
})()
|
|
6666
|
+
// The file name of the shortcut on the desktop (a `.lnk` since
|
|
6667
|
+
// 2026-11-02 — the host names it, so page and disk cannot disagree).
|
|
6668
|
+
const rescueShortcutName = typeof rescueKit.shortcutName === 'string' && rescueKit.shortcutName !== ''
|
|
6669
|
+
? rescueKit.shortcutName
|
|
6670
|
+
: 'DSH 出事了双击这里.lnk'
|
|
6671
|
+
// The icon the shortcut carries: THIS PLUGIN'S OWN `assets/icon.ico`.
|
|
6672
|
+
// It used to be DSH's executable, which put the face of the very program
|
|
6673
|
+
// that was broken on the rescue shortcut. '' = this install ships no icon
|
|
6674
|
+
// of its own: the `.lnk` is still created, it just gets no IconLocation —
|
|
6675
|
+
// never somebody else's face, and nothing is written at runtime.
|
|
6676
|
+
const rescueIconPath = typeof rescueKit.iconPath === 'string' ? rescueKit.iconPath.trim() : ''
|
|
6677
|
+
// THE SAME PICTURE THE DESKTOP CARRIES, DRAWN HERE (user requirement,
|
|
6678
|
+
// 2026-11-02): the row used to show a made-up "DSH" tile, so the page and
|
|
6679
|
+
// the `.lnk` on the desktop could look like two different things. The Host
|
|
6680
|
+
// now sends the icon as a data URI built from THE SAME BYTES as the file
|
|
6681
|
+
// it points `IconLocation` at, and this page renders that string as-is —
|
|
6682
|
+
// no base64 lives in this bundle, so the two copies cannot drift apart.
|
|
6683
|
+
//
|
|
6684
|
+
// ONLY A PNG DATA URI IS ACCEPTED. Anything else (another media type, a
|
|
6685
|
+
// remote URL, a missing field on an older Host) is treated as "no picture"
|
|
6686
|
+
// and the row falls back to its text tile: a wrong `src` would render a
|
|
6687
|
+
// broken-image marker, which is worse than saying nothing.
|
|
6688
|
+
const rescueIconDataUri = (() => {
|
|
6689
|
+
const raw = typeof rescueKit.iconDataUri === 'string' ? rescueKit.iconDataUri.trim() : ''
|
|
6690
|
+
return raw.startsWith('data:image/png;base64,') ? raw : ''
|
|
6691
|
+
})()
|
|
6692
|
+
// ONE SOURCE FOR THE ICON (user decision, 2026-11-02): the desktop `.lnk`
|
|
6693
|
+
// and this tile must come from the SAME host answer, and both must say
|
|
6694
|
+
// the same thing when there is none. The name travels with the path so
|
|
6695
|
+
// the page can prove which file it used — it never invents an icon.
|
|
6696
|
+
const rescueIconName = typeof rescueKit.iconName === 'string' && rescueKit.iconName !== ''
|
|
6697
|
+
? rescueKit.iconName
|
|
6698
|
+
: (rescueIconPath === '' ? '' : rescueIconPath.split(/[\\/]/).pop())
|
|
6699
|
+
const rescueIconAvailable = rescueKit.iconAvailable === true || rescueIconPath !== ''
|
|
5523
6700
|
// Whether it is already there (then the button says so).
|
|
5524
6701
|
const rescueShortcutExists = rescueKit.shortcutExists === true
|
|
5525
6702
|
// WHAT THIS MACHINE CAN DO: the automatic rollback needs a watchdog,
|
|
@@ -5544,6 +6721,18 @@
|
|
|
5544
6721
|
const watchdogLast = watchdog.report && typeof watchdog.report === 'object' && typeof watchdog.report.result === 'string'
|
|
5545
6722
|
? watchdog.report.result
|
|
5546
6723
|
: ''
|
|
6724
|
+
// DID THE WATCHDOG ACT, OR DID IT STAND DOWN FOR WANT OF A TRUSTED COPY?
|
|
6725
|
+
// (2026-11-01) `failed` alone is not enough for the user: the strict mode
|
|
6726
|
+
// deliberately does nothing when no copy qualifies, and that has to be
|
|
6727
|
+
// visible here — including WHICH copies were refused — otherwise "it did
|
|
6728
|
+
// not rescue me" and "it had nothing to rescue me with" look the same.
|
|
6729
|
+
const watchdogStrict = Boolean(
|
|
6730
|
+
watchdog.report && typeof watchdog.report === 'object' && watchdog.report.strictMode === true,
|
|
6731
|
+
)
|
|
6732
|
+
const watchdogRestoredFrom = watchdog.report && typeof watchdog.report === 'object'
|
|
6733
|
+
&& typeof watchdog.report.restoredFrom === 'string' && watchdog.report.restoredFrom !== ''
|
|
6734
|
+
? watchdog.report.restoredFrom
|
|
6735
|
+
: ''
|
|
5547
6736
|
useEffect(() => {
|
|
5548
6737
|
if (!schedulerNotice) return
|
|
5549
6738
|
// The user has read it: the Host remembers that and stops asking.
|
|
@@ -5555,6 +6744,21 @@
|
|
|
5555
6744
|
// The Host will ask again next time — no harm.
|
|
5556
6745
|
})
|
|
5557
6746
|
}, [schedulerNotice])
|
|
6747
|
+
// IS THE ONE-TIME "cannot restart" CARD STILL UNREAD? (2026-11-02)
|
|
6748
|
+
//
|
|
6749
|
+
// Same ledger as the feature announcement (same route, same key style,
|
|
6750
|
+
// same "answered once per host" rule) — the card is a reminder, not a
|
|
6751
|
+
// new mechanism.
|
|
6752
|
+
useEffect(() => {
|
|
6753
|
+
if (!restartMissing) return undefined
|
|
6754
|
+
let alive = true
|
|
6755
|
+
void (async () => {
|
|
6756
|
+
const pending = await fetchAnnouncements().catch(() => [])
|
|
6757
|
+
if (!alive) return
|
|
6758
|
+
setRestartCardPending(pending.some((entry) => entry.feature === ANNOUNCED_RESTART))
|
|
6759
|
+
})()
|
|
6760
|
+
return () => { alive = false }
|
|
6761
|
+
}, [restartMissing])
|
|
5558
6762
|
const supervisorPending = supervisorStatus !== null
|
|
5559
6763
|
&& supervisorStatus.detail !== null
|
|
5560
6764
|
&& typeof supervisorStatus.detail === 'object'
|
|
@@ -5633,6 +6837,81 @@
|
|
|
5633
6837
|
}
|
|
5634
6838
|
}
|
|
5635
6839
|
|
|
6840
|
+
/**
|
|
6841
|
+
* THE ONE-CLICK REPAIR of a host that cannot restart (user decision,
|
|
6842
|
+
* 2026-11-02 — option A).
|
|
6843
|
+
*
|
|
6844
|
+
* THREE STEPS, and the click is the only reason anything is written:
|
|
6845
|
+
* 1. `dry-run`: the Host derives the command that starts this host
|
|
6846
|
+
* again and validates it. NOTHING is written;
|
|
6847
|
+
* 2. `apply` with `confirmed: true`: NOW the command is written into
|
|
6848
|
+
* the profile configuration (the Host refuses without it);
|
|
6849
|
+
* 3. the announcement is marked as read, so the card does not come
|
|
6850
|
+
* back, and the undo stays available (`revert`).
|
|
6851
|
+
*
|
|
6852
|
+
* A failure is never silent: the Host's own reason is shown in the
|
|
6853
|
+
* user's words, and the manual path (the supervisor group's
|
|
6854
|
+
* "derive / use this command") stays where it is.
|
|
6855
|
+
*/
|
|
6856
|
+
const fixRestartCommand = async () => {
|
|
6857
|
+
if (busy) return
|
|
6858
|
+
setBusy(true)
|
|
6859
|
+
setError('')
|
|
6860
|
+
setRestartFixNote('')
|
|
6861
|
+
try {
|
|
6862
|
+
const dryResponse = await fetch(supervisorUrlFor(info), {
|
|
6863
|
+
method: 'POST',
|
|
6864
|
+
headers: { 'content-type': 'application/json' },
|
|
6865
|
+
body: JSON.stringify({ action: 'dry-run' }),
|
|
6866
|
+
})
|
|
6867
|
+
const dryBody = await dryResponse.json().catch(() => null)
|
|
6868
|
+
const plan = dryBody && typeof dryBody === 'object' && dryBody.plan && typeof dryBody.plan === 'object'
|
|
6869
|
+
? dryBody.plan
|
|
6870
|
+
: null
|
|
6871
|
+
if (!dryResponse.ok || !plan || plan.ok !== true) {
|
|
6872
|
+
const reason = plan && typeof plan.reason === 'string' ? plan.reason.trim() : ''
|
|
6873
|
+
setRestartFixNote(reason === ''
|
|
6874
|
+
? t('settings.supervisor.noCommand')
|
|
6875
|
+
: t('settings.supervisor.reason.' + reason))
|
|
6876
|
+
return
|
|
6877
|
+
}
|
|
6878
|
+
const applyResponse = await fetch(supervisorUrlFor(info), {
|
|
6879
|
+
method: 'POST',
|
|
6880
|
+
headers: { 'content-type': 'application/json' },
|
|
6881
|
+
body: JSON.stringify({ action: 'apply', confirmed: true }),
|
|
6882
|
+
})
|
|
6883
|
+
const applied = await applyResponse.json().catch(() => null)
|
|
6884
|
+
if (!applyResponse.ok || !applied || typeof applied !== 'object' || applied.ok !== true) {
|
|
6885
|
+
const message = applied && typeof applied === 'object' && applied.error
|
|
6886
|
+
? String(applied.error)
|
|
6887
|
+
: String(applyResponse.status)
|
|
6888
|
+
setRestartFixNote(fill(t('supervisor.error'), { message }))
|
|
6889
|
+
return
|
|
6890
|
+
}
|
|
6891
|
+
await acknowledgeAnnouncement(ANNOUNCED_RESTART).catch(() => {})
|
|
6892
|
+
setRestartCardPending(false)
|
|
6893
|
+
setRestartCardDone(true)
|
|
6894
|
+
// WHAT IS CONFIGURED NOW, straight from the Host's answer (never
|
|
6895
|
+
// from what we hoped it wrote).
|
|
6896
|
+
const written = applied.writes && typeof applied.writes === 'object'
|
|
6897
|
+
&& typeof applied.writes.restartCommand === 'string'
|
|
6898
|
+
? applied.writes.restartCommand
|
|
6899
|
+
: ''
|
|
6900
|
+
setRestartApplied(written === '' ? t('settings.supervisor.noCommand') : written)
|
|
6901
|
+
} catch (failure) {
|
|
6902
|
+
setRestartFixNote(fill(t('supervisor.error'), { message: describeError(failure) }))
|
|
6903
|
+
} finally {
|
|
6904
|
+
setBusy(false)
|
|
6905
|
+
}
|
|
6906
|
+
}
|
|
6907
|
+
|
|
6908
|
+
/** "Later": the card does not come back (the strip stays). */
|
|
6909
|
+
const dismissRestartCard = () => {
|
|
6910
|
+
setRestartCardPending(false)
|
|
6911
|
+
setRestartCardDone(true)
|
|
6912
|
+
void acknowledgeAnnouncement(ANNOUNCED_RESTART).catch(() => {})
|
|
6913
|
+
}
|
|
6914
|
+
|
|
5636
6915
|
/**
|
|
5637
6916
|
* Write one change into the profile configuration.
|
|
5638
6917
|
*
|
|
@@ -5645,6 +6924,46 @@
|
|
|
5645
6924
|
*
|
|
5646
6925
|
* @param {object} changes the keys to write
|
|
5647
6926
|
*/
|
|
6927
|
+
/**
|
|
6928
|
+
* THE MANUAL ROLLBACK (user decision, 2026-10-30).
|
|
6929
|
+
*
|
|
6930
|
+
* It is ONLY ever called from the confirmation card: the button on a row
|
|
6931
|
+
* opens the question, and nothing is posted until the user says yes. The
|
|
6932
|
+
* route takes a backup NAME (never a path) and does the rest on the Host
|
|
6933
|
+
* — save the current state, restore that copy, request a restart.
|
|
6934
|
+
*
|
|
6935
|
+
* @param {{ name?: string, atLocal?: string|null }|null} ask what the user picked
|
|
6936
|
+
*/
|
|
6937
|
+
const rollbackTo = async (ask) => {
|
|
6938
|
+
const name = ask && typeof ask.name === 'string' ? ask.name : ''
|
|
6939
|
+
if (name === '' || rollbackBusy === true) return
|
|
6940
|
+
setRollbackBusy(true)
|
|
6941
|
+
setRollbackNote('')
|
|
6942
|
+
try {
|
|
6943
|
+
const response = await fetch(rollbackUrlFor(info), {
|
|
6944
|
+
method: 'POST',
|
|
6945
|
+
headers: { 'content-type': 'application/json' },
|
|
6946
|
+
body: JSON.stringify({ name }),
|
|
6947
|
+
})
|
|
6948
|
+
const body = await response.json().catch(() => null)
|
|
6949
|
+
if (!response.ok || !body || typeof body !== 'object' || body.ok !== true) {
|
|
6950
|
+
const message = body && typeof body === 'object'
|
|
6951
|
+
? String(body.error || body.message || response.status)
|
|
6952
|
+
: String(response.status)
|
|
6953
|
+
setRollbackNote(fill(t('settings.backup.rollbackFailed'), { message }))
|
|
6954
|
+
return
|
|
6955
|
+
}
|
|
6956
|
+
// The copy is back and a restart has been requested: the card goes
|
|
6957
|
+
// away, and the panel says what is happening right now.
|
|
6958
|
+
setRollbackAsk(null)
|
|
6959
|
+
setRollbackNote(t('settings.backup.rollbackDone'))
|
|
6960
|
+
} catch (failure) {
|
|
6961
|
+
setRollbackNote(fill(t('settings.backup.rollbackFailed'), { message: describeError(failure) }))
|
|
6962
|
+
} finally {
|
|
6963
|
+
setRollbackBusy(false)
|
|
6964
|
+
}
|
|
6965
|
+
}
|
|
6966
|
+
|
|
5648
6967
|
const save = async (changes) => {
|
|
5649
6968
|
if (busy) return
|
|
5650
6969
|
setBusy(true)
|
|
@@ -5757,11 +7076,29 @@
|
|
|
5757
7076
|
*
|
|
5758
7077
|
* ONLY because the user pressed the button: the settings page never
|
|
5759
7078
|
* writes anything to the desktop by itself.
|
|
7079
|
+
*
|
|
7080
|
+
* THE FAILURE IS CLASSIFIED (2026-11-02): "the shortcut was not placed"
|
|
7081
|
+
* is not enough — a policy that forbids it, a desktop that does not
|
|
7082
|
+
* exist and a write that broke are three different situations, and only
|
|
7083
|
+
* one of them is worth trying again. All three fall back to the manual
|
|
7084
|
+
* way, and the page says so with the real script path.
|
|
5760
7085
|
*/
|
|
5761
7086
|
const createRescueShortcut = async () => {
|
|
5762
7087
|
if (busy) return
|
|
5763
7088
|
setBusy(true)
|
|
5764
7089
|
setRescueNote('')
|
|
7090
|
+
const manual = typeof rescueKit.restoreProfile === 'string' ? rescueKit.restoreProfile.trim() : ''
|
|
7091
|
+
const failureText = (kind, message) => {
|
|
7092
|
+
const kindKey = kind === 'no-desktop'
|
|
7093
|
+
? 'settings.rescue.noDesktop'
|
|
7094
|
+
: kind === 'blocked'
|
|
7095
|
+
? 'settings.rescue.shortcutBlocked'
|
|
7096
|
+
: 'settings.rescue.shortcutWriteFailed'
|
|
7097
|
+
const head = kindKey === 'settings.rescue.shortcutWriteFailed'
|
|
7098
|
+
? fill(t(kindKey), { message })
|
|
7099
|
+
: t(kindKey)
|
|
7100
|
+
return manual === '' ? head : head + ' ' + manual
|
|
7101
|
+
}
|
|
5765
7102
|
try {
|
|
5766
7103
|
const response = await fetch(rescueUrlFor(info), {
|
|
5767
7104
|
method: 'POST',
|
|
@@ -5770,8 +7107,9 @@
|
|
|
5770
7107
|
})
|
|
5771
7108
|
const body = await response.json().catch(() => null)
|
|
5772
7109
|
if (!response.ok) {
|
|
7110
|
+
const kind = body && typeof body === 'object' && typeof body.errorKind === 'string' ? body.errorKind : ''
|
|
5773
7111
|
const message = body && typeof body === 'object' && body.error ? body.error : String(response.status)
|
|
5774
|
-
setRescueNote(fill(t('settings.rescue.shortcutFailed'), { message }))
|
|
7112
|
+
setRescueNote(kind === '' ? fill(t('settings.rescue.shortcutFailed'), { message }) : failureText(kind, message))
|
|
5775
7113
|
return
|
|
5776
7114
|
}
|
|
5777
7115
|
setRescueNote(t('settings.rescue.shortcutDone'))
|
|
@@ -5812,10 +7150,136 @@
|
|
|
5812
7150
|
'div',
|
|
5813
7151
|
{ className: 'dsh-autorestart-settings' },
|
|
5814
7152
|
// THE RECEIPT: a `sticky` strip at the TOP of this panel. It is a
|
|
7153
|
+
// WHAT HAPPENED TO THE RESTART YOU ASKED FOR (user report, 2026-11-03): it waited
|
|
7154
|
+
// hours, a host restart cancelled it, and the page looked as if nothing had ever
|
|
7155
|
+
// been requested. It comes first, because the next thing the user wants is to ask
|
|
7156
|
+
// again — and that button is right below.
|
|
7157
|
+
cancelledRestart ? h(
|
|
7158
|
+
'div',
|
|
7159
|
+
{
|
|
7160
|
+
className: 'dsh-autorestart-settings__banner',
|
|
7161
|
+
'data-dsh-autorestart-restart': 'cancelled-note',
|
|
7162
|
+
role: 'status',
|
|
7163
|
+
},
|
|
7164
|
+
h(
|
|
7165
|
+
'span',
|
|
7166
|
+
{ className: 'dsh-autorestart-settings__banner-text', 'data-dsh-autorestart-restart': 'cancelled-text' },
|
|
7167
|
+
cancelledRestart.text,
|
|
7168
|
+
),
|
|
7169
|
+
) : null,
|
|
5815
7170
|
// child of the panel (the DOM ancestor of the message IS the
|
|
5816
7171
|
// settings section), so it can not float over the conversation area
|
|
5817
7172
|
// again — and it stays visible while the settings are scrolled.
|
|
5818
7173
|
saveNoticeBar(t, saveNotice),
|
|
7174
|
+
// ③ THE RESULT OF THE CLICK: the command that is configured NOW. It is
|
|
7175
|
+
// shown where the strip was, so "it worked" is visible without opening
|
|
7176
|
+
// a fold and without waiting for the next poll.
|
|
7177
|
+
restartApplied !== ''
|
|
7178
|
+
? h(
|
|
7179
|
+
'div',
|
|
7180
|
+
{ className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-restart-fixed': 'true' },
|
|
7181
|
+
h(
|
|
7182
|
+
'span',
|
|
7183
|
+
{ className: 'dsh-autorestart-settings__label', 'data-dsh-autorestart-restart-fixed': 'label' },
|
|
7184
|
+
t('settings.supervisor.command'),
|
|
7185
|
+
),
|
|
7186
|
+
h(
|
|
7187
|
+
'code',
|
|
7188
|
+
{ className: 'dsh-autorestart-settings__value', 'data-dsh-autorestart-restart-fixed': 'command' },
|
|
7189
|
+
restartApplied,
|
|
7190
|
+
),
|
|
7191
|
+
)
|
|
7192
|
+
: null,
|
|
7193
|
+
// ① A HOST THAT CANNOT RESTART, SAID AT THE TOP OF THE PAGE (user
|
|
7194
|
+
// decision, 2026-11-02 — option A).
|
|
7195
|
+
//
|
|
7196
|
+
// Without a `command` restart mode the plugin does nothing at all, and
|
|
7197
|
+
// that used to look exactly like a healthy install. The strip stays
|
|
7198
|
+
// while it is true, and it carries the ONE action that fixes it. It
|
|
7199
|
+
// never writes anything by itself: the button is the confirmation.
|
|
7200
|
+
restartMissing
|
|
7201
|
+
? h(
|
|
7202
|
+
'div',
|
|
7203
|
+
{
|
|
7204
|
+
className: 'dsh-autorestart-settings__banner',
|
|
7205
|
+
role: 'status',
|
|
7206
|
+
'data-dsh-autorestart-restart-missing': 'true',
|
|
7207
|
+
},
|
|
7208
|
+
h(
|
|
7209
|
+
'span',
|
|
7210
|
+
{ className: 'dsh-autorestart-settings__banner-text', 'data-dsh-autorestart-restart-missing': 'text' },
|
|
7211
|
+
t('settings.restartBanner.title'),
|
|
7212
|
+
),
|
|
7213
|
+
h(
|
|
7214
|
+
'div',
|
|
7215
|
+
{ className: 'dsh-autorestart-settings__banner-actions' },
|
|
7216
|
+
h(
|
|
7217
|
+
'button',
|
|
7218
|
+
{
|
|
7219
|
+
type: 'button',
|
|
7220
|
+
className: 'dsh-autorestart-settings__button',
|
|
7221
|
+
disabled: busy,
|
|
7222
|
+
'data-dsh-autorestart-restart-missing': 'fix',
|
|
7223
|
+
onClick: () => void fixRestartCommand(),
|
|
7224
|
+
},
|
|
7225
|
+
t('settings.restartBanner.fix'),
|
|
7226
|
+
),
|
|
7227
|
+
),
|
|
7228
|
+
restartFixNote === ''
|
|
7229
|
+
? null
|
|
7230
|
+
: h(
|
|
7231
|
+
'span',
|
|
7232
|
+
{ className: 'dsh-autorestart-settings__banner-note', 'data-dsh-autorestart-restart-missing': 'note' },
|
|
7233
|
+
restartFixNote,
|
|
7234
|
+
),
|
|
7235
|
+
)
|
|
7236
|
+
: null,
|
|
7237
|
+
// ② THE ONE-TIME CARD: same ledger as the feature announcement, so it
|
|
7238
|
+
// is shown once per host and never again after the user answered it.
|
|
7239
|
+
restartMissing && restartCardPending && !restartCardDone
|
|
7240
|
+
? h(
|
|
7241
|
+
'div',
|
|
7242
|
+
{ className: 'dsh-autorestart-settings__card', 'data-dsh-autorestart-restart-card': 'true' },
|
|
7243
|
+
h(
|
|
7244
|
+
'h3',
|
|
7245
|
+
{ className: 'dsh-autorestart-settings__card-title', 'data-dsh-autorestart-restart-card': 'title' },
|
|
7246
|
+
t('settings.restartBanner.title'),
|
|
7247
|
+
),
|
|
7248
|
+
h(
|
|
7249
|
+
'div',
|
|
7250
|
+
{ className: 'dsh-autorestart-settings__card-actions' },
|
|
7251
|
+
h(
|
|
7252
|
+
'button',
|
|
7253
|
+
{
|
|
7254
|
+
type: 'button',
|
|
7255
|
+
className: 'dsh-autorestart-settings__button',
|
|
7256
|
+
disabled: busy,
|
|
7257
|
+
'data-dsh-autorestart-restart-card': 'fix',
|
|
7258
|
+
onClick: () => void fixRestartCommand(),
|
|
7259
|
+
},
|
|
7260
|
+
t('settings.restartBanner.fix'),
|
|
7261
|
+
),
|
|
7262
|
+
h(
|
|
7263
|
+
'button',
|
|
7264
|
+
{
|
|
7265
|
+
type: 'button',
|
|
7266
|
+
className: 'dsh-autorestart-settings__button',
|
|
7267
|
+
disabled: busy,
|
|
7268
|
+
'data-dsh-autorestart-restart-card': 'later',
|
|
7269
|
+
onClick: () => dismissRestartCard(),
|
|
7270
|
+
},
|
|
7271
|
+
t('supervisor.later'),
|
|
7272
|
+
),
|
|
7273
|
+
),
|
|
7274
|
+
restartFixNote === ''
|
|
7275
|
+
? null
|
|
7276
|
+
: h(
|
|
7277
|
+
'span',
|
|
7278
|
+
{ className: 'dsh-autorestart-settings__card-note', 'data-dsh-autorestart-restart-card': 'note' },
|
|
7279
|
+
restartFixNote,
|
|
7280
|
+
),
|
|
7281
|
+
)
|
|
7282
|
+
: null,
|
|
5819
7283
|
// The panel is split into two titled groups: the RESTART settings
|
|
5820
7284
|
// come first, the SHUTDOWN settings after them. The two situations
|
|
5821
7285
|
// are different, so they do not share one flat list.
|
|
@@ -5898,7 +7362,26 @@
|
|
|
5898
7362
|
},
|
|
5899
7363
|
},
|
|
5900
7364
|
),
|
|
5901
|
-
|
|
7365
|
+
// THE EXPLANATION IS FOLDED (user decision, 2026-11-02): the switch
|
|
7366
|
+
// above stays on screen — only the words fold.
|
|
7367
|
+
h(
|
|
7368
|
+
'details',
|
|
7369
|
+
{
|
|
7370
|
+
className: 'dsh-autorestart-settings__details',
|
|
7371
|
+
'data-dsh-autorestart-backup': 'details',
|
|
7372
|
+
onToggle: (event) => {
|
|
7373
|
+
setBackupDetailsOpen(Boolean(event && event.target ? event.target.open : false))
|
|
7374
|
+
},
|
|
7375
|
+
},
|
|
7376
|
+
h('summary', { className: 'dsh-autorestart-settings__summary' }, t('settings.backup.details')),
|
|
7377
|
+
backupDetailsOpen
|
|
7378
|
+
? h(
|
|
7379
|
+
'span',
|
|
7380
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'content-hint' },
|
|
7381
|
+
t('settings.backup.hint'),
|
|
7382
|
+
)
|
|
7383
|
+
: null,
|
|
7384
|
+
),
|
|
5902
7385
|
),
|
|
5903
7386
|
h(
|
|
5904
7387
|
'label',
|
|
@@ -5923,28 +7406,188 @@
|
|
|
5923
7406
|
),
|
|
5924
7407
|
h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backupPlugins.hint')),
|
|
5925
7408
|
),
|
|
5926
|
-
//
|
|
5927
|
-
//
|
|
5928
|
-
//
|
|
5929
|
-
//
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
7409
|
+
// MORE THAN ONE PROFILE = NO BACKUPS AT ALL (2026-11-02): the Host
|
|
7410
|
+
// cannot tell which profile to copy, so backups and rollbacks are
|
|
7411
|
+
// paused. That is a fact about the whole block, so it sits ABOVE
|
|
7412
|
+
// the fold — and it is only ever shown when the Host actually said
|
|
7413
|
+
// so (`dir: null`); the browser never guesses.
|
|
7414
|
+
profileUnknown
|
|
7415
|
+
? h(
|
|
7416
|
+
'div',
|
|
7417
|
+
{ className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-backup': 'multi-profile' },
|
|
7418
|
+
h(
|
|
7419
|
+
'span',
|
|
7420
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'multi-profile-text' },
|
|
7421
|
+
t('settings.backup.multiProfile'),
|
|
7422
|
+
),
|
|
7423
|
+
)
|
|
7424
|
+
: null,
|
|
7425
|
+
// WHAT IS BACKED UP RIGHT NOW — one FOLD (user decision, 2026-11-02).
|
|
7426
|
+
//
|
|
7427
|
+
// The words that only EXPLAIN the backups (the count, the rules, the
|
|
7428
|
+
// copy list, the path and the per-plugin recovery) made the page
|
|
7429
|
+
// long enough that the switches were pushed out of sight. They are
|
|
7430
|
+
// all behind `settings.backup.section` now, closed by default — but
|
|
7431
|
+
// NOT closed on a boot the watchdog had to rescue: an intervention
|
|
7432
|
+
// is the one fact that must not be hidden, so the fold opens itself
|
|
7433
|
+
// and says so on its summary.
|
|
7434
|
+
(() => {
|
|
7435
|
+
const anyIntervened = backupItems.some((item) => Boolean(item && item.intervention === true))
|
|
7436
|
+
const sectionOpen = backupSectionOpen || anyIntervened
|
|
7437
|
+
return h(
|
|
7438
|
+
'details',
|
|
7439
|
+
{
|
|
7440
|
+
className: 'dsh-autorestart-settings__details',
|
|
7441
|
+
'data-dsh-autorestart-backup': 'section',
|
|
7442
|
+
...(sectionOpen ? { open: true } : {}),
|
|
7443
|
+
onToggle: (event) => {
|
|
7444
|
+
setBackupSectionOpen(Boolean(event && event.target ? event.target.open : false))
|
|
7445
|
+
},
|
|
7446
|
+
},
|
|
7447
|
+
h(
|
|
7448
|
+
'summary',
|
|
7449
|
+
{
|
|
7450
|
+
className: 'dsh-autorestart-settings__summary',
|
|
7451
|
+
'data-dsh-autorestart-backup': 'section-summary',
|
|
7452
|
+
},
|
|
7453
|
+
anyIntervened
|
|
7454
|
+
? t('settings.backup.section') + ' · ' + t('settings.backup.intervention.true')
|
|
7455
|
+
: t('settings.backup.section'),
|
|
7456
|
+
),
|
|
7457
|
+
// THE CONTENT IS ALWAYS IN THE TREE (only `<details>` hides it):
|
|
7458
|
+
// the panel is a few dozen nodes, and rendering them once keeps
|
|
7459
|
+
// the fold from re-mounting the buttons (and their state) every
|
|
7460
|
+
// time somebody opens it.
|
|
7461
|
+
h(
|
|
7462
|
+
'div',
|
|
7463
|
+
{ className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-backup': 'list' },
|
|
5933
7464
|
h(
|
|
5934
7465
|
'span',
|
|
5935
7466
|
{ className: 'dsh-autorestart-settings__label' },
|
|
5936
7467
|
t('settings.backup.list'),
|
|
5937
7468
|
),
|
|
7469
|
+
// TWO LINES, AND ONLY TWO (user report, 2026-11-02). This block used
|
|
7470
|
+
// to spend five lines on the same three facts — twice on the
|
|
7471
|
+
// retention limit, and once with the number spelled out inside a
|
|
7472
|
+
// sentence, which went stale the moment the dropdown changed it.
|
|
7473
|
+
// Line 1: how many copies there are and what they take on disk.
|
|
5938
7474
|
h(
|
|
5939
7475
|
'span',
|
|
5940
7476
|
{ className: 'dsh-autorestart-settings__value', 'data-dsh-autorestart-backup': 'count' },
|
|
5941
|
-
fill(t('settings.backup.count'), {
|
|
7477
|
+
fill(t('settings.backup.count'), {
|
|
7478
|
+
n: backupItems.length,
|
|
7479
|
+
size: formatBackupSize(
|
|
7480
|
+
backupItems.reduce((total, item) => total + (Number(item && item.bytes) || 0), 0),
|
|
7481
|
+
),
|
|
7482
|
+
}),
|
|
5942
7483
|
),
|
|
7484
|
+
// Line 2: the rule that bounds them, with the limit in force — the
|
|
7485
|
+
// dropdown below is what changes that number.
|
|
5943
7486
|
h(
|
|
5944
7487
|
'span',
|
|
5945
7488
|
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'keep' },
|
|
5946
7489
|
fill(t('settings.backup.keep'), { n: backupsInfo.max }),
|
|
5947
7490
|
),
|
|
7491
|
+
h(
|
|
7492
|
+
'span',
|
|
7493
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'kinds-local' },
|
|
7494
|
+
t('settings.backup.kindsLocal'),
|
|
7495
|
+
),
|
|
7496
|
+
h(
|
|
7497
|
+
'span',
|
|
7498
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'kinds-third-party' },
|
|
7499
|
+
t('settings.backup.kindsThirdParty'),
|
|
7500
|
+
),
|
|
7501
|
+
// THE COPY LIMIT IS EDITABLE HERE (user decision, 2026-11-02): a
|
|
7502
|
+
// dropdown of 1..10, defaulting to the value the Host reports. It
|
|
7503
|
+
// writes through the backup-limit route and the page re-reads the
|
|
7504
|
+
// state, so "the number shown" is always "the number in force".
|
|
7505
|
+
h(
|
|
7506
|
+
'div',
|
|
7507
|
+
{ className: 'dsh-autorestart-settings__backup-row', key: 'backup-limit' },
|
|
7508
|
+
h(
|
|
7509
|
+
'span',
|
|
7510
|
+
{ className: 'dsh-autorestart-settings__backup-label' },
|
|
7511
|
+
t('settings.backup.limitLabel'),
|
|
7512
|
+
),
|
|
7513
|
+
h(
|
|
7514
|
+
'select',
|
|
7515
|
+
{
|
|
7516
|
+
className: 'dsh-autorestart-settings__select',
|
|
7517
|
+
'data-dsh-autorestart-backup': 'limit-select',
|
|
7518
|
+
'data-dsh-autorestart-setting': 'maxBackups',
|
|
7519
|
+
value: String(limitValue === null ? backupsInfo.max : limitValue),
|
|
7520
|
+
disabled: limitBusy,
|
|
7521
|
+
onChange: (event) => {
|
|
7522
|
+
const wanted = Number(event.target.value)
|
|
7523
|
+
if (Number.isFinite(wanted) !== true) return
|
|
7524
|
+
// SHRINKING IS THE ONLY DANGEROUS DIRECTION: nothing is
|
|
7525
|
+
// written until the user confirms how many copies die.
|
|
7526
|
+
if (wanted < backupItems.length) {
|
|
7527
|
+
setLimitNote('')
|
|
7528
|
+
setLimitAsk({ wanted, from: backupItems.length, remove: backupItems.length - wanted })
|
|
7529
|
+
return
|
|
7530
|
+
}
|
|
7531
|
+
void submitBackupLimit(wanted)
|
|
7532
|
+
},
|
|
7533
|
+
},
|
|
7534
|
+
[2, 3, 4, 5, 6, 7, 8, 9, 10].map((value) =>
|
|
7535
|
+
h('option', { key: 'limit-' + value, value: String(value) }, String(value))),
|
|
7536
|
+
),
|
|
7537
|
+
// THE DISK WARNING SITS TO THE RIGHT OF THE DROPDOWN (user
|
|
7538
|
+
// report, 2026-11-02): as its own line BELOW the control it
|
|
7539
|
+
// arrived after the choice had already been made.
|
|
7540
|
+
h(
|
|
7541
|
+
'span',
|
|
7542
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'limit-hint' },
|
|
7543
|
+
t('settings.backup.limitHint'),
|
|
7544
|
+
),
|
|
7545
|
+
),
|
|
7546
|
+
limitAsk === null
|
|
7547
|
+
? null
|
|
7548
|
+
: h(
|
|
7549
|
+
'div',
|
|
7550
|
+
{ className: 'dsh-autorestart-settings__backup-row', key: 'limit-ask' },
|
|
7551
|
+
h(
|
|
7552
|
+
'span',
|
|
7553
|
+
{ className: 'dsh-autorestart-settings__backup-label', 'data-dsh-autorestart-backup': 'limit-ask-body' },
|
|
7554
|
+
fill(t('settings.backup.limitAsk'), {
|
|
7555
|
+
remove: String(limitAsk.remove),
|
|
7556
|
+
from: String(limitAsk.from),
|
|
7557
|
+
to: String(limitAsk.wanted),
|
|
7558
|
+
}),
|
|
7559
|
+
),
|
|
7560
|
+
h('button', {
|
|
7561
|
+
type: 'button',
|
|
7562
|
+
className: 'dsh-autorestart-announcement__cta',
|
|
7563
|
+
'data-dsh-autorestart-backup': 'limit-confirm',
|
|
7564
|
+
disabled: limitBusy,
|
|
7565
|
+
onClick: () => { void submitBackupLimit(limitAsk.wanted) },
|
|
7566
|
+
}, t('settings.backup.limitConfirm')),
|
|
7567
|
+
h('button', {
|
|
7568
|
+
type: 'button',
|
|
7569
|
+
className: 'dsh-autorestart-announcement__cta',
|
|
7570
|
+
'data-dsh-autorestart-backup': 'limit-cancel',
|
|
7571
|
+
// CANCELLING SENDS NOTHING: no route call, no write, no delete.
|
|
7572
|
+
onClick: () => { setLimitAsk(null); setLimitNote('') },
|
|
7573
|
+
}, t('settings.backup.limitCancel')),
|
|
7574
|
+
),
|
|
7575
|
+
limitNote === ''
|
|
7576
|
+
? null
|
|
7577
|
+
: h(
|
|
7578
|
+
'span',
|
|
7579
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'limit-note' },
|
|
7580
|
+
limitNote,
|
|
7581
|
+
),
|
|
7582
|
+
// THE TWO EXPLAINING PARAGRAPHS ARE GONE (user report, 2026-11-02).
|
|
7583
|
+
//
|
|
7584
|
+
// They said a backup has "one of two identities" and that only a
|
|
7585
|
+
// "safe" copy is ever rolled back — but since 2026-11-02 the list
|
|
7586
|
+
// has no tier at all: every copy is one row with its own rollback
|
|
7587
|
+
// button. Keeping the paragraph made the page contradict itself,
|
|
7588
|
+
// and a user who believes it will not dare press the button on an
|
|
7589
|
+
// older copy. What is left on this page is what is TRUE: the count,
|
|
7590
|
+
// the rules in force (`keep`), the rows themselves, the path.
|
|
5948
7591
|
backupItems.length === 0
|
|
5949
7592
|
? h(
|
|
5950
7593
|
'span',
|
|
@@ -5952,53 +7595,144 @@
|
|
|
5952
7595
|
t('settings.backup.empty'),
|
|
5953
7596
|
)
|
|
5954
7597
|
: null,
|
|
7598
|
+
// ONE ROW PER COPY, AND ONE SENTENCE ON ONE COPY
|
|
7599
|
+
// (user decision, 2026-11-02).
|
|
7600
|
+
//
|
|
7601
|
+
// The page used to split the list into "the good copy" and
|
|
7602
|
+
// "everything else". That split was a promise the plugin cannot
|
|
7603
|
+
// keep: on a hosted instance nothing ever qualified, so the good
|
|
7604
|
+
// tier was empty forever and every real copy sat in the demoted
|
|
7605
|
+
// row. So there is no tier any more, and nothing is read from
|
|
7606
|
+
// `certifiedBy` / `kind` / `certificationEvidence` to draw a row.
|
|
7607
|
+
//
|
|
7608
|
+
// What is left is what can be said without judging: one row per
|
|
7609
|
+
// copy with its time and its own rollback button, plus the single
|
|
7610
|
+
// sentence that describes WHICH copy this is — the state the host
|
|
7611
|
+
// last started from. `startupCopy` is resolved from the copies' own
|
|
7612
|
+
// records (`startupCopyName`), never from the row order: a sentence
|
|
7613
|
+
// that followed the position would walk onto an older copy the day
|
|
7614
|
+
// the Host returns them in another order.
|
|
5955
7615
|
backupItems.map((item, index) => h(
|
|
5956
7616
|
'div',
|
|
5957
7617
|
{
|
|
5958
7618
|
className: 'dsh-autorestart-settings__backup',
|
|
5959
7619
|
'data-dsh-autorestart-backup': 'item',
|
|
7620
|
+
'data-backup-index': String(index),
|
|
5960
7621
|
key: 'backup-' + index,
|
|
5961
7622
|
},
|
|
5962
|
-
// WHEN it was taken. No timezone offset: what is shown is
|
|
5963
|
-
// already the local clock of the machine that took the copy.
|
|
5964
7623
|
h(
|
|
5965
7624
|
'div',
|
|
5966
7625
|
{ className: 'dsh-autorestart-settings__backup-row' },
|
|
5967
7626
|
h(
|
|
5968
7627
|
'span',
|
|
5969
|
-
{
|
|
5970
|
-
|
|
7628
|
+
{
|
|
7629
|
+
className: 'dsh-autorestart-settings__backup-value',
|
|
7630
|
+
'data-dsh-autorestart-backup': 'time',
|
|
7631
|
+
},
|
|
7632
|
+
formatBackupTime(item && item.atLocal),
|
|
5971
7633
|
),
|
|
7634
|
+
// ROLL BACK TO THIS COPY. The button only OPENS the question —
|
|
7635
|
+
// the route is called from the confirmation card and nowhere
|
|
7636
|
+
// else, so a click alone never restores anything.
|
|
5972
7637
|
h(
|
|
5973
|
-
'
|
|
5974
|
-
{
|
|
5975
|
-
|
|
7638
|
+
'button',
|
|
7639
|
+
{
|
|
7640
|
+
type: 'button',
|
|
7641
|
+
className: 'dsh-autorestart-settings__button',
|
|
7642
|
+
disabled: busy === true || rollbackBusy === true,
|
|
7643
|
+
'data-dsh-autorestart-backup': 'rollback',
|
|
7644
|
+
'data-backup-name': item && typeof item.name === 'string' ? item.name : '',
|
|
7645
|
+
onClick: () => {
|
|
7646
|
+
setRollbackNote('')
|
|
7647
|
+
setRollbackAsk({
|
|
7648
|
+
name: item && typeof item.name === 'string' ? item.name : '',
|
|
7649
|
+
atLocal: item && typeof item.atLocal === 'string' ? item.atLocal : null,
|
|
7650
|
+
})
|
|
7651
|
+
},
|
|
7652
|
+
},
|
|
7653
|
+
t('settings.backup.rollback'),
|
|
5976
7654
|
),
|
|
5977
7655
|
),
|
|
5978
|
-
//
|
|
5979
|
-
//
|
|
5980
|
-
|
|
7656
|
+
// THE ONE SENTENCE, ON THE ONE COPY IT IS TRUE OF — matched by
|
|
7657
|
+
// the copy's own name, never by `index === 0`.
|
|
7658
|
+
startupCopy !== null && item && item.name === startupCopy
|
|
7659
|
+
? h(
|
|
7660
|
+
'div',
|
|
7661
|
+
{
|
|
7662
|
+
className: 'dsh-autorestart-settings__backup-note',
|
|
7663
|
+
'data-dsh-autorestart-backup': 'startup-hint',
|
|
7664
|
+
},
|
|
7665
|
+
t('settings.backup.startupHint'),
|
|
7666
|
+
)
|
|
7667
|
+
: null,
|
|
7668
|
+
)),
|
|
7669
|
+
// THE SECOND CONFIRMATION OF A MANUAL ROLLBACK (user decision,
|
|
7670
|
+
// 2026-10-30): it names the copy, says that the current state is
|
|
7671
|
+
// replaced (and that it is saved first), and says that a restart
|
|
7672
|
+
// follows. Nothing is posted until the confirm button is pressed.
|
|
7673
|
+
rollbackAsk === null
|
|
7674
|
+
? null
|
|
7675
|
+
: h(
|
|
5981
7676
|
'div',
|
|
5982
|
-
{
|
|
7677
|
+
{
|
|
7678
|
+
className: 'dsh-autorestart-settings__confirm',
|
|
7679
|
+
'data-dsh-autorestart-backup': 'rollback-confirm',
|
|
7680
|
+
},
|
|
5983
7681
|
h(
|
|
5984
7682
|
'span',
|
|
5985
|
-
{ className: 'dsh-autorestart-
|
|
5986
|
-
t('settings.backup.
|
|
7683
|
+
{ className: 'dsh-autorestart-settings__label' },
|
|
7684
|
+
t('settings.backup.rollbackConfirmTitle'),
|
|
5987
7685
|
),
|
|
5988
7686
|
h(
|
|
5989
7687
|
'span',
|
|
5990
|
-
{ className: 'dsh-autorestart-
|
|
5991
|
-
|
|
7688
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-confirm-body' },
|
|
7689
|
+
fill(t('settings.backup.rollbackConfirmBody'), { time: formatBackupTime(rollbackAsk.atLocal) }),
|
|
5992
7690
|
),
|
|
5993
7691
|
h(
|
|
5994
7692
|
'span',
|
|
5995
|
-
{ className: 'dsh-autorestart-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
7693
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-confirm-restart' },
|
|
7694
|
+
t('settings.backup.rollbackConfirmRestart'),
|
|
7695
|
+
),
|
|
7696
|
+
h(
|
|
7697
|
+
'div',
|
|
7698
|
+
{ className: 'dsh-autorestart-settings__backup-actions' },
|
|
7699
|
+
h(
|
|
7700
|
+
'button',
|
|
7701
|
+
{
|
|
7702
|
+
type: 'button',
|
|
7703
|
+
className: 'dsh-autorestart-settings__button',
|
|
7704
|
+
disabled: rollbackBusy === true,
|
|
7705
|
+
'data-dsh-autorestart-backup': 'rollback-confirm-yes',
|
|
7706
|
+
onClick: () => void rollbackTo(rollbackAsk),
|
|
7707
|
+
},
|
|
7708
|
+
rollbackBusy === true
|
|
7709
|
+
? t('settings.backup.rollbackBusy')
|
|
7710
|
+
: t('settings.backup.rollbackConfirmYes'),
|
|
7711
|
+
),
|
|
7712
|
+
h(
|
|
7713
|
+
'button',
|
|
7714
|
+
{
|
|
7715
|
+
type: 'button',
|
|
7716
|
+
className: 'dsh-autorestart-settings__button',
|
|
7717
|
+
disabled: rollbackBusy === true,
|
|
7718
|
+
'data-dsh-autorestart-backup': 'rollback-confirm-no',
|
|
7719
|
+
onClick: () => {
|
|
7720
|
+
// Cancel: nothing was posted, nothing is restored.
|
|
7721
|
+
setRollbackAsk(null)
|
|
7722
|
+
setRollbackNote('')
|
|
7723
|
+
},
|
|
7724
|
+
},
|
|
7725
|
+
t('settings.backup.rollbackConfirmNo'),
|
|
7726
|
+
),
|
|
5999
7727
|
),
|
|
6000
7728
|
),
|
|
6001
|
-
|
|
7729
|
+
rollbackNote === ''
|
|
7730
|
+
? null
|
|
7731
|
+
: h(
|
|
7732
|
+
'span',
|
|
7733
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-note' },
|
|
7734
|
+
rollbackNote,
|
|
7735
|
+
),
|
|
6002
7736
|
backupsInfo.dir === ''
|
|
6003
7737
|
? null
|
|
6004
7738
|
: h(
|
|
@@ -6033,7 +7767,9 @@
|
|
|
6033
7767
|
pathNote,
|
|
6034
7768
|
),
|
|
6035
7769
|
),
|
|
6036
|
-
|
|
7770
|
+
),
|
|
7771
|
+
)
|
|
7772
|
+
})(),
|
|
6037
7773
|
),
|
|
6038
7774
|
h(
|
|
6039
7775
|
'div',
|
|
@@ -6073,8 +7809,10 @@
|
|
|
6073
7809
|
),
|
|
6074
7810
|
),
|
|
6075
7811
|
// The SUPERVISOR group: who starts this host, and whether the restart
|
|
6076
|
-
// can work at all.
|
|
6077
|
-
|
|
7812
|
+
// can work at all. HIDDEN ON THE DESKTOP APP (user decision, 2026-11-02):
|
|
7813
|
+
// the desktop client relaunches itself, so there is no supervisor to
|
|
7814
|
+
// install and nothing in this group can be acted on.
|
|
7815
|
+
hostIsDesktop === true ? null : h(
|
|
6078
7816
|
'div',
|
|
6079
7817
|
{
|
|
6080
7818
|
className: 'dsh-autorestart-settings__group',
|
|
@@ -6371,10 +8109,16 @@
|
|
|
6371
8109
|
)
|
|
6372
8110
|
: null,
|
|
6373
8111
|
),
|
|
6374
|
-
// "IF DSH WILL NOT START": the
|
|
6375
|
-
//
|
|
6376
|
-
//
|
|
6377
|
-
//
|
|
8112
|
+
// "IF DSH WILL NOT START": the rescue scripts, where they are, and the
|
|
8113
|
+
// way to reach them. A user whose host is down cannot open a README —
|
|
8114
|
+
// they need the path, and they need to be able to copy it.
|
|
8115
|
+
//
|
|
8116
|
+
// BOTH ENDS SHOW IT (user decision, 2026-11-02, plan B): the desktop
|
|
8117
|
+
// build keeps its rescue entry, and the hosted / web page shows the
|
|
8118
|
+
// SAME group with the SAME title and the SAME entries — the only thing
|
|
8119
|
+
// that changes is that a hosted host has no desktop, so the
|
|
8120
|
+
// "double-click this .lnk" row becomes a COPYABLE COMMAND instead of
|
|
8121
|
+
// being explained away with "the web version has no desktop".
|
|
6378
8122
|
h(
|
|
6379
8123
|
'div',
|
|
6380
8124
|
{
|
|
@@ -6386,71 +8130,231 @@
|
|
|
6386
8130
|
{ className: 'dsh-autorestart-settings__group-title' },
|
|
6387
8131
|
t('settings.groups.rescue'),
|
|
6388
8132
|
),
|
|
8133
|
+
// The rescue group says ONE plain thing (user decision, 2026-11-02):
|
|
8134
|
+
// whether the automatic rollback can work here. The script paths,
|
|
8135
|
+
// "which one do I pick" and the overwrite warning are gone — a
|
|
8136
|
+
// worried user needs a status line and a button, not a manual.
|
|
8137
|
+
// THE TWO SCRIPTS THEMSELVES (audit finding G1, 2026-11-02).
|
|
8138
|
+
//
|
|
8139
|
+
// The page used to say "use the two .sh scripts above" while
|
|
8140
|
+
// rendering NOTHING — the worst possible dead end, because the user
|
|
8141
|
+
// reading it is exactly the one whose host will not start. The paths
|
|
8142
|
+
// come from the Host (the browser never guesses them) and each one
|
|
8143
|
+
// has a copy button.
|
|
8144
|
+
//
|
|
8145
|
+
// Where there IS a desktop entry, the paths sit behind 详情 (the
|
|
8146
|
+
// entry is the main door). Where there is NO desktop (a hosted host,
|
|
8147
|
+
// or a desktop app whose desktop directory could not be found) the
|
|
8148
|
+
// paths are the only way, so they are never folded — same shape on
|
|
8149
|
+
// both ends, keyed on "is there a desktop entry here", not on the OS.
|
|
8150
|
+
rescuePaths.length === 0
|
|
8151
|
+
? null
|
|
8152
|
+
: (() => {
|
|
8153
|
+
const list = h(
|
|
8154
|
+
'div',
|
|
8155
|
+
{ className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-rescue': 'paths' },
|
|
8156
|
+
h(
|
|
8157
|
+
'span',
|
|
8158
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'paths-hint' },
|
|
8159
|
+
t('settings.rescue.hint'),
|
|
8160
|
+
),
|
|
8161
|
+
rescuePaths.map((entry) => h(
|
|
8162
|
+
'div',
|
|
8163
|
+
{ key: 'rescue-path-' + entry.key, className: 'dsh-autorestart-settings__rescue-path' },
|
|
8164
|
+
h('span', {
|
|
8165
|
+
className: 'dsh-autorestart-settings__label',
|
|
8166
|
+
'data-dsh-autorestart-rescue': 'path-label',
|
|
8167
|
+
}, entry.key === 'restoreProfile' ? t('settings.rescue.restoreProfile') : t('settings.rescue.emergencyDisable')),
|
|
8168
|
+
h('code', {
|
|
8169
|
+
className: 'dsh-autorestart-settings__value',
|
|
8170
|
+
'data-dsh-autorestart-rescue': 'path',
|
|
8171
|
+
'data-rescue-key': entry.key,
|
|
8172
|
+
}, entry.path),
|
|
8173
|
+
h('button', {
|
|
8174
|
+
type: 'button',
|
|
8175
|
+
className: 'dsh-autorestart-settings__button',
|
|
8176
|
+
disabled: busy,
|
|
8177
|
+
'data-dsh-autorestart-rescue': 'copy-path',
|
|
8178
|
+
'data-rescue-key': entry.key,
|
|
8179
|
+
onClick: () => void copyRescuePath(entry.path),
|
|
8180
|
+
}, t('settings.rescue.copyPath')),
|
|
8181
|
+
)),
|
|
8182
|
+
)
|
|
8183
|
+
if (rescueDesktop === '') return list
|
|
8184
|
+
const pathsOpen = rescuePathsOpen || rescueDesktop === ''
|
|
8185
|
+
return h(
|
|
8186
|
+
'details',
|
|
8187
|
+
{
|
|
8188
|
+
className: 'dsh-autorestart-settings__details',
|
|
8189
|
+
'data-dsh-autorestart-rescue': 'paths-details',
|
|
8190
|
+
...(pathsOpen ? { open: true } : {}),
|
|
8191
|
+
onToggle: (event) => setRescuePathsOpen(Boolean(event && event.target ? event.target.open : false)),
|
|
8192
|
+
},
|
|
8193
|
+
h(
|
|
8194
|
+
'summary',
|
|
8195
|
+
{ className: 'dsh-autorestart-settings__summary', 'data-dsh-autorestart-rescue': 'paths-summary' },
|
|
8196
|
+
t('settings.rescue.path'),
|
|
8197
|
+
),
|
|
8198
|
+
list,
|
|
8199
|
+
)
|
|
8200
|
+
})(),
|
|
8201
|
+
// THE RESCUE ENTRY, SHOWN THE SAME WAY ON BOTH ENDS (plan B, user
|
|
8202
|
+
// decision 2026-11-02). On a machine WITH a desktop this is the
|
|
8203
|
+
// `.lnk` the user is looking for — its icon and its file name, so
|
|
8204
|
+
// "what am I looking for on the desktop?" is answered on screen, and
|
|
8205
|
+
// one button puts it there. On a host WITHOUT a desktop (a hosted web
|
|
8206
|
+
// / server install) the SAME entry is a COPYABLE COMMAND: copy it,
|
|
8207
|
+
// run it, and it does what the double-click would have done. The
|
|
8208
|
+
// group is never removed and never explained away — only the shape of
|
|
8209
|
+
// this one entry changes, and when the Host has no path at all the
|
|
8210
|
+
// page says so instead of inventing one.
|
|
6389
8211
|
h(
|
|
6390
8212
|
'div',
|
|
6391
|
-
{ className: 'dsh-autorestart-settings__field' },
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
6405
|
-
|
|
6406
|
-
|
|
6407
|
-
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
8213
|
+
{ className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-rescue': 'shortcut-block' },
|
|
8214
|
+
rescueDesktop === ''
|
|
8215
|
+
? h(
|
|
8216
|
+
'div',
|
|
8217
|
+
{ className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-rescue': 'rescue-command' },
|
|
8218
|
+
h(
|
|
8219
|
+
'span',
|
|
8220
|
+
{ className: 'dsh-autorestart-settings__label', 'data-dsh-autorestart-rescue': 'command-label' },
|
|
8221
|
+
t('settings.rescue.commandLabel'),
|
|
8222
|
+
),
|
|
8223
|
+
rescueCommand === ''
|
|
8224
|
+
? h(
|
|
8225
|
+
'span',
|
|
8226
|
+
{
|
|
8227
|
+
className: 'dsh-autorestart-settings__hint',
|
|
8228
|
+
'data-dsh-autorestart-rescue': 'command-unavailable',
|
|
8229
|
+
},
|
|
8230
|
+
t('settings.rescue.commandUnavailable'),
|
|
8231
|
+
)
|
|
8232
|
+
: [
|
|
8233
|
+
h(
|
|
8234
|
+
'code',
|
|
8235
|
+
{
|
|
8236
|
+
className: 'dsh-autorestart-settings__value',
|
|
8237
|
+
'data-dsh-autorestart-rescue': 'command',
|
|
8238
|
+
key: 'rescue-command-value',
|
|
8239
|
+
},
|
|
8240
|
+
rescueCommand,
|
|
8241
|
+
),
|
|
8242
|
+
h(
|
|
8243
|
+
'button',
|
|
8244
|
+
{
|
|
8245
|
+
type: 'button',
|
|
8246
|
+
className: 'dsh-autorestart-settings__button',
|
|
8247
|
+
disabled: busy,
|
|
8248
|
+
'data-dsh-autorestart-rescue': 'copy-command',
|
|
8249
|
+
key: 'rescue-command-copy',
|
|
8250
|
+
onClick: () => void copyRescuePath(rescueCommand),
|
|
8251
|
+
},
|
|
8252
|
+
t('settings.rescue.copyCommand'),
|
|
8253
|
+
),
|
|
8254
|
+
h(
|
|
8255
|
+
'span',
|
|
8256
|
+
{
|
|
8257
|
+
className: 'dsh-autorestart-settings__hint',
|
|
8258
|
+
'data-dsh-autorestart-rescue': 'command-hint',
|
|
8259
|
+
key: 'rescue-command-hint',
|
|
8260
|
+
},
|
|
8261
|
+
t('settings.rescue.commandHint'),
|
|
8262
|
+
),
|
|
8263
|
+
],
|
|
8264
|
+
)
|
|
8265
|
+
: [
|
|
8266
|
+
h(
|
|
8267
|
+
'div',
|
|
8268
|
+
{ className: 'dsh-autorestart-settings__shortcut', key: 'rescue-shortcut-row' },
|
|
8269
|
+
h(
|
|
8270
|
+
'span',
|
|
8271
|
+
{
|
|
8272
|
+
className: 'dsh-autorestart-settings__shortcut-icon',
|
|
8273
|
+
'data-dsh-autorestart-rescue': 'shortcut-icon',
|
|
8274
|
+
// WHICH FILE THIS ICON IS: the same one the `.lnk`
|
|
8275
|
+
// gets, or nothing at all — never a made-up picture.
|
|
8276
|
+
'data-icon-source': rescueIconName,
|
|
8277
|
+
'data-icon-available': rescueIconAvailable ? 'true' : 'false',
|
|
8278
|
+
// WHETHER THE PICTURE ON SCREEN IS THE REAL ONE
|
|
8279
|
+
// (2026-11-02): true means the bytes below came from
|
|
8280
|
+
// the Host, so the tile drops its stand-in background
|
|
8281
|
+
// and shows the actual image.
|
|
8282
|
+
'data-icon-real': rescueIconDataUri === '' ? 'false' : 'true',
|
|
8283
|
+
'aria-hidden': 'true',
|
|
8284
|
+
title: rescueIconPath,
|
|
8285
|
+
},
|
|
8286
|
+
// THE REAL PICTURE, or the text tile when the Host sent
|
|
8287
|
+
// none. Never both: two icons on one row would look like
|
|
8288
|
+
// two different shortcuts.
|
|
8289
|
+
rescueIconDataUri === ''
|
|
8290
|
+
? 'DSH'
|
|
8291
|
+
: h('img', {
|
|
8292
|
+
className: 'dsh-autorestart-settings__shortcut-icon-img',
|
|
8293
|
+
'data-dsh-autorestart-rescue': 'rescue-icon',
|
|
8294
|
+
src: rescueIconDataUri,
|
|
8295
|
+
alt: rescueShortcutName,
|
|
8296
|
+
width: 16,
|
|
8297
|
+
height: 16,
|
|
8298
|
+
draggable: 'false',
|
|
8299
|
+
}),
|
|
8300
|
+
),
|
|
8301
|
+
h(
|
|
8302
|
+
'span',
|
|
8303
|
+
{
|
|
8304
|
+
className: 'dsh-autorestart-settings__shortcut-name',
|
|
8305
|
+
'data-dsh-autorestart-rescue': 'shortcut-name',
|
|
8306
|
+
},
|
|
8307
|
+
rescueShortcutName,
|
|
8308
|
+
),
|
|
8309
|
+
),
|
|
8310
|
+
// The button lives only where it can work: a Windows machine
|
|
8311
|
+
// with a desktop.
|
|
8312
|
+
rescueIsWindows
|
|
8313
|
+
? h(
|
|
8314
|
+
'button',
|
|
8315
|
+
{
|
|
8316
|
+
type: 'button',
|
|
8317
|
+
className: 'dsh-autorestart-settings__button',
|
|
8318
|
+
disabled: busy,
|
|
8319
|
+
'data-dsh-autorestart-rescue': 'shortcut',
|
|
8320
|
+
key: 'rescue-shortcut-button',
|
|
8321
|
+
onClick: () => void createRescueShortcut(),
|
|
8322
|
+
},
|
|
8323
|
+
rescueShortcutExists ? t('settings.rescue.shortcutDone') : t('settings.rescue.shortcut'),
|
|
8324
|
+
)
|
|
8325
|
+
: null,
|
|
8326
|
+
],
|
|
8327
|
+
rescueNote === ''
|
|
8328
|
+
? null
|
|
8329
|
+
: h(
|
|
8330
|
+
'span',
|
|
8331
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'note' },
|
|
8332
|
+
rescueNote,
|
|
8333
|
+
),
|
|
8334
|
+
// REMINDER ONE: this is for a host that does NOT start. Using it
|
|
8335
|
+
// while DSH runs overwrites the configuration in use.
|
|
6440
8336
|
h(
|
|
6441
8337
|
'span',
|
|
6442
|
-
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': '
|
|
6443
|
-
t('settings.rescue.
|
|
8338
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'shortcut-when' },
|
|
8339
|
+
t('settings.rescue.warning'),
|
|
6444
8340
|
),
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
{ className: 'dsh-autorestart-settings__field' },
|
|
8341
|
+
// REMINDER TWO: it puts the WHOLE environment back. Taking one
|
|
8342
|
+
// plugin out is a different button, and the wrong one here costs
|
|
8343
|
+
// the user their working configuration.
|
|
6449
8344
|
h(
|
|
6450
8345
|
'span',
|
|
6451
|
-
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': '
|
|
6452
|
-
t('settings.rescue.
|
|
8346
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'shortcut-scope' },
|
|
8347
|
+
t('settings.rescue.shortcutScope'),
|
|
6453
8348
|
),
|
|
8349
|
+
// A Windows desktop app whose desktop directory could not be found:
|
|
8350
|
+
// no shortcut can be placed, and the paths above are the way out.
|
|
8351
|
+
rescueIsWindows && rescueDesktop === ''
|
|
8352
|
+
? h(
|
|
8353
|
+
'span',
|
|
8354
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'no-desktop' },
|
|
8355
|
+
t('settings.rescue.noDesktop'),
|
|
8356
|
+
)
|
|
8357
|
+
: null,
|
|
6454
8358
|
),
|
|
6455
8359
|
// WHAT THIS MACHINE CAN DO: the automatic rollback (and what is
|
|
6456
8360
|
// lost when the machine refuses a watchdog).
|
|
@@ -6472,6 +8376,18 @@
|
|
|
6472
8376
|
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'watchdog-last' },
|
|
6473
8377
|
t('settings.rescue.watchdogLast', { result: watchdogLast }),
|
|
6474
8378
|
),
|
|
8379
|
+
// The strict-mode answer, in the words the user asked for: nothing
|
|
8380
|
+
// was touched because there is nothing trustworthy to put back.
|
|
8381
|
+
watchdogStrict ? h(
|
|
8382
|
+
'span',
|
|
8383
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'watchdog-strict' },
|
|
8384
|
+
t('settings.rescue.watchdogStrict'),
|
|
8385
|
+
) : null,
|
|
8386
|
+
watchdogRestoredFrom === '' ? null : h(
|
|
8387
|
+
'span',
|
|
8388
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'watchdog-restored-from' },
|
|
8389
|
+
t('settings.rescue.watchdogRestoredFrom', { dir: watchdogRestoredFrom }),
|
|
8390
|
+
),
|
|
6475
8391
|
),
|
|
6476
8392
|
// The notice is shown ONCE: the Host remembers that the user has
|
|
6477
8393
|
// read it.
|
|
@@ -6481,45 +8397,14 @@
|
|
|
6481
8397
|
{ className: 'dsh-autorestart-settings__field' },
|
|
6482
8398
|
h(
|
|
6483
8399
|
'span',
|
|
6484
|
-
{ className: 'dsh-autorestart-settings__label', 'data-dsh-autorestart-rescue': 'rollback-notice' },
|
|
6485
|
-
t('settings.rescue.rollbackBlocked'),
|
|
6486
|
-
),
|
|
6487
|
-
)
|
|
6488
|
-
: null,
|
|
6489
|
-
// The desktop shortcut is a BUTTON, never a side effect: loading
|
|
6490
|
-
// this page writes nothing to the desktop.
|
|
6491
|
-
rescueDesktop === ''
|
|
6492
|
-
? h(
|
|
6493
|
-
'div',
|
|
6494
|
-
{ className: 'dsh-autorestart-settings__field' },
|
|
6495
|
-
h(
|
|
6496
|
-
'span',
|
|
6497
|
-
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'no-desktop' },
|
|
6498
|
-
t('settings.rescue.noDesktop'),
|
|
8400
|
+
{ className: 'dsh-autorestart-settings__label', 'data-dsh-autorestart-rescue': 'rollback-notice' },
|
|
8401
|
+
t('settings.rescue.rollbackBlocked'),
|
|
6499
8402
|
),
|
|
6500
8403
|
)
|
|
6501
|
-
:
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
'button',
|
|
6506
|
-
{
|
|
6507
|
-
type: 'button',
|
|
6508
|
-
className: 'dsh-autorestart-settings__button',
|
|
6509
|
-
disabled: busy,
|
|
6510
|
-
'data-dsh-autorestart-rescue': 'shortcut',
|
|
6511
|
-
onClick: () => void createRescueShortcut(),
|
|
6512
|
-
},
|
|
6513
|
-
rescueShortcutExists ? t('settings.rescue.shortcutDone') : t('settings.rescue.shortcut'),
|
|
6514
|
-
),
|
|
6515
|
-
rescueNote === ''
|
|
6516
|
-
? null
|
|
6517
|
-
: h(
|
|
6518
|
-
'span',
|
|
6519
|
-
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'note' },
|
|
6520
|
-
rescueNote,
|
|
6521
|
-
),
|
|
6522
|
-
),
|
|
8404
|
+
: null,
|
|
8405
|
+
// The desktop shortcut is a BUTTON, never a side effect: loading
|
|
8406
|
+
// this page writes nothing to the desktop. That button now lives in
|
|
8407
|
+
// the block above, together with the icon and the two reminders.
|
|
6523
8408
|
),
|
|
6524
8409
|
// "REPORT A PROBLEM": copy the diagnostics and open a pre-filled
|
|
6525
8410
|
// issue page. The email is the way out when there is no GitHub
|
|
@@ -7315,6 +9200,11 @@
|
|
|
7315
9200
|
// features, but only this one has a card here.
|
|
7316
9201
|
const ANNOUNCED_FEATURE = 'sleep'
|
|
7317
9202
|
|
|
9203
|
+
// A HOST THAT CANNOT RESTART (2026-11-02): its own one-time card, in the
|
|
9204
|
+
// same announcement ledger as the feature above — same route, same
|
|
9205
|
+
// "answered once per host" rule, no second mechanism.
|
|
9206
|
+
const ANNOUNCED_RESTART = 'restart-command'
|
|
9207
|
+
|
|
7318
9208
|
/**
|
|
7319
9209
|
* The announcements the user has not seen yet.
|
|
7320
9210
|
*
|
|
@@ -7564,6 +9454,632 @@
|
|
|
7564
9454
|
)
|
|
7565
9455
|
}
|
|
7566
9456
|
|
|
9457
|
+
/**
|
|
9458
|
+
* The single-plugin restore: the button in the plugin-failure panel.
|
|
9459
|
+
*
|
|
9460
|
+
* `POST /plugin-restore` with `{ "plugin": "<name>" }` puts THAT ONE
|
|
9461
|
+
* plugin back from the last known-good backup. The Host answers with
|
|
9462
|
+
* `ok:false` + a reason for "no backup" / "could not copy" — and it says
|
|
9463
|
+
* so with HTTP 200, because that is an answer, not a server error.
|
|
9464
|
+
*/
|
|
9465
|
+
const DEFAULT_PLUGIN_RESTORE_URL = ROUTE_PREFIX + '/plugin-restore'
|
|
9466
|
+
|
|
9467
|
+
/**
|
|
9468
|
+
* WHERE "the user said 知道了" IS REMEMBERED.
|
|
9469
|
+
*
|
|
9470
|
+
* One key, holding the FINGERPRINT of the report that was dismissed — not
|
|
9471
|
+
* a boolean. That is the whole point: a boolean would silence a real
|
|
9472
|
+
* failure forever, while a fingerprint only silences the boot that wrote
|
|
9473
|
+
* that report. The next start writes a new report (new `at`) and the panel
|
|
9474
|
+
* comes back by itself.
|
|
9475
|
+
*/
|
|
9476
|
+
const PLUGIN_FAILURE_DISMISS_KEY = 'dsh-autorestart.plugin-failure.dismissed'
|
|
9477
|
+
|
|
9478
|
+
async function restorePluginOnce(plugin) {
|
|
9479
|
+
const response = await fetch(DEFAULT_PLUGIN_RESTORE_URL, {
|
|
9480
|
+
method: 'POST',
|
|
9481
|
+
headers: { 'content-type': 'application/json' },
|
|
9482
|
+
body: JSON.stringify({ plugin }),
|
|
9483
|
+
cache: 'no-store',
|
|
9484
|
+
})
|
|
9485
|
+
if (!response.ok) throw new Error('HTTP ' + response.status)
|
|
9486
|
+
const body = await response.json()
|
|
9487
|
+
if (!body || typeof body !== 'object') throw new Error('the Host sent no answer')
|
|
9488
|
+
return body
|
|
9489
|
+
}
|
|
9490
|
+
|
|
9491
|
+
/**
|
|
9492
|
+
* THE PLUGIN THAT DID NOT LOAD — the popup the user asked for.
|
|
9493
|
+
*
|
|
9494
|
+
* WHY IT EXISTS: the host ALREADY survives a broken plugin (measured: 22 s
|
|
9495
|
+
* on the desktop, 2.0–2.6 s on hosted `dsh web`, profile untouched, no
|
|
9496
|
+
* repeated restarts under the supervisor), so the plugin no longer
|
|
9497
|
+
* restores anything by itself at boot. What was missing was the OTHER
|
|
9498
|
+
* half: the user finding out. This panel says which plugin did not
|
|
9499
|
+
* activate and what error it reported, and offers exactly one action —
|
|
9500
|
+
* "put THIS one plugin back from the last backup".
|
|
9501
|
+
*
|
|
9502
|
+
* It is an ordinary block in the composer dock (the same place as the
|
|
9503
|
+
* install prompt and the announcement): it never covers the interface and
|
|
9504
|
+
* never touches the countdown. It is not dismissible by hiding a problem:
|
|
9505
|
+
* it disappears when the plugin loads again (a new boot with a clean log,
|
|
9506
|
+
* which clears the report).
|
|
9507
|
+
*/
|
|
9508
|
+
/**
|
|
9509
|
+
* WHAT THE PANEL MAY SAY, AND NOTHING MORE.
|
|
9510
|
+
*
|
|
9511
|
+
* Two facts, kept apart on purpose:
|
|
9512
|
+
* - "these functions did not come up this time" - the watch compared this
|
|
9513
|
+
* boot with the previous one and something is gone. A hard fact.
|
|
9514
|
+
* - "these plugins differ from the backup" - a lead, not a verdict: the
|
|
9515
|
+
* files changed, which may simply mean an upgrade.
|
|
9516
|
+
*
|
|
9517
|
+
* It never says a plugin is broken or failed to load: nothing here can know
|
|
9518
|
+
* that. Interface module names are reported as ONE number and never as
|
|
9519
|
+
* plugin names (they are things like \`batches\`, not plugins).
|
|
9520
|
+
*/
|
|
9521
|
+
|
|
9522
|
+
/**
|
|
9523
|
+
* ASK ONCE WHETHER THE WATCHDOG STAYS ON.
|
|
9524
|
+
*
|
|
9525
|
+
* The watchdog already works; what was missing is that nobody ever asked.
|
|
9526
|
+
* The wording says what it DOES (put the last working version back when the
|
|
9527
|
+
* new one does not start) and what turning it off COSTS (you fix it by
|
|
9528
|
+
* hand), because the person reading this is not a programmer.
|
|
9529
|
+
*
|
|
9530
|
+
* The decision is made on the host by shouldAskWatchdog() and arrives as
|
|
9531
|
+
* info.watchdogAsk; this panel only renders it.
|
|
9532
|
+
* Hidden unless the host says it is time to ask: never answered, or the
|
|
9533
|
+
* plugin changed version since the answer.
|
|
9534
|
+
*/
|
|
9535
|
+
function WatchdogAskPanel() {
|
|
9536
|
+
const info = useSharedInfo(true)
|
|
9537
|
+
const [busy, setBusy] = useState('')
|
|
9538
|
+
const [answered, setAnswered] = useState(null)
|
|
9539
|
+
const t = makeTranslate(translate)
|
|
9540
|
+
ensureAnnouncementStyle()
|
|
9541
|
+
|
|
9542
|
+
// Nothing to ask: no answer yet is not the same as "we should ask".
|
|
9543
|
+
// The host decides, and it only says yes when there is no answer or the
|
|
9544
|
+
// plugin changed version since the last one.
|
|
9545
|
+
if (answered === 'dismissed') return null
|
|
9546
|
+
if (info === null || info === undefined || info.watchdogAsk !== true) return null
|
|
9547
|
+
|
|
9548
|
+
const answer = (keepOn) => {
|
|
9549
|
+
// THE BUTTON NO LONGER ANSWERS A QUESTION, IT ACKNOWLEDGES A NOTICE
|
|
9550
|
+
// (2026-11-01, user decision): the watchdog ships with the plugin, so
|
|
9551
|
+
// "should it stay on?" was a choice the user does not actually have.
|
|
9552
|
+
// The single button records "the user was told" and nothing else.
|
|
9553
|
+
if (busy !== '') return
|
|
9554
|
+
setBusy('notify')
|
|
9555
|
+
void (async () => {
|
|
9556
|
+
// "SAVED" IS A PROMISE, NOT A GUESS. The request is a success only
|
|
9557
|
+
// when the host really answered 2xx: a 400 or a dead connection means
|
|
9558
|
+
// NOTHING was written, and saying "saved" then would leave the user
|
|
9559
|
+
// believing a record that does not exist on disk.
|
|
9560
|
+
let saved = false
|
|
9561
|
+
try {
|
|
9562
|
+
const response = await fetch(choiceUrlFor(info), {
|
|
9563
|
+
method: 'POST',
|
|
9564
|
+
headers: { 'content-type': 'application/json' },
|
|
9565
|
+
body: JSON.stringify({
|
|
9566
|
+
watchdogNotified: true,
|
|
9567
|
+
askedForPluginVersion: typeof info.pluginVersion === 'string' ? info.pluginVersion : '',
|
|
9568
|
+
}),
|
|
9569
|
+
})
|
|
9570
|
+
saved = response !== null && response !== undefined && response.ok === true
|
|
9571
|
+
} catch {
|
|
9572
|
+
saved = false
|
|
9573
|
+
}
|
|
9574
|
+
setAnswered(saved === true ? 'notified' : 'failed')
|
|
9575
|
+
setBusy('')
|
|
9576
|
+
})()
|
|
9577
|
+
}
|
|
9578
|
+
|
|
9579
|
+
/**
|
|
9580
|
+
* PUT EVERY BROKEN PLUGIN BACK, one at a time, never in parallel: the host
|
|
9581
|
+
* writes into the profile, and two restores racing on the same tree is not
|
|
9582
|
+
* worth the risk. Each plugin is awaited before the next one starts.
|
|
9583
|
+
*/
|
|
9584
|
+
const restoreAll = (entries) => {
|
|
9585
|
+
if (busy !== '') return
|
|
9586
|
+
setBusy('all')
|
|
9587
|
+
setNote('')
|
|
9588
|
+
void (async () => {
|
|
9589
|
+
let done = 0
|
|
9590
|
+
let failed = 0
|
|
9591
|
+
for (const entry of entries) {
|
|
9592
|
+
try {
|
|
9593
|
+
const answer = await restorePluginOnce(entry.name)
|
|
9594
|
+
if (answer && answer.ok === true) done += 1
|
|
9595
|
+
else failed += 1
|
|
9596
|
+
} catch {
|
|
9597
|
+
failed += 1
|
|
9598
|
+
}
|
|
9599
|
+
}
|
|
9600
|
+
setNote(fill(t('pluginFailure.restoreAllDone'), { done: String(done), failed: String(failed) }))
|
|
9601
|
+
setBusy('')
|
|
9602
|
+
})()
|
|
9603
|
+
}
|
|
9604
|
+
|
|
9605
|
+
const rows = []
|
|
9606
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'title' }, t('watchdog.title')))
|
|
9607
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__line', key: 'explain' }, t('watchdog.explain')))
|
|
9608
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'off' }, t('watchdog.whereOff')))
|
|
9609
|
+
if (answered !== null) {
|
|
9610
|
+
rows.push(h('p', {
|
|
9611
|
+
className: 'dsh-autorestart-announcement__note',
|
|
9612
|
+
key: 'saved',
|
|
9613
|
+
}, t(answered === 'failed' ? 'watchdog.saveFailed' : 'watchdog.noticeSaved')))
|
|
9614
|
+
}
|
|
9615
|
+
// ONE BUTTON, BECAUSE THERE IS NO CHOICE TO MAKE: the watchdog ships with
|
|
9616
|
+
// the plugin. Turning it off is a settings-page decision, not a question
|
|
9617
|
+
// asked once at install time.
|
|
9618
|
+
rows.push(h('button', {
|
|
9619
|
+
type: 'button',
|
|
9620
|
+
className: 'dsh-autorestart-announcement__cta',
|
|
9621
|
+
key: 'got-it',
|
|
9622
|
+
disabled: busy !== '',
|
|
9623
|
+
onClick: () => answer(true),
|
|
9624
|
+
}, t('watchdog.gotIt')))
|
|
9625
|
+
rows.push(overlayHint(t('watchdog.hint')))
|
|
9626
|
+
return overlayCard('watchdog-ask', t('watchdog.title'), () => setAnswered('dismissed'), rows)
|
|
9627
|
+
}
|
|
9628
|
+
|
|
9629
|
+
/**
|
|
9630
|
+
* THE FLOATING CARD.
|
|
9631
|
+
*
|
|
9632
|
+
* The panels used to sit in `conversation.input.dock`, which is part of the
|
|
9633
|
+
* layout flow: the message column was pushed up and the text ran straight
|
|
9634
|
+
* into the conversation with no edge of its own. Declaring the frame here
|
|
9635
|
+
* means every panel draws the same card, whatever the page CSS does.
|
|
9636
|
+
*/
|
|
9637
|
+
// THE CARD'S OWN PAINT (2026-11-01).
|
|
9638
|
+
//
|
|
9639
|
+
// Hard-coded, OPAQUE colours on purpose. The first version used
|
|
9640
|
+
// `var(--dsh-surface, #fff)`, and when the theme does not define that
|
|
9641
|
+
// variable the card was drawn with no background at all - on the real
|
|
9642
|
+
// machine the text simply sat in the conversation, indistinguishable from
|
|
9643
|
+
// the messages around it.
|
|
9644
|
+
// THE ANCHOR THAT TAKES NO SPACE.
|
|
9645
|
+
//
|
|
9646
|
+
// `position: fixed` does NOT mean "relative to the viewport" when any
|
|
9647
|
+
// ancestor has `transform`, `filter`, `perspective` or `will-change`: the
|
|
9648
|
+
// card then anchors to that ancestor and stays in its layout, which is why
|
|
9649
|
+
// the conversation was pushed up. This wrapper is ZERO-sized and the card
|
|
9650
|
+
// hangs out of it with `position: absolute`, so the flow never sees it, no
|
|
9651
|
+
// matter what the host puts above us.
|
|
9652
|
+
/**
|
|
9653
|
+
* THE CARD, AS A REAL COMPONENT.
|
|
9654
|
+
*
|
|
9655
|
+
* It has to be a component (not a plain helper) because it now MEASURES
|
|
9656
|
+
* itself: the card renders a line with its own computed style, so a single
|
|
9657
|
+
* screenshot says which layer is broken - the inline style not applied, an
|
|
9658
|
+
* ancestor overriding it, or the portal never happening.
|
|
9659
|
+
*
|
|
9660
|
+
* Every painting property is inline and hard-coded: no className, no CSS
|
|
9661
|
+
* variable. Inline wins over every stylesheet rule, so if the card still
|
|
9662
|
+
* comes out unstyled the reason is not CSS specificity.
|
|
9663
|
+
*/
|
|
9664
|
+
// A quiet line under the buttons. The panel is the door; the settings page
|
|
9665
|
+
// is where you come back later if you change your mind.
|
|
9666
|
+
function overlayHint(text) {
|
|
9667
|
+
return h('p', {
|
|
9668
|
+
key: 'overlay-hint',
|
|
9669
|
+
'data-dsh-autorestart-hint': '1',
|
|
9670
|
+
style: {
|
|
9671
|
+
margin: '2px 0 0',
|
|
9672
|
+
color: 'var(--dsw-alias-label-tertiary, #888888)',
|
|
9673
|
+
fontSize: '11px',
|
|
9674
|
+
lineHeight: '1.4',
|
|
9675
|
+
},
|
|
9676
|
+
}, text)
|
|
9677
|
+
}
|
|
9678
|
+
|
|
9679
|
+
function OverlayCard(props) {
|
|
9680
|
+
const id = props ? props.id : 'overlay'
|
|
9681
|
+
const title = props ? props.title : ''
|
|
9682
|
+
const onClose = props && typeof props.onClose === 'function' ? props.onClose : () => {}
|
|
9683
|
+
const rows = props && Array.isArray(props.rows) ? props.rows : []
|
|
9684
|
+
// CARD-LEVEL ACTIONS LIVE IN THE HEADER (user report, 2026-11-02): buttons
|
|
9685
|
+
// that apply to the WHOLE card used to sit at the bottom of the list, so
|
|
9686
|
+
// they looked like they belonged to the last item in it. The header owns
|
|
9687
|
+
// them now — title on the left (it truncates), actions on the right.
|
|
9688
|
+
const actions = props && Array.isArray(props.actions) ? props.actions : []
|
|
9689
|
+
const header = h('div', {
|
|
9690
|
+
key: 'overlay-header',
|
|
9691
|
+
'data-dsh-autorestart-overlay-header': id,
|
|
9692
|
+
style: {
|
|
9693
|
+
display: 'flex',
|
|
9694
|
+
alignItems: 'center',
|
|
9695
|
+
justifyContent: 'space-between',
|
|
9696
|
+
gap: '8px',
|
|
9697
|
+
flexWrap: 'nowrap',
|
|
9698
|
+
},
|
|
9699
|
+
},
|
|
9700
|
+
h('strong', {
|
|
9701
|
+
key: 'overlay-title',
|
|
9702
|
+
style: {
|
|
9703
|
+
flex: '1 1 auto',
|
|
9704
|
+
minWidth: '0',
|
|
9705
|
+
overflow: 'hidden',
|
|
9706
|
+
textOverflow: 'ellipsis',
|
|
9707
|
+
whiteSpace: 'nowrap',
|
|
9708
|
+
fontSize: '14px',
|
|
9709
|
+
lineHeight: '1.4',
|
|
9710
|
+
color: 'var(--dsw-alias-label-primary, #111111)',
|
|
9711
|
+
},
|
|
9712
|
+
}, title),
|
|
9713
|
+
h('div', {
|
|
9714
|
+
key: 'overlay-actions',
|
|
9715
|
+
'data-dsh-autorestart-overlay-actions': id,
|
|
9716
|
+
style: {
|
|
9717
|
+
flex: '0 0 auto',
|
|
9718
|
+
display: 'flex',
|
|
9719
|
+
alignItems: 'center',
|
|
9720
|
+
gap: '6px',
|
|
9721
|
+
flexWrap: 'nowrap',
|
|
9722
|
+
},
|
|
9723
|
+
},
|
|
9724
|
+
actions,
|
|
9725
|
+
h('button', {
|
|
9726
|
+
type: 'button',
|
|
9727
|
+
key: 'overlay-close',
|
|
9728
|
+
title: translate('pluginFailure.close'),
|
|
9729
|
+
'aria-label': translate('pluginFailure.close'),
|
|
9730
|
+
onClick: onClose,
|
|
9731
|
+
style: {
|
|
9732
|
+
flex: '0 0 auto',
|
|
9733
|
+
cursor: 'pointer',
|
|
9734
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.22))',
|
|
9735
|
+
borderRadius: '8px',
|
|
9736
|
+
background: 'var(--dsw-alias-bg-layer-1, #ffffff)',
|
|
9737
|
+
color: 'var(--dsw-alias-label-primary, #111111)',
|
|
9738
|
+
fontSize: '16px',
|
|
9739
|
+
lineHeight: '1',
|
|
9740
|
+
padding: '2px 8px',
|
|
9741
|
+
},
|
|
9742
|
+
}, '\u00d7')))
|
|
9743
|
+
|
|
9744
|
+
const body = [header, ...rows]
|
|
9745
|
+
|
|
9746
|
+
const card = h('div', {
|
|
9747
|
+
key: id,
|
|
9748
|
+
'data-dsh-autorestart-overlay': id,
|
|
9749
|
+
style: {
|
|
9750
|
+
position: "fixed",
|
|
9751
|
+
left: '24px',
|
|
9752
|
+
bottom: '24px',
|
|
9753
|
+
width: 'min(560px, calc(100vw - 48px))',
|
|
9754
|
+
maxHeight: 'min(70vh, 640px)',
|
|
9755
|
+
overflowY: 'auto',
|
|
9756
|
+
boxSizing: 'border-box',
|
|
9757
|
+
background: 'var(--dsw-alias-bg-layer-1, #ffffff)',
|
|
9758
|
+
backgroundColor: 'var(--dsw-alias-bg-layer-1, #ffffff)',
|
|
9759
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.22))',
|
|
9760
|
+
borderRadius: '12px',
|
|
9761
|
+
boxShadow: '0 12px 32px rgba(0,0,0,0.18)',
|
|
9762
|
+
padding: '14px 16px',
|
|
9763
|
+
zIndex: 2147483000,
|
|
9764
|
+
color: 'var(--dsw-alias-label-primary, #111111)',
|
|
9765
|
+
font: 'inherit',
|
|
9766
|
+
textAlign: 'left',
|
|
9767
|
+
lineHeight: '1.5',
|
|
9768
|
+
fontSize: '13px',
|
|
9769
|
+
},
|
|
9770
|
+
}, body)
|
|
9771
|
+
|
|
9772
|
+
return card
|
|
9773
|
+
}
|
|
9774
|
+
|
|
9775
|
+
function overlayCard(id, title, onClose, rows) {
|
|
9776
|
+
return h(OverlayCard, { key: 'overlay-' + id, id, title, onClose, rows })
|
|
9777
|
+
}
|
|
9778
|
+
|
|
9779
|
+
const SETTINGS_STYLE = {
|
|
9780
|
+
section: {
|
|
9781
|
+
display: 'flex',
|
|
9782
|
+
flexDirection: 'column',
|
|
9783
|
+
gap: '10px',
|
|
9784
|
+
padding: '14px 16px',
|
|
9785
|
+
background: 'var(--dsw-alias-bg-layer-1, #ffffff)',
|
|
9786
|
+
color: 'var(--dsw-alias-label-primary, #111111)',
|
|
9787
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.18))',
|
|
9788
|
+
borderRadius: '12px',
|
|
9789
|
+
font: 'inherit',
|
|
9790
|
+
textAlign: 'left',
|
|
9791
|
+
lineHeight: '1.5',
|
|
9792
|
+
fontSize: '13px',
|
|
9793
|
+
},
|
|
9794
|
+
description: { color: 'var(--dsw-alias-label-secondary, #666666)', fontSize: '12px' },
|
|
9795
|
+
button: {
|
|
9796
|
+
alignSelf: 'flex-start',
|
|
9797
|
+
cursor: 'pointer',
|
|
9798
|
+
padding: '6px 14px',
|
|
9799
|
+
borderRadius: '8px',
|
|
9800
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.22))',
|
|
9801
|
+
background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
|
|
9802
|
+
color: 'var(--dsw-alias-label-primary, #111111)',
|
|
9803
|
+
font: 'inherit',
|
|
9804
|
+
fontSize: '13px',
|
|
9805
|
+
},
|
|
9806
|
+
restoreButton: {
|
|
9807
|
+
cursor: 'pointer',
|
|
9808
|
+
padding: '3px 10px',
|
|
9809
|
+
borderRadius: '6px',
|
|
9810
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.22))',
|
|
9811
|
+
background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
|
|
9812
|
+
color: 'var(--dsw-alias-label-primary, #111111)',
|
|
9813
|
+
font: 'inherit',
|
|
9814
|
+
fontSize: '12px',
|
|
9815
|
+
},
|
|
9816
|
+
list: {
|
|
9817
|
+
display: 'flex',
|
|
9818
|
+
flexDirection: 'column',
|
|
9819
|
+
gap: '6px',
|
|
9820
|
+
margin: 0,
|
|
9821
|
+
padding: 0,
|
|
9822
|
+
listStyle: 'none',
|
|
9823
|
+
},
|
|
9824
|
+
row: {
|
|
9825
|
+
display: 'grid',
|
|
9826
|
+
gridTemplateColumns: '1fr auto auto',
|
|
9827
|
+
alignItems: 'center',
|
|
9828
|
+
gap: '10px',
|
|
9829
|
+
padding: '6px 8px',
|
|
9830
|
+
borderRadius: '8px',
|
|
9831
|
+
background: 'var(--dsw-alias-bg-layer-2, #f7f7f7)',
|
|
9832
|
+
},
|
|
9833
|
+
name: { fontFamily: 'monospace', fontSize: '12px', wordBreak: 'break-all' },
|
|
9834
|
+
status: { color: 'var(--dsw-alias-label-secondary, #666666)', fontSize: '12px', whiteSpace: 'nowrap' },
|
|
9835
|
+
hint: {
|
|
9836
|
+
gridColumn: '1 / -1',
|
|
9837
|
+
color: 'var(--dsw-alias-label-tertiary, #888888)',
|
|
9838
|
+
fontSize: '11px',
|
|
9839
|
+
lineHeight: '1.4',
|
|
9840
|
+
},
|
|
9841
|
+
note: { color: 'var(--dsw-alias-state-warn-label, #a15c00)', fontSize: '12px' },
|
|
9842
|
+
}
|
|
9843
|
+
|
|
9844
|
+
/**
|
|
9845
|
+
* THE SETTINGS PAGE (plugins.bundle.config).
|
|
9846
|
+
*
|
|
9847
|
+
* Everything here is on demand: nothing talks to the inventory route until
|
|
9848
|
+
* the user presses the button. "Check now" really re-requests the route -
|
|
9849
|
+
* it does not re-render whatever the last reading was.
|
|
9850
|
+
*/
|
|
9851
|
+
function SettingsPanel() {
|
|
9852
|
+
const info = useSharedInfo(true)
|
|
9853
|
+
const t = makeTranslate(translate)
|
|
9854
|
+
|
|
9855
|
+
const items = []
|
|
9856
|
+
items.push(h('strong', { key: 'settings-title' }, 'dsh-autorestart'))
|
|
9857
|
+
items.push(h('p', { key: 'settings-description', style: SETTINGS_STYLE.description }, t('settings.description')))
|
|
9858
|
+
|
|
9859
|
+
return h('section', { 'data-dsh-autorestart-settings': 'dsh-autorestart', style: SETTINGS_STYLE.section }, items)
|
|
9860
|
+
}
|
|
9861
|
+
|
|
9862
|
+
function PluginFailurePanel() {
|
|
9863
|
+
const info = useSharedInfo(true)
|
|
9864
|
+
const [loaded, setLoaded] = useState(null)
|
|
9865
|
+
const [judgedHere, setJudgedHere] = useState(false)
|
|
9866
|
+
const [busy, setBusy] = useState('')
|
|
9867
|
+
const [note, setNote] = useState('')
|
|
9868
|
+
const [dismissed, setDismissed] = useState(() => {
|
|
9869
|
+
try {
|
|
9870
|
+
return window.localStorage.getItem(PLUGIN_FAILURE_DISMISS_KEY) || ''
|
|
9871
|
+
} catch {
|
|
9872
|
+
return ''
|
|
9873
|
+
}
|
|
9874
|
+
})
|
|
9875
|
+
const t = makeTranslate(translate)
|
|
9876
|
+
|
|
9877
|
+
// The pure helpers live in the second module and are reachable through
|
|
9878
|
+
// the global namespace, like `buildPluginFailureModel` above.
|
|
9879
|
+
const helpers = globalThis.__dshAutorestartClient
|
|
9880
|
+
const build = helpers && typeof helpers.buildPluginFailureModel === 'function'
|
|
9881
|
+
? helpers.buildPluginFailureModel
|
|
9882
|
+
: null
|
|
9883
|
+
const bootIdOf = helpers && typeof helpers.pluginFailureBootId === 'function'
|
|
9884
|
+
? helpers.pluginFailureBootId
|
|
9885
|
+
: () => ''
|
|
9886
|
+
const judgedKeyOf = helpers && typeof helpers.pluginFailureJudgedKey === 'function'
|
|
9887
|
+
? helpers.pluginFailureJudgedKey
|
|
9888
|
+
: () => PLUGIN_FAILURE_DISMISS_KEY
|
|
9889
|
+
const fingerprintOf = helpers && typeof helpers.pluginFailureFingerprint === 'function'
|
|
9890
|
+
? helpers.pluginFailureFingerprint
|
|
9891
|
+
: () => ''
|
|
9892
|
+
|
|
9893
|
+
// WHICH BOOT IS THIS? (user decision, 2026-11-02.) This card reports the
|
|
9894
|
+
// RESULT of a restart, so the boot identity — not the passing time —
|
|
9895
|
+
// decides when it may appear: `bootId` when the Host sends one,
|
|
9896
|
+
// `generation` when it does not (see pluginFailureBootId).
|
|
9897
|
+
const bootId = bootIdOf(info)
|
|
9898
|
+
const judgedKey = judgedKeyOf(bootId)
|
|
9899
|
+
|
|
9900
|
+
/** The record an earlier judgement of a boot left behind, or null. */
|
|
9901
|
+
const judgedRecord = (key) => {
|
|
9902
|
+
try {
|
|
9903
|
+
const stored = window.localStorage.getItem(key)
|
|
9904
|
+
return stored === null || stored === '' ? null : stored
|
|
9905
|
+
} catch {
|
|
9906
|
+
return null
|
|
9907
|
+
}
|
|
9908
|
+
}
|
|
9909
|
+
|
|
9910
|
+
// THE JUDGEMENT IS THE RESTART EVENT, NOT A LIVE READING. Two requests,
|
|
9911
|
+
// once, on the first mount of this boot — no polling, and nothing that
|
|
9912
|
+
// re-runs when the shared payload refreshes. The effect is keyed on the
|
|
9913
|
+
// boot identity alone, so a page refresh inside the same boot does not
|
|
9914
|
+
// judge again, and the next boot does.
|
|
9915
|
+
useEffect(() => {
|
|
9916
|
+
if (judgedRecord(judgedKey) !== null) return undefined
|
|
9917
|
+
let alive = true
|
|
9918
|
+
const get = async (url) => {
|
|
9919
|
+
try {
|
|
9920
|
+
const response = await fetch(url, { headers: { accept: 'application/json' } })
|
|
9921
|
+
if (!response || response.ok !== true) return null
|
|
9922
|
+
return await response.json()
|
|
9923
|
+
} catch {
|
|
9924
|
+
return null
|
|
9925
|
+
}
|
|
9926
|
+
}
|
|
9927
|
+
void (async () => {
|
|
9928
|
+
const watch = await get(pluginWatchUrlFor(info))
|
|
9929
|
+
const inventory = await get(pluginInventoryUrlFor(info))
|
|
9930
|
+
if (alive !== true) return
|
|
9931
|
+
// THE RECORD IS WRITTEN EVEN WHEN THERE IS NOTHING TO REPORT: "this
|
|
9932
|
+
// boot was judged" is the whole point, so a file that changes later
|
|
9933
|
+
// in the same boot cannot make the card appear out of nowhere.
|
|
9934
|
+
const modelNow = build === null ? null : build({ watch, inventory })
|
|
9935
|
+
try {
|
|
9936
|
+
window.localStorage.setItem(judgedKey, JSON.stringify({
|
|
9937
|
+
at: Date.now(),
|
|
9938
|
+
boot: bootId,
|
|
9939
|
+
fingerprint: fingerprintOf(modelNow),
|
|
9940
|
+
}))
|
|
9941
|
+
} catch {}
|
|
9942
|
+
setJudgedHere(true)
|
|
9943
|
+
setLoaded({ watch, inventory })
|
|
9944
|
+
})()
|
|
9945
|
+
return () => { alive = false }
|
|
9946
|
+
}, [judgedKey])
|
|
9947
|
+
|
|
9948
|
+
// AN EARLIER MOUNT OF THIS BOOT ALREADY JUDGED: nothing to show, and
|
|
9949
|
+
// nothing to ask the Host either (the effect above returned early).
|
|
9950
|
+
if (!judgedHere && judgedRecord(judgedKey) !== null) return null
|
|
9951
|
+
|
|
9952
|
+
if (loaded === null || build === null) return null
|
|
9953
|
+
const model = build({ watch: loaded.watch, inventory: loaded.inventory })
|
|
9954
|
+
if (model === null) return null
|
|
9955
|
+
|
|
9956
|
+
// The fingerprint is WHICH report this is (which plugins did not come up),
|
|
9957
|
+
// so dismissing silences this one for the rest of the boot; the boot record
|
|
9958
|
+
// keeps it silent even after a page refresh.
|
|
9959
|
+
const fingerprint = (model.certain || []).map((entry) => entry.name).sort().join(',')
|
|
9960
|
+
if (dismissed !== '' && dismissed === fingerprint) return null
|
|
9961
|
+
ensureAnnouncementStyle()
|
|
9962
|
+
|
|
9963
|
+
const restore = (plugin) => {
|
|
9964
|
+
if (busy !== '') return
|
|
9965
|
+
setBusy(plugin)
|
|
9966
|
+
setNote('')
|
|
9967
|
+
void (async () => {
|
|
9968
|
+
try {
|
|
9969
|
+
const restored = await restorePluginOnce(plugin)
|
|
9970
|
+
setNote(fill(t('pluginFailure.restored'), { plugin, at: '' }))
|
|
9971
|
+
if (restored && restored.ok !== true) setNote(fill(t('pluginFailure.failed'), { plugin, reason: '' }))
|
|
9972
|
+
} catch (failure) {
|
|
9973
|
+
setNote(fill(t('pluginFailure.failed'), { plugin, reason: describeError(failure) }))
|
|
9974
|
+
}
|
|
9975
|
+
setBusy('')
|
|
9976
|
+
})()
|
|
9977
|
+
}
|
|
9978
|
+
// DISMISS IS BOOT-SCOPED TOO (user decision, 2026-11-02): the record for
|
|
9979
|
+
// this boot already exists (it is written at judgement time), so the card
|
|
9980
|
+
// stays away for the rest of the boot — including after a page refresh —
|
|
9981
|
+
// and the next boot judges again. The fingerprint is stored as well so a
|
|
9982
|
+
// single report can be silenced without silencing the boot.
|
|
9983
|
+
// «全部恢复» needs no extra work for the same reason: it acts on the
|
|
9984
|
+
// judgement that this boot already made.
|
|
9985
|
+
const dismiss = () => {
|
|
9986
|
+
setDismissed(fingerprint)
|
|
9987
|
+
try { window.localStorage.setItem(PLUGIN_FAILURE_DISMISS_KEY, fingerprint) } catch {}
|
|
9988
|
+
try {
|
|
9989
|
+
if (judgedRecord(judgedKey) === null) {
|
|
9990
|
+
window.localStorage.setItem(judgedKey, JSON.stringify({ at: Date.now(), boot: bootId, fingerprint }))
|
|
9991
|
+
}
|
|
9992
|
+
} catch {}
|
|
9993
|
+
}
|
|
9994
|
+
|
|
9995
|
+
const rows = []
|
|
9996
|
+
const line = (key, text) => rows.push(h('p', { className: 'dsh-autorestart-announcement__line', key }, text))
|
|
9997
|
+
|
|
9998
|
+
// NO SECOND TITLE LINE (user report, 2026-11-02): the card header already
|
|
9999
|
+
// says «有两件事要你看一下», and repeating it as the first paragraph made
|
|
10000
|
+
// the panel look like it had two headings.
|
|
10001
|
+
|
|
10002
|
+
// WHICH PLUGINS DID NOT COME UP. There is exactly ONE list on this card
|
|
10003
|
+
// now (user decision, 2026-11-02): a plugin that is declared in the
|
|
10004
|
+
// profile and did not load this time. A file that merely differs from the
|
|
10005
|
+
// backup is NOT on this card — it belongs to the settings page, without a
|
|
10006
|
+
// verdict attached to it.
|
|
10007
|
+
const certain = Array.isArray(model.certain) ? model.certain : []
|
|
10008
|
+
if (certain.length > 0) {
|
|
10009
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'certain-title' }, t('pluginFailure.certainTitle')))
|
|
10010
|
+
line('certain-intro', t('pluginFailure.certainIntro'))
|
|
10011
|
+
for (const entry of certain) {
|
|
10012
|
+
line('certain:' + entry.name, entry.name)
|
|
10013
|
+
const action = entry.action && typeof entry.action === 'object' ? entry.action : {}
|
|
10014
|
+
if (entry.kind === 'linked') {
|
|
10015
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'hint:' + entry.name }, t('pluginFailure.linkedHint')))
|
|
10016
|
+
rows.push(h('button', {
|
|
10017
|
+
type: 'button',
|
|
10018
|
+
className: 'dsh-autorestart-announcement__cta',
|
|
10019
|
+
key: 'cta:' + entry.name,
|
|
10020
|
+
disabled: busy !== '',
|
|
10021
|
+
onClick: () => restore(entry.name),
|
|
10022
|
+
}, busy === entry.name
|
|
10023
|
+
? fill(t('pluginFailure.restoring'), { plugin: entry.name })
|
|
10024
|
+
: fill(t('pluginFailure.restore'), { plugin: entry.name })))
|
|
10025
|
+
} else {
|
|
10026
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'hint:' + entry.name }, t('pluginFailure.thirdPartyHint')))
|
|
10027
|
+
if (typeof action.command === 'string' && action.command !== '') {
|
|
10028
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__line', key: 'cmd:' + entry.name }, action.command))
|
|
10029
|
+
} else {
|
|
10030
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'nocmd:' + entry.name }, t('pluginFailure.noVersion')))
|
|
10031
|
+
}
|
|
10032
|
+
}
|
|
10033
|
+
}
|
|
10034
|
+
}
|
|
10035
|
+
|
|
10036
|
+
// ONE BUTTON FOR ALL OF THEM. Only plugins we wrote ourselves are in it:
|
|
10037
|
+
// a third-party plugin has no files in the backup, so "restore" cannot be
|
|
10038
|
+
// offered for it - those keep their reinstall hint instead.
|
|
10039
|
+
//
|
|
10040
|
+
// IT BELONGS TO THE CARD, SO IT SITS IN THE HEADER (user report,
|
|
10041
|
+
// 2026-11-02): at the bottom of the list it read like "restore all" applied
|
|
10042
|
+
// to the last plugin only. Same for «知道了».
|
|
10043
|
+
const linkedClues = certain.filter((entry) => entry.kind === 'linked')
|
|
10044
|
+
const actions = []
|
|
10045
|
+
if (linkedClues.length > 1) {
|
|
10046
|
+
actions.push(h('button', {
|
|
10047
|
+
type: 'button',
|
|
10048
|
+
className: 'dsh-autorestart-announcement__cta',
|
|
10049
|
+
'data-dsh-autorestart-plugin-failure': 'restore-all',
|
|
10050
|
+
key: 'restore-all',
|
|
10051
|
+
disabled: busy !== '',
|
|
10052
|
+
onClick: () => restoreAll(linkedClues),
|
|
10053
|
+
}, fill(t('pluginFailure.restoreAll'), { count: String(linkedClues.length) })))
|
|
10054
|
+
}
|
|
10055
|
+
|
|
10056
|
+
// A blind spot is a NUMBER: those module names are not plugin names.
|
|
10057
|
+
const blind = Number(model.uiBlindSpot)
|
|
10058
|
+
if (Number.isFinite(blind) && blind > 0) {
|
|
10059
|
+
rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'blind' },
|
|
10060
|
+
fill(t('pluginFailure.uiBlindSpot'), { count: String(blind) })))
|
|
10061
|
+
}
|
|
10062
|
+
if (note !== '') rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'note' }, note))
|
|
10063
|
+
|
|
10064
|
+
actions.push(h('button', {
|
|
10065
|
+
type: 'button',
|
|
10066
|
+
className: 'dsh-autorestart-announcement__cta',
|
|
10067
|
+
'data-dsh-autorestart-plugin-failure': 'dismiss',
|
|
10068
|
+
key: 'dismiss',
|
|
10069
|
+
title: t('pluginFailure.dismissLabel'),
|
|
10070
|
+
onClick: dismiss,
|
|
10071
|
+
}, t('pluginFailure.dismiss')))
|
|
10072
|
+
|
|
10073
|
+
return h(OverlayCard, {
|
|
10074
|
+
key: 'overlay-plugin-failure',
|
|
10075
|
+
id: 'plugin-failure',
|
|
10076
|
+
title: t('pluginFailure.title'),
|
|
10077
|
+
onClose: dismiss,
|
|
10078
|
+
rows: rows.concat([overlayHint(t('pluginFailure.hint'))]),
|
|
10079
|
+
actions,
|
|
10080
|
+
})
|
|
10081
|
+
}
|
|
10082
|
+
|
|
7567
10083
|
return {
|
|
7568
10084
|
inject: ['slots', 'locale'],
|
|
7569
10085
|
apply(ctx) {
|
|
@@ -7586,6 +10102,10 @@
|
|
|
7586
10102
|
// same dock (it never covers the interface and never touches the
|
|
7587
10103
|
// countdown) and disappears for good once the user answered it.
|
|
7588
10104
|
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'autorestart-feature-announcement', order: 8, locale: NS }, FeatureAnnouncementPanel))
|
|
10105
|
+
// THE PLUGIN THAT DID NOT LOAD (user decision, 2026-11-01): the boot
|
|
10106
|
+
// pass only REPORTS now, so this panel is where the user finds out
|
|
10107
|
+
// which plugin did not activate — and where the ONE action lives:
|
|
10108
|
+
// "put this one plugin back from the last backup".
|
|
7589
10109
|
// The graceful-shutdown button: a LIST slot at the foot of the left
|
|
7590
10110
|
// sidebar, beside the Settings row. The slot is a ROOT slot, so the
|
|
7591
10111
|
// button is there even when no conversation is open — and the
|
|
@@ -7627,6 +10147,20 @@
|
|
|
7627
10147
|
// back) in the middle of the interface, so the composer row can stay
|
|
7628
10148
|
// one short clipped line. It draws NOTHING when no restart is staged.
|
|
7629
10149
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-restart-overlay', order: 55, locale: NS }, RestartOverlay))
|
|
10150
|
+
// THESE TWO COME LAST ON PURPOSE: the countdown and the restart notice are
|
|
10151
|
+
// registered above, and the slot service keeps registrations in call order.
|
|
10152
|
+
// The plugin-failure and watchdog cards join them in the same floating layer.
|
|
10153
|
+
// THE DOCK, ON PURPOSE (2026-11-01). These two cards were registered on
|
|
10154
|
+
// `shell.overlay` first, and on the real machine NOTHING rendered at all -
|
|
10155
|
+
// not even the diagnostic skeleton - so that slot never reached this plugin.
|
|
10156
|
+
// `conversation.input.dock` is the slot this plugin has always rendered in.
|
|
10157
|
+
// The card is taken OUT of the layout flow by its own `position: fixed`, so
|
|
10158
|
+
// it floats in the corner instead of pushing the conversation up.
|
|
10159
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-plugin-failure', order: 9, label: () => 'dsh-autorestart', locale: NS }, PluginFailurePanel))
|
|
10160
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-watchdog-ask', order: 8, label: () => 'dsh-autorestart', locale: NS }, WatchdogAskPanel))
|
|
10161
|
+
// THE SETTINGS PAGE lives on its OWN slot, keyed to this package.
|
|
10162
|
+
// Name + key + locale is exactly how dsh-mnemon registers there.
|
|
10163
|
+
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-autorestart', locale: NS }, SettingsPanel))
|
|
7630
10164
|
// NOTE: the receipt of a settings change is NOT registered here. It
|
|
7631
10165
|
// is a `sticky` strip INSIDE the settings panel (see
|
|
7632
10166
|
// `saveNoticeBar`), because a window overlay put it on top of the
|
|
@@ -7643,3 +10177,301 @@
|
|
|
7643
10177
|
})
|
|
7644
10178
|
|
|
7645
10179
|
})()
|
|
10180
|
+
|
|
10181
|
+
;(function () {
|
|
10182
|
+
// ---------------------------------------------------------------------------
|
|
10183
|
+
// THE PLUGIN-FAILURE MODEL (pure, testable, no React and no fetch)
|
|
10184
|
+
//
|
|
10185
|
+
// Two DIFFERENT facts feed the panel, and they must never be blurred:
|
|
10186
|
+
//
|
|
10187
|
+
// certain - "these functions did not come up this time". A HARD fact: the last
|
|
10188
|
+
// reading had them and this one does not.
|
|
10189
|
+
// clues - "these plugins differ from the backup". A LEAD, not a verdict. A
|
|
10190
|
+
// plugin whose files changed may be perfectly fine.
|
|
10191
|
+
//
|
|
10192
|
+
// Neither of them says a plugin is BROKEN or FAILED to load: we have no way to
|
|
10193
|
+
// know that, and claiming it would be a guess dressed up as a finding.
|
|
10194
|
+
//
|
|
10195
|
+
// The three live layers are not equally good at naming a plugin. Measured on the
|
|
10196
|
+
// real host: tools 140 items, packages 29, clientModules only 4 - and those four
|
|
10197
|
+
// are module names (`batches`, `entries`, `release`), NOT plugin names. So
|
|
10198
|
+
// clientModules never produces a list entry; it becomes one number.
|
|
10199
|
+
// ---------------------------------------------------------------------------
|
|
10200
|
+
|
|
10201
|
+
const PLUGIN_FAILURE_THIRD_PARTY = 'third-party'
|
|
10202
|
+
const PLUGIN_FAILURE_LINKED = 'linked'
|
|
10203
|
+
|
|
10204
|
+
/** `codekg_query` -> `codekg`; a name with no separator is returned as it is. */
|
|
10205
|
+
function pluginFailureToolOwner(name) {
|
|
10206
|
+
const text = typeof name === 'string' ? name.trim() : ''
|
|
10207
|
+
if (text === '') return ''
|
|
10208
|
+
const cut = text.indexOf('_')
|
|
10209
|
+
return cut > 0 ? text.slice(0, cut) : text
|
|
10210
|
+
}
|
|
10211
|
+
|
|
10212
|
+
/** `@scope/name#host` -> `@scope/name`; `plain` stays `plain`. */
|
|
10213
|
+
function pluginFailurePackageName(name) {
|
|
10214
|
+
const text = typeof name === 'string' ? name.trim() : ''
|
|
10215
|
+
if (text === '') return ''
|
|
10216
|
+
const cut = text.indexOf('#')
|
|
10217
|
+
return cut > 0 ? text.slice(0, cut) : text
|
|
10218
|
+
}
|
|
10219
|
+
|
|
10220
|
+
/** Entry name out of one layer item, or '' when the shape is not recognised. */
|
|
10221
|
+
function pluginFailureLayerItemName(item) {
|
|
10222
|
+
if (typeof item === 'string') return item.trim()
|
|
10223
|
+
if (item === null || typeof item !== 'object') return ''
|
|
10224
|
+
for (const key of ['name', 'id', 'key', 'label']) {
|
|
10225
|
+
const value = item[key]
|
|
10226
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
10227
|
+
}
|
|
10228
|
+
return ''
|
|
10229
|
+
}
|
|
10230
|
+
|
|
10231
|
+
/** The items a watch layer reports as gone, whichever shape the host used. */
|
|
10232
|
+
function pluginFailureWatchLayer(watch, layer) {
|
|
10233
|
+
const out = []
|
|
10234
|
+
const push = (item) => {
|
|
10235
|
+
if (typeof item === 'string') { if (item.trim() !== '') out.push(item.trim()); return }
|
|
10236
|
+
if (item === null || typeof item !== 'object') return
|
|
10237
|
+
// `{layer, name}` (listDisappearances) or `{name, ...}`
|
|
10238
|
+
if (item.layer !== undefined && item.layer !== layer) return
|
|
10239
|
+
const name = pluginFailureLayerItemName(item)
|
|
10240
|
+
if (name !== '') out.push(name)
|
|
10241
|
+
}
|
|
10242
|
+
try {
|
|
10243
|
+
if (Array.isArray(watch && watch.disappeared)) {
|
|
10244
|
+
for (const item of watch.disappeared) push(item)
|
|
10245
|
+
}
|
|
10246
|
+
const layers = watch && typeof watch === 'object' ? watch.layers : null
|
|
10247
|
+
const bucket = layers && typeof layers === 'object' ? layers[layer] : null
|
|
10248
|
+
if (bucket && Array.isArray(bucket.removed)) {
|
|
10249
|
+
for (const item of bucket.removed) push(item)
|
|
10250
|
+
}
|
|
10251
|
+
} catch {
|
|
10252
|
+
return []
|
|
10253
|
+
}
|
|
10254
|
+
return out
|
|
10255
|
+
}
|
|
10256
|
+
|
|
10257
|
+
/** The inventory rows, whichever wrapper the route used. */
|
|
10258
|
+
function pluginFailureInventoryRows(inventory) {
|
|
10259
|
+
if (inventory === null || typeof inventory !== 'object') return []
|
|
10260
|
+
if (Array.isArray(inventory.plugins)) return inventory.plugins
|
|
10261
|
+
if (Array.isArray(inventory.items)) return inventory.items
|
|
10262
|
+
if (Array.isArray(inventory.entries)) return inventory.entries
|
|
10263
|
+
return []
|
|
10264
|
+
}
|
|
10265
|
+
|
|
10266
|
+
/** Is this inventory row a plugin the user wrote themselves (a link install)? */
|
|
10267
|
+
function pluginFailureIsLinked(row) {
|
|
10268
|
+
if (row === null || typeof row !== 'object') return false
|
|
10269
|
+
if (row.linked === true) return true
|
|
10270
|
+
if (row.isLink === true) return true
|
|
10271
|
+
for (const key of ['kind', 'installKind', 'source', 'install', 'mode']) {
|
|
10272
|
+
const value = row[key]
|
|
10273
|
+
if (typeof value === 'string' && /^(link|linked|local|dev|development)$/i.test(value.trim())) return true
|
|
10274
|
+
}
|
|
10275
|
+
return false
|
|
10276
|
+
}
|
|
10277
|
+
|
|
10278
|
+
/**
|
|
10279
|
+
* Turn one plugin into the action the panel is allowed to offer.
|
|
10280
|
+
*
|
|
10281
|
+
* A hand-written (link) plugin can be put back from the backup: we hold its
|
|
10282
|
+
* files. A third-party plugin CANNOT - that is the whole point of keeping only
|
|
10283
|
+
* the config - so it gets a hint and a command with the EXACT version, never a
|
|
10284
|
+
* one-click button that would silently install something else.
|
|
10285
|
+
*/
|
|
10286
|
+
function pluginFailureActionFor(name, kind, row, profile) {
|
|
10287
|
+
if (kind === PLUGIN_FAILURE_LINKED) {
|
|
10288
|
+
return { type: 'restore', label: name }
|
|
10289
|
+
}
|
|
10290
|
+
const version = row && typeof row === 'object' && typeof row.version === 'string' ? row.version.trim() : ''
|
|
10291
|
+
const profileName = typeof profile === 'string' && profile.trim() !== '' ? profile.trim() : '<profile>'
|
|
10292
|
+
if (version === '') {
|
|
10293
|
+
return {
|
|
10294
|
+
type: 'hint',
|
|
10295
|
+
command: null,
|
|
10296
|
+
note: 'the exact version is unknown, so no install command can be built',
|
|
10297
|
+
}
|
|
10298
|
+
}
|
|
10299
|
+
return {
|
|
10300
|
+
type: 'hint',
|
|
10301
|
+
command: 'dsh plugin --profile ' + profileName + ' add ' + name + '@' + version,
|
|
10302
|
+
note: '',
|
|
10303
|
+
}
|
|
10304
|
+
}
|
|
10305
|
+
|
|
10306
|
+
/**
|
|
10307
|
+
* THE BOOT THIS PAGE BELONGS TO (user decision, 2026-11-02).
|
|
10308
|
+
*
|
|
10309
|
+
* The plugin card reports the RESULT of a restart, so it is judged once per
|
|
10310
|
+
* boot — not monitored. The identity comes from the health payload: `bootId`
|
|
10311
|
+
* when the Host sends one, `generation` when it does not. Several wrappers are
|
|
10312
|
+
* accepted because the payload is flat in some builds and nested in others;
|
|
10313
|
+
* when the Host says nothing at all the identity is '' and the panel falls back
|
|
10314
|
+
* to one judgement per page load (reporting once beats monitoring forever).
|
|
10315
|
+
*
|
|
10316
|
+
* @param {object|null} info the shared health payload
|
|
10317
|
+
* @returns {string} the boot identity, or ''
|
|
10318
|
+
*/
|
|
10319
|
+
function pluginFailureBootId(info) {
|
|
10320
|
+
const read = (source, key) => {
|
|
10321
|
+
if (source === null || typeof source !== 'object') return ''
|
|
10322
|
+
const value = source[key]
|
|
10323
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
10324
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
10325
|
+
return ''
|
|
10326
|
+
}
|
|
10327
|
+
const holders = [info]
|
|
10328
|
+
if (info !== null && typeof info === 'object') {
|
|
10329
|
+
for (const key of ['health', 'status', 'instance', 'restart']) {
|
|
10330
|
+
if (info[key] !== null && typeof info[key] === 'object') holders.push(info[key])
|
|
10331
|
+
}
|
|
10332
|
+
}
|
|
10333
|
+
for (const key of ['bootId', 'boot', 'generation']) {
|
|
10334
|
+
for (const holder of holders) {
|
|
10335
|
+
const found = read(holder, key)
|
|
10336
|
+
if (found !== '') return found
|
|
10337
|
+
}
|
|
10338
|
+
}
|
|
10339
|
+
return ''
|
|
10340
|
+
}
|
|
10341
|
+
|
|
10342
|
+
/**
|
|
10343
|
+
* WHERE the "this boot was already judged" record lives: the key carries the
|
|
10344
|
+
* boot identity, so a restart leaves the old record behind and the next boot
|
|
10345
|
+
* starts clean.
|
|
10346
|
+
*
|
|
10347
|
+
* @param {string} bootId the identity from {@link pluginFailureBootId}
|
|
10348
|
+
* @returns {string} the localStorage key
|
|
10349
|
+
*/
|
|
10350
|
+
function pluginFailureJudgedKey(bootId) {
|
|
10351
|
+
const id = typeof bootId === 'string' && bootId.trim() !== '' ? bootId.trim() : 'page'
|
|
10352
|
+
return 'dsh-autorestart.plugin-failure.judged.' + id
|
|
10353
|
+
}
|
|
10354
|
+
|
|
10355
|
+
/**
|
|
10356
|
+
* The identity of one judgement: which functions are missing and which plugins
|
|
10357
|
+
* differ. Stored with the boot record so the page can tell "already reported"
|
|
10358
|
+
* from "the same report came back".
|
|
10359
|
+
*
|
|
10360
|
+
* @param {object|null} model a model from {@link buildPluginFailureModel}
|
|
10361
|
+
* @returns {string}
|
|
10362
|
+
*/
|
|
10363
|
+
function pluginFailureFingerprint(model) {
|
|
10364
|
+
const names = (list) => (Array.isArray(list) ? list : [])
|
|
10365
|
+
.map((entry) => (entry && typeof entry.name === 'string' ? entry.name : ''))
|
|
10366
|
+
.filter((name) => name !== '')
|
|
10367
|
+
.sort()
|
|
10368
|
+
.join(',')
|
|
10369
|
+
if (model === null || typeof model !== 'object') return ''
|
|
10370
|
+
return names(model.certain)
|
|
10371
|
+
}
|
|
10372
|
+
|
|
10373
|
+
/**
|
|
10374
|
+
* Merge the two data sources into the panel's model.
|
|
10375
|
+
*
|
|
10376
|
+
* @param {{ watch?: object|null, inventory?: object|null, profile?: string }} input
|
|
10377
|
+
* @returns {{ certain: object[], uiBlindSpot: number, watchAt: number|null }|null}
|
|
10378
|
+
*/
|
|
10379
|
+
function buildPluginFailureModel(input) {
|
|
10380
|
+
try {
|
|
10381
|
+
const source = input !== null && typeof input === 'object' ? input : {}
|
|
10382
|
+
const watch = source.watch !== null && typeof source.watch === 'object' ? source.watch : null
|
|
10383
|
+
const inventory = source.inventory !== null && typeof source.inventory === 'object' ? source.inventory : null
|
|
10384
|
+
const profile = typeof source.profile === 'string' ? source.profile : ''
|
|
10385
|
+
|
|
10386
|
+
// WHICH PLUGINS DID NOT COME UP (user decision, 2026-11-02).
|
|
10387
|
+
//
|
|
10388
|
+
// The ONLY evidence this card accepts: a plugin PACKAGE that loaded on the
|
|
10389
|
+
// previous start and is not loaded now. That is "it did not come up" — the
|
|
10390
|
+
// whole entry is gone, not just a few of its tools.
|
|
10391
|
+
//
|
|
10392
|
+
// What is deliberately NOT evidence:
|
|
10393
|
+
// * a file difference from the backup (an upgrade, our own edits) — that is
|
|
10394
|
+
// normal work, and the settings page shows it without judging it;
|
|
10395
|
+
// * a FEW capabilities disappearing while the package still loads — an
|
|
10396
|
+
// upgrade renames a tool; that is not a plugin that failed;
|
|
10397
|
+
// * a plugin the user removed from the bundles — that already happened.
|
|
10398
|
+
const notInstalled = new Set()
|
|
10399
|
+
const inventoryRows = pluginFailureInventoryRows(inventory)
|
|
10400
|
+
for (const row of inventoryRows) {
|
|
10401
|
+
if (row === null || typeof row !== 'object') continue
|
|
10402
|
+
if (row.status !== 'not-installed') continue
|
|
10403
|
+
const name = typeof row.name === 'string' ? row.name.trim() : ''
|
|
10404
|
+
if (name !== '') notInstalled.add(name)
|
|
10405
|
+
}
|
|
10406
|
+
const inventoryRowFor = (name) => inventoryRows
|
|
10407
|
+
.find((row) => row !== null && typeof row === 'object'
|
|
10408
|
+
&& typeof row.name === 'string' && row.name.trim() === name) || null
|
|
10409
|
+
|
|
10410
|
+
const certain = []
|
|
10411
|
+
const byName = new Map()
|
|
10412
|
+
const addNotLoaded = (value, from) => {
|
|
10413
|
+
// `@scope/pkg@1.2.3` is one package with a version: the card names the
|
|
10414
|
+
// PLUGIN, and the version belongs in the command, not in the name.
|
|
10415
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
10416
|
+
const at = text.lastIndexOf('@')
|
|
10417
|
+
const name = at > 0 ? text.slice(0, at) : text
|
|
10418
|
+
if (name === '') return
|
|
10419
|
+
if (notInstalled.has(name)) return
|
|
10420
|
+
if (byName.has(name)) return
|
|
10421
|
+
const row = inventoryRowFor(name)
|
|
10422
|
+
const kind = row !== null && pluginFailureIsLinked(row) ? PLUGIN_FAILURE_LINKED : PLUGIN_FAILURE_THIRD_PARTY
|
|
10423
|
+
const entry = {
|
|
10424
|
+
name,
|
|
10425
|
+
source: from,
|
|
10426
|
+
kind,
|
|
10427
|
+
action: pluginFailureActionFor(name, kind, row, profile),
|
|
10428
|
+
lostTools: [],
|
|
10429
|
+
}
|
|
10430
|
+
byName.set(name, entry)
|
|
10431
|
+
certain.push(entry)
|
|
10432
|
+
}
|
|
10433
|
+
for (const raw of pluginFailureWatchLayer(watch, 'packages')) addNotLoaded(pluginFailurePackageName(raw), 'packages')
|
|
10434
|
+
|
|
10435
|
+
// A vanished TOOL is EVIDENCE, never a trigger: it is attached to a package
|
|
10436
|
+
// that already did not come up. A tool that vanished while its package still
|
|
10437
|
+
// loads is an upgrade and stays out of this card.
|
|
10438
|
+
for (const raw of pluginFailureWatchLayer(watch, 'tools')) {
|
|
10439
|
+
const owner = pluginFailureToolOwner(raw)
|
|
10440
|
+
const entry = byName.get(owner)
|
|
10441
|
+
if (entry === undefined) continue
|
|
10442
|
+
const tool = typeof raw === 'string' ? raw.trim() : ''
|
|
10443
|
+
if (tool !== '' && entry.lostTools.includes(tool) === false) entry.lostTools.push(tool)
|
|
10444
|
+
}
|
|
10445
|
+
|
|
10446
|
+
// clientModules CANNOT name a plugin: its items are module names. It is a
|
|
10447
|
+
// blind spot, and it is reported as a number so nobody mistakes `batches`
|
|
10448
|
+
// for a plugin the user should go and look for.
|
|
10449
|
+
const uiBlindSpot = pluginFailureWatchLayer(watch, 'clientModules').length
|
|
10450
|
+
|
|
10451
|
+
// Nothing to say: no shell, no empty panel.
|
|
10452
|
+
if (certain.length === 0) return null
|
|
10453
|
+
|
|
10454
|
+
const watchAt = watch && Number.isFinite(Number(watch.at)) ? Number(watch.at) : null
|
|
10455
|
+
return { certain, uiBlindSpot, watchAt }
|
|
10456
|
+
} catch {
|
|
10457
|
+
// The panel is a convenience; a malformed payload must never break the page.
|
|
10458
|
+
return null
|
|
10459
|
+
}
|
|
10460
|
+
}
|
|
10461
|
+
|
|
10462
|
+
const PLUGIN_FAILURE_EXPORTS = {
|
|
10463
|
+
buildPluginFailureModel,
|
|
10464
|
+
pluginFailureBootId,
|
|
10465
|
+
pluginFailureJudgedKey,
|
|
10466
|
+
pluginFailureFingerprint,
|
|
10467
|
+
pluginFailureToolOwner,
|
|
10468
|
+
pluginFailurePackageName,
|
|
10469
|
+
}
|
|
10470
|
+
if (typeof globalThis !== 'undefined') {
|
|
10471
|
+
globalThis.__dshAutorestartClient = Object.assign(
|
|
10472
|
+
globalThis.__dshAutorestartClient || {},
|
|
10473
|
+
PLUGIN_FAILURE_EXPORTS,
|
|
10474
|
+
)
|
|
10475
|
+
}
|
|
10476
|
+
|
|
10477
|
+
})()
|