also looking at this
feat(orphans): scan Application Support and Containers
#29
3 files
+242
-19
| @@ -4,9 +4,16 @@ import { exec, macPaths, pathExists, safeReadDir, safeStat, shellEscape, readApp | ||
| 4 | 4 | export interface OrphanedItem { |
| 5 | 5 | path: string |
| 6 | 6 | bundleId: string |
| 7 | type: 'cache' | 'log' | 'saved-state' | 'webkit' | 'http-storage' | 'cookie' | |
| 7 | type: 'cache' | 'log' | 'saved-state' | 'webkit' | 'http-storage' | 'cookie' | 'app-support' | 'container' | |
| 8 | 8 | sizeBytes: number |
| 9 | 9 | daysSinceModified: number |
| 10 | /** | |
| 11 | * True for roots that hold real user documents rather than rebuildable | |
| 12 | * scratch. Callers must not auto-select these — a stale cache is free to | |
| 13 | * delete, an app's Application Support directory may be the only copy of | |
| 14 | * something the user cannot regenerate. | |
| 15 | */ | |
| 16 | holdsUserData: boolean | |
| 10 | 17 | } |
| 11 | 18 | |
| 12 | 19 | /** Bundle ID patterns that should NEVER be cleaned as orphans */ |
| @@ -16,27 +23,118 @@ const NEVER_DELETE = [ | ||
| 16 | 23 | /com\.apple\./i, /\bloginwindow\b/i, /\bdock\b/i, /\bfinder\b/i, /\bsafari\b/i, |
| 17 | 24 | ] |
| 18 | 25 | |
| 26 | /** | |
| 27 | * Plain-named directories under Application Support that belong to macOS | |
| 28 | * itself, not to a third-party app. None of these has a matching `.app`, so | |
| 29 | * any "is the owning app installed?" test reports them as orphaned — and | |
| 30 | * `MobileSync` alone can be every iPhone backup on the machine. | |
| 31 | * | |
| 32 | * Only consulted for the plain-name path; reverse-DNS entries are already | |
| 33 | * covered by the `com.apple.` rule in NEVER_DELETE. | |
| 34 | */ | |
| 35 | export const SYSTEM_DIR_NAMES = new Set([ | |
| 36 | 'addressbook', 'animoji', 'callhistorydb', 'callhistorytransactions', 'clouddocs', | |
| 37 | 'crashreporter', 'differentialprivacy', 'diskimages', 'dock', 'facetime', | |
| 38 | 'fileprovider', 'icloud', 'knowledge', 'mobilesync', 'music', 'sesstorage', | |
| 39 | 'syncservices', 'caches', 'com.apple', | |
| 40 | ]) | |
| 41 | ||
| 42 | /** | |
| 43 | * Plain-named Application Support directories mapped to the apps that own | |
| 44 | * them. Required because the directory name routinely is not the app name: | |
| 45 | * Chrome writes to `Google`, Firefox to `Mozilla`, Brave to `BraveSoftware`. | |
| 46 | * | |
| 47 | * This map is deliberately the *only* source of plain-name orphans. The | |
| 48 | * obvious alternative — flag any directory whose name matches no installed | |
| 49 | * app — is unsafe: on the machine this was developed against, `Google` held | |
| 50 | * 7.7 GB of live Chrome profile (history, bookmarks, saved passwords) and | |
| 51 | * would have been reported as orphaned data from an uninstalled app. | |
| 52 | * | |
| 53 | * An unrecognised directory is therefore never flagged. False negatives cost | |
| 54 | * disk space; false positives cost the user their data. | |
| 55 | */ | |
| 56 | export const KNOWN_APP_DIRS: Record<string, string[]> = { | |
| 57 | 'Arc': ['Arc'], | |
| 58 | 'BraveSoftware': ['Brave Browser', 'Brave Browser Beta', 'Brave Browser Nightly'], | |
| 59 | 'Chromium': ['Chromium'], | |
| 60 | 'Code': ['Visual Studio Code', 'Code'], | |
| 61 | 'Cursor': ['Cursor'], | |
| 62 | 'Discord': ['Discord'], | |
| 63 | 'Firefox': ['Firefox'], | |
| 64 | 'GitHub Desktop': ['GitHub Desktop'], | |
| 65 | 'Google': ['Google Chrome', 'Google Chrome Canary', 'Google Chrome Beta', 'Google Drive'], | |
| 66 | 'JetBrains': ['IntelliJ IDEA', 'WebStorm', 'PyCharm', 'GoLand', 'RubyMine', 'CLion', 'PhpStorm', 'Rider', 'DataGrip'], | |
| 67 | 'Microsoft': ['Microsoft Edge', 'Microsoft Word', 'Microsoft Excel', 'Microsoft PowerPoint', 'Microsoft Outlook', 'Microsoft Teams', 'OneDrive'], | |
| 68 | 'Mozilla': ['Firefox', 'Firefox Developer Edition', 'Thunderbird'], | |
| 69 | 'Notion': ['Notion'], | |
| 70 | 'Postman': ['Postman'], | |
| 71 | 'Slack': ['Slack'], | |
| 72 | 'Steam': ['Steam'], | |
| 73 | 'Sublime Text': ['Sublime Text'], | |
| 74 | 'TeamViewer': ['TeamViewer'], | |
| 75 | 'Zoom': ['zoom.us'], | |
| 76 | } | |
| 77 | ||
| 19 | 78 | /** Case-insensitive pattern to match reverse-DNS bundle IDs */ |
| 20 | 79 | const BUNDLE_ID_PATTERN = /^(com|org|net|io)\.[a-z]/i |
| 21 | 80 | |
| 22 | const ORPHAN_DIRS: { basePath: string, type: OrphanedItem['type'], pattern: RegExp }[] = [ | |
| 23 | { basePath: macPaths.caches, type: 'cache', pattern: BUNDLE_ID_PATTERN }, | |
| 24 | { basePath: macPaths.logs, type: 'log', pattern: BUNDLE_ID_PATTERN }, | |
| 25 | { basePath: macPaths.savedState, type: 'saved-state', pattern: /\.savedState$/i }, | |
| 26 | { basePath: macPaths.webkit, type: 'webkit', pattern: BUNDLE_ID_PATTERN }, | |
| 27 | { basePath: macPaths.httpStorages, type: 'http-storage', pattern: BUNDLE_ID_PATTERN }, | |
| 28 | ] | |
| 81 | /** | |
| 82 | * How entries in a root identify their owning app. `bundle-id` names are | |
| 83 | * unambiguous and resolve against installed bundle identifiers; `app-name` | |
| 84 | * names resolve through KNOWN_APP_DIRS against installed app names. | |
| 85 | */ | |
| 86 | type NameKind = 'bundle-id' | 'app-name' | |
| 87 | ||
| 88 | interface OrphanDir { | |
| 89 | basePath: string | |
| 90 | type: OrphanedItem['type'] | |
| 91 | pattern: RegExp | |
| 92 | nameKind: NameKind | |
| 93 | /** Age below which an entry in this root is never reported. */ | |
| 94 | minAgeDays: number | |
| 95 | holdsUserData: boolean | |
| 96 | } | |
| 29 | 97 | |
| 30 | 98 | const AGE_THRESHOLD_DAYS = 30 |
| 31 | 99 | |
| 100 | /** | |
| 101 | * Application Support and Containers get a longer threshold than caches do. | |
| 102 | * A cache untouched for a month is dead; an app's documents untouched for a | |
| 103 | * month may just be an app the user opens quarterly. | |
| 104 | */ | |
| 105 | const USER_DATA_AGE_THRESHOLD_DAYS = 90 | |
| 106 | ||
| 107 | const ORPHAN_DIRS: OrphanDir[] = [ | |
| 108 | { basePath: macPaths.caches, type: 'cache', pattern: BUNDLE_ID_PATTERN, nameKind: 'bundle-id', minAgeDays: AGE_THRESHOLD_DAYS, holdsUserData: false }, | |
| 109 | { basePath: macPaths.logs, type: 'log', pattern: BUNDLE_ID_PATTERN, nameKind: 'bundle-id', minAgeDays: AGE_THRESHOLD_DAYS, holdsUserData: false }, | |
| 110 | { basePath: macPaths.savedState, type: 'saved-state', pattern: /\.savedState$/i, nameKind: 'bundle-id', minAgeDays: AGE_THRESHOLD_DAYS, holdsUserData: false }, | |
| 111 | { basePath: macPaths.webkit, type: 'webkit', pattern: BUNDLE_ID_PATTERN, nameKind: 'bundle-id', minAgeDays: AGE_THRESHOLD_DAYS, holdsUserData: false }, | |
| 112 | { basePath: macPaths.httpStorages, type: 'http-storage', pattern: BUNDLE_ID_PATTERN, nameKind: 'bundle-id', minAgeDays: AGE_THRESHOLD_DAYS, holdsUserData: false }, | |
| 113 | ||
| 114 | // Where an uninstalled app leaves gigabytes rather than megabytes. Scanned | |
| 115 | // twice: once for the reverse-DNS directories, once for the plain-named | |
| 116 | // ones KNOWN_APP_DIRS can attribute. | |
| 117 | { basePath: macPaths.applicationSupport, type: 'app-support', pattern: BUNDLE_ID_PATTERN, nameKind: 'bundle-id', minAgeDays: USER_DATA_AGE_THRESHOLD_DAYS, holdsUserData: true }, | |
| 118 | { basePath: macPaths.applicationSupport, type: 'app-support', pattern: /^[A-Z][\w .+-]*$/i, nameKind: 'app-name', minAgeDays: USER_DATA_AGE_THRESHOLD_DAYS, holdsUserData: true }, | |
| 119 | ||
| 120 | // Sandboxed apps. Always bundle-id named, so unambiguous. | |
| 121 | { basePath: macPaths.containers, type: 'container', pattern: BUNDLE_ID_PATTERN, nameKind: 'bundle-id', minAgeDays: USER_DATA_AGE_THRESHOLD_DAYS, holdsUserData: true }, | |
| 122 | ||
| 123 | // Group Containers are deliberately omitted: they are prefixed with a team | |
| 124 | // identifier (`9BNSXJN65R.com.example.app`) and shared between several apps | |
| 125 | // of one vendor, so "the owning app is gone" is not a question a single | |
| 126 | // directory name can answer. | |
| 127 | ] | |
| 128 | ||
| 32 | 129 | /** |
| 33 | 130 | * Find orphaned app data — caches, logs, saved states for apps no longer installed. |
| 34 | 131 | * Uses shellEscape to prevent command injection from bundle IDs. |
| 35 | 132 | */ |
| 36 | 133 | export async function findOrphanedAppData(): Promise<OrphanedItem[]> { |
| 37 | const installedBundles = await getInstalledBundleIds() | |
| 134 | const installed = await getInstalledApps() | |
| 38 | 135 | const now = Date.now() |
| 39 | 136 | const orphans: OrphanedItem[] = [] |
| 137 | const seen = new Set<string>() | |
| 40 | 138 | |
| 41 | 139 | for (const dir of ORPHAN_DIRS) { |
| 42 | 140 | if (!pathExists(dir.basePath)) |
| @@ -52,26 +150,62 @@ export async function findOrphanedAppData(): Promise<OrphanedItem[]> { | ||
| 52 | 150 | if (NEVER_DELETE.some(p => p.test(bundleId))) |
| 53 | 151 | continue |
| 54 | 152 | |
| 55 | // Skip if app is installed | |
| 56 | if (installedBundles.has(bundleId) || installedBundles.has(bundleId.toLowerCase())) | |
| 57 | continue | |
| 153 | if (dir.nameKind === 'bundle-id') { | |
| 154 | // Skip if app is installed | |
| 155 | if (installed.bundleIds.has(bundleId) || installed.bundleIds.has(bundleId.toLowerCase())) | |
| 156 | continue | |
| 157 | } | |
| 158 | else { | |
| 159 | // If the app index came back empty — Spotlight disabled, mdfind | |
| 160 | // missing — then *every* mapped directory reads as uninstalled. | |
| 161 | // Refuse to answer rather than answer wrongly: the bundle-id roots | |
| 162 | // have NEVER_DELETE and `com.apple.` to fall back on, this path has | |
| 163 | // nothing but the index. | |
| 164 | if (installed.appNames.size === 0) | |
| 165 | continue | |
| 166 | ||
| 167 | // Plain name. macOS's own directories first — none of them has an | |
| 168 | // owning `.app`, so every one would otherwise read as orphaned. | |
| 169 | if (SYSTEM_DIR_NAMES.has(bundleId.toLowerCase())) | |
| 170 | continue | |
| 171 | ||
| 172 | // Only directories we can actually attribute. An unknown name is | |
| 173 | // left alone rather than guessed at; see KNOWN_APP_DIRS. | |
| 174 | const owners = KNOWN_APP_DIRS[bundleId] | |
| 175 | if (!owners) | |
| 176 | continue | |
| 177 | ||
| 178 | if (owners.some(name => installed.appNames.has(name.toLowerCase()))) | |
| 179 | continue | |
| 180 | } | |
| 58 | 181 | |
| 59 | 182 | const fullPath = path.join(dir.basePath, entry) |
| 183 | if (seen.has(fullPath)) | |
| 184 | continue | |
| 185 | ||
| 60 | 186 | const stat = safeStat(fullPath) |
| 61 | 187 | if (!stat) |
| 62 | 188 | continue |
| 63 | 189 | |
| 64 | 190 | const daysSinceModified = Math.floor((now - stat.mtimeMs) / 86_400_000) |
| 65 | if (daysSinceModified < AGE_THRESHOLD_DAYS) | |
| 191 | if (daysSinceModified < dir.minAgeDays) | |
| 66 | 192 | continue |
| 67 | 193 | |
| 68 | // Validate bundle ID format before using in shell command (reverse-DNS only) | |
| 69 | if (!/^[\w.-]+$/.test(bundleId)) | |
| 194 | // Validate the name before using it in a shell command. Plain app names | |
| 195 | // may legitimately contain spaces, dots and `+` (e.g. "Sublime Text"). | |
| 196 | const safeName = dir.nameKind === 'bundle-id' | |
| 197 | ? /^[\w.-]+$/.test(bundleId) | |
| 198 | : /^[\w .+-]+$/.test(bundleId) | |
| 199 | if (!safeName) | |
| 70 | 200 | continue |
| 71 | 201 | |
| 72 | // Final check: use mdfind with shellEscape to verify app is truly gone | |
| 202 | // Final check against Spotlight: verify the app is truly gone rather | |
| 203 | // than merely absent from the index we built. | |
| 204 | const query = dir.nameKind === 'bundle-id' | |
| 205 | ? `kMDItemCFBundleIdentifier == ${shellEscape(bundleId)}` | |
| 206 | : `kMDItemFSName == ${shellEscape(`${bundleId}.app`)}` | |
| 73 | 207 | const mdfindResult = await exec( |
| 74 | `mdfind "kMDItemCFBundleIdentifier == ${shellEscape(bundleId)}" 2>/dev/null | head -1`, | |
| 208 | `mdfind ${shellEscape(query)} 2>/dev/null | head -1`, | |
| 75 | 209 | { timeout: 3000 }, |
| 76 | 210 | ) |
| 77 | 211 | if (mdfindResult.ok && mdfindResult.stdout.trim()) |
| @@ -81,12 +215,14 @@ export async function findOrphanedAppData(): Promise<OrphanedItem[]> { | ||
| 81 | 215 | ? await getDirSizeQuick(fullPath) |
| 82 | 216 | : stat.size |
| 83 | 217 | |
| 218 | seen.add(fullPath) | |
| 84 | 219 | orphans.push({ |
| 85 | 220 | path: fullPath, |
| 86 | 221 | bundleId, |
| 87 | 222 | type: dir.type, |
| 88 | 223 | sizeBytes, |
| 89 | 224 | daysSinceModified, |
| 225 | holdsUserData: dir.holdsUserData, | |
| 90 | 226 | }) |
| 91 | 227 | } |
| 92 | 228 | } |
| @@ -94,13 +230,20 @@ export async function findOrphanedAppData(): Promise<OrphanedItem[]> { | ||
| 94 | 230 | return orphans.sort((a, b) => b.sizeBytes - a.sizeBytes) |
| 95 | 231 | } |
| 96 | 232 | |
| 97 | async function getInstalledBundleIds(): Promise<Set<string>> { | |
| 233 | interface InstalledApps { | |
| 234 | bundleIds: Set<string> | |
| 235 | /** Lowercased app names, from the bundle's own metadata and its filename. */ | |
| 236 | appNames: Set<string> | |
| 237 | } | |
| 238 | ||
| 239 | async function getInstalledApps(): Promise<InstalledApps> { | |
| 98 | 240 | const result = await exec( |
| 99 | 241 | 'mdfind "kMDItemContentType == \'com.apple.application-bundle\'" 2>/dev/null', |
| 100 | 242 | { timeout: 15_000 }, |
| 101 | 243 | ) |
| 102 | 244 | |
| 103 | 245 | const ids = new Set<string>() |
| 246 | const names = new Set<string>() | |
| 104 | 247 | |
| 105 | 248 | if (result.ok) { |
| 106 | 249 | // No artificial cap. The previous `slice(0, 300)` silently classified |
| @@ -111,11 +254,20 @@ async function getInstalledBundleIds(): Promise<Set<string>> { | ||
| 111 | 254 | const PLIST_BATCH = 20 |
| 112 | 255 | for (let i = 0; i < appPaths.length; i += PLIST_BATCH) { |
| 113 | 256 | for (const appPath of appPaths.slice(i, i + PLIST_BATCH)) { |
| 257 | // The filename is recorded whether or not the plist is readable — | |
| 258 | // an unreadable bundle is still an installed app, and treating it as | |
| 259 | // absent is how live data gets reported as orphaned. | |
| 260 | names.add(path.basename(appPath, '.app').toLowerCase()) | |
| 114 | 261 | try { |
| 115 | 262 | const info = readAppInfoPlist(appPath) |
| 116 | 263 | const bid = info.CFBundleIdentifier as string |
| 117 | 264 | if (bid) |
| 118 | 265 | ids.add(bid) |
| 266 | for (const key of ['CFBundleName', 'CFBundleDisplayName'] as const) { | |
| 267 | const value = info[key] | |
| 268 | if (typeof value === 'string' && value) | |
| 269 | names.add(value.toLowerCase()) | |
| 270 | } | |
| 119 | 271 | } |
| 120 | 272 | catch { /* skip unreadable bundles */ } |
| 121 | 273 | } |
| @@ -128,7 +280,7 @@ async function getInstalledBundleIds(): Promise<Set<string>> { | ||
| 128 | 280 | for (const id of ['com.apple.Safari', 'com.apple.mail', 'com.apple.finder', 'com.apple.dock']) |
| 129 | 281 | ids.add(id) |
| 130 | 282 | |
| 131 | return ids | |
| 283 | return { bundleIds: ids, appNames: names } | |
| 132 | 284 | } |
| 133 | 285 | |
| 134 | 286 | async function getDirSizeQuick(dirPath: string): Promise<number> { |