From 963d29f96935fb0b5e9a2a0384dff8723294a9b5 Mon Sep 17 00:00:00 2001 From: Shak Date: Fri, 31 Jul 2026 03:28:58 +0330 Subject: [PATCH 1/2] perf(background): include wallpaper in cache budget, batch evictions, dedupe maintenance Co-Authored-By: Claude Opus 5 --- background/cache-names.ts | 19 +++-- background/constants.ts | 0 background/events.ts | 12 ++-- background/utils.ts | 69 +++++++++++-------- background/wallpaper-cache.ts | 60 +++++++++++++--- entrypoints/background.ts | 30 ++++++-- src/common/types/sw-events.ts | 1 - .../wallpapers/hooks/use-wallpaper-apply.tsx | 1 - 8 files changed, 134 insertions(+), 58 deletions(-) delete mode 100644 background/constants.ts diff --git a/background/cache-names.ts b/background/cache-names.ts index f05d3ecc..25a358c4 100644 --- a/background/cache-names.ts +++ b/background/cache-names.ts @@ -34,11 +34,18 @@ export const LEGACY_CACHES: string[] = [ 'critical-resources-v1', ] +/** + * Maps the logical cache name sent in a message to the versioned cache it + * actually lives in. + * + * A Record (rather than a switch with a catch-all default) makes this + * exhaustive: adding a member to `CacheName` becomes a TypeScript error here + * instead of silently resolving to the API cache. + */ +const CACHE_BY_LOGICAL_NAME: Record = { + [CacheName.API]: CacheNames.api, +} + export function resolveCacheName(logical: CacheName): string { - switch (logical) { - case CacheName.API: - return CacheNames.api - default: - return CacheNames.api - } + return CACHE_BY_LOGICAL_NAME[logical] ?? CacheNames.api } diff --git a/background/constants.ts b/background/constants.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/background/events.ts b/background/events.ts index 48605a78..9f76e07f 100644 --- a/background/events.ts +++ b/background/events.ts @@ -1,9 +1,8 @@ -import { cleanupOutdatedCaches } from 'workbox-precaching' import Analytics from '../src/analytics' import { removeFromStorage, setToStorage } from '../src/common/storage' -import { enforceCacheBudget, purgeStaleCaches } from './utils' +import { enforceCacheBudget } from './utils' import { resolveCacheName } from './cache-names' -import { initActiveWallpaper, setActiveWallpaper } from './wallpaper-cache' +import { setActiveWallpaper } from './wallpaper-cache' import { type SwEvent, SwEventType } from '@/common/types/sw-events' export function setupEventListeners() { @@ -37,9 +36,10 @@ export function setupEventListeners() { offlineSupport: true, }) - await cleanupOutdatedCaches() - await purgeStaleCaches() - await initActiveWallpaper() + // Cache maintenance and wallpaper init deliberately do NOT run here. + // An update restarts the service worker, so the startup path in + // entrypoints/background.ts already performs both — doing it again + // meant the whole sequence ran up to three times per update. } }) diff --git a/background/utils.ts b/background/utils.ts index 793a557e..679f8361 100644 --- a/background/utils.ts +++ b/background/utils.ts @@ -1,15 +1,16 @@ -import { CacheNames, EXPECTED_CACHES, LEGACY_CACHES } from './cache-names' - -export const BOOKMARK_ORDER_KEY = '__root__' +import { CACHE_PREFIX, CacheNames, EXPECTED_CACHES, LEGACY_CACHES } from './cache-names' const MAX_CACHE_BYTES = 100 * 1024 * 1024 +/** How many entries to delete before re-checking the storage estimate. */ +const TRIM_BATCH_SIZE = 20 + export async function purgeStaleCaches(): Promise { try { const names = await caches.keys() await Promise.all( names.map((name) => { - const isOurs = name.startsWith('wgf-') + const isOurs = name.startsWith(CACHE_PREFIX) const isStaleVersion = isOurs && !EXPECTED_CACHES.has(name) const isLegacy = LEGACY_CACHES.includes(name) return isStaleVersion || isLegacy @@ -18,10 +19,32 @@ export async function purgeStaleCaches(): Promise { }) ) } catch (error) { - console.error('Failed to purge stale caches:', error) + console.error('[widgetify] failed to purge stale caches:', error) } } +/** + * Keep total cache usage under budget by evicting from cheapest-to-refetch to + * most expensive. + * + * Order matters. The wallpaper cache is FIRST because it holds a single entry + * that can be a multi-megabyte video — by far the most likely thing to blow the + * budget on its own. It was previously omitted entirely, which meant an + * oversized wallpaper caused every other cache to be wiped (destroying offline + * support and forcing refetches) while the actual offender stayed put, often + * still over budget afterwards. + * + * Fonts come last: they are small, immutable, and their absence is immediately + * visible as a font swap. + */ +const TRIM_ORDER = [ + CacheNames.wallpaper, + CacheNames.cdnCss, + CacheNames.cdn, + CacheNames.api, + CacheNames.fonts, +] as const + export async function enforceCacheBudget(): Promise { try { if (typeof navigator === 'undefined' || !navigator.storage?.estimate) return @@ -33,31 +56,23 @@ export async function enforceCacheBudget(): Promise { if (!(await overBudget())) return - const trimOrder = [ - CacheNames.cdnCss, - CacheNames.cdn, - CacheNames.api, - CacheNames.fonts, - ] - for (const name of trimOrder) { + for (const name of TRIM_ORDER) { const cache = await caches.open(name) const keys = await cache.keys() + if (keys.length === 0) continue - let deletions = 0 - for (const request of keys) { - await cache.delete(request) - if (++deletions % 10 === 0 && !(await overBudget())) return - } + // Delete in parallel batches rather than one awaited call per entry. + // A cache holding hundreds of entries previously meant hundreds of + // sequential round-trips, and `storage.estimate()` — which is not + // cheap — was being re-run every 10 of them. + for (let i = 0; i < keys.length; i += TRIM_BATCH_SIZE) { + const batch = keys.slice(i, i + TRIM_BATCH_SIZE) + await Promise.all(batch.map((request) => cache.delete(request))) - if (!(await overBudget())) return + if (!(await overBudget())) return + } } - } catch {} -} - -export function normalizeKey(folderId: string | null): string { - return folderId == null ? BOOKMARK_ORDER_KEY : folderId -} - -export function denormalizeKey(key: string) { - return key === BOOKMARK_ORDER_KEY ? null : key + } catch (error) { + console.error('[widgetify] failed to enforce cache budget:', error) + } } diff --git a/background/wallpaper-cache.ts b/background/wallpaper-cache.ts index eb804b14..3ecce023 100644 --- a/background/wallpaper-cache.ts +++ b/background/wallpaper-cache.ts @@ -3,6 +3,16 @@ import { CacheNames } from './cache-names' export const activeWallpaperUrls = new Set() +/** + * Ceiling for a cached wallpaper. + * + * Video wallpapers can be tens of megabytes. Caching one that large eats most + * of the 100MB budget by itself and then forces enforceCacheBudget to evict + * everything else — so a too-large wallpaper is deliberately streamed from the + * network each time instead of being pinned for offline use. + */ +const MAX_WALLPAPER_BYTES = 25 * 1024 * 1024 + function isCacheable(src: string | null | undefined): src is string { return typeof src === 'string' && /^https?:\/\//.test(src) } @@ -14,7 +24,9 @@ export async function initActiveWallpaper(): Promise { if (wallpaper && isCacheable(wallpaper.src)) { activeWallpaperUrls.add(wallpaper.src) } - } catch {} + } catch (error) { + console.error('[widgetify] failed to read active wallpaper:', error) + } } export async function setActiveWallpaper(src: string): Promise { @@ -25,6 +37,7 @@ export async function setActiveWallpaper(src: string): Promise { const cache = await caches.open(CacheNames.wallpaper) + // Only one wallpaper is ever pinned — drop whatever the previous one was. const keys = await cache.keys() await Promise.all( keys @@ -36,13 +49,40 @@ export async function setActiveWallpaper(src: string): Promise { // Replace any previously stored opaque response (status 0 / type opaque): // opaque entries get a large, misleading padding added to storage estimates. const isOpaque = !!already && (already.type === 'opaque' || already.status === 0) - if (!already || isOpaque) { - // Fetch with CORS (cdn.widgetify.ir sends ACAO) so we store a real - // response instead of an opaque one. - const response = await fetch(src, { mode: 'cors' }) - if (response.ok) { - await cache.put(src, response) - } - } - } catch {} + if (already && !isOpaque) return + + // Fetch with CORS (cdn.widgetify.ir sends ACAO) so we store a real + // response instead of an opaque one. + const response = await fetch(src, { mode: 'cors' }) + if (!response.ok) return + + if (await exceedsSizeLimit(response)) return + + await cache.put(src, response) + } catch (error) { + console.error('[widgetify] failed to cache wallpaper:', error) + } +} + +/** + * Checks Content-Length before committing the body to the cache. + * + * Only consumes a clone when the header is missing, so the common case costs + * nothing. Note this reads the whole body into memory for that fallback path, + * which is acceptable because it only happens for a single wallpaper. + */ +async function exceedsSizeLimit(response: Response): Promise { + const declared = Number(response.headers.get('content-length')) + if (Number.isFinite(declared) && declared > 0) { + return declared > MAX_WALLPAPER_BYTES + } + + try { + const blob = await response.clone().blob() + return blob.size > MAX_WALLPAPER_BYTES + } catch { + // Cannot determine the size — cache it rather than losing offline + // support; enforceCacheBudget will evict it first if it turns out big. + return false + } } diff --git a/entrypoints/background.ts b/entrypoints/background.ts index e8225062..2b696af8 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -4,20 +4,36 @@ import { setupEventListeners } from '../background/events' import { enforceCacheBudget, purgeStaleCaches } from '../background/utils' import { initActiveWallpaper } from '../background/wallpaper-cache' +/** + * Cache maintenance: drop precache leftovers, drop caches from older extension + * versions, then evict until we are under budget. Ordered because each step + * only makes sense once the previous one has freed what it can. + */ +async function runCacheMaintenance(): Promise { + try { + await cleanupOutdatedCaches() + await purgeStaleCaches() + await enforceCacheBudget() + } catch (error) { + console.error('[widgetify] cache maintenance failed:', error) + } +} + export default defineBackground(() => { setupCaching() setupEventListeners() - - cleanupOutdatedCaches() - - purgeStaleCaches() - .then(() => enforceCacheBudget()) - .catch(() => {}) initActiveWallpaper() if (!import.meta.env.FIREFOX && typeof self !== 'undefined') { + // `activate` is the correct home for this: waitUntil keeps the worker + // alive until maintenance finishes. A bare top-level call can be killed + // mid-flight, which is how half-purged caches happen. self.addEventListener('activate', (event: any) => { - event.waitUntil(purgeStaleCaches().then(() => enforceCacheBudget())) + event.waitUntil(runCacheMaintenance()) }) + } else { + // Firefox does not give us the activate event here, so fall back to + // running it directly at worker start. + runCacheMaintenance() } }) diff --git a/src/common/types/sw-events.ts b/src/common/types/sw-events.ts index c063ed63..f4ca5ca6 100644 --- a/src/common/types/sw-events.ts +++ b/src/common/types/sw-events.ts @@ -23,7 +23,6 @@ export type UpdateCacheEvent = { export type SetActiveWallpaperEvent = { type: SwEventType.SetActiveWallpaper src: string - wallpaperType: 'IMAGE' | 'VIDEO' } export type SwEvent = DeleteCacheEvent | UpdateCacheEvent | SetActiveWallpaperEvent diff --git a/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-apply.tsx b/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-apply.tsx index 0e9ed6fe..4895d31d 100644 --- a/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-apply.tsx +++ b/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-apply.tsx @@ -14,7 +14,6 @@ function pinWallpaperForOffline(wallpaper: StoredWallpaper) { .sendMessage({ type: SwEventType.SetActiveWallpaper, src: wallpaper.src, - wallpaperType: wallpaper.type, }) .catch(() => {}) } From 8b74e39d4e24bb59ef08c14631390075d7b3e8f6 Mon Sep 17 00:00:00 2001 From: Shak Date: Thu, 13 Aug 2026 18:15:19 +0330 Subject: [PATCH 2/2] chore: ignore local .claude directory Local Claude Code session config shouldn't be tracked. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f9b65d12..201c15df 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ dev local_output diff.txt -docs/* \ No newline at end of file +docs/* +.claude \ No newline at end of file