From 4e57c1dc9aee83cff61e4dce173307fe5331f720 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:00:36 -0300 Subject: [PATCH] fix(electron): derive macOS Helper name from execPath to remove 2nd Dock icon (#7941) (#8002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveNodeExecutable() built the macOS Helper path from app.getName() (package.json name = "omniroute-desktop"), but electron-builder names the Helper.app bundles from build.productName ("OmniRoute"). The two diverged, so the probe never matched a real Helper and fell through to process.execPath — spawning the main Electron binary via ELECTRON_RUN_AS_NODE, which macOS renders as a second, inert Dock icon. Extracted the resolution into electron/lib/resolveNodeHelper.js (pure, unit-testable), deriving the Helper name from path.basename(process.execPath) so it always tracks the productName-generated bundle. Registered the new module in electron build.files. Reported by Carlos Espinoza (Carloss616) with runtime instrumentation of a packaged .app pinpointing the exact field divergence. --- changelog.d/fixes/7941-macos-dock-icon.md | 1 + electron/lib/resolveNodeHelper.js | 46 +++++++++++++ electron/main.js | 28 +++----- electron/package.json | 1 + ...ctron-resolve-node-executable-7941.test.ts | 68 +++++++++++++++++++ 5 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 changelog.d/fixes/7941-macos-dock-icon.md create mode 100644 electron/lib/resolveNodeHelper.js create mode 100644 tests/unit/electron-resolve-node-executable-7941.test.ts diff --git a/changelog.d/fixes/7941-macos-dock-icon.md b/changelog.d/fixes/7941-macos-dock-icon.md new file mode 100644 index 0000000000..91c66d41a9 --- /dev/null +++ b/changelog.d/fixes/7941-macos-dock-icon.md @@ -0,0 +1 @@ +- fix(electron): resolve macOS Helper by binary name to remove second inert Dock icon (#7941) diff --git a/electron/lib/resolveNodeHelper.js b/electron/lib/resolveNodeHelper.js new file mode 100644 index 0000000000..dcb0136cff --- /dev/null +++ b/electron/lib/resolveNodeHelper.js @@ -0,0 +1,46 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); + +/** + * Resolve the macOS Helper binary used to spawn the Next.js standalone server via + * ELECTRON_RUN_AS_NODE, so macOS treats it as a background task with no Dock icon. + * + * #7941: the Helper name is derived from `path.basename(execPath)` — electron-builder + * generates BOTH the main binary and the Helper.app bundles from `build.productName` + * ("OmniRoute"). The previous code used `app.getName()`, which reads package.json + * `name` ("omniroute-desktop"); the two diverged, so it never matched a real Helper + * path and the caller fell through to `process.execPath`, spawning the main Electron + * binary and producing a second, inert Dock icon. + * + * @param {object} opts + * @param {string} opts.execPath Absolute path to the packaged Electron binary (process.execPath). + * @param {(p: string) => boolean} [opts.existsSync] Injectable fs.existsSync (for tests). + * @returns {string|null} The Helper binary path, or null when none exists (caller falls back to execPath). + */ +function resolveDarwinHelperExecutable({ execPath, existsSync = fs.existsSync } = {}) { + if (!execPath) return null; + const appName = path.basename(execPath); + + // Sibling Helper next to the main binary. + const siblingHelper = path.join(path.dirname(execPath), `${appName} Helper`); + if (existsSync(siblingHelper)) return siblingHelper; + + // Electron >= 20 ships Helper bundles under Contents/Frameworks. The unsuffixed + // Helper (not "(Renderer)"/"(GPU)"/"(Plugin)") is the one suitable for ELECTRON_RUN_AS_NODE. + const frameworkHelper = path.join( + path.dirname(execPath), + "..", + "Frameworks", + `${appName} Helper.app`, + "Contents", + "MacOS", + `${appName} Helper` + ); + if (existsSync(frameworkHelper)) return frameworkHelper; + + return null; +} + +module.exports = { resolveDarwinHelperExecutable }; diff --git a/electron/main.js b/electron/main.js index 5d37a91c22..a949bef103 100644 --- a/electron/main.js +++ b/electron/main.js @@ -36,6 +36,7 @@ const { hasEncryptedCredentials } = require("./sqlite-inspection"); const { loginManager } = require("./loginManager"); const { killProcessTree } = require("./processTree"); const { resolveServerEntry } = require("./lib/resolveServerEntry"); +const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -79,23 +80,16 @@ function resolveNodeExecutable(env = process.env) { // flash a shell window. Use the Helper binary instead — macOS treats // Helper processes as background tasks with no visible UI artifacts. if (process.platform === "darwin" && !isDev) { - const helperPath = path.join(path.dirname(process.execPath), `${app.getName()} Helper`); - if (fs.existsSync(helperPath)) { - return helperPath; - } - // Electron \u003e= 20 may use "(Renderer)" / "(GPU)" / "(Plugin)" suffixed helpers. - // The unsuffixed Helper is the one suitable for ELECTRON_RUN_AS_NODE. - const frameworkHelper = path.join( - path.dirname(process.execPath), - "..", - "Frameworks", - `${app.getName()} Helper.app`, - "Contents", - "MacOS", - `${app.getName()} Helper` - ); - if (fs.existsSync(frameworkHelper)) { - return frameworkHelper; + // #7941: derive the Helper name from the packaged binary name + // (path.basename(process.execPath)) rather than app.getName(). electron-builder + // generates BOTH the main binary and the Helper.app bundles from build.productName + // ("OmniRoute"), whereas app.getName() reads package.json `name` ("omniroute-desktop") + // — the two diverged, so app.getName() never matched a real Helper path and this fell + // through to process.execPath, spawning the main Electron binary and producing a + // second, inert macOS Dock icon. + const helper = resolveDarwinHelperExecutable({ execPath: process.execPath }); + if (helper) { + return helper; } } return process.execPath; diff --git a/electron/package.json b/electron/package.json index fcae7078db..739f720b2a 100644 --- a/electron/package.json +++ b/electron/package.json @@ -59,6 +59,7 @@ "processTree.js", "sqlite-inspection.js", "lib/resolveServerEntry.js", + "lib/resolveNodeHelper.js", "package.json", "node_modules/**/*" ], diff --git a/tests/unit/electron-resolve-node-executable-7941.test.ts b/tests/unit/electron-resolve-node-executable-7941.test.ts new file mode 100644 index 0000000000..aed4176dab --- /dev/null +++ b/tests/unit/electron-resolve-node-executable-7941.test.ts @@ -0,0 +1,68 @@ +/** + * Regression test for #7941 — macOS second, inert Dock icon. + * + * The Next.js standalone server is spawned via ELECTRON_RUN_AS_NODE. On macOS it must + * run through the Helper binary (a background LSUIElement task) so no extra Dock icon + * appears. resolveDarwinHelperExecutable() derives the Helper name from + * path.basename(execPath) — electron-builder generates BOTH the main binary and the + * Helper.app bundles from build.productName ("OmniRoute"). The old code used + * app.getName() (package.json `name` = "omniroute-desktop"); the two diverged, so it + * never matched a real Helper path and fell through to process.execPath — spawning the + * main Electron binary and producing a second, inert Dock icon. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const { resolveDarwinHelperExecutable } = require("../../electron/lib/resolveNodeHelper.js"); + +const EXEC = "/Applications/OmniRoute.app/Contents/MacOS/OmniRoute"; + +describe("#7941 — resolveDarwinHelperExecutable()", () => { + it("resolves the productName-derived framework Helper even though app.getName() would say 'omniroute-desktop'", () => { + const frameworkHelper = path.join( + path.dirname(EXEC), + "..", + "Frameworks", + "OmniRoute Helper.app", + "Contents", + "MacOS", + "OmniRoute Helper" + ); + const result = resolveDarwinHelperExecutable({ + execPath: EXEC, + existsSync: (p: string) => p === frameworkHelper, // only the REAL helper exists + }); + assert.equal( + result, + frameworkHelper, + "must resolve the Helper derived from basename(execPath), NOT fall back to null/execPath" + ); + // Guard against the exact regression: it must never hand back the main binary name. + assert.notEqual(result, EXEC); + }); + + it("prefers the sibling (unsuffixed) Helper next to the main binary when present", () => { + const sibling = path.join(path.dirname(EXEC), "OmniRoute Helper"); + const result = resolveDarwinHelperExecutable({ + execPath: EXEC, + existsSync: (p: string) => p === sibling, + }); + assert.equal(result, sibling); + }); + + it("returns null when no Helper bundle exists (caller then falls back to execPath)", () => { + const result = resolveDarwinHelperExecutable({ + execPath: EXEC, + existsSync: () => false, + }); + assert.equal(result, null); + }); + + it("returns null defensively when execPath is missing", () => { + assert.equal(resolveDarwinHelperExecutable({ execPath: "" }), null); + assert.equal(resolveDarwinHelperExecutable({}), null); + }); +});