// Generates the Tauri app icons (PNG + ICO) with a hand-drawn "relay" // glyph: two arrows passing each other on a teal tile. // Run: node scripts/gen-icons.mjs import zlib from "node:zlib"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const outDir = path.join( path.dirname(path.dirname(fileURLToPath(import.meta.url))), "src-tauri", "icons", ); function crc32(buf) { let c = ~0; for (let i = 0; i < buf.length; i++) { c ^= buf[i]; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); } return ~c >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, "ascii"), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([len, body, crc]); } function png(width, height, rgba) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // color type RGBA const raw = Buffer.alloc((width * 4 + 1) * height); for (let y = 0; y < height; y++) { raw[y * (width * 4 + 1)] = 0; // filter: none rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); } return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0)), ]); } const TEAL = [13, 148, 136]; // #0d9488 const TEAL_DARK = [15, 118, 110]; // #0f766e const WHITE = [255, 255, 255]; function drawRgba(size) { const rgba = Buffer.alloc(size * size * 4); const radius = size * 0.22; const center = (size - 1) / 2; const bar = Math.max(1, Math.round(size * 0.075)); const headLong = size * 0.17; const headShort = size * 0.11; const gap = size * 0.1; const armLen = size * 0.3; const inTile = (x, y) => { const dx = Math.min(x, size - 1 - x); const dy = Math.min(y, size - 1 - y); if (dx >= radius || dy >= radius) return true; const cx = dx - (radius - 1); const cy = dy - (radius - 1); return cx * cx + cy * cy <= 1; }; const inArrow = (x, y, dir) => { // dir = 1: arrow points right (top), -1: points left (bottom) const cy = dir === 1 ? center - gap : center + gap; const tipX = center + dir * (armLen + headLong); const baseX = center - dir * armLen; // shaft if (Math.abs(y - cy) <= bar / 2 && x >= Math.min(baseX, tipX) && x <= Math.max(baseX, tipX)) { return true; } // head: triangle growing away from the shaft const headStart = dir === 1 ? tipX - headLong : tipX; const headEnd = dir === 1 ? tipX : tipX + headLong; if (x < Math.min(headStart, headEnd) || x > Math.max(headStart, headEnd)) return false; const t = dir === 1 ? (x - headStart) / headLong : (headEnd - x) / headLong; return Math.abs(y - cy) <= (bar / 2) * (1 - t) + headShort * t; }; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; if (!inTile(x, y)) { rgba[i + 3] = 0; continue; } // subtle vertical gradient on the tile const mix = y / size; const base = TEAL.map((c, k) => Math.round(c * (1 - mix) + TEAL_DARK[k] * mix)); let color = base; if (inArrow(x, y, 1) || inArrow(x, y, -1)) color = WHITE; rgba[i] = color[0]; rgba[i + 1] = color[1]; rgba[i + 2] = color[2]; rgba[i + 3] = 255; } } return rgba; } function drawIcon(size) { return png(size, size, drawRgba(size)); } // Converts an RGBA buffer (top-down) into a bottom-up BGRA DIB plus a // transparent AND mask, the classic BMP-in-ICO layout RC.exe expects. function dibFromRgba(size, rgba) { const header = Buffer.alloc(40); header.writeUInt32LE(40, 0); header.writeInt32LE(size, 4); header.writeInt32LE(size * 2, 8); // height includes the AND mask header.writeUInt16LE(1, 12); header.writeUInt16LE(32, 14); const pixels = Buffer.alloc(size * size * 4); for (let y = 0; y < size; y++) { const srcRow = (size - 1 - y) * size * 4; rgba.copy(pixels, y * size * 4, srcRow, srcRow + size * 4); for (let x = 0; x < size; x++) { const i = y * size * 4 + x * 4; const r = pixels[i]; pixels[i] = pixels[i + 2]; pixels[i + 2] = r; // RGBA -> BGRA } } const maskRowBytes = Math.ceil(size / 8 / 4) * 4 || 4; const mask = Buffer.alloc(maskRowBytes * size); // all zero: fully opaque return Buffer.concat([header, pixels, mask]); } function ico(entries) { // entries: [{ size, dib }] const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(entries.length, 4); let offset = 6 + entries.length * 16; const dirEntries = entries.map(({ size, dib }) => { const entry = Buffer.alloc(16); entry[0] = size >= 256 ? 0 : size; entry[1] = size >= 256 ? 0 : size; entry[2] = 0; entry[3] = 0; entry.writeUInt16LE(1, 4); entry.writeUInt16LE(32, 6); entry.writeUInt32LE(dib.length, 8); entry.writeUInt32LE(offset, 12); offset += dib.length; return entry; }); return Buffer.concat([header, ...dirEntries, ...entries.map((e) => e.dib)]); } fs.mkdirSync(outDir, { recursive: true }); const sizes = [16, 32, 48, 256]; const dibs = sizes.map((size) => ({ size, dib: dibFromRgba(size, drawRgba(size)) })); fs.writeFileSync(path.join(outDir, "32x32.png"), drawIcon(32)); fs.writeFileSync(path.join(outDir, "128x128.png"), drawIcon(128)); fs.writeFileSync(path.join(outDir, "128x128@2x.png"), drawIcon(256)); fs.writeFileSync(path.join(outDir, "icon.ico"), ico(dibs)); console.log(`icons written to ${outDir}`);