@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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X host collectors — platform dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* The data contract (`Metrics`, the six sections, `Inventory`) is identical on
|
|
5
|
+
* every supported platform; this module only picks the implementation:
|
|
6
|
+
*
|
|
7
|
+
* - `darwin` → `collector-darwin.js` (macOS commands, the original target)
|
|
8
|
+
* - `win32` → `collector-win32.js` (PowerShell/CIM)
|
|
9
|
+
*
|
|
10
|
+
* `dsh.bundle.platforms` limits installation to those two hosts; the darwin
|
|
11
|
+
* branch is the safe fallback for an already-loaded tree on anything else
|
|
12
|
+
* (every command then fails into the section's `errors[]` instead of throwing).
|
|
13
|
+
*
|
|
14
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/collector
|
|
15
|
+
*/
|
|
16
|
+
import * as darwin from './collector-darwin.js';
|
|
17
|
+
import * as win32 from './collector-win32.js';
|
|
18
|
+
|
|
19
|
+
/** Host platform. */
|
|
20
|
+
/** @type {import('../../types/contracts.js').Platform} */
|
|
21
|
+
export const PLATFORM = /** @type {import('../../types/contracts.js').Platform} */ (process.platform);
|
|
22
|
+
|
|
23
|
+
/** Frozen section identifiers, in UI order. */
|
|
24
|
+
export const SECTIONS = ['hardware', 'system', 'software', 'network', 'security', 'processes'];
|
|
25
|
+
|
|
26
|
+
/** @type {import('../../types/contracts.js').PlatformCollector} */
|
|
27
|
+
const impl = process.platform === 'win32' ? win32 : darwin;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Start slow section collection in the background.
|
|
31
|
+
* @returns {void}
|
|
32
|
+
*/
|
|
33
|
+
export function warmup() {
|
|
34
|
+
return impl.warmup();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One fast metrics snapshot for the 2500 ms UI poll.
|
|
39
|
+
* @returns {Promise<object>}
|
|
40
|
+
*/
|
|
41
|
+
export function collectMetrics() {
|
|
42
|
+
return impl.collectMetrics();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Collect one section.
|
|
47
|
+
* @param {string} section one of SECTIONS.
|
|
48
|
+
* @param {{fresh?: boolean}} [options]
|
|
49
|
+
* @returns {Promise<object>}
|
|
50
|
+
*/
|
|
51
|
+
export function collectSection(section, options = {}) {
|
|
52
|
+
return impl.collectSection(section, options);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Full inventory for the panel's initial load and the agent tool.
|
|
57
|
+
* @param {{sections?: string[], fresh?: boolean}} [options]
|
|
58
|
+
* @returns {Promise<object>}
|
|
59
|
+
*/
|
|
60
|
+
export function collectInventory(options = {}) {
|
|
61
|
+
return impl.collectInventory(options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Drop cached section data.
|
|
66
|
+
* @param {string} [section]
|
|
67
|
+
* @returns {void}
|
|
68
|
+
*/
|
|
69
|
+
export function clearCache(section) {
|
|
70
|
+
return impl.clearCache(section);
|
|
71
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X — uninstall footprint: pure classification kernel.
|
|
3
|
+
*
|
|
4
|
+
* No filesystem, no child processes, no clock. Everything here is a pure
|
|
5
|
+
* function over plain data so the four confidence tiers can be unit-tested
|
|
6
|
+
* without touching a real `~/Library` (see `test/footprint-core.test.mjs`).
|
|
7
|
+
*
|
|
8
|
+
* The classification rules encode what was actually observed on a real macOS
|
|
9
|
+
* host, including the traps:
|
|
10
|
+
*
|
|
11
|
+
* - Group Containers carry a 10-character Team ID prefix
|
|
12
|
+
* (`6N38VWS5BX.ru.keepcoder.Telegram`), and shared groups additionally carry
|
|
13
|
+
* a literal `group.` marker (`group.W5364U7YZB.io.tailscale.shared`);
|
|
14
|
+
* - a directory can match more than one installed app purely by name
|
|
15
|
+
* (`Doubao` vs `DoubaoWork`), so tiering needs *every* installed app, not
|
|
16
|
+
* just the one being uninstalled;
|
|
17
|
+
* - substring matching is a trap: `Code` would swallow `Codex` and
|
|
18
|
+
* `CodeBuddyExtension`, so name matching is anchored, never substring.
|
|
19
|
+
*
|
|
20
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/footprint-core
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A 10-character Team ID followed by a dot, at the start of a directory name.
|
|
25
|
+
*
|
|
26
|
+
* Case-insensitive on purpose: callers lowercase directory names before
|
|
27
|
+
* matching, which turns `6N38VWS5BX` into `6n38vws5bx`.
|
|
28
|
+
*/
|
|
29
|
+
const TEAM_PREFIX = /^[A-Z0-9]{10}\./i;
|
|
30
|
+
/** The `group.` marker used by shared Group Containers, with or without a Team ID. */
|
|
31
|
+
const GROUP_PREFIX = /^group\./i;
|
|
32
|
+
/** The `group.<TeamID>.` form, kept separate so the Team ID is still checked. */
|
|
33
|
+
const GROUP_TEAM_PREFIX = /^group\.[A-Z0-9]{10}\./i;
|
|
34
|
+
|
|
35
|
+
/** Signals that a directory name is a vendor-level shared container. */
|
|
36
|
+
const VENDOR_ROOTS = new Set([
|
|
37
|
+
'google',
|
|
38
|
+
'microsoft',
|
|
39
|
+
'adobe',
|
|
40
|
+
'apple',
|
|
41
|
+
'mozilla',
|
|
42
|
+
'tencent',
|
|
43
|
+
'alibaba',
|
|
44
|
+
'bytedance',
|
|
45
|
+
'dropbox',
|
|
46
|
+
'jetbrains',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strip the Team ID (and the `group.` marker) from a container directory name.
|
|
51
|
+
* Non-container names pass through unchanged.
|
|
52
|
+
* @param {unknown} name
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function stripTeamPrefix(name) {
|
|
56
|
+
if (typeof name !== 'string') return '';
|
|
57
|
+
if (GROUP_TEAM_PREFIX.test(name)) return name.replace(GROUP_TEAM_PREFIX, '');
|
|
58
|
+
if (GROUP_PREFIX.test(name)) return name.replace(GROUP_PREFIX, '');
|
|
59
|
+
return name.replace(TEAM_PREFIX, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalise an application name to the slug used for name-similarity matching.
|
|
64
|
+
*
|
|
65
|
+
* Lowercase, drop a trailing `.app`, and collapse any run of non-alphanumeric
|
|
66
|
+
* characters into a single `-`, then trim the edges. Separators are *kept* as
|
|
67
|
+
* `-`: stripping them entirely would make `Claude-3p` collide with `claude3p`
|
|
68
|
+
* and lose the boundary that `matchesAppName` relies on. `Clash Verge` and
|
|
69
|
+
* `clash-verge` still compare equal.
|
|
70
|
+
* @param {unknown} name
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function slugify(name) {
|
|
74
|
+
if (typeof name !== 'string') return '';
|
|
75
|
+
return name
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/\.app$/, '')
|
|
78
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
79
|
+
.replace(/^-+|-+$/g, '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Strict slug: lowercase alphanumerics only, no separators.
|
|
84
|
+
*
|
|
85
|
+
* Used where a real filesystem name has to be compared against a slug with no
|
|
86
|
+
* known separator (`Packages/Acme.Widget_8wekyb3d8bbwe` → `acmewidget`).
|
|
87
|
+
* @param {unknown} name
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function compactSlug(name) {
|
|
91
|
+
return slugify(name).replace(/-/g, '');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Lowercase a directory base name for indexing.
|
|
96
|
+
* @param {unknown} name
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
function normalizeName(name) {
|
|
100
|
+
return typeof name === 'string' ? name.toLowerCase() : '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* True when `directoryName` is anchored to `bundleId`: either exactly equal, or
|
|
105
|
+
* a dot-separated child of it (`com.x.App` → `com.x.App.ShipIt`). A bare
|
|
106
|
+
* `startsWith` would make `com.foo` match `com.foobar`, which is wrong.
|
|
107
|
+
* @param {string} directoryName lowercased directory base name
|
|
108
|
+
* @param {string} bundleId lowercased bundle id
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function matchesBundleId(directoryName, bundleId) {
|
|
112
|
+
if (directoryName === '' || bundleId === '') return false;
|
|
113
|
+
if (directoryName === bundleId) return true;
|
|
114
|
+
return directoryName.startsWith(`${bundleId}.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* True when a directory name looks like an app-name match for `slug`.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately anchored at the start and separated by `-`, `.`, `_` or end of
|
|
121
|
+
* string. `code` matches `code` and `code-3p`, but never `codex`.
|
|
122
|
+
* @param {string} directoryName lowercased directory base name
|
|
123
|
+
* @param {string} slug slugified app name
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
export function matchesAppName(directoryName, slug) {
|
|
127
|
+
if (directoryName === '' || slug === '') return false;
|
|
128
|
+
if (directoryName === slug) return true;
|
|
129
|
+
return (
|
|
130
|
+
directoryName.startsWith(`${slug}-`) ||
|
|
131
|
+
directoryName.startsWith(`${slug}.`) ||
|
|
132
|
+
directoryName.startsWith(`${slug}_`)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Strip everything after the first dot so a vendor product directory can be
|
|
138
|
+
* compared on its leading segment (`com.google.chrome` → `com`).
|
|
139
|
+
* @param {string} value
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function leadingSegment(value) {
|
|
143
|
+
const index = value.indexOf('.');
|
|
144
|
+
return index < 0 ? value : value.slice(0, index);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* True when the directory is a vendor-level shared directory rather than one
|
|
149
|
+
* product's own directory. `~/Library/Application Support/Google` is shared by
|
|
150
|
+
* Chrome, Chrome for Testing and the updater, so it must never be deletable.
|
|
151
|
+
* @param {string} directoryName original (not lowercased) base name
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
export function isVendorShared(directoryName) {
|
|
155
|
+
if (typeof directoryName !== 'string' || directoryName === '') return false;
|
|
156
|
+
const lowered = directoryName.toLowerCase();
|
|
157
|
+
if (VENDOR_ROOTS.has(lowered)) return true;
|
|
158
|
+
// `com.google` style vendor roots: reverse-DNS with nothing after the vendor.
|
|
159
|
+
if (lowered.startsWith('com.') && lowered.split('.').length === 2 && VENDOR_ROOTS.has(leadingSegment(lowered.slice(4)))) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build the lookup index for a scan.
|
|
167
|
+
*
|
|
168
|
+
* @param {Array<{name: string, relPath: string, bytes?: number, files?: number,
|
|
169
|
+
* requiresAdmin?: boolean}>} entries every candidate directory found by a sweep
|
|
170
|
+
* @returns {{entries: Array<object>, byLowerName: Map<string, number[]>}}
|
|
171
|
+
*/
|
|
172
|
+
export function buildIndex(entries) {
|
|
173
|
+
const list = Array.isArray(entries)
|
|
174
|
+
? entries.filter((entry) => entry && typeof entry.name === 'string' && typeof entry.relPath === 'string')
|
|
175
|
+
: [];
|
|
176
|
+
/** @type {Map<string, number[]>} */
|
|
177
|
+
const byLowerName = new Map();
|
|
178
|
+
list.forEach((entry, index) => {
|
|
179
|
+
const key = normalizeName(entry.name);
|
|
180
|
+
const bucket = byLowerName.get(key);
|
|
181
|
+
if (bucket) bucket.push(index);
|
|
182
|
+
else byLowerName.set(key, [index]);
|
|
183
|
+
});
|
|
184
|
+
return { entries: list, byLowerName };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The app contexts needed to resolve a directory to one or more owners.
|
|
189
|
+
* @typedef {object} AppContext
|
|
190
|
+
* @property {string} name display name
|
|
191
|
+
* @property {string|null} bundleId
|
|
192
|
+
* @property {string} slug slugified display name
|
|
193
|
+
*/
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalise a raw installed-application record into an {@link AppContext}.
|
|
197
|
+
* @param {{name?: unknown, bundleId?: unknown}} app
|
|
198
|
+
* @returns {AppContext}
|
|
199
|
+
*/
|
|
200
|
+
export function toAppContext(app) {
|
|
201
|
+
const name = typeof app?.name === 'string' ? app.name : '';
|
|
202
|
+
const bundleId = typeof app?.bundleId === 'string' && app.bundleId !== '' ? app.bundleId : null;
|
|
203
|
+
return { name, bundleId, slug: slugify(name) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Find every app that could own a directory name.
|
|
208
|
+
*
|
|
209
|
+
* The Team ID prefix is stripped first, so `6N38VWS5BX.ru.keepcoder.Telegram`
|
|
210
|
+
* resolves to the app whose bundle id is `ru.keepcoder.Telegram`.
|
|
211
|
+
*
|
|
212
|
+
* Name matching compares *slugified* forms because directory names keep their
|
|
213
|
+
* spaces and punctuation: `%APPDATA%\Acme Widget` must match the slug
|
|
214
|
+
* `acmewidget`, and on macOS `Application Support/Clash Verge` is the same case.
|
|
215
|
+
* Bundle-id matching still uses the raw lowercased name, since a bundle id is
|
|
216
|
+
* never spelled with separators that slugify would remove.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} directoryName
|
|
219
|
+
* @param {AppContext[]} apps every installed app
|
|
220
|
+
* @returns {AppContext[]}
|
|
221
|
+
*/
|
|
222
|
+
export function ownersOf(directoryName, apps) {
|
|
223
|
+
const raw = normalizeName(directoryName);
|
|
224
|
+
if (raw === '') return [];
|
|
225
|
+
const bare = stripTeamPrefix(raw);
|
|
226
|
+
const bareSlug = slugify(bare);
|
|
227
|
+
const owners = [];
|
|
228
|
+
for (const app of Array.isArray(apps) ? apps : []) {
|
|
229
|
+
if (!app || typeof app !== 'object') continue;
|
|
230
|
+
const bundleId = typeof app.bundleId === 'string' ? app.bundleId.toLowerCase() : '';
|
|
231
|
+
const slug = typeof app.slug === 'string' ? app.slug : slugify(app.name);
|
|
232
|
+
if (bundleId !== '' && matchesBundleId(bare, bundleId)) {
|
|
233
|
+
owners.push(app);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (slug !== '' && matchesAppName(bareSlug, slug)) owners.push(app);
|
|
237
|
+
}
|
|
238
|
+
return owners;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Decide whether two app contexts denote the same installed application.
|
|
243
|
+
*
|
|
244
|
+
* A bundle id is the real identity; the slug is only a fallback for records
|
|
245
|
+
* that carry no bundle id. Comparing slugs alone would treat two genuinely
|
|
246
|
+
* different apps that happen to share a name as one, which silently collapses
|
|
247
|
+
* the `conflict` tier into `high`.
|
|
248
|
+
*
|
|
249
|
+
* @param {AppContext|null|undefined} left
|
|
250
|
+
* @param {AppContext|null|undefined} right
|
|
251
|
+
* @returns {boolean}
|
|
252
|
+
*/
|
|
253
|
+
export function isSameApp(left, right) {
|
|
254
|
+
if (!left || !right) return false;
|
|
255
|
+
if (left === right) return true;
|
|
256
|
+
const leftId = typeof left.bundleId === 'string' ? left.bundleId.toLowerCase() : '';
|
|
257
|
+
const rightId = typeof right.bundleId === 'string' ? right.bundleId.toLowerCase() : '';
|
|
258
|
+
if (leftId !== '' && rightId !== '') return leftId === rightId;
|
|
259
|
+
return left.slug !== '' && left.slug === right.slug;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Collapse repeated roster entries.
|
|
264
|
+
*
|
|
265
|
+
* The collector reports the same bundle from more than one source (an app and
|
|
266
|
+
* its helper both listing the parent's bundle id, or the same bundle found in
|
|
267
|
+
* two locations). Without this, one installed app with three duplicate rows
|
|
268
|
+
* looks like three competing owners and every match degrades to `conflict`.
|
|
269
|
+
*
|
|
270
|
+
* Identity is the bundle id when present, else the slug. The first entry wins
|
|
271
|
+
* so a bundle-id-bearing row is never displaced by a weaker one.
|
|
272
|
+
*
|
|
273
|
+
* @param {AppContext[]} apps
|
|
274
|
+
* @returns {AppContext[]}
|
|
275
|
+
*/
|
|
276
|
+
export function dedupeApps(apps) {
|
|
277
|
+
/** @type {AppContext[]} */
|
|
278
|
+
const out = [];
|
|
279
|
+
/** @type {Set<string>} */
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
for (const app of Array.isArray(apps) ? apps : []) {
|
|
282
|
+
if (!app || typeof app !== 'object') continue;
|
|
283
|
+
const bundleId = typeof app.bundleId === 'string' ? app.bundleId.toLowerCase() : '';
|
|
284
|
+
const slug = typeof app.slug === 'string' ? app.slug : slugify(app.name);
|
|
285
|
+
const key = bundleId !== '' ? `id:${bundleId}` : `slug:${slug}`;
|
|
286
|
+
if (key === 'slug:' || seen.has(key)) continue;
|
|
287
|
+
seen.add(key);
|
|
288
|
+
out.push(app);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolve one candidate directory to a confidence tier.
|
|
295
|
+
*
|
|
296
|
+
* @param {object} candidate
|
|
297
|
+
* @param {string} candidate.name directory base name (as found on disk)
|
|
298
|
+
* @param {string} [candidate.relPath] optional display path; classification
|
|
299
|
+
* only depends on the base name
|
|
300
|
+
* @param {AppContext} subject the app being uninstalled
|
|
301
|
+
* @param {AppContext[]} allApps every installed app (for conflict detection)
|
|
302
|
+
* @param {{fromBrewZap?: boolean, kind?: string, holdsSubject?: boolean}} [hints]
|
|
303
|
+
* `holdsSubject` must be `true` for a vendor-shared directory that actually
|
|
304
|
+
* contains the subject app's own data; a vendor directory belonging to some
|
|
305
|
+
* unrelated product is not a leftover of *this* app and yields `null`.
|
|
306
|
+
* @returns {{confidence: import('../../types/contracts.js').FootprintConfidence,
|
|
307
|
+
* source: import('../../types/contracts.js').FootprintSource,
|
|
308
|
+
* reason: string, alsoMatchedBy: string[]} | null} `null` when the directory is
|
|
309
|
+
* not attributable to the subject app at all; the caller drops those rows.
|
|
310
|
+
*/
|
|
311
|
+
export function classify(candidate, subject, allApps, hints = {}) {
|
|
312
|
+
const name = typeof candidate?.name === 'string' ? candidate.name : '';
|
|
313
|
+
const isShared = isVendorShared(name);
|
|
314
|
+
|
|
315
|
+
if (isShared) {
|
|
316
|
+
// A vendor directory only concerns us when it actually holds data for the
|
|
317
|
+
// subject app. Otherwise it is another product's directory and showing it
|
|
318
|
+
// would be pure noise (IINA has nothing to do with `Application Support/
|
|
319
|
+
// Google`), so it is dropped entirely.
|
|
320
|
+
if (hints.holdsSubject !== true) return null;
|
|
321
|
+
return {
|
|
322
|
+
confidence: 'shared',
|
|
323
|
+
source: 'bundle-id',
|
|
324
|
+
reason: 'Vendor-level directory shared with other products; shown for reference only.',
|
|
325
|
+
alsoMatchedBy: [],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const owners = ownersOf(name, Array.isArray(allApps) ? allApps : []);
|
|
330
|
+
const subjectOwns = owners.some((owner) => isSameApp(owner, subject));
|
|
331
|
+
|
|
332
|
+
if (hints.fromBrewZap === true) {
|
|
333
|
+
return {
|
|
334
|
+
confidence: 'high',
|
|
335
|
+
source: 'brew-zap',
|
|
336
|
+
reason: 'Listed in this app\'s Homebrew cask zap recipe.',
|
|
337
|
+
alsoMatchedBy: [],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (subjectOwns) {
|
|
342
|
+
const bare = stripTeamPrefix(normalizeName(name));
|
|
343
|
+
const viaBundleId =
|
|
344
|
+
typeof subject?.bundleId === 'string' && matchesBundleId(bare, subject.bundleId.toLowerCase());
|
|
345
|
+
const others = owners.filter((owner) => !isSameApp(owner, subject));
|
|
346
|
+
if (others.length > 0) {
|
|
347
|
+
return {
|
|
348
|
+
confidence: 'conflict',
|
|
349
|
+
source: 'name-match',
|
|
350
|
+
reason: `Directory name also matches ${others.length} other installed app(s).`,
|
|
351
|
+
alsoMatchedBy: others.map((owner) => owner.name).filter((value) => value !== ''),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// Design §4: a bundle-id hit is tier A; a pure name-similarity hit is tier
|
|
355
|
+
// B and must default to unselected, because the user knows better than the
|
|
356
|
+
// machine whether `Claude-3p` really belongs to Claude.
|
|
357
|
+
return {
|
|
358
|
+
confidence: viaBundleId ? 'high' : 'medium',
|
|
359
|
+
source: viaBundleId ? 'bundle-id' : 'name-match',
|
|
360
|
+
reason: viaBundleId
|
|
361
|
+
? 'Directory name matches the app\'s bundle identifier.'
|
|
362
|
+
: `Directory name resembles the app name: ${subject?.name ?? ''} ≈ ${name}.`,
|
|
363
|
+
alsoMatchedBy: [],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Total bytes of a set of found items.
|
|
372
|
+
* @param {Array<{bytes?: unknown}>} items
|
|
373
|
+
* @returns {number}
|
|
374
|
+
*/
|
|
375
|
+
export function totalBytes(items) {
|
|
376
|
+
let sum = 0;
|
|
377
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
378
|
+
const value = Number(item?.bytes);
|
|
379
|
+
if (Number.isFinite(value) && value > 0) sum += value;
|
|
380
|
+
}
|
|
381
|
+
return sum;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Bytes that a default-confirmed dialog would remove: the `high` tier only.
|
|
386
|
+
* @param {Array<{bytes?: unknown, confidence?: unknown}>} items
|
|
387
|
+
* @returns {number}
|
|
388
|
+
*/
|
|
389
|
+
export function selectedBytes(items) {
|
|
390
|
+
const high = (Array.isArray(items) ? items : []).filter((item) => item?.confidence === 'high');
|
|
391
|
+
return totalBytes(high);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Test seam: the exact sets this module classifies by. */
|
|
395
|
+
export const __test = {
|
|
396
|
+
VENDOR_ROOTS,
|
|
397
|
+
TEAM_PREFIX,
|
|
398
|
+
GROUP_TEAM_PREFIX,
|
|
399
|
+
};
|