diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts
index 3ef33ec55..e0c713bfb 100644
--- a/apps/ade-cli/src/cli.ts
+++ b/apps/ade-cli/src/cli.ts
@@ -12950,12 +12950,14 @@ async function runServe(
{ ProjectScopeRegistry },
{ createMultiProjectRpcRequestHandler },
{ createSharedSyncListener },
+ { resolveMobileProjectIconDataUrl },
] = await Promise.all([
import("./services/projects/machineLayout"),
import("./services/projects/projectRegistry"),
import("./services/projects/projectScope"),
import("./multiProjectRpcServer"),
import("./services/sync/sharedSyncListener"),
+ import("../../desktop/src/main/services/projects/projectIconThumbnail"),
]);
const layout = resolveMachineAdeLayout();
@@ -12999,6 +13001,7 @@ async function runServe(
record.lastOpenedAt > 0
? new Date(record.lastOpenedAt).toISOString()
: null,
+ iconDataUrl: resolveMobileProjectIconDataUrl(record.rootPath),
laneCount: 0,
isAvailable: true,
isCached: true,
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index 86e6d8a20..1828f85d2 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -83,7 +83,7 @@ import {
upsertProjectRow,
} from "./services/projects/projectService";
import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary";
-import { resolveProjectIcon } from "./services/projects/projectIconResolver";
+import { resolveMobileProjectIconDataUrl } from "./services/projects/projectIconThumbnail";
import { normalizeStartupProjectState, resolveStartupProject } from "./services/projects/startupProjectResolver";
import { createAdeProjectService } from "./services/projects/adeProjectService";
import { createConfigReloadService } from "./services/projects/configReloadService";
@@ -4903,15 +4903,7 @@ app.whenReady().then(async () => {
function mobileProjectIconDataUrl(projectRoot: string): string | null {
try {
- const icon = resolveProjectIcon(projectRoot);
- if (!icon.sourcePath) return null;
-
- const image = nativeImage.createFromPath(icon.sourcePath);
- if (!image.isEmpty()) {
- return image.resize({ width: 64, height: 64, quality: "best" }).toDataURL();
- }
-
- return icon.mimeType === "image/png" ? icon.dataUrl : null;
+ return resolveMobileProjectIconDataUrl(projectRoot, { nativeImage });
} catch {
return null;
}
diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts
index 8ec00db32..ecb1d8267 100644
--- a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts
+++ b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts
@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
removeProjectIconOverride,
@@ -10,8 +10,13 @@ import {
setProjectIconOverride,
setProjectIconOverrideFromSelection,
} from "./projectIconResolver";
+import { resolveMobileProjectIconDataUrl } from "./projectIconThumbnail";
const OVER_ICON_LIMIT_BYTES = 10 * 1024 * 1024 + 1;
+const PNG_DATA = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
+ "base64",
+);
function makeProjectRoot(): string {
// Resolve through realpath so the assertions still hold on platforms
@@ -179,4 +184,93 @@ describe("projectIconResolver", () => {
expect(icon.sourcePath).toContain("favicon.svg");
expect(icon.dataUrl).toMatch(/^data:image\/svg\+xml;base64,/);
});
+
+ it("uses an Electron nativeImage thumbnail for mobile when one can be decoded", () => {
+ const root = makeProjectRoot();
+ writeFile(root, "icon.png", PNG_DATA);
+ const rasterizeWithSips = vi.fn();
+
+ const dataUrl = resolveMobileProjectIconDataUrl(root, {
+ nativeImage: {
+ createFromPath: () => ({
+ isEmpty: () => false,
+ resize: () => ({
+ toDataURL: () => "data:image/png;base64,native-thumbnail",
+ }),
+ }),
+ },
+ rasterizeWithSips,
+ });
+
+ expect(dataUrl).toBe("data:image/png;base64,native-thumbnail");
+ expect(rasterizeWithSips).not.toHaveBeenCalled();
+ });
+
+ it("rasterizes SVG icons to a PNG data URL for mobile", () => {
+ const root = makeProjectRoot();
+ const iconPath = writeFile(
+ root,
+ "favicon.svg",
+ '',
+ );
+ const rasterizeWithSips = vi.fn((_sourcePath: string, outputPath: string) => {
+ fs.writeFileSync(outputPath, PNG_DATA);
+ });
+
+ const dataUrl = resolveMobileProjectIconDataUrl(root, {
+ nativeImage: {
+ createFromPath: () => ({
+ isEmpty: () => true,
+ resize: () => ({
+ toDataURL: () => "data:image/png;base64,unused",
+ }),
+ }),
+ },
+ rasterizeWithSips,
+ });
+
+ expect(dataUrl).toBe(`data:image/png;base64,${PNG_DATA.toString("base64")}`);
+ expect(rasterizeWithSips).toHaveBeenCalledWith(iconPath, expect.stringMatching(/icon\.png$/), 64);
+ });
+
+ it("falls back to raw PNG data for mobile when thumbnailing is unavailable", () => {
+ const root = makeProjectRoot();
+ writeFile(root, "icon.png", PNG_DATA);
+
+ const dataUrl = resolveMobileProjectIconDataUrl(root, {
+ rasterizeWithSips: () => {
+ throw new Error("sips unavailable");
+ },
+ });
+
+ expect(dataUrl).toBe(`data:image/png;base64,${PNG_DATA.toString("base64")}`);
+ });
+
+ it("keeps native and headless mobile thumbnail cache entries separate", () => {
+ const root = makeProjectRoot();
+ writeFile(
+ root,
+ "favicon.svg",
+ '',
+ );
+
+ const headlessMiss = resolveMobileProjectIconDataUrl(root, {
+ rasterizeWithSips: () => {
+ throw new Error("sips unavailable");
+ },
+ });
+ const nativeHit = resolveMobileProjectIconDataUrl(root, {
+ nativeImage: {
+ createFromPath: () => ({
+ isEmpty: () => false,
+ resize: () => ({
+ toDataURL: () => "data:image/png;base64,native-after-headless",
+ }),
+ }),
+ },
+ });
+
+ expect(headlessMiss).toBeNull();
+ expect(nativeHit).toBe("data:image/png;base64,native-after-headless");
+ });
});
diff --git a/apps/desktop/src/main/services/projects/projectIconThumbnail.ts b/apps/desktop/src/main/services/projects/projectIconThumbnail.ts
new file mode 100644
index 000000000..0b41bc808
--- /dev/null
+++ b/apps/desktop/src/main/services/projects/projectIconThumbnail.ts
@@ -0,0 +1,163 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { resolveProjectIcon } from "./projectIconResolver";
+
+const MOBILE_PROJECT_ICON_EDGE = 64;
+const MOBILE_PROJECT_ICON_THUMBNAIL_CACHE_MAX = 64;
+const SIPS_PATH = "/usr/bin/sips";
+
+type NativeImageInstanceLike = {
+ isEmpty(): boolean;
+ resize(options: { width: number; height: number; quality: "best" }): {
+ toDataURL(): string;
+ };
+};
+
+type NativeImageModuleLike = {
+ createFromPath(filePath: string): NativeImageInstanceLike;
+};
+
+type SipsRasterizer = (sourcePath: string, outputPath: string, edge: number) => void;
+
+type ThumbnailCacheEntry = {
+ mtimeMs: number;
+ size: number;
+ value: string | null;
+};
+
+type ResolveMobileProjectIconDataUrlOptions = {
+ nativeImage?: NativeImageModuleLike;
+ rasterizeWithSips?: SipsRasterizer;
+ tmpRoot?: string;
+};
+
+const thumbnailCache = new Map();
+
+function fileSignature(filePath: string): { mtimeMs: number; size: number } {
+ try {
+ const stat = fs.statSync(filePath);
+ return stat.isFile()
+ ? { mtimeMs: stat.mtimeMs, size: stat.size }
+ : { mtimeMs: -1, size: -1 };
+ } catch {
+ return { mtimeMs: -1, size: -1 };
+ }
+}
+
+function setThumbnailCache(key: string, entry: ThumbnailCacheEntry): void {
+ if (thumbnailCache.has(key)) {
+ thumbnailCache.delete(key);
+ } else if (thumbnailCache.size >= MOBILE_PROJECT_ICON_THUMBNAIL_CACHE_MAX) {
+ const oldestKey = thumbnailCache.keys().next().value;
+ if (oldestKey !== undefined) thumbnailCache.delete(oldestKey);
+ }
+ thumbnailCache.set(key, entry);
+}
+
+function thumbnailCacheKey(
+ sourcePath: string,
+ options: ResolveMobileProjectIconDataUrlOptions,
+): string {
+ const context = options.nativeImage ? "native" : "headless";
+ return `${context}:${sourcePath}`;
+}
+
+function defaultSipsRasterizer(sourcePath: string, outputPath: string, edge: number): void {
+ execFileSync(SIPS_PATH, [
+ "-Z",
+ String(edge),
+ "-s",
+ "format",
+ "png",
+ sourcePath,
+ "--out",
+ outputPath,
+ ], {
+ stdio: "ignore",
+ timeout: 5_000,
+ });
+}
+
+function nativeImagePngDataUrl(
+ sourcePath: string,
+ nativeImage: NativeImageModuleLike | undefined,
+): string | null {
+ if (!nativeImage) return null;
+ try {
+ const image = nativeImage.createFromPath(sourcePath);
+ if (image.isEmpty()) return null;
+ return image.resize({
+ width: MOBILE_PROJECT_ICON_EDGE,
+ height: MOBILE_PROJECT_ICON_EDGE,
+ quality: "best",
+ }).toDataURL();
+ } catch {
+ return null;
+ }
+}
+
+function sipsPngDataUrl(
+ sourcePath: string,
+ rasterizeWithSips: SipsRasterizer,
+ tmpRoot: string,
+): string | null {
+ let dir: string | null = null;
+ try {
+ dir = fs.mkdtempSync(path.join(tmpRoot, "ade-project-icon-"));
+ const outputPath = path.join(dir, "icon.png");
+ rasterizeWithSips(sourcePath, outputPath, MOBILE_PROJECT_ICON_EDGE);
+ const data = fs.readFileSync(outputPath);
+ return data.length > 0
+ ? `data:image/png;base64,${data.toString("base64")}`
+ : null;
+ } catch {
+ return null;
+ } finally {
+ try {
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // Best-effort cleanup.
+ }
+ }
+}
+
+export function resolveMobileProjectIconDataUrl(
+ projectRoot: string,
+ options: ResolveMobileProjectIconDataUrlOptions = {},
+): string | null {
+ let icon: ReturnType;
+ try {
+ icon = resolveProjectIcon(projectRoot);
+ } catch {
+ return null;
+ }
+ if (!icon.sourcePath) return null;
+
+ const signature = fileSignature(icon.sourcePath);
+ const cacheKey = thumbnailCacheKey(icon.sourcePath, options);
+ const cached = thumbnailCache.get(cacheKey);
+ if (
+ cached
+ && cached.mtimeMs === signature.mtimeMs
+ && cached.size === signature.size
+ ) {
+ thumbnailCache.delete(cacheKey);
+ thumbnailCache.set(cacheKey, cached);
+ return cached.value;
+ }
+
+ const value =
+ nativeImagePngDataUrl(icon.sourcePath, options.nativeImage)
+ ?? sipsPngDataUrl(
+ icon.sourcePath,
+ options.rasterizeWithSips ?? defaultSipsRasterizer,
+ options.tmpRoot ?? os.tmpdir(),
+ )
+ ?? (icon.mimeType === "image/png" ? icon.dataUrl : null);
+
+ setThumbnailCache(cacheKey, { ...signature, value });
+ return value;
+}
diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md
index b9c7dead3..f41d00fd8 100644
--- a/docs/features/project-home/README.md
+++ b/docs/features/project-home/README.md
@@ -199,11 +199,20 @@ main process because they execute before a runtime binding exists.
root is blocked end-to-end (probe paths run through
`resolvePathWithinRoot`, so symlinks pointing outside the worktree
silently fail to match instead of leaking files).
+- `apps/desktop/src/main/services/projects/projectIconThumbnail.ts` —
+ phone-facing thumbnail resolver for the mobile project catalog. It
+ reuses `resolveProjectIcon(rootPath)`, asks Electron `nativeImage` to
+ produce a 64px PNG when running in the desktop host, falls back to
+ macOS `/usr/bin/sips` for SVG / ICO / WebP sources that Electron does
+ not decode, and falls back to raw PNG data only when thumbnailing is
+ unavailable. Results are cached by source path + file signature and
+ temporary conversion files are removed after each attempt.
- `apps/desktop/src/main/services/projects/projectIconResolver.test.ts`
— vitest coverage: direct file matches, HTML link scrapes,
escape-attempt rejection, base64 data-URL emission, scoring
- preferences, and round-tripping `setProjectIconOverride` /
- `removeProjectIconOverride` against `.ade/ade.yaml`.
+ preferences, mobile PNG thumbnail generation, and round-tripping
+ `setProjectIconOverride` / `removeProjectIconOverride` against
+ `.ade/ade.yaml`.
Shared types:
@@ -444,11 +453,13 @@ scaffold so the actual bytes travel with the override.
The mobile companion gets the icon through a dedicated path: the host's
`mobileProjectSummaryForContext` / `mobileProjectSummaryForRecent` in
-`apps/desktop/src/main/main.ts` runs `resolveProjectIcon` on every
-project entry, downsamples it to 64×64 via Electron's
-`nativeImage.createFromPath(...).resize(...)` (PNG fallback for SVG /
-ICO sources that `nativeImage` can't read), and ships the resulting
-data URL to iOS as `MobileProjectSummary.iconDataUrl`. The iOS
+`apps/desktop/src/main/main.ts` runs `resolveMobileProjectIconDataUrl`
+on every project entry, which reuses `resolveProjectIcon`, downsamples
+the source image to a 64px PNG via Electron `nativeImage` when possible,
+and uses macOS `sips` as the conversion fallback for SVG / ICO / WebP
+sources that `nativeImage` cannot decode. The ADE CLI brain uses the
+same helper for its headless mobile project catalog. The resulting
+PNG data URL is sent to iOS as `MobileProjectSummary.iconDataUrl`; the iOS
`ProjectHomeView` renders that string as the project tile artwork.
## Data model