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