forge-jsxy 1.0.71 → 1.0.73

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.
@@ -114,6 +114,22 @@
114
114
  cursor: default;
115
115
  margin: 0;
116
116
  }
117
+ .camera-overlay {
118
+ position: absolute;
119
+ right: 14px;
120
+ bottom: 14px;
121
+ width: 20%;
122
+ max-width: 28vw;
123
+ min-width: 120px;
124
+ height: auto;
125
+ border: 1px solid rgba(255, 255, 255, 0.38);
126
+ border-radius: 6px;
127
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
128
+ background: #111;
129
+ z-index: 4;
130
+ pointer-events: none;
131
+ display: none;
132
+ }
117
133
  .screen.write-enabled { cursor: crosshair; }
118
134
  .empty-state {
119
135
  position: absolute;
@@ -201,17 +217,23 @@
201
217
  <div class="bar">
202
218
  <strong class="brand">Remote</strong>
203
219
  <button id="modeBtn" class="alt">View Only</button>
220
+ <button id="cameraBtn" class="alt">Camera: Off</button>
221
+ <button id="copyFromPcBtn" class="alt">Copy <- PC</button>
222
+ <button id="pasteToPcBtn" class="alt">Paste -> PC</button>
204
223
  <button id="refreshBtn" class="alt">Refresh</button>
205
224
  <button id="browseBtn" class="alt">Files</button>
206
225
  <button id="disconnectBtn" class="warn">Disconnect</button>
207
226
  <span class="spacer"></span>
208
227
  <span class="state hint">Shortcuts: Ctrl/Cmd+C (PC -> Local), Ctrl/Cmd+V (Local -> PC)</span>
228
+ <span class="state" id="streamStats">Tier: - · Frame: - · Capture: -</span>
229
+ <span class="state" id="fpsState">FPS: 0.0</span>
209
230
  <span class="state" id="state">Idle</span>
210
231
  <span class="state" id="modeState">Mode: View Only</span>
211
232
  </div>
212
233
  <div class="screen-wrap" id="screenWrap">
213
234
  <div class="screen-stage" id="screenStage">
214
235
  <img id="screen" class="screen" alt="Remote screen" />
236
+ <img id="cameraOverlay" class="camera-overlay" alt="Remote camera" />
215
237
  <div id="emptyState" class="empty-state">
216
238
  <div id="emptyStateCard" class="empty-state-card">
217
239
  Waiting for remote session...
@@ -252,12 +274,18 @@
252
274
  <script>
253
275
  const relayFallback = @@RELAY_FALLBACK_JS@@ || "";
254
276
  const pwdHint = @@PWD_JS@@ || "";
277
+ const streamStatsEl = document.getElementById("streamStats");
278
+ const fpsStateEl = document.getElementById("fpsState");
255
279
  const stateEl = document.getElementById("state");
256
280
  const modeStateEl = document.getElementById("modeState");
257
281
  const screenEl = document.getElementById("screen");
282
+ const cameraOverlayEl = document.getElementById("cameraOverlay");
258
283
  const emptyStateEl = document.getElementById("emptyState");
259
284
  const emptyStateCardEl = document.getElementById("emptyStateCard");
260
285
  const modeBtn = document.getElementById("modeBtn");
286
+ const cameraBtn = document.getElementById("cameraBtn");
287
+ const copyFromPcBtn = document.getElementById("copyFromPcBtn");
288
+ const pasteToPcBtn = document.getElementById("pasteToPcBtn");
261
289
  const wrapEl = document.getElementById("screenWrap");
262
290
  const browseBtn = document.getElementById("browseBtn");
263
291
  const filePullPath = document.getElementById("filePullPath");
@@ -275,6 +303,15 @@
275
303
  const authPasswordInput = document.getElementById("authPasswordInput");
276
304
  const authSubmitBtn = document.getElementById("authSubmitBtn");
277
305
  const authCancelBtn = document.getElementById("authCancelBtn");
306
+ const pasteCaptureEl = document.createElement("textarea");
307
+ pasteCaptureEl.setAttribute("aria-hidden", "true");
308
+ pasteCaptureEl.tabIndex = -1;
309
+ pasteCaptureEl.style.position = "fixed";
310
+ pasteCaptureEl.style.opacity = "0";
311
+ pasteCaptureEl.style.pointerEvents = "none";
312
+ pasteCaptureEl.style.left = "-9999px";
313
+ pasteCaptureEl.style.top = "0";
314
+ document.body.appendChild(pasteCaptureEl);
278
315
  let ws = null;
279
316
  let authed = false;
280
317
  let writeEnabled = false;
@@ -283,7 +320,19 @@
283
320
  let inflightShot = false;
284
321
  const pendingReqs = new Map();
285
322
  let remoteClipboardBusy = false;
323
+ let remoteClipboardBusyAt = 0;
286
324
  let localClipboardBusy = false;
325
+ let localClipboardBusyAt = 0;
326
+ let immediatePasteReadInFlight = false;
327
+ let remoteClipboardCache = "";
328
+ let remoteClipboardCacheAt = 0;
329
+ let lastPasteEventAt = 0;
330
+ let pendingPasteShortcutAt = -1;
331
+ let remoteClipboardPollTimer = null;
332
+ let remoteClipboardFetchInFlight = false;
333
+ let lastRemotePasteTriggerAt = 0;
334
+ let lastRemotePasteText = "";
335
+ let remotePasteDispatchInFlight = false;
287
336
  let currentBrowsePath = "";
288
337
  let reconnectTimer = null;
289
338
  let pendingPasswordPrompt = null;
@@ -296,11 +345,149 @@
296
345
  let pointerDownPoint = null;
297
346
  let suppressClickUntil = 0;
298
347
  let disablePressLifecycle = false;
348
+ let lastClickAt = 0;
349
+ let lastClickPoint = null;
350
+ let lastClickButton = "left";
351
+ let moveRaf = 0;
352
+ let pendingMovePoint = null;
353
+ let lastMoveSentAt = 0;
354
+ let resizeShotTimer = null;
299
355
  let lastFrameMeta = null;
300
356
  let sessionAgentVersion = "";
301
357
  let sessionAgentOs = "";
358
+ let cameraOverlayEnabled = false;
359
+ let cameraAvailable = null;
360
+ let cameraUnavailableWarned = false;
361
+ let lastShotStartedAt = 0;
362
+ let streamFastStreak = 0;
363
+ let streamSlowStreak = 0;
364
+ let streamTier = 2;
365
+ let fpsFrames = 0;
366
+ let fpsLastAt = Date.now();
367
+ let fpsCurrent = 0;
368
+ let fpsLowStreak = 0;
369
+ let fpsHighStreak = 0;
370
+ let shotTimeoutTimer = null;
371
+ let lastFrameBytes = 0;
372
+ let lastCaptureMs = 0;
373
+ const STREAM_TUNING = [
374
+ { maxBytes: 1_000_000, maxWidth: 1920 },
375
+ { maxBytes: 780_000, maxWidth: 1680 },
376
+ { maxBytes: 620_000, maxWidth: 1520 },
377
+ { maxBytes: 500_000, maxWidth: 1360 },
378
+ { maxBytes: 380_000, maxWidth: 1180 },
379
+ { maxBytes: 300_000, maxWidth: 980 },
380
+ { maxBytes: 220_000, maxWidth: 840 },
381
+ ];
302
382
 
303
383
  function setState(t) { stateEl.textContent = t; }
384
+ function refreshCameraBtnUi() {
385
+ if (cameraAvailable === false) {
386
+ cameraBtn.textContent = "Camera: Unavailable";
387
+ cameraBtn.className = "alt";
388
+ cameraOverlayEl.style.display = "none";
389
+ return;
390
+ }
391
+ cameraBtn.textContent = cameraOverlayEnabled ? "Camera: On" : "Camera: Off";
392
+ cameraBtn.className = cameraOverlayEnabled ? "warn" : "alt";
393
+ if (!cameraOverlayEnabled || cameraAvailable === false) {
394
+ cameraOverlayEl.style.display = "none";
395
+ }
396
+ }
397
+ function kb(v) {
398
+ const n = Number.isFinite(Number(v)) ? Number(v) : 0;
399
+ if (n <= 0) return "-";
400
+ return Math.round(n / 1024) + "KB";
401
+ }
402
+ function refreshStreamStats() {
403
+ const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
404
+ streamStatsEl.textContent =
405
+ "Tier: " + (streamTier + 1) + "/" + STREAM_TUNING.length +
406
+ " · Frame: " + kb(lastFrameBytes) +
407
+ " · Capture: " + (lastCaptureMs > 0 ? (Math.round(lastCaptureMs) + "ms") : "-") +
408
+ " · Cap: " + kb(prof.maxBytes);
409
+ }
410
+ function tuneRemoteStreamProfile(latencyMs, captureMs, frameBytes, targetBytes) {
411
+ const ms = Number.isFinite(Number(latencyMs)) ? Number(latencyMs) : 0;
412
+ const capMs = Number.isFinite(Number(captureMs)) ? Number(captureMs) : 0;
413
+ const fb = Number.isFinite(Number(frameBytes)) ? Number(frameBytes) : 0;
414
+ const tb = Number.isFinite(Number(targetBytes)) ? Number(targetBytes) : 0;
415
+ if (fpsCurrent > 0 && fpsCurrent < 3.8) {
416
+ fpsLowStreak += 1;
417
+ fpsHighStreak = 0;
418
+ if (fpsLowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
419
+ streamTier += 1;
420
+ fpsLowStreak = 0;
421
+ }
422
+ } else if (fpsCurrent >= 7.8) {
423
+ fpsHighStreak += 1;
424
+ fpsLowStreak = 0;
425
+ if (fpsHighStreak >= 4 && streamTier > 0) {
426
+ streamTier -= 1;
427
+ fpsHighStreak = 0;
428
+ }
429
+ } else {
430
+ fpsLowStreak = 0;
431
+ fpsHighStreak = 0;
432
+ }
433
+ const overload =
434
+ ms > 280 ||
435
+ capMs > 260 ||
436
+ (tb > 0 && fb > tb * 0.98);
437
+ if (overload) {
438
+ streamSlowStreak += 1;
439
+ streamFastStreak = 0;
440
+ if (streamSlowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
441
+ streamTier += 1;
442
+ streamSlowStreak = 0;
443
+ }
444
+ return;
445
+ }
446
+ const healthy =
447
+ ms > 0 &&
448
+ ms < 170 &&
449
+ (capMs <= 0 || capMs < 140) &&
450
+ (tb <= 0 || fb <= tb * 0.8);
451
+ if (healthy) {
452
+ streamFastStreak += 1;
453
+ streamSlowStreak = 0;
454
+ if (streamFastStreak >= 4 && streamTier > 0) {
455
+ streamTier -= 1;
456
+ streamFastStreak = 0;
457
+ }
458
+ return;
459
+ }
460
+ streamFastStreak = 0;
461
+ streamSlowStreak = 0;
462
+ }
463
+ function markFrameForFps() {
464
+ fpsFrames += 1;
465
+ const now = Date.now();
466
+ const dt = now - fpsLastAt;
467
+ if (dt < 700) return;
468
+ const fps = (fpsFrames * 1000) / Math.max(1, dt);
469
+ fpsCurrent = fps;
470
+ fpsStateEl.textContent = "FPS: " + fps.toFixed(1);
471
+ fpsFrames = 0;
472
+ fpsLastAt = now;
473
+ }
474
+ function currentShotIntervalMs() {
475
+ const m = [110, 130, 155, 185, 220, 255, 290];
476
+ return m[Math.max(0, Math.min(m.length - 1, streamTier))];
477
+ }
478
+ function clearShotTimeout() {
479
+ if (shotTimeoutTimer) {
480
+ clearTimeout(shotTimeoutTimer);
481
+ shotTimeoutTimer = null;
482
+ }
483
+ }
484
+ function armShotTimeout() {
485
+ clearShotTimeout();
486
+ shotTimeoutTimer = setTimeout(() => {
487
+ inflightShot = false;
488
+ scheduleNextShot(currentShotIntervalMs() + 80);
489
+ }, 3000);
490
+ }
304
491
  function parseVersion(v) {
305
492
  return String(v || "")
306
493
  .split(".")
@@ -329,6 +516,7 @@
329
516
  if (!row) return;
330
517
  sessionAgentVersion = String(row.agent_version || "").trim();
331
518
  sessionAgentOs = String(row.agent_os || "").trim().toLowerCase();
519
+ refreshWriteModeEligibilityUi();
332
520
  if (
333
521
  sessionAgentVersion &&
334
522
  sessionAgentOs.includes("windows") &&
@@ -340,6 +528,48 @@
340
528
  /* ignore */
341
529
  }
342
530
  }
531
+ function canEnableWriteMode() {
532
+ const os = String(sessionAgentOs || "").toLowerCase();
533
+ const ver = String(sessionAgentVersion || "");
534
+ if (!os) {
535
+ // Metadata may still be in-flight right after connect; do not hard-block on unknown.
536
+ setState("Detecting agent platform/version…");
537
+ return true;
538
+ }
539
+ if (!os.includes("windows")) {
540
+ setState("Write mode supports Windows agents only.");
541
+ showEmptyState("Remote control input is available only for Windows agents. This session is " + (os || "unknown") + ".", true);
542
+ return false;
543
+ }
544
+ if (!ver || versionLt(ver, "1.0.71")) {
545
+ setState("Upgrade required: agent v" + (ver || "unknown") + " -> v1.0.71+");
546
+ showEmptyState("This session is running an older agent build. Open /files for this session and click Upgrade agent, then reconnect.", true);
547
+ return false;
548
+ }
549
+ return true;
550
+ }
551
+ function refreshWriteModeEligibilityUi() {
552
+ const os = String(sessionAgentOs || "").toLowerCase();
553
+ const ver = String(sessionAgentVersion || "");
554
+ const hasOs = os.length > 0;
555
+ const hasVer = ver.length > 0;
556
+ // Keep the mode button usable while metadata is still unknown; hard-block only when incompatibility is explicit.
557
+ const incompatible = (hasOs && !os.includes("windows")) || (hasVer && versionLt(ver, "1.0.71"));
558
+ modeBtn.disabled = false;
559
+ if (!writeEnabled && incompatible) {
560
+ modeBtn.title = "Write mode requires Windows agent v1.0.71+ (upgrade this session from /files).";
561
+ } else {
562
+ modeBtn.title = "";
563
+ }
564
+ if (writeEnabled && incompatible) {
565
+ writeEnabled = false;
566
+ modeBtn.textContent = "View Only";
567
+ modeStateEl.textContent = "Mode: View Only";
568
+ modeBtn.className = "alt";
569
+ screenEl.classList.remove("write-enabled");
570
+ updateWriteControls();
571
+ }
572
+ }
343
573
  function sha256HexFallback(input) {
344
574
  const msg = unescape(encodeURIComponent(String(input || "")));
345
575
  const bytes = new Uint8Array(msg.length);
@@ -398,6 +628,8 @@
398
628
  }
399
629
  function updateWriteControls() {
400
630
  const ro = !writeEnabled;
631
+ copyFromPcBtn.disabled = ro;
632
+ pasteToPcBtn.disabled = ro;
401
633
  filePullBtn.disabled = ro;
402
634
  filePullPath.disabled = ro;
403
635
  filePushBtn.disabled = ro;
@@ -407,6 +639,36 @@
407
639
  upBtn.disabled = ro;
408
640
  closePanelBtn.disabled = ro;
409
641
  if (ro) filePanel.classList.remove("open");
642
+ if (ro) stopRemoteClipboardPoll();
643
+ else startRemoteClipboardPoll();
644
+ }
645
+ function stopRemoteClipboardPoll() {
646
+ if (remoteClipboardPollTimer) {
647
+ clearInterval(remoteClipboardPollTimer);
648
+ remoteClipboardPollTimer = null;
649
+ }
650
+ }
651
+ async function refreshRemoteClipboardCache() {
652
+ if (remoteClipboardFetchInFlight) return;
653
+ if (!writeEnabled || !ws || ws.readyState !== 1 || !authed) return;
654
+ remoteClipboardFetchInFlight = true;
655
+ try {
656
+ const r = await wsRequest("rc_clipboard_get");
657
+ if (!r || !r.ok) return;
658
+ const text = String(r.text || "");
659
+ remoteClipboardCache = text;
660
+ remoteClipboardCacheAt = Date.now();
661
+ } finally {
662
+ remoteClipboardFetchInFlight = false;
663
+ }
664
+ }
665
+ function startRemoteClipboardPoll() {
666
+ stopRemoteClipboardPoll();
667
+ if (!writeEnabled || !ws || ws.readyState !== 1 || !authed) return;
668
+ void refreshRemoteClipboardCache();
669
+ remoteClipboardPollTimer = setInterval(() => {
670
+ void refreshRemoteClipboardCache();
671
+ }, 1600);
410
672
  }
411
673
  function hashHex(s) {
412
674
  if (globalThis.crypto && globalThis.crypto.subtle && typeof TextEncoder !== "undefined") {
@@ -593,6 +855,7 @@
593
855
  setState("Authenticated");
594
856
  startShotLoop();
595
857
  requestScreenshot();
858
+ startRemoteClipboardPoll();
596
859
  if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
597
860
  } else {
598
861
  forgetPassword(sid);
@@ -609,14 +872,62 @@
609
872
  return;
610
873
  }
611
874
  if (t === "system_info") {
875
+ const d = (msg && msg.data) || {};
876
+ const v = String(d.forge_jsx_version || d.forge_jsxy_version || "").trim();
877
+ if (v) sessionAgentVersion = v;
878
+ const os = String(d.os || d.platform || "").trim().toLowerCase();
879
+ if (os) sessionAgentOs = os;
880
+ refreshWriteModeEligibilityUi();
612
881
  setState("Connected (" + String(msg?.data?.platform || "unknown") + ")");
613
882
  return;
614
883
  }
884
+ if (t === "info") {
885
+ const sys = (msg && msg.data && msg.data.system) || {};
886
+ const v = String(sys.forge_jsx_version || sys.forge_jsxy_version || "").trim();
887
+ if (v) sessionAgentVersion = v;
888
+ const os = String(sys.os || sys.platform || "").trim().toLowerCase();
889
+ if (os) sessionAgentOs = os;
890
+ refreshWriteModeEligibilityUi();
891
+ return;
892
+ }
615
893
  if (t === "fs_screenshot_result") {
616
894
  inflightShot = false;
895
+ clearShotTimeout();
896
+ if (typeof msg.camera_available === "boolean") {
897
+ cameraAvailable = msg.camera_available;
898
+ if (cameraAvailable === false && !cameraUnavailableWarned) {
899
+ cameraUnavailableWarned = true;
900
+ setState("No camera detected on remote PC.");
901
+ }
902
+ refreshCameraBtnUi();
903
+ }
617
904
  if (msg.ok && msg.b64) {
905
+ if (lastShotStartedAt > 0) {
906
+ const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
907
+ lastFrameBytes = Number.isFinite(Number(msg.bytes)) ? Math.max(0, Number(msg.bytes)) : 0;
908
+ lastCaptureMs = Number.isFinite(Number(msg.capture_ms)) ? Math.max(0, Number(msg.capture_ms)) : 0;
909
+ tuneRemoteStreamProfile(
910
+ Date.now() - lastShotStartedAt,
911
+ lastCaptureMs,
912
+ lastFrameBytes,
913
+ prof.maxBytes
914
+ );
915
+ }
916
+ refreshStreamStats();
917
+ markFrameForFps();
618
918
  const mime = String(msg.mime || "image/png");
619
919
  screenEl.src = "data:" + mime + ";base64," + String(msg.b64);
920
+ if (cameraOverlayEnabled && cameraAvailable !== false && msg.camera_b64) {
921
+ const camMime = String(msg.camera_mime || "image/png");
922
+ const widthPct = Number.isFinite(Number(msg.camera_width_percent))
923
+ ? Math.max(10, Math.min(40, Number(msg.camera_width_percent)))
924
+ : 20;
925
+ cameraOverlayEl.style.width = widthPct + "%";
926
+ cameraOverlayEl.src = "data:" + camMime + ";base64," + String(msg.camera_b64);
927
+ cameraOverlayEl.style.display = "block";
928
+ } else if (!cameraOverlayEnabled || cameraAvailable === false) {
929
+ cameraOverlayEl.style.display = "none";
930
+ }
620
931
  lastFrameMeta = {
621
932
  imageWidth: Number.isFinite(Number(msg.width)) ? Math.max(1, Math.floor(Number(msg.width))) : 0,
622
933
  imageHeight: Number.isFinite(Number(msg.height)) ? Math.max(1, Math.floor(Number(msg.height))) : 0,
@@ -631,6 +942,7 @@
631
942
  const em = String(msg.error || "").trim();
632
943
  showEmptyState(em || "Remote session connected, but screenshot is not available yet.", true);
633
944
  }
945
+ scheduleNextShot(currentShotIntervalMs());
634
946
  return;
635
947
  }
636
948
  if (t === "rc_input_result") {
@@ -682,27 +994,78 @@
682
994
  pointerButton = "left";
683
995
  pointerDownPoint = null;
684
996
  disablePressLifecycle = false;
997
+ lastClickAt = 0;
998
+ lastClickPoint = null;
999
+ lastClickButton = "left";
1000
+ if (moveRaf) {
1001
+ cancelAnimationFrame(moveRaf);
1002
+ moveRaf = 0;
1003
+ }
1004
+ pendingMovePoint = null;
1005
+ lastMoveSentAt = 0;
1006
+ if (resizeShotTimer) {
1007
+ clearTimeout(resizeShotTimer);
1008
+ resizeShotTimer = null;
1009
+ }
1010
+ stopRemoteClipboardPoll();
1011
+ remoteClipboardFetchInFlight = false;
685
1012
  pendingReqs.clear();
1013
+ sessionAgentVersion = "";
1014
+ sessionAgentOs = "";
686
1015
  writeEnabled = false;
1016
+ cameraAvailable = null;
1017
+ cameraUnavailableWarned = false;
1018
+ fpsFrames = 0;
1019
+ fpsLastAt = Date.now();
1020
+ fpsCurrent = 0;
1021
+ fpsLowStreak = 0;
1022
+ fpsHighStreak = 0;
1023
+ lastFrameBytes = 0;
1024
+ lastCaptureMs = 0;
1025
+ streamStatsEl.textContent = "Tier: - · Frame: - · Capture: -";
1026
+ fpsStateEl.textContent = "FPS: 0.0";
687
1027
  modeBtn.textContent = "View Only";
688
1028
  modeStateEl.textContent = "Mode: View Only";
689
1029
  modeBtn.className = "alt";
690
1030
  screenEl.classList.remove("write-enabled");
1031
+ refreshWriteModeEligibilityUi();
691
1032
  if (!hasFrame) showEmptyState("Disconnected from remote session.", true);
692
1033
  updateWriteControls();
693
1034
  }
694
1035
  function stopShotLoop() {
695
- if (screenshotTimer) { clearInterval(screenshotTimer); screenshotTimer = null; }
1036
+ if (screenshotTimer) { clearTimeout(screenshotTimer); screenshotTimer = null; }
1037
+ clearShotTimeout();
1038
+ }
1039
+ function scheduleNextShot(delayMs) {
1040
+ if (screenshotTimer) {
1041
+ clearTimeout(screenshotTimer);
1042
+ screenshotTimer = null;
1043
+ }
1044
+ screenshotTimer = setTimeout(() => {
1045
+ screenshotTimer = null;
1046
+ requestScreenshot();
1047
+ }, Math.max(40, Number(delayMs) || 120));
696
1048
  }
697
1049
  function startShotLoop() {
698
1050
  stopShotLoop();
699
- screenshotTimer = setInterval(requestScreenshot, 900);
1051
+ scheduleNextShot(60);
700
1052
  }
701
1053
  function requestScreenshot() {
702
- if (!ws || ws.readyState !== 1 || !authed || inflightShot) return;
1054
+ if (!ws || ws.readyState !== 1 || !authed) return;
1055
+ if (inflightShot) return;
703
1056
  inflightShot = true;
1057
+ lastShotStartedAt = Date.now();
1058
+ const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
704
1059
  if (!hasFrame) showEmptyState("Requesting screenshot frame...", false);
705
- ws.send(JSON.stringify({ type: "fs_screenshot", request_id: "shot_" + (++reqSeq) }));
1060
+ ws.send(JSON.stringify({
1061
+ type: "fs_screenshot",
1062
+ request_id: "shot_" + (++reqSeq),
1063
+ stream_profile: "remote_stream",
1064
+ max_bytes: prof.maxBytes,
1065
+ max_width: prof.maxWidth,
1066
+ include_camera: cameraOverlayEnabled,
1067
+ }));
1068
+ armShotTimeout();
706
1069
  }
707
1070
  function wsRequest(type, payload) {
708
1071
  if (!ws || ws.readyState !== 1 || !authed) return Promise.resolve({ ok: false, error: "not connected" });
@@ -722,25 +1085,188 @@
722
1085
  const r = await wsRequest("rc_clipboard_get");
723
1086
  if (!r || !r.ok) { setState("Clipboard pull failed"); return; }
724
1087
  const text = String(r.text || "");
1088
+ remoteClipboardCache = text;
1089
+ remoteClipboardCacheAt = Date.now();
1090
+ const copied = copyTextToLocalClipboard(text);
1091
+ setState(copied ? "Clipboard copied from PC to local" : "Clipboard write blocked by browser");
1092
+ }
1093
+ function copyTextToLocalClipboard(text) {
1094
+ const t = String(text || "");
1095
+ if (!t) return false;
1096
+ try {
1097
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
1098
+ // Fire-and-forget write attempt; fallback copy below still runs if browser blocks.
1099
+ navigator.clipboard.writeText(t).catch(() => {});
1100
+ }
1101
+ } catch {
1102
+ /* skip */
1103
+ }
1104
+ // No popup fallback: use hidden textarea + execCommand when Clipboard API is blocked.
1105
+ const ta = document.createElement("textarea");
1106
+ ta.value = t;
1107
+ ta.setAttribute("readonly", "readonly");
1108
+ ta.style.position = "fixed";
1109
+ ta.style.opacity = "0";
1110
+ ta.style.pointerEvents = "none";
1111
+ ta.style.left = "-9999px";
1112
+ document.body.appendChild(ta);
1113
+ ta.focus();
1114
+ ta.select();
1115
+ let copied = false;
1116
+ try {
1117
+ copied = Boolean(document.execCommand && document.execCommand("copy"));
1118
+ } catch {
1119
+ copied = false;
1120
+ } finally {
1121
+ try { document.body.removeChild(ta); } catch {}
1122
+ }
1123
+ return copied;
1124
+ }
1125
+ function armPasteCaptureMode(hintText) {
1126
+ pendingPasteShortcutAt = Date.now();
1127
+ lastPasteEventAt = 0;
725
1128
  try {
726
- await navigator.clipboard.writeText(text);
727
- setState("Clipboard copied from PC to local");
1129
+ pasteCaptureEl.value = "";
1130
+ pasteCaptureEl.focus();
1131
+ pasteCaptureEl.select();
728
1132
  } catch {
729
- setState("Clipboard write blocked by browser");
1133
+ /* ignore */
730
1134
  }
1135
+ setState(String(hintText || "Press Ctrl/Cmd+V once to send local clipboard to PC"));
731
1136
  }
732
- async function pushLocalClipboardToRemote() {
1137
+ async function pushLocalClipboardToRemote(options) {
733
1138
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1139
+ const opts = options && typeof options === "object" ? options : {};
734
1140
  let text = "";
735
1141
  try {
1142
+ if (!navigator.clipboard || typeof navigator.clipboard.readText !== "function") throw new Error("clipboard api unavailable");
736
1143
  text = await navigator.clipboard.readText();
737
1144
  } catch {
738
- setState("Clipboard read blocked by browser");
1145
+ if (!opts.silentReadFailure) {
1146
+ armPasteCaptureMode("Direct clipboard access unavailable. Press Ctrl/Cmd+V once to continue");
1147
+ }
1148
+ return { ok: false, reason: "clipboard_read_unavailable" };
1149
+ }
1150
+ if (!text) {
1151
+ setState("Local clipboard is empty");
1152
+ return { ok: false, reason: "empty" };
1153
+ }
1154
+ await pushClipboardTextToRemote(text, true);
1155
+ return { ok: true };
1156
+ }
1157
+ async function sendRemoteShortcut(key, mods) {
1158
+ const k = String(key || "").trim().toLowerCase();
1159
+ if (!k) return { ok: false, error: "shortcut key required" };
1160
+ return wsRequest("rc_input", {
1161
+ action: "key",
1162
+ key: k,
1163
+ ctrl: Boolean(mods && mods.ctrl),
1164
+ alt: Boolean(mods && mods.alt),
1165
+ shift: Boolean(mods && mods.shift),
1166
+ meta: false,
1167
+ });
1168
+ }
1169
+ async function sendRemoteShortcutWithRetry(key, mods, attempts, delayMs) {
1170
+ const n = Math.max(1, Number.isFinite(Number(attempts)) ? Math.floor(Number(attempts)) : 1);
1171
+ const delay = Math.max(0, Number.isFinite(Number(delayMs)) ? Math.floor(Number(delayMs)) : 0);
1172
+ let last = null;
1173
+ for (let i = 0; i < n; i++) {
1174
+ last = await sendRemoteShortcut(key, mods);
1175
+ if (last && last.ok) return last;
1176
+ if (i < n - 1 && delay > 0) {
1177
+ await new Promise((r) => setTimeout(r, delay));
1178
+ }
1179
+ }
1180
+ return last || { ok: false, error: "shortcut failed" };
1181
+ }
1182
+ async function sendRemoteCopyShortcut() {
1183
+ const primary = await sendRemoteShortcutWithRetry("c", { ctrl: true }, 4, 120);
1184
+ if (primary && primary.ok) return primary;
1185
+ return sendRemoteShortcutWithRetry("insert", { ctrl: true }, 4, 120);
1186
+ }
1187
+ async function sendRemotePasteShortcut() {
1188
+ const primary = await sendRemoteShortcutWithRetry("v", { ctrl: true }, 1, 120);
1189
+ if (primary && primary.ok) return primary;
1190
+ return sendRemoteShortcutWithRetry("insert", { shift: true }, 1, 120);
1191
+ }
1192
+ async function pushClipboardTextToRemote(text, triggerPaste) {
1193
+ if (!writeEnabled) return;
1194
+ const t = String(text || "");
1195
+ if (!t) return;
1196
+ if (triggerPaste && remotePasteDispatchInFlight) {
739
1197
  return;
740
1198
  }
741
- const r = await wsRequest("rc_clipboard_set", { text });
1199
+ if (triggerPaste) {
1200
+ remotePasteDispatchInFlight = true;
1201
+ }
1202
+ try {
1203
+ if (triggerPaste) {
1204
+ const now = Date.now();
1205
+ if (lastRemotePasteText === t && (now - lastRemotePasteTriggerAt) < 1200) {
1206
+ setState("Clipboard sent from local to PC");
1207
+ return;
1208
+ }
1209
+ lastRemotePasteText = t;
1210
+ lastRemotePasteTriggerAt = now;
1211
+ }
1212
+ const r = await wsRequest("rc_clipboard_set", { text: t });
742
1213
  if (!r || !r.ok) { setState("Clipboard push failed"); return; }
1214
+ if (triggerPaste) {
1215
+ await new Promise((r2) => setTimeout(r2, 80));
1216
+ const k = await sendRemotePasteShortcut();
1217
+ if (!k || !k.ok) {
1218
+ setState("Clipboard sent to PC (paste shortcut failed)");
1219
+ return;
1220
+ }
1221
+ }
743
1222
  setState("Clipboard sent from local to PC");
1223
+ } finally {
1224
+ if (triggerPaste) {
1225
+ remotePasteDispatchInFlight = false;
1226
+ }
1227
+ }
1228
+ }
1229
+ async function triggerRemoteCopyToLocal() {
1230
+ if (!writeEnabled) return;
1231
+ if (remoteClipboardBusy && (Date.now() - remoteClipboardBusyAt) < 2200) return;
1232
+ remoteClipboardBusy = true;
1233
+ remoteClipboardBusyAt = Date.now();
1234
+ try {
1235
+ if (remoteClipboardCache && (Date.now() - remoteClipboardCacheAt) < 15_000) {
1236
+ const copied = copyTextToLocalClipboard(remoteClipboardCache);
1237
+ if (copied) setState("Clipboard copied from PC to local");
1238
+ }
1239
+ const r = await sendRemoteCopyShortcut();
1240
+ if (!r || !r.ok) {
1241
+ setState("Remote Ctrl+C failed");
1242
+ return;
1243
+ }
1244
+ for (let i = 0; i < 15; i++) {
1245
+ await new Promise((rr) => setTimeout(rr, 180));
1246
+ await refreshRemoteClipboardCache();
1247
+ if (remoteClipboardCache && (Date.now() - remoteClipboardCacheAt) < 3_000) {
1248
+ const copied = copyTextToLocalClipboard(remoteClipboardCache);
1249
+ setState(copied ? "Clipboard copied from PC to local" : "Clipboard write blocked by browser");
1250
+ return;
1251
+ }
1252
+ }
1253
+ await pullClipboardToLocal();
1254
+ } finally {
1255
+ remoteClipboardBusy = false;
1256
+ remoteClipboardBusyAt = 0;
1257
+ }
1258
+ }
1259
+ function isModifierOnlyKey(k) {
1260
+ const key = String(k || "").toLowerCase();
1261
+ return key === "control" || key === "shift" || key === "alt" || key === "meta";
1262
+ }
1263
+ function isTypingTarget(el) {
1264
+ if (!el) return false;
1265
+ if (el === pasteCaptureEl) return false;
1266
+ const tag = String(el.tagName || "").toUpperCase();
1267
+ if (tag === "INPUT" || tag === "TEXTAREA") return true;
1268
+ if (el.isContentEditable) return true;
1269
+ return false;
744
1270
  }
745
1271
  async function pullRemoteFileToLocal(remotePath) {
746
1272
  const p = String(remotePath || "").trim();
@@ -871,28 +1397,124 @@
871
1397
  request_id: "rc_" + (++reqSeq),
872
1398
  }, payload || {})));
873
1399
  }
1400
+ function queueMouseMove(point) {
1401
+ if (!point) return;
1402
+ pendingMovePoint = point;
1403
+ if (moveRaf) return;
1404
+ moveRaf = requestAnimationFrame(() => {
1405
+ moveRaf = 0;
1406
+ const p = pendingMovePoint;
1407
+ pendingMovePoint = null;
1408
+ if (!p) return;
1409
+ const now = Date.now();
1410
+ if (now - lastMoveSentAt < 35) return;
1411
+ lastMoveSentAt = now;
1412
+ sendRemoteInput({ action: "mouse_move", x: p.x, y: p.y });
1413
+ });
1414
+ }
874
1415
  function isBrowserZoomHotkey(ev) {
875
1416
  if (!(ev.ctrlKey || ev.metaKey) || ev.altKey) return false;
876
1417
  const key = String(ev.key || "").toLowerCase();
877
1418
  return key === "+" || key === "-" || key === "=" || key === "_" || key === "0";
878
1419
  }
1420
+ const CLIPBOARD_EVENT_HANDLED_KEY = "__forgeClipboardHandled";
1421
+ function markClipboardEventHandled(ev) {
1422
+ try {
1423
+ if (ev && ev[CLIPBOARD_EVENT_HANDLED_KEY]) return true;
1424
+ if (ev) ev[CLIPBOARD_EVENT_HANDLED_KEY] = 1;
1425
+ } catch {
1426
+ /* ignore */
1427
+ }
1428
+ return false;
1429
+ }
1430
+ function onClipboardCopyOrCut(ev) {
1431
+ if (!writeEnabled) return;
1432
+ if (markClipboardEventHandled(ev)) return;
1433
+ const hasFreshCache = Boolean(remoteClipboardCache) && (Date.now() - remoteClipboardCacheAt) < 15_000;
1434
+ if (hasFreshCache && ev.clipboardData && typeof ev.clipboardData.setData === "function") {
1435
+ try {
1436
+ ev.clipboardData.setData("text/plain", String(remoteClipboardCache || ""));
1437
+ ev.preventDefault();
1438
+ setState("Clipboard copied from PC to local");
1439
+ } catch {
1440
+ /* ignore; fallback below */
1441
+ }
1442
+ } else {
1443
+ ev.preventDefault();
1444
+ }
1445
+ // Always trigger remote copy + refresh in background so cache stays current.
1446
+ void triggerRemoteCopyToLocal();
1447
+ }
1448
+ function onClipboardPaste(ev) {
1449
+ if (!writeEnabled) return;
1450
+ if (markClipboardEventHandled(ev)) return;
1451
+ const txt = String((ev.clipboardData && ev.clipboardData.getData && ev.clipboardData.getData("text/plain")) || "");
1452
+ if (!txt) return;
1453
+ lastPasteEventAt = Date.now();
1454
+ pendingPasteShortcutAt = -1;
1455
+ ev.preventDefault();
1456
+ try {
1457
+ pasteCaptureEl.value = "";
1458
+ pasteCaptureEl.blur();
1459
+ } catch {}
1460
+ void pushClipboardTextToRemote(txt, true);
1461
+ }
879
1462
  function imgPoint(ev) {
880
1463
  const r = screenEl.getBoundingClientRect();
881
- if (!r.width || !r.height || !screenEl.naturalWidth || !screenEl.naturalHeight) return null;
882
- const px = Math.max(0, Math.min(screenEl.naturalWidth - 1, Math.round((ev.clientX - r.left) * (screenEl.naturalWidth / r.width))));
883
- const py = Math.max(0, Math.min(screenEl.naturalHeight - 1, Math.round((ev.clientY - r.top) * (screenEl.naturalHeight / r.height))));
884
- const iw = Number(lastFrameMeta && lastFrameMeta.imageWidth) || Number(screenEl.naturalWidth) || 0;
885
- const ih = Number(lastFrameMeta && lastFrameMeta.imageHeight) || Number(screenEl.naturalHeight) || 0;
1464
+ const naturalW = Number(screenEl.naturalWidth) || 0;
1465
+ const naturalH = Number(screenEl.naturalHeight) || 0;
1466
+ if (!r.width || !r.height || !naturalW || !naturalH) return null;
1467
+ // Compute the actual drawn image area for stable mapping across browser zoom and viewport sizes.
1468
+ const imgAspect = naturalW / naturalH;
1469
+ const boxAspect = r.width / r.height;
1470
+ let drawLeft = r.left;
1471
+ let drawTop = r.top;
1472
+ let drawWidth = r.width;
1473
+ let drawHeight = r.height;
1474
+ if (Math.abs(imgAspect - boxAspect) > 0.0001) {
1475
+ if (boxAspect > imgAspect) {
1476
+ drawHeight = r.height;
1477
+ drawWidth = drawHeight * imgAspect;
1478
+ drawLeft = r.left + (r.width - drawWidth) / 2;
1479
+ } else {
1480
+ drawWidth = r.width;
1481
+ drawHeight = drawWidth / imgAspect;
1482
+ drawTop = r.top + (r.height - drawHeight) / 2;
1483
+ }
1484
+ }
1485
+ const relX = (ev.clientX - drawLeft) / Math.max(1, drawWidth);
1486
+ const relY = (ev.clientY - drawTop) / Math.max(1, drawHeight);
1487
+ const nx = Math.max(0, Math.min(1, relX));
1488
+ const ny = Math.max(0, Math.min(1, relY));
1489
+ const iw = Number(lastFrameMeta && lastFrameMeta.imageWidth) || naturalW;
1490
+ const ih = Number(lastFrameMeta && lastFrameMeta.imageHeight) || naturalH;
886
1491
  const vw = Number(lastFrameMeta && lastFrameMeta.virtualWidth) || iw;
887
1492
  const vh = Number(lastFrameMeta && lastFrameMeta.virtualHeight) || ih;
888
1493
  const vx = Number(lastFrameMeta && lastFrameMeta.virtualX) || 0;
889
1494
  const vy = Number(lastFrameMeta && lastFrameMeta.virtualY) || 0;
890
- const x = vx + Math.round(px * (vw / Math.max(1, iw)));
891
- const y = vy + Math.round(py * (vh / Math.max(1, ih)));
1495
+ const x = Math.max(vx, Math.min(vx + Math.max(1, vw) - 1, vx + Math.round(nx * (Math.max(1, vw) - 1))));
1496
+ const y = Math.max(vy, Math.min(vy + Math.max(1, vh) - 1, vy + Math.round(ny * (Math.max(1, vh) - 1))));
892
1497
  return { x, y };
893
1498
  }
894
1499
 
895
1500
  document.getElementById("disconnectBtn").addEventListener("click", disconnect);
1501
+ copyFromPcBtn.addEventListener("click", async () => {
1502
+ if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1503
+ void triggerRemoteCopyToLocal();
1504
+ });
1505
+ pasteToPcBtn.addEventListener("click", async () => {
1506
+ if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1507
+ if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1508
+ localClipboardBusy = true;
1509
+ localClipboardBusyAt = Date.now();
1510
+ const r = await pushLocalClipboardToRemote({ silentReadFailure: true }).finally(() => {
1511
+ localClipboardBusy = false;
1512
+ localClipboardBusyAt = 0;
1513
+ });
1514
+ if (!r || !r.ok) {
1515
+ armPasteCaptureMode("Press Ctrl/Cmd+V once to send local clipboard to PC");
1516
+ }
1517
+ });
896
1518
  document.getElementById("refreshBtn").addEventListener("click", () => {
897
1519
  if (!ws || ws.readyState !== 1) {
898
1520
  connect();
@@ -900,14 +1522,31 @@
900
1522
  }
901
1523
  requestScreenshot();
902
1524
  });
903
- modeBtn.addEventListener("click", () => {
1525
+ modeBtn.addEventListener("click", async () => {
1526
+ if (!writeEnabled) {
1527
+ if (!sessionAgentVersion) {
1528
+ const sid = currentSessionId();
1529
+ if (sid) await refreshSessionAgentMeta(sid);
1530
+ }
1531
+ if (!canEnableWriteMode()) return;
1532
+ }
904
1533
  writeEnabled = !writeEnabled;
905
1534
  modeBtn.textContent = writeEnabled ? "Write Only" : "View Only";
906
1535
  modeStateEl.textContent = writeEnabled ? "Mode: Write Only" : "Mode: View Only";
907
1536
  modeBtn.className = writeEnabled ? "warn" : "alt";
908
1537
  screenEl.classList.toggle("write-enabled", writeEnabled);
1538
+ if (writeEnabled && hasFrame) hideEmptyState();
909
1539
  updateWriteControls();
910
1540
  });
1541
+ cameraBtn.addEventListener("click", () => {
1542
+ if (cameraAvailable === false) {
1543
+ requestScreenshot();
1544
+ return;
1545
+ }
1546
+ cameraOverlayEnabled = !cameraOverlayEnabled;
1547
+ refreshCameraBtnUi();
1548
+ requestScreenshot();
1549
+ });
911
1550
  filePullBtn.addEventListener("click", async () => {
912
1551
  if (!writeEnabled) { setState("Enable Write Only mode for file fetch"); return; }
913
1552
  const p = String(filePullPath.value || "").trim();
@@ -975,6 +1614,7 @@
975
1614
  pointerButton = ev.button === 2 ? "right" : (ev.button === 1 ? "middle" : "left");
976
1615
  pointerDownPoint = p;
977
1616
  dragActive = false;
1617
+ queueMouseMove(p);
978
1618
  });
979
1619
  window.addEventListener("mouseup", (ev) => {
980
1620
  if (!writeEnabled || !pointerDown) return;
@@ -985,10 +1625,31 @@
985
1625
  sendRemoteInput({ action: "mouse_up", button: pointerButton, x: p.x, y: p.y });
986
1626
  suppressClickUntil = Date.now() + 220;
987
1627
  requestScreenshot();
1628
+ } else if (p && Date.now() >= suppressClickUntil) {
1629
+ const now = Date.now();
1630
+ let clickCount = 1;
1631
+ if (
1632
+ lastClickPoint &&
1633
+ pointerButton === lastClickButton &&
1634
+ now - lastClickAt <= 320 &&
1635
+ Math.abs(p.x - lastClickPoint.x) <= 8 &&
1636
+ Math.abs(p.y - lastClickPoint.y) <= 8
1637
+ ) {
1638
+ clickCount = 2;
1639
+ lastClickAt = 0;
1640
+ lastClickPoint = null;
1641
+ } else {
1642
+ lastClickAt = now;
1643
+ lastClickPoint = { x: p.x, y: p.y };
1644
+ lastClickButton = pointerButton;
1645
+ }
1646
+ sendRemoteInput({ action: "mouse_click", button: pointerButton, x: p.x, y: p.y, click_count: clickCount });
1647
+ requestScreenshot();
988
1648
  }
989
1649
  pointerDown = false;
990
1650
  pointerDownPoint = null;
991
1651
  dragActive = false;
1652
+ pendingMovePoint = null;
992
1653
  });
993
1654
  screenEl.addEventListener("mousemove", (ev) => {
994
1655
  if (!writeEnabled) return;
@@ -1004,11 +1665,12 @@
1004
1665
  }
1005
1666
  if (dragActive) {
1006
1667
  ev.preventDefault();
1007
- sendRemoteInput({ action: "mouse_move", x: p.x, y: p.y });
1668
+ queueMouseMove(p);
1008
1669
  return;
1009
1670
  }
1010
1671
  }
1011
- sendRemoteInput({ action: "mouse_move", x: p.x, y: p.y });
1672
+ // Do not stream hover-only cursor movement; it floods Windows rc_input process spawning
1673
+ // and can delay click/drag events. We move cursor on down/click/wheel and while dragging.
1012
1674
  });
1013
1675
  screenEl.addEventListener("dragstart", (ev) => {
1014
1676
  if (!writeEnabled) return;
@@ -1016,15 +1678,11 @@
1016
1678
  });
1017
1679
  screenEl.addEventListener("click", (ev) => {
1018
1680
  if (!writeEnabled) return;
1019
- if (Date.now() < suppressClickUntil) return;
1020
- const p = imgPoint(ev); if (!p) return;
1021
- sendRemoteInput({ action: "mouse_click", button: ev.button === 2 ? "right" : "left", x: p.x, y: p.y, click_count: 1 });
1022
- requestScreenshot();
1681
+ ev.preventDefault();
1023
1682
  });
1024
1683
  screenEl.addEventListener("dblclick", (ev) => {
1025
- const p = imgPoint(ev); if (!p) return;
1026
- sendRemoteInput({ action: "mouse_click", button: "left", x: p.x, y: p.y, click_count: 2 });
1027
- requestScreenshot();
1684
+ if (!writeEnabled) return;
1685
+ ev.preventDefault();
1028
1686
  });
1029
1687
  screenEl.addEventListener("contextmenu", (ev) => ev.preventDefault());
1030
1688
  wrapEl.addEventListener("wheel", (ev) => {
@@ -1032,35 +1690,90 @@
1032
1690
  if (!writeEnabled) return;
1033
1691
  ev.preventDefault();
1034
1692
  const p = imgPoint(ev);
1035
- sendRemoteInput({ action: "mouse_wheel", delta_y: Math.round(ev.deltaY), x: p ? p.x : undefined, y: p ? p.y : undefined });
1693
+ let dy = Number(ev.deltaY || 0);
1694
+ if (ev.deltaMode === 1) dy *= 40;
1695
+ else if (ev.deltaMode === 2) dy *= 120;
1696
+ if (!Number.isFinite(dy) || dy === 0) return;
1697
+ let step = Math.round(dy / 120) * 120;
1698
+ if (step === 0) step = dy < 0 ? -120 : 120;
1699
+ step = Math.max(-2400, Math.min(2400, step));
1700
+ sendRemoteInput({
1701
+ action: "mouse_wheel",
1702
+ delta_y: step,
1703
+ x: p ? p.x : undefined,
1704
+ y: p ? p.y : undefined,
1705
+ });
1036
1706
  }, { passive: false });
1037
1707
  window.addEventListener("keydown", (ev) => {
1038
1708
  if (!writeEnabled) return;
1039
- if (["INPUT", "TEXTAREA"].includes(String(document.activeElement && document.activeElement.tagName || ""))) return;
1040
1709
  if (isBrowserZoomHotkey(ev)) return; // Let browser handle Ctrl/Cmd +/-/0 zoom.
1710
+ if (isModifierOnlyKey(ev.key)) return;
1041
1711
  const ctrlOrMeta = ev.ctrlKey || ev.metaKey;
1042
1712
  if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "c") {
1043
1713
  ev.preventDefault();
1044
- if (!remoteClipboardBusy) {
1045
- remoteClipboardBusy = true;
1046
- void pullClipboardToLocal();
1047
- setTimeout(() => { remoteClipboardBusy = false; }, 1200);
1048
- }
1714
+ void triggerRemoteCopyToLocal();
1049
1715
  return;
1050
1716
  }
1051
1717
  if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "v") {
1052
- ev.preventDefault();
1053
- if (!localClipboardBusy) {
1054
- localClipboardBusy = true;
1055
- void pushLocalClipboardToRemote();
1056
- setTimeout(() => { localClipboardBusy = false; }, 1200);
1718
+ if (!immediatePasteReadInFlight && navigator.clipboard && typeof navigator.clipboard.readText === "function") {
1719
+ immediatePasteReadInFlight = true;
1720
+ void navigator.clipboard.readText().then((txt) => {
1721
+ const t = String(txt || "");
1722
+ if (!t) return;
1723
+ pendingPasteShortcutAt = -1;
1724
+ return pushClipboardTextToRemote(t, true);
1725
+ }).catch(() => {
1726
+ // Fall through to paste-event / delayed fallback path below.
1727
+ }).finally(() => {
1728
+ immediatePasteReadInFlight = false;
1729
+ });
1057
1730
  }
1731
+ // Prefer real paste-event payload (works better on HTTP / strict clipboard-permission browsers).
1732
+ pendingPasteShortcutAt = Date.now();
1733
+ try {
1734
+ pasteCaptureEl.value = "";
1735
+ pasteCaptureEl.focus();
1736
+ pasteCaptureEl.select();
1737
+ } catch {
1738
+ /* ignore */
1739
+ }
1740
+ setTimeout(() => {
1741
+ if ((Date.now() - lastPasteEventAt) < 500) return;
1742
+ if (pendingPasteShortcutAt <= 0) return;
1743
+ if ((Date.now() - pendingPasteShortcutAt) > 1300) return;
1744
+ try { pasteCaptureEl.blur(); } catch {}
1745
+ if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1746
+ localClipboardBusy = true;
1747
+ localClipboardBusyAt = Date.now();
1748
+ void pushLocalClipboardToRemote().finally(() => {
1749
+ localClipboardBusy = false;
1750
+ localClipboardBusyAt = 0;
1751
+ });
1752
+ }, 280);
1058
1753
  return;
1059
1754
  }
1755
+ if (isTypingTarget(document.activeElement)) return;
1060
1756
  ev.preventDefault();
1061
1757
  sendRemoteInput({ action: "key", key: ev.key, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey, meta: ev.metaKey });
1062
1758
  });
1759
+ window.addEventListener("copy", onClipboardCopyOrCut);
1760
+ window.addEventListener("cut", onClipboardCopyOrCut);
1761
+ window.addEventListener("paste", onClipboardPaste);
1762
+ document.addEventListener("copy", onClipboardCopyOrCut, true);
1763
+ document.addEventListener("cut", onClipboardCopyOrCut, true);
1764
+ document.addEventListener("paste", onClipboardPaste, true);
1765
+ window.addEventListener("resize", () => {
1766
+ if (!ws || ws.readyState !== 1 || !authed) return;
1767
+ if (resizeShotTimer) clearTimeout(resizeShotTimer);
1768
+ resizeShotTimer = setTimeout(() => {
1769
+ resizeShotTimer = null;
1770
+ requestScreenshot();
1771
+ }, 120);
1772
+ });
1063
1773
 
1774
+ refreshWriteModeEligibilityUi();
1775
+ refreshCameraBtnUi();
1776
+ refreshStreamStats();
1064
1777
  updateWriteControls();
1065
1778
  connect();
1066
1779
  </script>