@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.
Files changed (173) hide show
  1. package/OVERVIEW.md +2 -2
  2. package/README.md +316 -27
  3. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  4. package/STREAMING_DESIGN.md +196 -0
  5. package/client/browser-auto-capture-density.test.ts +102 -0
  6. package/client/browser-auto-capture-density.ts +30 -0
  7. package/client/browser-canvas-input.test.ts +198 -0
  8. package/client/browser-canvas-input.ts +202 -0
  9. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  10. package/client/browser-canvas-keyboard-web.ts +230 -0
  11. package/client/browser-canvas-keyboard.test.ts +123 -0
  12. package/client/browser-canvas-keyboard.ts +126 -0
  13. package/client/browser-canvas-layout.test.ts +109 -0
  14. package/client/browser-canvas-layout.ts +51 -0
  15. package/client/browser-canvas-viewport-web.test.ts +77 -0
  16. package/client/browser-canvas-viewport-web.ts +63 -0
  17. package/client/browser-canvas-viewport.tsx +152 -0
  18. package/client/browser-capture-cadence.test.ts +9 -0
  19. package/client/browser-capture-cadence.ts +6 -0
  20. package/client/browser-capture-density-controls.tsx +75 -0
  21. package/client/browser-chrome.tsx +234 -0
  22. package/client/browser-client-presence.ts +184 -0
  23. package/client/browser-compose-controls.test.tsx +275 -0
  24. package/client/browser-compose-controls.tsx +174 -0
  25. package/client/browser-control-button.tsx +109 -0
  26. package/client/browser-control-dialog.tsx +71 -0
  27. package/client/browser-control-request.test.ts +27 -0
  28. package/client/browser-control-request.ts +57 -0
  29. package/client/browser-control-tooltip-web.ts +10 -0
  30. package/client/browser-dialog-body.tsx +22 -0
  31. package/client/browser-emulation-mode.test.ts +75 -0
  32. package/client/browser-emulation-mode.ts +98 -0
  33. package/client/browser-fill-viewport.test.ts +45 -0
  34. package/client/browser-fill-viewport.ts +28 -0
  35. package/client/browser-frame-buffer.test.ts +157 -0
  36. package/client/browser-frame-buffer.ts +148 -0
  37. package/client/browser-frame-image.tsx +62 -0
  38. package/client/browser-image-load.test.ts +23 -0
  39. package/client/browser-image-load.ts +26 -0
  40. package/client/browser-input-queue.test.ts +1254 -0
  41. package/client/browser-input-queue.ts +519 -0
  42. package/client/browser-menu-placement.test.ts +90 -0
  43. package/client/browser-menu-placement.ts +78 -0
  44. package/client/browser-panel-styles.ts +384 -0
  45. package/client/browser-quality-controls.test.ts +26 -0
  46. package/client/browser-quality-controls.tsx +139 -0
  47. package/client/browser-resolution-picker.tsx +189 -0
  48. package/client/browser-tabs.tsx +117 -0
  49. package/client/browser-toolbar-menu-web.test.ts +185 -0
  50. package/client/browser-toolbar-menu-web.ts +159 -0
  51. package/client/browser-toolbar-menu.tsx +255 -0
  52. package/client/browser-toolbar-submenu.tsx +93 -0
  53. package/client/browser-video-bytes.test.ts +44 -0
  54. package/client/browser-video-currency.ts +45 -0
  55. package/client/browser-video-decoder.test.ts +473 -0
  56. package/client/browser-video-decoder.ts +311 -0
  57. package/client/browser-video-surface.tsx +21 -0
  58. package/client/browser-video-visibility.test.ts +45 -0
  59. package/client/browser-viewer-recovery.test.ts +111 -0
  60. package/client/browser-viewer-recovery.ts +78 -0
  61. package/client/browser.tsx +1650 -1579
  62. package/client/favorite-star.tsx +23 -0
  63. package/client/frame-lifecycle.ts +62 -59
  64. package/client/sidebar.tsx +1 -2
  65. package/client/use-browser-auto-capture-density.test.ts +104 -0
  66. package/client/use-browser-auto-capture-density.ts +43 -0
  67. package/client/use-browser-canvas-input.ts +375 -0
  68. package/client/use-browser-capture-density.ts +87 -0
  69. package/client/use-browser-emulation-mode.ts +56 -0
  70. package/client/use-browser-fill-viewport.test.ts +108 -0
  71. package/client/use-browser-fill-viewport.ts +62 -0
  72. package/client/use-browser-frame-buffer.ts +66 -0
  73. package/client/use-browser-image-capture.ts +139 -0
  74. package/client/use-browser-scoped-mutation.test.ts +118 -0
  75. package/client/use-browser-scoped-mutation.ts +59 -0
  76. package/client/use-browser-video.test.ts +1060 -0
  77. package/client/use-browser-video.ts +481 -0
  78. package/client/use-browser-viewer-recovery.ts +26 -0
  79. package/client/use-resolution-favorites.ts +90 -0
  80. package/client/web.test.ts +125 -0
  81. package/client/web.ts +405 -0
  82. package/index.client.tsx +0 -10
  83. package/index.server.ts +32 -4
  84. package/npm-shrinkwrap.json +9 -9
  85. package/package.json +10 -5
  86. package/paseo-plugin.json +1 -1
  87. package/scripts/prepare-runtime.mjs +1 -1
  88. package/server/agent-browser-runtime.ts +1601 -314
  89. package/server/browser-cursor.test.ts +64 -0
  90. package/server/browser-cursor.ts +69 -0
  91. package/server/browser-frame-context.ts +22 -0
  92. package/server/browser-frame-epoch.test.ts +95 -0
  93. package/server/browser-frame-rejection.ts +17 -0
  94. package/server/browser-gesture.ts +120 -0
  95. package/server/browser-operation-diagnostics.test.ts +79 -0
  96. package/server/browser-operation-diagnostics.ts +103 -0
  97. package/server/browser-policy.ts +1316 -151
  98. package/server/browser-video-input-authority.test.ts +211 -0
  99. package/server/browser-video-policy.test.ts +564 -0
  100. package/server/browser-video-socket.test.ts +368 -0
  101. package/server/browser.ts +151 -16
  102. package/server/capture-transport-policy.test.ts +105 -0
  103. package/server/capture-transport-policy.ts +154 -0
  104. package/server/capture-transport-runtime.test.ts +213 -0
  105. package/server/daemon-launch-chain.test.ts +70 -0
  106. package/server/daemon-shutdown.test.ts +289 -0
  107. package/server/discrete-input-freshness.test.ts +364 -0
  108. package/server/discrete-input-native-lifetime.test.ts +150 -0
  109. package/server/display-loss-recovery.test.ts +294 -0
  110. package/server/emulation-restoration.test.ts +262 -0
  111. package/server/input-cleanup.ts +23 -0
  112. package/server/input-generation.test.ts +31 -0
  113. package/server/input-generation.ts +36 -0
  114. package/server/jpeg-capture-demand.test.ts +76 -0
  115. package/server/jpeg-capture-demand.ts +98 -0
  116. package/server/jpeg-frame.ts +29 -0
  117. package/server/keyboard-input.test.ts +61 -0
  118. package/server/keyboard-input.ts +104 -0
  119. package/server/live-input-policy.test.ts +958 -0
  120. package/server/live-input-runtime.test.ts +732 -0
  121. package/server/live-input-supervisor.test.ts +147 -0
  122. package/server/mcp-entry.ts +44 -2
  123. package/server/native-video-backpressure.test.ts +228 -0
  124. package/server/native-video-buffer.test.ts +141 -0
  125. package/server/native-video-buffer.ts +114 -0
  126. package/server/native-video-capture.test.ts +599 -0
  127. package/server/native-video-capture.ts +497 -0
  128. package/server/native-video-extension-source.test.ts +201 -0
  129. package/server/native-video-extension-source.ts +364 -0
  130. package/server/native-video-extension.test.ts +41 -0
  131. package/server/native-video-extension.ts +83 -0
  132. package/server/native-video-packet.ts +9 -0
  133. package/server/native-video-settings.test.ts +245 -0
  134. package/server/navigation-budget.ts +8 -0
  135. package/server/navigation-commit.ts +125 -0
  136. package/server/navigation-policy.test.ts +153 -0
  137. package/server/process-identity.ts +58 -0
  138. package/server/reattach-held-input.test.ts +143 -0
  139. package/server/runtime-frame-reuse.test.ts +295 -0
  140. package/server/runtime-jpeg-demand.test.ts +200 -0
  141. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  142. package/server/runtime-jpeg-source-order.test.ts +201 -0
  143. package/server/runtime-launch-environment.test.ts +111 -0
  144. package/server/runtime-navigation.test.ts +327 -0
  145. package/server/runtime-owner-display.test.ts +152 -0
  146. package/server/runtime-owner.ts +340 -31
  147. package/server/runtime-page-state.test.ts +249 -0
  148. package/server/runtime-protocol.ts +31 -1
  149. package/server/runtime-target-retention.test.ts +181 -0
  150. package/server/runtime-video-opt-in.test.ts +82 -0
  151. package/server/runtime-video.test.ts +444 -0
  152. package/server/screencast-source-time.ts +46 -0
  153. package/server/stale-wheel-admission.test.ts +136 -0
  154. package/server/supervisor-client.ts +28 -4
  155. package/server/supervisor-heartbeat.test.ts +80 -0
  156. package/server/supervisor.ts +553 -55
  157. package/server/tab-close-confirmation.test.ts +97 -0
  158. package/server/video-source-recovery.test.ts +20 -0
  159. package/server/video-source-recovery.ts +64 -0
  160. package/server/viewer-capture-lifetime.ts +51 -0
  161. package/server/virtual-display.test.ts +184 -0
  162. package/server/virtual-display.ts +236 -0
  163. package/shared/browser-display-preferences.ts +46 -0
  164. package/shared/browser-video.ts +83 -0
  165. package/shared/browser.ts +286 -65
  166. package/shared/capture-density.ts +16 -0
  167. package/shared/capture-settings.ts +17 -0
  168. package/shared/device-presets.ts +90 -0
  169. package/shared/resolution-menu.test.ts +120 -0
  170. package/shared/resolution-menu.ts +72 -0
  171. package/shared/video-settings.test.ts +38 -0
  172. package/shared/video-settings.ts +34 -0
  173. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Trusted extension-page program. It captures one exact tab, never reads site DOM,
3
+ * and shares a bounded native track among at most three independent encoders.
4
+ * Binding acknowledgements limit outstanding packets; a dropped output forces a
5
+ * new keyframe before another delta can be forwarded.
6
+ */
7
+ export const NATIVE_VIDEO_EXTENSION_SOURCE = `
8
+ let capture = null;
9
+ const encoders = new Map();
10
+ const pendingEncoders = new Map();
11
+ const bytes64 = (bytes) => {
12
+ // Native byte conversion avoids constructing/copying a large binary string.
13
+ // Retain the bounded legacy path for browsers without the newer byte API.
14
+ if (typeof bytes.toBase64 === "function") return bytes.toBase64();
15
+ let result = "";
16
+ for (let offset = 0; offset < bytes.length; offset += 8192) {
17
+ result += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
18
+ }
19
+ return btoa(result);
20
+ };
21
+ /** Revoke the source, codecs and pending allocations before awaiting reader cleanup. */
22
+ async function stopCapture() {
23
+ const previous = capture;
24
+ capture = null;
25
+ pendingEncoders.clear();
26
+ if (!previous) return;
27
+ previous.stopped = true;
28
+ for (const encoder of encoders.values()) {
29
+ try {
30
+ encoder.encoder.close();
31
+ } catch {}
32
+ }
33
+ encoders.clear();
34
+ for (const track of previous.stream.getTracks()) track.stop();
35
+ await previous.reader.cancel().catch(() => {});
36
+ await previous.loop.catch(() => {});
37
+ }
38
+ /** Preserve requested geometry across Chromium's even-aligned YUV capture.
39
+ * Only the known one-pixel rounding is resampled by VideoEncoder. Other source
40
+ * changes remain failures; cloning retains the source timestamp and lifetime.
41
+ */
42
+ function frameForViewport(frame, width, height) {
43
+ if (frame.displayWidth === width && frame.displayHeight === height) return frame;
44
+ if (
45
+ !["I420", "NV12"].includes(frame.format) ||
46
+ frame.displayWidth !== (width & ~1) ||
47
+ frame.displayHeight !== (height & ~1)
48
+ ) return null;
49
+ return new VideoFrame(frame, { displayWidth: width, displayHeight: height });
50
+ }
51
+ /** Start one exact tab at physical pixel dimensions; generation fences previously queued frames. */
52
+ async function startCapture(targetId, width, height, generation) {
53
+ await stopCapture();
54
+ let stream;
55
+ try {
56
+ const targets = await chrome.debugger.getTargets();
57
+ const matched = targets.filter(
58
+ (target) =>
59
+ target.id === targetId && target.type === "page" && Number.isInteger(target.tabId),
60
+ );
61
+ if (matched.length !== 1) throw new Error("Exact capture target is unavailable");
62
+ const sourceId = await chrome.tabCapture.getMediaStreamId({ targetTabId: matched[0].tabId });
63
+ stream = await navigator.mediaDevices.getUserMedia({
64
+ audio: false,
65
+ video: {
66
+ mandatory: {
67
+ chromeMediaSource: "tab",
68
+ chromeMediaSourceId: sourceId,
69
+ maxWidth: width,
70
+ maxHeight: height,
71
+ minFrameRate: 1,
72
+ maxFrameRate: 60,
73
+ },
74
+ },
75
+ });
76
+ const track = stream.getVideoTracks()[0];
77
+ const reader = new MediaStreamTrackProcessor({ track, maxBufferSize: 1 }).readable.getReader();
78
+ const current = {
79
+ stream,
80
+ reader,
81
+ width,
82
+ height,
83
+ generation,
84
+ minimumTimestampUs: -Infinity,
85
+ stopped: false,
86
+ loop: null,
87
+ };
88
+ capture = current;
89
+ current.loop = (async () => {
90
+ try {
91
+ while (capture === current && !current.stopped) {
92
+ const { value: frame, done } = await reader.read();
93
+ if (done) {
94
+ if (capture === current && !current.stopped)
95
+ nativeVideoPacket(JSON.stringify({ error: "Native video capture stopped" }));
96
+ break;
97
+ }
98
+ let encodingFrame = null;
99
+ try {
100
+ if (capture !== current || current.stopped) break;
101
+ if (frame.timestamp < current.minimumTimestampUs) continue;
102
+ encodingFrame = frameForViewport(frame, width, height);
103
+ if (!encodingFrame) {
104
+ // Only native YUV even-alignment is correctable. Keep unexpected
105
+ // source geometry terminal without changing viewport or density.
106
+ nativeVideoPacket(
107
+ JSON.stringify({
108
+ error: "Native video dimensions changed",
109
+ reasonCode: "source-dimensions",
110
+ }),
111
+ );
112
+ break;
113
+ }
114
+ for (const state of encoders.values()) {
115
+ if (state.failed || performance.now() - state.lastRequestedAt > 2000) continue;
116
+ if (state.pending.size >= 2 || state.encoder.encodeQueueSize >= 2) {
117
+ // This source frame was never encoded, so skipping it does not
118
+ // lose a codec dependency. Keep already queued output usable.
119
+ // The output callback below still forces recovery if it must
120
+ // discard an actual encoded chunk.
121
+ continue;
122
+ }
123
+ // Source timestamps are integer microseconds. Allow one microsecond
124
+ // of rounding so genuine 30fps timestamps do not become 15fps.
125
+ const minimumIntervalUs = 1000000 / state.configuration.framerate;
126
+ // Recovery uses the next fresh frame even inside the ordinary FPS interval.
127
+ // Producer/encoder backlog checks above still apply to forced keys.
128
+ if (
129
+ !state.forceKey &&
130
+ frame.timestamp - state.lastEncodedTimestampUs < minimumIntervalUs - 1
131
+ )
132
+ continue;
133
+ state.lastEncodedTimestampUs = frame.timestamp;
134
+ state.frameGenerations.set(frame.timestamp, current.generation);
135
+ while (state.frameGenerations.size > 4)
136
+ state.frameGenerations.delete(state.frameGenerations.keys().next().value);
137
+ const keyFrame =
138
+ state.forceKey || frame.timestamp - state.lastKeyTimestamp >= 1000000;
139
+ try {
140
+ state.encoder.encode(encodingFrame, { keyFrame });
141
+ } catch {
142
+ // A synchronous codec rejection belongs to this cohort, just
143
+ // like its asynchronous error callback. Healthy peers keep the track.
144
+ state.failed = true;
145
+ try {
146
+ state.encoder.close();
147
+ } catch {}
148
+ state.frameGenerations.clear();
149
+ nativeVideoPacket(
150
+ JSON.stringify({
151
+ error: "Native video encoder stopped",
152
+ streamId: state.streamId,
153
+ }),
154
+ );
155
+ continue;
156
+ }
157
+ if (keyFrame) state.lastKeyTimestamp = frame.timestamp;
158
+ state.forceKey = false;
159
+ }
160
+ } finally {
161
+ if (encodingFrame && encodingFrame !== frame) encodingFrame.close();
162
+ frame.close();
163
+ }
164
+ }
165
+ } catch {
166
+ if (capture === current)
167
+ nativeVideoPacket(JSON.stringify({ error: "Native video capture stopped" }));
168
+ }
169
+ })();
170
+ return { width, height, settings: track.getSettings() };
171
+ } catch (error) {
172
+ if (stream) for (const track of stream.getTracks()) track.stop();
173
+ throw error;
174
+ }
175
+ }
176
+ /** Allocate a cohort selected by its stable quality/configuration key. Bitrate is bits per second,
177
+ * fps is the requested frame ceiling. Reserve capacity before asynchronous codec probes. */
178
+ async function addEncoder(quality, streamId, bitrate, fps) {
179
+ if (!capture) throw new Error("Native capture is not running");
180
+ const bitrates = { low: 2000000, medium: 5000000, high: 12000000 };
181
+ bitrate ??= bitrates[quality];
182
+ fps ??= 30;
183
+ if (![2000000, 5000000, 12000000, 24000000].includes(bitrate) || ![15, 30, 60].includes(fps))
184
+ throw new Error("Unknown video settings");
185
+ const existing = encoders.get(quality);
186
+ if (existing) {
187
+ if (
188
+ existing.streamId !== streamId ||
189
+ existing.configuration.bitrate !== bitrate ||
190
+ existing.configuration.framerate !== fps
191
+ )
192
+ throw new Error("Encoder identity changed");
193
+ touchEncoder(quality);
194
+ return { codec: existing.codec };
195
+ }
196
+ const pending = pendingEncoders.get(quality);
197
+ if (pending) {
198
+ if (pending.streamId !== streamId || pending.bitrate !== bitrate || pending.fps !== fps)
199
+ throw new Error("Encoder allocation identity changed");
200
+ return pending.promise;
201
+ }
202
+ if (encoders.size + pendingEncoders.size >= 3) throw new Error("Native encoder limit reached");
203
+ // Reserve before any codec probe. A timed-out CDP request can continue in this
204
+ // page, so host-side serialization alone cannot enforce the encoder ceiling.
205
+ const source = capture;
206
+ const reservation = { streamId, bitrate, fps, promise: null };
207
+ pendingEncoders.set(quality, reservation);
208
+ const assertAllocation = () => {
209
+ if (capture !== source || source.stopped || pendingEncoders.get(quality) !== reservation)
210
+ throw new Error("Native encoder allocation changed");
211
+ };
212
+ reservation.promise = (async () => {
213
+ let state = null;
214
+ try {
215
+ let configuration;
216
+ for (const codec of ["avc1.420033", "vp8"]) {
217
+ const candidate = {
218
+ codec,
219
+ width: source.width,
220
+ height: source.height,
221
+ bitrate,
222
+ framerate: fps,
223
+ latencyMode: "realtime",
224
+ hardwareAcceleration: "no-preference",
225
+ ...(codec.startsWith("avc") ? { avc: { format: "annexb" } } : {}),
226
+ };
227
+ const support = await VideoEncoder.isConfigSupported(candidate);
228
+ assertAllocation();
229
+ if (support.supported) {
230
+ configuration = candidate;
231
+ break;
232
+ }
233
+ }
234
+ if (!configuration) throw new Error("Native encoder is unavailable");
235
+ state = {
236
+ streamId,
237
+ codec: configuration.codec,
238
+ sequence: 0,
239
+ pending: new Set(),
240
+ forceKey: true,
241
+ lastKeyTimestamp: -Infinity,
242
+ lastEncodedTimestampUs: -Infinity,
243
+ lastRequestedAt: performance.now(),
244
+ needsKey: true,
245
+ configuration,
246
+ frameGenerations: new Map(),
247
+ failed: false,
248
+ encoder: null,
249
+ };
250
+ state.encoder = new VideoEncoder({
251
+ output: (chunk, metadata) => {
252
+ if (!capture || encoders.get(quality) !== state || state.failed) return;
253
+ const generation = state.frameGenerations.get(chunk.timestamp);
254
+ state.frameGenerations.delete(chunk.timestamp);
255
+ if (chunk.byteLength > 2097152) {
256
+ state.failed = true;
257
+ try {
258
+ state.encoder.close();
259
+ } catch {}
260
+ nativeVideoPacket(
261
+ JSON.stringify({ error: "Native video quality exceeds its packet bound", streamId }),
262
+ );
263
+ return;
264
+ }
265
+ if (generation !== capture.generation || state.pending.size >= 2) {
266
+ state.forceKey = true;
267
+ state.needsKey = true;
268
+ return;
269
+ }
270
+ if (state.needsKey && chunk.type !== "key") return;
271
+ if (chunk.type === "key") state.needsKey = false;
272
+ const bytes = new Uint8Array(chunk.byteLength);
273
+ chunk.copyTo(bytes);
274
+ const sequence = ++state.sequence;
275
+ state.pending.add(sequence);
276
+ const description = metadata.decoderConfig && metadata.decoderConfig.description;
277
+ nativeVideoPacket(
278
+ JSON.stringify({
279
+ streamId,
280
+ captureGeneration: generation,
281
+ sequence,
282
+ timestampUs: chunk.timestamp,
283
+ type: chunk.type,
284
+ codec: state.codec,
285
+ width: capture.width,
286
+ height: capture.height,
287
+ ...(description ? { descriptionBase64: bytes64(new Uint8Array(description)) } : {}),
288
+ dataBase64: bytes64(bytes),
289
+ }),
290
+ );
291
+ },
292
+ error: () => {
293
+ state.failed = true;
294
+ nativeVideoPacket(JSON.stringify({ error: "Native video encoder stopped", streamId }));
295
+ },
296
+ });
297
+ state.encoder.configure(configuration);
298
+ assertAllocation();
299
+ pendingEncoders.delete(quality);
300
+ encoders.set(quality, state);
301
+ return { codec: state.codec };
302
+ } catch (error) {
303
+ try {
304
+ state?.encoder?.close();
305
+ } catch {}
306
+ throw error;
307
+ } finally {
308
+ if (pendingEncoders.get(quality) === reservation) pendingEncoders.delete(quality);
309
+ }
310
+ })();
311
+ return reservation.promise;
312
+ }
313
+ /** Cancel an uncertain allocation and release only the selected cohort. */
314
+ function removeEncoder(quality) {
315
+ // Cancel uncertain pending allocation before its async support probe returns.
316
+ pendingEncoders.delete(quality);
317
+ const state = encoders.get(quality);
318
+ if (!state) return;
319
+ encoders.delete(quality);
320
+ try {
321
+ state.encoder.close();
322
+ } catch {}
323
+ state.pending.clear();
324
+ state.frameGenerations.clear();
325
+ }
326
+ /** Renew viewing demand without allocating or resetting a codec. */
327
+ function touchEncoder(quality) {
328
+ const state = encoders.get(quality);
329
+ if (state) state.lastRequestedAt = performance.now();
330
+ }
331
+ /** Request the next eligible independent picture while preserving healthy in-flight deltas. */
332
+ function requestKeyFrame(quality) {
333
+ const state = encoders.get(quality);
334
+ if (state) {
335
+ state.lastRequestedAt = performance.now();
336
+ // Joining/recovering viewers need an additional key, but healthy viewers
337
+ // can keep consuming an intact in-flight chain until that key is encoded.
338
+ // Preserve any existing needsKey from actual output loss or source reset.
339
+ state.forceKey = true;
340
+ }
341
+ }
342
+ /** Release producer pressure for this exact stream/sequence, including refused stale packets. */
343
+ function acknowledge(streamId, sequence) {
344
+ for (const state of encoders.values())
345
+ if (state.streamId === streamId) state.pending.delete(sequence);
346
+ }
347
+ /** Fence pre-transition output using source-clock microseconds without replacing the track. */
348
+ function resetCapture(generation, minimumTimestampUs) {
349
+ if (!capture) return;
350
+ if (generation < capture.generation) return;
351
+ capture.generation = generation;
352
+ capture.minimumTimestampUs = minimumTimestampUs;
353
+ for (const state of encoders.values()) {
354
+ if (state.failed) continue;
355
+ state.forceKey = true;
356
+ state.needsKey = true;
357
+ state.frameGenerations.clear();
358
+ state.pending.clear();
359
+ // reset cancels outstanding pre-navigation encoding, preserving the track.
360
+ state.encoder.reset();
361
+ state.encoder.configure(state.configuration);
362
+ }
363
+ }
364
+ `;
@@ -0,0 +1,41 @@
1
+ import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { prepareNativeVideoExtension } from "./native-video-extension";
6
+ import { NATIVE_VIDEO_EXTENSION_SOURCE } from "./native-video-extension-source";
7
+
8
+ describe("immutable trusted native video helper", () => {
9
+ it("concurrent workspace starts publish complete files once without in-place truncation", async () => {
10
+ const directory = await mkdtemp(join(tmpdir(), "owned-extension-"));
11
+ try {
12
+ const paths = await Promise.all(
13
+ Array.from({ length: 12 }, () => prepareNativeVideoExtension(directory)),
14
+ );
15
+ expect(new Set(paths).size).toBe(1);
16
+ const path = paths[0]!;
17
+ expect(await readFile(join(path, "recorder.js"), "utf8")).toBe(NATIVE_VIDEO_EXTENSION_SOURCE);
18
+ expect((await readdir(directory)).length).toBe(1);
19
+ const modified = (await stat(join(path, "recorder.js"))).mtimeMs;
20
+ await prepareNativeVideoExtension(directory);
21
+ expect((await stat(join(path, "recorder.js"))).mtimeMs).toBe(modified);
22
+ const manifest = JSON.parse(await readFile(join(path, "manifest.json"), "utf8"));
23
+ expect(manifest.permissions).toEqual(["tabCapture", "debugger"]);
24
+ expect(manifest.content_scripts).toBeUndefined();
25
+ } finally {
26
+ await rm(directory, { recursive: true, force: true });
27
+ }
28
+ });
29
+ it("a failed publication does not poison future startup", async () => {
30
+ const directory = await mkdtemp(join(tmpdir(), "owned-extension-retry-"));
31
+ const blocked = join(directory, "initially-file");
32
+ try {
33
+ await writeFile(blocked, "occupied");
34
+ await expect(prepareNativeVideoExtension(blocked)).rejects.toThrow();
35
+ await rm(blocked);
36
+ expect(await prepareNativeVideoExtension(blocked)).toContain("native-video-extension-");
37
+ } finally {
38
+ await rm(directory, { recursive: true, force: true });
39
+ }
40
+ });
41
+ });
@@ -0,0 +1,83 @@
1
+ /** Materializes the trusted tab-capture helper inside this runtime's private IPC tree. */
2
+ import { createHash } from "node:crypto";
3
+ import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { NATIVE_VIDEO_EXTENSION_SOURCE } from "./native-video-extension-source";
6
+
7
+ const PUBLIC_EXTENSION_KEY =
8
+ "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs/JqirNVWTX8dJUeT8eyDXNNZS9jbMpw+IeqE6MHaM6+DPzMH/+QoM1VswHRZ/dRDWpqnKLtf5mhB7dWqWYXxAttlgbxolhVvj2M1DE9qfW+vEY9ag2vmn0d33nkhX5tHZcl4l1z9Gq9nrOaRjOsSm8FOfQIkCvFKwUgbE7rdgXUjwKjPf598zM5Pu36vT6IH4rP1dpnJpi+wVJzRKL1bRziP0hrrZulVNO97JOHumQ05VmS8jrmnKhwk/koFMSOHVvonuxXqnnPwdzNHfgQeCuYUcRNqgWxSnSwFfGqRwRjJ14H0l4nmFS3gvcMuabma9c3fTAX5EEU1CmzdW3MYQIDAQAB";
9
+ export const NATIVE_VIDEO_EXTENSION_ID = [
10
+ ...createHash("sha256")
11
+ .update(Buffer.from(PUBLIC_EXTENSION_KEY, "base64"))
12
+ .digest()
13
+ .subarray(0, 16),
14
+ ]
15
+ .map((byte) => String.fromCharCode(97 + (byte >> 4), 97 + (byte & 15)))
16
+ .join("");
17
+ const HELPER_HTML = '<!doctype html><meta charset="utf-8"><script src="recorder.js"></script>';
18
+ const HELPER_MANIFEST = JSON.stringify({
19
+ manifest_version: 3,
20
+ name: "Shared Browser native capture",
21
+ version: "1.0.0",
22
+ key: PUBLIC_EXTENSION_KEY,
23
+ permissions: ["tabCapture", "debugger"],
24
+ });
25
+ const CONTENT_HASH = createHash("sha256")
26
+ .update(HELPER_MANIFEST)
27
+ .update(HELPER_HTML)
28
+ .update(NATIVE_VIDEO_EXTENSION_SOURCE)
29
+ .digest("hex");
30
+ const preparations = new Map<string, Promise<string>>();
31
+
32
+ /**
33
+ * Publish an immutable content-versioned directory atomically, safe for concurrent
34
+ * workspace startups sharing one IPC root. Failure is retryable, never cached.
35
+ * No web-accessible resources, messages, content scripts, or network permissions.
36
+ */
37
+ export function prepareNativeVideoExtension(ipcDirectory: string): Promise<string> {
38
+ let preparation = preparations.get(ipcDirectory);
39
+ if (!preparation) {
40
+ preparation = materializeExtension(ipcDirectory);
41
+ preparations.set(ipcDirectory, preparation);
42
+ void preparation.catch(() => {
43
+ if (preparations.get(ipcDirectory) === preparation) preparations.delete(ipcDirectory);
44
+ });
45
+ }
46
+ return preparation;
47
+ }
48
+
49
+ async function materializeExtension(ipcDirectory: string): Promise<string> {
50
+ await mkdir(ipcDirectory, { recursive: true, mode: 0o700 });
51
+ const directory = join(ipcDirectory, `native-video-extension-${CONTENT_HASH.slice(0, 24)}`);
52
+ if (await isComplete(directory)) return directory;
53
+ const temporary = await mkdtemp(join(ipcDirectory, ".native-video-extension-"));
54
+ try {
55
+ await chmod(temporary, 0o700);
56
+ await Promise.all([
57
+ writeFile(join(temporary, "manifest.json"), HELPER_MANIFEST, { mode: 0o600 }),
58
+ writeFile(join(temporary, "recorder.html"), HELPER_HTML, { mode: 0o600 }),
59
+ writeFile(join(temporary, "recorder.js"), NATIVE_VIDEO_EXTENSION_SOURCE, { mode: 0o600 }),
60
+ ]);
61
+ await writeFile(join(temporary, "complete"), CONTENT_HASH, { mode: 0o600 });
62
+ try {
63
+ await rename(temporary, directory);
64
+ } catch (error) {
65
+ const code = (error as NodeJS.ErrnoException).code;
66
+ if ((code !== "EEXIST" && code !== "ENOTEMPTY") || !(await isComplete(directory)))
67
+ throw error;
68
+ }
69
+ return directory;
70
+ } finally {
71
+ await rm(temporary, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ /** The completion marker is published with the whole directory, never separately. */
76
+ async function isComplete(directory: string): Promise<boolean> {
77
+ try {
78
+ return (await readFile(join(directory, "complete"), "utf8")) === CONTENT_HASH;
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
81
+ throw error;
82
+ }
83
+ }
@@ -0,0 +1,9 @@
1
+ /** Private supervisor receipts share one Node monotonic clock domain. */
2
+ import { z } from "zod";
3
+ import { nativeVideoPacketSchema } from "../shared/browser-video";
4
+
5
+ /** The native capture owns this timestamp; it is removed before public RPC output. */
6
+ export const runtimeVideoPacketSchema = nativeVideoPacketSchema.extend({
7
+ capturedAtMonotonicMs: z.number().finite(),
8
+ });
9
+ export type RuntimeVideoPacket = z.infer<typeof runtimeVideoPacketSchema>;