fix(core): find project icons in Icon Composer bundles and app folders (#57671)

This commit is contained in:
Kit Langton
2026-10-22 13:40:30 -07:00
committed by GitHub
Unverified
parent 548ea5deb1
commit 1496384c24
2 changed files with 213 additions and 26 deletions
+125 -26
View File
@@ -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<string>) {
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<string> } | 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
? `<defs><linearGradient id="bg" x2="0" y2="1">${colors.map((color, i) => `<stop offset="${i / (colors.length - 1)}" stop-color="${color}"/>`).join("")}</linearGradient></defs>`
: ""
// Embed the layer as an image, never as SVG markup: its content stays in an inert image document.
return dataURL(
"icon.svg",
Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">${gradient}<rect width="128" height="128" rx="28" fill="${colors.length > 1 ? "url(#bg)" : colors[0]}"/><image width="128" height="128" href="${url}"/></svg>`,
),
)
}
+88
View File
@@ -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(`<image width="128" height="128" href="${url}"`)
expect(markup).toContain("<rect")
expect(icon.url.length).toBeLessThanOrEqual(65536)
}
for (const content of [
"{",
"x".repeat(65537),
JSON.stringify({ groups: [{ layers: [{ "image-name": "missing.svg" }] }] }),
]) {
yield* Effect.promise(() => 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()
}),
)