@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.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/client.js +1008 -0
- package/cordis.patch.yml +3 -0
- package/host/actions-darwin.js +1261 -0
- package/host/actions-win32.js +1333 -0
- package/host/actions.js +45 -0
- package/host/benchmark.js +413 -0
- package/host/cleanup-darwin.js +715 -0
- package/host/cleanup-win32.js +760 -0
- package/host/cleanup.js +62 -0
- package/host/collector-darwin.js +2995 -0
- package/host/collector-win32.js +2060 -0
- package/host/collector.js +71 -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/proc.js +232 -0
- package/host/tool.js +254 -0
- package/host/transport.js +316 -0
- package/host/uninstall.js +244 -0
- package/host/win/hardware.ps1 +501 -0
- package/host/win/metrics.ps1 +535 -0
- package/host/win/network.ps1 +322 -0
- package/host/win/processes.ps1 +169 -0
- package/host/win/security.ps1 +197 -0
- package/host/win/software.ps1 +119 -0
- package/host/win/system.ps1 +279 -0
- package/host/win.js +187 -0
- package/icon.svg +18 -0
- package/index.js +67 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X — uninstall footprint scanner (macOS).
|
|
3
|
+
*
|
|
4
|
+
* Read-only. Finds where an installed `.app` keeps data outside its bundle:
|
|
5
|
+
* a sweep of the user and system data roots, an optional Homebrew cask `zap`
|
|
6
|
+
* recipe, and an entitlements probe that decides whether the app writes into a
|
|
7
|
+
* sandbox container or into `~/Library` directly.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here deletes, moves or opens a handle for writing. The only child
|
|
10
|
+
* processes are `plutil` and `codesign` (both argv-only).
|
|
11
|
+
*
|
|
12
|
+
* Why not REA / `strings`: measured on a real host, most native binaries do not
|
|
13
|
+
* contain `Library/...` path literals at all (they build paths at runtime from
|
|
14
|
+
* `NSSearchPathForDirectoriesInDomains`), and REA's JavaScript graph does not
|
|
15
|
+
* retain filesystem-path literals even when the source obviously has them. The
|
|
16
|
+
* sweep below is what actually found the leftover directories, including the
|
|
17
|
+
* one nobody's bundle-id rule can see (`Application Support/Claude-3p`, 11 GB).
|
|
18
|
+
*
|
|
19
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/footprint-darwin
|
|
20
|
+
*/
|
|
21
|
+
import * as fsp from 'node:fs/promises';
|
|
22
|
+
import * as os from 'node:os';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { collectSection } from './collector-darwin.js';
|
|
26
|
+
import { run } from './proc.js';
|
|
27
|
+
import { buildIndex, classify, dedupeApps, isSameApp, isVendorShared, selectedBytes, slugify, toAppContext, totalBytes } from './footprint-core.js';
|
|
28
|
+
|
|
29
|
+
/** Host platform for this implementation. */
|
|
30
|
+
export const PLATFORM = 'darwin';
|
|
31
|
+
|
|
32
|
+
/** Maximum directory entries visited per candidate (size measurement). */
|
|
33
|
+
const MAX_ENTRIES = 40_000;
|
|
34
|
+
/** Wall-clock budget per candidate size walk (ms). */
|
|
35
|
+
const TIME_BUDGET_MS = 3_000;
|
|
36
|
+
/** Maximum candidates returned in one report. */
|
|
37
|
+
const MAX_ITEMS = 200;
|
|
38
|
+
/** Maximum installed apps read for conflict detection. */
|
|
39
|
+
const MAX_APPS = 600;
|
|
40
|
+
/** Maximum diagnostic strings kept. */
|
|
41
|
+
const MAX_ERRORS = 20;
|
|
42
|
+
|
|
43
|
+
const HOME = os.homedir();
|
|
44
|
+
const PLUTIL = '/usr/bin/plutil';
|
|
45
|
+
const CODESIGN = '/usr/bin/codesign';
|
|
46
|
+
|
|
47
|
+
/** Directories whose direct children are candidate leftover locations. */
|
|
48
|
+
export const USER_ROOTS = Object.freeze([
|
|
49
|
+
'Application Support',
|
|
50
|
+
'Caches',
|
|
51
|
+
'Preferences',
|
|
52
|
+
'Preferences/ByHost',
|
|
53
|
+
'Logs',
|
|
54
|
+
'HTTPStorages',
|
|
55
|
+
'WebKit',
|
|
56
|
+
'Cookies',
|
|
57
|
+
'Saved Application State',
|
|
58
|
+
'Containers',
|
|
59
|
+
'Group Containers',
|
|
60
|
+
'Application Scripts',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** System-wide counterparts: reported, never deletable without admin rights. */
|
|
64
|
+
export const SYSTEM_ROOTS = Object.freeze([
|
|
65
|
+
'/Library/Application Support',
|
|
66
|
+
'/Library/Caches',
|
|
67
|
+
'/Library/Preferences',
|
|
68
|
+
'/Library/Logs',
|
|
69
|
+
'/Library/LaunchAgents',
|
|
70
|
+
'/Library/LaunchDaemons',
|
|
71
|
+
'/Library/PrivilegedHelperTools',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Where installed bundles live. */
|
|
75
|
+
const APP_DIRS = Object.freeze(['/Applications', path.join(HOME, 'Applications'), '/System/Applications']);
|
|
76
|
+
|
|
77
|
+
/** Homebrew cask receipt roots, in lookup order. */
|
|
78
|
+
const CASKROOM_CANDIDATES = ['/opt/homebrew/Caskroom', '/usr/local/Caskroom'];
|
|
79
|
+
|
|
80
|
+
/* -------------------------------------------------------------------------- */
|
|
81
|
+
/* Small utilities */
|
|
82
|
+
/* -------------------------------------------------------------------------- */
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Shorten an error message to one JSON-friendly line.
|
|
86
|
+
* @param {unknown} value
|
|
87
|
+
* @param {number} [max]
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function shortErr(value, max = 200) {
|
|
91
|
+
const text = String(value ?? 'unknown error')
|
|
92
|
+
.replace(/\s+/g, ' ')
|
|
93
|
+
.trim();
|
|
94
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `~`-abbreviate an absolute path for display.
|
|
99
|
+
* @param {string} absolute
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function displayPath(absolute) {
|
|
103
|
+
return absolute === HOME ? '~' : absolute.startsWith(`${HOME}${path.sep}`) ? `~${absolute.slice(HOME.length)}` : absolute;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read one raw string value from an Info.plist without launching anything.
|
|
108
|
+
* @param {string} plistPath
|
|
109
|
+
* @param {string} key
|
|
110
|
+
* @returns {Promise<string|null>}
|
|
111
|
+
*/
|
|
112
|
+
async function readPlistString(plistPath, key) {
|
|
113
|
+
const result = await run(PLUTIL, ['-extract', key, 'raw', '-o', '-', plistPath], { timeout: 5_000 });
|
|
114
|
+
if (!result.ok) return null;
|
|
115
|
+
const value = String(result.stdout ?? '').trim();
|
|
116
|
+
return value === '' ? null : value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* -------------------------------------------------------------------------- */
|
|
120
|
+
/* Installed applications (needed for conflict detection) */
|
|
121
|
+
/* -------------------------------------------------------------------------- */
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read every installed application's name and bundle id.
|
|
125
|
+
*
|
|
126
|
+
* This is the whole point of scanning all apps rather than just the subject:
|
|
127
|
+
* a directory that matches two installed apps must be downgraded to the
|
|
128
|
+
* `conflict` tier instead of being preselected.
|
|
129
|
+
*
|
|
130
|
+
* The software section is the preferred source: it is already collected for the
|
|
131
|
+
* Software tab, carries ~340 bundles (including nested helpers the plain folder
|
|
132
|
+
* scan cannot see), and is cached for 300 s, so a scan costs no extra process
|
|
133
|
+
* launches. The directory listing is the fallback for a cold/absent collector.
|
|
134
|
+
*
|
|
135
|
+
* @param {string[]} errors collector for non-fatal diagnostics
|
|
136
|
+
* @returns {Promise<Array<{name: string, bundleId: string|null, slug: string}>>}
|
|
137
|
+
*/
|
|
138
|
+
export async function readInstalledApps(errors = []) {
|
|
139
|
+
try {
|
|
140
|
+
const section = await collectSection('software');
|
|
141
|
+
const rows = Array.isArray(section?.applications) ? section.applications : [];
|
|
142
|
+
/** @type {Array<{name: string, bundleId: string|null, slug: string}>} */
|
|
143
|
+
const apps = [];
|
|
144
|
+
for (const row of rows) {
|
|
145
|
+
if (!row || typeof row.name !== 'string' || row.name === '') continue;
|
|
146
|
+
if (apps.length >= MAX_APPS) break;
|
|
147
|
+
const bundleId = typeof row.bundleId === 'string' && row.bundleId !== '' ? row.bundleId : null;
|
|
148
|
+
apps.push(toAppContext({ name: row.name, bundleId }));
|
|
149
|
+
}
|
|
150
|
+
if (apps.length > 0) return apps;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
errors.push(`software section: ${shortErr(error)}`);
|
|
153
|
+
}
|
|
154
|
+
return readAppsFromDirectories(errors);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fallback roster: enumerate `.app` bundles in the three standard locations and
|
|
159
|
+
* read each `CFBundleIdentifier` with `plutil`.
|
|
160
|
+
* @param {string[]} errors
|
|
161
|
+
* @returns {Promise<Array<{name: string, bundleId: string|null, slug: string}>>}
|
|
162
|
+
*/
|
|
163
|
+
async function readAppsFromDirectories(errors = []) {
|
|
164
|
+
/** @type {Array<{name: string, bundleId: string|null, slug: string}>} */
|
|
165
|
+
const apps = [];
|
|
166
|
+
for (const dir of APP_DIRS) {
|
|
167
|
+
let entries;
|
|
168
|
+
try {
|
|
169
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// /System/Applications is present on every supported macOS; a miss on the
|
|
172
|
+
// optional ~/Applications is normal and must not become an error line.
|
|
173
|
+
if (dir !== path.join(HOME, 'Applications')) errors.push(`readdir ${displayPath(dir)}: ${shortErr(error)}`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (!entry.isDirectory() || !entry.name.endsWith('.app')) continue;
|
|
178
|
+
if (apps.length >= MAX_APPS) break;
|
|
179
|
+
const bundlePath = path.join(dir, entry.name);
|
|
180
|
+
const bundleId = await readPlistString(path.join(bundlePath, 'Contents', 'Info.plist'), 'CFBundleIdentifier');
|
|
181
|
+
apps.push(toAppContext({ name: entry.name.replace(/\.app$/, ''), bundleId }));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return apps;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* -------------------------------------------------------------------------- */
|
|
188
|
+
/* Sandbox detection */
|
|
189
|
+
/* -------------------------------------------------------------------------- */
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* True when the bundle declares the App Sandbox entitlement.
|
|
193
|
+
*
|
|
194
|
+
* `codesign` writes entitlements to stdout; its diagnostics go to stderr. A
|
|
195
|
+
* failure is reported as "not sandboxed" plus an error line rather than being
|
|
196
|
+
* guessed at, because the answer decides which roots we sweep.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} bundlePath resolved `.app` path
|
|
199
|
+
* @param {string[]} errors
|
|
200
|
+
* @returns {Promise<boolean>}
|
|
201
|
+
*/
|
|
202
|
+
export async function isSandboxed(bundlePath, errors = []) {
|
|
203
|
+
const result = await run(CODESIGN, ['-d', '--entitlements', '-', bundlePath], { timeout: 10_000 });
|
|
204
|
+
const text = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
205
|
+
if (!result.ok && text === '') {
|
|
206
|
+
errors.push(`codesign entitlements: ${shortErr(result.error ?? 'no output')}`);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
return /com\.apple\.security\.app-sandbox/.test(text);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* -------------------------------------------------------------------------- */
|
|
213
|
+
/* Homebrew cask zap recipe */
|
|
214
|
+
/* -------------------------------------------------------------------------- */
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Glob patterns from a cask's recorded `zap` stanza, if this app is managed by
|
|
218
|
+
* Homebrew. These are curated by humans and much better than any heuristic, so
|
|
219
|
+
* they are treated as top-tier evidence.
|
|
220
|
+
*
|
|
221
|
+
* @param {string|null} bundleId
|
|
222
|
+
* @param {string[]} errors
|
|
223
|
+
* @returns {Promise<string[]>} raw glob patterns, `~`-relative or absolute
|
|
224
|
+
*/
|
|
225
|
+
export async function readBrewZap(bundleId, errors = []) {
|
|
226
|
+
if (typeof bundleId !== 'string' || bundleId === '') return [];
|
|
227
|
+
for (const caskroom of CASKROOM_CANDIDATES) {
|
|
228
|
+
let casks;
|
|
229
|
+
try {
|
|
230
|
+
casks = await fsp.readdir(caskroom, { withFileTypes: true });
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
for (const cask of casks) {
|
|
235
|
+
if (!cask.isDirectory()) continue;
|
|
236
|
+
const receipt = path.join(caskroom, cask.name, '.metadata', 'INSTALL_RECEIPT.json');
|
|
237
|
+
let raw;
|
|
238
|
+
try {
|
|
239
|
+
raw = await fsp.readFile(receipt, 'utf8');
|
|
240
|
+
} catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
let parsed;
|
|
244
|
+
try {
|
|
245
|
+
parsed = JSON.parse(raw);
|
|
246
|
+
} catch {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// Only trust the receipt when it belongs to this exact bundle id.
|
|
250
|
+
const installed = parsed?.uninstall_artifacts;
|
|
251
|
+
if (!Array.isArray(installed)) continue;
|
|
252
|
+
const belongs = JSON.stringify(installed).includes(bundleId);
|
|
253
|
+
if (!belongs) continue;
|
|
254
|
+
/** @type {string[]} */
|
|
255
|
+
const patterns = [];
|
|
256
|
+
for (const artifact of installed) {
|
|
257
|
+
if (!artifact || typeof artifact !== 'object') continue;
|
|
258
|
+
const zap = artifact.zap;
|
|
259
|
+
if (!Array.isArray(zap)) continue;
|
|
260
|
+
for (const entry of zap) {
|
|
261
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
262
|
+
if (Array.isArray(entry.trash)) {
|
|
263
|
+
for (const value of entry.trash) if (typeof value === 'string') patterns.push(value);
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(entry.rmdir)) {
|
|
266
|
+
for (const value of entry.rmdir) if (typeof value === 'string') patterns.push(value);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (patterns.length > 0) return patterns;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Expand a zap pattern into candidate base names inside the user roots.
|
|
278
|
+
*
|
|
279
|
+
* Cask recipes mix absolute paths, `~/`-relative paths and globs. Only the
|
|
280
|
+
* final path segment is used here, because the sweep enumerates directories
|
|
281
|
+
* per root and matches by base name; a glob segment is reduced to its literal
|
|
282
|
+
* prefix ( `IINA*.plist` → `iina` ).
|
|
283
|
+
*
|
|
284
|
+
* @param {string[]} patterns
|
|
285
|
+
* @returns {Set<string>} lowercased base names
|
|
286
|
+
*/
|
|
287
|
+
export function zapBaseNames(patterns) {
|
|
288
|
+
/** @type {Set<string>} */
|
|
289
|
+
const names = new Set();
|
|
290
|
+
for (const pattern of Array.isArray(patterns) ? patterns : []) {
|
|
291
|
+
if (typeof pattern !== 'string') continue;
|
|
292
|
+
const base = pattern.split('/').filter((segment) => segment !== '').pop();
|
|
293
|
+
if (base === undefined) continue;
|
|
294
|
+
const literal = base.split('*')[0].toLowerCase();
|
|
295
|
+
if (literal !== '') names.add(literal);
|
|
296
|
+
}
|
|
297
|
+
return names;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* -------------------------------------------------------------------------- */
|
|
301
|
+
/* Bounded size walk */
|
|
302
|
+
/* -------------------------------------------------------------------------- */
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @typedef {object} WalkResult
|
|
306
|
+
* @property {number} bytes
|
|
307
|
+
* @property {number} files
|
|
308
|
+
* @property {string|null} truncated
|
|
309
|
+
*/
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Measure one candidate with `lstat` only (never following symlinks), capped by
|
|
313
|
+
* entry count and wall clock. A truncated walk still reports what it saw.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} root
|
|
316
|
+
* @param {{maxEntries?: number, timeBudgetMs?: number}} [options]
|
|
317
|
+
* @returns {Promise<WalkResult>}
|
|
318
|
+
*/
|
|
319
|
+
export async function measurePath(root, options = {}) {
|
|
320
|
+
const maxEntries = options.maxEntries ?? MAX_ENTRIES;
|
|
321
|
+
const timeBudgetMs = options.timeBudgetMs ?? TIME_BUDGET_MS;
|
|
322
|
+
const deadline = Date.now() + timeBudgetMs;
|
|
323
|
+
let bytes = 0;
|
|
324
|
+
let files = 0;
|
|
325
|
+
let entries = 0;
|
|
326
|
+
/** @type {string|null} */
|
|
327
|
+
let truncated = null;
|
|
328
|
+
/** @type {string[]} */
|
|
329
|
+
let level = [root];
|
|
330
|
+
|
|
331
|
+
outer: while (level.length > 0) {
|
|
332
|
+
/** @type {string[]} */
|
|
333
|
+
const next = [];
|
|
334
|
+
for (const current of level) {
|
|
335
|
+
if (Date.now() > deadline) {
|
|
336
|
+
truncated = `timed out after ${timeBudgetMs} ms`;
|
|
337
|
+
break outer;
|
|
338
|
+
}
|
|
339
|
+
let stat;
|
|
340
|
+
try {
|
|
341
|
+
stat = await fsp.lstat(current);
|
|
342
|
+
} catch {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (stat.isSymbolicLink()) continue;
|
|
346
|
+
if (stat.isFile()) {
|
|
347
|
+
bytes += stat.size;
|
|
348
|
+
files += 1;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (!stat.isDirectory()) continue;
|
|
352
|
+
entries += 1;
|
|
353
|
+
if (entries > maxEntries) {
|
|
354
|
+
truncated = `stopped after ${maxEntries} entries`;
|
|
355
|
+
break outer;
|
|
356
|
+
}
|
|
357
|
+
let children;
|
|
358
|
+
try {
|
|
359
|
+
children = await fsp.readdir(current);
|
|
360
|
+
} catch {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
for (const child of children) next.push(path.join(current, child));
|
|
364
|
+
}
|
|
365
|
+
level = next;
|
|
366
|
+
}
|
|
367
|
+
return { bytes, files, truncated };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/* -------------------------------------------------------------------------- */
|
|
371
|
+
/* Sweep */
|
|
372
|
+
/* -------------------------------------------------------------------------- */
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* One candidate directory found by the sweep.
|
|
376
|
+
* @typedef {object} Candidate
|
|
377
|
+
* @property {string} name base name as found on disk
|
|
378
|
+
* @property {string} absolute absolute path
|
|
379
|
+
* @property {boolean} requiresAdmin
|
|
380
|
+
* @property {string} root which sweep root produced it
|
|
381
|
+
*/
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Enumerate the direct children of every root, without descending.
|
|
385
|
+
*
|
|
386
|
+
* Depth is deliberately 1: an app's own directory is a direct child of these
|
|
387
|
+
* roots, and recursing would turn a 12 ms scan into a multi-second one with no
|
|
388
|
+
* extra reach (measured: depth 2 cost 3.1 s and added zero true positives).
|
|
389
|
+
*
|
|
390
|
+
* @param {string[]} roots absolute roots
|
|
391
|
+
* @param {boolean} requiresAdmin value to stamp on candidates from these roots
|
|
392
|
+
* @param {string[]} errors
|
|
393
|
+
* @param {{timeBudgetMs?: number}} [options]
|
|
394
|
+
* @returns {Promise<Candidate[]>}
|
|
395
|
+
*/
|
|
396
|
+
export async function sweepRoots(roots, requiresAdmin, errors = [], options = {}) {
|
|
397
|
+
const deadline = Date.now() + (options.timeBudgetMs ?? 5_000);
|
|
398
|
+
/** @type {Candidate[]} */
|
|
399
|
+
const out = [];
|
|
400
|
+
for (const root of roots) {
|
|
401
|
+
if (Date.now() > deadline) {
|
|
402
|
+
errors.push('sweep budget exhausted');
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
let entries;
|
|
406
|
+
try {
|
|
407
|
+
entries = await fsp.readdir(root, { withFileTypes: true });
|
|
408
|
+
} catch {
|
|
409
|
+
// A missing or unreadable root is normal (TCC, absent /Library subdir).
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
for (const entry of entries) {
|
|
413
|
+
if (!entry.isDirectory()) continue;
|
|
414
|
+
out.push({ name: entry.name, absolute: path.join(root, entry.name), requiresAdmin, root: path.basename(root) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* True when a vendor-level directory actually contains the subject app's data.
|
|
422
|
+
*
|
|
423
|
+
* `~/Library/Application Support/Google` is shared by Chrome, Chrome for
|
|
424
|
+
* Testing and the updater; it is only relevant to Chrome. So a vendor directory
|
|
425
|
+
* is inspected one level deep and kept only when a child matches the subject.
|
|
426
|
+
* This is what stops an unrelated product's 17 GB vendor folder from appearing
|
|
427
|
+
* in an IINA uninstall dialog.
|
|
428
|
+
*
|
|
429
|
+
* @param {string} vendorAbsolute
|
|
430
|
+
* @param {{bundleId: string|null, slug: string}} subject
|
|
431
|
+
* @returns {Promise<boolean>}
|
|
432
|
+
*/
|
|
433
|
+
export async function vendorHoldsSubject(vendorAbsolute, subject) {
|
|
434
|
+
let entries;
|
|
435
|
+
try {
|
|
436
|
+
entries = await fsp.readdir(vendorAbsolute, { withFileTypes: true });
|
|
437
|
+
} catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
const bundleId = typeof subject?.bundleId === 'string' ? subject.bundleId.toLowerCase() : '';
|
|
441
|
+
const slug = typeof subject?.slug === 'string' ? subject.slug : '';
|
|
442
|
+
for (const entry of entries) {
|
|
443
|
+
const lowered = entry.name.toLowerCase();
|
|
444
|
+
if (bundleId !== '' && (lowered === bundleId || lowered.startsWith(`${bundleId}.`))) return true;
|
|
445
|
+
if (slug !== '' && lowered === slug) return true;
|
|
446
|
+
// Vendor directories often nest the product one level deeper, e.g.
|
|
447
|
+
// `Google/Chrome`, `Microsoft/DeveloperTools`.
|
|
448
|
+
if (slug !== '' && slugify(entry.name) === slug) return true;
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* -------------------------------------------------------------------------- */
|
|
454
|
+
/* Report */
|
|
455
|
+
/* -------------------------------------------------------------------------- */
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Stable, scan-local item id. Derived from the path so the same scan produces
|
|
459
|
+
* stable ids across two sweeps; the id is never a path and never reveals one.
|
|
460
|
+
* @param {string} absolute
|
|
461
|
+
* @returns {string}
|
|
462
|
+
*/
|
|
463
|
+
function itemId(absolute) {
|
|
464
|
+
let hash = 0x811c9dc5;
|
|
465
|
+
for (let index = 0; index < absolute.length; index += 1) {
|
|
466
|
+
hash ^= absolute.charCodeAt(index);
|
|
467
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
468
|
+
}
|
|
469
|
+
return `fp_${hash.toString(16).padStart(8, '0')}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Scan one application's leftover footprint. Read-only; never throws.
|
|
474
|
+
*
|
|
475
|
+
* @param {{appPath?: string, installedApps?: Array<object>, timeBudgetMs?: number}} [options]
|
|
476
|
+
* @returns {Promise<object>} a `FootprintReport` (without `scanId`, which the
|
|
477
|
+
* dispatcher adds when it registers the token)
|
|
478
|
+
*/
|
|
479
|
+
export async function scanFootprint(options = {}) {
|
|
480
|
+
const startedAt = Date.now();
|
|
481
|
+
/** @type {string[]} */
|
|
482
|
+
const errors = [];
|
|
483
|
+
const appPath = typeof options.appPath === 'string' ? options.appPath : '';
|
|
484
|
+
|
|
485
|
+
const empty = (extra = {}) => ({
|
|
486
|
+
appPath,
|
|
487
|
+
bundleId: null,
|
|
488
|
+
sandboxed: false,
|
|
489
|
+
targetedRoots: [],
|
|
490
|
+
generatedAt: startedAt,
|
|
491
|
+
durationMs: Date.now() - startedAt,
|
|
492
|
+
items: [],
|
|
493
|
+
totalBytes: 0,
|
|
494
|
+
selectedBytes: 0,
|
|
495
|
+
errors,
|
|
496
|
+
...extra,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
if (appPath === '') {
|
|
500
|
+
errors.push('missing application path');
|
|
501
|
+
return empty();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let bundleId = null;
|
|
505
|
+
try {
|
|
506
|
+
bundleId = await readPlistString(path.join(appPath, 'Contents', 'Info.plist'), 'CFBundleIdentifier');
|
|
507
|
+
} catch (error) {
|
|
508
|
+
errors.push(`read Info.plist: ${shortErr(error)}`);
|
|
509
|
+
}
|
|
510
|
+
if (bundleId === null) {
|
|
511
|
+
errors.push(`no CFBundleIdentifier in ${displayPath(appPath)}`);
|
|
512
|
+
return empty();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const subject = toAppContext({ name: path.basename(appPath).replace(/\.app$/, ''), bundleId });
|
|
516
|
+
const sandboxed = await isSandboxed(appPath, errors);
|
|
517
|
+
|
|
518
|
+
const installedApps = Array.isArray(options.installedApps)
|
|
519
|
+
? options.installedApps.map((app) => toAppContext(app))
|
|
520
|
+
: await readInstalledApps(errors);
|
|
521
|
+
// The subject must be present in the roster even if the sweep of app folders
|
|
522
|
+
// missed it (e.g. a bundle outside the three standard locations). Duplicate
|
|
523
|
+
// rows are collapsed first: the collector reports some bundles three times,
|
|
524
|
+
// which would otherwise read as three competing owners.
|
|
525
|
+
const roster = dedupeApps(
|
|
526
|
+
installedApps.some((app) => isSameApp(app, subject)) ? installedApps : [...installedApps, subject],
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const zapPatterns = await readBrewZap(bundleId, errors);
|
|
530
|
+
const zapNames = zapBaseNames(zapPatterns);
|
|
531
|
+
|
|
532
|
+
const userRoots = USER_ROOTS.map((root) => path.join(HOME, 'Library', root));
|
|
533
|
+
const timeBudgetMs = options.timeBudgetMs;
|
|
534
|
+
const userCandidates = await sweepRoots(userRoots, false, errors, { timeBudgetMs });
|
|
535
|
+
const systemCandidates = await sweepRoots([...SYSTEM_ROOTS], true, errors, { timeBudgetMs });
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* @typedef {Candidate & {fromBrewZap: boolean,
|
|
539
|
+
* confidence: import('../../types/contracts.js').FootprintConfidence,
|
|
540
|
+
* source: import('../../types/contracts.js').FootprintSource,
|
|
541
|
+
* reason: string, alsoMatchedBy: string[]}} Classified
|
|
542
|
+
*/
|
|
543
|
+
/** @type {Map<string, Classified>} */
|
|
544
|
+
const kept = new Map();
|
|
545
|
+
|
|
546
|
+
for (const candidate of /** @type {Candidate[]} */ ([...userCandidates, ...systemCandidates])) {
|
|
547
|
+
const lowered = candidate.name.toLowerCase();
|
|
548
|
+
const fromBrewZap = [...zapNames].some((literal) => lowered === literal || lowered.startsWith(literal));
|
|
549
|
+
// A vendor-level directory is only relevant when it actually holds the
|
|
550
|
+
// subject's own data; otherwise it belongs to some other product.
|
|
551
|
+
let holdsSubject = false;
|
|
552
|
+
if (isVendorShared(candidate.name)) {
|
|
553
|
+
holdsSubject = await vendorHoldsSubject(candidate.absolute, subject);
|
|
554
|
+
if (!holdsSubject) continue;
|
|
555
|
+
}
|
|
556
|
+
const verdict = classify(candidate, subject, roster, { fromBrewZap, holdsSubject });
|
|
557
|
+
if (verdict === null) continue;
|
|
558
|
+
const existing = kept.get(candidate.absolute);
|
|
559
|
+
if (existing && existing.confidence === 'high') continue;
|
|
560
|
+
kept.set(candidate.absolute, { ...candidate, ...verdict, fromBrewZap });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// The app bundle itself is always the first, mandatory row.
|
|
564
|
+
/** @type {Array<object>} */
|
|
565
|
+
const items = [];
|
|
566
|
+
const appMeasure = await measurePath(appPath);
|
|
567
|
+
items.push({
|
|
568
|
+
id: itemId(appPath),
|
|
569
|
+
kind: 'app',
|
|
570
|
+
path: appPath,
|
|
571
|
+
displayPath: displayPath(appPath),
|
|
572
|
+
bytes: appMeasure.bytes,
|
|
573
|
+
files: appMeasure.files,
|
|
574
|
+
confidence: 'high',
|
|
575
|
+
source: 'bundle-id',
|
|
576
|
+
reason: 'The application bundle itself.',
|
|
577
|
+
requiresAdmin: false,
|
|
578
|
+
exists: true,
|
|
579
|
+
...(appMeasure.truncated ? { warning: appMeasure.truncated } : {}),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const ordered = [...kept.values()].sort((left, right) => {
|
|
583
|
+
const rank = { high: 0, medium: 1, conflict: 2, shared: 3 };
|
|
584
|
+
const diff = (rank[left.confidence] ?? 9) - (rank[right.confidence] ?? 9);
|
|
585
|
+
return diff !== 0 ? diff : left.absolute.localeCompare(right.absolute);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
for (const candidate of ordered.slice(0, MAX_ITEMS)) {
|
|
589
|
+
const measure = await measurePath(candidate.absolute);
|
|
590
|
+
const kind = candidate.requiresAdmin
|
|
591
|
+
? 'system'
|
|
592
|
+
: candidate.confidence === 'shared'
|
|
593
|
+
? 'shared'
|
|
594
|
+
: /\/Containers\//.test(candidate.absolute)
|
|
595
|
+
? 'container'
|
|
596
|
+
: 'data';
|
|
597
|
+
items.push({
|
|
598
|
+
id: itemId(candidate.absolute),
|
|
599
|
+
kind,
|
|
600
|
+
path: candidate.absolute,
|
|
601
|
+
displayPath: displayPath(candidate.absolute),
|
|
602
|
+
bytes: measure.bytes,
|
|
603
|
+
files: measure.files,
|
|
604
|
+
confidence: candidate.confidence,
|
|
605
|
+
source: candidate.source,
|
|
606
|
+
reason: candidate.reason,
|
|
607
|
+
...(candidate.alsoMatchedBy.length > 0 ? { alsoMatchedBy: candidate.alsoMatchedBy } : {}),
|
|
608
|
+
requiresAdmin: candidate.requiresAdmin,
|
|
609
|
+
exists: true,
|
|
610
|
+
...(measure.truncated ? { warning: measure.truncated } : {}),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
if (ordered.length > MAX_ITEMS) {
|
|
614
|
+
errors.push(`report truncated to ${MAX_ITEMS} of ${ordered.length} candidates`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
appPath,
|
|
619
|
+
bundleId,
|
|
620
|
+
sandboxed,
|
|
621
|
+
targetedRoots: [...userRoots.map((root) => displayPath(root)), ...SYSTEM_ROOTS],
|
|
622
|
+
generatedAt: startedAt,
|
|
623
|
+
durationMs: Date.now() - startedAt,
|
|
624
|
+
items,
|
|
625
|
+
totalBytes: totalBytes(items),
|
|
626
|
+
selectedBytes: selectedBytes(items) - appMeasure.bytes,
|
|
627
|
+
errors,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Every root this scanner may ever report a candidate from.
|
|
633
|
+
*
|
|
634
|
+
* The dispatcher uses this list — not the report — to bound deletion, so a bug
|
|
635
|
+
* in the sweep cannot widen what `app.uninstall` is allowed to touch. It is
|
|
636
|
+
* computed once at module load and never derived from a request.
|
|
637
|
+
*/
|
|
638
|
+
export const SAFE_ROOTS = Object.freeze([
|
|
639
|
+
...USER_ROOTS.map((root) => path.join(HOME, 'Library', root)),
|
|
640
|
+
...SYSTEM_ROOTS,
|
|
641
|
+
...APP_DIRS,
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
/** Test seam: fixed roots and the pure helpers, for offline verification. */
|
|
645
|
+
export const __test = {
|
|
646
|
+
PLATFORM,
|
|
647
|
+
USER_ROOTS,
|
|
648
|
+
SYSTEM_ROOTS,
|
|
649
|
+
APP_DIRS,
|
|
650
|
+
SAFE_ROOTS,
|
|
651
|
+
MAX_ITEMS,
|
|
652
|
+
MAX_ENTRIES,
|
|
653
|
+
TIME_BUDGET_MS,
|
|
654
|
+
measurePath,
|
|
655
|
+
sweepRoots,
|
|
656
|
+
zapBaseNames,
|
|
657
|
+
readBrewZap,
|
|
658
|
+
isSandboxed,
|
|
659
|
+
readInstalledApps,
|
|
660
|
+
vendorHoldsSubject,
|
|
661
|
+
itemId,
|
|
662
|
+
buildIndex,
|
|
663
|
+
};
|