@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,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
+ };