diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 13cbe26cb..cd2c63fb9 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -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` ``, 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 `/.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 diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index 44761abe9..f14e9602a 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -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; diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 664c45d5e..dcc07114e 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -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, @@ -186,6 +187,60 @@ function createMachineProjectScaffoldService() { }); } +type ResolvedProjectIcon = ReturnType; + +// 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( + 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( + records: readonly T[], +): Array { + const icons = new Map(); + 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); @@ -474,7 +529,7 @@ export function createMultiProjectRpcRequestHandler( } if (method === "projects.list") { - return projectRegistry.list(); + return decorateProjectListWithIcons(projectRegistry.list()); } if (method === "projects.add") { @@ -486,7 +541,7 @@ export function createMultiProjectRpcRequestHandler( "projects.add requires rootPath.", ); } - return projectRegistry.add(rootPath); + return decorateProjectWithIcon(projectRegistry.add(rootPath)); } if (method === "projects.remove") { @@ -548,7 +603,7 @@ export function createMultiProjectRpcRequestHandler( await createMachineProjectScaffoldService().createLocalProject( readCreateProjectInput(params), ); - return projectRegistry.add(result.rootPath); + return decorateProjectWithIcon(projectRegistry.add(result.rootPath)); } if (method === "projects.clone") { @@ -556,7 +611,7 @@ export function createMultiProjectRpcRequestHandler( await createMachineProjectScaffoldService().cloneRepository( readCloneProjectInput(params), ); - return projectRegistry.add(result.rootPath); + return decorateProjectWithIcon(projectRegistry.add(result.rootPath)); } if (method === "projects.listMyGitHubRepos") { diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts new file mode 100644 index 000000000..30df38b73 --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts @@ -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(); + +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"), ""); + + 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(""); + }); + + 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 ", () => { + const root = makeTempRoot(); + writeFileEnsuringDir( + path.join(root, "index.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 }); + }); +}); diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.ts b/apps/ade-cli/src/services/projects/projectIconResolver.ts new file mode 100644 index 000000000..e934b6b3b --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectIconResolver.ts @@ -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; + } +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 659bdaae8..c0b2fe31d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -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( diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index df89b2f94..4dacc12a9 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -868,6 +868,7 @@ describe("registerRuntimeBridge", () => { projectId: "project-1", rootPath: "/srv/ade", displayName: "ADE", + iconDataUrl: null, }); expect(remoteConnectMock).toHaveBeenCalledWith(target, { @@ -883,6 +884,7 @@ describe("registerRuntimeBridge", () => { projectId: "project-1", rootPath: "/srv/ade", displayName: "ADE", + iconDataUrl: null, }); }); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 7a00e278a..ebb58ea64 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -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, }; if ( isLatestOpenRequest() && diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 3e4263868..570aa43a5 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -1057,7 +1057,11 @@ describe("local runtime connection pool", () => { const pool = new LocalRuntimeConnectionPool("1.2.3", logger as never); (pool as unknown as { createConnection: () => Promise }).createConnection = createConnection; - await expect(pool.ensureProject(rootPath)).resolves.toEqual(project); + // ensureProject coerces the record, which stamps a null icon by default. + await expect(pool.ensureProject(rootPath)).resolves.toEqual({ + ...project, + icon: null, + }); expect(createConnection).toHaveBeenCalledTimes(2); expect(firstClient.call).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 7cfbf1060..36ac902ac 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -11,6 +11,7 @@ import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; import { bootstrapRemoteRuntime, buildRemoteRuntimeEnvironmentPrefix, + coerceProjects, normalizeRemoteArch, normalizeRuntimeVersion, resolveRemoteRuntimeLayout, @@ -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(); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index b18d4e0fc..cbbc867b8 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -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, @@ -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; + 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) => { @@ -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), }]; }); } diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index b4e8c95f0..7cfe9a288 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -530,6 +530,7 @@ function ProjectTabIcon({ animate, disabled, readOnly = false, + iconDataUrlOverride, onAccentColorChange, }: { rootPath: string; @@ -537,6 +538,13 @@ function ProjectTabIcon({ animate: boolean; disabled: boolean; readOnly?: boolean; + /** + * When defined, the caller owns this tab's icon (remote tabs, whose files + * live on another machine). A non-empty data URL is rendered directly; null + * falls back to the folder glyph. Either way the local resolveIcon path is + * skipped, since it can only read the local filesystem. + */ + iconDataUrlOverride?: string | null; onAccentColorChange?: (rootPath: string, color: string | null) => void; }) { const [icon, setIcon] = useState(() => @@ -548,8 +556,22 @@ function ProjectTabIcon({ const [removing, setRemoving] = useState(false); const [iconError, setIconError] = useState(null); + // Remote tabs supply their icon via the override (resolved on the host), so + // the local resolveIcon path is bypassed entirely. + const managedIcon = iconDataUrlOverride !== undefined; + const overrideIcon: ProjectIcon | null = iconDataUrlOverride + ? { dataUrl: iconDataUrlOverride, sourcePath: null, mimeType: null } + : null; + const displayIcon: ProjectIcon | null = managedIcon ? overrideIcon : icon; + useEffect(() => { setFailed(false); + // Caller-managed icons (remote tabs) never resolve against the local + // filesystem — the project lives on another machine. + if (managedIcon) { + setIcon(null); + return; + } // Honor `disabled` (e.g. project marked missing) BEFORE consulting the // cache. Otherwise a project that was successfully resolved earlier in // the session keeps showing its stale icon after it goes missing. @@ -584,7 +606,7 @@ function ProjectTabIcon({ cancelled = true; window.clearTimeout(timer); }; - }, [disabled, isCurrent, rootPath]); + }, [disabled, isCurrent, rootPath, managedIcon, iconDataUrlOverride]); useEffect(() => { let cancelled = false; @@ -620,11 +642,11 @@ function ProjectTabIcon({ ); const iconNode = - !icon?.dataUrl || failed ? ( + !displayIcon?.dataUrl || failed ? ( fallbackIcon ) : ( {remoteTab.displayName} @@ -1977,7 +2000,7 @@ export function TopBar() { )} diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index c636ad006..d4adfcdf4 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -150,6 +150,12 @@ export type OpenProjectBinding = projectId: string; rootPath: string; displayName: string; + /** + * The remote project's icon as a base64 data URL, resolved on the host + * machine. Lets the project tab show the real project logo instead of a + * blank folder. Null/absent when the host couldn't resolve an icon. + */ + iconDataUrl?: string | null; }; export type AppNavigationTarget = diff --git a/apps/desktop/src/shared/types/remoteRuntime.ts b/apps/desktop/src/shared/types/remoteRuntime.ts index 53ed19f6f..d05f45c7a 100644 --- a/apps/desktop/src/shared/types/remoteRuntime.ts +++ b/apps/desktop/src/shared/types/remoteRuntime.ts @@ -1,3 +1,5 @@ +import type { ProjectIcon } from "./core"; + export type RemoteRuntimeTargetRouteSource = | "manual" | "bonjour" @@ -70,6 +72,13 @@ export type RemoteRuntimeProjectRecord = { addedAt: number; lastOpenedAt: number; gitOriginUrl: string | null; + /** + * The project's icon as resolved on the host machine, inlined as a base64 + * data URL so a connected desktop can render the real project logo in its + * tab instead of a blank folder. Null/absent when the host can't resolve an + * icon (older host, no icon file, or an oversized icon dropped from the wire). + */ + icon?: ProjectIcon | null; }; export type RemoteRuntimeConnectResult = { diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md index 806ff629e..54f0cb3c1 100644 --- a/docs/features/project-home/README.md +++ b/docs/features/project-home/README.md @@ -235,9 +235,11 @@ Shared types: `LaneOverlayPolicy`, `ProxyConfig`, `PortLease`, `LanePreviewInfo`. - `apps/desktop/src/shared/types/core.ts` — `ProjectIcon` (`{ dataUrl, sourcePath, mimeType }`), `RecentProjectSummary` (`kind`, `remote`, - `pinned`), remote `OpenProjectBinding` metadata, and `ProjectDetail` - dirty breakdowns consumed by the TopBar tab strip, welcome rows, - project browser preview, and mobile-facing project catalog. + `pinned`), remote `OpenProjectBinding` metadata (including + `iconDataUrl`, the host-resolved logo for the remote project tab), + and `ProjectDetail` dirty breakdowns consumed by the TopBar tab + strip, welcome rows, project browser preview, and mobile-facing + project catalog. Preload bridge: @@ -491,6 +493,26 @@ detection or override). The override is committed to `.ade/ade.yaml` the `.ade/project-icons/` directory is part of the tracked shared scaffold so the actual bytes travel with the override. +Remote project tabs cannot run the local resolver — the project files +live on another machine. Instead the host brain resolves the icon and +inlines it: the ade-cli `projects.list` RPC stamps each record with an +`icon: { dataUrl, sourcePath, mimeType }` produced by +`resolveRemoteProjectIcon` (`apps/ade-cli/src/services/projects/projectIconResolver.ts`), +a compact electron-free port of the desktop resolver that covers the +`.ade/ade.yaml` override, the conventional icon/logo files, and an +`index.html` `` (resolution is best-effort and +capped at 2 MB so it stays inline-safe on the wire; a failure for one +project degrades to a null icon rather than breaking the list). That +icon rides through `RemoteRuntimeProjectRecord.icon` → +`OpenProjectBinding.iconDataUrl` → the remote project tab. `TopBar`'s +`ProjectTabIcon` takes an `iconDataUrlOverride`: when the caller owns +the icon (remote tabs), it renders the data URL directly and skips the +local `resolveIcon` path entirely (falling back to the folder glyph +when the host returned no icon). The binding's `iconDataUrl` is +persisted to `globalState.lastRemoteProjectBinding` and restored on a +cold start so the real logo shows immediately, before the remote +reconnects and refreshes it. + The mobile companion gets the icon through a dedicated path: the host's `mobileProjectSummaryForContext` / `mobileProjectSummaryForRecent` in `apps/desktop/src/main/main.ts` runs `resolveMobileProjectIconDataUrl` diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 025a6249d..f0c04941d 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -57,9 +57,16 @@ The wire transport is the same JSON-RPC the local machine runtime answers. The r remote runtime are localized through a local TCP forward before the renderer opens them. - `apps/ade-cli/src/multiProjectRpcServer.ts` — runtime-level project catalog - and sync methods plus project-scoped action dispatch. -- `apps/ade-cli/src/services/projects/` — machine project registry and - per-project service scope cache. + and sync methods plus project-scoped action dispatch. `projects.list` inlines + each project's host-resolved icon (`icon: { dataUrl, sourcePath, mimeType }`) + so a connected desktop can render the real project logo in its remote tab + instead of a blank folder. +- `apps/ade-cli/src/services/projects/` — machine project registry, + per-project service scope cache, and `projectIconResolver.ts` + (`resolveRemoteProjectIcon`, an electron-free port of the desktop icon + resolver: `.ade/ade.yaml` override + conventional icon/logo files + + `index.html` ``, best-effort and capped at 2 MB to stay + inline-safe on the wire). - `apps/ade-cli/scripts/build-static.mjs` — produces the static `ade-` SEA binary and the `.native.tar.gz` of native modules, resolves the runtime version from the CLI / desktop package metadata, and @@ -147,7 +154,7 @@ After install, the headless machine can already serve clients. Desktop ADE on a ## What works remotely -Remote project bindings route lanes, agent chat, PTYs, terminal IO, file operations, file-watch notifications, git actions, PR actions, PR queue automation, PR AI conflict-resolution sessions, PR issue-resolution launch flows, AI PR summaries, issue inventory, and event streaming through the remote runtime. Remote lane preview URLs are opened through a local TCP forward created by the desktop, so a dev server bound to `127.0.0.1` on the remote can be inspected from the local window. Agent CLI failures (Claude / Codex / Cursor / Droid not installed or not authenticated) surface as inline `AgentCliAuthCard` cards in chat; the install / login buttons open a tracked terminal in the active runtime, so a remote project runs the install or login command on the remote machine. +Remote project bindings route lanes, agent chat, PTYs, terminal IO, file operations, file-watch notifications, git actions, PR actions, PR queue automation, PR AI conflict-resolution sessions, PR issue-resolution launch flows, AI PR summaries, issue inventory, and event streaming through the remote runtime. Remote lane preview URLs are opened through a local TCP forward created by the desktop, so a dev server bound to `127.0.0.1` on the remote can be inspected from the local window. A connected remote project's tab shows the real project logo and a yellow connected accent: the host brain resolves the icon and inlines it on `projects.list`, the desktop threads it through `RemoteRuntimeProjectRecord.icon` → `OpenProjectBinding.iconDataUrl` to the tab, and persists it so the logo is restored on a cold start before the remote reconnects. Agent CLI failures (Claude / Codex / Cursor / Droid not installed or not authenticated) surface as inline `AgentCliAuthCard` cards in chat; the install / login buttons open a tracked terminal in the active runtime, so a remote project runs the install or login command on the remote machine. Local project bindings use the local ADE runtime for the same surfaces — agent chat, session history, PTYs, terminal reads/writes, file operations and watchers, diffs, lanes, PRs, PR queues, PR issue-resolution launch flows, PR AI conflict-resolution sessions, issue inventory, tests, processes, project config, and most git operations. Electron main still owns desktop-only services that physically require an Electron host.