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
17export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) {
18 database = service;
19}
20
21const runDatabase = <A, E>(
22 use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect<A, E>,
23) =>
24 database
25 ? Effect.runPromise(use(database))
26 : Promise.reject(new Error("Project icon storage is not attached."));
27
28/** expo-image encodes only through disk cache; remove the temporary frame after reading bytes. */
29export async function downscaleProjectFavicon(
30 image: { readonly url: string },
31 signal: AbortSignal,
32) {
33 const [{ Image }, { File }] = await Promise.all([
34 import("expo-image"),
35 import("expo-file-system"),
36 ]);
37 for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) {
38 signal.throwIfAborted();
39 const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size });
40 const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`;
41 try {
42 signal.throwIfAborted();
43 if (decoded.width > size || decoded.height > size) {
44 throw new Error("Project icon was not resized.");
45 }
46 await Image.writeToCacheAsync(decoded, cacheKey);
47 const path = await Image.getCachePathAsync(cacheKey);
48 if (!path) throw new Error("Project icon thumbnail was not written.");
49 const file = new File(path.startsWith("file:") ? path : `file://${path}`);
50 try {
51 if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue;
52 const base64 = await file.base64();
53 // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG.
54 const mimeType = base64.startsWith("/9j/")
55 ? "image/jpeg"
56 : base64.startsWith("iVBORw0KGgo")
57 ? "image/png"
58 : null;
59 if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding.");
60 const dataUrl = `data:${mimeType};base64,${base64}`;
61 if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl;
62 } finally {
63 file.delete();
64 }
65 } finally {
66 decoded.release();
67 }
68 }
69 throw new Error("Project icon thumbnail exceeds the cache limit.");
70}
71
72export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({
73 storage: {
74 list: () =>
75 runDatabase((database) =>
76 database.listCache(CACHE_KIND).pipe(
77 Effect.map((payloads) =>
78 payloads.flatMap((payload): Array<unknown> => {
79 try {
80 return [JSON.parse(payload)];
81 } catch {
82 return [];
83 }
84 }),
85 ),
86 ),
87 ),
88 put: (key, entry: ProjectFaviconEntry) =>
89 runDatabase((database) =>
90 database.saveCache(
91 entry.environmentId,
92 CACHE_KIND,
93 key,
94 CACHE_SCHEMA_VERSION,
95 JSON.stringify(entry),
96 ),
97 ),
98 remove: (key, entry) =>
99 runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)),
100 },
101 load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }),
102});