Skip to content

fix(desktop): make macOS universal and x64 builds actually work - #193

Merged
quickbeard merged 1 commit into
mainfrom
desktop-universal-build
Aug 11, 2026
Merged

quickbeard merged 1 commit into
mainfrom
desktop-universal-build

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

electron-builder --mac --universal did not produce a working app. Three separate problems, all found by building and then inspecting the packaged result.

1. The terminal backend was pinned to the build host's arch

electron.vite.config.ts rewrote @lydell/node-pty to @lydell/node-pty-${process.platform}-${process.arch}. That is the build host's arch, resolved once — and electron-vite build runs before electron-builder, emitting a single bundle for whatever is packaged afterwards. On Apple Silicon it baked darwin-arm64 in, so an --x64 or --universal build shipped an Electron shell that starts and then cannot load its terminal backend. The failure reads as an app bug, not a build one.

The wrapper's own entry already does the same process.platform/process.arch lookup at run time, so the import is externalised and left to dispatch.

2. …but the wrapper was never shipped

That change alone would have been worse than the bug. @lydell/node-pty was a devDependency, so electron-builder never packed it — the old narrowing worked only because it pointed at the platform packages, which are optionalDependencies.

$ asar list app.asar | grep lydell
/node_modules/@lydell/node-pty-darwin-arm64        ← present
/node_modules/@lydell/node-pty-darwin-x64          ← present
                                                   ← wrapper absent

So require("@lydell/node-pty") could not resolve. Worth noting the bundled server (packages/opencode/dist/node/node.js) already required the bare wrapper, so packaged builds were carrying this break before any of my changes. It moves to dependencies.

3. @electron/universal refused to merge

Both halves of a universal build carry all six platform packages, so eight Mach-O files end up byte-identical across x64 and arm64 — node-pty's pty.node and its extension-less spawn-helper, plus @parcel/watcher and msgpackr-extract. @electron/universal treats that as a mistake and aborts. They are prebuilt binaries chosen at run time, not per-arch build output, so mac.x64ArchFiles now covers the unpacked node_modules.

I enumerated all eight by diffing the two temp app trees rather than discovering them one failed build at a time — my first pattern only covered *.node and missed spawn-helper.

Verification

On a packaged universal app, copied outside the repo and launched:

main:      x86_64 arm64
framework: x86_64 arm64
helper:    x86_64 arm64
pty.node loaded from: /Contents/Resources/app.asar.unpacked/node_modules/
                      @lydell/node-pty-darwin-arm64/prebuilds/darwin-arm64/pty.node

Running it from inside the repo is not a valid test: Node's module walk-up finds codev-code/node_modules and masks a missing wrapper. That is exactly how problem 2 nearly shipped — the app looked fine until I moved it to /tmp.

Intel itself is unverified — I have no x64 hardware here. What is verified is that the x64 prebuild ships and that the selection is made at run time from process.arch.

bun typecheck clean; bun lint 4741 + 1 error against main's 4746 + 1.

Not included

No CHANGELOG entry: release.yml builds no desktop artifact, so no shipped build changes behaviour for a user today. Worth adding if desktop builds ever start shipping — at which point problem 2 would have made the terminal dead on arrival.

🤖 Generated with Claude Code

Three things had to change together before `--universal` produced a
working app.

The vite build rewrote `@lydell/node-pty` to
`@lydell/node-pty-${process.platform}-${process.arch}` — the build
host's arch. `electron-vite build` runs before `electron-builder` and
emits one bundle for whatever is packaged afterwards, so on Apple
Silicon that baked darwin-arm64 in and an x64 or universal build shipped
a shell that starts and then cannot load its terminal backend. The
wrapper does the same lookup itself at run time, so it is externalised
instead.

That alone would have been worse than the bug: the wrapper was a
devDependency and never shipped. `asar list` on a packaged build showed
the platform packages present and `@lydell/node-pty/index.js` absent, so
the require could not resolve. The bundled server already required the
bare wrapper, so packaged builds were carrying that break already. It
moves to dependencies.

Finally, both halves of a universal build carry all six platform
packages, leaving eight byte-identical Mach-O files — including
node-pty's extension-less spawn-helper — which @electron/universal
rejects. `mac.x64ArchFiles` covers the unpacked node_modules, where
every binary is a prebuilt chosen at run time.

Verified on a packaged universal app run from outside the repo: main,
framework and helper are all x86_64+arm64, both darwin prebuilds ship,
and pty.node loads from inside the bundle. Run from inside the repo,
Node's module walk-up finds the repo node_modules and hides a missing
wrapper — which is how the second bug nearly shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 9046d78 into main Aug 11, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the desktop-universal-build branch August 11, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant