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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user