Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions desktop/scripts/generate-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)),
]);
}

/**
Expand Down
Binary file modified desktop/src-tauri/icons/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/128x128@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square107x107Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square142x142Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square150x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square284x284Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square30x30Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square310x310Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square44x44Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square71x71Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/Square89x89Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/StoreLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified desktop/src-tauri/icons/icon.icns
Binary file not shown.
Binary file modified desktop/src-tauri/icons/icon.ico
Binary file not shown.
Binary file modified desktop/src-tauri/icons/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions tests/ci-workflows/build-desktop-icon-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
Loading