diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 932fc6779f207430fca222d218e4d6bd8eed3e1c..e1d883a010261fc7a717e9642ff774d7cd934df8 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -18,7 +18,7 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "../lib/projectFaviconRequests"; const EMPTY_FAVICON_URL = Atom.make(null); diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/components/projectFaviconCache.test.ts deleted file mode 100644 index d0582a8b5f5ba3c6dc26dfe6cf7d877ea688e7ee..0000000000000000000000000000000000000000 --- a/apps/mobile/src/components/projectFaviconCache.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - beginProjectFaviconRequest, - createProjectFaviconRequest, - hasLoadedProjectFavicon, - markProjectFaviconFailed, - markProjectFaviconLoaded, -} from "./projectFaviconCache"; - -describe("project favicon cache", () => { - it("ignores callbacks from a superseded URL", () => { - const cacheKey = "environment-1:/workspace:v1-favicon.svg"; - const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; - const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; - - const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); - const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); - markProjectFaviconLoaded(expiredRequest); - const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); - const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); - - expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); - expect(markProjectFaviconFailed(expiredRequest)).toBe(false); - expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); - expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); - expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); - - endRefreshedRequest(); - endExpiredRequest(); - }); - - it("evicts the URL that actually failed", () => { - const cacheKey = "environment-1:/workspace:v2-favicon.svg"; - const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; - const request = createProjectFaviconRequest(cacheKey, faviconUrl); - const endRequest = beginProjectFaviconRequest(request); - - markProjectFaviconLoaded(request); - - expect(markProjectFaviconFailed(request)).toBe(true); - expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); - - endRequest(); - }); - - it("does not supersede a request until the next request begins", () => { - const cacheKey = "environment-1:/workspace:v3-favicon.svg"; - const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; - const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; - const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); - const endCommittedRequest = beginProjectFaviconRequest(committedRequest); - - createProjectFaviconRequest(cacheKey, abandonedUrl); - - expect(markProjectFaviconLoaded(committedRequest)).toBe(true); - expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); - - endCommittedRequest(); - }); - - it("requires a cache key before creating a URL-bearing request", () => { - const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; - const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; - - expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); - expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); - }); - - it("restores the remaining active URL when a newer request ends", () => { - const cacheKey = "environment-1:/workspace:v4-favicon.svg"; - const firstRequest = createProjectFaviconRequest( - cacheKey, - "https://environment.example/api/assets/first/v4-favicon.svg", - ); - const secondRequest = createProjectFaviconRequest( - cacheKey, - "https://environment.example/api/assets/second/v4-favicon.svg", - ); - const endFirstRequest = beginProjectFaviconRequest(firstRequest); - const endSecondRequest = beginProjectFaviconRequest(secondRequest); - - expect(markProjectFaviconLoaded(firstRequest)).toBe(false); - endSecondRequest(); - expect(markProjectFaviconLoaded(firstRequest)).toBe(true); - endFirstRequest(); - expect(markProjectFaviconLoaded(firstRequest)).toBe(false); - }); - - it("bounds remembered loaded revisions", () => { - const firstCacheKey = "environment-1:/workspace:revision-0"; - let lastCacheKey = firstCacheKey; - - for (let revision = 0; revision < 300; revision++) { - lastCacheKey = `environment-1:/workspace:revision-${revision}`; - const request = createProjectFaviconRequest( - lastCacheKey, - `https://environment.example/api/assets/revision-${revision}/favicon.svg`, - ); - const endRequest = beginProjectFaviconRequest(request); - markProjectFaviconLoaded(request); - endRequest(); - } - - expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); - expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); - }); -}); diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/components/projectFaviconCache.ts deleted file mode 100644 index da77d7613f2d7c6333d4e4f38965a19537194109..0000000000000000000000000000000000000000 --- a/apps/mobile/src/components/projectFaviconCache.ts +++ /dev/null @@ -1,94 +0,0 @@ -export interface ProjectFaviconRequest { - readonly cacheKey: string; - readonly faviconUrl: string; -} - -interface ActiveFaviconRequests { - readonly urls: Map; - currentUrl: string; -} - -const MAX_LOADED_FAVICONS = 256; -const activeFaviconRequests = new Map(); -const loadedFaviconKeys = new Map(); - -export function createProjectFaviconRequest( - cacheKey: string, - faviconUrl: string, -): ProjectFaviconRequest; -export function createProjectFaviconRequest( - cacheKey: string | null, - faviconUrl: string | null, -): ProjectFaviconRequest | null; -export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { - if (!cacheKey || !faviconUrl) return null; - return { cacheKey, faviconUrl }; -} - -export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { - let activeRequests = activeFaviconRequests.get(request.cacheKey); - if (!activeRequests) { - activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; - activeFaviconRequests.set(request.cacheKey, activeRequests); - } - - const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; - activeRequests.urls.delete(request.faviconUrl); - activeRequests.urls.set(request.faviconUrl, activeCount + 1); - activeRequests.currentUrl = request.faviconUrl; - - let ended = false; - return () => { - if (ended) return; - ended = true; - - const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; - if (remainingCount > 0) { - activeRequests.urls.set(request.faviconUrl, remainingCount); - return; - } - - activeRequests.urls.delete(request.faviconUrl); - if (activeRequests.urls.size === 0) { - if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { - activeFaviconRequests.delete(request.cacheKey); - } - return; - } - - if (activeRequests.currentUrl === request.faviconUrl) { - activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; - } - }; -} - -export function hasLoadedProjectFavicon(cacheKey: string | null) { - return cacheKey !== null && loadedFaviconKeys.has(cacheKey); -} - -function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { - return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; -} - -function rememberLoadedProjectFavicon(cacheKey: string) { - loadedFaviconKeys.delete(cacheKey); - loadedFaviconKeys.set(cacheKey, true); - - if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { - loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); - } -} - -export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { - if (!isCurrentProjectFaviconRequest(request)) return false; - - rememberLoadedProjectFavicon(request.cacheKey); - return true; -} - -export function markProjectFaviconFailed(request: ProjectFaviconRequest) { - if (!isCurrentProjectFaviconRequest(request)) return false; - - loadedFaviconKeys.delete(request.cacheKey); - return true; -} diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index c4a7cbdcce06362fcb9a2b7ffec15b7c5eea47f0..44a1d311453217f4c85e33572003c64ea0d5bb85 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,7 +15,10 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; -import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; +import { + attachProjectFaviconDatabase, + projectFaviconDatabaseCache, +} from "../lib/projectFaviconDatabaseCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -130,7 +133,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -241,7 +244,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.promise(() => projectFaviconDatabaseCache.clearEnvironment(environmentId)).pipe( Effect.andThen(database.clearEnvironmentCache(environmentId)), Effect.mapError(mapDatabaseError("clear-environment")), ), diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconCache.test.ts deleted file mode 100644 index adde56fbf0b18e9f3e539444197a75c5d0cb36d0..0000000000000000000000000000000000000000 --- a/apps/mobile/src/lib/projectFaviconCache.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; - -const native = vi.hoisted(() => ({ - load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ - width: options.maxWidth, - height: options.maxHeight, - release: vi.fn(), - })), - write: vi.fn(async () => {}), - path: vi.fn(async () => "/cache/thumbnail"), - read: vi.fn(), - remove: vi.fn(), -})); -vi.mock("expo-image", () => ({ - Image: { - loadAsync: native.load, - writeToCacheAsync: native.write, - getCachePathAsync: native.path, - }, -})); -vi.mock("expo-file-system", () => ({ - File: class { - size = 24_000; - base64 = native.read; - delete = native.remove; - }, -})); - -import { downscaleProjectFavicon } from "./projectFaviconCache"; - -const png = "iVBORw0KGgoAAAAA"; -const image = { url: "https://remote/icon.png" }; - -beforeEach(() => { - vi.clearAllMocks(); - native.read.mockReset().mockResolvedValue(png); - native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ - width: maxWidth, - height: maxWidth, - release: vi.fn(), - })); -}); - -describe("mobile project icon thumbnails", () => { - it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { - native.read.mockResolvedValueOnce( - `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, - ); - const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); - expect(thumbnail).toBe(`data:image/png;base64,${png}`); - expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); - expect(native.remove).toHaveBeenCalledTimes(2); - for (const call of native.load.mock.results) - expect((await call.value).release).toHaveBeenCalledOnce(); - }); - - it("releases a decoded image when its request was canceled", async () => { - const controller = new AbortController(); - const release = vi.fn(); - native.load.mockImplementationOnce(async () => { - controller.abort(); - return { width: 96, height: 96, release }; - }); - await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); - expect(release).toHaveBeenCalledOnce(); - expect(native.write).not.toHaveBeenCalled(); - }); - - it("rejects an image the native decoder did not downsize", async () => { - const release = vi.fn(); - native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); - await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( - "not resized", - ); - expect(native.write).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledOnce(); - }); -}); diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconCache.ts deleted file mode 100644 index 26a6d848d11d064b0405fbb86c09b5ce62e238af..0000000000000000000000000000000000000000 --- a/apps/mobile/src/lib/projectFaviconCache.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - createProjectFaviconCache, - createProjectFaviconImageLoader, - PROJECT_FAVICON_MAX_DATA_URL_LENGTH, - PROJECT_FAVICON_THUMBNAIL_SIZE, - type ProjectFaviconEntry, -} from "@t3tools/client-runtime/project-favicon-cache"; -import * as Effect from "effect/Effect"; - -import * as MobileDatabase from "../persistence/mobile-database"; - -const CACHE_KIND = "project-favicon"; -const CACHE_SCHEMA_VERSION = 1; - -let database: MobileDatabase.MobileDatabase["Service"] | undefined; - -/** - * The cache is a module singleton because the favicon atom family holds it outside - * any Effect runtime. Its rows live in `client_cache`, so the environment cache store - * hands over the database it already owns instead of the cache re-entering the runtime. - */ -export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { - database = service; -} - -const runDatabase = ( - use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, -) => - database - ? Effect.runPromise(use(database)) - : Promise.reject(new Error("Project icon storage is not attached.")); - -/** - * Rasterizes a bitmap that is too large to inline. The native decoder writes the - * downsized frame to expo-image's disk cache, which is the only encode path it - * exposes; the temporary entry is removed once its bytes are read. - */ -export async function downscaleProjectFavicon( - image: { readonly url: string }, - signal: AbortSignal, -) { - const [{ Image }, { File }] = await Promise.all([ - import("expo-image"), - import("expo-file-system"), - ]); - for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { - signal.throwIfAborted(); - const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); - const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; - try { - signal.throwIfAborted(); - if (decoded.width > size || decoded.height > size) { - throw new Error("Project icon was not resized."); - } - await Image.writeToCacheAsync(decoded, cacheKey); - const path = await Image.getCachePathAsync(cacheKey); - if (!path) throw new Error("Project icon thumbnail was not written."); - const file = new File(path.startsWith("file:") ? path : `file://${path}`); - try { - if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; - const base64 = await file.base64(); - // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. - const mimeType = base64.startsWith("/9j/") - ? "image/jpeg" - : base64.startsWith("iVBORw0KGgo") - ? "image/png" - : null; - if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); - const dataUrl = `data:${mimeType};base64,${base64}`; - if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; - } finally { - file.delete(); - } - } finally { - decoded.release(); - } - } - throw new Error("Project icon thumbnail exceeds the cache limit."); -} - -/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ -export const projectFaviconCache = createProjectFaviconCache({ - storage: { - list: () => - runDatabase((database) => - database.listCache(CACHE_KIND).pipe( - Effect.map((payloads) => - payloads.flatMap((payload): Array => { - try { - return [JSON.parse(payload)]; - } catch { - return []; - } - }), - ), - ), - ), - put: (key, entry: ProjectFaviconEntry) => - runDatabase((database) => - database.saveCache( - entry.environmentId, - CACHE_KIND, - key, - CACHE_SCHEMA_VERSION, - JSON.stringify(entry), - ), - ), - remove: (key, entry) => - runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), - }, - load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), -}); diff --git a/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e7344a684c33f18ae26048bf30969ac9575f297 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; + +const native = vi.hoisted(() => ({ + load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ + width: options.maxWidth, + height: options.maxHeight, + release: vi.fn(), + })), + write: vi.fn(async () => {}), + path: vi.fn(async () => "/cache/thumbnail"), + read: vi.fn(), + remove: vi.fn(), +})); +vi.mock("expo-image", () => ({ + Image: { + loadAsync: native.load, + writeToCacheAsync: native.write, + getCachePathAsync: native.path, + }, +})); +vi.mock("expo-file-system", () => ({ + File: class { + size = 24_000; + base64 = native.read; + delete = native.remove; + }, +})); + +import { downscaleProjectFavicon } from "./projectFaviconDatabaseCache"; + +const png = "iVBORw0KGgoAAAAA"; +const image = { url: "https://remote/icon.png" }; + +beforeEach(() => { + vi.clearAllMocks(); + native.read.mockReset().mockResolvedValue(png); + native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ + width: maxWidth, + height: maxWidth, + release: vi.fn(), + })); +}); + +describe("mobile project icon thumbnails", () => { + it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { + native.read.mockResolvedValueOnce( + `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); + expect(thumbnail).toBe(`data:image/png;base64,${png}`); + expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); + expect(native.remove).toHaveBeenCalledTimes(2); + for (const call of native.load.mock.results) + expect((await call.value).release).toHaveBeenCalledOnce(); + }); + + it("releases a decoded image when its request was canceled", async () => { + const controller = new AbortController(); + const release = vi.fn(); + native.load.mockImplementationOnce(async () => { + controller.abort(); + return { width: 96, height: 96, release }; + }); + await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); + expect(release).toHaveBeenCalledOnce(); + expect(native.write).not.toHaveBeenCalled(); + }); + + it("rejects an image the native decoder did not downsize", async () => { + const release = vi.fn(); + native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); + await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( + "not resized", + ); + expect(native.write).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/lib/projectFaviconDatabaseCache.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts new file mode 100644 index 0000000000000000000000000000000000000000..f24cb2b394f75ec20474cac7e103dae403d7ad66 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts @@ -0,0 +1,117 @@ +import { + createProjectFaviconCache as createSharedProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, + type ProjectFaviconEntry, +} from "@t3tools/client-runtime/project-favicon-cache"; +import * as Effect from "effect/Effect"; + +import * as MobileDatabase from "../persistence/mobile-database"; + +const CACHE_KIND = "project-favicon"; +const CACHE_SCHEMA_VERSION = 1; + +let database: MobileDatabase.MobileDatabase["Service"] | undefined; + +/** + * The cache is a module singleton because the favicon atom family holds it outside + * any Effect runtime. Its rows live in `client_cache`, so the environment cache store + * hands over the database it already owns instead of the cache re-entering the runtime. + */ +export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { + database = service; +} + +const runDatabase = ( + use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, +) => + database + ? Effect.runPromise(use(database)) + : Promise.reject(new Error("Project icon storage is not attached.")); + +/** + * Rasterizes a bitmap that is too large to inline. The native decoder writes the + * downsized frame to expo-image's disk cache, which is the only encode path it + * exposes; the temporary entry is removed once its bytes are read. + */ +export async function downscaleProjectFavicon( + image: { readonly url: string }, + signal: AbortSignal, +) { + const [{ Image }, { File }] = await Promise.all([ + import("expo-image"), + import("expo-file-system"), + ]); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + signal.throwIfAborted(); + const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); + const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; + try { + signal.throwIfAborted(); + if (decoded.width > size || decoded.height > size) { + throw new Error("Project icon was not resized."); + } + await Image.writeToCacheAsync(decoded, cacheKey); + const path = await Image.getCachePathAsync(cacheKey); + if (!path) throw new Error("Project icon thumbnail was not written."); + const file = new File(path.startsWith("file:") ? path : `file://${path}`); + try { + if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; + const base64 = await file.base64(); + // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. + const mimeType = base64.startsWith("/9j/") + ? "image/jpeg" + : base64.startsWith("iVBORw0KGgo") + ? "image/png" + : null; + if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); + const dataUrl = `data:${mimeType};base64,${base64}`; + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } finally { + file.delete(); + } + } finally { + decoded.release(); + } + } + throw new Error("Project icon thumbnail exceeds the cache limit."); +} + +/** + * The database-backed project favicon cache. Named apart from the shared + * `createProjectFaviconCache` factory it calls and from the in-flight request + * registry in `projectFaviconRequests`. Rows live in `client_cache` so + * Settings → Client storage counts and clears them. + */ +export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({ + storage: { + list: () => + runDatabase((database) => + database.listCache(CACHE_KIND).pipe( + Effect.map((payloads) => + payloads.flatMap((payload): Array => { + try { + return [JSON.parse(payload)]; + } catch { + return []; + } + }), + ), + ), + ), + put: (key, entry: ProjectFaviconEntry) => + runDatabase((database) => + database.saveCache( + entry.environmentId, + CACHE_KIND, + key, + CACHE_SCHEMA_VERSION, + JSON.stringify(entry), + ), + ), + remove: (key, entry) => + runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/mobile/src/lib/projectFaviconRequests.test.ts b/apps/mobile/src/lib/projectFaviconRequests.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..540a4547392634860ff0822e9e94380a5e3266e6 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconRequests.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + beginProjectFaviconRequest, + createProjectFaviconRequest, + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconRequests"; + +describe("project favicon requests", () => { + it("ignores callbacks from a superseded URL", () => { + const cacheKey = "environment-1:/workspace:v1-favicon.svg"; + const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; + const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; + + const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); + const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); + markProjectFaviconLoaded(expiredRequest); + const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); + const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); + + expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); + expect(markProjectFaviconFailed(expiredRequest)).toBe(false); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRefreshedRequest(); + endExpiredRequest(); + }); + + it("evicts the URL that actually failed", () => { + const cacheKey = "environment-1:/workspace:v2-favicon.svg"; + const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; + const request = createProjectFaviconRequest(cacheKey, faviconUrl); + const endRequest = beginProjectFaviconRequest(request); + + markProjectFaviconLoaded(request); + + expect(markProjectFaviconFailed(request)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRequest(); + }); + + it("does not supersede a request until the next request begins", () => { + const cacheKey = "environment-1:/workspace:v3-favicon.svg"; + const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; + const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; + const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); + const endCommittedRequest = beginProjectFaviconRequest(committedRequest); + + createProjectFaviconRequest(cacheKey, abandonedUrl); + + expect(markProjectFaviconLoaded(committedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + + endCommittedRequest(); + }); + + it("requires a cache key before creating a URL-bearing request", () => { + const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; + const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; + + expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); + expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); + }); + + it("restores the remaining active URL when a newer request ends", () => { + const cacheKey = "environment-1:/workspace:v4-favicon.svg"; + const firstRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/first/v4-favicon.svg", + ); + const secondRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/second/v4-favicon.svg", + ); + const endFirstRequest = beginProjectFaviconRequest(firstRequest); + const endSecondRequest = beginProjectFaviconRequest(secondRequest); + + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + endSecondRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(true); + endFirstRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + }); + + it("bounds remembered loaded revisions", () => { + const firstCacheKey = "environment-1:/workspace:revision-0"; + let lastCacheKey = firstCacheKey; + + for (let revision = 0; revision < 300; revision++) { + lastCacheKey = `environment-1:/workspace:revision-${revision}`; + const request = createProjectFaviconRequest( + lastCacheKey, + `https://environment.example/api/assets/revision-${revision}/favicon.svg`, + ); + const endRequest = beginProjectFaviconRequest(request); + markProjectFaviconLoaded(request); + endRequest(); + } + + expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); + expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/projectFaviconRequests.ts b/apps/mobile/src/lib/projectFaviconRequests.ts new file mode 100644 index 0000000000000000000000000000000000000000..202142d175e15a3a37b1e3390f3e392d320a2433 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconRequests.ts @@ -0,0 +1,102 @@ +/** + * Request bookkeeping behind `ProjectFavicon`: tracks the favicon URLs with + * mounted views per cache key, keeps one of them current, and remembers which + * keys have already loaded so superseded or already-shown loads settle without + * churn. This is not request coalescing — each mount still issues its own + * load — and it is not the cache; persistence lives in + * `projectFaviconDatabaseCache`. + */ +export interface ProjectFaviconRequest { + readonly cacheKey: string; + readonly faviconUrl: string; +} + +interface ActiveFaviconRequests { + readonly urls: Map; + currentUrl: string; +} + +const MAX_LOADED_FAVICONS = 256; +const activeFaviconRequests = new Map(); +const loadedFaviconKeys = new Map(); + +export function createProjectFaviconRequest( + cacheKey: string, + faviconUrl: string, +): ProjectFaviconRequest; +export function createProjectFaviconRequest( + cacheKey: string | null, + faviconUrl: string | null, +): ProjectFaviconRequest | null; +export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { + if (!cacheKey || !faviconUrl) return null; + return { cacheKey, faviconUrl }; +} + +export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { + let activeRequests = activeFaviconRequests.get(request.cacheKey); + if (!activeRequests) { + activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; + activeFaviconRequests.set(request.cacheKey, activeRequests); + } + + const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; + activeRequests.urls.delete(request.faviconUrl); + activeRequests.urls.set(request.faviconUrl, activeCount + 1); + activeRequests.currentUrl = request.faviconUrl; + + let ended = false; + return () => { + if (ended) return; + ended = true; + + const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; + if (remainingCount > 0) { + activeRequests.urls.set(request.faviconUrl, remainingCount); + return; + } + + activeRequests.urls.delete(request.faviconUrl); + if (activeRequests.urls.size === 0) { + if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { + activeFaviconRequests.delete(request.cacheKey); + } + return; + } + + if (activeRequests.currentUrl === request.faviconUrl) { + activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; + } + }; +} + +export function hasLoadedProjectFavicon(cacheKey: string | null) { + return cacheKey !== null && loadedFaviconKeys.has(cacheKey); +} + +function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { + return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; +} + +function rememberLoadedProjectFavicon(cacheKey: string) { + loadedFaviconKeys.delete(cacheKey); + loadedFaviconKeys.set(cacheKey, true); + + if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { + loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); + } +} + +export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + rememberLoadedProjectFavicon(request.cacheKey); + return true; +} + +export function markProjectFaviconFailed(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + loadedFaviconKeys.delete(request.cacheKey); + return true; +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 15cbd1d9a89fd0a235130dd66d110808c3d240e5..b2fb759c56806c0f93605c4ebecdf473d64f10f2 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -16,7 +16,7 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; @@ -26,7 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ - imageCache: projectFaviconCache, + imageCache: projectFaviconDatabaseCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, }); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index c210c54f2fdd41d0fa006dca9b312682f41cb39c..0060e6ab0789b754dd9bfed47816ffec991fea38 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -74,8 +74,8 @@ export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => Effect.promise(() => scope.type === "all" - ? projectFaviconCache.clearAll() - : projectFaviconCache.clearEnvironment(scope.environmentId), + ? projectFaviconDatabaseCache.clearAll() + : projectFaviconDatabaseCache.clearEnvironment(scope.environmentId), ).pipe( Effect.andThen(MobileDatabase), Effect.flatMap((database) =>