diff --git a/packages/gui/src/components/cache/CacheProvider.tsx b/packages/gui/src/components/cache/CacheProvider.tsx index a034235cc9..b3aa55805e 100644 --- a/packages/gui/src/components/cache/CacheProvider.tsx +++ b/packages/gui/src/components/cache/CacheProvider.tsx @@ -1,3 +1,4 @@ +import { usePrefs } from '@chia-network/api-react'; import React, { useMemo, useState, useEffect, useCallback, type ReactNode } from 'react'; const { cacheAPI } = window; @@ -15,6 +16,14 @@ export default function CacheProvider(props: CacheProviderProps) { const [cacheDirectory, setCacheDirectory] = useState(undefined); const [cacheSize, setCacheSize] = useState(undefined); + // The main process only reads these preferences at startup; changes made at + // runtime live in CacheManager's memory, so they are persisted here in the + // renderer where all other preferences are written (prefs.yaml is rewritten + // from the renderer's snapshot on every preference save - a main process + // write would be clobbered by the next renderer save). + const [, setMaxCacheSizePref] = usePrefs('maxCacheSize', undefined); + const [, setCacheFolderPref] = usePrefs('cacheFolder', undefined); + const updateCacheSize = useCallback(async () => { const size = await cacheAPI.getCacheSize(); setCacheSize(size); @@ -30,9 +39,21 @@ export default function CacheProvider(props: CacheProviderProps) { setMaxCacheSize(size); }, []); + const handleCacheDirectoryChanged = useCallback(async () => { + const directory = await cacheAPI.getCacheDirectory(); + setCacheDirectory(directory); + setCacheFolderPref(directory); + }, [setCacheFolderPref]); + + const handleMaxCacheSizeChanged = useCallback(async () => { + const size = await cacheAPI.getMaxCacheSize(); + setMaxCacheSize(size); + setMaxCacheSizePref(size); + }, [setMaxCacheSizePref]); + useEffect(() => { - const unbindCacheDirectoryChanged = cacheAPI.subscribeToDirectoryChange(updateCacheDirectory); - const unbindMaxCacheSizeChanged = cacheAPI.subscribeToMaxSizeChange(updateMaxCacheSize); + const unbindCacheDirectoryChanged = cacheAPI.subscribeToDirectoryChange(handleCacheDirectoryChanged); + const unbindMaxCacheSizeChanged = cacheAPI.subscribeToMaxSizeChange(handleMaxCacheSizeChanged); const unbindSizeChanged = cacheAPI.subscribeToSizeChange(updateCacheSize); updateCacheSize(); @@ -44,7 +65,13 @@ export default function CacheProvider(props: CacheProviderProps) { unbindMaxCacheSizeChanged(); unbindSizeChanged(); }; - }, [updateCacheSize, updateCacheDirectory, updateMaxCacheSize]); + }, [ + updateCacheSize, + updateCacheDirectory, + updateMaxCacheSize, + handleCacheDirectoryChanged, + handleMaxCacheSizeChanged, + ]); const context = useMemo(() => { const { getCacheDirectory, getCacheSize, getMaxCacheSize, ...rest } = cacheAPI; diff --git a/packages/gui/src/components/nfts/NFTPreview.tsx b/packages/gui/src/components/nfts/NFTPreview.tsx index ccb8b805c2..4d790d1391 100644 --- a/packages/gui/src/components/nfts/NFTPreview.tsx +++ b/packages/gui/src/components/nfts/NFTPreview.tsx @@ -210,7 +210,7 @@ export default function NFTPreview(props: NFTPreviewProps) { } `; - const cachedURI = await getURI(preview.uri); + const cachedURI = await getURI(preview.uri, { maxSize: ignoreSizeLimit ? -1 : undefined }); if (!cachedURI || !cachedURI.startsWith('cache://')) { setPreviewContent(undefined, signal); return; @@ -237,7 +237,17 @@ export default function NFTPreview(props: NFTPreviewProps) { setError(e as Error, signal); } }, - [preview, fit, getURI, previewFileType, disableInteractions, isDarkMode, setPreviewContent, setError], + [ + preview, + fit, + getURI, + ignoreSizeLimit, + previewFileType, + disableInteractions, + isDarkMode, + setPreviewContent, + setError, + ], ); useEffect(() => { diff --git a/packages/gui/src/components/settings/LimitCacheSize.tsx b/packages/gui/src/components/settings/LimitCacheSize.tsx index a06d33d876..9746ca2e6b 100644 --- a/packages/gui/src/components/settings/LimitCacheSize.tsx +++ b/packages/gui/src/components/settings/LimitCacheSize.tsx @@ -1,4 +1,3 @@ -import { usePrefs } from '@chia-network/api-react'; import { AlertDialog, ButtonLoading, Flex, Form, TextField, useOpenDialog } from '@chia-network/core'; import { Trans } from '@lingui/macro'; import React, { useEffect } from 'react'; @@ -16,8 +15,6 @@ export default function LimitCacheSize() { const openDialog = useOpenDialog(); const { maxCacheSize, setMaxCacheSize } = useCache(); - const [, setCacheLimitSize] = usePrefs(`cacheLimitSize`, 0); - const methods = useForm({ defaultValues: { maxCacheSize, @@ -45,8 +42,6 @@ export default function LimitCacheSize() { const newValue = Number(values.maxCacheSize) * MB_SIZE; - // todo move it ti electron/main - setCacheLimitSize(newValue); await setMaxCacheSize(newValue); await openDialog( diff --git a/packages/gui/src/electron/CacheManager.test.ts b/packages/gui/src/electron/CacheManager.test.ts new file mode 100644 index 0000000000..00a5371b25 --- /dev/null +++ b/packages/gui/src/electron/CacheManager.test.ts @@ -0,0 +1,221 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +type DownloadFile = typeof import('./utils/downloadFile').default; + +const mockDownloadFile = jest.fn, Parameters>(); + +jest.mock('electron', () => ({ + BrowserWindow: jest.fn(), + dialog: { + showOpenDialog: jest.fn(), + }, +})); + +jest.mock('./utils/downloadFile', () => ({ + __esModule: true, + default: mockDownloadFile, + MAX_FILE_SIZE_EXCEEDED_ERROR: 'Maximum file size exceeded', +})); + +jest.mock('./utils/ipcMainHandle', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const CacheManager = jest.requireActual('./CacheManager').default; + +describe('CacheManager eviction', () => { + let cacheDirectory: string; + + beforeEach(async () => { + mockDownloadFile.mockReset(); + cacheDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'chia-cache-manager-')); + }); + + afterEach(async () => { + await fs.rm(cacheDirectory, { recursive: true, force: true }); + }); + + it('does not evict a just-downloaded file that fits within the configured total size', async () => { + const payload = Buffer.alloc(600, 7); + mockDownloadFile.mockImplementation(async (_url, localPath) => { + await fs.writeFile(localPath, payload); + return { + 'content-type': 'image/png', + }; + }); + + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload); + await expect(cacheManager.getCacheSize()).resolves.toBeLessThanOrEqual(1024); + }); + + it('keeps a completed download cached when cache housekeeping fails', async () => { + const payload = Buffer.from('cached payload'); + mockDownloadFile.mockImplementation(async (_url, localPath) => { + await fs.writeFile(localPath, payload); + return { + 'content-type': 'image/png', + }; + }); + + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + // A concurrent invalidation can delete files mid-scan and make the + // post-download size check fail — that must not poison the download. + jest + .spyOn(cacheManager, 'getCacheSize') + .mockRejectedValueOnce(new Error("ENOENT: no such file or directory, stat '/cache/other-chiacache'")); + + await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload); + await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload); + expect(mockDownloadFile).toHaveBeenCalledTimes(1); + }); + + it('ignores files that vanish while the cache size is being measured', async () => { + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + await fs.writeFile(path.join(cacheDirectory, 'aaaa-chiacache'), Buffer.alloc(100)); + // a broken symlink stats like a file deleted between readdir and stat + await fs.symlink(path.join(cacheDirectory, 'missing-target'), path.join(cacheDirectory, 'bbbb-chiacache')); + + await expect(cacheManager.getCacheSize()).resolves.toBe(100); + }); + + it('evicts without failing when a file vanishes during the eviction scan', async () => { + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + await fs.writeFile(path.join(cacheDirectory, 'aaaa-chiacache'), Buffer.alloc(200)); + await fs.symlink(path.join(cacheDirectory, 'missing-target'), path.join(cacheDirectory, 'bbbb-chiacache')); + + await expect(cacheManager.setMaxCacheSize(100)).resolves.toBeUndefined(); + await expect(fs.stat(path.join(cacheDirectory, 'aaaa-chiacache'))).rejects.toThrow('ENOENT'); + }); + + it('does not retry a timed-out download on the next access', async () => { + mockDownloadFile.mockRejectedValue(new Error('Request timed out after 30000ms of inactivity')); + + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request timed out'); + await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request timed out'); + expect(mockDownloadFile).toHaveBeenCalledTimes(1); + }); + + it('retries an aborted download on the next access', async () => { + const payload = Buffer.from('cached payload'); + mockDownloadFile.mockRejectedValueOnce(new Error('Request aborted')).mockImplementation(async (_url, localPath) => { + await fs.writeFile(localPath, payload); + return { + 'content-type': 'image/png', + }; + }); + + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request aborted'); + await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload); + expect(mockDownloadFile).toHaveBeenCalledTimes(2); + }); + + it('does not overlap cache size scans when a scan outlives the coalescing window', async () => { + jest.useFakeTimers(); + try { + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + let runningScans = 0; + let maxConcurrentScans = 0; + const scanResolvers: Array<() => void> = []; + const getCacheSizeSpy = jest.spyOn(cacheManager, 'getCacheSize').mockImplementation( + () => + new Promise((resolve) => { + runningScans += 1; + maxConcurrentScans = Math.max(maxConcurrentScans, runningScans); + scanResolvers.push(() => { + runningScans -= 1; + resolve(0); + }); + }), + ); + + const send = jest.fn(); + const fakeWindow = { + webContents: { send }, + isDestroyed: () => false, + on: jest.fn(), + } as any; + cacheManager.bindEvents(fakeWindow); + + cacheManager.emit('sizeChanged'); + jest.advanceTimersByTime(500); // the first scan starts and stays in flight + + cacheManager.emit('sizeChanged'); // burst arriving mid-scan + jest.advanceTimersByTime(500); // previously this started an overlapping scan + + expect(maxConcurrentScans).toBe(1); + + scanResolvers.shift()?.(); + await Promise.resolve(); // let the first scan settle and reschedule + jest.advanceTimersByTime(500); // the follow-up scan delivers the fresh size + + expect(getCacheSizeSpy).toHaveBeenCalledTimes(2); + expect(maxConcurrentScans).toBe(1); + } finally { + jest.useRealTimers(); + } + }); + + it('treats a zero cache limit as unlimited when updating the setting', async () => { + const payload = Buffer.from('cached payload'); + mockDownloadFile.mockImplementation(async (_url, localPath) => { + await fs.writeFile(localPath, payload); + return { + 'content-type': 'image/png', + }; + }); + + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + await cacheManager.getContent('https://example.com/nft.png'); + + await cacheManager.setMaxCacheSize(0); + + expect(cacheManager.maxCacheSize).toBe(0); + await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload); + expect(mockDownloadFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/gui/src/electron/CacheManager.ts b/packages/gui/src/electron/CacheManager.ts index 8b6e1315f1..745cd4899d 100644 --- a/packages/gui/src/electron/CacheManager.ts +++ b/packages/gui/src/electron/CacheManager.ts @@ -1,8 +1,10 @@ -import { BrowserWindow, net, dialog, type Protocol } from 'electron'; +import { BrowserWindow, dialog, type Protocol } from 'electron'; import { EventEmitter } from 'events'; import crypto from 'node:crypto'; +import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { Readable } from 'node:stream'; import debug from 'debug'; import isURL from 'validator/lib/isURL'; @@ -14,7 +16,7 @@ import CacheState from '../constants/CacheState'; import limit from '../util/limit'; import CacheAPI from './constants/CacheAPI'; -import downloadFile from './utils/downloadFile'; +import downloadFile, { MAX_FILE_SIZE_EXCEEDED_ERROR } from './utils/downloadFile'; import ensureDirectoryExists from './utils/ensureDirectoryExists'; import getChecksum from './utils/getChecksum'; import ipcMainHandle from './utils/ipcMainHandle'; @@ -24,7 +26,51 @@ import sanitizeNumber from './utils/sanitizeNumber'; const log = debug('chia-gui:CacheManager'); -const CACHE_PROTOCOL = 'cache'; +export const CACHE_PROTOCOL = 'cache'; + +// A single-range `bytes=start-end` Range header, parsed against the file size. +// 'ignore' means the header is absent or uses a form we do not support +// (e.g. multiple ranges), in which case the full file is served with a 200. +type ParsedRange = { start: number; end: number } | 'invalid' | 'ignore'; + +function parseRangeHeader(rangeHeader: string | null, fileSize: number): ParsedRange { + if (!rangeHeader) { + return 'ignore'; + } + + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!match) { + return 'ignore'; + } + + const [, startString, endString] = match; + + if (startString === '' && endString === '') { + return 'invalid'; + } + + if (startString === '') { + // suffix range: the last N bytes of the file + const suffixLength = Number.parseInt(endString, 10); + if (suffixLength === 0 || fileSize === 0) { + return 'invalid'; + } + + return { start: Math.max(fileSize - suffixLength, 0), end: fileSize - 1 }; + } + + const start = Number.parseInt(startString, 10); + if (start >= fileSize) { + return 'invalid'; + } + + const end = endString === '' ? fileSize - 1 : Math.min(Number.parseInt(endString, 10), fileSize - 1); + if (start > end) { + return 'invalid'; + } + + return { start, end }; +} async function safeUnlink(filePath: string) { try { @@ -81,7 +127,10 @@ export default class CacheManager extends EventEmitter { this.cacheDirectory = cacheDirectory; this.maxCacheSize = maxCacheSize; - this.#downloadLimit = limit(concurrency); + // LIFO: downloads for what the user is currently viewing (an offer + // preview, a just-opened detail page) are requested last and must not + // wait behind a long gallery-wide rebuild of earlier requests. + this.#downloadLimit = limit(concurrency, { lifo: true }); this.setMaxListeners(50); @@ -112,8 +161,11 @@ export default class CacheManager extends EventEmitter { }); } - const response = await net.fetch(`file://${filePath}`); - if (!response.ok) { + let fileSize: number; + try { + const stats = await fs.stat(filePath); + fileSize = stats.size; + } catch (error) { return new Response('Not found', { status: 404, headers: { @@ -122,18 +174,45 @@ export default class CacheManager extends EventEmitter { }); } - const { headers } = cacheInfo; - const updatedHeaders = new Headers(response.headers); + const contentTypeHeader = cacheInfo.headers?.['content-type']; + const contentType = + (Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader) || 'application/octet-stream'; + + const responseHeaders: Record = { + 'content-type': contentType, + 'accept-ranges': 'bytes', + }; + + // Media elements seek by sending Range requests. Without 206 responses + // seeking is broken and MP4 files with the moov atom at the end of the + // file never start playing. + const range = parseRangeHeader(request.headers.get('range'), fileSize); + + if (range === 'invalid') { + return new Response('Range Not Satisfiable', { + status: 416, + headers: { + 'content-type': 'text/plain', + 'content-range': `bytes */${fileSize}`, + }, + }); + } + + if (range !== 'ignore') { + responseHeaders['content-length'] = String(range.end - range.start + 1); + responseHeaders['content-range'] = `bytes ${range.start}-${range.end}/${fileSize}`; - if (headers['content-type']) { - const contentType = Array.isArray(headers['content-type']) - ? headers['content-type'][0] - : headers['content-type']; - updatedHeaders.set('content-type', contentType); + const partialStream = createReadStream(filePath, { start: range.start, end: range.end }); + return new Response(Readable.toWeb(partialStream) as unknown as ReadableStream, { + status: 206, + headers: responseHeaders, + }); } - return new Response(response.body, { - headers: updatedHeaders, + responseHeaders['content-length'] = String(fileSize); + + return new Response(Readable.toWeb(createReadStream(filePath)) as unknown as ReadableStream, { + headers: responseHeaders, }); }); } @@ -170,8 +249,42 @@ export default class CacheManager extends EventEmitter { window.webContents.send(CacheAPI.ON_MAX_CACHE_SIZE_CHANGED, newSize); } - const onSizeChanged = async () => { - window.webContents.send(CacheAPI.ON_SIZE_CHANGED, await this.getCacheSize()); + // Download and invalidation bursts emit sizeChanged per file, and every + // notification triggers a full cache-directory scan (here and again in the + // renderer), so coalesce bursts into one trailing notification. Scans are + // also serialized: events arriving while a scan is running only mark it + // stale, and one follow-up scan is scheduled after it finishes, so a scan + // that outlives the coalescing window cannot overlap the next one. + let sizeChangedTimeout: NodeJS.Timeout | undefined; + let sizeScanRunning = false; + let sizeChangedDuringScan = false; + + const onSizeChanged = () => { + if (sizeChangedTimeout) { + return; + } + if (sizeScanRunning) { + sizeChangedDuringScan = true; + return; + } + sizeChangedTimeout = setTimeout(async () => { + sizeChangedTimeout = undefined; + sizeScanRunning = true; + try { + const size = await this.getCacheSize(); + if (!window.isDestroyed()) { + window.webContents.send(CacheAPI.ON_SIZE_CHANGED, size); + } + } catch { + // the next sizeChanged event delivers a fresh value + } finally { + sizeScanRunning = false; + if (sizeChangedDuringScan) { + sizeChangedDuringScan = false; + onSizeChanged(); + } + } + }, 500); }; this.on('cacheDirectoryChanged', onCacheDirectoryChanged); @@ -182,6 +295,11 @@ export default class CacheManager extends EventEmitter { this.off('cacheDirectoryChanged', onCacheDirectoryChanged); this.off('maxCacheSizeChanged', onMaxCacheSizeChanged); this.off('sizeChanged', onSizeChanged); + sizeChangedDuringScan = false; + if (sizeChangedTimeout) { + clearTimeout(sizeChangedTimeout); + sizeChangedTimeout = undefined; + } }; window.on('close', () => { @@ -201,9 +319,6 @@ export default class CacheManager extends EventEmitter { public set maxCacheSize(newSize: number | string) { const value = sanitizeNumber(newSize); - if (value < 0) { - throw new Error('Cache size cannot be negative'); - } this.#maxCacheSize = value; @@ -332,7 +447,12 @@ export default class CacheManager extends EventEmitter { if (cacheInfo.state === CacheState.ERROR) { log(`Url already downloaded with error: ${cacheInfo.error}`, url); - if (!['Response aborted', 'Request aborted'].includes(cacheInfo.error)) { + + const isTransientError = ['Response aborted', 'Request aborted'].includes(cacheInfo.error); + // A persisted size-limit error is only retried when the caller lifts + // the limit, so oversized files are not re-downloaded on every visit. + const isSizeLimitLifted = cacheInfo.error === MAX_FILE_SIZE_EXCEEDED_ERROR && maxSize <= 0; + if (!isTransientError && !isSizeLimitLifted) { return cacheInfo; } @@ -358,19 +478,26 @@ export default class CacheManager extends EventEmitter { log('Checksum computed', url); // save headers to a local JSON file - const updatedCacheInfo = this.setCacheInfo(url, { + const updatedCacheInfo = await this.setCacheInfo(url, { state: CacheState.CACHED, headers, checksum, }); log('Cache info saved', url); - // remove old files if the cache is full - const currentCacheSize = await this.getCacheSize(); - const stats = await fs.stat(cacheFilePath); - if (this.maxCacheSize && currentCacheSize + stats.size > this.maxCacheSize) { - const spaceNeeded = currentCacheSize + stats.size - this.maxCacheSize; - await this.removeOldestFiles(spaceNeeded); + try { + // remove old files if the cache is full + const currentCacheSize = await this.getCacheSize(); + if (this.maxCacheSize > 0 && currentCacheSize > this.maxCacheSize) { + // The current size already includes the file that was just + // downloaded. Keep that file available to the caller and evict + // older entries down to the configured total-size target. + await this.removeOldestFiles(this.maxCacheSize, cacheFilePath); + } + } catch (housekeepingError) { + // The download and its cache info are already saved — a failure in + // cache bookkeeping must not overwrite that state with an error. + log(`Cache housekeeping failed: ${(housekeepingError as Error).message}`, url); } // todo just add size and save it locally this.emit('sizeChanged'); @@ -574,36 +701,55 @@ export default class CacheManager extends EventEmitter { this.cacheDirectory = newDirectory; } - private async removeOldestFiles(targetSize: number): Promise { + private async removeOldestFiles(targetSize: number, preserveFilePath?: string): Promise { const files = await fs.readdir(this.cacheDirectory); const filePaths = files .filter((file) => isChiaCacheFile(file) && !isChiaCacheInfoFile(file)) .map((file) => path.join(this.cacheDirectory, file)); - // get the file sizes - const fileStats = await Promise.all( - filePaths.map(async (filePath) => { - const stats = await fs.stat(filePath); - return { - filePath, - size: stats.size, - mtime: stats.mtime, - }; - }), - ); + // Include the sidecar metadata in each entry's size so the eviction total + // uses the same accounting as getCacheSize(). + const fileStats = ( + await Promise.all( + filePaths.map(async (filePath) => { + try { + const stats = await fs.stat(filePath); + let infoSize = 0; + try { + infoSize = (await fs.stat(getInfoFilePath(filePath))).size; + } catch { + // A missing sidecar is cleaned up with the data file as usual. + } + + return { + filePath, + size: stats.size + infoSize, + mtime: stats.mtime, + }; + } catch { + // Deleted by invalidation while scanning — nothing left to evict. + return undefined; + } + }), + ) + ).filter((entry): entry is { filePath: string; size: number; mtime: Date } => entry !== undefined); // sort the file paths based on their last modified time (oldest first) fileStats.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); // remove files until the total size is below the new max total size let totalSize = fileStats.reduce((sum, { size }) => sum + size, 0); - const filesToRemove = fileStats.filter(({ size }) => { - if (totalSize > targetSize) { - totalSize -= size; - return true; + const filesToRemove: typeof fileStats = []; + for (const fileStat of fileStats) { + if (totalSize <= targetSize) { + break; } - return false; - }); + + if (fileStat.filePath !== preserveFilePath) { + totalSize -= fileStat.size; + filesToRemove.push(fileStat); + } + } await Promise.all( filesToRemove.map(async ({ filePath }) => { @@ -636,8 +782,10 @@ export default class CacheManager extends EventEmitter { } async setMaxCacheSize(maxCacheSize: number | string) { - this.maxCacheSize = sanitizeNumber(maxCacheSize); - await this.removeOldestFiles(this.maxCacheSize); + this.maxCacheSize = maxCacheSize; + if (this.maxCacheSize > 0) { + await this.removeOldestFiles(this.maxCacheSize); + } } async getCacheSize() { @@ -646,8 +794,17 @@ export default class CacheManager extends EventEmitter { .filter((filename) => isChiaCacheFile(filename)) .map((filename) => path.join(this.cacheDirectory, filename)); - // Get the file sizes and calculate the total size - const fileSizes = await Promise.all(filePaths.map(async (filePath) => (await fs.stat(filePath)).size)); + // Invalidation and eviction delete files while this scan runs — a file + // that vanished between readdir and stat no longer occupies space. + const fileSizes = await Promise.all( + filePaths.map(async (filePath) => { + try { + return (await fs.stat(filePath)).size; + } catch { + return 0; + } + }), + ); const totalSize = fileSizes.reduce((sum, size) => sum + size, 0); return totalSize; diff --git a/packages/gui/src/electron/main.tsx b/packages/gui/src/electron/main.tsx index c028d74567..80e442814b 100644 --- a/packages/gui/src/electron/main.tsx +++ b/packages/gui/src/electron/main.tsx @@ -10,6 +10,7 @@ import { Notification, type MenuItemConstructorOptions, nativeTheme, + protocol, } from 'electron'; import fs from 'node:fs'; import path from 'node:path'; @@ -29,7 +30,7 @@ import { WcError, WcErrorCode, encodeWcErrorForIpc } from '../@types/WcError'; import AppIcon from '../assets/img/chia64x64.png'; import { i18n } from '../config/locales'; -import CacheManager from './CacheManager'; +import CacheManager, { CACHE_PROTOCOL } from './CacheManager'; import { checkNFTOwnership } from './api/checkNFTOwnership'; import { getKeyDetails } from './api/getKeyDetails'; import { getNetworkInfo } from './api/getNetworkInfo'; @@ -79,6 +80,7 @@ import { addBypassCommand, } from './utils/pairStore'; import * as privatePreferences from './utils/privatePreferences'; +import resolveStoredMaxCacheSize from './utils/resolveStoredMaxCacheSize'; import toCamelCase from './utils/toCamelCase'; import { setUserDataDir } from './utils/userData'; import webSocketBridgeBindEvents from './utils/webSocketBridge'; @@ -94,6 +96,22 @@ type ConfirmDialogResult = { app.disableHardwareAcceleration(); app.commandLine.appendSwitch('disable-http-cache'); +// The cache: scheme serves NFT media to /