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
4 changes: 3 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,14 @@ ade init /path/to/project # adds an explicit path
…or call the same JSON-RPC methods directly:

```text
projects.list { } -> ProjectRecord[]
projects.list { } -> ProjectRecord[] # each record also carries a host-resolved icon
projects.add { rootPath } -> ProjectRecord
projects.remove { projectId } -> { removed }
projects.touch { projectId } -> ProjectRecord
```

`projects.list` stamps each returned record with an `icon: { dataUrl, sourcePath, mimeType }` resolved on the host (`resolveRemoteProjectIcon` in `src/services/projects/projectIconResolver.ts`) — a best-effort, electron-free icon lookup (`.ade/ade.yaml` override, conventional icon/logo files, `index.html` `<link rel="icon">`, capped at 2 MB) so a desktop connected over the remote runtime can show the real project logo in its tab instead of a blank folder. A per-project resolution failure degrades to a null icon and never breaks the list.

Adding a project creates `<rootPath>/.ade/` if needed but does not run any heavy onboarding. The first project-scoped JSON-RPC call lazily builds an `AdeRuntime` for that root via `ProjectScopeRegistry`.

## RPC surface
Expand Down
2 changes: 2 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ describe("multi-project RPC server", () => {
method: "projects.list",
params: {},
});
// Both projects.add and projects.list stamp the host-resolved icon; the
// temp project root has no icon file, so each yields the same all-null icon.
expect(listed).toEqual([added]);

const projectId = (added as { projectId: string }).projectId;
Expand Down
63 changes: 59 additions & 4 deletions apps/ade-cli/src/multiProjectRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type JsonRpcRequest,
} from "./jsonrpc";
import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver";
import {
ProjectRegistry,
type ProjectId,
Expand Down Expand Up @@ -186,6 +187,60 @@ function createMachineProjectScaffoldService() {
});
}

type ResolvedProjectIcon = ReturnType<typeof resolveRemoteProjectIcon>;

// Frozen so the shared fallback can't be mutated by a consumer and corrupt
// every subsequent budget-exceeded project record.
const EMPTY_PROJECT_ICON: ResolvedProjectIcon = Object.freeze({
dataUrl: null,
sourcePath: null,
mimeType: null,
});

// `projects.list` is on the connect-critical path (bootstrapRemoteRuntime awaits
// it before a target is "connected"), so bound the icon work it does: resolve at
// most this many icons and this many inlined bytes per call. A large or
// slow-filesystem registry then can't stall a connect just to render tab
// artwork — projects past the budget fall back to a null icon.
const LIST_ICON_COUNT_BUDGET = 64;
const LIST_ICON_BYTE_BUDGET = 12 * 1024 * 1024;

// Stamp a single project record with its host-resolved icon so a remote desktop
// can render the real project logo. Used for the records returned by
// add/create/clone (which feed the desktop's cached connection.projects), so a
// freshly registered project opens with its icon instead of a blank folder.
// Best-effort: a failed resolve degrades to a null icon and never throws.
function decorateProjectWithIcon<T extends { rootPath: string }>(
record: T,
): T & { icon: ResolvedProjectIcon } {
return { ...record, icon: resolveRemoteProjectIcon(record.rootPath) };
}

// Decorate a full project list with icons under the connect-path budget above.
// Icons are resolved for the most-recently-opened projects first (those most
// likely to be open as tabs) while the returned array stays in registry order.
function decorateProjectListWithIcons<T extends { rootPath: string; lastOpenedAt: number }>(
records: readonly T[],
): Array<T & { icon: ResolvedProjectIcon }> {
const icons = new Map<number, ResolvedProjectIcon>();
let count = 0;
let bytes = 0;
const byRecency = records
.map((record, index) => ({ record, index }))
.sort((a, b) => b.record.lastOpenedAt - a.record.lastOpenedAt);
for (const { record, index } of byRecency) {
if (count >= LIST_ICON_COUNT_BUDGET || bytes >= LIST_ICON_BYTE_BUDGET) break;
const icon = resolveRemoteProjectIcon(record.rootPath);
count += 1;
if (icon.dataUrl) bytes += icon.dataUrl.length;
icons.set(index, icon);
}
return records.map((record, index) => ({
...record,
icon: icons.get(index) ?? EMPTY_PROJECT_ICON,
}));
}

function defaultParentDir(projectRegistry: ProjectRegistry): string {
const first = projectRegistry.list()[0]?.rootPath;
if (first) return path.dirname(first);
Expand Down Expand Up @@ -474,7 +529,7 @@ export function createMultiProjectRpcRequestHandler(
}

if (method === "projects.list") {
return projectRegistry.list();
return decorateProjectListWithIcons(projectRegistry.list());
}

if (method === "projects.add") {
Expand All @@ -486,7 +541,7 @@ export function createMultiProjectRpcRequestHandler(
"projects.add requires rootPath.",
);
}
return projectRegistry.add(rootPath);
return decorateProjectWithIcon(projectRegistry.add(rootPath));
}

if (method === "projects.remove") {
Expand Down Expand Up @@ -548,15 +603,15 @@ export function createMultiProjectRpcRequestHandler(
await createMachineProjectScaffoldService().createLocalProject(
readCreateProjectInput(params),
);
return projectRegistry.add(result.rootPath);
return decorateProjectWithIcon(projectRegistry.add(result.rootPath));
}

if (method === "projects.clone") {
const result =
await createMachineProjectScaffoldService().cloneRepository(
readCloneProjectInput(params),
);
return projectRegistry.add(result.rootPath);
return decorateProjectWithIcon(projectRegistry.add(result.rootPath));
}

if (method === "projects.listMyGitHubRepos") {
Expand Down
166 changes: 166 additions & 0 deletions apps/ade-cli/src/services/projects/projectIconResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";

import { resolveRemoteProjectIcon } from "./projectIconResolver";

const tempRoots = new Set<string>();

function makeTempRoot(prefix = "ade-project-icon-"): string {
// realpath collapses the macOS /var -> /private/var tmpdir symlink so the
// resolver's within-root containment checks compare like-for-like paths.
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
tempRoots.add(root);
return root;
}

function writeFileEnsuringDir(filePath: string, data: Buffer | string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, data);
}

function decodeDataUrl(dataUrl: string): { mime: string; bytes: Buffer } {
const match = /^data:([^;]+);base64,(.*)$/s.exec(dataUrl);
if (!match) throw new Error(`not a base64 data url: ${dataUrl.slice(0, 32)}…`);
return { mime: match[1], bytes: Buffer.from(match[2], "base64") };
}

afterEach(() => {
for (const root of tempRoots) {
fs.rmSync(root, { recursive: true, force: true });
}
tempRoots.clear();
});

describe("resolveRemoteProjectIcon", () => {
it("returns an all-null icon when the project has no recognizable icon", () => {
const root = makeTempRoot();
writeFileEnsuringDir(path.join(root, "README.md"), "# no icon here");

const icon = resolveRemoteProjectIcon(root);

expect(icon.dataUrl).toBeNull();
expect(icon.sourcePath).toBeNull();
expect(icon.mimeType).toBeNull();
});

it("inlines a conventional PNG icon as a base64 data URL with the right mime", () => {
const root = makeTempRoot();
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
writeFileEnsuringDir(path.join(root, "logo.png"), pngBytes);

const icon = resolveRemoteProjectIcon(root);

expect(icon.mimeType).toBe("image/png");
expect(icon.sourcePath).toBe(path.join(root, "logo.png"));
expect(icon.dataUrl).not.toBeNull();
const decoded = decodeDataUrl(icon.dataUrl as string);
expect(decoded.mime).toBe("image/png");
// The inlined bytes must round-trip exactly, not a re-encoded approximation.
expect(decoded.bytes.equals(pngBytes)).toBe(true);
});

it("maps SVG icons to image/svg+xml", () => {
const root = makeTempRoot();
writeFileEnsuringDir(path.join(root, "public", "favicon.svg"), "<svg/>");

const icon = resolveRemoteProjectIcon(root);

expect(icon.mimeType).toBe("image/svg+xml");
expect(icon.sourcePath).toBe(path.join(root, "public", "favicon.svg"));
expect(decodeDataUrl(icon.dataUrl as string).bytes.toString("utf8")).toBe("<svg/>");
});

it("honors an explicit project.iconPath override in .ade/ade.yaml", () => {
const root = makeTempRoot();
// A conventional candidate exists, but the override must win.
writeFileEnsuringDir(path.join(root, "logo.png"), Buffer.from([1]));
writeFileEnsuringDir(path.join(root, "brand", "custom-icon.png"), Buffer.from([9, 9, 9]));
writeFileEnsuringDir(
path.join(root, ".ade", "ade.yaml"),
"version: 1\nproject:\n iconPath: brand/custom-icon.png\n",
);

const icon = resolveRemoteProjectIcon(root);

expect(icon.sourcePath).toBe(path.join(root, "brand", "custom-icon.png"));
expect(decodeDataUrl(icon.dataUrl as string).bytes.equals(Buffer.from([9, 9, 9]))).toBe(true);
});

it("treats an explicit null iconPath as 'no icon' even when a candidate exists", () => {
const root = makeTempRoot();
writeFileEnsuringDir(path.join(root, "logo.png"), Buffer.from([1, 2, 3]));
writeFileEnsuringDir(
path.join(root, ".ade", "ade.yaml"),
"version: 1\nproject:\n iconPath: null\n",
);

const icon = resolveRemoteProjectIcon(root);

expect(icon.dataUrl).toBeNull();
expect(icon.sourcePath).toBeNull();
expect(icon.mimeType).toBeNull();
});

it("resolves an icon referenced by index.html <link rel=icon>", () => {
const root = makeTempRoot();
writeFileEnsuringDir(
path.join(root, "index.html"),
'<!doctype html><html><head><link rel="icon" href="/brand.png"></head></html>',
);
writeFileEnsuringDir(path.join(root, "brand.png"), Buffer.from([7, 7]));

const icon = resolveRemoteProjectIcon(root);

expect(icon.sourcePath).toBe(path.join(root, "brand.png"));
expect(icon.mimeType).toBe("image/png");
});

it("rejects an iconPath override that escapes the project root via ..", () => {
const root = makeTempRoot();
const outside = path.join(path.dirname(root), `outside-${path.basename(root)}.png`);
fs.writeFileSync(outside, Buffer.from([0xde, 0xad]));
tempRoots.add(outside); // ensure cleanup
writeFileEnsuringDir(
path.join(root, ".ade", "ade.yaml"),
`version: 1\nproject:\n iconPath: ../${path.basename(outside)}\n`,
);

const icon = resolveRemoteProjectIcon(root);

expect(icon.dataUrl).toBeNull();
expect(icon.sourcePath).toBeNull();
});

it("does not follow a symlinked directory that points outside the project root", () => {
const root = makeTempRoot();
const outsideDir = makeTempRoot("ade-project-icon-outside-");
fs.writeFileSync(path.join(outsideDir, "favicon.png"), Buffer.from([0xca, 0xfe]));
// `public` is a symlink escaping the root; the public/favicon.png candidate
// must not be read.
fs.symlinkSync(outsideDir, path.join(root, "public"));

const icon = resolveRemoteProjectIcon(root);

expect(icon.dataUrl).toBeNull();
expect(icon.sourcePath).toBeNull();
});

it("skips an oversized icon but keeps its metadata", () => {
const root = makeTempRoot();
const big = Buffer.alloc(2 * 1024 * 1024 + 1, 0);
writeFileEnsuringDir(path.join(root, "logo.png"), big);

const icon = resolveRemoteProjectIcon(root);

expect(icon.dataUrl).toBeNull();
expect(icon.mimeType).toBe("image/png");
expect(icon.sourcePath).toBe(path.join(root, "logo.png"));
});

it("returns an all-null icon for an empty or whitespace root path", () => {
const icon = resolveRemoteProjectIcon(" ");
expect(icon).toEqual({ dataUrl: null, sourcePath: null, mimeType: null });
});
});
97 changes: 97 additions & 0 deletions apps/ade-cli/src/services/projects/projectIconResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import fs from "node:fs";
import path from "node:path";
import {
resolveProjectIcon,
resolveProjectIconPath,
} from "../../../../desktop/src/main/services/projects/projectIconResolver";

/**
* Resolves a project's icon on the machine that hosts the project files, so a
* desktop connected to this brain over the remote runtime can show the real
* project logo in its project tab instead of a blank folder.
*
* This reuses the desktop's `resolveProjectIcon` (already in the brain bundle —
* `cli.ts` imports the same module chain for the mobile sync icon path), so the
* icon a remote desktop sees is exactly the one the host machine would show,
* and we inherit its mtime-keyed result cache. Two things are layered on top:
* 1. A wire-size cap — these icons travel inline in the `projects.list`
* payload, so anything too large is dropped.
* 2. A size preflight via `resolveProjectIconPath` BEFORE `resolveProjectIcon`
* reads/encodes/caches the data URL, so an oversized icon is never inlined
* or retained in the resolver's cache just to be discarded.
*/
export type RemoteProjectIcon = {
dataUrl: string | null;
sourcePath: string | null;
mimeType: string | null;
};

// Cap on the raw icon file. base64 inflates ~33%, so a 2 MB file yields a
// ~2.7 MB data URL — an acceptable ceiling for inline transport, and well below
// the desktop resolver's 10 MB on-disk limit.
const REMOTE_ICON_MAX_FILE_BYTES = 2 * 1024 * 1024;

// Frozen so the shared singleton can't be mutated by a caller and silently
// corrupt every subsequent resolve.
const EMPTY_ICON: RemoteProjectIcon = Object.freeze({
dataUrl: null,
sourcePath: null,
mimeType: null,
});

function mimeTypeForIconPath(filePath: string): string | null {
switch (path.extname(filePath).toLowerCase()) {
case ".svg":
return "image/svg+xml";
case ".ico":
return "image/x-icon";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".webp":
return "image/webp";
default:
return null;
}
}

export function resolveRemoteProjectIcon(projectRoot: string): RemoteProjectIcon {
if (typeof projectRoot !== "string" || projectRoot.trim().length === 0) {
return EMPTY_ICON;
}
const root = projectRoot.trim();

let iconPath: string | null;
try {
iconPath = resolveProjectIconPath(root);
} catch {
return EMPTY_ICON;
}
if (!iconPath) return EMPTY_ICON;

// Preflight the file size BEFORE resolveProjectIcon reads, base64-encodes, and
// caches the full data URL. Without this, an oversized icon (under the
// desktop resolver's 10 MB cap) would be inlined and retained in the shared
// result cache even though we drop it from the wire.
let size: number;
try {
size = fs.statSync(iconPath).size;
} catch {
return EMPTY_ICON;
}
if (size > REMOTE_ICON_MAX_FILE_BYTES) {
return {
dataUrl: null,
sourcePath: iconPath,
mimeType: mimeTypeForIconPath(iconPath),
};
}

try {
return resolveProjectIcon(root);
} catch {
return EMPTY_ICON;
}
}
Loading