diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.ts b/packages/app/src/runtime/server/global-sync/bootstrap.ts index 623ea69168e6..755033f25d86 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.ts @@ -5,7 +5,8 @@ import { getFilename } from "@opencode/util/path" import { retry } from "@opencode/util/retry" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State } from "./types" -import { normalizeProjectInfo } from "./utils" +import { normalizeProjectInfo, type ProjectIconHttp } from "./utils" +import type { ServerConnection } from "@/runtime/server/registry" import { formatServerError } from "@/runtime/server/errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import type { ServerScope } from "@/runtime/server/scope" @@ -58,30 +59,37 @@ export const loadGlobalConfigQuery = (scope: ServerScope) => type ProjectApi = { readonly list: () => Promise + readonly icon?: (input: { projectID: string; v?: string }) => Promise } type LocationApi = { readonly get: (input?: LocationGetInput) => Promise } // Metadata only, in the server's order (most recently active first). Worktree inventories load per project when a // view shows it (see workspaces/inventory). -export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) => +export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, http?: ProjectIconHttp) => queryOptions({ queryKey: [scope, "project"], queryFn: () => retry(() => projects .list() - .then((items) => items.flatMap((project) => (project?.id ? [normalizeProjectInfo(project)] : []))), + .then((items) => items.flatMap((project) => (project?.id ? [normalizeProjectInfo(project, http)] : []))), ), }) export function loadProjects(input: { scope: ServerScope project: ProjectApi + http?: ServerConnection.HttpBase setGlobalStore: SetStoreFunction queryClient: QueryClient }) { - return input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.project)).then((data) => + const http = input.http && { + ...input.http, + icon: input.project.icon, + onIcon: (id: string, url: string) => input.setGlobalStore("project", (item) => item.id === id, "icon", "url", url), + } + return input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.project, http)).then((data) => input.setGlobalStore( "project", data.map((project) => @@ -96,6 +104,7 @@ export async function bootstrapGlobal(input: { readonly location: LocationApi readonly project: ProjectApi } + http?: ServerConnection.HttpBase scope: ServerScope setGlobalStore: SetStoreFunction queryClient: QueryClient @@ -107,6 +116,7 @@ export async function bootstrapGlobal(input: { loadProjects({ scope: input.scope, project: input.serverAPI.project, + http: input.http, setGlobalStore: input.setGlobalStore, queryClient: input.queryClient, }), diff --git a/packages/app/src/runtime/server/global-sync/utils.test.ts b/packages/app/src/runtime/server/global-sync/utils.test.ts index 2a39fce84f0c..2bb3cf6fdea9 100644 --- a/packages/app/src/runtime/server/global-sync/utils.test.ts +++ b/packages/app/src/runtime/server/global-sync/utils.test.ts @@ -92,6 +92,54 @@ describe("normalizeProjectInfo", () => { expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg") expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined() }) + + test("resolves unauthenticated server icon routes directly and fetches password-protected icons into blobs without leaking credentials", async () => { + const base: Project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] } + expect( + normalizeProjectInfo({ ...base, icon: { url: "https://example.com/a.svg" } }, { url: "http://127.0.0.1:4096" }) + .icon?.url, + ).toBe("https://example.com/a.svg") + expect( + normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=v1" } }, { url: "http://127.0.0.1:4096" }) + .icon?.url, + ).toBe("http://127.0.0.1:4096/api/project/prj/icon?v=v1") + + const loaded: string[] = [] + const http = { + url: "http://remote:4096", + password: "secret", + icon: async () => new TextEncoder().encode(""), + onIcon: (_id: string, url: string) => loaded.push(url), + } + const initial = normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=v1" } }, http) + expect(initial.icon?.url).toBeUndefined() + await Promise.resolve() + const cached = normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=v1" } }, http).icon?.url + expect(cached).toStartWith("blob:") + expect(cached).not.toContain("secret") + expect(cached).not.toContain(btoa("opencode:secret")) + expect(loaded).toEqual([cached!]) + + normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=v2" } }, http) + await Promise.resolve() + expect(loaded[1]).toStartWith("blob:") + expect(loaded[1]).not.toBe(cached) + normalizeProjectInfo({ ...base, icon: undefined }, http) + + let attempts = 0 + const failing = { + url: "http://remote:4096", + password: "secret", + icon: async () => { + attempts += 1 + throw new Error("missing") + }, + } + normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=bad" } }, failing) + await Promise.resolve() + normalizeProjectInfo({ ...base, icon: { url: "/api/project/prj/icon?v=bad" } }, failing) + expect(attempts).toBe(1) + }) }) test.each([ diff --git a/packages/app/src/runtime/server/global-sync/utils.ts b/packages/app/src/runtime/server/global-sync/utils.ts index af36458c609d..d10776ac1e2a 100644 --- a/packages/app/src/runtime/server/global-sync/utils.ts +++ b/packages/app/src/runtime/server/global-sync/utils.ts @@ -1,6 +1,7 @@ import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode/client/promise" import type { Agent, Project, Provider, ProviderListResponse } from "@/runtime/server/types" import type { Project as CurrentProject } from "@opencode/client/promise" +import type { ServerConnection } from "@/runtime/server/registry" import { unwrap } from "solid-js/store" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/workspaces/path-key" @@ -136,20 +137,63 @@ export function normalizeProviderList( return result } -export function normalizeProjectInfo(project: Project | CurrentProject): Project { +export type ProjectIconHttp = ServerConnection.HttpBase & { + readonly icon?: (input: { projectID: string; v?: string }) => Promise + readonly onIcon?: (projectID: string, url: string) => void +} + +const iconBlobs = new Map() + +function resolveProjectIcon(projectID: string, icon: CurrentProject["icon"], http?: ProjectIconHttp) { + const raw = icon?.url + const key = raw?.startsWith("/") && http?.url && http.password ? `${http.url}${raw}` : undefined + const prefix = http?.url ? `${http.url}/api/project/${encodeURIComponent(projectID)}/icon` : undefined + if (prefix) { + for (const [k, blob] of iconBlobs) { + if (k !== key && k.startsWith(prefix)) { + if (blob) URL.revokeObjectURL(blob) + iconBlobs.delete(k) + } + } + } + if (!raw?.startsWith("/")) return icon + if (!http?.url) return { ...icon, url: undefined } + if (!http.password) return { ...icon, url: new URL(raw, http.url).toString() } + const cached = key ? iconBlobs.get(key) : undefined + if (key && !iconBlobs.has(key) && http.icon) { + iconBlobs.set(key, "") + const v = new URL(raw, http.url).searchParams.get("v") ?? undefined + void http + .icon({ projectID, v }) + .then((bytes) => { + const blob = URL.createObjectURL( + new Blob([new Uint8Array(bytes)], bytes[0] === 0x3c ? { type: "image/svg+xml" } : undefined), + ) + if (iconBlobs.get(key) !== "") return URL.revokeObjectURL(blob) + iconBlobs.set(key, blob) + http.onIcon?.(projectID, blob) + }) + .catch(() => undefined) + } + return { ...icon, url: cached || undefined } +} + +export function normalizeProjectInfo(project: Project | CurrentProject, http?: ProjectIconHttp): Project { const worktree = "canonical" in project ? project.canonical : project.worktree return { ...project, + icon: resolveProjectIcon(project.id, project.icon, http), worktree, worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }], } } -export function updateProjectInfo(project: Project, update: CurrentProject): Project { +export function updateProjectInfo(project: Project, update: CurrentProject, http?: ProjectIconHttp): Project { return { ...project, ...update, + icon: resolveProjectIcon(update.id, update.icon, http), worktree: update.canonical, worktrees: project.worktrees, } diff --git a/packages/app/src/runtime/server/sync.tsx b/packages/app/src/runtime/server/sync.tsx index 6831b8dccdc7..b21cabd296f3 100644 --- a/packages/app/src/runtime/server/sync.tsx +++ b/packages/app/src/runtime/server/sync.tsx @@ -119,6 +119,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { queryFn: async () => { await bootstrapGlobal({ serverAPI: serverSDK.api, + http: serverSDK.server.http, scope: serverSDK.scope, setGlobalStore, queryClient, @@ -241,12 +242,20 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { setGlobalStore("project", (projects) => { const rest = projects.filter((project) => project.id !== update.id) - if (update.time.archived) return rest + if (update.time.archived) { + normalizeProjectInfo({ ...update, icon: undefined }, serverSDK.server.http) + return rest + } const previous = projects.find((project) => project.id === update.id) + const http = { + ...serverSDK.server.http, + icon: serverSDK.api.project.icon, + onIcon: (id: string, url: string) => setGlobalStore("project", (item) => item.id === id, "icon", "url", url), + } // The wire payload carries no worktrees; keep the inventory this project already loaded. const next = withWorktreeInventory( - previous ? updateProjectInfo(previous, update) : normalizeProjectInfo(update), + previous ? updateProjectInfo(previous, update, http) : normalizeProjectInfo(update, http), worktrees.cached(update.id), ) @@ -258,7 +267,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { // Observes the list the bootstrap loads without fetching it again, so readers can tell "no projects" from "not loaded". const projectsQuery = useQuery(() => ({ - ...loadProjectsQuery(serverSDK.scope, serverSDK.api.project), + ...loadProjectsQuery(serverSDK.scope, serverSDK.api.project, serverSDK.server.http), enabled: false, })) diff --git a/packages/app/src/settings/workspaces/queries.ts b/packages/app/src/settings/workspaces/queries.ts index 7e394c6d9718..6311036f83b2 100644 --- a/packages/app/src/settings/workspaces/queries.ts +++ b/packages/app/src/settings/workspaces/queries.ts @@ -34,7 +34,10 @@ export function workspaceInventoryQuery( if (shouldRefresh) void context.sync.worktrees.refresh(project.id) - return normalizeProjectInfo({ ...project, worktrees }) + return normalizeProjectInfo( + { ...project, worktrees }, + { ...context.sdk.server.http, icon: context.sdk.api.project.icon }, + ) }), ), staleTime: 30_000, diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 5f7533985643..94c8441b39ca 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1715,6 +1715,10 @@ export type ProjectListInput = { readonly archived?: boolean | undefined } export type ProjectListOutput = ReadonlyArray export type ProjectListOperation = (input?: ProjectListInput) => Effect.Effect +export type ProjectIconInput = { readonly projectID: Project.ID; readonly v?: string | undefined } +export type ProjectIconOutput = globalThis.Uint8Array +export type ProjectIconOperation = (input: ProjectIconInput) => Effect.Effect + export type ProjectUpdateInput = { readonly projectID: Project.ID readonly canonical?: AbsolutePath | undefined @@ -1727,6 +1731,7 @@ export type ProjectUpdateOperation = (input: ProjectUpdateInput) => E export interface ProjectApi { readonly list: ProjectListOperation + readonly icon: ProjectIconOperation readonly update: ProjectUpdateOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 7dd0df3d2558..88f1e09f6fb6 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -168,6 +168,8 @@ import type { CredentialRemoveOutput, ProjectListInput, ProjectListOutput, + ProjectIconInput, + ProjectIconOutput, ProjectUpdateInput, ProjectUpdateOutput, FormListInput, @@ -1134,6 +1136,13 @@ const EndpointProjectList = (raw: RawClient["server.project"]) => (input?: Proje raw["project.list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapClientError)), ) +const EndpointProjectIcon = (raw: RawClient["server.project"]) => (input: ProjectIconInput) => + preserveEffect()( + raw["project.icon"]({ params: { projectID: input["projectID"] }, query: { v: input["v"] } }).pipe( + Effect.mapError(mapClientError), + ), + ) + const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) => preserveEffect()( raw["project.update"]({ @@ -1144,6 +1153,7 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj const adaptGroupProject = (raw: RawClient["server.project"]) => ({ list: EndpointProjectList(raw), + icon: EndpointProjectIcon(raw), update: EndpointProjectUpdate(raw), }) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index b52d9d9bd7df..ef0123343ad8 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -162,6 +162,8 @@ import type { CredentialRemoveOutput, ProjectListInput, ProjectListOutput, + ProjectIconInput, + ProjectIconOutput, ProjectUpdateInput, ProjectUpdateOutput, FormListInput, @@ -1514,6 +1516,19 @@ export function make(options: ClientOptions) { }, requestOptions, ), + icon: (input: ProjectIconInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/project/${encodeURIComponent(input.projectID)}/icon`, + query: { v: input["v"] }, + successStatus: 200, + declaredStatuses: [400, 401, 404], + empty: false, + binary: true, + }, + requestOptions, + ), update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index cb83daffaf5e..74d657b1434b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2192,6 +2192,7 @@ export type ConfigEntry = share?: "manual" | "auto" | "disabled" enterprise?: { url?: string } username?: string + icon?: string permissions?: PermissionRuleset policies?: Array<{ action: "provider.use" | "tool.use" | "integration.use" @@ -2873,6 +2874,10 @@ export type ProjectNotFoundError = { export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError" +export type FileNotFoundError = { readonly _tag: "FileNotFoundError"; readonly path: string; readonly message: string } +export const isFileNotFoundError = (value: unknown): value is FileNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FileNotFoundError" + export type PermissionNotFoundError = { readonly _tag: "PermissionNotFoundError" readonly requestID: string @@ -2881,10 +2886,6 @@ export type PermissionNotFoundError = { export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError" -export type FileNotFoundError = { readonly _tag: "FileNotFoundError"; readonly path: string; readonly message: string } -export const isFileNotFoundError = (value: unknown): value is FileNotFoundError => - typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FileNotFoundError" - export type RpcError = { readonly _tag: "RpcError" readonly type: string @@ -6284,6 +6285,13 @@ export type ProjectListInput = { readonly archived?: { readonly archived?: boole export type ProjectListOutput = Array +export type ProjectIconInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly v?: { readonly v?: string | undefined }["v"] +} + +export type ProjectIconOutput = globalThis.Uint8Array + export type ProjectUpdateInput = { readonly projectID: { readonly projectID: string }["projectID"] readonly canonical?: { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index d2f8b4c28d5d..c786618244db 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -54,7 +54,7 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.experimental)).toEqual(["persistentPty"]) expect(client.experimental.persistentPty.read).toBeFunction() expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) - expect(Object.keys(client.project)).toEqual(["list", "update"]) + expect(Object.keys(client.project)).toEqual(["list", "icon", "update"]) expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"]) }) diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index af50de40729c..6d1f38894710 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -210,6 +210,7 @@ export function normalize(input: unknown): Result { share: Info.fields.share, enterprise: Info.fields.enterprise, username: Info.fields.username, + icon: Info.fields.icon, snapshots: Info.fields.snapshots, media: Info.fields.media, tool_output: Info.fields.tool_output, diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 2fe4d6d99414..1e1e3ca7e993 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -2,7 +2,7 @@ export * as Project from "./project.js" import { Context, Duration, Effect, Layer, Schedule, Schema } from "effect" import { ChildProcess } from "effect/process" -import { and, asc, desc, eq, gte, isNotNull, isNull, lt, lte, ne, sql } from "drizzle-orm" +import { and, asc, desc, eq, gte, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema.js" import { Bus } from "./bus.js" @@ -13,6 +13,7 @@ import { Git } from "./git.js" import { AppProcess } from "@opencode/util/process" import { makeGlobalNode } from "@opencode/util/effect/app-node" import { Hash } from "@opencode/util/hash" +import { ProjectIcon } from "./project/icon.js" import { ProjectSchema } from "./project/schema.js" import { ProjectTable, upsertProject } from "./project/sql.js" import { WorktreeTable } from "./worktree/sql.js" @@ -67,12 +68,14 @@ export interface ListInput { export interface Interface { readonly list: (input?: ListInput) => Effect.Effect> + /** The discovered icon file of a Project, or nothing when it has none or uses a remote URL. */ + readonly icon: (projectID: ID) => Effect.Effect readonly update: (input: UpdateInput) => Effect.Effect /** Records Project activity for recency ordering, at most once per minute per Project. */ readonly activate: (projectID: ID) => Effect.Effect /** Resolves and persists the owning Project. */ readonly resolve: (input: AbsolutePath) => Effect.Effect - /** Archives Projects whose known directories are all gone; resolving a Project unarchives it. */ + /** Archives Projects whose known directories are all gone and refreshes discovered icons; resolving a Project unarchives it. */ readonly sweep: () => Effect.Effect } @@ -82,7 +85,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info { const icon = row.icon_url || row.icon_url_override || row.icon_color ? { - url: row.icon_url ?? undefined, + url: row.icon_url?.split("&path=")[0] ?? undefined, override: row.icon_url_override ?? undefined, color: row.icon_color ?? undefined, } @@ -113,10 +116,17 @@ const layer = Layer.effect( const bus = yield* Bus.Service const db = (yield* Database.Service).db + const discoverIcon = (id: ID, root: AbsolutePath) => + ProjectIcon.discover(fs, root).pipe( + Effect.map((c) => c && ("url" in c ? c.url : `/api/project/${encodeURIComponent(id)}/icon?v=${c.hash}&path=${encodeURIComponent(c.path)}`)), + ) + const setIcon = (id: ID, icon_url: string | null) => + db.update(ProjectTable).set({ icon_url, time_updated: sql`${ProjectTable.time_updated}` }).where(eq(ProjectTable.id, id)).returning().get().pipe(Effect.orDie) + const announcing = new Set() const persist = Effect.fnUntraced(function* (project: Resolved) { const previous = yield* db - .select({ canonical: ProjectTable.worktree }) + .select({ canonical: ProjectTable.worktree, icon_url: ProjectTable.icon_url }) .from(ProjectTable) .where(eq(ProjectTable.id, project.id)) .get() @@ -129,6 +139,9 @@ const layer = Layer.effect( .returning() .get() .pipe(Effect.orDie) + const icon_url = + project.id !== ID.global && !previous?.icon_url ? yield* discoverIcon(project.id, project.canonical) : undefined + const backfilled = icon_url ? yield* setIcon(project.id, icon_url) : undefined // Clones share a project ID; only replace a canonical directory that is gone. const replaced = previous && @@ -142,7 +155,7 @@ const layer = Layer.effect( .get() .pipe(Effect.orDie) : undefined - const changed = replaced ?? unarchived + const changed = replaced ?? (previous || !project.vcs ? backfilled : undefined) ?? unarchived if (changed) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(changed)) if (!project.vcs) return project const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [ @@ -229,6 +242,15 @@ const layer = Layer.effect( return rows.map(fromRow) }) + const icon = Effect.fn("Project.icon")(function* (projectID: ID) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie) + if (!row) return yield* new NotFoundError({ projectID }) + const relative = row.icon_url?.startsWith("/api/project/") + ? new URL(row.icon_url, "http://localhost").searchParams.get("path") + : null + return relative ? yield* ProjectIcon.read(fs, row.worktree, relative) : undefined + }) + const update = Effect.fn("Project.update")(function* (input: UpdateInput) { const row = yield* db .update(ProjectTable) @@ -440,9 +462,25 @@ const layer = Layer.effect( }), { concurrency: 8, discard: true }, ) + const recent = yield* db + .select() + .from(ProjectTable) + .where(and(isNull(ProjectTable.time_archived), ne(ProjectTable.id, ID.global), or(gte(ProjectTable.time_active, cutoff), gte(ProjectTable.time_updated, cutoff)))) + .all() + .pipe(Effect.orDie) + yield* Effect.forEach( + recent, + Effect.fnUntraced(function* (project) { + if (project.icon_url && !project.icon_url.startsWith("/api/project/")) return + const next = (yield* discoverIcon(project.id, project.worktree)) ?? null + const updated = next !== project.icon_url ? yield* setIcon(project.id, next) : undefined + if (updated) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(updated)) + }), + { concurrency: 4, discard: true }, + ) }) - return Service.of({ list, update, activate, resolve, sweep }) + return Service.of({ list, icon, update, activate, resolve, sweep }) }), ) diff --git a/packages/core/src/project/icon.ts b/packages/core/src/project/icon.ts new file mode 100644 index 000000000000..909692a779f4 --- /dev/null +++ b/packages/core/src/project/icon.ts @@ -0,0 +1,71 @@ +export * as ProjectIcon from "./icon.js" + +import path from "path" +import { Effect, Option, Schema } from "effect" +import { parse } from "jsonc-parser" +import { FSUtil } from "@opencode/util/fs-util" +import { Hash } from "@opencode/util/hash" +import { AbsolutePath } from "../schema.js" + +export const MAX_BYTES = 2 * 1024 * 1024 + +export interface Image { + readonly path: string + readonly type: string + readonly hash: string + readonly bytes: Uint8Array +} +export type Candidate = Image | { readonly url: string } + +const IMAGES = new Set([".svg", ".png", ".ico", ".jpg", ".jpeg", ".webp"]) +const CANDIDATES = [ + "Assets.xcassets/AppIcon.appiconset", + "ios/App/Assets.xcassets/AppIcon.appiconset", + ...["assets/app-icon", "build/icon", "src-tauri/icons/icon", "resources/icon", "icons/icon", "extension/icons/icon"].flatMap( + (b) => [`${b}.svg`, `${b}.png`], + ), + ...["favicon.svg", "icon.svg", "favicon.png", "apple-touch-icon.png", "icon.png", "favicon.ico"].flatMap((f) => + ["public/", "app/", "src/app/", "site/public/", ""].map((d) => `${d}${f}`), + ), + ...["assets/icon", "assets/logo", "public/logo", "logo"].flatMap((b) => [`${b}.svg`, `${b}.png`]), +] +const decodeConfig = Schema.decodeUnknownOption(Schema.Struct({ icon: Schema.String })) + +export const discover = Effect.fn("ProjectIcon.discover")(function* (fs: FSUtil.Interface, root: AbsolutePath) { + for (const name of ["opencode.jsonc", "opencode.json"]) { + const text = yield* fs.readFileString(path.join(root, name)).pipe(Effect.orElseSucceed(() => undefined)) + const val = text !== undefined ? Option.getOrUndefined(decodeConfig(parse(text)))?.icon.trim() : undefined + const hit = val ? (/^https?:/i.test(val) ? { url: val } : yield* read(fs, root, val)) : undefined + if (hit) return hit + } + for (const candidate of CANDIDATES) { + const entries = candidate.endsWith(".appiconset") + ? yield* fs.readDirectoryEntries(path.join(root, 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 image = entries ? (picked ? yield* read(fs, root, `${candidate}/${picked}`) : undefined) : yield* read(fs, root, candidate) + if (image) return image + } +}) + +export const read = Effect.fn("ProjectIcon.read")(function* (fs: FSUtil.Interface, root: AbsolutePath, relative: string) { + if (relative.includes("\u0000") || path.isAbsolute(relative)) return undefined + const realRoot = yield* fs.realPath(root).pipe(Effect.orElseSucceed(() => undefined)) + const realFile = yield* fs.realPath(path.resolve(root, relative)).pipe(Effect.orElseSucceed(() => undefined)) + const ext = realFile ? path.extname(realFile).toLowerCase() : "" + if (!realRoot || !realFile || !FSUtil.contains(realRoot, realFile) || realFile === realRoot || !IMAGES.has(ext)) + return undefined + const info = yield* fs.stat(realFile).pipe(Effect.orElseSucceed(() => undefined)) + if (!info || info.type !== "File" || !Number(info.size) || Number(info.size) > MAX_BYTES) return undefined + const bytes = yield* fs.readFile(realFile).pipe(Effect.orElseSucceed(() => undefined)) + if (!bytes || !bytes.byteLength || bytes.byteLength > MAX_BYTES) return undefined + return { + path: path.relative(realRoot, realFile).split(path.sep).join("/"), + type: ext === ".ico" ? "image/x-icon" : FSUtil.mimeType(realFile), + hash: Hash.fast(Buffer.from(bytes)).slice(0, 16), + bytes, + } +}) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index d26771b623eb..f9211d3fb3dc 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -99,6 +99,7 @@ describe("node build", () => { acquisitions++ return Project.Service.of({ list: () => Effect.succeed([]), + icon: () => Effect.succeed(undefined), update: () => Effect.die("not implemented"), activate: () => Effect.void, sweep: () => Effect.void, diff --git a/packages/core/test/lib/project.ts b/packages/core/test/lib/project.ts index 32b96011143d..2d6f7eb899c9 100644 --- a/packages/core/test/lib/project.ts +++ b/packages/core/test/lib/project.ts @@ -12,6 +12,7 @@ export const globalProjectNode = makeGlobalNode({ const database = yield* Database.Service return Project.Service.of({ list: () => Effect.succeed([]), + icon: () => Effect.succeed(undefined), update: () => Effect.die("not implemented"), activate: () => Effect.void, sweep: () => Effect.void, diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 9f6799cbc5b4..6e7fcdbce099 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -13,6 +13,7 @@ const projectLayer = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), + icon: () => Effect.succeed(undefined), update: () => Effect.die("not implemented"), activate: () => Effect.void, sweep: () => Effect.void, diff --git a/packages/core/test/project-icon.test.ts b/packages/core/test/project-icon.test.ts new file mode 100644 index 000000000000..644747573075 --- /dev/null +++ b/packages/core/test/project-icon.test.ts @@ -0,0 +1,139 @@ +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { eq } from "drizzle-orm" +import { Effect, Stream } from "effect" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Bus } from "@opencode/core/bus" +import { Database } from "@opencode/core/database/database" +import { Project } from "@opencode/core/project" +import { ProjectIcon } from "@opencode/core/project/icon" +import { ProjectSchema } from "@opencode/core/project/schema" +import { ProjectTable } from "@opencode/core/project/sql" +import { AbsolutePath } from "@opencode/core/schema" +import { FSUtil } from "@opencode/util/fs-util" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node, FSUtil.node]))) +const SVG = "" +const SVG_ALT = "" + +async function writeFile(root: string, relative: string, content: string | Uint8Array) { + const target = path.join(root, relative) + await fs.mkdir(path.dirname(target), { recursive: true }) + await Bun.write(target, content) +} + +describe("ProjectIcon", () => { + it.live("discovers priority icons, keeps https config URLs, and rejects traversal/symlinks/oversized files", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const fsUtil = yield* FSUtil.Service + const root = AbsolutePath.make(path.join(tmp.path, "repo")) + yield* Effect.promise(async () => { + await writeFile(tmp.path, "outside.svg", SVG) + await writeFile(root, "secret.txt", "secret") + await writeFile(root, "Assets.xcassets/AppIcon.appiconset/icon_32.png", "small") + await writeFile(root, "Assets.xcassets/AppIcon.appiconset/icon_256.png", "medium") + await writeFile(root, "Assets.xcassets/AppIcon.appiconset/icon_1024.png", "huge") + await writeFile(root, "public/favicon.svg", new Uint8Array(ProjectIcon.MAX_BYTES + 1)) + await writeFile(root, "assets/logo.svg", SVG) + }) + + expect(yield* ProjectIcon.discover(fsUtil, root)).toMatchObject({ + path: "Assets.xcassets/AppIcon.appiconset/icon_256.png", + }) + + yield* Effect.promise(async () => { + await fs.rm(path.join(root, "Assets.xcassets"), { recursive: true }) + await fs.symlink(path.join(tmp.path, "outside.svg"), path.join(root, "public/icon.svg")) + await fs.symlink(path.join(root, "secret.txt"), path.join(root, "public/favicon.png")) + await writeFile(root, "opencode.jsonc", `{\n // custom\n "icon": "https://example.com/icon.svg"\n}`) + }) + expect(yield* ProjectIcon.discover(fsUtil, root)).toEqual({ url: "https://example.com/icon.svg" }) + expect(yield* ProjectIcon.read(fsUtil, root, "public/icon.svg")).toBeUndefined() + expect(yield* ProjectIcon.read(fsUtil, root, "public/favicon.png")).toBeUndefined() + + yield* Effect.promise(() => writeFile(root, "opencode.jsonc", JSON.stringify({ icon: "../outside.svg" }))) + expect(yield* ProjectIcon.discover(fsUtil, root)).toMatchObject({ path: "assets/logo.svg" }) + }), + ) + + it.live("persists versioned URLs, emits Updated for new non-VCS projects, and refreshes on sweep", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const root = AbsolutePath.make(path.join(tmp.path, "repo")) + yield* Effect.promise(async () => { + await writeFile(tmp.path, "outside.svg", SVG) + await writeFile(root, "public/favicon.svg", SVG) + await writeFile(root, "assets/logo.svg", SVG_ALT) + }) + const project = yield* Project.Service + const bus = yield* Bus.Service + const db = (yield* Database.Service).db + const events: Array = [] + yield* bus.subscribe(ProjectSchema.Event.Updated).pipe( + Stream.runForEach((event) => Effect.sync(() => events.push(event.data))), + Effect.forkScoped({ startImmediately: true }), + ) + + const resolved = yield* project.resolve(root) + yield* Effect.yieldNow + const currentUrl = () => + project.list().pipe(Effect.map((items) => items.find((item) => item.id === resolved.id)?.icon?.url ?? "")) + const firstUrl = yield* currentUrl() + const readText = () => + project.icon(resolved.id).pipe(Effect.map((img) => (img ? new TextDecoder().decode(img.bytes) : undefined))) + + expect(firstUrl).toMatch(new RegExp(`^/api/project/${resolved.id}/icon\\?v=[0-9a-f]{16}$`)) + expect(events.at(-1)?.icon?.url).toBe(firstUrl) + expect(yield* readText()).toBe(SVG) + + yield* Effect.promise(() => writeFile(root, "public/favicon.svg", SVG_ALT)) + yield* project.sweep() + expect(yield* currentUrl()).not.toBe(firstUrl) + + yield* Effect.promise(async () => { + await writeFile(root, "brand.svg", SVG) + await writeFile(root, "opencode.json", JSON.stringify({ icon: "./brand.svg" })) + }) + yield* project.sweep() + expect(yield* currentUrl()).toBe(firstUrl) + expect(yield* readText()).toBe(SVG) + + yield* Effect.promise(async () => { + await fs.rm(path.join(root, "opencode.json")) + await fs.rm(path.join(root, "brand.svg")) + await fs.rm(path.join(root, "public/favicon.svg")) + }) + expect(yield* readText()).toBeUndefined() + yield* project.sweep() + expect(yield* readText()).toBe(SVG_ALT) + + yield* db + .update(ProjectTable) + .set({ icon_url: "https://example.com/custom.svg" }) + .where(eq(ProjectTable.id, resolved.id)) + .run() + .pipe(Effect.orDie) + yield* project.sweep() + expect(yield* currentUrl()).toBe("https://example.com/custom.svg") + + yield* db + .update(ProjectTable) + .set({ icon_url: `/api/project/${resolved.id}/icon?v=deadbeef&path=${encodeURIComponent("../outside.svg")}` }) + .where(eq(ProjectTable.id, resolved.id)) + .run() + .pipe(Effect.orDie) + expect(yield* readText()).toBeUndefined() + }), + ) +}) diff --git a/packages/protocol/src/groups/project.ts b/packages/protocol/src/groups/project.ts index 7609dba2d5a0..c7c28647003d 100644 --- a/packages/protocol/src/groups/project.ts +++ b/packages/protocol/src/groups/project.ts @@ -1,7 +1,7 @@ import { Project } from "@opencode/schema/project" import { Schema, SchemaGetter, Struct } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" -import { ProjectNotFoundError } from "../errors.js" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api" +import { FileNotFoundError, ProjectNotFoundError } from "../errors.js" const root = "/api/project" const UpdatePayload = Schema.Struct(Struct.omit(Project.UpdateInput.fields, ["projectID"])) @@ -25,6 +25,20 @@ export const ProjectGroup = HttpApiGroup.make("server.project") }), ), ) + .add( + HttpApiEndpoint.get("project.icon", `${root}/:projectID/icon`, { + params: { projectID: Project.ID }, + query: Schema.Struct({ v: Schema.String.pipe(Schema.optional) }), + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + error: [ProjectNotFoundError, FileNotFoundError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.icon", + summary: "Get project icon", + description: "Serve the discovered icon file of the project.", + }), + ), + ) .add( HttpApiEndpoint.patch("project.update", `${root}/:projectID`, { params: { projectID: Project.ID }, diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts index 732947eb3fe9..1ad84fe2652e 100644 --- a/packages/schema/src/config.ts +++ b/packages/schema/src/config.ts @@ -52,6 +52,10 @@ export class Info extends Schema.Class("Config.Info")({ username: Schema.String.pipe(optional).annotate({ description: "Username displayed in conversations and used for telemetry identity", }), + icon: Schema.String.pipe(optional).annotate({ + description: + "Project icon shown by clients: an image path relative to this config file, or an http/https URL. Only read from the project root config.", + }), permissions: Permission.Ruleset.pipe(optional).annotate({ description: "Ordered tool permission rules applied to agent tool use", }), diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts index 4e59fb15cda5..45192aef1e29 100644 --- a/packages/server/src/handlers/project.ts +++ b/packages/server/src/handlers/project.ts @@ -1,23 +1,38 @@ import { Project } from "@opencode/core/project" import { Effect } from "effect" +import { HttpServerResponse } from "effect/http" import { HttpApiBuilder } from "effect/http-api" import { Api } from "../api" -import { ProjectNotFoundError } from "@opencode/protocol/errors" +import { FileNotFoundError, ProjectNotFoundError } from "@opencode/protocol/errors" + +const notFound = (projectID: Project.ID) => + new ProjectNotFoundError({ projectID, message: `Project not found: ${projectID}` }) export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) => handlers .handle("project.list", (ctx) => Project.Service.use((project) => project.list(ctx.query))) + .handleRaw("project.icon", (ctx) => + Effect.gen(function* () { + const project = yield* Project.Service + const image = yield* project.icon(ctx.params.projectID).pipe(Effect.mapError(() => notFound(ctx.params.projectID))) + if (!image) return yield* new FileNotFoundError({ path: "icon", message: "Project has no icon" }) + const etag = `"${image.hash}"` + const headers = { + etag, + "cache-control": ctx.query.v === image.hash ? "private, max-age=31536000, immutable" : "private, no-cache", + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", + "x-content-type-options": "nosniff", + } + return ctx.request.headers["if-none-match"]?.replace(/^W\//i, "") === etag + ? HttpServerResponse.empty({ status: 304, headers }) + : HttpServerResponse.uint8Array(image.bytes, { contentType: image.type, headers }) + }), + ) .handle("project.update", (ctx) => Project.Service.use((project) => - project.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe( - Effect.mapError( - () => - new ProjectNotFoundError({ - projectID: ctx.params.projectID, - message: `Project not found: ${ctx.params.projectID}`, - }), - ), - ), + project + .update({ ...ctx.payload, projectID: ctx.params.projectID }) + .pipe(Effect.mapError(() => notFound(ctx.params.projectID))), ), ), ) diff --git a/packages/server/test/project-icon.test.ts b/packages/server/test/project-icon.test.ts new file mode 100644 index 000000000000..82a2c4efdb76 --- /dev/null +++ b/packages/server/test/project-icon.test.ts @@ -0,0 +1,50 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { expect } from "bun:test" +import { $ } from "bun" +import { Effect } from "effect" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { startServer } from "./fixture/server" + +it.live( + "serves discovered project icon bytes, ETag, CSP/nosniff headers, 304, and 404 when deleted", + () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const directory = path.join(tmp.path, "checkout") + const iconFile = path.join(directory, "public", "favicon.svg") + yield* Effect.promise(async () => { + await fs.mkdir(path.dirname(iconFile), { recursive: true }) + await fs.writeFile(iconFile, "") + await $`git init -q && git remote add origin https://github.com/acme/sketchbook.git`.cwd(directory).quiet() + }) + const server = yield* startServer(path.join(tmp.path, "config")) + const get = (pathname: string, headers: Record = {}) => + Effect.promise(() => fetch(new URL(pathname, server.base), { headers: { ...server.headers, ...headers } })) + + const location = yield* get(`/api/location?location[directory]=${encodeURIComponent(directory)}`) + const projectID = (yield* Effect.promise(() => location.json())).project.id + const projects: Array<{ id: string; icon?: { url?: string } }> = yield* get("/api/project").pipe( + Effect.flatMap((response) => Effect.promise(() => response.json())), + ) + const url = projects.find((item) => item.id === projectID)?.icon?.url ?? "" + const icon = yield* get(url) + const etag = icon.headers.get("etag") ?? "" + const revalidated = yield* get(url, { "if-none-match": etag }) + + expect(url).toStartWith(`/api/project/${projectID}/icon?v=`) + expect(icon.status).toBe(200) + expect(icon.headers.get("content-type")).toBe("image/svg+xml") + expect(icon.headers.get("cache-control")).toBe("private, max-age=31536000, immutable") + expect(icon.headers.get("content-security-policy")).toBe("default-src 'none'; style-src 'unsafe-inline'; sandbox") + expect(icon.headers.get("x-content-type-options")).toBe("nosniff") + expect(yield* Effect.promise(() => icon.text())).toBe("") + expect(revalidated.status).toBe(304) + + yield* Effect.promise(() => fs.rm(iconFile)) + const missing = yield* get(url) + expect(missing.status).toBe(404) + }), + 15_000, +) diff --git a/packages/ui/src/data-display/project-avatar/project-avatar.tsx b/packages/ui/src/data-display/project-avatar/project-avatar.tsx index 2933b910a7f1..57bdb50647ea 100644 --- a/packages/ui/src/data-display/project-avatar/project-avatar.tsx +++ b/packages/ui/src/data-display/project-avatar/project-avatar.tsx @@ -1,4 +1,4 @@ -import { type ComponentProps, splitProps, Show } from "solid-js" +import { type ComponentProps, createSignal, splitProps, Show } from "solid-js" import "./project-avatar.css" const segmenter = @@ -85,6 +85,8 @@ export interface ProjectAvatarProps extends ComponentProps<"div"> { export function ProjectAvatar(props: ProjectAvatarProps) { const [split, rest] = splitProps(props, ["fallback", "src", "variant", "unread", "class", "classList", "style"]) + const [failed, setFailed] = createSignal() + const src = () => (split.src !== failed() ? split.src : undefined) return (
- - {(value) => } + + {(value) => setFailed(value())} draggable={false} data-slot="project-avatar-image" />}