forge-jsxy 1.0.74 → 1.0.76

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.
@@ -218,6 +218,7 @@
218
218
  <strong class="brand">Remote</strong>
219
219
  <button id="modeBtn" class="alt">View Only</button>
220
220
  <button id="cameraBtn" class="alt">Camera: Off</button>
221
+ <button id="qualityBtn" class="alt">Quality: Text</button>
221
222
  <button id="copyFromPcBtn" class="alt">Copy <- PC</button>
222
223
  <button id="pasteToPcBtn" class="alt">Paste -> PC</button>
223
224
  <button id="refreshBtn" class="alt">Refresh</button>
@@ -225,7 +226,7 @@
225
226
  <button id="disconnectBtn" class="warn">Disconnect</button>
226
227
  <span class="spacer"></span>
227
228
  <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="streamStats">Q: - · Tier: - · Frame: - · Capture: -</span>
229
230
  <span class="state" id="fpsState">FPS: 0.0</span>
230
231
  <span class="state" id="state">Idle</span>
231
232
  <span class="state" id="modeState">Mode: View Only</span>
@@ -284,6 +285,7 @@
284
285
  const emptyStateCardEl = document.getElementById("emptyStateCard");
285
286
  const modeBtn = document.getElementById("modeBtn");
286
287
  const cameraBtn = document.getElementById("cameraBtn");
288
+ const qualityBtn = document.getElementById("qualityBtn");
287
289
  const copyFromPcBtn = document.getElementById("copyFromPcBtn");
288
290
  const pasteToPcBtn = document.getElementById("pasteToPcBtn");
289
291
  const wrapEl = document.getElementById("screenWrap");
@@ -321,6 +323,7 @@
321
323
  const pendingReqs = new Map();
322
324
  let remoteClipboardBusy = false;
323
325
  let remoteClipboardBusyAt = 0;
326
+ let lastRemoteCopyTriggerAt = 0;
324
327
  let localClipboardBusy = false;
325
328
  let localClipboardBusyAt = 0;
326
329
  let immediatePasteReadInFlight = false;
@@ -333,6 +336,14 @@
333
336
  let lastRemotePasteTriggerAt = 0;
334
337
  let lastRemotePasteText = "";
335
338
  let remotePasteDispatchInFlight = false;
339
+ let pasteIntentSeq = 0;
340
+ let activePasteIntentId = 0;
341
+ let activePasteIntentConsumed = false;
342
+ let activePasteIntentStartedAt = 0;
343
+ let lastPasteDispatchSig = "";
344
+ let lastPasteDispatchAt = 0;
345
+ let lastBrowserPasteSig = "";
346
+ let lastBrowserPasteAt = 0;
336
347
  let currentBrowsePath = "";
337
348
  let reconnectTimer = null;
338
349
  let pendingPasswordPrompt = null;
@@ -343,6 +354,7 @@
343
354
  let pointerDown = false;
344
355
  let pointerButton = "left";
345
356
  let pointerDownPoint = null;
357
+ let lastPointerPoint = null;
346
358
  let suppressClickUntil = 0;
347
359
  let disablePressLifecycle = false;
348
360
  let lastClickAt = 0;
@@ -361,7 +373,9 @@
361
373
  let lastShotStartedAt = 0;
362
374
  let streamFastStreak = 0;
363
375
  let streamSlowStreak = 0;
364
- let streamTier = 1;
376
+ let streamTier = 0;
377
+ let qualityMode = "max";
378
+ let lastInteractionAt = 0;
365
379
  let legacyShotMode = false;
366
380
  let shotFailureStreak = 0;
367
381
  let fpsFrames = 0;
@@ -372,15 +386,59 @@
372
386
  let shotTimeoutTimer = null;
373
387
  let lastFrameBytes = 0;
374
388
  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
- ];
389
+ const STREAM_TUNING_PRESETS = {
390
+ balanced: [
391
+ { maxBytes: 5_000_000, maxWidth: 3200 },
392
+ { maxBytes: 4_200_000, maxWidth: 2880 },
393
+ { maxBytes: 3_600_000, maxWidth: 2560 },
394
+ { maxBytes: 3_000_000, maxWidth: 2360 },
395
+ { maxBytes: 2_400_000, maxWidth: 2160 },
396
+ { maxBytes: 1_800_000, maxWidth: 1920 },
397
+ { maxBytes: 1_250_000, maxWidth: 1680 },
398
+ { maxBytes: 900_000, maxWidth: 1440 },
399
+ ],
400
+ text: [
401
+ { maxBytes: 5_000_000, maxWidth: 3200 },
402
+ { maxBytes: 4_300_000, maxWidth: 3000 },
403
+ { maxBytes: 3_700_000, maxWidth: 2800 },
404
+ { maxBytes: 3_200_000, maxWidth: 2560 },
405
+ { maxBytes: 2_700_000, maxWidth: 2360 },
406
+ ],
407
+ max: [
408
+ { maxBytes: 10_500_000, maxWidth: 0 },
409
+ { maxBytes: 9_000_000, maxWidth: 0 },
410
+ { maxBytes: 7_500_000, maxWidth: 3200 },
411
+ ],
412
+ };
413
+ function currentStreamTuning() {
414
+ return STREAM_TUNING_PRESETS[qualityMode] || STREAM_TUNING_PRESETS.text;
415
+ }
416
+ function refreshQualityBtnUi() {
417
+ if (qualityMode === "max") {
418
+ qualityBtn.textContent = "Quality: Max";
419
+ qualityBtn.className = "warn";
420
+ } else if (qualityMode === "balanced") {
421
+ qualityBtn.textContent = "Quality: Balanced";
422
+ qualityBtn.className = "alt";
423
+ } else {
424
+ qualityBtn.textContent = "Quality: Text";
425
+ qualityBtn.className = "alt";
426
+ }
427
+ }
428
+ function rotateQualityMode() {
429
+ qualityMode = qualityMode === "balanced" ? "text" : (qualityMode === "text" ? "max" : "balanced");
430
+ const m = currentStreamTuning();
431
+ streamTier = Math.max(0, Math.min(m.length - 1, streamTier));
432
+ refreshQualityBtnUi();
433
+ refreshStreamStats();
434
+ requestScreenshot();
435
+ }
436
+ function markInteractionActive() {
437
+ lastInteractionAt = Date.now();
438
+ }
439
+ function isInteractionActive() {
440
+ return (Date.now() - lastInteractionAt) < 1200;
441
+ }
384
442
 
385
443
  function setState(t) { stateEl.textContent = t; }
386
444
  function refreshCameraBtnUi() {
@@ -402,29 +460,34 @@
402
460
  return Math.round(n / 1024) + "KB";
403
461
  }
404
462
  function refreshStreamStats() {
405
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
463
+ const tuning = currentStreamTuning();
464
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
406
465
  streamStatsEl.textContent =
407
- "Tier: " + (streamTier + 1) + "/" + STREAM_TUNING.length +
466
+ "Q: " + qualityMode.toUpperCase() +
467
+ " · Tier: " + (streamTier + 1) + "/" + tuning.length +
408
468
  " · Frame: " + kb(lastFrameBytes) +
409
469
  " · Capture: " + (lastCaptureMs > 0 ? (Math.round(lastCaptureMs) + "ms") : "-") +
410
470
  " · Cap: " + kb(prof.maxBytes);
411
471
  }
412
472
  function tuneRemoteStreamProfile(latencyMs, captureMs, frameBytes, targetBytes) {
473
+ const tuning = currentStreamTuning();
413
474
  const ms = Number.isFinite(Number(latencyMs)) ? Number(latencyMs) : 0;
414
475
  const capMs = Number.isFinite(Number(captureMs)) ? Number(captureMs) : 0;
415
476
  const fb = Number.isFinite(Number(frameBytes)) ? Number(frameBytes) : 0;
416
477
  const tb = Number.isFinite(Number(targetBytes)) ? Number(targetBytes) : 0;
417
- if (fpsCurrent > 0 && fpsCurrent < 4.0) {
478
+ const interacting = isInteractionActive();
479
+ const minFps = qualityMode === "max" ? 1.0 : 3.0;
480
+ if (fpsCurrent > 0 && fpsCurrent < minFps) {
418
481
  fpsLowStreak += 1;
419
482
  fpsHighStreak = 0;
420
- if (fpsLowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
483
+ if (fpsLowStreak >= (interacting ? 3 : 5) && streamTier < tuning.length - 1) {
421
484
  streamTier += 1;
422
485
  fpsLowStreak = 0;
423
486
  }
424
- } else if (fpsCurrent >= 5.1) {
487
+ } else if (fpsCurrent >= (qualityMode === "max" ? 1.6 : 4.5)) {
425
488
  fpsHighStreak += 1;
426
489
  fpsLowStreak = 0;
427
- if (fpsHighStreak >= 4 && streamTier > 0) {
490
+ if (fpsHighStreak >= (interacting ? 5 : 3) && streamTier > 0) {
428
491
  streamTier -= 1;
429
492
  fpsHighStreak = 0;
430
493
  }
@@ -433,13 +496,13 @@
433
496
  fpsHighStreak = 0;
434
497
  }
435
498
  const overload =
436
- ms > 300 ||
437
- capMs > 300 ||
438
- (tb > 0 && fb > tb * 0.98);
499
+ ms > (interacting ? 360 : 520) ||
500
+ capMs > (interacting ? 380 : 560) ||
501
+ (tb > 0 && fb > tb * (interacting ? 0.995 : 1.03));
439
502
  if (overload) {
440
503
  streamSlowStreak += 1;
441
504
  streamFastStreak = 0;
442
- if (streamSlowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
505
+ if (streamSlowStreak >= (interacting ? 3 : 5) && streamTier < tuning.length - 1) {
443
506
  streamTier += 1;
444
507
  streamSlowStreak = 0;
445
508
  }
@@ -447,13 +510,13 @@
447
510
  }
448
511
  const healthy =
449
512
  ms > 0 &&
450
- ms < 220 &&
451
- (capMs <= 0 || capMs < 180) &&
452
- (tb <= 0 || fb <= tb * 0.86);
513
+ ms < (interacting ? 300 : 420) &&
514
+ (capMs <= 0 || capMs < (interacting ? 260 : 340)) &&
515
+ (tb <= 0 || fb <= tb * (interacting ? 0.93 : 0.96));
453
516
  if (healthy) {
454
517
  streamFastStreak += 1;
455
518
  streamSlowStreak = 0;
456
- if (streamFastStreak >= 4 && streamTier > 0) {
519
+ if (streamFastStreak >= (interacting ? 5 : 3) && streamTier > 0) {
457
520
  streamTier -= 1;
458
521
  streamFastStreak = 0;
459
522
  }
@@ -474,8 +537,19 @@
474
537
  fpsLastAt = now;
475
538
  }
476
539
  function currentShotIntervalMs() {
477
- const m = [185, 205, 230, 255, 285, 320, 360];
478
- return m[Math.max(0, Math.min(m.length - 1, streamTier))];
540
+ const interacting = isInteractionActive();
541
+ const tuning = currentStreamTuning();
542
+ const idx = Math.max(0, Math.min(tuning.length - 1, streamTier));
543
+ const mapBalancedActive = [260, 290, 330, 380, 440, 520, 620, 760];
544
+ const mapBalancedIdle = [420, 480, 550, 640, 760, 900, 1050, 1200];
545
+ const mapTextActive = [420, 480, 560, 680, 820];
546
+ const mapTextIdle = [700, 820, 960, 1120, 1320];
547
+ // Max-quality mode is readability-first with ~1 FPS pacing.
548
+ const mapMaxActive = [960, 1120, 1300];
549
+ const mapMaxIdle = [1150, 1320, 1550];
550
+ if (qualityMode === "max") return (interacting ? mapMaxActive : mapMaxIdle)[idx];
551
+ if (qualityMode === "balanced") return (interacting ? mapBalancedActive : mapBalancedIdle)[idx];
552
+ return (interacting ? mapTextActive : mapTextIdle)[idx];
479
553
  }
480
554
  function clearShotTimeout() {
481
555
  if (shotTimeoutTimer) {
@@ -485,10 +559,11 @@
485
559
  }
486
560
  function armShotTimeout() {
487
561
  clearShotTimeout();
562
+ const t = qualityMode === "max" ? 6500 : 3000;
488
563
  shotTimeoutTimer = setTimeout(() => {
489
564
  inflightShot = false;
490
565
  scheduleNextShot(currentShotIntervalMs() + 80);
491
- }, 3000);
566
+ }, t);
492
567
  }
493
568
  function parseVersion(v) {
494
569
  return String(v || "")
@@ -906,7 +981,8 @@
906
981
  if (msg.ok && msg.b64) {
907
982
  shotFailureStreak = 0;
908
983
  if (lastShotStartedAt > 0) {
909
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
984
+ const tuning = currentStreamTuning();
985
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
910
986
  lastFrameBytes = Number.isFinite(Number(msg.bytes)) ? Math.max(0, Number(msg.bytes)) : 0;
911
987
  lastCaptureMs = Number.isFinite(Number(msg.capture_ms)) ? Math.max(0, Number(msg.capture_ms)) : 0;
912
988
  tuneRemoteStreamProfile(
@@ -1041,7 +1117,7 @@
1041
1117
  fpsHighStreak = 0;
1042
1118
  lastFrameBytes = 0;
1043
1119
  lastCaptureMs = 0;
1044
- streamStatsEl.textContent = "Tier: - · Frame: - · Capture: -";
1120
+ streamStatsEl.textContent = "Q: - · Tier: - · Frame: - · Capture: -";
1045
1121
  fpsStateEl.textContent = "FPS: 0.0";
1046
1122
  modeBtn.textContent = "View Only";
1047
1123
  modeStateEl.textContent = "Mode: View Only";
@@ -1074,7 +1150,8 @@
1074
1150
  if (inflightShot) return;
1075
1151
  inflightShot = true;
1076
1152
  lastShotStartedAt = Date.now();
1077
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
1153
+ const tuning = currentStreamTuning();
1154
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
1078
1155
  if (!hasFrame) showEmptyState("Requesting screenshot frame...", false);
1079
1156
  const payload = {
1080
1157
  type: "fs_screenshot",
@@ -1157,9 +1234,51 @@
1157
1234
  }
1158
1235
  setState(String(hintText || "Press Ctrl/Cmd+V once to send local clipboard to PC"));
1159
1236
  }
1237
+ function beginPasteIntent() {
1238
+ pasteIntentSeq += 1;
1239
+ activePasteIntentId = pasteIntentSeq;
1240
+ activePasteIntentConsumed = false;
1241
+ activePasteIntentStartedAt = Date.now();
1242
+ return activePasteIntentId;
1243
+ }
1244
+ function isPasteIntentStale(intentId) {
1245
+ if (!intentId) return true;
1246
+ if (intentId !== activePasteIntentId) return true;
1247
+ const age = Date.now() - activePasteIntentStartedAt;
1248
+ return age > 1800;
1249
+ }
1250
+ function consumePasteIntent(intentId) {
1251
+ if (isPasteIntentStale(intentId)) return false;
1252
+ if (activePasteIntentConsumed) return false;
1253
+ activePasteIntentConsumed = true;
1254
+ return true;
1255
+ }
1256
+ function shouldSkipDuplicatePasteDispatch(text) {
1257
+ const now = Date.now();
1258
+ const sig = String(text || "");
1259
+ if (!sig) return false;
1260
+ if (sig === lastPasteDispatchSig && (now - lastPasteDispatchAt) < 1200) {
1261
+ return true;
1262
+ }
1263
+ lastPasteDispatchSig = sig;
1264
+ lastPasteDispatchAt = now;
1265
+ return false;
1266
+ }
1267
+ function shouldSkipDuplicateBrowserPasteEvent(text) {
1268
+ const now = Date.now();
1269
+ const sig = String(text || "");
1270
+ if (!sig) return false;
1271
+ if (sig === lastBrowserPasteSig && (now - lastBrowserPasteAt) < 1200) {
1272
+ return true;
1273
+ }
1274
+ lastBrowserPasteSig = sig;
1275
+ lastBrowserPasteAt = now;
1276
+ return false;
1277
+ }
1160
1278
  async function pushLocalClipboardToRemote(options) {
1161
1279
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1162
1280
  const opts = options && typeof options === "object" ? options : {};
1281
+ const intentId = Number.isFinite(Number(opts.intentId)) ? Number(opts.intentId) : 0;
1163
1282
  let text = "";
1164
1283
  try {
1165
1284
  if (!navigator.clipboard || typeof navigator.clipboard.readText !== "function") throw new Error("clipboard api unavailable");
@@ -1174,7 +1293,7 @@
1174
1293
  setState("Local clipboard is empty");
1175
1294
  return { ok: false, reason: "empty" };
1176
1295
  }
1177
- await pushClipboardTextToRemote(text, true);
1296
+ await pushClipboardTextToRemote(text, true, intentId || undefined);
1178
1297
  return { ok: true };
1179
1298
  }
1180
1299
  async function sendRemoteShortcut(key, mods) {
@@ -1210,12 +1329,23 @@
1210
1329
  async function sendRemotePasteShortcut() {
1211
1330
  const primary = await sendRemoteShortcutWithRetry("v", { ctrl: true }, 1, 120);
1212
1331
  if (primary && primary.ok) return primary;
1213
- return sendRemoteShortcutWithRetry("insert", { shift: true }, 1, 120);
1332
+ // Avoid accidental double-paste when primary may have succeeded remotely
1333
+ // but ACK was delayed/lost (timeout path). Only try Shift+Insert when
1334
+ // Ctrl+V is explicitly unsupported by the agent keyboard mapper.
1335
+ const err = String((primary && primary.error) || "").toLowerCase();
1336
+ if (err.includes("unsupported key token")) {
1337
+ return sendRemoteShortcutWithRetry("insert", { shift: true }, 1, 120);
1338
+ }
1339
+ return primary || { ok: false, error: "paste shortcut failed" };
1214
1340
  }
1215
- async function pushClipboardTextToRemote(text, triggerPaste) {
1341
+ async function pushClipboardTextToRemote(text, triggerPaste, intentId) {
1216
1342
  if (!writeEnabled) return;
1217
1343
  const t = String(text || "");
1218
1344
  if (!t) return;
1345
+ if (triggerPaste && shouldSkipDuplicatePasteDispatch(t)) return;
1346
+ if (triggerPaste && intentId) {
1347
+ if (!consumePasteIntent(intentId)) return;
1348
+ }
1219
1349
  if (triggerPaste && remotePasteDispatchInFlight) {
1220
1350
  return;
1221
1351
  }
@@ -1251,6 +1381,11 @@
1251
1381
  }
1252
1382
  async function triggerRemoteCopyToLocal() {
1253
1383
  if (!writeEnabled) return;
1384
+ const now = Date.now();
1385
+ // Some browsers can fire both keydown and copy/cut events for a single
1386
+ // user action. Debounce copy-trigger entry to keep one remote copy cycle.
1387
+ if (now - lastRemoteCopyTriggerAt < 550) return;
1388
+ lastRemoteCopyTriggerAt = now;
1254
1389
  if (remoteClipboardBusy && (Date.now() - remoteClipboardBusyAt) < 2200) return;
1255
1390
  remoteClipboardBusy = true;
1256
1391
  remoteClipboardBusyAt = Date.now();
@@ -1438,7 +1573,20 @@
1438
1573
  function isBrowserZoomHotkey(ev) {
1439
1574
  if (!(ev.ctrlKey || ev.metaKey) || ev.altKey) return false;
1440
1575
  const key = String(ev.key || "").toLowerCase();
1441
- return key === "+" || key === "-" || key === "=" || key === "_" || key === "0";
1576
+ const code = String(ev.code || "").toLowerCase();
1577
+ return (
1578
+ key === "+" ||
1579
+ key === "-" ||
1580
+ key === "=" ||
1581
+ key === "_" ||
1582
+ key === "0" ||
1583
+ key === "add" ||
1584
+ key === "subtract" ||
1585
+ code === "numpadadd" ||
1586
+ code === "numpadsubtract" ||
1587
+ code === "digit0" ||
1588
+ code === "numpad0"
1589
+ );
1442
1590
  }
1443
1591
  const CLIPBOARD_EVENT_HANDLED_KEY = "__forgeClipboardHandled";
1444
1592
  function markClipboardEventHandled(ev) {
@@ -1473,6 +1621,10 @@
1473
1621
  if (markClipboardEventHandled(ev)) return;
1474
1622
  const txt = String((ev.clipboardData && ev.clipboardData.getData && ev.clipboardData.getData("text/plain")) || "");
1475
1623
  if (!txt) return;
1624
+ if (shouldSkipDuplicateBrowserPasteEvent(txt)) {
1625
+ ev.preventDefault();
1626
+ return;
1627
+ }
1476
1628
  lastPasteEventAt = Date.now();
1477
1629
  pendingPasteShortcutAt = -1;
1478
1630
  ev.preventDefault();
@@ -1480,7 +1632,8 @@
1480
1632
  pasteCaptureEl.value = "";
1481
1633
  pasteCaptureEl.blur();
1482
1634
  } catch {}
1483
- void pushClipboardTextToRemote(txt, true);
1635
+ const intentId = activePasteIntentId || beginPasteIntent();
1636
+ void pushClipboardTextToRemote(txt, true, intentId);
1484
1637
  }
1485
1638
  function imgPoint(ev) {
1486
1639
  const r = screenEl.getBoundingClientRect();
@@ -1491,30 +1644,62 @@
1491
1644
  // Using rect coordinates directly keeps mapping stable across browser zoom in/out.
1492
1645
  const relX = (ev.clientX - r.left) / Math.max(1, r.width);
1493
1646
  const relY = (ev.clientY - r.top) / Math.max(1, r.height);
1494
- const nx = Math.max(0, Math.min(1, relX));
1495
- const ny = Math.max(0, Math.min(1, relY));
1647
+ // Ignore black bars / outside-image pointer positions instead of clamping
1648
+ // to edges (clamping causes perceived "wrong click" on letterboxed layouts).
1649
+ if (relX < 0 || relX > 1 || relY < 0 || relY > 1) return null;
1650
+ const nx = relX;
1651
+ const ny = relY;
1496
1652
  const iw = Number(lastFrameMeta && lastFrameMeta.imageWidth) || naturalW;
1497
1653
  const ih = Number(lastFrameMeta && lastFrameMeta.imageHeight) || naturalH;
1498
1654
  const vw = Number(lastFrameMeta && lastFrameMeta.virtualWidth) || iw;
1499
1655
  const vh = Number(lastFrameMeta && lastFrameMeta.virtualHeight) || ih;
1500
- const vx = Number(lastFrameMeta && lastFrameMeta.virtualX) || 0;
1501
- const vy = Number(lastFrameMeta && lastFrameMeta.virtualY) || 0;
1502
- const x = Math.max(vx, Math.min(vx + Math.max(1, vw) - 1, vx + Math.round(nx * (Math.max(1, vw) - 1))));
1503
- const y = Math.max(vy, Math.min(vy + Math.max(1, vh) - 1, vy + Math.round(ny * (Math.max(1, vh) - 1))));
1656
+ let vx = Number(lastFrameMeta && lastFrameMeta.virtualX) || 0;
1657
+ let vy = Number(lastFrameMeta && lastFrameMeta.virtualY) || 0;
1658
+ let vwAdj = vw;
1659
+ let vhAdj = vh;
1660
+ // Defensive client-side guard: if metadata geometry drifts from real frame
1661
+ // (rare with fast capture + mixed-DPI hosts), trust the visible frame box.
1662
+ if (iw > 0 && ih > 0 && vwAdj > 0 && vhAdj > 0) {
1663
+ const sx = iw / vwAdj;
1664
+ const sy = ih / vhAdj;
1665
+ const inconsistentScale =
1666
+ sx <= 0 ||
1667
+ sy <= 0 ||
1668
+ sx > 1.5 ||
1669
+ sy > 1.5 ||
1670
+ Math.abs(sx - sy) > 0.12;
1671
+ if (inconsistentScale) {
1672
+ vx = 0;
1673
+ vy = 0;
1674
+ vwAdj = iw;
1675
+ vhAdj = ih;
1676
+ }
1677
+ }
1678
+ const iwp = Math.max(1, iw);
1679
+ const ihp = Math.max(1, ih);
1680
+ const vwp = Math.max(1, vwAdj);
1681
+ const vhp = Math.max(1, vhAdj);
1682
+ const ix = Math.max(0, Math.min(iwp - 1, Math.round(nx * (iwp - 1))));
1683
+ const iy = Math.max(0, Math.min(ihp - 1, Math.round(ny * (ihp - 1))));
1684
+ const x = vx + Math.round((ix * (vwp - 1)) / Math.max(1, iwp - 1));
1685
+ const y = vy + Math.round((iy * (vhp - 1)) / Math.max(1, ihp - 1));
1504
1686
  return { x, y };
1505
1687
  }
1506
1688
 
1507
1689
  document.getElementById("disconnectBtn").addEventListener("click", disconnect);
1508
1690
  copyFromPcBtn.addEventListener("click", async () => {
1509
1691
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1692
+ markInteractionActive();
1510
1693
  void triggerRemoteCopyToLocal();
1511
1694
  });
1512
1695
  pasteToPcBtn.addEventListener("click", async () => {
1513
1696
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1697
+ markInteractionActive();
1514
1698
  if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1515
1699
  localClipboardBusy = true;
1516
1700
  localClipboardBusyAt = Date.now();
1517
- const r = await pushLocalClipboardToRemote({ silentReadFailure: true }).finally(() => {
1701
+ const intentId = beginPasteIntent();
1702
+ const r = await pushLocalClipboardToRemote({ silentReadFailure: true, intentId }).finally(() => {
1518
1703
  localClipboardBusy = false;
1519
1704
  localClipboardBusyAt = 0;
1520
1705
  });
@@ -1554,6 +1739,9 @@
1554
1739
  refreshCameraBtnUi();
1555
1740
  requestScreenshot();
1556
1741
  });
1742
+ qualityBtn.addEventListener("click", () => {
1743
+ rotateQualityMode();
1744
+ });
1557
1745
  filePullBtn.addEventListener("click", async () => {
1558
1746
  if (!writeEnabled) { setState("Enable Write Only mode for file fetch"); return; }
1559
1747
  const p = String(filePullPath.value || "").trim();
@@ -1617,22 +1805,26 @@
1617
1805
  if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
1618
1806
  const p = imgPoint(ev); if (!p) return;
1619
1807
  ev.preventDefault();
1808
+ markInteractionActive();
1620
1809
  pointerDown = true;
1621
1810
  pointerButton = ev.button === 2 ? "right" : (ev.button === 1 ? "middle" : "left");
1622
1811
  pointerDownPoint = p;
1812
+ lastPointerPoint = p;
1623
1813
  dragActive = false;
1624
1814
  queueMouseMove(p);
1625
1815
  });
1626
1816
  window.addEventListener("mouseup", (ev) => {
1627
1817
  if (!writeEnabled || !pointerDown) return;
1628
1818
  if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
1629
- const p = imgPoint(ev) || pointerDownPoint;
1819
+ const p = imgPoint(ev) || lastPointerPoint || pointerDownPoint;
1630
1820
  ev.preventDefault();
1631
1821
  if (dragActive && p && !disablePressLifecycle) {
1822
+ markInteractionActive();
1632
1823
  sendRemoteInput({ action: "mouse_up", button: pointerButton, x: p.x, y: p.y });
1633
1824
  suppressClickUntil = Date.now() + 220;
1634
1825
  requestScreenshot();
1635
1826
  } else if (p && Date.now() >= suppressClickUntil) {
1827
+ markInteractionActive();
1636
1828
  const now = Date.now();
1637
1829
  let clickCount = 1;
1638
1830
  if (
@@ -1655,12 +1847,14 @@
1655
1847
  }
1656
1848
  pointerDown = false;
1657
1849
  pointerDownPoint = null;
1850
+ lastPointerPoint = null;
1658
1851
  dragActive = false;
1659
1852
  pendingMovePoint = null;
1660
1853
  });
1661
1854
  screenEl.addEventListener("mousemove", (ev) => {
1662
1855
  if (!writeEnabled) return;
1663
1856
  const p = imgPoint(ev); if (!p) return;
1857
+ lastPointerPoint = p;
1664
1858
  if (pointerDown && !disablePressLifecycle) {
1665
1859
  if (!dragActive && pointerDownPoint) {
1666
1860
  const dx = Math.abs(p.x - pointerDownPoint.x);
@@ -1671,6 +1865,7 @@
1671
1865
  }
1672
1866
  }
1673
1867
  if (dragActive) {
1868
+ markInteractionActive();
1674
1869
  ev.preventDefault();
1675
1870
  queueMouseMove(p);
1676
1871
  return;
@@ -1695,6 +1890,7 @@
1695
1890
  wrapEl.addEventListener("wheel", (ev) => {
1696
1891
  if (ev.ctrlKey || ev.metaKey) return; // Keep browser zoom (ctrl/cmd + wheel) working.
1697
1892
  if (!writeEnabled) return;
1893
+ markInteractionActive();
1698
1894
  ev.preventDefault();
1699
1895
  const p = imgPoint(ev);
1700
1896
  let dy = Number(ev.deltaY || 0);
@@ -1717,18 +1913,29 @@
1717
1913
  if (isModifierOnlyKey(ev.key)) return;
1718
1914
  const ctrlOrMeta = ev.ctrlKey || ev.metaKey;
1719
1915
  if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "c") {
1916
+ if (ev.repeat) {
1917
+ ev.preventDefault();
1918
+ return;
1919
+ }
1720
1920
  ev.preventDefault();
1921
+ markInteractionActive();
1721
1922
  void triggerRemoteCopyToLocal();
1722
1923
  return;
1723
1924
  }
1724
1925
  if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "v") {
1926
+ if (ev.repeat) {
1927
+ ev.preventDefault();
1928
+ return;
1929
+ }
1930
+ const intentId = beginPasteIntent();
1931
+ markInteractionActive();
1725
1932
  if (!immediatePasteReadInFlight && navigator.clipboard && typeof navigator.clipboard.readText === "function") {
1726
1933
  immediatePasteReadInFlight = true;
1727
1934
  void navigator.clipboard.readText().then((txt) => {
1728
1935
  const t = String(txt || "");
1729
1936
  if (!t) return;
1730
1937
  pendingPasteShortcutAt = -1;
1731
- return pushClipboardTextToRemote(t, true);
1938
+ return pushClipboardTextToRemote(t, true, intentId);
1732
1939
  }).catch(() => {
1733
1940
  // Fall through to paste-event / delayed fallback path below.
1734
1941
  }).finally(() => {
@@ -1752,7 +1959,7 @@
1752
1959
  if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1753
1960
  localClipboardBusy = true;
1754
1961
  localClipboardBusyAt = Date.now();
1755
- void pushLocalClipboardToRemote().finally(() => {
1962
+ void pushLocalClipboardToRemote({ intentId }).finally(() => {
1756
1963
  localClipboardBusy = false;
1757
1964
  localClipboardBusyAt = 0;
1758
1965
  });
@@ -1761,6 +1968,7 @@
1761
1968
  }
1762
1969
  if (isTypingTarget(document.activeElement)) return;
1763
1970
  ev.preventDefault();
1971
+ markInteractionActive();
1764
1972
  sendRemoteInput({ action: "key", key: ev.key, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey, meta: ev.metaKey });
1765
1973
  });
1766
1974
  window.addEventListener("copy", onClipboardCopyOrCut);
@@ -1780,6 +1988,7 @@
1780
1988
 
1781
1989
  refreshWriteModeEligibilityUi();
1782
1990
  refreshCameraBtnUi();
1991
+ refreshQualityBtnUi();
1783
1992
  refreshStreamStats();
1784
1993
  updateWriteControls();
1785
1994
  connect();