-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(desktop): generate every app icon from one SVG source #5329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
01aab51
cfa6269
3ddb648
6875e47
443d0c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| #!/usr/bin/env bun | ||
| /** | ||
| * Render every app icon from `src-tauri/icons/icon.svg`. | ||
| * | ||
| * The icon set used to be eighteen independent raster files with no vector source, so each size | ||
| * was a separate artifact that could drift from the others and nothing could detect it. This makes | ||
| * the sizes derived: one curve, rendered at each dimension the platforms ask for. | ||
| * | ||
| * The SVG reproduces the raster it replaced to within antialiasing (430 of 262144 pixels at 512), | ||
| * measured rather than assumed — the geometry in that file was read off the original bitmap. | ||
| * | ||
| * `--check` regenerates into a temporary directory and compares, so CI can fail on a hand-edited | ||
| * PNG instead of letting the source and the shipped icons disagree quietly. | ||
| */ | ||
| import { spawnSync } from "node:child_process"; | ||
| import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); | ||
| const iconsDir = join(desktopDir, "src-tauri", "icons"); | ||
| const source = join(iconsDir, "icon.svg"); | ||
|
|
||
| /** Square PNGs Tauri and the Windows store manifests reference, by output filename. */ | ||
| const PNG_SIZES: Record<string, number> = { | ||
| "32x32.png": 32, | ||
| "64x64.png": 64, | ||
| "128x128.png": 128, | ||
| "128x128@2x.png": 256, | ||
| "icon.png": 512, | ||
| "Square30x30Logo.png": 30, | ||
| "Square44x44Logo.png": 44, | ||
| "Square71x71Logo.png": 71, | ||
| "Square89x89Logo.png": 89, | ||
| "Square107x107Logo.png": 107, | ||
| "Square142x142Logo.png": 142, | ||
| "Square150x150Logo.png": 150, | ||
| "Square284x284Logo.png": 284, | ||
| "Square310x310Logo.png": 310, | ||
| "StoreLogo.png": 50, | ||
| }; | ||
|
|
||
| /** Sizes an .icns carries, as iconutil names them. */ | ||
| const ICNS_ENTRIES: Array<{ name: string; size: number }> = [ | ||
| { name: "icon_16x16.png", size: 16 }, | ||
| { name: "icon_16x16@2x.png", size: 32 }, | ||
| { name: "icon_32x32.png", size: 32 }, | ||
| { name: "icon_32x32@2x.png", size: 64 }, | ||
| { name: "icon_128x128.png", size: 128 }, | ||
| { name: "icon_128x128@2x.png", size: 256 }, | ||
| { name: "icon_256x256.png", size: 256 }, | ||
| { name: "icon_256x256@2x.png", size: 512 }, | ||
| { name: "icon_512x512.png", size: 512 }, | ||
| { name: "icon_512x512@2x.png", size: 1024 }, | ||
| ]; | ||
|
|
||
| /** Sizes packed into the .ico, which stores each one as an embedded PNG. */ | ||
| const ICO_SIZES = [16, 32, 48, 64, 128, 256]; | ||
|
|
||
| function render(size: number, out: string): void { | ||
| const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), source, "-o", out]); | ||
| if (result.status !== 0) { | ||
| const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; | ||
| throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Pack PNGs into an ICO. | ||
| * | ||
| * Written here rather than shelled out because the alternative is ImageMagick, and adding a | ||
| * system dependency to regenerate an icon is a worse trade than 30 lines of a container format | ||
| * that has not changed in decades. A 256px entry records its dimension as 0, which is how ICO | ||
| * spells "256". | ||
| */ | ||
| function buildIco(entries: Array<{ size: number; bytes: Buffer }>): Buffer { | ||
| const header = Buffer.alloc(6); | ||
| header.writeUInt16LE(0, 0); // reserved | ||
| header.writeUInt16LE(1, 2); // type: icon | ||
| header.writeUInt16LE(entries.length, 4); | ||
|
|
||
| const directory = Buffer.alloc(16 * entries.length); | ||
| let offset = header.length + directory.length; | ||
| entries.forEach((entry, index) => { | ||
| const at = index * 16; | ||
| directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at); | ||
| directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at + 1); | ||
| directory.writeUInt8(0, at + 2); // palette colours | ||
| directory.writeUInt8(0, at + 3); // reserved | ||
| directory.writeUInt16LE(1, at + 4); // colour planes | ||
| directory.writeUInt16LE(32, at + 6); // bits per pixel | ||
| directory.writeUInt32LE(entry.bytes.length, at + 8); | ||
| directory.writeUInt32LE(offset, at + 12); | ||
| offset += entry.bytes.length; | ||
| }); | ||
|
|
||
| return Buffer.concat([header, directory, ...entries.map(entry => entry.bytes)]); | ||
| } | ||
|
|
||
| /** | ||
| * Render the whole set into `target`, and report which artifacts were actually produced. | ||
| * | ||
| * The return value matters: `iconutil` is macOS-only, so on another platform no `.icns` exists to | ||
| * compare against. Reporting that is the difference between "the icns matches" and "nothing looked | ||
| * at the icns", and the check must not spell the second as the first. | ||
| */ | ||
| function generateInto(target: string): { produced: string[]; icnsSkipped: boolean } { | ||
| mkdirSync(target, { recursive: true }); | ||
| const produced: string[] = []; | ||
| for (const [name, size] of Object.entries(PNG_SIZES)) { | ||
| render(size, join(target, name)); | ||
| produced.push(name); | ||
| } | ||
|
|
||
| const iconset = join(target, "icon.iconset"); | ||
| mkdirSync(iconset, { recursive: true }); | ||
| for (const entry of ICNS_ENTRIES) render(entry.size, join(iconset, entry.name)); | ||
| const icns = spawnSync("iconutil", ["-c", "icns", iconset, "-o", join(target, "icon.icns")]); | ||
| const icnsSkipped = icns.status !== 0; | ||
| if (!icnsSkipped) produced.push("icon.icns"); | ||
| rmSync(iconset, { recursive: true, force: true }); | ||
|
|
||
| const icoParts: Array<{ size: number; bytes: Buffer }> = []; | ||
| for (const size of ICO_SIZES) { | ||
| const scratch = join(target, `.ico-${size}.png`); | ||
| render(size, scratch); | ||
| icoParts.push({ size, bytes: readFileSync(scratch) }); | ||
| rmSync(scratch, { force: true }); | ||
| } | ||
| writeFileSync(join(target, "icon.ico"), buildIco(icoParts)); | ||
| produced.push("icon.ico"); | ||
|
|
||
| return { produced, icnsSkipped }; | ||
| } | ||
|
|
||
| function main(): number { | ||
| if (!existsSync(source)) { | ||
| console.error(`[icons] missing source: ${source}`); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: sed -n '1,30p;132,165p' desktop/scripts/generate-icons.ts
find .. -name 'AGENTS.md' -o -name '.coderabbit*' | head -40
rg -n 'private paths|Do not log secrets|actionable failures' . --glob '!node_modules' --glob '!desktop/src-tauri/icons/**'Repository: lidge-jun/opencodex Length of output: 3974 Information Disclosure Reachability: Internal Avoid logging the absolute checkout path.
This is a minor information disclosure through retained or shared logs, not a major security incident. 🤖 Prompt for AI Agents |
||
| return 1; | ||
| } | ||
| const check = process.argv.includes("--check"); | ||
| if (!check) { | ||
| // Render into scratch first so a failure half way through cannot leave the committed set | ||
| // partly replaced, then move the finished artifacts over in one pass. | ||
| const scratch = mkdtempSync(join(tmpdir(), "ocx-icons-")); | ||
| try { | ||
| const { produced, icnsSkipped } = generateInto(scratch); | ||
| if (icnsSkipped) { | ||
| // Abort before touching the committed set. Copying the PNGs and the .ico and then | ||
| // reporting the missing .icns would leave the icons half regenerated: the rasters new, | ||
| // the .icns whatever it was, and no way to tell from the tree which is which. | ||
| console.error("[icons] iconutil is unavailable here, so the .icns cannot be regenerated."); | ||
| console.error("[icons] nothing was written; run this on a machine with iconutil."); | ||
| return 1; | ||
| } | ||
| for (const name of produced) writeFileSync(join(iconsDir, name), readFileSync(join(scratch, name))); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '137,192p' desktop/scripts/generate-icons.ts
rg -n 'src-tauri/icons|generate-icons|icons:check' desktop .github tests | head -120Repository: lidge-jun/opencodex Length of output: 3449 🏁 Script executed: sed -n '1,75p' desktop/scripts/generate-icons.ts
printf '\n--- generation and main ---\n'
sed -n '75,180p' desktop/scripts/generate-icons.ts
printf '\n--- owning test ---\n'
sed -n '1,95p' tests/ci-workflows/build-desktop-icon-set.test.tsRepository: lidge-jun/opencodex Length of output: 12116 Replace each committed artifact atomically.
This is a localized, recoverable generation failure. Classify it as minor. 🤖 Prompt for AI Agents |
||
| console.log(`[icons] regenerated ${produced.length} artifacts from ${source}`); | ||
| return 0; | ||
| } finally { | ||
| rmSync(scratch, { recursive: true, force: true }); | ||
| } | ||
| } | ||
|
|
||
| const scratch = mkdtempSync(join(tmpdir(), "ocx-icons-")); | ||
| try { | ||
| const { produced, icnsSkipped } = generateInto(scratch); | ||
| const drifted: string[] = []; | ||
| for (const name of produced) { | ||
| const fresh = join(scratch, name); | ||
| const committed = join(iconsDir, name); | ||
| if (!existsSync(committed) || !readFileSync(fresh).equals(readFileSync(committed))) { | ||
| drifted.push(name); | ||
| } | ||
| } | ||
| if (drifted.length > 0) { | ||
| console.error(`[icons] these do not match icon.svg: ${drifted.join(", ")}`); | ||
| console.error("[icons] regenerate with: bun run icons"); | ||
| return 1; | ||
| } | ||
| console.log(`[icons] ${produced.length} generated icons match the source`); | ||
| if (icnsSkipped) { | ||
| console.error("[icons] iconutil is unavailable here, so icon.icns was NOT compared."); | ||
| return 1; | ||
| } | ||
| return 0; | ||
| } finally { | ||
| rmSync(scratch, { recursive: true, force: true }); | ||
| } | ||
| } | ||
|
|
||
| process.exit(main()); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| # wp4 — one vector source for the icons, and a verdict on the widget | ||
|
|
||
| ## Why the icon set needed a source | ||
|
|
||
| `desktop/src-tauri/icons/` carried eighteen raster files and no vector. Every size was an | ||
| independent artifact: nothing tied `Square107x107Logo.png` to `icon.png`, nothing could tell | ||
| whether one of them had been hand-edited, and adding a platform size meant drawing it again. The | ||
| `.icns` and `.ico` containers hid the problem further, because a wrong member inside them is not | ||
| visible in a diff at all. | ||
|
|
||
| The fix is a single `icon.svg` plus `desktop/scripts/generate-icons.ts`, exposed as | ||
| `bun run icons` and `bun run icons:check`. Fifteen PNGs render through `rsvg-convert`, the | ||
| `.icns` is assembled by `iconutil` from its ten members, and the `.ico` is written directly with | ||
| six PNG-embedded entries (16, 32, 48, 64, 128, 256). `--check` regenerates into a temporary | ||
| directory and compares byte for byte, so a hand-edited PNG fails instead of silently disagreeing | ||
| with the source. | ||
|
|
||
| ## The geometry was measured, not redrawn | ||
|
|
||
| A redrawn mark would have been a different icon wearing the same name. The shape in `icon.png` | ||
| was measured instead: it spans 58..453 on both axes, the stroke is 48 wide, and the outer corner | ||
| turns at radius 135. A centred stroke therefore sits at `x=82 y=82 w=348 h=348` with | ||
| `stroke-width=48`, and the corner radius was swept to find the closest match. `rx=127` reproduces | ||
| the original to within **430 of 262144 pixels at 512×512 — 0.164%**, which is antialiasing along | ||
| the curve rather than a changed silhouette. | ||
|
|
||
| The mark stays pure black on transparency. Both macOS and Windows composite it over their own | ||
| backgrounds, so a baked background would appear as a card on one of the two. | ||
|
|
||
| ## The widget question | ||
|
|
||
| `OpenCodexWidget.appex` is bundled, and the acceptance note requires a verdict either way rather | ||
| than an absence. | ||
|
|
||
| **The extension registers, and that part is settled.** `pluginkit` lists it from the installed | ||
| application with the parent bundle resolved and no disabled or ignored marker: | ||
|
|
||
| ``` | ||
| com.opencodex.desktop.widget(2.61.0) | ||
| SDK = com.apple.widgetkit-extension | ||
| Parent Bundle = /Applications/OpenCodex.app | ||
| Parent Name = OpenCodex | ||
| Platform = macOS | ||
| ``` | ||
|
|
||
| That record is structurally identical to a system widget queried the same way, so the earlier | ||
| working hypothesis — that ad-hoc signing keeps the extension from being adopted at all — is wrong | ||
| and is recorded here as wrong. Registration is not the obstacle. | ||
|
|
||
| **And it does not appear in the gallery.** The gallery was opened on this machine and checked: | ||
| OpenCodex is not among the offered widgets. No `OpenCodexWidget` process has ever run here | ||
| either, so nothing has asked the extension for a timeline. Registration and adoption are two | ||
| different things, and only the first of them holds. | ||
|
|
||
| **What the signing state actually costs.** The host bundle carries the linker-signed placeholder: | ||
|
|
||
| ``` | ||
| host app Identifier = opencodex_desktop-b89067d97e1c189c | ||
| flags = 0x20002(adhoc,linker-signed) | ||
| Info.plist = not bound | ||
| Sealed Resources = none | ||
| appex Identifier = com.opencodex.desktop.widget | ||
| flags = 0x2(adhoc) | ||
| ``` | ||
|
|
||
| The host's `CFBundleIdentifier` is `com.opencodex.desktop`, but its *signed* identity is the | ||
| placeholder, its `Info.plist` is not bound into the signature, and it seals no resources. Locally | ||
| that is tolerated because the machine built the bundle itself. A distributed copy has no sealed | ||
| host for the system to validate the extension's containment against, and nothing binds the | ||
| declared identifier to the signed one. | ||
|
|
||
| **The verdict, then:** the extension is registered and the gallery does not offer it. The host | ||
| bundle is the thing that fails a requirement — its signed identity is not the identity it | ||
| declares, and it seals nothing — so nothing downstream can establish that this extension belongs | ||
| to `com.opencodex.desktop`. Until the release pipeline signs the host with a Developer ID | ||
| identity, the widget ships but cannot be added. That is the finding; it is not worked around here, | ||
| and no part of the icon work depends on it. | ||
|
|
||
| ## What the icon check does and does not cover | ||
|
|
||
| `bun run icons:check` compares all seventeen generated artifacts — fifteen PNGs, the `.ico` and | ||
| the `.icns` — byte for byte against a fresh render. It needs `rsvg-convert` and `iconutil`, and | ||
| when `iconutil` is missing it now says the `.icns` was not compared and fails, rather than | ||
| reporting a pass over a file it never looked at. | ||
|
|
||
| That check does not run in CI, and claiming otherwise would be the easy lie here. The renderer is | ||
| not pinned, so two machines with different librsvg builds produce different bytes with nothing | ||
| wrong; asserting byte identity on a hosted runner would be asserting the runner's renderer | ||
| version. What CI runs instead is `tests/ci-workflows/build-desktop-icon-set.test.ts`, which needs | ||
| no renderer at all and reads its expectations out of the generator: every declared size committed | ||
| at exactly that size, the `.ico` directory carrying exactly the packed sizes with each payload a | ||
| real PNG of its declared dimension, the `.icns` walking cleanly end to end with one image member | ||
| per declared entry, and nothing hand-added beside the generated set. It was driven red on a | ||
| resized raster and on a stray file before being trusted. | ||
|
|
||
| So the split is: shape is enforced everywhere, byte identity is enforced wherever the toolchain | ||
| exists. | ||
|
|
||
| ## Files | ||
|
|
||
| - `desktop/src-tauri/icons/icon.svg` — new, the single source. | ||
| - `desktop/scripts/generate-icons.ts` — new, renderer and `--check` verifier. | ||
| - `desktop/package.json` — `icons` and `icons:check` scripts. | ||
| - Seventeen regenerated raster artifacts under `desktop/src-tauri/icons/`. | ||
| - `tests/ci-workflows/build-desktop-icon-set.test.ts` — new, the renderer-free structural guard, | ||
| registered in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. | ||
|
|
||
| ## Acceptance | ||
|
|
||
| `bun run icons:check` passes on the committed tree over all seventeen artifacts, | ||
| `build-desktop-icon-set.test.ts` passes and has been shown to fail on a wrong-sized raster and on | ||
| a stray file, `bun run build:local` produces a bundle whose `Contents/Resources/icon.icns` is the | ||
| generated one, and the widget verdict is an observation of the gallery rather than an inference | ||
| from registration. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Report the actual
iconutilfailure.A nonzero status does not prove that
iconutilis unavailable. An installediconutilcan fail because the iconset is invalid or because the output cannot be written. The current code reports the wrong corrective action for all such failures.Treat only an
ENOENTspawn error as unavailable. For other failures, includeicns.errororicns.stderrin the error message.As per coding guidelines: “Use explicit paths, deterministic inputs, bounded resource use, and actionable failures.”
🤖 Prompt for AI Agents
Source: Coding guidelines