Complete the Native Client source/audio boundary, strict v5 loopback contract, Browser media lifecycle handling, and non-blocking release checks. Keep unverified platform/physical gates explicit; do not rewrite the Go Server.
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import { mkdtemp } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
CdpConnection,
|
|
cleanupRun,
|
|
createPage,
|
|
evaluate,
|
|
waitForVersion,
|
|
withDeadline,
|
|
} from "./browser-gate-harness";
|
|
import {
|
|
decodeClientEndpoint,
|
|
type ClientEndpoint as Endpoint,
|
|
} from "./client-gate-endpoint";
|
|
import { NATIVE_CLIENT_PROTOCOL, NATIVE_CLIENT_SUBPROTOCOL } from "../src/client/native/wire";
|
|
|
|
const PAGE_URL = "https://share.bonfire.icu/";
|
|
const NATIVE_PROTOCOL = NATIVE_CLIENT_PROTOCOL;
|
|
const NATIVE_SUBPROTOCOL = NATIVE_CLIENT_SUBPROTOCOL;
|
|
|
|
interface GateReport {
|
|
passed: boolean;
|
|
platform: NodeJS.Platform;
|
|
browser: string | null;
|
|
hostedExpectation: "blocked" | "connected";
|
|
clientStarted: boolean;
|
|
localPageConnected: boolean;
|
|
localPageError: string | null;
|
|
localPageEvents: string[];
|
|
hostedPageConnected: boolean;
|
|
hostedPageError: string | null;
|
|
hostedPageEvents: string[];
|
|
hostedPageBlockedByBrowser: boolean;
|
|
clientExited: boolean;
|
|
browserExited: boolean;
|
|
profileRemoved: boolean;
|
|
}
|
|
|
|
async function readEndpoint(
|
|
client: ChildProcessWithoutNullStreams,
|
|
): Promise<Endpoint> {
|
|
let buffered = "";
|
|
return await withDeadline(
|
|
() => new Promise<Endpoint>((resolveEndpoint, rejectEndpoint) => {
|
|
const onData = (chunk: Buffer) => {
|
|
buffered += chunk.toString();
|
|
const newline = buffered.indexOf("\n");
|
|
if (newline < 0) return;
|
|
client.stdout.off("data", onData);
|
|
try {
|
|
resolveEndpoint(decodeClientEndpoint(buffered.slice(0, newline)));
|
|
} catch (error) {
|
|
rejectEndpoint(error instanceof Error ? error : new Error("Client endpoint is invalid"));
|
|
}
|
|
};
|
|
client.stdout.on("data", onData);
|
|
client.once("error", rejectEndpoint);
|
|
client.once("exit", (code) => {
|
|
rejectEndpoint(new Error(`Client exited before endpoint (${code ?? "signal"})`));
|
|
});
|
|
}),
|
|
Date.now() + 8_000,
|
|
);
|
|
}
|
|
|
|
async function browserHandshake(
|
|
cdp: CdpConnection,
|
|
page: { sessionId: string },
|
|
endpoint: Endpoint,
|
|
): Promise<{ health: boolean; responses: string[]; error: string | null; events: string[] }> {
|
|
const endpointJSON = JSON.stringify({
|
|
host: "127.0.0.1",
|
|
expectedPort: endpoint.port,
|
|
portStart: 39721,
|
|
portEnd: 39730,
|
|
});
|
|
return await evaluate(
|
|
cdp,
|
|
page,
|
|
`(() => new Promise((resolve) => {
|
|
const expected = ${endpointJSON};
|
|
const result = { health: false, responses: [], error: null, events: [] };
|
|
const finish = () => resolve(result);
|
|
const discover = async () => {
|
|
for (let port = expected.portStart; port <= expected.portEnd; port += 1) {
|
|
const probeController = new AbortController();
|
|
const probeTimer = setTimeout(() => probeController.abort(), 500);
|
|
try {
|
|
const response = await fetch("http://" + expected.host + ":" + port + "/health", {
|
|
cache: "no-store", signal: probeController.signal, targetAddressSpace: "loopback",
|
|
});
|
|
if (!response.ok) continue;
|
|
const health = await response.json();
|
|
if (health.protocol === ${NATIVE_PROTOCOL} && health.service === "screener-client" &&
|
|
health.port === port && typeof health.instanceToken === "string" &&
|
|
health.nativeMedia?.video === false &&
|
|
health.nativeMedia?.processAudio === false &&
|
|
health.nativeMedia?.systemAudio === false &&
|
|
health.nativeMedia?.hardwareH264 === false) {
|
|
return { port, instanceToken: health.instanceToken };
|
|
}
|
|
} catch {} finally {
|
|
clearTimeout(probeTimer);
|
|
}
|
|
}
|
|
throw new Error("Client not found");
|
|
};
|
|
discover()
|
|
.then((endpoint) => {
|
|
result.health = endpoint.port === expected.expectedPort;
|
|
const socket = new WebSocket(
|
|
"ws://" + expected.host + ":" + endpoint.port + "/control",
|
|
["${NATIVE_SUBPROTOCOL}." + endpoint.instanceToken],
|
|
);
|
|
let settled = false;
|
|
const settle = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
finish();
|
|
};
|
|
const timer = setTimeout(() => { result.events.push("timeout"); result.error = "control timeout"; socket.close(); settle(); }, 8000);
|
|
socket.onopen = () => {
|
|
const expectedProtocol = "${NATIVE_SUBPROTOCOL}." + endpoint.instanceToken;
|
|
result.events.push(socket.protocol === expectedProtocol ? "open" : "protocol-mismatch");
|
|
if (socket.protocol !== expectedProtocol) {
|
|
result.error = "control protocol mismatch";
|
|
socket.close();
|
|
settle();
|
|
return;
|
|
}
|
|
socket.send(JSON.stringify({ version: ${NATIVE_PROTOCOL}, id: "request_hello", type: "hello" }));
|
|
};
|
|
socket.onclose = (event) => { result.events.push("close:" + event.code); if (!settled && !result.error) result.error = "control closed"; settle(); };
|
|
socket.onmessage = (event) => {
|
|
const message = JSON.parse(event.data);
|
|
result.events.push("message:" + message.type);
|
|
result.responses.push(message.type);
|
|
if (message.type === "ready") {
|
|
try { socket.send(JSON.stringify({ version: ${NATIVE_PROTOCOL}, id: "request_ping", type: "ping" })); } catch (error) { result.error = String(error?.message || error); settle(); }
|
|
} else if (message.type === "pong") {
|
|
socket.close(1000, "gate complete");
|
|
settle();
|
|
}
|
|
};
|
|
socket.onerror = () => { result.events.push("error"); result.error = "control socket error"; socket.close(); settle(); };
|
|
})
|
|
.catch((error) => { result.error = String(error?.message || error); finish(); });
|
|
}))()`,
|
|
Date.now() + 15_000,
|
|
);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
if (process.platform !== "win32" && process.platform !== "darwin" && process.platform !== "linux") {
|
|
throw new Error("loopback gate requires a desktop platform");
|
|
}
|
|
if (process.env.SCREENER_CLIENT_LOOPBACK_GATE !== "true") {
|
|
throw new Error("loopback gate was not explicitly enabled");
|
|
}
|
|
const browserPath = process.env.CHROME_PATH?.trim();
|
|
const clientPath = process.env.SCREENER_CLIENT_EXE?.trim();
|
|
if (!browserPath || !clientPath) {
|
|
throw new Error("CHROME_PATH and SCREENER_CLIENT_EXE are required");
|
|
}
|
|
const disposableNoSandbox =
|
|
process.env.SCREENER_CLIENT_GATE_NO_SANDBOX === "true";
|
|
const grantLoopback =
|
|
process.env.SCREENER_CLIENT_GATE_GRANT_LOOPBACK === "true";
|
|
|
|
const report: GateReport = {
|
|
passed: false,
|
|
platform: process.platform,
|
|
browser: null,
|
|
hostedExpectation: grantLoopback ? "connected" : "blocked",
|
|
clientStarted: false,
|
|
localPageConnected: false,
|
|
localPageError: null,
|
|
localPageEvents: [],
|
|
hostedPageConnected: false,
|
|
hostedPageError: null,
|
|
hostedPageEvents: [],
|
|
hostedPageBlockedByBrowser: false,
|
|
clientExited: false,
|
|
browserExited: false,
|
|
profileRemoved: false,
|
|
};
|
|
let profile: string | null = null;
|
|
let client: ChildProcessWithoutNullStreams | null = null;
|
|
let browser: ChildProcessWithoutNullStreams | null = null;
|
|
let cdp: CdpConnection | null = null;
|
|
let debugPort = 0;
|
|
try {
|
|
profile = await mkdtemp(join(tmpdir(), "screener-client-loopback-"));
|
|
client = spawn(clientPath, [
|
|
"--site", PAGE_URL,
|
|
"--config", join(profile, "client.json"),
|
|
], {
|
|
stdio: "pipe",
|
|
windowsHide: true,
|
|
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
|
|
});
|
|
client.stderr.resume();
|
|
const endpoint = await readEndpoint(client);
|
|
report.clientStarted = true;
|
|
|
|
const portServer = createServer();
|
|
debugPort = await new Promise<number>((resolvePort, rejectPort) => {
|
|
portServer.once("error", rejectPort);
|
|
portServer.listen(0, "127.0.0.1", () => {
|
|
const address = portServer.address();
|
|
if (!address || typeof address === "string") {
|
|
rejectPort(new Error("debug port reservation failed"));
|
|
return;
|
|
}
|
|
portServer.close((error) => error ? rejectPort(error) : resolvePort(address.port));
|
|
});
|
|
});
|
|
browser = spawn(browserPath, [
|
|
`--remote-debugging-port=${debugPort}`,
|
|
`--user-data-dir=${profile}`,
|
|
"--headless=new",
|
|
...(disposableNoSandbox ? ["--no-sandbox"] : []),
|
|
"--no-first-run",
|
|
"--no-proxy-server",
|
|
"--disable-logging",
|
|
"about:blank",
|
|
], { stdio: "pipe", windowsHide: true });
|
|
browser.stdout.resume();
|
|
browser.stderr.resume();
|
|
const version = await waitForVersion(debugPort, browser);
|
|
report.browser = version.Browser;
|
|
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000);
|
|
if (grantLoopback) {
|
|
await cdp.call(
|
|
"Browser.setPermission",
|
|
{
|
|
permission: { name: "loopback-network" },
|
|
setting: "granted",
|
|
origin: PAGE_URL,
|
|
},
|
|
undefined,
|
|
Date.now() + 5_000,
|
|
);
|
|
}
|
|
|
|
const localPage = await createPage(cdp, endpoint.url + "/health");
|
|
const localResult = await browserHandshake(cdp, localPage, endpoint);
|
|
report.localPageConnected = localResult.health &&
|
|
localResult.responses.join(",") === "ready,pong";
|
|
report.localPageError = localResult.error;
|
|
report.localPageEvents = localResult.events;
|
|
|
|
const hostedPage = await createPage(cdp, PAGE_URL);
|
|
const hostedResult = await browserHandshake(cdp, hostedPage, endpoint);
|
|
report.hostedPageConnected = hostedResult.health &&
|
|
hostedResult.responses.join(",") === "ready,pong";
|
|
report.hostedPageError = hostedResult.error;
|
|
report.hostedPageEvents = hostedResult.events;
|
|
report.hostedPageBlockedByBrowser = !report.hostedPageConnected &&
|
|
hostedResult.error !== null;
|
|
} finally {
|
|
const cleanup = await cleanupRun({
|
|
cdp,
|
|
native: client,
|
|
chrome: browser,
|
|
server: null,
|
|
profile,
|
|
ports: debugPort > 0 ? [debugPort] : [],
|
|
});
|
|
report.browserExited = cleanup.browserExited;
|
|
report.clientExited = cleanup.nativeExited;
|
|
if (profile) {
|
|
report.profileRemoved = cleanup.profileRemoved;
|
|
if (!report.profileRemoved) {
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
|
const retry = await cleanupRun({
|
|
cdp: null,
|
|
native: null,
|
|
chrome: null,
|
|
server: null,
|
|
profile,
|
|
ports: [],
|
|
});
|
|
report.profileRemoved = retry.profileRemoved;
|
|
}
|
|
}
|
|
}
|
|
const hostedExpectationMet = grantLoopback
|
|
? report.hostedPageConnected
|
|
: report.hostedPageBlockedByBrowser;
|
|
report.passed = report.clientStarted && report.localPageConnected &&
|
|
hostedExpectationMet &&
|
|
report.clientExited && report.browserExited && report.profileRemoved;
|
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
if (!report.passed) process.exitCode = 1;
|
|
}
|
|
|
|
await main();
|