diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 90952eba7..798c4580c 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -8,6 +8,20 @@ files: - "!electron.vite.config.*" - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" - "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}" + # Never repack the electron-builder output directory into the app. + # Multi-arch release builds (scripts/package.mjs) run each target in the + # same apps/desktop dir with per-arch output at dist/-. + # electron-builder only auto-excludes the *current* target's output dir, + # so the earlier arch's dist/ (its full .app + DMG + ZIP) would otherwise + # be pulled into the next arch's app.asar. That inflated v0.4.3/v0.4.4's + # Intel (x64) DMG until the HFS volume was full and the real + # `Electron Framework/Versions/A/Electron Framework` binary was dropped, + # so Intel Macs crashed on launch with a dyld missing-library error + # (github.com/multica-ai/multica/issues/5595). Excluding all of dist/** + # fixes it regardless of build order; a pre-build clean alone does not, + # because the first arch writes into dist/ mid-run before the second is + # packaged. + - "!dist/**" protocols: - name: Multica schemes: diff --git a/apps/desktop/scripts/package.mjs b/apps/desktop/scripts/package.mjs index d68dfd5a9..babd82380 100644 --- a/apps/desktop/scripts/package.mjs +++ b/apps/desktop/scripts/package.mjs @@ -27,6 +27,7 @@ // real `git describe` invocation against a throwaway repo. import { execFileSync, spawnSync } from "node:child_process"; +import { rmSync } from "node:fs"; import { delimiter, dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -365,6 +366,17 @@ function main() { `[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`, ); + // Step 0: start every release from an empty output directory. Stale + // artifacts from a prior run would otherwise be repacked into this run's + // app.asar (see the `!dist/**` note in electron-builder.yml). This clean + // is belt-and-braces only — it does NOT by itself prevent the same-run + // cross-arch contamination that broke the Intel DMG, because the first + // arch writes into dist/ mid-run before the next arch is packaged; the + // `!dist/**` files exclusion is what actually guarantees isolation. + const distDir = resolve(desktopRoot, "dist"); + rmSync(distDir, { recursive: true, force: true }); + console.log(`[package] cleaned output dir → ${distDir}`); + // Step 1: build the Electron main/preload/renderer bundles. Without // this step electron-builder silently packages whatever is already in // out/, which on a fresh checkout (or after a partial build) ships an diff --git a/apps/desktop/scripts/package.test.mjs b/apps/desktop/scripts/package.test.mjs index 3d3f5b564..5105c2895 100644 --- a/apps/desktop/scripts/package.test.mjs +++ b/apps/desktop/scripts/package.test.mjs @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { afterEach, describe, it, expect } from "vitest"; @@ -431,3 +431,49 @@ describe("envWithLocalBins", () => { ]); }); }); + +describe("electron-builder.yml packaging config", () => { + // Regression guard for github.com/multica-ai/multica/issues/5595. The + // multi-arch release build writes each target's output to + // dist/- in the same apps/desktop dir; electron-builder + // only auto-excludes the *current* target's output dir, so without an + // explicit `!dist/**` the earlier arch's dist/ was repacked into the next + // arch's app.asar. That inflated the Intel (x64) DMG until its Electron + // Framework binary was dropped and Intel Macs crashed on launch. Keep the + // exclusion pinned so a future edit to the files list cannot drop it + // unnoticed. + // Resolve electron-builder.yml relative to cwd, tolerating vitest running + // from either the desktop package dir or the repo root — import.meta.url is + // not a file:// URL under the test transform, so avoid fileURLToPath here. + const configPath = [ + resolve(process.cwd(), "electron-builder.yml"), + resolve(process.cwd(), "apps/desktop/electron-builder.yml"), + ].find((candidate) => existsSync(candidate)); + + // Extract the entries of the top-level `files:` block sequence without a + // YAML dependency: collect the ` - "…"` items that follow `files:` up to + // the next top-level key. Commented (`#`) lines are ignored, so a + // commented-out exclusion would (correctly) not count. + function readFilesBlock(raw) { + const lines = raw.split("\n"); + const start = lines.findIndex((l) => /^files:\s*$/.test(l)); + if (start === -1) return []; + const entries = []; + for (let i = start + 1; i < lines.length; i += 1) { + const line = lines[i]; + if (/^\S/.test(line)) break; // next top-level key ends the block + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + const m = trimmed.match(/^-\s*"?(.*?)"?\s*$/); + if (m) entries.push(m[1]); + } + return entries; + } + + it("excludes the dist output directory from the packaged files", () => { + expect(configPath, "electron-builder.yml not found").toBeTruthy(); + const entries = readFilesBlock(readFileSync(configPath, "utf-8")); + expect(entries.length).toBeGreaterThan(0); + expect(entries).toContain("!dist/**"); + }); +});