@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.
- package/README.md +51 -1
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +43 -14
- package/dist/artifacts.js.map +1 -1
- package/dist/{chunk-ROIRTM37.js → chunk-5UQPNLIO.js} +6 -6
- package/dist/{chunk-JR3QFY6B.js → chunk-6KLX2RH5.js} +49 -2
- package/dist/chunk-6KLX2RH5.js.map +1 -0
- package/dist/{chunk-FWS3KC3Z.js → chunk-B6WPZSTT.js} +104 -104
- package/dist/{chunk-FWS3KC3Z.js.map → chunk-B6WPZSTT.js.map} +1 -1
- package/dist/{chunk-HDBG7XGO.js → chunk-C5ZPSSS5.js} +50 -1
- package/dist/chunk-C5ZPSSS5.js.map +1 -0
- package/dist/{chunk-X4S56Y2H.js → chunk-HA46CYQK.js} +135 -11
- package/dist/chunk-HA46CYQK.js.map +1 -0
- package/dist/{chunk-724BW4TW.js → chunk-IGC6A2ZI.js} +2 -2
- package/dist/chunk-LLOLUHVG.js +5937 -0
- package/dist/chunk-LLOLUHVG.js.map +1 -0
- package/dist/{chunk-WYA65Y3F.js → chunk-MKPIAGVL.js} +13 -3
- package/dist/chunk-MKPIAGVL.js.map +1 -0
- package/dist/chunk-R3AZOAB5.js +933 -0
- package/dist/chunk-R3AZOAB5.js.map +1 -0
- package/dist/{chunk-23EJ676W.js → chunk-XBZWOM2K.js} +9 -4
- package/dist/chunk-XBZWOM2K.js.map +1 -0
- package/dist/{chunk-JAGDUCZQ.js → chunk-Y7OEDUJ5.js} +17 -13
- package/dist/chunk-Y7OEDUJ5.js.map +1 -0
- package/dist/client.d.ts +6 -4
- package/dist/components/artifacts/index.d.ts +1 -0
- package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
- package/dist/components/browser-input.d.ts +7 -0
- package/dist/components/browser-viewer.d.ts +5 -4
- package/dist/components/computer-viewer.d.ts +2 -1
- package/dist/components/desktop-viewer.d.ts +26 -2
- package/dist/components/interaction-intervention-banner.d.ts +11 -0
- package/dist/components/sandbox-terminal.d.ts +4 -1
- package/dist/components/sandbox-workspace.d.ts +3 -0
- package/dist/composer.js +6 -6
- package/dist/hooks/use-auth-runs.d.ts +32 -0
- package/dist/hooks/use-browser-downloads.d.ts +25 -0
- package/dist/hooks/use-browser-identities.d.ts +5 -1
- package/dist/hooks/use-browser-session.d.ts +2 -1
- package/dist/hooks/use-browser-sessions.d.ts +1 -1
- package/dist/hooks/use-channels.d.ts +26 -0
- package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
- package/dist/hooks/use-computer-session.d.ts +2 -1
- package/dist/hooks/use-computer-sessions.d.ts +1 -1
- package/dist/hooks/use-desktop-stream.d.ts +4 -0
- package/dist/hooks/use-interaction-interventions.d.ts +27 -0
- package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
- package/dist/hooks/use-network-routes.d.ts +25 -0
- package/dist/hooks/use-site-auth-connections.d.ts +25 -0
- package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
- package/dist/hooks/use-terminal-stream.d.ts +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1220 -1545
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +13 -3
- package/dist/interaction.js +520 -6
- package/dist/interaction.js.map +1 -1
- package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
- package/dist/lib/host-platform.d.ts +5 -0
- package/dist/lib/relay-wire.d.ts +20 -1
- package/dist/machines.js +3 -3
- package/dist/model-policy.d.ts +1 -1
- package/dist/model-policy.js +1 -1
- package/dist/provider.d.ts +3 -2
- package/dist/realtime/realtime-control.d.ts +6 -2
- package/dist/realtime.js +138 -24
- package/dist/realtime.js.map +1 -1
- package/dist/session-context.d.ts +10 -2
- package/dist/session-ui.js +3 -3
- package/dist/session.js +11 -11
- package/dist/timeline/types.d.ts +5 -0
- package/package.json +2 -2
- package/src/artifacts.ts +3 -0
- package/src/client.ts +73 -3
- package/src/components/artifacts/index.ts +5 -0
- package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
- package/src/components/browser-input.ts +65 -0
- package/src/components/browser-viewer.tsx +1287 -177
- package/src/components/computer-viewer.tsx +611 -114
- package/src/components/desktop-viewer.tsx +114 -1
- package/src/components/interaction-intervention-banner.tsx +99 -0
- package/src/components/machines/machine-detail.tsx +1 -1
- package/src/components/model-policy-picker.tsx +15 -0
- package/src/components/sandbox-terminal.tsx +24 -7
- package/src/components/sandbox-workspace.tsx +42 -4
- package/src/components/workspace-dock.tsx +59 -5
- package/src/hooks/use-attached-browsers.ts +13 -11
- package/src/hooks/use-auth-runs.ts +278 -0
- package/src/hooks/use-browser-downloads.ts +218 -0
- package/src/hooks/use-browser-frame-stream.ts +168 -59
- package/src/hooks/use-browser-identities.ts +47 -6
- package/src/hooks/use-browser-session.ts +94 -27
- package/src/hooks/use-browser-sessions.ts +19 -14
- package/src/hooks/use-channels.ts +99 -0
- package/src/hooks/use-computer-frame-stream.ts +184 -61
- package/src/hooks/use-computer-session.ts +22 -2
- package/src/hooks/use-computer-sessions.ts +19 -13
- package/src/hooks/use-desktop-stream.ts +39 -7
- package/src/hooks/use-interaction-interventions.ts +242 -0
- package/src/hooks/use-interaction-invalidation.ts +66 -0
- package/src/hooks/use-machine-chip.ts +13 -3
- package/src/hooks/use-network-routes.ts +214 -0
- package/src/hooks/use-site-auth-connections.ts +226 -0
- package/src/hooks/use-supergrok-accounts.ts +81 -0
- package/src/hooks/use-terminal-stream.ts +288 -55
- package/src/index.ts +8 -0
- package/src/interaction.ts +30 -0
- package/src/lib/host-platform.ts +10 -0
- package/src/lib/relay-wire.ts +43 -4
- package/src/lib/terminal-capability.ts +1 -1
- package/src/model-policy.ts +19 -4
- package/src/provider.tsx +133 -1
- package/src/realtime/realtime-control.tsx +181 -26
- package/src/session-context.ts +66 -1
- package/src/timeline/activity-rail.tsx +6 -0
- package/src/timeline/projection.ts +58 -0
- package/src/timeline/types.ts +2 -0
- package/styles/compiled.css +64 -6
- package/dist/chunk-23EJ676W.js.map +0 -1
- package/dist/chunk-6BBWWOOU.js +0 -82
- package/dist/chunk-6BBWWOOU.js.map +0 -1
- package/dist/chunk-7I2KB3OK.js +0 -3895
- package/dist/chunk-7I2KB3OK.js.map +0 -1
- package/dist/chunk-HDBG7XGO.js.map +0 -1
- package/dist/chunk-JAGDUCZQ.js.map +0 -1
- package/dist/chunk-JR3QFY6B.js.map +0 -1
- package/dist/chunk-WYA65Y3F.js.map +0 -1
- package/dist/chunk-X4S56Y2H.js.map +0 -1
- package/dist/interactive-workbench-viewers-KHNOHWQE.js +0 -13
- /package/dist/{chunk-ROIRTM37.js.map → chunk-5UQPNLIO.js.map} +0 -0
- /package/dist/{chunk-724BW4TW.js.map → chunk-IGC6A2ZI.js.map} +0 -0
- /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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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:
|
|
189
|
+
resumeFromSeq:
|
|
190
|
+
lastRelaySequence === null ? "0" : (BigInt(lastRelaySequence) + 1n).toString(),
|
|
153
191
|
});
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
276
|
+
const relayFrame = decodeStreamFrame(body);
|
|
277
|
+
lastRelaySequence = relayFrame.seq;
|
|
278
|
+
frameBytes = relayFrame.data;
|
|
180
279
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 = () =>
|
|
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
|
|
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
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
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 {
|
|
47
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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: {
|
|
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 {
|
|
75
|
-
|
|
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,
|
|
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
|
}
|