refactor: unify Go runtime and audited frontend
Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
This commit is contained in:
1 parent
0653b7a58a
commit
da47189fc3
291 files changed
+42361
-34024
No files matched your search
+240
-46
@@ -1,22 +1,51 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { access, mkdtemp, readFile, readdir } from "node:fs/promises";
|
||||
import { access, mkdir, mkdtemp, readFile, readdir } from "node:fs/promises";
|
||||
import {
|
||||
createServer,
|
||||
request as httpRequest,
|
||||
type IncomingHttpHeaders,
|
||||
type IncomingMessage,
|
||||
type OutgoingHttpHeaders,
|
||||
type Server,
|
||||
} from "node:http";
|
||||
import { connect } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createRoomResponseSchema } from "../src/shared/protocol";
|
||||
import { createScreenerServer, type ScreenerServer } from "../src/server/app";
|
||||
import type { ServerConfig } from "../src/server/config";
|
||||
import WebSocket from "ws";
|
||||
import {
|
||||
SIGNALING_PROTOCOL,
|
||||
createRoomResponseSchema,
|
||||
decodeServerMessage,
|
||||
roomAccessUpdateResponseSchema,
|
||||
} from "../src/shared/protocol";
|
||||
import {
|
||||
CdpConnection,
|
||||
cleanupRun,
|
||||
createPage,
|
||||
evaluate,
|
||||
fetchJsonBefore,
|
||||
launchChrome,
|
||||
reservePort,
|
||||
waitForSample,
|
||||
waitForVersion,
|
||||
withDeadline,
|
||||
} from "./browser-gate-harness";
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const nginxConfigPath = resolve(root, "deploy/nginx/share.bonfire.icu.conf.example");
|
||||
const serverExecutable = join(root, "build", "client-check",
|
||||
process.platform === "win32" ? "screener-server.exe" : "screener-server");
|
||||
// SCREENER_ENV=production refuses to start without STUN. Nothing listens on
|
||||
// this address, so the gate still never leaves the loopback interface.
|
||||
const gateStunUrl = "stun:127.0.0.1:3478";
|
||||
const hostClientId = "privacy-gate-host";
|
||||
const serverReadyTimeoutMs = 20_000;
|
||||
const signalDeadlineMs = 10_000;
|
||||
const serverStopTimeoutMs = 10_000;
|
||||
// Hop-by-hop headers the proxy must not forward (RFC 9110 7.6.1); Node frames
|
||||
// the forwarded body itself.
|
||||
const hopByHopHeaders = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade"]);
|
||||
const fragmentProbe = `(() => {
|
||||
let domContentLoaded = false;
|
||||
const state = { fragmentAtStart: location.hash.length > 0, replaced: false, beforeDomContentLoaded: false };
|
||||
@@ -47,20 +76,145 @@ interface BrowserAudit {
|
||||
otherSessionGrantMatches: number; localGrantMatches: number;
|
||||
cookieGrantMatches: number; resourceGrantMatches: number;
|
||||
}
|
||||
// An observer records the request line and returns the sink for its status.
|
||||
type RequestObserver = (request: IncomingMessage) => (status: number) => void;
|
||||
function secret(): string { return randomBytes(24).toString("base64url"); }
|
||||
function leakCount(values: readonly (string | Buffer)[], secrets: readonly string[]): number {
|
||||
return values.filter((value) => secrets.some((item) => value.includes(item))).length;
|
||||
}
|
||||
function diagnosticText(values: readonly unknown[]): string {
|
||||
return values.map((value) => {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof Error) return `${value.name}:${value.message}`;
|
||||
try { return JSON.stringify(value) ?? ""; } catch { return ""; }
|
||||
}).join(" ");
|
||||
}
|
||||
// The profile is also the server's working directory, so a room database the
|
||||
// gate never configured would land here.
|
||||
async function persistentRoomFiles(profile: string): Promise<string[]> {
|
||||
return (await readdir(profile)).filter((name) => name.startsWith("rooms.sqlite"));
|
||||
}
|
||||
async function reserveDistinctPorts(count: number): Promise<number[]> {
|
||||
const ports: number[] = [];
|
||||
while (ports.length < count) {
|
||||
const port = await reservePort();
|
||||
if (!ports.includes(port)) ports.push(port);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
function buildServer(): void {
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
const result = spawnSync(go, ["build", "-trimpath", "-o", serverExecutable, "./cmd/screener-server"], {
|
||||
cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, CGO_ENABLED: "0" },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error("screener-server build failed");
|
||||
}
|
||||
function forwardedHeaders(headers: IncomingHttpHeaders): OutgoingHttpHeaders {
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).filter(([name]) => !hopByHopHeaders.has(name)),
|
||||
);
|
||||
}
|
||||
function requestPreamble(request: IncomingMessage): string {
|
||||
const lines = [`${request.method} ${request.url} HTTP/1.1`];
|
||||
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
||||
lines.push(`${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}`);
|
||||
}
|
||||
return `${lines.join("\r\n")}\r\n\r\n`;
|
||||
}
|
||||
function upgradeStatus(chunk: Buffer): number {
|
||||
const statusLine = chunk.toString("latin1", 0, Math.min(chunk.length, 32));
|
||||
const match = /^HTTP\/1\.[01] (\d{3})/.exec(statusLine);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
// startProxy stands in for the nginx reverse proxy the deployment runs in
|
||||
// front of the server (deploy/nginx/share.bonfire.icu.conf.example). It
|
||||
// observes every request line the browser and the gate emit, which is what the
|
||||
// in-process httpServer.prependListener("request") hook used to do, and it is
|
||||
// the only origin the browser ever sees.
|
||||
// ponytail: the upgrade pipes ignore backpressure; gate traffic is a handful of
|
||||
// signaling frames. Use stream.pipe with drain handling if that ever changes.
|
||||
function startProxy(port: number, upstreamPort: number, observe: RequestObserver): Promise<Server> {
|
||||
const proxy = createServer((request, response) => {
|
||||
const finish = observe(request);
|
||||
response.once("finish", () => finish(response.statusCode));
|
||||
const upstream = httpRequest({
|
||||
host: "127.0.0.1", port: upstreamPort, method: request.method,
|
||||
path: request.url, headers: request.headers,
|
||||
}, (upstreamResponse) => {
|
||||
response.writeHead(upstreamResponse.statusCode ?? 502, forwardedHeaders(upstreamResponse.headers));
|
||||
upstreamResponse.pipe(response);
|
||||
});
|
||||
upstream.on("error", () => response.destroy());
|
||||
request.pipe(upstream);
|
||||
});
|
||||
proxy.on("upgrade", (request, clientSocket, head) => {
|
||||
const finish = observe(request);
|
||||
const upstream = connect({ host: "127.0.0.1", port: upstreamPort });
|
||||
const destroy = () => { clientSocket.destroy(); upstream.destroy(); };
|
||||
let statusRecorded = false;
|
||||
upstream.on("connect", () => {
|
||||
upstream.write(requestPreamble(request));
|
||||
if (head.length > 0) upstream.write(head);
|
||||
});
|
||||
upstream.on("data", (chunk: Buffer) => {
|
||||
if (!statusRecorded) { statusRecorded = true; finish(upgradeStatus(chunk)); }
|
||||
clientSocket.write(chunk);
|
||||
});
|
||||
clientSocket.on("data", (chunk: Buffer) => upstream.write(chunk));
|
||||
for (const socket of [clientSocket, upstream]) {
|
||||
socket.on("error", destroy);
|
||||
socket.on("close", destroy);
|
||||
}
|
||||
});
|
||||
return new Promise((resolveProxy, rejectProxy) => {
|
||||
proxy.once("error", rejectProxy);
|
||||
proxy.listen(port, "127.0.0.1", () => resolveProxy(proxy));
|
||||
});
|
||||
}
|
||||
async function stopChild(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const closed = new Promise<void>((resolveClosed) => child.once("close", () => resolveClosed()));
|
||||
child.kill();
|
||||
await withDeadline(() => closed, Date.now() + serverStopTimeoutMs);
|
||||
}
|
||||
async function closeProxy(proxy: Server | null): Promise<void> {
|
||||
if (!proxy) return;
|
||||
proxy.closeAllConnections();
|
||||
await new Promise<void>((resolveClosed) => proxy.close(() => resolveClosed()));
|
||||
}
|
||||
// authenticateHost replaces the in-process roomStore.connectParticipant call:
|
||||
// the room needs a connected Host, and out of process the only way to get one
|
||||
// is the handshake tests/server-signal.test.ts drives over /signal.
|
||||
async function authenticateHost(
|
||||
webSocketUrl: string, origin: string, cookie: string, roomId: string, hostToken: string,
|
||||
): Promise<WebSocket> {
|
||||
const socket = new WebSocket(webSocketUrl, { origin, headers: { Cookie: cookie } });
|
||||
const deadline = Date.now() + signalDeadlineMs;
|
||||
await withDeadline(() => new Promise<void>((resolveOpen, rejectOpen) => {
|
||||
socket.once("open", resolveOpen);
|
||||
socket.once("error", rejectOpen);
|
||||
}), deadline);
|
||||
const authenticated = new Promise<void>((resolveAuth, rejectAuth) => {
|
||||
socket.on("error", rejectAuth);
|
||||
socket.once("close", () => rejectAuth(new Error("Host signaling closed before authenticating")));
|
||||
socket.on("message", (data) => {
|
||||
try {
|
||||
if (decodeServerMessage(data.toString()).type === "authenticated") resolveAuth();
|
||||
} catch (error) {
|
||||
rejectAuth(error instanceof Error ? error : new Error("Unreadable server message"));
|
||||
}
|
||||
});
|
||||
});
|
||||
socket.send(JSON.stringify({
|
||||
type: "authenticate", protocol: SIGNALING_PROTOCOL, roomId,
|
||||
role: "host", token: hostToken, clientId: hostClientId,
|
||||
}));
|
||||
try {
|
||||
await withDeadline(() => authenticated, deadline);
|
||||
} catch (error) {
|
||||
socket.terminate();
|
||||
throw error;
|
||||
}
|
||||
// The Host session lives until the server stops; a late transport error must
|
||||
// not surface as an unhandled event.
|
||||
socket.on("error", () => {});
|
||||
return socket;
|
||||
}
|
||||
async function main(): Promise<void> {
|
||||
const report: GateReport = {
|
||||
passed: false, serverReady: false, roomPrepared: false,
|
||||
@@ -79,39 +233,71 @@ async function main(): Promise<void> {
|
||||
const requestRecords: string[] = [];
|
||||
const nginxLines: string[] = [];
|
||||
const appLogs: string[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
let profile: string | null = null;
|
||||
let server: ScreenerServer | null = null;
|
||||
let server: { close(): Promise<void> } | null = null;
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let appPort = 0;
|
||||
let proxyPort = 0;
|
||||
let serverPort = 0;
|
||||
let debugPort = 0;
|
||||
|
||||
console.error = (...values: unknown[]) => { appLogs.push(diagnosticText(values)); };
|
||||
try {
|
||||
await access(chromePath);
|
||||
appPort = await reservePort();
|
||||
do { debugPort = await reservePort(); } while (debugPort === appPort);
|
||||
[proxyPort, serverPort, debugPort] = await reserveDistinctPorts(3);
|
||||
profile = await mkdtemp(join(tmpdir(), "screener-access-privacy-"));
|
||||
const baseUrl = `http://127.0.0.1:${appPort}`;
|
||||
const config: ServerConfig = {
|
||||
nodeEnv: "production", port: appPort, listenHost: "127.0.0.1",
|
||||
publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]),
|
||||
siteAccessPassword: sitePassword, roomLeaseMs: 86_400_000,
|
||||
maxViewersPerRoom: 2, endpointMediaCopyCapacity: 2,
|
||||
peerAssistedMedia: false, stunUrls: [], natPredictionEnabled: false,
|
||||
const baseUrl = `http://127.0.0.1:${proxyPort}`;
|
||||
await mkdir(dirname(serverExecutable), { recursive: true });
|
||||
buildServer();
|
||||
let proxy: Server | null = null;
|
||||
let hostSocket: WebSocket | null = null;
|
||||
const child = spawn(serverExecutable, [], {
|
||||
// The working directory decides where .env is read and where an
|
||||
// unconfigured room database would be written; the empty profile keeps
|
||||
// both the developer's environment and the repository out of the run.
|
||||
cwd: profile, stdio: "pipe", windowsHide: true,
|
||||
// An explicit environment: config.Load fails closed on a removed
|
||||
// variable, so nothing from the developer's shell may reach the server.
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
SystemRoot: process.env.SystemRoot,
|
||||
SCREENER_ENV: "production",
|
||||
PORT: String(serverPort),
|
||||
LISTEN_HOST: "127.0.0.1",
|
||||
// Production requires an https public origin. The browser reaches the
|
||||
// same host and port over plain HTTP through the observing proxy, so
|
||||
// only the scheme of the invite URL is rewritten before navigating.
|
||||
PUBLIC_BASE_URL: `https://127.0.0.1:${proxyPort}`,
|
||||
ALLOWED_ORIGINS: baseUrl,
|
||||
SITE_ACCESS_PASSWORD: sitePassword,
|
||||
MAX_VIEWERS_PER_ROOM: "2",
|
||||
ENDPOINT_MEDIA_COPY_CAPACITY: "2",
|
||||
STUN_URLS: gateStunUrl,
|
||||
},
|
||||
});
|
||||
child.stdout.on("data", (chunk: Buffer) => appLogs.push(chunk.toString()));
|
||||
child.stderr.on("data", (chunk: Buffer) => appLogs.push(chunk.toString()));
|
||||
server = {
|
||||
async close() {
|
||||
hostSocket?.terminate();
|
||||
await stopChild(child);
|
||||
await closeProxy(proxy);
|
||||
},
|
||||
};
|
||||
server = await createScreenerServer({
|
||||
config,
|
||||
frontend: { mode: "static", directory: resolve(root, "dist/client") },
|
||||
});
|
||||
server.httpServer.prependListener("request", (request, response) => {
|
||||
const target = request.url ?? "";
|
||||
const record = `${request.method ?? ""} ${target} ${request.headers.referer ?? ""}`;
|
||||
proxy = await startProxy(proxyPort, serverPort, (request) => {
|
||||
// The fields the in-process request listener recorded, plus whether a
|
||||
// Cookie travelled at all: the audit must see credential-bearing
|
||||
// requests without ever storing a credential.
|
||||
const record = `${request.method ?? ""} ${request.url ?? ""} ` +
|
||||
`${request.headers.referer ?? ""} ${request.headers.cookie ? "cookie" : "no-cookie"}`;
|
||||
requestRecords.push(record);
|
||||
response.once("finish", () => nginxLines.push(`${record} ${response.statusCode}`));
|
||||
return (status) => nginxLines.push(`${record} ${status}`);
|
||||
});
|
||||
await server.listen(appPort, "127.0.0.1");
|
||||
await waitForSample(
|
||||
(deadline) => fetchJsonBefore<{ status: string }>(
|
||||
`http://127.0.0.1:${serverPort}/healthz`, deadline),
|
||||
(health) => health.status === "ok",
|
||||
serverReadyTimeoutMs,
|
||||
);
|
||||
report.serverReady = true;
|
||||
const login = await fetch(`${baseUrl}/api/site-access`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${sitePassword}`, Origin: baseUrl },
|
||||
@@ -126,17 +312,26 @@ async function main(): Promise<void> {
|
||||
const invite = new URL(room.inviteUrl);
|
||||
const grant = new URLSearchParams(invite.hash.slice(1)).get("v") ?? "";
|
||||
if (!grant) throw new Error("Room creation did not return a Viewer grant");
|
||||
server.roomStore.connectParticipant({
|
||||
roomId: room.roomId, role: "host", token: room.hostToken,
|
||||
clientId: "privacy-gate-host", sessionId: "privacy-gate-host-session",
|
||||
invite.protocol = "http:";
|
||||
hostSocket = await authenticateHost(
|
||||
`ws://127.0.0.1:${proxyPort}/signal`, baseUrl, cookie, room.roomId, room.hostToken);
|
||||
const accessResponse = await fetch(`${baseUrl}/api/rooms/${room.roomId}/access`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${room.hostToken}`, "Content-Type": "application/json",
|
||||
Cookie: cookie, Origin: baseUrl,
|
||||
},
|
||||
body: JSON.stringify({ action: "set-viewer-password", password: roomPassword }),
|
||||
});
|
||||
await server.roomStore.setViewerPassword(room.roomId, roomPassword, room.hostToken);
|
||||
const accessUpdate = roomAccessUpdateResponseSchema.parse(await accessResponse.json());
|
||||
if (accessUpdate.type !== "viewer-password-updated" || !accessUpdate.enabled) {
|
||||
throw new Error("Room preparation did not enable the Viewer password");
|
||||
}
|
||||
report.roomPrepared = true;
|
||||
chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`,
|
||||
chrome = launchChrome(chromePath, debugPort, profile, [
|
||||
"--headless=new", "--no-first-run", "--disable-extensions",
|
||||
"--disable-logging", "about:blank",
|
||||
], { cwd: root, stdio: "pipe", windowsHide: true });
|
||||
"--disable-logging",
|
||||
], { cwd: root });
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
@@ -145,7 +340,7 @@ async function main(): Promise<void> {
|
||||
const context = await cdp.call<{ browserContextId: string }>(
|
||||
"Target.createBrowserContext", { disposeOnDetach: true }, undefined, Date.now() + 5_000,
|
||||
);
|
||||
const page = await createPage(cdp, room.inviteUrl, fragmentProbe, true, context.browserContextId);
|
||||
const page = await createPage(cdp, invite.toString(), fragmentProbe, true, context.browserContextId);
|
||||
report.pageLoaded = true;
|
||||
const expected = JSON.stringify({ roomId: room.roomId, grant });
|
||||
const audit = await evaluate<BrowserAudit>(cdp, page, `(() => {
|
||||
@@ -185,10 +380,9 @@ async function main(): Promise<void> {
|
||||
} catch {
|
||||
// The report intentionally exposes no exception text or secret-bearing diagnostics.
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
const cleanup = await cleanupRun({
|
||||
cdp, native: null, chrome, server, profile,
|
||||
ports: [appPort, debugPort].filter((port) => port > 0),
|
||||
ports: [proxyPort, serverPort, debugPort].filter((port) => port > 0),
|
||||
});
|
||||
report.cleanupPassed = Object.values(cleanup).every(Boolean);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function tarExecutable() {
|
||||
if (process.platform !== "win32") return "tar";
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
|
||||
if (!systemRoot) return "tar";
|
||||
const systemTar = join(systemRoot, "System32", "tar.exe");
|
||||
return existsSync(systemTar) ? systemTar : "tar";
|
||||
}
|
||||
+22
-39
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
|
||||
import { writeClientPlatformAssets } from "./client-icons.mjs";
|
||||
import { writeClientLicenseNotices } from "./package-licenses.mjs";
|
||||
import { tarExecutable } from "./archive-tool.mjs";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
@@ -131,7 +132,7 @@ function readDescriptor(path) {
|
||||
|
||||
function assertOutsideRepository(repositoryRoot, outputRoot) {
|
||||
const path = relative(repositoryRoot, outputRoot);
|
||||
if (path === "" || (!path.startsWith("..") && !isAbsolute(path))) {
|
||||
if (path === "" || (path.split(/[\\/]/)[0] !== ".." && !isAbsolute(path))) {
|
||||
fail("Client output directory must be outside the repository");
|
||||
}
|
||||
if (existsSync(outputRoot)) {
|
||||
@@ -139,11 +140,11 @@ function assertOutsideRepository(repositoryRoot, outputRoot) {
|
||||
}
|
||||
}
|
||||
|
||||
const positional = process.argv.slice(2, 5);
|
||||
const options = process.argv.slice(5);
|
||||
if (positional.length !== 3 || options.length % 2 !== 0) {
|
||||
const positional = process.argv.slice(2, 4);
|
||||
const options = process.argv.slice(4);
|
||||
if (positional.length !== 2 || options.length % 2 !== 0) {
|
||||
fail(
|
||||
"Usage: node scripts/assemble-client.mjs <app-release.json> <node-executable> <new-output-directory> --target <windows-amd64|linux-amd64|darwin-arm64> [--capture <executable>] [--tunnel <executable>]",
|
||||
"Usage: node scripts/assemble-client.mjs <app-release.json> <new-output-directory> --target <windows-amd64|linux-amd64|darwin-arm64> [--capture <executable>] [--tunnel <executable>]",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,19 +175,10 @@ if (!target) fail("Client package target is invalid");
|
||||
|
||||
const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
||||
const descriptorPath = realpathSync(resolve(positional[0]));
|
||||
const nodePath = realpathSync(resolve(positional[1]));
|
||||
const outputRoot = resolve(process.cwd(), positional[2]);
|
||||
const outputRoot = resolve(process.cwd(), positional[1]);
|
||||
const capturePath = captureArgument ? realpathSync(resolve(captureArgument)) : null;
|
||||
const tunnelPath = tunnelArgument ? realpathSync(resolve(tunnelArgument)) : null;
|
||||
assertOutsideRepository(repositoryRoot, outputRoot);
|
||||
assertTargetExecutable(nodePath, target, "Node runtime");
|
||||
const expectedNodeVersion = `v${readFileSync(
|
||||
join(repositoryRoot, ".node-version"),
|
||||
"ascii",
|
||||
).trim()}`;
|
||||
if (run(nodePath, ["--version"], repositoryRoot) !== expectedNodeVersion) {
|
||||
fail(`Node runtime must be ${expectedNodeVersion}`);
|
||||
}
|
||||
if (capturePath && !target.captureName) {
|
||||
fail("Capture runtime is invalid for the Client package target");
|
||||
}
|
||||
@@ -208,21 +200,20 @@ if (!existsSync(artifactPath) || sha256(artifactPath) !== descriptor.artifactSha
|
||||
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-client-${revision.slice(0, 7)}-`));
|
||||
const packageRoot = join(temporaryRoot, "package");
|
||||
const appRoot = join(packageRoot, "app");
|
||||
const releaseRoot = join(temporaryRoot, "release");
|
||||
try {
|
||||
mkdirSync(appRoot, { recursive: true });
|
||||
run("tar", ["-xzf", artifactPath, "-C", appRoot], repositoryRoot);
|
||||
if (readFileSync(join(appRoot, "REVISION"), "ascii") !== `${revision}\n`) {
|
||||
// The application release is consumed for its provenance only: the Client
|
||||
// embeds the Web assets, so the revision, clean-tree and digest assertions
|
||||
// above plus this REVISION check tie the binary to that immutable release.
|
||||
mkdirSync(releaseRoot, { recursive: true });
|
||||
run(tarExecutable(), ["-xzf", artifactPath, "-C", releaseRoot], repositoryRoot);
|
||||
if (readFileSync(join(releaseRoot, "REVISION"), "ascii") !== `${revision}\n`) {
|
||||
fail("Extracted application revision does not match the Client");
|
||||
}
|
||||
runNpm(["ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], appRoot);
|
||||
|
||||
const runtimeRoot = join(packageRoot, "runtime", "node");
|
||||
mkdirSync(runtimeRoot, { recursive: true });
|
||||
const nodeName = target.nodeName;
|
||||
const packagedNode = join(runtimeRoot, nodeName);
|
||||
copyFileSync(nodePath, packagedNode);
|
||||
chmodSync(packagedNode, 0o755);
|
||||
// Build the Web assets the Go binary embeds. The checkout is clean and at the
|
||||
// release revision, so this reproduces that release's client.
|
||||
runNpm(["run", "build:client"], repositoryRoot);
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
|
||||
let packagedCapture = null;
|
||||
if (capturePath) {
|
||||
@@ -250,31 +241,25 @@ try {
|
||||
"build",
|
||||
"-trimpath",
|
||||
"-ldflags",
|
||||
`-s -w -X github.com/TNTcraftHIM/Screener/native/client/internal/clientapp.BuildRevision=${revision}`,
|
||||
`-s -w -X github.com/TNTcraftHIM/Screener/internal/client/clientapp.BuildRevision=${revision}`,
|
||||
"-o",
|
||||
clientPath,
|
||||
"./cmd/screener-client",
|
||||
], join(repositoryRoot, "native", "client"), {
|
||||
], repositoryRoot, {
|
||||
...process.env,
|
||||
GOOS: target.goos,
|
||||
GOARCH: target.goarch,
|
||||
CGO_ENABLED: "0",
|
||||
});
|
||||
chmodSync(clientPath, 0o755);
|
||||
assertTargetExecutable(clientPath, target, "Client executable");
|
||||
writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target,
|
||||
packagedTunnel ? CLOUDFLARED_VERSION : null);
|
||||
const platformAssets = writeClientPlatformAssets({
|
||||
packageRoot,
|
||||
target,
|
||||
revision,
|
||||
iconPath: join(
|
||||
repositoryRoot,
|
||||
"native",
|
||||
"client",
|
||||
"cmd",
|
||||
"screener-client",
|
||||
"screener.ico",
|
||||
),
|
||||
iconPath: join(repositoryRoot, "cmd", "screener-client", "screener.ico"),
|
||||
});
|
||||
writeFileSync(join(packageRoot, "REVISION"), `${revision}\n`, "ascii");
|
||||
|
||||
@@ -290,13 +275,11 @@ try {
|
||||
platform: target.goos,
|
||||
arch: target.goarch,
|
||||
client: clientName,
|
||||
node: `runtime/node/${nodeName}`,
|
||||
nativeCapture: packagedCapture ? `runtime/native/${target.captureName}` : null,
|
||||
publicTunnel: packagedTunnel
|
||||
? `runtime/tunnel/${target.tunnelName}`
|
||||
: null,
|
||||
platformAssets,
|
||||
app: "app",
|
||||
})}\n`);
|
||||
} finally {
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
|
||||
@@ -27,11 +27,15 @@ interface AsyncCloseable {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
// A gate's server is either an in-process page server it closes directly or a
|
||||
// spawned Screener server it stops like any other child.
|
||||
export type CleanupServer = AsyncCloseable | ChildProcessWithoutNullStreams;
|
||||
|
||||
export interface CleanupResources {
|
||||
cdp: CdpConnection | null;
|
||||
native: ChildProcessWithoutNullStreams | null;
|
||||
chrome: ChildProcessWithoutNullStreams | null;
|
||||
server: AsyncCloseable | null;
|
||||
server: CleanupServer | null;
|
||||
profile: string | null;
|
||||
ports: number[];
|
||||
}
|
||||
@@ -137,6 +141,27 @@ export class CdpConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// launchChrome starts an isolated Chrome process with the shared CDP flags.
|
||||
export function launchChrome(
|
||||
executable: string,
|
||||
debugPort: number,
|
||||
profile: string,
|
||||
flags: string[] = [],
|
||||
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
||||
): ChildProcessWithoutNullStreams {
|
||||
return spawn(executable, [
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
...flags,
|
||||
"about:blank",
|
||||
], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function withDeadline<T>(
|
||||
operation: () => Promise<T>,
|
||||
deadline: number,
|
||||
@@ -297,12 +322,16 @@ export async function reservePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
// Readiness is the debugging endpoint, never the spawned process: Chrome's
|
||||
// Windows launcher exits as soon as the browser process takes over the profile
|
||||
// (measured: exit at ~140 ms, endpoint ready at ~440 ms). The deadline bounds
|
||||
// the wait, and the launcher's exit code is reported when it runs out.
|
||||
export async function waitForVersion(
|
||||
port: number,
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
): Promise<{ Browser: string; webSocketDebuggerUrl: string }> {
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline && child.exitCode === null) {
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await fetchJsonBefore<{ Browser: string; webSocketDebuggerUrl: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
@@ -311,7 +340,7 @@ export async function waitForVersion(
|
||||
} catch {}
|
||||
await delay(Math.min(100, Math.max(0, deadline - Date.now())));
|
||||
}
|
||||
throw new Error("Chrome startup timed out");
|
||||
throw new Error(`Chrome startup timed out (exit ${child.exitCode ?? "running"})`);
|
||||
}
|
||||
|
||||
export async function cleanupRun(
|
||||
@@ -339,13 +368,16 @@ export async function cleanupRun(
|
||||
const nativeExited = resources.native
|
||||
? await stopProcessTree(resources.native)
|
||||
: true;
|
||||
const server = resources.server;
|
||||
let serverClosed = true;
|
||||
if (resources.server) {
|
||||
if (server && "close" in server) {
|
||||
try {
|
||||
await withDeadline(() => resources.server!.close(), Date.now() + 5_000);
|
||||
await withDeadline(() => server.close(), Date.now() + 5_000);
|
||||
} catch {
|
||||
serverClosed = false;
|
||||
}
|
||||
} else if (server) {
|
||||
serverClosed = await stopProcessTree(server);
|
||||
}
|
||||
const portsClosed = await waitForPortsClosed(
|
||||
[...new Set(resources.ports)],
|
||||
|
||||
+45
-37
@@ -9,10 +9,15 @@ import {
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { CLIENT_PACKAGE_TARGETS } from "./client-package-targets.mjs";
|
||||
import { CLIENT_PACKAGE_TARGETS, clientPackageTarget } from "./client-package-targets.mjs";
|
||||
|
||||
// Every Go command embeds the Vite output, so the build, vet and test steps all
|
||||
// fail without it. The Hosted binary is cross-built for its deployment target.
|
||||
const CLIENT_INDEX = join("internal", "server", "webassets", "dist", "index.html");
|
||||
const SERVER_TARGET = clientPackageTarget("linux-amd64");
|
||||
|
||||
const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
||||
const clientRoot = join(root, "native", "client");
|
||||
const captureRoot = join(root, "native", "capture");
|
||||
const mode = process.argv[2] ?? "--all";
|
||||
|
||||
if (!["--all", "--core", "--capture-only"].includes(mode)) {
|
||||
@@ -36,54 +41,65 @@ function run(command, args, options = {}) {
|
||||
return options.capture ? result.stdout.trim() : "";
|
||||
}
|
||||
|
||||
function buildWebAssets() {
|
||||
if (existsSync(join(root, CLIENT_INDEX))) return;
|
||||
if (process.platform === "win32") {
|
||||
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
|
||||
return;
|
||||
}
|
||||
run("npm", ["run", "build:client"]);
|
||||
}
|
||||
|
||||
function checkCore() {
|
||||
buildWebAssets();
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
const goRoot = run(go, ["env", "GOROOT"], { cwd: clientRoot, capture: true });
|
||||
const goRoot = run(go, ["env", "GOROOT"], { capture: true });
|
||||
const gofmt = process.env.SCREENER_GOFMT?.trim() ||
|
||||
join(goRoot, "bin", process.platform === "win32" ? "gofmt.exe" : "gofmt");
|
||||
const unformatted = run(gofmt, ["-l", "."], { cwd: clientRoot, capture: true });
|
||||
const unformatted = run(gofmt, ["-l", "."], { capture: true });
|
||||
if (unformatted) {
|
||||
throw new Error(`Go source is not formatted:\n${unformatted}`);
|
||||
}
|
||||
runClientTests(go);
|
||||
run(go, ["vet", "./..."], { cwd: clientRoot });
|
||||
run(go, ["vet", "./..."]);
|
||||
|
||||
const buildRoot = join(root, "build", "client-check");
|
||||
mkdirSync(buildRoot, { recursive: true });
|
||||
for (const target of CLIENT_PACKAGE_TARGETS) {
|
||||
for (const command of ["screener-client", "screener-peer-gate"]) {
|
||||
const outputName = target.goos === "windows"
|
||||
? `${command}.exe`
|
||||
: `${command}-${target.id}`;
|
||||
run(go, ["build", "-trimpath", "-o", join(buildRoot, outputName), `./cmd/${command}`], {
|
||||
cwd: clientRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
GOOS: target.goos,
|
||||
GOARCH: target.goarch,
|
||||
CGO_ENABLED: "0",
|
||||
},
|
||||
});
|
||||
}
|
||||
const builds = CLIENT_PACKAGE_TARGETS.flatMap((target) =>
|
||||
["screener-client", "screener-peer-gate"].map((command) => ({ target, command })),
|
||||
);
|
||||
builds.push({ target: SERVER_TARGET, command: "screener-server" });
|
||||
for (const { target, command } of builds) {
|
||||
const outputName = target.goos === "windows"
|
||||
? `${command}.exe`
|
||||
: `${command}-${target.id}`;
|
||||
run(go, ["build", "-trimpath", "-o", join(buildRoot, outputName), `./cmd/${command}`], {
|
||||
env: {
|
||||
...process.env,
|
||||
GOOS: target.goos,
|
||||
GOARCH: target.goarch,
|
||||
CGO_ENABLED: "0",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function runClientTests(go) {
|
||||
if (process.platform !== "win32") {
|
||||
run(go, ["test", "./..."], { cwd: clientRoot });
|
||||
run(go, ["test", "./..."]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows associates its listen prompt with the test executable path. Keep
|
||||
// the one UDP integration package at a stable path so repeated checks do not
|
||||
// create a new firewall rule for every Go build directory.
|
||||
const packages = run(go, ["list", "./..."], { cwd: clientRoot, capture: true })
|
||||
const packages = run(go, ["list", "./..."], { capture: true })
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const stableNetworkPackages = [
|
||||
{
|
||||
package: "github.com/TNTcraftHIM/Screener/native/client/internal/mediaedge",
|
||||
package: "github.com/TNTcraftHIM/Screener/internal/client/mediaedge",
|
||||
binary: "mediaedge.test.exe",
|
||||
},
|
||||
];
|
||||
@@ -92,16 +108,14 @@ function runClientTests(go) {
|
||||
);
|
||||
const otherPackages = packages.filter((value) => !stablePackageNames.has(value));
|
||||
if (otherPackages.length > 0) {
|
||||
run(go, ["test", ...otherPackages], { cwd: clientRoot });
|
||||
run(go, ["test", ...otherPackages]);
|
||||
}
|
||||
const stableRoot = join(root, "build", "client-check");
|
||||
mkdirSync(stableRoot, { recursive: true });
|
||||
for (const entry of stableNetworkPackages) {
|
||||
const binary = join(stableRoot, entry.binary);
|
||||
run(go, ["test", "-c", "-o", binary, entry.package], {
|
||||
cwd: clientRoot,
|
||||
});
|
||||
run(binary, ["-test.v"], { cwd: clientRoot });
|
||||
run(go, ["test", "-c", "-o", binary, entry.package]);
|
||||
run(binary, ["-test.v"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,23 +152,17 @@ function checkPlatformCapture() {
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
join(clientRoot, "platform", "windows", "capture", "build.ps1"),
|
||||
join(captureRoot, "windows", "build.ps1"),
|
||||
"-OutputDirectory",
|
||||
buildRoot,
|
||||
"-Check",
|
||||
]);
|
||||
executable = join(buildRoot, "screener-client-capture.exe");
|
||||
} else if (process.platform === "darwin") {
|
||||
run("sh", [
|
||||
join(clientRoot, "platform", "darwin", "capture", "build.sh"),
|
||||
buildRoot,
|
||||
]);
|
||||
run("sh", [join(captureRoot, "darwin", "build.sh"), buildRoot]);
|
||||
executable = join(buildRoot, "screener-client-capture");
|
||||
} else {
|
||||
run("sh", [
|
||||
join(clientRoot, "platform", "linux", "capture", "build.sh"),
|
||||
buildRoot,
|
||||
]);
|
||||
run("sh", [join(captureRoot, "linux", "build.sh"), buildRoot]);
|
||||
executable = join(buildRoot, "screener-client-capture");
|
||||
}
|
||||
if (!existsSync(executable)) {
|
||||
|
||||
@@ -19,7 +19,7 @@ if ($LASTEXITCODE -ne 0) { throw "Working-tree whitespace check failed." }
|
||||
git diff --cached --check
|
||||
if ($LASTEXITCODE -ne 0) { throw "Staged whitespace check failed." }
|
||||
|
||||
if ($env:SCREENER_BASE_SHA) {
|
||||
if ($env:SCREENER_BASE_SHA -and $env:SCREENER_BASE_SHA -notmatch '^0{40}$') {
|
||||
git diff --check "$env:SCREENER_BASE_SHA...HEAD"
|
||||
if ($LASTEXITCODE -ne 0) { throw "Commit-range whitespace check failed." }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ done < scripts/required-project-paths.txt
|
||||
git diff --check
|
||||
git diff --cached --check
|
||||
|
||||
if [ -n "${SCREENER_BASE_SHA:-}" ]; then
|
||||
if [ -n "${SCREENER_BASE_SHA:-}" ] && [ "$SCREENER_BASE_SHA" != "0000000000000000000000000000000000000000" ]; then
|
||||
git diff --check "$SCREENER_BASE_SHA"...HEAD
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createScreenerServer, type ScreenerServer } from "../src/server/app";
|
||||
import { createLocalServerConfig } from "../src/server/local-config";
|
||||
import {
|
||||
CdpConnection, cleanupRun, createPage, evaluate, reservePort,
|
||||
CdpConnection, cleanupRun, createPage, evaluate, fetchJsonBefore, launchChrome, reservePort,
|
||||
waitForSample, waitForVersion, type PageHandle,
|
||||
} from "./browser-gate-harness";
|
||||
import { decodeClientEndpoint } from "./client-gate-endpoint";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "..");
|
||||
const BUILD_ROOT = join(ROOT, "build/client-check");
|
||||
// A documentation-only LAN address: it makes the room server publish a LAN
|
||||
// invitation the gate rewrites to its loopback origins, exactly as the packaged
|
||||
// Client does on a real network.
|
||||
const LAN_ADDRESS = "192.0.2.1";
|
||||
|
||||
const initialAudio = process.env.SCREENER_CLIENT_GATE_AUDIO !== "false";
|
||||
const probe = String.raw`(() => {
|
||||
localStorage.setItem('screener:ui-lang', 'en');
|
||||
@@ -91,6 +95,54 @@ const probe = String.raw`(() => {
|
||||
};
|
||||
})()`;
|
||||
|
||||
function build(command: string, args: string[]): void {
|
||||
const result = spawnSync(command, args, { cwd: ROOT, encoding: "utf8", windowsHide: true });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout).trim() || command + " failed");
|
||||
}
|
||||
}
|
||||
|
||||
// startRoomServer builds and starts the Screener server the Browser pages and
|
||||
// both Clients share. SCREENER_ENV stays development because production
|
||||
// requires an https public origin, a site password and STUN; the Web assets are
|
||||
// embedded either way.
|
||||
async function startRoomServer(
|
||||
port: number,
|
||||
workingDirectory: string,
|
||||
): Promise<ChildProcessWithoutNullStreams> {
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
await mkdir(BUILD_ROOT, { recursive: true });
|
||||
// The server embeds the Vite output, so the Web build precedes the Go build.
|
||||
build(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
|
||||
const binary = join(BUILD_ROOT, "screener-server.exe");
|
||||
build(go, ["build", "-trimpath", "-o", binary, "./cmd/screener-server"]);
|
||||
const origins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://${LAN_ADDRESS}:${port}`];
|
||||
const child = spawn(binary, [], {
|
||||
// The gate profile has no .env, and the environment is explicit: the
|
||||
// configuration this measurement runs against is these five values, never
|
||||
// the repository's development file or the operator's shell.
|
||||
cwd: workingDirectory, stdio: "pipe", windowsHide: true,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
SystemRoot: process.env.SystemRoot,
|
||||
SCREENER_ENV: "development",
|
||||
PORT: String(port),
|
||||
LISTEN_HOST: "127.0.0.1",
|
||||
PUBLIC_BASE_URL: `http://${LAN_ADDRESS}:${port}`,
|
||||
ALLOWED_ORIGINS: origins.join(","),
|
||||
},
|
||||
});
|
||||
child.stdout.resume();
|
||||
child.stderr.resume();
|
||||
await waitForSample(
|
||||
(deadline) => fetchJsonBefore<{ status: string }>(`http://127.0.0.1:${port}/healthz`, deadline),
|
||||
(value) => value.status === "ok",
|
||||
20000,
|
||||
);
|
||||
return child;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.env.SCREENER_CLIENT_BROWSER_FANOUT_GATE !== "true") throw new Error("Browser fanout gate was not enabled");
|
||||
const chromePath = process.env.CHROME_PATH;
|
||||
@@ -102,21 +154,17 @@ async function main(): Promise<void> {
|
||||
let relayClient: ChildProcessWithoutNullStreams | null = null;
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let server: ScreenerServer | null = null;
|
||||
let server: ChildProcessWithoutNullStreams | null = null;
|
||||
let clientPort = 0;
|
||||
let relayPort = 0;
|
||||
const pages: PageHandle[] = [];
|
||||
const checks: Record<string, unknown> = {};
|
||||
let stage = "startup", error: string | null = null;
|
||||
try {
|
||||
server = await createScreenerServer({
|
||||
config: createLocalServerConfig({ port: appPort, publicAddress: "192.0.2.1" }),
|
||||
frontend: { mode: "static", directory: join(ROOT, "dist/client") },
|
||||
});
|
||||
await server.listen(appPort, "127.0.0.1");
|
||||
client = spawn(process.env.SCREENER_CLIENT_EXE || join(ROOT, "build/client-check/screener-client.exe"), [
|
||||
server = await startRoomServer(appPort, profile);
|
||||
client = spawn(process.env.SCREENER_CLIENT_EXE || join(BUILD_ROOT, "screener-client.exe"), [
|
||||
"--site", origin, "--config", join(profile, "client.json"),
|
||||
"--capture-process", join(ROOT, "build/client-check/screener-client-capture.exe"),
|
||||
"--capture-process", join(BUILD_ROOT, "screener-client-capture.exe"),
|
||||
], { windowsHide: true, stdio: "pipe", env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" } });
|
||||
client.stderr.resume();
|
||||
let output = "";
|
||||
@@ -125,12 +173,12 @@ async function main(): Promise<void> {
|
||||
if (!clientPort && output.includes("\n")) clientPort = decodeClientEndpoint(output.split("\n")[0]!).port;
|
||||
});
|
||||
await waitForSample(async () => clientPort, (value) => value > 0, 15000);
|
||||
chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`, "--headless=new",
|
||||
chrome = launchChrome(chromePath, debugPort, profile, [
|
||||
"--headless=new",
|
||||
"--no-first-run", "--no-default-browser-check", "--no-proxy-server",
|
||||
"--autoplay-policy=no-user-gesture-required", "--disable-background-timer-throttling",
|
||||
"--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows", "about:blank",
|
||||
], { windowsHide: true, stdio: "pipe" });
|
||||
"--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows",
|
||||
]);
|
||||
chrome.stdout.resume(); chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
checks.browser = version.Browser;
|
||||
@@ -161,9 +209,9 @@ async function main(): Promise<void> {
|
||||
let invite = await evaluate<string>(cdp, host, "document.querySelector('.lr-invite-url').value", Date.now()+5000);
|
||||
const url = new URL(invite); url.host = `localhost:${appPort}`; invite = url.toString();
|
||||
const relayOrigin = `http://127.0.0.1:${appPort}`;
|
||||
relayClient = spawn(process.env.SCREENER_CLIENT_EXE || join(ROOT,"build/client-check/screener-client.exe"), [
|
||||
relayClient = spawn(process.env.SCREENER_CLIENT_EXE || join(BUILD_ROOT,"screener-client.exe"), [
|
||||
"--site", relayOrigin, "--config", join(profile,"relay-client.json"),
|
||||
"--capture-process", join(ROOT,"build/client-check/screener-client-capture.exe"),
|
||||
"--capture-process", join(BUILD_ROOT,"screener-client-capture.exe"),
|
||||
], { windowsHide:true, stdio:"pipe", env:{...process.env,SCREENER_CLIENT_GATE_NO_BROWSER:"true"} });
|
||||
relayClient.stderr.resume(); let relayOutput="";
|
||||
relayClient.stdout.on("data", (chunk:Buffer) => {
|
||||
@@ -224,9 +272,9 @@ async function main(): Promise<void> {
|
||||
checks.clientExitRecovery = true;
|
||||
checks.finalHost = await read(host);
|
||||
if ((checks.finalHost as any).errors.length > 0) throw new Error("Browser reported an unhandled media error");
|
||||
await mkdir(join(ROOT,"build/client-check"), {recursive:true});
|
||||
await mkdir(BUILD_ROOT, {recursive:true});
|
||||
const screenshot = await cdp.call<{data:string}>("Page.captureScreenshot", {format:"png"}, host.sessionId, Date.now()+5000);
|
||||
await writeFile(join(ROOT,"build/client-check/browser-fanout.png"), Buffer.from(screenshot.data,"base64"));
|
||||
await writeFile(join(BUILD_ROOT,"browser-fanout.png"), Buffer.from(screenshot.data,"base64"));
|
||||
await click(host, 'button[aria-label="Stop sharing"]');
|
||||
stage = "done";
|
||||
} catch (caught) {
|
||||
@@ -237,8 +285,8 @@ async function main(): Promise<void> {
|
||||
const relayCleanup = await cleanupRun({cdp:null,native:relayClient,chrome:null,server:null,profile:null,ports:relayPort?[relayPort]:[]});
|
||||
const cleanup = await cleanupRun({cdp, native:client, chrome, server, profile, ports:[appPort,debugPort,...(clientPort?[clientPort]:[])]});
|
||||
const result = {passed: error===null && Object.values(cleanup).every(Boolean) && relayCleanup.nativeExited && relayCleanup.portsClosed, stage, checks, error, cleanup};
|
||||
await mkdir(join(ROOT,"build/client-check"),{recursive:true});
|
||||
await writeFile(join(ROOT,"build/client-check/browser-fanout.json"), JSON.stringify(result,null,2));
|
||||
await mkdir(BUILD_ROOT,{recursive:true});
|
||||
await writeFile(join(BUILD_ROOT,"browser-fanout.json"), JSON.stringify(result,null,2));
|
||||
process.stdout.write(JSON.stringify(result)+"\n");
|
||||
if (!result.passed) process.exitCode=1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
import {
|
||||
NATIVE_CLIENT_PORT_END,
|
||||
NATIVE_CLIENT_PORT_START,
|
||||
} from "../src/client/native/wire";
|
||||
import { withDeadline } from "./browser-gate-harness";
|
||||
|
||||
const INSTANCE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const CLIENT_PORT_START = 39_721;
|
||||
const CLIENT_PORT_END = 39_730;
|
||||
|
||||
export interface ClientEndpoint {
|
||||
url: string;
|
||||
@@ -22,8 +28,8 @@ export function decodeClientEndpoint(line: string): ClientEndpoint {
|
||||
const endpoint = value as Partial<ClientEndpoint>;
|
||||
if (
|
||||
!Number.isInteger(endpoint.port) ||
|
||||
endpoint.port! < CLIENT_PORT_START ||
|
||||
endpoint.port! > CLIENT_PORT_END ||
|
||||
endpoint.port! < NATIVE_CLIENT_PORT_START ||
|
||||
endpoint.port! > NATIVE_CLIENT_PORT_END ||
|
||||
endpoint.host !== `127.0.0.1:${endpoint.port}` ||
|
||||
endpoint.url !== `http://${endpoint.host}` ||
|
||||
typeof endpoint.instanceToken !== "string" ||
|
||||
@@ -34,3 +40,58 @@ export function decodeClientEndpoint(line: string): ClientEndpoint {
|
||||
}
|
||||
return endpoint as ClientEndpoint;
|
||||
}
|
||||
|
||||
// readClientEndpoint waits for the first valid loopback endpoint from stdout.
|
||||
export async function readClientEndpoint(
|
||||
client: ChildProcessWithoutNullStreams,
|
||||
options: { timeoutMs?: number; ignoreNonEndpointLines?: boolean } = {},
|
||||
): Promise<ClientEndpoint> {
|
||||
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
|
||||
let cleanup = () => undefined;
|
||||
try {
|
||||
return await withDeadline(
|
||||
() => new Promise<ClientEndpoint>((resolveEndpoint, rejectEndpoint) => {
|
||||
let buffered = "";
|
||||
const finish = (callback: () => void) => {
|
||||
cleanup();
|
||||
callback();
|
||||
};
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered += chunk.toString();
|
||||
for (;;) {
|
||||
const newline = buffered.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
const line = buffered.slice(0, newline).trim();
|
||||
buffered = buffered.slice(newline + 1);
|
||||
try {
|
||||
const endpoint = decodeClientEndpoint(line);
|
||||
finish(() => resolveEndpoint(endpoint));
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!options.ignoreNonEndpointLines) {
|
||||
finish(() => rejectEndpoint(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const onError = (error: Error) => finish(() => rejectEndpoint(error));
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) =>
|
||||
finish(() => rejectEndpoint(
|
||||
new Error(`Client exited before endpoint (${code ?? signal ?? "signal"})`),
|
||||
));
|
||||
cleanup = () => {
|
||||
client.stdout.off("data", onData);
|
||||
client.off("error", onError);
|
||||
client.off("exit", onExit);
|
||||
};
|
||||
client.stdout.on("data", onData);
|
||||
client.once("error", onError);
|
||||
client.once("exit", onExit);
|
||||
}),
|
||||
deadline,
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
@@ -204,8 +204,6 @@ async function main(): Promise<void> {
|
||||
const destination = `${remote.user}@${remote.host}`;
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
const configuredClient = process.env.SCREENER_CLIENT_EXE?.trim();
|
||||
const node = process.env.SCREENER_CLIENT_NODE?.trim() || process.execPath;
|
||||
const app = process.env.SCREENER_CLIENT_APP?.trim() || ROOT;
|
||||
const tunnel = process.env.SCREENER_CLOUDFLARED?.trim() ||
|
||||
join(BUILD_ROOT, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
|
||||
const profile = await mkdtemp(join(tmpdir(), "screener-client-link-"));
|
||||
@@ -231,14 +229,19 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
result.stage = "build";
|
||||
if (!configuredClient) {
|
||||
// The Client embeds the Vite output, and this gate asserts on the page it
|
||||
// serves over the public link, so the Web build precedes the Go build.
|
||||
if (process.platform === "win32") {
|
||||
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
|
||||
} else {
|
||||
run("npm", ["run", "build:client"]);
|
||||
}
|
||||
run(go, ["build", "-trimpath", "-o", clientBinary, "./cmd/screener-client"],
|
||||
join(ROOT, "native", "client"));
|
||||
ROOT);
|
||||
}
|
||||
result.stage = "client-start";
|
||||
client = spawn(clientBinary, [
|
||||
"--link",
|
||||
"--node", node,
|
||||
"--app", app,
|
||||
"--config", join(profile, "client.json"),
|
||||
"--port", String(port),
|
||||
"--lan-address", localLANAddress(),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
CdpConnection,
|
||||
@@ -10,18 +9,15 @@ import {
|
||||
createPage,
|
||||
evaluate,
|
||||
fetchJsonBefore,
|
||||
launchChrome,
|
||||
reservePort,
|
||||
waitForSample,
|
||||
waitForVersion,
|
||||
withDeadline,
|
||||
} from "./browser-gate-harness";
|
||||
import {
|
||||
decodeClientEndpoint,
|
||||
type ClientEndpoint as Endpoint,
|
||||
readClientEndpoint,
|
||||
} from "./client-gate-endpoint";
|
||||
|
||||
const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
|
||||
interface GateReport {
|
||||
passed: boolean;
|
||||
platform: NodeJS.Platform;
|
||||
@@ -55,45 +51,16 @@ const LOCAL_PAGE_STATE = `fetch('/api/site-access')
|
||||
hostReady: Boolean(document.querySelector('.lr-host-personal-controls')),
|
||||
}))`;
|
||||
|
||||
async function readEndpoint(
|
||||
client: ChildProcessWithoutNullStreams,
|
||||
): Promise<Endpoint> {
|
||||
let buffered = "";
|
||||
return 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);
|
||||
}
|
||||
};
|
||||
client.stdout.on("data", onData);
|
||||
client.once("error", rejectEndpoint);
|
||||
client.once("exit", (code) =>
|
||||
rejectEndpoint(new Error(`Client exited before readiness (${code ?? "signal"})`)),
|
||||
);
|
||||
}),
|
||||
Date.now() + 10_000,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.env.SCREENER_CLIENT_LOCAL_GATE !== "true") {
|
||||
throw new Error("Local Client gate was not explicitly enabled");
|
||||
}
|
||||
const browserPath = process.env.CHROME_PATH?.trim();
|
||||
const clientPath = process.env.SCREENER_CLIENT_EXE?.trim();
|
||||
const nodePath = process.env.SCREENER_CLIENT_NODE?.trim();
|
||||
const appDirectory = process.env.SCREENER_CLIENT_APP?.trim() || ROOT;
|
||||
const lanAddress = process.env.SCREENER_CLIENT_GATE_LAN_ADDRESS?.trim();
|
||||
if (!browserPath || !clientPath || !nodePath || !lanAddress) {
|
||||
if (!browserPath || !clientPath || !lanAddress) {
|
||||
throw new Error(
|
||||
"CHROME_PATH, SCREENER_CLIENT_EXE, SCREENER_CLIENT_NODE, and SCREENER_CLIENT_GATE_LAN_ADDRESS are required",
|
||||
"CHROME_PATH, SCREENER_CLIENT_EXE, and SCREENER_CLIENT_GATE_LAN_ADDRESS are required",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,8 +90,7 @@ async function main(): Promise<void> {
|
||||
let loopbackPort = 0;
|
||||
try {
|
||||
client = spawn(clientPath, [
|
||||
"--node", nodePath,
|
||||
"--app", appDirectory,
|
||||
"--local",
|
||||
"--config", configPath,
|
||||
"--lan-address", lanAddress,
|
||||
"--port", String(appPort),
|
||||
@@ -134,7 +100,7 @@ async function main(): Promise<void> {
|
||||
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
|
||||
});
|
||||
client.stderr.resume();
|
||||
const endpoint = await readEndpoint(client);
|
||||
const endpoint = await readClientEndpoint(client);
|
||||
loopbackPort = endpoint.port;
|
||||
report.clientStarted = true;
|
||||
await waitForSample(
|
||||
@@ -158,9 +124,7 @@ async function main(): Promise<void> {
|
||||
"screener-client": "1",
|
||||
}).toString();
|
||||
|
||||
browser = spawn(browserPath, [
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
browser = launchChrome(browserPath, debugPort, profile, [
|
||||
"--headless=new",
|
||||
...(process.env.SCREENER_CLIENT_GATE_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
@@ -168,8 +132,7 @@ async function main(): Promise<void> {
|
||||
"--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);
|
||||
|
||||
@@ -9,14 +9,19 @@ import {
|
||||
cleanupRun,
|
||||
createPage,
|
||||
evaluate,
|
||||
launchChrome,
|
||||
waitForVersion,
|
||||
withDeadline,
|
||||
} from "./browser-gate-harness";
|
||||
import {
|
||||
decodeClientEndpoint,
|
||||
readClientEndpoint,
|
||||
type ClientEndpoint as Endpoint,
|
||||
} from "./client-gate-endpoint";
|
||||
import { NATIVE_CLIENT_PROTOCOL, NATIVE_CLIENT_SUBPROTOCOL } from "../src/client/native/wire";
|
||||
import {
|
||||
NATIVE_CLIENT_PORT_END,
|
||||
NATIVE_CLIENT_PORT_START,
|
||||
NATIVE_CLIENT_PROTOCOL,
|
||||
NATIVE_CLIENT_SUBPROTOCOL,
|
||||
} from "../src/client/native/wire";
|
||||
|
||||
const PAGE_URL = "https://share.bonfire.icu/";
|
||||
const NATIVE_PROTOCOL = NATIVE_CLIENT_PROTOCOL;
|
||||
@@ -40,33 +45,6 @@ interface GateReport {
|
||||
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 },
|
||||
@@ -75,8 +53,8 @@ async function browserHandshake(
|
||||
const endpointJSON = JSON.stringify({
|
||||
host: "127.0.0.1",
|
||||
expectedPort: endpoint.port,
|
||||
portStart: 39721,
|
||||
portEnd: 39730,
|
||||
portStart: NATIVE_CLIENT_PORT_START,
|
||||
portEnd: NATIVE_CLIENT_PORT_END,
|
||||
});
|
||||
return await evaluate(
|
||||
cdp,
|
||||
@@ -205,7 +183,7 @@ async function main(): Promise<void> {
|
||||
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
|
||||
});
|
||||
client.stderr.resume();
|
||||
const endpoint = await readEndpoint(client);
|
||||
const endpoint = await readClientEndpoint(client, { timeoutMs: 8_000 });
|
||||
report.clientStarted = true;
|
||||
|
||||
const portServer = createServer();
|
||||
@@ -220,16 +198,13 @@ async function main(): Promise<void> {
|
||||
portServer.close((error) => error ? rejectPort(error) : resolvePort(address.port));
|
||||
});
|
||||
});
|
||||
browser = spawn(browserPath, [
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
browser = launchChrome(browserPath, debugPort, 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);
|
||||
|
||||
@@ -9,22 +9,25 @@ import {
|
||||
cleanupRun,
|
||||
createPage,
|
||||
evaluate,
|
||||
launchChrome,
|
||||
reservePort,
|
||||
waitForSample,
|
||||
waitForVersion,
|
||||
withDeadline,
|
||||
} from "./browser-gate-harness";
|
||||
import {
|
||||
decodeClientEndpoint,
|
||||
readClientEndpoint,
|
||||
type ClientEndpoint as Endpoint,
|
||||
} from "./client-gate-endpoint";
|
||||
import {
|
||||
NATIVE_CLIENT_PROTOCOL,
|
||||
NATIVE_CLIENT_SUBPROTOCOL,
|
||||
} from "../src/client/native/wire";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "..");
|
||||
const BUILD_ROOT = join(ROOT, "build", "client-check");
|
||||
const SOURCE_TITLE = "Screener Native Gate Source";
|
||||
const GATE_STUN_URL =
|
||||
process.env.SCREENER_CLIENT_GATE_STUN_URL?.trim() ||
|
||||
"stun:share.bonfire.icu:3478";
|
||||
const GATE_STUN_URLS = process.env.SCREENER_CLIENT_GATE_STUN_URLS?.trim();
|
||||
|
||||
interface Probe {
|
||||
protocol: number;
|
||||
@@ -242,36 +245,13 @@ async function stopCapture(child: ChildProcessWithoutNullStreams): Promise<void>
|
||||
if (code !== 0) throw new Error("Native capture did not stop cleanly");
|
||||
}
|
||||
|
||||
async function readEndpoint(child: ChildProcessWithoutNullStreams): Promise<Endpoint> {
|
||||
let buffered = "";
|
||||
return withDeadline(
|
||||
() => new Promise<Endpoint>((resolveEndpoint, rejectEndpoint) => {
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered += chunk.toString();
|
||||
const newline = buffered.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
child.stdout.off("data", onData);
|
||||
try {
|
||||
resolveEndpoint(decodeClientEndpoint(buffered.slice(0, newline)));
|
||||
} catch (error) {
|
||||
rejectEndpoint(error);
|
||||
}
|
||||
};
|
||||
child.stdout.on("data", onData);
|
||||
child.once("error", rejectEndpoint);
|
||||
child.once("exit", (code) => {
|
||||
rejectEndpoint(new Error("Client exited before readiness (" + String(code) + ")"));
|
||||
});
|
||||
}),
|
||||
Date.now() + 10_000,
|
||||
);
|
||||
}
|
||||
|
||||
async function browserMediaGate(input: {
|
||||
endpoint: Endpoint;
|
||||
protocol: number;
|
||||
subprotocol: string;
|
||||
sourceTitle: string;
|
||||
sourceKind: "window" | "display";
|
||||
stunUrl: string;
|
||||
stunUrls: string;
|
||||
}): Promise<MediaEvidence> {
|
||||
let socket: WebSocket | null = null;
|
||||
const peers: RTCPeerConnection[] = [];
|
||||
@@ -306,7 +286,7 @@ async function browserMediaGate(input: {
|
||||
} as RequestInit);
|
||||
const health = await healthResponse.json();
|
||||
if (
|
||||
health.protocol !== 7 ||
|
||||
health.protocol !== input.protocol ||
|
||||
health.service !== "screener-client" ||
|
||||
health.instanceToken !== input.endpoint.instanceToken ||
|
||||
health.nativeMedia?.video !== true ||
|
||||
@@ -320,7 +300,7 @@ async function browserMediaGate(input: {
|
||||
|
||||
socket = new WebSocket(
|
||||
"ws://127.0.0.1:" + input.endpoint.port + "/control",
|
||||
["screener-client-v8." + input.endpoint.instanceToken],
|
||||
[input.subprotocol + "." + input.endpoint.instanceToken],
|
||||
);
|
||||
await new Promise<void>((resolveOpen, rejectOpen) => {
|
||||
const timer = window.setTimeout(
|
||||
@@ -493,7 +473,7 @@ async function browserMediaGate(input: {
|
||||
const offer = await request("prepare-edge", {
|
||||
shareId,
|
||||
connectionId: nextConnectionId,
|
||||
iceServers: [{ urls: [input.stunUrl] }],
|
||||
iceServers: [{ urls: input.stunUrls.split(",") }],
|
||||
});
|
||||
await peer.setRemoteDescription({ type: "offer", sdp: offer.sdp });
|
||||
edge.remoteDescriptionSet = true;
|
||||
@@ -668,6 +648,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
const chromePath = process.env.CHROME_PATH?.trim();
|
||||
if (!chromePath) throw new Error("CHROME_PATH is required");
|
||||
if (!GATE_STUN_URLS) {
|
||||
throw new Error("SCREENER_CLIENT_GATE_STUN_URLS is required");
|
||||
}
|
||||
|
||||
const profile = await mkdtemp(join(tmpdir(), "screener-client-media-"));
|
||||
// Keep network-capable binaries at a stable repository path. Windows
|
||||
@@ -733,7 +716,7 @@ async function main(): Promise<void> {
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"),
|
||||
join(ROOT, "native", "capture", "windows", "build.ps1"),
|
||||
"-OutputDirectory",
|
||||
buildRoot,
|
||||
]);
|
||||
@@ -746,9 +729,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
server = await startPageServer(pagePort);
|
||||
chrome = spawn(chromePath, [
|
||||
"--remote-debugging-port=" + debugPort,
|
||||
"--user-data-dir=" + profile,
|
||||
chrome = launchChrome(chromePath, debugPort, profile, [
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
@@ -757,8 +738,7 @@ async function main(): Promise<void> {
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--window-size=1280,900",
|
||||
"about:blank",
|
||||
], { stdio: "pipe", windowsHide: true });
|
||||
]);
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
@@ -868,10 +848,14 @@ async function main(): Promise<void> {
|
||||
);
|
||||
const clientExecutable = join(packageRoot, "screener-client.exe");
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
// The Client embeds the Vite output, so the Web build precedes the Go build.
|
||||
run(process.env.ComSpec || "cmd.exe", [
|
||||
"/d", "/s", "/c", "npm run build:client",
|
||||
], ROOT);
|
||||
run(
|
||||
go,
|
||||
["build", "-trimpath", "-o", clientExecutable, "./cmd/screener-client"],
|
||||
join(ROOT, "native", "client"),
|
||||
ROOT,
|
||||
);
|
||||
client = spawn(clientExecutable, [
|
||||
"--site",
|
||||
@@ -884,7 +868,7 @@ async function main(): Promise<void> {
|
||||
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
|
||||
});
|
||||
client.stderr.resume();
|
||||
const endpoint = await readEndpoint(client);
|
||||
const endpoint = await readClientEndpoint(client);
|
||||
clientPort = endpoint.port;
|
||||
const controlPage = await createPage(
|
||||
cdp,
|
||||
@@ -897,9 +881,11 @@ async function main(): Promise<void> {
|
||||
controlPage,
|
||||
"((__name) => (" + browserMediaGate.toString() + ")(" + JSON.stringify({
|
||||
endpoint,
|
||||
protocol: NATIVE_CLIENT_PROTOCOL,
|
||||
subprotocol: NATIVE_CLIENT_SUBPROTOCOL,
|
||||
sourceTitle: SOURCE_TITLE,
|
||||
sourceKind,
|
||||
stunUrl: GATE_STUN_URL,
|
||||
stunUrls: GATE_STUN_URLS,
|
||||
}) + "))((target) => target)",
|
||||
Date.now() + 70_000,
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
cleanupRun,
|
||||
createPage,
|
||||
evaluate,
|
||||
launchChrome,
|
||||
reservePort,
|
||||
type PageHandle,
|
||||
waitForVersion,
|
||||
@@ -411,16 +412,14 @@ export async function startSourceBrowser(
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
cdp: CdpConnection;
|
||||
}> {
|
||||
const child = spawn(chromePath, [
|
||||
"--remote-debugging-port=" + debugPort,
|
||||
"--user-data-dir=" + profile,
|
||||
const child = launchChrome(chromePath, debugPort, profile, [
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-extensions", "--disable-logging",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--app=http://127.0.0.1:" + sourcePort + "/",
|
||||
], { stdio: "pipe", windowsHide: true });
|
||||
]);
|
||||
child.stdout.resume();
|
||||
child.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, child);
|
||||
@@ -547,6 +546,10 @@ async function main(): Promise<void> {
|
||||
throw new Error("Cross-NAT and one-link gate modes are mutually exclusive");
|
||||
}
|
||||
const mode: GateMode = linkMedia ? "one-link" : crossNat ? "cross-nat" : "local";
|
||||
const gateStunUrls = process.env.SCREENER_CLIENT_GATE_STUN_URLS?.trim();
|
||||
if (mode === "cross-nat" && !gateStunUrls) {
|
||||
throw new Error("SCREENER_CLIENT_GATE_STUN_URLS is required for the cross-NAT gate");
|
||||
}
|
||||
const crashGate =
|
||||
process.env.SCREENER_CLIENT_NATIVE_HOST_CRASH_GATE === "true";
|
||||
if (crashGate && mode !== "local") {
|
||||
@@ -564,7 +567,6 @@ async function main(): Promise<void> {
|
||||
const chromePath = process.env.CHROME_PATH?.trim();
|
||||
if (!chromePath) throw new Error("CHROME_PATH is required");
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
const node = process.env.SCREENER_NODE?.trim() || process.execPath;
|
||||
const tunnel = process.env.SCREENER_CLOUDFLARED?.trim() || join(
|
||||
BUILD_ROOT,
|
||||
"cloudflared.exe",
|
||||
@@ -634,26 +636,26 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
stage = "application-build";
|
||||
run(process.env.ComSpec || "cmd.exe", [
|
||||
"/d", "/s", "/c", "npm run build",
|
||||
"/d", "/s", "/c", "npm run build:client",
|
||||
]);
|
||||
stage = "source-server";
|
||||
source = await sourceServer(sourcePort);
|
||||
stage = "capture-build";
|
||||
run(powershell(), [
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
|
||||
join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"),
|
||||
join(ROOT, "native", "capture", "windows", "build.ps1"),
|
||||
"-OutputDirectory", captureBuild,
|
||||
]);
|
||||
stage = "client-build";
|
||||
run(go, [
|
||||
"build", "-trimpath", "-o", clientBinary, "./cmd/screener-client",
|
||||
], join(ROOT, "native", "client"));
|
||||
], ROOT);
|
||||
if (remote) {
|
||||
stage = "remote-peer-build";
|
||||
run(
|
||||
go,
|
||||
["build", "-trimpath", "-o", remoteBinary, "./cmd/screener-peer-gate"],
|
||||
join(ROOT, "native", "client"),
|
||||
ROOT,
|
||||
{ ...process.env, GOOS: "linux", GOARCH: "amd64", CGO_ENABLED: "0" },
|
||||
);
|
||||
}
|
||||
@@ -672,8 +674,6 @@ async function main(): Promise<void> {
|
||||
? ["--tunnel-process", tunnel]
|
||||
: []),
|
||||
"--capture-process", captureBinary,
|
||||
"--node", node,
|
||||
"--app", ROOT,
|
||||
"--config", clientConfig,
|
||||
"--port", String(appPort),
|
||||
], {
|
||||
@@ -681,15 +681,12 @@ async function main(): Promise<void> {
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_DEBUG: "screener-route",
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
SCREENER_DEBUG: "route",
|
||||
SCREENER_CLIENT_GATE_NO_BROWSER: "true",
|
||||
...(mode === "cross-nat"
|
||||
? {
|
||||
STUN_URLS:
|
||||
process.env.SCREENER_CLIENT_GATE_STUN_URLS?.trim() ||
|
||||
"stun:share.bonfire.icu:3478",
|
||||
}
|
||||
// STUN_URLS reaches only the Client's own Pion edge: the in-process
|
||||
// room server never reads it, so the cross-NAT arm stays isolated.
|
||||
...(mode === "cross-nat" && gateStunUrls
|
||||
? { STUN_URLS: gateStunUrls }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
@@ -729,17 +726,14 @@ async function main(): Promise<void> {
|
||||
15_000,
|
||||
);
|
||||
stage = "host-browser";
|
||||
chrome = spawn(chromePath, [
|
||||
"--remote-debugging-port=" + debugPort,
|
||||
"--user-data-dir=" + profile,
|
||||
chrome = launchChrome(chromePath, debugPort, profile, [
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-extensions", "--disable-logging",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--window-size=1280,900",
|
||||
"about:blank",
|
||||
], { stdio: "pipe", windowsHide: true });
|
||||
]);
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
stage = "host-cdp";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
cleanupRun,
|
||||
createPage,
|
||||
evaluate,
|
||||
launchChrome,
|
||||
reservePort,
|
||||
type PageHandle,
|
||||
waitForVersion,
|
||||
@@ -30,8 +31,7 @@ import {
|
||||
waitForCaptureWindow,
|
||||
} from "./client-native-host-gate";
|
||||
import {
|
||||
decodeClientEndpoint,
|
||||
type ClientEndpoint as Endpoint,
|
||||
readClientEndpoint,
|
||||
} from "./client-gate-endpoint";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "..");
|
||||
@@ -73,39 +73,6 @@ async function waitForHTTP(url: string, timeoutMs: number): Promise<void> {
|
||||
throw new Error(`service did not become ready: ${url}`);
|
||||
}
|
||||
|
||||
async function readEndpoint(
|
||||
process: ChildProcessWithoutNullStreams,
|
||||
): Promise<Endpoint> {
|
||||
return await new Promise<Endpoint>((resolveEndpoint, rejectEndpoint) => {
|
||||
const timer = setTimeout(
|
||||
() => rejectEndpoint(new Error("native Client did not become ready")),
|
||||
10_000,
|
||||
);
|
||||
let buffered = "";
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered += chunk.toString();
|
||||
for (;;) {
|
||||
const newline = buffered.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
const line = buffered.slice(0, newline).trim();
|
||||
buffered = buffered.slice(newline + 1);
|
||||
try {
|
||||
const value = decodeClientEndpoint(line);
|
||||
clearTimeout(timer);
|
||||
process.stdout.off("data", onData);
|
||||
resolveEndpoint(value);
|
||||
return;
|
||||
} catch {
|
||||
// Ignore non-JSON informational output.
|
||||
}
|
||||
}
|
||||
};
|
||||
process.stdout.on("data", onData);
|
||||
process.once("error", rejectEndpoint);
|
||||
process.once("exit", () => rejectEndpoint(new Error("native Client exited")));
|
||||
});
|
||||
}
|
||||
|
||||
async function issueToken(
|
||||
apiKey: string,
|
||||
apiSecret: string,
|
||||
@@ -190,14 +157,14 @@ async function main(): Promise<void> {
|
||||
let clientPort = 0;
|
||||
try {
|
||||
result.stage = "build";
|
||||
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build"]);
|
||||
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
|
||||
run(powershell(), [
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
|
||||
join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"),
|
||||
join(ROOT, "native", "capture", "windows", "build.ps1"),
|
||||
"-OutputDirectory", BUILD_ROOT,
|
||||
]);
|
||||
run(go, ["build", "-trimpath", "-o", clientBinary, "./cmd/screener-client"],
|
||||
join(ROOT, "native", "client"));
|
||||
ROOT);
|
||||
|
||||
result.stage = "livekit";
|
||||
livekit = spawn(livekitPath, [
|
||||
@@ -260,12 +227,12 @@ async function main(): Promise<void> {
|
||||
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
|
||||
});
|
||||
client.stderr.resume();
|
||||
clientPort = (await readEndpoint(client)).port;
|
||||
clientPort = (await readClientEndpoint(client, {
|
||||
ignoreNonEndpointLines: true,
|
||||
})).port;
|
||||
|
||||
result.stage = "browser";
|
||||
chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
chrome = launchChrome(chromePath, debugPort, profile, [
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
@@ -274,8 +241,7 @@ async function main(): Promise<void> {
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-features=WebRtcHideLocalIpsWithMdns",
|
||||
"about:blank",
|
||||
], { stdio: "pipe", windowsHide: true });
|
||||
]);
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
|
||||
@@ -8,7 +8,6 @@ export const CLIENT_PACKAGE_TARGETS = [
|
||||
goos: "windows",
|
||||
goarch: "amd64",
|
||||
clientName: "screener-client.exe",
|
||||
nodeName: "node.exe",
|
||||
tunnelName: "cloudflared.exe",
|
||||
captureName: "screener-client-capture.exe",
|
||||
tunnelAsset: "cloudflared-windows-amd64.exe",
|
||||
@@ -22,7 +21,6 @@ export const CLIENT_PACKAGE_TARGETS = [
|
||||
goos: "linux",
|
||||
goarch: "amd64",
|
||||
clientName: "screener-client",
|
||||
nodeName: "node",
|
||||
tunnelName: "cloudflared",
|
||||
captureName: "screener-client-capture",
|
||||
tunnelAsset: "cloudflared-linux-amd64",
|
||||
@@ -36,7 +34,6 @@ export const CLIENT_PACKAGE_TARGETS = [
|
||||
goos: "darwin",
|
||||
goarch: "arm64",
|
||||
clientName: "screener-client",
|
||||
nodeName: "node",
|
||||
tunnelName: "cloudflared",
|
||||
captureName: "screener-client-capture",
|
||||
tunnelAsset: "cloudflared-darwin-arm64.tgz",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
@@ -19,13 +20,31 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { writeServerLicenseNotices } from "./package-licenses.mjs";
|
||||
import { tarExecutable } from "./archive-tool.mjs";
|
||||
|
||||
// The Hosted deployment target. deploy/release-app.sh runs the archived binary
|
||||
// as the service user, so the release is always built for linux/amd64, and
|
||||
// CGO_ENABLED=0 keeps it self-contained.
|
||||
const SERVER_TARGET = { goos: "linux", goarch: "amd64" };
|
||||
const SERVER_NAME = "screener-server";
|
||||
|
||||
// Everything the archive may contain. deploy/release-app.sh enforces the same
|
||||
// four names in its entry allowlist and manifest pattern; both change together.
|
||||
const RUNTIME_PATHS = ["REVISION", "LICENSE", "THIRD-PARTY-NOTICES.txt", SERVER_NAME];
|
||||
|
||||
// The Vite output the server embeds. Its index.html names the main asset the
|
||||
// deployment postflight proves over HTTP.
|
||||
const CLIENT_DIST = "internal/server/webassets/dist";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function run(command, args, cwd) {
|
||||
function run(command, args, cwd, environment = process.env) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
env: environment,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -36,16 +55,16 @@ function run(command, args, cwd) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function buildRuntime(cwd) {
|
||||
function buildClient(cwd) {
|
||||
if (process.platform === "win32") {
|
||||
run(
|
||||
process.env.ComSpec || "cmd.exe",
|
||||
["/d", "/s", "/c", "npm run build"],
|
||||
["/d", "/s", "/c", "npm run build:client"],
|
||||
cwd,
|
||||
);
|
||||
return;
|
||||
}
|
||||
run("npm", ["run", "build"], cwd);
|
||||
run("npm", ["run", "build:client"], cwd);
|
||||
}
|
||||
|
||||
function assertCleanRevision(repositoryRoot, expectedRevision) {
|
||||
@@ -77,17 +96,6 @@ function normalizedRelative(root, path) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function runtimePathAllowed(path) {
|
||||
return (
|
||||
path === "REVISION" ||
|
||||
path === "LICENSE" ||
|
||||
path === "package.json" ||
|
||||
path === "package-lock.json" ||
|
||||
path.startsWith("dist/client/") ||
|
||||
path.startsWith("dist/server/")
|
||||
);
|
||||
}
|
||||
|
||||
function regularFiles(root) {
|
||||
const files = [];
|
||||
const pending = [root];
|
||||
@@ -111,18 +119,10 @@ function regularFiles(root) {
|
||||
);
|
||||
}
|
||||
|
||||
function copyRuntimeFile(sourceRoot, targetRoot, source) {
|
||||
const path = normalizedRelative(sourceRoot, source);
|
||||
if (!runtimePathAllowed(path)) fail(`Unexpected runtime file: ${path}`);
|
||||
const target = join(targetRoot, ...path.split("/"));
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
copyFileSync(source, target);
|
||||
}
|
||||
|
||||
function recordsFor(root) {
|
||||
return regularFiles(root).map((absolute) => {
|
||||
const path = normalizedRelative(root, absolute);
|
||||
if (!runtimePathAllowed(path)) fail(`Unexpected packaged file: ${path}`);
|
||||
if (!RUNTIME_PATHS.includes(path)) fail(`Unexpected packaged file: ${path}`);
|
||||
return { path, size: statSync(absolute).size, sha256: sha256(absolute) };
|
||||
});
|
||||
}
|
||||
@@ -155,20 +155,23 @@ function validateArchiveEntries(entries) {
|
||||
) {
|
||||
fail(`Invalid archive entry: ${rawEntry}`);
|
||||
}
|
||||
if (
|
||||
entry !== "REVISION" &&
|
||||
entry !== "LICENSE" &&
|
||||
entry !== "package.json" &&
|
||||
entry !== "package-lock.json" &&
|
||||
entry !== "dist" &&
|
||||
!entry.startsWith("dist/client") &&
|
||||
!entry.startsWith("dist/server")
|
||||
) {
|
||||
fail(`Unexpected archive entry: ${rawEntry}`);
|
||||
}
|
||||
if (!RUNTIME_PATHS.includes(entry)) fail(`Unexpected archive entry: ${rawEntry}`);
|
||||
}
|
||||
}
|
||||
|
||||
// mainAsset is the hashed entry script Vite named in the embedded index.html.
|
||||
// The deployment postflight proves it over HTTP, so it is read from the same
|
||||
// build the binary embeds rather than from the archive.
|
||||
function mainAssetOf(distributionRoot) {
|
||||
const html = readFileSync(join(distributionRoot, "index.html"), "utf8");
|
||||
const match = html.match(/<script[^>]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/);
|
||||
if (!match) fail("Built client HTML has no unique main asset");
|
||||
if (!existsSync(join(distributionRoot, ...match[1].split("/")))) {
|
||||
fail("Built client main asset is missing");
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
if (process.argv.length !== 3) {
|
||||
fail("Usage: node scripts/package-app-release.mjs <new-output-directory>");
|
||||
}
|
||||
@@ -176,21 +179,17 @@ if (process.argv.length !== 3) {
|
||||
const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
||||
const outputRoot = resolve(process.cwd(), process.argv[2]);
|
||||
const relativeOutput = relative(repositoryRoot, outputRoot);
|
||||
if (relativeOutput === "" || (!relativeOutput.startsWith("..") && !isAbsolute(relativeOutput))) {
|
||||
if (relativeOutput === "" || (relativeOutput.split(/[\\/]/)[0] !== ".." && !isAbsolute(relativeOutput))) {
|
||||
fail("Output directory must be outside the repository");
|
||||
}
|
||||
if (existsSync(outputRoot)) fail("Output directory must not already exist");
|
||||
|
||||
const revision = assertCleanRevision(repositoryRoot);
|
||||
|
||||
for (const required of ["LICENSE", "package.json", "package-lock.json"]) {
|
||||
if (!existsSync(join(repositoryRoot, required))) fail(`Missing release input: ${required}`);
|
||||
}
|
||||
buildRuntime(repositoryRoot);
|
||||
if (!existsSync(join(repositoryRoot, "LICENSE"))) fail("Missing release input: LICENSE");
|
||||
buildClient(repositoryRoot);
|
||||
assertCleanRevision(repositoryRoot, revision);
|
||||
for (const required of ["dist/client", "dist/server"]) {
|
||||
if (!existsSync(join(repositoryRoot, required))) fail(`Missing built runtime path: ${required}`);
|
||||
}
|
||||
const mainAsset = mainAssetOf(join(repositoryRoot, CLIENT_DIST));
|
||||
|
||||
const releaseId = revision.slice(0, 7);
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-app-${releaseId}-`));
|
||||
@@ -199,14 +198,30 @@ const verifyRoot = join(temporaryRoot, "verify");
|
||||
|
||||
try {
|
||||
mkdirSync(runtimeRoot, { recursive: true });
|
||||
for (const source of [
|
||||
join(repositoryRoot, "LICENSE"),
|
||||
join(repositoryRoot, "package.json"),
|
||||
join(repositoryRoot, "package-lock.json"),
|
||||
...regularFiles(join(repositoryRoot, "dist")),
|
||||
]) {
|
||||
copyRuntimeFile(repositoryRoot, runtimeRoot, source);
|
||||
}
|
||||
copyFileSync(join(repositoryRoot, "LICENSE"), join(runtimeRoot, "LICENSE"));
|
||||
const goCommand = process.env.SCREENER_GO?.trim() || "go";
|
||||
const serverPath = join(runtimeRoot, SERVER_NAME);
|
||||
run(goCommand, [
|
||||
"build",
|
||||
"-trimpath",
|
||||
"-ldflags",
|
||||
`-s -w -X main.BuildRevision=${revision}`,
|
||||
"-o",
|
||||
serverPath,
|
||||
"./cmd/screener-server",
|
||||
], repositoryRoot, {
|
||||
...process.env,
|
||||
GOOS: SERVER_TARGET.goos,
|
||||
GOARCH: SERVER_TARGET.goarch,
|
||||
CGO_ENABLED: "0",
|
||||
});
|
||||
chmodSync(serverPath, 0o755);
|
||||
writeServerLicenseNotices(
|
||||
repositoryRoot,
|
||||
join(runtimeRoot, "THIRD-PARTY-NOTICES.txt"),
|
||||
goCommand,
|
||||
SERVER_TARGET,
|
||||
);
|
||||
assertCleanRevision(repositoryRoot, revision);
|
||||
writeFileSync(join(runtimeRoot, "REVISION"), `${revision}\n`, "ascii");
|
||||
|
||||
@@ -224,20 +239,13 @@ try {
|
||||
"ascii",
|
||||
);
|
||||
|
||||
run("tar", ["-czf", artifactPath, "-C", runtimeRoot, "."], repositoryRoot);
|
||||
validateArchiveEntries(run("tar", ["-tzf", artifactPath], repositoryRoot));
|
||||
const tar = tarExecutable();
|
||||
run(tar, ["-czf", artifactPath, "-C", runtimeRoot, "."], repositoryRoot);
|
||||
validateArchiveEntries(run(tar, ["-tzf", artifactPath], repositoryRoot));
|
||||
mkdirSync(verifyRoot);
|
||||
run("tar", ["-xzf", artifactPath, "-C", verifyRoot], repositoryRoot);
|
||||
run(tar, ["-xzf", artifactPath, "-C", verifyRoot], repositoryRoot);
|
||||
compareRecords(records, recordsFor(verifyRoot));
|
||||
|
||||
const html = readFileSync(join(runtimeRoot, "dist/client/index.html"), "utf8");
|
||||
const mainAssetMatch = html.match(/<script[^>]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/);
|
||||
if (!mainAssetMatch) fail("Built client HTML has no unique main asset");
|
||||
const mainAsset = mainAssetMatch[1];
|
||||
if (!existsSync(join(runtimeRoot, "dist/client", ...mainAsset.split("/")))) {
|
||||
fail("Built client main asset is missing");
|
||||
}
|
||||
|
||||
const descriptor = {
|
||||
schema: 1,
|
||||
revision,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
|
||||
import { tarExecutable } from "./archive-tool.mjs";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
@@ -45,7 +46,7 @@ function sha256(path) {
|
||||
|
||||
function assertOutsideRepository(repositoryRoot, outputRoot) {
|
||||
const path = relative(repositoryRoot, outputRoot);
|
||||
if (path === "" || (!path.startsWith("..") && !isAbsolute(path))) {
|
||||
if (path === "" || (path.split(/[\\/]/)[0] !== ".." && !isAbsolute(path))) {
|
||||
fail("Client candidate output must be outside the repository");
|
||||
}
|
||||
if (existsSync(outputRoot)) {
|
||||
@@ -145,6 +146,7 @@ async function verifyLocalPackage(root, target, temporaryRoot) {
|
||||
const client = join(root, target.clientName);
|
||||
const port = await reservePort();
|
||||
const healthURL = `http://127.0.0.1:${port}/healthz`;
|
||||
const noticeURL = `http://127.0.0.1:${port}/third-party-licenses.txt`;
|
||||
const child = spawn(client, [
|
||||
"--local",
|
||||
"--config", join(temporaryRoot, "smoke-client.json"),
|
||||
@@ -195,6 +197,14 @@ async function verifyLocalPackage(root, target, temporaryRoot) {
|
||||
if (!healthReady || !clientReady) {
|
||||
fail("Packaged Client Local health did not become ready");
|
||||
}
|
||||
// The Web notices ship inside the binary; the embedded assets serve them.
|
||||
const notices = await fetch(noticeURL, {
|
||||
headers: { Connection: "close" },
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
if (!notices.ok || (await notices.text()).length === 0) {
|
||||
fail("Packaged Client did not serve its Web third-party notices");
|
||||
}
|
||||
child.stdin.write("\n");
|
||||
const exitCode = await waitForExit(child, 10_000);
|
||||
if (exitCode !== 0) {
|
||||
@@ -225,29 +235,18 @@ async function verifyLocalPackage(root, target, temporaryRoot) {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPackage(
|
||||
root,
|
||||
target,
|
||||
revision,
|
||||
expectedNodeVersion,
|
||||
temporaryRoot,
|
||||
) {
|
||||
async function verifyPackage(root, target, revision, temporaryRoot) {
|
||||
const packagedRevision = readFileSync(join(root, "REVISION"), "ascii").trim();
|
||||
if (packagedRevision !== revision) fail("Client package revision mismatch");
|
||||
for (const file of ["LICENSE", "THIRD-PARTY-NOTICES.txt", "app/LICENSE",
|
||||
"app/dist/client/third-party-licenses.txt", "runtime/node/LICENSE",
|
||||
for (const file of ["LICENSE", "THIRD-PARTY-NOTICES.txt",
|
||||
"runtime/tunnel/THIRD-PARTY-NOTICES.txt"]) {
|
||||
if (!existsSync(join(root, file)) || readFileSync(join(root, file)).length === 0) {
|
||||
fail(`Client package license text is missing: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
const node = join(root, "runtime", "node", target.nodeName);
|
||||
const client = join(root, target.clientName);
|
||||
const tunnel = join(root, "runtime", "tunnel", target.tunnelName);
|
||||
if (run(node, ["--version"], root) !== expectedNodeVersion) {
|
||||
fail(`Packaged Node runtime must be ${expectedNodeVersion}`);
|
||||
}
|
||||
run(client, ["--help"], root);
|
||||
run(tunnel, ["--version"], root);
|
||||
verifyPlatformAssets(root, target);
|
||||
@@ -323,10 +322,6 @@ if (process.platform !== target.nodePlatform || process.arch !== target.nodeArch
|
||||
assertOutsideRepository(repositoryRoot, outputRoot);
|
||||
|
||||
const revision = run("git", ["rev-parse", "HEAD"], repositoryRoot).toLowerCase();
|
||||
const expectedNodeVersion = `v${readFileSync(
|
||||
join(repositoryRoot, ".node-version"),
|
||||
"ascii",
|
||||
).trim()}`;
|
||||
const temporaryRoot = join(tmpdir(), "screener-client-candidate", target.id);
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
mkdirSync(temporaryRoot, { recursive: true, mode: 0o700 });
|
||||
@@ -342,7 +337,7 @@ try {
|
||||
|
||||
let tunnel = tunnelDownload;
|
||||
if (target.tunnelArchive) {
|
||||
run("tar", ["-xzf", tunnelDownload, "-C", temporaryRoot], repositoryRoot);
|
||||
run(tarExecutable(), ["-xzf", tunnelDownload, "-C", temporaryRoot], repositoryRoot);
|
||||
tunnel = join(temporaryRoot, target.tunnelName);
|
||||
}
|
||||
if (!existsSync(tunnel)) fail("Public-link runtime is missing");
|
||||
@@ -362,7 +357,6 @@ try {
|
||||
const assembleArguments = [
|
||||
join(repositoryRoot, "scripts", "assemble-client.mjs"),
|
||||
applicationDescriptor(applicationRoot),
|
||||
process.execPath,
|
||||
packageRoot,
|
||||
"--target",
|
||||
target.id,
|
||||
@@ -371,19 +365,13 @@ try {
|
||||
];
|
||||
if (capture) assembleArguments.push("--capture", capture);
|
||||
run(process.execPath, assembleArguments, repositoryRoot);
|
||||
await verifyPackage(
|
||||
packageRoot,
|
||||
target,
|
||||
revision,
|
||||
expectedNodeVersion,
|
||||
temporaryRoot,
|
||||
);
|
||||
await verifyPackage(packageRoot, target, revision, temporaryRoot);
|
||||
|
||||
mkdirSync(outputRoot, { recursive: false, mode: 0o700 });
|
||||
const shortRevision = revision.slice(0, 7);
|
||||
const archiveName = `Screener-Client-${target.id}-${shortRevision}.tar.gz`;
|
||||
const archive = join(outputRoot, archiveName);
|
||||
run("tar", ["-czf", archive, "-C", packageRoot, "."], repositoryRoot);
|
||||
run(tarExecutable(), ["-czf", archive, "-C", packageRoot, "."], repositoryRoot);
|
||||
const digest = sha256(archive);
|
||||
writeFileSync(join(outputRoot, `${archiveName}.sha256`), `${digest} ${archiveName}\n`, "ascii");
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
|
||||
@@ -10,6 +10,14 @@ const pinned = JSON.parse(readFileSync(join(root, "licenses", "upstream.json"),
|
||||
const licenseName = /^(licen[cs]e|copying|notice|copyright|authors)([._-]|$)/i;
|
||||
const section = (label, body) => `\n===== ${label} =====\n\n${body.trim()}\n`;
|
||||
|
||||
// The packages the Vite bundle ships to the Browser. Nothing installs
|
||||
// node_modules at runtime any more, so this list is the notice contract rather
|
||||
// than package.json's dependencies field.
|
||||
const WEB_BUNDLE_PACKAGES = ["react", "react-dom", "livekit-client", "sdp-transform", "zod"];
|
||||
|
||||
// Both binaries embed the Web bundle, which serves its own notice file.
|
||||
const WEB_NOTICE_LINE = "Web dependencies: served at /third-party-licenses.txt\n";
|
||||
|
||||
function pinnedNotice(key) {
|
||||
const entry = pinned[key];
|
||||
if (!entry) throw new Error(`Missing pinned license text for ${key}`);
|
||||
@@ -28,10 +36,8 @@ function noticesIn(directory) {
|
||||
|
||||
export function writeWebLicenseNotices(repositoryRoot, outputFile) {
|
||||
const packagePath = join(repositoryRoot, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
|
||||
const lock = JSON.parse(readFileSync(join(repositoryRoot, "package-lock.json"), "utf8"));
|
||||
const queue = [...Object.keys(pkg.dependencies), "react", "react-dom", "livekit-client", "sdp-transform"]
|
||||
.map((name) => [name, packagePath]);
|
||||
const queue = WEB_BUNDLE_PACKAGES.map((name) => [name, packagePath]);
|
||||
const visited = new Set();
|
||||
const entries = new Map();
|
||||
for (const [name, parent] of queue) {
|
||||
@@ -61,21 +67,16 @@ export function writeWebLicenseNotices(repositoryRoot, outputFile) {
|
||||
[...entries].sort(([a], [b]) => a.localeCompare(b)).map(([, body]) => body).join(""));
|
||||
}
|
||||
|
||||
export function writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target, tunnelVersion) {
|
||||
copyFileSync(join(repositoryRoot, "LICENSE"), join(packageRoot, "LICENSE"));
|
||||
const nodeVersion = readFileSync(join(repositoryRoot, ".node-version"), "ascii").trim();
|
||||
writeFileSync(join(packageRoot, "runtime", "node", "LICENSE"), pinnedNotice(`node@${nodeVersion}`));
|
||||
if (tunnelVersion) {
|
||||
writeFileSync(join(packageRoot, "runtime", "tunnel", "THIRD-PARTY-NOTICES.txt"),
|
||||
pinnedNotice(`cloudflared@${tunnelVersion}`));
|
||||
}
|
||||
const cwd = join(repositoryRoot, "native", "client");
|
||||
const options = { cwd, encoding: "utf8", windowsHide: true,
|
||||
// goNotices enumerates the Go toolchain notice and every license file that
|
||||
// belongs to a module actually compiled into `command` for `target`. A module
|
||||
// without a recognised license filename fails closed.
|
||||
function goNotices(repositoryRoot, goCommand, target, command) {
|
||||
const options = { cwd: repositoryRoot, encoding: "utf8", windowsHide: true,
|
||||
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: "0" } };
|
||||
const runGo = (args) => execFileSync(goCommand, args, options).trim();
|
||||
const goroot = runGo(["env", "GOROOT"]);
|
||||
const template = '{{if .Module}}{{if not .Module.Main}}[{{printf "%q" .Module.Path}},{{printf "%q" .Module.Version}},{{printf "%q" .Module.Dir}},{{printf "%q" .Dir}}]{{end}}{{end}}';
|
||||
const packages = runGo(["list", "-deps", "-f", template, "./cmd/screener-client"])
|
||||
const packages = runGo(["list", "-deps", "-f", template, command])
|
||||
.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
||||
const entries = new Map();
|
||||
for (const [name, version, directory, packageDirectory] of packages) {
|
||||
@@ -92,24 +93,57 @@ export function writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand
|
||||
}
|
||||
entries.set(key, files);
|
||||
}
|
||||
let text = "Native Client third-party software notices\n" +
|
||||
"Web and Server dependencies: app/dist/client/third-party-licenses.txt\n" +
|
||||
"Node runtime: runtime/node/LICENSE\n" +
|
||||
(tunnelVersion ? "Cloudflared: runtime/tunnel/THIRD-PARTY-NOTICES.txt\n" : "") +
|
||||
section(runGo(["version"]), readFileSync(join(goroot, "LICENSE"), "utf8"));
|
||||
if (target.goos === "windows" &&
|
||||
existsSync(join(packageRoot, "runtime", "native", target.captureName))) {
|
||||
const dependencies = JSON.parse(readFileSync(join(repositoryRoot, "native", "client",
|
||||
"platform", "windows", "capture", "libvpx-dependencies.json"), "utf8"));
|
||||
const name = `libvpx@${dependencies.libvpx.version}`;
|
||||
text += section(name, pinnedNotice(name));
|
||||
}
|
||||
for (const [name, files] of [...entries].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const toolchain = section(runGo(["version"]), readFileSync(join(goroot, "LICENSE"), "utf8"));
|
||||
return { toolchain, modules: entries };
|
||||
}
|
||||
|
||||
function moduleSections(modules) {
|
||||
let text = "";
|
||||
for (const [name, files] of [...modules].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
for (const [file, body] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
text += section(`${name}/${file}`, body);
|
||||
}
|
||||
}
|
||||
writeFileSync(join(packageRoot, "THIRD-PARTY-NOTICES.txt"), text);
|
||||
return text;
|
||||
}
|
||||
|
||||
// writeServerLicenseNotices produces the Hosted application release notice file,
|
||||
// which ships beside the single screener-server binary.
|
||||
export function writeServerLicenseNotices(repositoryRoot, outputFile, goCommand, target) {
|
||||
const notices = goNotices(repositoryRoot, goCommand, target, "./cmd/screener-server");
|
||||
writeFileSync(outputFile, "Screener server third-party software notices\n" +
|
||||
WEB_NOTICE_LINE + notices.toolchain + moduleSections(notices.modules));
|
||||
}
|
||||
|
||||
export function writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target, tunnelVersion) {
|
||||
copyFileSync(join(repositoryRoot, "LICENSE"), join(packageRoot, "LICENSE"));
|
||||
if (tunnelVersion) {
|
||||
writeFileSync(join(packageRoot, "runtime", "tunnel", "THIRD-PARTY-NOTICES.txt"),
|
||||
pinnedNotice(`cloudflared@${tunnelVersion}`));
|
||||
}
|
||||
const notices = goNotices(repositoryRoot, goCommand, target, "./cmd/screener-client");
|
||||
let text = "Native Client third-party software notices\n" +
|
||||
WEB_NOTICE_LINE +
|
||||
(tunnelVersion ? "Cloudflared: runtime/tunnel/THIRD-PARTY-NOTICES.txt\n" : "") +
|
||||
notices.toolchain;
|
||||
const linuxCapture = target.goos === "linux" && target.captureName &&
|
||||
existsSync(join(packageRoot, "runtime", "native", target.captureName));
|
||||
if (linuxCapture) {
|
||||
text += section("Linux system dependencies (not bundled)",
|
||||
readFileSync(join(repositoryRoot, "licenses", "linux-system-dependencies.txt"), "utf8"));
|
||||
text += section("LGPL-3.0-only (libportal)",
|
||||
readFileSync(join(repositoryRoot, "licenses", "LGPL-3.0.txt"), "utf8"));
|
||||
text += section("GPL-3.0 (libportal terms)",
|
||||
readFileSync(join(repositoryRoot, "licenses", "GPL-3.0.txt"), "utf8"));
|
||||
}
|
||||
if (target.goos === "windows" &&
|
||||
existsSync(join(packageRoot, "runtime", "native", target.captureName))) {
|
||||
const dependencies = JSON.parse(readFileSync(join(repositoryRoot, "native", "capture",
|
||||
"windows", "libvpx-dependencies.json"), "utf8"));
|
||||
const name = `libvpx@${dependencies.libvpx.version}`;
|
||||
text += section(name, pinnedNotice(name));
|
||||
}
|
||||
writeFileSync(join(packageRoot, "THIRD-PARTY-NOTICES.txt"), text + moduleSections(notices.modules));
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -15,15 +16,6 @@ import {
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
MAX_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
} from "../src/shared/media-copy-accounting";
|
||||
import {
|
||||
createScreenerServer,
|
||||
type ScreenerServer,
|
||||
} from "../src/server/app";
|
||||
import { loadConfig } from "../src/server/config";
|
||||
import {
|
||||
ROOM_CAPACITY,
|
||||
RoomStore,
|
||||
} from "../src/server/room-store";
|
||||
import {
|
||||
QUALITY_PROFILES,
|
||||
QUALITY_RESOLUTIONS,
|
||||
@@ -34,8 +26,8 @@ import {
|
||||
const PROFILE_SETTINGS = QUALITY_PROFILES;
|
||||
type ProfileId = QualityProfileId;
|
||||
type PageRole = "host" | "viewer";
|
||||
export type BenchmarkCodecMode = "vp8" | "auto" | "h264";
|
||||
export type BenchmarkCanaryMode = "none" | "viewer-mbb";
|
||||
type BenchmarkCodecMode = "vp8" | "auto" | "h264";
|
||||
type BenchmarkCanaryMode = "none" | "viewer-mbb";
|
||||
|
||||
const ROUTE_TIMING_KEYS = [
|
||||
"queueWaitMs",
|
||||
@@ -52,12 +44,12 @@ interface RouteTimingDistribution {
|
||||
p95Ms: number | null;
|
||||
maxMs: number | null;
|
||||
}
|
||||
export type BenchmarkRouteTimingSamples = {
|
||||
type BenchmarkRouteTimingSamples = {
|
||||
[Key in RouteTimingKey]: Array<
|
||||
RouteDiagnosticSnapshot["children"][number][Key]
|
||||
>;
|
||||
};
|
||||
export type BenchmarkRouteTimingSummary = Record<
|
||||
type BenchmarkRouteTimingSummary = Record<
|
||||
RouteTimingKey,
|
||||
RouteTimingDistribution
|
||||
>;
|
||||
@@ -70,7 +62,7 @@ interface ViewerMbbCanaryResult {
|
||||
failureCode?: "topology-unavailable" | "probe-unavailable";
|
||||
}
|
||||
|
||||
export interface BenchmarkConfig {
|
||||
interface BenchmarkConfig {
|
||||
chromePath: string;
|
||||
viewerCounts: number[];
|
||||
expectedEndpointCap: number;
|
||||
@@ -116,7 +108,7 @@ interface ConnectionObservation {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PagePerformanceSample {
|
||||
interface PagePerformanceSample {
|
||||
timestampSeconds: number;
|
||||
taskDurationSeconds: number;
|
||||
scriptDurationSeconds: number;
|
||||
@@ -190,7 +182,7 @@ interface RunCheck {
|
||||
expected: string;
|
||||
}
|
||||
|
||||
export interface RecoveryResult {
|
||||
interface RecoveryResult {
|
||||
triggered: boolean;
|
||||
relayPeerId?: string;
|
||||
affectedPeerIds?: string[];
|
||||
@@ -203,12 +195,12 @@ export interface RecoveryResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RecoveryHostPeaks {
|
||||
interface RecoveryHostPeaks {
|
||||
maxHostActiveMediaEdges?: number;
|
||||
maxHostAssignedChildren?: number;
|
||||
}
|
||||
|
||||
export interface RecoveryViewerBaseline {
|
||||
interface RecoveryViewerBaseline {
|
||||
label: string;
|
||||
routeRevision: number;
|
||||
upstreamKind: "peer" | "sfu";
|
||||
@@ -305,7 +297,7 @@ class BoundedLog {
|
||||
}
|
||||
}
|
||||
|
||||
export class CdpConnection {
|
||||
class CdpConnection {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<
|
||||
number,
|
||||
@@ -432,7 +424,7 @@ export class CdpConnection {
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeBenchmarkRouteTiming(
|
||||
function summarizeBenchmarkRouteTiming(
|
||||
samples: BenchmarkRouteTimingSamples,
|
||||
): BenchmarkRouteTimingSummary {
|
||||
return Object.fromEntries(
|
||||
@@ -467,7 +459,7 @@ function nearestRank(
|
||||
return sortedValues[Math.ceil(percentile * sortedValues.length) - 1] ?? null;
|
||||
}
|
||||
|
||||
export function buildRouteTimingCheck(
|
||||
function buildRouteTimingCheck(
|
||||
status: BenchmarkRun["routeTimingStatus"],
|
||||
summary: BenchmarkRouteTimingSummary | null,
|
||||
expectedChildCount = MAX_VIEWERS,
|
||||
@@ -492,7 +484,7 @@ export function buildRouteTimingCheck(
|
||||
};
|
||||
}
|
||||
|
||||
export async function joinViewerBurst<T>(
|
||||
async function joinViewerBurst<T>(
|
||||
viewerCount: number,
|
||||
createViewer: (viewerIndex: number) => Promise<T>,
|
||||
waitForMedia: (viewer: T, viewerIndex: number) => Promise<unknown>,
|
||||
@@ -523,7 +515,7 @@ export async function joinViewerBurst<T>(
|
||||
return viewers;
|
||||
}
|
||||
|
||||
export function parseViewerCounts(value: string | undefined): number[] {
|
||||
function parseViewerCounts(value: string | undefined): number[] {
|
||||
if (!value?.trim()) {
|
||||
return [...DEFAULT_VIEWER_COUNTS];
|
||||
}
|
||||
@@ -575,13 +567,13 @@ function parseBoolean(value: string | undefined, fallback: boolean, name: string
|
||||
throw new Error(`${name} must be true, false, 1, or 0`);
|
||||
}
|
||||
|
||||
export function parseBenchmarkCanaryMode(value: string | undefined): BenchmarkCanaryMode {
|
||||
function parseBenchmarkCanaryMode(value: string | undefined): BenchmarkCanaryMode {
|
||||
const mode = value?.trim() || "none";
|
||||
if (mode === "none" || mode === "viewer-mbb") return mode;
|
||||
throw new Error("BENCHMARK_CANARY must be none or viewer-mbb");
|
||||
}
|
||||
|
||||
export function parseBenchmarkCodecMode(
|
||||
function parseBenchmarkCodecMode(
|
||||
value: string | undefined,
|
||||
): BenchmarkCodecMode {
|
||||
const mode = value?.trim().toLowerCase() || "auto";
|
||||
@@ -589,7 +581,7 @@ export function parseBenchmarkCodecMode(
|
||||
throw new Error("BENCHMARK_CODEC_MODE must be vp8, auto, or h264");
|
||||
}
|
||||
|
||||
export function parseExpectedEndpointCap(value: string | undefined): number {
|
||||
function parseExpectedEndpointCap(value: string | undefined): number {
|
||||
const parsed = value?.trim()
|
||||
? Number(value)
|
||||
: DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY;
|
||||
@@ -605,7 +597,7 @@ export function parseExpectedEndpointCap(value: string | undefined): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseBenchmarkConfig(
|
||||
function parseBenchmarkConfig(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): BenchmarkConfig {
|
||||
const chromePath = environment.CHROME_PATH?.trim();
|
||||
@@ -709,7 +701,7 @@ export function parseBenchmarkConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseExternalServerTarget(
|
||||
function parseExternalServerTarget(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): ExternalServerTarget | null {
|
||||
const value = environment.BENCHMARK_SERVER_URL?.trim();
|
||||
@@ -736,7 +728,7 @@ export function parseExternalServerTarget(
|
||||
};
|
||||
}
|
||||
|
||||
export function activeVideoEdgeCount(
|
||||
function activeVideoEdgeCount(
|
||||
page: PageObservation,
|
||||
direction: "send" | "receive",
|
||||
): number {
|
||||
@@ -753,7 +745,7 @@ export function activeVideoEdgeCount(
|
||||
}).length;
|
||||
}
|
||||
|
||||
export function sanitizeFailurePageEvidence(
|
||||
function sanitizeFailurePageEvidence(
|
||||
page: PageObservation,
|
||||
): FailurePageEvidence {
|
||||
const connectionStates = new Set([
|
||||
@@ -891,7 +883,7 @@ const PAGE_PERFORMANCE_METRICS = {
|
||||
JSHeapUsedSize: "jsHeapUsedBytes",
|
||||
} as const satisfies Record<string, keyof PagePerformanceSample>;
|
||||
|
||||
export function parsePagePerformanceMetrics(
|
||||
function parsePagePerformanceMetrics(
|
||||
rawMetrics: unknown,
|
||||
): PagePerformanceSample | null {
|
||||
if (!Array.isArray(rawMetrics)) return null;
|
||||
@@ -927,7 +919,7 @@ export function parsePagePerformanceMetrics(
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSamples(samples: TimedSample[], viewerCount: number) {
|
||||
function summarizeSamples(samples: TimedSample[], viewerCount: number) {
|
||||
let maxHostActiveMediaEdges = 0;
|
||||
let maxHostAssignedChildren = 0;
|
||||
let maxRelayActiveMediaEdges = 0;
|
||||
@@ -1214,7 +1206,7 @@ function finiteMetric(metrics: Record<string, unknown>, key: string): number | n
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function summarizeViewerExperienceByDepth(samples: TimedSample[]) {
|
||||
function summarizeViewerExperienceByDepth(samples: TimedSample[]) {
|
||||
const cohorts = new Map<string, ViewerExperienceAccumulator>();
|
||||
let unresolvedRouteSamples = 0;
|
||||
const cohort = (route: ViewerExperienceRoute, depth: number | null) => {
|
||||
@@ -1516,7 +1508,7 @@ function finishPageRuntime(accumulator: PageRuntimeAccumulator) {
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizePageRuntimeResources(samples: TimedSample[]) {
|
||||
function summarizePageRuntimeResources(samples: TimedSample[]) {
|
||||
const accumulators: Record<PageRuntimeRole, PageRuntimeAccumulator> = {
|
||||
host: emptyPageRuntimeAccumulator(),
|
||||
viewer: emptyPageRuntimeAccumulator(),
|
||||
@@ -1593,7 +1585,7 @@ export function summarizePageRuntimeResources(samples: TimedSample[]) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRunChecks(
|
||||
function buildRunChecks(
|
||||
summary: NonNullable<BenchmarkRun["summary"]>,
|
||||
viewerCount: number,
|
||||
profileId: ProfileId,
|
||||
@@ -1656,7 +1648,7 @@ export function buildRunChecks(
|
||||
];
|
||||
}
|
||||
|
||||
export function buildRecoveryCheck(
|
||||
function buildRecoveryCheck(
|
||||
recovery: RecoveryResult,
|
||||
expectedEndpointCap = DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
): RunCheck {
|
||||
@@ -1676,7 +1668,7 @@ export function buildRecoveryCheck(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBenchmarkInitScript(options: {
|
||||
function buildBenchmarkInitScript(options: {
|
||||
label: string;
|
||||
role: PageRole;
|
||||
viewerIndex: number | null;
|
||||
@@ -2258,10 +2250,47 @@ async function reservePort(): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
function startProcess(command: string, args: string[], environment?: NodeJS.ProcessEnv): ManagedProcess {
|
||||
function buildStep(command: string, args: string[], failure: string): void {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout).trim() || failure);
|
||||
}
|
||||
}
|
||||
|
||||
// buildServer compiles the Screener server this run starts. The Web assets are
|
||||
// embedded, so the Vite build precedes the Go build and the Browser reaches the
|
||||
// same bundle the binary serves.
|
||||
function buildServer(): string {
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
const buildRoot = join(REPO_ROOT, "build", "peer-benchmark");
|
||||
mkdirSync(buildRoot, { recursive: true });
|
||||
const output = join(buildRoot,
|
||||
process.platform === "win32" ? "screener-server.exe" : "screener-server");
|
||||
if (process.platform === "win32") {
|
||||
buildStep(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"],
|
||||
"Browser client build failed");
|
||||
} else {
|
||||
buildStep("npm", ["run", "build:client"], "Browser client build failed");
|
||||
}
|
||||
buildStep(go, ["build", "-trimpath", "-o", output, "./cmd/screener-server"],
|
||||
"Screener server build failed");
|
||||
return output;
|
||||
}
|
||||
|
||||
function startProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
environment?: NodeJS.ProcessEnv,
|
||||
cwd: string = REPO_ROOT,
|
||||
): ManagedProcess {
|
||||
const log = new BoundedLog();
|
||||
const child = spawn(command, args, {
|
||||
cwd: REPO_ROOT,
|
||||
cwd,
|
||||
env: environment,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
@@ -2271,6 +2300,9 @@ function startProcess(command: string, args: string[], environment?: NodeJS.Proc
|
||||
return { child, log };
|
||||
}
|
||||
|
||||
// Readiness is the endpoint, never the spawned process: Chrome's Windows
|
||||
// launcher exits as soon as the browser process takes over the profile. The
|
||||
// deadline bounds the wait; the exit code and captured log explain a failure.
|
||||
async function waitForHttp(
|
||||
url: string,
|
||||
process: ManagedProcess,
|
||||
@@ -2279,9 +2311,6 @@ async function waitForHttp(
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (process.child.exitCode !== null) {
|
||||
throw new Error(`Process exited before ${url} was ready\n${process.log.tail()}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
|
||||
if (response.ok) {
|
||||
@@ -2292,7 +2321,9 @@ async function waitForHttp(
|
||||
}
|
||||
await delay(100, signal);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${url}\n${process.log.tail()}`);
|
||||
throw new Error(
|
||||
`Timed out waiting for ${url} (exit ${process.child.exitCode ?? "running"})\n${process.log.tail()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(process: ManagedProcess | null): Promise<void> {
|
||||
@@ -2359,7 +2390,7 @@ async function evaluate<T>(
|
||||
return evaluation.result.value as T;
|
||||
}
|
||||
|
||||
export async function createPage(
|
||||
async function createPage(
|
||||
cdp: CdpConnection,
|
||||
url: string,
|
||||
options: Parameters<typeof buildBenchmarkInitScript>[0],
|
||||
@@ -2479,7 +2510,7 @@ async function progressSample(
|
||||
);
|
||||
}
|
||||
|
||||
export function buildBenchmarkFailureEvidence(
|
||||
function buildBenchmarkFailureEvidence(
|
||||
pages: readonly PageObservation[],
|
||||
expectedPageCount: number,
|
||||
): BenchmarkFailureEvidence {
|
||||
@@ -2935,7 +2966,7 @@ function totalDecodedFrames(page: PageObservation): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function everyViewerAdvanced(
|
||||
function everyViewerAdvanced(
|
||||
before: PageObservation[],
|
||||
after: PageObservation[],
|
||||
): boolean {
|
||||
@@ -2991,7 +3022,7 @@ function recoveryViewerBaseline(
|
||||
};
|
||||
}
|
||||
|
||||
export function captureRecoveryViewerBaselines(
|
||||
function captureRecoveryViewerBaselines(
|
||||
pages: PageObservation[],
|
||||
): RecoveryViewerBaseline[] | null {
|
||||
const viewers = pages.filter((page) => page.role === "viewer");
|
||||
@@ -3001,7 +3032,7 @@ export function captureRecoveryViewerBaselines(
|
||||
: null;
|
||||
}
|
||||
|
||||
export function everyViewerRecoveredMedia(
|
||||
function everyViewerRecoveredMedia(
|
||||
baselines: RecoveryViewerBaseline[] | null,
|
||||
after: PageObservation[],
|
||||
): boolean {
|
||||
@@ -3030,7 +3061,7 @@ export function everyViewerRecoveredMedia(
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeRecoveryHostPeaks(
|
||||
function mergeRecoveryHostPeaks(
|
||||
peaks: RecoveryHostPeaks,
|
||||
host: PageObservation,
|
||||
): RecoveryHostPeaks {
|
||||
@@ -3590,7 +3621,7 @@ async function authenticateExternalServer(
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(): Promise<number> {
|
||||
async function main(): Promise<number> {
|
||||
let config: BenchmarkConfig;
|
||||
let externalServer: ExternalServerTarget | null;
|
||||
try {
|
||||
@@ -3656,7 +3687,7 @@ export async function main(): Promise<number> {
|
||||
runs: [],
|
||||
};
|
||||
|
||||
let server: ScreenerServer | null = null;
|
||||
let server: ManagedProcess | null = null;
|
||||
let chrome: ManagedProcess | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let profileDirectory: string | null = null;
|
||||
@@ -3673,30 +3704,32 @@ export async function main(): Promise<number> {
|
||||
const baseUrl = externalServer?.origin ?? `http://127.0.0.1:${appPort}`;
|
||||
profileDirectory = await mkdtemp(join(tmpdir(), "screener-peer-benchmark-"));
|
||||
if (appPort !== null) {
|
||||
const serverConfig = loadConfig({
|
||||
NODE_ENV: "development",
|
||||
// An explicit environment and a profile working directory: the measured
|
||||
// configuration is exactly these values, never the repository's .env or
|
||||
// the operator's shell (a stray LIVEKIT_* or SITE_ACCESS_PASSWORD would
|
||||
// silently change the topology this run reports).
|
||||
server = startProcess(buildServer(), [], {
|
||||
PATH: process.env.PATH,
|
||||
SystemRoot: process.env.SystemRoot,
|
||||
SCREENER_ENV: "development",
|
||||
PORT: String(appPort),
|
||||
LISTEN_HOST: "127.0.0.1",
|
||||
PUBLIC_BASE_URL: baseUrl,
|
||||
ALLOWED_ORIGINS: baseUrl,
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
ENDPOINT_MEDIA_COPY_CAPACITY: String(config.expectedEndpointCap),
|
||||
MAX_VIEWERS_PER_ROOM: String(Math.max(
|
||||
...config.viewerCounts,
|
||||
config.canaryMode === "viewer-mbb" ? 3 : 1,
|
||||
)),
|
||||
// The loopback gate measures peer topology, never a relayed candidate.
|
||||
STUN_URLS: "",
|
||||
});
|
||||
server = await createScreenerServer({
|
||||
config: serverConfig,
|
||||
frontend: { mode: "development" },
|
||||
roomStore: new RoomStore({
|
||||
leaseMs: serverConfig.roomLeaseMs,
|
||||
maxRooms: ROOM_CAPACITY,
|
||||
maxViewersPerRoom: serverConfig.maxViewersPerRoom,
|
||||
}),
|
||||
});
|
||||
await server.listen(appPort, "127.0.0.1");
|
||||
}, profileDirectory);
|
||||
await waitForHttp(
|
||||
`${baseUrl}/healthz`,
|
||||
server,
|
||||
20_000,
|
||||
abortController.signal,
|
||||
);
|
||||
}
|
||||
|
||||
const chromeArgs = [
|
||||
@@ -3773,7 +3806,7 @@ export async function main(): Promise<number> {
|
||||
}
|
||||
})(),
|
||||
),
|
||||
cleanup(server?.close() ?? Promise.resolve()),
|
||||
cleanup(stopProcess(server)),
|
||||
]);
|
||||
await cleanup(stopProcess(chrome));
|
||||
if (profileDirectory) {
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
export const DEFAULT_RELEASE_API_URL =
|
||||
"https://api.github.com/repos/TNTcraftHIM/Screener/releases/latest";
|
||||
|
||||
export const UPDATE_EXIT_CODES = Object.freeze({
|
||||
upToDate: 0,
|
||||
available: 10,
|
||||
unavailable: 20,
|
||||
});
|
||||
|
||||
const FULL_REVISION = /^[0-9a-f]{40}$/i;
|
||||
const RELEASE_PATH_PREFIX = "/TNTcraftHIM/Screener/releases/tag/";
|
||||
|
||||
export function normalizeRevision(value) {
|
||||
const revision = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
return FULL_REVISION.test(revision) ? revision : null;
|
||||
}
|
||||
|
||||
export function parseReleaseMetadata(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
if (value.draft === true || value.prerelease === true) {
|
||||
return null;
|
||||
}
|
||||
const revision = normalizeRevision(value.tag_name);
|
||||
if (!revision || typeof value.html_url !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let releaseURL;
|
||||
try {
|
||||
releaseURL = new URL(value.html_url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
releaseURL.protocol !== "https:" ||
|
||||
releaseURL.hostname !== "github.com" ||
|
||||
releaseURL.username ||
|
||||
releaseURL.password ||
|
||||
releaseURL.port ||
|
||||
releaseURL.search ||
|
||||
releaseURL.hash ||
|
||||
!releaseURL.pathname.startsWith(RELEASE_PATH_PREFIX)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let tag = "";
|
||||
try {
|
||||
tag = decodeURIComponent(releaseURL.pathname.slice(RELEASE_PATH_PREFIX.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (normalizeRevision(tag) !== revision) {
|
||||
return null;
|
||||
}
|
||||
return { revision, url: releaseURL.toString() };
|
||||
}
|
||||
|
||||
export function unavailableResult(currentRevision) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
currentRevision: normalizeRevision(currentRevision),
|
||||
latestRevision: null,
|
||||
releaseUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkRelease({
|
||||
currentRevision,
|
||||
apiURL = DEFAULT_RELEASE_API_URL,
|
||||
token = process.env.GITHUB_TOKEN || "",
|
||||
timeoutMs = 5_000,
|
||||
fetchImpl = globalThis.fetch,
|
||||
} = {}) {
|
||||
const current = normalizeRevision(currentRevision);
|
||||
if (!current || typeof fetchImpl !== "function") {
|
||||
return unavailableResult(currentRevision);
|
||||
}
|
||||
|
||||
let endpoint;
|
||||
try {
|
||||
endpoint = new URL(apiURL);
|
||||
} catch {
|
||||
return unavailableResult(currentRevision);
|
||||
}
|
||||
if (endpoint.protocol !== "https:" && endpoint.protocol !== "http:") {
|
||||
return unavailableResult(currentRevision);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const headers = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "Screener-release-check",
|
||||
};
|
||||
if (
|
||||
endpoint.protocol === "https:" &&
|
||||
endpoint.hostname === "api.github.com" &&
|
||||
typeof token === "string" &&
|
||||
token.trim() !== ""
|
||||
) {
|
||||
headers.Authorization = `Bearer ${token.trim()}`;
|
||||
}
|
||||
const response = await fetchImpl(endpoint.toString(), {
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
return unavailableResult(current);
|
||||
}
|
||||
const latest = parseReleaseMetadata(await response.json());
|
||||
if (!latest) {
|
||||
return unavailableResult(current);
|
||||
}
|
||||
return {
|
||||
status: latest.revision === current ? "up-to-date" : "update-available",
|
||||
currentRevision: current,
|
||||
latestRevision: latest.revision,
|
||||
releaseUrl: latest.url,
|
||||
};
|
||||
} catch {
|
||||
return unavailableResult(current);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return "Usage: node scripts/release-update.mjs --current-file <REVISION> [--api-url <URL>]";
|
||||
}
|
||||
|
||||
function parseArguments(argumentsList) {
|
||||
let currentFile = null;
|
||||
let apiURL = DEFAULT_RELEASE_API_URL;
|
||||
let apiURLSet = false;
|
||||
for (let index = 0; index < argumentsList.length; index += 1) {
|
||||
const name = argumentsList[index];
|
||||
const value = argumentsList[index + 1];
|
||||
if (
|
||||
(name === "--current-file" || name === "--api-url") &&
|
||||
(!value || value.startsWith("--"))
|
||||
) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
if (name === "--current-file" && currentFile === null) {
|
||||
currentFile = value;
|
||||
index += 1;
|
||||
} else if (name === "--api-url" && !apiURLSet) {
|
||||
apiURL = value;
|
||||
apiURLSet = true;
|
||||
index += 1;
|
||||
} else {
|
||||
throw new Error(usage());
|
||||
}
|
||||
}
|
||||
if (!currentFile) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
return { currentFile, apiURL };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let argumentsValue;
|
||||
try {
|
||||
argumentsValue = parseArguments(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : usage()}\n`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentRevision = null;
|
||||
try {
|
||||
currentRevision = readFileSync(resolve(argumentsValue.currentFile), "ascii");
|
||||
} catch {
|
||||
// An unavailable deployed identity is an unavailable check, not an update.
|
||||
}
|
||||
const result = await checkRelease({
|
||||
currentRevision,
|
||||
apiURL: argumentsValue.apiURL,
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
process.exitCode = UPDATE_EXIT_CODES[
|
||||
result.status === "update-available"
|
||||
? "available"
|
||||
: result.status === "up-to-date"
|
||||
? "upToDate"
|
||||
: "unavailable"
|
||||
];
|
||||
}
|
||||
|
||||
const entry = process.argv[1] ? resolve(process.argv[1]) : "";
|
||||
const self = resolve(dirname(fileURLToPath(import.meta.url)), "release-update.mjs");
|
||||
if (entry === self) {
|
||||
await main();
|
||||
}
|
||||
@@ -10,6 +10,8 @@ README.md
|
||||
package.json
|
||||
package-lock.json
|
||||
tsconfig.json
|
||||
go.mod
|
||||
go.sum
|
||||
.codex/config.toml
|
||||
.codex/README.md
|
||||
.agents/skills/ponytail/SKILL.md
|
||||
@@ -32,15 +34,16 @@ docs/deployment.md
|
||||
docs/operations/self-hosting.md
|
||||
docs/reference/configuration.md
|
||||
scripts/package-app-release.mjs
|
||||
scripts/release-update.mjs
|
||||
scripts/assemble-client.mjs
|
||||
scripts/client-icons.mjs
|
||||
scripts/package-client-candidate.mjs
|
||||
scripts/check-client.mjs
|
||||
scripts/client-native-host-gate.ts
|
||||
native/client/cmd/screener-peer-gate/main.go
|
||||
native/client/cmd/screener-client/screener.ico
|
||||
native/client/cmd/screener-client/screener_windows_amd64.syso
|
||||
cmd/screener-server/main.go
|
||||
cmd/screener-peer-gate/main.go
|
||||
cmd/screener-client/screener.ico
|
||||
cmd/screener-client/screener_windows_amd64.syso
|
||||
internal/server/webassets/dist/.gitkeep
|
||||
deploy/release-app.sh
|
||||
deploy/check-release.sh
|
||||
docs/adr/0001-p2p-first-media-topology.md
|
||||
@@ -48,16 +51,16 @@ docs/adr/0010-cross-platform-client-runtime.md
|
||||
docs/research/agent-context-governance.md
|
||||
docs/research/cross-platform-client-runtime.md
|
||||
docs/research/webrtc-p2p-screen-sharing.md
|
||||
native/client/platform/windows/capture/README.md
|
||||
native/client/platform/windows/capture/build.ps1
|
||||
native/client/platform/windows/capture/capture_target.cpp
|
||||
native/client/platform/windows/capture/capture_target.h
|
||||
native/client/platform/windows/capture/main.cpp
|
||||
native/client/platform/windows/capture/process_audio.cpp
|
||||
native/client/platform/windows/capture/process_audio.h
|
||||
native/client/platform/darwin/capture/README.md
|
||||
native/client/platform/darwin/capture/build.sh
|
||||
native/client/platform/darwin/capture/main.swift
|
||||
native/capture/windows/README.md
|
||||
native/capture/windows/build.ps1
|
||||
native/capture/windows/capture_target.cpp
|
||||
native/capture/windows/capture_target.h
|
||||
native/capture/windows/main.cpp
|
||||
native/capture/windows/process_audio.cpp
|
||||
native/capture/windows/process_audio.h
|
||||
native/capture/darwin/README.md
|
||||
native/capture/darwin/build.sh
|
||||
native/capture/darwin/main.swift
|
||||
scripts/required-project-paths.txt
|
||||
scripts/check-project-state.sh
|
||||
scripts/check-project-state.ps1
|
||||
|
||||
Reference in new issue
Block a user