fix(electron): derive macOS Helper name from execPath to remove 2nd Dock icon (#7941) (#8002)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-21 12:00:36 -03:00
committed by GitHub
parent 3238df3204
commit 4e57c1dc9a
5 changed files with 127 additions and 17 deletions

View File

@@ -0,0 +1 @@
- fix(electron): resolve macOS Helper by binary name to remove second inert Dock icon (#7941)

View File

@@ -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 };

View File

@@ -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;

View File

@@ -59,6 +59,7 @@
"processTree.js",
"sqlite-inspection.js",
"lib/resolveServerEntry.js",
"lib/resolveNodeHelper.js",
"package.json",
"node_modules/**/*"
],

View File

@@ -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);
});
});