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
|
|
370
|
-
|
|
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) {
|
|
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
|
-
|
|
1051
|
+
scheduleNextShot(60);
|
|
773
1052
|
}
|
|
774
1053
|
function requestScreenshot() {
|
|
775
|
-
if (!ws || ws.readyState !== 1 || !authed
|
|
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({
|
|
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 (
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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>
|