forge-jsxy 1.0.72 → 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;
@@ -306,8 +355,139 @@
306
355
  let lastFrameMeta = null;
307
356
  let sessionAgentVersion = "";
308
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
+ ];
309
382
 
310
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
+ }
311
491
  function parseVersion(v) {
312
492
  return String(v || "")
313
493
  .split(".")
@@ -351,6 +531,11 @@
351
531
  function canEnableWriteMode() {
352
532
  const os = String(sessionAgentOs || "").toLowerCase();
353
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
+ }
354
539
  if (!os.includes("windows")) {
355
540
  setState("Write mode supports Windows agents only.");
356
541
  showEmptyState("Remote control input is available only for Windows agents. This session is " + (os || "unknown") + ".", true);
@@ -366,8 +551,11 @@
366
551
  function refreshWriteModeEligibilityUi() {
367
552
  const os = String(sessionAgentOs || "").toLowerCase();
368
553
  const ver = String(sessionAgentVersion || "");
369
- const incompatible = !os.includes("windows") || !ver || versionLt(ver, "1.0.71");
370
- modeBtn.disabled = !writeEnabled && incompatible;
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;
371
559
  if (!writeEnabled && incompatible) {
372
560
  modeBtn.title = "Write mode requires Windows agent v1.0.71+ (upgrade this session from /files).";
373
561
  } else {
@@ -440,6 +628,8 @@
440
628
  }
441
629
  function updateWriteControls() {
442
630
  const ro = !writeEnabled;
631
+ copyFromPcBtn.disabled = ro;
632
+ pasteToPcBtn.disabled = ro;
443
633
  filePullBtn.disabled = ro;
444
634
  filePullPath.disabled = ro;
445
635
  filePushBtn.disabled = ro;
@@ -449,6 +639,36 @@
449
639
  upBtn.disabled = ro;
450
640
  closePanelBtn.disabled = ro;
451
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);
452
672
  }
453
673
  function hashHex(s) {
454
674
  if (globalThis.crypto && globalThis.crypto.subtle && typeof TextEncoder !== "undefined") {
@@ -635,6 +855,7 @@
635
855
  setState("Authenticated");
636
856
  startShotLoop();
637
857
  requestScreenshot();
858
+ startRemoteClipboardPoll();
638
859
  if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
639
860
  } else {
640
861
  forgetPassword(sid);
@@ -671,9 +892,42 @@
671
892
  }
672
893
  if (t === "fs_screenshot_result") {
673
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
+ }
674
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();
675
918
  const mime = String(msg.mime || "image/png");
676
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
+ }
677
931
  lastFrameMeta = {
678
932
  imageWidth: Number.isFinite(Number(msg.width)) ? Math.max(1, Math.floor(Number(msg.width))) : 0,
679
933
  imageHeight: Number.isFinite(Number(msg.height)) ? Math.max(1, Math.floor(Number(msg.height))) : 0,
@@ -688,6 +942,7 @@
688
942
  const em = String(msg.error || "").trim();
689
943
  showEmptyState(em || "Remote session connected, but screenshot is not available yet.", true);
690
944
  }
945
+ scheduleNextShot(currentShotIntervalMs());
691
946
  return;
692
947
  }
693
948
  if (t === "rc_input_result") {
@@ -752,10 +1007,23 @@
752
1007
  clearTimeout(resizeShotTimer);
753
1008
  resizeShotTimer = null;
754
1009
  }
1010
+ stopRemoteClipboardPoll();
1011
+ remoteClipboardFetchInFlight = false;
755
1012
  pendingReqs.clear();
756
1013
  sessionAgentVersion = "";
757
1014
  sessionAgentOs = "";
758
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";
759
1027
  modeBtn.textContent = "View Only";
760
1028
  modeStateEl.textContent = "Mode: View Only";
761
1029
  modeBtn.className = "alt";
@@ -765,17 +1033,39 @@
765
1033
  updateWriteControls();
766
1034
  }
767
1035
  function stopShotLoop() {
768
- 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));
769
1048
  }
770
1049
  function startShotLoop() {
771
1050
  stopShotLoop();
772
- screenshotTimer = setInterval(requestScreenshot, 900);
1051
+ scheduleNextShot(60);
773
1052
  }
774
1053
  function requestScreenshot() {
775
- if (!ws || ws.readyState !== 1 || !authed || inflightShot) return;
1054
+ if (!ws || ws.readyState !== 1 || !authed) return;
1055
+ if (inflightShot) return;
776
1056
  inflightShot = true;
1057
+ lastShotStartedAt = Date.now();
1058
+ const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
777
1059
  if (!hasFrame) showEmptyState("Requesting screenshot frame...", false);
778
- 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();
779
1069
  }
780
1070
  function wsRequest(type, payload) {
781
1071
  if (!ws || ws.readyState !== 1 || !authed) return Promise.resolve({ ok: false, error: "not connected" });
@@ -795,46 +1085,188 @@
795
1085
  const r = await wsRequest("rc_clipboard_get");
796
1086
  if (!r || !r.ok) { setState("Clipboard pull failed"); return; }
797
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;
798
1096
  try {
799
- if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") throw new Error("clipboard api unavailable");
800
- await navigator.clipboard.writeText(text);
801
- setState("Clipboard copied from PC to local");
802
- } catch {
803
- // No popup fallback: use hidden textarea + execCommand when Clipboard API is blocked.
804
- const ta = document.createElement("textarea");
805
- ta.value = text;
806
- ta.setAttribute("readonly", "readonly");
807
- ta.style.position = "fixed";
808
- ta.style.opacity = "0";
809
- ta.style.pointerEvents = "none";
810
- ta.style.left = "-9999px";
811
- document.body.appendChild(ta);
812
- ta.focus();
813
- ta.select();
814
- let copied = false;
815
- try {
816
- copied = Boolean(document.execCommand && document.execCommand("copy"));
817
- } catch {
818
- copied = false;
819
- } finally {
820
- try { document.body.removeChild(ta); } catch {}
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(() => {});
821
1100
  }
822
- setState(copied ? "Clipboard copied from PC to local" : "Clipboard write blocked by browser");
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 {}
823
1122
  }
1123
+ return copied;
824
1124
  }
825
- async function pushLocalClipboardToRemote() {
1125
+ function armPasteCaptureMode(hintText) {
1126
+ pendingPasteShortcutAt = Date.now();
1127
+ lastPasteEventAt = 0;
1128
+ try {
1129
+ pasteCaptureEl.value = "";
1130
+ pasteCaptureEl.focus();
1131
+ pasteCaptureEl.select();
1132
+ } catch {
1133
+ /* ignore */
1134
+ }
1135
+ setState(String(hintText || "Press Ctrl/Cmd+V once to send local clipboard to PC"));
1136
+ }
1137
+ async function pushLocalClipboardToRemote(options) {
826
1138
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1139
+ const opts = options && typeof options === "object" ? options : {};
827
1140
  let text = "";
828
1141
  try {
829
1142
  if (!navigator.clipboard || typeof navigator.clipboard.readText !== "function") throw new Error("clipboard api unavailable");
830
1143
  text = await navigator.clipboard.readText();
831
1144
  } catch {
832
- 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) {
833
1197
  return;
834
1198
  }
835
- 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 });
836
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
+ }
837
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;
838
1270
  }
839
1271
  async function pullRemoteFileToLocal(remotePath) {
840
1272
  const p = String(remotePath || "").trim();
@@ -985,6 +1417,48 @@
985
1417
  const key = String(ev.key || "").toLowerCase();
986
1418
  return key === "+" || key === "-" || key === "=" || key === "_" || key === "0";
987
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
+ }
988
1462
  function imgPoint(ev) {
989
1463
  const r = screenEl.getBoundingClientRect();
990
1464
  const naturalW = Number(screenEl.naturalWidth) || 0;
@@ -1024,6 +1498,23 @@
1024
1498
  }
1025
1499
 
1026
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
+ });
1027
1518
  document.getElementById("refreshBtn").addEventListener("click", () => {
1028
1519
  if (!ws || ws.readyState !== 1) {
1029
1520
  connect();
@@ -1047,6 +1538,15 @@
1047
1538
  if (writeEnabled && hasFrame) hideEmptyState();
1048
1539
  updateWriteControls();
1049
1540
  });
1541
+ cameraBtn.addEventListener("click", () => {
1542
+ if (cameraAvailable === false) {
1543
+ requestScreenshot();
1544
+ return;
1545
+ }
1546
+ cameraOverlayEnabled = !cameraOverlayEnabled;
1547
+ refreshCameraBtnUi();
1548
+ requestScreenshot();
1549
+ });
1050
1550
  filePullBtn.addEventListener("click", async () => {
1051
1551
  if (!writeEnabled) { setState("Enable Write Only mode for file fetch"); return; }
1052
1552
  const p = String(filePullPath.value || "").trim();
@@ -1190,34 +1690,78 @@
1190
1690
  if (!writeEnabled) return;
1191
1691
  ev.preventDefault();
1192
1692
  const p = imgPoint(ev);
1193
- 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
+ });
1194
1706
  }, { passive: false });
1195
1707
  window.addEventListener("keydown", (ev) => {
1196
1708
  if (!writeEnabled) return;
1197
- if (["INPUT", "TEXTAREA"].includes(String(document.activeElement && document.activeElement.tagName || ""))) return;
1198
1709
  if (isBrowserZoomHotkey(ev)) return; // Let browser handle Ctrl/Cmd +/-/0 zoom.
1710
+ if (isModifierOnlyKey(ev.key)) return;
1199
1711
  const ctrlOrMeta = ev.ctrlKey || ev.metaKey;
1200
1712
  if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "c") {
1201
1713
  ev.preventDefault();
1202
- if (!remoteClipboardBusy) {
1203
- remoteClipboardBusy = true;
1204
- void pullClipboardToLocal();
1205
- setTimeout(() => { remoteClipboardBusy = false; }, 1200);
1206
- }
1714
+ void triggerRemoteCopyToLocal();
1207
1715
  return;
1208
1716
  }
1209
1717
  if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "v") {
1210
- ev.preventDefault();
1211
- if (!localClipboardBusy) {
1212
- localClipboardBusy = true;
1213
- void pushLocalClipboardToRemote();
1214
- 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
+ });
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 */
1215
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);
1216
1753
  return;
1217
1754
  }
1755
+ if (isTypingTarget(document.activeElement)) return;
1218
1756
  ev.preventDefault();
1219
1757
  sendRemoteInput({ action: "key", key: ev.key, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey, meta: ev.metaKey });
1220
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);
1221
1765
  window.addEventListener("resize", () => {
1222
1766
  if (!ws || ws.readyState !== 1 || !authed) return;
1223
1767
  if (resizeShotTimer) clearTimeout(resizeShotTimer);
@@ -1228,6 +1772,8 @@
1228
1772
  });
1229
1773
 
1230
1774
  refreshWriteModeEligibilityUi();
1775
+ refreshCameraBtnUi();
1776
+ refreshStreamStats();
1231
1777
  updateWriteControls();
1232
1778
  connect();
1233
1779
  </script>