mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
fix(core): find project icons in Icon Composer bundles and app folders (#57671)
This commit is contained in:
committed by
GitHub
Unverified
parent
548ea5deb1
commit
1496384c24
@@ -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>`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user