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