import { existsSync } from "node:fs"; import { join } from "node:path"; import { ELECTROBUN_CODES, ElectrobunError, } from "../error-codes/error-codes.js"; import { GenerateIconsOptionsSchema, parseOrThrow, } from "../schemas/schemas.js"; import { recordIcons } from "../telemetry/telemetry.js"; import type { IconRendererAdapter, IconRendererInput, IconReport, } from "../types/electrobun.types.js"; const ICNS_SIZES = [16, 32, 64, 128, 256, 512, 1024] as const; const ICO_SIZES = [16, 32, 48, 64, 128, 256] as const; const PNG_SIZES = [16, 32, 48, 64, 128, 256, 512, 1024] as const; interface IconzModule { createIconz(input: { input: string; output: string; icons: Record< string, { type: string; name: string; sizes: readonly number[]; folder?: string; } >; }): { generate(): Promise }; } /** * Default icon renderer backed by the `iconz` package. Loads the upstream * library lazily so test environments that swap in a recording adapter * never pay the import cost. */ export const iconzRendererAdapter: IconRendererAdapter = { async render(input: IconRendererInput) { const mod = (await import("iconz")) as unknown as IconzModule; const iconz = mod.createIconz({ input: input.input, output: input.output, icons: { appIcns: { type: "icns", name: input.icons.icns.name, sizes: input.icons.icns.sizes, }, appIco: { type: "ico", name: input.icons.ico.name, sizes: input.icons.ico.sizes, }, pngIcons: { type: "png", name: input.icons.png.name, sizes: input.icons.png.sizes, folder: ".", }, }, }); await iconz.generate(); const files = [ `${input.icons.icns.name}.icns`, `${input.icons.ico.name}.ico`, ...input.icons.png.sizes.map( (size) => `${input.icons.png.name.replace("{{dims}}", String(size))}.png` ), ]; return { files }; }, }; export interface GenerateIconsOptions { readonly inputPath: string; readonly outputDir: string; } export interface GenerateIconsDeps { readonly renderer?: IconRendererAdapter; readonly fileExists?: (path: string) => boolean; } /** * Run the icon pipeline against the user's input image. The default * renderer is `iconz`; tests may inject a recording fake to assert the * `IconRendererInput` shape. Returns an `IconReport`; raises * `ELECTROBUN.ICON_SOURCE_MISSING` when the input path does not exist, * and wraps renderer throws as `ELECTROBUN.ICON_RENDER_FAILED` (the * report's `error` field carries the underlying message). */ export async function generateIcons( options: GenerateIconsOptions, deps: GenerateIconsDeps = {} ): Promise { const parsed = parseOrThrow( GenerateIconsOptionsSchema, options, "GenerateIconsOptions" ); const fileExists = deps.fileExists ?? ((p: string) => existsSync(p)); if (!fileExists(parsed.inputPath)) { recordIcons("failed"); throw new ElectrobunError( ELECTROBUN_CODES.ICON_SOURCE_MISSING, `icon source not found: ${parsed.inputPath}`, { details: { inputPath: parsed.inputPath } } ); } const renderer = deps.renderer ?? iconzRendererAdapter; const iconsDir = join(parsed.outputDir, "icons"); try { const { files } = await renderer.render({ input: parsed.inputPath, output: iconsDir, icons: { icns: { name: "app", sizes: ICNS_SIZES }, ico: { name: "app", sizes: ICO_SIZES }, png: { name: "icon-{{dims}}", sizes: PNG_SIZES }, }, }); recordIcons("generated"); return { generated: true, files: files.map((f) => `icons/${f}`), }; } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); recordIcons("failed"); return { generated: false, files: [], error: `Failed to generate icons: ${message}`, }; } }