dsh-autorestart 0.8.4 → 0.8.5

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/client.js CHANGED
@@ -379,6 +379,27 @@
379
379
  'settings.backup.size': '备份大小',
380
380
  'settings.backup.withPlugins': '(含已装插件)',
381
381
  'settings.backup.withoutPlugins': '(不含已装插件)',
382
+ // WHAT a copy is (user decision, 2026-10-30). Only a proven copy may be
383
+ // rolled back to, so the identity is said in plain words on every row.
384
+ 'settings.backup.kindLabel': '身份',
385
+ 'settings.backup.kind.known-good': '安全份',
386
+ 'settings.backup.kind.snapshot': '本次快照',
387
+ 'settings.backup.kind.unknown': '旧备份(未验证)',
388
+ 'settings.backup.version': '版本 {v}',
389
+ 'settings.backup.identityHint': '备份有两种身份:①「安全份」——启动成功后验证过的那一份,出问题只会回滚安全份;第一次安装时会自动生成一份。②「本次快照」——每次重启前刚拍的,还没验证过;旧备份(未验证)和它都不会被自动回滚。两类合计最多保留 {n} 份,超出删最旧的那份(安全份不会被删)。手动回退会先二次确认,然后会自动重启。',
390
+ // THE MANUAL ROLLBACK (user decision, 2026-10-30): the user may know better
391
+ // than "the newest proven copy", so ANY copy can be restored — but only
392
+ // after a card that says which one, what it replaces, and that a restart
393
+ // follows.
394
+ 'settings.backup.rollback': '回退到这一份',
395
+ 'settings.backup.rollbackConfirmTitle': '确定回退到这一份?',
396
+ 'settings.backup.rollbackConfirmBody': '会把 {time} 的那份备份还原到现在——**当前状态会被它覆盖**(覆盖前会先自动存一份当前快照,所以这次回退还能再退回来)。',
397
+ 'settings.backup.rollbackConfirmRestart': '还原完成后会自动重启一次;重启期间这个页面会断开,回来后接着用。',
398
+ 'settings.backup.rollbackConfirmYes': '确认回退',
399
+ 'settings.backup.rollbackConfirmNo': '取消',
400
+ 'settings.backup.rollbackBusy': '正在回退…',
401
+ 'settings.backup.rollbackDone': '已回退,正在重启…',
402
+ 'settings.backup.rollbackFailed': '回退没成功:{message}',
382
403
  'settings.groups.report': '报告问题',
383
404
  'settings.groups.rescue': '出事了怎么办',
384
405
  'settings.rescue.hint': 'DSH 打不开的时候,用下面这两个脚本救自己(都不用 DSH 跑起来)。',
@@ -497,6 +518,12 @@
497
518
  'restart.overlay.size': '{done} / {total}',
498
519
  'restart.overlay.sizeDone': '{done}',
499
520
  'restart.overlay.restarting': '正在重启…',
521
+ // THE HONEST CARD (user report, 2026-10-30): a restart that was interrupted
522
+ // by a new conversation said "正在重启…" for 25 s, which was not true. It now
523
+ // says what actually happened — and it may stay, because it IS the truth.
524
+ 'restart.overlay.deferred': '有新会话在跑,重启已暂缓',
525
+ 'restart.overlay.deferredNote': '等它结束会自动继续 · 已等 {count} 个会话',
526
+ 'restart.overlay.deferredNoteZero': '等它结束会自动继续',
500
527
  'restart.overlay.back': '已回来',
501
528
  }
502
529
 
@@ -728,6 +755,26 @@
728
755
  'settings.backup.size': 'Backup size',
729
756
  'settings.backup.withPlugins': '(plugins included)',
730
757
  'settings.backup.withoutPlugins': '(plugins excluded)',
758
+ // WHAT a copy is (user decision, 2026-10-30). Only a proven copy may be
759
+ // rolled back to, so the identity is said in plain words on every row.
760
+ 'settings.backup.kindLabel': 'Identity',
761
+ 'settings.backup.kind.known-good': 'Known good',
762
+ 'settings.backup.kind.snapshot': "This restart's snapshot",
763
+ 'settings.backup.kind.unknown': 'Old backup (unverified)',
764
+ 'settings.backup.version': 'version {v}',
765
+ 'settings.backup.identityHint': 'A backup has one of two identities: ① “known good” — the copy a boot has proven; when something breaks, only a known-good copy is rolled back, and the first install creates one automatically. ② “this restart\'s snapshot” — taken right before every restart and not proven yet; old backups (unverified) and that snapshot are never rolled back automatically. At most {n} copies are kept together (the oldest goes first; a known-good copy is never deleted). A manual rollback asks for confirmation first, then restarts automatically.',
766
+ // THE MANUAL ROLLBACK (user decision, 2026-10-30): any copy can be restored
767
+ // — but only after a card that says which one, what it replaces, and that
768
+ // a restart follows.
769
+ 'settings.backup.rollback': 'Roll back to this copy',
770
+ 'settings.backup.rollbackConfirmTitle': 'Roll back to this copy?',
771
+ '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).',
772
+ 'settings.backup.rollbackConfirmRestart': 'A restart follows automatically; this page disconnects during it and works again afterwards.',
773
+ 'settings.backup.rollbackConfirmYes': 'Roll back',
774
+ 'settings.backup.rollbackConfirmNo': 'Cancel',
775
+ 'settings.backup.rollbackBusy': 'Rolling back…',
776
+ 'settings.backup.rollbackDone': 'Rolled back — restarting…',
777
+ 'settings.backup.rollbackFailed': 'The rollback did not work: {message}',
731
778
  'settings.groups.report': 'Report a problem',
732
779
  'settings.groups.rescue': 'If DSH will not start',
733
780
  'settings.rescue.hint': 'When DSH does not come up, these two scripts rescue it for you (neither of them needs DSH running).',
@@ -837,6 +884,9 @@
837
884
  'restart.overlay.size': '{done} / {total}',
838
885
  'restart.overlay.sizeDone': '{done}',
839
886
  'restart.overlay.restarting': 'Restarting…',
887
+ 'restart.overlay.deferred': 'A conversation just started — restart postponed',
888
+ 'restart.overlay.deferredNote': 'It will retry automatically when idle · {count} waiting',
889
+ 'restart.overlay.deferredNoteZero': 'It will retry automatically when idle',
840
890
  'restart.overlay.back': 'Back again',
841
891
  }
842
892
 
@@ -2090,6 +2140,34 @@
2090
2140
  return Math.round(value) + ' B'
2091
2141
  }
2092
2142
 
2143
+ /**
2144
+ * WHAT A COPY IS — the identity word of the interface.
2145
+ *
2146
+ * Only a copy a boot has PROVEN may be rolled back to ("安全份" / known-good).
2147
+ * The snapshot of a restart is not proven yet ("本次快照"), and a copy without
2148
+ * an identity file was written before the identity layer ("旧备份(未验证)").
2149
+ * An unknown word is shown as unproven on purpose: never claim more than the
2150
+ * copy itself says.
2151
+ *
2152
+ * @param {string|null|undefined} kind
2153
+ * @returns {'known-good'|'snapshot'|'unknown'}
2154
+ */
2155
+ function backupKindKey(kind) {
2156
+ if (kind === 'known-good') return 'known-good'
2157
+ if (kind === 'snapshot') return 'snapshot'
2158
+ return 'unknown'
2159
+ }
2160
+
2161
+ /**
2162
+ * The version a copy was proven with, or an empty string when it carries none.
2163
+ *
2164
+ * @param {object|null|undefined} item one backup of the state payload
2165
+ * @returns {string}
2166
+ */
2167
+ function backupVersionOf(item) {
2168
+ return item && typeof item.version === 'string' && item.version !== '' ? item.version : ''
2169
+ }
2170
+
2093
2171
  /**
2094
2172
  * The local time of one backup, to the minute.
2095
2173
  *
@@ -2192,6 +2270,134 @@
2192
2270
  * @param {Function} [translator]
2193
2271
  * @returns {string}
2194
2272
  */
2273
+ /**
2274
+ * HOW LONG THE "RESTART POSTPONED" CARD STAYS ON SCREEN (milliseconds).
2275
+ *
2276
+ * THE REPORT (user, 2026-10-30): "after the interruption I still have a lot of
2277
+ * work to do — a card hovering for ever is in the way". The postponed card is an
2278
+ * ANNOUNCEMENT, not a progress bar: it says what happened for four seconds and
2279
+ * then folds away, while the capsule keeps saying "pending restart · waiting for
2280
+ * N sessions". A NEW postponement (a new stage timestamp) announces itself again.
2281
+ *
2282
+ * The stages that really are running ('snapshotting', 'restarting') are not
2283
+ * affected: they stay while the work lasts.
2284
+ */
2285
+ const RESTART_DEFERRED_CARD_MS = 4000
2286
+
2287
+ /**
2288
+ * HOW LONG A STAGE MAY GO UNMENTIONED BEFORE THE CARD STOPS TRUSTING IT.
2289
+ *
2290
+ * WHY (user report, 2026-10-29): a restart was postponed (a conversation
2291
+ * became active again) right after the copy, and the card kept saying
2292
+ * "DSH 重启前备份中" for ever — the stage on disk had not been brought back to
2293
+ * 'waiting'. The Host is fixed, but the interface must not be able to hang on
2294
+ * a stale stage either: a stage is evidence only while it keeps being
2295
+ * refreshed.
2296
+ */
2297
+ const RESTART_STAGE_STALE_MS = 15000
2298
+ // While files are being copied the Host refreshes the stage on every progress
2299
+ // tick, so ten silent seconds mean the copy is no longer reporting — either it
2300
+ // is over or nobody is copying any more. A 'restarting' stage has no progress
2301
+ // of its own and only has to stay inside the stale window above.
2302
+ const RESTART_SNAPSHOT_FRESH_MS = 10000
2303
+
2304
+ /**
2305
+ * THE TWO CADENCES OF THE SHARED POLL (milliseconds).
2306
+ *
2307
+ * 2 s is right for a page that merely shows whether a restart is pending: a
2308
+ * pending restart sits there for minutes. While a restart is MOVING, though,
2309
+ * the stage can flip at any moment (a conversation starts, the host postpones
2310
+ * the restart, the card has to change its words) — and 2 s of the wrong word is
2311
+ * exactly what the owner complained about. So the cadence follows the state.
2312
+ */
2313
+ const POLL_MS = 2000
2314
+ const POLL_FAST_MS = 500
2315
+
2316
+ /**
2317
+ * IS A RESTART MOVING RIGHT NOW?
2318
+ *
2319
+ * 'waiting' counts as well: the restart is requested and the gate may open (or
2320
+ * a conversation may appear) at any moment — that is exactly when the card must
2321
+ * change its words within a second.
2322
+ *
2323
+ * @param {object|null} info the state payload of the Host
2324
+ * @returns {boolean}
2325
+ */
2326
+ function isRestartMoving(info) {
2327
+ if (!info || typeof info !== 'object') return false
2328
+ if (info.pendingRestart !== true) return false
2329
+ const stage = info.restartPhase
2330
+ const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
2331
+ return phase === 'waiting' || phase === 'snapshotting' || phase === 'restarting'
2332
+ }
2333
+
2334
+ /**
2335
+ * The cadence the CURRENT state asks for.
2336
+ *
2337
+ * @param {object|null} info the state payload of the Host
2338
+ * @returns {number} milliseconds
2339
+ */
2340
+ function pollIntervalFor(info) {
2341
+ return isRestartMoving(info) ? POLL_FAST_MS : POLL_MS
2342
+ }
2343
+
2344
+ /**
2345
+ * Is the page on screen? A hidden tab has nobody to tell, so it must not spin
2346
+ * (and it asks once more as soon as it is visible again).
2347
+ *
2348
+ * @returns {boolean}
2349
+ */
2350
+ function isPageVisible() {
2351
+ if (typeof document === 'undefined' || !document) return true
2352
+ if (document.hidden === true) return false
2353
+ if (typeof document.visibilityState === 'string') return document.visibilityState !== 'hidden'
2354
+ return true
2355
+ }
2356
+
2357
+ /**
2358
+ * IS THE RESTART POSTPONED BY A CONVERSATION? (the Host says so in `reason`)
2359
+ *
2360
+ * A postponed restart is a stage of 'waiting' with a reason that starts with
2361
+ * "deferred:" (see the Host's deferRestartStage). That state is NOT an activity,
2362
+ * so it is drawn differently: the card names the interruption and promises the
2363
+ * retry, and the word "正在重启" never appears for it.
2364
+ *
2365
+ * @param {object|null} stage the `restartPhase` of the state route
2366
+ * @returns {boolean}
2367
+ */
2368
+ function isRestartStageDeferred(stage) {
2369
+ if (!stage || typeof stage !== 'object') return false
2370
+ if (stage.phase !== 'waiting') return false
2371
+ return typeof stage.reason === 'string' && stage.reason.indexOf('deferred:') === 0
2372
+ }
2373
+
2374
+ /**
2375
+ * Is the reported stage still something the card may draw?
2376
+ *
2377
+ * The card is an ALARM about something happening right now: 'snapshotting'
2378
+ * (files are being copied) or 'restarting' (the host is going down). A stage
2379
+ * that nobody refreshed for 15 s (10 s for a copy, which refreshes on every
2380
+ * file) describes something that is not happening any more, and drawing it
2381
+ * would leave a card hanging — exactly the bug to avoid. The pill keeps
2382
+ * telling the user the restart is still pending; the card simply stops
2383
+ * claiming progress.
2384
+ *
2385
+ * @param {object|null} stage the `restartPhase` of the state route
2386
+ * @param {number} now
2387
+ * @returns {boolean}
2388
+ */
2389
+ function isRestartStageLive(stage, now) {
2390
+ const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
2391
+ if (phase !== 'snapshotting' && phase !== 'restarting') return false
2392
+ const at = stage && Number.isFinite(Number(stage.at)) ? Number(stage.at) : null
2393
+ // No timestamp at all: the stage cannot be trusted to describe "now".
2394
+ if (at === null || at <= 0) return false
2395
+ const age = Math.max(0, now - at)
2396
+ if (age > RESTART_STAGE_STALE_MS) return false
2397
+ if (phase === 'restarting') return true
2398
+ return age <= RESTART_SNAPSHOT_FRESH_MS
2399
+ }
2400
+
2195
2401
  function restartOverlayTitle(stage, translator) {
2196
2402
  const phase = stage && typeof stage === 'object' && typeof stage.phase === 'string' ? stage.phase : ''
2197
2403
  if (!isRestartOverlayPhase(phase)) return ''
@@ -2275,6 +2481,19 @@
2275
2481
  // The stage of a restart on the two surfaces that have room for it: the
2276
2482
  // composer row (one short line) and the centred overlay (the detail).
2277
2483
  isRestartOverlayPhase,
2484
+ // Is the reported stage still worth a card? (a stale stage cannot hang it)
2485
+ isRestartStageLive,
2486
+ isRestartStageDeferred,
2487
+ RESTART_DEFERRED_CARD_MS,
2488
+ // The cadence of the shared poll: fast while a restart is moving, and no
2489
+ // polling at all in a hidden tab.
2490
+ isRestartMoving,
2491
+ pollIntervalFor,
2492
+ isPageVisible,
2493
+ POLL_MS,
2494
+ POLL_FAST_MS,
2495
+ RESTART_STAGE_STALE_MS,
2496
+ RESTART_SNAPSHOT_FRESH_MS,
2278
2497
  restartPillStageText,
2279
2498
  restartOverlayTitle,
2280
2499
  // The size with the unit that fits it ("92 KB", "156 MB"): a hosted profile
@@ -2316,6 +2535,8 @@
2316
2535
  const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
2317
2536
  const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
2318
2537
  const DEFAULT_CANCEL_URL = ROUTE_PREFIX + '/cancel'
2538
+ // The route of the MANUAL ROLLBACK (restore ONE copy the user picked).
2539
+ const DEFAULT_ROLLBACK_URL = ROUTE_PREFIX + '/rollback-to'
2319
2540
  // The route that persists the one-time install choice.
2320
2541
  const DEFAULT_CHOICE_URL = ROUTE_PREFIX + '/choice'
2321
2542
  // The browser-side record of the one-time install choice. The prompt is
@@ -2329,7 +2550,8 @@
2329
2550
  * the fallback for a Host that is older than the supervisor.
2330
2551
  */
2331
2552
  const DEFAULT_SUPERVISOR_URL = '/__dsh_autorestart/supervisor'
2332
- const POLL_MS = 2000
2553
+ // The cadence lives with the state it follows (see POLL_MS/POLL_FAST_MS
2554
+ // above): the shared poll speeds up while a restart is moving.
2333
2555
  // The brand accent (blue-purple). The pending-restart marker is a brand
2334
2556
  // signal, not a warning: no warning/yellow token is used here.
2335
2557
  const BRAND = 'var(--dsw-alias-brand-primary, #5b8def)'
@@ -2405,11 +2627,15 @@
2405
2627
  info: null,
2406
2628
  listeners: new Set(),
2407
2629
  timer: null,
2630
+ // The cadence the current timer was armed with (0 = not armed).
2631
+ interval: 0,
2408
2632
  inFlight: false,
2409
2633
  controller: null,
2410
2634
  // When the pending state was FIRST observed, so the details panel can
2411
2635
  // say how long this session has been waiting.
2412
2636
  pendingSince: null,
2637
+ // The visibility listener is bound once, on the first subscriber.
2638
+ visibilityBound: false,
2413
2639
  }
2414
2640
 
2415
2641
  function notifyListeners() {
@@ -2442,8 +2668,63 @@
2442
2668
  }
2443
2669
  }
2444
2670
 
2671
+ /**
2672
+ * (Re-)arm the shared timer with the cadence the state asks for.
2673
+ *
2674
+ * THE CADENCE IS RE-DECIDED AFTER EVERY ANSWER: a restart that starts makes
2675
+ * the next tick 500 ms away, a restart that ends puts it back to 2 s, and a
2676
+ * hidden page is not polled at all (an interval in a background tab only
2677
+ * spends battery — it asks once as soon as it is visible again).
2678
+ *
2679
+ * Re-arming only happens when the cadence really changed, so the ticks stay
2680
+ * evenly spaced and never pile up.
2681
+ */
2682
+ function armPoll() {
2683
+ if (stateStore.listeners.size === 0) return
2684
+ const wanted = isPageVisible() ? pollIntervalFor(stateStore.info) : 0
2685
+ if (wanted === 0) {
2686
+ if (stateStore.timer !== null) {
2687
+ clearInterval(stateStore.timer)
2688
+ stateStore.timer = null
2689
+ }
2690
+ stateStore.interval = 0
2691
+ return
2692
+ }
2693
+ if (stateStore.timer !== null && stateStore.interval === wanted) return
2694
+ if (stateStore.timer !== null) clearInterval(stateStore.timer)
2695
+ stateStore.interval = wanted
2696
+ stateStore.timer = setInterval(() => {
2697
+ pollOnce().then(armPoll)
2698
+ }, wanted)
2699
+ }
2700
+
2701
+ /**
2702
+ * Ask once more as soon as the page is back on screen.
2703
+ *
2704
+ * A hidden tab stops polling; coming back must not wait for the next tick of
2705
+ * a timer that was stopped, so the visibility event polls immediately and
2706
+ * then re-arms the cadence.
2707
+ */
2708
+ function bindVisibility() {
2709
+ if (stateStore.visibilityBound) return
2710
+ if (typeof document === 'undefined' || !document || typeof document.addEventListener !== 'function') return
2711
+ stateStore.visibilityBound = true
2712
+ document.addEventListener('visibilitychange', () => {
2713
+ if (stateStore.listeners.size === 0) return
2714
+ if (!isPageVisible()) {
2715
+ armPoll()
2716
+ return
2717
+ }
2718
+ pollOnce().then(armPoll)
2719
+ armPoll()
2720
+ })
2721
+ }
2722
+
2445
2723
  async function pollOnce() {
2724
+ // ONE REQUEST AT A TIME: a slow answer must never stack up a queue.
2446
2725
  if (stateStore.inFlight) return
2726
+ // Nobody is looking at a hidden tab; the next visible moment asks.
2727
+ if (!isPageVisible()) return
2447
2728
  stateStore.inFlight = true
2448
2729
  try {
2449
2730
  const response = await fetch(HEALTH_URL, {
@@ -2482,8 +2763,9 @@
2482
2763
  stateStore.listeners.add(listener)
2483
2764
  if (stateStore.listeners.size === 1) {
2484
2765
  stateStore.controller = new AbortController()
2485
- pollOnce()
2486
- stateStore.timer = setInterval(pollOnce, POLL_MS)
2766
+ bindVisibility()
2767
+ pollOnce().then(armPoll)
2768
+ armPoll()
2487
2769
  }
2488
2770
  return () => {
2489
2771
  stateStore.listeners.delete(listener)
@@ -2492,6 +2774,7 @@
2492
2774
  clearInterval(stateStore.timer)
2493
2775
  stateStore.timer = null
2494
2776
  }
2777
+ stateStore.interval = 0
2495
2778
  if (stateStore.controller) {
2496
2779
  stateStore.controller.abort()
2497
2780
  stateStore.controller = null
@@ -2688,6 +2971,17 @@
2688
2971
  return DEFAULT_CANCEL_URL
2689
2972
  }
2690
2973
 
2974
+ /**
2975
+ * The route that restores ONE backup the user picked (the manual
2976
+ * rollback): the one the Host sent with the state payload, or the derived
2977
+ * default. It never carries a path — only a backup NAME goes in the body.
2978
+ */
2979
+ function rollbackUrlFor(info) {
2980
+ const sent = info && typeof info.rollbackUrl === 'string' ? info.rollbackUrl.trim() : ''
2981
+ if (sent.startsWith('/')) return sent
2982
+ return DEFAULT_ROLLBACK_URL
2983
+ }
2984
+
2691
2985
  /**
2692
2986
  * Whether the one-time install prompt may be shown.
2693
2987
  *
@@ -3817,13 +4111,58 @@
3817
4111
  // on right now", the state file alone does not.
3818
4112
  const pending = Boolean(info && info.pendingRestart === true)
3819
4113
  // 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
- const running = pending && (phase === 'snapshotting' || phase === 'restarting')
4114
+ // snapshot is being written, or the host is on its way out — AND the
4115
+ // stage must still be fresh. A stage nobody refreshed (the restart was
4116
+ // postponed and the Host did not say so) cannot leave a card on screen
4117
+ // for ever; the pill still says the restart is pending.
4118
+ const running = pending && isRestartStageLive(stage, Date.now())
3822
4119
 
3823
4120
  useEffect(() => {
3824
4121
  if (running) setSawRunning(true)
3825
4122
  }, [running])
3826
4123
 
4124
+ // THE POSTPONED CARD IS AN ANNOUNCEMENT, NOT A PROGRESS BAR (user report,
4125
+ // 2026-10-30): "after the interruption I still have a lot of work to do — a
4126
+ // card hovering for ever is in the way". It says what happened for four
4127
+ // seconds and then folds away; the capsule keeps saying the restart is
4128
+ // pending. A NEW postponement (a new stage timestamp) announces itself
4129
+ // again. Hooks live up here, with the others, because they must run on
4130
+ // every render — including the renders that draw nothing.
4131
+ const deferredKey = pending && isRestartStageDeferred(stage)
4132
+ ? (Number.isFinite(Number(stage.at)) ? Number(stage.at) : 0)
4133
+ : null
4134
+ const [announcedKey, setAnnouncedKey] = useState(null)
4135
+ const [folded, setFolded] = useState(false)
4136
+
4137
+ useEffect(() => {
4138
+ if (deferredKey === null) {
4139
+ if (announcedKey !== null) {
4140
+ setAnnouncedKey(null)
4141
+ setFolded(false)
4142
+ }
4143
+ return undefined
4144
+ }
4145
+ if (announcedKey === deferredKey) return undefined
4146
+ setAnnouncedKey(deferredKey)
4147
+ setFolded(false)
4148
+ return undefined
4149
+ }, [deferredKey, announcedKey])
4150
+
4151
+ // The four seconds of THIS announcement.
4152
+ useEffect(() => {
4153
+ if (announcedKey === null || folded) return undefined
4154
+ const timer = setTimeout(() => setFolded(true), RESTART_DEFERRED_CARD_MS)
4155
+ return () => clearTimeout(timer)
4156
+ }, [announcedKey, folded])
4157
+
4158
+ // A POSTPONED RESTART IS NOT A RUNNING ONE (user report, 2026-10-30): when
4159
+ // the Host says the restart went back to waiting because a conversation
4160
+ // started, the card says exactly that. It is drawn AT ONCE (a fresh
4161
+ // postponement must not wait for a second render) and the hook above
4162
+ // records which postponement has been announced, so its four seconds can
4163
+ // run out; every new stage timestamp resets the clock.
4164
+ const postponed = deferredKey !== null && !folded
4165
+
3827
4166
  // "已回来" is a MOMENT, not a state: 1.5s and then the card is gone.
3828
4167
  useEffect(() => {
3829
4168
  if (phase !== 'back') {
@@ -3834,8 +4173,9 @@
3834
4173
  return () => clearTimeout(timer)
3835
4174
  }, [phase])
3836
4175
 
3837
- // THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS.
3838
- const visible = running || (phase === 'back' && sawRunning && !backGone)
4176
+ // THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS — or while
4177
+ // the restart is honestly postponed.
4178
+ const visible = running || postponed || (phase === 'back' && sawRunning && !backGone)
3839
4179
  if (!visible) return null
3840
4180
 
3841
4181
  const progress = stage.progress && typeof stage.progress === 'object' ? stage.progress : null
@@ -3845,11 +4185,32 @@
3845
4185
  ? Math.max(0, Math.min(100, Math.round(Number(progress.percent))))
3846
4186
  : null
3847
4187
 
3848
- const title = restartOverlayTitle(stage, t)
4188
+ // The postponed card wins over everything else: even a stage that still
4189
+ // says 'restarting' in the same render is downgraded here, so the old word
4190
+ // can never survive a single frame once the Host said "deferred".
4191
+ const deferredCount = postponed ? waitingTaskCount(info, info && Array.isArray(info.blockedBy) ? info.blockedBy : []) : 0
4192
+ const title = postponed
4193
+ ? t('restart.overlay.deferred')
4194
+ : restartOverlayTitle(stage, t)
3849
4195
 
3850
4196
  let bar = null
3851
4197
  let detail = null
3852
- if (phase === 'snapshotting') {
4198
+ if (postponed) {
4199
+ // A second, smaller line: what happens next, and how many conversations
4200
+ // are in the way (the same number the capsule shows).
4201
+ detail = h(
4202
+ 'p',
4203
+ { className: 'dsh-autorestart-restart-overlay__size' },
4204
+ h(
4205
+ 'span',
4206
+ { 'data-dsh-autorestart-restart-overlay-deferred': 'true' },
4207
+ deferredCount > 0
4208
+ ? fill(t('restart.overlay.deferredNote'), { count: deferredCount })
4209
+ : t('restart.overlay.deferredNoteZero'),
4210
+ ),
4211
+ )
4212
+ }
4213
+ if (!postponed && phase === 'snapshotting') {
3853
4214
  // The numbers are the ones the copy loop really measured; when there is
3854
4215
  // no measurement yet, the card shows no number at all (an invented
3855
4216
  // progress bar is worse than none). The unit fits the size, so a 92 KB
@@ -3894,6 +4255,9 @@
3894
4255
  'aria-live': 'polite',
3895
4256
  'data-dsh-autorestart-restart-overlay': 'true',
3896
4257
  'data-dsh-autorestart-restart-overlay-phase': phase,
4258
+ // WHAT THE CARD SAYS, independent of the Host phase: a postponed
4259
+ // restart is drawn from 'waiting'.
4260
+ 'data-dsh-autorestart-restart-overlay-card': postponed ? 'deferred' : phase,
3897
4261
  },
3898
4262
  h(
3899
4263
  'p',
@@ -4906,6 +5270,37 @@
4906
5270
  ' line-height:18px;',
4907
5271
  ' color:var(--dsw-alias-label-secondary);',
4908
5272
  '}',
5273
+ // WHAT a copy is (user decision, 2026-10-30): the identity is the first
5274
+ // thing read on a row, so it sits on its own line and is not muted.
5275
+ '.dsh-autorestart-settings__backup-identity{',
5276
+ ' display:flex;',
5277
+ ' flex-wrap:wrap;',
5278
+ ' gap:8px;',
5279
+ ' align-items:baseline;',
5280
+ '}',
5281
+ '.dsh-autorestart-settings__backup-badge{',
5282
+ ' font-size:13px;',
5283
+ ' line-height:18px;',
5284
+ ' font-weight:600;',
5285
+ ' color:var(--dsw-alias-label-primary);',
5286
+ '}',
5287
+ '.dsh-autorestart-settings__backup-actions{',
5288
+ ' display:flex;',
5289
+ ' flex-wrap:wrap;',
5290
+ ' gap:8px;',
5291
+ ' align-items:baseline;',
5292
+ '}',
5293
+ // THE SECOND CONFIRMATION of a manual rollback: a block that stands
5294
+ // apart from the list, because it is a question, not a fact.
5295
+ '.dsh-autorestart-settings__confirm{',
5296
+ ' display:flex;',
5297
+ ' flex-direction:column;',
5298
+ ' gap:6px;',
5299
+ ' padding:10px 12px;',
5300
+ ' border:1px solid var(--dsw-alias-border-l1);',
5301
+ ' border-radius:8px;',
5302
+ ' background:var(--dsw-alias-bg-l1);',
5303
+ '}',
4909
5304
  // The folded "technical details": the command itself stays out of the
4910
5305
  // way until somebody asks for it.
4911
5306
  '.dsh-autorestart-settings__details{',
@@ -5417,6 +5812,13 @@
5417
5812
  // a fresh page starts closed, so nobody leaves technical text open by
5418
5813
  // accident.
5419
5814
  const [detailsOpen, setDetailsOpen] = useState(false)
5815
+ // THE MANUAL ROLLBACK (user decision, 2026-10-30). Asking is a STATE,
5816
+ // not a `window.confirm`: the card has to say WHICH copy, that the
5817
+ // current state is replaced, and that a restart follows. `null` = nobody
5818
+ // is asking, and only a confirmation ever calls the route.
5819
+ const [rollbackAsk, setRollbackAsk] = useState(null)
5820
+ const [rollbackBusy, setRollbackBusy] = useState(false)
5821
+ const [rollbackNote, setRollbackNote] = useState('')
5420
5822
  const t = makeTranslate(props && typeof props.t === 'function' ? props.t : translate)
5421
5823
  // THE RECEIPT OF A SETTINGS CHANGE, on screen right now. It belongs to
5422
5824
  // THIS panel (it is not a window overlay), so it is read here and
@@ -5645,6 +6047,46 @@
5645
6047
  *
5646
6048
  * @param {object} changes the keys to write
5647
6049
  */
6050
+ /**
6051
+ * THE MANUAL ROLLBACK (user decision, 2026-10-30).
6052
+ *
6053
+ * It is ONLY ever called from the confirmation card: the button on a row
6054
+ * opens the question, and nothing is posted until the user says yes. The
6055
+ * route takes a backup NAME (never a path) and does the rest on the Host
6056
+ * — save the current state, restore that copy, request a restart.
6057
+ *
6058
+ * @param {{ name?: string, atLocal?: string|null }|null} ask what the user picked
6059
+ */
6060
+ const rollbackTo = async (ask) => {
6061
+ const name = ask && typeof ask.name === 'string' ? ask.name : ''
6062
+ if (name === '' || rollbackBusy === true) return
6063
+ setRollbackBusy(true)
6064
+ setRollbackNote('')
6065
+ try {
6066
+ const response = await fetch(rollbackUrlFor(info), {
6067
+ method: 'POST',
6068
+ headers: { 'content-type': 'application/json' },
6069
+ body: JSON.stringify({ name }),
6070
+ })
6071
+ const body = await response.json().catch(() => null)
6072
+ if (!response.ok || !body || typeof body !== 'object' || body.ok !== true) {
6073
+ const message = body && typeof body === 'object'
6074
+ ? String(body.error || body.message || response.status)
6075
+ : String(response.status)
6076
+ setRollbackNote(fill(t('settings.backup.rollbackFailed'), { message }))
6077
+ return
6078
+ }
6079
+ // The copy is back and a restart has been requested: the card goes
6080
+ // away, and the panel says what is happening right now.
6081
+ setRollbackAsk(null)
6082
+ setRollbackNote(t('settings.backup.rollbackDone'))
6083
+ } catch (failure) {
6084
+ setRollbackNote(fill(t('settings.backup.rollbackFailed'), { message: describeError(failure) }))
6085
+ } finally {
6086
+ setRollbackBusy(false)
6087
+ }
6088
+ }
6089
+
5648
6090
  const save = async (changes) => {
5649
6091
  if (busy) return
5650
6092
  setBusy(true)
@@ -5945,6 +6387,15 @@
5945
6387
  { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'keep' },
5946
6388
  fill(t('settings.backup.keep'), { n: backupsInfo.max }),
5947
6389
  ),
6390
+ // WHAT THE TWO IDENTITIES MEAN (user decision, 2026-10-30): the
6391
+ // words on the rows below are useless without the rule behind
6392
+ // them — only a proven copy is ever rolled back, and a manual
6393
+ // rollback asks before it restarts.
6394
+ h(
6395
+ 'span',
6396
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'identity-hint' },
6397
+ fill(t('settings.backup.identityHint'), { n: backupsInfo.max }),
6398
+ ),
5948
6399
  backupItems.length === 0
5949
6400
  ? h(
5950
6401
  'span',
@@ -5959,6 +6410,35 @@
5959
6410
  'data-dsh-autorestart-backup': 'item',
5960
6411
  key: 'backup-' + index,
5961
6412
  },
6413
+ // WHAT THIS COPY IS — read first, because the identity decides
6414
+ // whether this copy may be restored at all.
6415
+ h(
6416
+ 'div',
6417
+ { className: 'dsh-autorestart-settings__backup-identity' },
6418
+ h(
6419
+ 'span',
6420
+ { className: 'dsh-autorestart-settings__backup-label' },
6421
+ t('settings.backup.kindLabel'),
6422
+ ),
6423
+ h(
6424
+ 'span',
6425
+ {
6426
+ className: 'dsh-autorestart-settings__backup-badge',
6427
+ 'data-dsh-autorestart-backup': 'kind',
6428
+ },
6429
+ t('settings.backup.kind.' + backupKindKey(item && item.kind)),
6430
+ ),
6431
+ backupVersionOf(item) === ''
6432
+ ? null
6433
+ : h(
6434
+ 'span',
6435
+ {
6436
+ className: 'dsh-autorestart-settings__backup-note',
6437
+ 'data-dsh-autorestart-backup': 'version',
6438
+ },
6439
+ fill(t('settings.backup.version'), { v: backupVersionOf(item) }),
6440
+ ),
6441
+ ),
5962
6442
  // WHEN it was taken. No timezone offset: what is shown is
5963
6443
  // already the local clock of the machine that took the copy.
5964
6444
  h(
@@ -5998,7 +6478,99 @@
5998
6478
  : t('settings.backup.withoutPlugins'),
5999
6479
  ),
6000
6480
  ),
6481
+ // ROLL BACK TO THIS COPY. The button only OPENS the question —
6482
+ // the route is called from the confirmation card and nowhere
6483
+ // else, so a click alone never restores anything.
6484
+ h(
6485
+ 'div',
6486
+ { className: 'dsh-autorestart-settings__backup-actions' },
6487
+ h(
6488
+ 'button',
6489
+ {
6490
+ type: 'button',
6491
+ className: 'dsh-autorestart-settings__button',
6492
+ disabled: busy === true || rollbackBusy === true,
6493
+ 'data-dsh-autorestart-backup': 'rollback',
6494
+ 'data-backup-name': item && typeof item.name === 'string' ? item.name : '',
6495
+ onClick: () => {
6496
+ setRollbackNote('')
6497
+ setRollbackAsk({
6498
+ name: item && typeof item.name === 'string' ? item.name : '',
6499
+ atLocal: item && typeof item.atLocal === 'string' ? item.atLocal : null,
6500
+ })
6501
+ },
6502
+ },
6503
+ t('settings.backup.rollback'),
6504
+ ),
6505
+ ),
6001
6506
  )),
6507
+ // THE SECOND CONFIRMATION OF A MANUAL ROLLBACK (user decision,
6508
+ // 2026-10-30): it names the copy, says that the current state is
6509
+ // replaced (and that it is saved first), and says that a restart
6510
+ // follows. Nothing is posted until the confirm button is pressed.
6511
+ rollbackAsk === null
6512
+ ? null
6513
+ : h(
6514
+ 'div',
6515
+ {
6516
+ className: 'dsh-autorestart-settings__confirm',
6517
+ 'data-dsh-autorestart-backup': 'rollback-confirm',
6518
+ },
6519
+ h(
6520
+ 'span',
6521
+ { className: 'dsh-autorestart-settings__label' },
6522
+ t('settings.backup.rollbackConfirmTitle'),
6523
+ ),
6524
+ h(
6525
+ 'span',
6526
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-confirm-body' },
6527
+ fill(t('settings.backup.rollbackConfirmBody'), { time: formatBackupTime(rollbackAsk.atLocal) }),
6528
+ ),
6529
+ h(
6530
+ 'span',
6531
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-confirm-restart' },
6532
+ t('settings.backup.rollbackConfirmRestart'),
6533
+ ),
6534
+ h(
6535
+ 'div',
6536
+ { className: 'dsh-autorestart-settings__backup-actions' },
6537
+ h(
6538
+ 'button',
6539
+ {
6540
+ type: 'button',
6541
+ className: 'dsh-autorestart-settings__button',
6542
+ disabled: rollbackBusy === true,
6543
+ 'data-dsh-autorestart-backup': 'rollback-confirm-yes',
6544
+ onClick: () => void rollbackTo(rollbackAsk),
6545
+ },
6546
+ rollbackBusy === true
6547
+ ? t('settings.backup.rollbackBusy')
6548
+ : t('settings.backup.rollbackConfirmYes'),
6549
+ ),
6550
+ h(
6551
+ 'button',
6552
+ {
6553
+ type: 'button',
6554
+ className: 'dsh-autorestart-settings__button',
6555
+ disabled: rollbackBusy === true,
6556
+ 'data-dsh-autorestart-backup': 'rollback-confirm-no',
6557
+ onClick: () => {
6558
+ // Cancel: nothing was posted, nothing is restored.
6559
+ setRollbackAsk(null)
6560
+ setRollbackNote('')
6561
+ },
6562
+ },
6563
+ t('settings.backup.rollbackConfirmNo'),
6564
+ ),
6565
+ ),
6566
+ ),
6567
+ rollbackNote === ''
6568
+ ? null
6569
+ : h(
6570
+ 'span',
6571
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-note' },
6572
+ rollbackNote,
6573
+ ),
6002
6574
  backupsInfo.dir === ''
6003
6575
  ? null
6004
6576
  : h(