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
3 changes: 3 additions & 0 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment thread
arul28 marked this conversation as resolved.
]);

const layout = resolveMachineAdeLayout();
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 2 additions & 10 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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",
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64"/></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",
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="32"/></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");
});
});
163 changes: 163 additions & 0 deletions apps/desktop/src/main/services/projects/projectIconThumbnail.ts
Original file line number Diff line number Diff line change
@@ -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<string, ThumbnailCacheEntry>();

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<typeof resolveProjectIcon>;
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;
}
25 changes: 18 additions & 7 deletions docs/features/project-home/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down