diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c31f35..a26dbb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,6 +113,15 @@ jobs: with: node-version: "26" cache: npm + # The mac icon is the Icon Composer document (build/icon.icon); + # compiling it needs actool ≥ 26 (Xcode 26+). macos-15 images carry + # several Xcodes with an older default — select the latest stable + # explicitly. No-op for the windows/linux matrix legs. + - name: Select Xcode 26+ for actool (mac icon compile) + if: runner.os == 'macOS' + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable - run: npm ci - name: Native renderer tests on this OS run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts diff --git a/build/icon-flat.svg b/build/icon-flat.svg new file mode 100644 index 0000000..19fa818 --- /dev/null +++ b/build/icon-flat.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000..7b0f230 Binary files /dev/null and b/build/icon.ico differ diff --git a/build/icon.icon/Assets/lh_0 2.svg b/build/icon.icon/Assets/lh_0 2.svg new file mode 100644 index 0000000..6ca3e24 --- /dev/null +++ b/build/icon.icon/Assets/lh_0 2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build/icon.icon/Assets/lh_1 2.svg b/build/icon.icon/Assets/lh_1 2.svg new file mode 100644 index 0000000..0f795aa --- /dev/null +++ b/build/icon.icon/Assets/lh_1 2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build/icon.icon/Assets/lh_2 2.svg b/build/icon.icon/Assets/lh_2 2.svg new file mode 100644 index 0000000..bbcb692 --- /dev/null +++ b/build/icon.icon/Assets/lh_2 2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build/icon.icon/icon.json b/build/icon.icon/icon.json new file mode 100644 index 0000000..dcdac9d --- /dev/null +++ b/build/icon.icon/icon.json @@ -0,0 +1,37 @@ +{ + "fill" : { + "automatic-gradient" : "srgb:0.07843,0.07843,0.07843,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "lh_2 2.svg", + "name" : "lh_2 2" + }, + { + "image-name" : "lh_1 2.svg", + "name" : "lh_1 2" + }, + { + "image-name" : "lh_0 2.svg", + "name" : "lh_0 2" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.3 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000..315d202 Binary files /dev/null and b/build/icon.png differ diff --git a/docs/desktop-release.md b/docs/desktop-release.md index 3ac8301..e6dd66f 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -12,7 +12,7 @@ Windows signing is deliberately disabled for this first release. SmartScreen may The custom app bar stays on all three platforms. macOS keeps its left-side traffic lights; Windows/Linux use Electron's native right-side Window Controls Overlay, with CSS space reserved from the overlay's actual geometry. Windows/Linux hide the native menu until Alt is pressed. macOS keeps its system menu. -The existing `build/icon.icns` is the source for all platforms: electron-builder converts it to ICO/Linux PNGs. Linux's desktop identity is `learnharness.desktop`. Public package-maintainer metadata uses the owner's GitHub no-reply address; support goes through [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues). +Icons are per-platform (2026-10-13): macOS compiles the Icon Composer document `build/icon.icon` via actool into `Assets.car` (macOS 26 Liquid Glass) plus a fallback icns that `scripts/after-pack.cjs` replaces with the hand-packed full-res `build/icon.icns` (actool's caps at 256px); Windows embeds `build/icon.ico` and Linux uses `build/icon.png`, both generated from the flat design (`build/icon-flat.svg`) by `node scripts/make-icons.mjs`. Building the mac icon needs Xcode 26+ (`actool`) — `desktop:dist` injects `DEVELOPER_DIR` locally and the release workflow selects Xcode via setup-xcode. Linux's desktop identity is `learnharness.desktop`. Public package-maintainer metadata uses the owner's GitHub no-reply address; support goes through [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues). ## Build pipeline diff --git a/electron-builder.yml b/electron-builder.yml index ba15b18..5c91e18 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -13,6 +13,19 @@ appId: com.learnharness.LearnHarness productName: LearnHarness directories: output: dist +# Icons (owner ruling, 2026-10-13) — per-platform sources in build/: +# mac build/icon.icon — the Icon Composer document (layered Liquid +# Glass); actool (Xcode 26+) compiles Assets.car (CFBundleIconName, +# macOS 26+ renders the true layered icon) + an actool fallback icns +# capped at 256px, which scripts/after-pack.cjs swaps for the +# hand-packed full-res build/icon.icns (pre-Tahoe keeps 16→1024). +# Requires actool ≥ 26: desktop:dist injects DEVELOPER_DIR locally; +# the release workflow's mac job runs setup-xcode. +# win build/icon.ico — generated (16→256) from the flat design by +# scripts/make-icons.mjs. +# linux build/icon.png — 512×512, same script. Rounded squircle with +# baked transparency on both (owner ruling) — Windows/Linux don't +# mask, the shape ships in the artwork. files: # Only the shell goes into the asar. ORDER IS LOAD-BEARING (payload # wave, 2026-09-24): electron-builder's dependency collector packs the @@ -82,6 +95,7 @@ extraResources: filter: - "standalone/**/*" mac: + icon: build/icon.icon # zip rides next to dmg (flipped 2026-10-08): Squirrel.Mac — Electron's # macOS auto-updater — installs from ZIP and cannot read DMGs. DMG is # for humans; ZIP + blockmap are the updater's payload. @@ -114,7 +128,9 @@ mac: notarize: true dmg: title: "${productName} ${version}" +afterPack: scripts/after-pack.cjs win: + icon: build/icon.ico target: - target: nsis arch: @@ -128,6 +144,7 @@ nsis: oneClick: true perMachine: false linux: + icon: build/icon.png target: - target: AppImage arch: diff --git a/package.json b/package.json index dcbb9fe..c4c1e69 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "typecheck": "tsc --noEmit", "desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"", "dev:fresh": "bash scripts/dev-fresh.sh", - "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64" + "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64" }, "dependencies": { "@earendil-works/pi-agent-core": "1.0.2", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs new file mode 100644 index 0000000..69597b9 --- /dev/null +++ b/scripts/after-pack.cjs @@ -0,0 +1,27 @@ +// electron-builder afterPack hook — mac only. +// +// WHY (owner ruling 2026-10-13): mac.icon is the Icon Composer document +// (build/icon.icon); actool compiles it into Assets.car (the macOS 26 +// Liquid Glass render) PLUS a backwards-compat icns — but actool's +// fallback icns caps at 256px (known actool behavior), which looks soft +// at Finder/Launchpad sizes on pre-Tahoe macOS. This hook swaps in the +// hand-packed full-res build/icon.icns (all 10 entries, 16→1024, packed +// via iconutil from the design's PNG exports) AFTER packaging and BEFORE +// signing, so: +// +// macOS 26+ → Assets.car (CFBundleIconName) — true layered glass icon; +// older mac → icon.icns (CFBundleIconFile) — today's full-res flatten. +// +// Windows/Linux builds return immediately (their icons are static files: +// build/icon.ico / build/icon.png). +const fs = require("node:fs"); +const path = require("node:path"); + +module.exports = async function afterPack(context) { + if (context.electronPlatformName !== "darwin") return; + const appBundle = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`); + const target = path.join(appBundle, "Contents", "Resources", "icon.icns"); + const source = path.join(context.packager.info.projectDir, "build", "icon.icns"); + fs.copyFileSync(source, target); + console.log(`[after-pack] pre-Tahoe fallback icns (full-res) → ${target}`); +}; diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs new file mode 100644 index 0000000..066dbb6 --- /dev/null +++ b/scripts/make-icons.mjs @@ -0,0 +1,55 @@ +// Regenerate the Windows/Linux icon set from the flat design source +// (build/icon-flat.svg — the owner's Affinity export, rounded squircle +// with baked transparency, owner ruling 2026-10-13). +// +// build/icon.ico — Windows (.ico with 16/24/32/48/64/128/256 PNG entries) +// build/icon.png — Linux (512×512; AppImage/deb/desktop entry) +// +// macOS does NOT come from here: build/icon.icon (the Icon Composer +// document) is the mac source of truth, compiled by actool at package +// time into Assets.car (macOS 26 Liquid Glass) + a fallback icns; the +// hand-packed build/icon.icns replaces that fallback's 256px cap via +// scripts/after-pack.cjs (pre-Tahoe keeps full-res). +// +// Renders each size DIRECTLY from the SVG (resvg) — sharper squircle +// edges than downsampling a raster. Committed outputs; rerun only when +// the design changes: `node scripts/make-icons.mjs`. +import { Resvg } from "@resvg/resvg-js"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const svg = readFileSync(path.join(root, "build", "icon-flat.svg"), "utf8"); + +const render = (size) => new Resvg(svg, { fitTo: { mode: "width", value: size } }).render(size, size).asPng(); + +// ---- build/icon.ico: ICONDIR + PNG entries ------------------------------ +const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]; +const pngs = ICO_SIZES.map(render); +// Header (6) + one 16-byte directory entry per image, then the PNGs. +const header = Buffer.alloc(6 + ICO_SIZES.length * 16); +header.writeUInt16LE(0, 0); // reserved +header.writeUInt16LE(1, 2); // type: icon +header.writeUInt16LE(ICO_SIZES.length, 4); +let offset = header.length; +ICO_SIZES.forEach((size, i) => { + const e = 6 + i * 16; + header.writeUInt8(size === 256 ? 0 : size, e); // 0 encodes 256 + header.writeUInt8(size === 256 ? 0 : size, e + 1); + header.writeUInt8(0, e + 2); // colors + header.writeUInt8(0, e + 3); // reserved + header.writeUInt16LE(1, e + 4); // planes + header.writeUInt16LE(32, e + 6); // bpp + header.writeUInt32LE(pngs[i].length, e + 8); + header.writeUInt32LE(offset, e + 12); + offset += pngs[i].length; +}); +const ico = Buffer.concat([header, ...pngs]); +writeFileSync(path.join(root, "build", "icon.ico"), ico); +console.log(`build/icon.ico ${ICO_SIZES.join("/")} — ${(ico.length / 1024).toFixed(1)} KB`); + +// ---- build/icon.png: the Linux icon ------------------------------------- +const png512 = render(512); +writeFileSync(path.join(root, "build", "icon.png"), png512); +console.log(`build/icon.png 512×512 — ${(png512.length / 1024).toFixed(1)} KB`);