@oar-sh/oar 0.9.14 → 0.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +613 -56
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/oar-cli-helpers.mjs +12 -4
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2334
|
@@ -7,18 +7,19 @@ import { resolveLayoutStateDirs, resolveRelayStateDirs } from './oar-state-layou
|
|
|
7
7
|
import {
|
|
8
8
|
describeExecError,
|
|
9
9
|
describeSpawnEnvironment,
|
|
10
|
+
windowsSystemRoot,
|
|
10
11
|
windowsSystemToolPath,
|
|
11
12
|
} from '../../shared/windows-system-tools.mjs';
|
|
12
13
|
import {
|
|
13
|
-
LEGACY_WINDOWS_AUTOSTART_FILENAME,
|
|
14
|
-
WINDOWS_AUTOSTART_FILENAME,
|
|
15
14
|
autostartWaitLines,
|
|
15
|
+
backupPathFor,
|
|
16
16
|
buildLauncherFile,
|
|
17
|
+
createWindowsAutostartService,
|
|
17
18
|
escapeBatchValue,
|
|
18
19
|
launcherNodeLine,
|
|
19
20
|
launcherScriptsMatch,
|
|
21
|
+
otherInstallOf,
|
|
20
22
|
readLauncherNodePath,
|
|
21
|
-
resolveWindowsStartupDirectory,
|
|
22
23
|
stateDirLines,
|
|
23
24
|
} from './windows-autostart-service.mjs';
|
|
24
25
|
|
|
@@ -60,6 +61,9 @@ const ELEVATION_TIMEOUT_MS = 120_000;
|
|
|
60
61
|
export const BOOT_LAUNCHER_LOG_FILENAME = 'oar-web-relay-launcher.log';
|
|
61
62
|
// Each schtasks query; the settings page waits on two of them.
|
|
62
63
|
const QUERY_TIMEOUT_MS = 6_000;
|
|
64
|
+
// The PowerShell export a task with non-ASCII text is read again with
|
|
65
|
+
// (PowerShell and its ScheduledTasks module start first).
|
|
66
|
+
const EXPORT_TIMEOUT_MS = 20_000;
|
|
63
67
|
|
|
64
68
|
function normalizeText(value) {
|
|
65
69
|
return String(value == null ? '' : value).trim();
|
|
@@ -337,27 +341,134 @@ function samePath(a, b) {
|
|
|
337
341
|
return normalize(a) === normalize(b);
|
|
338
342
|
}
|
|
339
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Elements, attributes and text of a Task Scheduler export, as a tree of
|
|
346
|
+
* `{ name, attrs, children, text }`; no XML parser dependency. Throws on
|
|
347
|
+
* anything it cannot read (callers take that as hand-made).
|
|
348
|
+
*/
|
|
349
|
+
export function parseXmlTree(xmlText) {
|
|
350
|
+
const root = { name: '#document', attrs: {}, children: [], text: '' };
|
|
351
|
+
const stack = [root];
|
|
352
|
+
const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<(\/?)([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)|(<)/g;
|
|
353
|
+
for (const match of String(xmlText == null ? '' : xmlText).matchAll(token)) {
|
|
354
|
+
const [, closing, name, attrText, selfClosing, text, stray] = match;
|
|
355
|
+
const top = stack.at(-1);
|
|
356
|
+
if (stray) throw new Error('unreadable task XML');
|
|
357
|
+
if (text !== undefined) {
|
|
358
|
+
top.text += unescapeXml(text);
|
|
359
|
+
} else if (closing) {
|
|
360
|
+
if (top.name !== name || stack.length === 1) throw new Error('unbalanced task XML');
|
|
361
|
+
stack.pop();
|
|
362
|
+
} else if (name) {
|
|
363
|
+
const attrs = {};
|
|
364
|
+
for (const [, key, quoted] of String(attrText || '').matchAll(/([\w.:-]+)\s*=\s*("[^"]*"|'[^']*')/g)) {
|
|
365
|
+
attrs[key] = unescapeXml(quoted.slice(1, -1));
|
|
366
|
+
}
|
|
367
|
+
const element = { name, attrs, children: [], text: '' };
|
|
368
|
+
top.children.push(element);
|
|
369
|
+
if (!selfClosing) stack.push(element);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (stack.length !== 1) throw new Error('unbalanced task XML');
|
|
373
|
+
return root;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The settings buildBootTaskXml writes, and those Task Scheduler adds with
|
|
377
|
+
// their default values on export. Any other setting or value is a change.
|
|
378
|
+
const TASK_SETTINGS = {
|
|
379
|
+
DisallowStartIfOnBatteries: 'false',
|
|
380
|
+
StopIfGoingOnBatteries: 'false',
|
|
381
|
+
ExecutionTimeLimit: 'PT0S',
|
|
382
|
+
StartWhenAvailable: 'true',
|
|
383
|
+
RestartOnFailure: { Count: '5', Interval: 'PT1M' },
|
|
384
|
+
};
|
|
385
|
+
const TASK_SETTING_DEFAULTS = {
|
|
386
|
+
MultipleInstancesPolicy: 'IgnoreNew',
|
|
387
|
+
UseUnifiedSchedulingEngine: 'true',
|
|
388
|
+
AllowHardTerminate: 'true',
|
|
389
|
+
RunOnlyIfNetworkAvailable: 'false',
|
|
390
|
+
AllowStartOnDemand: 'true',
|
|
391
|
+
Enabled: 'true',
|
|
392
|
+
Hidden: 'false',
|
|
393
|
+
RunOnlyIfIdle: 'false',
|
|
394
|
+
DisallowStartOnRemoteAppSession: 'false',
|
|
395
|
+
WakeToRun: 'false',
|
|
396
|
+
Priority: '7',
|
|
397
|
+
IdleSettings: { StopOnIdleEnd: 'true', RestartOnIdle: 'false', Duration: 'PT10M', WaitTimeout: 'PT1H' },
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Children of `element` are exactly the named ones with these values
|
|
401
|
+
// (`optional` ones may be missing).
|
|
402
|
+
function childrenMatch(element, required, optional = {}) {
|
|
403
|
+
const seen = new Set();
|
|
404
|
+
for (const child of element?.children || []) {
|
|
405
|
+
if (seen.has(child.name)) return false;
|
|
406
|
+
seen.add(child.name);
|
|
407
|
+
const wanted = required[child.name] ?? optional[child.name];
|
|
408
|
+
if (wanted === undefined) return false;
|
|
409
|
+
if (typeof wanted === 'object' ? !childrenMatch(child, {}, wanted) || child.text.trim() : !sameText(child.text, wanted)) return false;
|
|
410
|
+
}
|
|
411
|
+
return Object.keys(required).every((name) => seen.has(name)
|
|
412
|
+
&& (typeof required[name] !== 'object' || childrenMatch(element.children.find((child) => child.name === name), required[name])));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function sameUser(userId, user) {
|
|
416
|
+
const id = normalizeText(userId).toLowerCase();
|
|
417
|
+
if (!id || !user) return false;
|
|
418
|
+
const name = normalizeText(user.name).toLowerCase();
|
|
419
|
+
return id === normalizeText(user.sid).toLowerCase() || (name && (id === name || id === name.split('\\').pop()));
|
|
420
|
+
}
|
|
421
|
+
|
|
340
422
|
/**
|
|
341
423
|
* How a task that runs our launcher differs from the one buildBootTaskXml
|
|
342
|
-
* registers, as short names (empty when it does not)
|
|
343
|
-
*
|
|
344
|
-
*
|
|
424
|
+
* registers, as short names (empty when it does not): only a task that is
|
|
425
|
+
* that one in every field that matters is OAR's own. Task Scheduler's own
|
|
426
|
+
* additions on export are no change (RegistrationInfo, the defaults in
|
|
427
|
+
* TASK_SETTING_DEFAULTS, `Enabled` on the trigger); anything else is: the
|
|
428
|
+
* account (`user`, when given: `{ sid, name }`, or null when it is not known),
|
|
429
|
+
* a run level above least privilege, a second action or trigger, any other
|
|
430
|
+
* setting. XML it cannot read is `unreadable`.
|
|
345
431
|
*/
|
|
346
|
-
export function describeTaskCustomisations(xmlText, { launcherPath, workingDirectory } = {}) {
|
|
347
|
-
|
|
432
|
+
export function describeTaskCustomisations(xmlText, { launcherPath, workingDirectory, user } = {}) {
|
|
433
|
+
let task;
|
|
434
|
+
try {
|
|
435
|
+
task = parseXmlTree(xmlText).children.find((element) => element.name === 'Task');
|
|
436
|
+
} catch {}
|
|
437
|
+
if (!task) return ['unreadable'];
|
|
348
438
|
const differences = [];
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
439
|
+
const section = (name) => task.children.filter((element) => element.name === name);
|
|
440
|
+
if (task.children.some((element) => !['RegistrationInfo', 'Principals', 'Settings', 'Triggers', 'Actions'].includes(element.name))
|
|
441
|
+
|| ['Principals', 'Settings', 'Triggers', 'Actions'].some((name) => section(name).length !== 1)) {
|
|
442
|
+
differences.push('other');
|
|
443
|
+
}
|
|
444
|
+
const [principal, ...morePrincipals] = section('Principals')[0]?.children || [];
|
|
445
|
+
const field = (element, name) => element?.children.find((child) => child.name === name)?.text ?? null;
|
|
446
|
+
if (user !== undefined && (!sameUser(field(principal, 'UserId'), user) || morePrincipals.length)) differences.push('user');
|
|
447
|
+
if (!sameText(field(principal, 'LogonType'), 'S4U')) differences.push('logon-type');
|
|
448
|
+
if (field(principal, 'RunLevel') !== null && !sameText(field(principal, 'RunLevel'), 'LeastPrivilege')) differences.push('run-level');
|
|
449
|
+
if ((principal?.children || []).some((child) => !['UserId', 'LogonType', 'RunLevel'].includes(child.name))) differences.push('user');
|
|
450
|
+
|
|
451
|
+
const actions = section('Actions')[0]?.children || [];
|
|
452
|
+
const exec = actions[0];
|
|
453
|
+
if (actions.length !== 1 || exec.name !== 'Exec'
|
|
454
|
+
|| exec.children.some((child) => !['Command', 'Arguments', 'WorkingDirectory'].includes(child.name))
|
|
455
|
+
|| !sameText(field(exec, 'Command'), 'C:\\Windows\\System32\\cmd.exe')
|
|
456
|
+
|| normalizeText(field(exec, 'Arguments')).replace(/\s+/g, ' ').toLowerCase() !== `/d /c "${normalizeText(launcherPath)}"`.toLowerCase()) {
|
|
352
457
|
differences.push('action');
|
|
353
458
|
}
|
|
354
|
-
if (normalizeText(workingDirectory) && !samePath(
|
|
459
|
+
if (normalizeText(workingDirectory) && !samePath(field(exec, 'WorkingDirectory'), workingDirectory)) {
|
|
355
460
|
differences.push('working-directory');
|
|
356
461
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
462
|
+
|
|
463
|
+
const triggers = section('Triggers')[0]?.children || [];
|
|
464
|
+
const boot = triggers[0];
|
|
465
|
+
if (triggers.length !== 1 || boot.name !== 'BootTrigger' || !childrenMatch(boot, {}, { Delay: field(boot, 'Delay') ?? '', Enabled: 'true' })) {
|
|
466
|
+
differences.push('trigger');
|
|
467
|
+
} else if (!sameText(field(boot, 'Delay'), 'PT30S')) {
|
|
468
|
+
differences.push('boot-delay');
|
|
469
|
+
}
|
|
470
|
+
if (!childrenMatch(section('Settings')[0], TASK_SETTINGS, TASK_SETTING_DEFAULTS)) differences.push('settings');
|
|
471
|
+
return [...new Set(differences)];
|
|
361
472
|
}
|
|
362
473
|
|
|
363
474
|
// Shared with the sign-in entry; still exported from here for its callers.
|
|
@@ -377,35 +488,150 @@ export function classifyTaskXml(xmlText, { launcherPath } = {}) {
|
|
|
377
488
|
return 'foreign';
|
|
378
489
|
}
|
|
379
490
|
|
|
491
|
+
/**
|
|
492
|
+
* A PowerShell string literal that keeps `value` as it is: single-quoted
|
|
493
|
+
* (no `$` or backtick expansion), each single quote doubled, typographic
|
|
494
|
+
* ones too (PowerShell ends a single-quoted string at those as well).
|
|
495
|
+
*/
|
|
496
|
+
export function psLiteral(value) {
|
|
497
|
+
return `'${String(value).replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The exit code of a sweep that failed (and put back what it had deleted).
|
|
501
|
+
export const SWEEP_FAILED_EXIT_CODE = 3;
|
|
502
|
+
// Windows' ERROR_CANCELLED: the administrator prompt was declined.
|
|
503
|
+
export const ELEVATION_DECLINED_EXIT_CODE = 1223;
|
|
504
|
+
|
|
380
505
|
/**
|
|
381
506
|
* The elevated sweep. Everything the admin token is actually needed for —
|
|
382
507
|
* and nothing else — in one script, so one UAC covers the whole transition
|
|
383
508
|
* out of any mixed state (legacy-named task, hand-made task, fresh install).
|
|
509
|
+
* Every path is a PowerShell literal (psLiteral): a space, an apostrophe,
|
|
510
|
+
* `&`, `%`, `$` or non-ASCII in the OAR folder are passed on as they are.
|
|
511
|
+
* When it fails (the create, or a task still there after its delete) it
|
|
512
|
+
* puts back each task it deleted from `restore` (`{ task, xml }`: the copies
|
|
513
|
+
* made before, which `schtasks /create /xml` takes back) and exits
|
|
514
|
+
* SWEEP_FAILED_EXIT_CODE.
|
|
384
515
|
*/
|
|
385
|
-
export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks' } = {}) {
|
|
516
|
+
export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks', includeLegacy = false, restore = [] } = {}) {
|
|
386
517
|
// PowerShell runs a quoted path only behind the call operator.
|
|
387
|
-
const schtasks = /[\\/]/.test(schtasksPath) ? `&
|
|
518
|
+
const schtasks = /[\\/]/.test(schtasksPath) ? `& ${psLiteral(schtasksPath)}` : schtasksPath;
|
|
519
|
+
const remove = (name) => `${schtasks} /delete /tn ${psLiteral(name)} /f 2>$null`;
|
|
520
|
+
const stillThere = (name) => [`${schtasks} /query /tn ${psLiteral(name)} *>$null`, 'if ($LASTEXITCODE -eq 0) { $failed = $true }'];
|
|
521
|
+
let steps;
|
|
388
522
|
if (action === 'enable') {
|
|
389
523
|
if (!normalizeText(xmlPath)) {
|
|
390
524
|
throw new Error('The enable sweep needs the task XML path.');
|
|
391
525
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
`${schtasks} /create /tn
|
|
396
|
-
'
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
'',
|
|
406
|
-
].join('\r\n');
|
|
526
|
+
steps = [
|
|
527
|
+
remove(LEGACY_BOOT_TASK_NAME),
|
|
528
|
+
remove(BOOT_TASK_NAME),
|
|
529
|
+
`${schtasks} /create /tn ${psLiteral(BOOT_TASK_NAME)} /xml ${psLiteral(xmlPath)}`,
|
|
530
|
+
'if ($LASTEXITCODE -ne 0) { $failed = $true }',
|
|
531
|
+
];
|
|
532
|
+
} else if (action === 'disable') {
|
|
533
|
+
// Our own task only: a pre-rebrand one was made by hand, and stays
|
|
534
|
+
// unless `includeLegacy` (--replace, a copy kept).
|
|
535
|
+
const names = [BOOT_TASK_NAME, ...(includeLegacy ? [LEGACY_BOOT_TASK_NAME] : [])];
|
|
536
|
+
steps = [...names.map(remove), ...names.flatMap(stillThere)];
|
|
537
|
+
} else {
|
|
538
|
+
throw new Error(`Unknown elevation action: ${JSON.stringify(action)}`);
|
|
407
539
|
}
|
|
408
|
-
|
|
540
|
+
const putBack = restore.flatMap(({ task, xml }) => [
|
|
541
|
+
` ${schtasks} /query /tn ${psLiteral(task)} *>$null`,
|
|
542
|
+
` if ($LASTEXITCODE -ne 0) { ${schtasks} /create /tn ${psLiteral(task)} /xml ${psLiteral(xml)} /f 2>$null }`,
|
|
543
|
+
]);
|
|
544
|
+
return [
|
|
545
|
+
'$failed = $false',
|
|
546
|
+
...steps,
|
|
547
|
+
'if ($failed) {',
|
|
548
|
+
...putBack,
|
|
549
|
+
` exit ${SWEEP_FAILED_EXIT_CODE}`,
|
|
550
|
+
'}',
|
|
551
|
+
'exit 0',
|
|
552
|
+
'',
|
|
553
|
+
].join('\r\n');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The elevated PowerShell did not start (Start-Process failed otherwise
|
|
557
|
+
// than by the cancel): nothing elevated ran, as with a declined prompt.
|
|
558
|
+
export const ELEVATION_NOT_STARTED_EXIT_CODE = 2;
|
|
559
|
+
// It started, but its exit code cannot be told: the sweep may have run.
|
|
560
|
+
export const ELEVATION_UNKNOWN_EXIT_CODE = 4;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The command the unelevated PowerShell runs: Start-Process -Verb RunAs of
|
|
564
|
+
* a PowerShell (no profile) that runs `script` as an -EncodedCommand (UTF-16,
|
|
565
|
+
* so no path in it is quoted or re-encoded on the way). Its exit code is the
|
|
566
|
+
* sweep's only when the elevated PowerShell ran: a declined prompt is
|
|
567
|
+
* ELEVATION_DECLINED_EXIT_CODE, whether Start-Process says so with an
|
|
568
|
+
* error (stopped here, also when it is not a terminating one) or returns no
|
|
569
|
+
* process at all; never 0, which `exit $null` would give.
|
|
570
|
+
* ELEVATION_NOT_STARTED_EXIT_CODE: Start-Process failed otherwise.
|
|
571
|
+
* `verb: ''` (tests) runs it unelevated.
|
|
572
|
+
*/
|
|
573
|
+
export function buildElevationCommand({ powershellPath, script, verb = 'RunAs' } = {}) {
|
|
574
|
+
const encoded = Buffer.from(String(script), 'utf16le').toString('base64');
|
|
575
|
+
const declined = ELEVATION_DECLINED_EXIT_CODE;
|
|
576
|
+
return [
|
|
577
|
+
'$ErrorActionPreference = \'Stop\'; $p = $null;',
|
|
578
|
+
`try { $p = Start-Process -FilePath ${psLiteral(powershellPath)}${verb ? ` -Verb ${verb}` : ''} -Wait -PassThru -WindowStyle Hidden -ErrorAction Stop`,
|
|
579
|
+
`-ArgumentList '-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}' }`,
|
|
580
|
+
// 1223 is ERROR_CANCELLED; -2147023673 the same as an HRESULT.
|
|
581
|
+
`catch { $e = $_.Exception; while ($e) { if ($e.NativeErrorCode -eq ${declined} -or $e.HResult -eq -2147023673) { exit ${declined} }; $e = $e.InnerException }; exit ${ELEVATION_NOT_STARTED_EXIT_CODE} };`,
|
|
582
|
+
`if (-not $p) { exit ${declined} };`,
|
|
583
|
+
`if ($null -eq $p.ExitCode) { exit ${ELEVATION_UNKNOWN_EXIT_CODE} };`,
|
|
584
|
+
'exit $p.ExitCode',
|
|
585
|
+
].join(' ');
|
|
586
|
+
}
|
|
587
|
+
/** How a task is named where entries are listed (`handMade`, `oar autostart status`). */
|
|
588
|
+
export function bootTaskLabel(name) {
|
|
589
|
+
return `scheduled task \\${name}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The autostart mode out of the sign-in entries (`listEntries()` of the
|
|
594
|
+
* sign-in service) and the boot state (`getState()`), with every entry as
|
|
595
|
+
* `{ mode, path, generated, otherInstall }`; `unknown` when the boot task
|
|
596
|
+
* cannot be read. `oar autostart` and the web UI read it alike.
|
|
597
|
+
*/
|
|
598
|
+
export function describeWindowsAutostart({ signinEntries = [], boot = {} } = {}) {
|
|
599
|
+
const handMade = boot.handMade || [];
|
|
600
|
+
const listed = (entryPath) => handMade.some((entry) => entry.path === entryPath);
|
|
601
|
+
const entries = signinEntries.map((entry) => ({ ...entry, otherInstall: entry.otherInstall || null, mode: 'signin' }));
|
|
602
|
+
const own = bootTaskLabel(boot.taskName || BOOT_TASK_NAME);
|
|
603
|
+
if (boot.taskStatus === 'ready' && !listed(own)) entries.push({ mode: 'boot', path: own, generated: true, otherInstall: null });
|
|
604
|
+
if (boot.launcherPath && !listed(boot.launcherPath)) entries.push({ mode: 'boot', path: boot.launcherPath, generated: true, otherInstall: null });
|
|
605
|
+
entries.push(...handMade.map((entry) => ({ mode: 'boot', path: entry.path, generated: false, otherInstall: entry.otherInstall || null })));
|
|
606
|
+
let mode = entries.some((entry) => entry.mode === 'signin') ? 'signin' : 'off';
|
|
607
|
+
if (boot.taskStatus === 'ready') mode = 'boot';
|
|
608
|
+
if (boot.readError) mode = 'unknown';
|
|
609
|
+
return { mode, entries };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The hand-made entries that stop a switch to `target`: one of another mode
|
|
614
|
+
* would stay beside it, and a hand-made boot entry would be replaced by
|
|
615
|
+
* switching boot on. While there are any, nothing changes (unless --replace).
|
|
616
|
+
*/
|
|
617
|
+
export function switchBlockers({ mode, entries = [] } = {}, target) {
|
|
618
|
+
return entries.filter((entry) => !entry.generated && (entry.mode !== target || (target === 'boot' && mode !== 'boot')));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function describeHandMade(entry) {
|
|
622
|
+
return `${entry.path} (hand-made${entry.otherInstall ? `: it starts the OAR install in ${entry.otherInstall}` : ''})`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* What `oar autostart` prints, and the web UI shows, when hand-made entries
|
|
627
|
+
* stop a switch: what to do first (the UI shortens a long error), then each
|
|
628
|
+
* entry.
|
|
629
|
+
*/
|
|
630
|
+
export function handMadeLines(entries, target) {
|
|
631
|
+
return [
|
|
632
|
+
`Nothing changed: OAR does not delete or overwrite an autostart entry it did not make. Remove it yourself, or replace it keeping a copy: oar autostart ${target} --replace.`,
|
|
633
|
+
...entries.map((entry) => `Left as it is: ${describeHandMade(entry)}.`),
|
|
634
|
+
];
|
|
409
635
|
}
|
|
410
636
|
|
|
411
637
|
/** `whoami /user /fo csv` → the current user's SID. */
|
|
@@ -414,11 +640,30 @@ export function parseSidFromWhoami(stdout) {
|
|
|
414
640
|
return match ? match[1] : null;
|
|
415
641
|
}
|
|
416
642
|
|
|
643
|
+
/** `whoami /user /fo csv` → `{ name, sid }` of the current user, or null. */
|
|
644
|
+
export function parseUserFromWhoami(stdout) {
|
|
645
|
+
const match = String(stdout || '').match(/"([^"\r\n]+)","(S-1-[\d-]+)"/);
|
|
646
|
+
return match ? { name: match[1], sid: match[2] } : null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The command that exports a task's XML with a known encoding: Windows
|
|
651
|
+
* PowerShell, its output switched to UTF-8 (no BOM), the XML written as it
|
|
652
|
+
* is. The name is a literal (psLiteral); our tasks sit in the root folder.
|
|
653
|
+
*/
|
|
654
|
+
export function buildTaskExportCommand(taskName) {
|
|
655
|
+
return [
|
|
656
|
+
"$ErrorActionPreference = 'Stop';",
|
|
657
|
+
'[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false;',
|
|
658
|
+
`[Console]::Out.Write((Export-ScheduledTask -TaskName ${psLiteral(taskName)} -TaskPath '\\'))`,
|
|
659
|
+
].join(' ');
|
|
660
|
+
}
|
|
661
|
+
|
|
417
662
|
// `startError` is set when the program did not run at all (not found, timed
|
|
418
663
|
// out): unlike an exit code, that says nothing about the task.
|
|
419
|
-
function defaultExecFileImpl(file, args) {
|
|
664
|
+
function defaultExecFileImpl(file, args, { timeout = QUERY_TIMEOUT_MS } = {}) {
|
|
420
665
|
return new Promise((resolve) => {
|
|
421
|
-
execFile(file, args, { windowsHide: true, timeout
|
|
666
|
+
execFile(file, args, { windowsHide: true, timeout }, (error, stdout, stderr) => {
|
|
422
667
|
const exitCode = typeof error?.code === 'number' ? error.code : null;
|
|
423
668
|
resolve({
|
|
424
669
|
code: error ? (exitCode ?? 1) : 0,
|
|
@@ -471,8 +716,10 @@ export function createWindowsBootAutostartService({
|
|
|
471
716
|
|
|
472
717
|
/**
|
|
473
718
|
* The task's XML, null when there is no such task, or `{ readError }` when
|
|
474
|
-
*
|
|
475
|
-
*
|
|
719
|
+
* it could not be read: schtasks did not run, it failed on a task that is
|
|
720
|
+
* there (a task only an administrator may read: one storing another
|
|
721
|
+
* account's password), or the export below failed. That says nothing about
|
|
722
|
+
* the task, so it must not read as "missing" (or as anyone's).
|
|
476
723
|
*/
|
|
477
724
|
async function queryTaskXml(taskName) {
|
|
478
725
|
let result;
|
|
@@ -486,7 +733,64 @@ export function createWindowsBootAutostartService({
|
|
|
486
733
|
// The details stay in the log (they can name folders on the host).
|
|
487
734
|
return { readError: 'schtasks could not be run; the relay log has the details.' };
|
|
488
735
|
}
|
|
489
|
-
|
|
736
|
+
if (result?.code !== 0) {
|
|
737
|
+
if (taskFileMissing(taskName)) return null;
|
|
738
|
+
log(`schtasks /query ${taskName} failed (exit code ${result?.code}): ${normalizeText(result?.stderr).split('\n')[0] || 'no detail'}`);
|
|
739
|
+
return { readError: `${bootTaskLabel(taskName)} could not be read; the relay log has the details.` };
|
|
740
|
+
}
|
|
741
|
+
return needsExport(result.stdout) ? exportTaskXml(taskName) : result.stdout;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Whether a task schtasks failed on is not there: its file under
|
|
746
|
+
* %SystemRoot%\System32\Tasks is not found. schtasks exits 1 alike for a
|
|
747
|
+
* missing task and one it may not read, and says which only in the
|
|
748
|
+
* system's language; the file is found (or access to it denied) either way
|
|
749
|
+
* for a task that is there.
|
|
750
|
+
*/
|
|
751
|
+
function taskFileMissing(taskName) {
|
|
752
|
+
try {
|
|
753
|
+
fsImpl.statSync(pathImpl.join(windowsSystemRoot(env), 'System32', 'Tasks', taskName));
|
|
754
|
+
return false;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return error?.code === 'ENOENT';
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* schtasks writes into a pipe in the console's code page, not UTF-8: a
|
|
762
|
+
* character outside ASCII comes out as a byte of that code page (ü as 0x81
|
|
763
|
+
* in 437), as a look-alike (Ł as L) or as `?` (日). Its output is taken as
|
|
764
|
+
* it is only when it is ASCII with no `?` after the declaration (no path
|
|
765
|
+
* holds one) and the OAR folder is ASCII too (else an L may be its Ł).
|
|
766
|
+
*/
|
|
767
|
+
function needsExport(xmlText) {
|
|
768
|
+
const body = String(xmlText || '').replace(/^\s*<\?xml[^>]*\?>/, '');
|
|
769
|
+
return /[^\x00-\x7f]|\?/.test(body) || /[^\x00-\x7f]/.test(oarRoot());
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// One export per task at a time: a poll while one runs shares its result.
|
|
773
|
+
const exportsInFlight = new Map();
|
|
774
|
+
function exportTaskXml(taskName) {
|
|
775
|
+
if (!exportsInFlight.has(taskName)) {
|
|
776
|
+
exportsInFlight.set(taskName, runTaskExport(taskName).finally(() => exportsInFlight.delete(taskName)));
|
|
777
|
+
}
|
|
778
|
+
return exportsInFlight.get(taskName);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// The task's XML through PowerShell (buildTaskExportCommand), or `{ readError }`.
|
|
782
|
+
async function runTaskExport(taskName) {
|
|
783
|
+
let result;
|
|
784
|
+
try {
|
|
785
|
+
result = await execFileImpl(tool('powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', buildTaskExportCommand(taskName)], { timeout: EXPORT_TIMEOUT_MS });
|
|
786
|
+
} catch (error) {
|
|
787
|
+
result = { code: 1, stdout: '', stderr: '', startError: describeExecError(error) };
|
|
788
|
+
}
|
|
789
|
+
const xml = String(result?.stdout || '').replace(/^\ufeff/, '');
|
|
790
|
+
if (result?.code === 0 && !result.startError && /<Task[\s>]/.test(xml)) return xml;
|
|
791
|
+
const why = result?.startError || normalizeText(result?.stderr).split('\n')[0] || `exit code ${result?.code}`;
|
|
792
|
+
log(`the task ${taskName} could not be exported to read its non-ASCII text exactly: ${why}`);
|
|
793
|
+
return { readError: 'the task could not be exported to read its non-ASCII text exactly; the relay log has the details.' };
|
|
490
794
|
}
|
|
491
795
|
|
|
492
796
|
function readLauncher() {
|
|
@@ -542,11 +846,48 @@ export function createWindowsBootAutostartService({
|
|
|
542
846
|
return true;
|
|
543
847
|
}
|
|
544
848
|
|
|
849
|
+
/**
|
|
850
|
+
* A launcher OAR generates or an earlier version generated for this
|
|
851
|
+
* install (whatever Node it runs): one a mode change may delete.
|
|
852
|
+
*/
|
|
853
|
+
function launcherIsGenerated(launcher) {
|
|
854
|
+
try {
|
|
855
|
+
const ownNodePath = readLauncherNodePath(launcher, serverPath());
|
|
856
|
+
if (!ownNodePath) return false;
|
|
857
|
+
if (launcherScriptsMatch(launcher, generatedLauncher({ nodePathOverride: ownNodePath }))) return true;
|
|
858
|
+
return buildEarlierBootLauncherScripts({
|
|
859
|
+
packageRoot,
|
|
860
|
+
nodePath: ownNodePath,
|
|
861
|
+
configPath,
|
|
862
|
+
...resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl }),
|
|
863
|
+
workingDirectory: oarRoot(),
|
|
864
|
+
pathImpl,
|
|
865
|
+
}).some((earlier) => launcherScriptsMatch(launcher, earlier, { ignoreCase: true }));
|
|
866
|
+
} catch {
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// The account this relay runs as (`whoami`), once: our task runs as it.
|
|
872
|
+
let userLookup = null;
|
|
873
|
+
function currentUser() {
|
|
874
|
+
userLookup ||= Promise.resolve()
|
|
875
|
+
.then(() => execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']))
|
|
876
|
+
.then((result) => parseUserFromWhoami(result?.stdout))
|
|
877
|
+
.catch(() => null)
|
|
878
|
+
.then((user) => {
|
|
879
|
+
if (!user) userLookup = null; // asked again next time
|
|
880
|
+
return user;
|
|
881
|
+
});
|
|
882
|
+
return userLookup;
|
|
883
|
+
}
|
|
884
|
+
|
|
545
885
|
/** What differs from the generated task and launcher, for a task that runs our launcher. */
|
|
546
|
-
function customisationsOf(xmlText) {
|
|
886
|
+
async function customisationsOf(xmlText) {
|
|
547
887
|
const differences = describeTaskCustomisations(xmlText, {
|
|
548
888
|
launcherPath: launcherPath(),
|
|
549
889
|
workingDirectory: oarRoot(),
|
|
890
|
+
user: await currentUser(),
|
|
550
891
|
});
|
|
551
892
|
const launcher = readLauncher();
|
|
552
893
|
if (launcher === null) differences.push('launcher-missing');
|
|
@@ -563,27 +904,82 @@ export function createWindowsBootAutostartService({
|
|
|
563
904
|
return differences;
|
|
564
905
|
}
|
|
565
906
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
907
|
+
/**
|
|
908
|
+
* The boot entries OAR did not generate, which no mode change deletes or
|
|
909
|
+
* writes over: a task under our name that runs something else, our task or
|
|
910
|
+
* launcher changed by hand (a missing launcher is no change), the
|
|
911
|
+
* pre-rebrand task, a hand-made launcher without our task.
|
|
912
|
+
*/
|
|
913
|
+
// Each `{ path, task, otherInstall }`: `task` names a scheduled task,
|
|
914
|
+
// `otherInstall` the package folder of another OAR install a launcher starts.
|
|
915
|
+
function handMadeEntries({ taskStatus, customisations = [], legacyTaskPresent }) {
|
|
916
|
+
const entries = [];
|
|
917
|
+
const task = (name) => ({ path: bootTaskLabel(name), task: name, otherInstall: null });
|
|
918
|
+
if (taskStatus === 'foreign') entries.push(task(BOOT_TASK_NAME));
|
|
919
|
+
if (taskStatus === 'ready' && customisations.some((name) => name !== 'launcher' && name !== 'launcher-missing')) {
|
|
920
|
+
entries.push(task(BOOT_TASK_NAME));
|
|
921
|
+
}
|
|
922
|
+
if (legacyTaskPresent) entries.push(task(LEGACY_BOOT_TASK_NAME));
|
|
923
|
+
const launcher = readLauncher();
|
|
924
|
+
if (launcher !== null && !launcherIsGenerated(launcher)) {
|
|
925
|
+
entries.push({ path: launcherPath(), task: null, otherInstall: otherInstallOf(launcher, packageRoot, pathImpl) });
|
|
926
|
+
}
|
|
927
|
+
return entries;
|
|
572
928
|
}
|
|
573
929
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
930
|
+
/**
|
|
931
|
+
* Before `replace` deletes or writes over hand-made boot entries: a copy
|
|
932
|
+
* of each, `<name>.bak` next to the launcher, and of each task its
|
|
933
|
+
* exported XML (`schtasks /create /xml` takes it back) in the OAR folder.
|
|
934
|
+
* The copies, each `{ path, backup }`, or null when one could not be written.
|
|
935
|
+
*/
|
|
936
|
+
async function backUpHandMade(entries) {
|
|
937
|
+
const copies = [];
|
|
938
|
+
try {
|
|
939
|
+
for (const entry of entries) {
|
|
940
|
+
let contents;
|
|
941
|
+
let target = entry.path;
|
|
942
|
+
if (entry.task) {
|
|
943
|
+
const xml = await queryTaskXml(entry.task);
|
|
944
|
+
if (xml === null) continue;
|
|
945
|
+
// Unread is no copy: nothing may be deleted without one.
|
|
946
|
+
if (typeof xml !== 'string') throw new Error(`${entry.path}: ${xml.readError}`);
|
|
947
|
+
contents = encodeTaskXml(xml);
|
|
948
|
+
target = pathImpl.join(oarRoot(), `${entry.task}.task.xml`);
|
|
949
|
+
} else {
|
|
950
|
+
contents = readLauncher();
|
|
951
|
+
if (contents === null) continue;
|
|
581
952
|
}
|
|
582
|
-
|
|
583
|
-
|
|
953
|
+
const copy = backupPathFor(target, oarRoot(), fsImpl, pathImpl);
|
|
954
|
+
fsImpl.mkdirSync(oarRoot(), { recursive: true });
|
|
955
|
+
fsImpl.writeFileSync(copy, contents);
|
|
956
|
+
copies.push({ path: entry.path, backup: copy });
|
|
957
|
+
log(`kept a copy of ${entry.path} as ${copy}`);
|
|
584
958
|
}
|
|
959
|
+
return copies;
|
|
960
|
+
} catch (error) {
|
|
961
|
+
log(`could not keep a copy before replacing: ${error?.message || error}`);
|
|
962
|
+
removeFiles(copies);
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function removeFiles(copies) {
|
|
968
|
+
for (const { backup } of copies) {
|
|
969
|
+
try { fsImpl.unlinkSync(backup); } catch {}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// The sign-in entries of this install, which boot mode replaces.
|
|
974
|
+
const signin = createWindowsAutostartService({ platform, env, packageRoot, nodePath, configPath, fsImpl, pathImpl });
|
|
975
|
+
|
|
976
|
+
function removeStartupCmds() {
|
|
977
|
+
try {
|
|
978
|
+
return signin.setEnabled(false).kept;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
log(`could not remove the sign-in entries: ${error?.message || error}`);
|
|
981
|
+
return [];
|
|
585
982
|
}
|
|
586
|
-
return removed;
|
|
587
983
|
}
|
|
588
984
|
|
|
589
985
|
function manualCommandFor(action) {
|
|
@@ -611,6 +1007,8 @@ export function createWindowsBootAutostartService({
|
|
|
611
1007
|
taskName: BOOT_TASK_NAME,
|
|
612
1008
|
legacyTaskPresent: false,
|
|
613
1009
|
launcherPath: null,
|
|
1010
|
+
generated: false,
|
|
1011
|
+
handMade: [],
|
|
614
1012
|
pendingElevation: null,
|
|
615
1013
|
lastError: null,
|
|
616
1014
|
manualCommand: manualCommandFor('enable'),
|
|
@@ -618,18 +1016,20 @@ export function createWindowsBootAutostartService({
|
|
|
618
1016
|
}
|
|
619
1017
|
const ownXml = await queryTaskXml(BOOT_TASK_NAME);
|
|
620
1018
|
const legacyXml = await queryTaskXml(LEGACY_BOOT_TASK_NAME);
|
|
621
|
-
|
|
1019
|
+
// Either task unread: nothing may be taken out or replaced (mode unknown).
|
|
1020
|
+
const readError = ownXml?.readError || legacyXml?.readError || null;
|
|
622
1021
|
const legacyKnown = typeof legacyXml === 'string';
|
|
623
1022
|
let taskStatus;
|
|
624
|
-
if (readError) taskStatus = 'unknown';
|
|
1023
|
+
if (ownXml?.readError) taskStatus = 'unknown';
|
|
625
1024
|
else if (ownXml === null) taskStatus = legacyKnown ? 'legacy' : 'missing';
|
|
626
1025
|
else taskStatus = classifyTaskXml(ownXml, { launcherPath: launcherPath() });
|
|
627
1026
|
// A task that runs our launcher but is not quite the one OAR registers (no
|
|
628
1027
|
// boot delay, an edited launcher) still is boot mode; it is reported,
|
|
629
1028
|
// never failed on.
|
|
630
|
-
const customisations = taskStatus === 'ready' ? customisationsOf(ownXml) : [];
|
|
1029
|
+
const customisations = taskStatus === 'ready' ? await customisationsOf(ownXml) : [];
|
|
631
1030
|
let launcherPresent = false;
|
|
632
1031
|
try { launcherPresent = fsImpl.existsSync(launcherPath()); } catch {}
|
|
1032
|
+
const handMade = handMadeEntries({ taskStatus, customisations, legacyTaskPresent: legacyKnown });
|
|
633
1033
|
return {
|
|
634
1034
|
supported: true,
|
|
635
1035
|
platform,
|
|
@@ -639,6 +1039,10 @@ export function createWindowsBootAutostartService({
|
|
|
639
1039
|
launcherPath: launcherPresent ? launcherPath() : null,
|
|
640
1040
|
customised: customisations.length > 0,
|
|
641
1041
|
customisations,
|
|
1042
|
+
// Our task as OAR registers it, with a launcher OAR generated (or
|
|
1043
|
+
// none): the one boot entry a mode change may delete.
|
|
1044
|
+
generated: taskStatus === 'ready' && !handMade.some((entry) => entry.task === BOOT_TASK_NAME || entry.path === launcherPath()),
|
|
1045
|
+
handMade,
|
|
642
1046
|
launcherBackupPath,
|
|
643
1047
|
readError,
|
|
644
1048
|
pendingElevation: pending ? pending.action : null,
|
|
@@ -654,61 +1058,163 @@ export function createWindowsBootAutostartService({
|
|
|
654
1058
|
fsImpl.renameSync(temporaryPath, targetPath);
|
|
655
1059
|
}
|
|
656
1060
|
|
|
657
|
-
function settlePending(outcome) {
|
|
658
|
-
if (!pending) return;
|
|
659
|
-
if (pending.timer) clearTimeoutImpl(pending.timer);
|
|
660
|
-
pending = null;
|
|
661
|
-
lastError = outcome || null;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
1061
|
/**
|
|
665
|
-
* The elevated child is powershell running Start-Process -Verb RunAs -Wait
|
|
666
|
-
* the outer process lives exactly as long as the
|
|
667
|
-
*
|
|
1062
|
+
* The elevated child is powershell running Start-Process -Verb RunAs -Wait
|
|
1063
|
+
* (buildElevationCommand): the outer process lives exactly as long as the
|
|
1064
|
+
* UAC prompt plus the sweep, so its exit is the signal. `onOutcome('ok' |
|
|
1065
|
+
* 'declined' | 'failed', detail)`: a declined prompt changed nothing; a
|
|
1066
|
+
* failed sweep may have run (it puts back what it deleted), and a timeout
|
|
1067
|
+
* leaves the prompt open, so both keep every copy.
|
|
668
1068
|
*/
|
|
669
|
-
function spawnElevated(action,
|
|
1069
|
+
function spawnElevated(action, script, onOutcome) {
|
|
670
1070
|
const powershell = tool('powershell.exe');
|
|
671
1071
|
const child = spawnImpl(powershell, [
|
|
672
1072
|
'-NoProfile',
|
|
673
1073
|
'-NonInteractive',
|
|
674
1074
|
'-Command',
|
|
675
|
-
|
|
676
|
-
`$p = Start-Process -FilePath '${powershell.replaceAll("'", "''")}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList`,
|
|
677
|
-
`'-NoProfile','-ExecutionPolicy','Bypass','-File','${elevationScriptPath()}'`,
|
|
678
|
-
'; exit $p.ExitCode',
|
|
679
|
-
].join(' '),
|
|
1075
|
+
buildElevationCommand({ powershellPath: powershell, script }),
|
|
680
1076
|
], {
|
|
681
1077
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
682
1078
|
windowsHide: true,
|
|
683
1079
|
});
|
|
684
1080
|
let stderr = '';
|
|
685
1081
|
child.stderr?.on?.('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-2000); });
|
|
1082
|
+
const settle = (outcome, detail) => {
|
|
1083
|
+
if (!pending) return; // settled already
|
|
1084
|
+
if (pending.timer) clearTimeoutImpl(pending.timer);
|
|
1085
|
+
pending = null;
|
|
1086
|
+
onOutcome(outcome, detail);
|
|
1087
|
+
};
|
|
686
1088
|
const timer = setTimeoutImpl(() => {
|
|
687
1089
|
try { child.kill('SIGTERM'); } catch {}
|
|
688
|
-
|
|
689
|
-
onSettled?.(false);
|
|
1090
|
+
settle('failed', 'no answer at the administrator prompt within 2 minutes');
|
|
690
1091
|
}, ELEVATION_TIMEOUT_MS);
|
|
691
1092
|
timer?.unref?.();
|
|
692
1093
|
pending = { action, startedAt: now(), timer, child };
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
});
|
|
1094
|
+
// Nothing elevated ran when the prompt was declined, or when neither
|
|
1095
|
+
// PowerShell could be started: those put everything back.
|
|
1096
|
+
child.on('error', (error) => settle('declined', `elevation could not be requested: ${error?.message || error}`));
|
|
697
1097
|
child.on('close', (code) => {
|
|
698
|
-
if (
|
|
699
|
-
if (code ===
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
} else {
|
|
703
|
-
// 1 from Start-Process usually means the UAC prompt was declined.
|
|
704
|
-
const detail = normalizeText(stderr).split('\n')[0] || `exit code ${code}`;
|
|
705
|
-
settlePending(`${action} was not confirmed (${detail}). Run the command below from an elevated shell instead.`);
|
|
706
|
-
onSettled?.(false);
|
|
707
|
-
}
|
|
1098
|
+
if (code === 0) settle('ok');
|
|
1099
|
+
else if (code === ELEVATION_DECLINED_EXIT_CODE) settle('declined');
|
|
1100
|
+
else if (code === ELEVATION_NOT_STARTED_EXIT_CODE) settle('declined', `the elevated step could not be started: ${normalizeText(stderr).split('\n')[0] || 'no detail'}`);
|
|
1101
|
+
else settle('failed', code === SWEEP_FAILED_EXIT_CODE ? 'schtasks failed' : (normalizeText(stderr).split('\n')[0] || `exit code ${code}`));
|
|
708
1102
|
});
|
|
709
1103
|
}
|
|
710
1104
|
|
|
711
|
-
|
|
1105
|
+
const COPY_FAILED = 'Could not keep a copy of an entry before changing it, so nothing was changed; the relay log has the details.';
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* A copy of each entry before a sweep deletes it or the launcher is
|
|
1109
|
+
* written over: `<name>.bak` beside the launcher, a task's exported XML
|
|
1110
|
+
* (`schtasks /create /xml` takes it back) in the OAR folder. Each copy is
|
|
1111
|
+
* `{ path, backup, task, generated }`; null when one could not be written.
|
|
1112
|
+
*/
|
|
1113
|
+
async function copyEntries(entries) {
|
|
1114
|
+
const copies = [];
|
|
1115
|
+
try {
|
|
1116
|
+
for (const entry of entries) {
|
|
1117
|
+
let contents = entry.contents;
|
|
1118
|
+
let target = entry.path;
|
|
1119
|
+
if (entry.task) {
|
|
1120
|
+
const xml = await queryTaskXml(entry.task);
|
|
1121
|
+
if (xml === null) continue;
|
|
1122
|
+
// Unread is no copy: nothing may be deleted without one.
|
|
1123
|
+
if (typeof xml !== 'string') throw new Error(`${entry.path}: ${xml.readError}`);
|
|
1124
|
+
contents = encodeTaskXml(xml);
|
|
1125
|
+
target = pathImpl.join(oarRoot(), `${entry.task}.task.xml`);
|
|
1126
|
+
}
|
|
1127
|
+
if (contents == null) continue;
|
|
1128
|
+
const backup = backupPathFor(target, oarRoot(), fsImpl, pathImpl);
|
|
1129
|
+
fsImpl.mkdirSync(oarRoot(), { recursive: true });
|
|
1130
|
+
fsImpl.writeFileSync(backup, contents);
|
|
1131
|
+
copies.push({ path: entry.path, backup, task: entry.task || null, generated: Boolean(entry.generated) });
|
|
1132
|
+
log(`kept a copy of ${entry.path} as ${backup}`);
|
|
1133
|
+
}
|
|
1134
|
+
return copies;
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
log(`could not keep a copy before changing an entry: ${error?.message || error}`);
|
|
1137
|
+
removeFiles(copies);
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// The tasks a sweep deletes, generated or not (`state.handMade`).
|
|
1143
|
+
function tasksToCopy(state, names) {
|
|
1144
|
+
return names.map((name) => ({
|
|
1145
|
+
path: bootTaskLabel(name),
|
|
1146
|
+
task: name,
|
|
1147
|
+
generated: !state.handMade.some((entry) => entry.task === name),
|
|
1148
|
+
}));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function restoreLauncher(previous) {
|
|
1152
|
+
try {
|
|
1153
|
+
if (previous === null) fsImpl.unlinkSync(launcherPath());
|
|
1154
|
+
else writeFileAtomic(launcherPath(), previous);
|
|
1155
|
+
return true;
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
log(`could not put the launcher back: ${error?.message || error}`);
|
|
1158
|
+
return previous === null;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Each copied task as read afterwards: one that could not be read is not
|
|
1163
|
+
// said to be there, or gone.
|
|
1164
|
+
async function tasksAfterwards(copies) {
|
|
1165
|
+
const lines = [];
|
|
1166
|
+
for (const copy of copies.filter((entry) => entry.task)) {
|
|
1167
|
+
const xml = await queryTaskXml(copy.task);
|
|
1168
|
+
if (typeof xml === 'string') lines.push(`${copy.path} is registered`);
|
|
1169
|
+
else if (xml === null) lines.push(`${copy.path} is not registered`);
|
|
1170
|
+
else lines.push(`${copy.path} could not be read, so whether it is registered is not known`);
|
|
1171
|
+
}
|
|
1172
|
+
return lines.length ? ` Afterwards: ${lines.join('; ')}.` : '';
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async function failureText(action, detail, copies) {
|
|
1176
|
+
const what = action === 'enable' ? 'registered' : 'removed';
|
|
1177
|
+
const kept = copies.length ? ` Copies kept: ${copies.map((copy) => copy.backup).join(', ')}.` : '';
|
|
1178
|
+
return `The boot task could not be ${what} (${detail}).${await tasksAfterwards(copies)}${kept} Run the command below from an elevated shell instead.`;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Confirmed (exit 0), but the task could not be read back: nothing is
|
|
1182
|
+
// undone or claimed, and every file stays.
|
|
1183
|
+
function unverifiedText(action, readError, copies) {
|
|
1184
|
+
const what = action === 'enable' ? 'registered' : 'removed';
|
|
1185
|
+
const kept = copies.length ? ` Copies kept: ${copies.map((copy) => copy.backup).join(', ')}.` : '';
|
|
1186
|
+
return `The administrator prompt was confirmed and the boot task should be ${what}, but it could not be read back to check (${readError}). Nothing was undone; the launcher stays.${kept}`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* After a declined prompt nothing ran: a copy goes once what it copies is
|
|
1191
|
+
* verified in place (a task still registered, the launcher put back).
|
|
1192
|
+
*/
|
|
1193
|
+
async function dropCopiesAfterDecline(copies, launcherBack) {
|
|
1194
|
+
const left = [];
|
|
1195
|
+
for (const copy of copies) {
|
|
1196
|
+
const inPlace = copy.task ? typeof (await queryTaskXml(copy.task)) === 'string' : launcherBack;
|
|
1197
|
+
if (inPlace) removeFiles([copy]);
|
|
1198
|
+
else left.push(copy);
|
|
1199
|
+
}
|
|
1200
|
+
return left;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Registers our task, elevated. Refused while a boot entry OAR did not
|
|
1205
|
+
* make is there (`handMade`: a task under our name or the pre-rebrand
|
|
1206
|
+
* one, our task or launcher changed by hand), unless `replace`. Every task
|
|
1207
|
+
* the sweep deletes and a launcher it writes over are copied first
|
|
1208
|
+
* (copyEntries). `onSettled(ok, { kept, copies, declined, error })` once
|
|
1209
|
+
* the elevation ends: `kept` the hand-made sign-in entries a confirmed
|
|
1210
|
+
* enable left, `copies` the copies still there. Confirmed and verified
|
|
1211
|
+
* (our task is registered), the copies of generated entries go and those
|
|
1212
|
+
* of hand-made ones stay; declined, the launcher is put back and the
|
|
1213
|
+
* copies go; failed, the launcher is put back and every copy stays.
|
|
1214
|
+
* Confirmed but our task not readable afterwards (`unverified`): nothing
|
|
1215
|
+
* is undone, every file stays, and it is no success.
|
|
1216
|
+
*/
|
|
1217
|
+
async function requestEnable({ onSettled, replace = false } = {}) {
|
|
712
1218
|
if (!supported) {
|
|
713
1219
|
throw new Error('Windows boot autostart is only available on Windows.');
|
|
714
1220
|
}
|
|
@@ -718,51 +1224,101 @@ export function createWindowsBootAutostartService({
|
|
|
718
1224
|
if (pending) {
|
|
719
1225
|
return { ...(await getState()), accepted: false };
|
|
720
1226
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
if (previous !== null && !launcherScriptsMatch(previous, launcher)) {
|
|
725
|
-
// An edited (or older) launcher is replaced, but kept: one copy, the
|
|
726
|
-
// latest replaced one.
|
|
727
|
-
const backupPath = `${launcherPath()}${BOOT_LAUNCHER_BACKUP_SUFFIX}`;
|
|
728
|
-
try {
|
|
729
|
-
fsImpl.writeFileSync(backupPath, previous);
|
|
730
|
-
launcherBackupPath = backupPath;
|
|
731
|
-
log(`the launcher differed from the generated one; saved it as ${backupPath}`);
|
|
732
|
-
} catch (error) {
|
|
733
|
-
log(`could not save the current launcher as ${backupPath}: ${error?.message || error}`);
|
|
734
|
-
lastError = 'Could not keep a copy of the current launcher next to it, so it was not replaced; the relay log has the details.';
|
|
735
|
-
return { ...(await getState()), accepted: false };
|
|
736
|
-
}
|
|
1227
|
+
const before = await getState();
|
|
1228
|
+
if (before.readError) {
|
|
1229
|
+
return { ...before, accepted: false, refused: `the boot task could not be read (${before.readError})` };
|
|
737
1230
|
}
|
|
738
|
-
|
|
1231
|
+
if (before.handMade.length && !replace) {
|
|
1232
|
+
return { ...before, accepted: false, refused: handMadeLines(before.handMade, 'boot').join(' ') };
|
|
1233
|
+
}
|
|
1234
|
+
lastError = null;
|
|
739
1235
|
const who = await execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']);
|
|
740
1236
|
const sid = parseSidFromWhoami(who.stdout);
|
|
741
1237
|
if (!sid) {
|
|
742
1238
|
lastError = 'Could not determine the current user SID (whoami /user failed).';
|
|
743
1239
|
return { ...(await getState()), accepted: false };
|
|
744
1240
|
}
|
|
1241
|
+
const launcher = generatedLauncher();
|
|
1242
|
+
const previous = readLauncher();
|
|
1243
|
+
const deleted = [
|
|
1244
|
+
...(['ready', 'foreign'].includes(before.taskStatus) ? [BOOT_TASK_NAME] : []),
|
|
1245
|
+
...(before.legacyTaskPresent ? [LEGACY_BOOT_TASK_NAME] : []),
|
|
1246
|
+
];
|
|
1247
|
+
const copies = await copyEntries([
|
|
1248
|
+
...tasksToCopy(before, deleted),
|
|
1249
|
+
...(previous !== null && !launcherScriptsMatch(previous, launcher)
|
|
1250
|
+
? [{ path: launcherPath(), contents: previous, generated: launcherIsGenerated(previous) }]
|
|
1251
|
+
: []),
|
|
1252
|
+
]);
|
|
1253
|
+
if (copies === null) {
|
|
1254
|
+
lastError = COPY_FAILED;
|
|
1255
|
+
return { ...(await getState()), accepted: false };
|
|
1256
|
+
}
|
|
1257
|
+
launcherBackupPath = copies.find((copy) => copy.path === launcherPath())?.backup || null;
|
|
1258
|
+
const putBackFiles = snapshotFiles([xmlPath()]);
|
|
1259
|
+
writeFileAtomic(launcherPath(), generatedLauncher({ coverBytes: previous === null ? 0 : Buffer.byteLength(previous, 'utf8') }));
|
|
745
1260
|
writeFileAtomic(xmlPath(), encodeTaskXml(buildBootTaskXml({
|
|
746
1261
|
sid,
|
|
747
1262
|
launcherPath: launcherPath(),
|
|
748
1263
|
packageRoot: pathImpl.resolve(String(packageRoot)),
|
|
749
1264
|
workingDirectory: oarRoot(),
|
|
750
1265
|
})));
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1266
|
+
const script = buildElevationScript({
|
|
1267
|
+
action: 'enable',
|
|
1268
|
+
xmlPath: xmlPath(),
|
|
1269
|
+
schtasksPath: tool('schtasks'),
|
|
1270
|
+
restore: copies.filter((copy) => copy.task).map((copy) => ({ task: copy.task, xml: copy.backup })),
|
|
1271
|
+
});
|
|
1272
|
+
spawnElevated('enable', script, async (outcome, detail) => {
|
|
1273
|
+
let kept = [];
|
|
1274
|
+
let left = copies;
|
|
1275
|
+
const after = outcome === 'ok' ? await getState() : null;
|
|
1276
|
+
if (after?.taskStatus === 'unknown') {
|
|
1277
|
+
// Registered, as far as the sweep knows; only a task known to be
|
|
1278
|
+
// missing or someone else's undoes the launcher.
|
|
1279
|
+
writeScriptFile(script);
|
|
1280
|
+
lastError = unverifiedText('enable', after.readError, copies);
|
|
1281
|
+
log(`enable not verified: ${lastError}`);
|
|
1282
|
+
onSettled?.(false, { kept, copies, unverified: true, reason: null, error: lastError });
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (after?.taskStatus === 'ready') {
|
|
1286
|
+
removeFiles(copies.filter((copy) => copy.generated));
|
|
1287
|
+
left = copies.filter((copy) => !copy.generated);
|
|
754
1288
|
// Mutual exclusion: boot mode owns startup now, so the sign-in
|
|
755
|
-
// entries
|
|
756
|
-
|
|
757
|
-
|
|
1289
|
+
// entries OAR generated must not race it at the next logon.
|
|
1290
|
+
kept = removeStartupCmds();
|
|
1291
|
+
writeScriptFile(script);
|
|
1292
|
+
log(`enabled; ${kept.length ? `left ${kept.length} hand-made sign-in entr${kept.length === 1 ? 'y' : 'ies'}` : 'no sign-in entry left'}`);
|
|
1293
|
+
onSettled?.(true, { kept, copies: left });
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const launcherBack = restoreLauncher(previous);
|
|
1297
|
+
if (outcome === 'declined') {
|
|
1298
|
+
putBackFiles();
|
|
1299
|
+
left = await dropCopiesAfterDecline(copies, launcherBack);
|
|
1300
|
+
lastError = declineText(detail);
|
|
758
1301
|
} else {
|
|
759
|
-
|
|
1302
|
+
lastError = await failureText('enable', outcome === 'ok' ? 'it is not registered afterwards' : detail, copies);
|
|
760
1303
|
}
|
|
1304
|
+
launcherBackupPath = null;
|
|
1305
|
+
log(`enable did not complete: ${lastError}`);
|
|
1306
|
+
onSettled?.(false, { kept, copies: left, declined: outcome === 'declined', reason: detail || null, error: lastError });
|
|
761
1307
|
});
|
|
762
1308
|
return { ...(await getState()), accepted: true };
|
|
763
1309
|
}
|
|
764
1310
|
|
|
765
|
-
|
|
1311
|
+
/**
|
|
1312
|
+
* Deletes our task, elevated, and then its launcher; only when both are as
|
|
1313
|
+
* OAR generates them (`generated`). Anything hand-made (`handMade`) stays
|
|
1314
|
+
* as it is: then nothing is deleted and nothing elevated. With `replace`
|
|
1315
|
+
* the hand-made ones go too (our task, the pre-rebrand one, the launcher).
|
|
1316
|
+
* The tasks are copied first, as for requestEnable, and so is a hand-made
|
|
1317
|
+
* launcher; the launcher goes only once the tasks are verified gone (not
|
|
1318
|
+
* readable afterwards: `unverified`, everything stays).
|
|
1319
|
+
* `onSettled(ok, { copies, declined, unverified, error })`.
|
|
1320
|
+
*/
|
|
1321
|
+
async function requestDisable({ onSettled, replace = false } = {}) {
|
|
766
1322
|
if (!supported) {
|
|
767
1323
|
throw new Error('Windows boot autostart is only available on Windows.');
|
|
768
1324
|
}
|
|
@@ -772,24 +1328,117 @@ export function createWindowsBootAutostartService({
|
|
|
772
1328
|
if (pending) {
|
|
773
1329
|
return { ...(await getState()), accepted: false };
|
|
774
1330
|
}
|
|
1331
|
+
const before = await getState();
|
|
1332
|
+
if (before.readError) {
|
|
1333
|
+
return { ...before, accepted: false, refused: `the boot task could not be read (${before.readError})` };
|
|
1334
|
+
}
|
|
1335
|
+
if (!before.generated && !(replace && before.handMade.length)) return { ...before, accepted: false };
|
|
775
1336
|
lastError = null;
|
|
776
1337
|
launcherBackupPath = null;
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1338
|
+
const deleted = [
|
|
1339
|
+
...(['ready', 'foreign'].includes(before.taskStatus) ? [BOOT_TASK_NAME] : []),
|
|
1340
|
+
...(replace && before.legacyTaskPresent ? [LEGACY_BOOT_TASK_NAME] : []),
|
|
1341
|
+
];
|
|
1342
|
+
const launcherHandMade = before.handMade.some((entry) => entry.path === launcherPath());
|
|
1343
|
+
const copies = await copyEntries([
|
|
1344
|
+
...tasksToCopy(before, deleted),
|
|
1345
|
+
...(replace && launcherHandMade ? [{ path: launcherPath(), contents: readLauncher(), generated: false }] : []),
|
|
1346
|
+
]);
|
|
1347
|
+
if (copies === null) {
|
|
1348
|
+
lastError = COPY_FAILED;
|
|
1349
|
+
return { ...(await getState()), accepted: false };
|
|
1350
|
+
}
|
|
1351
|
+
const removeLauncher = () => {
|
|
1352
|
+
const launcher = readLauncher();
|
|
1353
|
+
try {
|
|
1354
|
+
// Unless it was changed meanwhile, or is a copy's original.
|
|
1355
|
+
if (launcher !== null && (launcherIsGenerated(launcher) || copies.some((copy) => copy.path === launcherPath()))) {
|
|
1356
|
+
fsImpl.unlinkSync(launcherPath());
|
|
784
1357
|
}
|
|
785
|
-
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
log(`could not remove launcher: ${error?.message || error}`);
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
if (!deleted.length) {
|
|
1363
|
+
// Only a launcher no task runs: nothing to elevate.
|
|
1364
|
+
removeLauncher();
|
|
1365
|
+
onSettled?.(true, { copies });
|
|
1366
|
+
return { ...(await getState()), accepted: true };
|
|
1367
|
+
}
|
|
1368
|
+
const script = buildElevationScript({
|
|
1369
|
+
action: 'disable',
|
|
1370
|
+
schtasksPath: tool('schtasks'),
|
|
1371
|
+
includeLegacy: deleted.includes(LEGACY_BOOT_TASK_NAME),
|
|
1372
|
+
restore: copies.filter((copy) => copy.task).map((copy) => ({ task: copy.task, xml: copy.backup })),
|
|
1373
|
+
});
|
|
1374
|
+
spawnElevated('disable', script, async (outcome, detail) => {
|
|
1375
|
+
let left = copies;
|
|
1376
|
+
if (outcome === 'ok') {
|
|
1377
|
+
const after = await getState();
|
|
1378
|
+
if (after.readError) {
|
|
1379
|
+
// Not read back: never claimed removed; the launcher and every copy stay.
|
|
1380
|
+
lastError = unverifiedText('disable', after.readError, copies);
|
|
1381
|
+
log(`disable not verified: ${lastError}`);
|
|
1382
|
+
onSettled?.(false, { copies, unverified: true, reason: null, error: lastError });
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
const gone = !['ready', 'foreign'].includes(after.taskStatus) && !(deleted.includes(LEGACY_BOOT_TASK_NAME) && after.legacyTaskPresent);
|
|
1386
|
+
if (gone) {
|
|
1387
|
+
removeLauncher();
|
|
1388
|
+
// Out of boot mode, the task XML and the record of the last sweep go too.
|
|
1389
|
+
for (const file of [xmlPath(), elevationScriptPath()]) {
|
|
1390
|
+
try { if (fsImpl.existsSync(file)) fsImpl.unlinkSync(file); } catch {}
|
|
1391
|
+
}
|
|
1392
|
+
removeFiles(copies.filter((copy) => copy.generated));
|
|
1393
|
+
left = copies.filter((copy) => !copy.generated);
|
|
1394
|
+
log('disabled');
|
|
1395
|
+
onSettled?.(true, { copies: left });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
if (outcome === 'declined') {
|
|
1400
|
+
left = await dropCopiesAfterDecline(copies, true);
|
|
1401
|
+
lastError = declineText(detail);
|
|
786
1402
|
} else {
|
|
787
|
-
|
|
1403
|
+
lastError = await failureText('disable', outcome === 'ok' ? 'it is still registered afterwards' : detail, copies);
|
|
788
1404
|
}
|
|
1405
|
+
log(`disable did not complete: ${lastError}`);
|
|
1406
|
+
onSettled?.(false, { copies: left, declined: outcome === 'declined', reason: detail || null, error: lastError });
|
|
789
1407
|
});
|
|
790
1408
|
return { ...(await getState()), accepted: true };
|
|
791
1409
|
}
|
|
792
1410
|
|
|
1411
|
+
// The files as they are now; the returned function puts them back (after
|
|
1412
|
+
// a declined prompt nothing of the request is left).
|
|
1413
|
+
function snapshotFiles(files) {
|
|
1414
|
+
const saved = files.map((file) => {
|
|
1415
|
+
try { return { file, contents: fsImpl.existsSync(file) ? fsImpl.readFileSync(file) : null }; } catch { return { file, contents: null }; }
|
|
1416
|
+
});
|
|
1417
|
+
return () => {
|
|
1418
|
+
for (const { file, contents } of saved) {
|
|
1419
|
+
try {
|
|
1420
|
+
if (contents === null) { if (fsImpl.existsSync(file)) fsImpl.unlinkSync(file); } else fsImpl.writeFileSync(file, contents);
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
log(`could not put back ${file}: ${error?.message || error}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function declineText(detail) {
|
|
1429
|
+
return detail ? `Nothing was changed: ${detail}.` : 'The administrator prompt was declined; nothing was changed.';
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// A record of the last confirmed sweep beside the task XML (none before
|
|
1433
|
+
// the prompt is answered, so a declined one leaves none), UTF-8 with a BOM so
|
|
1434
|
+
// Windows PowerShell reads a non-ASCII path in it right when run by hand.
|
|
1435
|
+
function writeScriptFile(script) {
|
|
1436
|
+
try {
|
|
1437
|
+
writeFileAtomic(elevationScriptPath(), `${script}`);
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
log(`could not write ${elevationScriptPath()}: ${error?.message || error}`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
793
1442
|
return {
|
|
794
1443
|
getState,
|
|
795
1444
|
requestEnable,
|