Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ dev
local_output
diff.txt

docs/*
docs/*
.claude
19 changes: 13 additions & 6 deletions background/cache-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string> = {
[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
}
Empty file removed background/constants.ts
Empty file.
12 changes: 6 additions & 6 deletions background/events.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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.
}
})

Expand Down
69 changes: 42 additions & 27 deletions background/utils.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
Expand All @@ -18,10 +19,32 @@ export async function purgeStaleCaches(): Promise<void> {
})
)
} 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<void> {
try {
if (typeof navigator === 'undefined' || !navigator.storage?.estimate) return
Expand All @@ -33,31 +56,23 @@ export async function enforceCacheBudget(): Promise<void> {

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)
}
}
60 changes: 50 additions & 10 deletions background/wallpaper-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import { CacheNames } from './cache-names'

export const activeWallpaperUrls = new Set<string>()

/**
* 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)
}
Expand All @@ -14,7 +24,9 @@ export async function initActiveWallpaper(): Promise<void> {
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<void> {
Expand All @@ -25,6 +37,7 @@ export async function setActiveWallpaper(src: string): Promise<void> {

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
Expand All @@ -36,13 +49,40 @@ export async function setActiveWallpaper(src: string): Promise<void> {
// 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<boolean> {
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
}
}
30 changes: 23 additions & 7 deletions entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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()
}
})
1 change: 0 additions & 1 deletion src/common/types/sw-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export type UpdateCacheEvent = {
export type SetActiveWallpaperEvent = {
type: SwEventType.SetActiveWallpaper
src: string
wallpaperType: 'IMAGE' | 'VIDEO'
}

export type SwEvent = DeleteCacheEvent | UpdateCacheEvent | SetActiveWallpaperEvent
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ function pinWallpaperForOffline(wallpaper: StoredWallpaper) {
.sendMessage({
type: SwEventType.SetActiveWallpaper,
src: wallpaper.src,
wallpaperType: wallpaper.type,
})
.catch(() => {})
}
Expand Down
Loading