diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 32a9d3f5..cb426169 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -20,9 +20,26 @@ bun run dev:desktop The sidecar can be overridden for development with `EDGE_EVER_SIDECAR_PATH=/absolute/path/to/edgeever-sidecar`. +## App icon (macOS Dock) + +Dock tiles need a complete multi-resolution `.icns` (including 1024px). Do not +point electron-builder at a lone 512 PWA PNG — that produces a thin icon pack +and macOS can show a blank Dock placeholder forever after installs. + +Regenerate committed icon assets after changing the brand mark: + +```sh +bun run prepare:desktop:icons +``` + +This writes `apps/desktop/assets/icon.icns` (full ICNS) and `icon.png` (1024 +master). Packaging validation rejects incomplete ICNS types. Runtime also calls +`app.dock.setIcon` as a Launch Services / Dock cache fallback. + Build an unsigned installer for the current platform with: ```sh +bun run prepare:desktop:icons bun run build:web bun run build:desktop:sidecar CSC_IDENTITY_AUTO_DISCOVERY=false bun run --cwd apps/desktop dist -- --publish never diff --git a/apps/desktop/assets/icon.icns b/apps/desktop/assets/icon.icns new file mode 100644 index 00000000..260db2eb Binary files /dev/null and b/apps/desktop/assets/icon.icns differ diff --git a/apps/desktop/assets/icon.png b/apps/desktop/assets/icon.png new file mode 100644 index 00000000..457e5a91 Binary files /dev/null and b/apps/desktop/assets/icon.png differ diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 333f1583..3b3af81a 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -18,7 +18,9 @@ fileAssociations: description: Open a Markdown document in EdgeEver role: Editor asar: true -icon: ../web/public/pwa-512x512.png +# Cross-platform master (1024). macOS uses the prebuilt full ICNS below so Dock +# always receives ic10/ic14 instead of electron-builder's thin 512-only pack. +icon: assets/icon.png directories: output: ../../release/desktop files: @@ -34,6 +36,11 @@ extraResources: filter: - "trayTemplate.png" - "trayTemplate@2x.png" + - from: assets + to: . + filter: + - "icon.icns" + - "icon.png" - from: ../../migrations to: migrations filter: @@ -45,6 +52,7 @@ extraResources: - edgeever-sidecar.exe mac: category: public.app-category.productivity + icon: assets/icon.icns target: - target: dmg - target: zip diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 9aae9d2d..e44fab09 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -30,6 +30,29 @@ if (requestedUserDataDirectory) app.setPath("userData", requestedUserDataDirecto const currentDirectory = fileURLToPath(new URL(".", import.meta.url)); const projectRoot = join(currentDirectory, "../../.."); +/** + * Force Dock to use our multi-resolution app icon. Bundle Info.plist is still + * the primary source; this covers cases where Launch Services/Dock cache a + * blank tile after overwrite installs. + */ +const applyMacDockIcon = () => { + if (process.platform !== "darwin" || !app.dock) return; + const candidates = app.isPackaged + ? [join(process.resourcesPath, "icon.icns"), join(process.resourcesPath, "icon.png")] + : [ + join(projectRoot, "apps/desktop/assets/icon.icns"), + join(projectRoot, "apps/desktop/assets/icon.png"), + join(projectRoot, "apps/web/public/pwa-512x512.png"), + ]; + for (const iconPath of candidates) { + if (!existsSync(iconPath)) continue; + const image = nativeImage.createFromPath(iconPath); + if (!image.isEmpty()) { + app.dock.setIcon(image); + return; + } + } +}; const webUrl = process.env.EDGE_EVER_DESKTOP_WEB_URL || "http://127.0.0.1:5173"; // A packaged desktop app is self-hosted-client software: its instance URL must // come from the user-facing first-run setup, never from the build environment. @@ -564,6 +587,7 @@ const confirmMacInstallation = async () => { }; app.whenReady().then(async () => { + applyMacDockIcon(); if (app.isPackaged && isMountedInstallerPath(app.getAppPath())) { const isChinese = app.getLocale().toLowerCase().startsWith("zh"); const result = await dialog.showMessageBox({ diff --git a/package.json b/package.json index da68a250..8118b65f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dev:desktop": "concurrently -k -n web,desktop -c magenta,cyan \"bun run dev:web\" \"bun run --cwd apps/desktop dev\"", "dev:mobile": "bun --cwd apps/mobile expo start", "prepare:mobile:icons": "bun scripts/prepare-mobile-icons.mjs", + "prepare:desktop:icons": "bun scripts/prepare-desktop-icons.mjs", "build:android:fast": "bash scripts/build-android-local.sh fast", "build:android:fast:local": "bun --env-file=${EDGE_EVER_ANDROID_ENV_FILE:-$HOME/.config/edgeever/android/signing.env} run build:android:fast", "build:android:apk": "bash scripts/build-android-local.sh apk", @@ -36,10 +37,10 @@ "dev:extension": "vite --config apps/extension/vite.config.ts", "build:web": "vite build --config apps/web/vite.config.ts", "build:desktop:sidecar": "cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release", - "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", + "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run prepare:desktop:icons && bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", "verify:web-performance": "bun scripts/verify-web-performance.mjs", - "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/verify-android-apk-signature.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", + "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", diff --git a/scripts/desktop-icns.mjs b/scripts/desktop-icns.mjs new file mode 100644 index 00000000..9f8d5928 --- /dev/null +++ b/scripts/desktop-icns.mjs @@ -0,0 +1,116 @@ +/** + * macOS ICNS inspection helpers. + * + * Dock and Launch Services need a complete multi-resolution icon pack. A single + * 512 PNG converted by electron-builder often omits the 1024 (ic10) slot and + * can leave Dock tiles stuck on the generic "empty" placeholder after installs. + */ + +/** @typedef {{ type: string; length: number; offset: number }} IcnsEntry */ + +/** + * Types that a modern EdgeEver macOS app icon must include so Finder and Dock + * can pick an appropriate bitmap at every scale. + * + * Note: current `iconutil` emits `ic04`/`ic05` for the smallest slots, while + * older electron-builder packs used `icp4`/`icp5`/`icp6`. Either family is OK + * for the small sizes; the high-resolution set below is mandatory. + */ +export const REQUIRED_MAC_ICNS_TYPES = Object.freeze([ + "ic07", // 128 + "ic08", // 256 + "ic09", // 512 + "ic10", // 1024 — required for modern Dock / retina large tiles + "ic11", // 16@2x + "ic12", // 32@2x + "ic13", // 128@2x + "ic14", // 256@2x +]); + +/** At least one type from each group must be present. */ +export const REQUIRED_MAC_ICNS_ANY_OF = Object.freeze([ + Object.freeze(["icp4", "ic04"]), // ~16px family + Object.freeze(["icp5", "ic05", "icp6"]), // ~32–64px family +]); + +/** + * Parse the top-level type table of an ICNS container. + * @param {Buffer | Uint8Array} data + * @returns {IcnsEntry[]} + */ +export const parseIcnsEntries = (data) => { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + if (buffer.length < 8 || buffer.subarray(0, 4).toString("ascii") !== "icns") { + throw new Error("Not a valid ICNS file (missing icns magic)."); + } + + const declaredLength = buffer.readUInt32BE(4); + if (declaredLength !== buffer.length) { + throw new Error( + `ICNS length mismatch: header claims ${declaredLength} bytes, file has ${buffer.length}.`, + ); + } + + /** @type {IcnsEntry[]} */ + const entries = []; + let offset = 8; + while (offset + 8 <= buffer.length) { + const type = buffer.subarray(offset, offset + 4).toString("ascii"); + const length = buffer.readUInt32BE(offset + 4); + if (length < 8 || offset + length > buffer.length) { + throw new Error(`Invalid ICNS entry at offset ${offset} (type=${JSON.stringify(type)}, length=${length}).`); + } + entries.push({ type, length, offset }); + offset += length; + } + + if (offset !== buffer.length) { + throw new Error(`ICNS trailing bytes: parsed ${offset}, file ${buffer.length}.`); + } + + return entries; +}; + +/** + * @param {Buffer | Uint8Array} data + * @returns {{ types: string[]; missing: string[]; missingGroups: string[][]; ok: boolean }} + */ +export const inspectMacIcns = (data) => { + const entries = parseIcnsEntries(data); + const types = entries.map((entry) => entry.type); + const present = new Set(types); + const missing = REQUIRED_MAC_ICNS_TYPES.filter((type) => !present.has(type)); + const missingGroups = REQUIRED_MAC_ICNS_ANY_OF.filter( + (group) => !group.some((type) => present.has(type)), + ); + return { + types, + missing, + missingGroups, + ok: missing.length === 0 && missingGroups.length === 0, + }; +}; + +/** + * @param {Buffer | Uint8Array} data + * @param {string} [label] + */ +export const assertMacIcnsComplete = (data, label = "icon.icns") => { + const result = inspectMacIcns(data); + if (!result.ok) { + const parts = []; + if (result.missing.length > 0) { + parts.push(`types ${result.missing.join(", ")}`); + } + if (result.missingGroups.length > 0) { + parts.push( + `small-size groups ${result.missingGroups.map((group) => group.join("|")).join("; ")}`, + ); + } + throw new Error( + `${label} is missing Dock-critical ICNS ${parts.join(" and ")}. ` + + `Regenerate with: bun run prepare:desktop:icons`, + ); + } + return result; +}; diff --git a/scripts/desktop-icns.test.mjs b/scripts/desktop-icns.test.mjs new file mode 100644 index 00000000..27d8a1a4 --- /dev/null +++ b/scripts/desktop-icns.test.mjs @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + REQUIRED_MAC_ICNS_TYPES, + assertMacIcnsComplete, + inspectMacIcns, + parseIcnsEntries, +} from "./desktop-icns.mjs"; + +const incompleteIcnsFromPwa = () => { + // Synthetic ICNS with only a few modern types (mirrors the previous 512-only pack). + const chunks = [ + Buffer.from("icp4"), + (() => { + const body = Buffer.alloc(8); + body.writeUInt32BE(16, 0); + return body; + })(), + ]; + // Build one real-looking entry of length 16 (header 8 + 8 payload) + const entryType = Buffer.from("ic09"); + const entryLen = Buffer.alloc(4); + entryLen.writeUInt32BE(16, 0); + const entryPayload = Buffer.alloc(8, 1); + const entry = Buffer.concat([entryType, entryLen, entryPayload]); + const total = 8 + entry.length; + const header = Buffer.alloc(8); + header.write("icns", 0, 4, "ascii"); + header.writeUInt32BE(total, 4); + return Buffer.concat([header, entry]); +}; + +describe("desktop ICNS helpers", () => { + test("lists the Dock-critical type set including 1024px", () => { + expect(REQUIRED_MAC_ICNS_TYPES).toContain("ic10"); + expect(REQUIRED_MAC_ICNS_TYPES).toContain("ic14"); + }); + + test("rejects buffers that are not ICNS", () => { + expect(() => parseIcnsEntries(Buffer.from("not-an-icon"))).toThrow(/icns magic/i); + }); + + test("reports missing types for incomplete ICNS packs", () => { + const incomplete = incompleteIcnsFromPwa(); + const report = inspectMacIcns(incomplete); + expect(report.ok).toBe(false); + expect(report.missing).toContain("ic10"); + expect(() => assertMacIcnsComplete(incomplete, "test.icns")).toThrow(/missing Dock-critical/i); + }); + + test("committed desktop icon.icns is complete for Dock", () => { + const path = join(import.meta.dir, "../apps/desktop/assets/icon.icns"); + const data = readFileSync(path); + const report = assertMacIcnsComplete(data, path); + for (const type of REQUIRED_MAC_ICNS_TYPES) { + expect(report.types).toContain(type); + } + expect(report.types.some((type) => type === "ic04" || type === "icp4")).toBe(true); + }); +}); diff --git a/scripts/plan-native-release.mjs b/scripts/plan-native-release.mjs index 6e29381c..3c02c976 100644 --- a/scripts/plan-native-release.mjs +++ b/scripts/plan-native-release.mjs @@ -34,6 +34,8 @@ export const planNativeRelease = (platform, changedFiles) => { ".github/workflows/desktop-build.yml", "bun.lock", "scripts/create-mac-update-metadata.mjs", + "scripts/desktop-icns.mjs", + "scripts/prepare-desktop-icons.mjs", "scripts/run-desktop-builder.mjs", "scripts/verify-desktop-package.mjs", ]); diff --git a/scripts/plan-native-release.test.mjs b/scripts/plan-native-release.test.mjs index 8d201bb9..f496c57a 100644 --- a/scripts/plan-native-release.test.mjs +++ b/scripts/plan-native-release.test.mjs @@ -44,6 +44,8 @@ describe("native release planning", () => { const changedFiles = [ ".github/workflows/desktop-build.yml", "scripts/create-mac-update-metadata.mjs", + "scripts/prepare-desktop-icons.mjs", + "scripts/desktop-icns.mjs", "scripts/run-desktop-builder.mjs", ]; expect(planNativeRelease("desktop", changedFiles)).toEqual({ diff --git a/scripts/prepare-desktop-icons.mjs b/scripts/prepare-desktop-icons.mjs new file mode 100644 index 00000000..11227e52 --- /dev/null +++ b/scripts/prepare-desktop-icons.mjs @@ -0,0 +1,107 @@ +/** + * Build a complete macOS ICNS (and 1024 PNG master) for the desktop app. + * + * Why this exists: + * electron-builder converting a lone 512 PWA PNG yields a thin ICNS that often + * omits ic10 (1024). Dock then falls back to the generic empty placeholder — + * including after clean installs. We prebuild a full iconutil pack and commit it. + * + * Usage: bun run prepare:desktop:icons + */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; +import { assertMacIcnsComplete } from "./desktop-icns.mjs"; + +const projectRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); +const sourcePng = join(projectRoot, "apps/web/public/pwa-512x512.png"); +const assetsDir = join(projectRoot, "apps/desktop/assets"); + +/** iconutil expects these exact basenames inside a .iconset directory. */ +const ICONSET_FILES = [ + { name: "icon_16x16.png", size: 16 }, + { name: "icon_16x16@2x.png", size: 32 }, + { name: "icon_32x32.png", size: 32 }, + { name: "icon_32x32@2x.png", size: 64 }, + { name: "icon_128x128.png", size: 128 }, + { name: "icon_128x128@2x.png", size: 256 }, + { name: "icon_256x256.png", size: 256 }, + { name: "icon_256x256@2x.png", size: 512 }, + { name: "icon_512x512.png", size: 512 }, + { name: "icon_512x512@2x.png", size: 1024 }, +]; + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed:\n${result.stderr || result.stdout || result.error}`, + ); + } + return result; +}; + +const writeResizedPng = async (sourceBuffer, size, destination) => { + await sharp(sourceBuffer) + .resize(size, size, { + fit: "contain", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + kernel: sharp.kernel.lanczos3, + }) + .png({ compressionLevel: 9 }) + .toFile(destination); +}; + +export const prepareDesktopIcons = async ({ + sourcePath = sourcePng, + assetsDirectory = assetsDir, + requireIconutil = process.platform === "darwin", +} = {}) => { + await mkdir(assetsDirectory, { recursive: true }); + const sourceBuffer = await readFile(sourcePath); + + // Master PNG used by electron-builder on Windows/Linux and as a Dock fallback. + await writeResizedPng(sourceBuffer, 1024, join(assetsDirectory, "icon.png")); + + if (!requireIconutil) { + console.log("[prepare-desktop-icons] skipped iconutil on non-macOS; master PNG written."); + return { icnsPath: join(assetsDirectory, "icon.icns"), pngPath: join(assetsDirectory, "icon.png") }; + } + + const workRoot = await mkdtemp(join(tmpdir(), "edgeever-desktop-icon-")); + const iconsetDir = join(workRoot, "EdgeEver.iconset"); + await mkdir(iconsetDir, { recursive: true }); + + try { + for (const { name, size } of ICONSET_FILES) { + await writeResizedPng(sourceBuffer, size, join(iconsetDir, name)); + } + + const icnsPath = join(assetsDirectory, "icon.icns"); + run("iconutil", ["-c", "icns", iconsetDir, "-o", icnsPath]); + const icnsData = await readFile(icnsPath); + assertMacIcnsComplete(icnsData, icnsPath); + + console.log(`[prepare-desktop-icons] wrote ${icnsPath} (${icnsData.length} bytes)`); + console.log(`[prepare-desktop-icons] wrote ${join(assetsDirectory, "icon.png")} (1024×1024 master)`); + return { icnsPath, pngPath: join(assetsDirectory, "icon.png") }; + } finally { + await rm(workRoot, { recursive: true, force: true }); + } +}; + +if (import.meta.main) { + try { + await prepareDesktopIcons(); + } catch (error) { + console.error(`[prepare-desktop-icons] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 6932177c..6b77ec36 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -40,6 +40,7 @@ export const RELEASE_VALIDATIONS = [ "scripts/release.test.mjs", "scripts/validate-store-delivery.test.mjs", "scripts/store-delivery.test.mjs", + "scripts/desktop-icns.test.mjs", "apps/web/src/lib/version-check.test.mjs", "apps/mobile/src/lib/mobile-release.test.ts", ], diff --git a/scripts/verify-desktop-package.mjs b/scripts/verify-desktop-package.mjs index 7bd5fc05..96aee5f1 100644 --- a/scripts/verify-desktop-package.mjs +++ b/scripts/verify-desktop-package.mjs @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; import { basename, join } from "node:path"; import { listPackage } from "@electron/asar"; +import { assertMacIcnsComplete } from "./desktop-icns.mjs"; const outputDirectory = join(process.cwd(), "release", "desktop"); const version = JSON.parse( @@ -58,6 +59,11 @@ if (requestedPlatform === "darwin") { const asarFiles = new Set(listPackage(asarPath)); assert.ok(asarFiles.has("/src/preload/index.cjs"), "macOS app bundle must contain the sandbox-compatible CommonJS preload"); assert.ok(!asarFiles.has("/src/preload/index.mjs"), "macOS app bundle must not contain the unsupported ESM preload"); + const appIconPath = join(appResources, "icon.icns"); + assert.ok(existsSync(appIconPath), `macOS app bundle is missing icon.icns: ${appIconPath}`); + assertMacIcnsComplete(readFileSync(appIconPath), appIconPath); + const infoPlist = readFileSync(join(unpackedApp, "Contents", "Info.plist"), "utf8"); + assert.match(infoPlist, /CFBundleIconFile/, "macOS Info.plist must declare CFBundleIconFile"); // LaunchServices registers unpacked build products as additional document // handlers. The signed DMG/ZIP are the release artifacts, so discard the // disposable unpacked bundle once its contents have passed verification.