Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 8 additions & 1 deletion apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,14 @@ describe("multi-project RPC server", () => {
method: "projects.list",
params: {},
});
expect(listed).toEqual([added]);
// projects.list inlines each project's resolved icon; the temp project
// root has no icon file, so resolution yields the all-null icon.
expect(listed).toEqual([
{
...(added as Record<string, unknown>),
icon: { dataUrl: null, sourcePath: null, mimeType: null },
},
]);

const projectId = (added as { projectId: string }).projectId;
const touched = await handler({
Expand Down
10 changes: 9 additions & 1 deletion 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 @@ -474,7 +475,14 @@ export function createMultiProjectRpcRequestHandler(
}

if (method === "projects.list") {
return projectRegistry.list();
// Inline each project's icon so a remote desktop can render the real
// project logo in its tab instead of a blank folder. Resolution is a
// best-effort local filesystem read; a failure for one project must not
// break the whole list, so it degrades to a null icon.
return projectRegistry.list().map((record) => ({
...record,
icon: resolveRemoteProjectIcon(record.rootPath),
}));
}

if (method === "projects.add") {
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 });
});
});
49 changes: 49 additions & 0 deletions apps/ade-cli/src/services/projects/projectIconResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { resolveProjectIcon } 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. The only thing layered on top is
* a wire-size cap: the desktop resolver inlines the icon at full resolution
* (capped at 10 MB on disk), but these icons travel inline in the `projects.list`
* payload, so we drop any data URL above 2 MB while keeping the metadata.
*/
export type RemoteProjectIcon = {
dataUrl: string | null;
sourcePath: string | null;
mimeType: string | null;
};

const REMOTE_ICON_MAX_DATAURL_BYTES = 2 * 1024 * 1024;

const EMPTY_ICON: RemoteProjectIcon = {
dataUrl: null,
sourcePath: null,
mimeType: null,
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

export function resolveRemoteProjectIcon(projectRoot: string): RemoteProjectIcon {
if (typeof projectRoot !== "string" || projectRoot.trim().length === 0) {
return EMPTY_ICON;
}
let icon: RemoteProjectIcon;
try {
icon = resolveProjectIcon(projectRoot.trim());
} catch {
return EMPTY_ICON;
}
// Drop an oversized inline icon but keep its metadata so the desktop still
// knows an icon exists (it falls back to the folder glyph).
if (
icon.dataUrl &&
Buffer.byteLength(icon.dataUrl, "utf8") > REMOTE_ICON_MAX_DATAURL_BYTES

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid caching oversized remote icons before dropping them

When a project icon is larger than the 2 MB remote cap but still under the desktop resolver's 10 MB cap, resolveProjectIcon() has already read, base64-encoded, and stored the full data URL in its shared result cache before this size check drops it from the wire payload. On a remote host with several large project icons, a simple projects.list can retain hundreds of MB in the ADE RPC process even though none of those icons are sent. Please preflight the resolved file size or use a remote-specific resolver/cache cap before creating the full data URL.

Useful? React with 👍 / 👎.

) {
return { dataUrl: null, sourcePath: icon.sourcePath, mimeType: icon.mimeType };
}
return icon;
}
3 changes: 3 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,9 @@ app.whenReady().then(async () => {
projectId,
rootPath,
displayName: readString(record, "displayName") ?? path.basename(rootPath),
// Restore the cached project logo so the tab shows it immediately on a
// cold start, before the remote reconnects and refreshes the icon.
iconDataUrl: readString(record, "iconDataUrl") ?? null,
};
};
const savedRemoteProjectBinding = parseSavedRemoteProjectBinding(
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/ipc/runtimeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ export function registerRuntimeBridge({
projectId: project.projectId,
rootPath: project.rootPath,
displayName: project.displayName || path.basename(project.rootPath),
iconDataUrl: project.icon?.dataUrl ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh icons when opening newly registered remote projects

When the user adds, creates, or clones a remote project and immediately opens it, the project record in connection.projects came from projects.add/projects.create/projects.clone, which still return bare registry records without icon. Since this open path only refreshes the project list when the project is missing, it persists iconDataUrl: null even if the host would provide an icon via projects.list, so the new remote tab shows the folder glyph until a later refresh/reopen. Please decorate registration results with icons or refresh when project.icon is absent.

Useful? React with 👍 / 👎.

};
if (
isLatestOpenRequest() &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { RemoteTargetRegistry } from "./remoteTargetRegistry";
import {
bootstrapRemoteRuntime,
buildRemoteRuntimeEnvironmentPrefix,
coerceProjects,
normalizeRemoteArch,
normalizeRuntimeVersion,
resolveRemoteRuntimeLayout,
Expand Down Expand Up @@ -1577,3 +1578,48 @@ describe("bootstrapRemoteRuntime upload flow", () => {
expect(commands.some((command) => command.includes("runtime stop --text"))).toBe(false);
});
});

describe("coerceProjects icon coercion", () => {
const baseRecord = {
projectId: "p1",
rootPath: "/home/user/proj",
displayName: "proj",
addedAt: 1,
lastOpenedAt: 2,
gitOriginUrl: null,
};

it("coerces a well-formed icon from the wire into a ProjectIcon", () => {
const [record] = coerceProjects([
{
...baseRecord,
icon: {
dataUrl: "data:image/png;base64,AAA",
sourcePath: "/home/user/proj/logo.png",
mimeType: "image/png",
},
},
]);
expect(record.icon).toEqual({
dataUrl: "data:image/png;base64,AAA",
sourcePath: "/home/user/proj/logo.png",
mimeType: "image/png",
});
});

it("defaults the icon to null when the host omits it (older brain)", () => {
const [record] = coerceProjects([baseRecord]);
expect(record.icon).toBeNull();
});

it("rejects malformed icon payloads, coercing each to null", () => {
const [fromString, fromArray, fromWrongTypes] = coerceProjects([
{ ...baseRecord, projectId: "a", icon: "not-an-object" },
{ ...baseRecord, projectId: "b", icon: ["nope"] },
{ ...baseRecord, projectId: "c", icon: { dataUrl: 123, sourcePath: false } },
]);
expect(fromString.icon).toBeNull();
expect(fromArray.icon).toBeNull();
expect(fromWrongTypes.icon).toBeNull();
});
});
12 changes: 12 additions & 0 deletions apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Client, ConnectConfig, SFTPWrapper } from "ssh2";
import type { ProjectIcon } from "../../../shared/types/core";
import type {
RemoteRuntimeCapabilities,
RemoteRuntimeConnectResult,
Expand Down Expand Up @@ -1255,6 +1256,16 @@ async function openValidatedRuntimeClient(args: {
}
}

function coerceProjectIcon(value: unknown): ProjectIcon | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const dataUrl = typeof record.dataUrl === "string" ? record.dataUrl : null;
const sourcePath = typeof record.sourcePath === "string" ? record.sourcePath : null;
const mimeType = typeof record.mimeType === "string" ? record.mimeType : null;
if (dataUrl === null && sourcePath === null && mimeType === null) return null;
return { dataUrl, sourcePath, mimeType };
}

export function coerceProjects(value: unknown): RemoteRuntimeProjectRecord[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
Expand All @@ -1270,6 +1281,7 @@ export function coerceProjects(value: unknown): RemoteRuntimeProjectRecord[] {
addedAt: typeof record.addedAt === "number" ? record.addedAt : 0,
lastOpenedAt: typeof record.lastOpenedAt === "number" ? record.lastOpenedAt : 0,
gitOriginUrl: typeof record.gitOriginUrl === "string" ? record.gitOriginUrl : null,
icon: coerceProjectIcon(record.icon),
}];
});
}
Expand Down
Loading