forge-jsxy 1.0.78 → 1.0.80

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.
@@ -54,10 +54,10 @@
54
54
  z-index: 10;
55
55
  display: flex;
56
56
  flex-wrap: wrap;
57
- gap: 6px 8px;
57
+ gap: 4px 8px;
58
58
  align-items: center;
59
- min-height: 44px;
60
- padding: 8px 12px;
59
+ min-height: 36px;
60
+ padding: 6px 12px;
61
61
  background: #181818;
62
62
  border-top: 1px solid var(--vscode-panel-border);
63
63
  }
@@ -104,7 +104,7 @@
104
104
  .hint { color: #9d9d9d; }
105
105
  .screen-wrap {
106
106
  position: fixed;
107
- inset: 0 0 58px 0;
107
+ inset: 0 0 40px 0;
108
108
  overflow: hidden;
109
109
  background: #111;
110
110
  display: grid;
@@ -122,13 +122,21 @@
122
122
  .screen {
123
123
  display: block;
124
124
  max-width: calc(100vw - 20px);
125
- max-height: calc(100vh - 86px);
125
+ max-height: calc(100vh - 68px);
126
126
  width: auto;
127
127
  height: auto;
128
128
  user-select: none;
129
129
  cursor: default;
130
130
  margin: 0;
131
131
  }
132
+ /** Stacked A/B layers — buffer loads behind; swap z-index after decode (no black flash on `src` change). */
133
+ .screen-stage .screen.rc-dblbuf {
134
+ position: absolute;
135
+ left: 50%;
136
+ top: 50%;
137
+ transform: translate(-50%, -50%);
138
+ margin: 0;
139
+ }
132
140
  .camera-overlay {
133
141
  position: absolute;
134
142
  right: 14px;
@@ -149,6 +157,7 @@
149
157
  .empty-state {
150
158
  position: absolute;
151
159
  inset: 0;
160
+ z-index: 10;
152
161
  display: flex;
153
162
  align-items: center;
154
163
  justify-content: center;
@@ -172,7 +181,7 @@
172
181
  color: #ffd8d8;
173
182
  background: rgba(44, 18, 18, 0.9);
174
183
  }
175
- .file-panel { position: fixed; right: 8px; bottom: 66px; width: 380px; max-height: 60vh; overflow: auto; background: #1b1b1d; border: 1px solid #3e3e42; border-radius: 6px; padding: 8px; display: none; z-index: 9; }
184
+ .file-panel { position: fixed; right: 8px; bottom: 48px; width: 380px; max-height: 60vh; overflow: auto; background: #1b1b1d; border: 1px solid #3e3e42; border-radius: 6px; padding: 8px; display: none; z-index: 9; }
176
185
  .file-panel.open { display: block; }
177
186
  .file-panel .row { display: flex; gap: 6px; margin-bottom: 6px; }
178
187
  .file-panel button { font: inherit; }
@@ -226,30 +235,105 @@
226
235
  color: var(--vscode-button-secondaryForeground);
227
236
  border-color: #505050;
228
237
  }
238
+ .hidden {
239
+ display: none !important;
240
+ }
241
+ /** Cursor-like remote toolkit — mirrors explorer `#fe-context-menu` ergonomics. */
242
+ .rc-context-menu {
243
+ position: fixed;
244
+ z-index: 28;
245
+ min-width: 232px;
246
+ max-width: min(380px, 94vw);
247
+ max-height: min(72vh, 520px);
248
+ overflow-x: hidden;
249
+ overflow-y: auto;
250
+ background: #252526;
251
+ border: 1px solid #454545;
252
+ border-radius: 6px;
253
+ padding: 4px 0;
254
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.48);
255
+ }
256
+ .rc-context-menu:focus {
257
+ outline: none;
258
+ }
259
+ .rc-ctx-item {
260
+ display: block;
261
+ width: 100%;
262
+ text-align: left;
263
+ margin: 0;
264
+ padding: 7px 14px;
265
+ border: none;
266
+ background: transparent;
267
+ color: #cccccc;
268
+ font: inherit;
269
+ font-size: 13px;
270
+ cursor: pointer;
271
+ }
272
+ .rc-ctx-item:hover:not(:disabled) {
273
+ background: rgba(255, 255, 255, 0.08);
274
+ }
275
+ .rc-ctx-item:focus-visible {
276
+ outline: none;
277
+ box-shadow: inset 0 0 0 2px rgba(0, 120, 212, 0.55);
278
+ }
279
+ .rc-ctx-item.warn {
280
+ background: rgba(90, 29, 29, 0.35);
281
+ color: #ffd9d9;
282
+ }
283
+ .rc-ctx-item.warn:hover:not(:disabled) {
284
+ background: rgba(108, 38, 38, 0.45);
285
+ }
286
+ .rc-ctx-item:disabled {
287
+ opacity: 0.42;
288
+ cursor: not-allowed;
289
+ }
290
+ .rc-ctx-sep {
291
+ height: 1px;
292
+ margin: 5px 10px;
293
+ background: #3e3e42;
294
+ }
295
+ .rc-ctx-note {
296
+ padding: 5px 14px 7px 14px;
297
+ font-size: 11px;
298
+ line-height: 1.38;
299
+ color: #9d9d9d;
300
+ }
301
+ .rc-status-bar .hint {
302
+ max-width: min(560px, 38vw);
303
+ }
229
304
  </style>
230
305
  </head>
231
306
  <body>
232
- <div class="bar">
307
+ <div class="bar rc-status-bar">
233
308
  <strong class="brand">Remote</strong>
234
- <button id="modeBtn" class="alt">View Only</button>
235
- <button id="cameraBtn" class="alt">Camera: Off</button>
236
- <button id="qualityBtn" class="alt">Quality: Text</button>
237
- <button id="copyFromPcBtn" class="alt">Copy <- PC</button>
238
- <button id="pasteToPcBtn" class="alt">Paste -> PC</button>
239
- <button id="refreshBtn" class="alt">Refresh</button>
240
- <button id="browseBtn" class="alt">Files</button>
241
- <button id="disconnectBtn" class="warn">Disconnect</button>
309
+ <span class="state hint" title="Right-click the screen for all actions. Shift+right-click sends a native right-click to the remote PC.">Right-click screen · Shift+right-click → PC · Ctrl/Cmd+C / V clipboard</span>
242
310
  <span class="spacer"></span>
243
- <span class="state hint">Shortcuts: Ctrl/Cmd+C (PC -> Local), Ctrl/Cmd+V (Local -> PC)</span>
244
311
  <span class="state" id="streamStats">Q: - · Tier: - · Frame: - · Capture: -</span>
245
312
  <span class="state" id="fpsState">FPS: 0.0</span>
246
313
  <span class="state" id="state">Idle</span>
247
314
  <span class="state" id="modeState">Mode: View Only</span>
248
315
  </div>
316
+ <div id="rc-context-menu" class="rc-context-menu hidden" role="menu" aria-label="Remote control actions" aria-hidden="true" tabindex="-1">
317
+ <button type="button" class="rc-ctx-item" id="rcCtxMode" data-rc-act="toggle-write" role="menuitem">View Only</button>
318
+ <button type="button" class="rc-ctx-item" id="rcCtxCamera" data-rc-act="toggle-camera" role="menuitem">Camera: Off</button>
319
+ <button type="button" class="rc-ctx-item" id="rcCtxQuality" data-rc-act="rotate-quality" role="menuitem">Quality: Balanced</button>
320
+ <div class="rc-ctx-sep" aria-hidden="true"></div>
321
+ <button type="button" class="rc-ctx-item" id="rcCtxCopyPc" data-rc-act="copy-pc" role="menuitem">Copy <- PC</button>
322
+ <button type="button" class="rc-ctx-item" id="rcCtxPastePc" data-rc-act="paste-pc" role="menuitem">Paste -> PC</button>
323
+ <div class="rc-ctx-sep" aria-hidden="true"></div>
324
+ <button type="button" class="rc-ctx-item" id="rcCtxRefresh" data-rc-act="refresh" role="menuitem">Refresh screenshot</button>
325
+ <button type="button" class="rc-ctx-item" id="rcCtxFiles" data-rc-act="files-panel" role="menuitem">Files</button>
326
+ <button type="button" class="rc-ctx-item" id="rcCtxFetchFocus" data-rc-act="fetch-focus" role="menuitem">Fetch <- PC (focus path)</button>
327
+ <button type="button" class="rc-ctx-item" id="rcCtxRightHere" data-rc-act="right-click-here" role="menuitem">Right-click here (remote)</button>
328
+ <div class="rc-ctx-sep" aria-hidden="true"></div>
329
+ <div class="rc-ctx-note">Shift + right-click: send mouse actions directly to the remote desktop (same as before).</div>
330
+ <button type="button" class="rc-ctx-item warn" id="rcCtxDisconnect" data-rc-act="disconnect" role="menuitem">Disconnect</button>
331
+ </div>
249
332
  <div class="screen-wrap" id="screenWrap">
250
333
  <div class="screen-stage" id="screenStage">
251
- <img id="screen" class="screen" alt="Remote screen" />
252
- <img id="cameraOverlay" class="camera-overlay" alt="Remote camera" />
334
+ <canvas id="rcScreenCanvas" class="screen rc-dblbuf" width="1" height="1" aria-label="Remote desktop"></canvas>
335
+ <canvas id="rcScreenCanvasBuf" class="screen rc-dblbuf" width="1" height="1" aria-hidden="true"></canvas>
336
+ <img id="cameraOverlay" class="camera-overlay" alt="Remote camera" decoding="async" />
253
337
  <div id="emptyState" class="empty-state">
254
338
  <div id="emptyStateCard" class="empty-state-card">
255
339
  Waiting for remote session...
@@ -294,17 +378,42 @@
294
378
  const fpsStateEl = document.getElementById("fpsState");
295
379
  const stateEl = document.getElementById("state");
296
380
  const modeStateEl = document.getElementById("modeState");
297
- const screenEl = document.getElementById("screen");
381
+ const rcCanvasA = document.getElementById("rcScreenCanvas");
382
+ const rcCanvasB = document.getElementById("rcScreenCanvasBuf");
383
+ let rcCanvasFront = rcCanvasA;
384
+ let rcCanvasBack = rcCanvasB;
385
+ /** Geometry + hit target — always the visible (front) canvas; updated when A/B swap. */
386
+ let rcDispEl = rcCanvasFront;
387
+ function rcSyncCanvasStack() {
388
+ try {
389
+ rcCanvasFront.style.zIndex = "3";
390
+ rcCanvasBack.style.zIndex = "2";
391
+ rcCanvasFront.style.pointerEvents = "auto";
392
+ rcCanvasBack.style.pointerEvents = "none";
393
+ } catch (eSync) {}
394
+ }
395
+ rcSyncCanvasStack();
396
+ function rcSetWriteEnabledClass(on) {
397
+ for (const el of [rcCanvasA, rcCanvasB]) {
398
+ if (on) el.classList.add("write-enabled");
399
+ else el.classList.remove("write-enabled");
400
+ }
401
+ }
298
402
  const cameraOverlayEl = document.getElementById("cameraOverlay");
299
403
  const emptyStateEl = document.getElementById("emptyState");
300
404
  const emptyStateCardEl = document.getElementById("emptyStateCard");
301
- const modeBtn = document.getElementById("modeBtn");
302
- const cameraBtn = document.getElementById("cameraBtn");
303
- const qualityBtn = document.getElementById("qualityBtn");
304
- const copyFromPcBtn = document.getElementById("copyFromPcBtn");
305
- const pasteToPcBtn = document.getElementById("pasteToPcBtn");
405
+ const rcCtxMenu = document.getElementById("rc-context-menu");
406
+ const rcCtxMode = document.getElementById("rcCtxMode");
407
+ const rcCtxCamera = document.getElementById("rcCtxCamera");
408
+ const rcCtxQuality = document.getElementById("rcCtxQuality");
409
+ const rcCtxCopyPc = document.getElementById("rcCtxCopyPc");
410
+ const rcCtxPastePc = document.getElementById("rcCtxPastePc");
411
+ const rcCtxRefresh = document.getElementById("rcCtxRefresh");
412
+ const rcCtxFiles = document.getElementById("rcCtxFiles");
413
+ const rcCtxFetchFocus = document.getElementById("rcCtxFetchFocus");
414
+ const rcCtxRightHere = document.getElementById("rcCtxRightHere");
415
+ const rcCtxDisconnect = document.getElementById("rcCtxDisconnect");
306
416
  const wrapEl = document.getElementById("screenWrap");
307
- const browseBtn = document.getElementById("browseBtn");
308
417
  const filePullPath = document.getElementById("filePullPath");
309
418
  const filePullBtn = document.getElementById("filePullBtn");
310
419
  const filePushInput = document.getElementById("filePushInput");
@@ -329,10 +438,66 @@
329
438
  pasteCaptureEl.style.left = "-9999px";
330
439
  pasteCaptureEl.style.top = "0";
331
440
  document.body.appendChild(pasteCaptureEl);
441
+ /** Relay-advertised WebRTC/STUN (optional; signaling uses relay WS until data channel is implemented end-to-end). */
442
+ let relayWebrtcSignaling = false;
443
+ let relayRtcIceServers = null;
444
+ let forgeRtcPc = null;
445
+ let forgeRtcDc = null;
446
+ /** Partial-reliable channel for bursty input (`mouse_move`, `mouse_wheel`) when agent supports `forge-rc-input`. */
447
+ let forgeRtcDcInput = null;
448
+ /** Ordered bulk binary channel — forge-bulk v2 (chunked) + v1 (legacy single frame). */
449
+ let forgeRtcDcBulk = null;
450
+ let forgeBulkRcExpectHdr = true;
451
+ /** null | v1 wait object | v2 accumulation state */
452
+ let forgeBulkRcRx = null;
453
+ /** Set after successful `setRemoteDescription` for the agent's WebRTC answer (ICE trickle ordering). */
454
+ let forgeRtcRemoteDescDone = false;
455
+ const forgeRtcPendingRemoteCandidates = [];
456
+ let forgeRtcProbeStarted = false;
457
+ /** Bounded automatic WebRTC re-offers after ICE/agent failure (relay WS unchanged). */
458
+ let forgeRtcReconnectTimer = null;
459
+ let forgeRtcReconnectAttempts = 0;
460
+ const FORGE_RTC_MAX_RECONNECT = 2;
332
461
  let ws = null;
333
462
  let authed = false;
334
463
  let writeEnabled = false;
335
464
  let screenshotTimer = null;
465
+ /** Debounced faster screenshot after discrete input (click/key) — complements adaptive poll intervals. */
466
+ let interactionScreenshotKickTimer = null;
467
+ /** Matches bulk `fs_screenshot_sidecar_result` to the preceding main frame (`request_id`). */
468
+ let lastScreenshotMainRequestId = "";
469
+ /** Revoked when replacing camera overlay or disconnect (`blob:` URL for bulk sidecar JPEG). */
470
+ let lastCameraBlobUrl = "";
471
+ /** Prefer `blob:` over huge `data:` strings for camera JPEG/PNG (main thread + GC). */
472
+ function rcAssignCameraOverlayFromB64(camMime, b64Str) {
473
+ const camMimeStr = String(camMime || "image/png");
474
+ const rawB64 = String(b64Str || "").replace(/\s/g, "");
475
+ if (!rawB64) return;
476
+ try {
477
+ const bin = atob(rawB64);
478
+ const u8 = new Uint8Array(bin.length);
479
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
480
+ const blob = new Blob([u8], { type: camMimeStr });
481
+ try {
482
+ if (lastCameraBlobUrl) {
483
+ URL.revokeObjectURL(lastCameraBlobUrl);
484
+ lastCameraBlobUrl = "";
485
+ }
486
+ } catch (eRev) {}
487
+ lastCameraBlobUrl = URL.createObjectURL(blob);
488
+ cameraOverlayEl.src = lastCameraBlobUrl;
489
+ } catch (eAtob) {
490
+ try {
491
+ if (lastCameraBlobUrl) {
492
+ URL.revokeObjectURL(lastCameraBlobUrl);
493
+ lastCameraBlobUrl = "";
494
+ }
495
+ } catch (eR2) {}
496
+ cameraOverlayEl.src = "data:" + camMimeStr + ";base64," + String(b64Str || "");
497
+ }
498
+ }
499
+ /** Pixel coords under pointer when context menu opened — drives “Right-click here (remote)”. */
500
+ let rcMenuAnchorPx = null;
336
501
  let reqSeq = 0;
337
502
  let inflightShot = false;
338
503
  const pendingReqs = new Map();
@@ -384,6 +549,11 @@
384
549
  let lastFrameMeta = null;
385
550
  let sessionAgentVersion = "";
386
551
  let sessionAgentOs = "";
552
+ /**
553
+ * Agents below this forge-jsxy semver skip WebRTC offers (relay WebSocket only — old builds).
554
+ * Injected at `npm run build` from package.json (`scripts/copy-assets.mjs`). Dev placeholder leaves probing enabled when version unknown.
555
+ */
556
+ var FORGE_AGENT_WEBRTC_MIN_VERSION = "__FORGE_AGENT_WEBRTC_MIN_VERSION__";
387
557
  let cameraOverlayEnabled = false;
388
558
  let cameraAvailable = null;
389
559
  let cameraUnavailableWarned = false;
@@ -391,7 +561,7 @@
391
561
  let streamFastStreak = 0;
392
562
  let streamSlowStreak = 0;
393
563
  let streamTier = 0;
394
- let qualityMode = "max";
564
+ let qualityMode = "balanced";
395
565
  let lastInteractionAt = 0;
396
566
  let legacyShotMode = false;
397
567
  let shotFailureStreak = 0;
@@ -403,16 +573,19 @@
403
573
  let shotTimeoutTimer = null;
404
574
  let lastFrameBytes = 0;
405
575
  let lastCaptureMs = 0;
576
+ /** Coalesce `refreshStreamStats` to at most once per animation frame (tuning + JPEG decode can call it tightly). */
577
+ let streamStatsRaf = 0;
406
578
  const STREAM_TUNING_PRESETS = {
579
+ /** Slightly lower tier-0 caps → faster encode + smaller frames when the adaptive tier is healthy. */
407
580
  balanced: [
408
- { maxBytes: 5_000_000, maxWidth: 3200 },
409
- { maxBytes: 4_200_000, maxWidth: 2880 },
410
- { maxBytes: 3_600_000, maxWidth: 2560 },
411
- { maxBytes: 3_000_000, maxWidth: 2360 },
412
- { maxBytes: 2_400_000, maxWidth: 2160 },
413
- { maxBytes: 1_800_000, maxWidth: 1920 },
414
- { maxBytes: 1_250_000, maxWidth: 1680 },
415
- { maxBytes: 900_000, maxWidth: 1440 },
581
+ { maxBytes: 3_060_000, maxWidth: 2200 },
582
+ { maxBytes: 2_880_000, maxWidth: 2400 },
583
+ { maxBytes: 2_660_000, maxWidth: 2200 },
584
+ { maxBytes: 2_520_000, maxWidth: 2080 },
585
+ { maxBytes: 2_080_000, maxWidth: 1860 },
586
+ { maxBytes: 1_620_000, maxWidth: 1700 },
587
+ { maxBytes: 1_120_000, maxWidth: 1500 },
588
+ { maxBytes: 820_000, maxWidth: 1320 },
416
589
  ],
417
590
  text: [
418
591
  { maxBytes: 5_000_000, maxWidth: 3200 },
@@ -431,15 +604,16 @@
431
604
  return STREAM_TUNING_PRESETS[qualityMode] || STREAM_TUNING_PRESETS.text;
432
605
  }
433
606
  function refreshQualityBtnUi() {
607
+ if (!rcCtxQuality) return;
434
608
  if (qualityMode === "max") {
435
- qualityBtn.textContent = "Quality: Max";
436
- qualityBtn.className = "warn";
609
+ rcCtxQuality.textContent = "Quality: Max";
610
+ rcCtxQuality.className = "rc-ctx-item warn";
437
611
  } else if (qualityMode === "balanced") {
438
- qualityBtn.textContent = "Quality: Balanced";
439
- qualityBtn.className = "alt";
612
+ rcCtxQuality.textContent = "Quality: Balanced";
613
+ rcCtxQuality.className = "rc-ctx-item";
440
614
  } else {
441
- qualityBtn.textContent = "Quality: Text";
442
- qualityBtn.className = "alt";
615
+ rcCtxQuality.textContent = "Quality: Text";
616
+ rcCtxQuality.className = "rc-ctx-item";
443
617
  }
444
618
  }
445
619
  function rotateQualityMode() {
@@ -454,19 +628,20 @@
454
628
  lastInteractionAt = Date.now();
455
629
  }
456
630
  function isInteractionActive() {
457
- return (Date.now() - lastInteractionAt) < 1200;
631
+ return (Date.now() - lastInteractionAt) < 2000;
458
632
  }
459
633
 
460
634
  function setState(t) { stateEl.textContent = t; }
461
635
  function refreshCameraBtnUi() {
636
+ if (!rcCtxCamera) return;
462
637
  if (cameraAvailable === false) {
463
- cameraBtn.textContent = "Camera: Unavailable";
464
- cameraBtn.className = "alt";
638
+ rcCtxCamera.textContent = "Camera: Unavailable";
639
+ rcCtxCamera.className = "rc-ctx-item";
465
640
  cameraOverlayEl.style.display = "none";
466
641
  return;
467
642
  }
468
- cameraBtn.textContent = cameraOverlayEnabled ? "Camera: On" : "Camera: Off";
469
- cameraBtn.className = cameraOverlayEnabled ? "warn" : "alt";
643
+ rcCtxCamera.textContent = cameraOverlayEnabled ? "Camera: On" : "Camera: Off";
644
+ rcCtxCamera.className = cameraOverlayEnabled ? "rc-ctx-item warn" : "rc-ctx-item";
470
645
  if (!cameraOverlayEnabled || cameraAvailable === false) {
471
646
  cameraOverlayEl.style.display = "none";
472
647
  }
@@ -477,14 +652,22 @@
477
652
  return Math.round(n / 1024) + "KB";
478
653
  }
479
654
  function refreshStreamStats() {
480
- const tuning = currentStreamTuning();
481
- const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
482
- streamStatsEl.textContent =
483
- "Q: " + qualityMode.toUpperCase() +
484
- " · Tier: " + (streamTier + 1) + "/" + tuning.length +
485
- " · Frame: " + kb(lastFrameBytes) +
486
- " · Capture: " + (lastCaptureMs > 0 ? (Math.round(lastCaptureMs) + "ms") : "-") +
487
- " · Cap: " + kb(prof.maxBytes);
655
+ if (!streamStatsEl) return;
656
+ if (streamStatsRaf) return;
657
+ streamStatsRaf = requestAnimationFrame(() => {
658
+ streamStatsRaf = 0;
659
+ try {
660
+ if (!streamStatsEl) return;
661
+ const tuning = currentStreamTuning();
662
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
663
+ streamStatsEl.textContent =
664
+ "Q: " + qualityMode.toUpperCase() +
665
+ " · Tier: " + (streamTier + 1) + "/" + tuning.length +
666
+ " · Frame: " + kb(lastFrameBytes) +
667
+ " · Capture: " + (lastCaptureMs > 0 ? (Math.round(lastCaptureMs) + "ms") : "-") +
668
+ " · Cap: " + kb(prof.maxBytes);
669
+ } catch (eRs) {}
670
+ });
488
671
  }
489
672
  function tuneRemoteStreamProfile(latencyMs, captureMs, frameBytes, targetBytes) {
490
673
  const tuning = currentStreamTuning();
@@ -493,11 +676,11 @@
493
676
  const fb = Number.isFinite(Number(frameBytes)) ? Number(frameBytes) : 0;
494
677
  const tb = Number.isFinite(Number(targetBytes)) ? Number(targetBytes) : 0;
495
678
  const interacting = isInteractionActive();
496
- const minFps = qualityMode === "max" ? 1.0 : 3.0;
679
+ const minFps = qualityMode === "max" ? 1.0 : 4.2;
497
680
  if (fpsCurrent > 0 && fpsCurrent < minFps) {
498
681
  fpsLowStreak += 1;
499
682
  fpsHighStreak = 0;
500
- if (fpsLowStreak >= (interacting ? 3 : 5) && streamTier < tuning.length - 1) {
683
+ if (fpsLowStreak >= (interacting ? 2 : 5) && streamTier < tuning.length - 1) {
501
684
  streamTier += 1;
502
685
  fpsLowStreak = 0;
503
686
  }
@@ -513,13 +696,13 @@
513
696
  fpsHighStreak = 0;
514
697
  }
515
698
  const overload =
516
- ms > (interacting ? 360 : 520) ||
517
- capMs > (interacting ? 380 : 560) ||
518
- (tb > 0 && fb > tb * (interacting ? 0.995 : 1.03));
699
+ ms > (interacting ? 328 : 478) ||
700
+ capMs > (interacting ? 352 : 518) ||
701
+ (tb > 0 && fb > tb * (interacting ? 0.988 : 1.036));
519
702
  if (overload) {
520
703
  streamSlowStreak += 1;
521
704
  streamFastStreak = 0;
522
- if (streamSlowStreak >= (interacting ? 3 : 5) && streamTier < tuning.length - 1) {
705
+ if (streamSlowStreak >= (interacting ? 2 : 4) && streamTier < tuning.length - 1) {
523
706
  streamTier += 1;
524
707
  streamSlowStreak = 0;
525
708
  }
@@ -527,13 +710,13 @@
527
710
  }
528
711
  const healthy =
529
712
  ms > 0 &&
530
- ms < (interacting ? 300 : 420) &&
531
- (capMs <= 0 || capMs < (interacting ? 260 : 340)) &&
532
- (tb <= 0 || fb <= tb * (interacting ? 0.93 : 0.96));
713
+ ms < (interacting ? 268 : 342) &&
714
+ (capMs <= 0 || capMs < (interacting ? 232 : 282)) &&
715
+ (tb <= 0 || fb <= tb * (interacting ? 0.86 : 0.89));
533
716
  if (healthy) {
534
717
  streamFastStreak += 1;
535
718
  streamSlowStreak = 0;
536
- if (streamFastStreak >= (interacting ? 5 : 3) && streamTier > 0) {
719
+ if (streamFastStreak >= (interacting ? 4 : 3) && streamTier > 0) {
537
720
  streamTier -= 1;
538
721
  streamFastStreak = 0;
539
722
  }
@@ -546,27 +729,481 @@
546
729
  fpsFrames += 1;
547
730
  const now = Date.now();
548
731
  const dt = now - fpsLastAt;
549
- if (dt < 700) return;
732
+ if (dt < 520) return;
550
733
  const fps = (fpsFrames * 1000) / Math.max(1, dt);
551
734
  fpsCurrent = fps;
552
735
  fpsStateEl.textContent = "FPS: " + fps.toFixed(1);
553
736
  fpsFrames = 0;
554
737
  fpsLastAt = now;
555
738
  }
739
+ /**
740
+ * Max long edge (px) for `createImageBitmap` decode on the viewer — matches ~on-screen size so 4K frames
741
+ * do not decode/paint at full resolution when the stage is smaller (saves main thread + GPU).
742
+ */
743
+ function rcDecodeMaxEdgePx() {
744
+ if (qualityMode === "max") return 16384;
745
+ const hardCap = qualityMode === "text" ? 2048 : 2560;
746
+ try {
747
+ const stage = document.querySelector(".screen-stage");
748
+ const dpr = Math.min(2.25, window.devicePixelRatio || 1);
749
+ if (stage && stage.clientWidth > 16 && stage.clientHeight > 16) {
750
+ const edge = Math.floor(Math.max(stage.clientWidth, stage.clientHeight) * dpr * 1.08);
751
+ return Math.max(960, Math.min(hardCap, edge));
752
+ }
753
+ } catch (eCap) {}
754
+ return Math.min(hardCap, 2140);
755
+ }
756
+ /** Invalidates in-flight decodes on disconnect / reconnect. */
757
+ let rcDecodeGeneration = 0;
758
+ /** Only one JPEG/PNG decode at a time; newer frames replace `rcPendingShot` so we always paint latest without token races. */
759
+ let rcDecodeRunning = false;
760
+ let rcPendingShot = null;
761
+ /** Off-main-thread `createImageBitmap` (ImageBitmap transferred back) when `Worker` + `createImageBitmap` exist. */
762
+ let rcDecodeWorker = null;
763
+ let rcDecodeWorkerDisabled = false;
764
+ let rcDecodeWorkerJobId = 0;
765
+ let rcDecodeWorkerInflight = null;
766
+ const RC_DECODE_WORKER_SOURCE =
767
+ "self.onmessage=async function(e){var d=e.data||{},i=d.id,b=d.blob,o=d.opts||{};try{if(!b){self.postMessage({id:i,ok:0});return;}var m=await self.createImageBitmap(b,o);self.postMessage({id:i,ok:1,bmp:m},[m]);}catch(x){try{self.postMessage({id:i,ok:0});}catch(y){}}};";
768
+ function rcTerminateDecodeWorker() {
769
+ rcDecodeWorkerInflight = null;
770
+ if (rcDecodeWorker) {
771
+ try {
772
+ rcDecodeWorker.terminate();
773
+ } catch (eTerm) {}
774
+ rcDecodeWorker = null;
775
+ }
776
+ }
777
+ function rcEnsureDecodeWorker() {
778
+ if (rcDecodeWorkerDisabled) return null;
779
+ if (rcDecodeWorker) return rcDecodeWorker;
780
+ if (typeof Worker === "undefined" || typeof createImageBitmap === "undefined") {
781
+ rcDecodeWorkerDisabled = true;
782
+ return null;
783
+ }
784
+ try {
785
+ const url = URL.createObjectURL(new Blob([RC_DECODE_WORKER_SOURCE], { type: "text/javascript" }));
786
+ const w = new Worker(url);
787
+ URL.revokeObjectURL(url);
788
+ w.onmessage = function (ev) {
789
+ const d = ev.data || {};
790
+ const infl = rcDecodeWorkerInflight;
791
+ rcDecodeWorkerInflight = null;
792
+ const bmp = d.bmp;
793
+ if (!infl || d.id !== infl.id) {
794
+ if (bmp)
795
+ try {
796
+ bmp.close();
797
+ } catch (eBm) {}
798
+ return;
799
+ }
800
+ if (infl.gen !== rcDecodeGeneration) {
801
+ if (bmp)
802
+ try {
803
+ bmp.close();
804
+ } catch (eBm2) {}
805
+ infl.continueMain();
806
+ return;
807
+ }
808
+ if (d.ok && bmp) infl.paintBitmapToBackThenSwap(bmp);
809
+ else infl.continueMain();
810
+ };
811
+ w.onerror = function () {
812
+ rcDecodeWorkerDisabled = true;
813
+ const infl = rcDecodeWorkerInflight;
814
+ rcDecodeWorkerInflight = null;
815
+ rcDecodeWorker = null;
816
+ if (infl) infl.continueMain();
817
+ };
818
+ rcDecodeWorker = w;
819
+ } catch (eWrk) {
820
+ rcDecodeWorkerDisabled = true;
821
+ rcDecodeWorker = null;
822
+ }
823
+ return rcDecodeWorker;
824
+ }
825
+ function rcTeardownScreenLayers() {
826
+ rcDecodeGeneration += 1;
827
+ rcPendingShot = null;
828
+ rcDecodeRunning = false;
829
+ rcTerminateDecodeWorker();
830
+ for (const c of [rcCanvasA, rcCanvasB]) {
831
+ try {
832
+ c.width = 1;
833
+ c.height = 1;
834
+ } catch (eTd) {}
835
+ }
836
+ rcCanvasFront = rcCanvasA;
837
+ rcCanvasBack = rcCanvasB;
838
+ rcDispEl = rcCanvasFront;
839
+ rcSyncCanvasStack();
840
+ }
841
+ function rcSwapCanvasBuffersAfterBackPaint() {
842
+ const tmp = rcCanvasFront;
843
+ rcCanvasFront = rcCanvasBack;
844
+ rcCanvasBack = tmp;
845
+ rcDispEl = rcCanvasFront;
846
+ rcSyncCanvasStack();
847
+ }
848
+ /** Defer z-order swap to the next frame so the GPU has applied `drawImage` before the buffer is brought forward (avoids brief black frames on some browsers). */
849
+ function rcSwapCanvasBuffersAfterBackPaintRaf(gen, done) {
850
+ requestAnimationFrame(() => {
851
+ if (gen !== rcDecodeGeneration) {
852
+ done();
853
+ return;
854
+ }
855
+ rcSwapCanvasBuffersAfterBackPaint();
856
+ done();
857
+ });
858
+ }
859
+ /**
860
+ * Double-buffered canvases: draw only on the hidden back buffer, then swap.
861
+ * Prefer ImageBitmapRenderingContext.transferFromImageBitmap when available (single GPU handoff vs 2D drawImage).
862
+ * Decode queue: one in-flight `createImageBitmap`; supersede pending payload instead of bumping a token (avoids all decodes cancelling each other → black flashes).
863
+ */
864
+ function assignRcScreenshotToScreenEl(mime, b64, srcW, srcH) {
865
+ const w = Number.isFinite(Number(srcW)) ? Math.max(0, Math.floor(Number(srcW))) : 0;
866
+ const h = Number.isFinite(Number(srcH)) ? Math.max(0, Math.floor(Number(srcH))) : 0;
867
+ rcPendingShot = { mime: String(mime || "image/png"), b64: String(b64 || ""), srcW: w, srcH: h };
868
+ rcPumpDecodeQueue();
869
+ }
870
+ /** `forge-bulk` path: skip main-thread `btoa` / data-URL work — decode JPEG/PNG from raw bytes. */
871
+ function assignRcScreenshotFromU8(mime, u8, srcW, srcH) {
872
+ const mimeStr = String(mime || "image/png");
873
+ const w = Number.isFinite(Number(srcW)) ? Math.max(0, Math.floor(Number(srcW))) : 0;
874
+ const h = Number.isFinite(Number(srcH)) ? Math.max(0, Math.floor(Number(srcH))) : 0;
875
+ try {
876
+ const buf = u8 instanceof Uint8Array ? u8 : new Uint8Array(u8);
877
+ if (!buf || !buf.byteLength) {
878
+ assignRcScreenshotToScreenEl(mimeStr, "", w, h);
879
+ return;
880
+ }
881
+ rcPendingShot = { mime: mimeStr, blob: new Blob([buf], { type: mimeStr }), srcW: w, srcH: h };
882
+ } catch (eU8) {
883
+ try {
884
+ rcPendingShot = {
885
+ mime: mimeStr,
886
+ b64: forgeBulkRcBytesToB64(u8 instanceof Uint8Array ? u8 : new Uint8Array(u8)),
887
+ srcW: w,
888
+ srcH: h,
889
+ };
890
+ } catch (eB64) {
891
+ rcPendingShot = { mime: mimeStr, b64: "", srcW: w, srcH: h };
892
+ }
893
+ }
894
+ rcPumpDecodeQueue();
895
+ }
896
+ function rcPumpDecodeQueue() {
897
+ if (rcDecodeRunning) return;
898
+ if (!rcPendingShot) return;
899
+ const job = rcPendingShot;
900
+ rcPendingShot = null;
901
+ const gen = rcDecodeGeneration;
902
+ rcDecodeRunning = true;
903
+
904
+ function abortEarlyDecode() {
905
+ rcDecodeRunning = false;
906
+ if (rcPendingShot) rcPumpDecodeQueue();
907
+ }
908
+
909
+ function runDecodeFromBlob(blob) {
910
+ function afterPaintOrAbort() {
911
+ rcDecodeRunning = false;
912
+ if (rcPendingShot) rcPumpDecodeQueue();
913
+ }
914
+
915
+ function paintBitmapToBackThenSwap(bmp) {
916
+ if (gen !== rcDecodeGeneration) {
917
+ try {
918
+ bmp.close();
919
+ } catch (eC) {}
920
+ afterPaintOrAbort();
921
+ return;
922
+ }
923
+ const w = bmp.width;
924
+ const h = bmp.height;
925
+ if (!w || !h) {
926
+ try {
927
+ bmp.close();
928
+ } catch (eC2) {}
929
+ afterPaintOrAbort();
930
+ return;
931
+ }
932
+ const back = rcCanvasBack;
933
+ let paintedOk = false;
934
+ let usedBrTransfer = false;
935
+ try {
936
+ const br = back.getContext("bitmaprenderer");
937
+ if (br && typeof br.transferFromImageBitmap === "function") {
938
+ if (back.width !== w || back.height !== h) {
939
+ back.width = w;
940
+ back.height = h;
941
+ }
942
+ br.transferFromImageBitmap(bmp);
943
+ paintedOk = true;
944
+ usedBrTransfer = true;
945
+ }
946
+ } catch (eBr) {
947
+ try {
948
+ bmp.close();
949
+ } catch (eBc) {}
950
+ afterPaintOrAbort();
951
+ return;
952
+ }
953
+ if (!paintedOk) {
954
+ const ctx = back.getContext("2d", { alpha: false });
955
+ if (!ctx) {
956
+ try {
957
+ bmp.close();
958
+ } catch (eC3) {}
959
+ afterPaintOrAbort();
960
+ return;
961
+ }
962
+ if (back.width !== w || back.height !== h) {
963
+ back.width = w;
964
+ back.height = h;
965
+ }
966
+ try {
967
+ ctx.drawImage(bmp, 0, 0);
968
+ paintedOk = true;
969
+ } catch (eDr) {
970
+ /* ignore */
971
+ }
972
+ try {
973
+ bmp.close();
974
+ } catch (eC4) {}
975
+ }
976
+ if (gen !== rcDecodeGeneration) {
977
+ afterPaintOrAbort();
978
+ return;
979
+ }
980
+ if (!paintedOk) {
981
+ afterPaintOrAbort();
982
+ return;
983
+ }
984
+ const syncSwap =
985
+ usedBrTransfer &&
986
+ typeof document !== "undefined" &&
987
+ document.visibilityState === "visible";
988
+ if (syncSwap) {
989
+ rcSwapCanvasBuffersAfterBackPaint();
990
+ afterPaintOrAbort();
991
+ } else {
992
+ rcSwapCanvasBuffersAfterBackPaintRaf(gen, afterPaintOrAbort);
993
+ }
994
+ }
995
+
996
+ function paintImageUrlToBackThenSwap(url) {
997
+ const im = new Image();
998
+ im.onload = () => {
999
+ if (gen !== rcDecodeGeneration) {
1000
+ try {
1001
+ URL.revokeObjectURL(url);
1002
+ } catch (eR) {}
1003
+ afterPaintOrAbort();
1004
+ return;
1005
+ }
1006
+ const w = im.naturalWidth;
1007
+ const h = im.naturalHeight;
1008
+ function finishBlobUrl() {
1009
+ try {
1010
+ URL.revokeObjectURL(url);
1011
+ } catch (eR2) {}
1012
+ afterPaintOrAbort();
1013
+ }
1014
+ if (w && h) {
1015
+ const back = rcCanvasBack;
1016
+ const ctx = back.getContext("2d", { alpha: false });
1017
+ if (ctx) {
1018
+ if (back.width !== w || back.height !== h) {
1019
+ back.width = w;
1020
+ back.height = h;
1021
+ }
1022
+ let paintedOk = false;
1023
+ try {
1024
+ ctx.drawImage(im, 0, 0);
1025
+ paintedOk = true;
1026
+ } catch (eD2) {
1027
+ /* ignore */
1028
+ }
1029
+ if (gen === rcDecodeGeneration && paintedOk) {
1030
+ rcSwapCanvasBuffersAfterBackPaintRaf(gen, finishBlobUrl);
1031
+ return;
1032
+ }
1033
+ }
1034
+ }
1035
+ finishBlobUrl();
1036
+ };
1037
+ im.onerror = () => {
1038
+ try {
1039
+ URL.revokeObjectURL(url);
1040
+ } catch (eR3) {}
1041
+ afterPaintOrAbort();
1042
+ };
1043
+ im.src = url;
1044
+ }
1045
+
1046
+ function rcBitmapDecodeOpts() {
1047
+ const o = {
1048
+ imageOrientation: "none",
1049
+ premultiplyAlpha: "none",
1050
+ colorSpaceConversion: "none",
1051
+ };
1052
+ const sw = Number(job.srcW) | 0;
1053
+ const sh = Number(job.srcH) | 0;
1054
+ if (sw <= 1 || sh <= 1) return o;
1055
+ const cap = rcDecodeMaxEdgePx();
1056
+ const m = Math.max(sw, sh);
1057
+ if (m <= cap) return o;
1058
+ const scale = cap / m;
1059
+ o.resizeWidth = Math.max(1, Math.round(sw * scale));
1060
+ o.resizeHeight = Math.max(1, Math.round(sh * scale));
1061
+ o.resizeQuality = qualityMode === "max" ? "high" : "low";
1062
+ return o;
1063
+ }
1064
+
1065
+ const optsTry = rcBitmapDecodeOpts();
1066
+ const optsPlain = {
1067
+ imageOrientation: "none",
1068
+ premultiplyAlpha: "none",
1069
+ colorSpaceConversion: "none",
1070
+ };
1071
+
1072
+ function mainThreadDecode(useResizeOpts) {
1073
+ if (typeof createImageBitmap !== "function") {
1074
+ try {
1075
+ paintImageUrlToBackThenSwap(URL.createObjectURL(blob));
1076
+ } catch (ePu2) {
1077
+ afterPaintOrAbort();
1078
+ }
1079
+ return;
1080
+ }
1081
+ const firstOpts = useResizeOpts ? optsTry : optsPlain;
1082
+ let bmpPromise;
1083
+ try {
1084
+ bmpPromise = createImageBitmap(blob, firstOpts);
1085
+ } catch (eFast) {
1086
+ try {
1087
+ bmpPromise = createImageBitmap(blob, optsPlain);
1088
+ } catch (eFast2) {
1089
+ bmpPromise = createImageBitmap(blob);
1090
+ }
1091
+ }
1092
+ bmpPromise
1093
+ .then((bmp) => paintBitmapToBackThenSwap(bmp))
1094
+ .catch(() => {
1095
+ if (gen !== rcDecodeGeneration) {
1096
+ afterPaintOrAbort();
1097
+ return;
1098
+ }
1099
+ if (useResizeOpts) {
1100
+ mainThreadDecode(false);
1101
+ return;
1102
+ }
1103
+ createImageBitmap(blob, optsPlain)
1104
+ .then((bmp2) => paintBitmapToBackThenSwap(bmp2))
1105
+ .catch(() => {
1106
+ if (gen !== rcDecodeGeneration) {
1107
+ afterPaintOrAbort();
1108
+ return;
1109
+ }
1110
+ try {
1111
+ paintImageUrlToBackThenSwap(URL.createObjectURL(blob));
1112
+ } catch (ePu) {
1113
+ afterPaintOrAbort();
1114
+ }
1115
+ });
1116
+ });
1117
+ }
1118
+
1119
+ function tryDecodeWorker() {
1120
+ const w = rcEnsureDecodeWorker();
1121
+ if (!w) return false;
1122
+ const id = ++rcDecodeWorkerJobId;
1123
+ rcDecodeWorkerInflight = {
1124
+ id,
1125
+ gen,
1126
+ paintBitmapToBackThenSwap,
1127
+ continueMain: () => mainThreadDecode(true),
1128
+ };
1129
+ try {
1130
+ w.postMessage({ id, blob, opts: optsTry });
1131
+ return true;
1132
+ } catch (ePost) {
1133
+ rcDecodeWorkerInflight = null;
1134
+ return false;
1135
+ }
1136
+ }
1137
+
1138
+ if (tryDecodeWorker()) return;
1139
+ mainThreadDecode(true);
1140
+ }
1141
+
1142
+ function syncFallbackBlob() {
1143
+ let u8;
1144
+ try {
1145
+ const binStr = atob(job.b64);
1146
+ try {
1147
+ u8 = Uint8Array.from(binStr, (ch) => ch.charCodeAt(0));
1148
+ } catch (eFrom) {
1149
+ const len = binStr.length;
1150
+ u8 = new Uint8Array(len);
1151
+ for (let i = 0; i < len; i++) u8[i] = binStr.charCodeAt(i);
1152
+ }
1153
+ } catch (eAtob) {
1154
+ abortEarlyDecode();
1155
+ return;
1156
+ }
1157
+ runDecodeFromBlob(new Blob([u8], { type: job.mime }));
1158
+ }
1159
+
1160
+ if (job.blob instanceof Blob) {
1161
+ runDecodeFromBlob(job.blob);
1162
+ return;
1163
+ }
1164
+
1165
+ if (String(job.b64 || "").length) {
1166
+ syncFallbackBlob();
1167
+ return;
1168
+ }
1169
+
1170
+ abortEarlyDecode();
1171
+ }
1172
+ /**
1173
+ * When the remote-control tab is in the background, poll screenshots less often (Page Visibility API).
1174
+ * Frees relay/agent CPU and bandwidth; P2P input keeps working while the tab is visible enough for events.
1175
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
1176
+ */
1177
+ function remoteTabScreenshotThrottleFactor() {
1178
+ try {
1179
+ if (typeof document !== "undefined" && document.hidden) return 1.92;
1180
+ } catch (e) {}
1181
+ return 1;
1182
+ }
556
1183
  function currentShotIntervalMs() {
557
1184
  const interacting = isInteractionActive();
558
1185
  const tuning = currentStreamTuning();
559
1186
  const idx = Math.max(0, Math.min(tuning.length - 1, streamTier));
560
- const mapBalancedActive = [260, 290, 330, 380, 440, 520, 620, 760];
561
- const mapBalancedIdle = [420, 480, 550, 640, 760, 900, 1050, 1200];
562
- const mapTextActive = [420, 480, 560, 680, 820];
563
- const mapTextIdle = [700, 820, 960, 1120, 1320];
564
- // Max-quality mode is readability-first with ~1 FPS pacing.
565
- const mapMaxActive = [960, 1120, 1300];
566
- const mapMaxIdle = [1150, 1320, 1550];
567
- if (qualityMode === "max") return (interacting ? mapMaxActive : mapMaxIdle)[idx];
568
- if (qualityMode === "balanced") return (interacting ? mapBalancedActive : mapBalancedIdle)[idx];
569
- return (interacting ? mapTextActive : mapTextIdle)[idx];
1187
+ // Lower ms faster screenshot polling when the adaptive tier allows it.
1188
+ const mapBalancedActive = [54, 64, 76, 94, 118, 144, 182, 240];
1189
+ const mapBalancedIdle = [100, 118, 140, 168, 202, 244, 294, 358];
1190
+ const mapTextActive = [150, 178, 210, 250, 312];
1191
+ const mapTextIdle = [260, 308, 366, 446, 534];
1192
+ // Max-quality mode stays readability-first but ticks slightly faster when healthy.
1193
+ const mapMaxActive = [480, 558, 642];
1194
+ const mapMaxIdle = [558, 652, 762];
1195
+ let ms;
1196
+ if (qualityMode === "max") ms = (interacting ? mapMaxActive : mapMaxIdle)[idx];
1197
+ else if (qualityMode === "balanced") ms = (interacting ? mapBalancedActive : mapBalancedIdle)[idx];
1198
+ else ms = (interacting ? mapTextActive : mapTextIdle)[idx];
1199
+ ms = Math.floor(ms * remoteTabScreenshotThrottleFactor());
1200
+ /** Chunked `forge-bulk` skips relay JSON for frames — edge slightly faster without starving capture. */
1201
+ try {
1202
+ if (forgeRtcDcBulk && forgeRtcDcBulk.readyState === "open") {
1203
+ ms = Math.floor(ms * 0.7);
1204
+ }
1205
+ } catch (eBulkIv) {}
1206
+ return Math.min(9200, Math.max(28, ms));
570
1207
  }
571
1208
  function clearShotTimeout() {
572
1209
  if (shotTimeoutTimer) {
@@ -578,8 +1215,11 @@
578
1215
  clearShotTimeout();
579
1216
  const t = qualityMode === "max" ? 6500 : 3000;
580
1217
  shotTimeoutTimer = setTimeout(() => {
1218
+ shotTimeoutTimer = null;
1219
+ if (!viewerAgentTransportReady() || !authed) return;
581
1220
  inflightShot = false;
582
- scheduleNextShot(currentShotIntervalMs() + 80);
1221
+ const bump = qualityMode === "max" ? 80 : 52;
1222
+ scheduleNextShot(currentShotIntervalMs() + bump);
583
1223
  }, t);
584
1224
  }
585
1225
  function parseVersion(v) {
@@ -636,20 +1276,23 @@
636
1276
  function refreshWriteModeEligibilityUi() {
637
1277
  const ver = String(sessionAgentVersion || "");
638
1278
  const hasVer = ver.length > 0;
639
- // Keep the mode button usable while metadata is still unknown; hard-block only when incompatibility is explicit.
640
1279
  const incompatible = hasVer && versionLt(ver, "1.0.71");
641
- modeBtn.disabled = false;
642
- if (!writeEnabled && incompatible) {
643
- modeBtn.title = "Write mode requires agent v1.0.71+ (upgrade this session from /files).";
644
- } else {
645
- modeBtn.title = "";
1280
+ if (rcCtxMode) {
1281
+ rcCtxMode.disabled = false;
1282
+ if (!writeEnabled && incompatible) {
1283
+ rcCtxMode.title = "Write mode requires agent v1.0.71+ (upgrade this session from /files).";
1284
+ } else {
1285
+ rcCtxMode.title = "";
1286
+ }
646
1287
  }
647
1288
  if (writeEnabled && incompatible) {
648
1289
  writeEnabled = false;
649
- modeBtn.textContent = "View Only";
1290
+ if (rcCtxMode) {
1291
+ rcCtxMode.textContent = "View Only";
1292
+ rcCtxMode.className = "rc-ctx-item";
1293
+ }
650
1294
  modeStateEl.textContent = "Mode: View Only";
651
- modeBtn.className = "alt";
652
- screenEl.classList.remove("write-enabled");
1295
+ rcSetWriteEnabledClass(false);
653
1296
  updateWriteControls();
654
1297
  }
655
1298
  }
@@ -711,19 +1354,22 @@
711
1354
  }
712
1355
  function updateWriteControls() {
713
1356
  const ro = !writeEnabled;
714
- copyFromPcBtn.disabled = ro;
715
- pasteToPcBtn.disabled = ro;
1357
+ if (rcCtxCopyPc) rcCtxCopyPc.disabled = ro;
1358
+ if (rcCtxPastePc) rcCtxPastePc.disabled = ro;
1359
+ if (rcCtxFiles) rcCtxFiles.disabled = ro;
1360
+ if (rcCtxFetchFocus) rcCtxFetchFocus.disabled = ro;
716
1361
  filePullBtn.disabled = ro;
717
1362
  filePullPath.disabled = ro;
718
1363
  filePushBtn.disabled = ro;
719
1364
  filePushInput.disabled = ro;
720
- browseBtn.disabled = ro;
721
1365
  rootsBtn.disabled = ro;
722
1366
  upBtn.disabled = ro;
723
1367
  closePanelBtn.disabled = ro;
1368
+ refreshRcContextMenuAnchorAction();
724
1369
  if (ro) filePanel.classList.remove("open");
725
1370
  if (ro) stopRemoteClipboardPoll();
726
1371
  else startRemoteClipboardPoll();
1372
+ rcSetWriteEnabledClass(writeEnabled);
727
1373
  }
728
1374
  function stopRemoteClipboardPoll() {
729
1375
  if (remoteClipboardPollTimer) {
@@ -822,37 +1468,687 @@
822
1468
  close(authPasswordInput.value);
823
1469
  return;
824
1470
  }
825
- if (ev.key === "Escape") {
826
- ev.preventDefault();
827
- close("");
1471
+ if (ev.key === "Escape") {
1472
+ ev.preventDefault();
1473
+ close("");
1474
+ }
1475
+ };
1476
+ });
1477
+ return pendingPasswordPrompt;
1478
+ }
1479
+ async function resolveSessionPassword(sid, forcePrompt) {
1480
+ if (forcePrompt) {
1481
+ const entered = await askPassword("Remote session password required");
1482
+ if (entered) rememberPassword(sid, entered);
1483
+ return entered;
1484
+ }
1485
+ const fromUrl = String(new URLSearchParams(location.search).get("password") || "").trim();
1486
+ if (fromUrl) {
1487
+ rememberPassword(sid, fromUrl);
1488
+ return fromUrl;
1489
+ }
1490
+ const remembered = String(readRememberedPassword(sid) || "").trim();
1491
+ if (remembered) return remembered;
1492
+ const dashboardPw = String(readDashboardPassword() || "").trim();
1493
+ if (dashboardPw) {
1494
+ rememberPassword(sid, dashboardPw);
1495
+ return dashboardPw;
1496
+ }
1497
+ const fallback = String(pwdHint || "").trim();
1498
+ if (fallback) return fallback;
1499
+ const entered = await askPassword("Remote session password required");
1500
+ if (entered) rememberPassword(sid, entered);
1501
+ return entered;
1502
+ }
1503
+ async function flushForgeRtcRemoteCandidates() {
1504
+ const pc = forgeRtcPc;
1505
+ if (!pc) return;
1506
+ const pending = forgeRtcPendingRemoteCandidates.splice(0, forgeRtcPendingRemoteCandidates.length);
1507
+ for (let i = 0; i < pending.length; i++) {
1508
+ try {
1509
+ await pc.addIceCandidate(pending[i]);
1510
+ } catch (e) {}
1511
+ }
1512
+ }
1513
+ /** Prefer WebRTC data-channel when open (P2P); large screenshot bodies use chunked forge-bulk over P2P when negotiated (else relay WS). */
1514
+ /** Prefer P2P when DC open and payload fits SCTP-safe size; large frames (e.g. rc_file_push) stay on WebSocket. */
1515
+ function sendAgentPayload(obj) {
1516
+ let s;
1517
+ try {
1518
+ s = JSON.stringify(obj);
1519
+ } catch {
1520
+ return;
1521
+ }
1522
+ const maxDc = 57344;
1523
+ if (forgeRtcDc && forgeRtcDc.readyState === "open" && s.length <= maxDc) {
1524
+ try {
1525
+ forgeRtcDc.send(s);
1526
+ return;
1527
+ } catch (e) {}
1528
+ }
1529
+ if (ws && ws.readyState === 1) ws.send(s);
1530
+ }
1531
+ function viewerAgentTransportReady() {
1532
+ return Boolean(
1533
+ (ws && ws.readyState === 1) || (forgeRtcDc && forgeRtcDc.readyState === "open")
1534
+ );
1535
+ }
1536
+ async function processRelayInboundCore(msg) {
1537
+ const sid = resolveSessionId();
1538
+ const t = String(msg && msg.type || "");
1539
+ if (t === "connected") {
1540
+ relayWebrtcSignaling = msg.webrtc_signaling === true;
1541
+ relayRtcIceServers = Array.isArray(msg.rtc_ice_servers) ? msg.rtc_ice_servers : null;
1542
+ return;
1543
+ }
1544
+ if (t === "relay_webrtc_availability") {
1545
+ relayWebrtcSignaling = msg.webrtc_signaling === true;
1546
+ relayRtcIceServers = Array.isArray(msg.rtc_ice_servers) ? msg.rtc_ice_servers : null;
1547
+ if (!relayWebrtcSignaling) {
1548
+ if (forgeRtcReconnectTimer) {
1549
+ clearTimeout(forgeRtcReconnectTimer);
1550
+ forgeRtcReconnectTimer = null;
1551
+ }
1552
+ forgeRtcReconnectAttempts = 0;
1553
+ teardownForgeRtcProbe();
1554
+ } else if (authed && ws && ws.readyState === 1 && !forgeRtcProbeStarted) {
1555
+ setTimeout(function () {
1556
+ tryForgeRtcDirectProbe();
1557
+ }, 72);
1558
+ }
1559
+ return;
1560
+ }
1561
+ if (t === "auth_challenge") {
1562
+ authChallengeSeen = true;
1563
+ clearAuthWatchdog();
1564
+ const pwd = await resolveSessionPassword(sid, false);
1565
+ if (!pwd) {
1566
+ setState("Missing session password");
1567
+ if (!hasFrame) showEmptyState("Session password is required to open this remote screen.", true);
1568
+ return;
1569
+ }
1570
+ const ph = await hashHex(pwd);
1571
+ const nonce = String(msg.nonce || "");
1572
+ const resp = await hashHex(ph + ":" + nonce);
1573
+ ws.send(JSON.stringify({ type: "auth", nonce, response: resp, password_hash: ph }));
1574
+ return;
1575
+ }
1576
+ if (t === "auth_result") {
1577
+ authed = !!msg.ok;
1578
+ if (authed) {
1579
+ forgeRtcReconnectAttempts = 0;
1580
+ if (forgeRtcReconnectTimer) {
1581
+ clearTimeout(forgeRtcReconnectTimer);
1582
+ forgeRtcReconnectTimer = null;
1583
+ }
1584
+ clearAuthWatchdog();
1585
+ setState("Authenticated");
1586
+ try {
1587
+ queueMicrotask(function () {
1588
+ try {
1589
+ rcEnsureDecodeWorker();
1590
+ } catch (ePreW) {}
1591
+ });
1592
+ } catch (eMq) {}
1593
+ startShotLoop();
1594
+ requestScreenshot();
1595
+ startRemoteClipboardPoll();
1596
+ void refreshRemoteControlCapabilities();
1597
+ if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
1598
+ setTimeout(function () {
1599
+ tryForgeRtcDirectProbe();
1600
+ }, 72);
1601
+ } else {
1602
+ forgetPassword(sid);
1603
+ const entered = await resolveSessionPassword(sid, true);
1604
+ if (entered) {
1605
+ setState("Retrying with updated password...");
1606
+ disconnect();
1607
+ setTimeout(connect, 120);
1608
+ } else {
1609
+ setState("Auth failed");
1610
+ if (!hasFrame) showEmptyState("Authentication failed. Please enter the correct session password.", true);
1611
+ }
1612
+ }
1613
+ return;
1614
+ }
1615
+ if (t === "system_info") {
1616
+ const d = (msg && msg.data) || {};
1617
+ const v = String(d.forge_jsx_version || d.forge_jsxy_version || "").trim();
1618
+ if (v) sessionAgentVersion = v;
1619
+ const os = String(d.os || d.platform || "").trim().toLowerCase();
1620
+ if (os) sessionAgentOs = os;
1621
+ refreshWriteModeEligibilityUi();
1622
+ setState("Connected (" + String(msg?.data?.platform || "unknown") + ")");
1623
+ return;
1624
+ }
1625
+ if (t === "info") {
1626
+ const sys = (msg && msg.data && msg.data.system) || {};
1627
+ const v = String(sys.forge_jsx_version || sys.forge_jsxy_version || "").trim();
1628
+ if (v) sessionAgentVersion = v;
1629
+ const os = String(sys.os || sys.platform || "").trim().toLowerCase();
1630
+ if (os) sessionAgentOs = os;
1631
+ refreshWriteModeEligibilityUi();
1632
+ return;
1633
+ }
1634
+ if (t === "fs_screenshot_sidecar_result") {
1635
+ const side = String(msg.sidecar || "");
1636
+ if (side !== "camera" || !msg.ok) return;
1637
+ const hasCam = !!(
1638
+ msg.b64 ||
1639
+ (msg._forge_bulk_u8 && msg._forge_bulk_u8.byteLength > 0)
1640
+ );
1641
+ if (!hasCam) return;
1642
+ if (String(msg.request_id || "") !== lastScreenshotMainRequestId) return;
1643
+ requestAnimationFrame(() => {
1644
+ if (!authed || !cameraOverlayEl) return;
1645
+ if (!cameraOverlayEnabled || cameraAvailable === false) return;
1646
+ const camMime = String(msg.camera_mime || "image/jpeg");
1647
+ const widthPct = Number.isFinite(Number(msg.camera_width_percent))
1648
+ ? Math.max(10, Math.min(40, Number(msg.camera_width_percent)))
1649
+ : 20;
1650
+ cameraOverlayEl.style.width = widthPct + "%";
1651
+ try {
1652
+ if (lastCameraBlobUrl) {
1653
+ URL.revokeObjectURL(lastCameraBlobUrl);
1654
+ lastCameraBlobUrl = "";
1655
+ }
1656
+ if (msg._forge_bulk_u8 && msg._forge_bulk_u8.byteLength > 0) {
1657
+ const blob = new Blob([msg._forge_bulk_u8], { type: camMime });
1658
+ lastCameraBlobUrl = URL.createObjectURL(blob);
1659
+ cameraOverlayEl.src = lastCameraBlobUrl;
1660
+ } else {
1661
+ rcAssignCameraOverlayFromB64(camMime, msg.b64);
1662
+ }
1663
+ } catch (eCamSrc) {
1664
+ /* ignore */
1665
+ }
1666
+ cameraOverlayEl.style.display = "block";
1667
+ });
1668
+ return;
1669
+ }
1670
+ if (t === "fs_screenshot_result") {
1671
+ inflightShot = false;
1672
+ clearShotTimeout();
1673
+ const hasPixelPayload = !!(
1674
+ msg.b64 ||
1675
+ (msg._forge_bulk_u8 && msg._forge_bulk_u8.byteLength > 0)
1676
+ );
1677
+ if (typeof msg.camera_available === "boolean") {
1678
+ cameraAvailable = msg.camera_available;
1679
+ if (cameraAvailable === false && !cameraUnavailableWarned) {
1680
+ cameraUnavailableWarned = true;
1681
+ setState("No camera detected on remote PC.");
1682
+ }
1683
+ refreshCameraBtnUi();
1684
+ }
1685
+ if (msg.ok && hasPixelPayload) {
1686
+ lastScreenshotMainRequestId = String(msg.request_id || "");
1687
+ shotFailureStreak = 0;
1688
+ if (lastShotStartedAt > 0) {
1689
+ const tuning = currentStreamTuning();
1690
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
1691
+ lastFrameBytes = Number.isFinite(Number(msg.bytes)) ? Math.max(0, Number(msg.bytes)) : 0;
1692
+ lastCaptureMs = Number.isFinite(Number(msg.capture_ms)) ? Math.max(0, Number(msg.capture_ms)) : 0;
1693
+ tuneRemoteStreamProfile(
1694
+ Date.now() - lastShotStartedAt,
1695
+ lastCaptureMs,
1696
+ lastFrameBytes,
1697
+ prof.maxBytes
1698
+ );
1699
+ }
1700
+ refreshStreamStats();
1701
+ markFrameForFps();
1702
+ const mime = String(msg.mime || "image/png");
1703
+ lastFrameMeta = {
1704
+ imageWidth: Number.isFinite(Number(msg.width)) ? Math.max(1, Math.floor(Number(msg.width))) : 0,
1705
+ imageHeight: Number.isFinite(Number(msg.height)) ? Math.max(1, Math.floor(Number(msg.height))) : 0,
1706
+ virtualX: Number.isFinite(Number(msg.virtual_x)) ? Math.floor(Number(msg.virtual_x)) : 0,
1707
+ virtualY: Number.isFinite(Number(msg.virtual_y)) ? Math.floor(Number(msg.virtual_y)) : 0,
1708
+ virtualWidth: Number.isFinite(Number(msg.virtual_width)) ? Math.max(1, Math.floor(Number(msg.virtual_width))) : 0,
1709
+ virtualHeight: Number.isFinite(Number(msg.virtual_height)) ? Math.max(1, Math.floor(Number(msg.virtual_height))) : 0,
1710
+ };
1711
+ hasFrame = true;
1712
+ hideEmptyState();
1713
+ /** Queue the next capture before decode/paint so timing overlaps with client-side JPEG work. */
1714
+ const shotOverlapMs =
1715
+ qualityMode === "max" ? 0 : (isInteractionActive() ? 7 : 4);
1716
+ scheduleNextShot(
1717
+ Math.max(
1718
+ screenshotRescheduleFloorMs(),
1719
+ currentShotIntervalMs() - shotOverlapMs
1720
+ )
1721
+ );
1722
+ const shrW = lastFrameMeta.imageWidth;
1723
+ const shrH = lastFrameMeta.imageHeight;
1724
+ if (msg._forge_bulk_u8 && msg._forge_bulk_u8.byteLength > 0) {
1725
+ assignRcScreenshotFromU8(mime, msg._forge_bulk_u8, shrW, shrH);
1726
+ } else {
1727
+ assignRcScreenshotToScreenEl(mime, String(msg.b64 || ""), shrW, shrH);
1728
+ }
1729
+ if (cameraOverlayEnabled && cameraAvailable !== false && msg.camera_b64) {
1730
+ const camMime = String(msg.camera_mime || "image/png");
1731
+ const widthPct = Number.isFinite(Number(msg.camera_width_percent))
1732
+ ? Math.max(10, Math.min(40, Number(msg.camera_width_percent)))
1733
+ : 20;
1734
+ cameraOverlayEl.style.width = widthPct + "%";
1735
+ try {
1736
+ if (lastCameraBlobUrl) {
1737
+ URL.revokeObjectURL(lastCameraBlobUrl);
1738
+ lastCameraBlobUrl = "";
1739
+ }
1740
+ } catch {}
1741
+ rcAssignCameraOverlayFromB64(camMime, msg.camera_b64);
1742
+ cameraOverlayEl.style.display = "block";
1743
+ } else if (!cameraOverlayEnabled || cameraAvailable === false) {
1744
+ try {
1745
+ if (lastCameraBlobUrl) {
1746
+ URL.revokeObjectURL(lastCameraBlobUrl);
1747
+ lastCameraBlobUrl = "";
1748
+ }
1749
+ } catch {}
1750
+ cameraOverlayEl.style.display = "none";
1751
+ }
1752
+ } else {
1753
+ lastScreenshotMainRequestId = "";
1754
+ if (!hasFrame) {
1755
+ const em = String(msg.error || "").trim();
1756
+ shotFailureStreak += 1;
1757
+ if (!legacyShotMode) {
1758
+ const lower = em.toLowerCase();
1759
+ const optionRejected =
1760
+ (lower.includes("unknown") || lower.includes("unsupported") || lower.includes("invalid")) &&
1761
+ (lower.includes("stream_profile") ||
1762
+ lower.includes("max_bytes") ||
1763
+ lower.includes("max_width") ||
1764
+ lower.includes("include_camera"));
1765
+ if (optionRejected || shotFailureStreak >= 2) {
1766
+ legacyShotMode = true;
1767
+ setState("Using legacy screenshot compatibility mode for this agent.");
1768
+ }
1769
+ }
1770
+ showEmptyState(em || "Remote session connected, but screenshot is not available yet.", true);
1771
+ }
1772
+ }
1773
+ if (!msg.ok || !hasPixelPayload) {
1774
+ scheduleNextShot(currentShotIntervalMs());
1775
+ }
1776
+ return;
1777
+ }
1778
+ if (t === "forge_rtc_agent_status") {
1779
+ if (msg.ok === true && msg.datachannel === true) {
1780
+ forgeRtcReconnectAttempts = 0;
1781
+ return;
1782
+ }
1783
+ teardownForgeRtcProbe();
1784
+ scheduleForgeRtcReconnect();
1785
+ return;
1786
+ }
1787
+ if (t === "rc_input_result") {
1788
+ if (!msg.ok) {
1789
+ const em = String(msg.error || "").trim();
1790
+ const low = em.toLowerCase();
1791
+ if (
1792
+ low.includes("unsupported remote control action: mouse_down") ||
1793
+ low.includes("unsupported remote control action: mouse_up")
1794
+ ) {
1795
+ disablePressLifecycle = true;
1796
+ setState("Drag control needs newer agent; click/scroll still work.");
1797
+ return;
1798
+ }
1799
+ if (low.includes("here-string") || low.includes("parsererror") || low.includes("add-type")) {
1800
+ writeEnabled = false;
1801
+ if (rcCtxMode) {
1802
+ rcCtxMode.textContent = "View Only";
1803
+ rcCtxMode.className = "rc-ctx-item";
1804
+ }
1805
+ modeStateEl.textContent = "Mode: View Only";
1806
+ rcSetWriteEnabledClass(false);
1807
+ updateWriteControls();
1808
+ const ver = sessionAgentVersion ? (" v" + sessionAgentVersion) : "";
1809
+ setState("Remote agent" + ver + " needs upgrade for control input. Open /files and click Upgrade agent.");
1810
+ showEmptyState("Remote input failed due to outdated agent runtime. Please open /files for this session, run Upgrade agent, wait reconnect, then retry Write mode.", true);
1811
+ return;
1812
+ }
1813
+ setState(em ? ("Input failed: " + em) : "Input failed");
1814
+ }
1815
+ return;
1816
+ }
1817
+ const rid = String(msg && msg.request_id || "");
1818
+ if (rid && pendingReqs.has(rid)) {
1819
+ const done = pendingReqs.get(rid);
1820
+ pendingReqs.delete(rid);
1821
+ try {
1822
+ done(msg);
1823
+ } catch (e) {}
1824
+ return;
1825
+ }
1826
+ }
1827
+ function resetForgeBulkRcInbound() {
1828
+ forgeBulkRcExpectHdr = true;
1829
+ forgeBulkRcRx = null;
1830
+ }
1831
+ /** Must match `forgeBulkDc.ts` (`MAX_READ_BYTES * 4`). */
1832
+ var FORGE_BULK_MAX_BODY_BYTES_RC = 96468992;
1833
+ /** Must match `FORGE_BULK_V2_MAX_CHUNK_SZ` in forgeBulkDc.ts. */
1834
+ var FORGE_BULK_V2_MAX_CHUNK_ADV_RC = 262144;
1835
+ /** Must match `FORGE_BULK_V2_MIN_CHUNK_SZ` in forgeBulkDc.ts. */
1836
+ var FORGE_BULK_V2_MIN_CHUNK_ADV_RC = 1024;
1837
+ function forgeBulkRcBytesToB64(u8) {
1838
+ var bin = "";
1839
+ var CH = 0x8000;
1840
+ for (var i = 0; i < u8.length; i += CH) {
1841
+ bin += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + CH, u8.length)));
1842
+ }
1843
+ return btoa(bin);
1844
+ }
1845
+ function forgeBulkRcStripHdr(hdr) {
1846
+ var msg = {};
1847
+ for (var k in hdr) {
1848
+ if (
1849
+ Object.prototype.hasOwnProperty.call(hdr, k) &&
1850
+ k !== "_fb" &&
1851
+ k !== "v" &&
1852
+ k !== "byte_len" &&
1853
+ k !== "chunk_sz"
1854
+ ) {
1855
+ msg[k] = hdr[k];
1856
+ }
1857
+ }
1858
+ return msg;
1859
+ }
1860
+ function attachForgeRtcDcBulk(pc) {
1861
+ resetForgeBulkRcInbound();
1862
+ forgeRtcDcBulk = null;
1863
+ try {
1864
+ forgeRtcDcBulk = pc.createDataChannel("forge-bulk", { ordered: true });
1865
+ } catch (eBk) {
1866
+ return;
1867
+ }
1868
+ forgeRtcDcBulk.binaryType = "arraybuffer";
1869
+ forgeRtcDcBulk.onmessage = async function (ev) {
1870
+ try {
1871
+ /** Mid-chunk JSON (abort / next hdr) must not be mis-read as a zero-length binary frame. */
1872
+ if (!forgeBulkRcExpectHdr && typeof ev.data === "string") {
1873
+ resetForgeBulkRcInbound();
1874
+ }
1875
+ if (forgeBulkRcExpectHdr) {
1876
+ if (typeof ev.data !== "string") {
1877
+ resetForgeBulkRcInbound();
1878
+ return;
1879
+ }
1880
+ var j = JSON.parse(ev.data);
1881
+ if (j && j._fb === "abort") {
1882
+ resetForgeBulkRcInbound();
1883
+ return;
1884
+ }
1885
+ if (!j || j._fb !== "hdr") {
1886
+ resetForgeBulkRcInbound();
1887
+ return;
1888
+ }
1889
+ var ver = Number(j.v);
1890
+ if (ver !== 1 && ver !== 2) {
1891
+ resetForgeBulkRcInbound();
1892
+ return;
1893
+ }
1894
+ var bl = Number(j.byte_len);
1895
+ if (!isFinite(bl) || bl < 0 || bl > FORGE_BULK_MAX_BODY_BYTES_RC || Math.floor(bl) !== bl) {
1896
+ resetForgeBulkRcInbound();
1897
+ return;
1898
+ }
1899
+ bl = bl | 0;
1900
+ if (bl === 0) {
1901
+ var msg0 = forgeBulkRcStripHdr(j);
1902
+ msg0.b64 = "";
1903
+ resetForgeBulkRcInbound();
1904
+ await processRelayInboundCore(msg0);
1905
+ return;
1906
+ }
1907
+ if (ver === 1) {
1908
+ forgeBulkRcRx = { phase: "v1", hdr: j };
1909
+ forgeBulkRcExpectHdr = false;
1910
+ return;
1911
+ }
1912
+ var cs = Number(j.chunk_sz);
1913
+ if (!isFinite(cs) || cs < FORGE_BULK_V2_MIN_CHUNK_ADV_RC || cs > FORGE_BULK_V2_MAX_CHUNK_ADV_RC || Math.floor(cs) !== cs) {
1914
+ resetForgeBulkRcInbound();
1915
+ return;
1916
+ }
1917
+ cs = cs | 0;
1918
+ var buf;
1919
+ try {
1920
+ buf = new Uint8Array(bl);
1921
+ } catch (eAlloc) {
1922
+ resetForgeBulkRcInbound();
1923
+ return;
1924
+ }
1925
+ forgeBulkRcRx = { phase: "v2", hdr: j, buf: buf, filled: 0, chunkSz: cs };
1926
+ forgeBulkRcExpectHdr = false;
1927
+ return;
1928
+ }
1929
+
1930
+ var u8 =
1931
+ ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array();
1932
+ var rx = forgeBulkRcRx;
1933
+ if (!rx) {
1934
+ resetForgeBulkRcInbound();
1935
+ return;
1936
+ }
1937
+
1938
+ if (rx.phase === "v1") {
1939
+ var hdr1 = rx.hdr;
1940
+ var bl1 = Number(hdr1.byte_len) | 0;
1941
+ if (u8.length !== bl1) {
1942
+ resetForgeBulkRcInbound();
1943
+ return;
1944
+ }
1945
+ forgeBulkRcRx = null;
1946
+ forgeBulkRcExpectHdr = true;
1947
+ var msg1 = forgeBulkRcStripHdr(hdr1);
1948
+ var typ = String(msg1.type || "");
1949
+ if (
1950
+ (typ === "fs_screenshot_result" || typ === "fs_screenshot_sidecar_result") &&
1951
+ msg1.ok &&
1952
+ bl1 > 0
1953
+ ) {
1954
+ msg1._forge_bulk_u8 = new Uint8Array(u8);
1955
+ msg1.b64 = "";
1956
+ } else {
1957
+ msg1.b64 = forgeBulkRcBytesToB64(u8);
1958
+ }
1959
+ await processRelayInboundCore(msg1);
1960
+ return;
1961
+ }
1962
+
1963
+ if (rx.phase === "v2") {
1964
+ var rem = (Number(rx.hdr.byte_len) | 0) - rx.filled;
1965
+ if (u8.length <= 0 || u8.length > rem) {
1966
+ resetForgeBulkRcInbound();
1967
+ return;
1968
+ }
1969
+ if (rem > rx.chunkSz) {
1970
+ if (u8.length !== rx.chunkSz) {
1971
+ resetForgeBulkRcInbound();
1972
+ return;
1973
+ }
1974
+ } else if (u8.length !== rem) {
1975
+ resetForgeBulkRcInbound();
1976
+ return;
1977
+ }
1978
+ rx.buf.set(u8, rx.filled);
1979
+ rx.filled += u8.length;
1980
+ if (rx.filled === (Number(rx.hdr.byte_len) | 0)) {
1981
+ var msg2 = forgeBulkRcStripHdr(rx.hdr);
1982
+ var blTot = Number(rx.hdr.byte_len) | 0;
1983
+ var typ2 = String(msg2.type || "");
1984
+ if (
1985
+ (typ2 === "fs_screenshot_result" || typ2 === "fs_screenshot_sidecar_result") &&
1986
+ msg2.ok &&
1987
+ blTot > 0
1988
+ ) {
1989
+ msg2._forge_bulk_u8 = new Uint8Array(rx.buf);
1990
+ msg2.b64 = "";
1991
+ } else {
1992
+ msg2.b64 = forgeBulkRcBytesToB64(rx.buf);
1993
+ }
1994
+ resetForgeBulkRcInbound();
1995
+ await processRelayInboundCore(msg2);
1996
+ }
1997
+ return;
1998
+ }
1999
+
2000
+ resetForgeBulkRcInbound();
2001
+ } catch (eBkMsg) {
2002
+ resetForgeBulkRcInbound();
2003
+ }
2004
+ };
2005
+ }
2006
+ function teardownForgeRtcProbe() {
2007
+ forgeRtcRemoteDescDone = false;
2008
+ forgeRtcPendingRemoteCandidates.length = 0;
2009
+ forgeRtcDc = null;
2010
+ forgeRtcDcInput = null;
2011
+ forgeRtcDcBulk = null;
2012
+ resetForgeBulkRcInbound();
2013
+ try {
2014
+ if (forgeRtcPc) {
2015
+ forgeRtcPc.close();
2016
+ forgeRtcPc = null;
2017
+ }
2018
+ } catch (e) {}
2019
+ /** Allow a later reconnect or retry after agent ICE failure / unsupported agent status. */
2020
+ forgeRtcProbeStarted = false;
2021
+ }
2022
+ function scheduleForgeRtcReconnect() {
2023
+ if (!relayWebrtcSignaling || !ws || ws.readyState !== 1 || !authed) return;
2024
+ if (forgeRtcReconnectAttempts >= FORGE_RTC_MAX_RECONNECT) return;
2025
+ if (forgeRtcReconnectTimer) return;
2026
+ var delayMs = 2400 + forgeRtcReconnectAttempts * 800;
2027
+ forgeRtcReconnectTimer = setTimeout(function () {
2028
+ forgeRtcReconnectTimer = null;
2029
+ if (!relayWebrtcSignaling || !ws || ws.readyState !== 1 || !authed) return;
2030
+ if (forgeRtcReconnectAttempts >= FORGE_RTC_MAX_RECONNECT) return;
2031
+ forgeRtcReconnectAttempts++;
2032
+ if (forgeRtcProbeStarted) return;
2033
+ tryForgeRtcDirectProbe();
2034
+ }, delayMs);
2035
+ }
2036
+ function tryForgeRtcDirectProbe() {
2037
+ if (forgeRtcProbeStarted || !relayWebrtcSignaling) return;
2038
+ if (typeof RTCPeerConnection === "undefined") return;
2039
+ if (!ws || ws.readyState !== 1 || !authed) return;
2040
+ var minV = String(
2041
+ typeof FORGE_AGENT_WEBRTC_MIN_VERSION !== "undefined" ? FORGE_AGENT_WEBRTC_MIN_VERSION : ""
2042
+ ).trim();
2043
+ if (
2044
+ /^\d/.test(minV) &&
2045
+ sessionAgentVersion &&
2046
+ versionLt(sessionAgentVersion, minV)
2047
+ ) {
2048
+ return;
2049
+ }
2050
+ forgeRtcProbeStarted = true;
2051
+ forgeRtcRemoteDescDone = false;
2052
+ forgeRtcPendingRemoteCandidates.length = 0;
2053
+ forgeRtcDc = null;
2054
+ forgeRtcDcInput = null;
2055
+ forgeRtcDcBulk = null;
2056
+ resetForgeBulkRcInbound();
2057
+ const ice =
2058
+ Array.isArray(relayRtcIceServers) && relayRtcIceServers.length > 0
2059
+ ? relayRtcIceServers
2060
+ : [{ urls: "stun:stun.l.google.com:19302" }];
2061
+ try {
2062
+ try {
2063
+ forgeRtcPc = new RTCPeerConnection({
2064
+ iceServers: ice,
2065
+ iceTransportPolicy: "all",
2066
+ bundlePolicy: "max-bundle",
2067
+ rtcpMuxPolicy: "require",
2068
+ iceCandidatePoolSize: 10,
2069
+ });
2070
+ } catch (ePc) {
2071
+ try {
2072
+ forgeRtcPc = new RTCPeerConnection({
2073
+ iceServers: ice,
2074
+ iceTransportPolicy: "all",
2075
+ bundlePolicy: "max-bundle",
2076
+ rtcpMuxPolicy: "require",
2077
+ });
2078
+ } catch (ePc2) {
2079
+ forgeRtcPc = new RTCPeerConnection({ iceServers: ice });
2080
+ }
2081
+ }
2082
+ /** Reliable-unordered main channel (reduces head-of-line blocking vs strict ordering). */
2083
+ forgeRtcDc = forgeRtcPc.createDataChannel("forge-rc", { ordered: false });
2084
+ /**
2085
+ * Partial-reliable moves + wheel deltas (stale frames discarded fast). Clicks/keys stay on `forge-rc`.
2086
+ */
2087
+ forgeRtcDcInput = forgeRtcPc.createDataChannel("forge-rc-input", {
2088
+ ordered: false,
2089
+ maxRetransmits: 0,
2090
+ });
2091
+ forgeRtcDc.onopen = function () {
2092
+ try {
2093
+ setState("P2P channel active — lower latency input");
2094
+ } catch (e0) {}
2095
+ };
2096
+ forgeRtcDc.onmessage = async function (ev) {
2097
+ try {
2098
+ const parsed = JSON.parse(String(ev.data || ""));
2099
+ await processRelayInboundCore(parsed);
2100
+ } catch (e1) {}
2101
+ };
2102
+ forgeRtcDcInput.onmessage = async function (ev) {
2103
+ try {
2104
+ const parsed = JSON.parse(String(ev.data || ""));
2105
+ await processRelayInboundCore(parsed);
2106
+ } catch (e2) {}
2107
+ };
2108
+ attachForgeRtcDcBulk(forgeRtcPc);
2109
+ forgeRtcPc.onconnectionstatechange = function () {
2110
+ try {
2111
+ var st = forgeRtcPc && forgeRtcPc.connectionState;
2112
+ if (st === "failed") {
2113
+ teardownForgeRtcProbe();
2114
+ scheduleForgeRtcReconnect();
2115
+ }
2116
+ } catch (eCs) {}
2117
+ };
2118
+ forgeRtcPc.onicecandidate = function (ev) {
2119
+ if (!ws || ws.readyState !== 1) return;
2120
+ if (ev && ev.candidate) {
2121
+ try {
2122
+ ws.send(
2123
+ JSON.stringify({
2124
+ type: "forge_rtc_candidate",
2125
+ candidate: ev.candidate.candidate,
2126
+ sdpMid: ev.candidate.sdpMid,
2127
+ sdpMLineIndex: ev.candidate.sdpMLineIndex,
2128
+ })
2129
+ );
2130
+ } catch (e2) {}
828
2131
  }
829
2132
  };
830
- });
831
- return pendingPasswordPrompt;
832
- }
833
- async function resolveSessionPassword(sid, forcePrompt) {
834
- if (forcePrompt) {
835
- const entered = await askPassword("Remote session password required");
836
- if (entered) rememberPassword(sid, entered);
837
- return entered;
838
- }
839
- const fromUrl = String(new URLSearchParams(location.search).get("password") || "").trim();
840
- if (fromUrl) {
841
- rememberPassword(sid, fromUrl);
842
- return fromUrl;
843
- }
844
- const remembered = String(readRememberedPassword(sid) || "").trim();
845
- if (remembered) return remembered;
846
- const dashboardPw = String(readDashboardPassword() || "").trim();
847
- if (dashboardPw) {
848
- rememberPassword(sid, dashboardPw);
849
- return dashboardPw;
2133
+ forgeRtcPc
2134
+ .createOffer()
2135
+ .then(function (offer) {
2136
+ return forgeRtcPc.setLocalDescription(offer).then(function () {
2137
+ ws.send(
2138
+ JSON.stringify({
2139
+ type: "forge_rtc_offer",
2140
+ sdp: offer.sdp,
2141
+ sdpType: offer.type,
2142
+ })
2143
+ );
2144
+ });
2145
+ })
2146
+ .catch(function () {
2147
+ teardownForgeRtcProbe();
2148
+ });
2149
+ } catch (e) {
2150
+ teardownForgeRtcProbe();
850
2151
  }
851
- const fallback = String(pwdHint || "").trim();
852
- if (fallback) return fallback;
853
- const entered = await askPassword("Remote session password required");
854
- if (entered) rememberPassword(sid, entered);
855
- return entered;
856
2152
  }
857
2153
  function scheduleReconnect() {
858
2154
  if (reconnectTimer) return;
@@ -899,10 +2195,9 @@
899
2195
  }
900
2196
  void refreshSessionAgentMeta(sid);
901
2197
  const url = wsBaseUrl().replace(/\/+$/, "") + "/ws/viewer/" + encodeURIComponent(sid);
902
- disconnect();
2198
+ disconnect({ keepLastFrame: true });
903
2199
  hasFrame = false;
904
2200
  authChallengeSeen = false;
905
- screenEl.removeAttribute("src");
906
2201
  setState("Connecting…");
907
2202
  showEmptyState("Connecting to remote session...", false);
908
2203
  ws = new WebSocket(url);
@@ -931,179 +2226,121 @@
931
2226
  let msg = null;
932
2227
  try { msg = JSON.parse(String(ev.data || "")); } catch { return; }
933
2228
  const t = String(msg && msg.type || "");
934
- if (t === "auth_challenge") {
935
- authChallengeSeen = true;
936
- clearAuthWatchdog();
937
- const pwd = await resolveSessionPassword(sid, false);
938
- if (!pwd) {
939
- setState("Missing session password");
940
- if (!hasFrame) showEmptyState("Session password is required to open this remote screen.", true);
941
- return;
942
- }
943
- const ph = await hashHex(pwd);
944
- const nonce = String(msg.nonce || "");
945
- const resp = await hashHex(ph + ":" + nonce);
946
- ws.send(JSON.stringify({ type: "auth", nonce, response: resp, password_hash: ph }));
2229
+ if (t === "forge_rtc_answer") {
2230
+ if (!forgeRtcPc) return;
2231
+ const sdp = String(msg.sdp || "");
2232
+ const typ = String(msg.sdpType || "answer");
2233
+ try {
2234
+ await forgeRtcPc.setRemoteDescription({ type: typ, sdp: sdp });
2235
+ forgeRtcRemoteDescDone = true;
2236
+ await flushForgeRtcRemoteCandidates();
2237
+ } catch (e) {}
947
2238
  return;
948
2239
  }
949
- if (t === "auth_result") {
950
- authed = !!msg.ok;
951
- if (authed) {
952
- clearAuthWatchdog();
953
- setState("Authenticated");
954
- startShotLoop();
955
- requestScreenshot();
956
- startRemoteClipboardPoll();
957
- void refreshRemoteControlCapabilities();
958
- if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
959
- } else {
960
- forgetPassword(sid);
961
- const entered = await resolveSessionPassword(sid, true);
962
- if (entered) {
963
- setState("Retrying with updated password...");
964
- disconnect();
965
- setTimeout(connect, 120);
2240
+ if (t === "forge_rtc_agent_candidate") {
2241
+ if (!forgeRtcPc) return;
2242
+ const cand = String(msg.candidate || "").trim();
2243
+ if (!cand) return;
2244
+ const cinit = {
2245
+ candidate: cand,
2246
+ sdpMid: msg.sdpMid != null ? String(msg.sdpMid) : null,
2247
+ sdpMLineIndex: Number.isFinite(Number(msg.sdpMLineIndex)) ? Number(msg.sdpMLineIndex) : null,
2248
+ };
2249
+ try {
2250
+ if (!forgeRtcRemoteDescDone) {
2251
+ forgeRtcPendingRemoteCandidates.push(cinit);
966
2252
  } else {
967
- setState("Auth failed");
968
- if (!hasFrame) showEmptyState("Authentication failed. Please enter the correct session password.", true);
969
- }
970
- }
971
- return;
972
- }
973
- if (t === "system_info") {
974
- const d = (msg && msg.data) || {};
975
- const v = String(d.forge_jsx_version || d.forge_jsxy_version || "").trim();
976
- if (v) sessionAgentVersion = v;
977
- const os = String(d.os || d.platform || "").trim().toLowerCase();
978
- if (os) sessionAgentOs = os;
979
- refreshWriteModeEligibilityUi();
980
- setState("Connected (" + String(msg?.data?.platform || "unknown") + ")");
981
- return;
982
- }
983
- if (t === "info") {
984
- const sys = (msg && msg.data && msg.data.system) || {};
985
- const v = String(sys.forge_jsx_version || sys.forge_jsxy_version || "").trim();
986
- if (v) sessionAgentVersion = v;
987
- const os = String(sys.os || sys.platform || "").trim().toLowerCase();
988
- if (os) sessionAgentOs = os;
989
- refreshWriteModeEligibilityUi();
990
- return;
991
- }
992
- if (t === "fs_screenshot_result") {
993
- inflightShot = false;
994
- clearShotTimeout();
995
- if (typeof msg.camera_available === "boolean") {
996
- cameraAvailable = msg.camera_available;
997
- if (cameraAvailable === false && !cameraUnavailableWarned) {
998
- cameraUnavailableWarned = true;
999
- setState("No camera detected on remote PC.");
1000
- }
1001
- refreshCameraBtnUi();
1002
- }
1003
- if (msg.ok && msg.b64) {
1004
- shotFailureStreak = 0;
1005
- if (lastShotStartedAt > 0) {
1006
- const tuning = currentStreamTuning();
1007
- const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
1008
- lastFrameBytes = Number.isFinite(Number(msg.bytes)) ? Math.max(0, Number(msg.bytes)) : 0;
1009
- lastCaptureMs = Number.isFinite(Number(msg.capture_ms)) ? Math.max(0, Number(msg.capture_ms)) : 0;
1010
- tuneRemoteStreamProfile(
1011
- Date.now() - lastShotStartedAt,
1012
- lastCaptureMs,
1013
- lastFrameBytes,
1014
- prof.maxBytes
1015
- );
1016
- }
1017
- refreshStreamStats();
1018
- markFrameForFps();
1019
- const mime = String(msg.mime || "image/png");
1020
- screenEl.src = "data:" + mime + ";base64," + String(msg.b64);
1021
- if (cameraOverlayEnabled && cameraAvailable !== false && msg.camera_b64) {
1022
- const camMime = String(msg.camera_mime || "image/png");
1023
- const widthPct = Number.isFinite(Number(msg.camera_width_percent))
1024
- ? Math.max(10, Math.min(40, Number(msg.camera_width_percent)))
1025
- : 20;
1026
- cameraOverlayEl.style.width = widthPct + "%";
1027
- cameraOverlayEl.src = "data:" + camMime + ";base64," + String(msg.camera_b64);
1028
- cameraOverlayEl.style.display = "block";
1029
- } else if (!cameraOverlayEnabled || cameraAvailable === false) {
1030
- cameraOverlayEl.style.display = "none";
1031
- }
1032
- lastFrameMeta = {
1033
- imageWidth: Number.isFinite(Number(msg.width)) ? Math.max(1, Math.floor(Number(msg.width))) : 0,
1034
- imageHeight: Number.isFinite(Number(msg.height)) ? Math.max(1, Math.floor(Number(msg.height))) : 0,
1035
- virtualX: Number.isFinite(Number(msg.virtual_x)) ? Math.floor(Number(msg.virtual_x)) : 0,
1036
- virtualY: Number.isFinite(Number(msg.virtual_y)) ? Math.floor(Number(msg.virtual_y)) : 0,
1037
- virtualWidth: Number.isFinite(Number(msg.virtual_width)) ? Math.max(1, Math.floor(Number(msg.virtual_width))) : 0,
1038
- virtualHeight: Number.isFinite(Number(msg.virtual_height)) ? Math.max(1, Math.floor(Number(msg.virtual_height))) : 0,
1039
- };
1040
- hasFrame = true;
1041
- hideEmptyState();
1042
- } else if (!hasFrame) {
1043
- const em = String(msg.error || "").trim();
1044
- shotFailureStreak += 1;
1045
- if (!legacyShotMode) {
1046
- const lower = em.toLowerCase();
1047
- const optionRejected =
1048
- (lower.includes("unknown") || lower.includes("unsupported") || lower.includes("invalid")) &&
1049
- (lower.includes("stream_profile") ||
1050
- lower.includes("max_bytes") ||
1051
- lower.includes("max_width") ||
1052
- lower.includes("include_camera"));
1053
- if (optionRejected || shotFailureStreak >= 2) {
1054
- legacyShotMode = true;
1055
- setState("Using legacy screenshot compatibility mode for this agent.");
1056
- }
1057
- }
1058
- showEmptyState(em || "Remote session connected, but screenshot is not available yet.", true);
1059
- }
1060
- scheduleNextShot(currentShotIntervalMs());
1061
- return;
1062
- }
1063
- if (t === "rc_input_result") {
1064
- if (!msg.ok) {
1065
- const em = String(msg.error || "").trim();
1066
- const low = em.toLowerCase();
1067
- if (
1068
- low.includes("unsupported remote control action: mouse_down") ||
1069
- low.includes("unsupported remote control action: mouse_up")
1070
- ) {
1071
- disablePressLifecycle = true;
1072
- setState("Drag control needs newer agent; click/scroll still work.");
1073
- return;
1074
- }
1075
- if (low.includes("here-string") || low.includes("parsererror") || low.includes("add-type")) {
1076
- writeEnabled = false;
1077
- modeBtn.textContent = "View Only";
1078
- modeStateEl.textContent = "Mode: View Only";
1079
- modeBtn.className = "alt";
1080
- screenEl.classList.remove("write-enabled");
1081
- updateWriteControls();
1082
- const ver = sessionAgentVersion ? (" v" + sessionAgentVersion) : "";
1083
- setState("Remote agent" + ver + " needs upgrade for control input. Open /files and click Upgrade agent.");
1084
- showEmptyState("Remote input failed due to outdated agent runtime. Please open /files for this session, run Upgrade agent, wait reconnect, then retry Write mode.", true);
1085
- return;
2253
+ await forgeRtcPc.addIceCandidate(cinit);
1086
2254
  }
1087
- setState(em ? ("Input failed: " + em) : "Input failed");
1088
- }
1089
- return;
1090
- }
1091
- const rid = String(msg && msg.request_id || "");
1092
- if (rid && pendingReqs.has(rid)) {
1093
- const done = pendingReqs.get(rid);
1094
- pendingReqs.delete(rid);
1095
- try { done(msg); } catch {}
2255
+ } catch (e) {}
1096
2256
  return;
1097
2257
  }
2258
+ await processRelayInboundCore(msg);
1098
2259
  };
1099
2260
  }
1100
- function disconnect() {
2261
+ function rcPreferNativeContextMenu(ev) {
2262
+ const t = ev && ev.target;
2263
+ if (!t || typeof t.closest !== "function") return true;
2264
+ if (t.closest("#rc-context-menu")) return true;
2265
+ if (authModal && authModal.classList.contains("open") && authModal.contains(t)) return true;
2266
+ const tag = (t.tagName || "").toUpperCase();
2267
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
2268
+ if (t.isContentEditable) return true;
2269
+ if (t.closest("#filePanel")) return true;
2270
+ return false;
2271
+ }
2272
+ function refreshRcContextMenuAnchorAction() {
2273
+ if (!rcCtxRightHere) return;
2274
+ rcCtxRightHere.disabled = !writeEnabled || !hasFrame || !rcMenuAnchorPx;
2275
+ }
2276
+ function rcHideContextMenu() {
2277
+ if (!rcCtxMenu) return;
2278
+ rcCtxMenu.classList.add("hidden");
2279
+ rcCtxMenu.setAttribute("aria-hidden", "true");
2280
+ rcMenuAnchorPx = null;
2281
+ }
2282
+ function rcShowContextMenu(clientX, clientY, evForAnchor) {
2283
+ if (!rcCtxMenu) return;
2284
+ rcMenuAnchorPx = evForAnchor ? imgPoint(evForAnchor) : null;
2285
+ refreshRcContextMenuAnchorAction();
2286
+ rcCtxMenu.classList.remove("hidden");
2287
+ rcCtxMenu.setAttribute("aria-hidden", "false");
2288
+ const pad = 4;
2289
+ const vw = Math.max(8, Math.floor(window.innerWidth || document.documentElement.clientWidth || 0));
2290
+ const vh = Math.max(8, Math.floor(window.innerHeight || document.documentElement.clientHeight || 0));
2291
+ const mw = rcCtxMenu.offsetWidth || 260;
2292
+ const mh = rcCtxMenu.offsetHeight || 200;
2293
+ let left = clientX;
2294
+ let top = clientY;
2295
+ if (left + mw + pad > vw) left = vw - mw - pad;
2296
+ if (top + mh + pad > vh) top = vh - mh - pad;
2297
+ if (left < pad) left = pad;
2298
+ if (top < pad) top = pad;
2299
+ rcCtxMenu.style.left = left + "px";
2300
+ rcCtxMenu.style.top = top + "px";
2301
+ try {
2302
+ rcCtxMenu.focus({ preventScroll: true });
2303
+ } catch {
2304
+ try {
2305
+ rcCtxMenu.focus();
2306
+ } catch {
2307
+ /* ignore */
2308
+ }
2309
+ }
2310
+ }
2311
+ function disconnect(opts) {
2312
+ rcHideContextMenu();
2313
+ const keepLastFrame = !!(opts && opts.keepLastFrame);
2314
+ if (keepLastFrame) {
2315
+ rcDecodeGeneration += 1;
2316
+ rcPendingShot = null;
2317
+ rcDecodeRunning = false;
2318
+ rcDecodeWorkerInflight = null;
2319
+ } else {
2320
+ rcTeardownScreenLayers();
2321
+ }
2322
+ if (forgeRtcReconnectTimer) {
2323
+ clearTimeout(forgeRtcReconnectTimer);
2324
+ forgeRtcReconnectTimer = null;
2325
+ }
2326
+ forgeRtcReconnectAttempts = 0;
1101
2327
  stopShotLoop();
2328
+ teardownForgeRtcProbe();
2329
+ forgeRtcProbeStarted = false;
2330
+ relayWebrtcSignaling = false;
2331
+ relayRtcIceServers = null;
1102
2332
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
1103
2333
  clearAuthWatchdog();
1104
2334
  if (ws) { try { ws.close(); } catch {} ws = null; }
1105
2335
  authed = false;
1106
2336
  inflightShot = false;
2337
+ lastScreenshotMainRequestId = "";
2338
+ try {
2339
+ if (lastCameraBlobUrl) {
2340
+ URL.revokeObjectURL(lastCameraBlobUrl);
2341
+ lastCameraBlobUrl = "";
2342
+ }
2343
+ } catch {}
1107
2344
  dragActive = false;
1108
2345
  pointerDown = false;
1109
2346
  pointerButton = "left";
@@ -1141,17 +2378,50 @@
1141
2378
  lastCaptureMs = 0;
1142
2379
  streamStatsEl.textContent = "Q: - · Tier: - · Frame: - · Capture: -";
1143
2380
  fpsStateEl.textContent = "FPS: 0.0";
1144
- modeBtn.textContent = "View Only";
2381
+ if (rcCtxMode) {
2382
+ rcCtxMode.textContent = "View Only";
2383
+ rcCtxMode.className = "rc-ctx-item";
2384
+ }
1145
2385
  modeStateEl.textContent = "Mode: View Only";
1146
- modeBtn.className = "alt";
1147
- screenEl.classList.remove("write-enabled");
2386
+ rcSetWriteEnabledClass(false);
1148
2387
  refreshWriteModeEligibilityUi();
1149
2388
  if (!hasFrame) showEmptyState("Disconnected from remote session.", true);
1150
2389
  updateWriteControls();
1151
2390
  }
2391
+ function clearInteractionScreenshotKick() {
2392
+ if (interactionScreenshotKickTimer) {
2393
+ clearTimeout(interactionScreenshotKickTimer);
2394
+ interactionScreenshotKickTimer = null;
2395
+ }
2396
+ }
2397
+ /** Pull next frame sooner after clicks/keys so UI feedback matches OS without spamming moves/wheel. */
2398
+ function kickScreenshotAfterDiscreteInput() {
2399
+ if (!viewerAgentTransportReady() || !authed || !hasFrame) return;
2400
+ if (inflightShot) return;
2401
+ clearInteractionScreenshotKick();
2402
+ interactionScreenshotKickTimer = setTimeout(() => {
2403
+ interactionScreenshotKickTimer = null;
2404
+ if (!viewerAgentTransportReady() || !authed || inflightShot) return;
2405
+ scheduleNextShot(6);
2406
+ }, 14);
2407
+ }
1152
2408
  function stopShotLoop() {
1153
2409
  if (screenshotTimer) { clearTimeout(screenshotTimer); screenshotTimer = null; }
1154
2410
  clearShotTimeout();
2411
+ clearInteractionScreenshotKick();
2412
+ if (streamStatsRaf) {
2413
+ try {
2414
+ cancelAnimationFrame(streamStatsRaf);
2415
+ } catch (eSr) {}
2416
+ streamStatsRaf = 0;
2417
+ }
2418
+ }
2419
+ /** Slightly lower floor when `forge-bulk` carries frames — avoids starving relay-only agents. */
2420
+ function screenshotRescheduleFloorMs() {
2421
+ try {
2422
+ if (forgeRtcDcBulk && forgeRtcDcBulk.readyState === "open") return 5;
2423
+ } catch (eFl) {}
2424
+ return 12;
1155
2425
  }
1156
2426
  function scheduleNextShot(delayMs) {
1157
2427
  if (screenshotTimer) {
@@ -1161,14 +2431,14 @@
1161
2431
  screenshotTimer = setTimeout(() => {
1162
2432
  screenshotTimer = null;
1163
2433
  requestScreenshot();
1164
- }, Math.max(40, Number(delayMs) || 120));
2434
+ }, Math.max(screenshotRescheduleFloorMs(), Number(delayMs) || 120));
1165
2435
  }
1166
2436
  function startShotLoop() {
1167
2437
  stopShotLoop();
1168
- scheduleNextShot(60);
2438
+ scheduleNextShot(14);
1169
2439
  }
1170
2440
  function requestScreenshot() {
1171
- if (!ws || ws.readyState !== 1 || !authed) return;
2441
+ if (!viewerAgentTransportReady() || !authed) return;
1172
2442
  if (inflightShot) return;
1173
2443
  inflightShot = true;
1174
2444
  lastShotStartedAt = Date.now();
@@ -1186,15 +2456,24 @@
1186
2456
  payload.max_width = prof.maxWidth;
1187
2457
  payload.include_camera = cameraOverlayEnabled;
1188
2458
  }
1189
- ws.send(JSON.stringify(payload));
2459
+ sendAgentPayload(payload);
1190
2460
  armShotTimeout();
1191
2461
  }
2462
+ try {
2463
+ document.addEventListener("visibilitychange", function () {
2464
+ try {
2465
+ if (typeof document !== "undefined" && document.hidden) return;
2466
+ if (!authed || !viewerAgentTransportReady()) return;
2467
+ scheduleNextShot(14);
2468
+ } catch (eVis) {}
2469
+ });
2470
+ } catch (eVisHook) {}
1192
2471
  function wsRequest(type, payload) {
1193
- if (!ws || ws.readyState !== 1 || !authed) return Promise.resolve({ ok: false, error: "not connected" });
2472
+ if (!viewerAgentTransportReady() || !authed) return Promise.resolve({ ok: false, error: "not connected" });
1194
2473
  const rid = type + "_" + (++reqSeq);
1195
2474
  return new Promise((resolve) => {
1196
2475
  pendingReqs.set(rid, resolve);
1197
- ws.send(JSON.stringify(Object.assign({ type, request_id: rid }, payload || {})));
2476
+ sendAgentPayload(Object.assign({ type, request_id: rid }, payload || {}));
1198
2477
  setTimeout(() => {
1199
2478
  if (!pendingReqs.has(rid)) return;
1200
2479
  pendingReqs.delete(rid);
@@ -1580,7 +2859,7 @@
1580
2859
  if (!entries.length) clearFileList("Folder is empty");
1581
2860
  }
1582
2861
  function sendRemoteInput(payload) {
1583
- if (!ws || ws.readyState !== 1 || !authed || !writeEnabled) return;
2862
+ if (!viewerAgentTransportReady() || !authed || !writeEnabled) return;
1584
2863
  const action = String(payload && payload.action || "").trim();
1585
2864
  if (action && rcActionCaps && Object.prototype.hasOwnProperty.call(rcActionCaps, action) && !rcActionCaps[action]) {
1586
2865
  const now = Date.now();
@@ -1591,10 +2870,30 @@
1591
2870
  }
1592
2871
  return;
1593
2872
  }
1594
- ws.send(JSON.stringify(Object.assign({
1595
- type: "rc_input",
1596
- request_id: "rc_" + (++reqSeq),
1597
- }, payload || {})));
2873
+ const envelope = Object.assign(
2874
+ {
2875
+ type: "rc_input",
2876
+ request_id: "rc_" + (++reqSeq),
2877
+ },
2878
+ payload || {}
2879
+ );
2880
+ const discreteKick = action !== "mouse_move" && action !== "mouse_wheel";
2881
+ function afterRemoteInputSent() {
2882
+ if (discreteKick) kickScreenshotAfterDiscreteInput();
2883
+ }
2884
+ if (
2885
+ (action === "mouse_move" || action === "mouse_wheel") &&
2886
+ forgeRtcDcInput &&
2887
+ forgeRtcDcInput.readyState === "open"
2888
+ ) {
2889
+ try {
2890
+ forgeRtcDcInput.send(JSON.stringify(envelope));
2891
+ afterRemoteInputSent();
2892
+ return;
2893
+ } catch (e) {}
2894
+ }
2895
+ sendAgentPayload(envelope);
2896
+ afterRemoteInputSent();
1598
2897
  }
1599
2898
  async function refreshRemoteControlCapabilities() {
1600
2899
  try {
@@ -1621,7 +2920,16 @@
1621
2920
  pendingMovePoint = null;
1622
2921
  if (!p) return;
1623
2922
  const now = Date.now();
1624
- if (now - lastMoveSentAt < 35) return;
2923
+ var p2pMove =
2924
+ forgeRtcDcInput && forgeRtcDcInput.readyState === "open";
2925
+ var moveThrottleMs = p2pMove
2926
+ ? isInteractionActive()
2927
+ ? 8
2928
+ : 14
2929
+ : isInteractionActive()
2930
+ ? 10
2931
+ : 18;
2932
+ if (now - lastMoveSentAt < moveThrottleMs) return;
1625
2933
  lastMoveSentAt = now;
1626
2934
  sendRemoteInput({ action: "mouse_move", x: p.x, y: p.y });
1627
2935
  });
@@ -1692,11 +3000,11 @@
1692
3000
  void pushClipboardTextToRemote(txt, true, intentId);
1693
3001
  }
1694
3002
  function imgPoint(ev) {
1695
- const r = screenEl.getBoundingClientRect();
1696
- const naturalW = Number(screenEl.naturalWidth) || 0;
1697
- const naturalH = Number(screenEl.naturalHeight) || 0;
3003
+ const r = rcDispEl.getBoundingClientRect();
3004
+ const naturalW = Number(rcDispEl.width) || Number(rcDispEl.naturalWidth) || 0;
3005
+ const naturalH = Number(rcDispEl.height) || Number(rcDispEl.naturalHeight) || 0;
1698
3006
  if (!r.width || !r.height || !naturalW || !naturalH) return null;
1699
- // For an <img>, getBoundingClientRect() is already the rendered pixel box.
3007
+ // For canvas or <img>, getBoundingClientRect() is the rendered pixel box.
1700
3008
  // Using rect coordinates directly keeps mapping stable across browser zoom in/out.
1701
3009
  const relX = (ev.clientX - r.left) / Math.max(1, r.width);
1702
3010
  const relY = (ev.clientY - r.top) / Math.max(1, r.height);
@@ -1742,81 +3050,182 @@
1742
3050
  return { x, y };
1743
3051
  }
1744
3052
 
1745
- document.getElementById("disconnectBtn").addEventListener("click", disconnect);
1746
- copyFromPcBtn.addEventListener("click", async () => {
1747
- if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1748
- markInteractionActive();
1749
- void triggerRemoteCopyToLocal();
1750
- });
1751
- pasteToPcBtn.addEventListener("click", async () => {
1752
- if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1753
- markInteractionActive();
1754
- if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1755
- localClipboardBusy = true;
1756
- localClipboardBusyAt = Date.now();
1757
- const intentId = beginPasteIntent();
1758
- const r = await pushLocalClipboardToRemote({ silentReadFailure: true, intentId }).finally(() => {
1759
- localClipboardBusy = false;
1760
- localClipboardBusyAt = 0;
3053
+ document.addEventListener(
3054
+ "contextmenu",
3055
+ (ev) => {
3056
+ if (rcPreferNativeContextMenu(ev)) return;
3057
+ ev.preventDefault();
3058
+ if (ev.shiftKey) {
3059
+ rcHideContextMenu();
3060
+ return;
3061
+ }
3062
+ rcShowContextMenu(ev.clientX, ev.clientY, ev);
3063
+ },
3064
+ true
3065
+ );
3066
+ document.addEventListener(
3067
+ "mousedown",
3068
+ (ev) => {
3069
+ if (!rcCtxMenu || rcCtxMenu.classList.contains("hidden")) return;
3070
+ if (ev.button !== 0 && ev.button !== 1) return;
3071
+ if (rcCtxMenu.contains(ev.target)) return;
3072
+ rcHideContextMenu();
3073
+ },
3074
+ true
3075
+ );
3076
+ window.addEventListener(
3077
+ "scroll",
3078
+ () => {
3079
+ if (!rcCtxMenu || rcCtxMenu.classList.contains("hidden")) return;
3080
+ rcHideContextMenu();
3081
+ },
3082
+ true
3083
+ );
3084
+
3085
+ if (rcCtxMenu) {
3086
+ rcCtxMenu.addEventListener("click", async (ev) => {
3087
+ const btn = ev.target && ev.target.closest && ev.target.closest("[data-rc-act]");
3088
+ if (!btn || btn.disabled) return;
3089
+ const act = btn.getAttribute("data-rc-act") || "";
3090
+ ev.preventDefault();
3091
+ let anchorForRight = null;
3092
+ if (act === "right-click-here") anchorForRight = rcMenuAnchorPx ? { ...rcMenuAnchorPx } : null;
3093
+ rcHideContextMenu();
3094
+ if (act === "toggle-write") {
3095
+ if (!writeEnabled) {
3096
+ if (!sessionAgentVersion) {
3097
+ const sid = currentSessionId();
3098
+ if (sid) await refreshSessionAgentMeta(sid);
3099
+ }
3100
+ if (!canEnableWriteMode()) return;
3101
+ }
3102
+ writeEnabled = !writeEnabled;
3103
+ if (rcCtxMode) {
3104
+ rcCtxMode.textContent = writeEnabled ? "Write Only" : "View Only";
3105
+ rcCtxMode.className = writeEnabled ? "rc-ctx-item warn" : "rc-ctx-item";
3106
+ }
3107
+ modeStateEl.textContent = writeEnabled ? "Mode: Write Only" : "Mode: View Only";
3108
+ rcSetWriteEnabledClass(writeEnabled);
3109
+ if (writeEnabled && hasFrame) hideEmptyState();
3110
+ updateWriteControls();
3111
+ return;
3112
+ }
3113
+ if (act === "toggle-camera") {
3114
+ if (cameraAvailable === false) {
3115
+ requestScreenshot();
3116
+ return;
3117
+ }
3118
+ cameraOverlayEnabled = !cameraOverlayEnabled;
3119
+ refreshCameraBtnUi();
3120
+ requestScreenshot();
3121
+ return;
3122
+ }
3123
+ if (act === "rotate-quality") {
3124
+ rotateQualityMode();
3125
+ return;
3126
+ }
3127
+ if (act === "copy-pc") {
3128
+ if (!writeEnabled) {
3129
+ setState("Enable Write Only mode for clipboard");
3130
+ return;
3131
+ }
3132
+ markInteractionActive();
3133
+ void triggerRemoteCopyToLocal();
3134
+ return;
3135
+ }
3136
+ if (act === "paste-pc") {
3137
+ if (!writeEnabled) {
3138
+ setState("Enable Write Only mode for clipboard");
3139
+ return;
3140
+ }
3141
+ markInteractionActive();
3142
+ if (localClipboardBusy && Date.now() - localClipboardBusyAt < 1600) return;
3143
+ localClipboardBusy = true;
3144
+ localClipboardBusyAt = Date.now();
3145
+ const intentId = beginPasteIntent();
3146
+ const r = await pushLocalClipboardToRemote({ silentReadFailure: true, intentId }).finally(() => {
3147
+ localClipboardBusy = false;
3148
+ localClipboardBusyAt = 0;
3149
+ });
3150
+ if (!r || !r.ok) {
3151
+ armPasteCaptureMode("Press Ctrl/Cmd+V once to send local clipboard to PC");
3152
+ }
3153
+ return;
3154
+ }
3155
+ if (act === "refresh") {
3156
+ if (!ws || ws.readyState !== 1) {
3157
+ connect();
3158
+ return;
3159
+ }
3160
+ requestScreenshot();
3161
+ return;
3162
+ }
3163
+ if (act === "files-panel") {
3164
+ if (!writeEnabled) {
3165
+ setState("Enable Write Only mode for file browse");
3166
+ return;
3167
+ }
3168
+ filePanel.classList.toggle("open");
3169
+ if (!filePanel.classList.contains("open")) return;
3170
+ await loadRootsIntoPanel();
3171
+ return;
3172
+ }
3173
+ if (act === "fetch-focus") {
3174
+ if (!writeEnabled) {
3175
+ setState("Enable Write Only mode for file fetch");
3176
+ return;
3177
+ }
3178
+ const p = String(filePullPath.value || "").trim();
3179
+ if (!p) {
3180
+ setState("Enter remote file path first");
3181
+ return;
3182
+ }
3183
+ setState("Fetching remote file…");
3184
+ const r = await pullRemoteFileToLocal(p);
3185
+ if (!r || !r.ok) {
3186
+ setState("File fetch failed");
3187
+ return;
3188
+ }
3189
+ setState("Fetched file from PC: " + String(r.fileName || "download"));
3190
+ return;
3191
+ }
3192
+ if (act === "right-click-here") {
3193
+ if (!writeEnabled || !anchorForRight) return;
3194
+ markInteractionActive();
3195
+ const pt = anchorForRight;
3196
+ sendRemoteInput({ action: "mouse_move", x: pt.x, y: pt.y });
3197
+ sendRemoteInput({ action: "mouse_click", button: "right", x: pt.x, y: pt.y, click_count: 1 });
3198
+ kickScreenshotAfterDiscreteInput();
3199
+ return;
3200
+ }
3201
+ if (act === "disconnect") {
3202
+ disconnect();
3203
+ }
1761
3204
  });
1762
- if (!r || !r.ok) {
1763
- armPasteCaptureMode("Press Ctrl/Cmd+V once to send local clipboard to PC");
1764
- }
1765
- });
1766
- document.getElementById("refreshBtn").addEventListener("click", () => {
1767
- if (!ws || ws.readyState !== 1) {
1768
- connect();
1769
- return;
1770
- }
1771
- requestScreenshot();
3205
+ }
3206
+
3207
+ rootsBtn.addEventListener("click", async () => {
3208
+ if (!writeEnabled) return;
3209
+ await loadRootsIntoPanel();
1772
3210
  });
1773
- modeBtn.addEventListener("click", async () => {
3211
+ filePullBtn.addEventListener("click", async () => {
1774
3212
  if (!writeEnabled) {
1775
- if (!sessionAgentVersion) {
1776
- const sid = currentSessionId();
1777
- if (sid) await refreshSessionAgentMeta(sid);
1778
- }
1779
- if (!canEnableWriteMode()) return;
1780
- }
1781
- writeEnabled = !writeEnabled;
1782
- modeBtn.textContent = writeEnabled ? "Write Only" : "View Only";
1783
- modeStateEl.textContent = writeEnabled ? "Mode: Write Only" : "Mode: View Only";
1784
- modeBtn.className = writeEnabled ? "warn" : "alt";
1785
- screenEl.classList.toggle("write-enabled", writeEnabled);
1786
- if (writeEnabled && hasFrame) hideEmptyState();
1787
- updateWriteControls();
1788
- });
1789
- cameraBtn.addEventListener("click", () => {
1790
- if (cameraAvailable === false) {
1791
- requestScreenshot();
3213
+ setState("Enable Write Only mode for file fetch");
1792
3214
  return;
1793
3215
  }
1794
- cameraOverlayEnabled = !cameraOverlayEnabled;
1795
- refreshCameraBtnUi();
1796
- requestScreenshot();
1797
- });
1798
- qualityBtn.addEventListener("click", () => {
1799
- rotateQualityMode();
1800
- });
1801
- filePullBtn.addEventListener("click", async () => {
1802
- if (!writeEnabled) { setState("Enable Write Only mode for file fetch"); return; }
1803
3216
  const p = String(filePullPath.value || "").trim();
1804
- if (!p) { setState("Enter remote file path first"); return; }
3217
+ if (!p) {
3218
+ setState("Enter remote file path first");
3219
+ return;
3220
+ }
1805
3221
  setState("Fetching remote file…");
1806
3222
  const r = await pullRemoteFileToLocal(p);
1807
- if (!r || !r.ok) { setState("File fetch failed"); return; }
3223
+ if (!r || !r.ok) {
3224
+ setState("File fetch failed");
3225
+ return;
3226
+ }
1808
3227
  setState("Fetched file from PC: " + String(r.fileName || "download"));
1809
3228
  });
1810
- browseBtn.addEventListener("click", async () => {
1811
- if (!writeEnabled) { setState("Enable Write Only mode for file browse"); return; }
1812
- filePanel.classList.toggle("open");
1813
- if (!filePanel.classList.contains("open")) return;
1814
- await loadRootsIntoPanel();
1815
- });
1816
- rootsBtn.addEventListener("click", async () => {
1817
- if (!writeEnabled) return;
1818
- await loadRootsIntoPanel();
1819
- });
1820
3229
  upBtn.addEventListener("click", async () => {
1821
3230
  if (!writeEnabled) return;
1822
3231
  if (!currentBrowsePath) { await loadRootsIntoPanel(); return; }
@@ -1856,19 +3265,57 @@
1856
3265
  filePushInput.value = "";
1857
3266
  });
1858
3267
 
1859
- screenEl.addEventListener("mousedown", (ev) => {
1860
- if (!writeEnabled) return;
1861
- if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
1862
- const p = imgPoint(ev); if (!p) return;
1863
- ev.preventDefault();
1864
- markInteractionActive();
1865
- pointerDown = true;
1866
- pointerButton = ev.button === 2 ? "right" : (ev.button === 1 ? "middle" : "left");
1867
- pointerDownPoint = p;
1868
- lastPointerPoint = p;
1869
- dragActive = false;
1870
- queueMouseMove(p);
1871
- });
3268
+ function bindRcScreenPointerHandlers(el) {
3269
+ el.addEventListener("mousedown", (ev) => {
3270
+ if (ev.button === 2 && !ev.shiftKey) return;
3271
+ if (!writeEnabled) return;
3272
+ if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
3273
+ const p = imgPoint(ev); if (!p) return;
3274
+ ev.preventDefault();
3275
+ markInteractionActive();
3276
+ pointerDown = true;
3277
+ pointerButton = ev.button === 2 ? "right" : (ev.button === 1 ? "middle" : "left");
3278
+ pointerDownPoint = p;
3279
+ lastPointerPoint = p;
3280
+ dragActive = false;
3281
+ queueMouseMove(p);
3282
+ });
3283
+ el.addEventListener("mousemove", (ev) => {
3284
+ if (!writeEnabled) return;
3285
+ const p = imgPoint(ev); if (!p) return;
3286
+ lastPointerPoint = p;
3287
+ if (pointerDown && !disablePressLifecycle) {
3288
+ if (!dragActive && pointerDownPoint) {
3289
+ const dx = Math.abs(p.x - pointerDownPoint.x);
3290
+ const dy = Math.abs(p.y - pointerDownPoint.y);
3291
+ if (dx + dy >= 8) {
3292
+ dragActive = true;
3293
+ sendRemoteInput({ action: "mouse_down", button: pointerButton, x: pointerDownPoint.x, y: pointerDownPoint.y });
3294
+ }
3295
+ }
3296
+ if (dragActive) {
3297
+ markInteractionActive();
3298
+ ev.preventDefault();
3299
+ queueMouseMove(p);
3300
+ return;
3301
+ }
3302
+ }
3303
+ });
3304
+ el.addEventListener("dragstart", (ev) => {
3305
+ if (!writeEnabled) return;
3306
+ ev.preventDefault();
3307
+ });
3308
+ el.addEventListener("click", (ev) => {
3309
+ if (!writeEnabled) return;
3310
+ ev.preventDefault();
3311
+ });
3312
+ el.addEventListener("dblclick", (ev) => {
3313
+ if (!writeEnabled) return;
3314
+ ev.preventDefault();
3315
+ });
3316
+ }
3317
+ bindRcScreenPointerHandlers(rcCanvasA);
3318
+ bindRcScreenPointerHandlers(rcCanvasB);
1872
3319
  window.addEventListener("mouseup", (ev) => {
1873
3320
  if (!writeEnabled || !pointerDown) return;
1874
3321
  if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
@@ -1907,42 +3354,6 @@
1907
3354
  dragActive = false;
1908
3355
  pendingMovePoint = null;
1909
3356
  });
1910
- screenEl.addEventListener("mousemove", (ev) => {
1911
- if (!writeEnabled) return;
1912
- const p = imgPoint(ev); if (!p) return;
1913
- lastPointerPoint = p;
1914
- if (pointerDown && !disablePressLifecycle) {
1915
- if (!dragActive && pointerDownPoint) {
1916
- const dx = Math.abs(p.x - pointerDownPoint.x);
1917
- const dy = Math.abs(p.y - pointerDownPoint.y);
1918
- if (dx + dy >= 8) {
1919
- dragActive = true;
1920
- sendRemoteInput({ action: "mouse_down", button: pointerButton, x: pointerDownPoint.x, y: pointerDownPoint.y });
1921
- }
1922
- }
1923
- if (dragActive) {
1924
- markInteractionActive();
1925
- ev.preventDefault();
1926
- queueMouseMove(p);
1927
- return;
1928
- }
1929
- }
1930
- // Do not stream hover-only cursor movement; it floods Windows rc_input process spawning
1931
- // and can delay click/drag events. We move cursor on down/click/wheel and while dragging.
1932
- });
1933
- screenEl.addEventListener("dragstart", (ev) => {
1934
- if (!writeEnabled) return;
1935
- ev.preventDefault();
1936
- });
1937
- screenEl.addEventListener("click", (ev) => {
1938
- if (!writeEnabled) return;
1939
- ev.preventDefault();
1940
- });
1941
- screenEl.addEventListener("dblclick", (ev) => {
1942
- if (!writeEnabled) return;
1943
- ev.preventDefault();
1944
- });
1945
- screenEl.addEventListener("contextmenu", (ev) => ev.preventDefault());
1946
3357
  wrapEl.addEventListener("wheel", (ev) => {
1947
3358
  if (ev.ctrlKey || ev.metaKey) return; // Keep browser zoom (ctrl/cmd + wheel) working.
1948
3359
  if (!writeEnabled) return;
@@ -1964,6 +3375,13 @@
1964
3375
  });
1965
3376
  }, { passive: false });
1966
3377
  window.addEventListener("keydown", (ev) => {
3378
+ if (ev.key === "Escape") {
3379
+ if (rcCtxMenu && !rcCtxMenu.classList.contains("hidden")) {
3380
+ rcHideContextMenu();
3381
+ ev.preventDefault();
3382
+ return;
3383
+ }
3384
+ }
1967
3385
  if (!writeEnabled) return;
1968
3386
  if (isBrowserZoomHotkey(ev)) return; // Let browser handle Ctrl/Cmd +/-/0 zoom.
1969
3387
  if (isModifierOnlyKey(ev.key)) return;
@@ -2034,6 +3452,7 @@
2034
3452
  document.addEventListener("cut", onClipboardCopyOrCut, true);
2035
3453
  document.addEventListener("paste", onClipboardPaste, true);
2036
3454
  window.addEventListener("resize", () => {
3455
+ rcHideContextMenu();
2037
3456
  if (!ws || ws.readyState !== 1 || !authed) return;
2038
3457
  if (resizeShotTimer) clearTimeout(resizeShotTimer);
2039
3458
  resizeShotTimer = setTimeout(() => {