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
Binary file modified OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion scripts/generate-brand-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down Expand Up @@ -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 });
Expand Down
56 changes: 56 additions & 0 deletions scripts/generate-brand-assets.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
43 changes: 43 additions & 0 deletions scripts/icns-container.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
Binary file modified src/apps/desktop/icons/openbitfun-app-icon.icns
Binary file not shown.
Loading