authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-06 03:50:02+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 18:50:02-07:00
logeee05575ebd514db36f61d7eb05d2258a10c96bd
treeac282bd9edcc2541db816c5d0866798b3c3433b9
parent050690d1bc048c1f94096c17a1c9231c3ad61a83
signature Signed by PGP key B5690EEEBB952194

fix(clients): persist project icons across reloads and reconnects (#10138)


20 files changed, 1207 insertions(+), 62 deletions(-)

apps/mobile/src/components/ProjectFavicon.tsx+28-16
...@@ -5,9 +5,13 @@ import { View } from "react-native";...@@ -5,9 +5,13 @@ import { View } from "react-native";
5import type { EnvironmentId } from "@t3tools/contracts";5import type { EnvironmentId } from "@t3tools/contracts";
6import {6import {
7 getProjectFaviconCacheKey,7 getProjectFaviconCacheKey,
8 getProjectFaviconResourceKey,
8 isProjectFaviconFallbackUrl,9 isProjectFaviconFallbackUrl,
9} from "@t3tools/shared/projectFavicon";10} from "@t3tools/shared/projectFavicon";
10import { useAssetUrl } from "../state/assets";11import { useAtomValue } from "@effect/atom-react";
12import { Atom } from "effect/unstable/reactivity";
13import { projectFaviconUrlAtom } from "../state/assets";
14
11import {15import {
12 beginProjectFaviconRequest,16 beginProjectFaviconRequest,
13 createProjectFaviconRequest,17 createProjectFaviconRequest,
...@@ -16,6 +20,8 @@ import {...@@ -16,6 +20,8 @@ import {
16 markProjectFaviconLoaded,20 markProjectFaviconLoaded,
17} from "./projectFaviconCache";21} from "./projectFaviconCache";
1822
23const EMPTY_FAVICON_URL = Atom.make<string | null>(null);
24
19/* ─── Component ──────────────────────────────────────────────────────── */25/* ─── Component ──────────────────────────────────────────────────────── */
20export function ProjectFavicon(props: {26export function ProjectFavicon(props: {
21 readonly environmentId: EnvironmentId;27 readonly environmentId: EnvironmentId;
...@@ -26,20 +32,23 @@ export function ProjectFavicon(props: {...@@ -26,20 +32,23 @@ export function ProjectFavicon(props: {
26 readonly faviconPath?: string | null;32 readonly faviconPath?: string | null;
27}) {33}) {
28 const size = props.size ?? 42;34 const size = props.size ?? 42;
29 const faviconUrl = useAssetUrl(35 const faviconUrl = useAtomValue(
30 props.environmentId,36 props.workspaceRoot == null
31 props.workspaceRoot === null || props.workspaceRoot === undefined37 ? EMPTY_FAVICON_URL
32 ? null38 : projectFaviconUrlAtom({
33 : {39 environmentId: props.environmentId,
34 _tag: "project-favicon",
35 cwd: props.workspaceRoot,40 cwd: props.workspaceRoot,
36 ...(props.faviconPath ? { path: props.faviconPath } : {}),41 faviconPath: props.faviconPath,
37 },42 }),
38 );43 );
39 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;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 const cacheKey =47 const cacheKey =
41 renderableFaviconUrl && props.workspaceRoot48 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 : null;52 : null;
4453
45 return (54 return (
...@@ -75,7 +84,9 @@ function ProjectFaviconImage(props: {...@@ -75,7 +84,9 @@ function ProjectFaviconImage(props: {
75 }, [faviconRequest]);84 }, [faviconRequest]);
7685
77 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>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 );
8091
81 const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;92 const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
...@@ -104,11 +115,12 @@ function ProjectFaviconImage(props: {...@@ -104,11 +115,12 @@ function ProjectFaviconImage(props: {
104 {requestIsActive ? (115 {requestIsActive ? (
105 <Image116 <Image
106 key={faviconRequest.faviconUrl}117 key={faviconRequest.faviconUrl}
107 source={{118 source={
108 uri: faviconRequest.faviconUrl,119 faviconRequest.faviconUrl.startsWith("data:")
109 cacheKey: faviconRequest.cacheKey,120 ? { uri: faviconRequest.faviconUrl }
110 }}121 : { uri: faviconRequest.faviconUrl, cacheKey: faviconRequest.cacheKey }
111 cachePolicy="memory-disk"122 }
123 cachePolicy={faviconRequest.faviconUrl.startsWith("data:") ? "memory" : "memory-disk"}
112 recyclingKey={faviconRequest.cacheKey}124 recyclingKey={faviconRequest.cacheKey}
113 accessibilityLabel={`${props.projectTitle} favicon`}125 accessibilityLabel={`${props.projectTitle} favicon`}
114 style={{126 style={{
apps/mobile/src/connection/environment-cache-store.test.ts+6
...@@ -32,6 +32,12 @@ function makeDatabase() {...@@ -32,6 +32,12 @@ function makeDatabase() {
32 const database = MobileDatabase.of({32 const database = MobileDatabase.of({
33 loadCache: (environmentId, kind, cacheKey) =>33 loadCache: (environmentId, kind, cacheKey) =>
34 Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))),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 saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) =>41 saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) =>
36 Effect.sync(() => {42 Effect.sync(() => {
37 values.set(cacheId(environmentId, kind, cacheKey), payload);43 values.set(cacheId(environmentId, kind, cacheKey), payload);
apps/mobile/src/connection/environment-cache-store.ts+7-4
...@@ -15,6 +15,7 @@ import * as Option from "effect/Option";...@@ -15,6 +15,7 @@ import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
1616
17import * as MobileDatabase from "../persistence/mobile-database";17import * as MobileDatabase from "../persistence/mobile-database";
18import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache";
1819
19const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;20const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
20// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump21// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
...@@ -115,6 +116,7 @@ function loadDecodedCache<A, B>(input: {...@@ -115,6 +116,7 @@ function loadDecodedCache<A, B>(input: {
115116
116export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {117export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
117 const database = yield* MobileDatabase.MobileDatabase;118 const database = yield* MobileDatabase.MobileDatabase;
119 attachProjectFaviconDatabase(database);
118 return EnvironmentCacheStore.of({120 return EnvironmentCacheStore.of({
119 loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>121 loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
120 loadDecodedCache({122 loadDecodedCache({
...@@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {...@@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
126 decode: decodeStoredShellSnapshot,128 decode: decodeStoredShellSnapshot,
127 select: (stored) =>129 select: (stored) =>
128 stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),130 stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
129 }),131 }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))),
130 ),132 ),
131 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {133 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
132 const payload = yield* encodeStoredShellSnapshot({134 const payload = yield* encodeStoredShellSnapshot({
...@@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {...@@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
237 .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),239 .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),
238 ),240 ),
239 clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>241 clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>
240 database242 Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe(
241 .clearEnvironmentCache(environmentId)243 Effect.andThen(database.clearEnvironmentCache(environmentId)),
242 .pipe(Effect.mapError(mapDatabaseError("clear-environment"))),244 Effect.mapError(mapDatabaseError("clear-environment")),
245 ),
243 ),246 ),
244 });247 });
245});248});
apps/mobile/src/lib/projectFaviconCache.test.ts created+79
...@@ -0,0 +1,79 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache";
3
4const 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}));
15vi.mock("expo-image", () => ({
16 Image: {
17 loadAsync: native.load,
18 writeToCacheAsync: native.write,
19 getCachePathAsync: native.path,
20 },
21}));
22vi.mock("expo-file-system", () => ({
23 File: class {
24 size = 24_000;
25 base64 = native.read;
26 delete = native.remove;
27 },
28}));
29
30import { downscaleProjectFavicon } from "./projectFaviconCache";
31
32const png = "iVBORw0KGgoAAAAA";
33const image = { url: "https://remote/icon.png" };
34
35beforeEach(() => {
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
45describe("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});
apps/mobile/src/lib/projectFaviconCache.ts created+112
...@@ -0,0 +1,112 @@
1import {
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";
8import * as Effect from "effect/Effect";
9
10import * as MobileDatabase from "../persistence/mobile-database";
11
12const CACHE_KIND = "project-favicon";
13const CACHE_SCHEMA_VERSION = 1;
14
15let 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 */
22export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) {
23 database = service;
24}
25
26const 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 */
38export 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. */
82export 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});
apps/mobile/src/persistence/mobile-database.ts+22-1
...@@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [...@@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [
16 "connection-vcs-refs",16 "connection-vcs-refs",
17] as const;17] as const;
1818
19export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]);19export const ClientCacheKind = Schema.Literals([
20 "shell",
21 "thread",
22 "server-config",
23 "vcs-refs",
24 "project-favicon",
25]);
20export type ClientCacheKind = typeof ClientCacheKind.Type;26export type ClientCacheKind = typeof ClientCacheKind.Type;
2127
22export interface ClientCacheSummaryRow {28export interface ClientCacheSummaryRow {
...@@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([...@@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([
44 "open",50 "open",
45 "migrate",51 "migrate",
46 "load-cache",52 "load-cache",
53 "list-cache",
47 "save-cache",54 "save-cache",
48 "remove-cache",55 "remove-cache",
49 "clear-cache-kind",56 "clear-cache-kind",
...@@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service<...@@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service<
192 kind: ClientCacheKind,199 kind: ClientCacheKind,
193 cacheKey: string,200 cacheKey: string,
194 ) => Effect.Effect<Option.Option<string>, MobileDatabaseError>;201 ) => Effect.Effect<Option.Option<string>, MobileDatabaseError>;
202 readonly listCache: (
203 kind: ClientCacheKind,
204 ) => Effect.Effect<ReadonlyArray<string>, MobileDatabaseError>;
195 readonly saveCache: (205 readonly saveCache: (
196 environmentId: EnvironmentId,206 environmentId: EnvironmentId,
197 kind: ClientCacheKind,207 kind: ClientCacheKind,
...@@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () {...@@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () {
292 catch: databaseError("load-cache"),302 catch: databaseError("load-cache"),
293 }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))),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 saveCache: Effect.fn("MobileDatabase.saveCache")(315 saveCache: Effect.fn("MobileDatabase.saveCache")(
296 (environmentId, kind, cacheKey, schemaVersion, payload) =>316 (environmentId, kind, cacheKey, schemaVersion, payload) =>
297 Effect.tryPromise({317 Effect.tryPromise({
...@@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"]...@@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"]
405 const fail = Effect.fail(error);425 const fail = Effect.fail(error);
406 return MobileDatabase.of({426 return MobileDatabase.of({
407 loadCache: () => fail,427 loadCache: () => fail,
428 listCache: () => fail,
408 saveCache: () => fail,429 saveCache: () => fail,
409 removeCache: () => fail,430 removeCache: () => fail,
410 clearCacheKind: () => fail,431 clearCacheKind: () => fail,
apps/mobile/src/state/assets.ts+9-1
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6import {6import {
7 assetUrlStateFromResult,7 assetUrlStateFromResult,
8 createAssetEnvironmentAtoms,8 createAssetEnvironmentAtoms,
9 createProjectFaviconUrlAtomFamily,
9 EMPTY_ASSET_URL_ATOM,10 EMPTY_ASSET_URL_ATOM,
10} from "@t3tools/client-runtime/state/assets";11} from "@t3tools/client-runtime/state/assets";
11import type { AssetResource, EnvironmentId } from "@t3tools/contracts";12import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
...@@ -15,14 +16,21 @@ import { useCallback } from "react";...@@ -15,14 +16,21 @@ import { useCallback } from "react";
1516
16import { environmentCatalog } from "../connection/catalog";17import { environmentCatalog } from "../connection/catalog";
17import { connectionAtomRuntime } from "../connection/runtime";18import { connectionAtomRuntime } from "../connection/runtime";
19import { projectFaviconCache } from "../lib/projectFaviconCache";
18import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";20import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
19import { usePreparedConnection } from "./session";21import { environmentSession, usePreparedConnection } from "./session";
20import { useAtomQueryRunner } from "./use-atom-query-runner";22import { useAtomQueryRunner } from "./use-atom-query-runner";
2123
22export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";24export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";
2325
24export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);26export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
2527
28export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
29 imageCache: projectFaviconCache,
30 createUrl: assetEnvironment.createUrl,
31 preparedConnection: environmentSession.preparedConnectionValueAtom,
32});
33
26const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(34const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
27 Atom.withLabel("mobile-asset-connection-state:empty"),35 Atom.withLabel("mobile-asset-connection-state:empty"),
28);36);
apps/mobile/src/state/client-cache-state.ts+7-1
...@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";...@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
3import { Atom } from "effect/unstable/reactivity";3import { Atom } from "effect/unstable/reactivity";
44
5import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";5import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";
6import { projectFaviconCache } from "../lib/projectFaviconCache";
6import * as Runtime from "../lib/runtime";7import * as Runtime from "../lib/runtime";
78
8export interface EnvironmentClientCacheSummary {9export interface EnvironmentClientCacheSummary {
...@@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime...@@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime
7172
72export const clearClientCacheAtom = clientCacheRuntime73export const clearClientCacheAtom = clientCacheRuntime
73 .fn((scope: ClientCacheClearScope, get) =>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 Effect.flatMap((database) =>81 Effect.flatMap((database) =>
76 scope.type === "all"82 scope.type === "all"
77 ? database.clearAllCaches83 ? database.clearAllCaches
apps/web/src/assets/projectFaviconCache.ts created+85
...@@ -0,0 +1,85 @@
1import {
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
8const DATABASE_NAME = "t3code:project-favicons";
9const DATABASE_VERSION = 2;
10const STORE_NAME = "images";
11let database: Promise<IDBDatabase> | undefined;
12
13function 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
30function 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
38async 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. */
49export 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
74export 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});
apps/web/src/components/ProjectFavicon.test.tsx+10-8
...@@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"...@@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"
55
6const testState = vi.hoisted(() => ({6const testState = vi.hoisted(() => ({
7 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",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}));
1010
11const hooks = vi.hoisted(() => {11const hooks = vi.hoisted(() => {
...@@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({...@@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({
57 DynamicIcon: "dynamic-icon",57 DynamicIcon: "dynamic-icon",
58 iconNames: ["alarm-clock", "folder-code"],58 iconNames: ["alarm-clock", "folder-code"],
59}));59}));
60vi.mock("../assets/assetUrls", () => ({60vi.mock("@effect/atom-react", () => ({
61 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {61 useAtomValue: () => testState.faviconUrl,
62 testState.lastResource = resource;62}));
63 return { _tag: "Success", url: testState.faviconUrl };63vi.mock("../state/assets", () => ({
64 projectFaviconUrlAtom: (input: unknown) => {
65 testState.lastTarget = input;
64 },66 },
65}));67}));
6668
...@@ -212,10 +214,10 @@ describe("ProjectFavicon", () => {...@@ -212,10 +214,10 @@ describe("ProjectFavicon", () => {
212 faviconPath: "brand/icon.svg",214 faviconPath: "brand/icon.svg",
213 });215 });
214216
215 expect(testState.lastResource).toEqual({217 expect(testState.lastTarget).toMatchObject({
216 _tag: "project-favicon",218 environmentId: "environment-test",
217 cwd: "/workspace-test",219 cwd: "/workspace-test",
218 path: "brand/icon.svg",220 faviconPath: "brand/icon.svg",
219 });221 });
220 });222 });
221});223});
apps/web/src/components/ProjectFavicon.tsx+7-27
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";1import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
2import {2import {
3 getProjectFaviconCacheKey,3 getProjectFaviconResourceKey,
4 isProjectFaviconFallbackUrl,4 isProjectFaviconFallbackUrl,
5} from "@t3tools/shared/projectFavicon";5} from "@t3tools/shared/projectFavicon";
6import {6import {
...@@ -30,12 +30,12 @@ import {...@@ -30,12 +30,12 @@ import {
30import type { IconName } from "lucide-react/dynamic";30import type { IconName } from "lucide-react/dynamic";
31import type { ComponentType } from "react";31import type { ComponentType } from "react";
32import { lazy, Suspense, useState } from "react";32import { lazy, Suspense, useState } from "react";
33import { useAssetUrlState } from "../assets/assetUrls";33import { useAtomValue } from "@effect/atom-react";
34import { projectFaviconUrlAtom } from "../state/assets";
34import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";35import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
35import { projectIconColorClassName } from "../projectIconColors";36import { projectIconColorClassName } from "../projectIconColors";
36import { cn } from "~/lib/utils";37import { cn } from "~/lib/utils";
3738
38const loadedProjectFaviconSrcs = new Map<string, string>();
39const DynamicIcon = lazy(() =>39const DynamicIcon = lazy(() =>
40 import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),40 import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),
41);41);
...@@ -103,8 +103,7 @@ export function ProjectFavicon(input: {...@@ -103,8 +103,7 @@ export function ProjectFavicon(input: {
103 className?: string | undefined;103 className?: string | undefined;
104 fallbackIcon?: ComponentType<{ className?: string }>;104 fallbackIcon?: ComponentType<{ className?: string }>;
105}) {105}) {
106 const state = useProjectFaviconAsset(input);106 const src = useAtomValue(projectFaviconUrlAtom(input));
107 const src = state._tag === "Success" ? state.url : null;
108 if (input.projectIcon?.kind === "emoji") {107 if (input.projectIcon?.kind === "emoji") {
109 return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />;108 return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />;
110 }109 }
...@@ -150,12 +149,11 @@ export function ProjectFavicon(input: {...@@ -150,12 +149,11 @@ export function ProjectFavicon(input: {
150 );149 );
151 }150 }
152151
153 const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src);152 const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath);
154153
155 return (154 return (
156 <ProjectFaviconImage155 <ProjectFaviconImage
157 key={cacheKey}156 key={cacheKey}
158 cacheKey={cacheKey}
159 src={src}157 src={src}
160 className={input.className}158 className={input.className}
161 fallbackIcon={FallbackIcon}159 fallbackIcon={FallbackIcon}
...@@ -165,18 +163,6 @@ export function ProjectFavicon(input: {...@@ -165,18 +163,6 @@ export function ProjectFavicon(input: {
165 );163 );
166}164}
167165
168export 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
180function ProjectFaviconFallback({166function ProjectFaviconFallback({
181 className,167 className,
182 colorClassName,168 colorClassName,
...@@ -207,28 +193,23 @@ function ProjectFaviconFallback({...@@ -207,28 +193,23 @@ function ProjectFaviconFallback({
207}193}
208194
209function ProjectFaviconImage({195function ProjectFaviconImage({
210 cacheKey,
211 src,196 src,
212 className,197 className,
213 fallbackIcon: FallbackIcon,198 fallbackIcon: FallbackIcon,
214 fallbackEmoji,199 fallbackEmoji,
215 fallbackColorClassName,200 fallbackColorClassName,
216}: {201}: {
217 readonly cacheKey: string;
218 readonly src: string;202 readonly src: string;
219 readonly className?: string | undefined;203 readonly className?: string | undefined;
220 readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined;204 readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined;
221 readonly fallbackEmoji?: string | undefined;205 readonly fallbackEmoji?: string | undefined;
222 readonly fallbackColorClassName?: string | undefined;206 readonly fallbackColorClassName?: string | undefined;
223}) {207}) {
224 const [displayedSrc, setDisplayedSrc] = useState<string | null>(208 const [displayedSrc, setDisplayedSrc] = useState<string | null>(() =>
225 () => loadedProjectFaviconSrcs.get(cacheKey) ?? null,209 src.startsWith("data:image/") ? src : null,
226 );210 );
227 const isLoading = displayedSrc !== src;211 const isLoading = displayedSrc !== src;
228 const handleLoadError = (failedSrc: string) => {212 const handleLoadError = (failedSrc: string) => {
229 if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) {
230 loadedProjectFaviconSrcs.delete(cacheKey);
231 }
232 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));213 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
233 };214 };
234215
...@@ -256,7 +237,6 @@ function ProjectFaviconImage({...@@ -256,7 +237,6 @@ function ProjectFaviconImage({
256 alt=""237 alt=""
257 className="hidden"238 className="hidden"
258 onLoad={() => {239 onLoad={() => {
259 loadedProjectFaviconSrcs.set(cacheKey, src);
260 setDisplayedSrc(src);240 setDisplayedSrc(src);
261 }}241 }}
262 onError={() => handleLoadError(src)}242 onError={() => handleLoadError(src)}
apps/web/src/components/preview/PreviewView.test.tsx+2-1
...@@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({...@@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({
56 useThreadRecentHistory: () => EMPTY_HISTORY,56 useThreadRecentHistory: () => EMPTY_HISTORY,
57}));57}));
5858
59vi.mock("~/state/session", () => ({59vi.mock("~/state/session", async (importOriginal) => ({
60 ...(await importOriginal<typeof import("~/state/session")>()),
60 readPreparedConnection: mocks.readPreparedConnection,61 readPreparedConnection: mocks.readPreparedConnection,
61}));62}));
6263
apps/web/src/connection/storage.ts+3
...@@ -33,6 +33,7 @@ import * as Option from "effect/Option";...@@ -33,6 +33,7 @@ import * as Option from "effect/Option";
33import * as Ref from "effect/Ref";33import * as Ref from "effect/Ref";
34import * as Schema from "effect/Schema";34import * as Schema from "effect/Schema";
35import * as Semaphore from "effect/Semaphore";35import * as Semaphore from "effect/Semaphore";
36import { projectFaviconCache } from "../assets/projectFaviconCache";
3637
37const DATABASE_NAME = "t3code:connection-runtime";38const DATABASE_NAME = "t3code:connection-runtime";
38const DATABASE_VERSION = 4;39const DATABASE_VERSION = 4;
...@@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext(
461 const cacheStore = EnvironmentCacheStore.of({462 const cacheStore = EnvironmentCacheStore.of({
462 loadShell: (environmentId) =>463 loadShell: (environmentId) =>
463 readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe(464 readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe(
465 Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())),
464 Effect.flatMap((raw) => {466 Effect.flatMap((raw) => {
465 if (typeof raw !== "string") {467 if (typeof raw !== "string") {
466 return Effect.succeed(Option.none());468 return Effect.succeed(Option.none());
...@@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext(
638 clear: (environmentId) =>640 clear: (environmentId) =>
639 Effect.all(641 Effect.all(
640 [642 [
643 Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)),
641 removeDatabaseValue(database, SHELL_STORE_NAME, environmentId),644 removeDatabaseValue(database, SHELL_STORE_NAME, environmentId),
642 removeDatabaseValuesInRange(645 removeDatabaseValuesInRange(
643 database,646 database,
apps/web/src/state/assets.ts+12-1
...@@ -1,5 +1,16 @@...@@ -1,5 +1,16 @@
1import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets";1import {
2 createAssetEnvironmentAtoms,
3 createProjectFaviconUrlAtomFamily,
4} from "@t3tools/client-runtime/state/assets";
25
3import { connectionAtomRuntime } from "../connection/runtime";6import { connectionAtomRuntime } from "../connection/runtime";
7import { projectFaviconCache } from "../assets/projectFaviconCache";
8import { environmentSession } from "./session";
49
5export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);10export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
11
12export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
13 imageCache: projectFaviconCache,
14 createUrl: assetEnvironment.createUrl,
15 preparedConnection: environmentSession.preparedConnectionValueAtom,
16});
packages/client-runtime/package.json+4
...@@ -3,6 +3,10 @@...@@ -3,6 +3,10 @@
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 "./project-favicon-cache": {
7 "types": "./src/projectFaviconCache.ts",
8 "default": "./src/projectFaviconCache.ts"
9 },
6 "./connection": {10 "./connection": {
7 "types": "./src/connection/index.ts",11 "types": "./src/connection/index.ts",
8 "default": "./src/connection/index.ts"12 "default": "./src/connection/index.ts"
packages/client-runtime/src/projectFaviconCache.test.ts created+323
...@@ -0,0 +1,323 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
3
4import {
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
15const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" };
16const url = "https://remote.test/api/assets/token-a/vabc-icon.svg";
17const image = "data:image/png;base64,aWNvbg==";
18const replacement = "data:image/png;base64,bmV3";
19const signal = () => new AbortController().signal;
20
21function 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
29function 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
49describe("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
252describe("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});
packages/client-runtime/src/projectFaviconCache.ts created+263
...@@ -0,0 +1,263 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { mediaMimeType } from "@t3tools/shared/filePreview";
3import {
4 getProjectFaviconCacheKey,
5 getProjectFaviconResourceKey,
6 isProjectFaviconFallbackUrl,
7} from "@t3tools/shared/projectFavicon";
8import * as Encoding from "effect/Encoding";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11
12export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96;
13export 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. */
15export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024;
16export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024;
17export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128;
18
19export interface ProjectFaviconTarget {
20 readonly environmentId: EnvironmentId;
21 readonly cwd: string;
22 readonly faviconPath?: string | null | undefined;
23}
24
25const 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);
31const Entry = Schema.Struct({
32 environmentId: EnvironmentId,
33 cwd: Schema.String,
34 faviconPath: Schema.NullOr(Schema.String),
35 revision: Schema.String,
36 dataUrl: ImageDataUrl,
37});
38export type ProjectFaviconEntry = typeof Entry.Type;
39const decodeEntry = Schema.decodeUnknownOption(Entry);
40const isImageDataUrl = Schema.is(ImageDataUrl);
41
42function keyFor(target: ProjectFaviconTarget) {
43 return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath);
44}
45
46export 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
53async 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 */
90export 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. */
122export 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
263export type ProjectFaviconCache = ReturnType<typeof createProjectFaviconCache>;
packages/client-runtime/src/state/assets.test.ts+163-2
...@@ -1,11 +1,15 @@...@@ -1,11 +1,15 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";2import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Option from "effect/Option";
3import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
4import { Atom } from "effect/unstable/reactivity";6import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
57
6import type { EnvironmentRegistry } from "../connection/registry.ts";8import type { EnvironmentRegistry } from "../connection/registry.ts";
9import { createProjectFaviconCache } from "../projectFaviconCache.ts";
7import {10import {
8 createAssetEnvironmentAtoms,11 createAssetEnvironmentAtoms,
12 createProjectFaviconUrlAtomFamily,
9 InvalidAssetCollectionKeyError,13 InvalidAssetCollectionKeyError,
10 parseAssetCollectionKey,14 parseAssetCollectionKey,
11} from "./assets.ts";15} from "./assets.ts";
...@@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => {...@@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => {
118 ).not.toBe(assets.createUrls({ environmentId, resources }));122 ).not.toBe(assets.createUrls({ environmentId, resources }));
119 });123 });
120});124});
125
126describe("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});
packages/client-runtime/src/state/assets.ts+57
...@@ -5,10 +5,17 @@ import {...@@ -5,10 +5,17 @@ import {
5 EnvironmentId,5 EnvironmentId,
6 WS_METHODS,6 WS_METHODS,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import {
9 getProjectFaviconResourceKey,
10 isProjectFaviconFallbackUrl,
11} from "@t3tools/shared/projectFavicon";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
9import { AsyncResult, Atom } from "effect/unstable/reactivity";15import { AsyncResult, Atom } from "effect/unstable/reactivity";
1016
11import type { EnvironmentRegistry } from "../connection/registry.ts";17import type { EnvironmentRegistry } from "../connection/registry.ts";
18import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";
12import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";19import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
1320
14const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;21const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
...@@ -118,3 +125,53 @@ export function createAssetEnvironmentAtoms<R, E>(...@@ -118,3 +125,53 @@ export function createAssetEnvironmentAtoms<R, E>(
118 }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])),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 */
133export 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}
packages/shared/src/projectFavicon.ts+8
...@@ -1,5 +1,13 @@...@@ -1,5 +1,13 @@
1export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";1export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";
22
3export function getProjectFaviconResourceKey(
4 environmentId: string,
5 workspaceRoot: string,
6 faviconPath?: string | null,
7) {
8 return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]);
9}
10
3export function getProjectFaviconCacheKey(11export function getProjectFaviconCacheKey(
4 environmentId: string,12 environmentId: string,
5 workspaceRoot: string,13 workspaceRoot: string,