diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts index f16e8179a4b..04ab6e7c803 100644 --- a/desktop/scripts/generate-icons.ts +++ b/desktop/scripts/generate-icons.ts @@ -17,6 +17,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { deflateSync, inflateSync } from "node:zlib"; const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); const iconsDir = join(desktopDir, "src-tauri", "icons"); @@ -76,6 +77,117 @@ function render(size: number, out: string, from: string = source): void { const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); } + writeFileSync(out, toRgba(readFileSync(out))); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(bytes: Buffer): number { + let c = -1; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8); + return (c ^ -1) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, "latin1"); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** Undo one PNG scanline filter in place, given the already reconstructed row above. */ +function unfilter(kind: number, row: Buffer, prior: Buffer, stride: number): void { + for (let i = 0; i < row.length; i += 1) { + const a = i >= stride ? row[i - stride]! : 0; + const b = prior[i]!; + const c = i >= stride ? prior[i - stride]! : 0; + let add = 0; + if (kind === 1) add = a; + else if (kind === 2) add = b; + else if (kind === 3) add = (a + b) >> 1; + else if (kind === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } else if (kind !== 0) throw new Error(`unknown PNG filter ${kind}`); + row[i] = (row[i]! + add) & 0xff; + } +} + +/** + * Re-encode an 8-bit truecolour PNG as truecolour-with-alpha, and leave one that already has + * alpha untouched. + * + * This exists because the icon has an opaque backdrop. librsvg notices that nothing in the render + * is transparent and drops the alpha channel, which is a valid PNG and a broken icon: Tauri's + * `generate_context!` rejects a window icon that is not RGBA, so `bun run build` fails at + * compile time with "icon ... is not RGBA". Leaving that to the renderer would also make the + * committed bytes depend on which librsvg drew them. + */ +function toRgba(bytes: Buffer): Buffer { + if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("rsvg-convert did not emit a PNG"); + + let header: { width: number; height: number } | null = null; + let colourType = -1; + const idat: Buffer[] = []; + for (let at = 8; at + 8 <= bytes.length; ) { + const length = bytes.readUInt32BE(at); + const type = bytes.subarray(at + 4, at + 8).toString("latin1"); + const data = bytes.subarray(at + 8, at + 8 + length); + if (type === "IHDR") { + colourType = data[9]!; + if (data[8] !== 8 || data[12] !== 0) throw new Error("PNG is not 8-bit non-interlaced"); + header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4) }; + } else if (type === "IDAT") idat.push(Buffer.from(data)); + else if (type === "IEND") break; + at += 12 + length; + } + if (colourType === 6) return bytes; + if (colourType !== 2 || header === null) throw new Error(`unexpected PNG colour type ${colourType}`); + + const { width, height } = header; + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 3; + const out = Buffer.alloc(height * (1 + width * 4)); + let prior = Buffer.alloc(stride); + for (let y = 0; y < height; y += 1) { + const start = y * (1 + stride); + const row = Buffer.from(raw.subarray(start + 1, start + 1 + stride)); + unfilter(raw[start]!, row, prior, 3); + const target = y * (1 + width * 4); + out[target] = 0; + for (let x = 0; x < width; x += 1) { + row.copy(out, target + 1 + x * 4, x * 3, x * 3 + 3); + out[target + 1 + x * 4 + 3] = 0xff; + } + prior = row; + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(out, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); } /** diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index b500b7b1192..8827b1fdc27 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index d7616e4e2c0..21c507c776c 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index d378409ff52..47a19b8131c 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index 304e712fd37..5f7a0713c82 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index df082c8d71d..9436cf61e1c 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index ac18887b72e..80d163ad59a 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index 27fe4bca5b0..5148c874dcc 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index 68d2404adda..d4ccde8b23f 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 904b613b470..acbbb84ceb3 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index f02fc8c8916..1b9c14d45a4 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index 5709ec13c39..5ce2668bf23 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 99ab09e8a15..f9a361ad932 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index 3320233fbdb..eafa6f2087d 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index 817af2b2a14..86218245878 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index d96fa435002..12ddb09d391 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index 4ba7ed664e4..80ded4a940b 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index 64b6ed4f5b1..5c8fa696c68 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/tests/ci-workflows/build-desktop-icon-set.test.ts b/tests/ci-workflows/build-desktop-icon-set.test.ts index afb1d83d5ab..7af94c1f609 100644 --- a/tests/ci-workflows/build-desktop-icon-set.test.ts +++ b/tests/ci-workflows/build-desktop-icon-set.test.ts @@ -50,6 +50,9 @@ function declaredIcoSizes(source: string): number[] { const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +/** Truecolour with alpha, the only colour type a Tauri window icon is allowed to be. */ +const RGBA = 6; + function isPng(bytes: Buffer): boolean { return bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE); } @@ -83,6 +86,19 @@ describe("desktop icon set", () => { expect(wrong).toEqual([]); }); + /** + * The backdrop is opaque, and librsvg drops the alpha channel when nothing in a render is + * transparent. That is a valid PNG and a broken icon: `tauri::generate_context!` refuses a + * window icon that is not RGBA, so the desktop app stops compiling with "icon ... is not RGBA" + * — a failure that only a real bundle build reaches. The generator re-encodes, and this is what + * notices if that ever stops happening. + */ + test("every committed raster keeps the alpha channel Tauri requires", () => { + const names = [...pngs.keys(), join("tray", "icon.png")]; + const flat = names.filter(name => readFileSync(join(ICONS_DIR, name))[25] !== RGBA); + expect(flat, "these rasters lost their alpha channel").toEqual([]); + }); + test("icon.ico carries exactly the sizes the generator packs, each a real PNG of that size", () => { const ico = readFileSync(join(ICONS_DIR, "icon.ico")); expect(ico.readUInt16LE(0)).toBe(0);