fix(desktop): ship complete macOS ICNS so Dock never blanks

Prebuild a full iconutil ICNS (including 1024) instead of converting a lone
512 PWA PNG, validate Dock-critical types at package verify time, and set the
Dock icon at runtime as a Launch Services cache fallback.
This commit is contained in:
tianma-if committed 2026-08-05 19:22:00 +08:00
1 parent 1fe3fdc428
commit 2d784297ff
13 files changed
+348 -3

No files matched your search

+17
View File
@@ -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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+9 -1
View File
@@ -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
+24
View File
@@ -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({
+3 -2
View File
@@ -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",
+116
View File
@@ -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;
};
+61
View File
@@ -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);
});
});
+2
View File
@@ -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",
]);
+2
View File
@@ -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({
+107
View File
@@ -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);
}
}
+1
View File
@@ -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",
],
+6
View File
@@ -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.