@xbrowser/cli 1.9.8 → 1.9.9

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.
Files changed (39) hide show
  1. package/dist/{anti-bot-MCVM6IFB.js → anti-bot-DR56Y63V.js} +1 -1
  2. package/dist/{browser-GT4BMX6X.js → browser-FFYPFTBV.js} +1 -1
  3. package/dist/{browser-43YXUIML.js → browser-RHWUAMTB.js} +2 -2
  4. package/dist/{browser-SJWPJFN7.js → browser-YCO4GJMZ.js} +2 -2
  5. package/dist/{cdp-driver-WAFYSIIW.js → cdp-driver-2T4P4ZE2.js} +87 -1
  6. package/dist/{cdp-driver-PUZLDSQU.js → cdp-driver-J3YQ4LJV.js} +1 -1
  7. package/dist/chunk-3OD76SUE.js +20 -0
  8. package/dist/chunk-43TEMG4U.js +9 -0
  9. package/dist/{chunk-C3LIGO4V.js → chunk-4452SPFI.js} +906 -16
  10. package/dist/{chunk-VEWTHYDG.js → chunk-5UGR6MUK.js} +906 -16
  11. package/dist/{chunk-ENCEMM7Z.js → chunk-6OZWKXSW.js} +15 -16
  12. package/dist/{chunk-7ZDWM6T2.js → chunk-BNO7OKO4.js} +5 -6
  13. package/dist/{chunk-24SY6PE5.js → chunk-CG3D3CHY.js} +1 -8
  14. package/dist/{chunk-ZJHQPMCY.js → chunk-DWDXEGVK.js} +5 -6
  15. package/dist/{chunk-A4YHJW3Z.js → chunk-GUTBIYFW.js} +101 -16
  16. package/dist/{chunk-DPJNCMLC.js → chunk-INTQPBYF.js} +7 -0
  17. package/dist/{human-interaction-OU7LZ6OZ.js → chunk-MWFHZUIY.js} +6 -7
  18. package/dist/chunk-Q2DFJQGS.js +128 -0
  19. package/dist/{chunk-GM3HWQ5U.js → chunk-RRBXV7KE.js} +87 -1
  20. package/dist/{chunk-PBDID7SE.js → chunk-RZM5CNIY.js} +15 -16
  21. package/dist/{human-interaction-HJTXFVQS.js → chunk-SQHSZENE.js} +2 -2
  22. package/dist/chunk-TZPKFUBT.js +20 -0
  23. package/dist/{chunk-YAUG3NSI.js → chunk-YMUSHPU4.js} +16 -128
  24. package/dist/cli.js +41 -28
  25. package/dist/{daemon-client-4HJENICO.js → daemon-client-GKEPT4NY.js} +15 -3
  26. package/dist/{daemon-client-CMRAWXTN.js → daemon-client-O6BYVRXV.js} +4 -1
  27. package/dist/daemon-main.js +635 -57
  28. package/dist/human-interaction-5AO42MBA.js +8 -0
  29. package/dist/{human-interaction-2DZK4MW7.js → human-interaction-C5OEGXGO.js} +1 -1
  30. package/dist/human-interaction-ISXZTAYY.js +8 -0
  31. package/dist/index.d.ts +167 -2
  32. package/dist/index.js +173 -63
  33. package/dist/recovery-EF33LKRJ.js +77 -0
  34. package/dist/recovery-J2ISVGUL.js +77 -0
  35. package/dist/recovery-ZJVVHP7N.js +76 -0
  36. package/dist/{session-recorder-K3K63Q33.js → session-recorder-H3KEYU26.js} +1 -1
  37. package/dist/{session-recorder-AG6CH6EI.js → session-recorder-QRZMKFVL.js} +1 -1
  38. package/dist/{session-replayer-V4MP6M6I.js → session-replayer-LJUC4TI7.js} +24 -7
  39. package/package.json +1 -1
@@ -2,10 +2,12 @@ import {
2
2
  getPluginLoader
3
3
  } from "./chunk-PIWNMC36.js";
4
4
  import {
5
- getDaemonConfig,
6
- getDaemonProcessStatus,
7
5
  version
8
- } from "./chunk-24SY6PE5.js";
6
+ } from "./chunk-43TEMG4U.js";
7
+ import {
8
+ buildViewerUrl
9
+ } from "./chunk-TZPKFUBT.js";
10
+ import "./chunk-CG3D3CHY.js";
9
11
  import {
10
12
  commandLogStore,
11
13
  networkStore,
@@ -13,7 +15,7 @@ import {
13
15
  } from "./chunk-VEDJ5XSQ.js";
14
16
  import {
15
17
  SessionRecorder
16
- } from "./chunk-VEWTHYDG.js";
18
+ } from "./chunk-5UGR6MUK.js";
17
19
  import {
18
20
  closeEphemeralContext,
19
21
  closeSessionByName,
@@ -29,8 +31,8 @@ import {
29
31
  resolveLaunchOpts,
30
32
  saveSessionDiskMeta,
31
33
  setActivePage
32
- } from "./chunk-ENCEMM7Z.js";
33
- import "./chunk-GM3HWQ5U.js";
34
+ } from "./chunk-6OZWKXSW.js";
35
+ import "./chunk-RRBXV7KE.js";
34
36
  import "./chunk-TNEN6VQ2.js";
35
37
  import {
36
38
  errMsg
@@ -38,10 +40,10 @@ import {
38
40
  import {
39
41
  detectAntiBot,
40
42
  formatDetectionMessage
41
- } from "./chunk-DPJNCMLC.js";
43
+ } from "./chunk-INTQPBYF.js";
42
44
  import {
43
45
  ScreencastCapturer
44
- } from "./chunk-7ZDWM6T2.js";
46
+ } from "./chunk-BNO7OKO4.js";
45
47
  import "./chunk-KFQGP6VL.js";
46
48
 
47
49
  // src/daemon/daemon-main.ts
@@ -5825,23 +5827,11 @@ function describeSelector(strategy, value, name) {
5825
5827
  // src/context.ts
5826
5828
  function attachDetectAntiBot(ctx) {
5827
5829
  ctx.detectAntiBot = async (page, config) => {
5828
- const { detectAntiBot: detectAntiBot2 } = await import("./anti-bot-MCVM6IFB.js");
5830
+ const { detectAntiBot: detectAntiBot2 } = await import("./anti-bot-DR56Y63V.js");
5829
5831
  return detectAntiBot2(page, config);
5830
5832
  };
5831
5833
  }
5832
5834
 
5833
- // src/utils/viewer-url.ts
5834
- function buildViewerUrl(sessionName = "default") {
5835
- try {
5836
- const status = getDaemonProcessStatus();
5837
- if (!status.running) return void 0;
5838
- const port = status.port || getDaemonConfig().basePort;
5839
- return `http://localhost:${port}/preview/${encodeURIComponent(sessionName)}`;
5840
- } catch {
5841
- return void 0;
5842
- }
5843
- }
5844
-
5845
5835
  // src/plugin/login-guard.ts
5846
5836
  async function checkPluginLoginRequired(options) {
5847
5837
  const { site, command, commandName, ctx, page, sessionName } = options;
@@ -6575,7 +6565,7 @@ async function executeCommand(commandName, params, sessionName = "default", extr
6575
6565
  }
6576
6566
  let targetPageOverride = null;
6577
6567
  if (_target && extraOpts?.cdpEndpoint) {
6578
- const { findTargetPage } = await import("./browser-43YXUIML.js");
6568
+ const { findTargetPage } = await import("./browser-RHWUAMTB.js");
6579
6569
  targetPageOverride = await findTargetPage(extraOpts.cdpEndpoint, _target);
6580
6570
  if (!targetPageOverride) {
6581
6571
  return errorResult(`Target "${_target}" not found. Use 'xbrowser targets --cdp ${extraOpts.cdpEndpoint}' to list available pages.`);
@@ -6592,7 +6582,7 @@ async function executeCommand(commandName, params, sessionName = "default", extr
6592
6582
  params = result.data;
6593
6583
  }
6594
6584
  if (command.scope !== "cli" && !process.env.XBROWSER_DAEMON_WORKER) {
6595
- const { forwardExec } = await import("./daemon-client-4HJENICO.js");
6585
+ const { forwardExec } = await import("./daemon-client-GKEPT4NY.js");
6596
6586
  const result = await forwardExec(commandName, params, sessionName, extraOpts?.cdpEndpoint);
6597
6587
  if (result) return result;
6598
6588
  }
@@ -6780,6 +6770,26 @@ async function executeCommand(commandName, params, sessionName = "default", extr
6780
6770
  const end = Date.now();
6781
6771
  const duration = end - start;
6782
6772
  const errorMessage = errMsg(err);
6773
+ if (session?.page && process.env.XBROWSER_RECOVERY && !extraOpts?._recoveryAttempted) {
6774
+ try {
6775
+ const { attemptRecovery } = await import("./recovery-EF33LKRJ.js");
6776
+ const recovery = await attemptRecovery(
6777
+ session.page,
6778
+ sessionName,
6779
+ commandName,
6780
+ errorMessage
6781
+ );
6782
+ if (recovery.recovered) {
6783
+ const retry = await executeCommand(commandName, params, sessionName, {
6784
+ ...extraOpts,
6785
+ skipCleanup: true,
6786
+ _recoveryAttempted: true
6787
+ });
6788
+ return retry;
6789
+ }
6790
+ } catch {
6791
+ }
6792
+ }
6783
6793
  if (session) {
6784
6794
  streamCommandEvent(session.id, {
6785
6795
  sessionId: session.id,
@@ -7612,6 +7622,7 @@ var PlaybackEngine = class _PlaybackEngine {
7612
7622
  };
7613
7623
 
7614
7624
  // src/daemon/rpc-handlers.ts
7625
+ import { TipCollector as TipCollector3 } from "@dyyz1993/xcli-core";
7615
7626
  var activeRecorders = /* @__PURE__ */ new Map();
7616
7627
  var replayResumeResolvers = /* @__PURE__ */ new Map();
7617
7628
  function createRPCHandler() {
@@ -7640,6 +7651,8 @@ function createRPCHandler() {
7640
7651
  // ── Command execution ──
7641
7652
  case "exec":
7642
7653
  return handleExec(params);
7654
+ case "plugin:exec":
7655
+ return handlePluginExec(params);
7643
7656
  case "chain":
7644
7657
  return handleChain(params);
7645
7658
  case "agent:observe":
@@ -7705,10 +7718,116 @@ function createRPCHandler() {
7705
7718
  {
7706
7719
  setPreviewWS(ws) {
7707
7720
  previewWS = ws;
7721
+ },
7722
+ async handleReconnect(sessionId) {
7723
+ if (previewWS) {
7724
+ await previewWS.reconnectSession(sessionId);
7725
+ }
7708
7726
  }
7709
7727
  }
7710
7728
  );
7711
7729
  return handler;
7730
+ async function handlePluginExec(params) {
7731
+ const command = params.command;
7732
+ const cmdParams = params.params || {};
7733
+ const sessionName = params.session || "default";
7734
+ const cdp = params.cdpEndpoint;
7735
+ const dotIdx = command.indexOf(".");
7736
+ if (dotIdx < 0) {
7737
+ return { success: false, data: null, message: `Invalid plugin command format: ${command}` };
7738
+ }
7739
+ const pluginName = command.substring(0, dotIdx);
7740
+ const subCommand = command.substring(dotIdx + 1);
7741
+ const loader = await getPluginLoader();
7742
+ const site = loader.getCore().loader.getSite(pluginName);
7743
+ if (!site) {
7744
+ return { success: false, data: null, message: `Plugin "${pluginName}" not found` };
7745
+ }
7746
+ const cmdEntry = site.getCommand(subCommand);
7747
+ if (!cmdEntry) {
7748
+ return { success: false, data: null, message: `Unknown command "${subCommand}" for plugin "${pluginName}"` };
7749
+ }
7750
+ const needsBrowser = cmdEntry.scope === "page" || cmdEntry.scope === "browser";
7751
+ let session;
7752
+ if (needsBrowser) {
7753
+ session = findSession(sessionName);
7754
+ if (!session) {
7755
+ let endpoint;
7756
+ if (cdp) {
7757
+ try {
7758
+ endpoint = await resolveCDPEndpoint(cdp);
7759
+ } catch {
7760
+ endpoint = cdp;
7761
+ }
7762
+ } else {
7763
+ try {
7764
+ endpoint = await resolveCDPEndpoint("auto");
7765
+ } catch {
7766
+ endpoint = void 0;
7767
+ }
7768
+ }
7769
+ session = await createSession(sessionName, void 0, endpoint ? { cdpEndpoint: endpoint } : {});
7770
+ console.log(`[PLUGIN] Created session "${sessionName}" for plugin "${pluginName}.${subCommand}"`);
7771
+ }
7772
+ }
7773
+ const ctx = {
7774
+ args: [],
7775
+ options: {},
7776
+ cwd: process.cwd(),
7777
+ page: needsBrowser ? session.page : null,
7778
+ browser: needsBrowser ? session.context.browser() : null,
7779
+ browserContext: needsBrowser ? session.context : null,
7780
+ sessionId: needsBrowser ? session.id : "",
7781
+ cdpEndpoint: cdp || (needsBrowser ? session?.cdpEndpoint : void 0),
7782
+ storage: getPluginStorage(pluginName),
7783
+ output: { mode: "text", showTips: true, color: true, emoji: true },
7784
+ error: (msg) => {
7785
+ throw new Error(msg);
7786
+ },
7787
+ config: {},
7788
+ site,
7789
+ cliName: "xbrowser",
7790
+ waitForHuman: async () => {
7791
+ return { solved: false, timedOut: true };
7792
+ },
7793
+ tips: new TipCollector3()
7794
+ };
7795
+ attachDetectAntiBot(ctx);
7796
+ const loginGuard = await checkPluginLoginRequired({
7797
+ site,
7798
+ command: cmdEntry,
7799
+ commandName: subCommand,
7800
+ ctx,
7801
+ page: needsBrowser ? session?.page : null,
7802
+ sessionName
7803
+ });
7804
+ if (!loginGuard.ok) {
7805
+ return { success: false, data: loginGuard.data ?? null, message: loginGuard.message };
7806
+ }
7807
+ try {
7808
+ const result = await cmdEntry.handler(cmdParams, ctx);
7809
+ return result;
7810
+ } catch (err) {
7811
+ const errorMessage = errMsg(err);
7812
+ const { attemptRecovery } = await import("./recovery-EF33LKRJ.js");
7813
+ const recovery = await attemptRecovery(
7814
+ session?.page,
7815
+ sessionName,
7816
+ `${pluginName}.${subCommand}`,
7817
+ errorMessage,
7818
+ previewWS
7819
+ );
7820
+ if (recovery.recovered) {
7821
+ try {
7822
+ const retryResult = await cmdEntry.handler(cmdParams, ctx);
7823
+ return retryResult;
7824
+ } catch (retryErr) {
7825
+ return { success: false, data: null, message: `Retry failed: ${errMsg(retryErr)}` };
7826
+ }
7827
+ }
7828
+ return { success: false, data: null, message: errorMessage };
7829
+ }
7830
+ }
7712
7831
  function registerSessionIfNew(sessionName) {
7713
7832
  if (!previewWS) return;
7714
7833
  const session = findSession(sessionName);
@@ -7721,6 +7840,22 @@ function createRPCHandler() {
7721
7840
  const cdp = params.cdpEndpoint;
7722
7841
  const url = params.url;
7723
7842
  let session;
7843
+ const existing = findSession(name);
7844
+ if (existing) {
7845
+ if (url && existing.page.url() !== url) {
7846
+ await existing.page.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
7847
+ });
7848
+ }
7849
+ saveSessionDiskMeta(name, {
7850
+ id: existing.id,
7851
+ name: existing.name,
7852
+ url: existing.page.url(),
7853
+ createdAt: existing.createdAt,
7854
+ cdpEndpoint: existing.cdpEndpoint
7855
+ });
7856
+ if (previewWS) previewWS.registerSession(existing.name, existing.page);
7857
+ return existing.id;
7858
+ }
7724
7859
  if (cdp) {
7725
7860
  const endpoint = await resolveCDPEndpoint(cdp);
7726
7861
  session = await createSession(name, url, { cdpEndpoint: endpoint });
@@ -8045,7 +8180,23 @@ function createRPCHandler() {
8045
8180
  }
8046
8181
  try {
8047
8182
  const recorder = new SessionRecorder(session.context, session.page, sessionName);
8183
+ if (url) {
8184
+ try {
8185
+ await session.page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
8186
+ } catch {
8187
+ }
8188
+ await new Promise((r) => setTimeout(r, 1500));
8189
+ }
8048
8190
  await recorder.start(url, { stream });
8191
+ if (recorder.injectionFailed) {
8192
+ await recorder.stop();
8193
+ activeRecorders.delete(sessionName);
8194
+ return {
8195
+ ok: false,
8196
+ error: "Action signal script injection failed \u2014 user actions will NOT be recorded. This usually means the page sandbox blocked Runtime.evaluate, or the injected script has a syntax error. Check the daemon log for details.",
8197
+ hint: "Try: 1) reload the target page in Chrome, 2) restart daemon, 3) check daemon log for the specific error."
8198
+ };
8199
+ }
8049
8200
  activeRecorders.set(sessionName, recorder);
8050
8201
  return { ok: true, session: sessionName, startUrl: url || session.page.url() };
8051
8202
  } catch (e) {
@@ -8197,7 +8348,7 @@ function createRPCHandler() {
8197
8348
  if (isNewFormat) {
8198
8349
  try {
8199
8350
  const replayErrors = [];
8200
- const { SessionReplayer } = await import("./session-replayer-V4MP6M6I.js");
8351
+ const { SessionReplayer } = await import("./session-replayer-LJUC4TI7.js");
8201
8352
  const replayer = new SessionReplayer({
8202
8353
  page: session.page,
8203
8354
  stepDelay: slowMo * 500,
@@ -8281,10 +8432,37 @@ import { EventEmitter as EventEmitter2 } from "events";
8281
8432
  // src/stream/frame-processor.ts
8282
8433
  import sharp from "sharp";
8283
8434
  var STATE_CONFIGS = {
8284
- user_interacting: { format: "jpeg", quality: 85, maxFps: 60, scale: 0.8 },
8285
- screen_moving: { format: "jpeg", quality: 85, maxFps: 8, scale: 0.8 },
8286
- static: { format: "jpeg", quality: 95, maxFps: 2, scale: 1 }
8435
+ // Full-screen: aggressive compression for speed
8436
+ // user_interacting: low quality + downscale prioritize FPS over clarity
8437
+ user_interacting: { format: "jpeg", quality: 40, maxFps: 30, scale: 0.6 },
8438
+ // screen_moving: page is animating but user is NOT operating.
8439
+ // Keep quality close to interacting to avoid visible flicker on state change.
8440
+ screen_moving: { format: "jpeg", quality: 45, maxFps: 2, scale: 0.7 },
8441
+ // static: highest quality — user is reading, bandwidth is not a concern
8442
+ static: { format: "jpeg", quality: 95, maxFps: 1, scale: 1 }
8287
8443
  };
8444
+ function adjustConfigForSize(config, frameWidth, frameHeight, fullViewportWidth, fullViewportHeight) {
8445
+ const framePixels = frameWidth * frameHeight;
8446
+ const fullPixels = fullViewportWidth * fullViewportHeight;
8447
+ const ratio = fullPixels > 0 ? framePixels / fullPixels : 1;
8448
+ if (ratio < 0.3) {
8449
+ return {
8450
+ ...config,
8451
+ quality: Math.min(config.quality + 40, 90),
8452
+ // boost quality
8453
+ scale: Math.max(config.scale, 0.9)
8454
+ // don't downscale
8455
+ };
8456
+ }
8457
+ if (ratio < 0.6) {
8458
+ return {
8459
+ ...config,
8460
+ quality: Math.min(config.quality + 20, 80),
8461
+ scale: Math.max(config.scale, 0.7)
8462
+ };
8463
+ }
8464
+ return config;
8465
+ }
8288
8466
  var StreamStateManager = class {
8289
8467
  currentState = "static";
8290
8468
  isUserInteracting = false;
@@ -8293,9 +8471,11 @@ var StreamStateManager = class {
8293
8471
  frameInterval = Infinity;
8294
8472
  onStateChange = null;
8295
8473
  staticTimer = null;
8296
- USER_INTERACTION_TIMEOUT_MS = 1e3;
8474
+ USER_INTERACTION_TIMEOUT_MS = 2e3;
8475
+ // 1s → 2s: linger in interacting longer
8297
8476
  SCREEN_MOVING_THRESHOLD_MS = 1e3;
8298
- STATIC_TIMEOUT_MS = 1500;
8477
+ STATIC_TIMEOUT_MS = 3e3;
8478
+ // 1.5s → 3s: don't rush to static
8299
8479
  setStateChangeCallback(callback) {
8300
8480
  this.onStateChange = callback;
8301
8481
  }
@@ -8404,12 +8584,25 @@ var FrameProcessor = class {
8404
8584
  }
8405
8585
  let processed = sharp(buffer);
8406
8586
  if (cropConfig) {
8407
- processed = processed.extract({
8408
- left: Math.round(cropConfig.x),
8409
- top: Math.round(cropConfig.y),
8410
- width: Math.round(cropConfig.width),
8411
- height: Math.round(cropConfig.height)
8412
- });
8587
+ const meta = await processed.metadata();
8588
+ const actualW = meta.width ?? viewportWidth ?? 1;
8589
+ const actualH = meta.height ?? viewportHeight ?? 1;
8590
+ const refW = viewportWidth ?? actualW;
8591
+ const refH = viewportHeight ?? actualH;
8592
+ const scaleX = actualW / refW;
8593
+ const scaleY = actualH / refH;
8594
+ const cropLeft = Math.round(cropConfig.x * scaleX);
8595
+ const cropTop = Math.round(cropConfig.y * scaleY);
8596
+ const cropW = Math.min(Math.round(cropConfig.width * scaleX), actualW - cropLeft);
8597
+ const cropH = Math.min(Math.round(cropConfig.height * scaleY), actualH - cropTop);
8598
+ if (cropW > 0 && cropH > 0 && cropLeft >= 0 && cropTop >= 0) {
8599
+ processed = processed.extract({
8600
+ left: cropLeft,
8601
+ top: cropTop,
8602
+ width: cropW,
8603
+ height: cropH
8604
+ });
8605
+ }
8413
8606
  }
8414
8607
  if (needsResize) {
8415
8608
  const newWidth = Math.round((viewportWidth ?? 0) * config.scale);
@@ -8558,11 +8751,20 @@ var StreamCoordinator = class {
8558
8751
  const crop = this.sessionCrops.get(sessionId);
8559
8752
  const cropConfig = crop ? crop.box : void 0;
8560
8753
  const effectiveViewport = crop ? { width: crop.box.width, height: crop.box.height } : frameViewport;
8561
- const processedBuffer = await this.frameProcessor.process(
8562
- frameData,
8754
+ const adjustedConfig = adjustConfigForSize(
8563
8755
  config,
8564
8756
  effectiveViewport.width,
8565
8757
  effectiveViewport.height,
8758
+ frameViewport.width,
8759
+ frameViewport.height
8760
+ );
8761
+ const processedBuffer = await this.frameProcessor.process(
8762
+ frameData,
8763
+ adjustedConfig,
8764
+ // Pass the ORIGINAL viewport (not cropped) so crop coordinates
8765
+ // can be scaled correctly from viewport space to frame pixel space.
8766
+ crop ? frameViewport.width : effectiveViewport.width,
8767
+ crop ? frameViewport.height : effectiveViewport.height,
8566
8768
  cropConfig
8567
8769
  );
8568
8770
  const header = Buffer.from(JSON.stringify({
@@ -8734,7 +8936,18 @@ var ElementMonitor = class {
8734
8936
  }
8735
8937
  return results;
8736
8938
  });
8737
- const views = elements.map((e, i) => ({
8939
+ const TOLERANCE_PX = 10;
8940
+ const deduped = [];
8941
+ const isSameSize = (a, b) => Math.abs(a.width - b.width) <= TOLERANCE_PX && Math.abs(a.height - b.height) <= TOLERANCE_PX;
8942
+ const sorted = [...elements].sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height);
8943
+ for (const el of sorted) {
8944
+ const dupe = deduped.some(
8945
+ (d) => isSameSize(d.rect, el.rect) && Math.abs(d.rect.x - el.rect.x) <= TOLERANCE_PX && Math.abs(d.rect.y - el.rect.y) <= TOLERANCE_PX
8946
+ );
8947
+ if (!dupe) deduped.push(el);
8948
+ }
8949
+ deduped.sort((a, b) => b.rect.width * b.rect.height - a.rect.width * a.rect.height);
8950
+ const views = deduped.map((e, i) => ({
8738
8951
  id: "el-" + i + "-" + (e.id || e.tag),
8739
8952
  label: e.id || e.cls || e.tag,
8740
8953
  rect: e.rect
@@ -8793,10 +9006,10 @@ var SessionManager = class extends EventEmitter {
8793
9006
  this.screencasts.set(sessionId, {
8794
9007
  capturer: new ScreencastCapturer({
8795
9008
  interval: options?.interval ?? 100,
8796
- quality: options?.quality ?? 80,
9009
+ quality: options?.quality ?? 40,
8797
9010
  type: options?.type ?? "jpeg",
8798
- width: options?.width ?? 1920,
8799
- height: options?.height ?? 1080
9011
+ width: options?.width ?? 1024,
9012
+ height: options?.height ?? 576
8800
9013
  }),
8801
9014
  page,
8802
9015
  clientCount: 0
@@ -8937,6 +9150,8 @@ var SessionManager = class extends EventEmitter {
8937
9150
  this.resetStaticSnapshotTimer(sessionId);
8938
9151
  });
8939
9152
  await sc.capturer.startCapture(sc.page, sessionId, onFrame);
9153
+ this.streamCoordinator.takeStaticSnapshot(sessionId, sc.page, sc.clientCount).catch(() => {
9154
+ });
8940
9155
  }
8941
9156
  async stopCapturer(sessionId) {
8942
9157
  const sc = this.screencasts.get(sessionId);
@@ -9387,6 +9602,15 @@ var WSServer = class extends EventEmitter2 {
9387
9602
  registerSession(sessionId, page, options) {
9388
9603
  this.sessionManager.registerSession(sessionId, page, options);
9389
9604
  }
9605
+ /** Restart screencast for a session — used by viewer reconnect button. */
9606
+ async reconnectSession(sessionId) {
9607
+ try {
9608
+ await this.sessionManager.stopCapturer(sessionId);
9609
+ await new Promise((r) => setTimeout(r, 500));
9610
+ await this.sessionManager.startCapturer(sessionId);
9611
+ } catch {
9612
+ }
9613
+ }
9390
9614
  unregisterSession(sessionId) {
9391
9615
  this.sessionManager.unregisterSession(sessionId);
9392
9616
  }
@@ -9409,7 +9633,7 @@ var WSServer = class extends EventEmitter2 {
9409
9633
  });
9410
9634
  }
9411
9635
  this.sessionManager.incrementClientCount(sessionId);
9412
- if (this.streamCoordinator.getLastFrameViewport() && this.sessionManager.isCapturerActive(sessionId)) {
9636
+ if (this.streamCoordinator.getLastFrameViewport()) {
9413
9637
  this.streamCoordinator.replayLastFrame(sessionId).catch(() => {
9414
9638
  });
9415
9639
  }
@@ -9419,7 +9643,6 @@ var WSServer = class extends EventEmitter2 {
9419
9643
  if (count === 0 && this.sessionManager.isCapturerActive(sessionId)) {
9420
9644
  this.sessionManager.stopCapturer(sessionId).then(() => {
9421
9645
  this.streamCoordinator.resetFrameRate();
9422
- this.streamCoordinator.clearLastFrame();
9423
9646
  this.emit("screencast-stopped", sessionId);
9424
9647
  }).catch(() => {
9425
9648
  this.emit("screencast-stopped", sessionId);
@@ -9595,6 +9818,31 @@ var WSServer = class extends EventEmitter2 {
9595
9818
  this.emit("human-solved", { sessionId: sessionId ?? null, clientId });
9596
9819
  return;
9597
9820
  }
9821
+ if (msg.type === "reconnect") {
9822
+ this.emit("reconnect-request", { sessionId: sessionId ?? null, clientId });
9823
+ return;
9824
+ }
9825
+ if (msg.type === "health_ping") {
9826
+ this.sendToClient(clientId, { type: "health_pong", ts: msg.ts });
9827
+ return;
9828
+ }
9829
+ if (msg.type === "snapshot_request") {
9830
+ const page2 = sessionId ? this.sessionManager.getPageForSession(sessionId) : void 0;
9831
+ if (!page2) {
9832
+ this.sendToClient(clientId, { type: "snapshot_result", data: null, error: "no page" });
9833
+ return;
9834
+ }
9835
+ const fmt = msg.format === "webp" ? "webp" : "png";
9836
+ page2.screenshot({ type: fmt, quality: fmt === "webp" ? msg.quality ?? 90 : void 0 }).then((buf) => {
9837
+ this.sendToClient(clientId, {
9838
+ type: "snapshot_result",
9839
+ data: { data: buf.toString("base64"), format: fmt }
9840
+ });
9841
+ }).catch(() => {
9842
+ this.sendToClient(clientId, { type: "snapshot_result", data: null, error: "screenshot failed" });
9843
+ });
9844
+ return;
9845
+ }
9598
9846
  const ctx = {
9599
9847
  clientId,
9600
9848
  sessionId,
@@ -9681,7 +9929,7 @@ function alignHTML(sessionId, _host) {
9681
9929
  html,body{height:100%;overflow:hidden;background:#000}
9682
9930
  .viewport{position:absolute;top:0;left:0;right:0;bottom:0;overflow:hidden}
9683
9931
  .viewport img#screen{display:block;width:100%;height:100%;object-fit:contain;background:#111}
9684
- .crosshair{position:fixed;pointer-events:none;z-index:9999;width:16px;height:16px;border-radius:50%;background:rgba(255,0,0,0.8);border:2px solid #fff;transform:translate(-50%,-50%);display:none;box-shadow:0 0 6px rgba(255,0,0,0.6);transition:left 0.03s,top 0.03s}
9932
+ .crosshair{position:fixed;pointer-events:none;z-index:9999;width:16px;height:16px;border-radius:50%;background:rgba(255,0,0,0.8);border:2px solid #fff;transform:translate(-50%,-50%);display:none;box-shadow:0 0 6px rgba(255,0,0,0.6)}
9685
9933
  .coord{position:fixed;top:8px;left:8px;z-index:10000;font:12px/1.6 monospace;color:#0f0;background:rgba(0,0,0,0.7);padding:4px 8px;border-radius:4px;pointer-events:none;white-space:pre}
9686
9934
  .grid{position:fixed;pointer-events:none;z-index:9998;top:0;left:0;right:0;bottom:0;display:none}
9687
9935
  </style></head><body>
@@ -9773,9 +10021,17 @@ viewportEl.addEventListener('mousemove',function(e){
9773
10021
  var r=viewerToRemote(e.clientX,e.clientY);
9774
10022
  var rx=Math.max(0,Math.min(remoteViewport.width,r.x));
9775
10023
  var ry=Math.max(0,Math.min(remoteViewport.height,r.y));
10024
+ // \u672C\u5730\u5149\u6807\u7ACB\u5373\u66F4\u65B0\uFF08\u96F6\u5EF6\u8FDF\u53CD\u9988\uFF09
9776
10025
  setCrosshair(rx,ry);
9777
- if(e.buttons>0&&ws&&ws.readyState===1)ws.send(JSON.stringify({type:'input_mouse',action:'move',x:rx,y:ry}));
10026
+ // \u8282\u6D41\uFF1A\u6700\u591A\u6BCF 30ms \u53D1\u4E00\u6B21 move\uFF0C\u907F\u514D WS \u6D88\u606F\u6D2A\u6CDB
10027
+ if(!moveThrottled&&ws&&ws.readyState===1){
10028
+ moveThrottled=true;
10029
+ var action=e.buttons>0?'move':'hover';
10030
+ ws.send(JSON.stringify({type:'input_mouse',action:action,x:rx,y:ry}));
10031
+ setTimeout(function(){moveThrottled=false;},30);
10032
+ }
9778
10033
  });
10034
+ var moveThrottled=false;
9779
10035
  viewportEl.addEventListener('mousedown',function(e){var r=viewerToRemote(e.clientX,e.clientY);setCrosshair(r.x,r.y);if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'input_mouse',action:'down',x:r.x,y:r.y}));});
9780
10036
  viewportEl.addEventListener('mouseup',function(e){var r=viewerToRemote(e.clientX,e.clientY);setCrosshair(r.x,r.y);if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'input_mouse',action:'up',x:r.x,y:r.y}));});
9781
10037
  viewportEl.addEventListener('wheel',function(e){e.preventDefault();if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'scroll',deltaX:e.deltaX,deltaY:e.deltaY}));},{passive:false});
@@ -9803,7 +10059,7 @@ body{display:flex;flex-direction:column;touch-action:manipulation}
9803
10059
  .bar .dot.ok{background:#2ecc71}
9804
10060
  .bar .url{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8899aa;font-size:12px}
9805
10061
  .bar .conn{color:#556;font-size:11px;flex-shrink:0}
9806
- .viewport{flex-shrink:0;display:flex;align-items:flex-start;justify-content:center;overflow:hidden;background:#1a1a2e;position:relative}
10062
+ .viewport{flex:1 1 0;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#1a1a2e;position:relative;min-height:0}
9807
10063
  .viewport canvas#screen{display:none;user-select:none;-webkit-user-drag:none}
9808
10064
  .waiting{color:#556;font-size:14px;text-align:center}
9809
10065
  .toolbar{flex-shrink:0;background:#16213e;border-top:1px solid #0f3460;display:none;flex-direction:row;z-index:90;padding:4px 6px;gap:4px;overflow-x:auto;-webkit-overflow-scrolling:touch}
@@ -9866,6 +10122,33 @@ body{display:flex;flex-direction:column;touch-action:manipulation}
9866
10122
  <span class="conn" id="conn"></span>
9867
10123
  <button class="mode-btn" id="mode-btn" title="Switch mode (Mobile/Tablet/PC)">\u{1F4F1}</button>
9868
10124
  <div id="qualityBadge" class="quality-badge quality-static" style="font-size:10px;padding:2px 8px;border-radius:10px;background:#556;color:#eee;white-space:nowrap;flex-shrink:0">static</div>
10125
+ <button id="stats-btn" style="font-size:11px;padding:2px 8px;border-radius:10px;background:#0f3460;color:#8af;border:none;cursor:pointer;flex-shrink:0">\u{1F4CA}</button>
10126
+ <button id="reconnect-btn" style="font-size:11px;padding:2px 8px;border-radius:10px;background:#0f3460;color:#f39c12;border:none;cursor:pointer;flex-shrink:0" title="\u91CD\u8FDE\u8FDC\u7A0B\u6D4F\u89C8\u5668">\u{1F504}</button>
10127
+ <button id="snapshot-btn" style="font-size:11px;padding:2px 8px;border-radius:10px;background:#0f3460;color:#8af;border:none;cursor:pointer;flex-shrink:0" title="\u622A\u53D6\u9AD8\u6E05\u622A\u56FE">\u{1F4F7}</button>
10128
+ </div>
10129
+ <div id="snapshot-preview" style="display:none;position:fixed;bottom:8px;right:8px;z-index:10000;width:200px;background:rgba(10,15,30,0.95);border:1px solid #0f3460;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,0.5);cursor:pointer">
10130
+ <img id="snapshot-img" style="display:block;width:100%;height:auto" />
10131
+ <div style="padding:4px 8px;font:10px/1.4 monospace;color:#8af;display:flex;justify-content:space-between;align-items:center">
10132
+ <span>\u{1F4F7} \u9AD8\u6E05\u622A\u56FE \xB7 \u70B9\u51FB\u653E\u5927</span>
10133
+ <span id="snapshot-countdown" style="color:#f39c12"></span>
10134
+ </div>
10135
+ <div id="snapshot-progress" style="height:2px;background:#0f3460;width:100%"></div>
10136
+ </div>
10137
+ <div id="snapshot-fullscreen" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:99999;background:rgba(0,0,0,0.92);align-items:center;justify-content:center;cursor:zoom-out;overflow:auto;padding:20px">
10138
+ <img id="snapshot-full-img" style="max-width:100%;max-height:100%;object-fit:contain;border-radius:4px;box-shadow:0 8px 40px rgba(0,0,0,0.8)" />
10139
+ </div>
10140
+ </div>
10141
+ <div id="stats-panel" style="display:none;position:fixed;top:40px;right:8px;z-index:10001;background:rgba(10,15,30,0.95);border:1px solid #0f3460;border-radius:8px;padding:8px 12px;font:11px/1.7 monospace;color:#8af;min-width:200px;backdrop-filter:blur(8px)">
10142
+ <div style="font-weight:bold;color:#cef;margin-bottom:4px;border-bottom:1px solid #0f3460;padding-bottom:4px">\u{1F4CA} Live Monitor</div>
10143
+ <div>FPS: <span id="stat-fps" style="color:#2ecc71">0</span></div>
10144
+ <div>Frame size: <span id="stat-frame-size" style="color:#2ecc71">0KB</span></div>
10145
+ <div>Latency: <span id="stat-latency" style="color:#2ecc71">0ms</span></div>
10146
+ <div>Bandwidth: <span id="stat-bandwidth" style="color:#2ecc71">0KB/s</span></div>
10147
+ <div>Frames: <span id="stat-frames" style="color:#8af">0</span></div>
10148
+ <div>Sent: <span id="stat-sent" style="color:#8af">0</span></div>
10149
+ <div>WS: <span id="stat-ws" style="color:#e74c3c">disconnected</span></div>
10150
+ <div>Viewport: <span id="stat-viewport" style="color:#8af">0x0</span></div>
10151
+ <div>Uptime: <span id="stat-uptime" style="color:#8af">0s</span></div>
9869
10152
  </div>
9870
10153
  <div class="view-tabs" id="view-tabs"></div>
9871
10154
  <div class="viewport" id="viewport">
@@ -9934,6 +10217,14 @@ let currentFocusedSelector='';
9934
10217
  let currentFocusedValue='';
9935
10218
  let suppressFocus=false;
9936
10219
  let deviceMode='desktop';
10220
+
10221
+ // === Live Monitor stats ===
10222
+ const startTime=Date.now();
10223
+ let statFrames=0,statSent=0,statBytesRcvd=0,statBytesSent=0;
10224
+ let statLastFrameTime=0,statLatency=0,statFrameSize=0,statFps=0;
10225
+ let statFpsFrames=0,statFpsTimer=Date.now();
10226
+ let statEl=null;
10227
+
9937
10228
  let lastHoverSent=0;
9938
10229
 
9939
10230
  const $=id=>document.getElementById(id);
@@ -9942,13 +10233,136 @@ const viewportEl=$('viewport'),cursorEl=$('cursor'),cursorLabelEl=$('cursor-labe
9942
10233
  const touchpadEl=$('touchpad'),toolbarEl=$('toolbar');
9943
10234
  const inputPanel=$('input-panel'),inputField=$('input-field'),inputLabel=$('input-label');
9944
10235
 
10236
+ // Init monitor elements after $ is defined
10237
+ statEl={
10238
+ fps:$('stat-fps'),frameSize:$('stat-frame-size'),latency:$('stat-latency'),
10239
+ bandwidth:$('stat-bandwidth'),frames:$('stat-frames'),sent:$('stat-sent'),
10240
+ ws:$('stat-ws'),viewport:$('stat-viewport'),uptime:$('stat-uptime'),
10241
+ };
10242
+ $('stats-btn').addEventListener('click',()=>{
10243
+ const panel=$('stats-panel');
10244
+ panel.style.display=panel.style.display==='none'?'block':'none';
10245
+ });
10246
+ $('reconnect-btn').addEventListener('click',()=>{
10247
+ const btn=$('reconnect-btn');
10248
+ btn.textContent='\u23F3';
10249
+ btn.style.background='#e74c3c';
10250
+ // Tell daemon to reconnect the remote CDP session
10251
+ sendMsg({type:'reconnect'});
10252
+ // Also reload the page after 2s to re-establish viewer WS
10253
+ setTimeout(()=>{location.reload();},2000);
10254
+ });
10255
+
10256
+ // === Snapshot feature ===
10257
+ // Click \u{1F4F7} \u2192 daemon sends CDP captureScreenshot \u2192 returns high-quality image
10258
+ // Preview shows in bottom-right for 5s with countdown \u2192 click to fullscreen
10259
+ var snapshotDataUrl=null;
10260
+ var snapshotCountdownTimer=null;
10261
+ $('snapshot-btn').addEventListener('click',function(){
10262
+ var btn=$('snapshot-btn');
10263
+ btn.textContent='\u23F3';
10264
+ // Request high-quality screenshot from daemon
10265
+ sendMsg({type:'snapshot_request',format:'webp',quality:90});
10266
+ });
10267
+ // Handle snapshot response
10268
+ // (added to ws.onmessage handler below)
10269
+ var snapshotPendingHandler=function(msg){
10270
+ if(msg.type==='snapshot_result'&&msg.data){
10271
+ var data=msg.data;
10272
+ snapshotDataUrl='data:image/'+(data.format||'png')+';base64,'+data.data;
10273
+ var img=$('snapshot-img');
10274
+ img.src=snapshotDataUrl;
10275
+ var preview=$('snapshot-preview');
10276
+ preview.style.display='block';
10277
+ $('snapshot-btn').textContent='\u{1F4F7}';
10278
+ // Start 5s countdown
10279
+ var remaining=5;
10280
+ var progress=$('snapshot-progress');
10281
+ var countdownEl=$('snapshot-countdown');
10282
+ if(snapshotCountdownTimer) clearInterval(snapshotCountdownTimer);
10283
+ progress.style.width='100%';
10284
+ progress.style.background='#2ecc71';
10285
+ countdownEl.textContent=remaining+'s';
10286
+ snapshotCountdownTimer=setInterval(function(){
10287
+ remaining--;
10288
+ countdownEl.textContent=remaining+'s';
10289
+ progress.style.width=(remaining/5*100)+'%';
10290
+ if(remaining<=0){
10291
+ clearInterval(snapshotCountdownTimer);
10292
+ preview.style.display='none';
10293
+ }
10294
+ },1000);
10295
+ // Hover to pause countdown
10296
+ preview.onmouseenter=function(){if(snapshotCountdownTimer){clearInterval(snapshotCountdownTimer);countdownEl.textContent='\u23F8';}};
10297
+ preview.onmouseleave=function(){
10298
+ if(snapshotCountdownTimer) clearInterval(snapshotCountdownTimer);
10299
+ progress.style.width=(remaining/5*100)+'%';
10300
+ snapshotCountdownTimer=setInterval(function(){
10301
+ remaining--;
10302
+ countdownEl.textContent=remaining+'s';
10303
+ progress.style.width=(remaining/5*100)+'%';
10304
+ if(remaining<=0){clearInterval(snapshotCountdownTimer);preview.style.display='none';}
10305
+ },1000);
10306
+ };
10307
+ }
10308
+ };
10309
+ // Click preview \u2192 fullscreen
10310
+ $('snapshot-preview').addEventListener('click',function(){
10311
+ if(!snapshotDataUrl) return;
10312
+ $('snapshot-full-img').src=snapshotDataUrl;
10313
+ $('snapshot-fullscreen').style.display='flex';
10314
+ if(snapshotCountdownTimer){clearInterval(snapshotCountdownTimer);}
10315
+ });
10316
+ // Click fullscreen \u2192 close
10317
+ $('snapshot-fullscreen').addEventListener('click',function(){
10318
+ this.style.display='none';
10319
+ });
10320
+ // Auto-detect dead connection: if no frames for 30s, show warning
10321
+ let lastFrameAt=Date.now();
10322
+ setInterval(()=>{
10323
+ if(connected && Date.now()-lastFrameAt>30000 && statFrames>0){
10324
+ const btn=$('reconnect-btn');
10325
+ btn.style.background='#e74c3c';
10326
+ btn.textContent='\u{1F504}!';
10327
+ btn.title='\u8FDE\u63A5\u53EF\u80FD\u5DF2\u65AD\u5F00\uFF0C\u70B9\u51FB\u91CD\u8FDE';
10328
+ }
10329
+ },5000);
10330
+ setInterval(()=>{
10331
+ const now=Date.now();
10332
+ const elapsed=now-statFpsTimer;
10333
+ if(elapsed>=1000){
10334
+ statFps=Math.round(statFpsFrames*1000/elapsed);
10335
+ statFpsFrames=0;
10336
+ statFpsTimer=now;
10337
+ const bw=Math.round((statBytesRcvd+statBytesSent)/1024);
10338
+ statEl.fps.textContent=statFps;
10339
+ statEl.fps.style.color=statFps>=15?'#2ecc71':statFps>=5?'#f39c12':'#e74c3c';
10340
+ statEl.frameSize.textContent=Math.round(statFrameSize/1024)+'KB';
10341
+ statEl.latency.textContent=statLatency+'ms';
10342
+ statEl.latency.style.color=statLatency<100?'#2ecc71':statLatency<300?'#f39c12':'#e74c3c';
10343
+ statEl.bandwidth.textContent=bw+'KB/s';
10344
+ statEl.frames.textContent=statFrames;
10345
+ statEl.sent.textContent=statSent;
10346
+ statEl.ws.textContent=connected?'connected':'disconnected';
10347
+ statEl.ws.style.color=connected?'#2ecc71':'#e74c3c';
10348
+ statEl.viewport.textContent=remoteViewport.width+'x'+remoteViewport.height;
10349
+ statEl.uptime.textContent=Math.round((now-startTime)/1000)+'s';
10350
+ statBytesRcvd=0;statBytesSent=0;
10351
+ }
10352
+ },200);
10353
+
9945
10354
  function resizeCanvas(){
9946
10355
  const box=viewportEl.getBoundingClientRect();
9947
- const bw=box.width;
9948
- if(!bw||!remoteViewport.width||!remoteViewport.height) return;
10356
+ const bw=box.width,bh=box.height;
10357
+ if(!bw||!bh||!remoteViewport.width||!remoteViewport.height) return;
9949
10358
  const vpAspect=remoteViewport.width/remoteViewport.height;
9950
- const cw=bw;
9951
- const ch=bw/vpAspect;
10359
+ // Fit the remote viewport inside the available box without cropping:
10360
+ // pick the scale that keeps both dimensions within bounds.
10361
+ const scaleByWidth=bw/remoteViewport.width;
10362
+ const scaleByHeight=bh/remoteViewport.height;
10363
+ const scale=Math.min(scaleByWidth,scaleByHeight);
10364
+ const cw=remoteViewport.width*scale;
10365
+ const ch=remoteViewport.height*scale;
9952
10366
  if(canvas.width!==Math.round(cw)||canvas.height!==Math.round(ch)){
9953
10367
  canvas.width=Math.round(cw);
9954
10368
  canvas.height=Math.round(ch);
@@ -9981,6 +10395,10 @@ function connectWS(){
9981
10395
  connEl.textContent='WS';
9982
10396
  if(deviceMode==='desktop') createPCKeyboard();
9983
10397
  checkFocus();
10398
+ // Apply crop from URL params (?crop=x,y,w,h or ?selector=.cls)
10399
+ setTimeout(applyUrlCrop,500);
10400
+ // Start connection health check \u2014 auto-reconnect on failure
10401
+ startHealthCheck();
9984
10402
  };
9985
10403
  ws.binaryType='arraybuffer';
9986
10404
  ws.onmessage=(e)=>{
@@ -9991,13 +10409,31 @@ function connectWS(){
9991
10409
  updateViewTabs(_m.views);
9992
10410
  return;
9993
10411
  }
10412
+ if(_m.type==='health_pong'){
10413
+ healthCheckPending=false;
10414
+ var btn=$('reconnect-btn');
10415
+ if(btn.textContent!=='\u{1F504}'){btn.style.background='#0f3460';btn.textContent='\u{1F504}';}
10416
+ return;
10417
+ }
10418
+ if(_m.type==='snapshot_result'){
10419
+ if(snapshotPendingHandler) snapshotPendingHandler(_m);
10420
+ return;
10421
+ }
9994
10422
  }
9995
10423
  if(e.data instanceof ArrayBuffer){
9996
10424
  const buf=new Uint8Array(e.data);
10425
+ statBytesRcvd+=buf.length;
9997
10426
  const headerLen=(buf[0]<<24)|(buf[1]<<16)|(buf[2]<<8)|buf[3];
9998
10427
  const header=JSON.parse(new TextDecoder().decode(buf.slice(4,4+headerLen)));
9999
10428
  const jpegData=buf.slice(4+headerLen);
10000
10429
  if(header.type==='screenshot'){
10430
+ // === Monitor: track frame stats ===
10431
+ statFrames++;statFpsFrames++;
10432
+ lastFrameAt=Date.now();
10433
+ statFrameSize=jpegData.length;
10434
+ if(header.data.timestamp){
10435
+ statLatency=Math.max(0,Date.now()-header.data.timestamp);
10436
+ }
10001
10437
  const blob=new Blob([jpegData],{type:'image/jpeg'});
10002
10438
  createImageBitmap(blob).then(function(bmp){
10003
10439
  drawFrame(bmp);
@@ -10139,6 +10575,8 @@ function updateViewTabs(views){
10139
10575
  selectView('main');
10140
10576
  }
10141
10577
  renderViewTabs();
10578
+ // Auto-select view matching ?selector= param
10579
+ if(pendingSelector) trySelectBySelector();
10142
10580
  }
10143
10581
  function renderViewTabs(){
10144
10582
  if(detectedViews.length===0){
@@ -10227,7 +10665,12 @@ function selectView(vid){
10227
10665
  }
10228
10666
 
10229
10667
  function sendMsg(obj){
10230
- if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify(obj));
10668
+ if(ws&&ws.readyState===WebSocket.OPEN){
10669
+ const data=JSON.stringify(obj);
10670
+ ws.send(data);
10671
+ statSent++;
10672
+ statBytesSent+=data.length;
10673
+ }
10231
10674
  }
10232
10675
 
10233
10676
  function parseFocusHash(){
@@ -10237,6 +10680,107 @@ function parseFocusHash(){
10237
10680
  return m?decodeURIComponent(m[1]):null;
10238
10681
  }
10239
10682
 
10683
+ // Parse crop/view parameters from URL query string.
10684
+ // Supports:
10685
+ // ?selector=.login-modal \u2014 crop to element matching CSS selector
10686
+ // ?crop=100,200,500,400 \u2014 crop to x,y,width,height (remote viewport coords)
10687
+ function parseUrlCrop(){
10688
+ var params=new URLSearchParams(location.search);
10689
+
10690
+ // ?crop=x,y,w,h
10691
+ var cropParam=params.get('crop');
10692
+ if(cropParam){
10693
+ var parts=cropParam.split(',').map(function(s){return parseInt(s.trim(),10)});
10694
+ if(parts.length===4&&parts.every(function(n){return!isNaN(n)})){
10695
+ return {type:'rect',rect:{x:parts[0],y:parts[1],width:parts[2],height:parts[3]}};
10696
+ }
10697
+ }
10698
+
10699
+ // ?selector=.login-modal
10700
+ var selParam=params.get('selector')||params.get('sel');
10701
+ if(selParam){
10702
+ return {type:'selector',selector:selParam};
10703
+ }
10704
+
10705
+ return null;
10706
+ }
10707
+
10708
+ // Apply URL crop on connect \u2014 sends select_view immediately after WS opens
10709
+ function applyUrlCrop(){
10710
+ var cropSpec=parseUrlCrop();
10711
+ if(!cropSpec) return;
10712
+
10713
+ if(cropSpec.type==='rect'){
10714
+ // Direct rect \u2014 send select_view immediately
10715
+ sendMsg({type:'select_view',rect:cropSpec.rect});
10716
+ if(!originalViewport) originalViewport={width:remoteViewport.width,height:remoteViewport.height};
10717
+ remoteViewport={width:cropSpec.rect.width,height:cropSpec.rect.height};
10718
+ viewportLocked=true;
10719
+ resizeCanvas();
10720
+ return;
10721
+ }
10722
+
10723
+ // Selector \u2014 wait for views_update from element-monitor, then auto-select matching view
10724
+ if(cropSpec.type==='selector'){
10725
+ pendingSelector=cropSpec.selector;
10726
+ // Try match immediately in case views already arrived
10727
+ trySelectBySelector();
10728
+ }
10729
+ }
10730
+ // Connection health check \u2014 send a ping every 15s.
10731
+ // If no response in 5s, auto-reconnect the session.
10732
+ var healthCheckTimer=null;
10733
+ var healthCheckPending=false;
10734
+ var autoReconnecting=false;
10735
+ function startHealthCheck(){
10736
+ if(healthCheckTimer) clearInterval(healthCheckTimer);
10737
+ healthCheckTimer=setInterval(function(){
10738
+ if(!connected) return;
10739
+ if(healthCheckPending){
10740
+ // Previous ping never got a response \u2192 connection is dead \u2192 auto-reconnect
10741
+ if(!autoReconnecting){
10742
+ autoReconnecting=true;
10743
+ var btn=$('reconnect-btn');
10744
+ btn.style.background='#e74c3c';
10745
+ btn.textContent='\u{1F504}!';
10746
+ btn.title='\u8FDE\u63A5\u5DF2\u65AD\u5F00\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u8FDE...';
10747
+ // Auto-reconnect: send reconnect then reload after 2s
10748
+ sendMsg({type:'reconnect'});
10749
+ setTimeout(function(){location.reload();},2000);
10750
+ }
10751
+ return;
10752
+ }
10753
+ healthCheckPending=true;
10754
+ sendMsg({type:'health_ping',ts:Date.now()});
10755
+ setTimeout(function(){
10756
+ if(healthCheckPending){
10757
+ // Still pending after 5s = dead
10758
+ healthCheckPending=false;
10759
+ var btn2=$('reconnect-btn');
10760
+ btn2.style.background='#e74c3c';
10761
+ btn2.textContent='\u{1F504}!';
10762
+ }
10763
+ },5000);
10764
+ },15000);
10765
+ }
10766
+ function trySelectBySelector(){
10767
+ if(!pendingSelector||!detectedViews||detectedViews.length===0) return;
10768
+ // Match by label containing selector text, or id/class match
10769
+ var target=detectedViews.find(function(v){
10770
+ var label=(v.label||'').toLowerCase();
10771
+ var id=(v.id||'').toLowerCase();
10772
+ return label.indexOf(pendingSelector.toLowerCase())>=0||id.indexOf(pendingSelector.toLowerCase())>=0;
10773
+ });
10774
+ if(target){
10775
+ sendMsg({type:'select_view',rect:target.rect});
10776
+ if(!originalViewport) originalViewport={width:remoteViewport.width,height:remoteViewport.height};
10777
+ remoteViewport={width:target.rect.width,height:target.rect.height};
10778
+ viewportLocked=true;
10779
+ resizeCanvas();
10780
+ pendingSelector=null; // matched, stop looking
10781
+ }
10782
+ }
10783
+
10240
10784
  function checkFocus(){
10241
10785
  var sel=parseFocusHash();
10242
10786
  if(sel&&ws&&ws.readyState===WebSocket.OPEN){
@@ -10336,10 +10880,16 @@ function createPCKeyboard(){
10336
10880
  document.addEventListener('keydown',function(e){
10337
10881
  if(deviceMode!=='desktop') return;
10338
10882
  if(e.isComposing||_pcComposing) return;
10339
- var tag=e.target&&e.target.tagName;
10340
- if(tag==='INPUT'||tag==='TEXTAREA') return;
10341
10883
  var key=e.key;
10342
10884
  if(!key) return;
10885
+
10886
+ // Control keys (Backspace, Delete, Enter, Tab, arrows, etc.) must always
10887
+ // be forwarded even when focus is in an input/textarea \u2014 otherwise the user
10888
+ // can't edit text in the remote browser's input fields.
10889
+ var isControlKey=key.length>1; // Backspace, Delete, Enter, Tab, ArrowX, etc.
10890
+ var tag=e.target&&e.target.tagName;
10891
+ if(!isControlKey&&(tag==='INPUT'||tag==='TEXTAREA')) return;
10892
+
10343
10893
  e.preventDefault();
10344
10894
  sendMsg({type:'input_keyboard',action:'down',key:key});
10345
10895
  if(key.length===1){
@@ -10350,10 +10900,13 @@ function createPCKeyboard(){
10350
10900
  document.addEventListener('keyup',function(e){
10351
10901
  if(deviceMode!=='desktop') return;
10352
10902
  if(e.isComposing||_pcComposing) return;
10353
- var tag=e.target&&e.target.tagName;
10354
- if(tag==='INPUT'||tag==='TEXTAREA') return;
10355
10903
  var key=e.key;
10356
10904
  if(!key) return;
10905
+
10906
+ var isControlKey=key.length>1;
10907
+ var tag=e.target&&e.target.tagName;
10908
+ if(!isControlKey&&(tag==='INPUT'||tag==='TEXTAREA')) return;
10909
+
10357
10910
  e.preventDefault();
10358
10911
  sendMsg({type:'input_keyboard',action:'up',key:key});
10359
10912
  sendUserActivity();
@@ -10406,9 +10959,20 @@ viewportEl.addEventListener('mouseleave',()=>{
10406
10959
  viewportEl.addEventListener('wheel',(e)=>{
10407
10960
  if(deviceMode!=='desktop') return;
10408
10961
  e.preventDefault();
10409
- sendMsg({type:'scroll',deltaX:e.deltaX,deltaY:e.deltaY});
10410
- sendUserActivity();
10962
+ // Accumulate wheel deltas and send at most every 50ms to avoid flooding
10963
+ // the remote with hundreds of scroll commands during a single swipe.
10964
+ pendingScrollX+=e.deltaX;
10965
+ pendingScrollY+=e.deltaY;
10966
+ if(!scrollThrottled){
10967
+ scrollThrottled=true;
10968
+ sendMsg({type:'scroll',deltaX:Math.round(pendingScrollX),deltaY:Math.round(pendingScrollY)});
10969
+ sendUserActivity();
10970
+ pendingScrollX=0;
10971
+ pendingScrollY=0;
10972
+ setTimeout(function(){scrollThrottled=false;},50);
10973
+ }
10411
10974
  },{passive:false});
10975
+ var scrollThrottled=false,pendingScrollX=0,pendingScrollY=0;
10412
10976
 
10413
10977
  // --- Mobile Touchpad (incremental, smooth) ---
10414
10978
  let tpStartPos=null;
@@ -10905,6 +11469,13 @@ async function main() {
10905
11469
  rpcHandler.setPreviewWS(previewWS);
10906
11470
  previewWS.on("screencast-started", (sid) => log(`Preview screencast started: ${sid}`));
10907
11471
  previewWS.on("screencast-stopped", (sid) => log(`Preview screencast stopped: ${sid}`));
11472
+ previewWS.on("reconnect-request", ({ sessionId }) => {
11473
+ if (!sessionId) return;
11474
+ log(`Reconnect request from viewer for session: ${sessionId}`);
11475
+ rpcHandler.handleReconnect(sessionId).catch((e) => {
11476
+ log(`Reconnect failed: ${e instanceof Error ? e.message : String(e)}`);
11477
+ });
11478
+ });
10908
11479
  mkdirSync5(CONFIG_DIR3, { recursive: true });
10909
11480
  writeFileSync5(join7(CONFIG_DIR3, "daemon.json"), JSON.stringify({
10910
11481
  port: daemonPort,
@@ -10913,6 +11484,13 @@ async function main() {
10913
11484
  }, null, 2));
10914
11485
  console.log(`xbrowser daemon started (pid: ${process.pid}, port: ${daemonPort})`);
10915
11486
  log("Daemon main started successfully");
11487
+ const loader = await getPluginLoader();
11488
+ const sites = loader.getCore().loader.getSites();
11489
+ log(`Loaded ${sites.length} plugins`);
11490
+ if (sites.length > 0) {
11491
+ const names = sites.map((s) => s.name || "unknown").join(", ");
11492
+ log(`Plugins: ${names}`);
11493
+ }
10916
11494
  const shutdown = () => {
10917
11495
  log("Received shutdown signal, stopping");
10918
11496
  previewWS.stop().catch(() => {