diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e72ec92..d7947ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,13 +87,13 @@ jobs: with: name: installers-${{ matrix.os }} path: | - dist/*.exe - dist/*.dmg - dist/*.AppImage - dist/*.deb - dist/*.zip - dist/*.blockmap - dist/latest*.yml + dist/release/*.exe + dist/release/*.dmg + dist/release/*.AppImage + dist/release/*.deb + dist/release/*.zip + dist/release/*.blockmap + dist/release/latest*.yml if-no-files-found: error retention-days: 7 diff --git a/.vscode/settings.json b/.vscode/settings.json index 56360d1..768c5eb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -64,12 +64,19 @@ "browsable", "CHUNKID", "CHUNKIDS", + "downsample", "EXPORTMAP", "fguid", "gatherable", + "icns", + "ICONDIR", + "ICONDIRENTRY", + "iconutil", + "IEND", "konsole", "Nwnp", "OFPA", + "Paeth", "Qfecheck", "scrollview", "SERIALSIZES", diff --git a/README.md b/README.md index ebc3e74..4f4f82d 100644 --- a/README.md +++ b/README.md @@ -24,5 +24,5 @@ This project reproduces the Windows 95 look and feel, some of the icons, fonts, originate from Microsoft software and are not covered by the MIT License in [LICENSE](LICENSE), read [NOTICE.md](NOTICE.md) before forking this project or redistributing a build of it. -Cybearl is not affiliated with, endorsed by, or sponsored by Microsoft Corporation. Windows and +Cybearl is not affiliated with, endorsed by, or sponsored by Microsoft Corporation, Windows and Windows 95 are trademarks of Microsoft Corporation. diff --git a/electron-builder.yml b/electron-builder.yml index d772b4a..86016f9 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,11 +3,11 @@ productName: GitGame copyright: Copyright © ${author} directories: - output: dist - buildResources: build + output: dist/release + buildResources: resources files: - - out/**/* + - dist/app/**/* - package.json - "!**/*.map" @@ -20,6 +20,7 @@ publish: releaseType: release win: + icon: resources/icon.ico target: - target: nsis arch: [x64] @@ -33,6 +34,7 @@ nsis: deleteAppDataOnUninstall: false mac: + icon: resources/icon.icns category: public.app-category.developer-tools target: - target: dmg @@ -42,6 +44,7 @@ mac: gatekeeperAssess: false linux: + icon: resources/icons target: - target: AppImage arch: [x64] diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 171ff2b..33f2724 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -11,6 +11,9 @@ const rendererRoot = path.resolve(srcRoot, "renderer") const react95IconsRoot = path.resolve(__dirname, "node_modules", "@react95", "icons", "png") const react95FontsRoot = path.resolve(__dirname, "node_modules", "react95", "dist", "fonts") +// Every bundle lands under a single output root +const outRoot = path.resolve(__dirname, "dist", "app") + /** * Path aliases for module resolution. */ @@ -26,9 +29,11 @@ const aliases = { export default defineConfig({ main: { resolve: { alias: aliases }, + build: { outDir: path.join(outRoot, "main") }, }, preload: { resolve: { alias: aliases }, + build: { outDir: path.join(outRoot, "preload") }, }, renderer: { root: path.resolve(__dirname, "src", "renderer"), @@ -36,6 +41,7 @@ export default defineConfig({ resolve: { alias: aliases }, plugins: [react(), tailwindcss()], build: { + outDir: path.join(outRoot, "renderer"), rollupOptions: { input: path.resolve(__dirname, "src", "renderer", "index.html"), }, diff --git a/package.json b/package.json index 4dfdf9f..2a9822f 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,14 @@ "engines": { "node": ">=22.0.0" }, - "main": "./out/main/index.js", + "main": "./dist/app/main/index.js", "scripts": { "postinstall": "node node_modules/electron/install.js", "dev": "electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", "typecheck": "tsc --noEmit", + "generate-icons": "tsx scripts/generateIcons.ts", "test": "npm run test:unit", "test:unit": "vitest", "lint": "biome check && eslint \"src/**/*.{ts,tsx}\"", @@ -61,6 +62,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "tailwindcss": "^4.3.1", + "tsx": "^4.23.9", "typescript": "^5.8.3", "vite": "^7.0.0", "vite-tsconfig-paths": "^5.1.4", diff --git a/resources/icon.icns b/resources/icon.icns new file mode 100644 index 0000000..f3ae840 Binary files /dev/null and b/resources/icon.icns differ diff --git a/resources/icon.ico b/resources/icon.ico new file mode 100644 index 0000000..8d0765b Binary files /dev/null and b/resources/icon.ico differ diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000..0bcad39 Binary files /dev/null and b/resources/icon.png differ diff --git a/resources/icons/128x128.png b/resources/icons/128x128.png new file mode 100644 index 0000000..3063642 Binary files /dev/null and b/resources/icons/128x128.png differ diff --git a/resources/icons/16x16.png b/resources/icons/16x16.png new file mode 100644 index 0000000..271dd15 Binary files /dev/null and b/resources/icons/16x16.png differ diff --git a/resources/icons/24x24.png b/resources/icons/24x24.png new file mode 100644 index 0000000..181e8d3 Binary files /dev/null and b/resources/icons/24x24.png differ diff --git a/resources/icons/256x256.png b/resources/icons/256x256.png new file mode 100644 index 0000000..458bc00 Binary files /dev/null and b/resources/icons/256x256.png differ diff --git a/resources/icons/32x32.png b/resources/icons/32x32.png new file mode 100644 index 0000000..d9796f2 Binary files /dev/null and b/resources/icons/32x32.png differ diff --git a/resources/icons/48x48.png b/resources/icons/48x48.png new file mode 100644 index 0000000..6cd23e3 Binary files /dev/null and b/resources/icons/48x48.png differ diff --git a/resources/icons/512x512.png b/resources/icons/512x512.png new file mode 100644 index 0000000..6a6053a Binary files /dev/null and b/resources/icons/512x512.png differ diff --git a/resources/icons/64x64.png b/resources/icons/64x64.png new file mode 100644 index 0000000..80d8776 Binary files /dev/null and b/resources/icons/64x64.png differ diff --git a/scripts/generateIcons.ts b/scripts/generateIcons.ts new file mode 100644 index 0000000..04ca1c0 --- /dev/null +++ b/scripts/generateIcons.ts @@ -0,0 +1,77 @@ +import fs from "node:fs" +import path from "node:path" +import CONSTANTS from "@scripts/lib/constants" +import { encodeIcns } from "@scripts/lib/utils/icns" +import { encodeIco } from "@scripts/lib/utils/ico" +import { decodePng, encodePng } from "@scripts/lib/utils/png" +import { resizeIcon } from "@scripts/lib/utils/raster" +import type { IconSources, RasterImage } from "@scripts/types/icons" + +/** + * Reads the source art off disk. + * @returns The decoded source images. + */ +function readSources(): IconSources { + const { dir, smallFile, largeFile } = CONSTANTS.ICON_SOURCE + + return { + small: decodePng(fs.readFileSync(path.join(dir, smallFile))), + large: decodePng(fs.readFileSync(path.join(dir, largeFile))), + } +} + +/** + * Renders the app art at a given size, taking the hand-drawn 16x16 variant where it + * applies and scaling the 32x32 one everywhere else. + * @param sources The decoded source images. + * @param size The square size to render at, in pixels. + * @returns The rendered image. + */ +function renderIcon(sources: IconSources, size: number): RasterImage { + return size === sources.small.width ? sources.small : resizeIcon(sources.large, size) +} + +/** + * Renders every icon the packaging and the main process need. + * @param sources The decoded source images. + * @returns Each output file paired with the bytes to write to it. + */ +function renderOutputs(sources: IconSources): [string, Buffer][] { + const icnsSlots = Object.entries(CONSTANTS.ICNS_SLOT_SIZES).map(([type, size]) => ({ + type, + image: renderIcon(sources, size), + })) + + return [ + [ + path.join(CONSTANTS.ICON_OUTPUT.packagingDir, "icon.png"), + encodePng(renderIcon(sources, CONSTANTS.ICON_MASTER_SIZE)), + ], + [ + path.join(CONSTANTS.ICON_OUTPUT.packagingDir, "icon.ico"), + encodeIco(CONSTANTS.ICO_SIZES.map(size => renderIcon(sources, size))), + ], + [path.join(CONSTANTS.ICON_OUTPUT.packagingDir, "icon.icns"), encodeIcns(icnsSlots)], + [CONSTANTS.ICON_OUTPUT.runtimeFile, encodePng(renderIcon(sources, CONSTANTS.ICON_RUNTIME_SIZE))], + ...CONSTANTS.LINUX_ICON_SIZES.map((size): [string, Buffer] => [ + path.join(CONSTANTS.ICON_OUTPUT.linuxDir, `${size}x${size}.png`), + encodePng(renderIcon(sources, size)), + ]), + ] +} + +/** + * Regenerates every packaging icon from the React95 source art. + */ +function generateIcons() { + const outputs = renderOutputs(readSources()) + + for (const [file, contents] of outputs) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, contents) + + console.log(`${path.relative(process.cwd(), file).replace(/\\/g, "/")} (${contents.length} bytes)`) + } +} + +generateIcons() diff --git a/scripts/lib/constants.ts b/scripts/lib/constants.ts new file mode 100644 index 0000000..cc9337a --- /dev/null +++ b/scripts/lib/constants.ts @@ -0,0 +1,109 @@ +import { Buffer } from "node:buffer" +import path from "node:path" +import { fileURLToPath } from "node:url" + +/** + * The project root, resolved from this file's own location. + */ +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..") + +/** + * The constants for the build scripts. + */ +const CONSTANTS = { + /** + * Where the app art is taken from, the React95 set ships a hand-drawn 16x16 variant + * next to the 32x32 one every other size is scaled from. + */ + ICON_SOURCE: { + dir: path.join(ROOT_DIR, "node_modules", "@react95", "icons", "png"), + smallFile: "Computer3_16x16_4.png", + largeFile: "Computer3_32x32_4.png", + }, + + /** + * Where the generated icons land. + */ + ICON_OUTPUT: { + packagingDir: path.join(ROOT_DIR, "resources"), + linuxDir: path.join(ROOT_DIR, "resources", "icons"), + runtimeFile: path.join(ROOT_DIR, "src", "main", "assets", "icon.png"), + }, + + /** + * The size of the master PNG, which is what electron-builder falls back to for any + * platform left without a dedicated icon file. + */ + ICON_MASTER_SIZE: 1024, + + /** + * The size of the icon the main process hands to its windows. + */ + ICON_RUNTIME_SIZE: 256, + + /** + * The sizes packed into the Windows `.ico`. + */ + ICO_SIZES: [16, 24, 32, 48, 64, 128, 256], + + /** + * The sizes emitted as loose PNGs for Linux. + */ + LINUX_ICON_SIZES: [16, 24, 32, 48, 64, 128, 256, 512], + + /** + * The macOS `.icns` slots, each pairing an OSType with the size it holds, covering + * every 1x and 2x variant `iconutil` would emit. + */ + ICNS_SLOT_SIZES: { + icp4: 16, + icp5: 32, + ic11: 32, + ic12: 64, + ic07: 128, + ic13: 256, + ic08: 256, + ic09: 512, + ic14: 512, + ic10: 1024, + }, + + /** + * The eight byte signature every PNG file opens with. + */ + PNG_SIGNATURE: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + + /** + * The number of bytes a pixel takes in the 8-bit RGBA buffers used throughout. + */ + PNG_BYTES_PER_PIXEL: 4, + + /** + * The CRC-32 lookup table guarding PNG chunks. + */ + PNG_CRC_TABLE: new Uint32Array(256).map((_, index) => { + let value = index + for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 + return value + }), + + /** + * The size of the `BITMAPINFOHEADER` opening every bitmap backed `.ico` entry. + */ + ICO_BITMAP_HEADER_SIZE: 40, + + /** + * The size of the `ICONDIR` opening an `.ico`, followed by one `ICONDIRENTRY` of + * `ICO_DIRECTORY_ENTRY_SIZE` bytes per packed image. + */ + ICO_DIRECTORY_SIZE: 6, + ICO_DIRECTORY_ENTRY_SIZE: 16, + + /** + * The smallest entry stored as a PNG rather than as a bitmap, which is how the + * format has carried its largest sizes since Vista. + */ + ICO_PNG_THRESHOLD: 256, +} as const + +export default CONSTANTS diff --git a/scripts/lib/utils/icns.ts b/scripts/lib/utils/icns.ts new file mode 100644 index 0000000..d2e8d10 --- /dev/null +++ b/scripts/lib/utils/icns.ts @@ -0,0 +1,26 @@ +import { Buffer } from "node:buffer" +import { encodePng } from "@scripts/lib/utils/png" +import type { IcnsSlot } from "@scripts/types/icons" + +/** + * Packs slots into a macOS `.icns`, one PNG backed chunk each. + * @param slots The slots to pack. + * @returns The ICNS bytes. + */ +export function encodeIcns(slots: IcnsSlot[]): Buffer { + const chunks = slots.map(({ type, image }) => { + const data = encodePng(image) + const chunk = Buffer.alloc(8 + data.length) + chunk.write(type, 0, "ascii") + chunk.writeUInt32BE(chunk.length, 4) + data.copy(chunk, 8) + + return chunk + }) + + const header = Buffer.alloc(8) + header.write("icns", 0, "ascii") + header.writeUInt32BE(header.length + chunks.reduce((total, chunk) => total + chunk.length, 0), 4) + + return Buffer.concat([header, ...chunks]) +} diff --git a/scripts/lib/utils/ico.ts b/scripts/lib/utils/ico.ts new file mode 100644 index 0000000..6021d33 --- /dev/null +++ b/scripts/lib/utils/ico.ts @@ -0,0 +1,77 @@ +import { Buffer } from "node:buffer" +import CONSTANTS from "@scripts/lib/constants" +import { encodePng } from "@scripts/lib/utils/png" +import type { RasterImage } from "@scripts/types/icons" + +/** + * Encodes an image as an ICO device independent bitmap, a bottom-up BGRA buffer trailed + * by the 1-bit transparency mask the format still requires. + * @param image The image to encode. + * @returns The bitmap bytes. + */ +function encodeBitmap(image: RasterImage): Buffer { + const stride = image.width * CONSTANTS.PNG_BYTES_PER_PIXEL + const maskStride = Math.ceil(image.width / 32) * 4 + const colors = Buffer.alloc(image.height * stride) + const mask = Buffer.alloc(image.height * maskStride) + + for (let y = 0; y < image.height; y++) { + const flipped = image.height - 1 - y + + for (let x = 0; x < image.width; x++) { + const source = (y * image.width + x) * CONSTANTS.PNG_BYTES_PER_PIXEL + const target = flipped * stride + x * CONSTANTS.PNG_BYTES_PER_PIXEL + + colors[target] = image.pixels[source + 2] + colors[target + 1] = image.pixels[source + 1] + colors[target + 2] = image.pixels[source] + colors[target + 3] = image.pixels[source + 3] + + if (!image.pixels[source + 3]) mask[flipped * maskStride + (x >> 3)] |= 0x80 >> (x & 7) + } + } + + const header = Buffer.alloc(CONSTANTS.ICO_BITMAP_HEADER_SIZE) + header.writeUInt32LE(CONSTANTS.ICO_BITMAP_HEADER_SIZE, 0) + header.writeInt32LE(image.width, 4) + header.writeInt32LE(image.height * 2, 8) + header.writeUInt16LE(1, 12) + header.writeUInt16LE(32, 14) + header.writeUInt32LE(colors.length + mask.length, 20) + + return Buffer.concat([header, colors, mask]) +} + +/** + * Packs images into a Windows `.ico`, storing the largest entries as PNG and everything + * below the threshold as a bitmap. + * @param images The images to pack, in ascending size order. + * @returns The ICO bytes. + */ +export function encodeIco(images: RasterImage[]): Buffer { + const entries = images.map(image => + image.width >= CONSTANTS.ICO_PNG_THRESHOLD ? encodePng(image) : encodeBitmap(image), + ) + + const directory = Buffer.alloc(CONSTANTS.ICO_DIRECTORY_SIZE + entries.length * CONSTANTS.ICO_DIRECTORY_ENTRY_SIZE) + directory.writeUInt16LE(1, 2) + directory.writeUInt16LE(entries.length, 4) + + let offset = directory.length + + entries.forEach((entry, index) => { + const position = CONSTANTS.ICO_DIRECTORY_SIZE + index * CONSTANTS.ICO_DIRECTORY_ENTRY_SIZE + + // A 256 pixel side is written as a zero, which is the widest the single byte can say + directory[position] = images[index].width % 256 + directory[position + 1] = images[index].height % 256 + + directory.writeUInt16LE(1, position + 4) + directory.writeUInt16LE(32, position + 6) + directory.writeUInt32LE(entry.length, position + 8) + directory.writeUInt32LE(offset, position + 12) + offset += entry.length + }) + + return Buffer.concat([directory, ...entries]) +} diff --git a/scripts/lib/utils/png.ts b/scripts/lib/utils/png.ts new file mode 100644 index 0000000..4883b98 --- /dev/null +++ b/scripts/lib/utils/png.ts @@ -0,0 +1,133 @@ +import { Buffer } from "node:buffer" +import zlib from "node:zlib" +import CONSTANTS from "@scripts/lib/constants" +import type { RasterImage } from "@scripts/types/icons" + +/** + * Computes the CRC-32 checksum PNG appends to each of its chunks. + * @param buffer The bytes to checksum. + * @returns The unsigned CRC-32 value. + */ +function crc32(buffer: Buffer): number { + let crc = 0xffffffff + for (const byte of buffer) crc = CONSTANTS.PNG_CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8) + + return (crc ^ 0xffffffff) >>> 0 +} + +/** + * Picks the reference byte for PNG's Paeth filter, the neighbor the predicted value + * lands closest to. + * @param left The byte one pixel to the left. + * @param above The byte one row up. + * @param upperLeft The byte one pixel left on the row above. + * @returns The chosen neighbor. + */ +function paethPredictor(left: number, above: number, upperLeft: number): number { + const estimate = left + above - upperLeft + const toLeft = Math.abs(estimate - left) + const toAbove = Math.abs(estimate - above) + const toUpperLeft = Math.abs(estimate - upperLeft) + + if (toLeft <= toAbove && toLeft <= toUpperLeft) return left + return toAbove <= toUpperLeft ? above : upperLeft +} + +/** + * Wraps chunk data in the length, type and CRC framing PNG expects. + * @param type The four character chunk type. + * @param data The chunk payload. + * @returns The framed chunk. + */ +function buildChunk(type: string, data: Buffer): Buffer { + const chunk = Buffer.alloc(12 + data.length) + chunk.writeUInt32BE(data.length, 0) + chunk.write(type, 4, "ascii") + data.copy(chunk, 8) + chunk.writeUInt32BE(crc32(chunk.subarray(4, 8 + data.length)), 8 + data.length) + + return chunk +} + +/** + * Decodes an 8-bit non-interlaced RGBA PNG into a flat pixel buffer. + * @param file The raw PNG bytes. + * @returns The decoded image. + */ +export function decodePng(file: Buffer): RasterImage { + const blocks: Buffer[] = [] + let width = 0 + let height = 0 + let offset = CONSTANTS.PNG_SIGNATURE.length + + while (offset < file.length) { + const length = file.readUInt32BE(offset) + const type = file.toString("ascii", offset + 4, offset + 8) + const data = file.subarray(offset + 8, offset + 8 + length) + + if (type === "IHDR") { + width = data.readUInt32BE(0) + height = data.readUInt32BE(4) + + if (data[8] !== 8 || data[9] !== 6 || data[12] !== 0) { + throw new Error("only 8-bit non-interlaced RGBA PNG sources are supported") + } + } else if (type === "IDAT") blocks.push(data) + + offset += 12 + length + } + + const raw = zlib.inflateSync(Buffer.concat(blocks)) + const stride = width * CONSTANTS.PNG_BYTES_PER_PIXEL + const pixels = Buffer.alloc(height * stride) + + for (let row = 0; row < height; row++) { + const filter = raw[row * (stride + 1)] + + for (let index = 0; index < stride; index++) { + const hasLeft = index >= CONSTANTS.PNG_BYTES_PER_PIXEL + const left = hasLeft ? pixels[row * stride + index - CONSTANTS.PNG_BYTES_PER_PIXEL] : 0 + const above = row > 0 ? pixels[(row - 1) * stride + index] : 0 + const upperLeft = + row > 0 && hasLeft ? pixels[(row - 1) * stride + index - CONSTANTS.PNG_BYTES_PER_PIXEL] : 0 + + let value = raw[row * (stride + 1) + 1 + index] + if (filter === 1) value += left + else if (filter === 2) value += above + else if (filter === 3) value += (left + above) >> 1 + else if (filter === 4) value += paethPredictor(left, above, upperLeft) + + pixels[row * stride + index] = value & 0xff + } + } + + return { width, height, pixels } +} + +/** + * Encodes an image as an 8-bit RGBA PNG, leaving every scan-line unfiltered since flat + * pixel art deflates well enough on its own. + * @param image The image to encode. + * @returns The PNG bytes. + */ +export function encodePng(image: RasterImage): Buffer { + const stride = image.width * CONSTANTS.PNG_BYTES_PER_PIXEL + const raw = Buffer.alloc(image.height * (stride + 1)) + + for (let row = 0; row < image.height; row++) { + image.pixels.copy(raw, row * (stride + 1) + 1, row * stride, (row + 1) * stride) + } + + const header = Buffer.alloc(13) + header.writeUInt32BE(image.width, 0) + header.writeUInt32BE(image.height, 4) + header[8] = 8 + header[9] = 6 + + return Buffer.concat([ + CONSTANTS.PNG_SIGNATURE, + buildChunk("IHDR", header), + buildChunk("IDAT", zlib.deflateSync(raw, { level: 9 })), + buildChunk("IEND", Buffer.alloc(0)), + ]) +} diff --git a/scripts/lib/utils/raster.ts b/scripts/lib/utils/raster.ts new file mode 100644 index 0000000..40d11dc --- /dev/null +++ b/scripts/lib/utils/raster.ts @@ -0,0 +1,93 @@ +import { Buffer } from "node:buffer" +import CONSTANTS from "@scripts/lib/constants" +import type { RasterImage } from "@scripts/types/icons" + +/** + * Blows an image up by an integer factor, duplicating pixels so the art keeps its hard + * edges instead of being smoothed into a blur. + * @param image The image to enlarge. + * @param factor The integer factor to enlarge it by. + * @returns The enlarged image. + */ +function scaleNearest(image: RasterImage, factor: number): RasterImage { + const width = image.width * factor + const height = image.height * factor + const pixels = Buffer.alloc(width * height * CONSTANTS.PNG_BYTES_PER_PIXEL) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const source = + (Math.floor(y / factor) * image.width + Math.floor(x / factor)) * CONSTANTS.PNG_BYTES_PER_PIXEL + + image.pixels.copy( + pixels, + (y * width + x) * CONSTANTS.PNG_BYTES_PER_PIXEL, + source, + source + CONSTANTS.PNG_BYTES_PER_PIXEL, + ) + } + } + + return { width, height, pixels } +} + +/** + * Shrinks an image by an integer factor, averaging each source block over premultiplied + * alpha so transparent pixels never bleed their color into the edges. + * @param image The image to shrink. + * @param factor The integer factor to shrink it by. + * @returns The shrunk image. + */ +function downsampleBox(image: RasterImage, factor: number): RasterImage { + const width = image.width / factor + const height = image.height / factor + const pixels = Buffer.alloc(width * height * CONSTANTS.PNG_BYTES_PER_PIXEL) + const samples = factor * factor + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let red = 0 + let green = 0 + let blue = 0 + let alpha = 0 + + for (let dy = 0; dy < factor; dy++) { + for (let dx = 0; dx < factor; dx++) { + const source = ((y * factor + dy) * image.width + x * factor + dx) * CONSTANTS.PNG_BYTES_PER_PIXEL + const weight = image.pixels[source + 3] + + red += image.pixels[source] * weight + green += image.pixels[source + 1] * weight + blue += image.pixels[source + 2] * weight + alpha += weight + } + } + + const target = (y * width + x) * CONSTANTS.PNG_BYTES_PER_PIXEL + pixels[target] = alpha ? Math.round(red / alpha) : 0 + pixels[target + 1] = alpha ? Math.round(green / alpha) : 0 + pixels[target + 2] = alpha ? Math.round(blue / alpha) : 0 + pixels[target + 3] = Math.round(alpha / samples) + } + } + + return { width, height, pixels } +} + +/** + * Resizes an image to a square target size, going through the smallest whole multiple + * of the source the target divides into, so sizes that are an exact multiple stay pixel + * perfect and the rest only soften on their half pixel boundaries. + * @param image The image to resize. + * @param size The square size to resize to, in pixels. + * @returns The resized image. + */ +export function resizeIcon(image: RasterImage, size: number): RasterImage { + let multiplier = 1 + while ((image.width * multiplier) % size !== 0) multiplier++ + + const enlarged = multiplier > 1 ? scaleNearest(image, multiplier) : image + const factor = enlarged.width / size + + return factor > 1 ? downsampleBox(enlarged, factor) : enlarged +} diff --git a/scripts/types/icons.d.ts b/scripts/types/icons.d.ts new file mode 100644 index 0000000..09d4d82 --- /dev/null +++ b/scripts/types/icons.d.ts @@ -0,0 +1,26 @@ +/** + * A decoded raster image, its dimensions paired with a flat 8-bit RGBA pixel buffer. + */ +export type RasterImage = { + width: number + height: number + pixels: Buffer +} + +/** + * The two pieces of source art every packaging icon is rendered from, the hand-drawn + * 16x16 variant and the 32x32 one every other size is scaled from. + */ +export type IconSources = { + small: RasterImage + large: RasterImage +} + +/** + * A single macOS `.icns` entry, pairing the OSType naming the slot with the image + * filling it. + */ +export type IcnsSlot = { + type: string + image: RasterImage +} diff --git a/src/main/assets/icon.png b/src/main/assets/icon.png new file mode 100644 index 0000000..458bc00 Binary files /dev/null and b/src/main/assets/icon.png differ diff --git a/src/main/config/windows.ts b/src/main/config/windows.ts index 8695c5f..dbffd44 100644 --- a/src/main/config/windows.ts +++ b/src/main/config/windows.ts @@ -1,4 +1,5 @@ import path from "node:path" +import appIcon from "@main/assets/icon.png?asset" import CONSTANTS from "@main/lib/constants" import type { BrowserWindowConstructorOptions, WebPreferences } from "electron" @@ -39,6 +40,7 @@ const WINDOWS_CONFIG: { y: CONSTANTS.titleBarHeight / 2 - CONSTANTS.macOSTrafficLightsHeight / 2, }, acceptFirstMouse: true, + icon: appIcon, webPreferences: WEB_PREFERENCES, }, dialog: { @@ -50,6 +52,7 @@ const WINDOWS_CONFIG: { show: false, center: true, roundedCorners: false, + icon: appIcon, webPreferences: WEB_PREFERENCES, }, } diff --git a/src/main/types/env.d.ts b/src/main/types/env.d.ts new file mode 100644 index 0000000..d5822de --- /dev/null +++ b/src/main/types/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json index 168d419..19d75cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,8 +36,13 @@ "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ + // Scripts + "@scripts/*": ["./scripts/*"], + + // Source "@/*": ["./src/*"], + // Main process "@main/*": ["./src/main/*"], diff --git a/yarn.lock b/yarn.lock index a646341..693a128 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2264,7 +2264,7 @@ esbuild@^0.25.11: "@esbuild/win32-ia32" "0.25.12" "@esbuild/win32-x64" "0.25.12" -"esbuild@^0.27.0 || ^0.28.0": +"esbuild@^0.27.0 || ^0.28.0", esbuild@~0.28.0: version "0.28.1" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== @@ -4148,6 +4148,15 @@ tslib@^2.4.0, tslib@^2.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tsx@^4.23.9: + version "4.23.9" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.23.9.tgz#34857961fcfb44f22d05ae905fa32dc182608c33" + integrity sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw== + dependencies: + esbuild "~0.28.0" + optionalDependencies: + fsevents "~2.3.3" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"