replicas-computer 0.3.4 → 0.3.6

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 CHANGED
@@ -10,6 +10,7 @@ The versioned computer-use runtime for Replicas workspace images.
10
10
  - `src/pacer.ts` owns the per-viewer send budget: fresh media, NACK repairs and protective copies share one token bucket.
11
11
  - `src/rtp.ts` owns RTP continuity, keyframe detection, and frame reassembly for the socket fallback.
12
12
  - `src/stream-viewer.ts` owns the browser side: WebRTC playback, input routing, the WebSocket fallback, diagnostics and reconnects.
13
+ - `src/input-bridge.ts` converts takeover input into desktop events, accumulating small trackpad movements while preserving full mouse-wheel steps.
13
14
  - `src/viewer.ts` owns the optional noVNC fallback.
14
15
  - `assets/desktop/start-services.sh` owns the X11 and stream process lifecycle.
15
16
  - `xsettingsd` broadcasts theme changes to running GTK apps and Chrome. Chrome follows the dashboard theme without reopening tabs; launch it without a `GTK_THEME` override.
package/dist/index.js CHANGED
@@ -2937,7 +2937,7 @@ async function computerStatusCommand() {
2937
2937
  }
2938
2938
 
2939
2939
  // src/version.ts
2940
- var COMPUTER_USE_VERSION = "0.3.4";
2940
+ var COMPUTER_USE_VERSION = "0.3.6";
2941
2941
  var COMPUTER_USE_PROTOCOL_VERSION = 1;
2942
2942
 
2943
2943
  // src/program.ts
@@ -3556,6 +3556,7 @@ function superviseEncoder(options) {
3556
3556
 
3557
3557
  // src/input-bridge.ts
3558
3558
  import { spawn as spawn4 } from "child_process";
3559
+ var scrollPixelsPerStep = 80;
3559
3560
  var namedKeys = {
3560
3561
  AltLeft: "Alt_L",
3561
3562
  AltRight: "Alt_R",
@@ -3615,6 +3616,8 @@ var InputBridge = class {
3615
3616
  pointerFlush;
3616
3617
  positionSequence = -1;
3617
3618
  actionId = 0;
3619
+ scrollRemainder = 0;
3620
+ scrollButton = 0;
3618
3621
  latestPosition;
3619
3622
  pressedButtons = /* @__PURE__ */ new Set();
3620
3623
  pressedKeys = /* @__PURE__ */ new Set();
@@ -3673,13 +3676,23 @@ var InputBridge = class {
3673
3676
  if (message.type === "wheel") {
3674
3677
  this.pointer = this.coordinates(message.x, message.y);
3675
3678
  this.flushPointer(true);
3676
- const vertical = Math.min(4, Math.max(1, Math.round(Math.abs(message.deltaY) / 80)));
3677
- const horizontal = Math.min(4, Math.max(1, Math.round(Math.abs(message.deltaX) / 80)));
3678
- if (Math.abs(message.deltaY) >= Math.abs(message.deltaX) && message.deltaY) {
3679
- for (let index = 0; index < vertical; index += 1) this.write(`mouseclick ${message.deltaY < 0 ? 4 : 5}`);
3680
- } else if (message.deltaX) {
3681
- for (let index = 0; index < horizontal; index += 1) this.write(`mouseclick ${message.deltaX < 0 ? 6 : 7}`);
3679
+ const vertical = Math.abs(message.deltaY) >= Math.abs(message.deltaX);
3680
+ const delta = vertical ? message.deltaY : message.deltaX;
3681
+ if (!delta) return;
3682
+ const button = vertical ? delta < 0 ? 4 : 5 : delta < 0 ? 6 : 7;
3683
+ if (button !== this.scrollButton) this.scrollRemainder = 0;
3684
+ this.scrollButton = button;
3685
+ const distance = Math.abs(delta);
3686
+ let steps;
3687
+ if (distance >= scrollPixelsPerStep) {
3688
+ steps = Math.min(4, Math.round(distance / scrollPixelsPerStep));
3689
+ this.scrollRemainder = 0;
3690
+ } else {
3691
+ this.scrollRemainder += distance;
3692
+ steps = Math.floor(this.scrollRemainder / scrollPixelsPerStep);
3693
+ this.scrollRemainder %= scrollPixelsPerStep;
3682
3694
  }
3695
+ for (let index = 0; index < steps; index += 1) this.write(`mouseclick ${button}`);
3683
3696
  return;
3684
3697
  }
3685
3698
  if (message.type === "key") {
@@ -3701,6 +3714,8 @@ var InputBridge = class {
3701
3714
  if (this.pointerFlush) clearImmediate(this.pointerFlush);
3702
3715
  this.pointerFlush = void 0;
3703
3716
  this.pointer = void 0;
3717
+ this.scrollRemainder = 0;
3718
+ this.scrollButton = 0;
3704
3719
  for (const button of this.pressedButtons) this.write(`mouseup ${button}`);
3705
3720
  for (const key of this.pressedKeys) this.write(`keyup ${key}`);
3706
3721
  this.pressedButtons.clear();
@@ -3740,6 +3755,62 @@ var InputBridge = class {
3740
3755
  }
3741
3756
  };
3742
3757
 
3758
+ // ../shared/src/desktop-stream.ts
3759
+ var DEFAULT_STREAM_REQUEST = {
3760
+ transport: "webrtc",
3761
+ profile: "balanced",
3762
+ adaptive: true,
3763
+ iceTransportPolicy: "all"
3764
+ };
3765
+ function parseStreamRequest(query, defaults = DEFAULT_STREAM_REQUEST) {
3766
+ const transport = query.get("transport");
3767
+ const profile = query.get("profile");
3768
+ const adapt = query.get("adapt");
3769
+ const ice = query.get("ice");
3770
+ return {
3771
+ transport: transport === "webrtc" || transport === "websocket" ? transport : defaults.transport,
3772
+ profile: profile === "text" || profile === "balanced" ? profile : defaults.profile,
3773
+ adaptive: adapt === "0" ? false : adapt === "1" ? true : defaults.adaptive,
3774
+ iceTransportPolicy: ice === "relay" || ice === "all" ? ice : defaults.iceTransportPolicy
3775
+ };
3776
+ }
3777
+ var H264_PROFILES = {
3778
+ high444: { payloadType: 99, profileLevelId: "f4001f", codec: "avc1.F4002A", label: "High 4:4:4" },
3779
+ high: { payloadType: 96, profileLevelId: "64001f", codec: "avc1.64002A", label: "High" },
3780
+ main: { payloadType: 97, profileLevelId: "4d001f", codec: "avc1.4D002A", label: "Main" },
3781
+ baseline: { payloadType: 98, profileLevelId: "42e01f", codec: "avc1.42E02A", label: "Baseline" }
3782
+ };
3783
+ var PROFILE_ORDER = ["high444", "high", "main", "baseline"];
3784
+ var DESKTOP_WEBRTC_FAILURE_REASONS = {
3785
+ server_unavailable: "server has no WebRTC",
3786
+ browser_unavailable: "browser has no WebRTC",
3787
+ connection_failed: "WebRTC connection failed",
3788
+ server_connection_failed: "Server WebRTC connection failed",
3789
+ codec_unsupported: "browser cannot receive H.264 over WebRTC",
3790
+ negotiation_failed: "negotiation failed",
3791
+ video_timeout: "WebRTC did not decode video in time",
3792
+ video_stalled: "WebRTC video stopped arriving"
3793
+ };
3794
+ function isDesktopWebRtcFailure(value) {
3795
+ if (!isRecord(value) || typeof value.reasonCode !== "string" || !Object.hasOwn(DESKTOP_WEBRTC_FAILURE_REASONS, value.reasonCode) || typeof value.turnConfigured !== "boolean") return false;
3796
+ return [
3797
+ "id",
3798
+ "connectionState",
3799
+ "iceConnectionState",
3800
+ "iceGatheringState",
3801
+ "signalingState",
3802
+ "iceTransportPolicy",
3803
+ "serverVersion",
3804
+ "serverUnavailableReason",
3805
+ "localCandidateTypes",
3806
+ "remoteCandidateTypes",
3807
+ "iceErrorCodes",
3808
+ "errorName",
3809
+ "path",
3810
+ "codec"
3811
+ ].every((key) => typeof value[key] === "string" && value[key].length <= 200) && ["elapsedMs", "candidateErrors", "framesDecoded", "packetsReceived", "packetsLost", "bytesReceived", "lastFrameAgeMs", "encoderFps"].every((key) => typeof value[key] === "number" && Number.isFinite(value[key]));
3812
+ }
3813
+
3743
3814
  // src/input-validation.ts
3744
3815
  var finite = (value) => typeof value === "number" && Number.isFinite(value);
3745
3816
  var optionalFinite = (value) => value === void 0 || finite(value);
@@ -3765,7 +3836,7 @@ function validInput(value) {
3765
3836
  case "candidate":
3766
3837
  return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
3767
3838
  case "fallback":
3768
- return typeof value.reason === "string" && value.reason.length < 200;
3839
+ return typeof value.reason === "string" && value.reason.length < 200 && (value.failure === void 0 || isDesktopWebRtcFailure(value.failure));
3769
3840
  case "pointer":
3770
3841
  return finite(value.x) && finite(value.y);
3771
3842
  case "button":
@@ -3786,33 +3857,6 @@ function parseInput(raw) {
3786
3857
  return validInput(value) ? value : void 0;
3787
3858
  }
3788
3859
 
3789
- // ../shared/src/desktop-stream.ts
3790
- var DEFAULT_STREAM_REQUEST = {
3791
- transport: "webrtc",
3792
- profile: "balanced",
3793
- adaptive: true,
3794
- iceTransportPolicy: "all"
3795
- };
3796
- function parseStreamRequest(query, defaults = DEFAULT_STREAM_REQUEST) {
3797
- const transport = query.get("transport");
3798
- const profile = query.get("profile");
3799
- const adapt = query.get("adapt");
3800
- const ice = query.get("ice");
3801
- return {
3802
- transport: transport === "webrtc" || transport === "websocket" ? transport : defaults.transport,
3803
- profile: profile === "text" || profile === "balanced" ? profile : defaults.profile,
3804
- adaptive: adapt === "0" ? false : adapt === "1" ? true : defaults.adaptive,
3805
- iceTransportPolicy: ice === "relay" || ice === "all" ? ice : defaults.iceTransportPolicy
3806
- };
3807
- }
3808
- var H264_PROFILES = {
3809
- high444: { payloadType: 99, profileLevelId: "f4001f", codec: "avc1.F4002A", label: "High 4:4:4" },
3810
- high: { payloadType: 96, profileLevelId: "64001f", codec: "avc1.64002A", label: "High" },
3811
- main: { payloadType: 97, profileLevelId: "4d001f", codec: "avc1.4D002A", label: "Main" },
3812
- baseline: { payloadType: 98, profileLevelId: "42e01f", codec: "avc1.42E02A", label: "Baseline" }
3813
- };
3814
- var PROFILE_ORDER = ["high444", "high", "main", "baseline"];
3815
-
3816
3860
  // src/media-protocol.ts
3817
3861
  var STREAM_PROTOCOL_VERSION = 3;
3818
3862
  var MEDIA_PACKET_HEADER_BYTES = 12;
@@ -4356,6 +4400,7 @@ async function runMediaServer() {
4356
4400
  let videoBytes = 0;
4357
4401
  let audioBytes = 0;
4358
4402
  const events = [];
4403
+ const webrtcFailures = [];
4359
4404
  const samples = [];
4360
4405
  const record = (event, detail) => {
4361
4406
  if (events.push({ at: Date.now(), event, detail }) > eventHistory) events.shift();
@@ -4889,7 +4934,14 @@ async function runMediaServer() {
4889
4934
  record("keyframe", reason);
4890
4935
  void videoEncoder.replace();
4891
4936
  };
4892
- const enterFallback = (viewer, reason) => {
4937
+ const enterFallback = (viewer, reason, failure) => {
4938
+ if (failure && !viewer.failureReported && viewer.request.transport === "webrtc") {
4939
+ viewer.failureReported = true;
4940
+ const entry = { ...failure, at: Date.now() };
4941
+ if (webrtcFailures.push(entry) > 50) webrtcFailures.shift();
4942
+ process.stderr.write(`[desktop-stream] webrtc fallback diagnostics: ${JSON.stringify(entry)}
4943
+ `);
4944
+ }
4893
4945
  if (viewer.fallback) return;
4894
4946
  viewer.fallback = true;
4895
4947
  viewer.waitingForKeyframe = true;
@@ -4986,7 +5038,7 @@ async function runMediaServer() {
4986
5038
  viewer.rtc?.candidate(message.candidate, message.mid);
4987
5039
  return;
4988
5040
  case "fallback":
4989
- enterFallback(viewer, message.reason);
5041
+ enterFallback(viewer, message.reason, message.failure);
4990
5042
  return;
4991
5043
  case "keyframe":
4992
5044
  if (!viewer.fallback) requestKeyframe("viewer watchdog", void 0, viewer);
@@ -5001,6 +5053,8 @@ async function runMediaServer() {
5001
5053
  const config = {
5002
5054
  type: "config",
5003
5055
  protocolVersion: STREAM_PROTOCOL_VERSION,
5056
+ serverVersion: COMPUTER_USE_VERSION,
5057
+ ...!webrtc ? { webrtcUnavailableReason: webrtcEnabled ? "native_module_unavailable" : "disabled" } : {},
5004
5058
  inputRace: true,
5005
5059
  width,
5006
5060
  height,
@@ -5055,7 +5109,7 @@ async function runMediaServer() {
5055
5109
  width,
5056
5110
  height,
5057
5111
  fps,
5058
- ...url.pathname === "/telemetry" ? { events, samples } : {}
5112
+ ...url.pathname === "/telemetry" ? { events, samples, webrtcFailures } : {}
5059
5113
  }));
5060
5114
  return;
5061
5115
  }
@@ -5153,9 +5207,10 @@ async function runMediaServer() {
5153
5207
  });
5154
5208
  } catch (error) {
5155
5209
  record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
5210
+ reply({ type: "webrtc-failed" });
5156
5211
  }
5157
5212
  }
5158
- if (!viewer.rtc) enterFallback(viewer, offerWebRtc ? "webrtc unavailable" : "websocket requested");
5213
+ if (!viewer.rtc) enterFallback(viewer, request.transport === "webrtc" ? "webrtc unavailable" : "websocket requested");
5159
5214
  ensureEncoders();
5160
5215
  socket.on("message", (raw, binary) => {
5161
5216
  if (binary) return;
@@ -3,33 +3,6 @@ function isRecord(value) {
3
3
  return typeof value === "object" && value !== null && !Array.isArray(value);
4
4
  }
5
5
 
6
- // src/input-validation.ts
7
- var finite = (value) => typeof value === "number" && Number.isFinite(value);
8
- var iceServer = (value) => {
9
- if (!isRecord(value)) return false;
10
- return typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
11
- };
12
- function validServerMessage(value) {
13
- if (!isRecord(value) || typeof value.type !== "string") return false;
14
- switch (value.type) {
15
- case "ping":
16
- case "pong":
17
- return finite(value.sentAt);
18
- case "webrtc-failed":
19
- return true;
20
- case "offer":
21
- return typeof value.sdp === "string" && value.sdp.length < 65536;
22
- case "candidate":
23
- return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
24
- case "config":
25
- return finite(value.protocolVersion) && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(iceServer));
26
- case "telemetry":
27
- return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer) && Array.isArray(value.events);
28
- default:
29
- return false;
30
- }
31
- }
32
-
33
6
  // ../shared/src/desktop-stream.ts
34
7
  var DEFAULT_STREAM_REQUEST = {
35
8
  transport: "webrtc",
@@ -61,6 +34,49 @@ function h264ProfileLabel(sdpFmtpLine) {
61
34
  return match?.label ?? id ?? "";
62
35
  }
63
36
  var DESKTOP_TRANSPORT_MESSAGE = "replicas-desktop-transport";
37
+ var DESKTOP_WEBRTC_FAILURE_REASONS = {
38
+ server_unavailable: "server has no WebRTC",
39
+ browser_unavailable: "browser has no WebRTC",
40
+ connection_failed: "WebRTC connection failed",
41
+ server_connection_failed: "Server WebRTC connection failed",
42
+ codec_unsupported: "browser cannot receive H.264 over WebRTC",
43
+ negotiation_failed: "negotiation failed",
44
+ video_timeout: "WebRTC did not decode video in time",
45
+ video_stalled: "WebRTC video stopped arriving"
46
+ };
47
+
48
+ // src/input-validation.ts
49
+ var finite = (value) => typeof value === "number" && Number.isFinite(value);
50
+ var iceServer = (value) => {
51
+ if (!isRecord(value)) return false;
52
+ return typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
53
+ };
54
+ function validServerMessage(value) {
55
+ if (!isRecord(value) || typeof value.type !== "string") return false;
56
+ switch (value.type) {
57
+ case "ping":
58
+ case "pong":
59
+ return finite(value.sentAt);
60
+ case "webrtc-failed":
61
+ return true;
62
+ case "offer":
63
+ return typeof value.sdp === "string" && value.sdp.length < 65536;
64
+ case "candidate":
65
+ return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
66
+ case "config":
67
+ return finite(value.protocolVersion) && (value.serverVersion === void 0 || typeof value.serverVersion === "string") && (value.webrtcUnavailableReason === void 0 || typeof value.webrtcUnavailableReason === "string") && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(iceServer));
68
+ case "telemetry":
69
+ return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer) && Array.isArray(value.events);
70
+ default:
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // src/wheel.ts
76
+ function wheelDeltaPixels(event, pageHeight) {
77
+ const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 32 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? pageHeight : 1;
78
+ return { deltaX: event.deltaX * scale, deltaY: event.deltaY * scale };
79
+ }
64
80
 
65
81
  // src/media-protocol.ts
66
82
  var STREAM_PROTOCOL_VERSION = 3;
@@ -132,6 +148,13 @@ globalThis.__replicasDesktop = state;
132
148
  var viewerEvents = [];
133
149
  state.events = viewerEvents;
134
150
  var decoderResets = 0;
151
+ var connectionStartedAt = performance.now();
152
+ var failure;
153
+ var localCandidateTypes = /* @__PURE__ */ new Set();
154
+ var remoteCandidateTypes = /* @__PURE__ */ new Set();
155
+ var iceErrorCodes = /* @__PURE__ */ new Set();
156
+ var candidateErrors = 0;
157
+ var negotiationErrorName = "";
135
158
  var note = (event, detail) => {
136
159
  if (viewerEvents.push({ at: Date.now(), event, detail }) > 50) viewerEvents.shift();
137
160
  };
@@ -202,7 +225,7 @@ var sendInput = (message, unreliable = false) => {
202
225
  };
203
226
  var announceTransport = () => {
204
227
  if (window.parent === window) return;
205
- window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec }, "*");
228
+ window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure }, "*");
206
229
  };
207
230
  var closePeer = (preserveDeadline = false) => {
208
231
  if (!preserveDeadline) {
@@ -373,8 +396,38 @@ var unlockAudio = () => {
373
396
  });
374
397
  });
375
398
  };
376
- var useFallback = (reason, failed = false) => {
399
+ var useFallback = (reasonCode, failed = false) => {
377
400
  if (transport === "websocket" || !config) return;
401
+ const reason = reasonCode === "websocket_selected" ? "WebSocket transport selected" : reasonCode === "recent_failure" ? "recent WebRTC failure" : DESKTOP_WEBRTC_FAILURE_REASONS[reasonCode];
402
+ if (request.transport === "webrtc" && reasonCode !== "websocket_selected" && reasonCode !== "recent_failure") {
403
+ failure = {
404
+ id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
405
+ reasonCode,
406
+ elapsedMs: Math.round(performance.now() - connectionStartedAt),
407
+ connectionState: peer?.connectionState ?? "unavailable",
408
+ iceConnectionState: peer?.iceConnectionState ?? "unavailable",
409
+ iceGatheringState: peer?.iceGatheringState ?? "unavailable",
410
+ signalingState: peer?.signalingState ?? "unavailable",
411
+ iceTransportPolicy: request.iceTransportPolicy,
412
+ turnConfigured: config.iceServers?.some((server) => /^turns?:/i.test(server.urls)) ?? false,
413
+ serverVersion: config.serverVersion ?? "unknown",
414
+ serverUnavailableReason: config.webrtcUnavailableReason ?? "",
415
+ localCandidateTypes: [...localCandidateTypes].join(","),
416
+ remoteCandidateTypes: [...remoteCandidateTypes].join(","),
417
+ iceErrorCodes: [...iceErrorCodes].join(","),
418
+ candidateErrors,
419
+ errorName: negotiationErrorName,
420
+ path: route.path,
421
+ codec: route.codec,
422
+ framesDecoded: inbound?.framesDecoded ?? 0,
423
+ packetsReceived: inbound?.packetsReceived ?? 0,
424
+ packetsLost: inbound?.packetsLost ?? 0,
425
+ bytesReceived: inbound?.bytesReceived ?? 0,
426
+ lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : -1,
427
+ encoderFps: telemetry?.encoder.fps ?? -1
428
+ };
429
+ state.lastWebRtcFailure = failure;
430
+ }
378
431
  closePeer();
379
432
  if (failed) webrtcSkips = webrtcSkipsAfterFailure;
380
433
  note("fallback to socket", reason);
@@ -382,11 +435,11 @@ var useFallback = (reason, failed = false) => {
382
435
  transportReason = reason;
383
436
  route = { ...route, path: "proxy", codec: config.videoCodec };
384
437
  announceTransport();
438
+ sendSocket({ type: "fallback", reason, failure });
385
439
  if (!TrackGenerator) {
386
440
  showStatus("This browser cannot render the desktop stream");
387
441
  return;
388
442
  }
389
- sendSocket({ type: "fallback", reason });
390
443
  resetVideoTrack();
391
444
  void configureVideo(config).catch((error) => showStatus(describe(error)));
392
445
  void screen.play().catch(() => showStatus("Click the stream to begin playback"));
@@ -421,8 +474,14 @@ var negotiate = async (sdp) => {
421
474
  };
422
475
  pc.onicecandidate = (event) => {
423
476
  if (peer !== pc) return;
477
+ if (event.candidate?.type) localCandidateTypes.add(event.candidate.type);
424
478
  if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
425
479
  };
480
+ pc.onicecandidateerror = (event) => {
481
+ if (peer !== pc) return;
482
+ iceErrorCodes.add(event.errorCode);
483
+ note("ice candidate error", String(event.errorCode));
484
+ };
426
485
  pc.oniceconnectionstatechange = () => {
427
486
  if (peer === pc) note("ice", pc.iceConnectionState);
428
487
  };
@@ -438,7 +497,7 @@ var negotiate = async (sdp) => {
438
497
  unlockAudio();
439
498
  } else if (pc.connectionState === "failed") {
440
499
  if (transport === "webrtc") socket?.close();
441
- else useFallback("WebRTC connection failed", true);
500
+ else useFallback("connection_failed", true);
442
501
  }
443
502
  };
444
503
  try {
@@ -450,7 +509,7 @@ var negotiate = async (sdp) => {
450
509
  if (peer !== pc || !answer.sdp) return;
451
510
  const video = answer.sdp.split(/^m=/m).find((section) => section.startsWith("video")) ?? "";
452
511
  if (video.startsWith("video 0 ") || !/^a=rtpmap:\d+ H264\/90000/m.test(video)) {
453
- useFallback("browser cannot receive H.264 over WebRTC", true);
512
+ useFallback("codec_unsupported", true);
454
513
  return;
455
514
  }
456
515
  sendSocket({ type: "answer", sdp: answer.sdp });
@@ -478,15 +537,22 @@ var handleServerMessage = (message, viaSocket) => {
478
537
  return;
479
538
  }
480
539
  if (message.type === "webrtc-failed") {
481
- useFallback("Server WebRTC connection failed", true);
540
+ useFallback("server_connection_failed", true);
482
541
  return;
483
542
  }
484
543
  if (message.type === "offer") {
485
- void negotiate(message.sdp).catch((error) => useFallback(`negotiation failed: ${describe(error)}`, true));
544
+ void negotiate(message.sdp).catch((error) => {
545
+ negotiationErrorName = error instanceof Error ? error.name : "unknown";
546
+ useFallback("negotiation_failed", true);
547
+ });
486
548
  return;
487
549
  }
488
550
  if (message.type === "candidate") {
489
- void peer?.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid }).catch(() => {
551
+ const candidateType = message.candidate.match(/\btyp (host|srflx|prflx|relay)\b/)?.[1];
552
+ if (candidateType) remoteCandidateTypes.add(candidateType);
553
+ const pc = peer;
554
+ void pc?.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid }).catch(() => {
555
+ if (peer === pc) candidateErrors += 1;
490
556
  });
491
557
  return;
492
558
  }
@@ -498,15 +564,15 @@ var handleServerMessage = (message, viaSocket) => {
498
564
  config = message;
499
565
  request = message.request;
500
566
  fitScreen();
501
- if (request.transport !== "webrtc") useFallback("WebSocket transport selected");
502
- else if (!message.iceServers) useFallback("server has no WebRTC");
503
- else if (typeof RTCPeerConnection !== "function") useFallback("browser has no WebRTC");
567
+ if (request.transport !== "webrtc") useFallback("websocket_selected");
568
+ else if (!message.iceServers) useFallback("server_unavailable");
569
+ else if (typeof RTCPeerConnection !== "function") useFallback("browser_unavailable");
504
570
  else if (webrtcSkips > 0) {
505
571
  webrtcSkips -= 1;
506
- useFallback("recent WebRTC failure");
572
+ useFallback("recent_failure");
507
573
  } else if (!transport) {
508
574
  showStatus("Establishing connection\u2026");
509
- webrtcDeadline ??= setTimeout(() => useFallback("WebRTC did not decode video in time", true), webrtcFrameTimeoutMs);
575
+ webrtcDeadline ??= setTimeout(() => useFallback("video_timeout", true), webrtcFrameTimeoutMs);
510
576
  }
511
577
  };
512
578
  var handleServerText = (data, viaSocket) => {
@@ -523,6 +589,19 @@ var connect = () => {
523
589
  clearTimeout(reconnectTimer);
524
590
  closePeer();
525
591
  transport = void 0;
592
+ transportReason = "";
593
+ failure = void 0;
594
+ config = void 0;
595
+ telemetry = void 0;
596
+ connectionStartedAt = performance.now();
597
+ localCandidateTypes.clear();
598
+ remoteCandidateTypes.clear();
599
+ iceErrorCodes.clear();
600
+ candidateErrors = 0;
601
+ negotiationErrorName = "";
602
+ lastFrameAt = 0;
603
+ route = { path: "", codec: "", decoded: "", kbps: 0, displayedFps: 0 };
604
+ announceTransport();
526
605
  socket?.close();
527
606
  showStatus(reconnectAttempt ? "reconnecting\u2026" : "connecting\u2026");
528
607
  const query = new URLSearchParams([...params].filter(([key]) => ["transport", "profile", "adapt", "ice"].includes(key)));
@@ -611,7 +690,7 @@ screen.addEventListener("pointerup", (event) => {
611
690
  screen.addEventListener("pointercancel", () => send({ type: "release" }));
612
691
  screen.addEventListener("wheel", (event) => {
613
692
  event.preventDefault();
614
- send({ type: "wheel", ...normalizedPointer(event), deltaX: event.deltaX, deltaY: event.deltaY });
693
+ send({ type: "wheel", ...normalizedPointer(event), ...wheelDeltaPixels(event, screen.clientHeight) });
615
694
  }, { passive: false });
616
695
  screen.addEventListener("contextmenu", (event) => event.preventDefault());
617
696
  window.addEventListener("keydown", (event) => {
@@ -748,7 +827,7 @@ setInterval(() => {
748
827
  if (screen.paused && screen.srcObject instanceof MediaStream && screen.srcObject.getVideoTracks().some((track) => track.readyState === "live")) return;
749
828
  const now = performance.now();
750
829
  if (now - lastFrameAt >= webrtcFrameTimeoutMs) {
751
- useFallback("WebRTC video stopped arriving", true);
830
+ useFallback("video_stalled", true);
752
831
  return;
753
832
  }
754
833
  if (inputChannel?.readyState !== "open") return;
@@ -772,7 +851,7 @@ setInterval(() => {
772
851
  displayedWindow = { at: now, frames: presentedFrames };
773
852
  }
774
853
  const elapsedSeconds = (now - renderedWindowStartedAt) / 1e3;
775
- if (transport !== "webrtc" && elapsedSeconds < 1) return;
854
+ if (!peer && transport !== "webrtc" && elapsedSeconds < 1) return;
776
855
  const stats = {
777
856
  sequence: ++statsSequence,
778
857
  videoFps: renderedFrames / elapsedSeconds,
@@ -783,7 +862,7 @@ setInterval(() => {
783
862
  renderedFrames = 0;
784
863
  decodedAudioPackets = 0;
785
864
  renderedWindowStartedAt = now;
786
- if (transport === "webrtc" && peer) void reportWebRtcStats(peer, stats).catch(() => {
865
+ if (peer) void reportWebRtcStats(peer, stats).catch(() => {
787
866
  });
788
867
  else if (transport === "websocket") {
789
868
  sendInput({ type: "stats", ...stats });
package/dist/viewer.js CHANGED
@@ -1,6 +1,14 @@
1
1
  // src/viewer.ts
2
2
  import RFB from "./core/rfb.js";
3
3
  import { encodings } from "./core/encodings.js";
4
+
5
+ // src/wheel.ts
6
+ function wheelDeltaPixels(event, pageHeight) {
7
+ const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 32 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? pageHeight : 1;
8
+ return { deltaX: event.deltaX * scale, deltaY: event.deltaY * scale };
9
+ }
10
+
11
+ // src/viewer.ts
4
12
  var screen = document.querySelector("#screen");
5
13
  var status = document.querySelector("#status");
6
14
  var statusText = document.querySelector("#status-text");
@@ -145,9 +153,9 @@ var shiftCanvasForWheel = (event) => {
145
153
  const canvas = screen.querySelector("canvas");
146
154
  const context = canvas?.getContext("2d");
147
155
  if (!canvas || !context || !canvas.width || !canvas.height) return;
148
- const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 32 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? canvas.height : 1;
156
+ const { deltaY } = wheelDeltaPixels(event, canvas.height);
149
157
  const limit = Math.max(0, Math.min(optimisticScrollMax, canvas.height - 1));
150
- const offset = Math.trunc(Math.max(-limit, Math.min(limit, event.deltaY * scale)));
158
+ const offset = Math.trunc(Math.max(-limit, Math.min(limit, deltaY)));
151
159
  if (offset > 0) {
152
160
  context.drawImage(canvas, 0, offset, canvas.width, canvas.height - offset, 0, 0, canvas.width, canvas.height - offset);
153
161
  context.drawImage(canvas, 0, canvas.height - offset - 1, canvas.width, 1, 0, canvas.height - offset, canvas.width, offset);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-computer",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",