dsh-autorestart 0.3.6 → 0.5.0
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/README.md +147 -7
- package/client.js +2362 -147
- package/cordis.patch.yml +16 -0
- package/docs/README.zh.md +98 -10
- package/index.js +896 -67
- package/lib/checkpoint.js +38 -0
- package/lib/sleep.js +828 -0
- package/locale/en.json +101 -1
- package/locale/zh.json +101 -1
- package/package.json +1 -1
package/lib/sleep.js
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — the host half of the graceful shutdown ("收工关机").
|
|
3
|
+
*
|
|
4
|
+
* The feature waits until every conversation is idle, counts down on screen and
|
|
5
|
+
* then powers the REAL host machine off.
|
|
6
|
+
*
|
|
7
|
+
* The hard part is the host detection. When the Host runs inside WSL, shutting
|
|
8
|
+
* the distro down (or calling `wsl.exe --shutdown`) only stops the subsystem:
|
|
9
|
+
* Windows keeps running, so nothing was really switched off. This module
|
|
10
|
+
* therefore detects the host and always produces a command that reaches the
|
|
11
|
+
* physical machine:
|
|
12
|
+
*
|
|
13
|
+
* win32 → shutdown.exe /s /f /t 0
|
|
14
|
+
* linux inside WSL → /mnt/c/Windows/System32/shutdown.exe /s /f /t 0
|
|
15
|
+
* linux (real) → systemctl poweroff
|
|
16
|
+
* darwin → osascript -e 'tell application "System Events" to shut down'
|
|
17
|
+
* anything else → unsupported (NOTHING is ever executed)
|
|
18
|
+
*
|
|
19
|
+
* The countdown is ours, so the shutdown command never asks the operating
|
|
20
|
+
* system for another countdown of its own (`/t 0`).
|
|
21
|
+
*
|
|
22
|
+
* Everything in this module is pure or dependency-injected: the platform, the
|
|
23
|
+
* environment, the file probes, the clock, the spawner and the timer are all
|
|
24
|
+
* parameters. That is what lets the whole matrix be covered by unit tests
|
|
25
|
+
* without touching the machine.
|
|
26
|
+
*
|
|
27
|
+
* ESM only, Node builtins only. No DSH / npm imports.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawn as spawnProcess } from 'node:child_process';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
|
|
33
|
+
/** The Windows shutdown binary as it is reachable from WSL. */
|
|
34
|
+
export const WSL_SHUTDOWN_EXE = '/mnt/c/Windows/System32/shutdown.exe';
|
|
35
|
+
|
|
36
|
+
/** The file that tells a WSL kernel from a real Linux kernel. */
|
|
37
|
+
export const WSL_PROC_VERSION = '/proc/version';
|
|
38
|
+
|
|
39
|
+
/** How many seconds the countdown lasts by default. */
|
|
40
|
+
export const DEFAULT_COUNTDOWN_SECONDS = 20;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The countdown lengths the settings page offers (a dropdown).
|
|
44
|
+
*
|
|
45
|
+
* `0` means "no countdown at all": the machine is switched off as soon as
|
|
46
|
+
* every conversation is idle and the gate opens.
|
|
47
|
+
*/
|
|
48
|
+
export const COUNTDOWN_CHOICES = Object.freeze([0, 10, 20, 30, 60]);
|
|
49
|
+
|
|
50
|
+
/** The shortest countdown the configuration may ask for (0 = no countdown). */
|
|
51
|
+
export const MIN_COUNTDOWN_SECONDS = 0;
|
|
52
|
+
|
|
53
|
+
/** The longest countdown the configuration may ask for. */
|
|
54
|
+
export const MAX_COUNTDOWN_SECONDS = 120;
|
|
55
|
+
|
|
56
|
+
/** How often the controller checks the state (one countdown step per second). */
|
|
57
|
+
export const TICK_MS = 1000;
|
|
58
|
+
|
|
59
|
+
/** The states the sleep controller can be in. */
|
|
60
|
+
export const SLEEP_PHASES = Object.freeze([
|
|
61
|
+
'idle',
|
|
62
|
+
'waiting',
|
|
63
|
+
'countdown',
|
|
64
|
+
'shutting-down',
|
|
65
|
+
'cancelled',
|
|
66
|
+
'unsupported',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** @param {unknown} value */
|
|
70
|
+
function errorText(value) {
|
|
71
|
+
if (value && typeof value.message === 'string' && value.message) return value.message;
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {unknown} value */
|
|
76
|
+
function nonEmptyString(value) {
|
|
77
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Clamp a countdown length into the supported range.
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} value
|
|
84
|
+
* @returns {number}
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeCountdownSeconds(value) {
|
|
87
|
+
const seconds = Number(value);
|
|
88
|
+
if (!Number.isFinite(seconds)) return DEFAULT_COUNTDOWN_SECONDS;
|
|
89
|
+
return Math.min(MAX_COUNTDOWN_SECONDS, Math.max(MIN_COUNTDOWN_SECONDS, Math.trunc(seconds)));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Snap a configured countdown onto one of the offered choices.
|
|
94
|
+
*
|
|
95
|
+
* A profile that still carries an old value (`15`, `45`, …) must not break:
|
|
96
|
+
* the nearest offered length wins, and a tie goes to the LONGER one
|
|
97
|
+
* (`15` → `20`, `45` → `60`). Anything that is not a number at all (a
|
|
98
|
+
* string that is not a number, `null`, `undefined`) falls back to the
|
|
99
|
+
* default.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @returns {number} one of {@link COUNTDOWN_CHOICES}
|
|
103
|
+
*/
|
|
104
|
+
export function snapCountdownChoice(value) {
|
|
105
|
+
if (typeof value !== 'number' && typeof value !== 'string') return DEFAULT_COUNTDOWN_SECONDS;
|
|
106
|
+
if (typeof value === 'string' && value.trim() === '') return DEFAULT_COUNTDOWN_SECONDS;
|
|
107
|
+
const seconds = Number(value);
|
|
108
|
+
if (!Number.isFinite(seconds)) return DEFAULT_COUNTDOWN_SECONDS;
|
|
109
|
+
let best = COUNTDOWN_CHOICES[0];
|
|
110
|
+
let bestDistance = Infinity;
|
|
111
|
+
for (const choice of COUNTDOWN_CHOICES) {
|
|
112
|
+
const distance = Math.abs(choice - seconds);
|
|
113
|
+
// `<=` so a tie goes to the LONGER choice (15 → 20, 45 → 60).
|
|
114
|
+
if (distance <= bestDistance) {
|
|
115
|
+
best = choice;
|
|
116
|
+
bestDistance = distance;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return best;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The evidence that this Linux is really a WSL distribution.
|
|
124
|
+
*
|
|
125
|
+
* Any ONE of them is enough:
|
|
126
|
+
* 1. `WSL_DISTRO_NAME` is set (WSL exports it into every process),
|
|
127
|
+
* 2. `WSL_INTEROP` is set (WSL exports it into every process),
|
|
128
|
+
* 3. `/proc/version` mentions "microsoft" (the WSL kernel says so),
|
|
129
|
+
* 4. the Windows shutdown binary is reachable under `/mnt/c`.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} env
|
|
132
|
+
* @param {(file: string) => string} readFile
|
|
133
|
+
* @param {(file: string) => boolean} exists
|
|
134
|
+
* @returns {string|null} the name of the evidence, or null
|
|
135
|
+
*/
|
|
136
|
+
export function detectWslEvidence(env, readFile, exists) {
|
|
137
|
+
const environ = env && typeof env === 'object' ? env : {};
|
|
138
|
+
if (nonEmptyString(environ.WSL_DISTRO_NAME) !== '') return 'WSL_DISTRO_NAME';
|
|
139
|
+
if (nonEmptyString(environ.WSL_INTEROP) !== '') return 'WSL_INTEROP';
|
|
140
|
+
let version = '';
|
|
141
|
+
try {
|
|
142
|
+
version = String(readFile(WSL_PROC_VERSION) || '');
|
|
143
|
+
} catch {
|
|
144
|
+
version = '';
|
|
145
|
+
}
|
|
146
|
+
if (version.toLowerCase().includes('microsoft')) return WSL_PROC_VERSION;
|
|
147
|
+
let found = false;
|
|
148
|
+
try {
|
|
149
|
+
found = exists(WSL_SHUTDOWN_EXE) === true;
|
|
150
|
+
} catch {
|
|
151
|
+
found = false;
|
|
152
|
+
}
|
|
153
|
+
if (found) return WSL_SHUTDOWN_EXE;
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Detect the host and build the command that switches the physical machine off.
|
|
159
|
+
*
|
|
160
|
+
* The returned plan is a plain description: nothing is executed here.
|
|
161
|
+
*
|
|
162
|
+
* @param {{
|
|
163
|
+
* platform?: string,
|
|
164
|
+
* env?: object,
|
|
165
|
+
* readFile?: (file: string) => string,
|
|
166
|
+
* exists?: (file: string) => boolean,
|
|
167
|
+
* }} [options]
|
|
168
|
+
* @returns {{
|
|
169
|
+
* kind: 'windows'|'wsl'|'linux'|'macos'|'unsupported',
|
|
170
|
+
* label: string,
|
|
171
|
+
* source: string,
|
|
172
|
+
* command: string|null,
|
|
173
|
+
* args: string[],
|
|
174
|
+
* fallbacks: { command: string, args: string[] }[],
|
|
175
|
+
* platform: string,
|
|
176
|
+
* }}
|
|
177
|
+
*/
|
|
178
|
+
export function detectShutdownPlan({
|
|
179
|
+
platform = process.platform,
|
|
180
|
+
env = process.env,
|
|
181
|
+
readFile,
|
|
182
|
+
exists,
|
|
183
|
+
} = {}) {
|
|
184
|
+
const plat = nonEmptyString(platform) || process.platform;
|
|
185
|
+
const readFileSafe = typeof readFile === 'function'
|
|
186
|
+
? readFile
|
|
187
|
+
: (file) => fs.readFileSync(file, 'utf8');
|
|
188
|
+
const existsSafe = typeof exists === 'function'
|
|
189
|
+
? exists
|
|
190
|
+
: (file) => fs.existsSync(file);
|
|
191
|
+
|
|
192
|
+
if (plat === 'win32') {
|
|
193
|
+
return {
|
|
194
|
+
kind: 'windows',
|
|
195
|
+
label: '关掉这台 Windows 电脑',
|
|
196
|
+
source: 'process.platform',
|
|
197
|
+
command: 'shutdown.exe',
|
|
198
|
+
// The countdown is ours, so Windows must not count down a second time.
|
|
199
|
+
args: ['/s', '/f', '/t', '0'],
|
|
200
|
+
fallbacks: [],
|
|
201
|
+
platform: plat,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (plat === 'linux') {
|
|
206
|
+
const evidence = detectWslEvidence(env, readFileSafe, existsSafe);
|
|
207
|
+
if (evidence !== null) {
|
|
208
|
+
// The mounted Windows binary is only a HINT: a real Linux with a
|
|
209
|
+
// manually mounted C: drive looks exactly the same. When that is the
|
|
210
|
+
// only evidence, the plan says so instead of pretending to be sure.
|
|
211
|
+
const suspect = evidence === WSL_SHUTDOWN_EXE;
|
|
212
|
+
return {
|
|
213
|
+
kind: 'wsl',
|
|
214
|
+
label: suspect
|
|
215
|
+
? '疑似 WSL(仅凭 /mnt/c 探测)→ 关掉整台 Windows'
|
|
216
|
+
: 'WSL 里的 DSH → 关掉整台 Windows',
|
|
217
|
+
source: suspect
|
|
218
|
+
? `${WSL_SHUTDOWN_EXE}(疑似 WSL,仅凭 /mnt/c 探测)`
|
|
219
|
+
: evidence,
|
|
220
|
+
// The Windows binary, reached through the mounted C: drive: this shuts
|
|
221
|
+
// the whole machine down, not the WSL distribution.
|
|
222
|
+
command: WSL_SHUTDOWN_EXE,
|
|
223
|
+
args: ['/s', '/f', '/t', '0'],
|
|
224
|
+
fallbacks: [
|
|
225
|
+
{ command: 'powershell.exe', args: ['-NoProfile', '-Command', 'Stop-Computer -Force'] },
|
|
226
|
+
{ command: 'cmd.exe', args: ['/c', 'shutdown.exe', '/s', '/f', '/t', '0'] },
|
|
227
|
+
],
|
|
228
|
+
// True when the plan rests on the mounted drive alone.
|
|
229
|
+
suspect,
|
|
230
|
+
platform: plat,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
kind: 'linux',
|
|
235
|
+
label: '关闭这台 Linux 主机',
|
|
236
|
+
source: 'process.platform',
|
|
237
|
+
command: 'systemctl',
|
|
238
|
+
args: ['poweroff'],
|
|
239
|
+
fallbacks: [{ command: 'shutdown', args: ['-h', 'now'] }],
|
|
240
|
+
platform: plat,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (plat === 'darwin') {
|
|
245
|
+
return {
|
|
246
|
+
kind: 'macos',
|
|
247
|
+
label: '关闭这台 Mac',
|
|
248
|
+
source: 'process.platform',
|
|
249
|
+
command: 'osascript',
|
|
250
|
+
args: ['-e', 'tell application "System Events" to shut down'],
|
|
251
|
+
fallbacks: [],
|
|
252
|
+
platform: plat,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
kind: 'unsupported',
|
|
258
|
+
label: '这台机器没有可靠的关机方式',
|
|
259
|
+
source: 'process.platform',
|
|
260
|
+
command: null,
|
|
261
|
+
args: [],
|
|
262
|
+
fallbacks: [],
|
|
263
|
+
platform: plat,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Apply the configured custom command to a plan.
|
|
269
|
+
*
|
|
270
|
+
* A non-empty `sleepCommand` wins over the detected one: on Windows it runs
|
|
271
|
+
* through `cmd.exe /c`, everywhere else through `/bin/sh -c`. The override is
|
|
272
|
+
* recorded in `source` so the UI can say that the automatic detection was
|
|
273
|
+
* replaced by the operator's own command.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} plan the detected plan
|
|
276
|
+
* @param {{ sleepCommand?: string, platform?: string }} [options]
|
|
277
|
+
* @returns {object} the plan that will actually be executed
|
|
278
|
+
*/
|
|
279
|
+
export function planInvocation(plan, { sleepCommand = '', platform = process.platform } = {}) {
|
|
280
|
+
const base = plan && typeof plan === 'object' ? plan : {};
|
|
281
|
+
const command = nonEmptyString(sleepCommand);
|
|
282
|
+
if (command === '') return { ...base };
|
|
283
|
+
const plat = nonEmptyString(platform) || base.platform || process.platform;
|
|
284
|
+
const windows = plat === 'win32';
|
|
285
|
+
return {
|
|
286
|
+
...base,
|
|
287
|
+
command: windows ? 'cmd.exe' : '/bin/sh',
|
|
288
|
+
args: windows ? ['/c', command] : ['-c', command],
|
|
289
|
+
source: 'sleepCommand',
|
|
290
|
+
detectedSource: typeof base.source === 'string' ? base.source : '',
|
|
291
|
+
overridden: true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* How long a command may take to really start before it counts as failed (ms).
|
|
297
|
+
*
|
|
298
|
+
* `spawn()` reports a failure through the child's `error` event, so the answer
|
|
299
|
+
* only arrives asynchronously. The timeout is the safety net for a spawner
|
|
300
|
+
* that never answers at all.
|
|
301
|
+
*/
|
|
302
|
+
export const SPAWN_TIMEOUT_MS = 5000;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Start ONE command and wait for the real answer.
|
|
306
|
+
*
|
|
307
|
+
* `spawn` means the command really started; `error` means it did not (the
|
|
308
|
+
* binary is missing, the permission is denied, ...). Nothing is swallowed:
|
|
309
|
+
* the caller learns which one of the two happened.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} command
|
|
312
|
+
* @param {string[]} args
|
|
313
|
+
* @param {{
|
|
314
|
+
* spawn?: Function,
|
|
315
|
+
* log?: { warn?: Function },
|
|
316
|
+
* timeoutMs?: number,
|
|
317
|
+
* setTimeout?: Function,
|
|
318
|
+
* clearTimeout?: Function,
|
|
319
|
+
* }} [options]
|
|
320
|
+
* @returns {Promise<{ ok: boolean, pid: number|null, error: string|null }>}
|
|
321
|
+
*/
|
|
322
|
+
function startCommand(command, args, {
|
|
323
|
+
spawn = spawnProcess,
|
|
324
|
+
timeoutMs = SPAWN_TIMEOUT_MS,
|
|
325
|
+
setTimeout: setTimeoutFn = setTimeout,
|
|
326
|
+
clearTimeout: clearTimeoutFn = clearTimeout,
|
|
327
|
+
} = {}) {
|
|
328
|
+
return new Promise((resolve) => {
|
|
329
|
+
let child = null;
|
|
330
|
+
try {
|
|
331
|
+
child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
332
|
+
} catch (error) {
|
|
333
|
+
resolve({ ok: false, pid: null, error: `spawn-failed: ${errorText(error)}` });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const pid = child && Number.isFinite(child.pid) ? child.pid : null;
|
|
337
|
+
if (!child || typeof child.on !== 'function') {
|
|
338
|
+
// A spawner without events (a test double) counts as started.
|
|
339
|
+
try {
|
|
340
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
341
|
+
} catch { /* nothing else to do */ }
|
|
342
|
+
resolve({ ok: true, pid, error: null });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
let settled = false;
|
|
346
|
+
let timer = null;
|
|
347
|
+
const finish = (ok, error) => {
|
|
348
|
+
if (settled) return;
|
|
349
|
+
settled = true;
|
|
350
|
+
if (timer !== null) {
|
|
351
|
+
try {
|
|
352
|
+
clearTimeoutFn(timer);
|
|
353
|
+
} catch { /* nothing else to do */ }
|
|
354
|
+
timer = null;
|
|
355
|
+
}
|
|
356
|
+
resolve({ ok, pid, error });
|
|
357
|
+
};
|
|
358
|
+
child.on('spawn', () => {
|
|
359
|
+
try {
|
|
360
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
361
|
+
} catch { /* nothing else to do */ }
|
|
362
|
+
finish(true, null);
|
|
363
|
+
});
|
|
364
|
+
child.on('error', (error) => {
|
|
365
|
+
finish(false, `spawn-failed: ${errorText(error)}`);
|
|
366
|
+
});
|
|
367
|
+
try {
|
|
368
|
+
timer = setTimeoutFn(() => {
|
|
369
|
+
finish(false, `spawn-timeout: ${command} did not start within ${timeoutMs}ms`);
|
|
370
|
+
}, timeoutMs);
|
|
371
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
372
|
+
} catch { /* the timeout is only a safety net */ }
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Execute the shutdown command, detached from the host.
|
|
378
|
+
*
|
|
379
|
+
* The gate is checked ONE more time right before the command runs: a session
|
|
380
|
+
* that started working in the meantime must be able to stop the shutdown.
|
|
381
|
+
*
|
|
382
|
+
* The command is really started, not merely requested: the answer arrives
|
|
383
|
+
* with the child's `spawn` event, and a command that does not start is
|
|
384
|
+
* reported as a FAILURE (`executed: false`). When the main command does not
|
|
385
|
+
* start, the plan's fallbacks are tried one after another (the WSL plan
|
|
386
|
+
* reaches Windows through two of them), and the first one that starts wins.
|
|
387
|
+
*
|
|
388
|
+
* With `dryRun` nothing is executed at all: the command that WOULD run is
|
|
389
|
+
* reported instead.
|
|
390
|
+
*
|
|
391
|
+
* @param {object} plan the plan to execute
|
|
392
|
+
* @param {{
|
|
393
|
+
* spawn?: Function,
|
|
394
|
+
* dryRun?: boolean,
|
|
395
|
+
* log?: { warn?: Function, info?: Function },
|
|
396
|
+
* gate?: () => { allow?: boolean } | null,
|
|
397
|
+
* timeoutMs?: number,
|
|
398
|
+
* }} [options]
|
|
399
|
+
* @returns {Promise<{
|
|
400
|
+
* ok: boolean,
|
|
401
|
+
* at: string,
|
|
402
|
+
* command: string|null,
|
|
403
|
+
* args: string[],
|
|
404
|
+
* executed: boolean,
|
|
405
|
+
* pid: number|null,
|
|
406
|
+
* error: string|null,
|
|
407
|
+
* dryRun?: boolean,
|
|
408
|
+
* }>}
|
|
409
|
+
*/
|
|
410
|
+
export function runShutdown(plan, {
|
|
411
|
+
spawn = spawnProcess,
|
|
412
|
+
dryRun = false,
|
|
413
|
+
log = console,
|
|
414
|
+
gate = null,
|
|
415
|
+
timeoutMs = SPAWN_TIMEOUT_MS,
|
|
416
|
+
} = {}) {
|
|
417
|
+
const at = new Date().toISOString();
|
|
418
|
+
const source = plan && typeof plan === 'object' ? plan : {};
|
|
419
|
+
const command = nonEmptyString(source.command);
|
|
420
|
+
const args = Array.isArray(source.args) ? source.args.map((entry) => String(entry)) : [];
|
|
421
|
+
if (command === '') {
|
|
422
|
+
return Promise.resolve({ ok: false, at, command: null, args: [], executed: false, pid: null, error: 'no-shutdown-command' });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (typeof gate === 'function') {
|
|
426
|
+
let verdict = null;
|
|
427
|
+
try {
|
|
428
|
+
verdict = gate();
|
|
429
|
+
} catch (error) {
|
|
430
|
+
verdict = null;
|
|
431
|
+
if (log && typeof log.warn === 'function') log.warn('the sleep gate failed:', errorText(error));
|
|
432
|
+
}
|
|
433
|
+
if (!verdict || verdict.allow !== true) {
|
|
434
|
+
return Promise.resolve({ ok: false, at, command, args, executed: false, pid: null, error: 'gate-blocked' });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (dryRun === true) {
|
|
439
|
+
return Promise.resolve({ ok: true, at, command, args, executed: false, pid: null, error: null, dryRun: true });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// The main command first, then the fallbacks in the order of the plan.
|
|
443
|
+
const attempts = [{ command, args }];
|
|
444
|
+
const fallbacks = Array.isArray(source.fallbacks) ? source.fallbacks : [];
|
|
445
|
+
for (const fallback of fallbacks) {
|
|
446
|
+
if (!fallback || typeof fallback !== 'object') continue;
|
|
447
|
+
const fallbackCommand = nonEmptyString(fallback.command);
|
|
448
|
+
if (fallbackCommand === '') continue;
|
|
449
|
+
attempts.push({
|
|
450
|
+
command: fallbackCommand,
|
|
451
|
+
args: Array.isArray(fallback.args) ? fallback.args.map((entry) => String(entry)) : [],
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return (async () => {
|
|
456
|
+
let lastError = 'no-shutdown-command';
|
|
457
|
+
for (const attempt of attempts) {
|
|
458
|
+
const outcome = await startCommand(attempt.command, attempt.args, { spawn, timeoutMs });
|
|
459
|
+
if (outcome.ok) {
|
|
460
|
+
return {
|
|
461
|
+
ok: true,
|
|
462
|
+
at,
|
|
463
|
+
command: attempt.command,
|
|
464
|
+
args: attempt.args,
|
|
465
|
+
executed: true,
|
|
466
|
+
pid: outcome.pid,
|
|
467
|
+
error: null,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
lastError = outcome.error;
|
|
471
|
+
if (log && typeof log.warn === 'function') {
|
|
472
|
+
log.warn(`the sleep command ${attempt.command} did not start:`, outcome.error);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return { ok: false, at, command, args, executed: false, pid: null, error: lastError };
|
|
476
|
+
})();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The state machine behind the graceful sleep.
|
|
481
|
+
*
|
|
482
|
+
* `idle → waiting → countdown → shutting-down`, with `cancelled` and
|
|
483
|
+
* `unsupported` as the two ways out. The controller never decides on its own
|
|
484
|
+
* whether a conversation is busy: it asks the injected `busyAgents` and the
|
|
485
|
+
* injected `gate`, so the sleep feature and the restart feature always agree
|
|
486
|
+
* on what "idle" means.
|
|
487
|
+
*
|
|
488
|
+
* Every tick is wrapped in a try/catch: a broken tick must never take the
|
|
489
|
+
* host down.
|
|
490
|
+
*
|
|
491
|
+
* @param {{
|
|
492
|
+
* now?: () => number,
|
|
493
|
+
* busyAgents?: () => { sessionId: string, reason?: string }[],
|
|
494
|
+
* gate?: () => { allow?: boolean, blockedBy?: any[] },
|
|
495
|
+
* shutdown?: () => any,
|
|
496
|
+
* countdownSeconds?: number,
|
|
497
|
+
* log?: { warn?: Function, info?: Function },
|
|
498
|
+
* setInterval?: (fn: () => void, ms: number) => any,
|
|
499
|
+
* clearInterval?: (handle: any) => void,
|
|
500
|
+
* titleOf?: (sessionId: string) => string|undefined,
|
|
501
|
+
* hostLabel?: string,
|
|
502
|
+
* supported?: boolean,
|
|
503
|
+
* }} [options]
|
|
504
|
+
*/
|
|
505
|
+
export function createSleepController({
|
|
506
|
+
now = () => Date.now(),
|
|
507
|
+
busyAgents = () => [],
|
|
508
|
+
gate = () => ({ allow: true, reason: 'quiet', blockedBy: [] }),
|
|
509
|
+
shutdown = () => ({ ok: false, error: 'no-shutdown-handler' }),
|
|
510
|
+
countdownSeconds = DEFAULT_COUNTDOWN_SECONDS,
|
|
511
|
+
log = console,
|
|
512
|
+
setInterval: setIntervalFn = setInterval,
|
|
513
|
+
clearInterval: clearIntervalFn = clearInterval,
|
|
514
|
+
titleOf = () => '',
|
|
515
|
+
hostLabel = '',
|
|
516
|
+
supported = true,
|
|
517
|
+
} = {}) {
|
|
518
|
+
const seconds = normalizeCountdownSeconds(countdownSeconds);
|
|
519
|
+
const countdownMs = seconds * 1000;
|
|
520
|
+
const target = nonEmptyString(hostLabel) || '关掉这台电脑';
|
|
521
|
+
|
|
522
|
+
let phase = supported ? 'idle' : 'unsupported';
|
|
523
|
+
let armed = false;
|
|
524
|
+
let blockers = [];
|
|
525
|
+
let remainingMs = 0;
|
|
526
|
+
let lastRun = null;
|
|
527
|
+
let message = supported
|
|
528
|
+
? '还没点「收工关机」:所有会话收工后倒数几秒,就关掉这台电脑。'
|
|
529
|
+
: '这台机器没有可靠的关机方式,「收工关机」按钮不可用。';
|
|
530
|
+
let timer = null;
|
|
531
|
+
let disposed = false;
|
|
532
|
+
let lastTickAt = now();
|
|
533
|
+
|
|
534
|
+
/** The busy conversations, as `{ sessionId, title }` entries. */
|
|
535
|
+
const listBusy = () => {
|
|
536
|
+
let list = [];
|
|
537
|
+
try {
|
|
538
|
+
list = busyAgents();
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (log && typeof log.warn === 'function') log.warn('could not read the busy conversations:', errorText(error));
|
|
541
|
+
list = [];
|
|
542
|
+
}
|
|
543
|
+
if (!Array.isArray(list)) return [];
|
|
544
|
+
const out = [];
|
|
545
|
+
for (const entry of list) {
|
|
546
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
547
|
+
const sessionId = entry.sessionId == null ? '' : String(entry.sessionId);
|
|
548
|
+
if (sessionId === '') continue;
|
|
549
|
+
let title = '';
|
|
550
|
+
try {
|
|
551
|
+
const resolved = titleOf(sessionId);
|
|
552
|
+
title = typeof resolved === 'string' ? resolved.trim() : '';
|
|
553
|
+
} catch { /* a missing title is not a problem */ }
|
|
554
|
+
// The title falls back to the session id: the field always exists, so
|
|
555
|
+
// the UI can always say WHO is still working.
|
|
556
|
+
out.push({ sessionId, title: title !== '' ? title : sessionId });
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
/** The gate verdict; a broken gate denies, it never opens. */
|
|
562
|
+
const readGate = () => {
|
|
563
|
+
try {
|
|
564
|
+
const verdict = gate();
|
|
565
|
+
if (verdict && typeof verdict === 'object') return verdict;
|
|
566
|
+
return { allow: false, reason: 'gate-unavailable', blockedBy: [] };
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (log && typeof log.warn === 'function') log.warn('the sleep gate failed:', errorText(error));
|
|
569
|
+
return { allow: false, reason: 'gate-error', blockedBy: [] };
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/** The blockers of a gate verdict, in the shape the UI expects. */
|
|
574
|
+
const blockersFromVerdict = (verdict) => {
|
|
575
|
+
const list = verdict && Array.isArray(verdict.blockedBy) ? verdict.blockedBy : [];
|
|
576
|
+
const out = [];
|
|
577
|
+
for (const entry of list) {
|
|
578
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
579
|
+
const sessionId = entry.sessionId == null ? '' : String(entry.sessionId);
|
|
580
|
+
if (sessionId === '') continue;
|
|
581
|
+
let title = '';
|
|
582
|
+
try {
|
|
583
|
+
const resolved = titleOf(sessionId);
|
|
584
|
+
title = typeof resolved === 'string' ? resolved.trim() : '';
|
|
585
|
+
} catch { /* a missing title is not a problem */ }
|
|
586
|
+
out.push({ sessionId, title: title !== '' ? title : sessionId });
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/** The human name of the busy conversations. */
|
|
592
|
+
const names = (list) => list.map((entry) => entry.title || entry.sessionId).join('、');
|
|
593
|
+
|
|
594
|
+
/** Normalize whatever the shutdown handler returned into the reported shape. */
|
|
595
|
+
const normalizeRun = (value) => {
|
|
596
|
+
const run = value && typeof value === 'object' ? value : {};
|
|
597
|
+
const normalized = {
|
|
598
|
+
at: typeof run.at === 'string' ? run.at : new Date(now()).toISOString(),
|
|
599
|
+
command: typeof run.command === 'string' ? run.command : null,
|
|
600
|
+
args: Array.isArray(run.args) ? run.args.map((entry) => String(entry)) : [],
|
|
601
|
+
executed: run.executed === true,
|
|
602
|
+
pid: Number.isFinite(run.pid) ? run.pid : null,
|
|
603
|
+
error: run.error == null ? null : String(run.error),
|
|
604
|
+
};
|
|
605
|
+
// A dry run is reported as such, because the UI has to know that the
|
|
606
|
+
// command was NOT executed. A real run keeps its shape untouched.
|
|
607
|
+
if (run.dryRun === true) normalized.dryRun = true;
|
|
608
|
+
return normalized;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/** The current state of the machine. */
|
|
612
|
+
const status = () => ({
|
|
613
|
+
phase,
|
|
614
|
+
armed,
|
|
615
|
+
countdownSeconds: seconds,
|
|
616
|
+
remainingMs,
|
|
617
|
+
blockers: blockers.map((entry) => ({ ...entry })),
|
|
618
|
+
message,
|
|
619
|
+
lastRun: lastRun === null ? null : { ...lastRun },
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const stopTimer = () => {
|
|
623
|
+
if (timer === null) return;
|
|
624
|
+
const handle = timer;
|
|
625
|
+
timer = null;
|
|
626
|
+
try {
|
|
627
|
+
clearIntervalFn(handle);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
if (log && typeof log.warn === 'function') log.warn('could not stop the sleep timer:', errorText(error));
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const startTimer = () => {
|
|
634
|
+
if (timer !== null) return;
|
|
635
|
+
try {
|
|
636
|
+
timer = setIntervalFn(() => {
|
|
637
|
+
void tick();
|
|
638
|
+
}, TICK_MS);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
timer = null;
|
|
641
|
+
if (log && typeof log.warn === 'function') log.warn('could not start the sleep timer:', errorText(error));
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* One step of the state machine.
|
|
647
|
+
*
|
|
648
|
+
* Never throws: a broken tick is logged and the state is left as it is.
|
|
649
|
+
*/
|
|
650
|
+
const tick = () => {
|
|
651
|
+
if (disposed) return status();
|
|
652
|
+
if (!armed) return status();
|
|
653
|
+
if (phase === 'shutting-down') return status();
|
|
654
|
+
try {
|
|
655
|
+
const busy = listBusy();
|
|
656
|
+
if (busy.length > 0) {
|
|
657
|
+
const again = phase === 'countdown';
|
|
658
|
+
phase = 'waiting';
|
|
659
|
+
blockers = busy;
|
|
660
|
+
remainingMs = countdownMs;
|
|
661
|
+
message = again
|
|
662
|
+
? `${names(busy)} 又开始干活了,倒数先停一下,等它们收工。`
|
|
663
|
+
: (countdownMs > 0
|
|
664
|
+
? `等 ${busy.length} 个会话收工(${names(busy)}),收工后开始倒数。`
|
|
665
|
+
: `等 ${busy.length} 个会话收工(${names(busy)}),收工后立即关机。`);
|
|
666
|
+
return status();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (phase !== 'countdown') {
|
|
670
|
+
phase = 'countdown';
|
|
671
|
+
remainingMs = countdownMs;
|
|
672
|
+
lastTickAt = now();
|
|
673
|
+
} else {
|
|
674
|
+
const stamp = now();
|
|
675
|
+
const elapsed = Math.max(TICK_MS, stamp - lastTickAt);
|
|
676
|
+
lastTickAt = stamp;
|
|
677
|
+
remainingMs = Math.max(0, remainingMs - elapsed);
|
|
678
|
+
}
|
|
679
|
+
// A countdown of zero seconds is no countdown at all: the gate is asked
|
|
680
|
+
// right away and the machine is switched off the moment it opens.
|
|
681
|
+
message = countdownMs > 0
|
|
682
|
+
? `倒数 ${Math.ceil(remainingMs / 1000)} 秒后${target}……`
|
|
683
|
+
: '0 秒:立即关机。';
|
|
684
|
+
if (remainingMs > 0) return status();
|
|
685
|
+
|
|
686
|
+
// The countdown is over: the gate is checked ONE more time, so a
|
|
687
|
+
// conversation that started working in the meantime still wins.
|
|
688
|
+
const verdict = readGate();
|
|
689
|
+
if (verdict.allow !== true) {
|
|
690
|
+
phase = 'waiting';
|
|
691
|
+
blockers = blockersFromVerdict(verdict);
|
|
692
|
+
remainingMs = countdownMs;
|
|
693
|
+
const reason = typeof verdict.reason === 'string' ? verdict.reason : '';
|
|
694
|
+
if (reason === 'settling') {
|
|
695
|
+
// The machine was not quiet long enough: say THAT, and do not claim
|
|
696
|
+
// that somebody started working.
|
|
697
|
+
message = '机器还没安静够久,再等一轮……';
|
|
698
|
+
} else if (blockers.length > 0) {
|
|
699
|
+
message = `倒数结束前又有人开始干活了(${names(blockers)}),关机先暂停,等所有会话收工。`;
|
|
700
|
+
} else {
|
|
701
|
+
message = '倒数结束时机器还没安静下来,关机先暂停,再等一轮。';
|
|
702
|
+
}
|
|
703
|
+
return status();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
phase = 'shutting-down';
|
|
707
|
+
stopTimer();
|
|
708
|
+
// A countdown of zero seconds says so in the message too: the user
|
|
709
|
+
// must see that the machine went off without any countdown.
|
|
710
|
+
message = countdownMs > 0
|
|
711
|
+
? `正在${target}……`
|
|
712
|
+
: '0 秒:立即关机,正在关掉这台电脑。';
|
|
713
|
+
let result = null;
|
|
714
|
+
try {
|
|
715
|
+
result = shutdown();
|
|
716
|
+
} catch (error) {
|
|
717
|
+
result = { ok: false, error: errorText(error) };
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* The phase only says "shutting down" when the command really started:
|
|
721
|
+
* a command that did not start leaves the machine alone, and the state
|
|
722
|
+
* must never keep saying "正在关机" (the UI would then show a screen
|
|
723
|
+
* without a way out).
|
|
724
|
+
*/
|
|
725
|
+
const applyRun = (value) => {
|
|
726
|
+
const raw = value && typeof value === 'object' ? value : {};
|
|
727
|
+
const run = normalizeRun(value);
|
|
728
|
+
lastRun = run;
|
|
729
|
+
// A dry run reports the command it WOULD run: nothing was executed, and
|
|
730
|
+
// the state must NOT keep saying "shutting down" — the UI would then
|
|
731
|
+
// show a screen without a way out (the countdown is over, the moon
|
|
732
|
+
// button is covered, and there is no cancel button on that screen).
|
|
733
|
+
if (raw.dryRun === true) {
|
|
734
|
+
phase = 'idle';
|
|
735
|
+
armed = false;
|
|
736
|
+
blockers = [];
|
|
737
|
+
remainingMs = 0;
|
|
738
|
+
const planned = [run.command || '', ...run.args].filter((part) => part !== '').join(' ');
|
|
739
|
+
message = `试运行:本来会执行 ${planned},这次不会真的关机。`;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (run.executed === true) return;
|
|
743
|
+
if (run.error === 'gate-blocked') {
|
|
744
|
+
// Somebody started working in the very last moment: wait for them —
|
|
745
|
+
// and KEEP the list, so the waiting card can say WHO is still busy.
|
|
746
|
+
const verdict = readGate();
|
|
747
|
+
phase = 'waiting';
|
|
748
|
+
blockers = blockersFromVerdict(verdict);
|
|
749
|
+
remainingMs = countdownMs;
|
|
750
|
+
const reason = typeof verdict.reason === 'string' ? verdict.reason : '';
|
|
751
|
+
if (reason === 'settling') {
|
|
752
|
+
// The machine was not quiet long enough: say THAT, and do not claim
|
|
753
|
+
// that somebody started working.
|
|
754
|
+
message = '机器还没安静够久,再等一轮……';
|
|
755
|
+
} else if (blockers.length > 0) {
|
|
756
|
+
message = `倒数结束时又有人开始干活了(${names(blockers)}),关机先暂停,等所有会话收工。`;
|
|
757
|
+
} else {
|
|
758
|
+
message = '倒数结束时机器还没安静下来,关机先暂停,再等一轮。';
|
|
759
|
+
}
|
|
760
|
+
startTimer();
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
phase = 'idle';
|
|
764
|
+
armed = false;
|
|
765
|
+
blockers = [];
|
|
766
|
+
remainingMs = 0;
|
|
767
|
+
message = `关机命令没跑起来(${run.error || 'unknown error'}),电脑还开着,可以再点一次月亮按钮。`;
|
|
768
|
+
};
|
|
769
|
+
if (result && typeof result.then === 'function') {
|
|
770
|
+
result.then(
|
|
771
|
+
(value) => { applyRun(value); },
|
|
772
|
+
(error) => { applyRun({ ok: false, error: errorText(error) }); },
|
|
773
|
+
);
|
|
774
|
+
} else {
|
|
775
|
+
applyRun(result);
|
|
776
|
+
}
|
|
777
|
+
return status();
|
|
778
|
+
} catch (error) {
|
|
779
|
+
// A tick must never take the host down.
|
|
780
|
+
if (log && typeof log.warn === 'function') log.warn('the sleep tick failed:', errorText(error));
|
|
781
|
+
return status();
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
/** Arm the feature: wait for the conversations, then count down, then shut down. */
|
|
786
|
+
const arm = () => {
|
|
787
|
+
if (disposed) return status();
|
|
788
|
+
if (!supported) {
|
|
789
|
+
armed = false;
|
|
790
|
+
phase = 'unsupported';
|
|
791
|
+
blockers = [];
|
|
792
|
+
remainingMs = 0;
|
|
793
|
+
message = '这台机器没有可靠的关机方式,「收工关机」按钮不可用。';
|
|
794
|
+
return status();
|
|
795
|
+
}
|
|
796
|
+
armed = true;
|
|
797
|
+
phase = 'waiting';
|
|
798
|
+
blockers = [];
|
|
799
|
+
remainingMs = countdownMs;
|
|
800
|
+
lastTickAt = now();
|
|
801
|
+
message = countdownMs > 0
|
|
802
|
+
? '已武装,等所有会话收工后开始倒数。'
|
|
803
|
+
: '已武装:等所有会话收工后立即关机。';
|
|
804
|
+
startTimer();
|
|
805
|
+
tick();
|
|
806
|
+
return status();
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
/** Cancel everything: no timer may survive the cancellation. */
|
|
810
|
+
const cancel = () => {
|
|
811
|
+
stopTimer();
|
|
812
|
+
armed = false;
|
|
813
|
+
phase = 'cancelled';
|
|
814
|
+
blockers = [];
|
|
815
|
+
remainingMs = 0;
|
|
816
|
+
message = '收工关机已取消,电脑不会关机。';
|
|
817
|
+
return status();
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
/** Release the timer when the plugin is unloaded. */
|
|
821
|
+
const dispose = () => {
|
|
822
|
+
disposed = true;
|
|
823
|
+
stopTimer();
|
|
824
|
+
armed = false;
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
return { arm, tick, cancel, status, dispose };
|
|
828
|
+
}
|