@opengeni/react 0.52.1 → 1.0.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 (132) hide show
  1. package/README.md +51 -1
  2. package/dist/artifacts.d.ts +1 -1
  3. package/dist/artifacts.js +43 -14
  4. package/dist/artifacts.js.map +1 -1
  5. package/dist/{chunk-ROIRTM37.js → chunk-5UQPNLIO.js} +6 -6
  6. package/dist/{chunk-JR3QFY6B.js → chunk-6KLX2RH5.js} +49 -2
  7. package/dist/chunk-6KLX2RH5.js.map +1 -0
  8. package/dist/{chunk-FWS3KC3Z.js → chunk-B6WPZSTT.js} +104 -104
  9. package/dist/{chunk-FWS3KC3Z.js.map → chunk-B6WPZSTT.js.map} +1 -1
  10. package/dist/{chunk-HDBG7XGO.js → chunk-C5ZPSSS5.js} +50 -1
  11. package/dist/chunk-C5ZPSSS5.js.map +1 -0
  12. package/dist/{chunk-X4S56Y2H.js → chunk-HA46CYQK.js} +135 -11
  13. package/dist/chunk-HA46CYQK.js.map +1 -0
  14. package/dist/{chunk-724BW4TW.js → chunk-IGC6A2ZI.js} +2 -2
  15. package/dist/chunk-LLOLUHVG.js +5937 -0
  16. package/dist/chunk-LLOLUHVG.js.map +1 -0
  17. package/dist/{chunk-WYA65Y3F.js → chunk-MKPIAGVL.js} +13 -3
  18. package/dist/chunk-MKPIAGVL.js.map +1 -0
  19. package/dist/chunk-R3AZOAB5.js +933 -0
  20. package/dist/chunk-R3AZOAB5.js.map +1 -0
  21. package/dist/{chunk-23EJ676W.js → chunk-XBZWOM2K.js} +9 -4
  22. package/dist/chunk-XBZWOM2K.js.map +1 -0
  23. package/dist/{chunk-JAGDUCZQ.js → chunk-Y7OEDUJ5.js} +17 -13
  24. package/dist/chunk-Y7OEDUJ5.js.map +1 -0
  25. package/dist/client.d.ts +6 -4
  26. package/dist/components/artifacts/index.d.ts +1 -0
  27. package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
  28. package/dist/components/browser-input.d.ts +7 -0
  29. package/dist/components/browser-viewer.d.ts +5 -4
  30. package/dist/components/computer-viewer.d.ts +2 -1
  31. package/dist/components/desktop-viewer.d.ts +26 -2
  32. package/dist/components/interaction-intervention-banner.d.ts +11 -0
  33. package/dist/components/sandbox-terminal.d.ts +4 -1
  34. package/dist/components/sandbox-workspace.d.ts +3 -0
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/use-auth-runs.d.ts +32 -0
  37. package/dist/hooks/use-browser-downloads.d.ts +25 -0
  38. package/dist/hooks/use-browser-identities.d.ts +5 -1
  39. package/dist/hooks/use-browser-session.d.ts +2 -1
  40. package/dist/hooks/use-browser-sessions.d.ts +1 -1
  41. package/dist/hooks/use-channels.d.ts +26 -0
  42. package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
  43. package/dist/hooks/use-computer-session.d.ts +2 -1
  44. package/dist/hooks/use-computer-sessions.d.ts +1 -1
  45. package/dist/hooks/use-desktop-stream.d.ts +4 -0
  46. package/dist/hooks/use-interaction-interventions.d.ts +27 -0
  47. package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
  48. package/dist/hooks/use-network-routes.d.ts +25 -0
  49. package/dist/hooks/use-site-auth-connections.d.ts +25 -0
  50. package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
  51. package/dist/hooks/use-terminal-stream.d.ts +3 -0
  52. package/dist/index.d.ts +4 -0
  53. package/dist/index.js +1220 -1545
  54. package/dist/index.js.map +1 -1
  55. package/dist/interaction.d.ts +13 -3
  56. package/dist/interaction.js +520 -6
  57. package/dist/interaction.js.map +1 -1
  58. package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
  59. package/dist/lib/host-platform.d.ts +5 -0
  60. package/dist/lib/relay-wire.d.ts +20 -1
  61. package/dist/machines.js +3 -3
  62. package/dist/model-policy.d.ts +1 -1
  63. package/dist/model-policy.js +1 -1
  64. package/dist/provider.d.ts +3 -2
  65. package/dist/realtime/realtime-control.d.ts +6 -2
  66. package/dist/realtime.js +138 -24
  67. package/dist/realtime.js.map +1 -1
  68. package/dist/session-context.d.ts +10 -2
  69. package/dist/session-ui.js +3 -3
  70. package/dist/session.js +11 -11
  71. package/dist/timeline/types.d.ts +5 -0
  72. package/package.json +2 -2
  73. package/src/artifacts.ts +3 -0
  74. package/src/client.ts +73 -3
  75. package/src/components/artifacts/index.ts +5 -0
  76. package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
  77. package/src/components/browser-input.ts +65 -0
  78. package/src/components/browser-viewer.tsx +1287 -177
  79. package/src/components/computer-viewer.tsx +611 -114
  80. package/src/components/desktop-viewer.tsx +114 -1
  81. package/src/components/interaction-intervention-banner.tsx +99 -0
  82. package/src/components/machines/machine-detail.tsx +1 -1
  83. package/src/components/model-policy-picker.tsx +15 -0
  84. package/src/components/sandbox-terminal.tsx +24 -7
  85. package/src/components/sandbox-workspace.tsx +42 -4
  86. package/src/components/workspace-dock.tsx +59 -5
  87. package/src/hooks/use-attached-browsers.ts +13 -11
  88. package/src/hooks/use-auth-runs.ts +278 -0
  89. package/src/hooks/use-browser-downloads.ts +218 -0
  90. package/src/hooks/use-browser-frame-stream.ts +168 -59
  91. package/src/hooks/use-browser-identities.ts +47 -6
  92. package/src/hooks/use-browser-session.ts +94 -27
  93. package/src/hooks/use-browser-sessions.ts +19 -14
  94. package/src/hooks/use-channels.ts +99 -0
  95. package/src/hooks/use-computer-frame-stream.ts +184 -61
  96. package/src/hooks/use-computer-session.ts +22 -2
  97. package/src/hooks/use-computer-sessions.ts +19 -13
  98. package/src/hooks/use-desktop-stream.ts +39 -7
  99. package/src/hooks/use-interaction-interventions.ts +242 -0
  100. package/src/hooks/use-interaction-invalidation.ts +66 -0
  101. package/src/hooks/use-machine-chip.ts +13 -3
  102. package/src/hooks/use-network-routes.ts +214 -0
  103. package/src/hooks/use-site-auth-connections.ts +226 -0
  104. package/src/hooks/use-supergrok-accounts.ts +81 -0
  105. package/src/hooks/use-terminal-stream.ts +288 -55
  106. package/src/index.ts +8 -0
  107. package/src/interaction.ts +30 -0
  108. package/src/lib/host-platform.ts +10 -0
  109. package/src/lib/relay-wire.ts +43 -4
  110. package/src/lib/terminal-capability.ts +1 -1
  111. package/src/model-policy.ts +19 -4
  112. package/src/provider.tsx +133 -1
  113. package/src/realtime/realtime-control.tsx +181 -26
  114. package/src/session-context.ts +66 -1
  115. package/src/timeline/activity-rail.tsx +6 -0
  116. package/src/timeline/projection.ts +58 -0
  117. package/src/timeline/types.ts +2 -0
  118. package/styles/compiled.css +64 -6
  119. package/dist/chunk-23EJ676W.js.map +0 -1
  120. package/dist/chunk-6BBWWOOU.js +0 -82
  121. package/dist/chunk-6BBWWOOU.js.map +0 -1
  122. package/dist/chunk-7I2KB3OK.js +0 -3895
  123. package/dist/chunk-7I2KB3OK.js.map +0 -1
  124. package/dist/chunk-HDBG7XGO.js.map +0 -1
  125. package/dist/chunk-JAGDUCZQ.js.map +0 -1
  126. package/dist/chunk-JR3QFY6B.js.map +0 -1
  127. package/dist/chunk-WYA65Y3F.js.map +0 -1
  128. package/dist/chunk-X4S56Y2H.js.map +0 -1
  129. package/dist/interactive-workbench-viewers-KHNOHWQE.js +0 -13
  130. /package/dist/{chunk-ROIRTM37.js.map → chunk-5UQPNLIO.js.map} +0 -0
  131. /package/dist/{chunk-724BW4TW.js.map → chunk-IGC6A2ZI.js.map} +0 -0
  132. /package/dist/{interactive-workbench-viewers-KHNOHWQE.js.map → interactive-workbench-viewers-TOHBXJEE.js.map} +0 -0
@@ -9,7 +9,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
9
9
  import {
10
10
  decodeStreamFrame,
11
11
  decodeStreamOpenAck,
12
+ encodeStreamClose,
12
13
  encodeStreamOpen,
14
+ STREAM_CLOSE_REASON_NORMAL,
13
15
  STREAM_KIND_COMPUTER,
14
16
  STREAM_ROLE_CLIENT,
15
17
  } from "../lib/relay-wire";
@@ -39,6 +41,8 @@ export type ComputerFrameWebSocketFactory = (
39
41
  const RELAY_TAG_OPEN = 1;
40
42
  const RELAY_TAG_OPEN_ACK = 2;
41
43
  const RELAY_TAG_FRAME = 3;
44
+ const RELAY_TAG_CLOSE = 4;
45
+ const MAX_AUTOMATIC_RECONNECTS_WITHOUT_A_FRAME = 2;
42
46
 
43
47
  export type UseComputerFrameStreamOptions = EmbeddedComputerInteractionClientOverride & {
44
48
  computerSessionId: string | null;
@@ -53,7 +57,7 @@ export type UseComputerFrameStreamResult = {
53
57
  frame: ComputerFrame | null;
54
58
  attachment: Pick<
55
59
  ComputerSessionAttachment,
56
- "computerSessionId" | "controllerGeneration" | "targetId" | "expiresAt"
60
+ "computerSessionId" | "controllerGeneration" | "targetId" | "stream" | "expiresAt"
57
61
  > | null;
58
62
  error: Error | null;
59
63
  reconnect: () => void;
@@ -102,66 +106,144 @@ export function useComputerFrameStream(
102
106
 
103
107
  let disposed = false;
104
108
  let socket: ComputerFrameWebSocket | null = null;
109
+ let socketHandlers: {
110
+ open: () => void;
111
+ message: (event: MessageEvent) => void;
112
+ error: () => void;
113
+ close: () => void;
114
+ } | null = null;
105
115
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
106
116
  let expiryTimer: ReturnType<typeof setTimeout> | null = null;
107
117
  let failures = 0;
108
118
  let controllerGeneration: string | null = null;
119
+ let activeAttachment: ComputerSessionAttachment | null = null;
109
120
  let activeStream: ComputerSessionAttachment["stream"] | null = null;
121
+ let attachmentExpiresAt = 0;
122
+ let lastRelaySequence: string | null = null;
110
123
  let relayAccepted = false;
124
+ let pendingFrame: { bytes: Uint8Array; source: ComputerFrameWebSocket } | null = null;
125
+ let decodingFrame = false;
111
126
  const attachmentAbort = new AbortController();
112
127
 
113
- const clearSocket = () => {
128
+ const clearSocket = (terminateProducer = false) => {
114
129
  if (!socket) return;
115
- socket.removeEventListener("open", onOpen);
116
- socket.removeEventListener("message", onMessage);
117
- socket.removeEventListener("error", onError);
118
- socket.removeEventListener("close", onClose);
119
- if (socket.readyState === 0 || socket.readyState === 1) {
120
- socket.close(1000, "viewer detached");
130
+ const current = socket;
131
+ const handlers = socketHandlers;
132
+ if (terminateProducer && activeStream?.kind === "relay" && socket.readyState === 1) {
133
+ try {
134
+ socket.send(
135
+ relayDatagram(
136
+ RELAY_TAG_CLOSE,
137
+ encodeStreamClose({
138
+ channelId: activeStream.channel.channelId,
139
+ reason: STREAM_CLOSE_REASON_NORMAL,
140
+ message: "viewer detached",
141
+ }),
142
+ ),
143
+ );
144
+ } catch {
145
+ // OPEN can race a transport close. Local teardown must still finish.
146
+ }
147
+ }
148
+ if (handlers) {
149
+ current.removeEventListener("open", handlers.open);
150
+ current.removeEventListener("message", handlers.message);
151
+ current.removeEventListener("error", handlers.error);
152
+ current.removeEventListener("close", handlers.close);
153
+ }
154
+ if (current.readyState === 0 || current.readyState === 1) {
155
+ current.close(1000, "viewer detached");
121
156
  }
122
157
  socket = null;
158
+ socketHandlers = null;
159
+ pendingFrame = null;
123
160
  };
124
161
 
125
162
  const scheduleReconnect = () => {
126
163
  if (disposed || reconnectTimer) return;
164
+ if (failures >= MAX_AUTOMATIC_RECONNECTS_WITHOUT_A_FRAME) return;
127
165
  const delay = Math.min(5_000, 250 * 2 ** Math.min(failures, 5));
128
166
  failures += 1;
129
167
  setResult((current) => ({ ...current, state: "reconnecting" }));
130
168
  reconnectTimer = setTimeout(() => {
131
169
  reconnectTimer = null;
132
- void connect();
170
+ connectSocket();
133
171
  }, delay);
134
172
  };
135
173
 
136
- const fail = (cause: unknown) => {
174
+ const fail = (cause: unknown, reconnectAutomatically = true) => {
137
175
  if (disposed) return;
138
176
  const error = cause instanceof Error ? cause : new Error(String(cause));
139
177
  setResult((current) => ({ ...current, state: "error", error }));
140
178
  clearSocket();
141
- scheduleReconnect();
179
+ if (reconnectAutomatically) scheduleReconnect();
142
180
  };
143
181
 
144
- const onOpen = () => {
145
- if (disposed) return;
146
- failures = 0;
182
+ const onOpen = (source: ComputerFrameWebSocket) => {
183
+ if (disposed || source !== socket) return;
147
184
  if (activeStream?.kind === "relay") {
148
185
  const body = encodeStreamOpen({
149
186
  channel: { ...activeStream.channel, kind: STREAM_KIND_COMPUTER },
150
187
  token: activeStream.token,
151
188
  role: STREAM_ROLE_CLIENT,
152
- resumeFromSeq: "0",
189
+ resumeFromSeq:
190
+ lastRelaySequence === null ? "0" : (BigInt(lastRelaySequence) + 1n).toString(),
153
191
  });
154
- socket?.send(relayDatagram(RELAY_TAG_OPEN, body));
192
+ try {
193
+ source.send(relayDatagram(RELAY_TAG_OPEN, body));
194
+ } catch (error) {
195
+ fail(error);
196
+ }
155
197
  return;
156
198
  }
157
199
  setResult((current) => ({ ...current, state: "live", error: null }));
158
200
  };
159
201
 
160
- const onMessage = (event: MessageEvent) => {
202
+ const drainLatestFrame = async () => {
203
+ if (decodingFrame) return;
204
+ decodingFrame = true;
205
+ try {
206
+ while (pendingFrame) {
207
+ if (disposed) break;
208
+ const pending = pendingFrame;
209
+ pendingFrame = null;
210
+ const { bytes, source } = pending;
211
+ const frame = await decodeComputerFrameMessage(bytes);
212
+ if (disposed || source !== socket) continue;
213
+ if (frame.computerSessionId !== computerSessionId || frame.targetId !== targetId) {
214
+ throw new Error("computer frame belongs to another resource");
215
+ }
216
+ if (!controllerGeneration || frame.controllerGeneration !== controllerGeneration) {
217
+ throw new Error("computer frame belongs to a stale controller");
218
+ }
219
+ const key = `${computerSessionId}:${targetId}:${frame.controllerGeneration}:${frame.targetGeneration}`;
220
+ if (latestRef.current.key === key && frame.sequence <= latestRef.current.sequence)
221
+ continue;
222
+ latestRef.current = { key, sequence: frame.sequence };
223
+ // A real decoded frame—not merely a socket handshake—is the recovery
224
+ // boundary. Resetting on `open` caused a dead producer to reconnect
225
+ // forever and continuously spawn fresh relay/SCK work.
226
+ failures = 0;
227
+ setResult((current) => ({
228
+ ...current,
229
+ state: "live",
230
+ frame,
231
+ error: null,
232
+ }));
233
+ }
234
+ } catch (cause) {
235
+ fail(cause);
236
+ } finally {
237
+ decodingFrame = false;
238
+ if (!disposed && pendingFrame) void drainLatestFrame();
239
+ }
240
+ };
241
+
242
+ const onMessage = (source: ComputerFrameWebSocket, event: MessageEvent) => {
161
243
  void (async () => {
162
244
  try {
163
245
  const bytes = await messageBytes(event.data);
164
- if (disposed) return;
246
+ if (disposed || source !== socket) return;
165
247
  let frameBytes = bytes;
166
248
  if (activeStream?.kind === "relay") {
167
249
  if (bytes.length < 1) throw new Error("computer relay returned an empty message");
@@ -170,46 +252,93 @@ export function useComputerFrameStream(
170
252
  if (tag === RELAY_TAG_OPEN_ACK) {
171
253
  const ack = decodeStreamOpenAck(body);
172
254
  if (!ack.accepted) {
255
+ activeAttachment = null;
256
+ activeStream = null;
257
+ attachmentExpiresAt = 0;
173
258
  throw new Error(ack.error?.message ?? "computer stream was rejected by the relay");
174
259
  }
175
260
  relayAccepted = true;
176
261
  return;
177
262
  }
263
+ if (tag === RELAY_TAG_CLOSE) {
264
+ // A relay close is terminal for this producer/channel. Reusing
265
+ // the same attachment only reconnects to a dead channel forever;
266
+ // invalidate it so the retry mints a fresh producer.
267
+ activeAttachment = null;
268
+ activeStream = null;
269
+ attachmentExpiresAt = 0;
270
+ lastRelaySequence = null;
271
+ relayAccepted = false;
272
+ fail(new Error("Computer frame source ended."), false);
273
+ return;
274
+ }
178
275
  if (tag !== RELAY_TAG_FRAME || !relayAccepted) return;
179
- frameBytes = decodeStreamFrame(body).data;
276
+ const relayFrame = decodeStreamFrame(body);
277
+ lastRelaySequence = relayFrame.seq;
278
+ frameBytes = relayFrame.data;
180
279
  }
181
- const frame = await decodeComputerFrameMessage(frameBytes);
182
- if (disposed) return;
183
- if (frame.computerSessionId !== computerSessionId || frame.targetId !== targetId) {
184
- throw new Error("computer frame belongs to another resource");
185
- }
186
- if (!controllerGeneration || frame.controllerGeneration !== controllerGeneration) {
187
- throw new Error("computer frame belongs to a stale controller");
188
- }
189
- const key = `${computerSessionId}:${targetId}:${frame.controllerGeneration}:${frame.targetGeneration}`;
190
- if (latestRef.current.key === key && frame.sequence <= latestRef.current.sequence) return;
191
- latestRef.current = { key, sequence: frame.sequence };
192
- setResult((current) => ({
193
- ...current,
194
- state: "live",
195
- frame,
196
- error: null,
197
- }));
280
+ // Digest verification can take longer than an inter-frame interval on
281
+ // low-power clients. Keep exactly one pending encoded frame and replace
282
+ // it with newer input while a decode is in flight; never build latency.
283
+ pendingFrame = { bytes: frameBytes, source };
284
+ void drainLatestFrame();
198
285
  } catch (cause) {
199
286
  fail(cause);
200
287
  }
201
288
  })();
202
289
  };
203
290
 
204
- const onError = () => fail(new Error("Computer view lost connection."));
291
+ const onError = (source: ComputerFrameWebSocket) => {
292
+ if (source !== socket) return;
293
+ fail(new Error("Computer view lost connection."));
294
+ };
205
295
 
206
- const onClose = () => {
207
- if (disposed) return;
296
+ const onClose = (source: ComputerFrameWebSocket) => {
297
+ if (disposed || source !== socket) return;
208
298
  clearSocket();
209
299
  scheduleReconnect();
210
300
  };
211
301
 
212
- const connect = async () => {
302
+ const connectSocket = () => {
303
+ if (disposed) return;
304
+ clearSocket();
305
+ if (!activeAttachment || !activeStream || attachmentExpiresAt <= Date.now() + 1_000) {
306
+ void attach();
307
+ return;
308
+ }
309
+ if (activeStream.kind === "direct_rfb") {
310
+ setResult((current) => ({ ...current, state: "live", error: null }));
311
+ return;
312
+ }
313
+ relayAccepted = false;
314
+ setResult((current) => ({
315
+ ...current,
316
+ state: "connecting",
317
+ error: null,
318
+ }));
319
+ const createSocket =
320
+ factoryRef.current ?? ((url: string, protocols: string[]) => new WebSocket(url, protocols));
321
+ const openedSocket = createSocket(
322
+ activeStream.kind === "relay"
323
+ ? activeStream.url
324
+ : computerFrameSocketUrl(activeAttachment, streamRef.current),
325
+ activeStream.kind === "direct_websocket" ? [...activeStream.protocols] : [],
326
+ );
327
+ socket = openedSocket;
328
+ socketHandlers = {
329
+ open: () => onOpen(openedSocket),
330
+ message: (event) => onMessage(openedSocket, event),
331
+ error: () => onError(openedSocket),
332
+ close: () => onClose(openedSocket),
333
+ };
334
+ openedSocket.binaryType = "arraybuffer";
335
+ openedSocket.addEventListener("open", socketHandlers.open);
336
+ openedSocket.addEventListener("message", socketHandlers.message);
337
+ openedSocket.addEventListener("error", socketHandlers.error);
338
+ openedSocket.addEventListener("close", socketHandlers.close);
339
+ };
340
+
341
+ const attach = async () => {
213
342
  if (disposed) return;
214
343
  clearSocket();
215
344
  if (expiryTimer) clearTimeout(expiryTimer);
@@ -237,7 +366,10 @@ export function useComputerFrameStream(
237
366
  throw new Error("computer attachment does not match the requested resource");
238
367
  }
239
368
  controllerGeneration = attachment.controllerGeneration;
369
+ activeAttachment = attachment;
240
370
  activeStream = attachment.stream;
371
+ attachmentExpiresAt = Date.parse(attachment.expiresAt);
372
+ lastRelaySequence = null;
241
373
  relayAccepted = false;
242
374
  setResult((current) => ({
243
375
  ...current,
@@ -246,46 +378,37 @@ export function useComputerFrameStream(
246
378
  computerSessionId: attachment.computerSessionId,
247
379
  controllerGeneration: attachment.controllerGeneration,
248
380
  targetId: attachment.targetId,
381
+ stream: attachment.stream,
249
382
  expiresAt: attachment.expiresAt,
250
383
  },
251
384
  error: null,
252
385
  }));
253
- const createSocket =
254
- factoryRef.current ??
255
- ((url: string, protocols: string[]) => new WebSocket(url, protocols));
256
- socket = createSocket(
257
- attachment.stream.kind === "relay"
258
- ? attachment.stream.url
259
- : computerFrameSocketUrl(attachment, streamRef.current),
260
- attachment.stream.kind === "direct_websocket" ? [...attachment.stream.protocols] : [],
261
- );
262
- socket.binaryType = "arraybuffer";
263
- socket.addEventListener("open", onOpen);
264
- socket.addEventListener("message", onMessage);
265
- socket.addEventListener("error", onError);
266
- socket.addEventListener("close", onClose);
267
- const expiresAt = Date.parse(attachment.expiresAt);
268
- const refreshIn = Number.isFinite(expiresAt)
269
- ? Math.max(1_000, expiresAt - Date.now() - 5_000)
386
+ const refreshIn = Number.isFinite(attachmentExpiresAt)
387
+ ? Math.max(1_000, attachmentExpiresAt - Date.now() - 5_000)
270
388
  : 60_000;
271
389
  expiryTimer = setTimeout(() => {
272
390
  if (disposed) return;
273
- clearSocket();
274
- void connect();
391
+ clearSocket(true);
392
+ activeAttachment = null;
393
+ activeStream = null;
394
+ attachmentExpiresAt = 0;
395
+ void attach();
275
396
  }, refreshIn);
397
+ connectSocket();
276
398
  } catch (cause) {
277
399
  if (attachmentAbort.signal.aborted || disposed) return;
278
400
  fail(cause);
279
401
  }
280
402
  };
281
403
 
282
- void connect();
404
+ void attach();
283
405
  return () => {
284
406
  disposed = true;
407
+ pendingFrame = null;
285
408
  attachmentAbort.abort();
286
409
  if (reconnectTimer) clearTimeout(reconnectTimer);
287
410
  if (expiryTimer) clearTimeout(expiryTimer);
288
- clearSocket();
411
+ clearSocket(true);
289
412
  };
290
413
  }, [client, enabled, nonce, options.computerSessionId, options.targetId, workspaceId]);
291
414
 
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ComputerAction,
3
3
  ComputerActionReceipt,
4
+ ComputerClipboard,
4
5
  ComputerFrame,
5
6
  ComputerObservation,
6
7
  ComputerSession,
@@ -28,6 +29,7 @@ export type UseComputerSessionResult = {
28
29
  mutating: boolean;
29
30
  error: Error | null;
30
31
  refresh: () => Promise<void>;
32
+ readClipboard: () => Promise<ComputerClipboard>;
31
33
  /** Changes only this viewer's target cursor; it never takes ownership or
32
34
  * foregrounds an application. */
33
35
  selectTarget: (targetId: string) => Promise<ComputerTarget>;
@@ -112,7 +114,7 @@ export function useComputerSession(options: UseComputerSessionOptions): UseCompu
112
114
  ]);
113
115
  if (!mountedRef.current || requestRef.current.id !== id) return;
114
116
  const targets = sortComputerTargets(targetResponse.targets);
115
- const selected = chooseTarget(targets, selectedTargetIdRef.current);
117
+ const selected = chooseTarget(targets, selectedTargetIdRef.current, session.platform);
116
118
  const observation = selected
117
119
  ? await client.observeComputerTarget(workspaceId, computerSessionId, selected.id, {
118
120
  signal: controller.signal,
@@ -364,6 +366,11 @@ export function useComputerSession(options: UseComputerSessionOptions): UseCompu
364
366
  [dispatchAction],
365
367
  );
366
368
 
369
+ const readClipboard = useCallback(async (): Promise<ComputerClipboard> => {
370
+ if (!computerSessionId) throw new Error("No ComputerSession is selected.");
371
+ return await client.readComputerClipboard(workspaceId, computerSessionId);
372
+ }, [client, computerSessionId, workspaceId]);
373
+
367
374
  return {
368
375
  session: visible.session,
369
376
  targets: visible.targets,
@@ -374,6 +381,7 @@ export function useComputerSession(options: UseComputerSessionOptions): UseCompu
374
381
  mutating: visible.mutating,
375
382
  error: visible.error,
376
383
  refresh,
384
+ readClipboard,
377
385
  selectTarget,
378
386
  act,
379
387
  actFromFrame,
@@ -407,9 +415,21 @@ function emptyState(computerSessionId: string | null, loading: boolean): Compute
407
415
  function chooseTarget(
408
416
  targets: readonly ComputerTarget[],
409
417
  preferredId: string | null,
418
+ platform: ComputerSession["platform"],
410
419
  ): ComputerTarget | null {
420
+ const preferred = targets.find((target) => target.id === preferredId);
421
+ if (preferred) return preferred;
422
+ if (platform === "linux") {
423
+ return (
424
+ targets.find((target) => target.focused && target.kind === "screen") ??
425
+ targets.find((target) => target.kind === "screen") ??
426
+ targets.find((target) => target.focused && target.kind === "window") ??
427
+ targets.find((target) => target.kind === "window") ??
428
+ targets[0] ??
429
+ null
430
+ );
431
+ }
411
432
  return (
412
- targets.find((target) => target.id === preferredId) ??
413
433
  targets.find((target) => target.focused && target.kind === "window") ??
414
434
  targets.find((target) => target.kind === "window") ??
415
435
  targets.find((target) => target.focused && target.kind === "app") ??
@@ -9,13 +9,13 @@ import {
9
9
  type EmbeddedComputerInteractionClientOverride,
10
10
  useEmbeddedComputerInteraction,
11
11
  } from "../session-context";
12
- import { usePageLiveActivity } from "./internal";
12
+ import { useInteractionInvalidation } from "./use-interaction-invalidation";
13
13
 
14
14
  export type UseComputerSessionsOptions = EmbeddedComputerInteractionClientOverride & {
15
15
  /** Current agent/session relevance. Workspace peers remain in `sessions`. */
16
16
  sessionId?: string | undefined;
17
17
  enabled?: boolean | undefined;
18
- /** Temporary invalidation path until the interaction event stream lands. */
18
+ /** Polling fallback used only while the workspace invalidation stream is unavailable. */
19
19
  pollIntervalMs?: number | undefined;
20
20
  };
21
21
 
@@ -43,11 +43,14 @@ export type UseComputerSessionsResult = {
43
43
  export function useComputerSessions(
44
44
  options: UseComputerSessionsOptions = {},
45
45
  ): UseComputerSessionsResult {
46
- const { client, workspaceId, registerSessionReconciler } =
47
- useEmbeddedComputerInteraction(options);
46
+ const {
47
+ client,
48
+ workspaceId,
49
+ registerSessionReconciler,
50
+ workspaceInteractionEvent,
51
+ workspaceInteractionConnectionState,
52
+ } = useEmbeddedComputerInteraction(options);
48
53
  const enabled = options.enabled ?? true;
49
- const pageLive = usePageLiveActivity();
50
- const pollIntervalMs = Math.max(1_000, options.pollIntervalMs ?? 3_000);
51
54
  const [state, setState] = useState<ComputerRegistryState>(() => emptyState(workspaceId, enabled));
52
55
  const requestRef = useRef<{ id: number; controller: AbortController | null }>({
53
56
  id: 0,
@@ -120,13 +123,16 @@ export function useComputerSessions(
120
123
  };
121
124
  }, [cancelLoad, enabled, load, workspaceId]);
122
125
 
123
- useEffect(() => {
124
- if (!enabled || !pageLive) return;
125
- const timer = setInterval(() => {
126
- if (!requestRef.current.controller) void load(false);
127
- }, pollIntervalMs);
128
- return () => clearInterval(timer);
129
- }, [enabled, load, pageLive, pollIntervalMs]);
126
+ useInteractionInvalidation({
127
+ workspaceId,
128
+ key: workspaceId,
129
+ enabled,
130
+ event: workspaceInteractionEvent,
131
+ connectionState: workspaceInteractionConnectionState,
132
+ knownRevision: visible.revision,
133
+ refresh,
134
+ fallbackPollIntervalMs: options.pollIntervalMs,
135
+ });
130
136
 
131
137
  useEffect(() => {
132
138
  if (!enabled || !options.sessionId) return;
@@ -6,7 +6,7 @@ import {
6
6
  type DesktopRfbLike,
7
7
  type DesktopStreamCapability,
8
8
  } from "@opengeni/sdk";
9
- import { type RefObject, useEffect, useRef, useState } from "react";
9
+ import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
10
10
  import { type DesktopWebSocketFactory, useRelayFrameStream } from "./use-relay-frame-stream";
11
11
 
12
12
  export type UseDesktopStreamOptions = {
@@ -19,6 +19,8 @@ export type UseDesktopStreamOptions = {
19
19
  scaleViewport?: boolean | undefined;
20
20
  /** Custom RFB factory (tests / a WebRTC swap). Defaults to a lazy @novnc/novnc. */
21
21
  rfbFactory?: DesktopRfbFactory | undefined;
22
+ /** Additional WebSocket protocols for an authenticated placement proxy. */
23
+ webSocketProtocols?: string[] | undefined;
22
24
  /** Custom socket factory for the `relay-frames` transport (tests). Defaults to
23
25
  * `new WebSocket(url)`. Mirrors `rfbFactory` for the frame renderer. */
24
26
  webSocketFactory?: DesktopWebSocketFactory | undefined;
@@ -29,6 +31,8 @@ export type UseDesktopStreamResult = {
29
31
  error: Error | null;
30
32
  /** Manual reconnect (e.g. after a securityfailure once a fresh URL arrives). */
31
33
  reconnect: () => void;
34
+ /** Synchronous input on the already-open RFB authority. */
35
+ sendKey?: ((keysym: number, code: string, down?: boolean) => boolean) | undefined;
32
36
  };
33
37
 
34
38
  /** Lazy-load @novnc/novnc's RFB as the default factory. Imported inside the
@@ -44,7 +48,10 @@ async function defaultRfbFactory(): Promise<DesktopRfbFactory> {
44
48
  default: new (
45
49
  t: HTMLElement,
46
50
  u: string,
47
- o: { credentials?: { password?: string | undefined } | undefined },
51
+ o: {
52
+ credentials?: { password?: string | undefined } | undefined;
53
+ wsProtocols?: string[] | undefined;
54
+ },
48
55
  ) => DesktopRfbLike;
49
56
  };
50
57
  const RFB = mod.default;
@@ -71,8 +78,15 @@ async function defaultRfbFactory(): Promise<DesktopRfbFactory> {
71
78
  * way, so `DesktopViewer` is transport-agnostic.
72
79
  */
73
80
  export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopStreamResult {
74
- const { capability, containerRef, interactive, scaleViewport, rfbFactory, webSocketFactory } =
75
- options;
81
+ const {
82
+ capability,
83
+ containerRef,
84
+ interactive,
85
+ scaleViewport,
86
+ rfbFactory,
87
+ webSocketProtocols,
88
+ webSocketFactory,
89
+ } = options;
76
90
 
77
91
  // The self-hosted PNG-frame path. Always invoked (rules of hooks); dormant
78
92
  // unless `transport === "relay-frames"`, in which case it owns the surface.
@@ -91,6 +105,12 @@ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopSt
91
105
  };
92
106
 
93
107
  const reconnect = () => setNonce((n) => n + 1);
108
+ const sendKey = useCallback((keysym: number, code: string, down?: boolean): boolean => {
109
+ const rfb = rfbRef.current;
110
+ if (!rfb?.sendKey || rfb.viewOnly || stateRef.current !== "connected") return false;
111
+ rfb.sendKey(keysym, code, down);
112
+ return true;
113
+ }, []);
94
114
 
95
115
  const url = capability?.url ?? null;
96
116
  // The credential is keyed into the connect effect so a token rotation (new
@@ -99,6 +119,14 @@ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopSt
99
119
  const token = capability?.token ?? null;
100
120
  const transport = capability?.transport ?? null;
101
121
  const mode = capability?.mode ?? "read-only";
122
+ // For direct sandbox RFB, authorization is carried in WebSocket
123
+ // subprotocols rather than `capability.token`. The view grant rotates before
124
+ // expiry while the URL stays stable, so the protocol credential is a real
125
+ // connection boundary: keeping the old socket leaves the viewer black once
126
+ // browserd expires it. Key the effect on the primitive value (not the caller's
127
+ // array identity) so credential rotation reconnects exactly once without
128
+ // churning on ordinary renders.
129
+ const webSocketProtocolsKey = webSocketProtocols?.join("\u0000") ?? "";
102
130
 
103
131
  // The live RFB handle. Holding it in a ref lets us flip view-only (take
104
132
  // control / return control) and re-scale on the OPEN connection instead of
@@ -113,10 +141,12 @@ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopSt
113
141
  const scaleViewportRef = useRef(scaleViewport);
114
142
  const modeRef = useRef(mode);
115
143
  const rfbFactoryRef = useRef(rfbFactory);
144
+ const webSocketProtocolsRef = useRef(webSocketProtocols);
116
145
  interactiveRef.current = interactive;
117
146
  scaleViewportRef.current = scaleViewport;
118
147
  modeRef.current = mode;
119
148
  rfbFactoryRef.current = rfbFactory;
149
+ webSocketProtocolsRef.current = webSocketProtocols;
120
150
 
121
151
  // read-only is forced when the server says so OR the caller didn't opt in.
122
152
  const viewOnlyFor = (m: string, want: boolean | undefined) => m === "read-only" || !want;
@@ -164,6 +194,7 @@ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopSt
164
194
  setBoth(nextDesktopState(stateRef.current, { type: "negotiated" }));
165
195
  rfb = factory(container, socketUrl, {
166
196
  credentials: token ? { password: token } : undefined,
197
+ ...(webSocketProtocolsRef.current ? { wsProtocols: webSocketProtocolsRef.current } : {}),
167
198
  });
168
199
  rfb.viewOnly = viewOnlyFor(modeRef.current, interactiveRef.current);
169
200
  // Fit-to-panel: SCALE the 1280x800 framebuffer down to the container
@@ -200,11 +231,12 @@ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopSt
200
231
  }
201
232
  };
202
233
  // ONLY a real transport change reconnects: a fresh url (rotation), a new
203
- // credential, the transport flipping, or an explicit manual reconnect
234
+ // credential (RFB password OR bearer subprotocol), the transport flipping,
235
+ // or an explicit manual reconnect
204
236
  // (`nonce`). `interactive`/`scaleViewport`/`mode`/`rfbFactory` are read via
205
237
  // refs and applied live below — they must never re-open the socket.
206
238
  // eslint-disable-next-line react-hooks/exhaustive-deps
207
- }, [url, token, transport, nonce]);
239
+ }, [url, token, transport, webSocketProtocolsKey, nonce]);
208
240
 
209
241
  // Apply take-control / return-control to the OPEN connection in place. noVNC
210
242
  // honours `viewOnly` live, so flipping it neither blinks the surface nor drops
@@ -236,5 +268,5 @@ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopSt
236
268
  // drives; for `vnc-ws` (and everything else) the noVNC path drives while the
237
269
  // frame renderer stays idle. Same public shape either way.
238
270
  if (transport === "relay-frames") return frameStream;
239
- return { state, error, reconnect };
271
+ return { state, error, reconnect, sendKey };
240
272
  }