From 51256c855188833c72caab1479fd19f236d6a3cf Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 27 Jul 2026 08:46:55 +0200 Subject: [PATCH 1/3] fix(browser-cleaner): report Chromium caches the scan was silently dropping (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome's Code Cache never appeared in a scan while Edge's did. Both are the same Chromium directory; the difference was that Edge wasn't being used. scanDirectory skips any entry modified in the last 60 minutes. Code Cache contains exactly two entries, `js` and `wasm`, whose mtimes move on every write, so any browser used within the hour had both skipped and the whole result discarded for having no items. Browser caches are regenerated from scratch by the browser and a locked file still fails its delete safely as 'in-use', so the guard is turned off for this category. Also adds the Chromium cache directories that were never scanned at all. Four of them sit beside the profiles rather than inside one, which needed chromiumCacheDirs to grow a `shared` list alongside the per-profile entries: profile: DawnWebGPUCache, DawnGraphiteCache shared: component_crx_cache, extensions_crx_cache, GrShaderCache, ShaderCache, GraphiteDawnCache, GPUPersistentCache, PnaclTranslationCache Newer builds moved the Dawn and GPU caches up to the user-data level, so both spellings are listed; missing directories are skipped as before. The browser cleaner IPC, the CLI, and the cloud agent each kept their own copy of the browser list and cache-dir loop — which is how these directories stayed missing from all three. They now share services/chromium-cache. --- rules/darwin/browsers.json | 21 +++- rules/linux/browsers.json | 21 +++- rules/schema/rules.schema.json | 32 ++++- rules/win32/browsers.json | 21 +++- src/main/cli.ts | 65 ++-------- src/main/ipc/browser-cleaner.ipc.test.ts | 74 ++++++++++-- src/main/ipc/browser-cleaner.ipc.ts | 90 ++------------ src/main/platform/types.ts | 15 ++- src/main/platform/win32/paths.test.ts | 18 ++- src/main/rules/loader.test.ts | 25 +++- src/main/rules/loader.ts | 20 ++-- src/main/services/chromium-cache.test.ts | 144 +++++++++++++++++++++++ src/main/services/chromium-cache.ts | 113 ++++++++++++++++++ src/main/services/cloud-agent.ts | 78 ++---------- 14 files changed, 480 insertions(+), 257 deletions(-) create mode 100644 src/main/services/chromium-cache.test.ts create mode 100644 src/main/services/chromium-cache.ts 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..1291170e 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_SKIP_RECENT_MINUTES, 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 skipRecent = BROWSER_CACHE_SKIP_RECENT_MINUTES + for (const browser of chromiumBrowsers(browserPaths)) { + for (const target of await chromiumCacheTargets(browser)) { + const result = await scanDirectory(target.path, category, target.label, skipRecent) + 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`, skipRecent) 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`, skipRecent) 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', skipRecent) 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..8b8afc76 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,42 @@ describe('BROWSER_SCAN handler', () => { expect(mockCacheItems).toHaveBeenCalled() }) + // Issue #265: the 60-minute default made scanDirectory drop `Code Cache` + // whole — it holds only `js` and `wasm`, both touched by any recent browsing. + it('scans browser caches with the recency guard disabled', 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]).toBe(0) + } + }) + + 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', 0 + ) + }) + 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..564c2c9f 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_SKIP_RECENT_MINUTES, 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 skipRecent = BROWSER_CACHE_SKIP_RECENT_MINUTES // 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, skipRecent) + 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`, skipRecent) 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`, skipRecent) 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', skipRecent) 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..eeb82548 --- /dev/null +++ b/src/main/services/chromium-cache.test.ts @@ -0,0 +1,144 @@ +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_SKIP_RECENT_MINUTES, + 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_SKIP_RECENT_MINUTES', () => { + // The 60-minute default hid whole caches: `Code Cache` holds just `js` and + // `wasm`, so a browser used in the last hour had both entries skipped and the + // result dropped for being empty (issue #265). + it('disables the recency guard for browser caches', () => { + expect(BROWSER_CACHE_SKIP_RECENT_MINUTES).toBe(0) + }) +}) diff --git a/src/main/services/chromium-cache.ts b/src/main/services/chromium-cache.ts new file mode 100644 index 00000000..2dde52c8 --- /dev/null +++ b/src/main/services/chromium-cache.ts @@ -0,0 +1,113 @@ +// ─── 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' + +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 }, +] + +/** + * Browser cache scans pass this as `skipRecentMinutes`. + * + * The default 60-minute guard keeps a scan from listing something an app is + * still writing, but at directory granularity it hides whole caches: `Code + * Cache` holds exactly two entries (`js` and `wasm`), so a browser that ran in + * the last hour has both of them skipped and the entire result discarded for + * having no items. That is why Chrome's Code Cache never appeared while Edge's + * did — Edge simply wasn't being used. Chromium rebuilds any of these + * directories from scratch, and a locked file still fails the delete safely as + * 'in-use', so there is nothing left for the guard to protect here. + */ +export const BROWSER_CACHE_SKIP_RECENT_MINUTES = 0 + +/** 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..470c7267 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_SKIP_RECENT_MINUTES, 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 skipRecent = BROWSER_CACHE_SKIP_RECENT_MINUTES + + for (const browser of chromiumBrowsers(browserPaths)) { + for (const target of await chromiumCacheTargets(browser)) { + try { + const r = await scanDirectory(target.path, browserCategory, target.label, skipRecent) + 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`, skipRecent) 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`, skipRecent) 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', skipRecent) 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 -} From 797547a50d1e4822ed0952d5ed5d3beeed6f6943 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 27 Jul 2026 09:04:53 +0200 Subject: [PATCH 2/3] fix(browser-cleaner): apply the recency guard to files, not directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to review feedback on #266. The previous commit disabled the guard outright for browser caches, justified by locked files failing their delete as 'in-use'. That reasoning only holds on Windows: on macOS and Linux an open file is unlinked happily, and with secure delete on, Kudu overwrites it in place first — which for the cache block files a running browser keeps memory-mapped could disrupt the browser rather than just cost it a cold cache. The guard is now scoped instead of switched off. A directory's mtime moves whenever an entry is added or removed inside it, which says nothing about whether its contents are in use, and skipping it discards the whole subtree — that is the actual defect behind Chrome's missing Code Cache, which holds exactly the `js` and `wasm` directories. Directories are exempt; files are not, so `data_0`-`data_3` and `index` stay out of a scan exactly as before, which is also the behaviour the issue reported as correct. scanDirectory's recency parameter now takes either the existing number or { skipRecentMinutes, filesOnly }, so the trash scans that pass 0 are untouched. Verified against a synthetic replica of the reported layout: Code Cache goes from 0 items to 310 MB across its two subdirectories, while Cache_Data stays at 98 MB with the hot block files still excluded. --- src/main/cli.ts | 12 +-- src/main/ipc/browser-cleaner.ipc.test.ts | 11 +-- src/main/ipc/browser-cleaner.ipc.ts | 12 +-- src/main/services/chromium-cache.test.ts | 19 +++-- src/main/services/chromium-cache.ts | 23 +++--- src/main/services/cloud-agent.ts | 12 +-- src/main/services/file-utils.recency.test.ts | 87 ++++++++++++++++++++ src/main/services/file-utils.ts | 27 +++++- 8 files changed, 160 insertions(+), 43 deletions(-) create mode 100644 src/main/services/file-utils.recency.test.ts diff --git a/src/main/cli.ts b/src/main/cli.ts index 1291170e..8ae60ba0 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -4,7 +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_SKIP_RECENT_MINUTES, chromiumBrowsers, chromiumCacheTargets } from './services/chromium-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' @@ -183,10 +183,10 @@ async function scanBrowserCli(): Promise { const results: ScanResult[] = [] const category = CleanerType.Browser const browserPaths = getPlatform().paths.browserPaths() - const skipRecent = BROWSER_CACHE_SKIP_RECENT_MINUTES + 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, skipRecent) + const result = await scanDirectory(target.path, category, target.label, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -197,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`, skipRecent) + const result = await scanDirectory(cachePath, category, `Firefox - ${dir.name} Cache`, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -218,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`, skipRecent) + const result = await scanDirectory(cachePath, category, `${fork.label} - ${dir.name} Cache`, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -227,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', skipRecent) + const result = await scanDirectory(browserPaths.safari.cache, category, 'Safari - Cache', recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } return results diff --git a/src/main/ipc/browser-cleaner.ipc.test.ts b/src/main/ipc/browser-cleaner.ipc.test.ts index 8b8afc76..d07e58c6 100644 --- a/src/main/ipc/browser-cleaner.ipc.test.ts +++ b/src/main/ipc/browser-cleaner.ipc.test.ts @@ -162,9 +162,10 @@ describe('BROWSER_SCAN handler', () => { expect(mockCacheItems).toHaveBeenCalled() }) - // Issue #265: the 60-minute default made scanDirectory drop `Code Cache` - // whole — it holds only `js` and `wasm`, both touched by any recent browsing. - it('scans browser caches with the recency guard disabled', async () => { + // Issue #265: the guard made scanDirectory drop `Code Cache` whole — it holds + // only `js` and `wasm`, both touched by any recent browsing. Directories are + // now exempt; files are not, so the block files stay protected. + it('scans browser caches with the recency guard applied to files only', async () => { mockExistsSync.mockReturnValue(true) mockReaddir.mockResolvedValue([]) mockScanDirectory.mockResolvedValue({ category: 'browser', subcategory: 'test', items: [], totalSize: 0, itemCount: 0 }) @@ -174,7 +175,7 @@ describe('BROWSER_SCAN handler', () => { expect(mockScanDirectory).toHaveBeenCalled() for (const call of mockScanDirectory.mock.calls) { - expect(call[3]).toBe(0) + expect(call[3]).toEqual({ filesOnly: true }) } }) @@ -194,7 +195,7 @@ describe('BROWSER_SCAN handler', () => { await getHandler('cleaner:browser:scan')() expect(mockScanDirectory).toHaveBeenCalledWith( - join(chromeBase, 'GrShaderCache'), 'browser', 'Chrome - Skia Shader Cache', 0 + join(chromeBase, 'GrShaderCache'), 'browser', 'Chrome - Skia Shader Cache', { filesOnly: true } ) }) diff --git a/src/main/ipc/browser-cleaner.ipc.ts b/src/main/ipc/browser-cleaner.ipc.ts index 564c2c9f..1a8ed4ce 100644 --- a/src/main/ipc/browser-cleaner.ipc.ts +++ b/src/main/ipc/browser-cleaner.ipc.ts @@ -11,19 +11,19 @@ import { CleanerType } from '../../shared/enums' import type { ScanResult, CleanResult } from '../../shared/types' import type { WindowGetter } from './index' import { validateStringArray } from '../services/ipc-validation' -import { BROWSER_CACHE_SKIP_RECENT_MINUTES, chromiumBrowsers, chromiumCacheTargets } from '../services/chromium-cache' +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 skipRecent = BROWSER_CACHE_SKIP_RECENT_MINUTES + const recency = BROWSER_CACHE_RECENCY // Scan all Chromium-based browsers for (const browser of chromiumBrowsers(browserPaths)) { for (const target of await chromiumCacheTargets(browser)) { - const result = await scanDirectory(target.path, category, target.label, skipRecent) + const result = await scanDirectory(target.path, category, target.label, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -36,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`, skipRecent) + const result = await scanDirectory(cachePath, category, `Firefox - ${dir.name} Cache`, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -60,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`, skipRecent) + const result = await scanDirectory(cachePath, category, `${fork.label} - ${dir.name} Cache`, recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } } @@ -72,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', skipRecent) + const result = await scanDirectory(browserPaths.safari.cache, category, 'Safari - Cache', recency) if (result.items.length > 0) { cacheItems(result.items); results.push(result) } } diff --git a/src/main/services/chromium-cache.test.ts b/src/main/services/chromium-cache.test.ts index eeb82548..b0006af1 100644 --- a/src/main/services/chromium-cache.test.ts +++ b/src/main/services/chromium-cache.test.ts @@ -8,7 +8,7 @@ vi.mock('fs/promises', () => ({ readdir: (...args: unknown[]) => mockReaddir(... import { join } from 'path' import { - BROWSER_CACHE_SKIP_RECENT_MINUTES, + BROWSER_CACHE_RECENCY, chromiumBrowsers, chromiumCacheTargets, getChromiumProfiles, @@ -134,11 +134,16 @@ describe('getChromiumProfiles', () => { }) }) -describe('BROWSER_CACHE_SKIP_RECENT_MINUTES', () => { - // The 60-minute default hid whole caches: `Code Cache` holds just `js` and - // `wasm`, so a browser used in the last hour had both entries skipped and the - // result dropped for being empty (issue #265). - it('disables the recency guard for browser caches', () => { - expect(BROWSER_CACHE_SKIP_RECENT_MINUTES).toBe(0) +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 exempt; files are not, which is what keeps a + // running browser's memory-mapped block files out of a scan. + it('exempts directories from the recency guard but not files', () => { + expect(BROWSER_CACHE_RECENCY.filesOnly).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 index 2dde52c8..34304835 100644 --- a/src/main/services/chromium-cache.ts +++ b/src/main/services/chromium-cache.ts @@ -8,6 +8,7 @@ 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 @@ -34,18 +35,20 @@ const BROWSERS: Array<{ key: string; label: string; hasProfiles: boolean }> = [ ] /** - * Browser cache scans pass this as `skipRecentMinutes`. + * Recency settings every browser cache scan passes to `scanDirectory`. * - * The default 60-minute guard keeps a scan from listing something an app is - * still writing, but at directory granularity it hides whole caches: `Code - * Cache` holds exactly two entries (`js` and `wasm`), so a browser that ran in - * the last hour has both of them skipped and the entire result discarded for - * having no items. That is why Chrome's Code Cache never appeared while Edge's - * did — Edge simply wasn't being used. Chromium rebuilds any of these - * directories from scratch, and a locked file still fails the delete safely as - * 'in-use', so there is nothing left for the guard to protect here. + * The 60-minute guard stays on for files — that is what keeps the cache block + * files a running browser has memory-mapped (`data_0`–`data_3`, `index`) out of + * a scan, so they are never unlinked or overwritten underneath it. + * + * It does not apply to directories. A directory's mtime moves whenever an entry + * is added or removed inside it, so applying the guard there hid whole caches: + * `Code Cache` holds exactly two entries, the `js` and `wasm` directories, and + * any recent browsing had both skipped and the whole 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_SKIP_RECENT_MINUTES = 0 +export const BROWSER_CACHE_RECENCY: ScanRecencyOptions = { filesOnly: true } /** Pair each known Chromium browser with its resolved paths. */ export function chromiumBrowsers(paths: BrowserPathConfig): ChromiumBrowser[] { diff --git a/src/main/services/cloud-agent.ts b/src/main/services/cloud-agent.ts index 470c7267..3718e71f 100644 --- a/src/main/services/cloud-agent.ts +++ b/src/main/services/cloud-agent.ts @@ -18,7 +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_SKIP_RECENT_MINUTES, chromiumBrowsers, chromiumCacheTargets } from './chromium-cache' +import { BROWSER_CACHE_RECENCY, chromiumBrowsers, chromiumCacheTargets } from './chromium-cache' import { psUtf8 } from './exec-utf8' import { getPlatform } from '../platform' import { CleanerType } from '../../shared/enums' @@ -1935,12 +1935,12 @@ class CloudAgentService { const browserResults: ScanResult[] = [] const browserPaths = getPlatform().paths.browserPaths() const browserCategory = CleanerType.Browser - const skipRecent = BROWSER_CACHE_SKIP_RECENT_MINUTES + 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, skipRecent) + const r = await scanDirectory(target.path, browserCategory, target.label, recency) if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } } catch { /* skip */ } } @@ -1954,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`, skipRecent) + const r = await scanDirectory(cachePath, browserCategory, `Firefox - ${dir.name} Cache`, recency) if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } } } @@ -1976,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`, skipRecent) + const r = await scanDirectory(cachePath, browserCategory, `${fork.label} - ${dir.name} Cache`, recency) if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } } } @@ -1987,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', skipRecent) + const r = await scanDirectory(browserPaths.safari.cache, browserCategory, 'Safari - Cache', recency) if (r.items.length > 0) { cacheItems(r.items); browserResults.push(r) } } catch { /* skip */ } } 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..9cae8231 --- /dev/null +++ b/src/main/services/file-utils.recency.test.ts @@ -0,0 +1,87 @@ +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' + +let testDir: string + +/** A file whose mtime is `minutesAgo` in the past. */ +function file(name: string, minutesAgo: number, bytes = 32): string { + const path = join(testDir, name) + writeFileSync(path, Buffer.alloc(bytes)) + const when = new Date(Date.now() - minutesAgo * 60 * 1000) + utimesSync(path, when, when) + return path +} + +/** A directory holding one file, with the directory's own mtime set to `minutesAgo`. */ +function dir(name: string, minutesAgo: number, bytes = 64): string { + const path = join(testDir, name) + mkdirSync(path) + writeFileSync(join(path, 'entry'), Buffer.alloc(bytes)) + const when = new Date(Date.now() - minutesAgo * 60 * 1000) + utimesSync(path, when, when) + return path +} + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'kudu-recency-')) +}) + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) +}) + +describe('scanDirectory recency guard', () => { + it('skips both recent files and recent directories by default', async () => { + file('hot.bin', 1) + dir('hot-dir', 1) + file('cold.bin', 180) + + const result = await scanDirectory(testDir, 'browser', 'Test') + expect(result.items.map((i) => i.path)).toEqual([join(testDir, 'cold.bin')]) + }) + + // Issue #265: Chrome's `Code Cache` holds only the `js` and `wasm` + // directories, whose mtimes move on every write, so the guard discarded the + // whole cache — ~310 MB — and the empty result was then dropped entirely. + it('keeps a recently touched directory when the guard is files-only', async () => { + dir('hot-dir', 1) + file('cold.bin', 180) + + const result = await scanDirectory(testDir, 'browser', 'Test', { filesOnly: true }) + expect(result.items.map((i) => i.path).sort()).toEqual( + [join(testDir, 'cold.bin'), join(testDir, 'hot-dir')].sort() + ) + expect(result.totalSize).toBe(64 + 32) + }) + + // A running browser keeps `data_0`-`data_3` and `index` memory-mapped, so + // they must stay out of a scan even with the directory exemption on. + it('still skips recently written files when the guard is files-only', async () => { + file('data_0', 1) + file('f_00001', 180) + + const result = await scanDirectory(testDir, 'browser', 'Test', { filesOnly: true }) + expect(result.items.map((i) => i.path)).toEqual([join(testDir, 'f_00001')]) + }) + + it('accepts a plain number as the cutoff, as before', async () => { + file('hot.bin', 1) + dir('hot-dir', 1) + + const guarded = await scanDirectory(testDir, 'browser', 'Test', 60) + expect(guarded.items).toHaveLength(0) + + const ungated = await scanDirectory(testDir, 'browser', 'Test', 0) + expect(ungated.items).toHaveLength(2) + }) +}) diff --git a/src/main/services/file-utils.ts b/src/main/services/file-utils.ts index 858f98ad..7430f26a 100644 --- a/src/main/services/file-utils.ts +++ b/src/main/services/file-utils.ts @@ -254,12 +254,32 @@ export async function cleanItems( return { totalCleaned, filesDeleted, filesSkipped, errors, needsElevation } } +export interface ScanRecencyOptions { + /** Skip entries modified within this many minutes (default 60) */ + skipRecentMinutes?: number + /** + * Apply the guard to files only. + * + * A directory's mtime moves whenever an entry is added or removed inside it, + * which says nothing about whether its contents are in use — and skipping it + * 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). + * + * Files still get the guard, which is what keeps a running browser's + * memory-mapped cache block files out of a scan. + */ + filesOnly?: boolean +} + export async function scanDirectory( dirPath: string, category: string, subcategory: string, - skipRecentMinutes = 60 + recency: number | ScanRecencyOptions = {} ): Promise { + const { skipRecentMinutes = 60, filesOnly = false } = + typeof recency === 'number' ? { skipRecentMinutes: recency } : recency const items: ScanItem[] = [] let totalSize = 0 const cutoff = Date.now() - skipRecentMinutes * 60 * 1000 @@ -278,10 +298,11 @@ export async function scanDirectory( try { const stats = await stat(fullPath) + const isDirectory = stats.isDirectory() - if (stats.mtimeMs > cutoff) continue + if (stats.mtimeMs > cutoff && !(filesOnly && isDirectory)) continue - const size = stats.isDirectory() ? await getDirectorySize(fullPath, 2) : stats.size + const size = isDirectory ? await getDirectorySize(fullPath, 2) : stats.size const item: ScanItem = { id: randomUUID(), From 7a5b221375bbf00b99f6023284514d28579c6ff8 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 27 Jul 2026 09:27:06 +0200 Subject: [PATCH 3/3] fix(cleaner): judge a directory by its contents, not its own mtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to review feedback on #266. Exempting directories from the recency guard fixed the reporting but not the delete: a directory went in as a single scan item without anything checking its descendants, and safeDelete then recursed through every one of them — unlinking, and with secure delete on overwriting in place, files a running browser had written moments earlier. The guard now descends. A directory collapses into one item only 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 the settled space around a live file is still reclaimed without the live file ever being offered. The cutoff itself is unchanged at 60 minutes and now genuinely holds at every depth, where before it only ever looked at the top level. Symlinks are never descended into, since rm only unlinks the link and would otherwise have the check pass judgement on a tree this scan cannot remove. Depth is capped at 8; a subtree that cannot be shown to be settled is left alone rather than offered up. Synthetic replica of the reported layout, both ways: Chrome closed Code Cache 0 items -> 310 MB across js and wasm Chrome running Code Cache 0 items -> 310 MB, js opened so that js/settled is offered and js/live is not Both Cache_Data 98 MB unchanged, block files still excluded --- src/main/ipc/browser-cleaner.ipc.test.ts | 12 +- src/main/services/chromium-cache.test.ts | 7 +- src/main/services/chromium-cache.ts | 19 ++- src/main/services/file-utils.recency.test.ts | 123 +++++++++++----- src/main/services/file-utils.ts | 139 +++++++++++++++---- 5 files changed, 218 insertions(+), 82 deletions(-) diff --git a/src/main/ipc/browser-cleaner.ipc.test.ts b/src/main/ipc/browser-cleaner.ipc.test.ts index d07e58c6..dfc99988 100644 --- a/src/main/ipc/browser-cleaner.ipc.test.ts +++ b/src/main/ipc/browser-cleaner.ipc.test.ts @@ -162,10 +162,10 @@ describe('BROWSER_SCAN handler', () => { expect(mockCacheItems).toHaveBeenCalled() }) - // Issue #265: the guard made scanDirectory drop `Code Cache` whole — it holds - // only `js` and `wasm`, both touched by any recent browsing. Directories are - // now exempt; files are not, so the block files stay protected. - it('scans browser caches with the recency guard applied to files only', async () => { + // 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 }) @@ -175,7 +175,7 @@ describe('BROWSER_SCAN handler', () => { expect(mockScanDirectory).toHaveBeenCalled() for (const call of mockScanDirectory.mock.calls) { - expect(call[3]).toEqual({ filesOnly: true }) + expect(call[3]).toEqual({ deepRecencyCheck: true }) } }) @@ -195,7 +195,7 @@ describe('BROWSER_SCAN handler', () => { await getHandler('cleaner:browser:scan')() expect(mockScanDirectory).toHaveBeenCalledWith( - join(chromeBase, 'GrShaderCache'), 'browser', 'Chrome - Skia Shader Cache', { filesOnly: true } + join(chromeBase, 'GrShaderCache'), 'browser', 'Chrome - Skia Shader Cache', { deepRecencyCheck: true } ) }) diff --git a/src/main/services/chromium-cache.test.ts b/src/main/services/chromium-cache.test.ts index b0006af1..bca2dafb 100644 --- a/src/main/services/chromium-cache.test.ts +++ b/src/main/services/chromium-cache.test.ts @@ -137,10 +137,9 @@ describe('getChromiumProfiles', () => { 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 exempt; files are not, which is what keeps a - // running browser's memory-mapped block files out of a scan. - it('exempts directories from the recency guard but not files', () => { - expect(BROWSER_CACHE_RECENCY.filesOnly).toBe(true) + // (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', () => { diff --git a/src/main/services/chromium-cache.ts b/src/main/services/chromium-cache.ts index 34304835..c9a2b106 100644 --- a/src/main/services/chromium-cache.ts +++ b/src/main/services/chromium-cache.ts @@ -37,18 +37,17 @@ const BROWSERS: Array<{ key: string; label: string; hasProfiles: boolean }> = [ /** * Recency settings every browser cache scan passes to `scanDirectory`. * - * The 60-minute guard stays on for files — that is what keeps the cache block - * files a running browser has memory-mapped (`data_0`–`data_3`, `index`) out of - * a scan, so they are never unlinked or overwritten underneath it. + * 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. * - * It does not apply to directories. A directory's mtime moves whenever an entry - * is added or removed inside it, so applying the guard there hid whole caches: - * `Code Cache` holds exactly two entries, the `js` and `wasm` directories, and - * any recent browsing had both skipped and the whole 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). + * 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 = { filesOnly: true } +export const BROWSER_CACHE_RECENCY: ScanRecencyOptions = { deepRecencyCheck: true } /** Pair each known Chromium browser with its resolved paths. */ export function chromiumBrowsers(paths: BrowserPathConfig): ChromiumBrowser[] { diff --git a/src/main/services/file-utils.recency.test.ts b/src/main/services/file-utils.recency.test.ts index 9cae8231..96ee3087 100644 --- a/src/main/services/file-utils.recency.test.ts +++ b/src/main/services/file-utils.recency.test.ts @@ -11,27 +11,34 @@ vi.mock('./scan-cache', () => ({ getCachedItems: () => [] })) import { scanDirectory } from './file-utils' +const DEEP = { deepRecencyCheck: true } + let testDir: string -/** A file whose mtime is `minutesAgo` in the past. */ -function file(name: string, minutesAgo: number, bytes = 32): string { - const path = join(testDir, name) +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)) - const when = new Date(Date.now() - minutesAgo * 60 * 1000) - utimesSync(path, when, when) + utimesSync(path, ago(minutesAgo), ago(minutesAgo)) return path } -/** A directory holding one file, with the directory's own mtime set to `minutesAgo`. */ -function dir(name: string, minutesAgo: number, bytes = 64): string { - const path = join(testDir, name) - mkdirSync(path) - writeFileSync(join(path, 'entry'), Buffer.alloc(bytes)) - const when = new Date(Date.now() - minutesAgo * 60 * 1000) - utimesSync(path, when, when) +/** 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-')) }) @@ -41,47 +48,89 @@ afterEach(() => { }) describe('scanDirectory recency guard', () => { - it('skips both recent files and recent directories by default', async () => { + it('skips recent files and recent directories by default', async () => { file('hot.bin', 1) - dir('hot-dir', 1) + file('hot-dir/entry', 1) + touchDir('hot-dir', 1) file('cold.bin', 180) const result = await scanDirectory(testDir, 'browser', 'Test') - expect(result.items.map((i) => i.path)).toEqual([join(testDir, 'cold.bin')]) + 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, whose mtimes move on every write, so the guard discarded the - // whole cache — ~310 MB — and the empty result was then dropped entirely. - it('keeps a recently touched directory when the guard is files-only', async () => { - dir('hot-dir', 1) - file('cold.bin', 180) + // 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) + }) - const result = await scanDirectory(testDir, 'browser', 'Test', { filesOnly: true }) - expect(result.items.map((i) => i.path).sort()).toEqual( - [join(testDir, 'cold.bin'), join(testDir, 'hot-dir')].sort() - ) - expect(result.totalSize).toBe(64 + 32) + 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, so - // they must stay out of a scan even with the directory exemption on. - it('still skips recently written files when the guard is files-only', async () => { + // 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', { filesOnly: true }) - expect(result.items.map((i) => i.path)).toEqual([join(testDir, 'f_00001')]) + const result = await scanDirectory(testDir, 'browser', 'Test', DEEP) + expect(paths(result)).toEqual([join(testDir, 'f_00001')]) }) - it('accepts a plain number as the cutoff, as before', async () => { - file('hot.bin', 1) - dir('hot-dir', 1) + 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) + }) - const guarded = await scanDirectory(testDir, 'browser', 'Test', 60) - expect(guarded.items).toHaveLength(0) + it('reports nothing when every entry is live', async () => { + file('js/live', 1) - const ungated = await scanDirectory(testDir, 'browser', 'Test', 0) - expect(ungated.items).toHaveLength(2) + 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 7430f26a..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' @@ -258,18 +258,106 @@ export interface ScanRecencyOptions { /** Skip entries modified within this many minutes (default 60) */ skipRecentMinutes?: number /** - * Apply the guard to files only. + * 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 its contents are in use — and skipping it - * 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). + * 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). * - * Files still get the guard, which is what keeps a running browser's - * memory-mapped cache block files out of a scan. + * 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. */ - filesOnly?: boolean + 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( @@ -278,7 +366,7 @@ export async function scanDirectory( subcategory: string, recency: number | ScanRecencyOptions = {} ): Promise { - const { skipRecentMinutes = 60, filesOnly = false } = + const { skipRecentMinutes = 60, deepRecencyCheck = false } = typeof recency === 'number' ? { skipRecentMinutes: recency } : recency const items: ScanItem[] = [] let totalSize = 0 @@ -286,6 +374,19 @@ export async function scanDirectory( 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 }) @@ -298,24 +399,12 @@ export async function scanDirectory( try { const stats = await stat(fullPath) - const isDirectory = stats.isDirectory() - if (stats.mtimeMs > cutoff && !(filesOnly && isDirectory)) continue + if (stats.mtimeMs > cutoff) continue - const size = isDirectory ? await getDirectorySize(fullPath, 2) : stats.size - - const item: ScanItem = { - id: randomUUID(), - path: fullPath, - size, - category, - subcategory, - lastModified: stats.mtimeMs, - selected: true - } + const size = stats.isDirectory() ? await getDirectorySize(fullPath, 2) : stats.size - items.push(item) - totalSize += item.size + add(fullPath, size, stats.mtimeMs) } catch { // Skip inaccessible files }