@cfour-hi/dsh-plugin-devicex 1.5.0 → 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.
- package/client.js +539 -285
- package/host/actions-darwin.js +230 -21
- package/host/actions-win32.js +38 -4
- package/host/collector-win32.js +3 -0
- package/host/footprint-core.js +399 -0
- package/host/footprint-darwin.js +663 -0
- package/host/footprint-win32.js +411 -0
- package/host/footprint.js +284 -0
- package/host/transport.js +27 -5
- package/host/uninstall.js +244 -0
- package/index.js +2 -0
- package/package.json +1 -1
package/host/actions-darwin.js
CHANGED
|
@@ -16,6 +16,8 @@ import * as os from 'node:os';
|
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
|
|
18
18
|
import { cleanupItems, deleteCleanup } from './cleanup-darwin.js';
|
|
19
|
+
import { consumeToken, resolveScanItems, scanFootprint } from './footprint.js';
|
|
20
|
+
import { runUninstallPipeline } from './uninstall.js';
|
|
19
21
|
import { run } from './proc.js';
|
|
20
22
|
|
|
21
23
|
/** Action groups, in UI order. */
|
|
@@ -187,6 +189,11 @@ const SPECS = [
|
|
|
187
189
|
params: [
|
|
188
190
|
param('path', 'string', { placeholder: '/Applications/SomeApp.app' }),
|
|
189
191
|
param('mode', 'select', { required: false, options: ['trash', 'delete'], placeholder: 'trash' }),
|
|
192
|
+
// 1.6: optional footprint cleanup. All three are omitted by the plain UI
|
|
193
|
+
// flow, which keeps the single-step behaviour exactly as before.
|
|
194
|
+
param('scanId', 'string', { required: false, placeholder: 'scan id from GET /devicex/api/footprint' }),
|
|
195
|
+
param('itemIds', 'string', { required: false, multiple: true, placeholder: 'fp_1a2b3c4d, fp_5e6f7a8b' }),
|
|
196
|
+
param('cleanup', 'select', { required: false, options: ['trash', 'none'], placeholder: 'trash' }),
|
|
190
197
|
],
|
|
191
198
|
},
|
|
192
199
|
{
|
|
@@ -575,39 +582,108 @@ async function guardedAppBundle(raw, mode) {
|
|
|
575
582
|
}
|
|
576
583
|
|
|
577
584
|
/**
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
585
|
+
* Reserve a destination name inside the Trash atomically.
|
|
586
|
+
*
|
|
587
|
+
* `rename` overwrites an existing destination without complaint, so probing
|
|
588
|
+
* with `access()` first and then renaming is a race that can silently destroy
|
|
589
|
+
* whatever landed in between. Creating the destination ourselves first makes
|
|
590
|
+
* the reservation atomic: `mkdir` and `open('wx')` both fail with EEXIST when
|
|
591
|
+
* the name is taken, and the following `rename` only ever overwrites our own
|
|
592
|
+
* placeholder.
|
|
593
|
+
*
|
|
594
|
+
* @param {string} trash absolute Trash directory
|
|
595
|
+
* @param {string} stem desired base name
|
|
596
|
+
* @param {string} extension suffix to keep ('' for none, '.app' for a bundle)
|
|
597
|
+
* @param {boolean} isDirectory whether the source is a directory
|
|
598
|
+
* @returns {Promise<string>} the reserved absolute destination path
|
|
581
599
|
*/
|
|
582
|
-
async function
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
let destination = path.join(trash, `${stem}-${Date.now()}.app`);
|
|
588
|
-
let suffix = 1;
|
|
589
|
-
while (true) {
|
|
600
|
+
async function reserveTrashName(trash, stem, extension, isDirectory) {
|
|
601
|
+
let attempt = 0;
|
|
602
|
+
while (attempt < 100) {
|
|
603
|
+
const suffix = attempt === 0 ? '' : `-${attempt}`;
|
|
604
|
+
const destination = path.join(trash, `${stem}-${Date.now()}${suffix}${extension}`);
|
|
590
605
|
try {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
606
|
+
if (isDirectory) {
|
|
607
|
+
await fsp.mkdir(destination);
|
|
608
|
+
} else {
|
|
609
|
+
const handle = await fsp.open(destination, 'wx');
|
|
610
|
+
await handle.close();
|
|
611
|
+
}
|
|
612
|
+
return destination;
|
|
613
|
+
} catch (error) {
|
|
614
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
615
|
+
if (code === 'EEXIST') {
|
|
616
|
+
attempt += 1;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
throw error;
|
|
596
620
|
}
|
|
597
621
|
}
|
|
622
|
+
throw Object.assign(new Error('Could not reserve a unique name in the Trash.'), { code: 'EEXIST' });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Move a file or directory into the user's Trash under a unique name.
|
|
627
|
+
*
|
|
628
|
+
* Portable across bundle and data-directory sources: the previous version
|
|
629
|
+
* hard-coded a `.app` suffix, so trashing `com.example.app` produced
|
|
630
|
+
* `com.example-1712345678901.app` — a data directory wearing an application
|
|
631
|
+
* name.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} source resolved absolute path
|
|
634
|
+
* @param {{trashDir?: string}} [options] `trashDir` exists only so offline tests
|
|
635
|
+
* can drive a sandbox instead of the real `~/.Trash`.
|
|
636
|
+
* @returns {Promise<string>} the destination path
|
|
637
|
+
*/
|
|
638
|
+
async function moveToTrash(source, options = {}) {
|
|
639
|
+
const trash = options.trashDir ?? path.join(os.homedir(), '.Trash');
|
|
640
|
+
await fsp.mkdir(trash, { recursive: true });
|
|
641
|
+
|
|
642
|
+
const base = path.basename(source);
|
|
643
|
+
const stat = await fsp.lstat(source);
|
|
644
|
+
const isDirectory = stat.isDirectory();
|
|
645
|
+
const extension = /\.app$/i.test(base) ? '.app' : '';
|
|
646
|
+
const stem = extension === '' ? base : base.slice(0, -extension.length);
|
|
647
|
+
|
|
648
|
+
const destination = await reserveTrashName(trash, stem, extension, isDirectory);
|
|
598
649
|
try {
|
|
650
|
+
// `rename` onto our own placeholder is atomic and cannot clobber a third
|
|
651
|
+
// party: the name was reserved a moment ago and nothing else can claim it.
|
|
599
652
|
await fsp.rename(source, destination);
|
|
600
653
|
return destination;
|
|
601
|
-
} catch (
|
|
602
|
-
|
|
603
|
-
|
|
654
|
+
} catch (error) {
|
|
655
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
656
|
+
if (code !== 'EXDEV') {
|
|
657
|
+
// Release the placeholder so a failed move does not litter the Trash.
|
|
658
|
+
await fsp.rm(destination, { recursive: true, force: true }).catch(() => {});
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
// Cross-device: copy into the placeholder, then remove the source. The
|
|
662
|
+
// placeholder already exists, so `force: true` is safe here and keeps the
|
|
663
|
+
// partial copy from surviving a failure.
|
|
664
|
+
try {
|
|
665
|
+
await fsp.cp(source, destination, { recursive: true, force: true });
|
|
666
|
+
} catch (copyError) {
|
|
667
|
+
await fsp.rm(destination, { recursive: true, force: true }).catch(() => {});
|
|
668
|
+
throw copyError;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
604
671
|
await fsp.rm(source, { recursive: true, force: false, maxRetries: 2, retryDelay: 100 });
|
|
605
|
-
|
|
672
|
+
} catch (removeError) {
|
|
673
|
+
// The copy exists, so the data is safe; report the leftover so the caller
|
|
674
|
+
// can tell the user the original is still in place.
|
|
675
|
+
const reason = removeError instanceof Error ? removeError.message : String(removeError);
|
|
676
|
+
throw Object.assign(new Error(`Copied to Trash but could not remove the original: ${reason}`), {
|
|
677
|
+
code: 'PARTIAL_TRASH',
|
|
678
|
+
});
|
|
606
679
|
}
|
|
607
|
-
|
|
680
|
+
return destination;
|
|
608
681
|
}
|
|
609
682
|
}
|
|
610
683
|
|
|
684
|
+
/** Test seam: expose the Trash mover so offline tests can drive a sandbox. */
|
|
685
|
+
export const __trashTest = { moveToTrash, reserveTrashName };
|
|
686
|
+
|
|
611
687
|
/* -------------------------------------------------------------------------- */
|
|
612
688
|
/* Action runners */
|
|
613
689
|
/* -------------------------------------------------------------------------- */
|
|
@@ -616,6 +692,132 @@ async function moveToTrash(source) {
|
|
|
616
692
|
* @typedef {(params: Record<string, unknown>) => Promise<{ok: boolean, message: string, data?: object, code?: string}>} Runner
|
|
617
693
|
*/
|
|
618
694
|
|
|
695
|
+
/**
|
|
696
|
+
* The 1.6 footprint pipeline adapter for macOS.
|
|
697
|
+
*
|
|
698
|
+
* **No vendor uninstaller is ever executed here**, by design. macOS apps are
|
|
699
|
+
* uninstalled by dragging them to the Trash; the two helpers that exist on a
|
|
700
|
+
* real host ship as opaque binaries or scripts whose argument contract cannot
|
|
701
|
+
* be verified, and running one with a guessed flag risks a half-finished
|
|
702
|
+
* uninstall. So `hasUninstaller` is always false: the bundle goes to the Trash
|
|
703
|
+
* and the leftover list is offered separately. `uninstall.js` still supports
|
|
704
|
+
* the uninstaller path for platforms where it is verified (Windows
|
|
705
|
+
* MSI/winget/registry).
|
|
706
|
+
*
|
|
707
|
+
* @param {Record<string, unknown>} params validated-ish request params
|
|
708
|
+
* @param {string} appPath resolved bundle path
|
|
709
|
+
* @returns {Promise<object>} an ActionResult
|
|
710
|
+
*/
|
|
711
|
+
async function runDarwinUninstallPipeline(params, appPath) {
|
|
712
|
+
const scanId = typeof params.scanId === 'string' ? params.scanId : '';
|
|
713
|
+
const itemIds = params.itemIds;
|
|
714
|
+
const cleanup = params.cleanup === 'none' ? 'none' : 'trash';
|
|
715
|
+
|
|
716
|
+
let items = [];
|
|
717
|
+
if (cleanup === 'trash' && itemIds !== undefined && itemIds !== null) {
|
|
718
|
+
const resolved = resolveScanItems(scanId, itemIds);
|
|
719
|
+
if (resolved.ok !== true) return failure(resolved.message, resolved.code);
|
|
720
|
+
items = resolved.items;
|
|
721
|
+
if (resolved.appPath !== '' && resolved.appPath !== appPath) {
|
|
722
|
+
return failure('The scan belongs to a different application.', 'SCAN_MISMATCH');
|
|
723
|
+
}
|
|
724
|
+
} else if (cleanup === 'trash' && itemIds !== undefined) {
|
|
725
|
+
return failure('itemIds must be an array of scan item ids.', 'BAD_PARAMS');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Refuse to touch a running app: its files would be recreated and the moves
|
|
729
|
+
// would fail half-way through.
|
|
730
|
+
const running = await isBundleRunning(appPath);
|
|
731
|
+
if (running !== null) {
|
|
732
|
+
return failure(
|
|
733
|
+
`"${running}" is still running. Quit it before uninstalling so its files are not recreated.`,
|
|
734
|
+
'APP_RUNNING',
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const report = await runUninstallPipeline(
|
|
739
|
+
{
|
|
740
|
+
appPath,
|
|
741
|
+
hasUninstaller: false,
|
|
742
|
+
cleanupApp: true,
|
|
743
|
+
items,
|
|
744
|
+
leftoverBytes: items.reduce((sum, item) => sum + (Number(item.bytes) || 0), 0),
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
async uninstallApp() {
|
|
748
|
+
// Unreachable: `hasUninstaller: false` above. Implemented as a refusal
|
|
749
|
+
// so a future change cannot quietly start executing a bundle helper.
|
|
750
|
+
return {
|
|
751
|
+
ok: false,
|
|
752
|
+
message: 'macOS uninstalls by moving the bundle to the Trash; no vendor uninstaller is executed.',
|
|
753
|
+
code: 'NO_UNINSTALLER',
|
|
754
|
+
};
|
|
755
|
+
},
|
|
756
|
+
async isStillInstalled(target) {
|
|
757
|
+
try {
|
|
758
|
+
await fsp.lstat(target);
|
|
759
|
+
return true;
|
|
760
|
+
} catch {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
async trashApp(target) {
|
|
765
|
+
try {
|
|
766
|
+
const destination = await moveToTrash(target);
|
|
767
|
+
return { ok: true, message: `Moved to Trash as ${path.basename(destination)}.`, destination };
|
|
768
|
+
} catch (error) {
|
|
769
|
+
return {
|
|
770
|
+
ok: false,
|
|
771
|
+
message: error instanceof Error ? error.message : String(error),
|
|
772
|
+
code: error && typeof error === 'object' && 'code' in error ? String(error.code) : 'TRASH_FAILED',
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
async trashPath(target) {
|
|
777
|
+
try {
|
|
778
|
+
const destination = await moveToTrash(target);
|
|
779
|
+
return { ok: true, message: `Moved to Trash as ${path.basename(destination)}.`, destination };
|
|
780
|
+
} catch (error) {
|
|
781
|
+
return {
|
|
782
|
+
ok: false,
|
|
783
|
+
message: error instanceof Error ? error.message : String(error),
|
|
784
|
+
code: error && typeof error === 'object' && 'code' in error ? String(error.code) : 'TRASH_FAILED',
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
async rescan(target) {
|
|
789
|
+
return scanFootprint({ path: target, fresh: true });
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
if (report.ok === true) consumeToken(scanId);
|
|
795
|
+
|
|
796
|
+
const data = { ...report };
|
|
797
|
+
if (report.ok === true) return success(report.message, data);
|
|
798
|
+
return { ok: false, message: report.message, code: report.aborted ? 'ABORTED' : 'PARTIAL', data };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* The display name of a running app whose bundle path matches, or null.
|
|
803
|
+
*
|
|
804
|
+
* Uses `ps` rather than `NSRunningApplication` because the whole host half is
|
|
805
|
+
* argv-only `execFile`; the bundle path appears in the process command line for
|
|
806
|
+
* every app launched by LaunchServices.
|
|
807
|
+
*
|
|
808
|
+
* @param {string} appPath
|
|
809
|
+
* @returns {Promise<string|null>}
|
|
810
|
+
*/
|
|
811
|
+
async function isBundleRunning(appPath) {
|
|
812
|
+
const result = await run('/bin/ps', ['-Ao', 'command='], { timeout: 8_000, maxBuffer: 8 * 1024 * 1024 });
|
|
813
|
+
if (!result.ok) return null;
|
|
814
|
+
const needle = `${appPath}/Contents/MacOS/`;
|
|
815
|
+
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
816
|
+
if (line.includes(needle)) return path.basename(appPath);
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
619
821
|
/** @type {Record<string, Runner>} */
|
|
620
822
|
const RUNNERS = {
|
|
621
823
|
async 'process.signal'(params) {
|
|
@@ -675,6 +877,13 @@ const RUNNERS = {
|
|
|
675
877
|
if (!modeRead.ok) return failure(modeRead.message, 'BAD_PARAMS');
|
|
676
878
|
const guarded = await guardedAppBundle(pathRead.value, modeRead.value);
|
|
677
879
|
if (!guarded.ok) return guarded;
|
|
880
|
+
|
|
881
|
+
// 1.6: when a scan id is present the caller wants the footprint pipeline.
|
|
882
|
+
// Without one, behaviour is byte-for-byte the pre-1.6 single step.
|
|
883
|
+
if (typeof params.scanId === 'string' && params.scanId !== '') {
|
|
884
|
+
return runDarwinUninstallPipeline(params, guarded.path);
|
|
885
|
+
}
|
|
886
|
+
|
|
678
887
|
const name = path.basename(guarded.path);
|
|
679
888
|
if (modeRead.value === 'trash') {
|
|
680
889
|
try {
|
package/host/actions-win32.js
CHANGED
|
@@ -316,6 +316,22 @@ function parseUninstallId(params) {
|
|
|
316
316
|
if (!raw.ok) return raw;
|
|
317
317
|
const id = raw.value;
|
|
318
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
|
+
|
|
319
335
|
if (lower.startsWith('msi:')) {
|
|
320
336
|
if (!MSI_ID_PATTERN.test(id)) {
|
|
321
337
|
return failure(
|
|
@@ -323,18 +339,18 @@ function parseUninstallId(params) {
|
|
|
323
339
|
'BAD_PARAMS',
|
|
324
340
|
);
|
|
325
341
|
}
|
|
326
|
-
return {
|
|
342
|
+
return withScan({ kind: 'msi', id, guid: id.slice(4).toUpperCase() });
|
|
327
343
|
}
|
|
328
344
|
if (lower.startsWith('winget:')) {
|
|
329
345
|
const token = id.slice('winget:'.length);
|
|
330
346
|
if (!WINGET_TOKEN_PATTERN.test(token)) {
|
|
331
347
|
return failure('winget uninstall ids may only contain letters, digits and . _ + -.', 'BAD_PARAMS');
|
|
332
348
|
}
|
|
333
|
-
return {
|
|
349
|
+
return withScan({ kind: 'winget', id, token });
|
|
334
350
|
}
|
|
335
351
|
const registry = parseRegistryKey(id);
|
|
336
352
|
if (!registry.ok) return registry;
|
|
337
|
-
return {
|
|
353
|
+
return withScan({ kind: 'registry', id, key: registry.value });
|
|
338
354
|
}
|
|
339
355
|
|
|
340
356
|
/** Parse `startup:<HKCU|HKLM|HKLM32>:<ValueName>` for `system.launchAgent`. * @returns {{ok: true, value: any} | {ok: false, message: string, code?: string}}
|
|
@@ -520,7 +536,14 @@ const SPECS = [
|
|
|
520
536
|
group: 'app',
|
|
521
537
|
danger: 'high',
|
|
522
538
|
requiresAdmin: false,
|
|
523
|
-
params: [
|
|
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
|
+
],
|
|
524
547
|
/**
|
|
525
548
|
* @returns {{ok: true, value: Win32Params['app.uninstall']} | {ok: false, message: string, code?: string}}
|
|
526
549
|
*/
|
|
@@ -981,6 +1004,17 @@ Start-Process -FilePath ${psString('explorer.exe')} -ArgumentList ('/select,"' +
|
|
|
981
1004
|
return success(`Revealed "${path}" in File Explorer.`, { path });
|
|
982
1005
|
},
|
|
983
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
|
+
}
|
|
984
1018
|
if (value.kind === 'msi') return uninstallMsi(value);
|
|
985
1019
|
if (value.kind === 'winget') return uninstallWinget(value, true);
|
|
986
1020
|
return uninstallRegistry(value);
|
package/host/collector-win32.js
CHANGED
|
@@ -1367,6 +1367,9 @@ function mapSoftware(raw, transportError) {
|
|
|
1367
1367
|
arch: str(application.arch),
|
|
1368
1368
|
lastUsed: null,
|
|
1369
1369
|
sizeBytes: estimatedSizeKb === null ? null : Math.round(estimatedSizeKb * 1024),
|
|
1370
|
+
// Additive (1.6): `software.ps1` already reads Publisher, and Windows
|
|
1371
|
+
// has no bundle id, so leftover matching needs it.
|
|
1372
|
+
publisher: str(application.publisher),
|
|
1370
1373
|
};
|
|
1371
1374
|
})
|
|
1372
1375
|
.filter((application) => application.name !== null);
|