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
+13
View File
@@ -40,6 +40,19 @@ function fetchWorkspacesResponse(workspace: Record<string, unknown>) {
};
}
describe("project icon message security", () => {
test("rejects URL sources at the daemon boundary", () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "project.icon.set.request",
projectId: "project-1",
source: { type: "url", url: "http://127.0.0.1/private" },
requestId: "request-1",
});
expect(parsed.success).toBe(false);
});
});
describe("workspace descriptor message compatibility", () => {
test("old-shaped fetch_workspaces_response without project still parses", () => {
const parsed = SessionOutboundMessageSchema.parse(
+56
View File
@@ -813,6 +813,13 @@ export const UpdateAgentRequestMessageSchema = z.object({
requestId: z.string(),
});
// The daemon accepts only image bytes chosen or acquired by the client. It must
// never fetch a user-provided URL on the host's network.
export const ProjectIconSourceSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("automatic") }),
z.object({ type: z.literal("upload"), data: z.string() }),
]);
export const ProjectRenameRequestSchema = z.object({
type: z.literal("project.rename.request"),
projectId: z.string(),
@@ -821,6 +828,13 @@ export const ProjectRenameRequestSchema = z.object({
requestId: z.string(),
});
export const ProjectIconSetRequestSchema = z.object({
type: z.literal("project.icon.set.request"),
projectId: z.string(),
source: ProjectIconSourceSchema,
requestId: z.string(),
});
export const ProjectRemoveRequestSchema = z.object({
type: z.literal("project.remove.request"),
projectId: z.string(),
@@ -1515,6 +1529,16 @@ export const ProjectRenameResponseSchema = z.object({
payload: ProjectRenameResponsePayloadSchema,
});
export const ProjectIconSetResponseSchema = z.object({
type: z.literal("project.icon.set.response"),
payload: z.object({
requestId: z.string(),
projectId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const ProjectRemoveResponsePayloadSchema = z.object({
requestId: z.string(),
projectId: z.string(),
@@ -2209,6 +2233,12 @@ export const ProjectIconRequestSchema = z.object({
requestId: z.string(),
});
export const ProjectIconGetRequestSchema = z.object({
type: z.literal("project.icon.get.request"),
projectId: z.string(),
requestId: z.string(),
});
export const FileDownloadTokenRequestSchema = z.object({
type: z.literal("file_download_token_request"),
cwd: z.string(),
@@ -2457,6 +2487,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CloseItemsRequestMessageSchema,
UpdateAgentRequestMessageSchema,
ProjectRenameRequestSchema,
ProjectIconSetRequestSchema,
ProjectRemoveRequestSchema,
WorkspaceTitleSetRequestSchema,
WorkspacePinSetRequestSchema,
@@ -2555,6 +2586,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FileUnsubscribeRequestSchema,
FileWriteRequestSchema,
ProjectIconRequestSchema,
ProjectIconGetRequestSchema,
FileDownloadTokenRequestSchema,
FileUploadRequestSchema,
ClearAgentAttentionMessageSchema,
@@ -2840,6 +2872,8 @@ export const ServerInfoStatusPayloadSchema = z
stableProjectIdentity: z.boolean().optional(),
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
workspaceScriptManagement: z.boolean().optional(),
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIcon: z.boolean().optional(),
})
.optional(),
})
@@ -3106,6 +3140,9 @@ export const WorkspaceDescriptorPayloadSchema = z
// value (customName) and projectCustomName mirrors the raw override so the
// settings UI can prefill its input and offer a "reset" action.
projectCustomName: z.string().nullable().optional(),
// Identifies the project's stored custom icon; null means automatic.
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIconRevision: z.string().nullable().optional(),
projectRootPath: z.string(),
workspaceDirectory: z.string().optional(),
projectKind: z.enum(["git", "non_git", "directory"]),
@@ -3244,6 +3281,8 @@ export const WorkspaceProjectDescriptorPayloadSchema = z.object({
projectKey: z.string().optional(),
projectDisplayName: z.string(),
projectCustomName: z.string().nullable().optional(),
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIconRevision: z.string().nullable().optional(),
projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]),
});
@@ -4755,6 +4794,16 @@ export const ProjectIconResponseSchema = z.object({
}),
});
export const ProjectIconGetResponseSchema = z.object({
type: z.literal("project.icon.get.response"),
payload: z.object({
projectId: z.string(),
icon: ProjectIconSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const FileDownloadTokenResponseSchema = z.object({
type: z.literal("file_download_token_response"),
payload: z.object({
@@ -5260,6 +5309,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentRewindResponseMessageSchema,
UpdateAgentResponseMessageSchema,
ProjectRenameResponseSchema,
ProjectIconSetResponseSchema,
ProjectRemoveResponseSchema,
WorkspaceTitleSetResponseSchema,
WorkspacePinSetResponseSchema,
@@ -5310,6 +5360,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileWriteResponseSchema,
FileUpdateSchema,
ProjectIconResponseSchema,
ProjectIconGetResponseSchema,
FileDownloadTokenResponseSchema,
FileUploadResponseSchema,
ListProviderModelsResponseMessageSchema,
@@ -5444,6 +5495,7 @@ export type AgentDetachResponseMessage = z.infer<typeof AgentDetachResponseMessa
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
export type ProjectIconSetResponse = z.infer<typeof ProjectIconSetResponseSchema>;
export type ProjectRemoveResponse = z.infer<typeof ProjectRemoveResponseSchema>;
export type WorkspaceTitleSetResponse = z.infer<typeof WorkspaceTitleSetResponseSchema>;
export type WorkspaceTitleSetResponsePayload = z.infer<
@@ -5590,7 +5642,9 @@ export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
export type ProjectIconSource = z.infer<typeof ProjectIconSourceSchema>;
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
@@ -5728,6 +5782,8 @@ export type FileWriteResult = z.infer<typeof FileWriteResultSchema>;
export type FileUpdate = z.infer<typeof FileUpdateSchema>;
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
export type ProjectIconGetRequest = z.infer<typeof ProjectIconGetRequestSchema>;
export type ProjectIconGetResponse = z.infer<typeof ProjectIconGetResponseSchema>;
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;