From de0dd4e5580f195e1dff62ab95851dc8b9b3e7f6 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 11 Aug 2026 18:43:31 +0700 Subject: [PATCH] fix(desktop): make macOS universal and x64 builds actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- DIVERGENCES.md | 1 + bun.lock | 2 +- packages/desktop/electron-builder.config.ts | 10 ++++++++++ packages/desktop/electron.vite.config.ts | 19 +++++++++---------- packages/desktop/package.json | 2 +- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 623dba1a2b..d7e835a2dc 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -181,6 +181,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/assets/help/` (introducing-tabs.mp4, placeholder.png, home.png, tabs.png — whole directory) | Deleted | The 13 MB promo video's and the v1.18 home/tabs screenshots' only consumer was the removed `TabsInfoPopup` (row above) and they bloat every clone; `placeholder.png` is referenced by nothing. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); delete new upstream assets under this directory whose only consumer is the removed popup. | | `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev Code"; "OpenCode Documentation" item → "CoDev Code Documentation" (href `opencode.ai/docs` stays — real URL); trigger `aria-label` "OpenCode menu" → "CoDev Code menu" (×2) | Visible menu branding, incl. screen-reader labels. | Keep our labels; take upstream's surrounding menu changes. Rebrand any new "OpenCode" label/aria-label. | | Hardcoded product-string rebrand in TUI + web app: `packages/tui/src/app.tsx`, `packages/tui/src/routes/session/permission.tsx`, `packages/tui/src/attention.ts` (+ `packages/opencode/test/cli/cmd/tui/attention.test.ts`), `packages/app/src/wsl/settings-model.ts` (+ `.test.ts`), `packages/ui/src/components/favicon.tsx` | User-visible "OpenCode" strings outside the i18n dicts → fork brand: update-complete toast "Successfully updated to OpenCode v…" → "CoDev Code"; permission dialog "…until OpenCode is restarted" → "CoDev Code" (the sibling patterns line was already rebranded); OS-notification fallback title `opencode` → `codev` and built-in sound-pack display name "OpenCode Default" → "CoDev Code Default" (pack id `opencode.default` stays — internal identifier; the three default-title expectations in `packages/opencode/test/cli/cmd/tui/attention.test.ts` follow, while its explicit-title pass-through fixture stays `opencode`); WSL settings actions "Install/Update OpenCode" → "…CoDev Code" (+ test expectations/titles; fixture paths `/usr/local/bin/opencode` stay); PWA `apple-mobile-web-app-title` "OpenCode" → "CoDev Code" (matches the rebranded `site.webmanifest`, favicon-assets row) | These render in surfaces the fork ships (TUI + embedded web UI); the i18n-dict strings remain a known gap (UI branding bullet below). | Keep our strings; take upstream's surrounding changes. Rebrand any new hardcoded user-visible "OpenCode" string in these surfaces; leave identifiers, URLs, and fixture paths. | +| **Universal macOS builds**: `packages/desktop/electron.vite.config.ts`, `packages/desktop/electron-builder.config.ts`, `packages/desktop/package.json` | Three changes that together make `electron-builder --mac --universal` (and `--x64`) produce a working app. (1) The `codev:node-pty-narrower` plugin and its `nodePtyPkg` constant are gone; the main build externalises `"@lydell/node-pty"` itself. (2) `@lydell/node-pty` moves from `devDependencies` to `dependencies`, so the wrapper is actually packed. (3) `mac.x64ArchFiles` is set to `**/app.asar.unpacked/node_modules/**`. | (1) Upstream rewrote the import to `@lydell/node-pty-${process.platform}-${process.arch}` — the **build host's** arch, resolved once, while `electron-vite build` runs before `electron-builder` and emits one bundle for whatever is packaged after it. On Apple Silicon that baked `darwin-arm64` in, so an `--x64` or `--universal` build shipped an Electron shell that starts and then cannot load its terminal backend. The wrapper's own entry does the same platform/arch lookup at run time, so externalising it picks the right binary on the machine that runs the app. (2) The wrapper was a devDependency and therefore never shipped: the old narrowing worked only because it pointed at the *platform* packages, which are `optionalDependencies`. Verified against a packaged build — `asar list` showed `@lydell/node-pty-darwin-*` present and `@lydell/node-pty/index.js` absent — so `require("@lydell/node-pty")` could not resolve. The bundled server (`packages/opencode/dist/node/node.js`) already required the bare wrapper, so this was a live bug in packaged builds before the vite change, not one it introduced. (3) All six platform packages ship in both the x64 and arm64 halves, so eight Mach-O files (node-pty's `pty.node` and its extension-less `spawn-helper`, `@parcel/watcher`, `msgpackr-extract`) are byte-identical across them; `@electron/universal` reads that as a mistake and refuses to merge. They are prebuilt binaries chosen at run time, not per-arch build output. | Keep all three. On conflict take upstream's files and re-apply. Do not reintroduce host-arch narrowing to silence a bundler warning, and do not move `@lydell/node-pty` back to `devDependencies` — the two are load-bearing together. Verify a packaged build rather than trusting the build log: `grep -o "@lydell/node-pty[a-z0-9-]*" out/main/index.js` must print the bare wrapper with no platform suffix, `asar list` must contain `/node_modules/@lydell/node-pty/index.js`, and the app must load its `pty.node` from inside the bundle when run from **outside** this repo — inside it, Node's module walk-up finds the repo's `node_modules` and masks a missing wrapper. | | Desktop app text + identifier rebrand: `packages/desktop/**` (README, `package.json`, `src/renderer/index.html`, `src/renderer/i18n/{en,es}.ts`, `src/main/{index,windows,server,logging}.ts`, `src/main/wsl/{sidecar,startup,servers,servers.test}.ts`, `src/main/install-state.test.ts`, `scripts/copy-metainfo.ts`, `electron-builder.config.ts`) plus the WSL/deep-link surfaces it shares with `packages/app` (`src/wsl/{types,settings-model,settings-model.test,settings,dialog-add-server}.ts(x)`, `src/i18n/{en,es}.ts`, `src/pages/layout/{deep-links,helpers.test}.ts`, `src/app.tsx`, `src/index.ts`) and `packages/desktop/src/{preload,renderer}` | Every user-visible "OpenCode" the desktop app renders → "CoDev Code": crash dialogs, window ``, updater/CLI-installed i18n (en+es, with the CLI-command hint `'opencode'`→`'codev'`), the unpackaged app-name fallback `"OpenCode Dev"`, the AppStream metainfo (product name, description, `developer` → `<name>CoDev</name>` with the upstream `id="ly.anoma"` dropped, the three URLs repointed to the fork repo, upstream's lander screenshot removed), the OS deep-link handler display name, README, and `package.json` (upstream `homepage`/`author` removed — `packages/opencode` and `packages/app` declare neither). Codebase identifiers renamed `Opencode`→`Codev`/`opencode`→`codev` (`WslOpencodeCheck`, `resolveWslOpencode`, `installWslOpencode`, `expectOpencodeVersion`, `releaseOpencodeResolve`, `checkOpencode`/`refreshOpencodeCheck*`/`setOpencodeCheck`, `opencodeChecks`, `wslOpencodeAction`, …) together with the internal wire values whose producer and consumer both ship here (`virtual:opencode-server`, the `opencode:{node-pty,virtual-server,copy-server}` vite plugin names, the `wsl-servers-install-opencode` IPC channel, the `install-opencode` job kind, the `__OPENCODE__` deep-link bridge global, the `opencode:deep-link` renderer event, the `opencode-onboarding-` temp dir, the `wsl.onboarding.installOpencode*` i18n keys), and the server-log collector path `<xdgData>/opencode/log` → `/codev/log` (a real bug fix — the fork CLI writes to `<xdgData>/codev/log`, `packages/core/src/global.ts` `app = "codev"`, so debug-log export was silently collecting nothing). Two stale product-named locals renamed to what they hold (`opencode`→`binary` in wsl/sidecar.ts, `opencode`→`checked` in the servers test). | The fork ships the desktop app, so every surface it shows and every identifier a maintainer reads should say codev. | Keep our strings + identifiers; take upstream's surrounding changes. PRESERVED deliberately, do not rename: `@opencode-ai/*` package ids, the `ai.opencode.desktop*` appIds and `opencode://` scheme + `schemes:["opencode"]` (identity plumbing), the `packageName:"opencode"` Linux package names and `opencode-desktop.desktop` legacy launcher, the persisted on-disk store names (`opencode.settings`, `opencode.{draft,workspace,global,window,updater}.*`, the `opencode` data dir in install-state), the basic-auth username `"opencode"` (must match the server default in `packages/server/src/auth.ts`), and the `../opencode/dist` build paths. | | `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/pages/layout/deep-links.ts` | App-self-reference "OpenCode" → "CoDev Code" across the shared web UI's i18n dicts (28 strings per locale): settings row descriptions, the update toasts, the WSL onboarding step/status strings, the `dialog.server.description`, `error.chain.mcpFailed`, `sidebar.gettingStarted.line1`, the provider-connect "use {{provider}} models in OpenCode" lines, and `app.name.desktop`. The Zen free-models dialog title (`dialog.model.unpaid.freeModels.title`, "Free models provided by OpenCode") was **neutralized** to "Free models included" / "Modelos gratuitos incluidos" rather than rebranded or kept — it read oddly branded either way. i18n **keys** are untouched (they carry `Opencode`/lowercase `opencode`, which never match the exact-case `OpenCode` in values). The deep-link bridge window type `OpenCodeWindow` → `CodevWindow` (its `__CODEV__` global was already renamed in the desktop row). | These render in the desktop app (and the web console), which the fork ships; closes the "in-app i18n strings" gap for this package. | Keep our strings; take upstream's surrounding changes. PRESERVED deliberately: the real external-service names **"OpenCode Zen"** / **"OpenCode Go"** (in the `opencodeZen.*` provider-connect keys and the "OpenCode Go" upsell), plus every `opencode.ai/*` URL — the fork routes to those actual services, so the display text must match what users land on. The built-in **"OpenCode" theme** name (`packages/ui/src/theme/context.tsx`, the shiki theme id, `desktop-theme.schema.json`) is also kept — it names a specific palette, and the fork ships its own `codev` theme alongside it. | | Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev Code team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | diff --git a/bun.lock b/bun.lock index cd72ccdbe1..fedcca0f72 100644 --- a/bun.lock +++ b/bun.lock @@ -398,6 +398,7 @@ "name": "@opencode-ai/desktop", "version": "1.18.4-8", "dependencies": { + "@lydell/node-pty": "catalog:", "@zip.js/zip.js": "2.7.62", "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -409,7 +410,6 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", - "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", "@sentry/solid": "catalog:", diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 198285c402..e54f19c91f 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -63,6 +63,16 @@ const getBase = (appId: string): Configuration => ({ mac: { category: "public.app-category.developer-tools", icon: `resources/icons/icon.icns`, + // Everything under the unpacked node_modules is a prebuilt binary shipped + // for all platforms — node-pty (including its extension-less spawn-helper), + // @parcel/watcher, msgpackr-extract — not a per-arch build output, so the + // x64 and arm64 packages hold byte-identical copies. @electron/universal + // reads a Mach-O file that is the same in both as a mistake and refuses to + // merge unless told otherwise, which blocked `--universal` outright. Each + // package picks its own binary from process.platform/process.arch at run + // time. Scoped to the directory rather than to `*.node`: eight Mach-O files + // trip the check and two of them have no extension. + x64ArchFiles: "**/app.asar.unpacked/node_modules/**", hardenedRuntime: true, gatekeeperAssess: false, entitlements: "resources/entitlements.plist", diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index c3e92e1194..3f785a8096 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -12,8 +12,6 @@ const channel = (() => { return "dev" })() -const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` - const sentry = process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT ? sentryVitePlugin({ @@ -51,16 +49,17 @@ const require = __cjs_mod__.createRequire(import.meta.url); `, }, }, - externalizeDeps: { include: [nodePtyPkg] }, + // `@lydell/node-pty`'s entry picks its platform package from + // `process.arch` at run time, so the import is left alone and simply + // externalised. Rewriting it to a single platform package here resolved + // against the *build host*, which baked darwin-arm64 into the bundle: + // an x64 or universal build then shipped an Electron shell that could + // not load its terminal backend. Every platform package is an + // optionalDependency, so they all ship and the right one is picked on + // the machine that runs the app. + externalizeDeps: { include: ["@lydell/node-pty"] }, }, plugins: [ - { - name: "codev:node-pty-narrower", - enforce: "pre", - resolveId(s) { - if (s === "@lydell/node-pty") return nodePtyPkg - }, - }, { name: "codev:virtual-server-module", enforce: "pre", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5cef1ca8b9..a61cfa6090 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -19,6 +19,7 @@ }, "main": "./out/main/index.js", "dependencies": { + "@lydell/node-pty": "catalog:", "@zip.js/zip.js": "2.7.62", "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -30,7 +31,6 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", - "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", "@sentry/solid": "catalog:",