From cc643cec4a3aa059158e83d5605f0b50c1e1bd9f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 4 Sep 2026 21:42:46 +0800 Subject: [PATCH] fix(build): stabilize generated macOS icons --- .../src-tauri/icons/openbitfun-app-icon.icns | Bin 212958 -> 212958 bytes package.json | 2 +- scripts/generate-brand-assets.mjs | 3 +- scripts/generate-brand-assets.test.mjs | 56 ++++++++++++++++++ scripts/icns-container.mjs | 43 ++++++++++++++ .../desktop/icons/openbitfun-app-icon.icns | Bin 212958 -> 212958 bytes 6 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 scripts/generate-brand-assets.test.mjs create mode 100644 scripts/icns-container.mjs diff --git a/OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns b/OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns index 8acffe8cdac28c1eaadb5c2f9b1c36cec2b6b398..6e8e2fb1107bceae13320c9aec1cc50cf4197355 100644 GIT binary patch delta 86 zcmV-c0IC1p{|w&$3==ptiT5wgFUhw^Yso s6a}-8l-UZmqzMB$Hn9kYl(#R#0viXnc)tR!0=G)a0tf`RCc**;0c|-W00000 delta 112 zcmccjpXc6x9*)f9ykZ7s`+M8#<}kHS*`5{6bc>74z?^|W++uoRG?VZ~WqI*tmPgxJ z9x(=&Y_9Wdow8lgf_W{|_G@;`9E{sp44Gxvx1Y0O=3oIDWoXF2z$yaPx4j^Y={L(} OCvh9I?Jj9dR%QU$ha{>1 diff --git a/package.json b/package.json index 931b689049..1c5217c9dd 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", - "test:release-packaging": "node --test scripts/release-channel.test.mjs scripts/desktop-tauri-build.test.mjs scripts/server-build.test.mjs scripts/version-generation.test.mjs scripts/generate-frontend-revision.test.mjs scripts/tauri-release-manifest.test.mjs OpenBitFun-Installer/scripts/build-installer.test.cjs", + "test:release-packaging": "node --test scripts/release-channel.test.mjs scripts/desktop-tauri-build.test.mjs scripts/server-build.test.mjs scripts/version-generation.test.mjs scripts/generate-frontend-revision.test.mjs scripts/generate-brand-assets.test.mjs scripts/tauri-release-manifest.test.mjs OpenBitFun-Installer/scripts/build-installer.test.cjs", "fmt:rs": "node scripts/format-changed-rust.mjs", "lint:rs": "cargo clippy --workspace --exclude openbitfun-desktop --all-targets", "lint:rs:desktop": "pnpm run prepare:mobile-web && cargo clippy -p openbitfun-desktop --all-targets", diff --git a/scripts/generate-brand-assets.mjs b/scripts/generate-brand-assets.mjs index 64a487ed3d..aecad12d85 100644 --- a/scripts/generate-brand-assets.mjs +++ b/scripts/generate-brand-assets.mjs @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; +import { canonicalizeIcns } from './icns-container.mjs'; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(SCRIPT_DIR, '..'); @@ -171,7 +172,7 @@ async function generateTauriContainers(applicationIcon) { return { ico: await readFile(path.join(tempDir, 'icon.ico')), - icns: await readFile(path.join(tempDir, 'icon.icns')), + icns: canonicalizeIcns(await readFile(path.join(tempDir, 'icon.icns'))), }; } finally { await rm(tempDir, { recursive: true, force: true }); diff --git a/scripts/generate-brand-assets.test.mjs b/scripts/generate-brand-assets.test.mjs new file mode 100644 index 0000000000..8d705a8a45 --- /dev/null +++ b/scripts/generate-brand-assets.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { canonicalizeIcns } from './icns-container.mjs'; + +const GENERATED_ICNS_FILES = [ + 'src/apps/desktop/icons/openbitfun-app-icon.icns', + 'OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns', +]; + +function createChunk(type, payload) { + const chunk = Buffer.alloc(8 + payload.length); + chunk.write(type, 0, 4, 'ascii'); + chunk.writeUInt32BE(chunk.length, 4); + payload.copy(chunk, 8); + return chunk; +} + +function createIcns(chunks) { + const length = 8 + chunks.reduce((total, chunk) => total + chunk.length, 0); + const header = Buffer.alloc(8); + header.write('icns', 0, 4, 'ascii'); + header.writeUInt32BE(length, 4); + return Buffer.concat([header, ...chunks], length); +} + +test('ICNS canonicalization is independent of Tauri chunk order', () => { + const chunks = [ + createChunk('ic10', Buffer.from('large')), + createChunk('ic07', Buffer.from('small')), + createChunk('s8mk', Buffer.from('mask')), + ]; + const forward = canonicalizeIcns(createIcns(chunks)); + const reverse = canonicalizeIcns(createIcns([...chunks].reverse())); + + assert.deepEqual(forward, reverse); + assert.deepEqual(canonicalizeIcns(forward), forward); +}); + +test('generated macOS icons use the canonical ICNS layout', () => { + const [desktop, installer] = GENERATED_ICNS_FILES.map(filePath => readFileSync(filePath)); + + assert.ok(desktop.equals(canonicalizeIcns(desktop)), 'desktop ICNS is not canonical'); + assert.ok(installer.equals(canonicalizeIcns(installer)), 'installer ICNS is not canonical'); + assert.ok(desktop.equals(installer), 'desktop and installer ICNS files differ'); +}); + +test('ICNS canonicalization rejects malformed containers', () => { + assert.throws( + () => canonicalizeIcns(Buffer.from('not-an-icns')), + /Invalid ICNS header/, + ); + + const truncated = createIcns([createChunk('ic07', Buffer.from('small'))]).subarray(0, -1); + assert.throws(() => canonicalizeIcns(truncated), /Invalid ICNS length/); +}); diff --git a/scripts/icns-container.mjs b/scripts/icns-container.mjs new file mode 100644 index 0000000000..cbd56e1c3d --- /dev/null +++ b/scripts/icns-container.mjs @@ -0,0 +1,43 @@ +const ICNS_HEADER_LENGTH = 8; +const ICNS_CHUNK_HEADER_LENGTH = 8; + +export function canonicalizeIcns(input) { + const buffer = Buffer.from(input); + + if ( + buffer.length < ICNS_HEADER_LENGTH || + buffer.toString('ascii', 0, 4) !== 'icns' + ) { + throw new Error('Invalid ICNS header'); + } + + const declaredLength = buffer.readUInt32BE(4); + if (declaredLength !== buffer.length) { + throw new Error( + `Invalid ICNS length: header declares ${declaredLength}, got ${buffer.length}`, + ); + } + + const chunks = []; + for (let offset = ICNS_HEADER_LENGTH; offset < buffer.length;) { + if (offset + ICNS_CHUNK_HEADER_LENGTH > buffer.length) { + throw new Error(`Truncated ICNS chunk header at offset ${offset}`); + } + + const chunkLength = buffer.readUInt32BE(offset + 4); + const chunkEnd = offset + chunkLength; + if (chunkLength < ICNS_CHUNK_HEADER_LENGTH || chunkEnd > buffer.length) { + throw new Error(`Invalid ICNS chunk length ${chunkLength} at offset ${offset}`); + } + + chunks.push(buffer.subarray(offset, chunkEnd)); + offset = chunkEnd; + } + + chunks.sort((left, right) => { + const typeOrder = Buffer.compare(left.subarray(0, 4), right.subarray(0, 4)); + return typeOrder || Buffer.compare(left, right); + }); + + return Buffer.concat([buffer.subarray(0, ICNS_HEADER_LENGTH), ...chunks], buffer.length); +} diff --git a/src/apps/desktop/icons/openbitfun-app-icon.icns b/src/apps/desktop/icons/openbitfun-app-icon.icns index 8acffe8cdac28c1eaadb5c2f9b1c36cec2b6b398..6e8e2fb1107bceae13320c9aec1cc50cf4197355 100644 GIT binary patch delta 86 zcmV-c0IC1p{|w&$3==ptiT5wgFUhw^Yso s6a}-8l-UZmqzMB$Hn9kYl(#R#0viXnc)tR!0=G)a0tf`RCc**;0c|-W00000 delta 112 zcmccjpXc6x9*)f9ykZ7s`+M8#<}kHS*`5{6bc>74z?^|W++uoRG?VZ~WqI*tmPgxJ z9x(=&Y_9Wdow8lgf_W{|_G@;`9E{sp44Gxvx1Y0O=3oIDWoXF2z$yaPx4j^Y={L(} OCvh9I?Jj9dR%QU$ha{>1