@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.7.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.
@@ -0,0 +1,1333 @@
1
+ /**
2
+ * Device X action engine - Windows implementation.
3
+ *
4
+ * Same frozen surface as `actions-darwin.js`: `GROUPS`, `actionCatalog()`,
5
+ * `actionHistory()`, `runAction()`. Every action validates in JavaScript and
6
+ * then runs one constant PowerShell script through `./win.js`; parameters reach
7
+ * PowerShell only as `DEVICEX_ARG_*` environment variables and are never
8
+ * interpolated into script text (no shell, no `Invoke-Expression`).
9
+ *
10
+ * @module @cfour-hi/dsh-plugin-devicex/host/actions-win32
11
+ */
12
+ import { statSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { isIP } from 'node:net';
15
+ import { resolve } from 'node:path';
16
+ import { cleanupItems, deleteCleanup } from './cleanup-win32.js';
17
+ import { runPS, runPSJson, psString } from './win.js';
18
+
19
+ /** @typedef {import('../../types/contracts.js').Win32ActionParamsById} Win32Params */
20
+ /** @typedef {import('../../types/contracts.js').Win32Signal} Win32Signal */
21
+ /** @typedef {import('../../types/contracts.js').ActionResult} ActionResult */
22
+
23
+ /** Action groups, in UI order. */
24
+ export const GROUPS = ['process', 'app', 'system', 'network', 'software', 'power'];
25
+
26
+ /** Maximum retained history entries. */
27
+ const HISTORY_LIMIT = 50;
28
+
29
+ /** Shared PowerShell preamble for every action script. */
30
+ const PS_HEAD = "$ErrorActionPreference='Stop';";
31
+
32
+ /** Panes accepted by `system.openSettings` (Windows `ms-settings:` URIs). */
33
+ const SETTINGS_PANES = [
34
+ 'display',
35
+ 'sound',
36
+ 'bluetooth',
37
+ 'apps',
38
+ 'privacy',
39
+ 'network',
40
+ 'wifi',
41
+ 'windowsupdate',
42
+ 'windowsdefender',
43
+ 'storage',
44
+ 'battery',
45
+ 'power',
46
+ 'users',
47
+ 'datetime',
48
+ 'language',
49
+ 'defaultapps',
50
+ 'startup',
51
+ ];
52
+
53
+ /** Frozen pane name -> Windows `ms-settings:` page slug for `system.openSettings`. */
54
+ const PANE_URIS = {
55
+ display: 'display',
56
+ sound: 'sound',
57
+ bluetooth: 'bluetooth',
58
+ apps: 'appsfeatures',
59
+ privacy: 'privacy',
60
+ network: 'network',
61
+ wifi: 'network-wifi',
62
+ windowsupdate: 'windowsupdate',
63
+ windowsdefender: 'windowsdefender',
64
+ storage: 'storagesense',
65
+ battery: 'batterysaver',
66
+ power: 'powersleep',
67
+ users: 'otherusers',
68
+ datetime: 'dateandtime',
69
+ language: 'regionlanguage',
70
+ defaultapps: 'defaultapps',
71
+ startup: 'startupapps',
72
+ };
73
+
74
+ /** `process.signal` signal names; TERM maps to Stop-Process without -Force. */
75
+ const SIGNALS = ['TERM', 'KILL', 'INT'];
76
+
77
+ const GUID_PATTERN = '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}';
78
+ const MSI_ID_PATTERN = new RegExp(`^msi:\\{${GUID_PATTERN}\\}$`);
79
+ const WINGET_TOKEN_PATTERN = /^[A-Za-z0-9._+-]{1,200}$/;
80
+ const REGISTRY_SEGMENT_PATTERN = /^[^\\/:*?"<>|]{1,128}$/;
81
+ const STARTUP_PATH_PATTERN = /^startup:(HKCU|HKLM32|HKLM):([^\\/:*?"<>|]{1,128})$/;
82
+ const HOSTNAME_PATTERN = /^[A-Za-z0-9-]{1,63}$/;
83
+ const PROCESS_NAME_PATTERN = /^[A-Za-z0-9._ -]{1,128}$/;
84
+
85
+ /* -------------------------------------------------------------------------- */
86
+ /* Result / JSON helpers */
87
+ /* -------------------------------------------------------------------------- */
88
+
89
+ /**
90
+ * @param {string} message
91
+ * @param {string} [code]
92
+ * @returns {{ok: false, message: string, code?: string}}
93
+ */
94
+ function failure(message, code) {
95
+ return code ? { ok: false, message, code } : { ok: false, message };
96
+ }
97
+
98
+ /**
99
+ * @param {string} message
100
+ * @param {object} [data]
101
+ * @returns {{ok: true, message: string, data?: object}}
102
+ */
103
+ function success(message, data) {
104
+ if (data && typeof data === 'object' && Object.keys(data).length > 0) {
105
+ return { ok: true, message, data: jsonSafe(data) };
106
+ }
107
+ return { ok: true, message };
108
+ }
109
+
110
+ /**
111
+ * Deep-copy through JSON; never throws, never leaves `undefined` in the graph.
112
+ * @param {unknown} value
113
+ * @returns {unknown}
114
+ */
115
+ function jsonSafe(value) {
116
+ try {
117
+ return JSON.parse(JSON.stringify(value ?? null));
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /** Keep only a plain params object (the API may send an array or garbage). */
124
+ function paramsObject(value) {
125
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
126
+ }
127
+
128
+ /**
129
+ * Serialize validated values into `DEVICEX_ARG_*` strings.
130
+ * @param {Record<string, unknown>} values
131
+ * @returns {Record<string, string>}
132
+ */
133
+ function envOf(values) {
134
+ /** @type {Record<string, string>} */
135
+ const env = {};
136
+ for (const [key, value] of Object.entries(values)) {
137
+ env[`DEVICEX_ARG_${key}`] = value === undefined || value === null ? '' : String(value);
138
+ }
139
+ return env;
140
+ }
141
+
142
+ /**
143
+ * Turn a failed PowerShell result into an ActionResult with its diagnostic.
144
+ * @param {string} label
145
+ * @param {{error?: string, stderr?: string, raw?: string}} result
146
+ * @returns {{ok: false, message: string, code?: string}}
147
+ */
148
+ function commandFailure(label, result) {
149
+ const detail = String(result.error || result.stderr || result.raw || 'the command failed').trim();
150
+ return failure(`${label} failed: ${detail}`, 'COMMAND_FAILED');
151
+ }
152
+
153
+ /* -------------------------------------------------------------------------- */
154
+ /* Pure validators (no process spawn, no side effects) */
155
+ /* -------------------------------------------------------------------------- */
156
+
157
+ /** Read an integer/number param with range checks. * @returns {{ok: true, value: number|null} | {ok: false, message: string, code?: string}}
158
+ */
159
+ function readNumber(params, name, options = {}) {
160
+ const raw = params[name];
161
+ if (raw === undefined || raw === null || raw === '') {
162
+ if (options.defaultValue !== undefined) return { ok: true, value: options.defaultValue };
163
+ if (options.required !== false) return failure(`Parameter "${name}" is required.`, 'BAD_PARAMS');
164
+ return { ok: true, value: null };
165
+ }
166
+ const value = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : Number.NaN;
167
+ if (!Number.isFinite(value)) return failure(`Parameter "${name}" must be a number.`, 'BAD_PARAMS');
168
+ if (options.integer === true && !Number.isInteger(value)) {
169
+ return failure(`Parameter "${name}" must be an integer.`, 'BAD_PARAMS');
170
+ }
171
+ const below = typeof options.min === 'number' && value < options.min;
172
+ const above = typeof options.max === 'number' && value > options.max;
173
+ if (below || above) {
174
+ return failure(`Parameter "${name}" must be between ${options.min} and ${options.max}.`, 'BAD_PARAMS');
175
+ }
176
+ return { ok: true, value };
177
+ }
178
+
179
+ /** Read a trimmed string param, rejecting controls and enforcing a pattern. * @returns {{ok: true, value: string} | {ok: false, message: string, code?: string}}
180
+ */
181
+ function readString(params, name, options = {}) {
182
+ const raw = params[name];
183
+ if (raw === undefined || raw === null) {
184
+ if (options.defaultValue !== undefined) return { ok: true, value: options.defaultValue };
185
+ return failure(`Parameter "${name}" is required.`, 'BAD_PARAMS');
186
+ }
187
+ if (typeof raw !== 'string') return failure(`Parameter "${name}" must be a string.`, 'BAD_PARAMS');
188
+ const value = raw.trim();
189
+ if (value === '') return failure(`Parameter "${name}" must not be empty.`, 'BAD_PARAMS');
190
+ if (options.maxLength && value.length > options.maxLength) {
191
+ return failure(`Parameter "${name}" must be at most ${options.maxLength} characters.`, 'BAD_PARAMS');
192
+ }
193
+ if (/[\u0000-\u001F\u007F]/.test(value)) {
194
+ return failure(`Parameter "${name}" must not contain control characters.`, 'BAD_PARAMS');
195
+ }
196
+ if (options.pattern && !options.pattern.test(value)) {
197
+ return failure(options.patternMessage ?? `Parameter "${name}" has an invalid format.`, 'BAD_PARAMS');
198
+ }
199
+ return { ok: true, value };
200
+ }
201
+
202
+ /** Read a boolean param, tolerating the JSON/string/0-1 forms clients send. * @returns {{ok: true, value: boolean} | {ok: false, message: string, code?: string}}
203
+ */
204
+ function readBoolean(params, name, options = {}) {
205
+ const raw = params[name];
206
+ if (raw === undefined && options.defaultValue !== undefined) return { ok: true, value: options.defaultValue };
207
+ if (raw === true || raw === 'true' || raw === 1 || raw === '1') return { ok: true, value: true };
208
+ if (raw === false || raw === 'false' || raw === 0 || raw === '0') return { ok: true, value: false };
209
+ return failure(`Parameter "${name}" must be a boolean.`, 'BAD_PARAMS');
210
+ }
211
+
212
+ /** Read a select param restricted to a fixed option list. * @returns {{ok: true, value: string} | {ok: false, message: string, code?: string}}
213
+ */
214
+ function readSelect(params, name, options) {
215
+ const raw = params[name];
216
+ if ((raw === undefined || raw === null || raw === '') && options.defaultValue !== undefined) {
217
+ return { ok: true, value: options.defaultValue };
218
+ }
219
+ if (typeof raw !== 'string') {
220
+ return failure(`Parameter "${name}" must be one of: ${options.options.join(', ')}.`, 'BAD_PARAMS');
221
+ }
222
+ const value = raw.trim();
223
+ if (!options.options.includes(value)) {
224
+ return failure(`Parameter "${name}" must be one of: ${options.options.join(', ')}.`, 'BAD_PARAMS');
225
+ }
226
+ return { ok: true, value };
227
+ }
228
+
229
+ /**
230
+ * Resolve a path that must already exist on disk (absolute, `~` or CWD-relative).
231
+ * @param {object} params
232
+ * @param {string} name
233
+ * @param {{allowDirectory?: boolean}} [options]
234
+ * @returns {{ok: true, value: string} | {ok: false, message: string, code?: string}}
235
+ */
236
+ function readExistingPath(params, name, options = {}) {
237
+ const raw = readString(params, name, { required: true, maxLength: 4096 });
238
+ if (!raw.ok) return raw;
239
+ let candidate = raw.value;
240
+ if (candidate === '~' || candidate.startsWith('~/') || candidate.startsWith('~\\')) {
241
+ candidate = `${homedir()}/${candidate.slice(1).replace(/^[\\/]+/, '')}`;
242
+ }
243
+ let absolute;
244
+ try {
245
+ absolute = resolve(candidate);
246
+ } catch {
247
+ return failure(`Parameter "${name}" is not a usable path.`, 'BAD_PARAMS');
248
+ }
249
+ let stat;
250
+ try {
251
+ stat = statSync(absolute);
252
+ } catch {
253
+ return failure(`Path does not exist: ${absolute}`, 'BAD_PARAMS');
254
+ }
255
+ if (options.allowDirectory !== true && !stat.isFile()) {
256
+ return failure(`Path is not a file: ${absolute}`, 'BAD_PARAMS');
257
+ }
258
+ if (!stat.isFile() && !stat.isDirectory()) {
259
+ return failure(`Path is not a regular file or directory: ${absolute}`, 'BAD_PARAMS');
260
+ }
261
+ return { ok: true, value: absolute };
262
+ }
263
+
264
+ /**
265
+ * Normalize a registry uninstall key (`HKLM:\...\Uninstall\Name` or `HKLM:Name`).
266
+ * @param {unknown} raw
267
+ * @returns {{ok: true, value: string} | {ok: false, message: string, code?: string}}
268
+ */
269
+ function parseRegistryKey(raw) {
270
+ if (typeof raw !== 'string') {
271
+ return failure('Registry uninstall ids must be a string.', 'BAD_PARAMS');
272
+ }
273
+ const text = raw.trim();
274
+ if (text.length === 0 || text.length > 512) {
275
+ return failure('Registry uninstall ids must be 1-512 characters.', 'BAD_PARAMS');
276
+ }
277
+ const match = /^(HKLM|HKCU)(?:[:\/\\])(.*)$/i.exec(text);
278
+ if (!match) {
279
+ return failure(
280
+ 'Registry uninstall ids must start with "HKLM:" or "HKCU:".',
281
+ 'BAD_PARAMS',
282
+ );
283
+ }
284
+ const hive = match[1].toUpperCase();
285
+ const rest = match[2].replace(/^[\\/]+/, '').replace(/[\\/]+$/, '');
286
+ if (rest === '') return failure('Registry uninstall ids must include a key path.', 'BAD_PARAMS');
287
+ const segments = rest.split(/[\\/]+/);
288
+ if (segments.length === 0 || segments.length > 24) {
289
+ return failure('Registry uninstall ids have too many path segments.', 'BAD_PARAMS');
290
+ }
291
+ for (const segment of segments) {
292
+ if (segment === '.' || segment === '..' || !REGISTRY_SEGMENT_PATTERN.test(segment)) {
293
+ return failure(`Registry uninstall id contains an invalid key segment: "${segment}".`, 'BAD_PARAMS');
294
+ }
295
+ }
296
+ const lower = segments.map((segment) => segment.toLowerCase());
297
+ const standardRoot64 = ['software', 'microsoft', 'windows', 'currentversion', 'uninstall'];
298
+ const standardRoot32 = ['software', 'wow6432node', 'microsoft', 'windows', 'currentversion', 'uninstall'];
299
+ const underRoot = (root) => lower.length > root.length && root.every((part, index) => lower[index] === part);
300
+ if (!underRoot(standardRoot64) && !underRoot(standardRoot32)) {
301
+ return failure(
302
+ 'Registry uninstall ids must live under Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall (or its WOW6432Node variant).',
303
+ 'BAD_PARAMS',
304
+ );
305
+ }
306
+ return { ok: true, value: `${hive}:\\${segments.join('\\')}` };
307
+ }
308
+
309
+ /**
310
+ * Classify `id` for `app.uninstall`: `msi:{GUID}`, `winget:<token>` or a registry key.
311
+ * @param {object} params
312
+ * @returns {{ok: true, value: any} | {ok: false, message: string, code?: string}}
313
+ */
314
+ function parseUninstallId(params) {
315
+ const raw = readString(params, 'id', { required: true, maxLength: 1024 });
316
+ if (!raw.ok) return raw;
317
+ const id = raw.value;
318
+ const lower = id.toLowerCase();
319
+
320
+ // 1.6: an optional footprint tail. Windows can only scan for now, so a
321
+ // request that asks to clean leftovers is refused by the runner rather than
322
+ // silently ignored - but the id is still validated first so the caller gets
323
+ // the precise rejection reason.
324
+ const scanId = params.scanId;
325
+ if (scanId !== undefined && scanId !== null && scanId !== '') {
326
+ if (typeof scanId !== 'string' || scanId.length > 128) {
327
+ return failure('scanId must be a string of at most 128 characters.', 'BAD_PARAMS');
328
+ }
329
+ }
330
+ const withScan = (value) =>
331
+ typeof scanId === 'string' && scanId !== ''
332
+ ? /** @type {{ok: true, value: any}} */ ({ ok: true, value: { ...value, scanId } })
333
+ : /** @type {{ok: true, value: any}} */ ({ ok: true, value });
334
+
335
+ if (lower.startsWith('msi:')) {
336
+ if (!MSI_ID_PATTERN.test(id)) {
337
+ return failure(
338
+ 'MSI uninstall ids must look like "msi:{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}".',
339
+ 'BAD_PARAMS',
340
+ );
341
+ }
342
+ return withScan({ kind: 'msi', id, guid: id.slice(4).toUpperCase() });
343
+ }
344
+ if (lower.startsWith('winget:')) {
345
+ const token = id.slice('winget:'.length);
346
+ if (!WINGET_TOKEN_PATTERN.test(token)) {
347
+ return failure('winget uninstall ids may only contain letters, digits and . _ + -.', 'BAD_PARAMS');
348
+ }
349
+ return withScan({ kind: 'winget', id, token });
350
+ }
351
+ const registry = parseRegistryKey(id);
352
+ if (!registry.ok) return registry;
353
+ return withScan({ kind: 'registry', id, key: registry.value });
354
+ }
355
+
356
+ /** Parse `startup:<HKCU|HKLM|HKLM32>:<ValueName>` for `system.launchAgent`. * @returns {{ok: true, value: any} | {ok: false, message: string, code?: string}}
357
+ */
358
+ function parseLaunchAgentPath(params) {
359
+ const raw = readString(params, 'path', { required: true, maxLength: 160 });
360
+ if (!raw.ok) return raw;
361
+ const match = STARTUP_PATH_PATTERN.exec(raw.value);
362
+ if (!match) {
363
+ return failure(
364
+ 'Startup paths must look like "startup:HKCU:<ValueName>", "startup:HKLM:<ValueName>" or "startup:HKLM32:<ValueName>".',
365
+ 'BAD_PARAMS',
366
+ );
367
+ }
368
+ return { ok: true, value: { hive: match[1], name: match[2], path: raw.value } };
369
+ }
370
+
371
+ /** Parse `servers` (string or string[]) into 0-4 validated IP addresses. * @returns {{ok: true, value: any} | {ok: false, message: string, code?: string}}
372
+ */
373
+ function readDnsServers(params) {
374
+ const raw = params.servers;
375
+ let list;
376
+ if (raw === undefined || raw === null || raw === '') {
377
+ list = [];
378
+ } else if (typeof raw === 'string') {
379
+ list = raw.trim() === '' ? [] : [raw.trim()];
380
+ } else if (Array.isArray(raw)) {
381
+ list = [];
382
+ for (const entry of raw) {
383
+ if (typeof entry !== 'string') {
384
+ return failure('Parameter "servers" must contain only strings.', 'BAD_PARAMS');
385
+ }
386
+ const trimmed = entry.trim();
387
+ if (trimmed !== '') list.push(trimmed);
388
+ }
389
+ } else {
390
+ return failure('Parameter "servers" must be a string or an array of IP addresses.', 'BAD_PARAMS');
391
+ }
392
+ if (list.length > 4) return failure('Parameter "servers" accepts at most 4 addresses.', 'BAD_PARAMS');
393
+ const families = new Set();
394
+ for (const entry of list) {
395
+ const version = isIP(entry);
396
+ if (version === 0) {
397
+ return failure(`"${entry}" is not a valid IP address.`, 'BAD_PARAMS');
398
+ }
399
+ families.add(version);
400
+ }
401
+ if (families.size > 1) {
402
+ return failure('Mixing IPv4 and IPv6 servers is not supported.', 'BAD_PARAMS');
403
+ }
404
+ return { ok: true, value: list };
405
+ }
406
+
407
+ /**
408
+ * Read the `ids` parameter of `cleanup.delete`: an array (or comma separated
409
+ * string) of 1-20 unique ids from the cleanup allowlist. Unknown ids, caller
410
+ * paths, duplicates and oversized batches are refused before any filesystem
411
+ * call.
412
+ * @param {object} params
413
+ * @returns {{ok: true, value: any} | {ok: false, message: string, code?: string}}
414
+ */
415
+ function readCleanupIds(params) {
416
+ const raw = params.ids;
417
+ let values;
418
+ if (Array.isArray(raw)) {
419
+ values = raw;
420
+ } else if (typeof raw === 'string') {
421
+ values = raw.split(',');
422
+ } else {
423
+ return failure(
424
+ 'Parameter "ids" must be an array of cleanup item ids (or a comma separated string).',
425
+ 'BAD_PARAMS',
426
+ );
427
+ }
428
+ const ids = [];
429
+ for (const value of values) {
430
+ if (typeof value !== 'string') return failure('Cleanup item ids must be strings.', 'BAD_PARAMS');
431
+ const id = value.trim();
432
+ if (id !== '') ids.push(id);
433
+ }
434
+ if (ids.length < 1 || ids.length > 20) {
435
+ return failure('Parameter "ids" must contain 1 to 20 cleanup item ids.', 'BAD_PARAMS');
436
+ }
437
+ const known = new Set(cleanupItems().map((item) => item.id));
438
+ const seen = new Set();
439
+ for (const id of ids) {
440
+ if (seen.has(id)) return failure(`Duplicate cleanup item id: ${id}`, 'BAD_PARAMS');
441
+ seen.add(id);
442
+ if (!known.has(id)) return failure(`Unknown cleanup item id: ${id}`, 'BAD_PARAMS');
443
+ }
444
+ return { ok: true, value: ids };
445
+ }
446
+
447
+ /* -------------------------------------------------------------------------- */
448
+ /* Action catalog */
449
+ /* -------------------------------------------------------------------------- */
450
+
451
+ /**
452
+ * Frozen action metadata plus its pure validator. `validate(params)` returns
453
+ * `{ ok: true, value }` with the normalized value or an ActionResult failure.
454
+ * @type {Array<object>}
455
+ */
456
+ const SPECS = [
457
+ {
458
+ id: 'process.signal',
459
+ group: 'process',
460
+ danger: 'medium',
461
+ requiresAdmin: false,
462
+ params: [
463
+ { name: 'pid', type: 'number', required: true, min: 2, max: 4294967295 },
464
+ { name: 'signal', type: 'select', required: false, options: SIGNALS },
465
+ ],
466
+ /**
467
+ * @returns {{ok: true, value: Win32Params['process.signal']} | {ok: false, message: string, code?: string}}
468
+ */
469
+ validate(params) {
470
+ const pid = readNumber(params, 'pid', { required: true, integer: true, min: 2, max: 4294967295 });
471
+ if (!pid.ok) return pid;
472
+ if (pid.value === process.pid || pid.value === process.ppid) {
473
+ return failure('Refusing to signal the Device X host process or its parent.', 'PROTECTED_PID');
474
+ }
475
+ const signal = readSelect(params, 'signal', { options: SIGNALS, defaultValue: 'TERM' });
476
+ if (!signal.ok) return signal;
477
+ return {
478
+ ok: true,
479
+ value: { pid: /** @type {number} */ (pid.value), signal: /** @type {Win32Signal} */ (signal.value) },
480
+ };
481
+ },
482
+ },
483
+ {
484
+ id: 'app.quit',
485
+ group: 'app',
486
+ danger: 'low',
487
+ requiresAdmin: false,
488
+ params: [{ name: 'name', type: 'string', required: true, placeholder: 'Process name, e.g. Code' }],
489
+ /**
490
+ * @returns {{ok: true, value: Win32Params['app.quit']} | {ok: false, message: string, code?: string}}
491
+ */
492
+ validate(params) {
493
+ const name = readString(params, 'name', {
494
+ required: true,
495
+ maxLength: 128,
496
+ pattern: PROCESS_NAME_PATTERN,
497
+ patternMessage:
498
+ 'Process names may only contain letters, digits, spaces and . _ - (1-128 characters).',
499
+ });
500
+ if (!name.ok) return name;
501
+ return { ok: true, value: { name: name.value } };
502
+ },
503
+ },
504
+ {
505
+ id: 'app.launch',
506
+ group: 'app',
507
+ danger: 'low',
508
+ requiresAdmin: false,
509
+ params: [{ name: 'path', type: 'string', required: true, placeholder: 'C:\\Path\\app.exe' }],
510
+ /**
511
+ * @returns {{ok: true, value: Win32Params['app.launch']} | {ok: false, message: string, code?: string}}
512
+ */
513
+ validate(params) {
514
+ const path = readExistingPath(params, 'path');
515
+ if (!path.ok) return path;
516
+ return { ok: true, value: { path: path.value } };
517
+ },
518
+ },
519
+ {
520
+ id: 'app.reveal',
521
+ group: 'app',
522
+ danger: 'low',
523
+ requiresAdmin: false,
524
+ params: [{ name: 'path', type: 'string', required: true, placeholder: 'C:\\Path\\item' }],
525
+ /**
526
+ * @returns {{ok: true, value: Win32Params['app.reveal']} | {ok: false, message: string, code?: string}}
527
+ */
528
+ validate(params) {
529
+ const path = readExistingPath(params, 'path', { allowDirectory: true });
530
+ if (!path.ok) return path;
531
+ return { ok: true, value: { path: path.value } };
532
+ },
533
+ },
534
+ {
535
+ id: 'app.uninstall',
536
+ group: 'app',
537
+ danger: 'high',
538
+ requiresAdmin: false,
539
+ params: [
540
+ { name: 'id', type: 'string', required: true, placeholder: 'msi:{GUID} | winget:<id> | HKLM:\\...\\Uninstall\\...' },
541
+ // 1.6: present so the schema matches macOS. Supplying it is refused today
542
+ // (see the runner) because the Recycle Bin move is not verified yet.
543
+ { name: 'scanId', type: 'string', required: false, placeholder: 'scan id from GET /devicex/api/footprint' },
544
+ { name: 'itemIds', type: 'string', required: false, multiple: true, placeholder: 'fp_1a2b3c4d' },
545
+ { name: 'cleanup', type: 'select', required: false, options: ['trash', 'none'] },
546
+ ],
547
+ /**
548
+ * @returns {{ok: true, value: Win32Params['app.uninstall']} | {ok: false, message: string, code?: string}}
549
+ */
550
+ validate(params) {
551
+ return parseUninstallId(params);
552
+ },
553
+ },
554
+ {
555
+ id: 'system.setHostname',
556
+ group: 'system',
557
+ danger: 'high',
558
+ requiresAdmin: true,
559
+ params: [
560
+ { name: 'kind', type: 'select', required: true, options: ['ComputerName'] },
561
+ { name: 'value', type: 'string', required: true, placeholder: 'NEW-HOSTNAME' },
562
+ ],
563
+ /**
564
+ * @returns {{ok: true, value: Win32Params['system.setHostname']} | {ok: false, message: string, code?: string}}
565
+ */
566
+ validate(params) {
567
+ const rawKind = params.kind;
568
+ const kind = readSelect(params, 'kind', { options: ['ComputerName'] });
569
+ if (!kind.ok) {
570
+ if (rawKind !== undefined && rawKind !== null && rawKind !== '') {
571
+ return failure(
572
+ `Unsupported hostname kind "${String(rawKind)}"; Windows only supports "ComputerName".`,
573
+ 'UNSUPPORTED_KIND',
574
+ );
575
+ }
576
+ return kind;
577
+ }
578
+ const value = readString(params, 'value', {
579
+ required: true,
580
+ maxLength: 63,
581
+ pattern: HOSTNAME_PATTERN,
582
+ patternMessage: 'Host names may only contain letters, digits and - (1-63 characters).',
583
+ });
584
+ if (!value.ok) return value;
585
+ return { ok: true, value: { kind: 'ComputerName', value: value.value } };
586
+ },
587
+ },
588
+ {
589
+ id: 'system.setVolume',
590
+ group: 'system',
591
+ danger: 'low',
592
+ requiresAdmin: false,
593
+ params: [{ name: 'percent', type: 'number', required: true, min: 0, max: 100 }],
594
+ /**
595
+ * @returns {{ok: true, value: Win32Params['system.setVolume']} | {ok: false, message: string, code?: string}}
596
+ */
597
+ validate(params) {
598
+ const percent = readNumber(params, 'percent', { required: true, min: 0, max: 100 });
599
+ if (!percent.ok) return percent;
600
+ return { ok: true, value: { percent: /** @type {number} */ (percent.value) } };
601
+ },
602
+ },
603
+ {
604
+ id: 'system.setDarkMode',
605
+ group: 'system',
606
+ danger: 'low',
607
+ requiresAdmin: false,
608
+ params: [{ name: 'enabled', type: 'boolean', required: true }],
609
+ /**
610
+ * @returns {{ok: true, value: Win32Params['system.setDarkMode']} | {ok: false, message: string, code?: string}}
611
+ */
612
+ validate(params) {
613
+ const enabled = readBoolean(params, 'enabled');
614
+ if (!enabled.ok) return enabled;
615
+ return { ok: true, value: { enabled: enabled.value } };
616
+ },
617
+ },
618
+ {
619
+ id: 'system.launchAgent',
620
+ group: 'system',
621
+ danger: 'medium',
622
+ requiresAdmin: true,
623
+ params: [
624
+ { name: 'path', type: 'string', required: true, placeholder: 'startup:HKCU:ValueName' },
625
+ { name: 'enabled', type: 'boolean', required: true },
626
+ ],
627
+ /**
628
+ * @returns {{ok: true, value: Win32Params['system.launchAgent']} | {ok: false, message: string, code?: string}}
629
+ */
630
+ validate(params) {
631
+ const path = parseLaunchAgentPath(params);
632
+ if (!path.ok) return path;
633
+ const enabled = readBoolean(params, 'enabled');
634
+ if (!enabled.ok) return enabled;
635
+ return { ok: true, value: { ...path.value, enabled: enabled.value } };
636
+ },
637
+ },
638
+ {
639
+ id: 'system.openSettings',
640
+ group: 'system',
641
+ danger: 'low',
642
+ requiresAdmin: false,
643
+ params: [{ name: 'pane', type: 'select', required: true, options: SETTINGS_PANES }],
644
+ /**
645
+ * @returns {{ok: true, value: Win32Params['system.openSettings']} | {ok: false, message: string, code?: string}}
646
+ */
647
+ validate(params) {
648
+ const pane = readSelect(params, 'pane', { options: SETTINGS_PANES });
649
+ if (!pane.ok) return pane;
650
+ return { ok: true, value: { pane: pane.value } };
651
+ },
652
+ },
653
+ {
654
+ id: 'system.toggleFirewall',
655
+ group: 'system',
656
+ danger: 'high',
657
+ requiresAdmin: true,
658
+ params: [{ name: 'enabled', type: 'boolean', required: true }],
659
+ /**
660
+ * @returns {{ok: true, value: Win32Params['system.toggleFirewall']} | {ok: false, message: string, code?: string}}
661
+ */
662
+ validate(params) {
663
+ const enabled = readBoolean(params, 'enabled');
664
+ if (!enabled.ok) return enabled;
665
+ return { ok: true, value: { enabled: enabled.value } };
666
+ },
667
+ },
668
+ {
669
+ id: 'system.flushDNS',
670
+ group: 'system',
671
+ danger: 'low',
672
+ requiresAdmin: false,
673
+ params: [],
674
+ /**
675
+ * @returns {{ok: true, value: Win32Params['system.flushDNS']} | {ok: false, message: string, code?: string}}
676
+ */
677
+ validate() {
678
+ return { ok: true, value: {} };
679
+ },
680
+ },
681
+ {
682
+ id: 'system.emptyTrash',
683
+ group: 'system',
684
+ danger: 'medium',
685
+ requiresAdmin: false,
686
+ params: [],
687
+ /**
688
+ * @returns {{ok: true, value: Win32Params['system.emptyTrash']} | {ok: false, message: string, code?: string}}
689
+ */
690
+ validate() {
691
+ return { ok: true, value: {} };
692
+ },
693
+ },
694
+ {
695
+ id: 'cleanup.delete',
696
+ group: 'system',
697
+ danger: 'high',
698
+ requiresAdmin: false,
699
+ params: [
700
+ { name: 'ids', type: 'string', required: true, multiple: true, placeholder: 'user-temp, crash-dumps' },
701
+ ],
702
+ /**
703
+ * @returns {{ok: true, value: Win32Params['cleanup.delete']} | {ok: false, message: string, code?: string}}
704
+ */
705
+ validate(params) {
706
+ const ids = readCleanupIds(params);
707
+ if (ids.ok !== true) return ids;
708
+ return { ok: true, value: { ids: ids.value } };
709
+ },
710
+ },
711
+ {
712
+ id: 'network.setWifiPower',
713
+ group: 'network',
714
+ danger: 'medium',
715
+ requiresAdmin: true,
716
+ params: [{ name: 'enabled', type: 'boolean', required: true }],
717
+ /**
718
+ * @returns {{ok: true, value: Win32Params['network.setWifiPower']} | {ok: false, message: string, code?: string}}
719
+ */
720
+ validate(params) {
721
+ const enabled = readBoolean(params, 'enabled');
722
+ if (!enabled.ok) return enabled;
723
+ return { ok: true, value: { enabled: enabled.value } };
724
+ },
725
+ },
726
+ {
727
+ id: 'network.setDns',
728
+ group: 'network',
729
+ danger: 'high',
730
+ requiresAdmin: true,
731
+ params: [
732
+ { name: 'service', type: 'string', required: true, placeholder: 'Interface alias, e.g. Ethernet' },
733
+ { name: 'servers', type: 'string', required: false, placeholder: '1.1.1.1, 8.8.8.8' },
734
+ ],
735
+ /**
736
+ * @returns {{ok: true, value: Win32Params['network.setDns']} | {ok: false, message: string, code?: string}}
737
+ */
738
+ validate(params) {
739
+ const service = readString(params, 'service', { required: true, maxLength: 256 });
740
+ if (!service.ok) return service;
741
+ const servers = readDnsServers(params);
742
+ if (!servers.ok) return servers;
743
+ return { ok: true, value: { service: service.value, servers: servers.value } };
744
+ },
745
+ },
746
+ {
747
+ id: 'power.sleep',
748
+ group: 'power',
749
+ danger: 'low',
750
+ requiresAdmin: false,
751
+ params: [],
752
+ /**
753
+ * @returns {{ok: true, value: Win32Params['power.sleep']} | {ok: false, message: string, code?: string}}
754
+ */
755
+ validate() {
756
+ return { ok: true, value: {} };
757
+ },
758
+ },
759
+ {
760
+ id: 'power.lock',
761
+ group: 'power',
762
+ danger: 'low',
763
+ requiresAdmin: false,
764
+ params: [],
765
+ /**
766
+ * @returns {{ok: true, value: Win32Params['power.lock']} | {ok: false, message: string, code?: string}}
767
+ */
768
+ validate() {
769
+ return { ok: true, value: {} };
770
+ },
771
+ },
772
+ {
773
+ id: 'software.wingetUninstall',
774
+ group: 'software',
775
+ danger: 'medium',
776
+ requiresAdmin: false,
777
+ params: [{ name: 'id', type: 'string', required: true, placeholder: 'Publisher.Package' }],
778
+ /**
779
+ * @returns {{ok: true, value: Win32Params['software.wingetUninstall']} | {ok: false, message: string, code?: string}}
780
+ */
781
+ validate(params) {
782
+ const id = readString(params, 'id', {
783
+ required: true,
784
+ maxLength: 200,
785
+ pattern: WINGET_TOKEN_PATTERN,
786
+ patternMessage: 'winget package ids may only contain letters, digits and . _ + - (1-200 characters).',
787
+ });
788
+ if (!id.ok) return id;
789
+ return { ok: true, value: { id: id.value } };
790
+ },
791
+ },
792
+ ];
793
+
794
+ /** @type {Map<string, object>} */
795
+ const SPEC_BY_ID = new Map(SPECS.map((spec) => [spec.id, spec]));
796
+
797
+ /* -------------------------------------------------------------------------- */
798
+ /* Runners: one constant PowerShell script per action */
799
+ /* -------------------------------------------------------------------------- */
800
+
801
+ /** Counter feeding unique inline CoreAudio type names (one per process call). */
802
+ let volumeSerial = 0;
803
+
804
+ /**
805
+ * `system.setVolume`: inline C# CoreAudio interop. Each call compiles a
806
+ * uniquely named type because `Add-Type` cannot re-add the same type name.
807
+ * @returns {string} PowerShell script.
808
+ */
809
+ function setVolumeScript() {
810
+ volumeSerial += 1;
811
+ const typeName = `DeviceXVolumeAction${volumeSerial}`;
812
+ const source = [
813
+ 'using System;',
814
+ 'using System.Runtime.InteropServices;',
815
+ '',
816
+ '[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]',
817
+ 'internal class MMDeviceEnumeratorComObject { }',
818
+ '',
819
+ 'internal enum EDataFlow { eRender = 0, eCapture = 1, eAll = 2 }',
820
+ 'internal enum ERole { eConsole = 0, eMultimedia = 1, eCommunications = 2 }',
821
+ '',
822
+ '[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")]',
823
+ 'internal interface IMMDeviceEnumerator',
824
+ '{',
825
+ ' [PreserveSig] int EnumAudioEndpoints(EDataFlow dataFlow, int stateMask, out IntPtr devices);',
826
+ ' [PreserveSig] int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice endpoint);',
827
+ '}',
828
+ '',
829
+ '[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("D666063F-1587-4E43-81F1-B948E807363F")]',
830
+ 'internal interface IMMDevice',
831
+ '{',
832
+ ' [PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object instance);',
833
+ '}',
834
+ '',
835
+ '[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("5CDF2C82-841E-4546-9722-0CF74078229A")]',
836
+ 'internal interface IAudioEndpointVolume',
837
+ '{',
838
+ ' [PreserveSig] int RegisterControlChangeNotify(IntPtr notify);',
839
+ ' [PreserveSig] int UnregisterControlChangeNotify(IntPtr notify);',
840
+ ' [PreserveSig] int GetChannelCount(out uint channelCount);',
841
+ ' [PreserveSig] int SetMasterVolumeLevel(float level, Guid eventContext);',
842
+ ' [PreserveSig] int SetMasterVolumeLevelScalar(float level, Guid eventContext);',
843
+ '}',
844
+ '',
845
+ `public static class ${typeName}`,
846
+ '{',
847
+ ' public static int SetVolume(float scalar)',
848
+ ' {',
849
+ ' if (scalar < 0f) { scalar = 0f; }',
850
+ ' if (scalar > 1f) { scalar = 1f; }',
851
+ ' IMMDeviceEnumerator enumerator = (IMMDeviceEnumerator)(new MMDeviceEnumeratorComObject());',
852
+ ' IMMDevice device;',
853
+ ' int hr = enumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eConsole, out device);',
854
+ ' if (hr != 0) { return hr; }',
855
+ ' Guid iid = typeof(IAudioEndpointVolume).GUID;',
856
+ ' object raw;',
857
+ ' hr = device.Activate(ref iid, 23, IntPtr.Zero, out raw);',
858
+ ' if (hr != 0) { return hr; }',
859
+ ' IAudioEndpointVolume volume = (IAudioEndpointVolume)raw;',
860
+ ' return volume.SetMasterVolumeLevelScalar(scalar, Guid.Empty);',
861
+ ' }',
862
+ '}',
863
+ ].join('\n');
864
+ return `${PS_HEAD}
865
+ $source = @'
866
+ ${source}
867
+ '@;
868
+ Add-Type -TypeDefinition $source -Language CSharp -ErrorAction Stop;
869
+ $scalar = [float]([double]$env:DEVICEX_ARG_PERCENT / 100.0);
870
+ $hr = [${typeName}]::SetVolume($scalar);
871
+ if ($hr -ne 0) { throw ("CoreAudio SetMasterVolumeLevelScalar failed with HRESULT 0x{0:X8}." -f $hr) }
872
+ @{ok=$true; percent=[int]$env:DEVICEX_ARG_PERCENT} | ConvertTo-Json -Compress`;
873
+ }
874
+
875
+ /** `msi:{GUID}` uninstall via msiexec (3010/1641 = success, reboot required). */
876
+ async function uninstallMsi({ id, guid }) {
877
+ const script = `${PS_HEAD}
878
+ $proc = Start-Process -FilePath ${psString('msiexec.exe')} -ArgumentList @('/x', $env:DEVICEX_ARG_GUID, '/qn') -Wait -PassThru -ErrorAction Stop;
879
+ if ($proc.ExitCode -notin 0,3010,1641) { throw "msiexec exited with code $($proc.ExitCode)." }
880
+ @{ok=$true; exitCode=$proc.ExitCode} | ConvertTo-Json -Compress`;
881
+ const result = await runPSJson(script, { env: envOf({ GUID: guid }), timeout: 180_000 });
882
+ if (!result.ok) return commandFailure('MSI uninstall', result);
883
+ const exitCode = Number(result.data?.exitCode ?? 0);
884
+ const restartRequired = exitCode === 3010 || exitCode === 1641;
885
+ return success(
886
+ restartRequired
887
+ ? `Uninstalled MSI package ${guid}; a restart is required to finish the uninstall.`
888
+ : `Uninstalled MSI package ${guid}.`,
889
+ { id, guid, exitCode, restartRequired },
890
+ );
891
+ }
892
+
893
+ /** `winget:<id>` / software.wingetUninstall through winget.exe. */
894
+ async function uninstallWinget({ token, id }, packageAgreements) {
895
+ const flags = packageAgreements ? ' --accept-package-agreements' : '';
896
+ const script = `${PS_HEAD}
897
+ $output = (& ${psString('winget.exe')} uninstall --id $env:DEVICEX_ARG_ID --silent --accept-source-agreements${flags} 2>&1 | Out-String).Trim();
898
+ if ($LASTEXITCODE -ne 0) { throw ("winget uninstall failed with exit code {0}: {1}" -f $LASTEXITCODE, $output.Substring(0, [Math]::Min(400, $output.Length))) }
899
+ @{ok=$true} | ConvertTo-Json -Compress`;
900
+ const result = await runPSJson(script, { env: envOf({ ID: token }), timeout: 180_000 });
901
+ if (!result.ok) return commandFailure('winget uninstall', result);
902
+ return success(`Uninstalled "${token}" with winget.`, { id, token });
903
+ }
904
+
905
+ /** Registry `Uninstall` key uninstall: read the entry, run it without a shell. */
906
+ async function uninstallRegistry({ id, key }) {
907
+ const script = `${PS_HEAD}
908
+ $key = $env:DEVICEX_ARG_KEY;
909
+ $entry = Get-ItemProperty -LiteralPath $key -ErrorAction Stop;
910
+ $displayName = [string]$entry.DisplayName;
911
+ if ([string]::IsNullOrWhiteSpace($displayName)) { $displayName = Split-Path -Leaf $key }
912
+ $uninstall = [string]$entry.UninstallString;
913
+ if ([string]::IsNullOrWhiteSpace($uninstall)) { throw "No UninstallString is stored under '$key'." }
914
+ $msi = [regex]::Match($uninstall, '(?i)^\\s*"?MsiExec\\.exe"?\\s+(?:/|-)(?:i|x)\\s*(\\{[0-9A-Fa-f-]{36}\\})');
915
+ if ($msi.Success) {
916
+ $guid = $msi.Groups[1].Value;
917
+ $proc = Start-Process -FilePath ${psString('msiexec.exe')} -ArgumentList @('/x', $guid, '/qn') -Wait -PassThru -ErrorAction Stop;
918
+ if ($proc.ExitCode -notin 0,3010,1641) { throw "msiexec exited with code $($proc.ExitCode)." }
919
+ $command = "msiexec.exe /x $guid /qn";
920
+ $exitCode = $proc.ExitCode;
921
+ } else {
922
+ $quoted = [regex]::Match($uninstall, '^\\s*"([^"]+)"\\s*(.*)$');
923
+ if ($quoted.Success) {
924
+ $exe = $quoted.Groups[1].Value;
925
+ $arguments = $quoted.Groups[2].Value;
926
+ } else {
927
+ $exeMatch = [regex]::Match($uninstall, '(?i)^\\s*(.+?\\.exe)(?:\\s+(.*))?$');
928
+ if ($exeMatch.Success) {
929
+ $exe = $exeMatch.Groups[1].Value;
930
+ $arguments = $exeMatch.Groups[2].Value;
931
+ } else {
932
+ $parts = $uninstall.Trim() -split '\\s+', 2;
933
+ $exe = $parts[0];
934
+ if ($parts.Count -gt 1) { $arguments = $parts[1] }
935
+ }
936
+ }
937
+ if ([string]::IsNullOrWhiteSpace($exe)) { throw 'Could not parse the stored UninstallString.' }
938
+ $startArgs = @{ FilePath = $exe.Trim('"'); Wait = $true; PassThru = $true; ErrorAction = 'Stop' };
939
+ if (-not [string]::IsNullOrWhiteSpace($arguments)) { $startArgs.ArgumentList = $arguments.Trim() }
940
+ $proc = Start-Process @startArgs;
941
+ if ($proc.ExitCode -notin 0,3010,1641) { throw "The uninstaller exited with code $($proc.ExitCode)." }
942
+ $command = $uninstall;
943
+ $exitCode = $proc.ExitCode;
944
+ }
945
+ @{displayName=$displayName; command=$command; exitCode=$exitCode} | ConvertTo-Json -Compress`;
946
+ const result = await runPSJson(script, { env: envOf({ KEY: key }), timeout: 180_000 });
947
+ if (!result.ok) return commandFailure('Uninstall from registry', result);
948
+ const displayName = String(result.data?.displayName ?? key);
949
+ const exitCode = Number(result.data?.exitCode ?? 0);
950
+ const restartRequired = exitCode === 3010 || exitCode === 1641;
951
+ return success(
952
+ restartRequired
953
+ ? `Uninstalled "${displayName}"; a restart is required to finish the uninstall.`
954
+ : `Uninstalled "${displayName}".`,
955
+ {
956
+ id,
957
+ key,
958
+ displayName,
959
+ command: String(result.data?.command ?? ''),
960
+ exitCode,
961
+ restartRequired,
962
+ },
963
+ );
964
+ }
965
+
966
+ /** Validators already ran; each runner receives the normalized value object. */
967
+ const RUNNERS = {
968
+ 'process.signal': async ({ pid, signal }) => {
969
+ const script = `${PS_HEAD}
970
+ $force = $env:DEVICEX_ARG_SIGNAL -ne 'TERM';
971
+ Stop-Process -Id ([int]$env:DEVICEX_ARG_PID) -Force:$force -ErrorAction Stop;
972
+ @{ok=$true} | ConvertTo-Json -Compress`;
973
+ const result = await runPSJson(script, { env: envOf({ PID: pid, SIGNAL: signal }) });
974
+ if (!result.ok) return commandFailure(`Signal process ${pid}`, result);
975
+ return success(`Sent ${signal} to process ${pid}.`, { pid, signal });
976
+ },
977
+ 'app.quit': async ({ name }) => {
978
+ const script = `${PS_HEAD}
979
+ $name = $env:DEVICEX_ARG_NAME;
980
+ $processes = @(Get-Process -Name $name -ErrorAction SilentlyContinue);
981
+ if ($processes.Count -eq 0) { throw "No running process named '$name' was found." }
982
+ $processes | Stop-Process -ErrorAction Stop;
983
+ @{count=$processes.Count} | ConvertTo-Json -Compress`;
984
+ const result = await runPSJson(script, { env: envOf({ NAME: name }) });
985
+ if (!result.ok) return commandFailure(`Quit application "${name}"`, result);
986
+ const matched = Number(result.data?.count ?? 0);
987
+ return success(`Closed "${name}".`, { name, matched });
988
+ },
989
+ 'app.launch': async ({ path }) => {
990
+ const script = `${PS_HEAD}
991
+ Start-Process -FilePath $env:DEVICEX_ARG_PATH -ErrorAction Stop;
992
+ @{ok=$true} | ConvertTo-Json -Compress`;
993
+ const result = await runPSJson(script, { env: envOf({ PATH: path }) });
994
+ if (!result.ok) return commandFailure(`Launch "${path}"`, result);
995
+ return success(`Launched "${path}".`, { path });
996
+ },
997
+ 'app.reveal': async ({ path }) => {
998
+ const script = `${PS_HEAD}
999
+ $target = $env:DEVICEX_ARG_PATH;
1000
+ Start-Process -FilePath ${psString('explorer.exe')} -ArgumentList ('/select,"' + $target + '"') -ErrorAction Stop;
1001
+ @{ok=$true} | ConvertTo-Json -Compress`;
1002
+ const result = await runPSJson(script, { env: envOf({ PATH: path }) });
1003
+ if (!result.ok) return commandFailure(`Reveal "${path}"`, result);
1004
+ return success(`Revealed "${path}" in File Explorer.`, { path });
1005
+ },
1006
+ 'app.uninstall': async (value) => {
1007
+ // 1.6: refuse the footprint tail explicitly instead of uninstalling and
1008
+ // then quietly leaving the leftovers the user selected. Windows has no
1009
+ // verified Recycle Bin move yet, so listing works and cleanup does not.
1010
+ if (typeof value?.scanId === 'string' && value.scanId !== '') {
1011
+ return {
1012
+ ok: false,
1013
+ message:
1014
+ 'Leftover cleanup is not available on Windows yet: this build can list an application\'s leftover folders but will not move them to the Recycle Bin. Uninstall without selecting leftovers to continue.',
1015
+ code: 'UNSUPPORTED_PLATFORM',
1016
+ };
1017
+ }
1018
+ if (value.kind === 'msi') return uninstallMsi(value);
1019
+ if (value.kind === 'winget') return uninstallWinget(value, true);
1020
+ return uninstallRegistry(value);
1021
+ },
1022
+ 'system.setHostname': async ({ value }) => {
1023
+ const script = `${PS_HEAD}
1024
+ Rename-Computer -NewName $env:DEVICEX_ARG_VALUE -Force -ErrorAction Stop;
1025
+ @{ok=$true} | ConvertTo-Json -Compress`;
1026
+ const result = await runPSJson(script, { env: envOf({ VALUE: value }) });
1027
+ if (!result.ok) return commandFailure('Rename computer', result);
1028
+ return success(
1029
+ `Renamed this computer to "${value}". A restart is required for the change to take effect.`,
1030
+ { kind: 'ComputerName', value, restartRequired: true },
1031
+ );
1032
+ },
1033
+ 'system.setVolume': async ({ percent }) => {
1034
+ const result = await runPSJson(setVolumeScript(), { env: envOf({ PERCENT: percent }) });
1035
+ if (!result.ok) return commandFailure('Set system volume', result);
1036
+ return success(`Set the system volume to ${percent}%.`, { percent });
1037
+ },
1038
+ 'system.setDarkMode': async ({ enabled }) => {
1039
+ const script = `${PS_HEAD}
1040
+ $key = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize';
1041
+ if (-not (Test-Path -LiteralPath $key)) { New-Item -Path $key -Force | Out-Null }
1042
+ $value = if ($env:DEVICEX_ARG_ENABLED -eq 'true') { 0 } else { 1 };
1043
+ New-ItemProperty -LiteralPath $key -Name 'AppsUseLightTheme' -Value $value -PropertyType DWord -Force | Out-Null;
1044
+ New-ItemProperty -LiteralPath $key -Name 'SystemUsesLightTheme' -Value $value -PropertyType DWord -Force | Out-Null;
1045
+ @{ok=$true} | ConvertTo-Json -Compress`;
1046
+ const result = await runPSJson(script, { env: envOf({ ENABLED: enabled }) });
1047
+ if (!result.ok) return commandFailure('Toggle dark mode', result);
1048
+ return success(`Dark mode ${enabled ? 'enabled' : 'disabled'}.`, { enabled });
1049
+ },
1050
+ 'system.launchAgent': async ({ hive, name, path, enabled }) => {
1051
+ const script = `${PS_HEAD}
1052
+ $hive = $env:DEVICEX_ARG_HIVE;
1053
+ $name = $env:DEVICEX_ARG_NAME;
1054
+ $escapedName = [WildcardPattern]::Escape($name);
1055
+ $enable = $env:DEVICEX_ARG_ENABLED -eq 'true';
1056
+ $runKey = switch ($hive) {
1057
+ 'HKLM' { 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' }
1058
+ 'HKLM32' { 'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Run' }
1059
+ default { 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' }
1060
+ };
1061
+ $disabledRoot = 'HKCU:\\Software\\DeviceX\\DisabledStartup';
1062
+ $disabledKey = Join-Path $disabledRoot $hive;
1063
+ if ($enable) {
1064
+ if (-not (Test-Path -LiteralPath $disabledKey)) { throw "No disabled startup entry named '$name' is stored for $hive." }
1065
+ $stored = Get-ItemProperty -LiteralPath $disabledKey -ErrorAction Stop;
1066
+ $prop = $stored.PSObject.Properties[$name];
1067
+ if ($null -eq $prop) { throw "No disabled startup entry named '$name' is stored for $hive." }
1068
+ if (-not (Test-Path -LiteralPath $runKey)) { New-Item -Path $runKey -Force | Out-Null }
1069
+ New-ItemProperty -LiteralPath $runKey -Name $name -Value ([string]$prop.Value) -PropertyType String -Force | Out-Null;
1070
+ Remove-ItemProperty -LiteralPath $disabledKey -Name $escapedName -ErrorAction Stop;
1071
+ } else {
1072
+ $current = Get-ItemProperty -LiteralPath $runKey -ErrorAction SilentlyContinue;
1073
+ $prop = if ($null -eq $current) { $null } else { $current.PSObject.Properties[$name] };
1074
+ if ($null -eq $prop -or [string]::IsNullOrWhiteSpace([string]$prop.Value)) { throw "No startup entry named '$name' exists for $hive." }
1075
+ if (-not (Test-Path -LiteralPath $disabledKey)) { New-Item -Path $disabledKey -Force | Out-Null }
1076
+ New-ItemProperty -LiteralPath $disabledKey -Name $name -Value ([string]$prop.Value) -PropertyType String -Force | Out-Null;
1077
+ Remove-ItemProperty -LiteralPath $runKey -Name $escapedName -ErrorAction Stop;
1078
+ }
1079
+ @{ok=$true} | ConvertTo-Json -Compress`;
1080
+ const result = await runPSJson(script, {
1081
+ env: envOf({ HIVE: hive, NAME: name, PATH: path, ENABLED: enabled }),
1082
+ });
1083
+ if (!result.ok) return commandFailure(`Toggle startup entry "${name}"`, result);
1084
+ return success(
1085
+ `Startup entry "${name}" ${enabled ? 'enabled' : 'disabled'} for ${hive}. It takes effect at the next sign-in.`,
1086
+ { path, hive, name, enabled },
1087
+ );
1088
+ },
1089
+ 'system.openSettings': async ({ pane }) => {
1090
+ const uri = `ms-settings:${PANE_URIS[pane] ?? pane}`;
1091
+ const script = `${PS_HEAD}
1092
+ Start-Process -FilePath $env:DEVICEX_ARG_URI -ErrorAction Stop;
1093
+ @{ok=$true} | ConvertTo-Json -Compress`;
1094
+ const result = await runPSJson(script, { env: envOf({ URI: uri }) });
1095
+ if (!result.ok) return commandFailure(`Open settings pane "${pane}"`, result);
1096
+ return success(`Opened Windows Settings: ${pane}.`, { pane, uri });
1097
+ },
1098
+ 'system.toggleFirewall': async ({ enabled }) => {
1099
+ const script = `${PS_HEAD}
1100
+ $enabled = $env:DEVICEX_ARG_ENABLED -eq 'true';
1101
+ Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled $enabled -ErrorAction Stop | Out-Null;
1102
+ @{ok=$true; enabled=$enabled} | ConvertTo-Json -Compress`;
1103
+ const result = await runPSJson(script, { env: envOf({ ENABLED: enabled }) });
1104
+ if (!result.ok) return commandFailure('Toggle Windows Firewall', result);
1105
+ return success(
1106
+ `Windows Firewall ${enabled ? 'enabled' : 'disabled'} for the Domain, Private and Public profiles.`,
1107
+ { enabled },
1108
+ );
1109
+ },
1110
+ 'system.flushDNS': async () => {
1111
+ const script = `${PS_HEAD}
1112
+ try {
1113
+ Clear-DnsClientCache -ErrorAction Stop;
1114
+ $method = 'Clear-DnsClientCache';
1115
+ } catch {
1116
+ & ${psString('ipconfig.exe')} /flushdns | Out-Null;
1117
+ if ($LASTEXITCODE -ne 0) { throw "ipconfig /flushdns failed with exit code $LASTEXITCODE." }
1118
+ $method = 'ipconfig /flushdns';
1119
+ }
1120
+ @{method=$method} | ConvertTo-Json -Compress`;
1121
+ const result = await runPSJson(script);
1122
+ if (!result.ok) return commandFailure('Flush DNS cache', result);
1123
+ return success('Flushed the DNS resolver cache.', { method: String(result.data?.method ?? 'Clear-DnsClientCache') });
1124
+ },
1125
+ 'system.emptyTrash': async () => {
1126
+ // Locale-independent emptiness probe first (the localized Clear-RecycleBin
1127
+ // error text differs per language); the English message match stays only as
1128
+ // a last-resort fallback inside catch.
1129
+ const script = `${PS_HEAD}
1130
+ $alreadyEmpty = $false;
1131
+ $shell = New-Object -ComObject Shell.Application;
1132
+ try {
1133
+ $bin = $shell.Namespace(0xA);
1134
+ if ($null -ne $bin -and @($bin.Items()).Count -eq 0) { $alreadyEmpty = $true }
1135
+ if (-not $alreadyEmpty) {
1136
+ try {
1137
+ Clear-RecycleBin -Force -ErrorAction Stop;
1138
+ } catch {
1139
+ if ($null -ne $shell.Namespace(0xA) -and @($shell.Namespace(0xA).Items()).Count -eq 0) { $alreadyEmpty = $true }
1140
+ elseif ([string]$_.Exception.Message -match '(?i)empty') { $alreadyEmpty = $true }
1141
+ else { throw }
1142
+ }
1143
+ }
1144
+ } finally {
1145
+ if ($null -ne $shell) { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) }
1146
+ }
1147
+ @{alreadyEmpty=$alreadyEmpty} | ConvertTo-Json -Compress`;
1148
+ const result = await runPSJson(script);
1149
+ if (!result.ok) return commandFailure('Empty the Recycle Bin', result);
1150
+ if (result.data?.alreadyEmpty === true) {
1151
+ return success('Recycle Bin is already empty.', { alreadyEmpty: true });
1152
+ }
1153
+ return success('Emptied the Recycle Bin.');
1154
+ },
1155
+ 'cleanup.delete': async ({ ids }) => {
1156
+ // The cleanup module owns the allowlist and the fixed absolute paths: the
1157
+ // action passes ids only, never a path, and reports per-item outcomes.
1158
+ return deleteCleanup(ids);
1159
+ },
1160
+ 'network.setWifiPower': async ({ enabled }) => {
1161
+ const script = `${PS_HEAD}
1162
+ $enabled = $env:DEVICEX_ARG_ENABLED -eq 'true';
1163
+ $adapter = Get-NetAdapter -Physical -ErrorAction Stop | Where-Object { $_.InterfaceDescription -match 'Wi-Fi|Wireless|WLAN' -or $_.NdisPhysicalMedium -in 1,8,9,12 } | Select-Object -First 1;
1164
+ if ($null -eq $adapter) { throw 'No physical wireless adapter was found.' }
1165
+ if ($enabled) { Enable-NetAdapter -Name $adapter.Name -Confirm:$false -ErrorAction Stop | Out-Null } else { Disable-NetAdapter -Name $adapter.Name -Confirm:$false -ErrorAction Stop | Out-Null }
1166
+ @{adapter=$adapter.Name; enabled=$enabled} | ConvertTo-Json -Compress`;
1167
+ const result = await runPSJson(script, { env: envOf({ ENABLED: enabled }) });
1168
+ if (!result.ok) return commandFailure(`Turn Wi-Fi ${enabled ? 'on' : 'off'}`, result);
1169
+ const adapter = String(result.data?.adapter ?? 'wireless adapter');
1170
+ return success(`Wi-Fi adapter "${adapter}" ${enabled ? 'enabled' : 'disabled'}.`, { adapter, enabled });
1171
+ },
1172
+ 'network.setDns': async ({ service, servers }) => {
1173
+ const script = `${PS_HEAD}
1174
+ $service = $env:DEVICEX_ARG_SERVICE;
1175
+ $adapters = @(Get-NetAdapter -Name $service -ErrorAction Stop | Where-Object { $_.Name -eq $service });
1176
+ if ($adapters.Count -eq 0) { throw "Network adapter '$service' was not found." }
1177
+ if ($adapters.Count -gt 1) { throw "More than one network adapter is named '$service'." }
1178
+ $index = $adapters[0].ifIndex;
1179
+ $addresses = @($env:DEVICEX_ARG_SERVERS -split '\\|' | Where-Object { $_ -ne '' });
1180
+ if ($addresses.Count -eq 0) {
1181
+ Set-DnsClientServerAddress -InterfaceIndex $index -ResetServerAddresses -ErrorAction Stop;
1182
+ } else {
1183
+ Set-DnsClientServerAddress -InterfaceIndex $index -ServerAddresses $addresses -ErrorAction Stop;
1184
+ }
1185
+ @{ok=$true} | ConvertTo-Json -Compress`;
1186
+ const result = await runPSJson(script, { env: envOf({ SERVICE: service, SERVERS: servers.join('|') }) });
1187
+ if (!result.ok) return commandFailure(`Set DNS servers for "${service}"`, result);
1188
+ if (servers.length === 0) {
1189
+ return success(`Cleared custom DNS servers for "${service}".`, { service, servers: [] });
1190
+ }
1191
+ return success(`Set DNS servers for "${service}" to ${servers.join(', ')}.`, { service, servers });
1192
+ },
1193
+ 'power.sleep': async () => {
1194
+ // Success is signalled by the parsed JSON document, not the process exit
1195
+ // code: with `-Command -` a terminating error can reach stderr while the
1196
+ // host still exits 0.
1197
+ const script = `${PS_HEAD}
1198
+ Start-Process -FilePath ${psString('rundll32.exe')} -ArgumentList 'powrprof.dll,SetSuspendState','0,1,0' -ErrorAction Stop;
1199
+ @{ok=$true} | ConvertTo-Json -Compress`;
1200
+ const result = await runPSJson(script);
1201
+ if (!result.ok) return commandFailure('Sleep', result);
1202
+ return success(
1203
+ 'Requested system sleep. (SetSuspendState follows the system power policy, so this may hibernate instead of sleep.)',
1204
+ );
1205
+ },
1206
+ 'power.lock': async () => {
1207
+ const script = `${PS_HEAD}
1208
+ Start-Process -FilePath ${psString('rundll32.exe')} -ArgumentList 'user32.dll,LockWorkStation' -ErrorAction Stop;
1209
+ @{ok=$true} | ConvertTo-Json -Compress`;
1210
+ const result = await runPSJson(script);
1211
+ if (!result.ok) return commandFailure('Lock the workstation', result);
1212
+ return success('Locked the workstation.');
1213
+ },
1214
+ 'software.wingetUninstall': async ({ id }) => uninstallWinget({ token: id, id }, false),
1215
+ };
1216
+
1217
+ /* -------------------------------------------------------------------------- */
1218
+ /* History and public API */
1219
+ /* -------------------------------------------------------------------------- */
1220
+
1221
+ /** @type {Array<{ts: number, id: string, params: object, ok: boolean, message: string}>} */
1222
+ const history = [];
1223
+
1224
+ /**
1225
+ * @param {{ts: number, id: string, params: object, ok: boolean, message: string}} entry
1226
+ * @returns {void}
1227
+ */
1228
+ function recordHistory(entry) {
1229
+ history.unshift(entry);
1230
+ if (history.length > HISTORY_LIMIT) history.length = HISTORY_LIMIT;
1231
+ }
1232
+
1233
+ /**
1234
+ * The frozen action catalog, in stable order.
1235
+ * @returns {Array<{id: string, group: string, danger: string,
1236
+ * requiresAdmin: boolean, params: object[]}>}
1237
+ */
1238
+ export function actionCatalog() {
1239
+ return SPECS.map((spec) => ({
1240
+ id: spec.id,
1241
+ group: spec.group,
1242
+ danger: spec.danger,
1243
+ requiresAdmin: spec.requiresAdmin,
1244
+ params: spec.params.map((descriptor) => ({
1245
+ ...descriptor,
1246
+ ...(descriptor.options ? { options: [...descriptor.options] } : {}),
1247
+ })),
1248
+ }));
1249
+ }
1250
+
1251
+ /**
1252
+ * Attempted actions, newest first, at most 50 entries.
1253
+ * @returns {Array<{ts: number, id: string, params: object, ok: boolean, message: string}>}
1254
+ */
1255
+ export function actionHistory() {
1256
+ return history.map((entry) => ({
1257
+ ts: entry.ts,
1258
+ id: entry.id,
1259
+ params: jsonSafe(entry.params),
1260
+ ok: entry.ok,
1261
+ message: entry.message,
1262
+ }));
1263
+ }
1264
+
1265
+ /**
1266
+ * Validate and execute one action. Never throws for bad input; every attempt
1267
+ * (success or failure) is appended to the history.
1268
+ *
1269
+ * @param {string} id action id from {@link actionCatalog}
1270
+ * @param {object} [params]
1271
+ * @returns {Promise<{ok: boolean, message: string, data?: object, code?: string}>}
1272
+ */
1273
+ export async function runAction(id, params) {
1274
+ const ts = Date.now();
1275
+ const actionId = typeof id === 'string' ? id : String(id);
1276
+ const requestParams = paramsObject(jsonSafe(params));
1277
+ let result;
1278
+ try {
1279
+ const spec = SPEC_BY_ID.get(actionId);
1280
+ const runner = RUNNERS[actionId];
1281
+ if (!spec || !runner) {
1282
+ result = failure(`Unknown action: ${actionId}`, 'UNKNOWN_ACTION');
1283
+ } else {
1284
+ const checked = spec.validate(requestParams);
1285
+ if (checked.ok !== true) {
1286
+ result = checked;
1287
+ } else {
1288
+ const candidate = await runner(checked.value);
1289
+ result =
1290
+ candidate && typeof candidate === 'object' && typeof candidate.ok === 'boolean'
1291
+ ? {
1292
+ ok: candidate.ok === true,
1293
+ message: String(candidate.message ?? ''),
1294
+ ...(candidate.data ? { data: jsonSafe(candidate.data) } : {}),
1295
+ ...(candidate.code ? { code: String(candidate.code) } : {}),
1296
+ }
1297
+ : failure('Action returned an invalid result.', 'INTERNAL');
1298
+ }
1299
+ }
1300
+ } catch (error) {
1301
+ result = failure(
1302
+ `Action failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
1303
+ 'INTERNAL',
1304
+ );
1305
+ }
1306
+ recordHistory({ ts, id: actionId, params: requestParams, ok: result.ok === true, message: result.message });
1307
+ return /** @type {any} */ (jsonSafe(result));
1308
+ }
1309
+
1310
+ /**
1311
+ * Test seam for the offline Windows verification script: pure validators and
1312
+ * catalog ids, no PowerShell involved.
1313
+ *
1314
+ * `validators[id](params)` returns `null` when valid or the error string when
1315
+ * not (pure, synchronous, never spawns a process). `validate[id](params)` is
1316
+ * the underlying normalized-result form.
1317
+ */
1318
+ export const __test = {
1319
+ actionIds: SPECS.map((spec) => spec.id),
1320
+ validators: Object.fromEntries(
1321
+ SPECS.map((spec) => [
1322
+ spec.id,
1323
+ (params) => {
1324
+ const checked = spec.validate(paramsObject(params));
1325
+ return checked.ok === true ? null : String(checked.message);
1326
+ },
1327
+ ]),
1328
+ ),
1329
+ validate: Object.fromEntries(
1330
+ SPECS.map((spec) => [spec.id, (params) => spec.validate(paramsObject(params))]),
1331
+ ),
1332
+ guards: { parseRegistryKey, parseUninstallId, parseLaunchAgentPath, readDnsServers, readExistingPath, readCleanupIds },
1333
+ };