Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions apps/mobile/src/components/ProjectFavicon.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import { View } from "react-native";
import type { EnvironmentId } from "@t3tools/contracts";
import {
getProjectFaviconCacheKey,
getProjectFaviconResourceKey,
isProjectFaviconFallbackUrl,
} from "@t3tools/shared/projectFavicon";
import { useAssetUrl } from "../state/assets";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/unstable/reactivity";
import { projectFaviconUrlAtom } from "../state/assets";

import {
beginProjectFaviconRequest,
createProjectFaviconRequest,
Expand All @@ -16,6 +20,8 @@ import {
markProjectFaviconLoaded,
} from "./projectFaviconCache";

const EMPTY_FAVICON_URL = Atom.make<string | null>(null);

/* ─── Component ──────────────────────────────────────────────────────── */
export function ProjectFavicon(props: {
readonly environmentId: EnvironmentId;
Expand All @@ -26,20 +32,23 @@ export function ProjectFavicon(props: {
readonly faviconPath?: string | null;
}) {
const size = props.size ?? 42;
const faviconUrl = useAssetUrl(
props.environmentId,
props.workspaceRoot === null || props.workspaceRoot === undefined
? null
: {
_tag: "project-favicon",
const faviconUrl = useAtomValue(
props.workspaceRoot == null
? EMPTY_FAVICON_URL
: projectFaviconUrlAtom({
environmentId: props.environmentId,
cwd: props.workspaceRoot,
...(props.faviconPath ? { path: props.faviconPath } : {}),
},
faviconPath: props.faviconPath,
}),
);
const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
// Inline images are self-contained; remote URLs key on their revision so signed-token
// rotation reuses the disk cache while a changed icon starts from the loading state.
const cacheKey =
renderableFaviconUrl && props.workspaceRoot
? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
? renderableFaviconUrl.startsWith("data:")
? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath)
: getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
: null;

return (
Expand Down Expand Up @@ -75,7 +84,9 @@ function ProjectFaviconImage(props: {
}, [faviconRequest]);

const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading",
props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey)
? "loaded"
: "loading",
);

const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
Expand Down Expand Up @@ -104,11 +115,12 @@ function ProjectFaviconImage(props: {
{requestIsActive ? (
<Image
key={faviconRequest.faviconUrl}
source={{
uri: faviconRequest.faviconUrl,
cacheKey: faviconRequest.cacheKey,
}}
cachePolicy="memory-disk"
source={
faviconRequest.faviconUrl.startsWith("data:")
? { uri: faviconRequest.faviconUrl }
: { uri: faviconRequest.faviconUrl, cacheKey: faviconRequest.cacheKey }
}
cachePolicy={faviconRequest.faviconUrl.startsWith("data:") ? "memory" : "memory-disk"}
recyclingKey={faviconRequest.cacheKey}
accessibilityLabel={`${props.projectTitle} favicon`}
style={{
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/connection/environment-cache-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ function makeDatabase() {
const database = MobileDatabase.of({
loadCache: (environmentId, kind, cacheKey) =>
Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))),
listCache: (kind) =>
Effect.sync(() =>
[...values.entries()]
.filter(([key]) => key.split(":")[1] === kind)
.map(([, payload]) => payload),
),
saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) =>
Effect.sync(() => {
values.set(cacheId(environmentId, kind, cacheKey), payload);
Expand Down
11 changes: 7 additions & 4 deletions apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as MobileDatabase from "../persistence/mobile-database";
import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache";

const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
Expand Down Expand Up @@ -115,6 +116,7 @@ function loadDecodedCache<A, B>(input: {

export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
const database = yield* MobileDatabase.MobileDatabase;
attachProjectFaviconDatabase(database);
return EnvironmentCacheStore.of({
loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
loadDecodedCache({
Expand All @@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
decode: decodeStoredShellSnapshot,
select: (stored) =>
stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
}),
}).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))),
),
saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
const payload = yield* encodeStoredShellSnapshot({
Expand Down Expand Up @@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
.pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),
),
clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>
database
.clearEnvironmentCache(environmentId)
.pipe(Effect.mapError(mapDatabaseError("clear-environment"))),
Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe(
Effect.andThen(database.clearEnvironmentCache(environmentId)),
Effect.mapError(mapDatabaseError("clear-environment")),
),
),
});
});
Expand Down
79 changes: 79 additions & 0 deletions apps/mobile/src/lib/projectFaviconCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache";

const native = vi.hoisted(() => ({
load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({
width: options.maxWidth,
height: options.maxHeight,
release: vi.fn(),
})),
write: vi.fn(async () => {}),
path: vi.fn(async () => "/cache/thumbnail"),
read: vi.fn(),
remove: vi.fn(),
}));
vi.mock("expo-image", () => ({
Image: {
loadAsync: native.load,
writeToCacheAsync: native.write,
getCachePathAsync: native.path,
},
}));
vi.mock("expo-file-system", () => ({
File: class {
size = 24_000;
base64 = native.read;
delete = native.remove;
},
}));

import { downscaleProjectFavicon } from "./projectFaviconCache";

const png = "iVBORw0KGgoAAAAA";
const image = { url: "https://remote/icon.png" };

beforeEach(() => {
vi.clearAllMocks();
native.read.mockReset().mockResolvedValue(png);
native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({
width: maxWidth,
height: maxWidth,
release: vi.fn(),
}));
});

describe("mobile project icon thumbnails", () => {
it("reduces an oversized encoding and deletes temporary thumbnail files", async () => {
native.read.mockResolvedValueOnce(
`iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`,
);
const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal);
expect(thumbnail).toBe(`data:image/png;base64,${png}`);
expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]);
expect(native.remove).toHaveBeenCalledTimes(2);
for (const call of native.load.mock.results)
expect((await call.value).release).toHaveBeenCalledOnce();
});

it("releases a decoded image when its request was canceled", async () => {
const controller = new AbortController();
const release = vi.fn();
native.load.mockImplementationOnce(async () => {
controller.abort();
return { width: 96, height: 96, release };
});
await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow();
expect(release).toHaveBeenCalledOnce();
expect(native.write).not.toHaveBeenCalled();
});

it("rejects an image the native decoder did not downsize", async () => {
const release = vi.fn();
native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release });
await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow(
"not resized",
);
expect(native.write).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledOnce();
});
});
112 changes: 112 additions & 0 deletions apps/mobile/src/lib/projectFaviconCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
createProjectFaviconCache,
createProjectFaviconImageLoader,
PROJECT_FAVICON_MAX_DATA_URL_LENGTH,
PROJECT_FAVICON_THUMBNAIL_SIZE,
type ProjectFaviconEntry,
} from "@t3tools/client-runtime/project-favicon-cache";
import * as Effect from "effect/Effect";

import * as MobileDatabase from "../persistence/mobile-database";

const CACHE_KIND = "project-favicon";
const CACHE_SCHEMA_VERSION = 1;

let database: MobileDatabase.MobileDatabase["Service"] | undefined;

/**
* The cache is a module singleton because the favicon atom family holds it outside
* any Effect runtime. Its rows live in `client_cache`, so the environment cache store
* hands over the database it already owns instead of the cache re-entering the runtime.
*/
export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) {
database = service;
}

const runDatabase = <A, E>(
use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect<A, E>,
) =>
database
? Effect.runPromise(use(database))
: Promise.reject(new Error("Project icon storage is not attached."));

/**
* Rasterizes a bitmap that is too large to inline. The native decoder writes the
* downsized frame to expo-image's disk cache, which is the only encode path it
* exposes; the temporary entry is removed once its bytes are read.
*/
export async function downscaleProjectFavicon(
image: { readonly url: string },
signal: AbortSignal,
) {
const [{ Image }, { File }] = await Promise.all([
import("expo-image"),
import("expo-file-system"),
]);
for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) {
signal.throwIfAborted();
const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size });
const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`;
try {
signal.throwIfAborted();
if (decoded.width > size || decoded.height > size) {
throw new Error("Project icon was not resized.");
}
await Image.writeToCacheAsync(decoded, cacheKey);
const path = await Image.getCachePathAsync(cacheKey);
if (!path) throw new Error("Project icon thumbnail was not written.");
const file = new File(path.startsWith("file:") ? path : `file://${path}`);
try {
if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue;
const base64 = await file.base64();
// SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG.
const mimeType = base64.startsWith("/9j/")
? "image/jpeg"
: base64.startsWith("iVBORw0KGgo")
? "image/png"
: null;
if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding.");
const dataUrl = `data:${mimeType};base64,${base64}`;
if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl;
} finally {
file.delete();
}
} finally {
decoded.release();
}
}
throw new Error("Project icon thumbnail exceeds the cache limit.");
}

/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */
export const projectFaviconCache = createProjectFaviconCache({
storage: {
list: () =>
runDatabase((database) =>
database.listCache(CACHE_KIND).pipe(
Effect.map((payloads) =>
payloads.flatMap((payload): Array<unknown> => {
try {
return [JSON.parse(payload)];
} catch {
return [];
}
}),
),
),
),
put: (key, entry: ProjectFaviconEntry) =>
runDatabase((database) =>
database.saveCache(
entry.environmentId,
CACHE_KIND,
key,
CACHE_SCHEMA_VERSION,
JSON.stringify(entry),
),
),
remove: (key, entry) =>
runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)),
},
load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }),
});
23 changes: 22 additions & 1 deletion apps/mobile/src/persistence/mobile-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [
"connection-vcs-refs",
] as const;

export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]);
export const ClientCacheKind = Schema.Literals([
"shell",
"thread",
"server-config",
"vcs-refs",
"project-favicon",
]);
export type ClientCacheKind = typeof ClientCacheKind.Type;

export interface ClientCacheSummaryRow {
Expand Down Expand Up @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([
"open",
"migrate",
"load-cache",
"list-cache",
"save-cache",
"remove-cache",
"clear-cache-kind",
Expand Down Expand Up @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service<
kind: ClientCacheKind,
cacheKey: string,
) => Effect.Effect<Option.Option<string>, MobileDatabaseError>;
readonly listCache: (
kind: ClientCacheKind,
) => Effect.Effect<ReadonlyArray<string>, MobileDatabaseError>;
readonly saveCache: (
environmentId: EnvironmentId,
kind: ClientCacheKind,
Expand Down Expand Up @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () {
catch: databaseError("load-cache"),
}).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))),
),
listCache: Effect.fn("MobileDatabase.listCache")((kind) =>
Effect.tryPromise({
try: () =>
database.getAllAsync<{ readonly payload: string }>(
"SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at",
kind,
),
catch: databaseError("list-cache"),
}).pipe(Effect.map((rows) => rows.map((row) => row.payload))),
),
saveCache: Effect.fn("MobileDatabase.saveCache")(
(environmentId, kind, cacheKey, schemaVersion, payload) =>
Effect.tryPromise({
Expand Down Expand Up @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"]
const fail = Effect.fail(error);
return MobileDatabase.of({
loadCache: () => fail,
listCache: () => fail,
saveCache: () => fail,
removeCache: () => fail,
clearCacheKind: () => fail,
Expand Down
Loading
Loading