diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c60709baf4c9..932fc6779f20 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -5,9 +5,13 @@ import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { useAssetUrl } from "../state/assets"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { projectFaviconUrlAtom } from "../state/assets"; + import { beginProjectFaviconRequest, createProjectFaviconRequest, @@ -16,6 +20,8 @@ import { markProjectFaviconLoaded, } from "./projectFaviconCache"; +const EMPTY_FAVICON_URL = Atom.make(null); + /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; @@ -26,20 +32,23 @@ export function ProjectFavicon(props: { readonly faviconPath?: string | null; }) { const size = props.size ?? 42; - const faviconUrl = useAssetUrl( - props.environmentId, - props.workspaceRoot === null || props.workspaceRoot === undefined - ? null - : { - _tag: "project-favicon", + const faviconUrl = useAtomValue( + props.workspaceRoot == null + ? EMPTY_FAVICON_URL + : projectFaviconUrlAtom({ + environmentId: props.environmentId, cwd: props.workspaceRoot, - ...(props.faviconPath ? { path: props.faviconPath } : {}), - }, + faviconPath: props.faviconPath, + }), ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + // Inline images are self-contained; remote URLs key on their revision so signed-token + // rotation reuses the disk cache while a changed icon starts from the loading state. const cacheKey = renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; return ( @@ -75,7 +84,9 @@ function ProjectFaviconImage(props: { }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", + props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey) + ? "loaded" + : "loading", ); const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; @@ -104,11 +115,12 @@ function ProjectFaviconImage(props: { {requestIsActive ? ( Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + listCache: (kind) => + Effect.sync(() => + [...values.entries()] + .filter(([key]) => key.split(":")[1] === kind) + .map(([, payload]) => payload), + ), saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => Effect.sync(() => { values.set(cacheId(environmentId, kind, cacheKey), payload); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index ad5ef13b62d5..ccf4945b3bef 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,6 +15,7 @@ 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"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -115,6 +116,7 @@ function loadDecodedCache(input: { export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { const database = yield* MobileDatabase.MobileDatabase; + attachProjectFaviconDatabase(database); return EnvironmentCacheStore.of({ loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => loadDecodedCache({ @@ -126,7 +128,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()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - database - .clearEnvironmentCache(environmentId) - .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + Effect.promise(() => projectFaviconCache.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 new file mode 100644 index 000000000000..adde56fbf0b1 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.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 "./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 new file mode 100644 index 000000000000..26a6d848d11d --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.ts @@ -0,0 +1,112 @@ +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/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 71876932b789..aca830f24c71 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [ "connection-vcs-refs", ] as const; -export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); +export const ClientCacheKind = Schema.Literals([ + "shell", + "thread", + "server-config", + "vcs-refs", + "project-favicon", +]); export type ClientCacheKind = typeof ClientCacheKind.Type; export interface ClientCacheSummaryRow { @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ "open", "migrate", "load-cache", + "list-cache", "save-cache", "remove-cache", "clear-cache-kind", @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect, MobileDatabaseError>; + readonly listCache: ( + kind: ClientCacheKind, + ) => Effect.Effect, MobileDatabaseError>; readonly saveCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { catch: databaseError("load-cache"), }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), ), + listCache: Effect.fn("MobileDatabase.listCache")((kind) => + Effect.tryPromise({ + try: () => + database.getAllAsync<{ readonly payload: string }>( + "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at", + kind, + ), + catch: databaseError("list-cache"), + }).pipe(Effect.map((rows) => rows.map((row) => row.payload))), + ), saveCache: Effect.fn("MobileDatabase.saveCache")( (environmentId, kind, cacheKey, schemaVersion, payload) => Effect.tryPromise({ @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] const fail = Effect.fail(error); return MobileDatabase.of({ loadCache: () => fail, + listCache: () => fail, saveCache: () => fail, removeCache: () => fail, clearCacheKind: () => fail, diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 400bdb6b705a..15cbd1d9a89f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -6,6 +6,7 @@ import { import { assetUrlStateFromResult, createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, EMPTY_ASSET_URL_ATOM, } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; @@ -15,14 +16,21 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; -import { usePreparedConnection } from "./session"; +import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); + const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-asset-connection-state:empty"), ); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index 3912857b5751..c210c54f2fdd 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,6 +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 * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => - MobileDatabase.pipe( + Effect.promise(() => + scope.type === "all" + ? projectFaviconCache.clearAll() + : projectFaviconCache.clearEnvironment(scope.environmentId), + ).pipe( + Effect.andThen(MobileDatabase), Effect.flatMap((database) => scope.type === "all" ? database.clearAllCaches diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts new file mode 100644 index 000000000000..e6fcc817b969 --- /dev/null +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -0,0 +1,85 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, +} from "@t3tools/client-runtime/project-favicon-cache"; + +const DATABASE_NAME = "t3code:project-favicons"; +const DATABASE_VERSION = 2; +const STORE_NAME = "images"; +let database: Promise | undefined; + +function openDatabase() { + return (database ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + for (const name of request.result.objectStoreNames) { + if (name !== STORE_NAME) request.result.deleteObjectStore(name); + } + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); + })); +} + +function completed(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); +} + +async function withStore( + mode: IDBTransactionMode, + use: (store: IDBObjectStore) => IDBRequest | void, +) { + const transaction = (await openDatabase()).transaction(STORE_NAME, mode); + const request = use(transaction.objectStore(STORE_NAME)); + await completed(transaction); + return request?.result; +} + +/** Rasterizes a bitmap that is too large to inline, retrying at half size. */ +export async function downscaleProjectFavicon( + image: { readonly mimeType: string; readonly bytes: Uint8Array }, + signal: AbortSignal, +) { + const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); + try { + signal.throwIfAborted(); + const canvas = document.createElement("canvas"); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + const scale = Math.min(1, size / bitmap.width, size / bitmap.height); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas is unavailable."); + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL("image/webp", 0.85); + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } + throw new Error("Project icon thumbnail exceeds the cache limit."); + } finally { + bitmap.close(); + } +} + +export const projectFaviconCache = createProjectFaviconCache({ + storage: { + list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], + put: async (key, entry) => { + await withStore("readwrite", (store) => store.put(entry, key)); + }, + remove: async (key) => { + await withStore("readwrite", (store) => store.delete(key)); + }, + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bfb5487031b7..98458cfa2dcd 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon" const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", - lastResource: null as unknown, + lastTarget: null as unknown, })); const hooks = vi.hoisted(() => { @@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({ DynamicIcon: "dynamic-icon", iconNames: ["alarm-clock", "folder-code"], })); -vi.mock("../assets/assetUrls", () => ({ - useAssetUrlState: (_environmentId: unknown, resource: unknown) => { - testState.lastResource = resource; - return { _tag: "Success", url: testState.faviconUrl }; +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => testState.faviconUrl, +})); +vi.mock("../state/assets", () => ({ + projectFaviconUrlAtom: (input: unknown) => { + testState.lastTarget = input; }, })); @@ -212,10 +214,10 @@ describe("ProjectFavicon", () => { faviconPath: "brand/icon.svg", }); - expect(testState.lastResource).toEqual({ - _tag: "project-favicon", + expect(testState.lastTarget).toMatchObject({ + environmentId: "environment-test", cwd: "/workspace-test", - path: "brand/icon.svg", + faviconPath: "brand/icon.svg", }); }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 2ebc6e267443..19467adde404 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,6 +1,6 @@ import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; import { - getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; import { @@ -30,12 +30,12 @@ import { import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; import { lazy, Suspense, useState } from "react"; -import { useAssetUrlState } from "../assets/assetUrls"; +import { useAtomValue } from "@effect/atom-react"; +import { projectFaviconUrlAtom } from "../state/assets"; import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Map(); const DynamicIcon = lazy(() => import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), ); @@ -103,8 +103,7 @@ export function ProjectFavicon(input: { className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const state = useProjectFaviconAsset(input); - const src = state._tag === "Success" ? state.url : null; + const src = useAtomValue(projectFaviconUrlAtom(input)); if (input.projectIcon?.kind === "emoji") { return ; } @@ -150,12 +149,11 @@ export function ProjectFavicon(input: { ); } - const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath); return ( | undefined; readonly fallbackEmoji?: string | undefined; readonly fallbackColorClassName?: string | undefined; }) { - const [displayedSrc, setDisplayedSrc] = useState( - () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, + const [displayedSrc, setDisplayedSrc] = useState(() => + src.startsWith("data:image/") ? src : null, ); const isLoading = displayedSrc !== src; const handleLoadError = (failedSrc: string) => { - if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { - loadedProjectFaviconSrcs.delete(cacheKey); - } setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); }; @@ -256,7 +237,6 @@ function ProjectFaviconImage({ alt="" className="hidden" onLoad={() => { - loadedProjectFaviconSrcs.set(cacheKey, src); setDisplayedSrc(src); }} onError={() => handleLoadError(src)} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 9456daef72d8..2a146834012e 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({ useThreadRecentHistory: () => EMPTY_HISTORY, })); -vi.mock("~/state/session", () => ({ +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), readPreparedConnection: mocks.readPreparedConnection, })); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 0a1183d48abd..8ec2b16add76 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -33,6 +33,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; const DATABASE_NAME = "t3code:connection-runtime"; const DATABASE_VERSION = 4; @@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext( const cacheStore = EnvironmentCacheStore.of({ loadShell: (environmentId) => readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), Effect.flatMap((raw) => { if (typeof raw !== "string") { return Effect.succeed(Option.none()); @@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext( clear: (environmentId) => Effect.all( [ + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)), removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), removeDatabaseValuesInRange( database, diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 5e31beb826b5..d1ef71f8662d 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -1,5 +1,16 @@ -import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; +import { + createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, +} from "@t3tools/client-runtime/state/assets"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; +import { environmentSession } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); + +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 68f5bad51474..fe08435ea3cd 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./project-favicon-cache": { + "types": "./src/projectFaviconCache.ts", + "default": "./src/projectFaviconCache.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts new file mode 100644 index 000000000000..980ccf495165 --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_CACHE_MAX_BYTES, + PROJECT_FAVICON_CACHE_MAX_ENTRIES, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_MAX_SOURCE_BYTES, + type ProjectFaviconEntry, + type ProjectFaviconStorage, +} from "./projectFaviconCache.ts"; + +const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; +const url = "https://remote.test/api/assets/token-a/vabc-icon.svg"; +const image = "data:image/png;base64,aWNvbg=="; +const replacement = "data:image/png;base64,bmV3"; +const signal = () => new AbortController().signal; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fixture() { + const records = new Map(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + records.delete(key); + }, + }; + return { + storage, + load, + records, + cache: createProjectFaviconCache({ storage, load }), + }; +} + +describe("persistent project favicon cache", () => { + it("restores image bytes in a fresh client before any remote response", async () => { + const { cache, storage, load } = fixture(); + expect(await cache.resolve(target, url, signal())).toBe(image); + await cache.flush(); + const reloaded = createProjectFaviconCache({ storage, load }); + await reloaded.hydrate(); + expect(reloaded.peek(target)).toBe(image); + expect(await reloaded.resolve(target, null, signal())).toBe(image); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("reuses the image when signed URLs or connection origins change", async () => { + const { cache, load } = fixture(); + await cache.resolve(target, url, signal()); + expect( + await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()), + ).toBe(image); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("keeps the old image during refresh and failures, then persists its replacement", async () => { + const { cache, load, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + load.mockImplementationOnce(() => next.promise); + const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + expect(cache.peek(target)).toBe(image); + next.resolve(replacement); + expect(await refreshing).toBe(replacement); + load.mockRejectedValueOnce(new Error("offline")); + expect(await cache.resolve(target, url, signal())).toBe(replacement); + await cache.flush(); + expect(await createProjectFaviconCache({ storage, load }).resolve(target, null, signal())).toBe( + replacement, + ); + }); + + it("persists confirmed removal and ignores an aborted older download", async () => { + const { cache, load, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const controller = new AbortController(); + const pending = cache.resolve(target, url.replace("vabc", "vdef"), controller.signal); + await started.promise; + controller.abort(); + expect( + await cache.resolve( + target, + "https://remote.test/api/assets/token/project-favicon-missing", + signal(), + ), + ).toBeNull(); + next.resolve(replacement); + await pending; + await cache.flush(); + expect( + await createProjectFaviconCache({ storage, load }).resolve(target, null, signal()), + ).toBeNull(); + }); + + it("isolates environments, workspaces, and icon selections", async () => { + const { cache } = fixture(); + await cache.resolve(target, url, signal()); + expect(cache.peek({ ...target, faviconPath: null })).toBe(image); + expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/other" })).toBeNull(); + expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull(); + }); + + it.each([ + { + scope: "one environment", + clear: (cache: ReturnType["cache"]) => + cache.clearEnvironment(target.environmentId), + remaining: 1, + }, + { + scope: "every environment", + clear: (cache: ReturnType["cache"]) => cache.clearAll(), + remaining: 0, + }, + ])( + "does not restore images for $scope removed during a download", + async ({ clear, remaining }) => { + const { cache, load, records } = fixture(); + const other = { ...target, environmentId: EnvironmentId.make("other") }; + await cache.resolve(other, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const pending = cache.resolve(target, url, signal()); + await started.promise; + await clear(cache); + next.resolve(image); + await pending; + await cache.flush(); + expect(cache.peek(target)).toBeNull(); + expect(records.size).toBe(remaining); + }, + ); + + it("discards a download that starts while the environment is being cleared", async () => { + const records = new Map(); + const removal = deferred(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + await removal.promise; + records.delete(key); + }, + }; + const cache = createProjectFaviconCache({ storage, load }); + await cache.resolve(target, url, signal()); + await cache.flush(); + const clearing = cache.clearEnvironment(target.environmentId); + await Promise.resolve(); + const late = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + removal.resolve(); + await clearing; + expect(records.size).toBe(0); + expect(cache.peek(target)).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + expect(await late).toBe(image); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("bounds individual images, total bytes, and entry count in storage", async () => { + const { cache, load, records } = fixture(); + load.mockResolvedValueOnce( + `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + expect(await cache.resolve(target, url, signal())).toBe(url); + expect(cache.peek(target)).toBeNull(); + const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`; + load.mockResolvedValue(large); + for (let i = 0; i < 40; i++) { + await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal()); + } + await cache.flush(); + expect( + [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0), + ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES); + expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large); + load.mockResolvedValue(image); + for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) { + await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal()); + } + await cache.flush(); + expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES); + expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull(); + }); + + it("skips corrupt records and tolerates unavailable storage", async () => { + const corrupt = createProjectFaviconCache({ + storage: { + list: async () => [ + { ...target, faviconPath: null, revision: "r", dataUrl: image }, + { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" }, + "garbage", + ], + put: async () => {}, + remove: async () => {}, + }, + load: async () => replacement, + }); + await corrupt.hydrate(); + expect(corrupt.peek(target)).toBe(image); + expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull(); + + const unavailable = createProjectFaviconCache({ + storage: { + list: async () => { + throw new Error("storage unavailable"); + }, + put: async () => { + throw new Error("quota exceeded"); + }, + remove: async () => { + throw new Error("quota exceeded"); + }, + }, + load: async () => image, + }); + expect(await unavailable.resolve(target, url, signal())).toBe(image); + await unavailable.flush(); + expect(unavailable.peek(target)).toBe(image); + }); +}); + +describe("project favicon image loader", () => { + const svg = + ''; + const svgBase64 = btoa(svg); + + function loader(response: Response, downscale = vi.fn(async () => replacement)) { + return { + downscale, + load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }), + }; + } + + it("inlines small icons exactly as served without rasterizing", async () => { + const { load, downscale } = loader( + new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8" } }), + ); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + expect(downscale).not.toHaveBeenCalled(); + }); + + it("falls back to the file extension when the response has no image type", async () => { + const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } })); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + }); + + it("downscales large bitmaps and refuses large vector icons", async () => { + const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH); + const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } })); + expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe( + replacement, + ); + expect(bitmap.downscale).toHaveBeenCalledWith( + expect.objectContaining({ mimeType: "image/png", bytes }), + expect.any(AbortSignal), + ); + const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } })); + await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit"); + expect(vector.downscale).not.toHaveBeenCalled(); + }); + + it("stops reading a response that exceeds the source limit", async () => { + let pulled = 0; + const chunk = new Uint8Array(1024 * 1024); + const stream = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(chunk); + }, + }); + const { load, downscale } = loader( + new Response(stream, { headers: { "content-type": "image/png" } }), + ); + await expect(load(url, signal())).rejects.toThrow("too large"); + expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); + expect(downscale).not.toHaveBeenCalled(); + const declared = loader( + new Response("x", { + headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, + }), + ); + await expect(declared.load(url, signal())).rejects.toThrow("too large"); + }); + + it("rejects failed responses and non-image payloads", async () => { + const failed = loader(new Response("nope", { status: 404 })); + await expect(failed.load(url, signal())).rejects.toThrow("404"); + const html = loader(new Response("", { headers: { "content-type": "text/html" } })); + await expect( + html.load("https://remote.test/api/assets/t/v1-favicon", signal()), + ).rejects.toThrow("no image type"); + }); +}); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts new file mode 100644 index 000000000000..53fd58372190 --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -0,0 +1,263 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { mediaMimeType } from "@t3tools/shared/filePreview"; +import { + getProjectFaviconCacheKey, + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; +export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; +/** Larger sources are not worth decoding for an icon and are left to the remote URL. */ +export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; + +export interface ProjectFaviconTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +} + +const ImageDataUrl = Schema.String.check( + Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH), + Schema.isPattern( + /^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/, + ), +); +const Entry = Schema.Struct({ + environmentId: EnvironmentId, + cwd: Schema.String, + faviconPath: Schema.NullOr(Schema.String), + revision: Schema.String, + dataUrl: ImageDataUrl, +}); +export type ProjectFaviconEntry = typeof Entry.Type; +const decodeEntry = Schema.decodeUnknownOption(Entry); +const isImageDataUrl = Schema.is(ImageDataUrl); + +function keyFor(target: ProjectFaviconTarget) { + return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath); +} + +export interface ProjectFaviconStorage { + /** Every persisted record; entries that fail validation are ignored. */ + readonly list: () => Promise>; + readonly put: (key: string, entry: ProjectFaviconEntry) => Promise; + readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise; +} + +async function readBounded(response: Response, maxBytes: number) { + const declared = Number(response.headers.get("content-length")); + if (declared > maxBytes) throw new Error("Project icon is too large to decode."); + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); + return bytes; + } + const reader = response.body.getReader(); + const chunks: Array = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw new Error("Project icon is too large to decode."); + chunks.push(value); + } + } finally { + reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +/** + * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs + * and small bitmaps are stored exactly as served. Larger bitmaps go through the + * platform downscaler; larger SVGs stay remote because rasterizing them without + * intrinsic dimensions is unreliable. + */ +export function createProjectFaviconImageLoader(input: { + readonly fetch?: typeof fetch; + readonly downscale: ( + image: { + readonly url: string; + readonly mimeType: string; + readonly bytes: Uint8Array; + }, + signal: AbortSignal, + ) => Promise; +}) { + const fetchImpl = input.fetch ?? globalThis.fetch; + return async (url: string, signal: AbortSignal): Promise => { + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`Project icon request failed with ${response.status}.`); + const contentType = response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url); + if (!mimeType) throw new Error("Project icon has no image type."); + const bytes = await readBounded(response, PROJECT_FAVICON_MAX_SOURCE_BYTES); + signal.throwIfAborted(); + const dataUrl = `data:${mimeType};base64,${Encoding.encodeBase64(bytes)}`; + if (isImageDataUrl(dataUrl)) return dataUrl; + if (mimeType === "image/svg+xml") throw new Error("Project icon exceeds the cache limit."); + return input.downscale({ url, mimeType, bytes }, signal); + }; +} + +/** Stores small, self-contained images so startup never needs an old signed URL. */ +export function createProjectFaviconCache(input: { + readonly storage: ProjectFaviconStorage; + readonly load: (url: string, signal: AbortSignal) => Promise; +}) { + const entries = new Map(); + const environmentRevisions = new Map(); + let generation = 0; + let hydration: Promise | undefined; + let clearing: Promise | undefined; + const pending = new Set>(); + + const persist = (operation: () => Promise) => { + const task: Promise = operation() + .catch(() => { + // Keep the in-memory image if local storage is full or unavailable. + }) + .finally(() => pending.delete(task)); + pending.add(task); + }; + + const remove = (key: string) => { + const entry = entries.get(key); + if (!entry) return; + entries.delete(key); + persist(() => input.storage.remove(key, entry)); + }; + + const trim = () => { + let bytes = 0; + for (const entry of entries.values()) bytes += entry.dataUrl.length; + while ( + entries.size > PROJECT_FAVICON_CACHE_MAX_ENTRIES || + bytes > PROJECT_FAVICON_CACHE_MAX_BYTES + ) { + const oldest = entries.entries().next().value; + if (!oldest) break; + bytes -= oldest[1].dataUrl.length; + remove(oldest[0]); + } + }; + + const hydrate = () => + (hydration ??= (async () => { + try { + for (const record of await input.storage.list()) { + const entry = decodeEntry(record); + if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value); + } + trim(); + } catch { + // A missing, corrupt, or unavailable cache must not prevent startup. + } + })()); + + const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; + + const resolve = async ( + target: ProjectFaviconTarget, + url: string | null, + signal: AbortSignal, + ): Promise => { + await clearing; + const startGeneration = generation; + const startRevision = environmentRevisions.get(target.environmentId) ?? 0; + await hydrate(); + if (signal.aborted || url === null) return peek(target); + const key = keyFor(target); + if (isProjectFaviconFallbackUrl(url)) { + remove(key); + return null; + } + const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url); + const cached = entries.get(key); + if (cached) { + entries.delete(key); + entries.set(key, cached); + if (cached.revision === revision) return cached.dataUrl; + } + try { + const dataUrl = await input.load(url, signal); + if ( + signal.aborted || + startGeneration !== generation || + startRevision !== (environmentRevisions.get(target.environmentId) ?? 0) + ) { + return peek(target); + } + if (isImageDataUrl(dataUrl)) { + const entry = { + environmentId: target.environmentId, + cwd: target.cwd, + faviconPath: target.faviconPath || null, + revision, + dataUrl, + }; + entries.set(key, entry); + persist(() => input.storage.put(key, entry)); + trim(); + return dataUrl; + } + } catch { + // An outage or failed decode leaves the last successful image visible. + } + return peek(target) ?? url; + }; + + const flush = async () => { + await Promise.all(pending); + }; + + // A download that started before the clear sees the revision change and is discarded; + // one that starts during the clear waits for it, so it cannot repopulate storage. + const clear = async (environmentId?: EnvironmentId) => { + if (environmentId === undefined) generation += 1; + else + environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); + const previous = clearing; + const task = (async () => { + await previous; + await hydrate(); + for (const [key, entry] of entries) { + if (environmentId === undefined || entry.environmentId === environmentId) remove(key); + } + await flush(); + })().finally(() => { + if (clearing === task) clearing = undefined; + }); + clearing = task; + await task; + }; + + return { + hydrate, + peek, + resolve, + clearEnvironment: (environmentId: EnvironmentId) => clear(environmentId), + clearAll: () => clear(), + flush, + }; +} + +export type ProjectFaviconCache = ReturnType; diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index d75e82281382..1cbc970df928 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createProjectFaviconCache } from "../projectFaviconCache.ts"; import { createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, InvalidAssetCollectionKeyError, parseAssetCollectionKey, } from "./assets.ts"; @@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => { ).not.toBe(assets.createUrls({ environmentId, resources })); }); }); + +describe("project favicon URL cache", () => { + it("renders a persisted thumbnail immediately in a fresh registry and refreshes it remotely", async () => { + const image = "data:image/png;base64,aWNvbg=="; + const replacement = "data:image/png;base64,bmV3"; + const records = new Map(); + const storage = { + list: async () => [...records.values()], + put: async (key: string, entry: unknown) => { + records.set(key, entry); + }, + remove: async (key: string) => { + records.delete(key); + }, + }; + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const previousCache = createProjectFaviconCache({ storage, load: async () => image }); + await previousCache.resolve( + target, + "https://remote.test/api/assets/old/v1-icon.png", + new AbortController().signal, + ); + await previousCache.flush(); + const cache = createProjectFaviconCache({ storage, load: async () => replacement }); + await cache.hydrate(); + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make>(Option.none()); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + imageCache: cache, + })(target); + const unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBe(image); + let unsubscribe = () => {}; + const refreshed = new Promise((resolve) => { + unsubscribe = registry.subscribe(favicon, (value) => { + if (value === replacement) resolve(); + }); + }); + registry.set(connection, Option.some({ httpBaseUrl: "https://remote.test" })); + registry.set( + result, + AsyncResult.success({ + relativeUrl: "/api/assets/new/v2-icon.png", + expiresAt: 4_000_000_000_000, + }), + ); + expect(registry.get(favicon)).toBe(image); + await refreshed; + unsubscribe(); + expect(registry.get(favicon)).toBe(replacement); + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("offline"))); + expect(registry.get(favicon)).toBe(replacement); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); + let unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBeNull(); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-a/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + unmount(); + unmount = registry.mount(favicon); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(result, AsyncResult.initial()); + registry.set(connection, Option.some({ httpBaseUrl: "https://reconnected.test" })); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-b/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://reconnected.test/api/assets/token-b/icon.svg"); + + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-c/project-favicon-missing", + }), + ); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + registry.set(connection, Option.none()); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("does not reuse another environment, workspace, or selected icon's cached URL", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token/icon.svg", + }), + ); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })), + }); + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const unmount = registry.mount(favicon(target)); + try { + expect(registry.get(favicon(target))).toBe("https://remote.test/api/assets/token/icon.svg"); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect( + registry.get(favicon({ ...target, environmentId: EnvironmentId.make("other") })), + ).toBeNull(); + expect(registry.get(favicon({ ...target, cwd: "/other" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: "brand.svg" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: null }))).toBe( + "https://remote.test/api/assets/token/icon.svg", + ); + } finally { + unmount(); + registry.dispose(); + } + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index f2f82def3dd7..9eb77444d45c 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -4,10 +4,17 @@ import { EnvironmentId, WS_METHODS, } from "@t3tools/contracts"; +import { + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; @@ -112,3 +119,53 @@ export function createAssetEnvironmentAtoms( }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), }; } + +/** + * Keeps project icons visible while their environment reconnects. Each resource + * owns its last resolved URL, including a confirmed missing-icon response. + */ +export function createProjectFaviconUrlAtomFamily(input: { + readonly imageCache?: ProjectFaviconCache; + readonly createUrl: (target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Atom.Atom>; + readonly preparedConnection: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; +}) { + const decodeKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), + ); + const family = Atom.family((key: string) => { + const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); + const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; + const request = input.createUrl({ environmentId, input: { resource } }); + const resolvedUrl = Atom.make((get): string | null => { + const result = get(request); + const connection = get(input.preparedConnection(environmentId)); + const state = assetUrlStateFromResult( + result, + Option.isSome(connection) ? connection.value.httpBaseUrl : null, + ); + return state._tag === "Success" ? state.url : Option.getOrNull(get.self()); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + const cache = input.imageCache; + if (!cache) return resolvedUrl; + + const target = { environmentId, cwd, faviconPath: path }; + const image = Atom.make((get) => { + get(request); + const url = get(resolvedUrl); + return Effect.promise((signal) => cache.resolve(target, url, signal)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + + return Atom.make((get): string | null => { + const result = get(image); + if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null; + return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + }); + return (target: ProjectFaviconTarget) => + family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath)); +} diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index eebc1a8a1b63..b6fd9e56f3a6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,13 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconResourceKey( + environmentId: string, + workspaceRoot: string, + faviconPath?: string | null, +) { + return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]); +} + export function getProjectFaviconCacheKey( environmentId: string, workspaceRoot: string,