1import {
2 createProjectFaviconCache as createSharedProjectFaviconCache,
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/**
82 * The database-backed project favicon cache. Named apart from the shared
83 * `createProjectFaviconCache` factory it calls and from the in-flight request
84 * registry in `projectFaviconRequests`. Rows live in `client_cache` so
85 * Settings → Client storage counts and clears them.
86 */
87export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({
88 storage: {
89 list: () =>
90 runDatabase((database) =>
91 database.listCache(CACHE_KIND).pipe(
92 Effect.map((payloads) =>
93 payloads.flatMap((payload): Array<unknown> => {
94 try {
95 return [JSON.parse(payload)];
96 } catch {
97 return [];
98 }
99 }),
100 ),
101 ),
102 ),
103 put: (key, entry: ProjectFaviconEntry) =>
104 runDatabase((database) =>
105 database.saveCache(
106 entry.environmentId,
107 CACHE_KIND,
108 key,
109 CACHE_SCHEMA_VERSION,
110 JSON.stringify(entry),
111 ),
112 ),
113 remove: (key, entry) =>
114 runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)),
115 },
116 load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }),
117});