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:
co-authored by
Mohamed Boudra
parent
706ae3d9bb
commit
800bd2bef1
@@ -668,6 +668,7 @@ describe("project command-center RPCs", () => {
|
||||
projectId: "prj_created_directory",
|
||||
projectDisplayName: "new-project",
|
||||
projectCustomName: null,
|
||||
projectCustomIconRevision: null,
|
||||
projectRootPath: directoryPath,
|
||||
projectKind: "non_git",
|
||||
},
|
||||
@@ -4950,6 +4951,7 @@ test("project.list returns every active project descriptor", async () => {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectDisplayName: "acme/app",
|
||||
projectCustomName: null,
|
||||
projectCustomIconRevision: null,
|
||||
projectRootPath: "/tmp/project-active",
|
||||
projectKind: "git",
|
||||
},
|
||||
|
||||
@@ -138,6 +138,11 @@ import {
|
||||
} from "./workspace-registry.js";
|
||||
import { wrapSpokenInput } from "./voice-config.js";
|
||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
||||
import {
|
||||
readProjectIcon,
|
||||
removeProjectCustomIcon,
|
||||
setProjectCustomIcon,
|
||||
} from "../utils/project-custom-icon.js";
|
||||
import { VoiceSession } from "./session/voice/voice-session.js";
|
||||
import { CheckoutSession } from "./session/checkout/checkout-session.js";
|
||||
import {
|
||||
@@ -1934,6 +1939,8 @@ export class Session {
|
||||
return this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
|
||||
case "project.rename.request":
|
||||
return this.handleProjectRenameRequest(msg.projectId, msg.customName, msg.requestId);
|
||||
case "project.icon.set.request":
|
||||
return this.handleProjectIconSetRequest(msg);
|
||||
case "send_agent_message_request":
|
||||
return this.handleSendAgentMessageRequest(msg);
|
||||
case "wait_for_finish_request":
|
||||
@@ -2131,6 +2138,8 @@ export class Session {
|
||||
return this.workspaceFilesSession.handleFileWriteRequest(msg);
|
||||
case "project_icon_request":
|
||||
return this.workspaceFilesSession.handleProjectIconRequest(msg);
|
||||
case "project.icon.get.request":
|
||||
return this.handleProjectIconGetRequest(msg.projectId, msg.requestId);
|
||||
case "file_download_token_request":
|
||||
return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg);
|
||||
case "file.upload.request":
|
||||
@@ -2665,6 +2674,61 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProjectIconSetRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "project.icon.set.request" }>,
|
||||
): Promise<void> {
|
||||
const { projectId, requestId } = request;
|
||||
try {
|
||||
const updated = await setProjectCustomIcon({
|
||||
paseoHome: this.paseoHome,
|
||||
projectId,
|
||||
source: request.source,
|
||||
projects: this.projectRegistry,
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: "project.icon.set.response",
|
||||
payload: { requestId, projectId, accepted: true, error: null },
|
||||
});
|
||||
this.emitProjectUpdate({ kind: "upsert", project: updated });
|
||||
|
||||
const affectedWorkspaceIds = (await this.workspaceRegistry.list())
|
||||
.filter((workspace) => workspace.projectId === projectId)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
if (affectedWorkspaceIds.length > 0) {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds);
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "project.icon.set.response",
|
||||
payload: {
|
||||
requestId,
|
||||
projectId,
|
||||
accepted: false,
|
||||
error: getErrorMessageOr(error, "Failed to update project icon"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProjectIconGetRequest(projectId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
const project = await this.projectRegistry.get(projectId);
|
||||
if (!project) throw new Error("Project not found");
|
||||
|
||||
const icon = await readProjectIcon({ paseoHome: this.paseoHome, project });
|
||||
this.emit({
|
||||
type: "project.icon.get.response",
|
||||
payload: { projectId, icon, error: null, requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "project.icon.get.response",
|
||||
payload: { projectId, icon: null, error: getErrorMessage(error), requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProjectRemoveRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "project.remove.request" }>,
|
||||
): Promise<void> {
|
||||
@@ -2704,6 +2768,15 @@ export class Session {
|
||||
}
|
||||
|
||||
await this.projectRegistry.remove(resolvedProjectId);
|
||||
await removeProjectCustomIcon({
|
||||
paseoHome: this.paseoHome,
|
||||
projectId: resolvedProjectId,
|
||||
}).catch((error) => {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, projectId: resolvedProjectId },
|
||||
"Failed to clean up removed project icon",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
if (activeWorkspaceIds.length > 0) {
|
||||
this.clearWorkspaceArchiving(activeWorkspaceIds);
|
||||
@@ -4210,6 +4283,7 @@ export class Session {
|
||||
? resolveProjectDisplayName(resolvedProjectRecord)
|
||||
: workspace.projectId,
|
||||
projectCustomName: resolvedProjectRecord?.customName ?? null,
|
||||
projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null,
|
||||
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
|
||||
@@ -4296,6 +4370,7 @@ export class Session {
|
||||
? resolveProjectDisplayName(projectRecord)
|
||||
: result.workspace.projectId,
|
||||
projectCustomName: projectRecord?.customName ?? null,
|
||||
projectCustomIconRevision: projectRecord?.customIconRevision ?? null,
|
||||
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
|
||||
workspaceDirectory: result.workspace.cwd,
|
||||
projectKind: projectRecord?.kind ?? "git",
|
||||
@@ -4467,6 +4542,7 @@ export class Session {
|
||||
...(project.projectKey ? { projectKey: project.projectKey } : {}),
|
||||
projectDisplayName: resolveProjectDisplayName(project),
|
||||
projectCustomName: project.customName ?? null,
|
||||
projectCustomIconRevision: project.customIconRevision ?? null,
|
||||
projectRootPath: project.rootPath,
|
||||
projectKind: project.kind,
|
||||
};
|
||||
|
||||
@@ -3405,6 +3405,7 @@ test("archiving the last workspace emits a remove carrying the now-empty project
|
||||
projectId: project.projectId,
|
||||
projectDisplayName: "repo",
|
||||
projectCustomName: null,
|
||||
projectCustomIconRevision: null,
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "git",
|
||||
},
|
||||
@@ -7402,6 +7403,98 @@ test("a workspace leaving a filtered subscription after bootstrap emits a remova
|
||||
]);
|
||||
});
|
||||
|
||||
const ICON_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,
|
||||
]);
|
||||
|
||||
test("project.icon.set.request publishes a custom icon that project.icon.get serves back", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true });
|
||||
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
|
||||
const projectRoot = path.join(tempDir, "project-without-icons");
|
||||
mkdirSync(projectRoot, { recursive: true });
|
||||
session.paseoHome = path.join(tempDir, "paseo-home");
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "prj_icon",
|
||||
rootPath: projectRoot,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-07-22T12:00:00.000Z",
|
||||
updatedAt: "2026-07-22T12:00:00.000Z",
|
||||
});
|
||||
const projects = new Map([[project.projectId, project]]);
|
||||
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
||||
session.projectRegistry.update = async (id, updater) => {
|
||||
const current = projects.get(id);
|
||||
if (!current) return null;
|
||||
const updated = updater(current);
|
||||
projects.set(id, updated);
|
||||
return updated;
|
||||
};
|
||||
session.workspaceRegistry.list = async () => [];
|
||||
|
||||
await session.handleMessage({
|
||||
type: "project.icon.set.request",
|
||||
projectId: project.projectId,
|
||||
source: { type: "upload", data: ICON_PNG_1X1.toString("base64") },
|
||||
requestId: "req-icon-set",
|
||||
});
|
||||
|
||||
expect(findByType(emitted, "project.icon.set.response")?.payload).toEqual({
|
||||
requestId: "req-icon-set",
|
||||
projectId: project.projectId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
});
|
||||
const revision = projects.get(project.projectId)?.customIconRevision;
|
||||
expect(revision).toEqual(expect.any(String));
|
||||
expect(findByType(emitted, "project.update")?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
project: { projectCustomIconRevision: revision },
|
||||
});
|
||||
|
||||
emitted.length = 0;
|
||||
await session.handleMessage({
|
||||
type: "project.icon.get.request",
|
||||
projectId: project.projectId,
|
||||
requestId: "req-icon-custom",
|
||||
});
|
||||
expect(findByType(emitted, "project.icon.get.response")?.payload).toEqual({
|
||||
projectId: project.projectId,
|
||||
icon: { data: ICON_PNG_1X1.toString("base64"), mimeType: "image/png" },
|
||||
error: null,
|
||||
requestId: "req-icon-custom",
|
||||
});
|
||||
|
||||
emitted.length = 0;
|
||||
await session.handleMessage({
|
||||
type: "project.icon.set.request",
|
||||
projectId: project.projectId,
|
||||
source: { type: "automatic" },
|
||||
requestId: "req-icon-automatic",
|
||||
});
|
||||
expect(projects.get(project.projectId)?.customIconRevision).toBeNull();
|
||||
expect(findByType(emitted, "project.icon.get.response")).toBeUndefined();
|
||||
|
||||
await session.handleMessage({
|
||||
type: "project.icon.get.request",
|
||||
projectId: project.projectId,
|
||||
requestId: "req-icon-scan",
|
||||
});
|
||||
expect(findByType(emitted, "project.icon.get.response")?.payload).toMatchObject({
|
||||
icon: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("project.rename.request stores customName and emits an updated workspace descriptor", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
|
||||
@@ -262,11 +262,13 @@ function createNoopProjectRegistry(): ProjectRegistry {
|
||||
displayName: input.displayName,
|
||||
projectKey: input.projectKey ?? null,
|
||||
customName: null,
|
||||
customIconRevision: null,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
archivedAt: null,
|
||||
}),
|
||||
upsert: async () => {},
|
||||
update: async () => null,
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
};
|
||||
@@ -1581,6 +1583,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
stableProjectIdentity: true,
|
||||
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
|
||||
workspaceScriptManagement: true,
|
||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||
projectCustomIcon: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,6 +364,7 @@ describe("wire compatibility", () => {
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Favorite project",
|
||||
projectCustomName: "Favorite project",
|
||||
projectCustomIconRevision: null,
|
||||
projectRootPath: "/tmp/project",
|
||||
projectKind: "git",
|
||||
},
|
||||
|
||||
@@ -571,6 +571,7 @@ describe("WorkspaceDirectory empty projects", () => {
|
||||
projectId: "empty",
|
||||
projectDisplayName: "Renamed",
|
||||
projectCustomName: "Renamed",
|
||||
projectCustomIconRevision: null,
|
||||
projectRootPath: "/workspace/empty",
|
||||
projectKind: "non_git",
|
||||
},
|
||||
|
||||
@@ -505,6 +505,7 @@ export class WorkspaceDirectory {
|
||||
projectKey: project.projectKey ?? undefined,
|
||||
projectDisplayName: resolveProjectDisplayName(project),
|
||||
projectCustomName: project.customName ?? null,
|
||||
projectCustomIconRevision: project.customIconRevision ?? null,
|
||||
projectRootPath: project.rootPath,
|
||||
projectKind: project.kind,
|
||||
}));
|
||||
|
||||
@@ -29,6 +29,12 @@ const PersistedProjectRecordSchema = z.object({
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
// Identifies the project's stored custom icon; null means automatic.
|
||||
customIconRevision: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
@@ -114,6 +120,10 @@ export interface ProjectRegistry {
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord>;
|
||||
upsert(record: PersistedProjectRecord): Promise<void>;
|
||||
update(
|
||||
projectId: string,
|
||||
updater: (record: PersistedProjectRecord) => PersistedProjectRecord,
|
||||
): Promise<PersistedProjectRecord | null>;
|
||||
archive(projectId: string, archivedAt: string): Promise<void>;
|
||||
remove(projectId: string): Promise<void>;
|
||||
/** Central lifecycle seam for daemon-global project observers. */
|
||||
@@ -381,6 +391,16 @@ export class FileBackedProjectRegistry
|
||||
await this.notifyMutation({ kind: "upsert", projectId: record.projectId, project: record });
|
||||
}
|
||||
|
||||
override async update(
|
||||
projectId: string,
|
||||
updater: (record: PersistedProjectRecord) => PersistedProjectRecord,
|
||||
): Promise<PersistedProjectRecord | null> {
|
||||
const project = await super.update(projectId, updater);
|
||||
if (!project) return null;
|
||||
await this.notifyMutation({ kind: "upsert", projectId, project });
|
||||
return project;
|
||||
}
|
||||
|
||||
override async archive(projectId: string, archivedAt: string): Promise<void> {
|
||||
const project = await this.archiveIfActive(projectId, archivedAt);
|
||||
if (!project) return;
|
||||
@@ -475,6 +495,7 @@ export function createPersistedProjectRecord(input: {
|
||||
displayName: string;
|
||||
customName?: string | null;
|
||||
projectKey?: string | null;
|
||||
customIconRevision?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
@@ -483,6 +504,7 @@ export function createPersistedProjectRecord(input: {
|
||||
...input,
|
||||
customName: input.customName ?? null,
|
||||
projectKey: input.projectKey ?? null,
|
||||
customIconRevision: input.customIconRevision ?? null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user