diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index bf9df5f4c0..e79a76e20a 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -337,6 +337,19 @@ jobs: - name: Download and verify target Flashgrep binary run: node scripts/prepare-flashgrep-resource.mjs --target "${{ matrix.platform.target }}" + - name: Select Xcode for macOS Icon Composer assets + if: runner.os == 'macOS' + shell: bash + run: | + test -d /Applications/Xcode_26.2.app/Contents/Developer + echo "DEVELOPER_DIR=/Applications/Xcode_26.2.app/Contents/Developer" >> "$GITHUB_ENV" + + - name: Verify native macOS icon bundling + if: runner.os == 'macOS' + env: + OPENBITFUN_TEST_MACOS_BUNDLE: '1' + run: node --test scripts/macos-icon-bundle.test.mjs + - name: Build desktop app run: ${{ matrix.platform.build_command }} diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml index 7b806fc29f..494c5a1ff7 100644 --- a/.github/workflows/nightly-artifacts.yml +++ b/.github/workflows/nightly-artifacts.yml @@ -219,6 +219,19 @@ jobs: CARGO_PROFILE_TEST_DEBUG: "0" run: cargo test --locked -p openbitfun-cli --test terminal_process_contracts -- --test-threads=1 + - name: Select Xcode for macOS Icon Composer assets + if: runner.os == 'macOS' + shell: bash + run: | + test -d /Applications/Xcode_26.2.app/Contents/Developer + echo "DEVELOPER_DIR=/Applications/Xcode_26.2.app/Contents/Developer" >> "$GITHUB_ENV" + + - name: Verify native macOS icon bundling + if: runner.os == 'macOS' + env: + OPENBITFUN_TEST_MACOS_BUNDLE: '1' + run: node --test scripts/macos-icon-bundle.test.mjs + - name: Build desktop app shell: bash run: | diff --git a/OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns b/OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns index 976eec4c27..b6470b0b89 100644 Binary files a/OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns and b/OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns differ diff --git a/OpenBitFun-Installer/src-tauri/tauri.macos.conf.json b/OpenBitFun-Installer/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000000..dab4b5c4f7 --- /dev/null +++ b/OpenBitFun-Installer/src-tauri/tauri.macos.conf.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "build": { + "beforeBundleCommand": "pnpm --dir .. run check:macos-icons" + }, + "bundle": { + "icon": [ + "icons/openbitfun-app-icon.icns", + "icons/openbitfun-app-icon.ico", + "icons/openbitfun-app-icon.png", + "../../assets/brand/macos/openbitfun.icon" + ] + } +} diff --git a/assets/brand/README.md b/assets/brand/README.md index d7472f2af8..4130ba203d 100644 --- a/assets/brand/README.md +++ b/assets/brand/README.md @@ -23,19 +23,90 @@ The iOS App Store icon uses an opaque black square; iOS applies the corner mask. mark, a light transparent mark, and the application icon. Fine-line mark exports are rendered directly from the SVG with size-specific optical treatments at small sizes. Application icons are resized from the preserved application artwork. -Windows ICO frames, Linux icons, and macOS PNG representations use the matching -size-specific application exports. Tauri encodes the legacy 16/32 px ICNS -representations from the same artwork. +Windows ICO frames and Linux icons use the matching size-specific application +exports. macOS owns a separate native resource pipeline described below; the +shared PNGs, Windows ICO, Linux, web, and mobile artwork are unchanged. + +## macOS icons + +`macos/openbitfun.icon` is the Icon Composer document for macOS 26 and newer, +including macOS 27. Its background is an opaque, full-bleed black fill and its +foreground is the transparent silver mark projected from the same brand master. +It has no baked outer mask, shadow, or Dock padding. macOS applies the enclosure +and appearance treatment through `Assets.car`. The source uses Icon Composer 1.2 +features supported by Xcode 26.2; it does not require an Xcode 27-only document. +No language or region-specific icon variants are needed. + +The committed ICNS is the static dev and older-macOS fallback. It is rendered +from that Composer document by Apple's `ictool`, on the separate authored +`macos/legacy-canvas.json` layout, and encoded with Apple's `iconutil`. That +legacy layout has an 824 px body on a 1024 px transparent canvas. This is our +legacy artwork geometry, **not a universal Apple padding requirement**. It never +alters the modern Composer source. Ten 1x/2x representations cover 16–1024 px. +There is no runtime OS-version scaling and no modification of ICNS chunks by +the shared brand generator; canonicalization only orders the native chunks. + +Tauri CLI is pinned to **2.11.4** in both workspace apps. The macOS config overlay +adds `.icon` alongside ICNS; base and Windows/Linux icon selections stay intact. +Tauri dev reads ICNS through `NSApplication.applicationIconImage`. Bundled builds +use `CFBundleIconName` with `Assets.car`, plus `CFBundleIconFile` for the legacy +resource. `tauri.dev.conf.json` and the generated release config inherit the +platform icon list instead of overwriting it. The minimum application OS version +is unchanged; compiling the modern catalog for 26 does not raise that minimum. + +Normal installs, dev builds, and non-Apple builds use the committed ICNS and need +no Icon Composer installation. After changing the application mark, Composer +document, or legacy layout, regenerate on macOS with **Icon Composer 1.2**: + +```bash +# ICON_COMPOSER_APP can point to an alternate location for Icon Composer.app. +pnpm run generate:macos-icons +pnpm run generate-brand-assets +``` + +The native export manifest records the renderer version and source/output +hashes. Normal asset generation refuses stale native exports instead of silently +shipping a mismatched dev icon. Commit the Composer layer, manifest, and all +three generated ICNS copies together. + +Packaging requires **full Xcode 26 or newer**, with `actool` and `assetutil` +selected by `DEVELOPER_DIR` or `xcode-select`. Command Line Tools alone are not +enough. Stable/beta and nightly macOS runners select **Xcode 26.2** on both Intel +and Apple Silicon. The pre-bundle check fails when modern tools are unavailable; +the build wrapper verifies the resulting catalog, both Info.plist icon keys, +and the exact fallback bytes before reporting success. No signed bundle is +modified by verification. + +```bash +node --test scripts/generate-brand-assets.test.mjs scripts/macos-icon-bundle.test.mjs scripts/desktop-tauri-build.test.mjs +# Full Xcode: exercise actual Tauri + actool packaging in a disposable fixture. +OPENBITFUN_TEST_MACOS_BUNDLE=1 node --test scripts/macos-icon-bundle.test.mjs +# Inspect an existing bundle without launching it. +node scripts/macos-icon-bundle.mjs verify /path/to/OpenBitFun.app +``` + +These checks validate resources, native decoding, and package metadata. They do +not establish visual Dock equivalence on every supported OS or display. Remote +workspace, remote control, Peer Device Mode, and detached dispatch are not +exercised by these local packaging checks; their protocols and host logic are +unchanged. + +References: [Apple app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons), +[Icon Composer](https://developer.apple.com/documentation/xcode/creating-your-app-icon-using-icon-composer), +[NSApplication.applicationIconImage](https://developer.apple.com/documentation/appkit/nsapplication/applicationiconimage), +and [Tauri 2.11.4 macOS icon bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/macos/icon.rs). Browser entry points select explicit 16/32 px favicons. The system tray reuses -the configured application/window icon on every desktop platform. +the macOS template mark on macOS and the configured application icon elsewhere. Run `pnpm run generate-brand-assets` after changing either source master. The -generator is the single owner of the derived desktop, web, installer, Android, -iOS, and HarmonyOS files. +generator owns the shared desktop, web, installer, Android, iOS, and HarmonyOS +projections. Native macOS exports have the separate owner described above. -Verify generated dimensions, small-size rim contrast, favicon references, and -icon containers with `node --test scripts/generate-brand-assets.test.mjs`. +Verify generated dimensions, small-size rim contrast, favicon references, +platform selection, and icon containers with +`node --test scripts/generate-brand-assets.test.mjs`. macOS export and native +bundling checks also run `scripts/macos-icon-bundle.test.mjs`. `source/release-letter-mascot.svg` preserves the authored character from `openbitfun-letter.html`. Its named body, rigid head/rod, and eye parts are used diff --git a/assets/brand/exports/openbitfun-app-icon.icns b/assets/brand/exports/openbitfun-app-icon.icns index 976eec4c27..b6470b0b89 100644 Binary files a/assets/brand/exports/openbitfun-app-icon.icns and b/assets/brand/exports/openbitfun-app-icon.icns differ diff --git a/assets/brand/macos/generated.json b/assets/brand/macos/generated.json new file mode 100644 index 0000000000..03eebaaed9 --- /dev/null +++ b/assets/brand/macos/generated.json @@ -0,0 +1,6 @@ +{ + "iconComposerVersion": "1.2", + "iconComposerBuild": "76", + "sourceSha256": "9b600c6605029a2b23d480ec5595b7634ffb94dd9c6a2cc91a98d645601f65f0", + "icnsSha256": "04ca39fd01030f7dcdf787670c3e56f006e33e8384eba14e793db75b6765af3e" +} diff --git a/assets/brand/macos/legacy-canvas.json b/assets/brand/macos/legacy-canvas.json new file mode 100644 index 0000000000..9282c40350 --- /dev/null +++ b/assets/brand/macos/legacy-canvas.json @@ -0,0 +1,4 @@ +{ + "canvasSize": 1024, + "artworkBounds": { "x": 100, "y": 100, "width": 824, "height": 824 } +} diff --git a/assets/brand/macos/openbitfun.icon/Assets/Mark.png b/assets/brand/macos/openbitfun.icon/Assets/Mark.png new file mode 100644 index 0000000000..2c6607f2dc Binary files /dev/null and b/assets/brand/macos/openbitfun.icon/Assets/Mark.png differ diff --git a/assets/brand/macos/openbitfun.icon/icon.json b/assets/brand/macos/openbitfun.icon/icon.json new file mode 100644 index 0000000000..23775127a3 --- /dev/null +++ b/assets/brand/macos/openbitfun.icon/icon.json @@ -0,0 +1,31 @@ +{ + "fill": { + "solid": "srgb:0.00000,0.00000,0.00000,1.00000" + }, + "groups": [ + { + "name": "Application mark", + "layers": [ + { + "image-name": "Mark.png", + "name": "Silver mark", + "glass": false + } + ], + "shadow": { + "kind": "none", + "opacity": 0 + }, + "specular": false, + "translucency": { + "enabled": false, + "value": 0 + } + } + ], + "supported-platforms": { + "squares": [ + "macOS" + ] + } +} diff --git a/package.json b/package.json index 61804dd90d..5aa801ed84 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "agent-harness:generate": "node scripts/generate-agent-harness.mjs", "agent-harness:check": "node scripts/generate-agent-harness.mjs --check", "copy-monaco": "copyfiles -u 5 \"src/web-ui/node_modules/monaco-editor/min/vs/**/*\" src/web-ui/public/monaco-editor && node scripts/prune-monaco-nls.cjs", + "check:macos-icons": "node scripts/macos-icon-bundle.mjs preflight", + "generate:macos-icons": "node scripts/generate-macos-icons.mjs", "generate-brand-assets": "node scripts/generate-brand-assets.mjs", "copy-assets": "pnpm run copy-monaco && pnpm run generate-brand-assets", "generate-version": "node scripts/generate-version.cjs --build-env production", @@ -78,7 +80,7 @@ "check:core-boundaries": "pnpm run agent-harness:check && 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/generate-brand-assets.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/macos-icon-bundle.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", @@ -174,7 +176,7 @@ }, "devDependencies": { "@noble/hashes": "^2.0.1", - "@tauri-apps/cli": "^2.10.0", + "@tauri-apps/cli": "2.11.4", "@vitejs/plugin-react": "^4.6.0", "copyfiles": "^2.4.1", "cross-env": "^10.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcaad14a73..578b54df4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^2.0.1 version: 2.0.1 '@tauri-apps/cli': - specifier: ^2.10.0 - version: 2.10.0 + specifier: 2.11.4 + version: 2.11.4 '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -1921,143 +1921,72 @@ packages: '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - '@tauri-apps/cli-darwin-arm64@2.10.0': - resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@tauri-apps/cli-darwin-arm64@2.11.4': resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.10.0': - resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@tauri-apps/cli-darwin-x64@2.11.4': resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': - resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': - resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-arm64-musl@2.10.0': - resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@tauri-apps/cli-linux-arm64-musl@2.11.4': resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': - resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@tauri-apps/cli-linux-x64-gnu@2.10.0': - resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@tauri-apps/cli-linux-x64-gnu@2.11.4': resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-linux-x64-musl@2.10.0': - resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@tauri-apps/cli-linux-x64-musl@2.11.4': resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': - resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': - resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.10.0': - resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.11.4': resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.10.0': - resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==} - engines: {node: '>= 10'} - hasBin: true - '@tauri-apps/cli@2.11.4': resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} engines: {node: '>= 10'} @@ -7263,86 +7192,39 @@ snapshots: '@tauri-apps/api@2.11.1': {} - '@tauri-apps/cli-darwin-arm64@2.10.0': - optional: true - '@tauri-apps/cli-darwin-arm64@2.11.4': optional: true - '@tauri-apps/cli-darwin-x64@2.10.0': - optional: true - '@tauri-apps/cli-darwin-x64@2.11.4': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': - optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': - optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.10.0': - optional: true - '@tauri-apps/cli-linux-arm64-musl@2.11.4': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': - optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.10.0': - optional: true - '@tauri-apps/cli-linux-x64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-musl@2.10.0': - optional: true - '@tauri-apps/cli-linux-x64-musl@2.11.4': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': - optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': - optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.10.0': - optional: true - '@tauri-apps/cli-win32-x64-msvc@2.11.4': optional: true - '@tauri-apps/cli@2.10.0': - optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.0 - '@tauri-apps/cli-darwin-x64': 2.10.0 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.0 - '@tauri-apps/cli-linux-arm64-musl': 2.10.0 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-musl': 2.10.0 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.0 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 - '@tauri-apps/cli-win32-x64-msvc': 2.10.0 - '@tauri-apps/cli@2.11.4': optionalDependencies: '@tauri-apps/cli-darwin-arm64': 2.11.4 diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 39cc8a9eeb..66fef1038f 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -18,6 +18,7 @@ import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; import { resolveReleaseChannel } from './release-channel.mjs'; +import { requireModernIconTools, verifyMacosIconBundle } from './macos-icon-bundle.mjs'; import { WEB_FONT_PROFILE_ENV, fontProfileForDesktopTarget, @@ -49,6 +50,8 @@ async function main() { console.log(`[release] channel=${releaseChannel.channel}`); const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); + const macosBundle = requestsMacosBundle(forward); + if (macosBundle) requireModernIconTools(); if (!bundleOnly) preparePluginHost(); const flashgrepBinary = prepareMacOSFlashgrepForSigning( ensureFlashgrepBinary({ target: optionValue(forward, '--target') || rustHostTargetTriple() }), @@ -96,6 +99,16 @@ async function main() { process.exit(1); } + if (r.status === 0 && macosBundle) { + const config = JSON.parse(readFileSync(tauriConfig, 'utf8')); + const targetDir = process.env.CARGO_TARGET_DIR + ? resolve(desktopDir, process.env.CARGO_TARGET_DIR) : join(ROOT, 'target'); + const target = optionValue(forward, '--target'); + const profile = forward.includes('--debug') ? 'debug' : optionValue(forward, '--profile') || 'release'; + verifyMacosIconBundle(join(targetDir, ...(target ? [target] : []), profile, + 'bundle', 'macos', `${config.productName}.app`)); + } + // Keep only the latest useful Cargo caches for this build profile after tauri build ends. try { const { profileFromTauriBuildArgs, runGcBestEffort, targetFromTauriBuildArgs } = await import( @@ -119,6 +132,12 @@ async function main() { process.exit(r.status ?? 1); } +export function requestsMacosBundle(args, platform = process.platform) { + if (args.includes('--no-bundle')) return false; + const target = optionValue(args, '--target'); + return target ? target.endsWith('-apple-darwin') : platform === 'darwin'; +} + function rustHostTargetTriple() { const result = spawnSync('rustc', ['-vV'], { cwd: ROOT, @@ -322,6 +341,9 @@ export function prepareTauriConfig( config.mainBinaryName = resolution.assembly.binaryName; config.identifier = resolution.assembly.bundleId; } + // Let Tauri merge platform icon lists. Re-emitting the base list here would + // overwrite tauri.macos.conf.json and silently discard the .icon source. + delete config.bundle.icon; configureWindowsSigning(config); injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); // The DeepSeek bridge is not a compile-time resource: cargo check and diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 749990bc68..6feef057c6 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -8,6 +8,7 @@ import { configureWindowsSigning, prepareMacOSFlashgrepForSigning, prepareTauriConfig, + requestsMacosBundle, shouldRetryMacDmgBuild, } from './desktop-tauri-build.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -21,6 +22,17 @@ const FAILED_BUILD = { status: 1 }; const DMG_ARGS = ['--target', 'x86_64-apple-darwin', '--bundles', 'app,dmg']; const ROOT = join(import.meta.dirname, '..'); +test('modern icon tooling is required only for macOS bundles, including bundle-only builds', () => { + assert.equal(requestsMacosBundle([], 'darwin'), true); + assert.equal(requestsMacosBundle(['--bundle-only', '--target=aarch64-apple-darwin'], 'darwin'), true); + assert.equal(requestsMacosBundle(['--target', 'universal-apple-darwin'], 'darwin'), true); + assert.equal(requestsMacosBundle(['--no-bundle'], 'darwin'), false); + assert.equal(requestsMacosBundle(['--target', 'x86_64-pc-windows-msvc'], 'darwin'), false); + assert.equal(requestsMacosBundle(['--target=x86_64-unknown-linux-gnu'], 'darwin'), false); + assert.equal(requestsMacosBundle([], 'win32'), false); + assert.equal(requestsMacosBundle([], 'linux'), false); +}); + test('Windows desktop manifest opts into layered child windows and preserves common controls', () => { const desktop = join(ROOT, 'src', 'apps', 'desktop'); const manifest = readFileSync(join(desktop, 'windows-app.manifest'), 'utf8'); diff --git a/scripts/generate-brand-assets.mjs b/scripts/generate-brand-assets.mjs index 4ae12c08c7..90967c080d 100644 --- a/scripts/generate-brand-assets.mjs +++ b/scripts/generate-brand-assets.mjs @@ -4,7 +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'; +import { COMPOSER_ICON, readMacosFallback } from './macos-icon-assets.mjs'; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(SCRIPT_DIR, '..'); @@ -199,11 +199,10 @@ async function createAdaptiveForeground(applicationMark, size) { .toBuffer(); } -async function generateTauriContainers(applicationIcon, renderIcon) { +async function generateWindowsIcon(applicationIcon, renderIcon) { const tempDir = await mkdtemp(path.join(os.tmpdir(), 'openbitfun-tauri-icons-')); const inputPath = path.join(tempDir, 'openbitfun-app-icon.png'); const tauriCliPath = path.join(ROOT_DIR, 'node_modules', '@tauri-apps', 'cli', 'tauri.js'); - try { await writeFile(inputPath, applicationIcon); execFileSync( @@ -229,46 +228,7 @@ async function generateTauriContainers(applicationIcon, renderIcon) { icoOffset += png.length; } - // Let Tauri encode legacy RGB/mask chunks from the same application - // artwork; modern PNG representations use the matching exported sizes. - const icnsTemplate = await readFile(path.join(tempDir, 'icon.icns')); - const legacyChunks = new Map(); - for (const [size, types] of [[16, ['is32', 's8mk']], [32, ['il32', 'l8mk']]]) { - const smallInput = path.join(tempDir, `input-${size}.png`); - const smallOutput = path.join(tempDir, `legacy-${size}`); - await writeFile(smallInput, await renderIcon(size)); - execFileSync(process.execPath, - [tauriCliPath, 'icon', smallInput, '--output', smallOutput], - { cwd: ROOT_DIR, stdio: 'ignore', windowsHide: true }); - const smallIcns = await readFile(path.join(smallOutput, 'icon.icns')); - for (let offset = 8; offset < smallIcns.length;) { - const length = smallIcns.readUInt32BE(offset + 4); - const type = smallIcns.toString('ascii', offset, offset + 4); - if (types.includes(type)) legacyChunks.set(type, smallIcns.subarray(offset, offset + length)); - offset += length; - } - } - const icnsChunks = []; - for (let offset = 8; offset < icnsTemplate.length;) { - const length = icnsTemplate.readUInt32BE(offset + 4); - const chunk = icnsTemplate.subarray(offset, offset + length); - if (chunk.subarray(8, 16).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) { - const { width } = await sharp(chunk.subarray(8)).metadata(); - const png = await renderIcon(width); - const header = Buffer.from(chunk.subarray(0, 8)); - header.writeUInt32BE(png.length + 8, 4); - icnsChunks.push(Buffer.concat([header, png])); - } else { - icnsChunks.push(legacyChunks.get(chunk.toString('ascii', 0, 4)) ?? chunk); - } - offset += length; - } - const icnsHeader = Buffer.from(icnsTemplate.subarray(0, 8)); - icnsHeader.writeUInt32BE(8 + icnsChunks.reduce((sum, chunk) => sum + chunk.length, 0), 4); - return { - ico: Buffer.concat([icoDirectory, ...icoFrames]), - icns: canonicalizeIcns(Buffer.concat([icnsHeader, ...icnsChunks])), - }; + return Buffer.concat([icoDirectory, ...icoFrames]); } finally { await rm(tempDir, { recursive: true, force: true }); } @@ -282,15 +242,17 @@ async function removeLegacyApplicationAssets() { } async function generateBrandAssets() { + const applicationMark = await createApplicationMark(await normalizePng(SOURCE_APP_MARK)); + await writePng(path.join(COMPOSER_ICON, 'Assets/Mark.png'), await resizePng(applicationMark, APP_ICON_SIZE)); + if (process.argv.includes('--macos-source-only')) return; + const macosFallback = readMacosFallback(); const svg = await readFile(SOURCE_SVG, 'utf8'); - const [darkMark, lightMark, applicationSourceMark] = await Promise.all([ + const [darkMark, lightMark] = await Promise.all([ renderMark(svg, BRAND_SIZE, '#202020'), renderMark(svg, BRAND_SIZE, '#e8e8e8'), - normalizePng(SOURCE_APP_MARK), ]); await writePng(SOURCE_MARKS.dark, darkMark); await writePng(SOURCE_MARKS.light, lightMark); - const applicationMark = await createApplicationMark(applicationSourceMark); const applicationIcon = await createApplicationIcon(applicationMark); const applicationIconLarge = await resizePng(applicationIcon, APP_ICON_SIZE); const darkMarkSmall = await renderMark(svg, 128, '#202020'); @@ -305,7 +267,7 @@ async function generateBrandAssets() { } return iconCache.get(size); }; - const tauriContainers = await generateTauriContainers(applicationIconLarge, renderIcon); + const windowsIcon = await generateWindowsIcon(applicationIconLarge, renderIcon); const exportDir = outputPath('assets', 'brand', 'exports'); await mkdir(exportDir, { recursive: true }); @@ -329,8 +291,8 @@ async function generateBrandAssets() { await renderIcon(size), ); } - await writeFile(path.join(exportDir, 'openbitfun-app-icon.ico'), tauriContainers.ico); - await writeFile(path.join(exportDir, 'openbitfun-app-icon.icns'), tauriContainers.icns); + await writeFile(path.join(exportDir, 'openbitfun-app-icon.ico'), windowsIcon); + await writeFile(path.join(exportDir, 'openbitfun-app-icon.icns'), macosFallback); await writePng(outputPath('src', 'miniapp-market-web', 'public', 'assets', 'openbitfun-email-mark.png'), darkMarkSmall); @@ -348,8 +310,8 @@ async function generateBrandAssets() { const desktopIconDir = outputPath('src', 'apps', 'desktop', 'icons'); await writePng(path.join(desktopIconDir, 'openbitfun-app-icon.png'), applicationIconLarge); - await writePng(path.join(desktopIconDir, 'openbitfun-app-icon.ico'), tauriContainers.ico); - await writePng(path.join(desktopIconDir, 'openbitfun-app-icon.icns'), tauriContainers.icns); + await writePng(path.join(desktopIconDir, 'openbitfun-app-icon.ico'), windowsIcon); + await writePng(path.join(desktopIconDir, 'openbitfun-app-icon.icns'), macosFallback); for (const size of DESKTOP_HICOLOR_SIZES) { const icon = await renderIcon(size); await writePng( @@ -376,8 +338,8 @@ async function generateBrandAssets() { await writePng(path.join(installerBrandDir, 'openbitfun-app-icon.png'), applicationIcon); const installerIconDir = outputPath('OpenBitFun-Installer', 'src-tauri', 'icons'); await writePng(path.join(installerIconDir, 'openbitfun-app-icon.png'), applicationIconLarge); - await writePng(path.join(installerIconDir, 'openbitfun-app-icon.ico'), tauriContainers.ico); - await writePng(path.join(installerIconDir, 'openbitfun-app-icon.icns'), tauriContainers.icns); + await writePng(path.join(installerIconDir, 'openbitfun-app-icon.ico'), windowsIcon); + await writePng(path.join(installerIconDir, 'openbitfun-app-icon.icns'), macosFallback); for (const directory of [webBrandDir, installerBrandDir, outputPath('src', 'mobile-web', 'public', 'brand'), diff --git a/scripts/generate-brand-assets.test.mjs b/scripts/generate-brand-assets.test.mjs index 1d6de62322..adfc7ea1a2 100644 --- a/scripts/generate-brand-assets.test.mjs +++ b/scripts/generate-brand-assets.test.mjs @@ -61,6 +61,55 @@ test('generated macOS icons use the canonical ICNS layout', () => { 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'); + assert.deepEqual(desktop, readFileSync('assets/brand/exports/openbitfun-app-icon.icns')); +}); + +test('macOS ICNS PNG representations preserve a visible body and transparent Dock margins', async () => { + const icns = readFileSync(GENERATED_ICNS_FILES[0]); + let checked = 0; + for (let offset = 8; offset < icns.length;) { + const length = icns.readUInt32BE(offset + 4); + const payload = icns.subarray(offset + 8, offset + length); + offset += length; + if (!payload.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) continue; + const { data, info } = await sharp(payload).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const size = info.width; + assert.equal(info.height, size); + // Catch both full-bleed and double-padded exports without baking a drawing + // constant into the test. The native decoder test also covers 16 px ARGB. + const middle = Math.floor(size / 2); + let first = -1; + let last = -1; + for (let x = 0; x < size; x++) { + if (data[(middle * size + x) * 4 + 3] >= 128) { + if (first < 0) first = x; + last = x; + } + assert.equal(data[x * 4 + 3], 0, 'top edge'); + assert.equal(data[((size - 1) * size + x) * 4 + 3], 0, 'bottom edge'); + } + assert.ok(first >= size * 0.07 && first <= size * 0.14, `visible body: ${size}, ${first}`); + assert.ok(Math.abs(first - (size - 1 - last)) <= 1, 'centered body'); + checked++; + } + assert.equal(checked, 8); +}); + +test('desktop and installer retain independent Windows/Linux icons alongside the macOS fallback', () => { + for (const configPath of ['src/apps/desktop/tauri.conf.json', 'OpenBitFun-Installer/src-tauri/tauri.conf.json']) { + const { bundle } = JSON.parse(readFileSync(configPath, 'utf8')); + assert.equal(bundle.icon.find(icon => icon.endsWith('.icns')), 'icons/openbitfun-app-icon.icns'); + assert.equal(bundle.icon.find(icon => icon.endsWith('.png')), 'icons/openbitfun-app-icon.png'); + assert.equal(bundle.icon.find(icon => icon.endsWith('.ico')), 'icons/openbitfun-app-icon.ico'); + } + for (const iconDir of ['src/apps/desktop/icons', 'OpenBitFun-Installer/src-tauri/icons']) { + assert.deepEqual(readFileSync(`${iconDir}/openbitfun-app-icon.png`), + readFileSync('assets/brand/exports/openbitfun-app-icon-1024.png')); + } + for (const size of [16, 32, 48, 64, 96, 128, 256, 512]) { + assert.deepEqual(readFileSync(`src/apps/desktop/icons/hicolor/${size}x${size}/apps/openbitfun-desktop.png`), + readFileSync(`assets/brand/exports/openbitfun-app-icon-${size}.png`)); + } }); test('application icons preserve the submitted artwork independently from the startup Logo', async () => { diff --git a/scripts/generate-macos-icons.mjs b/scripts/generate-macos-icons.mjs new file mode 100644 index 0000000000..0f78e6c6d4 --- /dev/null +++ b/scripts/generate-macos-icons.mjs @@ -0,0 +1,58 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import { canonicalizeIcns } from './icns-container.mjs'; +import { COMPOSER_ICON, ICNS_OUTPUTS, MACOS_ICON_DIR, ROOT, macosSourceDigest, sha256 } from './macos-icon-assets.mjs'; + +if (process.platform !== 'darwin') throw new Error('Exporting macOS icons requires macOS and Icon Composer 1.2.'); +const composer = process.env.ICON_COMPOSER_APP || '/Applications/Icon Composer.app'; +const ictool = join(composer, 'Contents/Executables/ictool'); +const run = (command, args) => execFileSync(command, args, { cwd: ROOT, encoding: 'utf8', windowsHide: true }); +const version = JSON.parse(run(ictool, ['--version'])); +if (version['short-bundle-version'] !== '1.2') { + throw new Error('Use Icon Composer 1.2 for reproducible legacy exports; review the assets before updating the renderer version.'); +} +run(process.execPath, [join(ROOT, 'scripts/generate-brand-assets.mjs'), '--macos-source-only']); + +const temp = await mkdtemp(join(tmpdir(), 'openbitfun-macos-icons-')); +try { + const { canvasSize, artworkBounds: bounds } = JSON.parse(await readFile(join(MACOS_ICON_DIR, 'legacy-canvas.json'), 'utf8')); + const iconset = join(temp, 'openbitfun.iconset'); + await mkdir(iconset); + // The legacy canvas is an authored macOS asset, not a modification of the + // shared PNG/ICO or the full-bleed Icon Composer document. Render each size + // with Apple's renderer before placing it on that canvas; do not rescale an + // already flattened icon or rewrite Tauri's ICNS internals. + for (const points of [16, 32, 128, 256, 512]) { + for (const scale of [1, 2]) { + const pixels = points * scale; + const left = Math.round(pixels * bounds.x / canvasSize); + const top = Math.round(pixels * bounds.y / canvasSize); + const width = pixels - left - Math.round(pixels * (canvasSize - bounds.x - bounds.width) / canvasSize); + const height = pixels - top - Math.round(pixels * (canvasSize - bounds.y - bounds.height) / canvasSize); + const rendered = join(temp, 'rendered.png'); + run(ictool, [COMPOSER_ICON, '--export-image', '--output-file', rendered, + '--platform', 'macOS', '--rendition', 'Default', '--width', String(width), + '--height', String(height), '--scale', '1']); + await sharp({ create: { width: pixels, height: pixels, channels: 4, background: '#00000000' } }) + .composite([{ input: rendered, left, top }]) + .png({ compressionLevel: 9, adaptiveFiltering: true }) + .toFile(join(iconset, `icon_${points}x${points}${scale === 2 ? '@2x' : ''}.png`)); + } + } + const output = join(temp, 'openbitfun.icns'); + run('/usr/bin/iconutil', ['--convert', 'icns', '--output', output, iconset]); + const icns = canonicalizeIcns(await readFile(output)); + for (const relative of ICNS_OUTPUTS) await writeFile(join(ROOT, relative), icns); + await writeFile(join(MACOS_ICON_DIR, 'generated.json'), `${JSON.stringify({ + iconComposerVersion: version['short-bundle-version'], + iconComposerBuild: version['bundle-version'], + sourceSha256: macosSourceDigest(), + icnsSha256: sha256(icns), + }, null, 2)}\n`); + console.log('Exported macOS Icon Composer fallback with iconutil.'); +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/scripts/macos-icon-assets.mjs b/scripts/macos-icon-assets.mjs new file mode 100644 index 0000000000..fef44d1496 --- /dev/null +++ b/scripts/macos-icon-assets.mjs @@ -0,0 +1,36 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const ROOT = fileURLToPath(new URL('../', import.meta.url)); +export const MACOS_ICON_DIR = join(ROOT, 'assets/brand/macos'); +export const COMPOSER_ICON = join(MACOS_ICON_DIR, 'openbitfun.icon'); +export const ICNS_OUTPUTS = [ + 'assets/brand/exports/openbitfun-app-icon.icns', + 'src/apps/desktop/icons/openbitfun-app-icon.icns', + 'OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns', +]; + +export function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +export function macosSourceDigest() { + const hash = createHash('sha256'); + for (const file of ['openbitfun.icon/icon.json', 'openbitfun.icon/Assets/Mark.png', 'legacy-canvas.json']) { + hash.update(file).update('\0').update(readFileSync(join(MACOS_ICON_DIR, file))); + } + return hash.digest('hex'); +} + +// Normal installs and non-Apple builds only copy the committed native export. +// Changing either composition requires an explicit export on a Mac. +export function readMacosFallback() { + const manifest = JSON.parse(readFileSync(join(MACOS_ICON_DIR, 'generated.json'), 'utf8')); + const icns = readFileSync(join(ROOT, ICNS_OUTPUTS[0])); + if (manifest.sourceSha256 !== macosSourceDigest() || manifest.icnsSha256 !== sha256(icns)) { + throw new Error('macOS icon export is stale. Run pnpm run generate:macos-icons on macOS and commit the generated assets.'); + } + return icns; +} diff --git a/scripts/macos-icon-bundle.mjs b/scripts/macos-icon-bundle.mjs new file mode 100644 index 0000000000..19f72f7839 --- /dev/null +++ b/scripts/macos-icon-bundle.mjs @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readMacosFallback } from './macos-icon-assets.mjs'; + +const run = (command, args) => execFileSync(command, args, { encoding: 'utf8', windowsHide: true }); + +export function requireModernIconTools(execute = run) { + let output; + try { + // Use the same actool lookup as Tauri's bundler, including DEVELOPER_DIR. + output = execute('actool', ['--version', '--output-format=human-readable-text']); + } catch { + throw new Error('macOS bundles require full Xcode 26 or newer (actool). Select it with DEVELOPER_DIR or xcode-select; Command Line Tools alone cannot compile Icon Composer assets.'); + } + const major = Number(output.match(/^\s*short-bundle-version:\s*(\d+)\./m)?.[1]); + if (!Number.isFinite(major) || major < 26) { + throw new Error('macOS bundles require actool 26 or newer; refusing to silently omit Assets.car.'); + } + execute('xcrun', ['--find', 'assetutil']); +} + +export function verifyMacosIconBundle(appPath, { execute = run, fallback = readMacosFallback() } = {}) { + const contents = join(appPath, 'Contents'); + const plist = JSON.parse(execute('/usr/bin/plutil', ['-convert', 'json', '-o', '-', join(contents, 'Info.plist')])); + const name = plist.CFBundleIconName; + const file = plist.CFBundleIconFile; + if (!name || !file || basename(file) !== file) { + throw new Error('macOS bundle must declare both CFBundleIconName and a local CFBundleIconFile.'); + } + const resources = join(contents, 'Resources'); + const car = join(resources, 'Assets.car'); + if (statSync(car).size === 0) throw new Error('Assets.car is empty.'); + const assets = JSON.parse(execute('assetutil', ['--info', car])); + if (!assets.some(asset => asset.AssetType === 'Icon Image' && asset.Name === name)) { + throw new Error(`Assets.car has no Icon Image matching CFBundleIconName ${name}.`); + } + const icns = readFileSync(join(resources, file.endsWith('.icns') ? file : `${file}.icns`)); + if (!icns.equals(fallback)) throw new Error('Bundled legacy ICNS differs from the verified dev/legacy export.'); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + if (process.argv[2] === 'preflight') { + readMacosFallback(); + requireModernIconTools(); + console.log('macOS icon sources and Xcode tools verified.'); + } else if (process.argv[2] === 'verify' && process.argv.length > 3) { + for (const appPath of process.argv.slice(3)) { + verifyMacosIconBundle(appPath); + console.log(`Verified modern and legacy macOS icons: ${appPath}`); + } + } else { + throw new Error('Usage: node scripts/macos-icon-bundle.mjs preflight | verify [...]'); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/macos-icon-bundle.test.mjs b/scripts/macos-icon-bundle.test.mjs new file mode 100644 index 0000000000..d31798bd74 --- /dev/null +++ b/scripts/macos-icon-bundle.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import sharp from 'sharp'; +import { COMPOSER_ICON, ROOT, macosSourceDigest, readMacosFallback } from './macos-icon-assets.mjs'; +import { requireModernIconTools, verifyMacosIconBundle } from './macos-icon-bundle.mjs'; +import { prepareTauriConfig } from './desktop-tauri-build.mjs'; + +const json = path => JSON.parse(readFileSync(path, 'utf8')); +const run = (command, args, options = {}) => execFileSync(command, args, { + encoding: 'utf8', windowsHide: true, ...options, +}); + +test('macOS uses a full-bleed Composer source and a current independent legacy export', async () => { + const source = json(join(COMPOSER_ICON, 'icon.json')); + assert.deepEqual(source['supported-platforms'], { squares: ['macOS'] }); + assert.equal(source.fill.solid, 'srgb:0.00000,0.00000,0.00000,1.00000'); + const mark = await sharp(join(COMPOSER_ICON, 'Assets/Mark.png')).metadata(); + assert.equal(mark.width, 1024); + assert.equal(mark.height, 1024); + assert.equal(mark.hasAlpha, true); + assert.ok(readMacosFallback().length > 0); + assert.equal(json(join(ROOT, 'assets/brand/macos/generated.json')).sourceSha256, macosSourceDigest()); +}); + +test('CLI is locked and macOS overlays preserve the same dev/legacy icon', () => { + for (const packagePath of ['package.json', 'OpenBitFun-Installer/package.json']) { + assert.equal(json(join(ROOT, packagePath)).devDependencies['@tauri-apps/cli'], '2.11.4'); + } + for (const app of ['src/apps/desktop', 'OpenBitFun-Installer/src-tauri']) { + const directory = join(ROOT, app); + const base = json(join(directory, 'tauri.conf.json')); + const mac = json(join(directory, 'tauri.macos.conf.json')); + assert.deepEqual(mac.bundle.icon.filter(icon => !icon.endsWith('.icon')), base.bundle.icon); + assert.equal(resolve(directory, mac.bundle.icon.find(icon => icon.endsWith('.icon'))), COMPOSER_ICON); + assert.match(mac.build.beforeBundleCommand, /check:macos-icons/); + } + assert.equal(json(join(ROOT, 'src/apps/desktop/tauri.dev.conf.json')).bundle.icon, undefined); +}); + +test('generated release config cannot overwrite platform icon selection', () => { + const directory = mkdtempSync(join(tmpdir(), 'openbitfun-icon-config-')); + const updaterEnabled = process.env.OPENBITFUN_ENABLE_UPDATER_ARTIFACTS; + process.env.OPENBITFUN_ENABLE_UPDATER_ARTIFACTS = 'false'; + try { + const configPath = join(directory, 'tauri.conf.json'); + writeFileSync(configPath, JSON.stringify({ bundle: { icon: ['legacy.icns', 'windows.ico'] } })); + const generated = prepareTauriConfig(configPath, { desktopDir: directory }); + assert.equal(json(generated).bundle.icon, undefined); + } finally { + if (updaterEnabled === undefined) delete process.env.OPENBITFUN_ENABLE_UPDATER_ARTIFACTS; + else process.env.OPENBITFUN_ENABLE_UPDATER_ARTIFACTS = updaterEnabled; + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('missing, old, and unparseable actool fail instead of silently dropping modern icons', () => { + assert.throws(() => requireModernIconTools(() => { throw new Error('not found'); }), /full Xcode 26/); + for (const output of ['short-bundle-version: 16.4', 'bundle-version: 24411']) { + assert.throws(() => requireModernIconTools(() => output), /refusing to silently omit/); + } + for (const version of ['26.2', '27.0']) { + const calls = []; + requireModernIconTools((command, args) => { + calls.push([command, args]); + return `/* com.apple.actool.version */\nshort-bundle-version: ${version}\n`; + }); + assert.deepEqual(calls[1], ['xcrun', ['--find', 'assetutil']]); + } +}); + +test('bundle validation rejects a missing catalog, wrong icon name, and mismatched fallback', () => { + const app = mkdtempSync(join(tmpdir(), 'openbitfun-icon-validation-')); + const resources = join(app, 'Contents/Resources'); + mkdirSync(resources, { recursive: true }); + let plist = { CFBundleIconName: 'Icon', CFBundleIconFile: 'app.icns' }; + let assets = [{ AssetType: 'Icon Image', Name: 'Icon' }]; + const execute = command => JSON.stringify(command.endsWith('plutil') ? plist : assets); + const fallback = Buffer.from('fixture legacy data'); + const verify = () => verifyMacosIconBundle(app, { execute, fallback }); + try { + assert.throws(verify, /ENOENT/); + writeFileSync(join(resources, 'Assets.car'), 'fixture catalog'); + writeFileSync(join(resources, 'app.icns'), fallback); + verify(); + assets = [{ AssetType: 'Image', Name: 'Icon' }]; + assert.throws(verify, /no Icon Image/); + assets = [{ AssetType: 'Icon Image', Name: 'WrongName' }]; + assert.throws(verify, /no Icon Image/); + assets = [{ AssetType: 'Icon Image', Name: 'Icon' }]; + writeFileSync(join(resources, 'app.icns'), 'wrong fallback'); + assert.throws(verify, /differs/); + plist = { CFBundleIconFile: 'app.icns' }; + assert.throws(verify, /both CFBundleIconName/); + } finally { + rmSync(app, { recursive: true, force: true }); + } +}); + +test('Apple iconutil decodes all ten legacy/Retina representations with Dock margins', { + skip: process.platform !== 'darwin', +}, async () => { + const directory = mkdtempSync(join(tmpdir(), 'openbitfun-icon-decode-')); + try { + const iconset = join(directory, 'fallback.iconset'); + run('/usr/bin/iconutil', ['--convert', 'iconset', '--output', iconset, + join(ROOT, 'assets/brand/exports/openbitfun-app-icon.icns')]); + const files = readdirSync(iconset).filter(file => file.endsWith('.png')); + assert.equal(files.length, 10); + for (const file of files) { + const { data, info } = await sharp(join(iconset, file)).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const size = info.width; + assert.equal(info.height, size); + let first = -1; + let last = -1; + for (let x = 0; x < size; x++) { + assert.equal(data[x * 4 + 3], 0, `${file} top margin`); + if (data[(Math.floor(size / 2) * size + x) * 4 + 3] >= 128) { + if (first < 0) first = x; + last = x; + } + } + assert.ok(first >= size * 0.07 && first <= size * 0.14, `${file} body size`); + assert.ok(Math.abs(first - (size - 1 - last)) <= 1, `${file} centered body`); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function bundleFixture(modern) { + const directory = mkdtempSync(join(tmpdir(), 'openbitfun-icon-bundle-')); + try { + for (const name of ['src', 'frontend', 'target/release']) mkdirSync(join(directory, name), { recursive: true }); + writeFileSync(join(directory, 'src/main.rs'), 'fn main() {}'); + writeFileSync(join(directory, 'frontend/index.html'), ''); + writeFileSync(join(directory, 'Cargo.toml'), '[package]\nname="icon-probe"\nversion="0.1.0"\nedition="2021"\n'); + // Bundle a real Mach-O without building/launching the application. This + // exercises the locked Tauri bundler and Apple asset tools, not Dock UI. + copyFileSync('/usr/bin/true', join(directory, 'target/release/icon-probe')); + const desktop = join(ROOT, 'src/apps/desktop'); + const selected = json(join(desktop, modern ? 'tauri.macos.conf.json' : 'tauri.conf.json')); + writeFileSync(join(directory, 'tauri.conf.json'), JSON.stringify({ + productName: 'Icon Probe', version: '0.1.0', identifier: 'com.openbitfun.iconprobe', + build: { frontendDist: 'frontend' }, + bundle: { active: true, targets: ['app'], icon: selected.bundle.icon.map(icon => resolve(desktop, icon)) }, + })); + const env = { ...process.env, CI: 'true', CARGO_TARGET_DIR: join(directory, 'target') }; + // Release runners have signing/notarization credentials. Disposable icon + // fixtures must never submit to Apple's notary service or use those keys. + for (const key of Object.keys(env)) { + if (key.startsWith('APPLE_') || key.startsWith('TAURI_SIGNING_')) delete env[key]; + } + delete env.TAURI_CONFIG; + delete env.CARGO_BUILD_TARGET; + run(process.execPath, [join(ROOT, 'node_modules/@tauri-apps/cli/tauri.js'), 'bundle', '--bundles', 'app'], { cwd: directory, env }); + const app = join(directory, 'target/release/bundle/macos/Icon Probe.app'); + const plist = JSON.parse(run('/usr/bin/plutil', ['-convert', 'json', '-o', '-', join(app, 'Contents/Info.plist')])); + assert.deepEqual(readFileSync(join(app, 'Contents/Resources', plist.CFBundleIconFile)), readMacosFallback()); + if (modern) verifyMacosIconBundle(app); + else assert.equal(existsSync(join(app, 'Contents/Resources/Assets.car')), false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test('locked Tauri CLI bundles the legacy ICNS without modifying it', { + skip: process.platform !== 'darwin', +}, () => bundleFixture(false)); + +test('locked Tauri CLI compiles the modern source and emits both Info.plist icon keys', { + skip: process.platform !== 'darwin' || process.env.OPENBITFUN_TEST_MACOS_BUNDLE !== '1' + ? 'Requires full Xcode; enabled explicitly on both macOS packaging runners.' : false, +}, () => { + requireModernIconTools(); + bundleFixture(true); +}); diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 9258b21c23..9fa16df9fe 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -96,6 +96,17 @@ The `devtools` Cargo feature exists for debugging UI/UX in the desktop app. When ## Verification +For application icon resources and packaging, run `pnpm run generate-brand-assets` +and `node --test scripts/generate-brand-assets.test.mjs scripts/macos-icon-bundle.test.mjs scripts/desktop-tauri-build.test.mjs`. +The [brand guide](../../../assets/brand/README.md#macos-icons) owns native exports. +On a Mac with full Xcode 26+, also run +`OPENBITFUN_TEST_MACOS_BUNDLE=1 node --test scripts/macos-icon-bundle.test.mjs`; +CI requires this on both macOS architectures. The fixture bundles a Mach-O +without launching UI. Resource-only edits do not require the Rust suites below; +packaging/startup code changes still require the desktop build. These checks do +not establish visual Dock equivalence on an older OS. Keep modern `.icon` +resources in the macOS config overlay and preserve the dev/legacy ICNS path. + For macOS microphone signing metadata, run `plutil -lint src/apps/desktop/Info.plist src/apps/desktop/Entitlements.plist` and `node --test scripts/ci/verify-macos-microphone.test.mjs` on macOS. diff --git a/src/apps/desktop/README.md b/src/apps/desktop/README.md index d0f1f127c6..655834256c 100644 --- a/src/apps/desktop/README.md +++ b/src/apps/desktop/README.md @@ -116,6 +116,17 @@ real desktop and asks you to select its dedicated fixture window in the system picker. These fixtures require Node.js 22/24, Rust, `python3-gi` and GTK 3; the portal fixture also needs the runtime packages listed above. +## macOS application icon builds + +Development uses the committed macOS ICNS fallback and does not require Icon +Composer. Packaged macOS 26/27 builds also include the native Icon Composer +catalog; earlier macOS releases use the static fallback. Building a distributable +macOS bundle requires full Xcode 26 or newer selected through `DEVELOPER_DIR` or +`xcode-select`. The packaging command rejects a missing catalog instead of +silently shipping only the legacy icon. See the +[brand resource guide](../../../assets/brand/README.md#macos-icons) for regeneration +and focused validation commands. + ## macOS menu bar The menu bar uses the solid ring brand mark as a transparent template logo that follows the system's light diff --git a/src/apps/desktop/icons/openbitfun-app-icon.icns b/src/apps/desktop/icons/openbitfun-app-icon.icns index 976eec4c27..b6470b0b89 100644 Binary files a/src/apps/desktop/icons/openbitfun-app-icon.icns and b/src/apps/desktop/icons/openbitfun-app-icon.icns differ diff --git a/src/apps/desktop/tauri.dev.conf.json b/src/apps/desktop/tauri.dev.conf.json index b30674a0bb..9eec81f0b8 100644 --- a/src/apps/desktop/tauri.dev.conf.json +++ b/src/apps/desktop/tauri.dev.conf.json @@ -11,11 +11,6 @@ "bundle": { "active": true, "targets": "all", - "icon": [ - "icons/openbitfun-app-icon.icns", - "icons/openbitfun-app-icon.ico", - "icons/openbitfun-app-icon.png" - ], "resources": { "../../mobile-web/dist": "mobile-web/dist", "../extension-host/dist/extension-host.js": "resources/ext-host/extension-host.js", diff --git a/src/apps/desktop/tauri.macos.conf.json b/src/apps/desktop/tauri.macos.conf.json new file mode 100644 index 0000000000..c067eb55c0 --- /dev/null +++ b/src/apps/desktop/tauri.macos.conf.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "build": { + "beforeBundleCommand": "pnpm run check:macos-icons" + }, + "bundle": { + "icon": [ + "icons/openbitfun-app-icon.icns", + "icons/openbitfun-app-icon.ico", + "icons/openbitfun-app-icon.png", + "../../../assets/brand/macos/openbitfun.icon" + ] + } +}