diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 909b362..c8783fa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -92,7 +92,11 @@ "Bash(rustfmt --check --edition 2021 src/git.rs)", "Bash(awk *)", "Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)", - "Read(//tmp/**)" + "Read(//tmp/**)", + "Bash(identify src-tauri/icons/GitCat.ico)", + "Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")", + "Bash(python3 *)", + "Bash(xxd -l 16 src-tauri/icons/GitCat.ico)" ] } } diff --git a/package.json b/package.json index afa815f..6dcb189 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "vite build", + "icons": "node scripts/generate-app-icons.mjs", "preview": "vite preview --host 127.0.0.1", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/scripts/generate-app-icons.mjs b/scripts/generate-app-icons.mjs new file mode 100644 index 0000000..9b48520 --- /dev/null +++ b/scripts/generate-app-icons.mjs @@ -0,0 +1,266 @@ +import { Buffer } from "node:buffer"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateSync, inflateSync } from "node:zlib"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const iconsDir = join(root, "src-tauri", "icons"); +const sourcePath = join(iconsDir, "GitCat_org.png"); + +const pngSizes = [32, 64, 128, 512]; +const icoSizes = [16, 24, 32, 48, 64, 128, 256]; + +function decodePng(buffer) { + if (buffer.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") { + throw new Error("Source icon must be a PNG file."); + } + + let width = 0; + let height = 0; + const idatChunks = []; + + for (let offset = 8; offset < buffer.length; ) { + const length = buffer.readUInt32BE(offset); + const type = buffer.subarray(offset + 4, offset + 8).toString("ascii"); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + const data = buffer.subarray(dataStart, dataEnd); + + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + if (data[8] !== 8 || data[9] !== 6) { + throw new Error("Source icon must be an 8-bit RGBA PNG."); + } + } else if (type === "IDAT") { + idatChunks.push(data); + } else if (type === "IEND") { + break; + } + + offset = dataEnd + 4; + } + + const stride = width * 4; + const raw = inflateSync(Buffer.concat(idatChunks)); + const rgba = Buffer.alloc(width * height * 4); + + for (let y = 0; y < height; y += 1) { + const rowStart = y * (stride + 1); + const filter = raw[rowStart]; + const input = raw.subarray(rowStart + 1, rowStart + 1 + stride); + const priorOffset = (y - 1) * stride; + const outputOffset = y * stride; + + for (let x = 0; x < stride; x += 1) { + const left = x >= 4 ? rgba[outputOffset + x - 4] : 0; + const up = y > 0 ? rgba[priorOffset + x] : 0; + const upLeft = y > 0 && x >= 4 ? rgba[priorOffset + x - 4] : 0; + let value = input[x]; + + if (filter === 1) value += left; + else if (filter === 2) value += up; + else if (filter === 3) value += Math.floor((left + up) / 2); + else if (filter === 4) value += paeth(left, up, upLeft); + else if (filter !== 0) throw new Error(`Unsupported PNG filter: ${filter}`); + + rgba[outputOffset + x] = value & 0xff; + } + } + + return { width, height, rgba }; +} + +function paeth(left, up, upLeft) { + const estimate = left + up - upLeft; + const distanceLeft = Math.abs(estimate - left); + const distanceUp = Math.abs(estimate - up); + const distanceUpLeft = Math.abs(estimate - upLeft); + + if (distanceLeft <= distanceUp && distanceLeft <= distanceUpLeft) return left; + if (distanceUp <= distanceUpLeft) return up; + return upLeft; +} + +function visibleSquare(image, alphaThreshold = 8) { + let minX = image.width; + let minY = image.height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < image.height; y += 1) { + for (let x = 0; x < image.width; x += 1) { + const alpha = image.rgba[(y * image.width + x) * 4 + 3]; + if (alpha > alphaThreshold) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (maxX < 0 || maxY < 0) { + throw new Error("Source icon has no visible pixels."); + } + + const visibleWidth = maxX - minX + 1; + const visibleHeight = maxY - minY + 1; + const side = Math.max(visibleWidth, visibleHeight); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const left = Math.max(0, Math.min(image.width - side, Math.round(centerX - side / 2))); + const top = Math.max(0, Math.min(image.height - side, Math.round(centerY - side / 2))); + + return { left, top, side }; +} + +function resizePng(image, crop, size) { + const rgba = Buffer.alloc(size * size * 4); + const scale = crop.side / size; + + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const sourceX = crop.left + (x + 0.5) * scale - 0.5; + const sourceY = crop.top + (y + 0.5) * scale - 0.5; + const pixel = sampleBilinear(image, sourceX, sourceY); + const offset = (y * size + x) * 4; + rgba[offset] = pixel.r; + rgba[offset + 1] = pixel.g; + rgba[offset + 2] = pixel.b; + rgba[offset + 3] = pixel.a; + } + } + + return encodePng(size, size, rgba); +} + +function sampleBilinear(image, x, y) { + const x0 = Math.max(0, Math.min(image.width - 1, Math.floor(x))); + const y0 = Math.max(0, Math.min(image.height - 1, Math.floor(y))); + const x1 = Math.max(0, Math.min(image.width - 1, x0 + 1)); + const y1 = Math.max(0, Math.min(image.height - 1, y0 + 1)); + const tx = x - x0; + const ty = y - y0; + const top = mixPixels(getPixel(image, x0, y0), getPixel(image, x1, y0), tx); + const bottom = mixPixels(getPixel(image, x0, y1), getPixel(image, x1, y1), tx); + + return mixPixels(top, bottom, ty); +} + +function getPixel(image, x, y) { + const offset = (y * image.width + x) * 4; + return { + r: image.rgba[offset], + g: image.rgba[offset + 1], + b: image.rgba[offset + 2], + a: image.rgba[offset + 3], + }; +} + +function mixPixels(a, b, t) { + return { + r: Math.round(a.r + (b.r - a.r) * t), + g: Math.round(a.g + (b.g - a.g) * t), + b: Math.round(a.b + (b.b - a.b) * t), + a: Math.round(a.a + (b.a - a.a) * t), + }; +} + +const crcTable = new Uint32Array(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; + } + crcTable[n] = c >>> 0; +} + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type, payload) { + const typeBytes = Buffer.from(type); + const out = Buffer.alloc(12 + payload.length); + out.writeUInt32BE(payload.length, 0); + typeBytes.copy(out, 4); + payload.copy(out, 8); + out.writeUInt32BE(crc32(Buffer.concat([typeBytes, payload])), out.length - 4); + return out; +} + +function encodePng(width, height, rgba) { + const header = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + + const raw = Buffer.alloc((width * 4 + 1) * height); + for (let y = 0; y < height; y += 1) { + const rowStart = y * (width * 4 + 1); + raw[rowStart] = 0; + rgba.copy(raw, rowStart + 1, y * width * 4, (y + 1) * width * 4); + } + + return Buffer.concat([ + header, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +function makeIco(images) { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(images.length, 4); + + const entries = Buffer.alloc(images.length * 16); + let offset = 6 + images.length * 16; + + images.forEach((image, index) => { + const entry = index * 16; + entries[entry] = image.size >= 256 ? 0 : image.size; + entries[entry + 1] = image.size >= 256 ? 0 : image.size; + entries[entry + 2] = 0; + entries[entry + 3] = 0; + entries.writeUInt16LE(1, entry + 4); + entries.writeUInt16LE(32, entry + 6); + entries.writeUInt32LE(image.png.length, entry + 8); + entries.writeUInt32LE(offset, entry + 12); + offset += image.png.length; + }); + + return Buffer.concat([header, entries, ...images.map((image) => image.png)]); +} + +mkdirSync(iconsDir, { recursive: true }); + +const source = decodePng(readFileSync(sourcePath)); +const crop = visibleSquare(source); +const rendered = new Map( + [...new Set([...pngSizes, ...icoSizes])].map((size) => [size, resizePng(source, crop, size)]), +); + +writeFileSync(join(iconsDir, "32x32.png"), rendered.get(32)); +writeFileSync(join(iconsDir, "64x64.png"), rendered.get(64)); +writeFileSync(join(iconsDir, "128x128.png"), rendered.get(128)); +writeFileSync(join(iconsDir, "icon.png"), rendered.get(512)); +writeFileSync(join(iconsDir, "icon.ico"), makeIco(icoSizes.map((size) => ({ size, png: rendered.get(size) })))); + +console.log(`Read ${join("src-tauri", "icons", "GitCat_org.png")}`); +console.log(`Cropped visible icon area to ${crop.side}x${crop.side} at ${crop.left},${crop.top}`); +console.log(`Wrote ${join("src-tauri", "icons", "32x32.png")}`); +console.log(`Wrote ${join("src-tauri", "icons", "64x64.png")}`); +console.log(`Wrote ${join("src-tauri", "icons", "128x128.png")}`); +console.log(`Wrote ${join("src-tauri", "icons", "icon.png")}`); +console.log(`Wrote ${join("src-tauri", "icons", "icon.ico")} (${icoSizes.length} sizes)`); diff --git a/scripts/generate-icon.mjs b/scripts/generate-icon.mjs index 595878a..309cc2a 100644 --- a/scripts/generate-icon.mjs +++ b/scripts/generate-icon.mjs @@ -1,3 +1,7 @@ +import "./generate-app-icons.mjs"; + +process.exit(0); + import { Buffer } from "node:buffer"; import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..e5b7ae7 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..1c44abb Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..0336d5c Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/GitCatIcon.png b/src-tauri/icons/GitCatIcon.png new file mode 100644 index 0000000..79a1aec Binary files /dev/null and b/src-tauri/icons/GitCatIcon.png differ diff --git a/src-tauri/icons/GitCat_512x512.png b/src-tauri/icons/GitCat_512x512.png new file mode 100644 index 0000000..d16bc87 Binary files /dev/null and b/src-tauri/icons/GitCat_512x512.png differ diff --git a/src-tauri/icons/GitCat_org.png___ b/src-tauri/icons/GitCat_org.png___ new file mode 100644 index 0000000..ddeae78 Binary files /dev/null and b/src-tauri/icons/GitCat_org.png___ differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 6b2c758..a63e107 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index bb0ffd8..d16bc87 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6de617c..ee0594b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,6 +32,9 @@ "nsis" ], "icon": [ + "icons/32x32.png", + "icons/64x64.png", + "icons/128x128.png", "icons/icon.png", "icons/icon.ico" ],