From 480a29aaeafa80ed48173a57f93f47d522877d05 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 24 Oct 2026 21:56:57 +0300 Subject: [PATCH] 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. --- electron-builder.yml | 5 + electron/main.cjs | 6 +- electron/server-title-guard.cjs | 24 +++++ tests/server-title-guard.test.ts | 158 +++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 electron/server-title-guard.cjs create mode 100644 tests/server-title-guard.test.ts diff --git a/electron-builder.yml b/electron-builder.yml index bd28156..cb0dce6 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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 diff --git a/electron/main.cjs b/electron/main.cjs index 521f47a..61c5af3 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -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: { diff --git a/electron/server-title-guard.cjs b/electron/server-title-guard.cjs new file mode 100644 index 0000000..de186d4 --- /dev/null +++ b/electron/server-title-guard.cjs @@ -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); + }, + }); +} diff --git a/tests/server-title-guard.test.ts b/tests/server-title-guard.test.ts new file mode 100644 index 0000000..503b68c --- /dev/null +++ b/tests/server-title-guard.test.ts @@ -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 = backendEnv) { + const nativeWrites: unknown[] = []; + let nativeReads = 0; + const process = { platform, env } as { platform: string; env: Record; 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; 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 }); + } + }); +});