feat: add host-local custom project icons (#2335)

* feat(projects): add local icons and colors

* refactor(projects): simplify appearance handling

* fix(projects): serialize appearance saves

* test(app): update project appearance expectations

* test: fix project appearance CI expectations

* refactor(projects): organize appearance code

* fix(server): adopt main's reconcile model in project appearance updates

* fix(projects): preserve host-local appearance targets

* fix(desktop): restore Linux development startup

Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged.

* feat(projects): add custom project icons

Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet.

* refactor(settings): scope projects by host

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nikuscs
2026-08-01 01:34:42 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 706ae3d9bb
commit 800bd2bef1
67 changed files with 2158 additions and 912 deletions
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { openProjectEditForm, type ProjectEditFormSnapshot } from "./edit-form";
const derivedName: ProjectEditFormSnapshot = {
projectName: "grumpy-turtle",
projectCustomName: null,
hasCustomIcon: false,
currentIconDataUri: "data:image/png;base64,ZGVyaXZlZA==",
};
const customIcon: ProjectEditFormSnapshot = {
projectName: "Turtle",
projectCustomName: "Turtle",
hasCustomIcon: true,
currentIconDataUri: "data:image/png;base64,Y3VzdG9t",
};
describe("project edit form model", () => {
it("has nothing to submit until the user changes something", () => {
expect(openProjectEditForm(derivedName).getState()).toMatchObject({
name: "",
canSubmit: false,
canUseAutomatic: false,
submission: { rename: null, icon: null },
});
});
it("submits only the name when only the name changed", () => {
const model = openProjectEditForm(derivedName);
model.setName(" Turtle ");
expect(model.getState()).toMatchObject({
canSubmit: true,
submission: { rename: { customName: "Turtle" }, icon: null },
});
});
it("clears the override when the name is emptied", () => {
const model = openProjectEditForm(customIcon);
model.setName(" ");
expect(model.getState().submission.rename).toEqual({ customName: null });
});
it("treats a retyped identical name as no change", () => {
const model = openProjectEditForm(customIcon);
model.setName("Turtle");
expect(model.getState()).toMatchObject({ canSubmit: false, submission: { rename: null } });
});
it("previews a picked image and submits it as an upload", () => {
const model = openProjectEditForm(derivedName);
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
expect(model.getState()).toMatchObject({
previewDataUri: "data:image/png;base64,aW1hZ2U=",
pickedFileName: "logo.png",
canUseAutomatic: true,
submission: { rename: null, icon: { type: "upload", data: "aW1hZ2U=" } },
});
});
it("keeps a URL client-side and leaves the preview on the current icon", () => {
const model = openProjectEditForm(derivedName);
model.setImageUrl(" https://example.com ");
expect(model.getState()).toMatchObject({
previewDataUri: derivedName.currentIconDataUri,
pickedFileName: null,
submission: { icon: { type: "url", url: "https://example.com" } },
});
});
it("falls back to the earlier pick when the URL box is emptied", () => {
const model = openProjectEditForm(derivedName);
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
model.setImageUrl("https://example.com");
expect(model.getState().submission.icon).toEqual({
type: "url",
url: "https://example.com",
});
model.setImageUrl("");
expect(model.getState().submission.icon).toEqual({ type: "upload", data: "aW1hZ2U=" });
});
it("resets a stored custom icon back to automatic", () => {
const model = openProjectEditForm(customIcon);
expect(model.getState().canUseAutomatic).toBe(true);
model.useAutomaticIcon();
expect(model.getState()).toMatchObject({
previewDataUri: null,
canUseAutomatic: false,
submission: { rename: null, icon: { type: "automatic" } },
});
});
it("discards a pick without asking the daemon for an icon it already has", () => {
const model = openProjectEditForm(derivedName);
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
model.useAutomaticIcon();
expect(model.getState()).toMatchObject({
previewDataUri: derivedName.currentIconDataUri,
canSubmit: false,
submission: { rename: null, icon: null },
});
});
it("clears the URL box when the user falls back to automatic", () => {
const model = openProjectEditForm(customIcon);
model.setImageUrl("https://example.com");
const before = model.getState().urlResetKey;
model.useAutomaticIcon();
expect(model.getState().urlResetKey).not.toBe(before);
});
it("submits both fields together when both changed", () => {
const model = openProjectEditForm(customIcon);
model.setName("Renamed");
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
expect(model.getState().submission).toEqual({
rename: { customName: "Renamed" },
icon: { type: "upload", data: "aW1hZ2U=" },
});
});
it("drops a submit failure as soon as the user edits again", () => {
const model = openProjectEditForm(derivedName);
model.setError({ scope: "icon", message: "Enter a valid website or image URL" });
expect(model.getState().error).toMatchObject({ scope: "icon" });
model.setImageUrl("https://example.com");
expect(model.getState().error).toBeNull();
});
});
+167
View File
@@ -0,0 +1,167 @@
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
/**
* What the user asked the icon to become. A URL is client-side only: the caller
* acquires its bytes and submits the same upload payload a picked file produces.
*/
export type ProjectIconIntent = ProjectIconSource | { type: "url"; url: string };
/** The RPCs one submit has to issue. A null field is a field the user left alone. */
export interface ProjectEditSubmission {
rename: { customName: string | null } | null;
icon: ProjectIconIntent | null;
}
export interface ProjectEditFormError {
/** Which field the failure belongs under. */
scope: "name" | "icon";
message: string;
}
export interface ProjectEditFormState {
name: string;
/** The image the icon tile renders, null for the derived letter fallback. */
previewDataUri: string | null;
pickedFileName: string | null;
/** False once the resulting icon is the automatic one — the reset has nothing to do. */
canUseAutomatic: boolean;
canSubmit: boolean;
error: ProjectEditFormError | null;
/** Bumped only when the model clears the URL box on the user's behalf. */
urlResetKey: number;
submission: ProjectEditSubmission;
}
export interface ProjectEditFormModel {
getState: () => ProjectEditFormState;
subscribe: (listener: () => void) => () => void;
setName: (name: string) => void;
setImageUrl: (url: string) => void;
setPickedImage: (image: { fileName: string; mimeType: string; data: string }) => void;
useAutomaticIcon: () => void;
setError: (error: ProjectEditFormError | null) => void;
}
export interface ProjectEditFormSnapshot {
/** The name on screen today, custom or derived. Seeds the input's placeholder. */
projectName: string;
/** The override in effect, null while the project uses its derived name. */
projectCustomName: string | null;
hasCustomIcon: boolean;
/** The icon the project renders today, custom or derived. */
currentIconDataUri: string | null;
}
type IconChoice = "unchanged" | "automatic" | "upload" | "url";
interface PickedImage {
fileName: string;
mimeType: string;
data: string;
}
export function openProjectEditForm(snapshot: ProjectEditFormSnapshot): ProjectEditFormModel {
let name = snapshot.projectCustomName ?? "";
let urlText = "";
let picked: PickedImage | null = null;
let iconChoice: IconChoice = "unchanged";
let error: ProjectEditFormError | null = null;
let urlResetKey = 0;
const listeners = new Set<() => void>();
function deriveRename(): ProjectEditSubmission["rename"] {
const trimmed = name.trim();
const customName = trimmed.length === 0 ? null : trimmed;
return customName === snapshot.projectCustomName ? null : { customName };
}
function deriveIcon(): ProjectIconIntent | null {
if (iconChoice === "automatic") {
return snapshot.hasCustomIcon ? { type: "automatic" } : null;
}
if (iconChoice === "upload" && picked) {
return { type: "upload", data: picked.data };
}
if (iconChoice === "url") {
const url = urlText.trim();
return url.length === 0 ? null : { type: "url", url };
}
return null;
}
function derivePreview(): string | null {
// Dropping a custom icon leaves the daemon to re-derive one, so the tile
// falls back to the letter rather than claiming to know the result.
if (iconChoice === "automatic") return null;
if (iconChoice === "upload" && picked) {
return `data:${picked.mimeType};base64,${picked.data}`;
}
return snapshot.currentIconDataUri;
}
function deriveState(): ProjectEditFormState {
const submission = { rename: deriveRename(), icon: deriveIcon() };
const willBeCustom = submission.icon
? submission.icon.type !== "automatic"
: snapshot.hasCustomIcon;
return {
name,
previewDataUri: derivePreview(),
pickedFileName: iconChoice === "upload" ? (picked?.fileName ?? null) : null,
canUseAutomatic: willBeCustom,
canSubmit: Boolean(submission.rename || submission.icon),
error,
urlResetKey,
submission,
};
}
let state = deriveState();
function publish(): void {
state = deriveState();
for (const listener of listeners) listener();
}
return {
getState: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
setName: (nextName) => {
name = nextName;
error = null;
publish();
},
setImageUrl: (url) => {
urlText = url;
// Emptying the box withdraws the URL without discarding an earlier pick.
if (url.trim().length > 0) {
iconChoice = "url";
} else {
iconChoice = picked ? "upload" : "unchanged";
}
error = null;
publish();
},
setPickedImage: (image) => {
picked = image;
iconChoice = "upload";
error = null;
publish();
},
useAutomaticIcon: () => {
picked = null;
urlText = "";
urlResetKey += 1;
iconChoice = snapshot.hasCustomIcon ? "automatic" : "unchanged";
error = null;
publish();
},
setError: (nextError) => {
error = nextError;
publish();
},
};
}
@@ -81,6 +81,7 @@ export function hostProjectFromWorkspace(input: {
projectId: input.workspace.projectId,
iconWorkingDir,
worktreeSupport: canCreate ? "supported" : "unsupported",
customIconRevision: input.workspace.projectCustomIconRevision,
},
],
workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { resolveProjectIconLookup } from "./icons";
describe("project icon lookup", () => {
const target = {
projectId: "prj_host_local",
iconWorkingDir: "/projects/paseo",
};
it("uses the host-local project ID with custom-icon-capable daemons", () => {
expect(resolveProjectIconLookup(target, true)).toEqual({
kind: "project",
projectId: "prj_host_local",
});
});
it("uses the project directory with legacy daemons", () => {
expect(resolveProjectIconLookup(target, false)).toEqual({
kind: "legacy",
cwd: "/projects/paseo",
});
});
});
+157
View File
@@ -0,0 +1,157 @@
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { ProjectIcon } from "@getpaseo/protocol/messages";
import { useHostFeatureMap } from "@/runtime/host-features";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeClient,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
interface ProjectIconTarget {
serverId: string;
projectViewKey: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
}
/**
* Daemons without custom-icon support only answer the legacy cwd lookup, which
* still serves their automatically discovered icons.
*/
export function resolveProjectIconLookup(
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
supportsCustomIcons: boolean,
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } {
return supportsCustomIcons
? { kind: "project", projectId: target.projectId }
: { kind: "legacy", cwd: target.iconWorkingDir };
}
function legacyIconQueryKey(serverId: string, cwd: string) {
return ["projectIcon", serverId, "legacy", cwd] as const;
}
function iconQueryKey(serverId: string, projectId: string, revision: string) {
return ["projectIcon", serverId, projectId, revision] as const;
}
function iconDataUri(icon: ProjectIcon | null): string | null {
if (!icon) return null;
return `data:${icon.mimeType};base64,${icon.data}`;
}
function useStableIconData(data: (string | null)[], signature: string): readonly (string | null)[] {
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
if (stableRef.current?.signature !== signature) {
stableRef.current = { signature, data };
}
return stableRef.current.data;
}
export function useProjectIcon({ serverId, cwd }: { serverId: string; cwd: string }) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
queryKey: legacyIconQueryKey(serverId, cwd),
queryFn: async (): Promise<ProjectIcon | null> => {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const result = await client.requestProjectIcon(cwd);
return result.icon;
},
enabled: Boolean(client && isConnected && cwd),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
return {
icon: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
};
}
export function useProjectIcons(input: {
projects: readonly ProjectIconTarget[];
}): Map<string, string | null> {
const serverIds = useMemo(
() => [...new Set(input.projects.map((project) => project.serverId))],
[input.projects],
);
const supportsCustomIcons = useHostFeatureMap(serverIds, "projectCustomIcon");
const requests = useMemo(() => {
const unique = new Map<string, ProjectIconTarget>();
for (const project of input.projects) {
if (!project.serverId || !project.projectId || !project.iconWorkingDir.trim()) continue;
unique.set(`${project.serverId}:${project.projectId}`, project);
}
return Array.from(unique.values());
}, [input.projects]);
const queries = useQueries({
queries: requests.map((request) => {
const revision = request.customIconRevision ?? "automatic";
const lookup = resolveProjectIconLookup(
request,
supportsCustomIcons.get(request.serverId) === true,
);
return {
queryKey:
lookup.kind === "project"
? iconQueryKey(request.serverId, lookup.projectId, revision)
: legacyIconQueryKey(request.serverId, lookup.cwd),
queryFn: async () => {
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) return null;
const result =
lookup.kind === "project"
? await client.getProjectIcon(lookup.projectId)
: await client.requestProjectIcon(lookup.cwd);
return result.icon;
},
select: iconDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
};
}),
});
const signature = queries.map((query) => query.data ?? "").join("\u0000");
const data = useStableIconData(
queries.map((query) => query.data ?? null),
signature,
);
return useMemo(() => {
const byTarget = new Map<string, string | null>();
requests.forEach((request, index) => {
byTarget.set(`${request.serverId}:${request.projectId}`, data[index] ?? null);
});
const byProject = new Map<string, string | null>();
for (const project of input.projects) {
byProject.set(
project.projectViewKey,
byTarget.get(`${project.serverId}:${project.projectId}`) ?? null,
);
}
return byProject;
}, [data, input.projects, requests]);
}
@@ -0,0 +1,46 @@
import { Buffer } from "buffer";
import { describe, expect, it } from "vitest";
import { importProjectIconFromUrl } from "./import-project-icon";
const IMAGE = new Uint8Array([1, 2, 3, 4]);
function response(body: BodyInit, contentType: string): Response {
return new Response(body, { headers: { "content-type": contentType } });
}
describe("project icon URL import", () => {
it("turns a direct image URL into an upload payload", async () => {
const source = await importProjectIconFromUrl("https://example.com/icon.png", async () =>
response(IMAGE.buffer, "image/png"),
);
expect(source).toEqual({ type: "upload", data: Buffer.from(IMAGE).toString("base64") });
});
it("fetches a website icon on the client and returns an upload payload", async () => {
const requested: string[] = [];
const source = await importProjectIconFromUrl("https://example.com", async (input) => {
const url = input.toString();
requested.push(url);
if (url === "https://example.com/") {
return response('<link rel="icon" href="/brand.png">', "text/html");
}
return response(IMAGE.buffer, "image/png");
});
expect(requested).toEqual(["https://example.com/", "https://example.com/brand.png"]);
expect(source.type).toBe("upload");
});
it("rejects non-HTTP URLs without fetching", async () => {
let fetched = false;
await expect(
importProjectIconFromUrl("file:///etc/passwd", async () => {
fetched = true;
return response(IMAGE.buffer, "image/png");
}),
).rejects.toThrow("URL must use HTTP or HTTPS without credentials");
expect(fetched).toBe(false);
});
});
@@ -0,0 +1,89 @@
import { Buffer } from "buffer";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
const MAX_ICON_BYTES = 512 * 1024;
const MAX_HTML_BYTES = 512 * 1024;
const MAX_ICON_CANDIDATES = 8;
type UploadSource = Extract<ProjectIconSource, { type: "upload" }>;
type FetchIcon = (input: string | URL, init?: RequestInit) => Promise<Response>;
function parseHttpUrl(value: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("Enter a valid website or image URL");
}
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
throw new Error("URL must use HTTP or HTTPS without credentials");
}
return url;
}
async function fetchOk(fetchIcon: FetchIcon, url: URL): Promise<Response> {
const response = await fetchIcon(url);
if (!response.ok) throw new Error(`Download failed (${response.status})`);
return response;
}
async function readBytes(response: Response, maxBytes: number): Promise<Buffer> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error("Icon must be 512 KB or smaller");
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > maxBytes) throw new Error("Icon must be 512 KB or smaller");
return bytes;
}
function parseTagAttribute(tag: string, name: string): string | null {
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i"));
return match?.[1]?.trim() || null;
}
function iconCandidates(html: string, pageUrl: URL): URL[] {
const candidates: URL[] = [];
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
const rel = parseTagAttribute(tag, "rel")?.toLowerCase() ?? "";
const href = parseTagAttribute(tag, "href");
const type = parseTagAttribute(tag, "type")?.toLowerCase() ?? "";
if (!href || !rel.includes("icon") || type.includes("svg") || href.endsWith(".svg")) continue;
try {
candidates.push(new URL(href, pageUrl));
} catch {
// Try the next declared icon and then the conventional fallback.
}
}
candidates.push(new URL("/favicon.ico", pageUrl));
return candidates.slice(0, MAX_ICON_CANDIDATES);
}
/** Acquires a URL on the client and collapses it into the normal upload payload. */
export async function importProjectIconFromUrl(
value: string,
fetchIcon: FetchIcon = fetch,
): Promise<UploadSource> {
const url = parseHttpUrl(value.trim());
const response = await fetchOk(fetchIcon, url);
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("text/html")) {
return { type: "upload", data: (await readBytes(response, MAX_ICON_BYTES)).toString("base64") };
}
const html = (await readBytes(response, MAX_HTML_BYTES)).toString("utf8");
let lastError: unknown = new Error("Website does not expose a supported icon");
for (const candidate of iconCandidates(html, url)) {
try {
const iconResponse = await fetchOk(fetchIcon, candidate);
return {
type: "upload",
data: (await readBytes(iconResponse, MAX_ICON_BYTES)).toString("base64"),
};
} catch (error) {
lastError = error;
}
}
throw lastError;
}
@@ -1,94 +0,0 @@
import { useMemo, useRef } from "react";
import { useQueries } from "@tanstack/react-query";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
export interface ProjectIconRequestTarget {
serverId: string;
projectViewKey: string;
iconWorkingDir: string;
}
function useStableProjectIconData(
data: (string | null)[],
signature: string,
): readonly (string | null)[] {
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
if (stableRef.current?.signature !== signature) {
stableRef.current = { signature, data };
}
return stableRef.current.data;
}
export function useProjectIconDataByProjectViewKey(input: {
projects: readonly ProjectIconRequestTarget[];
}): Map<string, string | null> {
const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const project of input.projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${project.serverId}:${cwd}`, { serverId: project.serverId, cwd });
}
return Array.from(unique.values());
}, [input.projects]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
queryKey: projectIconQueryKey(request.serverId, request.cwd),
queryFn: async () => {
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) {
return null;
}
const result = await client.requestProjectIcon(request.cwd);
return result.icon;
},
select: projectIconToDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
request.cwd,
),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})),
});
const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000");
const projectIconData = useStableProjectIconData(
projectIconQueries.map((query) => query.data ?? null),
projectIconSignature,
);
return useMemo(() => {
const iconByServerAndCwd = new Map<string, string | null>();
for (let index = 0; index < projectIconRequests.length; index += 1) {
const request = projectIconRequests[index];
if (!request) {
continue;
}
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
}
const byProject = new Map<string, string | null>();
for (const project of input.projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
byProject.set(project.projectViewKey, null);
continue;
}
byProject.set(
project.projectViewKey,
iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null,
);
}
return byProject;
}, [input.projects, projectIconData, projectIconRequests]);
}
@@ -6,6 +6,7 @@ export interface WorkspaceStructureHostPlacement {
projectId: string;
iconWorkingDir: string;
worktreeSupport: "supported" | "unsupported" | "unknown";
customIconRevision?: string | null;
}
export interface WorkspaceStructureProject {
@@ -158,6 +159,7 @@ function addProjectToView(input: {
projectId: project.projectId,
iconWorkingDir: project.projectRootPath,
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
customIconRevision: project.projectCustomIconRevision,
};
const draft = byProject.get(viewKey);
if (!draft) {