@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/transport.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Device X browser API:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The Device X browser API: same-origin JSON routes on the DSH Web server,
|
|
3
|
+
* each behind the shipped Connection trust fence (the page's signed session
|
|
4
|
+
* cookie authenticates the caller; foreign origins are rejected by the
|
|
5
|
+
* Host/Origin fence). Route handlers never shell out themselves — they call
|
|
6
|
+
* the shared operation surface, exactly like the agent tools.
|
|
7
7
|
*
|
|
8
8
|
* @module @cfour-hi/dsh-plugin-devicex/host/transport
|
|
9
9
|
*/
|
|
@@ -219,6 +219,28 @@ export function registerRoutes(ctx, operations) {
|
|
|
219
219
|
}),
|
|
220
220
|
}));
|
|
221
221
|
|
|
222
|
+
disposers.push(ctx.webServer.register({
|
|
223
|
+
kind: 'exact',
|
|
224
|
+
path: `${API_PREFIX}/footprint`,
|
|
225
|
+
handler: guarded(async (req, res) => {
|
|
226
|
+
if (rejected(ctx, req, res)) return;
|
|
227
|
+
if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
|
|
228
|
+
const url = requestUrl(req);
|
|
229
|
+
const appPath = url.searchParams.get('path');
|
|
230
|
+
const fresh = url.searchParams.get('fresh') === '1';
|
|
231
|
+
if (appPath === null || appPath.trim() === '') {
|
|
232
|
+
sendJson(res, 400, {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: { code: 'missing-path', message: 'query parameter "path" is required' },
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// Read-only: no action-history entry, no filesystem writes.
|
|
239
|
+
const footprint = await operations.scanFootprint({ path: appPath, fresh });
|
|
240
|
+
sendJson(res, 200, { ok: true, footprint });
|
|
241
|
+
}),
|
|
242
|
+
}));
|
|
243
|
+
|
|
222
244
|
disposers.push(ctx.webServer.register({
|
|
223
245
|
kind: 'exact',
|
|
224
246
|
path: `${API_PREFIX}/benchmark`,
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X — uninstall pipeline.
|
|
3
|
+
*
|
|
4
|
+
* One shared state machine so both platforms behave identically and the
|
|
5
|
+
* ordering rule is enforced in exactly one place:
|
|
6
|
+
*
|
|
7
|
+
* scan -> [user confirms] -> uninstall -> VERIFY -> trash app -> re-scan -> trash leftovers
|
|
8
|
+
*
|
|
9
|
+
* Two invariants matter more than the happy path:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Verification gates everything.** A vendor uninstaller exiting 0 is not
|
|
12
|
+
* evidence that the app is gone (MSI returns 0/3010/1641, vendor scripts
|
|
13
|
+
* lie). If the app is still installed afterwards, the run aborts and
|
|
14
|
+
* *nothing* is deleted — the failure path has zero side effects.
|
|
15
|
+
* 2. **A missing uninstaller is not a failure.** Only 2 of 79 measured apps
|
|
16
|
+
* ship one; treating "none" as failure would disable the feature almost
|
|
17
|
+
* everywhere. So `hasUninstaller: false` skips straight to trashing the app.
|
|
18
|
+
*
|
|
19
|
+
* The pipeline never invents paths. Leftover items come from a scan token
|
|
20
|
+
* resolved by `footprint.resolveScanItems`, and the platform adapter performs
|
|
21
|
+
* the actual moves.
|
|
22
|
+
*
|
|
23
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/uninstall
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} UninstallSteps
|
|
28
|
+
* @property {(appPath: string) => Promise<{ok: boolean, message: string, code?: string}>} uninstallApp
|
|
29
|
+
* run the vendor uninstaller; `ok: false` aborts the pipeline
|
|
30
|
+
* @property {(appPath: string) => Promise<boolean>} isStillInstalled
|
|
31
|
+
* objective post-condition check (path gone / registry key gone)
|
|
32
|
+
* @property {(appPath: string) => Promise<{ok: boolean, message: string, destination?: string, code?: string}>} trashApp
|
|
33
|
+
* @property {(absolutePath: string) => Promise<{ok: boolean, message: string, code?: string}>} trashPath
|
|
34
|
+
* move one leftover to the trash
|
|
35
|
+
* @property {(appPath: string) => Promise<object>} rescan
|
|
36
|
+
* re-run the footprint scan after the app is gone
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Pipeline outcomes, in the order they can occur. */
|
|
40
|
+
export const PHASES = Object.freeze([
|
|
41
|
+
'uninstall',
|
|
42
|
+
'verify',
|
|
43
|
+
'trash-app',
|
|
44
|
+
'rescan',
|
|
45
|
+
'trash-leftovers',
|
|
46
|
+
'done',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format bytes for a user-facing message.
|
|
51
|
+
* @param {unknown} value
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function humanBytes(value) {
|
|
55
|
+
const bytes = Number(value);
|
|
56
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
|
57
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
58
|
+
let index = 0;
|
|
59
|
+
let current = bytes;
|
|
60
|
+
while (current >= 1024 && index < units.length - 1) {
|
|
61
|
+
current /= 1024;
|
|
62
|
+
index += 1;
|
|
63
|
+
}
|
|
64
|
+
return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run the uninstall pipeline.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} request
|
|
71
|
+
* @param {string} request.appPath resolved application path / install location
|
|
72
|
+
* @param {boolean} request.hasUninstaller whether the app ships its own uninstaller
|
|
73
|
+
* @param {boolean} [request.cleanupApp] move the app itself to the trash when
|
|
74
|
+
* there is no uninstaller (or after it ran); default true
|
|
75
|
+
* @param {Array<{id: string, path: string}>} [request.items] leftovers chosen by
|
|
76
|
+
* the user; already resolved through the scan token
|
|
77
|
+
* @param {number} [request.leftoverBytes] bytes those leftovers occupy, used
|
|
78
|
+
* only for the user-facing message
|
|
79
|
+
* @param {UninstallSteps} steps platform adapter
|
|
80
|
+
* @returns {Promise<object>} a JSON-safe report
|
|
81
|
+
*/
|
|
82
|
+
export async function runUninstallPipeline(request, steps) {
|
|
83
|
+
const startedAt = Date.now();
|
|
84
|
+
const appPath = typeof request?.appPath === 'string' ? request.appPath : '';
|
|
85
|
+
/** @type {string[]} */
|
|
86
|
+
const errors = [];
|
|
87
|
+
|
|
88
|
+
/** @type {object} */
|
|
89
|
+
const report = {
|
|
90
|
+
ok: false,
|
|
91
|
+
aborted: false,
|
|
92
|
+
phase: 'uninstall',
|
|
93
|
+
appPath,
|
|
94
|
+
uninstaller: {
|
|
95
|
+
ran: false,
|
|
96
|
+
ok: false,
|
|
97
|
+
message: '',
|
|
98
|
+
},
|
|
99
|
+
verified: false,
|
|
100
|
+
appTrashed: false,
|
|
101
|
+
appTrashPath: null,
|
|
102
|
+
leftoversRequested: Array.isArray(request?.items) ? request.items.length : 0,
|
|
103
|
+
leftoversRemoved: 0,
|
|
104
|
+
leftoversFailed: 0,
|
|
105
|
+
leftoverBytes: 0,
|
|
106
|
+
leftoverResults: [],
|
|
107
|
+
errors,
|
|
108
|
+
durationMs: 0,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const finish = (phase, ok, message) => {
|
|
112
|
+
report.phase = phase;
|
|
113
|
+
report.ok = ok;
|
|
114
|
+
report.message = message;
|
|
115
|
+
report.durationMs = Date.now() - startedAt;
|
|
116
|
+
return report;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const abort = (phase, message) => {
|
|
120
|
+
report.phase = phase;
|
|
121
|
+
report.aborted = true;
|
|
122
|
+
report.ok = false;
|
|
123
|
+
report.message = message;
|
|
124
|
+
report.durationMs = Date.now() - startedAt;
|
|
125
|
+
return report;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (appPath === '') return abort('uninstall', 'No application path was supplied.');
|
|
129
|
+
|
|
130
|
+
// ---- 1. vendor uninstaller (only when one exists) ----------------------
|
|
131
|
+
if (request.hasUninstaller === true) {
|
|
132
|
+
report.phase = 'uninstall';
|
|
133
|
+
const result = await steps.uninstallApp(appPath);
|
|
134
|
+
report.uninstaller.ran = true;
|
|
135
|
+
report.uninstaller.ok = result?.ok === true;
|
|
136
|
+
report.uninstaller.message = typeof result?.message === 'string' ? result.message : '';
|
|
137
|
+
if (report.uninstaller.ok !== true) {
|
|
138
|
+
// Invariant 2: the failure path changes nothing on disk.
|
|
139
|
+
return abort(
|
|
140
|
+
'uninstall',
|
|
141
|
+
`The application's own uninstaller did not complete, so nothing was removed. ${report.uninstaller.message}`.trim(),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- 2. verify the app is actually gone --------------------------------
|
|
147
|
+
// Verification only makes sense as a gate after something claimed to remove
|
|
148
|
+
// the app. When there is no vendor uninstaller the app is obviously still
|
|
149
|
+
// installed - that is the expected state, and step 3 removes it - so asking
|
|
150
|
+
// and then aborting would make the feature unusable for 77 of 79 apps.
|
|
151
|
+
report.phase = 'verify';
|
|
152
|
+
if (request.hasUninstaller === true) {
|
|
153
|
+
let stillInstalled;
|
|
154
|
+
try {
|
|
155
|
+
stillInstalled = await steps.isStillInstalled(appPath);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
errors.push(`verification failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
158
|
+
return abort('verify', 'Could not verify whether the application was removed, so nothing was deleted.');
|
|
159
|
+
}
|
|
160
|
+
if (stillInstalled === true) {
|
|
161
|
+
return abort(
|
|
162
|
+
'verify',
|
|
163
|
+
'The application is still installed after its uninstaller finished, so nothing was deleted.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
report.verified = true;
|
|
168
|
+
|
|
169
|
+
// ---- 3. move the app itself to the trash ------------------------------
|
|
170
|
+
// After a successful vendor uninstall the bundle is usually already gone;
|
|
171
|
+
// only attempt the move when something is still there.
|
|
172
|
+
if (request.cleanupApp !== false) {
|
|
173
|
+
report.phase = 'trash-app';
|
|
174
|
+
let appStillThere = false;
|
|
175
|
+
try {
|
|
176
|
+
appStillThere = await steps.isStillInstalled(appPath);
|
|
177
|
+
} catch {
|
|
178
|
+
appStillThere = false;
|
|
179
|
+
}
|
|
180
|
+
if (appStillThere) {
|
|
181
|
+
const moved = await steps.trashApp(appPath);
|
|
182
|
+
report.appTrashed = moved?.ok === true;
|
|
183
|
+
report.appTrashPath = typeof moved?.destination === 'string' ? moved.destination : null;
|
|
184
|
+
if (moved?.ok !== true) {
|
|
185
|
+
return abort(
|
|
186
|
+
'trash-app',
|
|
187
|
+
`Could not move the application to the Trash, so no leftovers were touched. ${
|
|
188
|
+
typeof moved?.message === 'string' ? moved.message : ''
|
|
189
|
+
}`.trim(),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- 4. re-scan, because the uninstaller may have cleaned some data ----
|
|
196
|
+
report.phase = 'rescan';
|
|
197
|
+
if (Array.isArray(request?.items) && request.items.length > 0) {
|
|
198
|
+
try {
|
|
199
|
+
const after = await steps.rescan(appPath);
|
|
200
|
+
report.rescan = {
|
|
201
|
+
items: Array.isArray(after?.items) ? after.items.length : 0,
|
|
202
|
+
totalBytes: Number(after?.totalBytes) || 0,
|
|
203
|
+
errors: Array.isArray(after?.errors) ? after.errors.slice(0, 5) : [],
|
|
204
|
+
};
|
|
205
|
+
} catch (error) {
|
|
206
|
+
errors.push(`re-scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- 5. move the chosen leftovers to the trash ------------------------
|
|
211
|
+
if (Array.isArray(request?.items) && request.items.length > 0) {
|
|
212
|
+
report.phase = 'trash-leftovers';
|
|
213
|
+
for (const item of request.items) {
|
|
214
|
+
const path = typeof item?.path === 'string' ? item.path : '';
|
|
215
|
+
if (path === '') continue;
|
|
216
|
+
const outcome = await steps.trashPath(path);
|
|
217
|
+
const ok = outcome?.ok === true;
|
|
218
|
+
if (ok) report.leftoversRemoved += 1;
|
|
219
|
+
else report.leftoversFailed += 1;
|
|
220
|
+
report.leftoverResults.push({
|
|
221
|
+
id: typeof item?.id === 'string' ? item.id : '',
|
|
222
|
+
path,
|
|
223
|
+
ok,
|
|
224
|
+
message: typeof outcome?.message === 'string' ? outcome.message : '',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
report.leftoverBytes = Number(request.leftoverBytes) || 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const leftoverPart =
|
|
231
|
+
report.leftoversRequested === 0
|
|
232
|
+
? 'No leftover directories were selected.'
|
|
233
|
+
: report.leftoversFailed === 0
|
|
234
|
+
? `Moved ${report.leftoversRemoved} leftover location(s) to the Trash (${humanBytes(report.leftoverBytes)}).`
|
|
235
|
+
: `Moved ${report.leftoversRemoved} of ${report.leftoversRequested} leftover location(s) to the Trash; ${report.leftoversFailed} could not be moved.`;
|
|
236
|
+
|
|
237
|
+
// Trash is not freed space; say so rather than implying the disk grew.
|
|
238
|
+
const spaceHint =
|
|
239
|
+
report.leftoverBytes > 0
|
|
240
|
+
? ` Empty the Trash to actually free ${humanBytes(report.leftoverBytes)}.`
|
|
241
|
+
: '';
|
|
242
|
+
|
|
243
|
+
return finish('done', report.leftoversFailed === 0, `${leftoverPart}${spaceHint}`.trim());
|
|
244
|
+
}
|
package/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from './host/collector.js';
|
|
19
19
|
import { GROUPS, actionCatalog, actionHistory, runAction } from './host/actions.js';
|
|
20
20
|
import { scanCleanup } from './host/cleanup.js';
|
|
21
|
+
import { scanFootprint } from './host/footprint.js';
|
|
21
22
|
import { runBenchmark } from './host/benchmark.js';
|
|
22
23
|
import { registerRoutes } from './host/transport.js';
|
|
23
24
|
import { registerTools } from './host/tool.js';
|
|
@@ -44,6 +45,7 @@ const operations = {
|
|
|
44
45
|
actionHistory,
|
|
45
46
|
runAction,
|
|
46
47
|
scanCleanup,
|
|
48
|
+
scanFootprint,
|
|
47
49
|
runBenchmark,
|
|
48
50
|
};
|
|
49
51
|
|