@omercnet/paseo-shared-browser 1.2.1 → 1.2.2-next.237.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.
Files changed (170) hide show
  1. package/README.md +312 -25
  2. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  3. package/STREAMING_DESIGN.md +191 -0
  4. package/client/browser-auto-capture-density.test.ts +102 -0
  5. package/client/browser-auto-capture-density.ts +30 -0
  6. package/client/browser-canvas-input.test.ts +198 -0
  7. package/client/browser-canvas-input.ts +202 -0
  8. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  9. package/client/browser-canvas-keyboard-web.ts +230 -0
  10. package/client/browser-canvas-keyboard.test.ts +123 -0
  11. package/client/browser-canvas-keyboard.ts +126 -0
  12. package/client/browser-canvas-layout.test.ts +109 -0
  13. package/client/browser-canvas-layout.ts +51 -0
  14. package/client/browser-canvas-viewport-web.test.ts +77 -0
  15. package/client/browser-canvas-viewport-web.ts +63 -0
  16. package/client/browser-canvas-viewport.tsx +152 -0
  17. package/client/browser-capture-cadence.test.ts +9 -0
  18. package/client/browser-capture-cadence.ts +6 -0
  19. package/client/browser-capture-density-controls.tsx +75 -0
  20. package/client/browser-chrome.tsx +234 -0
  21. package/client/browser-client-presence.ts +184 -0
  22. package/client/browser-compose-controls.test.tsx +275 -0
  23. package/client/browser-compose-controls.tsx +174 -0
  24. package/client/browser-control-button.tsx +109 -0
  25. package/client/browser-control-dialog.tsx +71 -0
  26. package/client/browser-control-request.test.ts +27 -0
  27. package/client/browser-control-request.ts +57 -0
  28. package/client/browser-control-tooltip-web.ts +10 -0
  29. package/client/browser-dialog-body.tsx +22 -0
  30. package/client/browser-emulation-mode.test.ts +75 -0
  31. package/client/browser-emulation-mode.ts +98 -0
  32. package/client/browser-fill-viewport.test.ts +45 -0
  33. package/client/browser-fill-viewport.ts +28 -0
  34. package/client/browser-frame-buffer.test.ts +157 -0
  35. package/client/browser-frame-buffer.ts +148 -0
  36. package/client/browser-frame-image.tsx +62 -0
  37. package/client/browser-image-load.test.ts +23 -0
  38. package/client/browser-image-load.ts +26 -0
  39. package/client/browser-input-queue.test.ts +1201 -0
  40. package/client/browser-input-queue.ts +505 -0
  41. package/client/browser-menu-placement.test.ts +90 -0
  42. package/client/browser-menu-placement.ts +78 -0
  43. package/client/browser-panel-styles.ts +384 -0
  44. package/client/browser-quality-controls.test.ts +26 -0
  45. package/client/browser-quality-controls.tsx +139 -0
  46. package/client/browser-resolution-picker.tsx +189 -0
  47. package/client/browser-tabs.tsx +117 -0
  48. package/client/browser-toolbar-menu-web.test.ts +185 -0
  49. package/client/browser-toolbar-menu-web.ts +159 -0
  50. package/client/browser-toolbar-menu.tsx +255 -0
  51. package/client/browser-toolbar-submenu.tsx +93 -0
  52. package/client/browser-video-bytes.test.ts +44 -0
  53. package/client/browser-video-decoder.test.ts +473 -0
  54. package/client/browser-video-decoder.ts +311 -0
  55. package/client/browser-video-surface.tsx +21 -0
  56. package/client/browser-video-visibility.test.ts +45 -0
  57. package/client/browser-viewer-recovery.test.ts +111 -0
  58. package/client/browser-viewer-recovery.ts +78 -0
  59. package/client/browser.tsx +1642 -1569
  60. package/client/favorite-star.tsx +23 -0
  61. package/client/frame-lifecycle.ts +62 -59
  62. package/client/use-browser-auto-capture-density.test.ts +104 -0
  63. package/client/use-browser-auto-capture-density.ts +43 -0
  64. package/client/use-browser-canvas-input.ts +370 -0
  65. package/client/use-browser-capture-density.ts +87 -0
  66. package/client/use-browser-emulation-mode.ts +56 -0
  67. package/client/use-browser-fill-viewport.test.ts +108 -0
  68. package/client/use-browser-fill-viewport.ts +62 -0
  69. package/client/use-browser-frame-buffer.ts +66 -0
  70. package/client/use-browser-image-capture.ts +139 -0
  71. package/client/use-browser-scoped-mutation.test.ts +118 -0
  72. package/client/use-browser-scoped-mutation.ts +59 -0
  73. package/client/use-browser-video.test.ts +991 -0
  74. package/client/use-browser-video.ts +474 -0
  75. package/client/use-browser-viewer-recovery.ts +26 -0
  76. package/client/use-resolution-favorites.ts +90 -0
  77. package/client/web.test.ts +125 -0
  78. package/client/web.ts +405 -0
  79. package/index.client.tsx +0 -10
  80. package/index.server.ts +32 -0
  81. package/npm-shrinkwrap.json +1834 -0
  82. package/package.json +13 -8
  83. package/paseo-plugin.json +3 -1
  84. package/scripts/prepare-dependencies.mjs +1 -1
  85. package/scripts/prepare-runtime.mjs +1 -1
  86. package/server/agent-browser-runtime.ts +1598 -314
  87. package/server/browser-cursor.test.ts +64 -0
  88. package/server/browser-cursor.ts +69 -0
  89. package/server/browser-frame-context.ts +22 -0
  90. package/server/browser-frame-epoch.test.ts +95 -0
  91. package/server/browser-frame-rejection.ts +17 -0
  92. package/server/browser-gesture.ts +120 -0
  93. package/server/browser-operation-diagnostics.test.ts +79 -0
  94. package/server/browser-operation-diagnostics.ts +103 -0
  95. package/server/browser-policy.ts +1313 -151
  96. package/server/browser-video-input-authority.test.ts +208 -0
  97. package/server/browser-video-policy.test.ts +564 -0
  98. package/server/browser-video-socket.test.ts +368 -0
  99. package/server/browser.ts +151 -16
  100. package/server/capture-transport-policy.test.ts +105 -0
  101. package/server/capture-transport-policy.ts +154 -0
  102. package/server/capture-transport-runtime.test.ts +213 -0
  103. package/server/daemon-launch-chain.test.ts +70 -0
  104. package/server/daemon-shutdown.test.ts +289 -0
  105. package/server/discrete-input-freshness.test.ts +364 -0
  106. package/server/discrete-input-native-lifetime.test.ts +150 -0
  107. package/server/display-loss-recovery.test.ts +294 -0
  108. package/server/emulation-restoration.test.ts +262 -0
  109. package/server/input-cleanup.ts +23 -0
  110. package/server/input-generation.test.ts +31 -0
  111. package/server/input-generation.ts +36 -0
  112. package/server/jpeg-capture-demand.test.ts +76 -0
  113. package/server/jpeg-capture-demand.ts +98 -0
  114. package/server/jpeg-frame.ts +29 -0
  115. package/server/keyboard-input.test.ts +61 -0
  116. package/server/keyboard-input.ts +104 -0
  117. package/server/live-input-policy.test.ts +958 -0
  118. package/server/live-input-runtime.test.ts +732 -0
  119. package/server/live-input-supervisor.test.ts +147 -0
  120. package/server/mcp-entry.ts +44 -2
  121. package/server/native-video-backpressure.test.ts +228 -0
  122. package/server/native-video-buffer.test.ts +141 -0
  123. package/server/native-video-buffer.ts +114 -0
  124. package/server/native-video-capture.test.ts +599 -0
  125. package/server/native-video-capture.ts +497 -0
  126. package/server/native-video-extension-source.test.ts +201 -0
  127. package/server/native-video-extension-source.ts +364 -0
  128. package/server/native-video-extension.test.ts +41 -0
  129. package/server/native-video-extension.ts +83 -0
  130. package/server/native-video-packet.ts +9 -0
  131. package/server/native-video-settings.test.ts +245 -0
  132. package/server/navigation-budget.ts +8 -0
  133. package/server/navigation-commit.ts +125 -0
  134. package/server/navigation-policy.test.ts +153 -0
  135. package/server/process-identity.ts +58 -0
  136. package/server/reattach-held-input.test.ts +143 -0
  137. package/server/runtime-frame-reuse.test.ts +295 -0
  138. package/server/runtime-jpeg-demand.test.ts +200 -0
  139. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  140. package/server/runtime-jpeg-source-order.test.ts +201 -0
  141. package/server/runtime-launch-environment.test.ts +111 -0
  142. package/server/runtime-navigation.test.ts +327 -0
  143. package/server/runtime-owner-display.test.ts +152 -0
  144. package/server/runtime-owner.ts +305 -31
  145. package/server/runtime-page-state.test.ts +249 -0
  146. package/server/runtime-protocol.ts +31 -1
  147. package/server/runtime-target-retention.test.ts +181 -0
  148. package/server/runtime-video-opt-in.test.ts +82 -0
  149. package/server/runtime-video.test.ts +444 -0
  150. package/server/screencast-source-time.ts +46 -0
  151. package/server/stale-wheel-admission.test.ts +136 -0
  152. package/server/supervisor-client.ts +28 -4
  153. package/server/supervisor-heartbeat.test.ts +80 -0
  154. package/server/supervisor.ts +553 -55
  155. package/server/video-source-recovery.test.ts +20 -0
  156. package/server/video-source-recovery.ts +64 -0
  157. package/server/viewer-capture-lifetime.ts +51 -0
  158. package/server/virtual-display.test.ts +184 -0
  159. package/server/virtual-display.ts +236 -0
  160. package/shared/browser-display-preferences.ts +46 -0
  161. package/shared/browser-video.ts +80 -0
  162. package/shared/browser.ts +286 -65
  163. package/shared/capture-density.ts +16 -0
  164. package/shared/capture-settings.ts +17 -0
  165. package/shared/device-presets.ts +90 -0
  166. package/shared/resolution-menu.test.ts +120 -0
  167. package/shared/resolution-menu.ts +72 -0
  168. package/shared/video-settings.test.ts +38 -0
  169. package/shared/video-settings.ts +34 -0
  170. package/shared/viewport-limits.ts +5 -0
@@ -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
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Host-persisted display favorites through Paseo's settings service. These values
3
+ * affect viewing quality and picker convenience, never input ownership, page
4
+ * navigation, emulation or pixel density. Version1 migration retains the bitrate
5
+ * formerly coupled to JPEG quality.
6
+ * Retired IDs remain parseable; the current catalogue prunes them at the view.
7
+ */
8
+ import { defineSettings } from "@getpaseo/plugin";
9
+ import { z } from "zod";
10
+ import { DEFAULT_CAPTURE_QUALITY } from "./capture-settings";
11
+ import { DEVICE_PRESETS } from "./device-presets";
12
+ import {
13
+ resolveVideoEncoderSettings,
14
+ videoBitrateSchema,
15
+ videoFrameRateSchema,
16
+ } from "./video-settings";
17
+
18
+ export const browserDisplayPreferencesSchema = z.object({
19
+ captureQuality: z.enum(["low", "medium", "high", "maximum"]).default(DEFAULT_CAPTURE_QUALITY),
20
+ videoBitrate: videoBitrateSchema.default(12_000_000),
21
+ videoFps: videoFrameRateSchema.default(30),
22
+ favoritePresetIds: z.array(z.string().min(1).max(128)).max(DEVICE_PRESETS.length).default([]),
23
+ });
24
+
25
+ export type BrowserDisplayPreferences = z.output<typeof browserDisplayPreferencesSchema>;
26
+
27
+ /** One revisioned document per plugin host, shared across that host's panels. */
28
+ export const browserDisplayPreferences = defineSettings({
29
+ id: "display-preferences",
30
+ scope: "host",
31
+ version: 2,
32
+ migrate(values, fromVersion) {
33
+ if (fromVersion !== 1 || !values || typeof values !== "object") return values;
34
+ const previous = values as Record<string, unknown>;
35
+ const quality =
36
+ previous.captureQuality === "low" || previous.captureQuality === "medium"
37
+ ? previous.captureQuality
38
+ : "high";
39
+ return {
40
+ ...previous,
41
+ videoBitrate: resolveVideoEncoderSettings({ quality }).bitrate,
42
+ videoFps: 30,
43
+ };
44
+ },
45
+ schema: browserDisplayPreferencesSchema,
46
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Encoded browser-tab video over the existing authenticated Paseo RPC transport.
3
+ * Opt-in on the daemon and decoded by WebCodecs on the web; every other client retains JPEG.
4
+ * Packets never grant control; only a decoded
5
+ * presentation with current browser authority can provide an input target.
6
+ */
7
+ import { defineRpc } from "@getpaseo/plugin";
8
+ import { z } from "zod";
9
+ import { browserFrameSchema, browserStateSchema } from "./browser";
10
+ import { videoBitrateSchema, videoFrameRateSchema } from "./video-settings";
11
+ import { MAX_VIEWPORT } from "./viewport-limits";
12
+
13
+ export const VIDEO_MAX_PACKET_BYTES = 2 * 1024 * 1024;
14
+ export const VIDEO_MAX_BATCH_BYTES = 4 * 1024 * 1024;
15
+ export const VIDEO_MAX_BATCH_PACKETS = 32;
16
+ export const VIDEO_MAX_WAIT_MS = 500;
17
+ export const VIDEO_FRAME_RATE = 30;
18
+ /** Maximum future source-clock skew admitted by the calibrated native capture. */
19
+ export const VIDEO_SOURCE_CLOCK_TOLERANCE_MS = 50;
20
+
21
+ const opaqueIdSchema = browserFrameSchema.shape.frameId;
22
+ const sequenceSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
23
+ const base64PacketSchema = z
24
+ .string()
25
+ .min(1)
26
+ .max(Math.ceil(VIDEO_MAX_PACKET_BYTES / 3) * 4);
27
+
28
+ /** Geometry and generation shared by JPEG and decoded video input admission. */
29
+ export const browserFrameAuthoritySchema = browserFrameSchema.omit({
30
+ mimeType: true,
31
+ transport: true,
32
+ dataBase64: true,
33
+ byteLength: true,
34
+ });
35
+ export type BrowserFrameAuthority = z.infer<typeof browserFrameAuthoritySchema>;
36
+
37
+ /** Native encoded output; timestamps are source VideoFrame microseconds, not wall-clock time. */
38
+ export const nativeVideoPacketSchema = z.object({
39
+ streamId: opaqueIdSchema,
40
+ captureGeneration: sequenceSchema,
41
+ sequence: sequenceSchema,
42
+ timestampUs: sequenceSchema,
43
+ type: z.enum(["key", "delta"]),
44
+ codec: z.string().min(1).max(64),
45
+ width: z.number().int().positive().max(MAX_VIEWPORT.width),
46
+ height: z.number().int().positive().max(MAX_VIEWPORT.height),
47
+ descriptionBase64: z.string().max(16_384).optional(),
48
+ dataBase64: base64PacketSchema,
49
+ capturedAt: z.string().datetime(),
50
+ });
51
+ export type NativeVideoPacket = z.infer<typeof nativeVideoPacketSchema>;
52
+
53
+ export const browserVideoPacketSchema = nativeVideoPacketSchema.extend({
54
+ frame: browserFrameAuthoritySchema,
55
+ });
56
+ export type BrowserVideoPacket = z.infer<typeof browserVideoPacketSchema>;
57
+
58
+ export const readBrowserVideoRpc = defineRpc({
59
+ name: "shared-browser.video.read",
60
+ input: z.object({
61
+ viewerToken: opaqueIdSchema,
62
+ quality: z.enum(["low", "medium", "high"]).default("high"),
63
+ bitrate: videoBitrateSchema.optional(),
64
+ fps: videoFrameRateSchema.optional(),
65
+ streamId: opaqueIdSchema.nullable().default(null),
66
+ afterSequence: sequenceSchema.default(0),
67
+ requestKeyFrame: z.boolean().default(false),
68
+ waitMs: z.number().int().min(0).max(VIDEO_MAX_WAIT_MS).default(250),
69
+ }),
70
+ output: z.object({
71
+ state: browserStateSchema,
72
+ status: z.enum(["ready", "waiting", "reset", "unsupported"]),
73
+ streamId: opaqueIdSchema.nullable(),
74
+ packets: z.array(browserVideoPacketSchema).max(VIDEO_MAX_BATCH_PACKETS),
75
+ reason: z.string().max(256).optional(),
76
+ reasonCode: z.enum(["encoder-capacity", "video-disabled"]).optional(),
77
+ }),
78
+ });
79
+ export type BrowserVideoReadInput = z.infer<typeof readBrowserVideoRpc.input>;
80
+ export type BrowserVideoReadReply = z.infer<typeof readBrowserVideoRpc.output>;