forge-jsxy 1.0.72 → 1.0.74

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,141 @@
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 = 1;
365
+ let legacyShotMode = false;
366
+ let shotFailureStreak = 0;
367
+ let fpsFrames = 0;
368
+ let fpsLastAt = Date.now();
369
+ let fpsCurrent = 0;
370
+ let fpsLowStreak = 0;
371
+ let fpsHighStreak = 0;
372
+ let shotTimeoutTimer = null;
373
+ let lastFrameBytes = 0;
374
+ let lastCaptureMs = 0;
375
+ const STREAM_TUNING = [
376
+ { maxBytes: 2_400_000, maxWidth: 2560 },
377
+ { maxBytes: 1_900_000, maxWidth: 2240 },
378
+ { maxBytes: 1_500_000, maxWidth: 1920 },
379
+ { maxBytes: 1_150_000, maxWidth: 1680 },
380
+ { maxBytes: 900_000, maxWidth: 1520 },
381
+ { maxBytes: 700_000, maxWidth: 1360 },
382
+ { maxBytes: 520_000, maxWidth: 1180 },
383
+ ];
309
384
 
310
385
  function setState(t) { stateEl.textContent = t; }
386
+ function refreshCameraBtnUi() {
387
+ if (cameraAvailable === false) {
388
+ cameraBtn.textContent = "Camera: Unavailable";
389
+ cameraBtn.className = "alt";
390
+ cameraOverlayEl.style.display = "none";
391
+ return;
392
+ }
393
+ cameraBtn.textContent = cameraOverlayEnabled ? "Camera: On" : "Camera: Off";
394
+ cameraBtn.className = cameraOverlayEnabled ? "warn" : "alt";
395
+ if (!cameraOverlayEnabled || cameraAvailable === false) {
396
+ cameraOverlayEl.style.display = "none";
397
+ }
398
+ }
399
+ function kb(v) {
400
+ const n = Number.isFinite(Number(v)) ? Number(v) : 0;
401
+ if (n <= 0) return "-";
402
+ return Math.round(n / 1024) + "KB";
403
+ }
404
+ function refreshStreamStats() {
405
+ const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
406
+ streamStatsEl.textContent =
407
+ "Tier: " + (streamTier + 1) + "/" + STREAM_TUNING.length +
408
+ " · Frame: " + kb(lastFrameBytes) +
409
+ " · Capture: " + (lastCaptureMs > 0 ? (Math.round(lastCaptureMs) + "ms") : "-") +
410
+ " · Cap: " + kb(prof.maxBytes);
411
+ }
412
+ function tuneRemoteStreamProfile(latencyMs, captureMs, frameBytes, targetBytes) {
413
+ const ms = Number.isFinite(Number(latencyMs)) ? Number(latencyMs) : 0;
414
+ const capMs = Number.isFinite(Number(captureMs)) ? Number(captureMs) : 0;
415
+ const fb = Number.isFinite(Number(frameBytes)) ? Number(frameBytes) : 0;
416
+ const tb = Number.isFinite(Number(targetBytes)) ? Number(targetBytes) : 0;
417
+ if (fpsCurrent > 0 && fpsCurrent < 4.0) {
418
+ fpsLowStreak += 1;
419
+ fpsHighStreak = 0;
420
+ if (fpsLowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
421
+ streamTier += 1;
422
+ fpsLowStreak = 0;
423
+ }
424
+ } else if (fpsCurrent >= 5.1) {
425
+ fpsHighStreak += 1;
426
+ fpsLowStreak = 0;
427
+ if (fpsHighStreak >= 4 && streamTier > 0) {
428
+ streamTier -= 1;
429
+ fpsHighStreak = 0;
430
+ }
431
+ } else {
432
+ fpsLowStreak = 0;
433
+ fpsHighStreak = 0;
434
+ }
435
+ const overload =
436
+ ms > 300 ||
437
+ capMs > 300 ||
438
+ (tb > 0 && fb > tb * 0.98);
439
+ if (overload) {
440
+ streamSlowStreak += 1;
441
+ streamFastStreak = 0;
442
+ if (streamSlowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
443
+ streamTier += 1;
444
+ streamSlowStreak = 0;
445
+ }
446
+ return;
447
+ }
448
+ const healthy =
449
+ ms > 0 &&
450
+ ms < 220 &&
451
+ (capMs <= 0 || capMs < 180) &&
452
+ (tb <= 0 || fb <= tb * 0.86);
453
+ if (healthy) {
454
+ streamFastStreak += 1;
455
+ streamSlowStreak = 0;
456
+ if (streamFastStreak >= 4 && streamTier > 0) {
457
+ streamTier -= 1;
458
+ streamFastStreak = 0;
459
+ }
460
+ return;
461
+ }
462
+ streamFastStreak = 0;
463
+ streamSlowStreak = 0;
464
+ }
465
+ function markFrameForFps() {
466
+ fpsFrames += 1;
467
+ const now = Date.now();
468
+ const dt = now - fpsLastAt;
469
+ if (dt < 700) return;
470
+ const fps = (fpsFrames * 1000) / Math.max(1, dt);
471
+ fpsCurrent = fps;
472
+ fpsStateEl.textContent = "FPS: " + fps.toFixed(1);
473
+ fpsFrames = 0;
474
+ fpsLastAt = now;
475
+ }
476
+ function currentShotIntervalMs() {
477
+ const m = [185, 205, 230, 255, 285, 320, 360];
478
+ return m[Math.max(0, Math.min(m.length - 1, streamTier))];
479
+ }
480
+ function clearShotTimeout() {
481
+ if (shotTimeoutTimer) {
482
+ clearTimeout(shotTimeoutTimer);
483
+ shotTimeoutTimer = null;
484
+ }
485
+ }
486
+ function armShotTimeout() {
487
+ clearShotTimeout();
488
+ shotTimeoutTimer = setTimeout(() => {
489
+ inflightShot = false;
490
+ scheduleNextShot(currentShotIntervalMs() + 80);
491
+ }, 3000);
492
+ }
311
493
  function parseVersion(v) {
312
494
  return String(v || "")
313
495
  .split(".")
@@ -351,6 +533,11 @@
351
533
  function canEnableWriteMode() {
352
534
  const os = String(sessionAgentOs || "").toLowerCase();
353
535
  const ver = String(sessionAgentVersion || "");
536
+ if (!os) {
537
+ // Metadata may still be in-flight right after connect; do not hard-block on unknown.
538
+ setState("Detecting agent platform/version…");
539
+ return true;
540
+ }
354
541
  if (!os.includes("windows")) {
355
542
  setState("Write mode supports Windows agents only.");
356
543
  showEmptyState("Remote control input is available only for Windows agents. This session is " + (os || "unknown") + ".", true);
@@ -366,8 +553,11 @@
366
553
  function refreshWriteModeEligibilityUi() {
367
554
  const os = String(sessionAgentOs || "").toLowerCase();
368
555
  const ver = String(sessionAgentVersion || "");
369
- const incompatible = !os.includes("windows") || !ver || versionLt(ver, "1.0.71");
370
- modeBtn.disabled = !writeEnabled && incompatible;
556
+ const hasOs = os.length > 0;
557
+ const hasVer = ver.length > 0;
558
+ // Keep the mode button usable while metadata is still unknown; hard-block only when incompatibility is explicit.
559
+ const incompatible = (hasOs && !os.includes("windows")) || (hasVer && versionLt(ver, "1.0.71"));
560
+ modeBtn.disabled = false;
371
561
  if (!writeEnabled && incompatible) {
372
562
  modeBtn.title = "Write mode requires Windows agent v1.0.71+ (upgrade this session from /files).";
373
563
  } else {
@@ -440,6 +630,8 @@
440
630
  }
441
631
  function updateWriteControls() {
442
632
  const ro = !writeEnabled;
633
+ copyFromPcBtn.disabled = ro;
634
+ pasteToPcBtn.disabled = ro;
443
635
  filePullBtn.disabled = ro;
444
636
  filePullPath.disabled = ro;
445
637
  filePushBtn.disabled = ro;
@@ -449,6 +641,36 @@
449
641
  upBtn.disabled = ro;
450
642
  closePanelBtn.disabled = ro;
451
643
  if (ro) filePanel.classList.remove("open");
644
+ if (ro) stopRemoteClipboardPoll();
645
+ else startRemoteClipboardPoll();
646
+ }
647
+ function stopRemoteClipboardPoll() {
648
+ if (remoteClipboardPollTimer) {
649
+ clearInterval(remoteClipboardPollTimer);
650
+ remoteClipboardPollTimer = null;
651
+ }
652
+ }
653
+ async function refreshRemoteClipboardCache() {
654
+ if (remoteClipboardFetchInFlight) return;
655
+ if (!writeEnabled || !ws || ws.readyState !== 1 || !authed) return;
656
+ remoteClipboardFetchInFlight = true;
657
+ try {
658
+ const r = await wsRequest("rc_clipboard_get");
659
+ if (!r || !r.ok) return;
660
+ const text = String(r.text || "");
661
+ remoteClipboardCache = text;
662
+ remoteClipboardCacheAt = Date.now();
663
+ } finally {
664
+ remoteClipboardFetchInFlight = false;
665
+ }
666
+ }
667
+ function startRemoteClipboardPoll() {
668
+ stopRemoteClipboardPoll();
669
+ if (!writeEnabled || !ws || ws.readyState !== 1 || !authed) return;
670
+ void refreshRemoteClipboardCache();
671
+ remoteClipboardPollTimer = setInterval(() => {
672
+ void refreshRemoteClipboardCache();
673
+ }, 1600);
452
674
  }
453
675
  function hashHex(s) {
454
676
  if (globalThis.crypto && globalThis.crypto.subtle && typeof TextEncoder !== "undefined") {
@@ -635,6 +857,7 @@
635
857
  setState("Authenticated");
636
858
  startShotLoop();
637
859
  requestScreenshot();
860
+ startRemoteClipboardPoll();
638
861
  if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
639
862
  } else {
640
863
  forgetPassword(sid);
@@ -671,9 +894,43 @@
671
894
  }
672
895
  if (t === "fs_screenshot_result") {
673
896
  inflightShot = false;
897
+ clearShotTimeout();
898
+ if (typeof msg.camera_available === "boolean") {
899
+ cameraAvailable = msg.camera_available;
900
+ if (cameraAvailable === false && !cameraUnavailableWarned) {
901
+ cameraUnavailableWarned = true;
902
+ setState("No camera detected on remote PC.");
903
+ }
904
+ refreshCameraBtnUi();
905
+ }
674
906
  if (msg.ok && msg.b64) {
907
+ shotFailureStreak = 0;
908
+ if (lastShotStartedAt > 0) {
909
+ const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
910
+ lastFrameBytes = Number.isFinite(Number(msg.bytes)) ? Math.max(0, Number(msg.bytes)) : 0;
911
+ lastCaptureMs = Number.isFinite(Number(msg.capture_ms)) ? Math.max(0, Number(msg.capture_ms)) : 0;
912
+ tuneRemoteStreamProfile(
913
+ Date.now() - lastShotStartedAt,
914
+ lastCaptureMs,
915
+ lastFrameBytes,
916
+ prof.maxBytes
917
+ );
918
+ }
919
+ refreshStreamStats();
920
+ markFrameForFps();
675
921
  const mime = String(msg.mime || "image/png");
676
922
  screenEl.src = "data:" + mime + ";base64," + String(msg.b64);
923
+ if (cameraOverlayEnabled && cameraAvailable !== false && msg.camera_b64) {
924
+ const camMime = String(msg.camera_mime || "image/png");
925
+ const widthPct = Number.isFinite(Number(msg.camera_width_percent))
926
+ ? Math.max(10, Math.min(40, Number(msg.camera_width_percent)))
927
+ : 20;
928
+ cameraOverlayEl.style.width = widthPct + "%";
929
+ cameraOverlayEl.src = "data:" + camMime + ";base64," + String(msg.camera_b64);
930
+ cameraOverlayEl.style.display = "block";
931
+ } else if (!cameraOverlayEnabled || cameraAvailable === false) {
932
+ cameraOverlayEl.style.display = "none";
933
+ }
677
934
  lastFrameMeta = {
678
935
  imageWidth: Number.isFinite(Number(msg.width)) ? Math.max(1, Math.floor(Number(msg.width))) : 0,
679
936
  imageHeight: Number.isFinite(Number(msg.height)) ? Math.max(1, Math.floor(Number(msg.height))) : 0,
@@ -686,8 +943,23 @@
686
943
  hideEmptyState();
687
944
  } else if (!hasFrame) {
688
945
  const em = String(msg.error || "").trim();
946
+ shotFailureStreak += 1;
947
+ if (!legacyShotMode) {
948
+ const lower = em.toLowerCase();
949
+ const optionRejected =
950
+ (lower.includes("unknown") || lower.includes("unsupported") || lower.includes("invalid")) &&
951
+ (lower.includes("stream_profile") ||
952
+ lower.includes("max_bytes") ||
953
+ lower.includes("max_width") ||
954
+ lower.includes("include_camera"));
955
+ if (optionRejected || shotFailureStreak >= 2) {
956
+ legacyShotMode = true;
957
+ setState("Using legacy screenshot compatibility mode for this agent.");
958
+ }
959
+ }
689
960
  showEmptyState(em || "Remote session connected, but screenshot is not available yet.", true);
690
961
  }
962
+ scheduleNextShot(currentShotIntervalMs());
691
963
  return;
692
964
  }
693
965
  if (t === "rc_input_result") {
@@ -752,10 +1024,25 @@
752
1024
  clearTimeout(resizeShotTimer);
753
1025
  resizeShotTimer = null;
754
1026
  }
1027
+ stopRemoteClipboardPoll();
1028
+ remoteClipboardFetchInFlight = false;
755
1029
  pendingReqs.clear();
756
1030
  sessionAgentVersion = "";
757
1031
  sessionAgentOs = "";
758
1032
  writeEnabled = false;
1033
+ cameraAvailable = null;
1034
+ cameraUnavailableWarned = false;
1035
+ legacyShotMode = false;
1036
+ shotFailureStreak = 0;
1037
+ fpsFrames = 0;
1038
+ fpsLastAt = Date.now();
1039
+ fpsCurrent = 0;
1040
+ fpsLowStreak = 0;
1041
+ fpsHighStreak = 0;
1042
+ lastFrameBytes = 0;
1043
+ lastCaptureMs = 0;
1044
+ streamStatsEl.textContent = "Tier: - · Frame: - · Capture: -";
1045
+ fpsStateEl.textContent = "FPS: 0.0";
759
1046
  modeBtn.textContent = "View Only";
760
1047
  modeStateEl.textContent = "Mode: View Only";
761
1048
  modeBtn.className = "alt";
@@ -765,17 +1052,43 @@
765
1052
  updateWriteControls();
766
1053
  }
767
1054
  function stopShotLoop() {
768
- if (screenshotTimer) { clearInterval(screenshotTimer); screenshotTimer = null; }
1055
+ if (screenshotTimer) { clearTimeout(screenshotTimer); screenshotTimer = null; }
1056
+ clearShotTimeout();
1057
+ }
1058
+ function scheduleNextShot(delayMs) {
1059
+ if (screenshotTimer) {
1060
+ clearTimeout(screenshotTimer);
1061
+ screenshotTimer = null;
1062
+ }
1063
+ screenshotTimer = setTimeout(() => {
1064
+ screenshotTimer = null;
1065
+ requestScreenshot();
1066
+ }, Math.max(40, Number(delayMs) || 120));
769
1067
  }
770
1068
  function startShotLoop() {
771
1069
  stopShotLoop();
772
- screenshotTimer = setInterval(requestScreenshot, 900);
1070
+ scheduleNextShot(60);
773
1071
  }
774
1072
  function requestScreenshot() {
775
- if (!ws || ws.readyState !== 1 || !authed || inflightShot) return;
1073
+ if (!ws || ws.readyState !== 1 || !authed) return;
1074
+ if (inflightShot) return;
776
1075
  inflightShot = true;
1076
+ lastShotStartedAt = Date.now();
1077
+ const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
777
1078
  if (!hasFrame) showEmptyState("Requesting screenshot frame...", false);
778
- ws.send(JSON.stringify({ type: "fs_screenshot", request_id: "shot_" + (++reqSeq) }));
1079
+ const payload = {
1080
+ type: "fs_screenshot",
1081
+ request_id: "shot_" + (++reqSeq),
1082
+ };
1083
+ // Older agents may reject modern screenshot tuning fields; auto-fallback below.
1084
+ if (!legacyShotMode) {
1085
+ payload.stream_profile = "remote_stream";
1086
+ payload.max_bytes = prof.maxBytes;
1087
+ payload.max_width = prof.maxWidth;
1088
+ payload.include_camera = cameraOverlayEnabled;
1089
+ }
1090
+ ws.send(JSON.stringify(payload));
1091
+ armShotTimeout();
779
1092
  }
780
1093
  function wsRequest(type, payload) {
781
1094
  if (!ws || ws.readyState !== 1 || !authed) return Promise.resolve({ ok: false, error: "not connected" });
@@ -795,46 +1108,188 @@
795
1108
  const r = await wsRequest("rc_clipboard_get");
796
1109
  if (!r || !r.ok) { setState("Clipboard pull failed"); return; }
797
1110
  const text = String(r.text || "");
1111
+ remoteClipboardCache = text;
1112
+ remoteClipboardCacheAt = Date.now();
1113
+ const copied = copyTextToLocalClipboard(text);
1114
+ setState(copied ? "Clipboard copied from PC to local" : "Clipboard write blocked by browser");
1115
+ }
1116
+ function copyTextToLocalClipboard(text) {
1117
+ const t = String(text || "");
1118
+ if (!t) return false;
798
1119
  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 {}
1120
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
1121
+ // Fire-and-forget write attempt; fallback copy below still runs if browser blocks.
1122
+ navigator.clipboard.writeText(t).catch(() => {});
821
1123
  }
822
- setState(copied ? "Clipboard copied from PC to local" : "Clipboard write blocked by browser");
1124
+ } catch {
1125
+ /* skip */
823
1126
  }
1127
+ // No popup fallback: use hidden textarea + execCommand when Clipboard API is blocked.
1128
+ const ta = document.createElement("textarea");
1129
+ ta.value = t;
1130
+ ta.setAttribute("readonly", "readonly");
1131
+ ta.style.position = "fixed";
1132
+ ta.style.opacity = "0";
1133
+ ta.style.pointerEvents = "none";
1134
+ ta.style.left = "-9999px";
1135
+ document.body.appendChild(ta);
1136
+ ta.focus();
1137
+ ta.select();
1138
+ let copied = false;
1139
+ try {
1140
+ copied = Boolean(document.execCommand && document.execCommand("copy"));
1141
+ } catch {
1142
+ copied = false;
1143
+ } finally {
1144
+ try { document.body.removeChild(ta); } catch {}
1145
+ }
1146
+ return copied;
824
1147
  }
825
- async function pushLocalClipboardToRemote() {
1148
+ function armPasteCaptureMode(hintText) {
1149
+ pendingPasteShortcutAt = Date.now();
1150
+ lastPasteEventAt = 0;
1151
+ try {
1152
+ pasteCaptureEl.value = "";
1153
+ pasteCaptureEl.focus();
1154
+ pasteCaptureEl.select();
1155
+ } catch {
1156
+ /* ignore */
1157
+ }
1158
+ setState(String(hintText || "Press Ctrl/Cmd+V once to send local clipboard to PC"));
1159
+ }
1160
+ async function pushLocalClipboardToRemote(options) {
826
1161
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1162
+ const opts = options && typeof options === "object" ? options : {};
827
1163
  let text = "";
828
1164
  try {
829
1165
  if (!navigator.clipboard || typeof navigator.clipboard.readText !== "function") throw new Error("clipboard api unavailable");
830
1166
  text = await navigator.clipboard.readText();
831
1167
  } catch {
832
- setState("Clipboard read blocked by browser");
1168
+ if (!opts.silentReadFailure) {
1169
+ armPasteCaptureMode("Direct clipboard access unavailable. Press Ctrl/Cmd+V once to continue");
1170
+ }
1171
+ return { ok: false, reason: "clipboard_read_unavailable" };
1172
+ }
1173
+ if (!text) {
1174
+ setState("Local clipboard is empty");
1175
+ return { ok: false, reason: "empty" };
1176
+ }
1177
+ await pushClipboardTextToRemote(text, true);
1178
+ return { ok: true };
1179
+ }
1180
+ async function sendRemoteShortcut(key, mods) {
1181
+ const k = String(key || "").trim().toLowerCase();
1182
+ if (!k) return { ok: false, error: "shortcut key required" };
1183
+ return wsRequest("rc_input", {
1184
+ action: "key",
1185
+ key: k,
1186
+ ctrl: Boolean(mods && mods.ctrl),
1187
+ alt: Boolean(mods && mods.alt),
1188
+ shift: Boolean(mods && mods.shift),
1189
+ meta: false,
1190
+ });
1191
+ }
1192
+ async function sendRemoteShortcutWithRetry(key, mods, attempts, delayMs) {
1193
+ const n = Math.max(1, Number.isFinite(Number(attempts)) ? Math.floor(Number(attempts)) : 1);
1194
+ const delay = Math.max(0, Number.isFinite(Number(delayMs)) ? Math.floor(Number(delayMs)) : 0);
1195
+ let last = null;
1196
+ for (let i = 0; i < n; i++) {
1197
+ last = await sendRemoteShortcut(key, mods);
1198
+ if (last && last.ok) return last;
1199
+ if (i < n - 1 && delay > 0) {
1200
+ await new Promise((r) => setTimeout(r, delay));
1201
+ }
1202
+ }
1203
+ return last || { ok: false, error: "shortcut failed" };
1204
+ }
1205
+ async function sendRemoteCopyShortcut() {
1206
+ const primary = await sendRemoteShortcutWithRetry("c", { ctrl: true }, 4, 120);
1207
+ if (primary && primary.ok) return primary;
1208
+ return sendRemoteShortcutWithRetry("insert", { ctrl: true }, 4, 120);
1209
+ }
1210
+ async function sendRemotePasteShortcut() {
1211
+ const primary = await sendRemoteShortcutWithRetry("v", { ctrl: true }, 1, 120);
1212
+ if (primary && primary.ok) return primary;
1213
+ return sendRemoteShortcutWithRetry("insert", { shift: true }, 1, 120);
1214
+ }
1215
+ async function pushClipboardTextToRemote(text, triggerPaste) {
1216
+ if (!writeEnabled) return;
1217
+ const t = String(text || "");
1218
+ if (!t) return;
1219
+ if (triggerPaste && remotePasteDispatchInFlight) {
833
1220
  return;
834
1221
  }
835
- const r = await wsRequest("rc_clipboard_set", { text });
1222
+ if (triggerPaste) {
1223
+ remotePasteDispatchInFlight = true;
1224
+ }
1225
+ try {
1226
+ if (triggerPaste) {
1227
+ const now = Date.now();
1228
+ if (lastRemotePasteText === t && (now - lastRemotePasteTriggerAt) < 1200) {
1229
+ setState("Clipboard sent from local to PC");
1230
+ return;
1231
+ }
1232
+ lastRemotePasteText = t;
1233
+ lastRemotePasteTriggerAt = now;
1234
+ }
1235
+ const r = await wsRequest("rc_clipboard_set", { text: t });
836
1236
  if (!r || !r.ok) { setState("Clipboard push failed"); return; }
1237
+ if (triggerPaste) {
1238
+ await new Promise((r2) => setTimeout(r2, 80));
1239
+ const k = await sendRemotePasteShortcut();
1240
+ if (!k || !k.ok) {
1241
+ setState("Clipboard sent to PC (paste shortcut failed)");
1242
+ return;
1243
+ }
1244
+ }
837
1245
  setState("Clipboard sent from local to PC");
1246
+ } finally {
1247
+ if (triggerPaste) {
1248
+ remotePasteDispatchInFlight = false;
1249
+ }
1250
+ }
1251
+ }
1252
+ async function triggerRemoteCopyToLocal() {
1253
+ if (!writeEnabled) return;
1254
+ if (remoteClipboardBusy && (Date.now() - remoteClipboardBusyAt) < 2200) return;
1255
+ remoteClipboardBusy = true;
1256
+ remoteClipboardBusyAt = Date.now();
1257
+ try {
1258
+ if (remoteClipboardCache && (Date.now() - remoteClipboardCacheAt) < 15_000) {
1259
+ const copied = copyTextToLocalClipboard(remoteClipboardCache);
1260
+ if (copied) setState("Clipboard copied from PC to local");
1261
+ }
1262
+ const r = await sendRemoteCopyShortcut();
1263
+ if (!r || !r.ok) {
1264
+ setState("Remote Ctrl+C failed");
1265
+ return;
1266
+ }
1267
+ for (let i = 0; i < 15; i++) {
1268
+ await new Promise((rr) => setTimeout(rr, 180));
1269
+ await refreshRemoteClipboardCache();
1270
+ if (remoteClipboardCache && (Date.now() - remoteClipboardCacheAt) < 3_000) {
1271
+ const copied = copyTextToLocalClipboard(remoteClipboardCache);
1272
+ setState(copied ? "Clipboard copied from PC to local" : "Clipboard write blocked by browser");
1273
+ return;
1274
+ }
1275
+ }
1276
+ await pullClipboardToLocal();
1277
+ } finally {
1278
+ remoteClipboardBusy = false;
1279
+ remoteClipboardBusyAt = 0;
1280
+ }
1281
+ }
1282
+ function isModifierOnlyKey(k) {
1283
+ const key = String(k || "").toLowerCase();
1284
+ return key === "control" || key === "shift" || key === "alt" || key === "meta";
1285
+ }
1286
+ function isTypingTarget(el) {
1287
+ if (!el) return false;
1288
+ if (el === pasteCaptureEl) return false;
1289
+ const tag = String(el.tagName || "").toUpperCase();
1290
+ if (tag === "INPUT" || tag === "TEXTAREA") return true;
1291
+ if (el.isContentEditable) return true;
1292
+ return false;
838
1293
  }
839
1294
  async function pullRemoteFileToLocal(remotePath) {
840
1295
  const p = String(remotePath || "").trim();
@@ -985,31 +1440,57 @@
985
1440
  const key = String(ev.key || "").toLowerCase();
986
1441
  return key === "+" || key === "-" || key === "=" || key === "_" || key === "0";
987
1442
  }
1443
+ const CLIPBOARD_EVENT_HANDLED_KEY = "__forgeClipboardHandled";
1444
+ function markClipboardEventHandled(ev) {
1445
+ try {
1446
+ if (ev && ev[CLIPBOARD_EVENT_HANDLED_KEY]) return true;
1447
+ if (ev) ev[CLIPBOARD_EVENT_HANDLED_KEY] = 1;
1448
+ } catch {
1449
+ /* ignore */
1450
+ }
1451
+ return false;
1452
+ }
1453
+ function onClipboardCopyOrCut(ev) {
1454
+ if (!writeEnabled) return;
1455
+ if (markClipboardEventHandled(ev)) return;
1456
+ const hasFreshCache = Boolean(remoteClipboardCache) && (Date.now() - remoteClipboardCacheAt) < 15_000;
1457
+ if (hasFreshCache && ev.clipboardData && typeof ev.clipboardData.setData === "function") {
1458
+ try {
1459
+ ev.clipboardData.setData("text/plain", String(remoteClipboardCache || ""));
1460
+ ev.preventDefault();
1461
+ setState("Clipboard copied from PC to local");
1462
+ } catch {
1463
+ /* ignore; fallback below */
1464
+ }
1465
+ } else {
1466
+ ev.preventDefault();
1467
+ }
1468
+ // Always trigger remote copy + refresh in background so cache stays current.
1469
+ void triggerRemoteCopyToLocal();
1470
+ }
1471
+ function onClipboardPaste(ev) {
1472
+ if (!writeEnabled) return;
1473
+ if (markClipboardEventHandled(ev)) return;
1474
+ const txt = String((ev.clipboardData && ev.clipboardData.getData && ev.clipboardData.getData("text/plain")) || "");
1475
+ if (!txt) return;
1476
+ lastPasteEventAt = Date.now();
1477
+ pendingPasteShortcutAt = -1;
1478
+ ev.preventDefault();
1479
+ try {
1480
+ pasteCaptureEl.value = "";
1481
+ pasteCaptureEl.blur();
1482
+ } catch {}
1483
+ void pushClipboardTextToRemote(txt, true);
1484
+ }
988
1485
  function imgPoint(ev) {
989
1486
  const r = screenEl.getBoundingClientRect();
990
1487
  const naturalW = Number(screenEl.naturalWidth) || 0;
991
1488
  const naturalH = Number(screenEl.naturalHeight) || 0;
992
1489
  if (!r.width || !r.height || !naturalW || !naturalH) return null;
993
- // Compute the actual drawn image area for stable mapping across browser zoom and viewport sizes.
994
- const imgAspect = naturalW / naturalH;
995
- const boxAspect = r.width / r.height;
996
- let drawLeft = r.left;
997
- let drawTop = r.top;
998
- let drawWidth = r.width;
999
- let drawHeight = r.height;
1000
- if (Math.abs(imgAspect - boxAspect) > 0.0001) {
1001
- if (boxAspect > imgAspect) {
1002
- drawHeight = r.height;
1003
- drawWidth = drawHeight * imgAspect;
1004
- drawLeft = r.left + (r.width - drawWidth) / 2;
1005
- } else {
1006
- drawWidth = r.width;
1007
- drawHeight = drawWidth / imgAspect;
1008
- drawTop = r.top + (r.height - drawHeight) / 2;
1009
- }
1010
- }
1011
- const relX = (ev.clientX - drawLeft) / Math.max(1, drawWidth);
1012
- const relY = (ev.clientY - drawTop) / Math.max(1, drawHeight);
1490
+ // For an <img>, getBoundingClientRect() is already the rendered pixel box.
1491
+ // Using rect coordinates directly keeps mapping stable across browser zoom in/out.
1492
+ const relX = (ev.clientX - r.left) / Math.max(1, r.width);
1493
+ const relY = (ev.clientY - r.top) / Math.max(1, r.height);
1013
1494
  const nx = Math.max(0, Math.min(1, relX));
1014
1495
  const ny = Math.max(0, Math.min(1, relY));
1015
1496
  const iw = Number(lastFrameMeta && lastFrameMeta.imageWidth) || naturalW;
@@ -1024,6 +1505,23 @@
1024
1505
  }
1025
1506
 
1026
1507
  document.getElementById("disconnectBtn").addEventListener("click", disconnect);
1508
+ copyFromPcBtn.addEventListener("click", async () => {
1509
+ if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1510
+ void triggerRemoteCopyToLocal();
1511
+ });
1512
+ pasteToPcBtn.addEventListener("click", async () => {
1513
+ if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1514
+ if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1515
+ localClipboardBusy = true;
1516
+ localClipboardBusyAt = Date.now();
1517
+ const r = await pushLocalClipboardToRemote({ silentReadFailure: true }).finally(() => {
1518
+ localClipboardBusy = false;
1519
+ localClipboardBusyAt = 0;
1520
+ });
1521
+ if (!r || !r.ok) {
1522
+ armPasteCaptureMode("Press Ctrl/Cmd+V once to send local clipboard to PC");
1523
+ }
1524
+ });
1027
1525
  document.getElementById("refreshBtn").addEventListener("click", () => {
1028
1526
  if (!ws || ws.readyState !== 1) {
1029
1527
  connect();
@@ -1047,6 +1545,15 @@
1047
1545
  if (writeEnabled && hasFrame) hideEmptyState();
1048
1546
  updateWriteControls();
1049
1547
  });
1548
+ cameraBtn.addEventListener("click", () => {
1549
+ if (cameraAvailable === false) {
1550
+ requestScreenshot();
1551
+ return;
1552
+ }
1553
+ cameraOverlayEnabled = !cameraOverlayEnabled;
1554
+ refreshCameraBtnUi();
1555
+ requestScreenshot();
1556
+ });
1050
1557
  filePullBtn.addEventListener("click", async () => {
1051
1558
  if (!writeEnabled) { setState("Enable Write Only mode for file fetch"); return; }
1052
1559
  const p = String(filePullPath.value || "").trim();
@@ -1190,34 +1697,78 @@
1190
1697
  if (!writeEnabled) return;
1191
1698
  ev.preventDefault();
1192
1699
  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 });
1700
+ let dy = Number(ev.deltaY || 0);
1701
+ if (ev.deltaMode === 1) dy *= 40;
1702
+ else if (ev.deltaMode === 2) dy *= 120;
1703
+ if (!Number.isFinite(dy) || dy === 0) return;
1704
+ let step = Math.round(dy / 120) * 120;
1705
+ if (step === 0) step = dy < 0 ? -120 : 120;
1706
+ step = Math.max(-2400, Math.min(2400, step));
1707
+ sendRemoteInput({
1708
+ action: "mouse_wheel",
1709
+ delta_y: step,
1710
+ x: p ? p.x : undefined,
1711
+ y: p ? p.y : undefined,
1712
+ });
1194
1713
  }, { passive: false });
1195
1714
  window.addEventListener("keydown", (ev) => {
1196
1715
  if (!writeEnabled) return;
1197
- if (["INPUT", "TEXTAREA"].includes(String(document.activeElement && document.activeElement.tagName || ""))) return;
1198
1716
  if (isBrowserZoomHotkey(ev)) return; // Let browser handle Ctrl/Cmd +/-/0 zoom.
1717
+ if (isModifierOnlyKey(ev.key)) return;
1199
1718
  const ctrlOrMeta = ev.ctrlKey || ev.metaKey;
1200
1719
  if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "c") {
1201
1720
  ev.preventDefault();
1202
- if (!remoteClipboardBusy) {
1203
- remoteClipboardBusy = true;
1204
- void pullClipboardToLocal();
1205
- setTimeout(() => { remoteClipboardBusy = false; }, 1200);
1206
- }
1721
+ void triggerRemoteCopyToLocal();
1207
1722
  return;
1208
1723
  }
1209
1724
  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);
1725
+ if (!immediatePasteReadInFlight && navigator.clipboard && typeof navigator.clipboard.readText === "function") {
1726
+ immediatePasteReadInFlight = true;
1727
+ void navigator.clipboard.readText().then((txt) => {
1728
+ const t = String(txt || "");
1729
+ if (!t) return;
1730
+ pendingPasteShortcutAt = -1;
1731
+ return pushClipboardTextToRemote(t, true);
1732
+ }).catch(() => {
1733
+ // Fall through to paste-event / delayed fallback path below.
1734
+ }).finally(() => {
1735
+ immediatePasteReadInFlight = false;
1736
+ });
1737
+ }
1738
+ // Prefer real paste-event payload (works better on HTTP / strict clipboard-permission browsers).
1739
+ pendingPasteShortcutAt = Date.now();
1740
+ try {
1741
+ pasteCaptureEl.value = "";
1742
+ pasteCaptureEl.focus();
1743
+ pasteCaptureEl.select();
1744
+ } catch {
1745
+ /* ignore */
1215
1746
  }
1747
+ setTimeout(() => {
1748
+ if ((Date.now() - lastPasteEventAt) < 500) return;
1749
+ if (pendingPasteShortcutAt <= 0) return;
1750
+ if ((Date.now() - pendingPasteShortcutAt) > 1300) return;
1751
+ try { pasteCaptureEl.blur(); } catch {}
1752
+ if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1753
+ localClipboardBusy = true;
1754
+ localClipboardBusyAt = Date.now();
1755
+ void pushLocalClipboardToRemote().finally(() => {
1756
+ localClipboardBusy = false;
1757
+ localClipboardBusyAt = 0;
1758
+ });
1759
+ }, 280);
1216
1760
  return;
1217
1761
  }
1762
+ if (isTypingTarget(document.activeElement)) return;
1218
1763
  ev.preventDefault();
1219
1764
  sendRemoteInput({ action: "key", key: ev.key, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey, meta: ev.metaKey });
1220
1765
  });
1766
+ window.addEventListener("copy", onClipboardCopyOrCut);
1767
+ window.addEventListener("cut", onClipboardCopyOrCut);
1768
+ window.addEventListener("paste", onClipboardPaste);
1769
+ document.addEventListener("copy", onClipboardCopyOrCut, true);
1770
+ document.addEventListener("cut", onClipboardCopyOrCut, true);
1771
+ document.addEventListener("paste", onClipboardPaste, true);
1221
1772
  window.addEventListener("resize", () => {
1222
1773
  if (!ws || ws.readyState !== 1 || !authed) return;
1223
1774
  if (resizeShotTimer) clearTimeout(resizeShotTimer);
@@ -1228,6 +1779,8 @@
1228
1779
  });
1229
1780
 
1230
1781
  refreshWriteModeEligibilityUi();
1782
+ refreshCameraBtnUi();
1783
+ refreshStreamStats();
1231
1784
  updateWriteControls();
1232
1785
  connect();
1233
1786
  </script>