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