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.
Files changed (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
package/lib/sleep.js CHANGED
@@ -32,6 +32,7 @@ import fs from 'node:fs';
32
32
 
33
33
  import { blockerTaskKeys, countBlockerTasks } from './blockers.js';
34
34
  import { detectHostKind, detectWslEvidence, HOST_KINDS, WSL_PROC_VERSION, WSL_SHUTDOWN_EXE } from './host-kind.js';
35
+ import { detectContainer } from './supervisor-config.js';
35
36
 
36
37
  /**
37
38
  * The Windows shutdown binary as it is reachable from WSL.
@@ -201,6 +202,30 @@ export function detectShutdownPlan({
201
202
  platform: plat,
202
203
  };
203
204
  }
205
+ // A CONTAINER IS NOT A LINUX MACHINE (2026-11-02). `systemctl poweroff`
206
+ // inside a container either fails or takes the container's own PID 1 down
207
+ // with it, and `shutdown -h now` is no better. The old code fell through to
208
+ // the plain Linux plan and the page then promised "关闭这台 Linux 主机" —
209
+ // a promise this machine cannot keep. The honest answer is the same one the
210
+ // unsupported platforms get: there is no reliable way to switch it off.
211
+ const container = detectContainer({
212
+ env: env && typeof env === 'object' ? env : process.env,
213
+ existsSyncFn: existsSafe,
214
+ });
215
+ if (container.container === true) {
216
+ return {
217
+ kind: 'container',
218
+ label: '这台机器没有可靠的关机方式',
219
+ source: 'container: ' + container.evidence,
220
+ command: null,
221
+ args: [],
222
+ fallbacks: [],
223
+ platform: plat,
224
+ reason: 'this host runs in a container (evidence: '
225
+ + container.evidence
226
+ + '): the orchestrator owns the process, and there is no reliable way to switch this machine off',
227
+ };
228
+ }
204
229
  return {
205
230
  kind: 'linux',
206
231
  label: '关闭这台 Linux 主机',
@@ -273,11 +298,32 @@ export function planInvocation(plan, { sleepCommand = '', platform = process.pla
273
298
  export const SPAWN_TIMEOUT_MS = 5000;
274
299
 
275
300
  /**
276
- * Start ONE command and wait for the real answer.
301
+ * How long the shutdown command may run before "it started" is the only answer.
302
+ *
303
+ * A real shutdown binary answers in well under a second: `shutdown.exe /s /f
304
+ * /t 0`, `systemctl poweroff`, `osascript -e …` all return immediately (the
305
+ * machine goes off in the background). A CUSTOM `sleepCommand` may instead keep
306
+ * running in the foreground (`a script that starts something`), and the plugin
307
+ * must not wait for it forever — so the wait is bounded and the answer then says
308
+ * exactly what is known: it started, and it had not finished yet.
309
+ */
310
+ export const EXIT_TIMEOUT_MS = 1500;
311
+
312
+ /**
313
+ * Start ONE command, wait for the real answer, AND read its exit code.
314
+ *
315
+ * Three outcomes, all of them distinguishable:
316
+ *
317
+ * - `spawn-failed` the command never started (missing binary, permission);
318
+ * - `exited` it ran and exited: `exitCode === 0` is success, anything
319
+ * else is a REFUSAL ("the system said no") and carries the
320
+ * code in the error text;
321
+ * - `stillRunning` it started and was still running when the bounded wait
322
+ * ran out (a custom foreground command).
277
323
  *
278
- * `spawn` means the command really started; `error` means it did not (the
279
- * binary is missing, the permission is denied, ...). Nothing is swallowed:
280
- * the caller learns which one of the two happened.
324
+ * A spawner without events (a test double) cannot report any of that: it counts
325
+ * as started, `exited` stays `null`, and `observed: false` says that nothing was
326
+ * actually proven — the caller must not claim more.
281
327
  *
282
328
  * @param {string} command
283
329
  * @param {string[]} args
@@ -285,14 +331,25 @@ export const SPAWN_TIMEOUT_MS = 5000;
285
331
  * spawn?: Function,
286
332
  * log?: { warn?: Function },
287
333
  * timeoutMs?: number,
334
+ * exitTimeoutMs?: number,
288
335
  * setTimeout?: Function,
289
336
  * clearTimeout?: Function,
290
337
  * }} [options]
291
- * @returns {Promise<{ ok: boolean, pid: number|null, error: string|null }>}
338
+ * @returns {Promise<{
339
+ * ok: boolean,
340
+ * pid: number|null,
341
+ * error: string|null,
342
+ * exitCode: number|null,
343
+ * exited: boolean|null,
344
+ * started: boolean,
345
+ * stillRunning?: boolean,
346
+ * observed?: boolean,
347
+ * }>}
292
348
  */
293
349
  function startCommand(command, args, {
294
350
  spawn = spawnProcess,
295
351
  timeoutMs = SPAWN_TIMEOUT_MS,
352
+ exitTimeoutMs = EXIT_TIMEOUT_MS,
296
353
  setTimeout: setTimeoutFn = setTimeout,
297
354
  clearTimeout: clearTimeoutFn = clearTimeout,
298
355
  } = {}) {
@@ -301,45 +358,101 @@ function startCommand(command, args, {
301
358
  try {
302
359
  child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
303
360
  } catch (error) {
304
- resolve({ ok: false, pid: null, error: `spawn-failed: ${errorText(error)}` });
361
+ resolve({
362
+ ok: false,
363
+ pid: null,
364
+ error: `spawn-failed: ${errorText(error)}`,
365
+ exitCode: null,
366
+ exited: null,
367
+ started: false,
368
+ });
305
369
  return;
306
370
  }
307
371
  const pid = child && Number.isFinite(child.pid) ? child.pid : null;
308
372
  if (!child || typeof child.on !== 'function') {
309
- // A spawner without events (a test double) counts as started.
373
+ // A spawner without events (a test double) counts as started — but the
374
+ // exit code CANNOT be observed, and `observed: false` says so.
310
375
  try {
311
376
  if (child && typeof child.unref === 'function') child.unref();
312
377
  } catch { /* nothing else to do */ }
313
- resolve({ ok: true, pid, error: null });
378
+ resolve({
379
+ ok: true,
380
+ pid,
381
+ error: null,
382
+ exitCode: null,
383
+ exited: null,
384
+ started: true,
385
+ observed: false,
386
+ });
314
387
  return;
315
388
  }
316
389
  let settled = false;
317
- let timer = null;
390
+ let spawnTimer = null;
391
+ let exitTimer = null;
392
+ // WHAT THE ANSWER SAYS BESIDES ok/error (2026-11-02): the exit code, whether
393
+ // it was observed, and whether the command is still running. Kept in a
394
+ // small record so `finish` keeps its two-argument shape.
395
+ let outcome = { exitCode: null, exited: null, started: true };
396
+ const stopTimer = (handle) => {
397
+ if (handle === null) return;
398
+ try {
399
+ clearTimeoutFn(handle);
400
+ } catch { /* nothing else to do */ }
401
+ };
318
402
  const finish = (ok, error) => {
319
403
  if (settled) return;
320
404
  settled = true;
321
- if (timer !== null) {
322
- try {
323
- clearTimeoutFn(timer);
324
- } catch { /* nothing else to do */ }
325
- timer = null;
405
+ stopTimer(spawnTimer);
406
+ stopTimer(exitTimer);
407
+ resolve({ ok, pid, error, ...outcome });
408
+ };
409
+ // THE EXIT CODE IS THE ANSWER (2026-11-02). "The command started" is not
410
+ // "the machine is going off": a refused shutdown exits non-zero and used to
411
+ // be reported as success, so the page said "正在关机" while the machine
412
+ // stayed on. Both `exit` and `close` are listened to, because a spawner may
413
+ // emit only one of them; `finish` makes the second one a no-op.
414
+ const onExit = (code, signal) => {
415
+ const numeric = Number.isFinite(code) ? Number(code) : null;
416
+ if (numeric === 0) {
417
+ outcome = { exitCode: 0, exited: true, started: true };
418
+ finish(true, null);
419
+ return;
326
420
  }
327
- resolve({ ok, pid, error });
421
+ const why = numeric === null
422
+ ? `the command was killed by ${signal || 'a signal'}`
423
+ : `the command exited with code ${numeric}`;
424
+ outcome = { exitCode: numeric, exited: true, started: true };
425
+ finish(false, `shutdown-refused: ${why}`);
328
426
  };
329
427
  child.on('spawn', () => {
330
428
  try {
331
429
  if (child && typeof child.unref === 'function') child.unref();
332
430
  } catch { /* nothing else to do */ }
333
- finish(true, null);
431
+ stopTimer(spawnTimer);
432
+ // The command started; now give it a bounded window to answer with its
433
+ // exit code.
434
+ try {
435
+ exitTimer = setTimeoutFn(() => {
436
+ outcome = { exitCode: null, exited: false, started: true, stillRunning: true };
437
+ finish(true, null);
438
+ }, exitTimeoutMs);
439
+ if (exitTimer && typeof exitTimer.unref === 'function') exitTimer.unref();
440
+ } catch { /* the wait is only a safety net the other way round */ }
334
441
  });
335
442
  child.on('error', (error) => {
443
+ outcome = { exitCode: null, exited: null, started: false };
336
444
  finish(false, `spawn-failed: ${errorText(error)}`);
337
445
  });
446
+ if (typeof child.once === 'function' || typeof child.on === 'function') {
447
+ child.on('exit', onExit);
448
+ child.on('close', onExit);
449
+ }
338
450
  try {
339
- timer = setTimeoutFn(() => {
451
+ spawnTimer = setTimeoutFn(() => {
452
+ outcome = { exitCode: null, exited: null, started: false };
340
453
  finish(false, `spawn-timeout: ${command} did not start within ${timeoutMs}ms`);
341
454
  }, timeoutMs);
342
- if (timer && typeof timer.unref === 'function') timer.unref();
455
+ if (spawnTimer && typeof spawnTimer.unref === 'function') spawnTimer.unref();
343
456
  } catch { /* the timeout is only a safety net */ }
344
457
  });
345
458
  }
@@ -350,11 +463,15 @@ function startCommand(command, args, {
350
463
  * The gate is checked ONE more time right before the command runs: a session
351
464
  * that started working in the meantime must be able to stop the shutdown.
352
465
  *
353
- * The command is really started, not merely requested: the answer arrives
354
- * with the child's `spawn` event, and a command that does not start is
355
- * reported as a FAILURE (`executed: false`). When the main command does not
356
- * start, the plan's fallbacks are tried one after another (the WSL plan
357
- * reaches Windows through two of them), and the first one that starts wins.
466
+ * The command is really started AND its exit code is read: a command that does
467
+ * not start, and a command the system REFUSED (non-zero exit code), are both a
468
+ * FAILURE (`executed: false`) with the reason in `error`. When the main command
469
+ * fails that way, the plan's fallbacks are tried one after another (the WSL
470
+ * plan reaches Windows through two of them), and the first one that works wins.
471
+ *
472
+ * `executed: true` therefore means: the command ran and the system did not
473
+ * refuse it. `exitCode` / `exited` / `stillRunning` travel with the answer so a
474
+ * caller can tell "confirmed" from "started, not confirmed".
358
475
  *
359
476
  * With `dryRun` nothing is executed at all: the command that WOULD run is
360
477
  * reported instead.
@@ -366,6 +483,7 @@ function startCommand(command, args, {
366
483
  * log?: { warn?: Function, info?: Function },
367
484
  * gate?: () => { allow?: boolean } | null,
368
485
  * timeoutMs?: number,
486
+ * exitTimeoutMs?: number,
369
487
  * }} [options]
370
488
  * @returns {Promise<{
371
489
  * ok: boolean,
@@ -375,6 +493,10 @@ function startCommand(command, args, {
375
493
  * executed: boolean,
376
494
  * pid: number|null,
377
495
  * error: string|null,
496
+ * exitCode?: number|null,
497
+ * exited?: boolean|null,
498
+ * stillRunning?: boolean,
499
+ * observed?: boolean,
378
500
  * dryRun?: boolean,
379
501
  * }>}
380
502
  */
@@ -384,6 +506,7 @@ export function runShutdown(plan, {
384
506
  log = console,
385
507
  gate = null,
386
508
  timeoutMs = SPAWN_TIMEOUT_MS,
509
+ exitTimeoutMs = EXIT_TIMEOUT_MS,
387
510
  } = {}) {
388
511
  const at = new Date().toISOString();
389
512
  const source = plan && typeof plan === 'object' ? plan : {};
@@ -425,8 +548,9 @@ export function runShutdown(plan, {
425
548
 
426
549
  return (async () => {
427
550
  let lastError = 'no-shutdown-command';
551
+ let lastExitCode = null;
428
552
  for (const attempt of attempts) {
429
- const outcome = await startCommand(attempt.command, attempt.args, { spawn, timeoutMs });
553
+ const outcome = await startCommand(attempt.command, attempt.args, { spawn, timeoutMs, exitTimeoutMs });
430
554
  if (outcome.ok) {
431
555
  return {
432
556
  ok: true,
@@ -436,14 +560,29 @@ export function runShutdown(plan, {
436
560
  executed: true,
437
561
  pid: outcome.pid,
438
562
  error: null,
563
+ exitCode: outcome.exitCode === undefined ? null : outcome.exitCode,
564
+ exited: outcome.exited === undefined ? null : outcome.exited,
565
+ ...(outcome.stillRunning === true ? { stillRunning: true } : {}),
566
+ ...(outcome.observed === false ? { observed: false } : {}),
439
567
  };
440
568
  }
441
569
  lastError = outcome.error;
570
+ lastExitCode = outcome.exitCode === undefined ? null : outcome.exitCode;
442
571
  if (log && typeof log.warn === 'function') {
443
- log.warn(`the sleep command ${attempt.command} did not start:`, outcome.error);
572
+ log.warn(`the sleep command ${attempt.command} did not work:`, outcome.error);
444
573
  }
445
574
  }
446
- return { ok: false, at, command, args, executed: false, pid: null, error: lastError };
575
+ return {
576
+ ok: false,
577
+ at,
578
+ command,
579
+ args,
580
+ executed: false,
581
+ pid: null,
582
+ error: lastError,
583
+ exitCode: lastExitCode,
584
+ exited: true,
585
+ };
447
586
  })();
448
587
  }
449
588
 
@@ -728,6 +867,13 @@ export function createSleepController({
728
867
  pid: Number.isFinite(run.pid) ? run.pid : null,
729
868
  error: run.error == null ? null : String(run.error),
730
869
  };
870
+ // THE EXIT CODE TRAVELS WITH THE ANSWER (2026-11-02): "the command exited
871
+ // with code 1" and "the command is still running" are different facts, and
872
+ // the interface must be able to say which one happened.
873
+ if (Number.isFinite(run.exitCode)) normalized.exitCode = Number(run.exitCode);
874
+ if (typeof run.exited === 'boolean') normalized.exited = run.exited;
875
+ if (run.stillRunning === true) normalized.stillRunning = true;
876
+ if (run.observed === false) normalized.observed = false;
731
877
  // A dry run is reported as such, because the UI has to know that the
732
878
  // command was NOT executed. A real run keeps its shape untouched.
733
879
  if (run.dryRun === true) normalized.dryRun = true;
@@ -879,9 +1025,15 @@ export function createSleepController({
879
1025
  stopTimer();
880
1026
  // A countdown of zero seconds says so in the message too: the user
881
1027
  // must see that the machine went off without any countdown.
1028
+ //
1029
+ // THE WORDING IS "REQUESTED", NOT "SHUTTING DOWN" (2026-11-02): at this
1030
+ // moment the command has only been ISSUED. Whether the machine really
1031
+ // goes off is decided by the system, and the exit code arrives a moment
1032
+ // later — the run's answer either keeps this stage or turns it back into
1033
+ // "idle, the machine is still on".
882
1034
  message = countdownMs > 0
883
- ? `正在${target}……`
884
- : '0 秒:立即关机,正在关掉这台电脑。';
1035
+ ? `已发出${target}命令,正在等系统的回答……`
1036
+ : '0 秒:立即关机,已发出关机命令,正在等系统的回答……';
885
1037
  let result = null;
886
1038
  try {
887
1039
  result = shutdown();
@@ -941,7 +1093,16 @@ export function createSleepController({
941
1093
  armed = false;
942
1094
  blockers = [];
943
1095
  remainingMs = 0;
944
- message = `关机命令没跑起来(${run.error || 'unknown error'}),电脑还开着,可以再点一次月亮按钮。`;
1096
+ // A REFUSAL IS NOT A CRASH (2026-11-02): "the system said no" (a
1097
+ // non-zero exit code) and "the command could not be started" are
1098
+ // different sentences, and the user has to read the right one.
1099
+ const failure = run.error || 'unknown error';
1100
+ if (String(failure).startsWith('shutdown-refused')) {
1101
+ const detail = String(failure).replace(/^shutdown-refused:\s*/, '');
1102
+ message = `关机被系统拒绝(${detail}),电脑还开着,可以再点一次月亮按钮。`;
1103
+ } else {
1104
+ message = `关机命令没跑起来(${failure}),电脑还开着,可以再点一次月亮按钮。`;
1105
+ }
945
1106
  disarm('shutdown-failed');
946
1107
  };
947
1108
  if (result && typeof result.then === 'function') {
@@ -41,7 +41,7 @@ import {
41
41
  readReady,
42
42
  writeReady,
43
43
  } from './watchdog.js';
44
- import { removeScheduleItem } from './watchdog-schedule.js';
44
+ import { removeLeftoverProbeTasks, removeScheduleItem } from './watchdog-schedule.js';
45
45
  import { TIERS } from './watchdog-tier.js';
46
46
 
47
47
  /** The file that records how the last disarm went. */
@@ -270,7 +270,9 @@ export function disarmOnce({
270
270
  clearPendingFn = clearPending,
271
271
  removeItem = removeScheduleItem,
272
272
  taskExists = defaultTaskExists,
273
+ removeProbes = removeLeftoverProbeTasks,
273
274
  pendingHint = null,
275
+ platform = process.platform,
274
276
  } = {}) {
275
277
  const dir = typeof stateDir === 'string' ? stateDir : '';
276
278
  const errors = [];
@@ -377,6 +379,40 @@ export function disarmOnce({
377
379
  }
378
380
  }
379
381
 
382
+ // (4) THE LEFTOVER CAPABILITY PROBES (2026-11-01).
383
+ //
384
+ // The capability probe (lib/scheduler-capability.js) registers a one-off task
385
+ // and deletes it as its LAST step, so a crash, a kill or a refused delete
386
+ // leaves it behind: two were found on the real machine, both `Ready`, neither
387
+ // with a next run time. The old disarm only removed the watchdog item named by
388
+ // the pending marker and never looked for them, so they accumulated one per
389
+ // boot.
390
+ //
391
+ // A refusal here is recorded and NOT pushed into `errors`: those decide whether
392
+ // the disarm itself counts as done, and the host being up does not depend on
393
+ // cleaning a stale probe task. The caller logs it and the record carries it, so
394
+ // "the cleanup did not work" is still visible.
395
+ let probes = null;
396
+ if (typeof removeProbes === 'function') {
397
+ try {
398
+ // THE PLATFORM TRAVELS WITH THE REQUEST (2026-11-02): off Windows there
399
+ // are no scheduled tasks to clean, so the cleanup says `skipped` instead
400
+ // of reporting a `schtasks` failure on every single boot.
401
+ probes = removeProbes({ platform: typeof platform === 'string' && platform !== '' ? platform : process.platform });
402
+ } catch (error) {
403
+ probes = {
404
+ ok: false,
405
+ prefix: '',
406
+ matched: [],
407
+ removed: [],
408
+ failed: [],
409
+ skipped: false,
410
+ reason: null,
411
+ error: error && error.message ? String(error.message) : String(error),
412
+ };
413
+ }
414
+ }
415
+
380
416
  const ready = (() => {
381
417
  try {
382
418
  const marker = read(dir);
@@ -389,7 +425,7 @@ export function disarmOnce({
389
425
  const pendingLeft = pendingAfter !== null && pendingAfter !== undefined;
390
426
  const ok = errors.length === 0 && ready === true && pendingLeft === false
391
427
  && (removed === null || removed.ok === true);
392
- return { ok, ready, pending: pendingLeft, removed, errors };
428
+ return { ok, ready, pending: pendingLeft, removed, errors, probes };
393
429
  }
394
430
 
395
431
  /**
@@ -437,8 +473,10 @@ export async function disarmWatchdog({
437
473
  clearPendingFn = clearPending,
438
474
  removeItem = removeScheduleItem,
439
475
  taskExists = defaultTaskExists,
476
+ removeProbes = removeLeftoverProbeTasks,
440
477
  writeRecord = true,
441
478
  log = null,
479
+ platform = process.platform,
442
480
  } = {}) {
443
481
  const dir = typeof stateDir === 'string' ? stateDir : '';
444
482
  const wanted = Number.isFinite(Number(attempts)) && Number(attempts) > 0
@@ -469,7 +507,9 @@ export async function disarmWatchdog({
469
507
  clearPendingFn,
470
508
  removeItem,
471
509
  taskExists,
510
+ removeProbes,
472
511
  pendingHint: hint,
512
+ platform,
473
513
  });
474
514
  errors.push(...last.errors);
475
515
  if (last.ok) break;
@@ -493,6 +533,10 @@ export async function disarmWatchdog({
493
533
  scheduleError: last !== null && last.removed !== null && last.removed.error
494
534
  ? String(last.removed.error)
495
535
  : null,
536
+ // THE LEFTOVER CAPABILITY PROBES: what the cleanup found and what it did.
537
+ // Kept OUT of `errors` on purpose — a refused cleanup of a stale probe task
538
+ // is not a failed disarm, but it must not be invisible either.
539
+ probes: last !== null && last.probes !== null && last.probes !== undefined ? last.probes : null,
496
540
  errors,
497
541
  };
498
542
  if (writeRecord) writeDisarmRecord(dir, record);