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
@@ -0,0 +1,137 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import {
createPersistedProjectRecord,
type PersistedProjectRecord,
type ProjectRegistry,
} from "../server/workspace-registry.js";
import {
readProjectIcon,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "./project-custom-icon.js";
const PNG_1X1 = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00,
]);
const PNG_1X1_ICON = { data: PNG_1X1.toString("base64"), mimeType: "image/png" };
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
async function tempDir(prefix: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), prefix));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
return directory;
}
/** A project whose root holds no discoverable icon, over an in-memory registry. */
async function project() {
const rootPath = await tempDir("paseo-project-root-");
const paseoHome = await tempDir("paseo-home-");
let record = createPersistedProjectRecord({
projectId: "project-a",
rootPath,
kind: "git",
displayName: "repo",
createdAt: "2026-07-22T12:00:00.000Z",
updatedAt: "2026-07-22T12:00:00.000Z",
});
const projects = {
get: async () => record,
update: async (_id: string, updater: (r: PersistedProjectRecord) => PersistedProjectRecord) => {
record = updater(record);
return record;
},
} as unknown as ProjectRegistry;
return {
paseoHome,
rootPath,
set: (source: ProjectIconSource) =>
setProjectCustomIcon({ paseoHome, projectId: "project-a", source, projects }),
read: () => readProjectIcon({ paseoHome, project: record }),
revision: () => record.customIconRevision,
remove: () => removeProjectCustomIcon({ paseoHome, projectId: "project-a" }),
};
}
function nonSquarePng(): Buffer {
const wide = Buffer.from(PNG_1X1);
wide.writeUInt32BE(2, 16);
return wide;
}
describe("project custom icon", () => {
it("stores uploaded bytes as the custom icon", async () => {
const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
expect(target.revision()).toEqual(expect.any(String));
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
});
it("scans the project directory when no custom icon is stored", async () => {
const target = await project();
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
});
it("drops the stored image when the project goes back to automatic", async () => {
const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
await target.set({ type: "automatic" });
expect(target.revision()).toBeNull();
await expect(target.read()).resolves.toBeNull();
});
it("drops the stored image when the project is removed", async () => {
const target = await project();
const stored = await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
await target.remove();
await expect(
readProjectIcon({ paseoHome: target.paseoHome, project: stored }),
).resolves.toBeNull();
});
it.each([
["data that is not an image", Buffer.from("nope"), "Unsupported or invalid icon file"],
["a non-square image", nonSquarePng(), "square image up to 1024×1024"],
["a file over 512 KB", Buffer.alloc(512 * 1024 + 1), "512 KB or smaller"],
])("rejects %s", async (_name, bytes, message) => {
const target = await project();
await expect(target.set({ type: "upload", data: bytes.toString("base64") })).rejects.toThrow(
message,
);
expect(target.revision()).toBeNull();
});
it("serializes concurrent saves for one project", async () => {
const target = await project();
const [first, second] = await Promise.all([
target.set({ type: "upload", data: PNG_1X1.toString("base64") }),
target.set({ type: "automatic" }),
]);
expect(first.customIconRevision).toEqual(expect.any(String));
expect(second.customIconRevision).toBeNull();
await expect(target.read()).resolves.toBeNull();
});
});
@@ -0,0 +1,123 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import { writeFileAtomic } from "../server/atomic-file.js";
import type { PersistedProjectRecord, ProjectRegistry } from "../server/workspace-registry.js";
import { getImageDimensions, getProjectIcon, type ProjectIcon } from "./project-icon.js";
const MAX_ICON_BYTES = 512 * 1024;
const ICON_TOO_LARGE_ERROR = "Icon must be 512 KB or smaller";
// A save validates, writes bytes, and updates the registry. Two concurrent
// saves for one project would interleave those steps.
const inFlightSaves = new Map<string, Promise<unknown>>();
/**
* The only writer of custom project icons. The client owns image acquisition;
* this boundary accepts bytes, validates them, and replaces whatever was stored.
*/
export async function setProjectCustomIcon(input: {
paseoHome: string;
projectId: string;
source: ProjectIconSource;
projects: ProjectRegistry;
}): Promise<PersistedProjectRecord> {
return serialize(input.projectId, async () => {
if (!(await input.projects.get(input.projectId))) throw new Error("Project not found");
let customIconRevision: string | null = null;
if (input.source.type === "automatic") {
await removeProjectCustomIcon(input);
} else {
const bytes = Buffer.from(input.source.data, "base64");
validateIcon(bytes);
await writeFileAtomic(cachePath(input.paseoHome, input.projectId), bytes);
customIconRevision = randomUUID();
}
const updated = await input.projects.update(input.projectId, (current) => ({
...current,
customIconRevision,
updatedAt: new Date().toISOString(),
}));
if (!updated) {
// The project was removed mid-save, so its own cleanup missed these bytes.
await removeProjectCustomIcon(input);
throw new Error("Project not found");
}
return updated;
});
}
/** Resolves the icon a project renders with, whichever mode it is in. */
export async function readProjectIcon(input: {
paseoHome: string;
project: PersistedProjectRecord;
}): Promise<ProjectIcon | null> {
if (!input.project.customIconRevision) return getProjectIcon(input.project.rootPath);
try {
return validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId)));
} catch {
return null;
}
}
export async function removeProjectCustomIcon(input: {
paseoHome: string;
projectId: string;
}): Promise<void> {
await rm(cachePath(input.paseoHome, input.projectId), { force: true });
}
async function serialize<T>(projectId: string, save: () => Promise<T>): Promise<T> {
const previous = inFlightSaves.get(projectId) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(save);
inFlightSaves.set(projectId, next);
try {
return await next;
} finally {
if (inFlightSaves.get(projectId) === next) inFlightSaves.delete(projectId);
}
}
function cachePath(paseoHome: string, projectId: string): string {
const key = createHash("sha256").update(projectId).digest("hex");
return join(paseoHome, "projects", "icons", `${key}.bin`);
}
function detectMimeType(buffer: Buffer): string | null {
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return "image/png";
}
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
const header = buffer.toString("ascii", 0, 6);
if (header === "GIF87a" || header === "GIF89a") return "image/gif";
if (buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
return "image/webp";
}
if (
buffer.length >= 22 &&
buffer.readUInt16LE(0) === 0 &&
buffer.readUInt16LE(2) === 1 &&
buffer.readUInt16LE(4) > 0
) {
return "image/x-icon";
}
return null;
}
function validateIcon(buffer: Buffer): ProjectIcon {
if (buffer.length === 0 || buffer.length > MAX_ICON_BYTES) {
throw new Error(ICON_TOO_LARGE_ERROR);
}
const mimeType = detectMimeType(buffer);
if (!mimeType) throw new Error("Unsupported or invalid icon file");
const size = getImageDimensions(buffer, mimeType);
if (!size || size.width <= 0 || size.width !== size.height || size.width > 1024) {
throw new Error("Icon must be a square image up to 1024×1024");
}
return { data: buffer.toString("base64"), mimeType };
}
+2 -2
View File
@@ -60,7 +60,7 @@ export interface ProjectIcon {
const MAX_ICON_SIZE = 32 * 1024; // 32KB max
interface ImageDimensions {
export interface ImageDimensions {
width: number;
height: number;
}
@@ -136,7 +136,7 @@ function getWebpDimensions(buffer: Buffer): ImageDimensions | null {
return null;
}
function getImageDimensions(buffer: Buffer, mimeType: string): ImageDimensions | null {
export function getImageDimensions(buffer: Buffer, mimeType: string): ImageDimensions | null {
switch (mimeType) {
case "image/png":
return getPngDimensions(buffer);