diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index 8ea5660..8542692 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -6,6 +6,12 @@ on: branches: - main workflow_dispatch: + inputs: + native_checks: + description: Run the optional Native sender research checks + required: false + default: false + type: boolean permissions: contents: read @@ -18,26 +24,50 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Validate tracked project state - run: sh scripts/check-project-state.sh - env: - SCREENER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: 1.26.6 - cache-dependency-path: native/sender/go.sum - - name: Check native sender - working-directory: native/sender - run: | - go test ./... - go vet ./... - GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o "$RUNNER_TEMP/screener-sender.exe" ./cmd/screener-sender - name: Set up Node.js uses: actions/setup-node@v6 with: node-version-file: .node-version cache: npm + - name: Validate tracked project state + run: sh scripts/check-project-state.sh + env: + SCREENER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + - name: Detect Native sender scope + id: native-scope + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + EXPLICIT_NATIVE_CHECKS: ${{ inputs.native_checks || false }} + run: | + run_native=false + if [ "$EXPLICIT_NATIVE_CHECKS" = "true" ]; then + run_native=true + elif [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then + base="$BASE_SHA" + case "$base" in + ""|0000000000000000000000000000000000000000) + base=$(git rev-parse HEAD^ 2>/dev/null || true) + ;; + esac + if [ -n "$base" ] && ! git diff --quiet "$base" HEAD -- native/sender; then + run_native=true + fi + fi + echo "run=$run_native" >> "$GITHUB_OUTPUT" + - name: Set up Go + if: steps.native-scope.outputs.run == 'true' + uses: actions/setup-go@v6 + with: + go-version: 1.26.6 + cache-dependency-path: native/sender/go.sum + - name: Check native sender + if: steps.native-scope.outputs.run == 'true' + working-directory: native/sender + run: | + go test ./... + go vet ./... + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o "$RUNNER_TEMP/screener-sender.exe" ./cmd/screener-sender - name: Install dependencies run: npm ci - name: Type-check, test, and build diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 07734f5..a71aef8 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -232,8 +232,8 @@ deterministic candidate filters, ordering, cursor, reservations, and total deadline. Candidate Peer parents need a usable source path and available steady capacity; clear native paths are tried before the remaining deterministic Peer candidates but are not a hard eligibility tier. Only one candidate runs at a -time and the old route keeps playing. The same Viewer compares fresh old and -candidate receive windows. A P2P candidate commits only after first decoded +time and the old route keeps playing. The same Viewer compares fresh, +overlapping old and candidate receive windows. A P2P candidate commits only after first decoded frame and three consecutive windows with no freeze/pause, no lower pixel area or rounded FPS, and a strict improvement in at least one of those dimensions. Bitrate does not rank P2P candidates because codec and content phase make it diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md index 5eed20f..dc58c07 100644 --- a/docs/product/routing-transport.md +++ b/docs/product/routing-transport.md @@ -111,7 +111,7 @@ per-share convergence gate is enabled, Screener uses an exact persistent native sender limitation only to trigger one measured experiment through the existing serial operation. The same Viewer must then prove a P2P candidate strictly improves delivered pixel area or rounded FPS without regressing either dimension -or producing a freeze/pause across three fresh paired windows. A persistently +or producing a freeze/pause across three fresh overlapping windows. A persistently limited exact candidate sender cannot commit. Ordinary quality moves remain Peer-to-Peer. SFU may join quality work only as Host fanout relief when every current Host-origin Peer diff --git a/docs/todo.md b/docs/todo.md index 5665804..4028f82 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -11,23 +11,6 @@ No implementation task is currently authorized. The audit root-cause corrections are integrated and verified in current source but remain undeployed; deployment requires explicit authority. -## Parked Repository Work - -1. **Bounded mechanism simplification.** Remove only the proved zero-consumer or - duplicate owners: coarse `mediaAssignment` wire residue, legacy SFU room-name - acceptance, orphan `scripts/browser-codec-preflight.ts`, and the access gate's - test-title scan. The same bounded pass may remove the unused SFU publisher - snapshot callback, test-only production helpers, unused SFU token allowlist - input, debug ordinals after participant retirement, and share-unowned sender - evidence caches. Keep the production - `src/client/webrtc/video-codec-preflight.ts`, live internal media-assignment - helpers, and destructive-path safety gates. -2. **Tooling ownership correction.** Give every CDP call a deadline, move shared - Browser harness code out of the Native owner, type-check the access/privacy - gate, run repository hygiene under the pinned Node version, ignore untracked - drafts in canonical-document gates, and scope rejected Native checks to - Native changes or explicit research runs. - ## Parked Product Work 1. **Broader quality optimization.** Weighted/global optimization, diff --git a/scripts/access-privacy-gate.ts b/scripts/access-privacy-gate.ts index e58d488..fa12537 100644 --- a/scripts/access-privacy-gate.ts +++ b/scripts/access-privacy-gate.ts @@ -7,10 +7,16 @@ 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 { CdpConnection, cleanupRun, createPage, evaluate, reservePort, waitForVersion } from "./native-one-viewer-gate"; +import { + CdpConnection, + cleanupRun, + createPage, + evaluate, + reservePort, + waitForVersion, +} from "./browser-gate-harness"; const root = resolve(import.meta.dirname, ".."); const nginxConfigPath = resolve(root, "deploy/nginx/share.bonfire.icu.conf.example"); -const teardownTest = "strongly rotates and revokes every Viewer generation and media edge"; const fragmentProbe = `(() => { let domContentLoaded = false; const state = { fragmentAtStart: location.hash.length > 0, replaced: false, beforeDomContentLoaded: false }; @@ -32,7 +38,7 @@ interface GateReport { cookieGrantMatches: number; resourceGrantMatches: number; requestLeakCount: number; nginxLogLeakCount: number; nginxConfigSafe: boolean; appLogLeakCount: number; persistentRoomFileCount: number; leakAuditCompleted: boolean; - rotateRevokeCasePresent: boolean; cleanupPassed: boolean; + cleanupPassed: boolean; } interface BrowserAudit { fragmentSeenAtDocumentStart: boolean; fragmentReplaceObserved: boolean; @@ -65,8 +71,7 @@ async function main(): Promise { otherSessionGrantMatches: 0, localGrantMatches: 0, cookieGrantMatches: 0, resourceGrantMatches: 0, requestLeakCount: 0, nginxLogLeakCount: 0, nginxConfigSafe: false, appLogLeakCount: 0, persistentRoomFileCount: 0, - leakAuditCompleted: false, rotateRevokeCasePresent: false, - cleanupPassed: false, + leakAuditCompleted: false, cleanupPassed: false, }; const chromePath = process.env.CHROME_PATH?.trim() ?? ""; const sitePassword = secret(); @@ -84,9 +89,6 @@ async function main(): Promise { console.error = (...values: unknown[]) => { appLogs.push(diagnosticText(values)); }; try { - report.rotateRevokeCasePresent = (await readFile( - resolve(root, "tests/server-signal.test.ts"), "utf8", - )).includes(teardownTest); await access(chromePath); appPort = await reservePort(); do { debugPort = await reservePort(); } while (debugPort === appPort); @@ -197,7 +199,7 @@ async function main(): Promise { report.leakAuditCompleted && report.requestLeakCount === 0 && report.nginxLogLeakCount === 0 && report.nginxConfigSafe && report.appLogLeakCount === 0 && report.persistentRoomFileCount === 0 && - report.rotateRevokeCasePresent && report.cleanupPassed; + report.cleanupPassed; process.stdout.write(`${JSON.stringify(report)}\n`); process.exitCode = report.passed ? 0 : 1; } diff --git a/scripts/browser-codec-preflight.ts b/scripts/browser-codec-preflight.ts deleted file mode 100644 index 6cc2c34..0000000 --- a/scripts/browser-codec-preflight.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { createServer, type Server } from "node:http"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { AccessToken, TrackSource } from "livekit-server-sdk"; - -import { - CdpConnection, - cleanupRun, - createPage, - reservePort, - waitForVersion, - type PageHandle, -} from "./native-one-viewer-gate"; - -const WIDTH = 1_920; -const HEIGHT = 1_080; -const FRAME_RATE = 30; -const MAX_BITRATE = 5_000_000; -const SETTLE_MS = 5_000; -const SAMPLE_MS = 10_000; - -interface RuntimeResult { - result: { value?: T }; - exceptionDetails?: { - text?: string; - exception?: { description?: string }; - }; -} - -interface ProcessInfo { - type: string; - id: number; - cpuTime: number; -} - -interface ProcessSnapshot { - processInfo: ProcessInfo[]; -} - -async function evaluate( - cdp: CdpConnection, - page: PageHandle, - expression: string, - timeoutMs: number, -): Promise { - const value = await cdp.call>( - "Runtime.evaluate", - { - expression, - awaitPromise: true, - returnByValue: true, - userGesture: true, - }, - page.sessionId, - Date.now() + timeoutMs, - ); - if (value.exceptionDetails) { - throw new Error( - value.exceptionDetails.exception?.description ?? - value.exceptionDetails.text ?? - "Browser evaluation failed", - ); - } - return value.result.value as T; -} - -function sourceHtml(): string { - return `Codec Preflight Source - -`; -} - -async function startServer(port: number): Promise { - const livekitBundle = await readFile( - resolve("node_modules/livekit-client/dist/livekit-client.umd.js"), - ); - const server = createServer((request, response) => { - const path = new URL(request.url ?? "/", `http://127.0.0.1:${port}`).pathname; - if (path === "/livekit.js") { - response.setHeader("content-type", "text/javascript; charset=utf-8"); - response.end(livekitBundle); - return; - } - response.setHeader("content-type", "text/html; charset=utf-8"); - response.end(path === "/source" - ? sourceHtml() - : "Codec Preflight"); - }); - await new Promise((resolveListen, rejectListen) => { - server.once("error", rejectListen); - server.listen(port, "127.0.0.1", resolveListen); - }); - return server; -} - -function browserProbe(): string { - return `(() => { - const WIDTH=${WIDTH},HEIGHT=${HEIGHT},FRAME_RATE=${FRAME_RATE},MAX_BITRATE=${MAX_BITRATE}; - const SETTLE_MS=${SETTLE_MS},SAMPLE_MS=${SAMPLE_MS};let stream=null,track=null; - const delay=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms)); - async function waitFor(predicate,timeout,label){const deadline=performance.now()+timeout; - while(performance.now()item.mimeType.toLowerCase()===mime); - if(!primary.length)throw new Error(mime+' unavailable'); - const repair=new Set(['video/rtx','video/red','video/ulpfec','video/flexfec-03']); - return [...primary,...all.filter((item)=>repair.has(item.mimeType.toLowerCase()))]} - async function gathered(pc){if(pc.iceGatheringState==='complete')return; - await new Promise((resolve)=>{const changed=()=>{if(pc.iceGatheringState!=='complete')return; - pc.removeEventListener('icegatheringstatechange',changed);resolve()};pc.addEventListener('icegatheringstatechange',changed)})} - async function negotiate(a,b){await a.setLocalDescription(await a.createOffer());await gathered(a); - await b.setRemoteDescription(a.localDescription);await b.setLocalDescription(await b.createAnswer()); - await gathered(b);await a.setRemoteDescription(b.localDescription)} - function statsValue(item){if(!item)return null;const keys=['id','timestamp','bytesSent','bytesReceived', - 'framesEncoded','framesDecoded','framesDropped','framesPerSecond','frameWidth','frameHeight','totalEncodeTime', - 'totalDecodeTime','qualityLimitationReason','qualityLimitationDurations','encoderImplementation', - 'decoderImplementation','powerEfficientEncoder','powerEfficientDecoder','frames'];const value={}; - for(const key of keys)value[key]=item[key]??null;return value} - async function snapshot(sender,receiver,rendered){const [sendReport,receiveReport]=await Promise.all([sender.getStats(),receiver.getStats()]); - let outbound=null,inbound=null;sendReport.forEach((item)=>{if(item.type==='outbound-rtp'&&item.kind==='video'&&!item.isRemote)outbound=item}); - receiveReport.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video'&&!item.isRemote)inbound=item}); - const sendCodec=outbound?.codecId?sendReport.get(outbound.codecId):null; - const receiveCodec=inbound?.codecId?receiveReport.get(inbound.codecId):null; - const mediaSource=outbound?.mediaSourceId?sendReport.get(outbound.mediaSourceId):null; - const codec=(item)=>item?{mimeType:item.mimeType||null,sdpFmtpLine:item.sdpFmtpLine||null}:null; - return {rendered:rendered(),sender:statsValue(outbound),receiver:statsValue(inbound), - mediaSource:statsValue(mediaSource),senderCodec:codec(sendCodec),receiverCodec:codec(receiveCodec)}} - function delta(after,before,key){const a=after?.[key],b=before?.[key];return Number.isFinite(a)&&Number.isFinite(b)&&a>=b?a-b:null} - function summarize(before,after,sampleMs){const encoded=delta(after.sender,before.sender,'framesEncoded'); - const decoded=delta(after.receiver,before.receiver,'framesDecoded');const bytes=delta(after.sender,before.sender,'bytesSent'); - const sourceFrames=delta(after.mediaSource,before.mediaSource,'frames'); - const encodeTime=delta(after.sender,before.sender,'totalEncodeTime');const decodeTime=delta(after.receiver,before.receiver,'totalDecodeTime'); - return {senderCodec:after.senderCodec,receiverCodec:after.receiverCodec,sender:after.sender,receiver:after.receiver, - measured:{sampleMs,encodedFps:encoded===null?null:encoded*1000/sampleMs, - decodedFps:decoded===null?null:decoded*1000/sampleMs,bitrateKbps:bytes===null?null:bytes*8/sampleMs, - encodeMsPerFrame:encodeTime!==null&&encoded>0?encodeTime*1000/encoded:null, - decodeMsPerFrame:decodeTime!==null&&decoded>0?decodeTime*1000/decoded:null, - sourceFps:sourceFrames===null?after.mediaSource?.framesPerSecond:sourceFrames*1000/sampleMs, - encodedSourceRatio:sourceFrames>0&&encoded!==null?encoded/sourceFrames:null, - renderedFrames:delta(after,before,'rendered')}}} - async function setup(){stream=await navigator.mediaDevices.getDisplayMedia({video:{ - width:{ideal:WIDTH,max:WIDTH},height:{ideal:HEIGHT,max:HEIGHT},frameRate:{ideal:FRAME_RATE,max:FRAME_RATE}},audio:false}); - track=stream.getVideoTracks()[0];if(!track)throw new Error('No display video track');track.contentHint='motion'; - return {settings:track.getSettings(),constraints:track.getConstraints(),contentHint:track.contentHint,label:track.label}} - function capabilities(){const simple=(codec)=>({mimeType:codec.mimeType,sdpFmtpLine:codec.sdpFmtpLine||null}); - return {sender:(RTCRtpSender.getCapabilities('video')?.codecs||[]).map(simple), - receiver:(RTCRtpReceiver.getCapabilities('video')?.codecs||[]).map(simple)}} - async function edge(codec,sourceTrack){ - const sendPc=new RTCPeerConnection({iceServers:[]}),receivePc=new RTCPeerConnection({iceServers:[]}); - const transceiver=sendPc.addTransceiver(sourceTrack,{direction:'sendonly'});transceiver.setCodecPreferences(preferences(codec)); - let receiver=null,remoteTrack=null;receivePc.addEventListener('track',(event)=>{receiver=event.receiver;remoteTrack=event.track}); - await negotiate(sendPc,receivePc);await waitFor(()=>sendPc.connectionState==='connected'&&receivePc.connectionState==='connected',10000,'connection'); - await waitFor(()=>receiver&&remoteTrack,5000,'remote track');const video=document.createElement('video');let rendered=0; - video.autoplay=true;video.muted=true;video.playsInline=true;document.body.append(video); - video.srcObject=new MediaStream([remoteTrack]);if(video.requestVideoFrameCallback){const frame=()=>{rendered+=1;video.requestVideoFrameCallback(frame)};video.requestVideoFrameCallback(frame)} - await video.play();const parameters=transceiver.sender.getParameters();if(!parameters.encodings.length)parameters.encodings=[{}]; - parameters.encodings[0].maxBitrate=MAX_BITRATE;parameters.encodings[0].maxFramerate=FRAME_RATE; - parameters.encodings[0].scaleResolutionDownBy=1;parameters.degradationPreference='balanced';await transceiver.sender.setParameters(parameters); - await waitFor(async()=>{const report=await receiver.getStats();let frames=0;report.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video')frames=Math.max(frames,item.framesDecoded||0)});return frames>0&&rendered>0},10000,'decoded frame'); - return {sendPc,receivePc,sender:transceiver.sender,receiver,remoteTrack,rendered:()=>rendered}} - async function run(codec,count,topology){if(!track)throw new Error('Capture not initialized');const edges=[]; - try{edges.push(await edge(codec,track)); - if(topology==='relay')edges.push(await edge(codec,edges[0].remoteTrack)); - else for(let index=1;indexsnapshot(item.sender,item.receiver,item.rendered))),started=performance.now(); - await delay(SAMPLE_MS);const sampleMs=performance.now()-started; - const after=await Promise.all(edges.map((item)=>snapshot(item.sender,item.receiver,item.rendered))); - return edges.map((_,index)=>summarize(before[index],after[index],sampleMs))} - finally{for(const item of edges){item.sendPc.close();item.receivePc.close()}await delay(300)}} - async function runLivekit(codec,url,publisherToken,subscriberToken){if(!track)throw new Error('Capture not initialized'); - const sdk=globalThis.LivekitClient,publisher=new sdk.Room({dynacast:true}),subscriber=new sdk.Room();let remoteTrack=null,rendered=0; - subscriber.on(sdk.RoomEvent.TrackSubscribed,(nextTrack)=>{if(nextTrack.kind==='video')remoteTrack=nextTrack}); - try{await publisher.connect(url,publisherToken,{autoSubscribe:false,rtcConfig:{iceServers:[]}}); - await subscriber.connect(url,subscriberToken,{rtcConfig:{iceServers:[]}}); - const publication=await publisher.localParticipant.publishTrack(track,{source:sdk.Track.Source.ScreenShare, - videoCodec:codec,backupCodec:false,screenShareEncoding:{maxBitrate:MAX_BITRATE,maxFramerate:FRAME_RATE}, - degradationPreference:'balanced'}); - await waitFor(()=>publication.videoTrack?.sender&&remoteTrack,15000,'LiveKit publication'); - const video=document.createElement('video');video.autoplay=true;video.muted=true;video.playsInline=true;document.body.append(video); - video.srcObject=new MediaStream([remoteTrack.mediaStreamTrack]);if(video.requestVideoFrameCallback){const frame=()=>{rendered+=1;video.requestVideoFrameCallback(frame)};video.requestVideoFrameCallback(frame)} - await video.play();const receiver={getStats:async()=>{const report=await remoteTrack.getRTCStatsReport();if(!report)throw new Error('LiveKit receiver stats unavailable');return report}}; - await waitFor(async()=>{const report=await receiver.getStats();let frames=0;report?.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video')frames=Math.max(frames,item.framesDecoded||0)});return frames>0&&rendered>0},15000,'LiveKit decoded frame'); - await delay(SETTLE_MS);const sender=publication.videoTrack.sender; - const before=await snapshot(sender,receiver,()=>rendered),started=performance.now();await delay(SAMPLE_MS); - const sampleMs=performance.now()-started,after=await snapshot(sender,receiver,()=>rendered); - return {...summarize(before,after,sampleMs),senderParameters:sender.getParameters()}} - finally{await Promise.allSettled([publisher.disconnect(false),subscriber.disconnect(false)]);await delay(300)}} - function stop(){stream?.getTracks().forEach((item)=>item.stop())} - Object.defineProperty(globalThis,'__CODEC_PREFLIGHT__',{value:{setup,capabilities,run,runLivekit,stop}}); - })()`; -} - -async function processSnapshot(cdp: CdpConnection): Promise { - return cdp.call( - "SystemInfo.getProcessInfo", - {}, - undefined, - Date.now() + 5_000, - ); -} - -function processCpu( - before: ProcessSnapshot, - after: ProcessSnapshot, - wallMs: number, -): Record { - const baseline = new Map( - before.processInfo.map((item) => [`${item.type}:${item.id}`, item.cpuTime]), - ); - let cpuSeconds = 0; - let stableProcesses = 0; - for (const item of after.processInfo) { - const previous = baseline.get(`${item.type}:${item.id}`); - if (previous === undefined || item.cpuTime < previous) continue; - cpuSeconds += item.cpuTime - previous; - stableProcesses += 1; - } - const wallSeconds = wallMs / 1_000; - return { - wallSeconds, - cpuSeconds, - logicalCoreEquivalent: cpuSeconds / wallSeconds, - stableProcesses, - processIdentityChanged: - stableProcesses !== before.processInfo.length || - stableProcesses !== after.processInfo.length, - }; -} - -async function main(): Promise { - const chromePath = process.env.CHROME_PATH?.trim(); - if (!chromePath) throw new Error("CHROME_PATH must point to Chrome"); - const codec = process.env.CODEC_PREFLIGHT_CODEC?.trim().toLowerCase(); - if (codec !== "vp8" && codec !== "h264") { - throw new Error("CODEC_PREFLIGHT_CODEC must be vp8 or h264"); - } - const edges = Number(process.env.CODEC_PREFLIGHT_EDGES ?? "1"); - if (edges !== 1 && edges !== 2) { - throw new Error("CODEC_PREFLIGHT_EDGES must be 1 or 2"); - } - const topology = process.env.CODEC_PREFLIGHT_TOPOLOGY?.trim() || "direct"; - if (topology !== "direct" && topology !== "relay" && topology !== "livekit") { - throw new Error("CODEC_PREFLIGHT_TOPOLOGY must be direct, relay, or livekit"); - } - const [debugPort, pagePort] = await Promise.all([reservePort(), reservePort()]); - const profile = await mkdtemp(join(tmpdir(), "screener-codec-preflight-")); - let chrome: ChildProcessWithoutNullStreams | null = null; - let cdp: CdpConnection | null = null; - let server: Server | null = null; - try { - server = await startServer(pagePort); - chrome = spawn(chromePath, [ - `--remote-debugging-port=${debugPort}`, - `--user-data-dir=${profile}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-extensions", - "--disable-background-timer-throttling", - "--disable-backgrounding-occluded-windows", - "--disable-renderer-backgrounding", - "--autoplay-policy=no-user-gesture-required", - "--auto-select-tab-capture-source-by-title=Codec Preflight Source", - "--window-size=1920,1080", - "about:blank", - ], { stdio: "pipe", windowsHide: true }); - chrome.stdout.resume(); - chrome.stderr.resume(); - const version = await waitForVersion(debugPort, chrome); - cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5_000); - const gpuInfo = await cdp.call("SystemInfo.getInfo", {}, undefined, Date.now() + 5_000); - await createPage(cdp, `http://127.0.0.1:${pagePort}/source`, undefined, true); - const page = await createPage(cdp, `http://127.0.0.1:${pagePort}/test`, undefined, true); - await evaluate(cdp, page, browserProbe(), 5_000); - const capabilities = await evaluate( - cdp, - page, - "globalThis.__CODEC_PREFLIGHT__.capabilities()", - 5_000, - ); - const capture = await evaluate( - cdp, - page, - "globalThis.__CODEC_PREFLIGHT__.setup()", - 15_000, - ); - const before = await processSnapshot(cdp); - const started = performance.now(); - let media: unknown; - if (topology === "livekit") { - const livekitUrl = process.env.CODEC_PREFLIGHT_LIVEKIT_URL?.trim(); - const apiKey = process.env.CODEC_PREFLIGHT_LIVEKIT_KEY?.trim(); - const apiSecret = process.env.CODEC_PREFLIGHT_LIVEKIT_SECRET?.trim(); - if (!livekitUrl || !apiKey || !apiSecret) { - throw new Error("LiveKit preflight credentials are missing"); - } - const room = `codec-preflight-${Date.now()}`; - const token = async (identity: string, publish: boolean) => { - const access = new AccessToken(apiKey, apiSecret, { identity, ttl: 60 }); - access.addGrant({ - roomJoin: true, - room, - canPublish: publish, - canPublishSources: publish ? [TrackSource.SCREEN_SHARE] : undefined, - canSubscribe: !publish, - canPublishData: false, - }); - return access.toJwt(); - }; - const [publisherToken, subscriberToken] = await Promise.all([ - token("host", true), - token("viewer", false), - ]); - media = await evaluate( - cdp, - page, - `globalThis.__CODEC_PREFLIGHT__.runLivekit(${JSON.stringify(codec)}, ${JSON.stringify(livekitUrl)}, ${JSON.stringify(publisherToken)}, ${JSON.stringify(subscriberToken)})`, - 60_000, - ); - } else { - media = await evaluate( - cdp, - page, - `globalThis.__CODEC_PREFLIGHT__.run(${JSON.stringify(codec)}, ${edges}, ${JSON.stringify(topology)})`, - 45_000, - ); - } - const wallMs = performance.now() - started; - const after = await processSnapshot(cdp); - await evaluate(cdp, page, "globalThis.__CODEC_PREFLIGHT__.stop()", 5_000); - const report = { - schemaVersion: 1, - browser: version.Browser, - configuration: { - width: WIDTH, - height: HEIGHT, - frameRate: FRAME_RATE, - maxBitrate: MAX_BITRATE, - contentHint: "motion", - settleMs: SETTLE_MS, - sampleMs: SAMPLE_MS, - edges, - topology, - }, - gpuInfo, - capabilities, - capture, - codec, - media, - cpu: processCpu(before, after, wallMs), - }; - const outputDirectory = resolve("benchmark-results"); - await mkdir(outputDirectory, { recursive: true }); - const output = join( - outputDirectory, - `browser-codec-preflight-${codec}-${topology}-${edges}.json`, - ); - await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8"); - console.log(output); - } finally { - await cleanupRun({ - cdp, - native: null, - chrome, - server: null, - profile, - ports: [debugPort], - }); - if (server) { - await new Promise((resolveClose) => server!.close(() => resolveClose())); - } - } -} - -await main(); diff --git a/scripts/browser-gate-harness.ts b/scripts/browser-gate-harness.ts new file mode 100644 index 0000000..5491e6f --- /dev/null +++ b/scripts/browser-gate-harness.ts @@ -0,0 +1,538 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { lstat, readdir, rm } from "node:fs/promises"; +import { createConnection, createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; + +import WebSocket from "ws"; + +export interface PageHandle { + sessionId: string; +} + +interface CdpResult { + result: { value?: T }; + exceptionDetails?: unknown; +} + +export interface CleanupResult { + browserExited: boolean; + nativeExited: boolean; + serverClosed: boolean; + portsClosed: boolean; + profileRemoved: boolean; +} + +interface AsyncCloseable { + close(): Promise; +} + +export interface CleanupResources { + cdp: CdpConnection | null; + native: ChildProcessWithoutNullStreams | null; + chrome: ChildProcessWithoutNullStreams | null; + server: AsyncCloseable | null; + profile: string | null; + ports: number[]; +} + +export class CdpConnection { + private nextId = 1; + private readonly pending = new Map void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + }>(); + private closed = false; + + private constructor(private readonly socket: WebSocket) { + socket.on("message", (raw) => this.onMessage(raw.toString())); + socket.on("close", () => this.closePending()); + socket.on("error", () => this.closePending()); + } + + static async connect(url: string, deadline: number): Promise { + const socket = new WebSocket(url, { maxPayload: 16 * 1024 * 1024 }); + try { + await withDeadline(() => new Promise((resolveOpen, rejectOpen) => { + socket.once("open", resolveOpen); + socket.once("error", rejectOpen); + }), deadline); + } catch (error) { + socket.terminate(); + throw error; + } + return new CdpConnection(socket); + } + + call( + method: string, + params: Record, + sessionId: string | undefined, + deadline: number, + ): Promise { + if (this.closed || this.socket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error(`Cannot call ${method}: CDP is closed`)); + } + const id = this.nextId++; + return new Promise((resolveCall, rejectCall) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + rejectCall(new Error(`CDP command timed out: ${method}`)); + }, Math.max(0, deadline - Date.now())); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolveCall(value as T); + }, + reject: (error) => { + clearTimeout(timeout); + rejectCall(error); + }, + timeout, + }); + try { + this.socket.send(JSON.stringify({ id, method, params, sessionId })); + } catch (error) { + clearTimeout(timeout); + this.pending.delete(id); + rejectCall(error instanceof Error ? error : new Error("CDP send failed")); + } + }); + } + + close(): void { + if (!this.closed) this.socket.close(); + } + + private onMessage(raw: string): void { + let message: { id?: number; result?: unknown; error?: unknown }; + try { + message = JSON.parse(raw) as typeof message; + } catch { + return; + } + if (message.id === undefined) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error("CDP command failed")); + } else { + pending.resolve(message.result ?? {}); + } + } + + private closePending(): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("CDP connection closed")); + } + this.pending.clear(); + } +} + +export function withDeadline( + operation: () => Promise, + deadline: number, + now: () => number = Date.now, +): Promise { + const remaining = Math.max(0, deadline - now()); + return new Promise((resolveValue, rejectValue) => { + const timeout = setTimeout( + () => rejectValue(new Error("Operation deadline exceeded")), + remaining, + ); + Promise.resolve().then(operation).then( + (value) => { + clearTimeout(timeout); + resolveValue(value); + }, + (error: unknown) => { + clearTimeout(timeout); + rejectValue(error); + }, + ); + }); +} + +type JsonFetch = ( + input: string, + init: { signal: AbortSignal }, +) => Promise<{ ok: boolean; json(): Promise }>; + +export async function fetchJsonBefore( + url: string, + deadline: number, + fetcher: JsonFetch = fetch, +): Promise { + const controller = new AbortController(); + const abort = setTimeout( + () => controller.abort(), + Math.max(0, deadline - Date.now()), + ); + try { + const response = await withDeadline( + () => fetcher(url, { signal: controller.signal }), + deadline, + ); + if (!response.ok) throw new Error("HTTP response was not successful"); + return await withDeadline(() => response.json() as Promise, deadline); + } finally { + clearTimeout(abort); + controller.abort(); + } +} + +export async function waitForSample( + sample: (deadline: number) => Promise, + retainAndAccept: (value: T) => boolean, + timeoutMs: number, + pollMs = 100, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + let value: T | undefined; + try { + value = await withDeadline(() => sample(deadline), deadline); + } catch {} + if (value !== undefined && retainAndAccept(value)) return; + await delay(Math.min(pollMs, Math.max(0, deadline - Date.now()))); + } + throw new Error("Bounded browser stage timed out"); +} + +export async function createPage( + cdp: CdpConnection, + url: string, + probe?: string, + newWindow = false, + browserContextId?: string, +): Promise { + const deadline = Date.now() + 15_000; + const created = await cdp.call<{ targetId: string }>( + "Target.createTarget", + { + url: "about:blank", + background: false, + newWindow, + ...(browserContextId ? { browserContextId } : {}), + }, + undefined, + deadline, + ); + const attached = await cdp.call<{ sessionId: string }>( + "Target.attachToTarget", + { targetId: created.targetId, flatten: true }, + undefined, + deadline, + ); + const page = { sessionId: attached.sessionId }; + await Promise.all([ + cdp.call("Page.enable", {}, page.sessionId, deadline), + cdp.call("Runtime.enable", {}, page.sessionId, deadline), + ]); + if (probe) { + await cdp.call( + "Page.addScriptToEvaluateOnNewDocument", + { source: probe }, + page.sessionId, + deadline, + ); + } + await cdp.call("Page.navigate", { url }, page.sessionId, deadline); + await waitForSample( + (sampleDeadline) => evaluate( + cdp, + page, + "document.readyState === 'complete'", + sampleDeadline, + ), + Boolean, + Math.max(1, deadline - Date.now()), + ); + return page; +} + +export async function evaluate( + cdp: CdpConnection, + page: PageHandle, + expression: string, + deadline: number, +): Promise { + const value = await cdp.call>( + "Runtime.evaluate", + { + expression, + awaitPromise: true, + returnByValue: true, + userGesture: true, + }, + page.sessionId, + deadline, + ); + if (value.exceptionDetails) throw new Error("Browser evaluation failed"); + return value.result.value as T; +} + +export async function reservePort(): Promise { + const server = createServer(); + return new Promise((resolvePort, rejectPort) => { + server.once("error", rejectPort); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + rejectPort(new Error("Port reservation failed")); + return; + } + server.close((error) => + error ? rejectPort(error) : resolvePort(address.port), + ); + }); + }); +} + +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) { + try { + return await fetchJsonBefore<{ Browser: string; webSocketDebuggerUrl: string }>( + `http://127.0.0.1:${port}/json/version`, + deadline, + ); + } catch {} + await delay(Math.min(100, Math.max(0, deadline - Date.now()))); + } + throw new Error("Chrome startup timed out"); +} + +export async function cleanupRun( + resources: CleanupResources, +): Promise { + if (resources.cdp) { + try { + await resources.cdp.call( + "Browser.close", + {}, + undefined, + Date.now() + 2_000, + ); + } catch {} + resources.cdp.close(); + } + const browserExited = resources.chrome + ? await stopProcessTree(resources.chrome) + : true; + if (resources.native && processRunning(resources.native)) { + try { + resources.native.stdin.write("\n"); + } catch {} + } + const nativeExited = resources.native + ? await stopProcessTree(resources.native) + : true; + let serverClosed = true; + if (resources.server) { + try { + await withDeadline(() => resources.server!.close(), Date.now() + 5_000); + } catch { + serverClosed = false; + } + } + const portsClosed = await waitForPortsClosed( + [...new Set(resources.ports)], + 5_000, + ); + const profileRemoved = resources.profile + ? await removeGateProfile(resources.profile) + : true; + return { + browserExited, + nativeExited, + serverClosed, + portsClosed, + profileRemoved, + }; +} + +function processRunning(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode === null && child.signalCode === null; +} + +async function stopProcessTree( + child: ChildProcessWithoutNullStreams, +): Promise { + if (!processRunning(child)) return true; + if (await waitForExit(child, 2_000)) return true; + if (process.platform === "win32" && child.pid !== undefined) { + await runBounded( + "taskkill.exe", + ["/PID", String(child.pid), "/T", "/F"], + 5_000, + ); + } else { + child.kill("SIGTERM"); + if (await waitForExit(child, 2_000)) return true; + child.kill("SIGKILL"); + } + return waitForExit(child, 3_000); +} + +async function waitForExit( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise { + if (!processRunning(child)) return true; + return new Promise((resolveExit) => { + const onExit = () => { + clearTimeout(timeout); + resolveExit(true); + }; + const timeout = setTimeout(() => { + child.off("exit", onExit); + resolveExit(!processRunning(child)); + }, timeoutMs); + child.once("exit", onExit); + }); +} + +async function runBounded( + command: string, + args: string[], + timeoutMs: number, + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const child = spawn(command, args, { + stdio: "pipe", + windowsHide: true, + env: environment, + }); + child.stdout.resume(); + child.stderr.resume(); + try { + const code = await withDeadline( + () => new Promise((resolveExit, rejectExit) => { + child.once("error", rejectExit); + child.once("exit", resolveExit); + }), + Date.now() + timeoutMs, + ); + return code === 0; + } catch { + child.kill(); + return false; + } +} + +async function waitForPortsClosed( + ports: number[], + timeoutMs: number, +): Promise { + if (ports.length === 0) return true; + try { + await waitForSample( + (deadline) => Promise.all(ports.map((port) => portClosed(port, deadline))), + (closed) => closed.every(Boolean), + timeoutMs, + ); + return true; + } catch { + return false; + } +} + +function portClosed(port: number, deadline: number): Promise { + return withDeadline(() => new Promise((resolveClosed) => { + const socket = createConnection({ host: "127.0.0.1", port }); + const finish = (closed: boolean) => { + socket.destroy(); + resolveClosed(closed); + }; + socket.once("connect", () => finish(false)); + socket.once("error", () => finish(true)); + }), deadline); +} + +export const profileCleanupScript = String.raw` +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($env:SCREENER_GATE_PROFILE_TO_REMOVE) +$temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar) +$parent = [IO.Path]::GetDirectoryName($root).TrimEnd([IO.Path]::DirectorySeparatorChar) +$name = [IO.Path]::GetFileName($root) +if ($parent -ine $temp -or $name -notmatch '^screener-(native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$') { exit 31 } +$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly)) +if ($rootEntries.Count -eq 0) { exit 0 } +if ($rootEntries.Count -ne 1) { exit 34 } +$root = $rootEntries[0] +$rootAttributes = [IO.File]::GetAttributes($root) +if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } +if (($rootAttributes -band [IO.FileAttributes]::Directory) -eq 0) { exit 34 } +$pending = [Collections.Generic.Stack[string]]::new() +$pending.Push($root) +while ($pending.Count -gt 0) { + $directory = $pending.Pop() + $attributes = [IO.File]::GetAttributes($directory) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } + foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($directory)) { + $attributes = [IO.File]::GetAttributes($entry) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } + if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) { $pending.Push($entry) } + } +} +Remove-Item -LiteralPath $root -Recurse -Force +$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly)) +if ($rootEntries.Count -ne 0) { exit 33 } +`; + +export function isExactGateProfile( + profile: string, + systemTemp: string, +): boolean { + const candidate = resolve(profile); + const temp = resolve(systemTemp); + const same = process.platform === "win32" + ? dirname(candidate).toLowerCase() === temp.toLowerCase() + : dirname(candidate) === temp; + return same && + /^screener-(?:native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$/.test( + basename(candidate), + ); +} + +async function removeGateProfile(profile: string): Promise { + if (!isExactGateProfile(profile, tmpdir())) return false; + if (process.platform !== "win32") { + const pending = [profile]; + try { + while (pending.length > 0) { + const current = pending.pop()!; + const stat = await lstat(current); + if (stat.isSymbolicLink()) return false; + if (stat.isDirectory()) { + pending.push( + ...(await readdir(current)).map((name) => join(current, name)), + ); + } + } + await rm(profile, { recursive: true }); + return true; + } catch { + return false; + } + } + return runBounded( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", profileCleanupScript], + 10_000, + { ...process.env, SCREENER_GATE_PROFILE_TO_REMOVE: profile }, + ); +} + +function delay(ms: number): Promise { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 1aba5f4..024f259 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -6,11 +6,10 @@ import { dirname, extname, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const repositoryFiles = execFileSync( - "git", - ["ls-files", "--cached", "--others", "--exclude-standard"], - { cwd: root, encoding: "utf8" }, -) +const repositoryFiles = execFileSync("git", ["ls-files", "--cached"], { + cwd: root, + encoding: "utf8", +}) .trim() .split(/\r?\n/u) .filter((file) => file && existsSync(resolve(root, file))); diff --git a/scripts/native-one-viewer-gate-core.ts b/scripts/native-one-viewer-gate-core.ts index a78af9a..7d21dd1 100644 --- a/scripts/native-one-viewer-gate-core.ts +++ b/scripts/native-one-viewer-gate-core.ts @@ -1,4 +1,4 @@ -import { basename, dirname, resolve } from "node:path"; +import type { CleanupResult } from "./browser-gate-harness"; export interface ViewerIdentityEvidence { socketCount: number; @@ -50,14 +50,6 @@ export interface OneViewerIdentity { pionEdgeGeneration: number; } -export interface CleanupResult { - browserExited: boolean; - nativeExited: boolean; - serverClosed: boolean; - portsClosed: boolean; - profileRemoved: boolean; -} - interface FinalizableReport { status: "passed" | "failed"; failedStage: string | null; @@ -130,62 +122,6 @@ export async function verifyFinalSenderEvidence( - operation: () => Promise, - deadline: number, - now: () => number = Date.now, -): Promise { - const remaining = Math.max(0, deadline - now()); - return new Promise((resolveValue, rejectValue) => { - const timeout = setTimeout(() => rejectValue(new Error("Operation deadline exceeded")), remaining); - Promise.resolve().then(operation).then( - (value) => { clearTimeout(timeout); resolveValue(value); }, - (error: unknown) => { clearTimeout(timeout); rejectValue(error); }, - ); - }); -} - -type JsonFetch = ( - input: string, - init: { signal: AbortSignal }, -) => Promise<{ ok: boolean; json(): Promise }>; - -export async function fetchJsonBefore( - url: string, - deadline: number, - fetcher: JsonFetch = fetch, -): Promise { - const controller = new AbortController(); - const abort = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())); - try { - const response = await withDeadline( - () => fetcher(url, { signal: controller.signal }), - deadline, - ); - if (!response.ok) throw new Error("HTTP response was not successful"); - return await withDeadline(() => response.json() as Promise, deadline); - } finally { - clearTimeout(abort); - controller.abort(); - } -} - -export async function waitForSample( - sample: (deadline: number) => Promise, - retainAndAccept: (value: T) => boolean, - timeoutMs: number, - pollMs = 100, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - let value: T | undefined; - try { value = await withDeadline(() => sample(deadline), deadline); } catch {} - if (value !== undefined && retainAndAccept(value)) return; - await new Promise((resolveDelay) => setTimeout(resolveDelay, Math.min(pollMs, Math.max(0, deadline - Date.now())))); - } - throw new Error("Bounded browser stage timed out"); -} - export async function finalizeGate( report: FinalizableReport, gateSucceeded: boolean, @@ -208,12 +144,3 @@ export async function finalizeGate( report.failedStage = "cleanup"; } } - -export function isExactGateProfile(profile: string, systemTemp: string): boolean { - const candidate = resolve(profile); - const temp = resolve(systemTemp); - const same = process.platform === "win32" - ? dirname(candidate).toLowerCase() === temp.toLowerCase() - : dirname(candidate) === temp; - return same && /^screener-(?:native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$/.test(basename(candidate)); -} diff --git a/scripts/native-one-viewer-gate.ts b/scripts/native-one-viewer-gate.ts index 3026a54..29471b3 100644 --- a/scripts/native-one-viewer-gate.ts +++ b/scripts/native-one-viewer-gate.ts @@ -1,13 +1,11 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomBytes } from "node:crypto"; import { closeSync, fsyncSync, openSync, writeFileSync } from "node:fs"; -import { lstat, mkdtemp, readdir, rm } from "node:fs/promises"; -import { createConnection, createServer } from "node:net"; +import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import WebSocket from "ws"; import { createScreenerServer, type ScreenerServer } from "../src/server/app"; import { loadConfig } from "../src/server/config"; import { @@ -22,19 +20,24 @@ import { waitForSenderStart, type SenderStartObservation, } from "./native-one-viewer-ledger"; +import { + CdpConnection, + cleanupRun, + createPage, + evaluate, + reservePort, + waitForSample, + waitForVersion, + type PageHandle, +} from "./browser-gate-harness"; import { captureOneViewerIdentity, - fetchJsonBefore, finalizeGate, hasNoCriticalSenderErrors, hasOneViewerPionProof, - isExactGateProfile, retainsFirstBridgeSend, retainsOneViewerIdentity, - waitForSample, verifyFinalSenderEvidence, - withDeadline, - type CleanupResult, type OneViewerIdentity, type SenderIdentityEvidence, type ViewerIdentityEvidence, @@ -42,15 +45,6 @@ import { type Stage = "preflight" | "sender-start" | "node-host" | "viewer-signal" | "viewer-media" | "cleanup"; -export interface PageHandle { - sessionId: string; -} - -interface CdpResult { - result: { value?: T }; - exceptionDetails?: unknown; -} - interface GateReport { schemaVersion: 1; status: "passed" | "failed"; @@ -137,82 +131,6 @@ interface ViewerSnapshot { audioBytesReceived: number; } -export class CdpConnection { - private nextId = 1; - private readonly pending = new Map void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; - }>(); - - private constructor(private readonly socket: WebSocket) { - socket.on("message", (raw) => this.onMessage(raw.toString())); - socket.on("close", () => this.closePending()); - socket.on("error", () => this.closePending()); - } - - static async connect(url: string, deadline: number): Promise { - const socket = new WebSocket(url, { maxPayload: 16 * 1024 * 1024 }); - try { - await withDeadline(() => new Promise((resolveOpen, rejectOpen) => { - socket.once("open", resolveOpen); - socket.once("error", rejectOpen); - }), deadline); - } catch (error) { - socket.terminate(); - throw error; - } - return new CdpConnection(socket); - } - - call(method: string, params: Record, sessionId: string | undefined, deadline: number): Promise { - const id = this.nextId++; - return new Promise((resolveCall, rejectCall) => { - const remaining = Math.max(0, deadline - Date.now()); - const timeout = setTimeout(() => { - this.pending.delete(id); - rejectCall(new Error("CDP command timed out")); - }, remaining); - this.pending.set(id, { - resolve: (value) => { clearTimeout(timeout); resolveCall(value as T); }, - reject: (error) => { clearTimeout(timeout); rejectCall(error); }, - timeout, - }); - this.socket.send(JSON.stringify({ id, method, params, sessionId })); - }); - } - - close(): void { - this.socket.close(); - } - - private onMessage(raw: string): void { - let message: { id?: number; result?: unknown; error?: unknown }; - try { - message = JSON.parse(raw) as typeof message; - } catch { - return; - } - if (message.id === undefined) return; - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (message.error) { - pending.reject(new Error("CDP command failed")); - } else { - pending.resolve(message.result ?? {}); - } - } - - private closePending(): void { - for (const pending of this.pending.values()) { - clearTimeout(pending.timeout); - pending.reject(new Error("CDP connection closed")); - } - this.pending.clear(); - } -} - const root = resolve(import.meta.dirname, ".."); class ObservedRoomStore extends RoomStore { @@ -942,44 +860,6 @@ function viewerProbe(): string { })();`; } -export async function createPage( - cdp: CdpConnection, - url: string, - probe?: string, - newWindow = false, - browserContextId?: string, -): Promise { - const deadline = Date.now() + 15_000; - const created = await cdp.call<{ targetId: string }>( - "Target.createTarget", { - url: "about:blank", background: false, newWindow, - ...(browserContextId ? { browserContextId } : {}), - }, undefined, deadline, - ); - const attached = await cdp.call<{ sessionId: string }>("Target.attachToTarget", { targetId: created.targetId, flatten: true }, undefined, deadline); - const page = { sessionId: attached.sessionId }; - await Promise.all([ - cdp.call("Page.enable", {}, page.sessionId, deadline), - cdp.call("Runtime.enable", {}, page.sessionId, deadline), - ]); - if (probe) await cdp.call("Page.addScriptToEvaluateOnNewDocument", { source: probe }, page.sessionId, deadline); - await cdp.call("Page.navigate", { url }, page.sessionId, deadline); - await waitForSample( - (sampleDeadline) => evaluate(cdp, page, "document.readyState === 'complete'", sampleDeadline), - Boolean, - Math.max(1, deadline - Date.now()), - ); - return page; -} - -export async function evaluate(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise { - const value = await cdp.call>("Runtime.evaluate", { - expression, awaitPromise: true, returnByValue: true, userGesture: true, - }, page.sessionId, deadline); - if (value.exceptionDetails) throw new Error("Browser evaluation failed"); - return value.result.value as T; -} - function senderSnapshot(cdp: CdpConnection, page: PageHandle, deadline: number): Promise { return evaluate(cdp, page, "globalThis.__NATIVE_ONE_VIEWER_GATE__.snapshot()", deadline); } @@ -1003,18 +883,6 @@ function safeRoute(value: string | undefined): string { .has(value ?? "") ? value! : "unknown"; } -export async function reservePort(): Promise { - const server = createServer(); - return new Promise((resolvePort, rejectPort) => { - server.once("error", rejectPort); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") return rejectPort(new Error("Port reservation failed")); - server.close((error) => error ? rejectPort(error) : resolvePort(address.port)); - }); - }); -} - async function readLaunchUrl(child: ChildProcessWithoutNullStreams): Promise { let buffer = ""; return new Promise((resolveUrl, rejectUrl) => { @@ -1039,170 +907,6 @@ async function readLaunchUrl(child: ChildProcessWithoutNullStreams): Promise { - const deadline = Date.now() + 20_000; - while (Date.now() < deadline && child.exitCode === null) { - try { - return await fetchJsonBefore<{ Browser: string; webSocketDebuggerUrl: string }>( - `http://127.0.0.1:${port}/json/version`, deadline, - ); - } catch {} - await delay(Math.min(100, Math.max(0, deadline - Date.now()))); - } - throw new Error("Chrome startup timed out"); -} - -export interface CleanupResources { - cdp: CdpConnection | null; - native: ChildProcessWithoutNullStreams | null; - chrome: ChildProcessWithoutNullStreams | null; - server: ScreenerServer | null; - profile: string | null; - ports: number[]; -} - -export async function cleanupRun(resources: CleanupResources): Promise { - if (resources.cdp) { - try { await resources.cdp.call("Browser.close", {}, undefined, Date.now() + 2_000); } catch {} - resources.cdp.close(); - } - const browserExited = resources.chrome ? await stopProcessTree(resources.chrome) : true; - if (resources.native && processRunning(resources.native)) { - try { resources.native.stdin.write("\n"); } catch {} - } - const nativeExited = resources.native ? await stopProcessTree(resources.native) : true; - let serverClosed = true; - if (resources.server) { - try { await withDeadline(() => resources.server!.close(), Date.now() + 5_000); } catch { serverClosed = false; } - } - const portsClosed = await waitForPortsClosed([...new Set(resources.ports)], 5_000); - const profileRemoved = resources.profile ? await removeGateProfile(resources.profile) : true; - return { browserExited, nativeExited, serverClosed, portsClosed, profileRemoved }; -} - -function processRunning(child: ChildProcessWithoutNullStreams): boolean { - return child.exitCode === null && child.signalCode === null; -} - -async function stopProcessTree(child: ChildProcessWithoutNullStreams): Promise { - if (!processRunning(child)) return true; - if (await waitForExit(child, 2_000)) return true; - if (process.platform === "win32" && child.pid !== undefined) { - await runBounded("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], 5_000); - } else { - child.kill("SIGTERM"); - if (await waitForExit(child, 2_000)) return true; - child.kill("SIGKILL"); - } - return waitForExit(child, 3_000); -} - -async function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { - if (!processRunning(child)) return true; - return new Promise((resolveExit) => { - const onExit = () => { clearTimeout(timeout); resolveExit(true); }; - const timeout = setTimeout(() => { - child.off("exit", onExit); - resolveExit(!processRunning(child)); - }, timeoutMs); - child.once("exit", onExit); - }); -} - -async function runBounded(command: string, args: string[], timeoutMs: number, - environment: NodeJS.ProcessEnv = process.env): Promise { - const child = spawn(command, args, { stdio: "pipe", windowsHide: true, env: environment }); - child.stdout.resume(); - child.stderr.resume(); - try { - const code = await withDeadline(() => new Promise((resolveExit, rejectExit) => { - child.once("error", rejectExit); - child.once("exit", resolveExit); - }), Date.now() + timeoutMs); - return code === 0; - } catch { - child.kill(); - return false; - } -} - -async function waitForPortsClosed(ports: number[], timeoutMs: number): Promise { - if (ports.length === 0) return true; - try { - await waitForSample( - (deadline) => Promise.all(ports.map((port) => portClosed(port, deadline))), - (closed) => closed.every(Boolean), - timeoutMs, - ); - return true; - } catch { - return false; - } -} - -function portClosed(port: number, deadline: number): Promise { - return withDeadline(() => new Promise((resolveClosed) => { - const socket = createConnection({ host: "127.0.0.1", port }); - const finish = (closed: boolean) => { socket.destroy(); resolveClosed(closed); }; - socket.once("connect", () => finish(false)); - socket.once("error", () => finish(true)); - }), deadline); -} - -export const profileCleanupScript = String.raw` -$ErrorActionPreference = 'Stop' -$root = [IO.Path]::GetFullPath($env:SCREENER_GATE_PROFILE_TO_REMOVE) -$temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar) -$parent = [IO.Path]::GetDirectoryName($root).TrimEnd([IO.Path]::DirectorySeparatorChar) -$name = [IO.Path]::GetFileName($root) -if ($parent -ine $temp -or $name -notmatch '^screener-(native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$') { exit 31 } -$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly)) -if ($rootEntries.Count -eq 0) { exit 0 } -if ($rootEntries.Count -ne 1) { exit 34 } -$root = $rootEntries[0] -$rootAttributes = [IO.File]::GetAttributes($root) -if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } -if (($rootAttributes -band [IO.FileAttributes]::Directory) -eq 0) { exit 34 } -$pending = [Collections.Generic.Stack[string]]::new() -$pending.Push($root) -while ($pending.Count -gt 0) { - $directory = $pending.Pop() - $attributes = [IO.File]::GetAttributes($directory) - if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } - foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($directory)) { - $attributes = [IO.File]::GetAttributes($entry) - if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } - if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) { $pending.Push($entry) } - } -} -Remove-Item -LiteralPath $root -Recurse -Force -$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly)) -if ($rootEntries.Count -ne 0) { exit 33 } -`; - -async function removeGateProfile(profile: string): Promise { - if (!isExactGateProfile(profile, tmpdir())) return false; - if (process.platform !== "win32") { - const pending = [profile]; - try { - while (pending.length > 0) { - const current = pending.pop()!; - const stat = await lstat(current); - if (stat.isSymbolicLink()) return false; - if (stat.isDirectory()) { - pending.push(...(await readdir(current)).map((name) => join(current, name))); - } - } - await rm(profile, { recursive: true }); - return true; - } catch { - return false; - } - } - return runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", profileCleanupScript], - 10_000, { ...process.env, SCREENER_GATE_PROFILE_TO_REMOVE: profile }); -} - export function animatedSourceUrl(): string { const html = `Native Gate Source