diff --git a/rules/darwin/browsers.json b/rules/darwin/browsers.json index f278260d..33a57cc4 100644 --- a/rules/darwin/browsers.json +++ b/rules/darwin/browsers.json @@ -2,10 +2,23 @@ "$schema": "../schema/rules.schema.json", "type": "browsers", "chromiumCacheDirs": { - "cache": "Cache", - "codeCache": "Code Cache", - "gpuCache": "GpuCache", - "serviceWorker": "Service Worker/CacheStorage" + "profile": [ + { "dir": "Cache", "label": "Cache" }, + { "dir": "Code Cache", "label": "Code Cache" }, + { "dir": "GpuCache", "label": "GPU Cache" }, + { "dir": "Service Worker/CacheStorage", "label": "Service Worker Cache" }, + { "dir": "DawnWebGPUCache", "label": "WebGPU Shader Cache", "description": "Dawn's compiled WebGPU shaders — rebuilt on demand" }, + { "dir": "DawnGraphiteCache", "label": "Graphite Shader Cache", "description": "Dawn/Skia Graphite pipeline cache — rebuilt on demand" } + ], + "shared": [ + { "dir": "component_crx_cache", "label": "Component Extension Cache", "description": "Downloaded CRX packages for component extensions — re-fetched on next update check, not the installed extensions themselves" }, + { "dir": "extensions_crx_cache", "label": "Extension Update Cache", "description": "Downloaded CRX packages awaiting install — re-fetched on next update check" }, + { "dir": "GrShaderCache", "label": "Skia Shader Cache", "description": "Skia GPU shader cache — rebuilt on demand" }, + { "dir": "ShaderCache", "label": "Shader Cache", "description": "Legacy GPU shader cache — rebuilt on demand" }, + { "dir": "GraphiteDawnCache", "label": "Graphite Pipeline Cache", "description": "Where newer Chromium builds keep the Graphite pipeline cache — rebuilt on demand" }, + { "dir": "GPUPersistentCache", "label": "Persistent GPU Cache", "description": "Where newer Chromium builds keep the on-disk GPU cache — rebuilt on demand" }, + { "dir": "PnaclTranslationCache", "label": "PNaCl Translation Cache", "description": "Translated PNaCl modules — regenerated on demand" } + ] }, "chromium": [ { "key": "chrome", "base": "${APP_SUPPORT}/Google/Chrome" }, diff --git a/rules/linux/browsers.json b/rules/linux/browsers.json index 5704e880..1212f965 100644 --- a/rules/linux/browsers.json +++ b/rules/linux/browsers.json @@ -2,10 +2,23 @@ "$schema": "../schema/rules.schema.json", "type": "browsers", "chromiumCacheDirs": { - "cache": "Cache", - "codeCache": "Code Cache", - "gpuCache": "GpuCache", - "serviceWorker": "Service Worker/CacheStorage" + "profile": [ + { "dir": "Cache", "label": "Cache" }, + { "dir": "Code Cache", "label": "Code Cache" }, + { "dir": "GpuCache", "label": "GPU Cache" }, + { "dir": "Service Worker/CacheStorage", "label": "Service Worker Cache" }, + { "dir": "DawnWebGPUCache", "label": "WebGPU Shader Cache", "description": "Dawn's compiled WebGPU shaders — rebuilt on demand" }, + { "dir": "DawnGraphiteCache", "label": "Graphite Shader Cache", "description": "Dawn/Skia Graphite pipeline cache — rebuilt on demand" } + ], + "shared": [ + { "dir": "component_crx_cache", "label": "Component Extension Cache", "description": "Downloaded CRX packages for component extensions — re-fetched on next update check, not the installed extensions themselves" }, + { "dir": "extensions_crx_cache", "label": "Extension Update Cache", "description": "Downloaded CRX packages awaiting install — re-fetched on next update check" }, + { "dir": "GrShaderCache", "label": "Skia Shader Cache", "description": "Skia GPU shader cache — rebuilt on demand" }, + { "dir": "ShaderCache", "label": "Shader Cache", "description": "Legacy GPU shader cache — rebuilt on demand" }, + { "dir": "GraphiteDawnCache", "label": "Graphite Pipeline Cache", "description": "Where newer Chromium builds keep the Graphite pipeline cache — rebuilt on demand" }, + { "dir": "GPUPersistentCache", "label": "Persistent GPU Cache", "description": "Where newer Chromium builds keep the on-disk GPU cache — rebuilt on demand" }, + { "dir": "PnaclTranslationCache", "label": "PNaCl Translation Cache", "description": "Translated PNaCl modules — regenerated on demand" } + ] }, "chromium": [ { "key": "chrome", "base": "${CONFIG}/google-chrome" }, diff --git a/rules/schema/rules.schema.json b/rules/schema/rules.schema.json index c1879eec..97fbe5b0 100644 --- a/rules/schema/rules.schema.json +++ b/rules/schema/rules.schema.json @@ -59,6 +59,21 @@ } }, + "ChromiumCacheDir": { + "type": "object", + "required": ["dir", "label"], + "additionalProperties": false, + "properties": { + "dir": { + "type": "string", + "minLength": 1, + "description": "Directory relative to the profile (or to the user-data base, for shared entries). Use forward slashes — the loader converts to OS-native separators." + }, + "label": { "type": "string", "minLength": 1, "description": "Display label appended to the browser (and profile) name" }, + "description": { "type": "string", "description": "Optional explanation of what this directory holds and why it's safe to delete" } + } + }, + "ChromiumBrowser": { "type": "object", "required": ["key", "base"], @@ -147,13 +162,20 @@ "type": { "const": "browsers" }, "chromiumCacheDirs": { "type": "object", - "required": ["cache", "codeCache", "gpuCache", "serviceWorker"], + "required": ["profile"], "additionalProperties": false, "properties": { - "cache": { "type": "string" }, - "codeCache": { "type": "string" }, - "gpuCache": { "type": "string" }, - "serviceWorker": { "type": "string" } + "profile": { + "type": "array", + "items": { "$ref": "#/definitions/ChromiumCacheDir" }, + "minItems": 1, + "description": "Cache directories that live inside each browser profile (Default, Profile 1, …)" + }, + "shared": { + "type": "array", + "items": { "$ref": "#/definitions/ChromiumCacheDir" }, + "description": "Cache directories shared by every profile, sitting directly under the user-data base" + } } }, "chromium": { diff --git a/rules/win32/browsers.json b/rules/win32/browsers.json index 08b921d5..844932cf 100644 --- a/rules/win32/browsers.json +++ b/rules/win32/browsers.json @@ -2,10 +2,23 @@ "$schema": "../schema/rules.schema.json", "type": "browsers", "chromiumCacheDirs": { - "cache": "Cache/Cache_Data", - "codeCache": "Code Cache", - "gpuCache": "GPUCache", - "serviceWorker": "Service Worker/CacheStorage" + "profile": [ + { "dir": "Cache/Cache_Data", "label": "Cache" }, + { "dir": "Code Cache", "label": "Code Cache" }, + { "dir": "GPUCache", "label": "GPU Cache" }, + { "dir": "Service Worker/CacheStorage", "label": "Service Worker Cache" }, + { "dir": "DawnWebGPUCache", "label": "WebGPU Shader Cache", "description": "Dawn's compiled WebGPU shaders — rebuilt on demand" }, + { "dir": "DawnGraphiteCache", "label": "Graphite Shader Cache", "description": "Dawn/Skia Graphite pipeline cache — rebuilt on demand" } + ], + "shared": [ + { "dir": "component_crx_cache", "label": "Component Extension Cache", "description": "Downloaded CRX packages for component extensions — re-fetched on next update check, not the installed extensions themselves" }, + { "dir": "extensions_crx_cache", "label": "Extension Update Cache", "description": "Downloaded CRX packages awaiting install — re-fetched on next update check" }, + { "dir": "GrShaderCache", "label": "Skia Shader Cache", "description": "Skia GPU shader cache — rebuilt on demand" }, + { "dir": "ShaderCache", "label": "Shader Cache", "description": "Legacy GPU shader cache — rebuilt on demand" }, + { "dir": "GraphiteDawnCache", "label": "Graphite Pipeline Cache", "description": "Where newer Chromium builds keep the Graphite pipeline cache — rebuilt on demand" }, + { "dir": "GPUPersistentCache", "label": "Persistent GPU Cache", "description": "Where newer Chromium builds keep the on-disk GPU cache — rebuilt on demand" }, + { "dir": "PnaclTranslationCache", "label": "PNaCl Translation Cache", "description": "Translated PNaCl modules — regenerated on demand" } + ] }, "chromium": [ { "key": "chrome", "base": "${LOCALAPPDATA}/Google/Chrome/User Data" }, diff --git a/src/main/cli.ts b/src/main/cli.ts index 3de545f0..8ae60ba0 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -4,6 +4,7 @@ import { readdir } from 'fs/promises' import { join } from 'path' import { scanDirectory, scanFile, scanMultipleDirectories, scanDirectoriesAsItems, resolveChildSubdirs, cleanItems, getDirectorySize } from './services/file-utils' import { cacheItems } from './services/scan-cache' +import { BROWSER_CACHE_RECENCY, chromiumBrowsers, chromiumCacheTargets } from './services/chromium-cache' import { CleanerType } from '../shared/enums' import type { ScanResult, CleanResult } from '../shared/types' import { getPlatform } from './platform' @@ -182,48 +183,11 @@ async function scanBrowserCli(): Promise { const results: ScanResult[] = [] const category = CleanerType.Browser const browserPaths = getPlatform().paths.browserPaths() - const chromiumBrowsers = [ - { label: 'Chrome', ...browserPaths.chrome, hasProfiles: true }, - { label: 'Edge', ...browserPaths.edge, hasProfiles: true }, - { label: 'Brave', ...browserPaths.brave, hasProfiles: true }, - { label: 'Vivaldi', ...browserPaths.vivaldi, hasProfiles: true }, - { label: 'Opera', ...browserPaths.opera, hasProfiles: false }, - { label: 'Opera GX', ...browserPaths.operaGX, hasProfiles: false }, - { label: 'Arc', ...browserPaths.arc, hasProfiles: true }, - { label: 'Chromium', ...browserPaths.chromium, hasProfiles: true }, - { label: 'Thorium', ...browserPaths.thorium, hasProfiles: true }, - { label: 'Supermium', ...browserPaths.supermium, hasProfiles: true }, - { label: 'Helium', ...browserPaths.helium, hasProfiles: true }, - { label: 'Cromite', ...browserPaths.cromite, hasProfiles: true }, - { label: 'CatsXP', ...browserPaths.catsxp, hasProfiles: true }, - ] - for (const browser of chromiumBrowsers) { - if (!existsSync(browser.base)) continue - if (browser.hasProfiles) { - const profiles = await getChromiumProfiles(browser.base) - for (const profile of profiles) { - for (const { dir, label } of [ - { dir: browser.cache, label: 'Cache' }, { dir: browser.codeCache, label: 'Code Cache' }, - { dir: browser.gpuCache, label: 'GPU Cache' }, { dir: browser.serviceWorker, label: 'Service Worker Cache' }, - ]) { - const cachePath = join(browser.base, profile, dir) - if (existsSync(cachePath)) { - const result = await scanDirectory(cachePath, category, `${browser.label} - ${profile} ${label}`) - if (result.items.length > 0) { cacheItems(result.items); results.push(result) } - } - } - } - } else { - for (const { dir, label } of [ - { dir: browser.cache, label: 'Cache' }, { dir: browser.codeCache, label: 'Code Cache' }, - { dir: browser.gpuCache, label: 'GPU Cache' }, { dir: browser.serviceWorker, label: 'Service Worker Cache' }, - ]) { - const cachePath = join(browser.base, dir) - if (existsSync(cachePath)) { - const result = await scanDirectory(cachePath, category, `${browser.label} - ${label}`) - if (result.items.length > 0) { cacheItems(result.items); results.push(result) } - } - } + const recency = BROWSER_CACHE_RECENCY + for (const browser of chromiumBrowsers(browserPaths)) { + for (const target of await chromiumCacheTargets(browser)) { + const result = await scanDirectory(target.path, category, target.label, recency) + if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } if (existsSync(browserPaths.firefox.cache)) { @@ -233,7 +197,7 @@ async function scanBrowserCli(): Promise { if (dir.isDirectory()) { const cachePath = join(browserPaths.firefox.cache, dir.name, 'cache2', 'entries') if (existsSync(cachePath)) { - const result = await scanDirectory(cachePath, category, `Firefox - ${dir.name} Cache`) + const result = await scanDirectory(cachePath, category, `Firefox - ${dir.name} Cache`, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -254,7 +218,7 @@ async function scanBrowserCli(): Promise { if (dir.isDirectory()) { const cachePath = join(fork.cache, dir.name, 'cache2') if (existsSync(cachePath)) { - const result = await scanDirectory(cachePath, category, `${fork.label} - ${dir.name} Cache`) + const result = await scanDirectory(cachePath, category, `${fork.label} - ${dir.name} Cache`, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -263,7 +227,7 @@ async function scanBrowserCli(): Promise { } // Safari (macOS only) — cache directory only, never cookies/history/bookmarks if (browserPaths.safari && existsSync(browserPaths.safari.cache)) { - const result = await scanDirectory(browserPaths.safari.cache, category, 'Safari - Cache') + const result = await scanDirectory(browserPaths.safari.cache, category, 'Safari - Cache', recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } return results @@ -471,17 +435,6 @@ async function cleanDatabasesCli(itemIds: string[]): Promise { return { totalCleaned, filesDeleted, filesSkipped, errors, needsElevation: errors.some((e) => e.reason === 'permission-denied') } } -async function getChromiumProfiles(basePath: string): Promise { - const profiles = ['Default'] - try { - const entries = await readdir(basePath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('Profile ')) profiles.push(entry.name) - } - } catch { /* skip */ } - return profiles -} - // ─── Help text ─────────────────────────────────────────────── function printHelp(): void { diff --git a/src/main/ipc/browser-cleaner.ipc.test.ts b/src/main/ipc/browser-cleaner.ipc.test.ts index b30b830d..dfc99988 100644 --- a/src/main/ipc/browser-cleaner.ipc.test.ts +++ b/src/main/ipc/browser-cleaner.ipc.test.ts @@ -59,21 +59,33 @@ function getHandler(channel: string): (...args: unknown[]) => unknown { return call[1] as (...args: unknown[]) => unknown } +const PROFILE_CACHES = [ + { dir: 'Cache', label: 'Cache' }, + { dir: 'Code Cache', label: 'Code Cache' }, + { dir: 'GPUCache', label: 'GPU Cache' }, + { dir: 'Service Worker', label: 'Service Worker Cache' }, +] +const SHARED_CACHES = [{ dir: 'GrShaderCache', label: 'Skia Shader Cache' }] + +function browser(base: string) { + return { base, profileCaches: PROFILE_CACHES, sharedCaches: SHARED_CACHES } +} + function makeBrowserPaths() { return { - chrome: { base: '/home/user/.config/google-chrome', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - edge: { base: '/home/user/.config/microsoft-edge', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - brave: { base: '/fake/brave', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - vivaldi: { base: '/fake/vivaldi', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - opera: { base: '/fake/opera', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - operaGX: { base: '/fake/operaGX', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - arc: { base: '/fake/arc', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - chromium: { base: '/fake/chromium', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - thorium: { base: '/fake/thorium', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - supermium: { base: '/fake/supermium', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - helium: { base: '/fake/helium', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - cromite: { base: '/fake/cromite', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, - catsxp: { base: '/fake/catsxp', cache: 'Cache', codeCache: 'Code Cache', gpuCache: 'GPUCache', serviceWorker: 'Service Worker' }, + chrome: browser('/home/user/.config/google-chrome'), + edge: browser('/home/user/.config/microsoft-edge'), + brave: browser('/fake/brave'), + vivaldi: browser('/fake/vivaldi'), + opera: browser('/fake/opera'), + operaGX: browser('/fake/operaGX'), + arc: browser('/fake/arc'), + chromium: browser('/fake/chromium'), + thorium: browser('/fake/thorium'), + supermium: browser('/fake/supermium'), + helium: browser('/fake/helium'), + cromite: browser('/fake/cromite'), + catsxp: browser('/fake/catsxp'), firefox: { cache: '/fake/firefox-cache' }, librewolf: { cache: '' }, waterfox: { cache: '' }, @@ -150,6 +162,43 @@ describe('BROWSER_SCAN handler', () => { expect(mockCacheItems).toHaveBeenCalled() }) + // Issue #265: judging a directory by its own mtime made scanDirectory drop + // `Code Cache` whole — it holds only `js` and `wasm`, both touched by any + // recent browsing. + it('scans browser caches with directories judged by their contents', async () => { + mockExistsSync.mockReturnValue(true) + mockReaddir.mockResolvedValue([]) + mockScanDirectory.mockResolvedValue({ category: 'browser', subcategory: 'test', items: [], totalSize: 0, itemCount: 0 }) + + registerBrowserCleanerIpc(() => mockWindow() as any) + await getHandler('cleaner:browser:scan')() + + expect(mockScanDirectory).toHaveBeenCalled() + for (const call of mockScanDirectory.mock.calls) { + expect(call[3]).toEqual({ deepRecencyCheck: true }) + } + }) + + it('scans the shared caches that sit beside the profiles', async () => { + const chromeBase = '/home/user/.config/google-chrome' + mockExistsSync.mockImplementation((p: string) => p === chromeBase || p.startsWith(join(chromeBase, 'GrShaderCache'))) + mockReaddir.mockResolvedValue([]) + mockScanDirectory.mockResolvedValue({ + category: 'browser', + subcategory: 'Chrome - Skia Shader Cache', + items: [{ id: '1', path: '/test', size: 100 }], + totalSize: 100, + itemCount: 1, + }) + + registerBrowserCleanerIpc(() => mockWindow() as any) + await getHandler('cleaner:browser:scan')() + + expect(mockScanDirectory).toHaveBeenCalledWith( + join(chromeBase, 'GrShaderCache'), 'browser', 'Chrome - Skia Shader Cache', { deepRecencyCheck: true } + ) + }) + it('scans Opera-style browsers without profiles', async () => { const paths = makeBrowserPaths() // Only opera exists diff --git a/src/main/ipc/browser-cleaner.ipc.ts b/src/main/ipc/browser-cleaner.ipc.ts index 071f6f05..1a8ed4ce 100644 --- a/src/main/ipc/browser-cleaner.ipc.ts +++ b/src/main/ipc/browser-cleaner.ipc.ts @@ -11,77 +11,20 @@ import { CleanerType } from '../../shared/enums' import type { ScanResult, CleanResult } from '../../shared/types' import type { WindowGetter } from './index' import { validateStringArray } from '../services/ipc-validation' - -interface ChromiumBrowserDef { - key: string - label: string - base: string - cache: string - codeCache: string - gpuCache: string - serviceWorker: string - hasProfiles: boolean -} +import { BROWSER_CACHE_RECENCY, chromiumBrowsers, chromiumCacheTargets } from '../services/chromium-cache' export function registerBrowserCleanerIpc(getWindow: WindowGetter): void { ipcMain.handle(IPC.BROWSER_SCAN, async (): Promise => { const results: ScanResult[] = [] const category = CleanerType.Browser const browserPaths = getPlatform().paths.browserPaths() - - const chromiumBrowsers: ChromiumBrowserDef[] = [ - { key: 'chrome', label: 'Chrome', ...browserPaths.chrome, hasProfiles: true }, - { key: 'edge', label: 'Edge', ...browserPaths.edge, hasProfiles: true }, - { key: 'brave', label: 'Brave', ...browserPaths.brave, hasProfiles: true }, - { key: 'vivaldi', label: 'Vivaldi', ...browserPaths.vivaldi, hasProfiles: true }, - // Opera stores profiles differently — cache is directly under the base path - { key: 'opera', label: 'Opera', ...browserPaths.opera, hasProfiles: false }, - { key: 'operaGX', label: 'Opera GX', ...browserPaths.operaGX, hasProfiles: false }, - { key: 'arc', label: 'Arc', ...browserPaths.arc, hasProfiles: true }, - { key: 'chromium', label: 'Chromium', ...browserPaths.chromium, hasProfiles: true }, - { key: 'thorium', label: 'Thorium', ...browserPaths.thorium, hasProfiles: true }, - { key: 'supermium', label: 'Supermium', ...browserPaths.supermium, hasProfiles: true }, - { key: 'helium', label: 'Helium', ...browserPaths.helium, hasProfiles: true }, - { key: 'cromite', label: 'Cromite', ...browserPaths.cromite, hasProfiles: true }, - { key: 'catsxp', label: 'CatsXP', ...browserPaths.catsxp, hasProfiles: true }, - ] + const recency = BROWSER_CACHE_RECENCY // Scan all Chromium-based browsers - for (const browser of chromiumBrowsers) { - if (!existsSync(browser.base)) continue - - if (browser.hasProfiles) { - const profiles = await getChromiumProfiles(browser.base) - for (const profile of profiles) { - const cacheDirs = [ - { dir: browser.cache, label: 'Cache' }, - { dir: browser.codeCache, label: 'Code Cache' }, - { dir: browser.gpuCache, label: 'GPU Cache' }, - { dir: browser.serviceWorker, label: 'Service Worker Cache' }, - ] - for (const { dir, label } of cacheDirs) { - const cachePath = join(browser.base, profile, dir) - if (existsSync(cachePath)) { - const result = await scanDirectory(cachePath, category, `${browser.label} - ${profile} ${label}`) - if (result.items.length > 0) { cacheItems(result.items); results.push(result) } - } - } - } - } else { - // Opera-style: cache dirs directly under base - const cacheDirs = [ - { dir: browser.cache, label: 'Cache' }, - { dir: browser.codeCache, label: 'Code Cache' }, - { dir: browser.gpuCache, label: 'GPU Cache' }, - { dir: browser.serviceWorker, label: 'Service Worker Cache' }, - ] - for (const { dir, label } of cacheDirs) { - const cachePath = join(browser.base, dir) - if (existsSync(cachePath)) { - const result = await scanDirectory(cachePath, category, `${browser.label} - ${label}`) - if (result.items.length > 0) { cacheItems(result.items); results.push(result) } - } - } + for (const browser of chromiumBrowsers(browserPaths)) { + for (const target of await chromiumCacheTargets(browser)) { + const result = await scanDirectory(target.path, category, target.label, recency) + if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -93,7 +36,7 @@ export function registerBrowserCleanerIpc(getWindow: WindowGetter): void { if (dir.isDirectory()) { const cachePath = join(browserPaths.firefox.cache, dir.name, 'cache2', 'entries') if (existsSync(cachePath)) { - const result = await scanDirectory(cachePath, category, `Firefox - ${dir.name} Cache`) + const result = await scanDirectory(cachePath, category, `Firefox - ${dir.name} Cache`, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -117,7 +60,7 @@ export function registerBrowserCleanerIpc(getWindow: WindowGetter): void { if (dir.isDirectory()) { const cachePath = join(fork.cache, dir.name, 'cache2') if (existsSync(cachePath)) { - const result = await scanDirectory(cachePath, category, `${fork.label} - ${dir.name} Cache`) + const result = await scanDirectory(cachePath, category, `${fork.label} - ${dir.name} Cache`, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -129,7 +72,7 @@ export function registerBrowserCleanerIpc(getWindow: WindowGetter): void { // Safari (macOS only) — cache directory only, never cookies/history/bookmarks if (browserPaths.safari && existsSync(browserPaths.safari.cache)) { - const result = await scanDirectory(browserPaths.safari.cache, category, 'Safari - Cache') + const result = await scanDirectory(browserPaths.safari.cache, category, 'Safari - Cache', recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } @@ -166,18 +109,3 @@ export function registerBrowserCleanerIpc(getWindow: WindowGetter): void { }) }) } - -async function getChromiumProfiles(basePath: string): Promise { - const profiles = ['Default'] - try { - const entries = await readdir(basePath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('Profile ')) { - profiles.push(entry.name) - } - } - } catch { - // Skip - } - return profiles -} diff --git a/src/main/platform/types.ts b/src/main/platform/types.ts index 5bcbc5fb..6e3a9b96 100644 --- a/src/main/platform/types.ts +++ b/src/main/platform/types.ts @@ -46,12 +46,19 @@ export interface BrowserPathConfig { safari: { cache: string } | null } +export interface ChromiumCacheDir { + /** Directory relative to the profile, or to `base` for shared entries */ + dir: string + /** Display label appended to the browser (and profile) name */ + label: string +} + export interface BrowserPaths { base: string - cache: string - codeCache: string - gpuCache: string - serviceWorker: string + /** Cache directories inside each profile (Default, Profile 1, …) */ + profileCaches: ChromiumCacheDir[] + /** Cache directories shared by every profile, directly under `base` */ + sharedCaches: ChromiumCacheDir[] } export interface AppCacheDef { diff --git a/src/main/platform/win32/paths.test.ts b/src/main/platform/win32/paths.test.ts index 7c54cea8..2d5ef599 100644 --- a/src/main/platform/win32/paths.test.ts +++ b/src/main/platform/win32/paths.test.ts @@ -120,13 +120,23 @@ describe('win32 paths', () => { it('Chromium browsers have consistent cache dir names', () => { const chromiumBrowsers = [browsers.chrome, browsers.edge, browsers.brave, browsers.vivaldi, browsers.arc, browsers.chromium] for (const b of chromiumBrowsers) { - expect(b.cache).toBe('Cache\\Cache_Data') - expect(b.codeCache).toBe('Code Cache') - expect(b.gpuCache).toBe('GPUCache') - expect(b.serviceWorker).toBe('Service Worker\\CacheStorage') + const profile = b.profileCaches.map((c) => c.dir) + expect(profile).toContain('Cache\\Cache_Data') + expect(profile).toContain('Code Cache') + expect(profile).toContain('GPUCache') + expect(profile).toContain('Service Worker\\CacheStorage') + // Shader and CRX caches sit beside the profiles, not inside them (issue #265) + expect(b.sharedCaches.map((c) => c.dir)).toEqual( + expect.arrayContaining(['component_crx_cache', 'extensions_crx_cache', 'GrShaderCache', 'ShaderCache']) + ) } }) + it('every cache dir carries a distinct display label', () => { + const labels = [...browsers.chrome.profileCaches, ...browsers.chrome.sharedCaches].map((c) => c.label) + expect(new Set(labels).size).toBe(labels.length) + }) + it('Safari is null on Windows', () => { expect(browsers.safari).toBeNull() }) diff --git a/src/main/rules/loader.test.ts b/src/main/rules/loader.test.ts index fea2e95f..11e9d129 100644 --- a/src/main/rules/loader.test.ts +++ b/src/main/rules/loader.test.ts @@ -67,10 +67,13 @@ describe('buildCleanerPaths', () => { browsers: { type: 'browsers', chromiumCacheDirs: { - cache: 'Cache', - codeCache: 'Code Cache', - gpuCache: 'GpuCache', - serviceWorker: 'Service Worker/CacheStorage', + profile: [ + { dir: 'Cache', label: 'Cache' }, + { dir: 'Code Cache', label: 'Code Cache' }, + { dir: 'GpuCache', label: 'GPU Cache' }, + { dir: 'Service Worker/CacheStorage', label: 'Service Worker Cache' }, + ], + shared: [{ dir: 'GrShaderCache', label: 'Skia Shader Cache' }], }, chromium: [ { key: 'chrome', base: '${CONFIG}/google-chrome' }, @@ -132,12 +135,24 @@ describe('buildCleanerPaths', () => { it('resolves browserPaths with chromium cache dirs', () => { const bp = paths.browserPaths() - expect(bp.chrome.cache).toBe('Cache') + expect(bp.chrome.profileCaches).toContainEqual({ dir: 'Cache', label: 'Cache' }) + expect(bp.chrome.sharedCaches).toEqual([{ dir: 'GrShaderCache', label: 'Skia Shader Cache' }]) expect(bp.chrome.base).toContain('google-chrome') expect(bp.firefox.base).toContain('.mozilla') expect(bp.safari).toBeNull() }) + it('defaults sharedCaches to an empty list when the rules omit it', () => { + const noShared = { + ...minimalJson, + browsers: { + ...minimalJson.browsers, + chromiumCacheDirs: { profile: [{ dir: 'Cache', label: 'Cache' }] }, + }, + } + expect(buildCleanerPaths(noShared, 'linux').browserPaths().chrome.sharedCaches).toEqual([]) + }) + it('resolves appPaths with childSubdir', () => { const apps = paths.appPaths() expect(apps).toHaveLength(2) diff --git a/src/main/rules/loader.ts b/src/main/rules/loader.ts index 5d7a685c..3dfdbc6a 100644 --- a/src/main/rules/loader.ts +++ b/src/main/rules/loader.ts @@ -9,6 +9,7 @@ import type { CleanTarget, BrowserPathConfig, BrowserPaths, + ChromiumCacheDir, AppCacheDef, DatabaseTarget, } from '../platform/types' @@ -29,10 +30,8 @@ export interface SystemRulesJson { export interface BrowserRulesJson { type: 'browsers' chromiumCacheDirs: { - cache: string - codeCache: string - gpuCache: string - serviceWorker: string + profile: Array<{ dir: string; label: string }> + shared?: Array<{ dir: string; label: string }> } chromium: Array<{ key: string; base: string }> firefox: { base: string; cache: string } @@ -194,18 +193,17 @@ export function buildCleanerPaths(json: RulesJsonSet, platform: 'win32' | 'darwi browserPaths(): BrowserPathConfig { const dirs = json.browsers.chromiumCacheDirs - const cacheDirsResolved = { - cache: resolvePath(dirs.cache, vars, platform), - codeCache: resolvePath(dirs.codeCache, vars, platform), - gpuCache: resolvePath(dirs.gpuCache, vars, platform), - serviceWorker: resolvePath(dirs.serviceWorker, vars, platform), - } + const resolveDirs = (list: Array<{ dir: string; label: string }>): ChromiumCacheDir[] => + list.map((d) => ({ dir: resolvePath(d.dir, vars, platform), label: d.label })) + const profileCaches = resolveDirs(dirs.profile) + const sharedCaches = resolveDirs(dirs.shared || []) const config: Record = {} for (const browser of json.browsers.chromium) { config[browser.key] = { base: resolvePath(browser.base, vars, platform), - ...cacheDirsResolved, + profileCaches, + sharedCaches, } } diff --git a/src/main/services/chromium-cache.test.ts b/src/main/services/chromium-cache.test.ts new file mode 100644 index 00000000..bca2dafb --- /dev/null +++ b/src/main/services/chromium-cache.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockExistsSync = vi.fn() +vi.mock('fs', () => ({ existsSync: (...args: unknown[]) => mockExistsSync(...args) })) + +const mockReaddir = vi.fn() +vi.mock('fs/promises', () => ({ readdir: (...args: unknown[]) => mockReaddir(...args) })) + +import { join } from 'path' +import { + BROWSER_CACHE_RECENCY, + chromiumBrowsers, + chromiumCacheTargets, + getChromiumProfiles, +} from './chromium-cache' +import type { BrowserPathConfig } from '../platform/types' + +const PROFILE_CACHES = [ + { dir: 'Cache', label: 'Cache' }, + { dir: 'Code Cache', label: 'Code Cache' }, +] +const SHARED_CACHES = [ + { dir: 'component_crx_cache', label: 'Component Extension Cache' }, + { dir: 'GrShaderCache', label: 'Skia Shader Cache' }, +] + +function browser(base: string) { + return { base, profileCaches: PROFILE_CACHES, sharedCaches: SHARED_CACHES } +} + +function makePaths(): BrowserPathConfig { + const keys = ['chrome', 'edge', 'brave', 'opera', 'operaGX', 'vivaldi', 'arc', 'chromium', 'thorium', 'supermium', 'helium', 'cromite', 'catsxp'] + const config: Record = {} + for (const key of keys) config[key] = browser(`/fake/${key}`) + config.firefox = { base: '/fake/ff', cache: '/fake/ff-cache' } + config.safari = null + return config as unknown as BrowserPathConfig +} + +describe('chromiumBrowsers', () => { + it('pairs every known browser with its resolved paths', () => { + const browsers = chromiumBrowsers(makePaths()) + expect(browsers).toHaveLength(13) + expect(browsers.find((b) => b.key === 'chrome')?.label).toBe('Chrome') + expect(browsers.find((b) => b.key === 'chrome')?.base).toBe('/fake/chrome') + }) + + it('marks only the Opera family as profile-less', () => { + const profileLess = chromiumBrowsers(makePaths()).filter((b) => !b.hasProfiles).map((b) => b.key) + expect(profileLess).toEqual(['opera', 'operaGX']) + }) + + it('skips browsers the platform rules do not define', () => { + const partial = { chrome: browser('/fake/chrome') } as unknown as BrowserPathConfig + expect(chromiumBrowsers(partial).map((b) => b.key)).toEqual(['chrome']) + }) +}) + +describe('chromiumCacheTargets', () => { + beforeEach(() => { + vi.clearAllMocks() + mockReaddir.mockResolvedValue([]) + }) + + const chrome = { key: 'chrome', label: 'Chrome', hasProfiles: true, ...browser('/fake/chrome') } + + it('returns nothing when the browser is not installed', async () => { + mockExistsSync.mockReturnValue(false) + expect(await chromiumCacheTargets(chrome)).toEqual([]) + }) + + it('labels per-profile caches with the profile name', async () => { + mockExistsSync.mockReturnValue(true) + mockReaddir.mockResolvedValue([{ isDirectory: () => true, name: 'Profile 1' }]) + + const targets = await chromiumCacheTargets(chrome) + expect(targets).toContainEqual({ path: join('/fake/chrome', 'Default', 'Code Cache'), label: 'Chrome - Default Code Cache' }) + expect(targets).toContainEqual({ path: join('/fake/chrome', 'Profile 1', 'Cache'), label: 'Chrome - Profile 1 Cache' }) + }) + + // Issue #265: these live beside the profiles, so scanning only profile + // subdirectories missed them entirely (~370 MB on the reporter's machine). + it('scans shared caches once, directly under the user-data base', async () => { + mockExistsSync.mockReturnValue(true) + mockReaddir.mockResolvedValue([{ isDirectory: () => true, name: 'Profile 1' }]) + + const targets = await chromiumCacheTargets(chrome) + const shared = targets.filter((t) => t.path.includes('GrShaderCache')) + expect(shared).toEqual([{ path: join('/fake/chrome', 'GrShaderCache'), label: 'Chrome - Skia Shader Cache' }]) + expect(targets).toContainEqual({ path: join('/fake/chrome', 'component_crx_cache'), label: 'Chrome - Component Extension Cache' }) + }) + + it('puts every cache directly under the base for profile-less builds', async () => { + mockExistsSync.mockReturnValue(true) + const opera = { key: 'opera', label: 'Opera', hasProfiles: false, ...browser('/fake/opera') } + + const targets = await chromiumCacheTargets(opera) + expect(targets.map((t) => t.label)).toEqual([ + 'Opera - Component Extension Cache', + 'Opera - Skia Shader Cache', + 'Opera - Cache', + 'Opera - Code Cache', + ]) + expect(targets.every((t) => t.path.startsWith(join('/fake/opera')))).toBe(true) + expect(mockReaddir).not.toHaveBeenCalled() + }) + + it('omits cache directories that do not exist', async () => { + mockExistsSync.mockImplementation((p: string) => p === '/fake/chrome' || p.endsWith('Code Cache')) + + const targets = await chromiumCacheTargets(chrome) + expect(targets).toEqual([ + { path: join('/fake/chrome', 'Default', 'Code Cache'), label: 'Chrome - Default Code Cache' }, + ]) + }) +}) + +describe('getChromiumProfiles', () => { + beforeEach(() => vi.clearAllMocks()) + + it('always includes Default and adds numbered profiles', async () => { + mockReaddir.mockResolvedValue([ + { isDirectory: () => true, name: 'Profile 1' }, + { isDirectory: () => true, name: 'Profile 2' }, + { isDirectory: () => true, name: 'ShaderCache' }, + { isDirectory: () => false, name: 'Local State' }, + ]) + expect(await getChromiumProfiles('/fake/chrome')).toEqual(['Default', 'Profile 1', 'Profile 2']) + }) + + it('falls back to Default when the base is unreadable', async () => { + mockReaddir.mockRejectedValue(new Error('EACCES')) + expect(await getChromiumProfiles('/fake/chrome')).toEqual(['Default']) + }) +}) + +describe('BROWSER_CACHE_RECENCY', () => { + // `Code Cache` holds just the `js` and `wasm` directories, so a browser used + // in the last hour had both skipped and the result dropped for being empty + // (issue #265). Directories are judged by their contents instead. + it('judges directories by their contents', () => { + expect(BROWSER_CACHE_RECENCY.deepRecencyCheck).toBe(true) + }) + + it('leaves the default cutoff in place', () => { + expect(BROWSER_CACHE_RECENCY.skipRecentMinutes).toBeUndefined() + }) +}) diff --git a/src/main/services/chromium-cache.ts b/src/main/services/chromium-cache.ts new file mode 100644 index 00000000..c9a2b106 --- /dev/null +++ b/src/main/services/chromium-cache.ts @@ -0,0 +1,115 @@ +// ─── Chromium Cache Targets ─────────────────────────────────── +// Shared by the browser cleaner IPC, the CLI, and the cloud agent so all three +// scan the same set of directories. They used to keep their own copies of this +// list, which is how Chromium's shader and CRX caches ended up missing from +// every scan (issue #265). + +import { existsSync } from 'fs' +import { readdir } from 'fs/promises' +import { join } from 'path' +import type { BrowserPathConfig, BrowserPaths } from '../platform/types' +import type { ScanRecencyOptions } from './file-utils' + +export interface ChromiumBrowser extends BrowserPaths { + key: string + label: string + /** Opera-family builds put the profile at `base` instead of in `Default`/`Profile N` */ + hasProfiles: boolean +} + +const BROWSERS: Array<{ key: string; label: string; hasProfiles: boolean }> = [ + { key: 'chrome', label: 'Chrome', hasProfiles: true }, + { key: 'edge', label: 'Edge', hasProfiles: true }, + { key: 'brave', label: 'Brave', hasProfiles: true }, + { key: 'vivaldi', label: 'Vivaldi', hasProfiles: true }, + // Opera stores profiles differently — cache is directly under the base path + { key: 'opera', label: 'Opera', hasProfiles: false }, + { key: 'operaGX', label: 'Opera GX', hasProfiles: false }, + { key: 'arc', label: 'Arc', hasProfiles: true }, + { key: 'chromium', label: 'Chromium', hasProfiles: true }, + { key: 'thorium', label: 'Thorium', hasProfiles: true }, + { key: 'supermium', label: 'Supermium', hasProfiles: true }, + { key: 'helium', label: 'Helium', hasProfiles: true }, + { key: 'cromite', label: 'Cromite', hasProfiles: true }, + { key: 'catsxp', label: 'CatsXP', hasProfiles: true }, +] + +/** + * Recency settings every browser cache scan passes to `scanDirectory`. + * + * The 60-minute cutoff stays as it was — a browser's live files are still left + * alone, at any depth. What changes is that a directory is judged by what is + * inside it rather than by its own mtime, which moves whenever an entry is + * added or removed and so says nothing about whether the contents are in use. + * + * Testing it directly hid whole caches: `Code Cache` holds exactly two entries, + * the `js` and `wasm` directories, so any recent browsing had both skipped and + * the result dropped for being empty. That is why Chrome's Code Cache never + * appeared while Edge's did — Edge simply wasn't being used (issue #265). + */ +export const BROWSER_CACHE_RECENCY: ScanRecencyOptions = { deepRecencyCheck: true } + +/** Pair each known Chromium browser with its resolved paths. */ +export function chromiumBrowsers(paths: BrowserPathConfig): ChromiumBrowser[] { + const config = paths as unknown as Record + const browsers: ChromiumBrowser[] = [] + for (const { key, label, hasProfiles } of BROWSERS) { + const entry = config[key] + if (entry) browsers.push({ key, label, hasProfiles, ...entry }) + } + return browsers +} + +export interface CacheTarget { + path: string + /** Full subcategory label, e.g. "Chrome - Default Code Cache" */ + label: string +} + +/** + * Every cache directory of `browser` that exists on disk, labelled for display. + * Returns an empty list when the browser isn't installed. + */ +export async function chromiumCacheTargets(browser: ChromiumBrowser): Promise { + if (!existsSync(browser.base)) return [] + + const targets: CacheTarget[] = [] + const add = (path: string, label: string): void => { + if (existsSync(path)) targets.push({ path, label }) + } + + // Shared caches sit next to the profiles rather than inside them. Profile-less + // builds treat `base` as the profile, so their profile caches land there too. + for (const { dir, label } of browser.sharedCaches) { + add(join(browser.base, dir), `${browser.label} - ${label}`) + } + + if (!browser.hasProfiles) { + for (const { dir, label } of browser.profileCaches) { + add(join(browser.base, dir), `${browser.label} - ${label}`) + } + return targets + } + + for (const profile of await getChromiumProfiles(browser.base)) { + for (const { dir, label } of browser.profileCaches) { + add(join(browser.base, profile, dir), `${browser.label} - ${profile} ${label}`) + } + } + return targets +} + +export async function getChromiumProfiles(basePath: string): Promise { + const profiles = ['Default'] + try { + const entries = await readdir(basePath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('Profile ')) { + profiles.push(entry.name) + } + } + } catch { + // Skip + } + return profiles +} diff --git a/src/main/services/cloud-agent.ts b/src/main/services/cloud-agent.ts index 1698a92f..3718e71f 100644 --- a/src/main/services/cloud-agent.ts +++ b/src/main/services/cloud-agent.ts @@ -18,6 +18,7 @@ type Pusher = PusherImport import { getSettings, setSettings, getMachineId } from './settings-store' import { scanDirectory, scanMultipleDirectories, scanDirectoriesAsItems, resolveChildSubdirs, cleanItems } from './file-utils' import { cacheItems } from './scan-cache' +import { BROWSER_CACHE_RECENCY, chromiumBrowsers, chromiumCacheTargets } from './chromium-cache' import { psUtf8 } from './exec-utf8' import { getPlatform } from '../platform' import { CleanerType } from '../../shared/enums' @@ -1934,54 +1935,14 @@ class CloudAgentService { const browserResults: ScanResult[] = [] const browserPaths = getPlatform().paths.browserPaths() const browserCategory = CleanerType.Browser - - const chromiumBrowsers = [ - { label: 'Chrome', ...browserPaths.chrome, hasProfiles: true }, - { label: 'Edge', ...browserPaths.edge, hasProfiles: true }, - { label: 'Brave', ...browserPaths.brave, hasProfiles: true }, - { label: 'Vivaldi', ...browserPaths.vivaldi, hasProfiles: true }, - { label: 'Opera', ...browserPaths.opera, hasProfiles: false }, - { label: 'Opera GX', ...browserPaths.operaGX, hasProfiles: false }, - { label: 'Arc', ...browserPaths.arc, hasProfiles: true }, - { label: 'Chromium', ...browserPaths.chromium, hasProfiles: true }, - { label: 'Thorium', ...browserPaths.thorium, hasProfiles: true }, - { label: 'Supermium', ...browserPaths.supermium, hasProfiles: true }, - { label: 'Helium', ...browserPaths.helium, hasProfiles: true }, - { label: 'Cromite', ...browserPaths.cromite, hasProfiles: true }, - { label: 'CatsXP', ...browserPaths.catsxp, hasProfiles: true }, - ] - - for (const browser of chromiumBrowsers) { - if (!existsSync(browser.base)) continue - const cacheDirs = [ - { dir: browser.cache, label: 'Cache' }, - { dir: browser.codeCache, label: 'Code Cache' }, - { dir: browser.gpuCache, label: 'GPU Cache' }, - { dir: browser.serviceWorker, label: 'Service Worker Cache' }, - ] - if (browser.hasProfiles) { - const profiles = await getChromiumProfiles(browser.base) - for (const profile of profiles) { - for (const { dir, label } of cacheDirs) { - const cachePath = join(browser.base, profile, dir) - if (existsSync(cachePath)) { - try { - const r = await scanDirectory(cachePath, browserCategory, `${browser.label} - ${profile} ${label}`) - if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } - } catch { /* skip */ } - } - } - } - } else { - for (const { dir, label } of cacheDirs) { - const cachePath = join(browser.base, dir) - if (existsSync(cachePath)) { - try { - const r = await scanDirectory(cachePath, browserCategory, `${browser.label} - ${label}`) - if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } - } catch { /* skip */ } - } - } + const recency = BROWSER_CACHE_RECENCY + + for (const browser of chromiumBrowsers(browserPaths)) { + for (const target of await chromiumCacheTargets(browser)) { + try { + const r = await scanDirectory(target.path, browserCategory, target.label, recency) + if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } + } catch { /* skip */ } } } @@ -1993,7 +1954,7 @@ class CloudAgentService { if (dir.isDirectory()) { const cachePath = join(browserPaths.firefox.cache, dir.name, 'cache2', 'entries') if (existsSync(cachePath)) { - const r = await scanDirectory(cachePath, browserCategory, `Firefox - ${dir.name} Cache`) + const r = await scanDirectory(cachePath, browserCategory, `Firefox - ${dir.name} Cache`, recency) if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } } } @@ -2015,7 +1976,7 @@ class CloudAgentService { if (dir.isDirectory()) { const cachePath = join(fork.cache, dir.name, 'cache2') if (existsSync(cachePath)) { - const r = await scanDirectory(cachePath, browserCategory, `${fork.label} - ${dir.name} Cache`) + const r = await scanDirectory(cachePath, browserCategory, `${fork.label} - ${dir.name} Cache`, recency) if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } } } @@ -2026,7 +1987,7 @@ class CloudAgentService { // Safari (macOS only) — cache directory only, never cookies/history/bookmarks if (browserPaths.safari && existsSync(browserPaths.safari.cache)) { try { - const r = await scanDirectory(browserPaths.safari.cache, browserCategory, 'Safari - Cache') + const r = await scanDirectory(browserPaths.safari.cache, browserCategory, 'Safari - Cache', recency) if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } } catch { /* skip */ } } @@ -3308,18 +3269,3 @@ class CloudAgentService { } export const cloudAgent = new CloudAgentService() - -async function getChromiumProfiles(basePath: string): Promise { - const profiles = ['Default'] - try { - const entries = await readdir(basePath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('Profile ')) { - profiles.push(entry.name) - } - } - } catch { - // Skip - } - return profiles -} diff --git a/src/main/services/file-utils.recency.test.ts b/src/main/services/file-utils.recency.test.ts new file mode 100644 index 00000000..96ee3087 --- /dev/null +++ b/src/main/services/file-utils.recency.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +vi.mock('./settings-store', () => ({ + getSettings: () => ({ cleaner: { secureDelete: false, skipRecentMinutes: 60 }, exclusions: [] }), +})) + +vi.mock('./scan-cache', () => ({ getCachedItems: () => [] })) + +import { scanDirectory } from './file-utils' + +const DEEP = { deepRecencyCheck: true } + +let testDir: string + +function ago(minutes: number): Date { + return new Date(Date.now() - minutes * 60 * 1000) +} + +/** A file `minutesAgo` old, creating parent directories as needed. */ +function file(relPath: string, minutesAgo: number, bytes = 32): string { + const path = join(testDir, relPath) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, Buffer.alloc(bytes)) + utimesSync(path, ago(minutesAgo), ago(minutesAgo)) + return path +} + +/** Set a directory's own mtime, leaving its contents alone. */ +function touchDir(relPath: string, minutesAgo: number): string { + const path = join(testDir, relPath) + utimesSync(path, ago(minutesAgo), ago(minutesAgo)) + return path +} + +function paths(result: { items: Array<{ path: string }> }): string[] { + return result.items.map((i) => i.path).sort() +} + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'kudu-recency-')) +}) + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) +}) + +describe('scanDirectory recency guard', () => { + it('skips recent files and recent directories by default', async () => { + file('hot.bin', 1) + file('hot-dir/entry', 1) + touchDir('hot-dir', 1) + file('cold.bin', 180) + + const result = await scanDirectory(testDir, 'browser', 'Test') + expect(paths(result)).toEqual([join(testDir, 'cold.bin')]) + }) + + it('accepts a plain number as the cutoff, as before', async () => { + file('hot.bin', 1) + file('hot-dir/entry', 1) + touchDir('hot-dir', 1) + + expect((await scanDirectory(testDir, 'browser', 'Test', 60)).items).toHaveLength(0) + expect((await scanDirectory(testDir, 'browser', 'Test', 0)).items).toHaveLength(2) + }) +}) + +describe('scanDirectory with deepRecencyCheck', () => { + // Issue #265: Chrome's `Code Cache` holds only the `js` and `wasm` + // directories. Their mtimes move on every write, so judging them directly + // discarded ~310 MB and the empty result was then dropped entirely. + it('keeps a directory whose own mtime is recent but whose contents are settled', async () => { + file('js/entry', 180, 300) + file('wasm/entry', 180, 10) + touchDir('js', 1) + touchDir('wasm', 1) + + const result = await scanDirectory(testDir, 'browser', 'Test', DEEP) + expect(paths(result)).toEqual([join(testDir, 'js'), join(testDir, 'wasm')].sort()) + expect(result.totalSize).toBe(310) + }) + + // The hole Codex found in the first attempt: admitting the directory whole + // means safeDelete recurses into it, so a live descendant would be removed — + // and securely overwritten in place — underneath a running browser. + it('never offers a directory that still holds a live file', async () => { + file('js/settled', 180, 100) + file('js/live', 1, 5) + + const result = await scanDirectory(testDir, 'browser', 'Test', DEEP) + expect(paths(result)).toEqual([join(testDir, 'js', 'settled')]) + expect(result.totalSize).toBe(100) + }) + + it('checks descendants all the way down, not just one level', async () => { + file('a/b/c/settled', 180, 100) + file('a/b/c/live', 1, 5) + file('a/other/settled', 180, 20) + + const result = await scanDirectory(testDir, 'browser', 'Test', DEEP) + expect(paths(result)).toEqual([ + join(testDir, 'a', 'b', 'c', 'settled'), + join(testDir, 'a', 'other'), + ].sort()) + expect(result.totalSize).toBe(120) + }) + + // A running browser keeps `data_0`-`data_3` and `index` memory-mapped. + it('still skips recently written files at the top level', async () => { + file('data_0', 1) + file('f_00001', 180) + + const result = await scanDirectory(testDir, 'browser', 'Test', DEEP) + expect(paths(result)).toEqual([join(testDir, 'f_00001')]) + }) + + it('collapses a fully settled tree into one item per top-level entry', async () => { + file('js/a', 180, 10) + file('js/index-dir/the-real-index', 180, 5) + + const result = await scanDirectory(testDir, 'browser', 'Test', DEEP) + expect(paths(result)).toEqual([join(testDir, 'js')]) + expect(result.totalSize).toBe(15) + }) + + it('reports nothing when every entry is live', async () => { + file('js/live', 1) + + const result = await scanDirectory(testDir, 'browser', 'Test', DEEP) + expect(result.items).toHaveLength(0) + expect(result.itemCount).toBe(0) + }) +}) diff --git a/src/main/services/file-utils.ts b/src/main/services/file-utils.ts index 858f98ad..cb78d5c8 100644 --- a/src/main/services/file-utils.ts +++ b/src/main/services/file-utils.ts @@ -1,6 +1,6 @@ import { rm, stat, lstat, readdir, open, writeFile } from 'fs/promises' import { existsSync } from 'fs' -import type { Dirent } from 'fs' +import type { Dirent, Stats } from 'fs' import { join } from 'path' import { randomUUID, randomBytes } from 'crypto' import type { ScanItem, ScanResult, CleanResult, DeletedFileRecord, DeletionOrigin } from '../../shared/types' @@ -254,18 +254,139 @@ export async function cleanItems( return { totalCleaned, filesDeleted, filesSkipped, errors, needsElevation } } +export interface ScanRecencyOptions { + /** Skip entries modified within this many minutes (default 60) */ + skipRecentMinutes?: number + /** + * Judge a directory by its contents rather than by its own mtime. + * + * A directory's mtime moves whenever an entry is added or removed inside it, + * which says nothing about whether the contents are in use. Testing it + * directly discards the whole subtree: Chrome's `Code Cache` holds exactly + * two entries, the `js` and `wasm` directories, so any recent browsing had + * both skipped and the entire result dropped for being empty (issue #265). + * + * With this on, a directory is only offered as one item when nothing beneath + * it falls inside the cutoff — which is what makes the recursive delete that + * follows safe. When something under it is live, the directory is opened and + * its children judged the same way, so a running app keeps the files it is + * still writing while everything settled around them is still reclaimed. + */ + deepRecencyCheck?: boolean +} + +/** How far the contents check descends before it gives up on a subtree. */ +const MAX_RECENCY_DEPTH = 8 + +interface RecencyScan { + cutoff: number + exclusions: string[] + /** Item budget shared across the whole scan */ + remaining: number +} + +interface ResolvedEntry { + items: Array<{ path: string; size: number; mtimeMs: number }> + /** Nothing beneath this entry was withheld, so deleting it whole is safe */ + complete: boolean + size: number +} + +function withheld(): ResolvedEntry { + return { items: [], complete: false, size: 0 } +} + +/** Resolve the entries inside `dirPath`, honouring the cutoff at every depth. */ +async function resolveChildren(dirPath: string, ctx: RecencyScan, depth: number): Promise { + let entries: Dirent[] + try { + entries = await readdir(dirPath, { withFileTypes: true }) + } catch { + return withheld() + } + + const items: ResolvedEntry['items'] = [] + let complete = true + let size = 0 + + for (const entry of entries) { + if (ctx.remaining <= 0) { complete = false; break } + const childPath = join(dirPath, entry.name) + if (isExcluded(childPath, ctx.exclusions)) { complete = false; continue } + + // Never descend a symlink — rm only unlinks the link, so what readdir would + // list here is the target's contents, which this scan does not remove. + const child = entry.isSymbolicLink() + ? withheld() + : await resolveEntry(childPath, entry.isDirectory(), ctx, depth) + + if (!child.complete) complete = false + items.push(...child.items) + size += child.size + } + + return { items, complete, size } +} + +/** Resolve a single entry into the items it contributes. */ +async function resolveEntry( + path: string, + isDirectory: boolean, + ctx: RecencyScan, + depth: number +): Promise { + let stats: Stats + try { + stats = await stat(path) + } catch { + return withheld() + } + + if (!isDirectory) { + if (stats.mtimeMs > ctx.cutoff) return withheld() + ctx.remaining-- + return { items: [{ path, size: stats.size, mtimeMs: stats.mtimeMs }], complete: true, size: stats.size } + } + + // Out of depth: the subtree can't be shown to be settled, so leave it alone. + if (depth <= 0) return withheld() + + const children = await resolveChildren(path, ctx, depth - 1) + if (!children.complete) return children + + // Everything inside is settled, so collapse to one item and let a single + // recursive delete take the lot — handing back the budget those children held. + ctx.remaining += children.items.length - 1 + return { items: [{ path, size: children.size, mtimeMs: stats.mtimeMs }], complete: true, size: children.size } +} + export async function scanDirectory( dirPath: string, category: string, subcategory: string, - skipRecentMinutes = 60 + recency: number | ScanRecencyOptions = {} ): Promise { + const { skipRecentMinutes = 60, deepRecencyCheck = false } = + typeof recency === 'number' ? { skipRecentMinutes: recency } : recency const items: ScanItem[] = [] let totalSize = 0 const cutoff = Date.now() - skipRecentMinutes * 60 * 1000 const MAX_ITEMS = 5000 const exclusions = getSettings().exclusions + const add = (path: string, size: number, mtimeMs: number): void => { + items.push({ id: randomUUID(), path, size, category, subcategory, lastModified: mtimeMs, selected: true }) + totalSize += size + } + + if (deepRecencyCheck) { + // The scanned directory itself is never offered — only what is inside it — + // so the top level takes `resolveChildren` and ignores whether it collapsed. + const resolved = await resolveChildren(dirPath, { cutoff, exclusions, remaining: MAX_ITEMS }, MAX_RECENCY_DEPTH) + for (const item of resolved.items.slice(0, MAX_ITEMS)) add(item.path, item.size, item.mtimeMs) + return { category, subcategory, items, totalSize, itemCount: items.length } + } + try { const entries = await readdir(dirPath, { withFileTypes: true }) @@ -283,18 +404,7 @@ export async function scanDirectory( const size = stats.isDirectory() ? await getDirectorySize(fullPath, 2) : stats.size - const item: ScanItem = { - id: randomUUID(), - path: fullPath, - size, - category, - subcategory, - lastModified: stats.mtimeMs, - selected: true - } - - items.push(item) - totalSize += item.size + add(fullPath, size, stats.mtimeMs) } catch { // Skip inaccessible files }