Unified · Split
| @@ -5,9 +5,13 @@ import { View } from "react-native"; · 4 unmodified lines | ||
| 5 | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 6 | 6 | import { |
| 7 | 7 | getProjectFaviconCacheKey, |
| 8 | + getProjectFaviconResourceKey, | |
| 8 | 9 | isProjectFaviconFallbackUrl, |
| 9 | 10 | } from "@t3tools/shared/projectFavicon"; |
| 10 | −import { useAssetUrl } from "../state/assets"; | |
| 11 | +import { useAtomValue } from "@effect/atom-react"; | |
| 12 | +import { Atom } from "effect/unstable/reactivity"; | |
| 13 | +import { projectFaviconUrlAtom } from "../state/assets"; | |
| 14 | + | |
| 11 | 15 | import { |
| 12 | 16 | beginProjectFaviconRequest, |
| 13 | 17 | createProjectFaviconRequest, |
| @@ -16,6 +20,8 @@ import { · 2 unmodified lines | ||
| 16 | 20 | markProjectFaviconLoaded, |
| 17 | 21 | } from "./projectFaviconCache"; |
| 18 | 22 | |
| 23 | +const EMPTY_FAVICON_URL = Atom.make<string | null>(null); | |
| 24 | + | |
| 19 | 25 | /* ─── Component ──────────────────────────────────────────────────────── */ |
| 20 | 26 | export function ProjectFavicon(props: { |
| 21 | 27 | readonly environmentId: EnvironmentId; |
| @@ -26,20 +32,23 @@ export function ProjectFavicon(props: { · 4 unmodified lines | ||
| 26 | 32 | readonly faviconPath?: string | null; |
| 27 | 33 | }) { |
| 28 | 34 | const size = props.size ?? 42; |
| 29 | − const faviconUrl = useAssetUrl( | |
| 30 | − props.environmentId, | |
| 31 | − props.workspaceRoot === null || props.workspaceRoot === undefined | |
| 32 | − ? null | |
| 33 | − : { | |
| 34 | − _tag: "project-favicon", | |
| 35 | + const faviconUrl = useAtomValue( | |
| 36 | + props.workspaceRoot == null | |
| 37 | + ? EMPTY_FAVICON_URL | |
| 38 | + : projectFaviconUrlAtom({ | |
| 39 | + environmentId: props.environmentId, | |
| 35 | 40 | cwd: props.workspaceRoot, |
| 36 | − ...(props.faviconPath ? { path: props.faviconPath } : {}), | |
| 37 | − }, | |
| 41 | + faviconPath: props.faviconPath, | |
| 42 | + }), | |
| 38 | 43 | ); |
| 39 | 44 | const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; |
| 45 | + // Inline images are self-contained; remote URLs key on their revision so signed-token | |
| 46 | + // rotation reuses the disk cache while a changed icon starts from the loading state. | |
| 40 | 47 | const cacheKey = |
| 41 | 48 | renderableFaviconUrl && props.workspaceRoot |
| 42 | − ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) | |
| 49 | + ? renderableFaviconUrl.startsWith("data:") | |
| 50 | + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) | |
| 51 | + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) | |
| 43 | 52 | : null; |
| 44 | 53 | |
| 45 | 54 | return ( |
| @@ -75,7 +84,9 @@ function ProjectFaviconImage(props: { · 29 unmodified lines | ||
| 75 | 84 | }, [faviconRequest]); |
| 76 | 85 | |
| 77 | 86 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 78 | − hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", | |
| 87 | + props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey) | |
| 88 | + ? "loaded" | |
| 89 | + : "loading", | |
| 79 | 90 | ); |
| 80 | 91 | |
| 81 | 92 | const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; |
| @@ -104,11 +115,12 @@ function ProjectFaviconImage(props: { · 22 unmodified lines | ||
| 104 | 115 | {requestIsActive ? ( |
| 105 | 116 | <Image |
| 106 | 117 | key={faviconRequest.faviconUrl} |
| 107 | − source={{ | |
| 108 | − uri: faviconRequest.faviconUrl, | |
| 109 | − cacheKey: faviconRequest.cacheKey, | |
| 110 | − }} | |
| 111 | − cachePolicy="memory-disk" | |
| 118 | + source={ | |
| 119 | + faviconRequest.faviconUrl.startsWith("data:") | |
| 120 | + ? { uri: faviconRequest.faviconUrl } | |
| 121 | + : { uri: faviconRequest.faviconUrl, cacheKey: faviconRequest.cacheKey } | |
| 122 | + } | |
| 123 | + cachePolicy={faviconRequest.faviconUrl.startsWith("data:") ? "memory" : "memory-disk"} | |
| 112 | 124 | recyclingKey={faviconRequest.cacheKey} |
| 113 | 125 | accessibilityLabel={`${props.projectTitle} favicon`} |
| 114 | 126 | style={{ |
| @@ -32,6 +32,12 @@ function makeDatabase() { · 31 unmodified lines | ||
| 32 | 32 | const database = MobileDatabase.of({ |
| 33 | 33 | loadCache: (environmentId, kind, cacheKey) => |
| 34 | 34 | Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), |
| 35 | + listCache: (kind) => | |
| 36 | + Effect.sync(() => | |
| 37 | + [...values.entries()] | |
| 38 | + .filter(([key]) => key.split(":")[1] === kind) | |
| 39 | + .map(([, payload]) => payload), | |
| 40 | + ), | |
| 35 | 41 | saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => |
| 36 | 42 | Effect.sync(() => { |
| 37 | 43 | values.set(cacheId(environmentId, kind, cacheKey), payload); |
| @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; · 14 unmodified lines | ||
| 15 | 15 | import * as Schema from "effect/Schema"; |
| 16 | 16 | |
| 17 | 17 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 18 | +import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 18 | 19 | |
| 19 | 20 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; |
| 20 | 21 | // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump |
| @@ -115,6 +116,7 @@ function loadDecodedCache<A, B>(input: { · 94 unmodified lines | ||
| 115 | 116 | |
| 116 | 117 | export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 117 | 118 | const database = yield* MobileDatabase.MobileDatabase; |
| 119 | + attachProjectFaviconDatabase(database); | |
| 118 | 120 | return EnvironmentCacheStore.of({ |
| 119 | 121 | loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => |
| 120 | 122 | loadDecodedCache({ |
| @@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { · 5 unmodified lines | ||
| 126 | 128 | decode: decodeStoredShellSnapshot, |
| 127 | 129 | select: (stored) => |
| 128 | 130 | stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), |
| 129 | − }), | |
| 131 | + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), | |
| 130 | 132 | ), |
| 131 | 133 | saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { |
| 132 | 134 | const payload = yield* encodeStoredShellSnapshot({ |
| @@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { · 104 unmodified lines | ||
| 237 | 239 | .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), |
| 238 | 240 | ), |
| 239 | 241 | clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => |
| 240 | − database | |
| 241 | − .clearEnvironmentCache(environmentId) | |
| 242 | − .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), | |
| 242 | + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( | |
| 243 | + Effect.andThen(database.clearEnvironmentCache(environmentId)), | |
| 244 | + Effect.mapError(mapDatabaseError("clear-environment")), | |
| 245 | + ), | |
| 243 | 246 | ), |
| 244 | 247 | }); |
| 245 | 248 | }); |
| @@ -0,0 +1,79 @@ | ||
| 1 | +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | +import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; | |
| 3 | + | |
| 4 | +const native = vi.hoisted(() => ({ | |
| 5 | + load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ | |
| 6 | + width: options.maxWidth, | |
| 7 | + height: options.maxHeight, | |
| 8 | + release: vi.fn(), | |
| 9 | + })), | |
| 10 | + write: vi.fn(async () => {}), | |
| 11 | + path: vi.fn(async () => "/cache/thumbnail"), | |
| 12 | + read: vi.fn(), | |
| 13 | + remove: vi.fn(), | |
| 14 | +})); | |
| 15 | +vi.mock("expo-image", () => ({ | |
| 16 | + Image: { | |
| 17 | + loadAsync: native.load, | |
| 18 | + writeToCacheAsync: native.write, | |
| 19 | + getCachePathAsync: native.path, | |
| 20 | + }, | |
| 21 | +})); | |
| 22 | +vi.mock("expo-file-system", () => ({ | |
| 23 | + File: class { | |
| 24 | + size = 24_000; | |
| 25 | + base64 = native.read; | |
| 26 | + delete = native.remove; | |
| 27 | + }, | |
| 28 | +})); | |
| 29 | + | |
| 30 | +import { downscaleProjectFavicon } from "./projectFaviconCache"; | |
| 31 | + | |
| 32 | +const png = "iVBORw0KGgoAAAAA"; | |
| 33 | +const image = { url: "https://remote/icon.png" }; | |
| 34 | + | |
| 35 | +beforeEach(() => { | |
| 36 | + vi.clearAllMocks(); | |
| 37 | + native.read.mockReset().mockResolvedValue(png); | |
| 38 | + native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ | |
| 39 | + width: maxWidth, | |
| 40 | + height: maxWidth, | |
| 41 | + release: vi.fn(), | |
| 42 | + })); | |
| 43 | +}); | |
| 44 | + | |
| 45 | +describe("mobile project icon thumbnails", () => { | |
| 46 | + it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { | |
| 47 | + native.read.mockResolvedValueOnce( | |
| 48 | + `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, | |
| 49 | + ); | |
| 50 | + const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); | |
| 51 | + expect(thumbnail).toBe(`data:image/png;base64,${png}`); | |
| 52 | + expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); | |
| 53 | + expect(native.remove).toHaveBeenCalledTimes(2); | |
| 54 | + for (const call of native.load.mock.results) | |
| 55 | + expect((await call.value).release).toHaveBeenCalledOnce(); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it("releases a decoded image when its request was canceled", async () => { | |
| 59 | + const controller = new AbortController(); | |
| 60 | + const release = vi.fn(); | |
| 61 | + native.load.mockImplementationOnce(async () => { | |
| 62 | + controller.abort(); | |
| 63 | + return { width: 96, height: 96, release }; | |
| 64 | + }); | |
| 65 | + await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); | |
| 66 | + expect(release).toHaveBeenCalledOnce(); | |
| 67 | + expect(native.write).not.toHaveBeenCalled(); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it("rejects an image the native decoder did not downsize", async () => { | |
| 71 | + const release = vi.fn(); | |
| 72 | + native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); | |
| 73 | + await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( | |
| 74 | + "not resized", | |
| 75 | + ); | |
| 76 | + expect(native.write).not.toHaveBeenCalled(); | |
| 77 | + expect(release).toHaveBeenCalledOnce(); | |
| 78 | + }); | |
| 79 | +}); | |
| @@ -0,0 +1,112 @@ | ||
| 1 | +import { | |
| 2 | + createProjectFaviconCache, | |
| 3 | + createProjectFaviconImageLoader, | |
| 4 | + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, | |
| 5 | + PROJECT_FAVICON_THUMBNAIL_SIZE, | |
| 6 | + type ProjectFaviconEntry, | |
| 7 | +} from "@t3tools/client-runtime/project-favicon-cache"; | |
| 8 | +import * as Effect from "effect/Effect"; | |
| 9 | + | |
| 10 | +import * as MobileDatabase from "../persistence/mobile-database"; | |
| 11 | + | |
| 12 | +const CACHE_KIND = "project-favicon"; | |
| 13 | +const CACHE_SCHEMA_VERSION = 1; | |
| 14 | + | |
| 15 | +let database: MobileDatabase.MobileDatabase["Service"] | undefined; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * The cache is a module singleton because the favicon atom family holds it outside | |
| 19 | + * any Effect runtime. Its rows live in `client_cache`, so the environment cache store | |
| 20 | + * hands over the database it already owns instead of the cache re-entering the runtime. | |
| 21 | + */ | |
| 22 | +export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { | |
| 23 | + database = service; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const runDatabase = <A, E>( | |
| 27 | + use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect<A, E>, | |
| 28 | +) => | |
| 29 | + database | |
| 30 | + ? Effect.runPromise(use(database)) | |
| 31 | + : Promise.reject(new Error("Project icon storage is not attached.")); | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * Rasterizes a bitmap that is too large to inline. The native decoder writes the | |
| 35 | + * downsized frame to expo-image's disk cache, which is the only encode path it | |
| 36 | + * exposes; the temporary entry is removed once its bytes are read. | |
| 37 | + */ | |
| 38 | +export async function downscaleProjectFavicon( | |
| 39 | + image: { readonly url: string }, | |
| 40 | + signal: AbortSignal, | |
| 41 | +) { | |
| 42 | + const [{ Image }, { File }] = await Promise.all([ | |
| 43 | + import("expo-image"), | |
| 44 | + import("expo-file-system"), | |
| 45 | + ]); | |
| 46 | + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { | |
| 47 | + signal.throwIfAborted(); | |
| 48 | + const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); | |
| 49 | + const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; | |
| 50 | + try { | |
| 51 | + signal.throwIfAborted(); | |
| 52 | + if (decoded.width > size || decoded.height > size) { | |
| 53 | + throw new Error("Project icon was not resized."); | |
| 54 | + } | |
| 55 | + await Image.writeToCacheAsync(decoded, cacheKey); | |
| 56 | + const path = await Image.getCachePathAsync(cacheKey); | |
| 57 | + if (!path) throw new Error("Project icon thumbnail was not written."); | |
| 58 | + const file = new File(path.startsWith("file:") ? path : `file://${path}`); | |
| 59 | + try { | |
| 60 | + if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; | |
| 61 | + const base64 = await file.base64(); | |
| 62 | + // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. | |
| 63 | + const mimeType = base64.startsWith("/9j/") | |
| 64 | + ? "image/jpeg" | |
| 65 | + : base64.startsWith("iVBORw0KGgo") | |
| 66 | + ? "image/png" | |
| 67 | + : null; | |
| 68 | + if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); | |
| 69 | + const dataUrl = `data:${mimeType};base64,${base64}`; | |
| 70 | + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; | |
| 71 | + } finally { | |
| 72 | + file.delete(); | |
| 73 | + } | |
| 74 | + } finally { | |
| 75 | + decoded.release(); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + throw new Error("Project icon thumbnail exceeds the cache limit."); | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ | |
| 82 | +export const projectFaviconCache = createProjectFaviconCache({ | |
| 83 | + storage: { | |
| 84 | + list: () => | |
| 85 | + runDatabase((database) => | |
| 86 | + database.listCache(CACHE_KIND).pipe( | |
| 87 | + Effect.map((payloads) => | |
| 88 | + payloads.flatMap((payload): Array<unknown> => { | |
| 89 | + try { | |
| 90 | + return [JSON.parse(payload)]; | |
| 91 | + } catch { | |
| 92 | + return []; | |
| 93 | + } | |
| 94 | + }), | |
| 95 | + ), | |
| 96 | + ), | |
| 97 | + ), | |
| 98 | + put: (key, entry: ProjectFaviconEntry) => | |
| 99 | + runDatabase((database) => | |
| 100 | + database.saveCache( | |
| 101 | + entry.environmentId, | |
| 102 | + CACHE_KIND, | |
| 103 | + key, | |
| 104 | + CACHE_SCHEMA_VERSION, | |
| 105 | + JSON.stringify(entry), | |
| 106 | + ), | |
| 107 | + ), | |
| 108 | + remove: (key, entry) => | |
| 109 | + runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), | |
| 110 | + }, | |
| 111 | + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), | |
| 112 | +}); | |
| @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [ · 15 unmodified lines | ||
| 16 | 16 | "connection-vcs-refs", |
| 17 | 17 | ] as const; |
| 18 | 18 | |
| 19 | −export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); | |
| 19 | +export const ClientCacheKind = Schema.Literals([ | |
| 20 | + "shell", | |
| 21 | + "thread", | |
| 22 | + "server-config", | |
| 23 | + "vcs-refs", | |
| 24 | + "project-favicon", | |
| 25 | +]); | |
| 20 | 26 | export type ClientCacheKind = typeof ClientCacheKind.Type; |
| 21 | 27 | |
| 22 | 28 | export interface ClientCacheSummaryRow { |
| @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ · 21 unmodified lines | ||
| 44 | 50 | "open", |
| 45 | 51 | "migrate", |
| 46 | 52 | "load-cache", |
| 53 | + "list-cache", | |
| 47 | 54 | "save-cache", |
| 48 | 55 | "remove-cache", |
| 49 | 56 | "clear-cache-kind", |
| @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< · 142 unmodified lines | ||
| 192 | 199 | kind: ClientCacheKind, |
| 193 | 200 | cacheKey: string, |
| 194 | 201 | ) => Effect.Effect<Option.Option<string>, MobileDatabaseError>; |
| 202 | + readonly listCache: ( | |
| 203 | + kind: ClientCacheKind, | |
| 204 | + ) => Effect.Effect<ReadonlyArray<string>, MobileDatabaseError>; | |
| 195 | 205 | readonly saveCache: ( |
| 196 | 206 | environmentId: EnvironmentId, |
| 197 | 207 | kind: ClientCacheKind, |
| @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { · 94 unmodified lines | ||
| 292 | 302 | catch: databaseError("load-cache"), |
| 293 | 303 | }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), |
| 294 | 304 | ), |
| 305 | + listCache: Effect.fn("MobileDatabase.listCache")((kind) => | |
| 306 | + Effect.tryPromise({ | |
| 307 | + try: () => | |
| 308 | + database.getAllAsync<{ readonly payload: string }>( | |
| 309 | + "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at", | |
| 310 | + kind, | |
| 311 | + ), | |
| 312 | + catch: databaseError("list-cache"), | |
| 313 | + }).pipe(Effect.map((rows) => rows.map((row) => row.payload))), | |
| 314 | + ), | |
| 295 | 315 | saveCache: Effect.fn("MobileDatabase.saveCache")( |
| 296 | 316 | (environmentId, kind, cacheKey, schemaVersion, payload) => |
| 297 | 317 | Effect.tryPromise({ |
| @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] · 107 unmodified lines | ||
| 405 | 425 | const fail = Effect.fail(error); |
| 406 | 426 | return MobileDatabase.of({ |
| 407 | 427 | loadCache: () => fail, |
| 428 | + listCache: () => fail, | |
| 408 | 429 | saveCache: () => fail, |
| 409 | 430 | removeCache: () => fail, |
| 410 | 431 | clearCacheKind: () => fail, |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | import { |
| 7 | 7 | assetUrlStateFromResult, |
| 8 | 8 | createAssetEnvironmentAtoms, |
| 9 | + createProjectFaviconUrlAtomFamily, | |
| 9 | 10 | EMPTY_ASSET_URL_ATOM, |
| 10 | 11 | } from "@t3tools/client-runtime/state/assets"; |
| 11 | 12 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| @@ -15,14 +16,21 @@ import { useCallback } from "react"; · 3 unmodified lines | ||
| 15 | 16 | |
| 16 | 17 | import { environmentCatalog } from "../connection/catalog"; |
| 17 | 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | +import { projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 18 | 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; |
| 19 | −import { usePreparedConnection } from "./session"; | |
| 21 | +import { environmentSession, usePreparedConnection } from "./session"; | |
| 20 | 22 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 21 | 23 | |
| 22 | 24 | export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; |
| 23 | 25 | |
| 24 | 26 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 25 | 27 | |
| 28 | +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ | |
| 29 | + imageCache: projectFaviconCache, | |
| 30 | + createUrl: assetEnvironment.createUrl, | |
| 31 | + preparedConnection: environmentSession.preparedConnectionValueAtom, | |
| 32 | +}); | |
| 33 | + | |
| 26 | 34 | const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( |
| 27 | 35 | Atom.withLabel("mobile-asset-connection-state:empty"), |
| 28 | 36 | ); |
| @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | ||
| 3 | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | |
| 5 | 5 | import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; |
| 6 | +import { projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 6 | 7 | import * as Runtime from "../lib/runtime"; |
| 7 | 8 | |
| 8 | 9 | export interface EnvironmentClientCacheSummary { |
| @@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime · 62 unmodified lines | ||
| 71 | 72 | |
| 72 | 73 | export const clearClientCacheAtom = clientCacheRuntime |
| 73 | 74 | .fn((scope: ClientCacheClearScope, get) => |
| 74 | − MobileDatabase.pipe( | |
| 75 | + Effect.promise(() => | |
| 76 | + scope.type === "all" | |
| 77 | + ? projectFaviconCache.clearAll() | |
| 78 | + : projectFaviconCache.clearEnvironment(scope.environmentId), | |
| 79 | + ).pipe( | |
| 80 | + Effect.andThen(MobileDatabase), | |
| 75 | 81 | Effect.flatMap((database) => |
| 76 | 82 | scope.type === "all" |
| 77 | 83 | ? database.clearAllCaches |
| @@ -0,0 +1,85 @@ | ||
| 1 | +import { | |
| 2 | + createProjectFaviconCache, | |
| 3 | + createProjectFaviconImageLoader, | |
| 4 | + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, | |
| 5 | + PROJECT_FAVICON_THUMBNAIL_SIZE, | |
| 6 | +} from "@t3tools/client-runtime/project-favicon-cache"; | |
| 7 | + | |
| 8 | +const DATABASE_NAME = "t3code:project-favicons"; | |
| 9 | +const DATABASE_VERSION = 2; | |
| 10 | +const STORE_NAME = "images"; | |
| 11 | +let database: Promise<IDBDatabase> | undefined; | |
| 12 | + | |
| 13 | +function openDatabase() { | |
| 14 | + return (database ??= new Promise<IDBDatabase>((resolve, reject) => { | |
| 15 | + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); | |
| 16 | + request.addEventListener("upgradeneeded", () => { | |
| 17 | + for (const name of request.result.objectStoreNames) { | |
| 18 | + if (name !== STORE_NAME) request.result.deleteObjectStore(name); | |
| 19 | + } | |
| 20 | + if (!request.result.objectStoreNames.contains(STORE_NAME)) { | |
| 21 | + request.result.createObjectStore(STORE_NAME); | |
| 22 | + } | |
| 23 | + }); | |
| 24 | + request.addEventListener("success", () => resolve(request.result)); | |
| 25 | + request.addEventListener("error", () => reject(request.error)); | |
| 26 | + request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); | |
| 27 | + })); | |
| 28 | +} | |
| 29 | + | |
| 30 | +function completed(transaction: IDBTransaction) { | |
| 31 | + return new Promise<void>((resolve, reject) => { | |
| 32 | + transaction.addEventListener("complete", () => resolve()); | |
| 33 | + transaction.addEventListener("abort", () => reject(transaction.error)); | |
| 34 | + transaction.addEventListener("error", () => reject(transaction.error)); | |
| 35 | + }); | |
| 36 | +} | |
| 37 | + | |
| 38 | +async function withStore<A>( | |
| 39 | + mode: IDBTransactionMode, | |
| 40 | + use: (store: IDBObjectStore) => IDBRequest<A> | void, | |
| 41 | +) { | |
| 42 | + const transaction = (await openDatabase()).transaction(STORE_NAME, mode); | |
| 43 | + const request = use(transaction.objectStore(STORE_NAME)); | |
| 44 | + await completed(transaction); | |
| 45 | + return request?.result; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Rasterizes a bitmap that is too large to inline, retrying at half size. */ | |
| 49 | +export async function downscaleProjectFavicon( | |
| 50 | + image: { readonly mimeType: string; readonly bytes: Uint8Array<ArrayBuffer> }, | |
| 51 | + signal: AbortSignal, | |
| 52 | +) { | |
| 53 | + const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); | |
| 54 | + try { | |
| 55 | + signal.throwIfAborted(); | |
| 56 | + const canvas = document.createElement("canvas"); | |
| 57 | + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { | |
| 58 | + const scale = Math.min(1, size / bitmap.width, size / bitmap.height); | |
| 59 | + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); | |
| 60 | + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); | |
| 61 | + const context = canvas.getContext("2d"); | |
| 62 | + if (!context) throw new Error("Canvas is unavailable."); | |
| 63 | + context.clearRect(0, 0, canvas.width, canvas.height); | |
| 64 | + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); | |
| 65 | + const dataUrl = canvas.toDataURL("image/webp", 0.85); | |
| 66 | + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; | |
| 67 | + } | |
| 68 | + throw new Error("Project icon thumbnail exceeds the cache limit."); | |
| 69 | + } finally { | |
| 70 | + bitmap.close(); | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +export const projectFaviconCache = createProjectFaviconCache({ | |
| 75 | + storage: { | |
| 76 | + list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], | |
| 77 | + put: async (key, entry) => { | |
| 78 | + await withStore("readwrite", (store) => store.put(entry, key)); | |
| 79 | + }, | |
| 80 | + remove: async (key) => { | |
| 81 | + await withStore("readwrite", (store) => store.delete(key)); | |
| 82 | + }, | |
| 83 | + }, | |
| 84 | + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), | |
| 85 | +}); | |
| @@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon" · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | const testState = vi.hoisted(() => ({ |
| 7 | 7 | faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", |
| 8 | − lastResource: null as unknown, | |
| 8 | + lastTarget: null as unknown, | |
| 9 | 9 | })); |
| 10 | 10 | |
| 11 | 11 | const hooks = vi.hoisted(() => { |
| @@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({ · 45 unmodified lines | ||
| 57 | 57 | DynamicIcon: "dynamic-icon", |
| 58 | 58 | iconNames: ["alarm-clock", "folder-code"], |
| 59 | 59 | })); |
| 60 | −vi.mock("../assets/assetUrls", () => ({ | |
| 61 | − useAssetUrlState: (_environmentId: unknown, resource: unknown) => { | |
| 62 | − testState.lastResource = resource; | |
| 63 | − return { _tag: "Success", url: testState.faviconUrl }; | |
| 60 | +vi.mock("@effect/atom-react", () => ({ | |
| 61 | + useAtomValue: () => testState.faviconUrl, | |
| 62 | +})); | |
| 63 | +vi.mock("../state/assets", () => ({ | |
| 64 | + projectFaviconUrlAtom: (input: unknown) => { | |
| 65 | + testState.lastTarget = input; | |
| 64 | 66 | }, |
| 65 | 67 | })); |
| 66 | 68 | |
| @@ -212,10 +214,10 @@ describe("ProjectFavicon", () => { · 145 unmodified lines | ||
| 212 | 214 | faviconPath: "brand/icon.svg", |
| 213 | 215 | }); |
| 214 | 216 | |
| 215 | − expect(testState.lastResource).toEqual({ | |
| 216 | − _tag: "project-favicon", | |
| 217 | + expect(testState.lastTarget).toMatchObject({ | |
| 218 | + environmentId: "environment-test", | |
| 217 | 219 | cwd: "/workspace-test", |
| 218 | − path: "brand/icon.svg", | |
| 220 | + faviconPath: "brand/icon.svg", | |
| 219 | 221 | }); |
| 220 | 222 | }); |
| 221 | 223 | }); |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; |
| 2 | 2 | import { |
| 3 | − getProjectFaviconCacheKey, | |
| 3 | + getProjectFaviconResourceKey, | |
| 4 | 4 | isProjectFaviconFallbackUrl, |
| 5 | 5 | } from "@t3tools/shared/projectFavicon"; |
| 6 | 6 | import { |
| @@ -30,12 +30,12 @@ import { · 23 unmodified lines | ||
| 30 | 30 | import type { IconName } from "lucide-react/dynamic"; |
| 31 | 31 | import type { ComponentType } from "react"; |
| 32 | 32 | import { lazy, Suspense, useState } from "react"; |
| 33 | −import { useAssetUrlState } from "../assets/assetUrls"; | |
| 33 | +import { useAtomValue } from "@effect/atom-react"; | |
| 34 | +import { projectFaviconUrlAtom } from "../state/assets"; | |
| 34 | 35 | import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; |
| 35 | 36 | import { projectIconColorClassName } from "../projectIconColors"; |
| 36 | 37 | import { cn } from "~/lib/utils"; |
| 37 | 38 | |
| 38 | −const loadedProjectFaviconSrcs = new Map<string, string>(); | |
| 39 | 39 | const DynamicIcon = lazy(() => |
| 40 | 40 | import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), |
| 41 | 41 | ); |
| @@ -103,8 +103,7 @@ export function ProjectFavicon(input: { · 61 unmodified lines | ||
| 103 | 103 | className?: string | undefined; |
| 104 | 104 | fallbackIcon?: ComponentType<{ className?: string }>; |
| 105 | 105 | }) { |
| 106 | − const state = useProjectFaviconAsset(input); | |
| 107 | − const src = state._tag === "Success" ? state.url : null; | |
| 106 | + const src = useAtomValue(projectFaviconUrlAtom(input)); | |
| 108 | 107 | if (input.projectIcon?.kind === "emoji") { |
| 109 | 108 | return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />; |
| 110 | 109 | } |
| @@ -150,12 +149,11 @@ export function ProjectFavicon(input: { · 39 unmodified lines | ||
| 150 | 149 | ); |
| 151 | 150 | } |
| 152 | 151 | |
| 153 | − const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); | |
| 152 | + const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath); | |
| 154 | 153 | |
| 155 | 154 | return ( |
| 156 | 155 | <ProjectFaviconImage |
| 157 | 156 | key={cacheKey} |
| 158 | − cacheKey={cacheKey} | |
| 159 | 157 | src={src} |
| 160 | 158 | className={input.className} |
| 161 | 159 | fallbackIcon={FallbackIcon} |
| @@ -165,18 +163,6 @@ export function ProjectFavicon(input: { · 3 unmodified lines | ||
| 165 | 163 | ); |
| 166 | 164 | } |
| 167 | 165 | |
| 168 | −export function useProjectFaviconAsset(input: { | |
| 169 | − readonly environmentId: EnvironmentId; | |
| 170 | − readonly cwd: string; | |
| 171 | − readonly faviconPath?: string | null | undefined; | |
| 172 | −}) { | |
| 173 | − return useAssetUrlState(input.environmentId, { | |
| 174 | − _tag: "project-favicon", | |
| 175 | − cwd: input.cwd, | |
| 176 | − ...(input.faviconPath ? { path: input.faviconPath } : {}), | |
| 177 | − }); | |
| 178 | −} | |
| 179 | − | |
| 180 | 166 | function ProjectFaviconFallback({ |
| 181 | 167 | className, |
| 182 | 168 | colorClassName, |
| @@ -207,28 +193,23 @@ function ProjectFaviconFallback({ · 24 unmodified lines | ||
| 207 | 193 | } |
| 208 | 194 | |
| 209 | 195 | function ProjectFaviconImage({ |
| 210 | − cacheKey, | |
| 211 | 196 | src, |
| 212 | 197 | className, |
| 213 | 198 | fallbackIcon: FallbackIcon, |
| 214 | 199 | fallbackEmoji, |
| 215 | 200 | fallbackColorClassName, |
| 216 | 201 | }: { |
| 217 | − readonly cacheKey: string; | |
| 218 | 202 | readonly src: string; |
| 219 | 203 | readonly className?: string | undefined; |
| 220 | 204 | readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; |
| 221 | 205 | readonly fallbackEmoji?: string | undefined; |
| 222 | 206 | readonly fallbackColorClassName?: string | undefined; |
| 223 | 207 | }) { |
| 224 | − const [displayedSrc, setDisplayedSrc] = useState<string | null>( | |
| 225 | − () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, | |
| 208 | + const [displayedSrc, setDisplayedSrc] = useState<string | null>(() => | |
| 209 | + src.startsWith("data:image/") ? src : null, | |
| 226 | 210 | ); |
| 227 | 211 | const isLoading = displayedSrc !== src; |
| 228 | 212 | const handleLoadError = (failedSrc: string) => { |
| 229 | − if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { | |
| 230 | − loadedProjectFaviconSrcs.delete(cacheKey); | |
| 231 | − } | |
| 232 | 213 | setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); |
| 233 | 214 | }; |
| 234 | 215 | |
| @@ -256,7 +237,6 @@ function ProjectFaviconImage({ · 21 unmodified lines | ||
| 256 | 237 | alt="" |
| 257 | 238 | className="hidden" |
| 258 | 239 | onLoad={() => { |
| 259 | − loadedProjectFaviconSrcs.set(cacheKey, src); | |
| 260 | 240 | setDisplayedSrc(src); |
| 261 | 241 | }} |
| 262 | 242 | onError={() => handleLoadError(src)} |
| @@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({ · 55 unmodified lines | ||
| 56 | 56 | useThreadRecentHistory: () => EMPTY_HISTORY, |
| 57 | 57 | })); |
| 58 | 58 | |
| 59 | −vi.mock("~/state/session", () => ({ | |
| 59 | +vi.mock("~/state/session", async (importOriginal) => ({ | |
| 60 | + ...(await importOriginal<typeof import("~/state/session")>()), | |
| 60 | 61 | readPreparedConnection: mocks.readPreparedConnection, |
| 61 | 62 | })); |
| 62 | 63 | |
| @@ -33,6 +33,7 @@ import * as Option from "effect/Option"; · 32 unmodified lines | ||
| 33 | 33 | import * as Ref from "effect/Ref"; |
| 34 | 34 | import * as Schema from "effect/Schema"; |
| 35 | 35 | import * as Semaphore from "effect/Semaphore"; |
| 36 | +import { projectFaviconCache } from "../assets/projectFaviconCache"; | |
| 36 | 37 | |
| 37 | 38 | const DATABASE_NAME = "t3code:connection-runtime"; |
| 38 | 39 | const DATABASE_VERSION = 4; |
| @@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext( · 422 unmodified lines | ||
| 461 | 462 | const cacheStore = EnvironmentCacheStore.of({ |
| 462 | 463 | loadShell: (environmentId) => |
| 463 | 464 | readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( |
| 465 | + Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), | |
| 464 | 466 | Effect.flatMap((raw) => { |
| 465 | 467 | if (typeof raw !== "string") { |
| 466 | 468 | return Effect.succeed(Option.none()); |
| @@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext( · 171 unmodified lines | ||
| 638 | 640 | clear: (environmentId) => |
| 639 | 641 | Effect.all( |
| 640 | 642 | [ |
| 643 | + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)), | |
| 641 | 644 | removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), |
| 642 | 645 | removeDatabaseValuesInRange( |
| 643 | 646 | database, |
| @@ -1,5 +1,16 @@ | ||
| 1 | −import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; | |
| 1 | +import { | |
| 2 | + createAssetEnvironmentAtoms, | |
| 3 | + createProjectFaviconUrlAtomFamily, | |
| 4 | +} from "@t3tools/client-runtime/state/assets"; | |
| 2 | 5 | |
| 3 | 6 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 7 | +import { projectFaviconCache } from "../assets/projectFaviconCache"; | |
| 8 | +import { environmentSession } from "./session"; | |
| 4 | 9 | |
| 5 | 10 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 11 | + | |
| 12 | +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ | |
| 13 | + imageCache: projectFaviconCache, | |
| 14 | + createUrl: assetEnvironment.createUrl, | |
| 15 | + preparedConnection: environmentSession.preparedConnectionValueAtom, | |
| 16 | +}); | |
| @@ -3,6 +3,10 @@ · 2 unmodified lines | ||
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | + "./project-favicon-cache": { | |
| 7 | + "types": "./src/projectFaviconCache.ts", | |
| 8 | + "default": "./src/projectFaviconCache.ts" | |
| 9 | + }, | |
| 6 | 10 | "./connection": { |
| 7 | 11 | "types": "./src/connection/index.ts", |
| 8 | 12 | "default": "./src/connection/index.ts" |
| @@ -0,0 +1,323 @@ | ||
| 1 | +import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + createProjectFaviconCache, | |
| 6 | + createProjectFaviconImageLoader, | |
| 7 | + PROJECT_FAVICON_CACHE_MAX_BYTES, | |
| 8 | + PROJECT_FAVICON_CACHE_MAX_ENTRIES, | |
| 9 | + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, | |
| 10 | + PROJECT_FAVICON_MAX_SOURCE_BYTES, | |
| 11 | + type ProjectFaviconEntry, | |
| 12 | + type ProjectFaviconStorage, | |
| 13 | +} from "./projectFaviconCache.ts"; | |
| 14 | + | |
| 15 | +const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; | |
| 16 | +const url = "https://remote.test/api/assets/token-a/vabc-icon.svg"; | |
| 17 | +const image = "data:image/png;base64,aWNvbg=="; | |
| 18 | +const replacement = "data:image/png;base64,bmV3"; | |
| 19 | +const signal = () => new AbortController().signal; | |
| 20 | + | |
| 21 | +function deferred<A>() { | |
| 22 | + let resolve!: (value: A) => void; | |
| 23 | + const promise = new Promise<A>((done) => { | |
| 24 | + resolve = done; | |
| 25 | + }); | |
| 26 | + return { promise, resolve }; | |
| 27 | +} | |
| 28 | + | |
| 29 | +function fixture() { | |
| 30 | + const records = new Map<string, ProjectFaviconEntry>(); | |
| 31 | + const load = vi.fn(async () => image); | |
| 32 | + const storage: ProjectFaviconStorage = { | |
| 33 | + list: async () => [...records.values()], | |
| 34 | + put: async (key, entry) => { | |
| 35 | + records.set(key, entry); | |
| 36 | + }, | |
| 37 | + remove: async (key) => { | |
| 38 | + records.delete(key); | |
| 39 | + }, | |
| 40 | + }; | |
| 41 | + return { | |
| 42 | + storage, | |
| 43 | + load, | |
| 44 | + records, | |
| 45 | + cache: createProjectFaviconCache({ storage, load }), | |
| 46 | + }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +describe("persistent project favicon cache", () => { | |
| 50 | + it("restores image bytes in a fresh client before any remote response", async () => { | |
| 51 | + const { cache, storage, load } = fixture(); | |
| 52 | + expect(await cache.resolve(target, url, signal())).toBe(image); | |
| 53 | + await cache.flush(); | |
| 54 | + const reloaded = createProjectFaviconCache({ storage, load }); | |
| 55 | + await reloaded.hydrate(); | |
| 56 | + expect(reloaded.peek(target)).toBe(image); | |
| 57 | + expect(await reloaded.resolve(target, null, signal())).toBe(image); | |
| 58 | + expect(load).toHaveBeenCalledTimes(1); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it("reuses the image when signed URLs or connection origins change", async () => { | |
| 62 | + const { cache, load } = fixture(); | |
| 63 | + await cache.resolve(target, url, signal()); | |
| 64 | + expect( | |
| 65 | + await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()), | |
| 66 | + ).toBe(image); | |
| 67 | + expect(load).toHaveBeenCalledTimes(1); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it("keeps the old image during refresh and failures, then persists its replacement", async () => { | |
| 71 | + const { cache, load, storage } = fixture(); | |
| 72 | + await cache.resolve(target, url, signal()); | |
| 73 | + const next = deferred<string>(); | |
| 74 | + load.mockImplementationOnce(() => next.promise); | |
| 75 | + const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal()); | |
| 76 | + expect(cache.peek(target)).toBe(image); | |
| 77 | + next.resolve(replacement); | |
| 78 | + expect(await refreshing).toBe(replacement); | |
| 79 | + load.mockRejectedValueOnce(new Error("offline")); | |
| 80 | + expect(await cache.resolve(target, url, signal())).toBe(replacement); | |
| 81 | + await cache.flush(); | |
| 82 | + expect(await createProjectFaviconCache({ storage, load }).resolve(target, null, signal())).toBe( | |
| 83 | + replacement, | |
| 84 | + ); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it("persists confirmed removal and ignores an aborted older download", async () => { | |
| 88 | + const { cache, load, storage } = fixture(); | |
| 89 | + await cache.resolve(target, url, signal()); | |
| 90 | + const next = deferred<string>(); | |
| 91 | + const started = deferred<void>(); | |
| 92 | + load.mockImplementationOnce(() => { | |
| 93 | + started.resolve(); | |
| 94 | + return next.promise; | |
| 95 | + }); | |
| 96 | + const controller = new AbortController(); | |
| 97 | + const pending = cache.resolve(target, url.replace("vabc", "vdef"), controller.signal); | |
| 98 | + await started.promise; | |
| 99 | + controller.abort(); | |
| 100 | + expect( | |
| 101 | + await cache.resolve( | |
| 102 | + target, | |
| 103 | + "https://remote.test/api/assets/token/project-favicon-missing", | |
| 104 | + signal(), | |
| 105 | + ), | |
| 106 | + ).toBeNull(); | |
| 107 | + next.resolve(replacement); | |
| 108 | + await pending; | |
| 109 | + await cache.flush(); | |
| 110 | + expect( | |
| 111 | + await createProjectFaviconCache({ storage, load }).resolve(target, null, signal()), | |
| 112 | + ).toBeNull(); | |
| 113 | + }); | |
| 114 | + | |
| 115 | + it("isolates environments, workspaces, and icon selections", async () => { | |
| 116 | + const { cache } = fixture(); | |
| 117 | + await cache.resolve(target, url, signal()); | |
| 118 | + expect(cache.peek({ ...target, faviconPath: null })).toBe(image); | |
| 119 | + expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull(); | |
| 120 | + expect(cache.peek({ ...target, cwd: "/other" })).toBeNull(); | |
| 121 | + expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull(); | |
| 122 | + }); | |
| 123 | + | |
| 124 | + it.each([ | |
| 125 | + { | |
| 126 | + scope: "one environment", | |
| 127 | + clear: (cache: ReturnType<typeof fixture>["cache"]) => | |
| 128 | + cache.clearEnvironment(target.environmentId), | |
| 129 | + remaining: 1, | |
| 130 | + }, | |
| 131 | + { | |
| 132 | + scope: "every environment", | |
| 133 | + clear: (cache: ReturnType<typeof fixture>["cache"]) => cache.clearAll(), | |
| 134 | + remaining: 0, | |
| 135 | + }, | |
| 136 | + ])( | |
| 137 | + "does not restore images for $scope removed during a download", | |
| 138 | + async ({ clear, remaining }) => { | |
| 139 | + const { cache, load, records } = fixture(); | |
| 140 | + const other = { ...target, environmentId: EnvironmentId.make("other") }; | |
| 141 | + await cache.resolve(other, url, signal()); | |
| 142 | + const next = deferred<string>(); | |
| 143 | + const started = deferred<void>(); | |
| 144 | + load.mockImplementationOnce(() => { | |
| 145 | + started.resolve(); | |
| 146 | + return next.promise; | |
| 147 | + }); | |
| 148 | + const pending = cache.resolve(target, url, signal()); | |
| 149 | + await started.promise; | |
| 150 | + await clear(cache); | |
| 151 | + next.resolve(image); | |
| 152 | + await pending; | |
| 153 | + await cache.flush(); | |
| 154 | + expect(cache.peek(target)).toBeNull(); | |
| 155 | + expect(records.size).toBe(remaining); | |
| 156 | + }, | |
| 157 | + ); | |
| 158 | + | |
| 159 | + it("discards a download that starts while the environment is being cleared", async () => { | |
| 160 | + const records = new Map<string, ProjectFaviconEntry>(); | |
| 161 | + const removal = deferred<void>(); | |
| 162 | + const load = vi.fn(async () => image); | |
| 163 | + const storage: ProjectFaviconStorage = { | |
| 164 | + list: async () => [...records.values()], | |
| 165 | + put: async (key, entry) => { | |
| 166 | + records.set(key, entry); | |
| 167 | + }, | |
| 168 | + remove: async (key) => { | |
| 169 | + await removal.promise; | |
| 170 | + records.delete(key); | |
| 171 | + }, | |
| 172 | + }; | |
| 173 | + const cache = createProjectFaviconCache({ storage, load }); | |
| 174 | + await cache.resolve(target, url, signal()); | |
| 175 | + await cache.flush(); | |
| 176 | + const clearing = cache.clearEnvironment(target.environmentId); | |
| 177 | + await Promise.resolve(); | |
| 178 | + const late = cache.resolve(target, url.replace("vabc", "vdef"), signal()); | |
| 179 | + removal.resolve(); | |
| 180 | + await clearing; | |
| 181 | + expect(records.size).toBe(0); | |
| 182 | + expect(cache.peek(target)).toBeNull(); | |
| 183 | + expect(load).toHaveBeenCalledTimes(1); | |
| 184 | + expect(await late).toBe(image); | |
| 185 | + expect(load).toHaveBeenCalledTimes(2); | |
| 186 | + }); | |
| 187 | + | |
| 188 | + it("bounds individual images, total bytes, and entry count in storage", async () => { | |
| 189 | + const { cache, load, records } = fixture(); | |
| 190 | + load.mockResolvedValueOnce( | |
| 191 | + `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, | |
| 192 | + ); | |
| 193 | + expect(await cache.resolve(target, url, signal())).toBe(url); | |
| 194 | + expect(cache.peek(target)).toBeNull(); | |
| 195 | + const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`; | |
| 196 | + load.mockResolvedValue(large); | |
| 197 | + for (let i = 0; i < 40; i++) { | |
| 198 | + await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal()); | |
| 199 | + } | |
| 200 | + await cache.flush(); | |
| 201 | + expect( | |
| 202 | + [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0), | |
| 203 | + ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES); | |
| 204 | + expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull(); | |
| 205 | + expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large); | |
| 206 | + load.mockResolvedValue(image); | |
| 207 | + for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) { | |
| 208 | + await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal()); | |
| 209 | + } | |
| 210 | + await cache.flush(); | |
| 211 | + expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES); | |
| 212 | + expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull(); | |
| 213 | + }); | |
| 214 | + | |
| 215 | + it("skips corrupt records and tolerates unavailable storage", async () => { | |
| 216 | + const corrupt = createProjectFaviconCache({ | |
| 217 | + storage: { | |
| 218 | + list: async () => [ | |
| 219 | + { ...target, faviconPath: null, revision: "r", dataUrl: image }, | |
| 220 | + { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" }, | |
| 221 | + "garbage", | |
| 222 | + ], | |
| 223 | + put: async () => {}, | |
| 224 | + remove: async () => {}, | |
| 225 | + }, | |
| 226 | + load: async () => replacement, | |
| 227 | + }); | |
| 228 | + await corrupt.hydrate(); | |
| 229 | + expect(corrupt.peek(target)).toBe(image); | |
| 230 | + expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull(); | |
| 231 | + | |
| 232 | + const unavailable = createProjectFaviconCache({ | |
| 233 | + storage: { | |
| 234 | + list: async () => { | |
| 235 | + throw new Error("storage unavailable"); | |
| 236 | + }, | |
| 237 | + put: async () => { | |
| 238 | + throw new Error("quota exceeded"); | |
| 239 | + }, | |
| 240 | + remove: async () => { | |
| 241 | + throw new Error("quota exceeded"); | |
| 242 | + }, | |
| 243 | + }, | |
| 244 | + load: async () => image, | |
| 245 | + }); | |
| 246 | + expect(await unavailable.resolve(target, url, signal())).toBe(image); | |
| 247 | + await unavailable.flush(); | |
| 248 | + expect(unavailable.peek(target)).toBe(image); | |
| 249 | + }); | |
| 250 | +}); | |
| 251 | + | |
| 252 | +describe("project favicon image loader", () => { | |
| 253 | + const svg = | |
| 254 | + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16"/></svg>'; | |
| 255 | + const svgBase64 = btoa(svg); | |
| 256 | + | |
| 257 | + function loader(response: Response, downscale = vi.fn(async () => replacement)) { | |
| 258 | + return { | |
| 259 | + downscale, | |
| 260 | + load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }), | |
| 261 | + }; | |
| 262 | + } | |
| 263 | + | |
| 264 | + it("inlines small icons exactly as served without rasterizing", async () => { | |
| 265 | + const { load, downscale } = loader( | |
| 266 | + new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8" } }), | |
| 267 | + ); | |
| 268 | + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); | |
| 269 | + expect(downscale).not.toHaveBeenCalled(); | |
| 270 | + }); | |
| 271 | + | |
| 272 | + it("falls back to the file extension when the response has no image type", async () => { | |
| 273 | + const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } })); | |
| 274 | + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); | |
| 275 | + }); | |
| 276 | + | |
| 277 | + it("downscales large bitmaps and refuses large vector icons", async () => { | |
| 278 | + const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH); | |
| 279 | + const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } })); | |
| 280 | + expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe( | |
| 281 | + replacement, | |
| 282 | + ); | |
| 283 | + expect(bitmap.downscale).toHaveBeenCalledWith( | |
| 284 | + expect.objectContaining({ mimeType: "image/png", bytes }), | |
| 285 | + expect.any(AbortSignal), | |
| 286 | + ); | |
| 287 | + const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } })); | |
| 288 | + await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit"); | |
| 289 | + expect(vector.downscale).not.toHaveBeenCalled(); | |
| 290 | + }); | |
| 291 | + | |
| 292 | + it("stops reading a response that exceeds the source limit", async () => { | |
| 293 | + let pulled = 0; | |
| 294 | + const chunk = new Uint8Array(1024 * 1024); | |
| 295 | + const stream = new ReadableStream<Uint8Array>({ | |
| 296 | + pull(controller) { | |
| 297 | + pulled += 1; | |
| 298 | + controller.enqueue(chunk); | |
| 299 | + }, | |
| 300 | + }); | |
| 301 | + const { load, downscale } = loader( | |
| 302 | + new Response(stream, { headers: { "content-type": "image/png" } }), | |
| 303 | + ); | |
| 304 | + await expect(load(url, signal())).rejects.toThrow("too large"); | |
| 305 | + expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); | |
| 306 | + expect(downscale).not.toHaveBeenCalled(); | |
| 307 | + const declared = loader( | |
| 308 | + new Response("x", { | |
| 309 | + headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, | |
| 310 | + }), | |
| 311 | + ); | |
| 312 | + await expect(declared.load(url, signal())).rejects.toThrow("too large"); | |
| 313 | + }); | |
| 314 | + | |
| 315 | + it("rejects failed responses and non-image payloads", async () => { | |
| 316 | + const failed = loader(new Response("nope", { status: 404 })); | |
| 317 | + await expect(failed.load(url, signal())).rejects.toThrow("404"); | |
| 318 | + const html = loader(new Response("<html/>", { headers: { "content-type": "text/html" } })); | |
| 319 | + await expect( | |
| 320 | + html.load("https://remote.test/api/assets/t/v1-favicon", signal()), | |
| 321 | + ).rejects.toThrow("no image type"); | |
| 322 | + }); | |
| 323 | +}); | |
| @@ -0,0 +1,263 @@ | ||
| 1 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | +import { mediaMimeType } from "@t3tools/shared/filePreview"; | |
| 3 | +import { | |
| 4 | + getProjectFaviconCacheKey, | |
| 5 | + getProjectFaviconResourceKey, | |
| 6 | + isProjectFaviconFallbackUrl, | |
| 7 | +} from "@t3tools/shared/projectFavicon"; | |
| 8 | +import * as Encoding from "effect/Encoding"; | |
| 9 | +import * as Option from "effect/Option"; | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 11 | + | |
| 12 | +export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; | |
| 13 | +export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; | |
| 14 | +/** Larger sources are not worth decoding for an icon and are left to the remote URL. */ | |
| 15 | +export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024; | |
| 16 | +export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; | |
| 17 | +export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; | |
| 18 | + | |
| 19 | +export interface ProjectFaviconTarget { | |
| 20 | + readonly environmentId: EnvironmentId; | |
| 21 | + readonly cwd: string; | |
| 22 | + readonly faviconPath?: string | null | undefined; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const ImageDataUrl = Schema.String.check( | |
| 26 | + Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH), | |
| 27 | + Schema.isPattern( | |
| 28 | + /^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/, | |
| 29 | + ), | |
| 30 | +); | |
| 31 | +const Entry = Schema.Struct({ | |
| 32 | + environmentId: EnvironmentId, | |
| 33 | + cwd: Schema.String, | |
| 34 | + faviconPath: Schema.NullOr(Schema.String), | |
| 35 | + revision: Schema.String, | |
| 36 | + dataUrl: ImageDataUrl, | |
| 37 | +}); | |
| 38 | +export type ProjectFaviconEntry = typeof Entry.Type; | |
| 39 | +const decodeEntry = Schema.decodeUnknownOption(Entry); | |
| 40 | +const isImageDataUrl = Schema.is(ImageDataUrl); | |
| 41 | + | |
| 42 | +function keyFor(target: ProjectFaviconTarget) { | |
| 43 | + return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath); | |
| 44 | +} | |
| 45 | + | |
| 46 | +export interface ProjectFaviconStorage { | |
| 47 | + /** Every persisted record; entries that fail validation are ignored. */ | |
| 48 | + readonly list: () => Promise<ReadonlyArray<unknown>>; | |
| 49 | + readonly put: (key: string, entry: ProjectFaviconEntry) => Promise<void>; | |
| 50 | + readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise<void>; | |
| 51 | +} | |
| 52 | + | |
| 53 | +async function readBounded(response: Response, maxBytes: number) { | |
| 54 | + const declared = Number(response.headers.get("content-length")); | |
| 55 | + if (declared > maxBytes) throw new Error("Project icon is too large to decode."); | |
| 56 | + if (!response.body) { | |
| 57 | + const bytes = new Uint8Array<ArrayBuffer>(await response.arrayBuffer()); | |
| 58 | + if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); | |
| 59 | + return bytes; | |
| 60 | + } | |
| 61 | + const reader = response.body.getReader(); | |
| 62 | + const chunks: Array<Uint8Array> = []; | |
| 63 | + let total = 0; | |
| 64 | + try { | |
| 65 | + for (;;) { | |
| 66 | + const { done, value } = await reader.read(); | |
| 67 | + if (done) break; | |
| 68 | + total += value.byteLength; | |
| 69 | + if (total > maxBytes) throw new Error("Project icon is too large to decode."); | |
| 70 | + chunks.push(value); | |
| 71 | + } | |
| 72 | + } finally { | |
| 73 | + reader.cancel().catch(() => {}); | |
| 74 | + } | |
| 75 | + const bytes = new Uint8Array<ArrayBuffer>(new ArrayBuffer(total)); | |
| 76 | + let offset = 0; | |
| 77 | + for (const chunk of chunks) { | |
| 78 | + bytes.set(chunk, offset); | |
| 79 | + offset += chunk.byteLength; | |
| 80 | + } | |
| 81 | + return bytes; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** | |
| 85 | + * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs | |
| 86 | + * and small bitmaps are stored exactly as served. Larger bitmaps go through the | |
| 87 | + * platform downscaler; larger SVGs stay remote because rasterizing them without | |
| 88 | + * intrinsic dimensions is unreliable. | |
| 89 | + */ | |
| 90 | +export function createProjectFaviconImageLoader(input: { | |
| 91 | + readonly fetch?: typeof fetch; | |
| 92 | + readonly downscale: ( | |
| 93 | + image: { | |
| 94 | + readonly url: string; | |
| 95 | + readonly mimeType: string; | |
| 96 | + readonly bytes: Uint8Array<ArrayBuffer>; | |
| 97 | + }, | |
| 98 | + signal: AbortSignal, | |
| 99 | + ) => Promise<string>; | |
| 100 | +}) { | |
| 101 | + const fetchImpl = input.fetch ?? globalThis.fetch; | |
| 102 | + return async (url: string, signal: AbortSignal): Promise<string> => { | |
| 103 | + const response = await fetchImpl(url, { signal }); | |
| 104 | + if (!response.ok) throw new Error(`Project icon request failed with ${response.status}.`); | |
| 105 | + const contentType = response.headers | |
| 106 | + .get("content-type") | |
| 107 | + ?.split(";", 1)[0] | |
| 108 | + ?.trim() | |
| 109 | + .toLowerCase(); | |
| 110 | + const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url); | |
| 111 | + if (!mimeType) throw new Error("Project icon has no image type."); | |
| 112 | + const bytes = await readBounded(response, PROJECT_FAVICON_MAX_SOURCE_BYTES); | |
| 113 | + signal.throwIfAborted(); | |
| 114 | + const dataUrl = `data:${mimeType};base64,${Encoding.encodeBase64(bytes)}`; | |
| 115 | + if (isImageDataUrl(dataUrl)) return dataUrl; | |
| 116 | + if (mimeType === "image/svg+xml") throw new Error("Project icon exceeds the cache limit."); | |
| 117 | + return input.downscale({ url, mimeType, bytes }, signal); | |
| 118 | + }; | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** Stores small, self-contained images so startup never needs an old signed URL. */ | |
| 122 | +export function createProjectFaviconCache(input: { | |
| 123 | + readonly storage: ProjectFaviconStorage; | |
| 124 | + readonly load: (url: string, signal: AbortSignal) => Promise<string>; | |
| 125 | +}) { | |
| 126 | + const entries = new Map<string, ProjectFaviconEntry>(); | |
| 127 | + const environmentRevisions = new Map<EnvironmentId, number>(); | |
| 128 | + let generation = 0; | |
| 129 | + let hydration: Promise<void> | undefined; | |
| 130 | + let clearing: Promise<void> | undefined; | |
| 131 | + const pending = new Set<Promise<void>>(); | |
| 132 | + | |
| 133 | + const persist = (operation: () => Promise<void>) => { | |
| 134 | + const task: Promise<void> = operation() | |
| 135 | + .catch(() => { | |
| 136 | + // Keep the in-memory image if local storage is full or unavailable. | |
| 137 | + }) | |
| 138 | + .finally(() => pending.delete(task)); | |
| 139 | + pending.add(task); | |
| 140 | + }; | |
| 141 | + | |
| 142 | + const remove = (key: string) => { | |
| 143 | + const entry = entries.get(key); | |
| 144 | + if (!entry) return; | |
| 145 | + entries.delete(key); | |
| 146 | + persist(() => input.storage.remove(key, entry)); | |
| 147 | + }; | |
| 148 | + | |
| 149 | + const trim = () => { | |
| 150 | + let bytes = 0; | |
| 151 | + for (const entry of entries.values()) bytes += entry.dataUrl.length; | |
| 152 | + while ( | |
| 153 | + entries.size > PROJECT_FAVICON_CACHE_MAX_ENTRIES || | |
| 154 | + bytes > PROJECT_FAVICON_CACHE_MAX_BYTES | |
| 155 | + ) { | |
| 156 | + const oldest = entries.entries().next().value; | |
| 157 | + if (!oldest) break; | |
| 158 | + bytes -= oldest[1].dataUrl.length; | |
| 159 | + remove(oldest[0]); | |
| 160 | + } | |
| 161 | + }; | |
| 162 | + | |
| 163 | + const hydrate = () => | |
| 164 | + (hydration ??= (async () => { | |
| 165 | + try { | |
| 166 | + for (const record of await input.storage.list()) { | |
| 167 | + const entry = decodeEntry(record); | |
| 168 | + if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value); | |
| 169 | + } | |
| 170 | + trim(); | |
| 171 | + } catch { | |
| 172 | + // A missing, corrupt, or unavailable cache must not prevent startup. | |
| 173 | + } | |
| 174 | + })()); | |
| 175 | + | |
| 176 | + const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; | |
| 177 | + | |
| 178 | + const resolve = async ( | |
| 179 | + target: ProjectFaviconTarget, | |
| 180 | + url: string | null, | |
| 181 | + signal: AbortSignal, | |
| 182 | + ): Promise<string | null> => { | |
| 183 | + await clearing; | |
| 184 | + const startGeneration = generation; | |
| 185 | + const startRevision = environmentRevisions.get(target.environmentId) ?? 0; | |
| 186 | + await hydrate(); | |
| 187 | + if (signal.aborted || url === null) return peek(target); | |
| 188 | + const key = keyFor(target); | |
| 189 | + if (isProjectFaviconFallbackUrl(url)) { | |
| 190 | + remove(key); | |
| 191 | + return null; | |
| 192 | + } | |
| 193 | + const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url); | |
| 194 | + const cached = entries.get(key); | |
| 195 | + if (cached) { | |
| 196 | + entries.delete(key); | |
| 197 | + entries.set(key, cached); | |
| 198 | + if (cached.revision === revision) return cached.dataUrl; | |
| 199 | + } | |
| 200 | + try { | |
| 201 | + const dataUrl = await input.load(url, signal); | |
| 202 | + if ( | |
| 203 | + signal.aborted || | |
| 204 | + startGeneration !== generation || | |
| 205 | + startRevision !== (environmentRevisions.get(target.environmentId) ?? 0) | |
| 206 | + ) { | |
| 207 | + return peek(target); | |
| 208 | + } | |
| 209 | + if (isImageDataUrl(dataUrl)) { | |
| 210 | + const entry = { | |
| 211 | + environmentId: target.environmentId, | |
| 212 | + cwd: target.cwd, | |
| 213 | + faviconPath: target.faviconPath || null, | |
| 214 | + revision, | |
| 215 | + dataUrl, | |
| 216 | + }; | |
| 217 | + entries.set(key, entry); | |
| 218 | + persist(() => input.storage.put(key, entry)); | |
| 219 | + trim(); | |
| 220 | + return dataUrl; | |
| 221 | + } | |
| 222 | + } catch { | |
| 223 | + // An outage or failed decode leaves the last successful image visible. | |
| 224 | + } | |
| 225 | + return peek(target) ?? url; | |
| 226 | + }; | |
| 227 | + | |
| 228 | + const flush = async () => { | |
| 229 | + await Promise.all(pending); | |
| 230 | + }; | |
| 231 | + | |
| 232 | + // A download that started before the clear sees the revision change and is discarded; | |
| 233 | + // one that starts during the clear waits for it, so it cannot repopulate storage. | |
| 234 | + const clear = async (environmentId?: EnvironmentId) => { | |
| 235 | + if (environmentId === undefined) generation += 1; | |
| 236 | + else | |
| 237 | + environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); | |
| 238 | + const previous = clearing; | |
| 239 | + const task = (async () => { | |
| 240 | + await previous; | |
| 241 | + await hydrate(); | |
| 242 | + for (const [key, entry] of entries) { | |
| 243 | + if (environmentId === undefined || entry.environmentId === environmentId) remove(key); | |
| 244 | + } | |
| 245 | + await flush(); | |
| 246 | + })().finally(() => { | |
| 247 | + if (clearing === task) clearing = undefined; | |
| 248 | + }); | |
| 249 | + clearing = task; | |
| 250 | + await task; | |
| 251 | + }; | |
| 252 | + | |
| 253 | + return { | |
| 254 | + hydrate, | |
| 255 | + peek, | |
| 256 | + resolve, | |
| 257 | + clearEnvironment: (environmentId: EnvironmentId) => clear(environmentId), | |
| 258 | + clearAll: () => clear(), | |
| 259 | + flush, | |
| 260 | + }; | |
| 261 | +} | |
| 262 | + | |
| 263 | +export type ProjectFaviconCache = ReturnType<typeof createProjectFaviconCache>; | |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | +import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | +import * as Cause from "effect/Cause"; | |
| 4 | +import * as Option from "effect/Option"; | |
| 3 | 5 | import * as Layer from "effect/Layer"; |
| 4 | −import { Atom } from "effect/unstable/reactivity"; | |
| 6 | +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 5 | 7 | |
| 6 | 8 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 9 | +import { createProjectFaviconCache } from "../projectFaviconCache.ts"; | |
| 7 | 10 | import { |
| 8 | 11 | createAssetEnvironmentAtoms, |
| 12 | + createProjectFaviconUrlAtomFamily, | |
| 9 | 13 | InvalidAssetCollectionKeyError, |
| 10 | 14 | parseAssetCollectionKey, |
| 11 | 15 | } from "./assets.ts"; |
| @@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => { · 106 unmodified lines | ||
| 118 | 122 | ).not.toBe(assets.createUrls({ environmentId, resources })); |
| 119 | 123 | }); |
| 120 | 124 | }); |
| 125 | + | |
| 126 | +describe("project favicon URL cache", () => { | |
| 127 | + it("renders a persisted thumbnail immediately in a fresh registry and refreshes it remotely", async () => { | |
| 128 | + const image = "data:image/png;base64,aWNvbg=="; | |
| 129 | + const replacement = "data:image/png;base64,bmV3"; | |
| 130 | + const records = new Map<string, unknown>(); | |
| 131 | + const storage = { | |
| 132 | + list: async () => [...records.values()], | |
| 133 | + put: async (key: string, entry: unknown) => { | |
| 134 | + records.set(key, entry); | |
| 135 | + }, | |
| 136 | + remove: async (key: string) => { | |
| 137 | + records.delete(key); | |
| 138 | + }, | |
| 139 | + }; | |
| 140 | + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; | |
| 141 | + const previousCache = createProjectFaviconCache({ storage, load: async () => image }); | |
| 142 | + await previousCache.resolve( | |
| 143 | + target, | |
| 144 | + "https://remote.test/api/assets/old/v1-icon.png", | |
| 145 | + new AbortController().signal, | |
| 146 | + ); | |
| 147 | + await previousCache.flush(); | |
| 148 | + const cache = createProjectFaviconCache({ storage, load: async () => replacement }); | |
| 149 | + await cache.hydrate(); | |
| 150 | + const registry = AtomRegistry.make(); | |
| 151 | + const result = Atom.make<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>( | |
| 152 | + AsyncResult.initial(), | |
| 153 | + ); | |
| 154 | + const connection = Atom.make<Option.Option<{ httpBaseUrl: string }>>(Option.none()); | |
| 155 | + const favicon = createProjectFaviconUrlAtomFamily({ | |
| 156 | + createUrl: () => result, | |
| 157 | + preparedConnection: () => connection, | |
| 158 | + imageCache: cache, | |
| 159 | + })(target); | |
| 160 | + const unmount = registry.mount(favicon); | |
| 161 | + try { | |
| 162 | + expect(registry.get(favicon)).toBe(image); | |
| 163 | + let unsubscribe = () => {}; | |
| 164 | + const refreshed = new Promise<void>((resolve) => { | |
| 165 | + unsubscribe = registry.subscribe(favicon, (value) => { | |
| 166 | + if (value === replacement) resolve(); | |
| 167 | + }); | |
| 168 | + }); | |
| 169 | + registry.set(connection, Option.some({ httpBaseUrl: "https://remote.test" })); | |
| 170 | + registry.set( | |
| 171 | + result, | |
| 172 | + AsyncResult.success({ | |
| 173 | + relativeUrl: "/api/assets/new/v2-icon.png", | |
| 174 | + expiresAt: 4_000_000_000_000, | |
| 175 | + }), | |
| 176 | + ); | |
| 177 | + expect(registry.get(favicon)).toBe(image); | |
| 178 | + await refreshed; | |
| 179 | + unsubscribe(); | |
| 180 | + expect(registry.get(favicon)).toBe(replacement); | |
| 181 | + registry.set(connection, Option.none()); | |
| 182 | + registry.set(result, AsyncResult.failure(Cause.die("offline"))); | |
| 183 | + expect(registry.get(favicon)).toBe(replacement); | |
| 184 | + } finally { | |
| 185 | + unmount(); | |
| 186 | + registry.dispose(); | |
| 187 | + } | |
| 188 | + }); | |
| 189 | + | |
| 190 | + it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => { | |
| 191 | + const registry = AtomRegistry.make(); | |
| 192 | + const result = Atom.make<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>( | |
| 193 | + AsyncResult.initial(), | |
| 194 | + ); | |
| 195 | + const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); | |
| 196 | + const favicon = createProjectFaviconUrlAtomFamily({ | |
| 197 | + createUrl: () => result, | |
| 198 | + preparedConnection: () => connection, | |
| 199 | + })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); | |
| 200 | + let unmount = registry.mount(favicon); | |
| 201 | + try { | |
| 202 | + expect(registry.get(favicon)).toBeNull(); | |
| 203 | + registry.set( | |
| 204 | + result, | |
| 205 | + AsyncResult.success({ | |
| 206 | + expiresAt: 4_000_000_000_000, | |
| 207 | + relativeUrl: "/api/assets/token-a/icon.svg", | |
| 208 | + }), | |
| 209 | + ); | |
| 210 | + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); | |
| 211 | + | |
| 212 | + registry.set(connection, Option.none()); | |
| 213 | + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); | |
| 214 | + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); | |
| 215 | + unmount(); | |
| 216 | + unmount = registry.mount(favicon); | |
| 217 | + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); | |
| 218 | + | |
| 219 | + registry.set(result, AsyncResult.initial()); | |
| 220 | + registry.set(connection, Option.some({ httpBaseUrl: "https://reconnected.test" })); | |
| 221 | + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); | |
| 222 | + registry.set( | |
| 223 | + result, | |
| 224 | + AsyncResult.success({ | |
| 225 | + expiresAt: 4_000_000_000_000, | |
| 226 | + relativeUrl: "/api/assets/token-b/icon.svg", | |
| 227 | + }), | |
| 228 | + ); | |
| 229 | + expect(registry.get(favicon)).toBe("https://reconnected.test/api/assets/token-b/icon.svg"); | |
| 230 | + | |
| 231 | + registry.set( | |
| 232 | + result, | |
| 233 | + AsyncResult.success({ | |
| 234 | + expiresAt: 4_000_000_000_000, | |
| 235 | + relativeUrl: "/api/assets/token-c/project-favicon-missing", | |
| 236 | + }), | |
| 237 | + ); | |
| 238 | + expect(registry.get(favicon)).toBe( | |
| 239 | + "https://reconnected.test/api/assets/token-c/project-favicon-missing", | |
| 240 | + ); | |
| 241 | + registry.set(connection, Option.none()); | |
| 242 | + expect(registry.get(favicon)).toBe( | |
| 243 | + "https://reconnected.test/api/assets/token-c/project-favicon-missing", | |
| 244 | + ); | |
| 245 | + } finally { | |
| 246 | + unmount(); | |
| 247 | + registry.dispose(); | |
| 248 | + } | |
| 249 | + }); | |
| 250 | + | |
| 251 | + it("does not reuse another environment, workspace, or selected icon's cached URL", () => { | |
| 252 | + const registry = AtomRegistry.make(); | |
| 253 | + const result = Atom.make<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>( | |
| 254 | + AsyncResult.success({ | |
| 255 | + expiresAt: 4_000_000_000_000, | |
| 256 | + relativeUrl: "/api/assets/token/icon.svg", | |
| 257 | + }), | |
| 258 | + ); | |
| 259 | + const favicon = createProjectFaviconUrlAtomFamily({ | |
| 260 | + createUrl: () => result, | |
| 261 | + preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })), | |
| 262 | + }); | |
| 263 | + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; | |
| 264 | + const unmount = registry.mount(favicon(target)); | |
| 265 | + try { | |
| 266 | + expect(registry.get(favicon(target))).toBe("https://remote.test/api/assets/token/icon.svg"); | |
| 267 | + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); | |
| 268 | + expect( | |
| 269 | + registry.get(favicon({ ...target, environmentId: EnvironmentId.make("other") })), | |
| 270 | + ).toBeNull(); | |
| 271 | + expect(registry.get(favicon({ ...target, cwd: "/other" }))).toBeNull(); | |
| 272 | + expect(registry.get(favicon({ ...target, faviconPath: "brand.svg" }))).toBeNull(); | |
| 273 | + expect(registry.get(favicon({ ...target, faviconPath: null }))).toBe( | |
| 274 | + "https://remote.test/api/assets/token/icon.svg", | |
| 275 | + ); | |
| 276 | + } finally { | |
| 277 | + unmount(); | |
| 278 | + registry.dispose(); | |
| 279 | + } | |
| 280 | + }); | |
| 281 | +}); | |
| @@ -5,10 +5,17 @@ import { · 4 unmodified lines | ||
| 5 | 5 | EnvironmentId, |
| 6 | 6 | WS_METHODS, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | +import { | |
| 9 | + getProjectFaviconResourceKey, | |
| 10 | + isProjectFaviconFallbackUrl, | |
| 11 | +} from "@t3tools/shared/projectFavicon"; | |
| 12 | +import * as Effect from "effect/Effect"; | |
| 13 | +import * as Option from "effect/Option"; | |
| 8 | 14 | import * as Schema from "effect/Schema"; |
| 9 | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 10 | 16 | |
| 11 | 17 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 18 | +import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; | |
| 12 | 19 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; |
| 13 | 20 | |
| 14 | 21 | const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; |
| @@ -118,3 +125,53 @@ export function createAssetEnvironmentAtoms<R, E>( · 103 unmodified lines | ||
| 118 | 125 | }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), |
| 119 | 126 | }; |
| 120 | 127 | } |
| 128 | + | |
| 129 | +/** | |
| 130 | + * Keeps project icons visible while their environment reconnects. Each resource | |
| 131 | + * owns its last resolved URL, including a confirmed missing-icon response. | |
| 132 | + */ | |
| 133 | +export function createProjectFaviconUrlAtomFamily(input: { | |
| 134 | + readonly imageCache?: ProjectFaviconCache; | |
| 135 | + readonly createUrl: (target: { | |
| 136 | + readonly environmentId: EnvironmentId; | |
| 137 | + readonly input: { readonly resource: AssetResource }; | |
| 138 | + }) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>; | |
| 139 | + readonly preparedConnection: ( | |
| 140 | + environmentId: EnvironmentId, | |
| 141 | + ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>; | |
| 142 | +}) { | |
| 143 | + const decodeKey = Schema.decodeUnknownSync( | |
| 144 | + Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), | |
| 145 | + ); | |
| 146 | + const family = Atom.family((key: string) => { | |
| 147 | + const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); | |
| 148 | + const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; | |
| 149 | + const request = input.createUrl({ environmentId, input: { resource } }); | |
| 150 | + const resolvedUrl = Atom.make((get): string | null => { | |
| 151 | + const result = get(request); | |
| 152 | + const connection = get(input.preparedConnection(environmentId)); | |
| 153 | + const state = assetUrlStateFromResult( | |
| 154 | + result, | |
| 155 | + Option.isSome(connection) ? connection.value.httpBaseUrl : null, | |
| 156 | + ); | |
| 157 | + return state._tag === "Success" ? state.url : Option.getOrNull(get.self<string | null>()); | |
| 158 | + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); | |
| 159 | + const cache = input.imageCache; | |
| 160 | + if (!cache) return resolvedUrl; | |
| 161 | + | |
| 162 | + const target = { environmentId, cwd, faviconPath: path }; | |
| 163 | + const image = Atom.make((get) => { | |
| 164 | + get(request); | |
| 165 | + const url = get(resolvedUrl); | |
| 166 | + return Effect.promise((signal) => cache.resolve(target, url, signal)); | |
| 167 | + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); | |
| 168 | + | |
| 169 | + return Atom.make((get): string | null => { | |
| 170 | + const result = get(image); | |
| 171 | + if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null; | |
| 172 | + return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target)); | |
| 173 | + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); | |
| 174 | + }); | |
| 175 | + return (target: ProjectFaviconTarget) => | |
| 176 | + family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath)); | |
| 177 | +} | |
| @@ -1,5 +1,13 @@ | ||
| 1 | 1 | export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; |
| 2 | 2 | |
| 3 | +export function getProjectFaviconResourceKey( | |
| 4 | + environmentId: string, | |
| 5 | + workspaceRoot: string, | |
| 6 | + faviconPath?: string | null, | |
| 7 | +) { | |
| 8 | + return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]); | |
| 9 | +} | |
| 10 | + | |
| 3 | 11 | export function getProjectFaviconCacheKey( |
| 4 | 12 | environmentId: string, |
| 5 | 13 | workspaceRoot: string, |