mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 15:03:43 +08:00
fix: rename desktop app to OpenHands Agent Canvas (#16353)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 `<arg>/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` → `<productName>.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 `<productName>.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<ver>-<platform>-<arch>` tarball from `https://nodejs.org/dist/v<ver>/` 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 → `<Resources>/node/`. `electron/main.mjs::injectBundledNode()` prepends the platform-appropriate bin dir to `PATH` (POSIX: `<Resources>/node/bin`; Windows: `<Resources>/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.
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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-<version>-<arch>.dmg"; GitHub release assets
|
||||
// mangle spaces, so keep the asset name literal (matches the nsis
|
||||
// Default is "OpenHands Agent Canvas-<version>-<arch>.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 <version>.exe";
|
||||
// The default artifact name is "OpenHands Agent Canvas Setup <version>.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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Canvas</title>
|
||||
<title>OpenHands Agent Canvas</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<img class="logo" src="build-resources/icon.png" alt="" draggable="false" />
|
||||
<h1>Agent Canvas</h1>
|
||||
<h1>OpenHands Agent Canvas</h1>
|
||||
<p class="tagline">AI coding agent interface</p>
|
||||
<svg class="spinner" viewBox="0 0 66 66" fill="none" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
+13
-5
@@ -17,13 +17,19 @@
|
||||
* Contents/Resources/bin/ ← process.resourcesPath/bin
|
||||
* uv uvx ← bundled via extraResources
|
||||
*
|
||||
* Dev (npm run desktop → electron electron/main.mjs):
|
||||
* Dev (npm run desktop → electron electron):
|
||||
* electron/main.mjs ← __dirname = <repo>/electron/
|
||||
* scripts/ config/ build/ ← one level up: <repo>/
|
||||
* system uvx from PATH
|
||||
*
|
||||
* When packaged, scripts/config/build are siblings of main.mjs so
|
||||
* projectRoot === __dirname. In dev they are one level up.
|
||||
*
|
||||
* The dev command points electron at the electron/ DIRECTORY, not at
|
||||
* main.mjs directly. Electron's default_app only reads name/productName/
|
||||
* version out of <arg>/package.json, so passing the file makes it look for
|
||||
* electron/main.mjs/package.json, miss, and leave app.name at the host
|
||||
* bundle's default — "Electron" in the menu bar and userData path.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -47,8 +53,10 @@ const __dirname = dirname(__filename);
|
||||
// ── Path resolution ───────────────────────────────────────────────────────────
|
||||
// Packaged (directories.app: 'electron'): scripts/config/build are SIBLINGS of
|
||||
// main.mjs inside Resources/app/, so projectRoot === __dirname.
|
||||
// Dev (electron electron/main.mjs): those directories are one level UP in the
|
||||
// Dev (electron electron): those directories are one level UP in the
|
||||
// repo root, so projectRoot === join(__dirname, '..').
|
||||
// Both branches key off __dirname (always <repo>/electron in dev), not
|
||||
// app.getAppPath(), so the entry-point form doesn't affect them.
|
||||
|
||||
const projectRoot = app.isPackaged ? __dirname : join(__dirname, "..");
|
||||
const buildDir = join(projectRoot, "build");
|
||||
@@ -597,7 +605,7 @@ async function startStack() {
|
||||
// onServiceLog: stream uvx/agent-server output to the loading window so
|
||||
// the user sees progress instead of an indefinite spinner.
|
||||
const result = await main({
|
||||
bannerTitle: "Agent Canvas",
|
||||
bannerTitle: "OpenHands Agent Canvas",
|
||||
staticMode: true,
|
||||
staticDir: buildDir,
|
||||
mode: "agent-canvas",
|
||||
@@ -641,7 +649,7 @@ app.whenReady().then(async () => {
|
||||
dialog.showErrorBox(
|
||||
"Missing prerequisite: uv",
|
||||
app.isPackaged
|
||||
? "The bundled uv binary could not be found. Please reinstall Agent Canvas."
|
||||
? "The bundled uv binary could not be found. Please reinstall OpenHands Agent Canvas."
|
||||
: "uv (uvx) is not installed.\n\nInstall it from https://docs.astral.sh/uv/ then restart.",
|
||||
);
|
||||
app.quit();
|
||||
@@ -684,7 +692,7 @@ app.whenReady().then(async () => {
|
||||
const errorTail = recentServiceErrors.length
|
||||
? `\n\nRecent service errors:\n${recentServiceErrors.join("\n")}`
|
||||
: "";
|
||||
dialog.showErrorBox("Agent Canvas failed to start", summary + errorTail);
|
||||
dialog.showErrorBox("OpenHands Agent Canvas failed to start", summary + errorTail);
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "agent-canvas",
|
||||
"productName": "OpenHands Agent Canvas",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "main.mjs"
|
||||
|
||||
+2
-1
@@ -104,7 +104,8 @@
|
||||
"build:lib": "npm run make-i18n && react-router typegen && cross-env BUILD_LIB=true vite build && tsc -p tsconfig.lib.json",
|
||||
"build:docker": "node scripts/docker-build.mjs",
|
||||
"example:acp-docker:env": "node scripts/gen-acp-docker-env.mjs",
|
||||
"desktop": "npm run build:app && electron electron/main.mjs",
|
||||
"predesktop": "node scripts/brand-dev-electron.mjs",
|
||||
"desktop": "npm run build:app && electron electron",
|
||||
"build:desktop": "npm run build:app && node scripts/download-uv.mjs && node scripts/download-node.mjs && electron-builder --config electron-builder.config.mjs --publish never",
|
||||
"build:desktop:universal": "npm run build:app && node scripts/download-uv.mjs && node scripts/download-node.mjs && cross-env ELECTRON_ARCH=universal electron-builder --config electron-builder.config.mjs --publish never",
|
||||
"download-uv": "node scripts/download-uv.mjs",
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Brand the dev Electron app bundle with the product name (macOS only).
|
||||
*
|
||||
* Run automatically as the `predesktop` npm hook.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
*
|
||||
* `npm run desktop` runs the app inside Electron's own prebuilt bundle,
|
||||
* node_modules/electron/dist/Electron.app. macOS derives the name it shows
|
||||
* in the Dock tooltip, the ⌘-Tab switcher and Finder from that bundle, at
|
||||
* launch, before any JavaScript runs. No runtime API can change it —
|
||||
* `app.setName()`, `app.name` and package.json `productName` only drive
|
||||
* Electron's own notion of the name (menu bar, About panel, and
|
||||
* app.getPath("userData")).
|
||||
*
|
||||
* The packaged app has never had this problem: electron-builder emits a
|
||||
* bundle literally named "<productName>.app" with matching plist keys. This
|
||||
* script puts the dev bundle in that same state.
|
||||
*
|
||||
* THE BUNDLE FILENAME IS THE PART THAT ACTUALLY SHOWS
|
||||
*
|
||||
* Patching the plist alone is NOT enough — verified the hard way. macOS
|
||||
* prefers the bundle's filesystem name over CFBundleName/CFBundleDisplayName
|
||||
* for the Dock tooltip. Two installed apps prove each half of this:
|
||||
*
|
||||
* DBeaver.app CFBundleName "DBeaver Community" → displays "DBeaver"
|
||||
* (the filename wins over the plist)
|
||||
* Antigravity.app CFBundleExecutable "Electron" → displays "Antigravity"
|
||||
* (an Electron app whose executable name is irrelevant)
|
||||
*
|
||||
* So the fix aligns every source of the name at once: the .app directory
|
||||
* name, CFBundleName and CFBundleDisplayName. That is exactly the shape of a
|
||||
* packaged build, which is known to display correctly.
|
||||
*
|
||||
* Renaming the bundle means node_modules/electron/path.txt has to move with
|
||||
* it: getElectronPath() in node_modules/electron/index.js joins path.txt onto
|
||||
* dist/ and silently re-downloads Electron (~100 MB) if the result does not
|
||||
* exist. The two are updated together, and the rename is rolled back if
|
||||
* path.txt cannot be written.
|
||||
*
|
||||
* WHY THIS IS SAFE
|
||||
*
|
||||
* - Electron's prebuilt dist is ad-hoc *linker-signed*: `codesign -dv`
|
||||
* reports `flags=0x20002(adhoc,linker-signed)`, `Info.plist=not bound`,
|
||||
* `Sealed Resources=none`. The signature covers only the Mach-O, so
|
||||
* editing Info.plist does not invalidate it and no re-signing is needed.
|
||||
* - `npm run build:desktop` is unaffected: electron-builder packages from
|
||||
* its own download cache (~/Library/Caches/electron/electron-v*.zip),
|
||||
* never from node_modules/electron/dist.
|
||||
*
|
||||
* WHAT IT DELIBERATELY DOES NOT TOUCH
|
||||
*
|
||||
* CFBundleExecutable — left as "Electron". Antigravity above shows it has
|
||||
* no bearing on the displayed name; it only feeds ps / Activity Monitor.
|
||||
* Leaving it alone keeps path.txt's trailing segments valid.
|
||||
* CFBundleIdentifier — kept at com.github.Electron. Changing it would split
|
||||
* LaunchServices / TCC state per checkout for no visible gain.
|
||||
*
|
||||
* The changes live in node_modules, which `npm ci` wipes. That is fine: the
|
||||
* `predesktop` hook re-applies them on every `npm run desktop`.
|
||||
*
|
||||
* This script must never block the desktop run — every failure path warns
|
||||
* and exits 0.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = join(__dirname, "..");
|
||||
|
||||
// Both keys are set. CFBundleDisplayName is the one LaunchServices reports;
|
||||
// CFBundleName is the fallback and shows up in other bundle-name surfaces.
|
||||
const NAME_KEYS = ["CFBundleDisplayName", "CFBundleName"];
|
||||
|
||||
function warn(message) {
|
||||
console.warn(`[brand-dev-electron] ${message}`);
|
||||
}
|
||||
|
||||
/** Root of the installed `electron` package, or null if it isn't resolvable. */
|
||||
function resolveElectronPackage() {
|
||||
const require = createRequire(import.meta.url);
|
||||
return dirname(require.resolve("electron/package.json"));
|
||||
}
|
||||
|
||||
/** Current value of `key`, or null when the key is absent. */
|
||||
function readPlistString(plistPath, key) {
|
||||
try {
|
||||
return execFileSync(
|
||||
"plutil",
|
||||
["-extract", key, "raw", "-o", "-", plistPath],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePlistString(plistPath, key, value) {
|
||||
execFileSync("plutil", ["-replace", key, "-string", value, plistPath], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename dist/<current>.app to dist/<productName>.app and repoint path.txt.
|
||||
* Returns { appDir, renamed }, or null if the bundle can't be determined.
|
||||
*/
|
||||
function ensureBundleName(pkgDir, productName) {
|
||||
const distDir = join(pkgDir, "dist");
|
||||
const pathFile = join(pkgDir, "path.txt");
|
||||
if (!existsSync(pathFile)) {
|
||||
warn(`No ${pathFile} — leaving the dev bundle alone.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// e.g. "Electron.app/Contents/MacOS/Electron" — only the first segment
|
||||
// (the bundle directory) is ours to rename.
|
||||
const relative = readFileSync(pathFile, "utf8").trim();
|
||||
const segments = relative.split("/");
|
||||
const currentName = segments[0];
|
||||
const desiredName = `${productName}.app`;
|
||||
if (!currentName.endsWith(".app")) {
|
||||
warn(`Unexpected path.txt entry "${relative}" — leaving the bundle alone.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const desiredDir = join(distDir, desiredName);
|
||||
if (currentName === desiredName && existsSync(desiredDir)) {
|
||||
return { appDir: desiredDir, renamed: false };
|
||||
}
|
||||
|
||||
const currentDir = join(distDir, currentName);
|
||||
let renamed = false;
|
||||
if (existsSync(currentDir) && currentDir !== desiredDir) {
|
||||
if (existsSync(desiredDir)) {
|
||||
// Both present: a previous run renamed the bundle and something
|
||||
// restored the original. Prefer the correctly named one and just fix
|
||||
// path.txt rather than clobbering either bundle.
|
||||
warn(`Both ${currentName} and ${desiredName} exist — using the latter.`);
|
||||
} else {
|
||||
renameSync(currentDir, desiredDir);
|
||||
renamed = true;
|
||||
}
|
||||
} else if (!existsSync(desiredDir)) {
|
||||
warn(`No Electron bundle under ${distDir} — leaving the dev bundle alone.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// path.txt MUST agree with the directory on disk; a stale entry makes
|
||||
// getElectronPath() re-download Electron on the next run.
|
||||
segments[0] = desiredName;
|
||||
try {
|
||||
writeFileSync(pathFile, segments.join("/"));
|
||||
} catch (err) {
|
||||
// Undo the rename so the checkout is left in a working state.
|
||||
if (existsSync(desiredDir) && !existsSync(currentDir)) {
|
||||
try {
|
||||
renameSync(desiredDir, currentDir);
|
||||
} catch {
|
||||
warn(`Could not roll back the bundle rename in ${distDir}.`);
|
||||
}
|
||||
}
|
||||
warn(`Could not update ${pathFile}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { appDir: desiredDir, renamed };
|
||||
}
|
||||
|
||||
function main() {
|
||||
// The Dock/⌘-Tab name is a macOS bundle concept. On Windows the dev
|
||||
// taskbar name comes from electron.exe's version resource and on Linux
|
||||
// from the .desktop file / WM_CLASS — neither exists until the app is
|
||||
// packaged, so there is nothing to patch.
|
||||
if (process.platform !== "darwin") return;
|
||||
|
||||
const manifestPath = join(projectRoot, "electron", "package.json");
|
||||
let productName;
|
||||
try {
|
||||
productName = JSON.parse(readFileSync(manifestPath, "utf8")).productName;
|
||||
} catch (err) {
|
||||
warn(`Could not read ${manifestPath}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (!productName) {
|
||||
warn(`No productName in ${manifestPath} — leaving the dev bundle alone.`);
|
||||
return;
|
||||
}
|
||||
// The name becomes a directory entry; a "/" would silently retarget it.
|
||||
if (productName.includes("/")) {
|
||||
warn(`productName "${productName}" cannot be used as a bundle name.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let pkgDir;
|
||||
try {
|
||||
pkgDir = resolveElectronPackage();
|
||||
} catch (err) {
|
||||
warn(`Could not resolve the electron package: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = ensureBundleName(pkgDir, productName);
|
||||
} catch (err) {
|
||||
warn(`Could not rename the dev bundle: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (!bundle) return;
|
||||
|
||||
const plistPath = join(bundle.appDir, "Contents", "Info.plist");
|
||||
if (!existsSync(plistPath)) {
|
||||
warn(`No Info.plist at ${plistPath} — leaving the plist alone.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const stale = NAME_KEYS.filter(
|
||||
(key) => readPlistString(plistPath, key) !== productName,
|
||||
);
|
||||
|
||||
try {
|
||||
for (const key of stale) writePlistString(plistPath, key, productName);
|
||||
} catch (err) {
|
||||
// Read-only node_modules (CI caches, sandboxes) lands here. The app still
|
||||
// runs; only the displayed name keeps saying "Electron".
|
||||
warn(`Could not patch ${plistPath}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stay quiet when there was nothing to do, so repeat runs don't add noise.
|
||||
if (bundle.renamed || stale.length > 0) {
|
||||
console.log(
|
||||
`[brand-dev-electron] Dev Electron bundle now identifies as "${productName}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user