From 1496384c243ce17d377dfe0c882a6a497fa9b506 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 22 Oct 2026 13:40:30 -0700 Subject: [PATCH] fix(core): find project icons in Icon Composer bundles and app folders (#57671) --- packages/core/src/project/icon.ts | 151 ++++++++++++++++++++---- packages/core/test/project-icon.test.ts | 88 ++++++++++++++ 2 files changed, 213 insertions(+), 26 deletions(-) diff --git a/packages/core/src/project/icon.ts b/packages/core/src/project/icon.ts index 36d73fcde33..c90e8c8c79b 100644 --- a/packages/core/src/project/icon.ts +++ b/packages/core/src/project/icon.ts @@ -1,7 +1,7 @@ export * as ProjectIcon from "./icon.js" import path from "path" -import { Effect } from "effect" +import { Effect, Option, Schema } from "effect" import { eq } from "drizzle-orm" import { FSUtil } from "@opencode/util/fs-util" import { Hash } from "@opencode/util/hash" @@ -26,6 +26,21 @@ const CANDIDATES = [ ), ...["assets/icon", "assets/logo", "public/logo", "logo"].flatMap((b) => [`${b}.svg`, `${b}.png`]), ] +const composer = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + groups: Schema.Array( + Schema.Struct({ layers: Schema.Array(Schema.Struct({ "image-name": Schema.optional(Schema.String) })) }), + ), + fill: Schema.optional( + Schema.Struct({ + solid: Schema.optional(Schema.String), + "linear-gradient": Schema.optional(Schema.Array(Schema.String)), + }), + ), + }), + ), +) export const refresh = Effect.fn("ProjectIcon.refresh")(function* ( fs: FSUtil.Interface, @@ -34,33 +49,117 @@ export const refresh = Effect.fn("ProjectIcon.refresh")(function* ( ) { const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get() if (!project || id === "global") return - const root = yield* fs.realPath(project.worktree) - for (const name of CANDIDATES) { + const image = yield* discover(fs, project.worktree) + if (!image || (project.icon_url && Hash.fast(project.icon_url) === Hash.fast(image.url))) return + return yield* db.update(ProjectTable).set({ icon_url: image.url }).where(eq(ProjectTable.id, id)).returning().get() +}) + +export const discover = Effect.fn("ProjectIcon.discover")(function* (fs: FSUtil.Interface, directory: string) { + const root = yield* fs.realPath(directory) + const children = yield* fs.readDirectoryEntries(root).pipe(Effect.orElseSucceed(() => [])) + const catalogs = children + .filter((e) => e.type === "directory" && !e.name.startsWith(".")) + .map((e) => e.name) + .toSorted() + .slice(0, 128) + .map((name) => `${name}/Assets.xcassets/AppIcon.appiconset`) + // Prefer flattened icons: a small Composer layer may be only a glow beneath an oversized foreground. + for (const name of [ + ...CANDIDATES, + "AppIcon.icon", + "app/AppIcon.icon", + "assets/AppIcon.icon", + ...catalogs, + "resources/windows", + ]) { const candidate = yield* fs.realPath(path.join(root, name)).pipe(Effect.orElseSucceed(() => undefined)) if (!candidate || candidate === root || !FSUtil.contains(root, candidate)) continue - const entries = name.endsWith(".appiconset") - ? yield* fs.readDirectoryEntries(candidate).pipe(Effect.orElseSucceed(() => [])) - : undefined - const files = entries - ?.flatMap((e) => (e.type === "file" && IMAGES.has(path.extname(e.name).toLowerCase()) ? [e.name] : [])) - .toSorted() - const picked = files ? (files.find((n) => /128|256/.test(n)) ?? files[0]) : undefined - const file = entries - ? picked - ? yield* fs.realPath(path.join(candidate, picked)).pipe(Effect.orElseSucceed(() => undefined)) - : undefined - : candidate - if (!file || file === root || !FSUtil.contains(root, file) || !IMAGES.has(path.extname(file).toLowerCase())) + if (name.endsWith(".icon")) { + const manifest = yield* read(fs, root, path.join(candidate, "icon.json"), new Set([".json"])) + const config = manifest ? Option.getOrUndefined(composer(manifest.bytes.toString("utf8"))) : undefined + for (const layer of config?.groups.flatMap((group) => group.layers) ?? []) { + if (!layer["image-name"]) continue + const image = yield* read( + fs, + root, + path.join(candidate, "Assets", layer["image-name"]), + new Set([".svg", ".png"]), + ) + if (!image) continue + const plain = dataURL(image.file, image.bytes) + const url = image.file.endsWith(".svg") ? backdrop(plain, config?.fill) : plain + if (url.length <= 64 * 1024) return { path: path.relative(root, image.file), url } + if (plain.length <= 64 * 1024) return { path: path.relative(root, image.file), url: plain } + } continue - const info = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined)) - if (!info || info.type !== "File" || !Number(info.size) || Number(info.size) > 64 * 1024) continue - const bytes = yield* fs.readFile(file).pipe(Effect.orElseSucceed(() => undefined)) - if (!bytes?.byteLength || bytes.byteLength > 64 * 1024) continue - const mime = path.extname(file).toLowerCase() === ".ico" ? "image/x-icon" : FSUtil.mimeType(file) - const url = `data:${mime};base64,${Buffer.from(bytes).toString("base64")}` - if (url.length > 64 * 1024) continue - // The stored data URL is the content fingerprint; no separate hash column or cache is needed. - if (project.icon_url && Hash.fast(project.icon_url) === Hash.fast(url)) return - return yield* db.update(ProjectTable).set({ icon_url: url }).where(eq(ProjectTable.id, id)).returning().get() + } + const windows = name === "resources/windows" + const entries = + name.endsWith(".appiconset") || windows + ? yield* fs.readDirectoryEntries(candidate).pipe(Effect.orElseSucceed(() => [])) + : undefined + const rank = (file: string) => + windows ? Math.abs(Number(file.match(/^icon-(\d+)\.png$/i)?.[1]) - 128) : /128|256/.test(file) ? 0 : 1 + const files = entries + ?.flatMap((e) => + e.type === "file" && + (windows ? /^icon-\d+\.png$/i.test(e.name) : IMAGES.has(path.extname(e.name).toLowerCase())) + ? [e.name] + : [], + ) + .toSorted() + .sort((a, b) => rank(a) - rank(b)) + .slice(0, 128) + .map((file) => path.join(candidate, file)) ?? [candidate] + for (const file of files) { + const image = yield* read(fs, root, file, IMAGES) + if (!image) continue + const url = dataURL(image.file, image.bytes) + if (url.length <= 64 * 1024) return { path: path.relative(root, image.file), url } + } } }) + +const read = Effect.fn("ProjectIcon.read")( + function* (fs: FSUtil.Interface, root: string, name: string, extensions: ReadonlySet) { + const file = yield* fs.realPath(name) + if (file === root || !FSUtil.contains(root, file) || !extensions.has(path.extname(file).toLowerCase())) return + const info = yield* fs.stat(file) + if (info.type !== "File" || !Number(info.size) || Number(info.size) > 64 * 1024) return + const bytes = yield* fs.readFile(file) + if (bytes.byteLength && bytes.byteLength <= 64 * 1024) return { file, bytes: Buffer.from(bytes) } + }, + Effect.orElseSucceed(() => undefined), +) + +function dataURL(file: string, bytes: Buffer) { + const mime = path.extname(file).toLowerCase() === ".ico" ? "image/x-icon" : FSUtil.mimeType(file) + return `data:${mime};base64,${bytes.toString("base64")}` +} + +function backdrop(url: string, fill: { solid?: string; "linear-gradient"?: ReadonlyArray } | undefined) { + const colors = (fill?.solid ? [fill.solid] : (fill?.["linear-gradient"] ?? [])).map((color) => { + const match = /^(gray|srgb|display-p3):([\d.,]+)$/.exec(color) + const values = match?.[2]?.split(",").map(Number) ?? [] + if ( + !match || + values.length !== (match[1] === "gray" ? 2 : 4) || + values.some((n) => !Number.isFinite(n) || n < 0 || n > 1) + ) + return + const rgb = match[1] === "gray" ? [values[0], values[0], values[0]] : values.slice(0, 3) + return `color(${match[1] === "display-p3" ? "display-p3" : "srgb"} ${rgb.join(" ")} / ${values.at(-1)})` + }) + if (!colors.length || colors.some((color) => !color)) return url + const gradient = + colors.length > 1 + ? `${colors.map((color, i) => ``).join("")}` + : "" + // Embed the layer as an image, never as SVG markup: its content stays in an inert image document. + return dataURL( + "icon.svg", + Buffer.from( + `${gradient}`, + ), + ) +} diff --git a/packages/core/test/project-icon.test.ts b/packages/core/test/project-icon.test.ts index 3671f60b28f..3a5fce254b8 100644 --- a/packages/core/test/project-icon.test.ts +++ b/packages/core/test/project-icon.test.ts @@ -78,3 +78,91 @@ it.live("resolves in the background, preserves overrides, and only writes change expect(changed).toMatchObject({ icon_url_override: "my-icon", icon_color: "blue" }) }), ) + +for (const fixture of [ + { name: "root Composer SVG", file: "AppIcon.icon/Assets/logo.svg", manifest: "AppIcon.icon/icon.json" }, + { name: "app Composer PNG", file: "app/AppIcon.icon/Assets/logo.png", manifest: "app/AppIcon.icon/icon.json" }, + { + name: "assets Composer SVG", + file: "assets/AppIcon.icon/Assets/logo.svg", + manifest: "assets/AppIcon.icon/icon.json", + }, + { name: "direct-child Xcode catalog", file: "OpenCode/Assets.xcassets/AppIcon.appiconset/icon128.svg" }, + { name: "Windows nearest usable size", file: "resources/windows/icon-96.png" }, + { name: "Scrawl oversized flat icon", file: "site/public/icon.svg" }, +]) { + it.live(`discovers ${fixture.name} without a database`, () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const filesystem = yield* FSUtil.Service + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, fixture.file), + fixture.file.endsWith(".png") + ? Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWZkAAAAASUVORK5CYII=", + "base64", + ) + : svg, + ), + ) + if (fixture.manifest) + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, fixture.manifest), + JSON.stringify({ + groups: [{ layers: [{ "image-name": path.basename(fixture.file) }] }], + }), + ), + ) + if (fixture.name === "Windows nearest usable size") { + yield* Effect.promise(() => Bun.write(path.join(tmp.path, "resources/windows/icon-128.png"), "x".repeat(65537))) + yield* Effect.promise(() => Bun.write(path.join(tmp.path, "resources/windows/icon-32.png"), "other")) + } + if (fixture.name === "Scrawl oversized flat icon") + yield* Effect.promise(() => Bun.write(path.join(tmp.path, "assets/app-icon.png"), "x".repeat(65537))) + expect((yield* ProjectIcon.discover(filesystem, tmp.path))?.path.split(path.sep).join("/")).toBe(fixture.file) + }), + ) +} + +it.live("Composer backgrounds stay inert and malformed, oversized or escaping manifests fall through", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const filesystem = yield* FSUtil.Service + const manifest = path.join(tmp.path, "AppIcon.icon/icon.json") + yield* Effect.promise(() => Bun.write(path.join(tmp.path, "AppIcon.icon/Assets/logo.svg"), svg)) + for (const fill of [ + { solid: "display-p3:0.075,0.063,0.063,1" }, + { "linear-gradient": ["gray:0.2,1", "srgb:0,0,0,1"] }, + ]) { + yield* Effect.promise(() => + Bun.write(manifest, JSON.stringify({ fill, groups: [{ layers: [{ "image-name": "logo.svg" }] }] })), + ) + const icon = yield* ProjectIcon.discover(filesystem, tmp.path) + if (!icon) throw new Error("Expected Composer icon") + const markup = Buffer.from(icon.url.slice(icon.url.indexOf(",") + 1), "base64").toString() + expect(markup).toContain(` Bun.write(manifest, content)) + expect(yield* ProjectIcon.discover(filesystem, tmp.path)).toBeUndefined() + } + const outside = yield* tmpdirScoped() + yield* Effect.promise(() => + Bun.write( + path.join(outside.path, "icon.json"), + JSON.stringify({ groups: [{ layers: [{ "image-name": "logo.svg" }] }] }), + ), + ) + yield* Effect.promise(() => fs.rm(manifest)) + yield* Effect.promise(() => fs.symlink(path.join(outside.path, "icon.json"), manifest)) + expect(yield* ProjectIcon.discover(filesystem, tmp.path)).toBeUndefined() + }), +)