diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml index c45bec1dff..55897c619e 100644 --- a/.github/workflows/desktop-macos.yml +++ b/.github/workflows/desktop-macos.yml @@ -9,7 +9,7 @@ name: Desktop (macOS) # The app is only ad-hoc signed (no signing certs exist for any platform); # a downloaded DMG is quarantined by Gatekeeper, which reports the app as # "damaged" until the attribute is cleared: -# xattr -d com.apple.quarantine "/Applications/Agent Canvas.app" +# xattr -d com.apple.quarantine "/Applications/OpenHands Agent Canvas.app" # Intel DMGs are not produced — the bundled uv/node runtimes are host-arch # only, so Intel users build from source. on: diff --git a/.github/workflows/release-ready.yml b/.github/workflows/release-ready.yml index 20cce0926b..d78222d4fd 100644 --- a/.github/workflows/release-ready.yml +++ b/.github/workflows/release-ready.yml @@ -14,5 +14,5 @@ jobs: secrets: inherit uses: OpenHands/release-actions/.github/workflows/release-ready.yml@main with: - product-name: Agent Canvas + product-name: OpenHands Agent Canvas slack-channel: '#proj-agent-canvas' diff --git a/AGENTS.md b/AGENTS.md index 44e484fc03..1cdc0c8bcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -617,6 +617,8 @@ When adding code that needs a new string, decide up front which rule it falls un - Electron desktop packaging — `electron-builder.config.mjs` uses `directories.app: "electron"` so electron/package.json is the app manifest. Even though electron/package.json has zero `dependencies`, app-builder-lib's `collectNodeModulesWithLogging` walks UP from the app dir looking for the first npm workspace that resolves modules. The next dir in line is the project root, where `npm list --json` reports the full hoisted tree (~342 dirs, ~600 MB of Vite/React/Monaco/HeroUI), and electron-builder copies all of it into `Resources/app/node_modules/`. The walk is hardcoded in `app-builder-lib/out/util/appFileCopier.js::collectNodeModulesWithLogging` — there is no config knob to disable it. Creating an empty `electron/node_modules/` does NOT help because the collector falls through to project root when it sees zero deps. **The fix is the `afterPack` hook** (`stripBundledNodeModules` in `electron-builder.config.mjs`): after electron-builder copies everything, the hook `rm -rf`s `Resources/app/node_modules/` (handling macOS `.app` bundle layout and Linux/Windows flat resources/ layout), then copies back the dependency closure of `RUNTIME_PACKAGES` (`sirv` for static-server.mjs, `httpxy` for proxy-utils.mjs/ingress.mjs — ~200 KB total). Effect: `resources/app/` drops from ~598 MB to ~7 MB; total `linux-unpacked/` from ~1 GB to ~365 MB (the rest is Electron + Chromium + the bundled `uv` binary). If a spawned backend script gains a new bare npm import, add the package to `RUNTIME_PACKAGES` — otherwise that service crashes with `ERR_MODULE_NOT_FOUND` only in the installed app. **Testing trap:** an app launched from `dist-electron/` inside the repo resolves bare specifiers against the repo's own `node_modules` (Node ESM resolution walks up from the script file), so a missing runtime package is invisible there — verify packaged builds from a copy outside the repo tree (e.g. `/Applications`). Don't add real deps to electron/package.json — any real dep would survive the strip and would also have to be hand-installed inside electron/ since the project root is npm-hoisted. `build:desktop:universal` and `--linux/--win/--mac` variants all run the same hook. +- Electron desktop app name in dev (macOS) — `npm run desktop` shows the app as "Electron" in the Dock unless `scripts/brand-dev-electron.mjs` (wired as the `predesktop` hook) has run. There are **three independent name sources** and they must all be set; getting one wrong looks like the fix silently not working. (1) `app.name` — Electron-internal, drives the menu bar, About panel and `app.getPath("userData")`. It comes from `productName` in `electron/package.json`, read by Electron's `default_app` in dev and `lib/browser/init` when packaged. Note `default_app` only reads `/package.json`, so `npm run desktop` must point electron at the `electron/` **directory** — `electron electron/main.mjs` makes it probe `electron/main.mjs/package.json`, miss, and leave `app.name` at the host bundle default. (2) `CFBundleDisplayName` / `CFBundleName` in the running bundle's Info.plist — what `lsappinfo` and `NSRunningApplication.localizedName` report. (3) **The `.app` directory name — this is what the Dock tooltip actually shows.** macOS prefers the bundle's filesystem name over the plist keys; `/Applications/DBeaver.app` displays as "DBeaver" despite `CFBundleName = "DBeaver Community"`. So patching only the plist is NOT enough — the script also renames `node_modules/electron/dist/Electron.app` → `.app` and rewrites `node_modules/electron/path.txt` to match (`getElectronPath()` in `node_modules/electron/index.js` joins path.txt onto `dist/` and silently re-downloads Electron ~100 MB if it doesn't resolve, so the two must move together). `CFBundleExecutable` is deliberately left as `Electron` — `/Applications/Antigravity.app` ships that exact value and still displays correctly, so it only affects `ps`/Activity Monitor. Editing the plist does not break code signing: Electron's dist is ad-hoc *linker-signed* (`Info.plist=not bound`, `Sealed Resources=none`), so the signature covers only the Mach-O. `npm run build:desktop` is unaffected by the rename — electron-builder packages from `~/Library/Caches/electron/electron-v*.zip`, never from `node_modules/electron/dist`. The packaged app never had the problem: electron-builder emits `.app` with matching plist keys. Already-running instances keep the name they launched with, so quit and relaunch when verifying. + - Electron desktop `node` / `npm` / `npx` PATH bridging — when the packaged `.app` is launched from Finder/Spotlight on macOS, the OS gives it a minimal PATH (`/usr/bin:/bin`). Homebrew, nvm, asdf installs of Node.js are invisible to spawned subprocesses. Two breakages flow from that: (1) backend launcher scripts that do `spawn("node", ...)` can't find Node; (2) most stdio MCP marketplace entries (Slack, GitHub, Figma, etc.) use `command: "npx"`, and when the agent-server tries to spawn them the missing `npx` makes the spawn fail with ENOENT — the SDK reports it as an `error_kind: "connection"` MCP test failure, which the install modal renders as `MCP$TEST_ERROR_CONNECTION` ("Could not reach the server. Check the URL and server type."), a misleading error since no URL is involved. **First fix attempt — DOES NOT WORK for stdio MCPs:** wrap `node`/`npm`/`npx` with thin shell scripts that run Electron with `ELECTRON_RUN_AS_NODE=1` against the package's CLI JS. That bridges the ENOENT but stdio JSON-RPC servers spawned through the wrapper exit with `McpError: Connection closed` before completing the MCP handshake — Electron-as-Node has subtly different stdin/stdout pipe semantics from a vanilla `node` binary when used as a stdio child of a windowed process. **Working fix:** bundle the real Node.js distribution. `scripts/download-node.mjs` downloads the official `node-v--` tarball from `https://nodejs.org/dist/v/` into `resources/node/` (gitignored), prunes `include/`, `share/`, docs, and `node_modules/corepack` to keep the size down (~130 MB on Linux x64, dominated by the Node binary itself). Default pin: `NODE_BUNDLE_VERSION = "22.12.0"` (the repo's `engines.node` floor; every 22.x build shares the Electron 42 ABI); override with `NODE_VERSION=`. `electron-builder.config.mjs` ships `resources/node/` as an extraResource → `/node/`. `electron/main.mjs::injectBundledNode()` prepends the platform-appropriate bin dir to `PATH` (POSIX: `/node/bin`; Windows: `/node/`) so subsequent spawns of `node`/`npm`/`npx` resolve to real binaries with full stdio fidelity. It also `chmod +x`'s the binaries on POSIX because electron-builder doesn't always preserve the bit. `injectBundledNode()` is a no-op when `!app.isPackaged` (dev `npm run desktop` uses the developer's system node). `build:desktop` and `build:desktop:universal` both run `download-node.mjs` after `download-uv.mjs`. If the bundled dir is missing at runtime, `injectBundledNode()` logs a loud `[desktop]` warning instead of silently leaving PATH bare. - Cloud conversation resume gating: when a cloud conversation is closed from the UI (`pauseCloudSandbox` is called), the conversation's `conversation_url` is NOT cleared -- it still points to the old sandbox host. `WebSocketProviderWrapper` must suppress the URL (pass `null` to `ConversationWebSocketProvider`) while `sandbox_status === "PAUSED"`, otherwise the WebSocket immediately tries the stale URL before the sandbox wakes. Symmetrically, `useActiveConversation`'s refetch interval must fast-poll (3 s) on both `!conversation_url` AND `sandbox_status === "PAUSED"` -- checking only the missing URL would leave the hook on the 30 s interval while the sandbox is resuming. The resume sequence: navigate -> sandbox PAUSED detected -> `resumeCloudSandbox` called (in `conversation.tsx`) -> fast-poll detects RUNNING -> `conversationUrl` unblocked -> WebSocket connects. diff --git a/__tests__/scripts/electron-builder-config.test.ts b/__tests__/scripts/electron-builder-config.test.ts index 53571f0e85..60b0f1ac98 100644 --- a/__tests__/scripts/electron-builder-config.test.ts +++ b/__tests__/scripts/electron-builder-config.test.ts @@ -7,7 +7,7 @@ // These tests therefore build a fake bundle under os.tmpdir() and verify // resolution with a real `node --eval` from that location. import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -15,7 +15,7 @@ import config from "../../electron-builder.config.mjs"; const afterPack = config.afterPack as (ctx: unknown) => Promise; -const PRODUCT_FILENAME = "Agent Canvas"; +const PRODUCT_FILENAME = "OpenHands Agent Canvas"; function makeContext(platform: string, appOutDir: string) { return { @@ -96,3 +96,23 @@ describe("electron-builder afterPack hook", () => { expect(existsSync(join(appDir, "node_modules", "httpxy", "package.json"))).toBe(true); }); }); + +// The app's display name is set in two places that must agree: +// electron-builder.config.mjs productName → CFBundleName / NSIS / .desktop +// name of the PACKAGED bundle (what the Dock, ⌘-Tab and Finder show). +// electron/package.json productName → app.name at runtime (macOS menu +// bar, About panel, app.getPath("userData")), read by Electron's +// default_app in dev and by lib/browser/init when packaged. +// Neither one implies the other — the packaged app shipped with +// CFBundleName "Agent Canvas" but app.name "agent-canvas" until both were +// set. Pin them together so they can't drift again. +describe("desktop app name", () => { + it("keeps electron/package.json productName in sync with the builder config", () => { + const appManifest = JSON.parse( + readFileSync(join(import.meta.dirname, "../../electron/package.json"), "utf8"), + ); + + expect(appManifest.productName).toBe(config.productName); + expect(config.productName).toBe(PRODUCT_FILENAME); + }); +}); diff --git a/electron-builder.config.mjs b/electron-builder.config.mjs index ed40b4ad05..4ca1a2d4d2 100644 --- a/electron-builder.config.mjs +++ b/electron-builder.config.mjs @@ -209,7 +209,7 @@ function getDirSizeBytes(dir) { /** @type {import('electron-builder').Configuration} */ const config = { appId: "dev.openhands.agent-canvas", - productName: "Agent Canvas", + productName: "OpenHands Agent Canvas", copyright: "Copyright © 2025 All Hands AI", // Stamp the packaged app with the released version (see rootPackageJson @@ -312,16 +312,16 @@ const config = { }, dmg: { - title: "Agent Canvas", + title: "OpenHands Agent Canvas", contents: [ { x: 130, y: 220 }, { x: 410, y: 220, type: "link", path: "/Applications" }, ], window: { width: 540, height: 380 }, - // Default is "Agent Canvas--.dmg"; GitHub release assets - // mangle spaces, so keep the asset name literal (matches the nsis + // Default is "OpenHands Agent Canvas--.dmg"; GitHub release + // assets mangle spaces, so keep the asset name literal (matches the nsis // convention). ${version}/${arch}/${ext} are electron-builder macros. - artifactName: "Agent-Canvas-${version}-${arch}.${ext}", + artifactName: "OpenHands-Agent-Canvas-${version}-${arch}.${ext}", }, // ── Windows ──────────────────────────────────────────────────────────────── @@ -338,10 +338,10 @@ const config = { allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, - // The default artifact name is "Agent Canvas Setup .exe"; + // The default artifact name is "OpenHands Agent Canvas Setup .exe"; // GitHub release assets mangle spaces, so ship a space-free name. // ${version}/${ext} are electron-builder macros, not JS interpolation. - artifactName: "Agent-Canvas-Setup-${version}.${ext}", + artifactName: "OpenHands-Agent-Canvas-Setup-${version}.${ext}", }, // ── Linux ────────────────────────────────────────────────────────────────── diff --git a/electron/loading.html b/electron/loading.html index 4e523c0f56..c879a26351 100644 --- a/electron/loading.html +++ b/electron/loading.html @@ -3,7 +3,7 @@ - Agent Canvas + OpenHands Agent Canvas