| @@ -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 | + |
| 12 | + |
| 13 | + |
| 14 | + |
| 15 | + |
| 16 | + holdsUserData: boolean |
| 10 | 17 | } |
| 11 | 18 | |
| 12 | 19 | |
| @@ -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 | + |
| 28 | + |
| 29 | + |
| 30 | + |
| 31 | + |
| 32 | + |
| 33 | + |
| 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 | + |
| 44 | + |
| 45 | + |
| 46 | + |
| 47 | + |
| 48 | + |
| 49 | + |
| 50 | + |
| 51 | + |
| 52 | + |
| 53 | + |
| 54 | + |
| 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 | |
| 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 | + |
| 83 | + |
| 84 | + |
| 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 | + |
| 94 | + minAgeDays: number |
| 95 | + holdsUserData: boolean |
| 96 | +} |
| 29 | 97 | |
| 30 | 98 | const AGE_THRESHOLD_DAYS = 30 |
| 31 | 99 | |
| 100 | + |
| 101 | + |
| 102 | + |
| 103 | + |
| 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 | + |
| 115 | + |
| 116 | + |
| 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 | + |
| 121 | + { basePath: macPaths.containers, type: 'container', pattern: BUNDLE_ID_PATTERN, nameKind: 'bundle-id', minAgeDays: USER_DATA_AGE_THRESHOLD_DAYS, holdsUserData: true }, |
| 122 | + |
| 123 | + |
| 124 | + |
| 125 | + |
| 126 | + |
| 127 | +] |
| 128 | + |
| 32 | 129 | |
| 33 | 130 | |
| 34 | 131 | |
| 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 | | - |
| 56 | | - if (installedBundles.has(bundleId) || installedBundles.has(bundleId.toLowerCase())) |
| 57 | | - continue |
| 153 | + if (dir.nameKind === 'bundle-id') { |
| 154 | + |
| 155 | + if (installed.bundleIds.has(bundleId) || installed.bundleIds.has(bundleId.toLowerCase())) |
| 156 | + continue |
| 157 | + } |
| 158 | + else { |
| 159 | + |
| 160 | + |
| 161 | + |
| 162 | + |
| 163 | + |
| 164 | + if (installed.appNames.size === 0) |
| 165 | + continue |
| 166 | + |
| 167 | + |
| 168 | + |
| 169 | + if (SYSTEM_DIR_NAMES.has(bundleId.toLowerCase())) |
| 170 | + continue |
| 171 | + |
| 172 | + |
| 173 | + |
| 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 | | - |
| 69 | | - if (!/^[\w.-]+$/.test(bundleId)) |
| 194 | + |
| 195 | + |
| 196 | + const safeName = dir.nameKind === 'bundle-id' |
| 197 | + ? /^[\w.-]+$/.test(bundleId) |
| 198 | + : /^[\w .+-]+$/.test(bundleId) |
| 199 | + if (!safeName) |
| 70 | 200 | continue |
| 71 | 201 | |
| 72 | | - |
| 202 | + |
| 203 | + |
| 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 | + |
| 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 | |
| @@ -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 | + |
| 258 | + |
| 259 | + |
| 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 { } |
| 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> { |