fix: prevent macOS backend Dock registration

Preload a backend-only JS title accessor from an unpacked macOS resource before Next starts. Preserve Windows and Linux launch arguments, and test title semantics, process scope, argument order, and resource copying.

Based on the prevention approach proposed by marchellodev in PR #47.
This commit is contained in:
amosblomqvist
2026-10-24 21:56:57 +03:00
parent c549494488
commit 480a29aaea
4 changed files with 192 additions and 1 deletions
+5
View File
@@ -100,6 +100,11 @@ extraResources:
filter:
- "standalone/**/*"
mac:
# The backend's --require runs before Next loads. Keep this resource
# outside app.asar so early Node-mode loading needs no ASAR support.
extraResources:
- from: electron/server-title-guard.cjs
to: server-title-guard.cjs
icon: build/icon.icon
# zip rides next to dmg (flipped 2026-10-08): Squirrel.Mac — Electron's
# macOS auto-updater — installs from ZIP and cannot read DMGs. DMG is
+5 -1
View File
@@ -196,7 +196,11 @@ function startServer(port) {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[serverEntry()],
// Only the macOS backend needs this: Next's native title setter
// otherwise checks this Node-mode process in as a foreground app.
process.platform === "darwin"
? ["--require", path.join(process.resourcesPath, "server-title-guard.cjs"), serverEntry()]
: [serverEntry()],
{
cwd: path.dirname(serverEntry()),
env: {
+24
View File
@@ -0,0 +1,24 @@
/**
* Preloaded only into the packaged macOS backend, before Next starts.
*
* Next assigns process.title. In Electron's Node mode on macOS, the native
* setter checks in with Launch Services using the main app bundle, creating
* an unwanted foreground application registration. Keep title writes in JS
* instead: the native process name stays unchanged while JS can read back
* its assigned title. This deliberately does not emulate native truncation
* or every coercion detail; assignments use String(value).
*
* Explicit launch arguments (not NODE_OPTIONS) keep the full-Electron browser
* helper untouched. Node-mode fork descendants can inherit this preload.
*/
if (process.platform === "darwin" && process.env.ELECTRON_RUN_AS_NODE === "1" && process.env.LH_DESKTOP_SERVER === "1") {
let title = process.title;
Object.defineProperty(process, "title", {
configurable: true,
enumerable: true,
get: () => title,
set: (value) => {
title = String(value);
},
});
}
+158
View File
@@ -0,0 +1,158 @@
/** Backend title isolation without launching Electron or touching a user profile. */
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import { runInNewContext } from "node:vm";
const require = createRequire(import.meta.url);
const root = fileURLToPath(new URL("../", import.meta.url));
const mainFile = path.join(root, "electron/main.cjs");
const mainSource = readFileSync(mainFile, "utf8");
const mainRequire = createRequire(mainFile);
const guardSource = readFileSync(path.join(root, "electron/server-title-guard.cjs"), "utf8");
const backendEnv = { ELECTRON_RUN_AS_NODE: "1", LH_DESKTOP_SERVER: "1" };
function titleFixture(platform = "darwin", env: Record<string, string> = backendEnv) {
const nativeWrites: unknown[] = [];
let nativeReads = 0;
const process = { platform, env } as { platform: string; env: Record<string, string>; title: unknown };
Object.defineProperty(process, "title", {
configurable: true, enumerable: true,
get() { nativeReads++; return "initial native title"; },
set(value) { nativeWrites.push(value); },
});
const original = Object.getOwnPropertyDescriptor(process, "title");
runInNewContext(guardSource, { process });
return { process, nativeWrites, original, nativeReads: () => nativeReads };
}
describe("macOS backend process.title guard", () => {
it("reads the initial title once, then isolates strict-mode writes from the native setter", () => {
const fixture = titleFixture();
assert.equal(fixture.nativeReads(), 1);
assert.equal(fixture.process.title, "initial native title");
runInNewContext('"use strict"; process.title = "next-server (v16.3.2)";', { process: fixture.process });
assert.equal(fixture.process.title, "next-server (v16.3.2)");
fixture.process.title = "next-router-worker";
assert.equal(fixture.process.title, "next-router-worker");
assert.deepEqual(fixture.nativeWrites, []);
assert.equal(fixture.nativeReads(), 1);
const descriptor = Object.getOwnPropertyDescriptor(fixture.process, "title")!;
assert.equal(typeof descriptor.get, "function");
assert.equal(typeof descriptor.set, "function");
assert.equal(descriptor.configurable, true);
assert.equal(descriptor.enumerable, true);
});
it("uses the approved String(value) contract, without native length truncation", () => {
const fixture = titleFixture();
for (const value of [42, true, null, undefined, Symbol("title"), "x".repeat(4096)]) {
fixture.process.title = value;
assert.equal(fixture.process.title, String(value));
}
let coercions = 0;
fixture.process.title = { toString() { coercions++; return "custom title"; } };
assert.equal(fixture.process.title, "custom title");
assert.equal(coercions, 1);
assert.throws(() => { fixture.process.title = { toString() { throw new Error("conversion failed"); } }; }, /conversion failed/);
assert.equal(fixture.process.title, "custom title");
assert.deepEqual(fixture.nativeWrites, []);
});
it("can be loaded again without restoring the native accessor", () => {
const fixture = titleFixture();
fixture.process.title = "remembered";
runInNewContext(guardSource, { process: fixture.process });
assert.equal(fixture.process.title, "remembered");
fixture.process.title = "next-server";
assert.deepEqual(fixture.nativeWrites, []);
assert.equal(fixture.nativeReads(), 1);
});
for (const [label, platform, env] of [
["Windows", "win32", backendEnv],
["Linux", "linux", backendEnv],
["full-Electron browser helper", "darwin", {}],
["non-backend Node child", "darwin", { ELECTRON_RUN_AS_NODE: "1" }],
["non-Node-mode process", "darwin", { LH_DESKTOP_SERVER: "1" }],
] as const) {
it(`leaves the original property untouched for ${label}`, () => {
const fixture = titleFixture(platform, env);
assert.deepEqual(Object.getOwnPropertyDescriptor(fixture.process, "title"), fixture.original);
assert.equal(fixture.nativeReads(), 0);
assert.deepEqual(fixture.nativeWrites, []);
});
}
});
async function serverLaunch(platform: string) {
let launch!: { binary: string; args: string[]; options: { cwd: string; env: Record<string, string>; stdio: string[] } };
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter() });
const context = {
require: (id: string) => id === "electron" ? {
app: { setName() {}, requestSingleInstanceLock: () => false, quit() {}, getPath: () => "/fixture/user-data" },
} : id === "node:child_process" ? {
spawn(binary: string, args: string[], options: typeof launch.options) {
launch = JSON.parse(JSON.stringify({ binary, args, options }));
return child;
},
} : mainRequire(id),
process: { platform, argv: [], env: { FIXTURE: "kept" }, on() {}, execPath: "/fixture/LearnHarness", resourcesPath: "/fixture/resources with spaces" },
__dirname: path.dirname(mainFile),
};
runInNewContext(mainSource, context, { filename: mainFile });
await runInNewContext("startServer(54321)", context);
return launch;
}
describe("backend-only preload wiring and packaging", () => {
for (const platform of ["darwin", "win32", "linux"]) {
it(`${platform} uses the real server launch with the intended argument order`, async () => {
const launch = await serverLaunch(platform);
const entry = path.join("/fixture/resources with spaces", "server", "standalone", "server.js");
assert.equal(launch.binary, "/fixture/LearnHarness");
assert.deepEqual(launch.args, platform === "darwin"
? ["--require", path.join("/fixture/resources with spaces", "server-title-guard.cjs"), entry]
: [entry]);
assert.equal(launch.options.cwd, path.dirname(entry));
assert.equal(launch.options.env.NODE_OPTIONS, undefined, "do not add NODE_OPTIONS to the child environment");
assert.equal(launch.options.env.ELECTRON_RUN_AS_NODE, "1");
assert.equal(launch.options.env.LH_DESKTOP_SERVER, "1");
assert.equal(launch.options.env.LH_DATA_DIR, path.join("/fixture/user-data", "data"));
assert.equal(launch.options.env.LH_AUTH_FILE, path.join("/fixture/user-data", "auth.json"));
assert.equal(launch.options.env.PORT, "54321");
assert.equal(launch.options.env.HOSTNAME, "127.0.0.1");
assert.equal(launch.options.env.FIXTURE, "kept");
assert.deepEqual(launch.options.stdio, ["ignore", "pipe", "pipe", "ipc"]);
});
}
it("electron-builder copies the guard outside ASAR only on macOS, retaining shared resources", async () => {
const yaml = require("js-yaml");
const config = yaml.load(readFileSync(path.join(root, "electron-builder.yml"), "utf8"));
const { getFileMatchers, copyFiles } = require("app-builder-lib/out/fileMatcher.js");
const destination = mkdtempSync(path.join(tmpdir(), "lh-title-resource-"));
try {
for (const platform of ["mac", "win", "linux"]) {
const matchers = getFileMatchers(config, "extraResources", destination, {
defaultSrc: root, customBuildOptions: config[platform], macroExpander: (value: string) => value,
globalOutDir: path.join(root, "dist"),
});
assert.ok(matchers.some((matcher: { to: string }) => matcher.to === path.join(destination, "server")), "shared standalone payload remains included");
const guards = matchers.filter((matcher: { to: string }) => matcher.to === path.join(destination, "server-title-guard.cjs"));
assert.equal(guards.length, platform === "mac" ? 1 : 0);
if (platform === "mac") {
await copyFiles(guards, null, false);
assert.equal(readFileSync(path.join(destination, "server-title-guard.cjs"), "utf8"), guardSource);
}
}
} finally {
rmSync(destination, { recursive: true, force: true });
}
});
});