@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.244.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +2 -2
- package/README.md +316 -27
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +196 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1254 -0
- package/client/browser-input-queue.ts +519 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-currency.ts +45 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1650 -1579
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/sidebar.tsx +1 -2
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +375 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +1060 -0
- package/client/use-browser-video.ts +481 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -4
- package/npm-shrinkwrap.json +9 -9
- package/package.json +10 -5
- package/paseo-plugin.json +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1601 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1316 -151
- package/server/browser-video-input-authority.test.ts +211 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +340 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/tab-close-confirmation.test.ts +97 -0
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +83 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { createRuntimeOwner } from "./runtime-owner";
|
|
3
|
+
|
|
4
|
+
type Owner = Awaited<ReturnType<typeof createRuntimeOwner>>;
|
|
5
|
+
type Owned = Parameters<Owner["request"]>[0];
|
|
6
|
+
|
|
7
|
+
beforeEach(() => vi.useFakeTimers());
|
|
8
|
+
afterEach(() => vi.useRealTimers());
|
|
9
|
+
|
|
10
|
+
async function fixture(lingerSnapshots: number | null) {
|
|
11
|
+
const owner = await createRuntimeOwner({ virtualDisplay: false, nativeVideo: false });
|
|
12
|
+
const state = { ids: ["root", "closing"], closing: false, polls: 0, next: 0 };
|
|
13
|
+
const native = {
|
|
14
|
+
targets: vi.fn(async () => {
|
|
15
|
+
if (state.closing && lingerSnapshots !== null && state.polls++ >= lingerSnapshots) {
|
|
16
|
+
state.ids = state.ids.filter((id) => id !== "closing");
|
|
17
|
+
} else if (state.closing && lingerSnapshots === null) {
|
|
18
|
+
state.polls++;
|
|
19
|
+
}
|
|
20
|
+
return state.ids.map((targetId) => ({ targetId, title: targetId, url: "about:blank" }));
|
|
21
|
+
}),
|
|
22
|
+
closeTarget: vi.fn(async () => {
|
|
23
|
+
state.closing = true;
|
|
24
|
+
}),
|
|
25
|
+
createTarget: vi.fn(async () => {
|
|
26
|
+
const id = `new-${++state.next}`;
|
|
27
|
+
state.ids.unshift(id);
|
|
28
|
+
return id;
|
|
29
|
+
}),
|
|
30
|
+
selectTarget: vi.fn(async () => {}),
|
|
31
|
+
};
|
|
32
|
+
const owned = {
|
|
33
|
+
runtimeId: "review-runtime",
|
|
34
|
+
runtime: native,
|
|
35
|
+
rootTargetId: "root",
|
|
36
|
+
runtimeOptions: {},
|
|
37
|
+
tabs: new Map(),
|
|
38
|
+
tabOrder: ["root", "closing"],
|
|
39
|
+
display: null,
|
|
40
|
+
displayNotice: null,
|
|
41
|
+
} as unknown as Owned;
|
|
42
|
+
return { owner, owned, native, state };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it("confirms an acknowledged close only after the target disappears without replay", async () => {
|
|
46
|
+
const f = await fixture(2);
|
|
47
|
+
const pending = f.owner.request(f.owned, "tabs.close", { targetId: "closing" });
|
|
48
|
+
await vi.runAllTimersAsync();
|
|
49
|
+
await expect(pending).resolves.toBeNull();
|
|
50
|
+
expect(f.native.closeTarget).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(f.state.polls).toBe(3);
|
|
52
|
+
expect(
|
|
53
|
+
((await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[]).map(
|
|
54
|
+
(x) => x.targetId,
|
|
55
|
+
),
|
|
56
|
+
).toEqual(["root"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("fails explicitly, without replaying close or reordering, when the target never disappears", async () => {
|
|
60
|
+
const f = await fixture(null);
|
|
61
|
+
const outcome = f.owner.request(f.owned, "tabs.close", { targetId: "closing" }).then(
|
|
62
|
+
() => "ok",
|
|
63
|
+
(error) => String(error),
|
|
64
|
+
);
|
|
65
|
+
await vi.runAllTimersAsync();
|
|
66
|
+
expect(await outcome).toContain(
|
|
67
|
+
"CdpUnknownOutcomeError: Browser tab close could not be confirmed",
|
|
68
|
+
);
|
|
69
|
+
expect(f.native.closeTarget).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(f.state.polls).toBe(20);
|
|
71
|
+
const ids = (await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[];
|
|
72
|
+
expect(ids.map((tab) => tab.targetId)).toEqual(["root", "closing"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("retains known ordering across unordered snapshots and appends explicit creations", async () => {
|
|
76
|
+
const f = await fixture(null);
|
|
77
|
+
f.state.ids = ["third", "closing", "root"];
|
|
78
|
+
const ids = async () =>
|
|
79
|
+
((await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[]).map(
|
|
80
|
+
(x) => x.targetId,
|
|
81
|
+
);
|
|
82
|
+
expect(await ids()).toEqual(["root", "closing", "third"]);
|
|
83
|
+
f.state.ids = ["closing", "root", "third"];
|
|
84
|
+
expect(await ids()).toEqual(["root", "closing", "third"]);
|
|
85
|
+
await f.owner.request(f.owned, "tabs.create", null);
|
|
86
|
+
await f.owner.request(f.owned, "tabs.create", null);
|
|
87
|
+
expect(await ids()).toEqual(["root", "closing", "third", "new-1", "new-2"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("preserves the existing last-tab refusal", async () => {
|
|
91
|
+
const f = await fixture(null);
|
|
92
|
+
f.state.ids = ["root"];
|
|
93
|
+
await expect(f.owner.request(f.owned, "tabs.close", { targetId: "root" })).rejects.toThrow(
|
|
94
|
+
"last browser tab",
|
|
95
|
+
);
|
|
96
|
+
expect(f.native.closeTarget).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { createVideoSourceRecovery } from "./video-source-recovery";
|
|
3
|
+
|
|
4
|
+
it("uses monotonic cooldown and clears permanent refusal only on exact configuration replacement", () => {
|
|
5
|
+
let now = 0;
|
|
6
|
+
const recovery = createVideoSourceRecovery(() => now);
|
|
7
|
+
const context = { connection: {}, page: {}, viewport: {}, attachmentGeneration: 0 };
|
|
8
|
+
recovery.failed(context, "startup");
|
|
9
|
+
expect(recovery.blocked(context)).toBe("startup");
|
|
10
|
+
now = 2999;
|
|
11
|
+
expect(recovery.blocked(context)).toBe("startup");
|
|
12
|
+
now = 3000;
|
|
13
|
+
expect(recovery.blocked(context)).toBeNull();
|
|
14
|
+
recovery.failed(context, "source-dimensions");
|
|
15
|
+
now = 1_000_000;
|
|
16
|
+
expect(recovery.blocked({ ...context })).toBe("source-dimensions");
|
|
17
|
+
expect(recovery.blocked({ ...context, viewport: {} })).toBeNull();
|
|
18
|
+
recovery.failed(context, "source-dimensions");
|
|
19
|
+
expect(recovery.blocked({ ...context, attachmentGeneration: 1 })).toBeNull();
|
|
20
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Native source recovery is viewing-only and never retries a browser mutation.
|
|
2
|
+
* Transient failures wait three monotonic seconds after failure admission, and
|
|
3
|
+
* retirement independently gates replacement. A proven
|
|
4
|
+
* size mismatch remains blocked until the owning attachment or viewport changes.
|
|
5
|
+
*/
|
|
6
|
+
import { performance } from "node:perf_hooks";
|
|
7
|
+
|
|
8
|
+
export const VIDEO_SOURCE_RECOVERY_COOLDOWN_MS = 3_000;
|
|
9
|
+
export type VideoSourceFailureCode =
|
|
10
|
+
| "startup"
|
|
11
|
+
| "source-stopped"
|
|
12
|
+
| "source-reset"
|
|
13
|
+
| "source-dimensions";
|
|
14
|
+
export interface VideoSourceRecoveryContext {
|
|
15
|
+
connection: unknown;
|
|
16
|
+
page: unknown;
|
|
17
|
+
viewport: unknown;
|
|
18
|
+
attachmentGeneration: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Keep failure admission separate from track lifetime, so demand cannot pin a
|
|
22
|
+
* failed source and a stopped permanent failure cannot spin up replacements. */
|
|
23
|
+
export function createVideoSourceRecovery(now: () => number = () => performance.now()) {
|
|
24
|
+
let context: VideoSourceRecoveryContext | null = null;
|
|
25
|
+
let failure: VideoSourceFailureCode | null = null;
|
|
26
|
+
let retryAfter = 0;
|
|
27
|
+
const sameContext = (value: VideoSourceRecoveryContext): boolean => {
|
|
28
|
+
if (context === null) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return (
|
|
32
|
+
value.connection === context.connection &&
|
|
33
|
+
value.page === context.page &&
|
|
34
|
+
value.viewport === context.viewport &&
|
|
35
|
+
value.attachmentGeneration === context.attachmentGeneration
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
const selectContext = (value: VideoSourceRecoveryContext) => {
|
|
39
|
+
if (sameContext(value)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
context = { ...value };
|
|
43
|
+
failure = null;
|
|
44
|
+
retryAfter = 0;
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
/** Returns a private reason while acquisition is blocked. Document changes
|
|
48
|
+
* alone cannot cure a mismatch in this exact configured pixel geometry. */
|
|
49
|
+
blocked(value: VideoSourceRecoveryContext): VideoSourceFailureCode | null {
|
|
50
|
+
selectContext(value);
|
|
51
|
+
if (failure === "source-dimensions" || now() < retryAfter) {
|
|
52
|
+
return failure;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
},
|
|
56
|
+
/** Start the cooldown before asynchronous retirement, closing the admission
|
|
57
|
+
* gap even when cleanup is slow. Cleanup completion remains mandatory. */
|
|
58
|
+
failed(value: VideoSourceRecoveryContext, code: VideoSourceFailureCode): void {
|
|
59
|
+
selectContext(value);
|
|
60
|
+
failure = code;
|
|
61
|
+
retryAfter = now() + VIDEO_SOURCE_RECOVERY_COOLDOWN_MS;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Releases optional capture resources when their last viewer lease expires.
|
|
3
|
+
* Pixel receipts are not ownership: failed startup can still own a track/helper.
|
|
4
|
+
* The browser itself stays alive. Callers recheck current viewers before cleanup.
|
|
5
|
+
*/
|
|
6
|
+
export interface ViewerCaptureLifetime {
|
|
7
|
+
/** Start or refresh cleanup scheduling after capture is attempted. */
|
|
8
|
+
arm(): void;
|
|
9
|
+
/** Cancel this lifetime during explicit cleanup or session disposal. */
|
|
10
|
+
cancel(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Own one unreferenced timer per session, using current viewer expiry facts. */
|
|
14
|
+
export function createViewerCaptureLifetime(options: {
|
|
15
|
+
now(): number;
|
|
16
|
+
latestExpiry(): number | null;
|
|
17
|
+
onExpired(): Promise<void>;
|
|
18
|
+
}): ViewerCaptureLifetime {
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
20
|
+
let revision = 0;
|
|
21
|
+
|
|
22
|
+
function cancel(): void {
|
|
23
|
+
revision += 1;
|
|
24
|
+
if (timer) clearTimeout(timer);
|
|
25
|
+
timer = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function arm(): void {
|
|
29
|
+
cancel();
|
|
30
|
+
const expiry = options.latestExpiry();
|
|
31
|
+
if (expiry === null) return;
|
|
32
|
+
const scheduledRevision = revision;
|
|
33
|
+
timer = setTimeout(
|
|
34
|
+
async () => {
|
|
35
|
+
timer = null;
|
|
36
|
+
if (scheduledRevision !== revision) return;
|
|
37
|
+
const currentExpiry = options.latestExpiry();
|
|
38
|
+
if (currentExpiry !== null && currentExpiry > options.now()) {
|
|
39
|
+
arm();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// A slow cleanup must not clear a newer viewer's schedule or replay work.
|
|
43
|
+
await options.onExpired().catch(() => undefined);
|
|
44
|
+
},
|
|
45
|
+
Math.max(1, expiry - options.now()),
|
|
46
|
+
);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { arm, cancel };
|
|
51
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { beforeEach, expect, it, vi } from "vitest";
|
|
6
|
+
import { createPrivateVirtualDisplay, encodeDisplayAuthority } from "./virtual-display";
|
|
7
|
+
|
|
8
|
+
/** Host-native path: production joins with the platform separator. */
|
|
9
|
+
const fixtureDirectory = join(tmpdir(), "private-display-fixture");
|
|
10
|
+
|
|
11
|
+
const fake = vi.hoisted(() => ({
|
|
12
|
+
access: vi.fn(),
|
|
13
|
+
chmod: vi.fn(),
|
|
14
|
+
mkdtemp: vi.fn(),
|
|
15
|
+
rm: vi.fn(),
|
|
16
|
+
writeFile: vi.fn(),
|
|
17
|
+
spawn: vi.fn(),
|
|
18
|
+
connect: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock("node:fs/promises", () => fake);
|
|
21
|
+
vi.mock("node:child_process", () => ({ spawn: fake.spawn }));
|
|
22
|
+
vi.mock("node:net", () => ({ createConnection: fake.connect }));
|
|
23
|
+
|
|
24
|
+
let child: EventEmitter & {
|
|
25
|
+
pid: number;
|
|
26
|
+
exitCode: number | null;
|
|
27
|
+
signalCode: string | null;
|
|
28
|
+
stdio: unknown[];
|
|
29
|
+
kill: ReturnType<typeof vi.fn>;
|
|
30
|
+
};
|
|
31
|
+
let ready: Readable;
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
fake.access.mockResolvedValue(undefined);
|
|
35
|
+
fake.chmod.mockResolvedValue(undefined);
|
|
36
|
+
fake.mkdtemp.mockResolvedValue(fixtureDirectory);
|
|
37
|
+
fake.writeFile.mockResolvedValue(undefined);
|
|
38
|
+
fake.rm.mockResolvedValue(undefined);
|
|
39
|
+
ready = new Readable({ read() {} });
|
|
40
|
+
child = Object.assign(new EventEmitter(), {
|
|
41
|
+
pid: 123,
|
|
42
|
+
exitCode: null,
|
|
43
|
+
signalCode: null,
|
|
44
|
+
stdio: [null, null, null, ready],
|
|
45
|
+
kill: vi.fn(() => {
|
|
46
|
+
child.signalCode = "SIGTERM";
|
|
47
|
+
child.emit("exit", null, "SIGTERM");
|
|
48
|
+
return true;
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
fake.spawn.mockImplementation((_path, args) => {
|
|
52
|
+
queueMicrotask(() => ready.push(`${args[0].slice(1)}\n`));
|
|
53
|
+
return child;
|
|
54
|
+
});
|
|
55
|
+
fake.connect.mockImplementation(() => {
|
|
56
|
+
const socket = Object.assign(new EventEmitter(), {
|
|
57
|
+
destroy: vi.fn(),
|
|
58
|
+
write: vi.fn((packet: Buffer) => {
|
|
59
|
+
const response = Buffer.alloc(8);
|
|
60
|
+
response[0] = packet.readUInt16LE(8) === 16 ? 1 : 0;
|
|
61
|
+
response.writeUInt16LE(11, 2);
|
|
62
|
+
queueMicrotask(() => socket.emit("data", response));
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
queueMicrotask(() => socket.emit("connect"));
|
|
66
|
+
return socket;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps non-Linux and missing Xvfb on the original path without side effects", async () => {
|
|
71
|
+
expect(await createPrivateVirtualDisplay({ platform: "win32" })).toBeNull();
|
|
72
|
+
expect(fake.access).not.toHaveBeenCalled();
|
|
73
|
+
fake.access.mockRejectedValue(Object.assign(new Error(), { code: "ENOENT" }));
|
|
74
|
+
expect(await createPrivateVirtualDisplay({ platform: "linux" })).toBeNull();
|
|
75
|
+
expect(fake.spawn).not.toHaveBeenCalled();
|
|
76
|
+
expect(fake.mkdtemp).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("uses an authenticated private high abstract-only display and idempotent cleanup", async () => {
|
|
80
|
+
const originalDisplay = process.env.DISPLAY;
|
|
81
|
+
const display = await createPrivateVirtualDisplay({ platform: "linux" });
|
|
82
|
+
expect(display).not.toBeNull();
|
|
83
|
+
const args = fake.spawn.mock.calls[0]![1] as string[];
|
|
84
|
+
expect(Number(args[0]!.slice(1))).toBeGreaterThanOrEqual(20_000);
|
|
85
|
+
expect(args).toEqual([
|
|
86
|
+
args[0],
|
|
87
|
+
"-screen",
|
|
88
|
+
"0",
|
|
89
|
+
"3840x3840x24",
|
|
90
|
+
"-nolisten",
|
|
91
|
+
"unix",
|
|
92
|
+
"-nolisten",
|
|
93
|
+
"tcp",
|
|
94
|
+
"-nolock",
|
|
95
|
+
"-auth",
|
|
96
|
+
join(fixtureDirectory, "authority"),
|
|
97
|
+
"-displayfd",
|
|
98
|
+
"3",
|
|
99
|
+
]);
|
|
100
|
+
expect(fake.chmod).toHaveBeenCalledWith(fixtureDirectory, 0o700);
|
|
101
|
+
expect(fake.writeFile.mock.calls[0]![2]).toEqual({ mode: 0o600, flag: "wx" });
|
|
102
|
+
expect(fake.connect).toHaveBeenCalledTimes(2); // Authorized and anonymous denial.
|
|
103
|
+
expect(fake.connect.mock.calls[0]![0].path).toBe(`\0/tmp/.X11-unix/X${args[0]!.slice(1)}`);
|
|
104
|
+
expect(process.env.DISPLAY).toBe(originalDisplay);
|
|
105
|
+
display!.assertAvailable();
|
|
106
|
+
await display!.stop();
|
|
107
|
+
await display!.stop();
|
|
108
|
+
expect(child.kill).toHaveBeenCalledTimes(1);
|
|
109
|
+
expect(fake.rm).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(() => display!.assertAvailable()).toThrow("ended");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("refuses a changed readiness identity before transmitting the secret", async () => {
|
|
114
|
+
fake.spawn.mockImplementation(() => {
|
|
115
|
+
queueMicrotask(() => ready.push("0\n"));
|
|
116
|
+
return child;
|
|
117
|
+
});
|
|
118
|
+
await expect(createPrivateVirtualDisplay({ platform: "linux" })).rejects.toThrow(
|
|
119
|
+
"identity changed",
|
|
120
|
+
);
|
|
121
|
+
expect(fake.connect).not.toHaveBeenCalled();
|
|
122
|
+
expect(child.kill).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(fake.rm).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("bounds a never-ready child and removes its authority file", async () => {
|
|
127
|
+
fake.spawn.mockReturnValue(child);
|
|
128
|
+
await expect(createPrivateVirtualDisplay({ platform: "linux", timeoutMs: 5 })).rejects.toThrow(
|
|
129
|
+
"not ready",
|
|
130
|
+
);
|
|
131
|
+
expect(child.kill).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(fake.rm).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("handles spawn failure and prevents silent headless fallback", async () => {
|
|
136
|
+
fake.spawn.mockImplementation(() => {
|
|
137
|
+
queueMicrotask(() => child.emit("error", new Error("fixture spawn failure")));
|
|
138
|
+
return child;
|
|
139
|
+
});
|
|
140
|
+
await expect(createPrivateVirtualDisplay({ platform: "linux" })).rejects.toThrow(
|
|
141
|
+
"failed to start",
|
|
142
|
+
);
|
|
143
|
+
expect(fake.rm).toHaveBeenCalledTimes(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("refuses a display which accepts clients without the private cookie", async () => {
|
|
147
|
+
fake.connect.mockImplementation(() => {
|
|
148
|
+
const socket = Object.assign(new EventEmitter(), {
|
|
149
|
+
destroy: vi.fn(),
|
|
150
|
+
write: () => {
|
|
151
|
+
const response = Buffer.alloc(8);
|
|
152
|
+
response[0] = 1;
|
|
153
|
+
response.writeUInt16LE(11, 2);
|
|
154
|
+
queueMicrotask(() => socket.emit("data", response));
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
queueMicrotask(() => socket.emit("connect"));
|
|
158
|
+
return socket;
|
|
159
|
+
});
|
|
160
|
+
await expect(createPrivateVirtualDisplay({ platform: "linux" })).rejects.toThrow(
|
|
161
|
+
"unauthenticated",
|
|
162
|
+
);
|
|
163
|
+
expect(child.kill).toHaveBeenCalledTimes(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("invalidates on unexpected child exit without starting a replacement", async () => {
|
|
167
|
+
const display = await createPrivateVirtualDisplay({ platform: "linux" });
|
|
168
|
+
child.exitCode = 1;
|
|
169
|
+
child.emit("exit", 1);
|
|
170
|
+
expect(() => display!.assertAvailable()).toThrow("ended");
|
|
171
|
+
await display!.stop();
|
|
172
|
+
expect(fake.spawn).toHaveBeenCalledTimes(1);
|
|
173
|
+
expect(child.kill).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("encodes the display-scoped authority format and rejects malformed inputs", () => {
|
|
177
|
+
const cookie = Buffer.alloc(16, 7);
|
|
178
|
+
const authority = encodeDisplayAuthority("23067", cookie);
|
|
179
|
+
expect(authority.readUInt16BE(0)).toBe(65_535);
|
|
180
|
+
expect(authority.includes(Buffer.from("MIT-MAGIC-COOKIE-1"))).toBe(true);
|
|
181
|
+
expect(authority.subarray(-16)).toEqual(cookie);
|
|
182
|
+
expect(() => encodeDisplayAuthority("0", cookie)).toThrow();
|
|
183
|
+
expect(() => encodeDisplayAuthority("23067", Buffer.alloc(2))).toThrow();
|
|
184
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns one authenticated, invisible Linux X display per browser runtime. Abstract
|
|
3
|
+
* Unix transport avoids WSLg's protected pathname directory; TCP is never enabled.
|
|
4
|
+
* The display stays alive across CDP reconnects. Startup failure is definitive:
|
|
5
|
+
* callers must not silently relaunch a partly initialized browser headlessly.
|
|
6
|
+
*/
|
|
7
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
8
|
+
import { randomBytes, randomInt } from "node:crypto";
|
|
9
|
+
import { constants } from "node:fs";
|
|
10
|
+
import { access, chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { createConnection } from "node:net";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { Readable } from "node:stream";
|
|
15
|
+
import { MAX_VIEWPORT } from "../shared/viewport-limits";
|
|
16
|
+
import { RuntimeLostError } from "./runtime-protocol";
|
|
17
|
+
|
|
18
|
+
const AUTH_NAME = "MIT-MAGIC-COOKIE-1";
|
|
19
|
+
const STARTUP_MS = 5_000;
|
|
20
|
+
|
|
21
|
+
/** Fixed-text startup failure; safe to show operators because it never embeds paths or secrets. */
|
|
22
|
+
export class PrivateDisplayError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "PrivateDisplayError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PrivateVirtualDisplay {
|
|
30
|
+
readonly launchEnvironment: Readonly<{ DISPLAY: string; XAUTHORITY: string }>;
|
|
31
|
+
/** Throws RuntimeLostError after the display exits; it never creates a replacement display. */
|
|
32
|
+
assertAvailable(): void;
|
|
33
|
+
/** Idempotently terminates only the owned child and removes its private auth file. */
|
|
34
|
+
stop(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Serializes a FamilyWild Xauthority record, scoped to this display and cookie. */
|
|
38
|
+
export function encodeDisplayAuthority(display: string, cookie: Buffer): Buffer {
|
|
39
|
+
if (!/^\d{4,5}$/.test(display) || Number(display) > 65_535 || cookie.length !== 16) {
|
|
40
|
+
throw new PrivateDisplayError("Invalid private display authority");
|
|
41
|
+
}
|
|
42
|
+
const fields = [Buffer.alloc(0), Buffer.from(display), Buffer.from(AUTH_NAME), cookie];
|
|
43
|
+
const family = Buffer.alloc(2);
|
|
44
|
+
family.writeUInt16BE(65_535);
|
|
45
|
+
return Buffer.concat([
|
|
46
|
+
family,
|
|
47
|
+
...fields.flatMap((field) => {
|
|
48
|
+
const length = Buffer.alloc(2);
|
|
49
|
+
length.writeUInt16BE(field.length);
|
|
50
|
+
return [length, field];
|
|
51
|
+
}),
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Performs a bounded X11 setup exchange to prove the owned secret is accepted. */
|
|
56
|
+
export async function authenticateDisplay(display: string, cookie: Buffer): Promise<boolean> {
|
|
57
|
+
return await new Promise((resolve) => {
|
|
58
|
+
const socket = createConnection({ path: `\0/tmp/.X11-unix/X${display}` });
|
|
59
|
+
let settled = false;
|
|
60
|
+
let received = Buffer.alloc(0);
|
|
61
|
+
const finish = (ready: boolean) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
socket.destroy();
|
|
66
|
+
resolve(ready);
|
|
67
|
+
};
|
|
68
|
+
const timer = setTimeout(() => finish(false), 200);
|
|
69
|
+
socket.once("error", () => finish(false));
|
|
70
|
+
socket.once("close", () => finish(false));
|
|
71
|
+
socket.once("connect", () => {
|
|
72
|
+
const name = Buffer.from(AUTH_NAME);
|
|
73
|
+
const header = Buffer.alloc(12);
|
|
74
|
+
header[0] = 0x6c; // X11 little-endian client, protocol 11.
|
|
75
|
+
header.writeUInt16LE(11, 2);
|
|
76
|
+
header.writeUInt16LE(name.length, 6);
|
|
77
|
+
header.writeUInt16LE(cookie.length, 8);
|
|
78
|
+
socket.write(Buffer.concat([header, name, Buffer.alloc(2), cookie]));
|
|
79
|
+
});
|
|
80
|
+
socket.on("data", (chunk) => {
|
|
81
|
+
if (received.length + chunk.length > 32_768) return finish(false);
|
|
82
|
+
received = Buffer.concat([received, chunk]);
|
|
83
|
+
if (received.length >= 8) {
|
|
84
|
+
finish(received[0] === 1 && received.readUInt16LE(2) === 11);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reads only the exact child readiness FD before sending its cookie to a socket. */
|
|
91
|
+
async function waitDisplayReady(
|
|
92
|
+
child: ChildProcess,
|
|
93
|
+
display: string,
|
|
94
|
+
timeoutMs: number,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
await new Promise<void>((resolve, reject) => {
|
|
97
|
+
const ready = child.stdio[3] as Readable;
|
|
98
|
+
let response = "";
|
|
99
|
+
const finish = (error?: Error) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
ready.removeListener("data", onData);
|
|
102
|
+
ready.removeListener("error", onError);
|
|
103
|
+
child.removeListener("error", onError);
|
|
104
|
+
child.removeListener("exit", onExit);
|
|
105
|
+
ready.destroy();
|
|
106
|
+
if (error) reject(error);
|
|
107
|
+
else resolve();
|
|
108
|
+
};
|
|
109
|
+
const onError = () =>
|
|
110
|
+
finish(new PrivateDisplayError("Private browser display failed to start"));
|
|
111
|
+
const onExit = () =>
|
|
112
|
+
finish(new PrivateDisplayError("Private browser display ended before readiness"));
|
|
113
|
+
const onData = (chunk: Buffer) => {
|
|
114
|
+
response += chunk.toString("ascii");
|
|
115
|
+
if (response.length > 16)
|
|
116
|
+
return finish(new PrivateDisplayError("Invalid private display readiness"));
|
|
117
|
+
if (response.includes("\n")) {
|
|
118
|
+
finish(
|
|
119
|
+
response === `${display}\n`
|
|
120
|
+
? undefined
|
|
121
|
+
: new PrivateDisplayError("Private display identity changed"),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const timer = setTimeout(
|
|
126
|
+
() => finish(new PrivateDisplayError("Private browser display was not ready")),
|
|
127
|
+
timeoutMs,
|
|
128
|
+
);
|
|
129
|
+
ready.on("data", onData);
|
|
130
|
+
ready.once("error", onError);
|
|
131
|
+
child.once("error", onError);
|
|
132
|
+
child.once("exit", onExit);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Stops a spawned child before deleting the secret it may still be reading. */
|
|
137
|
+
async function stopDisplayChild(child: ChildProcess): Promise<void> {
|
|
138
|
+
if (child.exitCode !== null || child.signalCode !== null || !child.pid) return;
|
|
139
|
+
await new Promise<void>((resolve) => {
|
|
140
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 1_000);
|
|
141
|
+
child.once("exit", () => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
resolve();
|
|
144
|
+
});
|
|
145
|
+
child.kill("SIGTERM");
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Starts Xvfb only for Linux hidden browsers when the existing executable exists.
|
|
151
|
+
* Absence returns null. Present-but-broken Xvfb fails closed with bounded cleanup.
|
|
152
|
+
* No process.env mutation, network listener, host display, or dependency install.
|
|
153
|
+
* One 3840x3840x24 framebuffer is bounded at about 57 MiB before server overhead;
|
|
154
|
+
* Chromium CPU/memory costs remain those of the existing browser runtime.
|
|
155
|
+
*/
|
|
156
|
+
export async function createPrivateVirtualDisplay(
|
|
157
|
+
options: { platform?: NodeJS.Platform; executablePath?: string; timeoutMs?: number } = {},
|
|
158
|
+
): Promise<PrivateVirtualDisplay | null> {
|
|
159
|
+
if ((options.platform ?? process.platform) !== "linux") return null;
|
|
160
|
+
const executable = options.executablePath ?? "/usr/bin/Xvfb";
|
|
161
|
+
try {
|
|
162
|
+
await access(executable, constants.X_OK);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
165
|
+
throw new PrivateDisplayError("Private browser display executable is not usable");
|
|
166
|
+
}
|
|
167
|
+
const display = String(randomInt(20_000, 60_000));
|
|
168
|
+
const directory = await mkdtemp(join(tmpdir(), "paseo-browser-display-"));
|
|
169
|
+
const authPath = join(directory, "authority");
|
|
170
|
+
const cookie = randomBytes(16);
|
|
171
|
+
let child: ChildProcess | null = null;
|
|
172
|
+
let stopPromise: Promise<void> | null = null;
|
|
173
|
+
let exited = false;
|
|
174
|
+
const stop = () =>
|
|
175
|
+
(stopPromise ??= (async () => {
|
|
176
|
+
exited = true;
|
|
177
|
+
try {
|
|
178
|
+
if (child) await stopDisplayChild(child);
|
|
179
|
+
} finally {
|
|
180
|
+
await rm(directory, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
})());
|
|
183
|
+
try {
|
|
184
|
+
await chmod(directory, 0o700);
|
|
185
|
+
await writeFile(authPath, encodeDisplayAuthority(display, cookie), { mode: 0o600, flag: "wx" });
|
|
186
|
+
child = spawn(
|
|
187
|
+
executable,
|
|
188
|
+
[
|
|
189
|
+
`:${display}`,
|
|
190
|
+
"-screen",
|
|
191
|
+
"0",
|
|
192
|
+
`${MAX_VIEWPORT.width}x${MAX_VIEWPORT.height}x24`,
|
|
193
|
+
"-nolisten",
|
|
194
|
+
"unix",
|
|
195
|
+
"-nolisten",
|
|
196
|
+
"tcp",
|
|
197
|
+
"-nolock",
|
|
198
|
+
"-auth",
|
|
199
|
+
authPath,
|
|
200
|
+
"-displayfd",
|
|
201
|
+
"3",
|
|
202
|
+
],
|
|
203
|
+
{ stdio: ["ignore", "ignore", "ignore", "pipe"] },
|
|
204
|
+
);
|
|
205
|
+
child.once("error", () => {
|
|
206
|
+
exited = true;
|
|
207
|
+
});
|
|
208
|
+
child.once("exit", () => {
|
|
209
|
+
exited = true;
|
|
210
|
+
});
|
|
211
|
+
// An explicit high display plus readiness FD does not invoke Xvfb's scan
|
|
212
|
+
// starting at :0. The child's pipe proves it bound that exact listener first.
|
|
213
|
+
await waitDisplayReady(
|
|
214
|
+
child,
|
|
215
|
+
display,
|
|
216
|
+
Math.min(STARTUP_MS, Math.max(1, options.timeoutMs ?? STARTUP_MS)),
|
|
217
|
+
);
|
|
218
|
+
if (exited || !(await authenticateDisplay(display, cookie))) {
|
|
219
|
+
throw new PrivateDisplayError("Private browser display authentication failed");
|
|
220
|
+
}
|
|
221
|
+
if (await authenticateDisplay(display, Buffer.alloc(0))) {
|
|
222
|
+
throw new PrivateDisplayError("Private browser display accepted an unauthenticated client");
|
|
223
|
+
}
|
|
224
|
+
if (exited) throw new PrivateDisplayError("Private browser display ended");
|
|
225
|
+
return {
|
|
226
|
+
launchEnvironment: Object.freeze({ DISPLAY: `:${display}`, XAUTHORITY: authPath }),
|
|
227
|
+
assertAvailable() {
|
|
228
|
+
if (exited) throw new RuntimeLostError("Private browser display ended");
|
|
229
|
+
},
|
|
230
|
+
stop,
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
await stop();
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|