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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user