@xbrowser/cli 1.9.8 → 1.10.0

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-SJWPJFN7.js → browser-HBL72GPZ.js} +2 -2
  3. package/dist/{browser-GT4BMX6X.js → browser-T3V3JWVH.js} +1 -1
  4. package/dist/{browser-43YXUIML.js → browser-WQZ3D6AE.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-BAHSRZIX.js} +82 -29
  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-DPJNCMLC.js → chunk-INTQPBYF.js} +7 -0
  16. package/dist/{human-interaction-OU7LZ6OZ.js → chunk-MWFHZUIY.js} +6 -7
  17. package/dist/{chunk-A4YHJW3Z.js → chunk-NITFVWWS.js} +168 -29
  18. package/dist/chunk-Q2DFJQGS.js +128 -0
  19. package/dist/{chunk-GM3HWQ5U.js → chunk-RRBXV7KE.js} +87 -1
  20. package/dist/{human-interaction-HJTXFVQS.js → chunk-SQHSZENE.js} +2 -2
  21. package/dist/chunk-TZPKFUBT.js +20 -0
  22. package/dist/{chunk-PBDID7SE.js → chunk-WSCP7QCJ.js} +82 -29
  23. package/dist/{chunk-YAUG3NSI.js → chunk-YMUSHPU4.js} +16 -128
  24. package/dist/cli.js +48 -31
  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 +649 -58
  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 +194 -67
  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-BAHSRZIX.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-WQZ3D6AE.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,
@@ -8279,12 +8430,51 @@ function createRPCHandler() {
8279
8430
  import { EventEmitter as EventEmitter2 } from "events";
8280
8431
 
8281
8432
  // src/stream/frame-processor.ts
8282
- import sharp from "sharp";
8433
+ var _sharp = null;
8434
+ async function loadSharp() {
8435
+ if (!_sharp) {
8436
+ try {
8437
+ _sharp = (await import("sharp")).default;
8438
+ } catch (err) {
8439
+ throw new Error(
8440
+ "\u76F4\u64AD\u6D41\u5E27\u5904\u7406\u9700\u8981 sharp \u539F\u751F\u6A21\u5757\uFF0C\u5F53\u524D\u73AF\u5883\u52A0\u8F7D\u5931\u8D25\uFF1A" + (err instanceof Error ? err.message : String(err)) + "\u3002\u4E0D\u7528\u76F4\u64AD\u6D41\u529F\u80FD\u53EF\u5FFD\u7565\uFF1B\u9700\u8981\u76F4\u64AD\u8BF7\u5728\u517C\u5BB9\u73AF\u5883\u8FD0\u884C\u6216\u5B89\u88C5\u5BF9\u5E94\u5E73\u53F0\u4F9D\u8D56\u3002"
8441
+ );
8442
+ }
8443
+ }
8444
+ return _sharp;
8445
+ }
8283
8446
  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 }
8447
+ // Full-screen: aggressive compression for speed
8448
+ // user_interacting: low quality + downscale prioritize FPS over clarity
8449
+ user_interacting: { format: "jpeg", quality: 40, maxFps: 30, scale: 0.6 },
8450
+ // screen_moving: page is animating but user is NOT operating.
8451
+ // Keep quality close to interacting to avoid visible flicker on state change.
8452
+ screen_moving: { format: "jpeg", quality: 45, maxFps: 2, scale: 0.7 },
8453
+ // static: highest quality — user is reading, bandwidth is not a concern
8454
+ static: { format: "jpeg", quality: 95, maxFps: 1, scale: 1 }
8287
8455
  };
8456
+ function adjustConfigForSize(config, frameWidth, frameHeight, fullViewportWidth, fullViewportHeight) {
8457
+ const framePixels = frameWidth * frameHeight;
8458
+ const fullPixels = fullViewportWidth * fullViewportHeight;
8459
+ const ratio = fullPixels > 0 ? framePixels / fullPixels : 1;
8460
+ if (ratio < 0.3) {
8461
+ return {
8462
+ ...config,
8463
+ quality: Math.min(config.quality + 40, 90),
8464
+ // boost quality
8465
+ scale: Math.max(config.scale, 0.9)
8466
+ // don't downscale
8467
+ };
8468
+ }
8469
+ if (ratio < 0.6) {
8470
+ return {
8471
+ ...config,
8472
+ quality: Math.min(config.quality + 20, 80),
8473
+ scale: Math.max(config.scale, 0.7)
8474
+ };
8475
+ }
8476
+ return config;
8477
+ }
8288
8478
  var StreamStateManager = class {
8289
8479
  currentState = "static";
8290
8480
  isUserInteracting = false;
@@ -8293,9 +8483,11 @@ var StreamStateManager = class {
8293
8483
  frameInterval = Infinity;
8294
8484
  onStateChange = null;
8295
8485
  staticTimer = null;
8296
- USER_INTERACTION_TIMEOUT_MS = 1e3;
8486
+ USER_INTERACTION_TIMEOUT_MS = 2e3;
8487
+ // 1s → 2s: linger in interacting longer
8297
8488
  SCREEN_MOVING_THRESHOLD_MS = 1e3;
8298
- STATIC_TIMEOUT_MS = 1500;
8489
+ STATIC_TIMEOUT_MS = 3e3;
8490
+ // 1.5s → 3s: don't rush to static
8299
8491
  setStateChangeCallback(callback) {
8300
8492
  this.onStateChange = callback;
8301
8493
  }
@@ -8402,14 +8594,28 @@ var FrameProcessor = class {
8402
8594
  if (!needsResize && !needsCrop && !needsReencode) {
8403
8595
  return buffer;
8404
8596
  }
8597
+ const sharp = await loadSharp();
8405
8598
  let processed = sharp(buffer);
8406
8599
  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
- });
8600
+ const meta = await processed.metadata();
8601
+ const actualW = meta.width ?? viewportWidth ?? 1;
8602
+ const actualH = meta.height ?? viewportHeight ?? 1;
8603
+ const refW = viewportWidth ?? actualW;
8604
+ const refH = viewportHeight ?? actualH;
8605
+ const scaleX = actualW / refW;
8606
+ const scaleY = actualH / refH;
8607
+ const cropLeft = Math.round(cropConfig.x * scaleX);
8608
+ const cropTop = Math.round(cropConfig.y * scaleY);
8609
+ const cropW = Math.min(Math.round(cropConfig.width * scaleX), actualW - cropLeft);
8610
+ const cropH = Math.min(Math.round(cropConfig.height * scaleY), actualH - cropTop);
8611
+ if (cropW > 0 && cropH > 0 && cropLeft >= 0 && cropTop >= 0) {
8612
+ processed = processed.extract({
8613
+ left: cropLeft,
8614
+ top: cropTop,
8615
+ width: cropW,
8616
+ height: cropH
8617
+ });
8618
+ }
8413
8619
  }
8414
8620
  if (needsResize) {
8415
8621
  const newWidth = Math.round((viewportWidth ?? 0) * config.scale);
@@ -8558,11 +8764,20 @@ var StreamCoordinator = class {
8558
8764
  const crop = this.sessionCrops.get(sessionId);
8559
8765
  const cropConfig = crop ? crop.box : void 0;
8560
8766
  const effectiveViewport = crop ? { width: crop.box.width, height: crop.box.height } : frameViewport;
8561
- const processedBuffer = await this.frameProcessor.process(
8562
- frameData,
8767
+ const adjustedConfig = adjustConfigForSize(
8563
8768
  config,
8564
8769
  effectiveViewport.width,
8565
8770
  effectiveViewport.height,
8771
+ frameViewport.width,
8772
+ frameViewport.height
8773
+ );
8774
+ const processedBuffer = await this.frameProcessor.process(
8775
+ frameData,
8776
+ adjustedConfig,
8777
+ // Pass the ORIGINAL viewport (not cropped) so crop coordinates
8778
+ // can be scaled correctly from viewport space to frame pixel space.
8779
+ crop ? frameViewport.width : effectiveViewport.width,
8780
+ crop ? frameViewport.height : effectiveViewport.height,
8566
8781
  cropConfig
8567
8782
  );
8568
8783
  const header = Buffer.from(JSON.stringify({
@@ -8734,7 +8949,18 @@ var ElementMonitor = class {
8734
8949
  }
8735
8950
  return results;
8736
8951
  });
8737
- const views = elements.map((e, i) => ({
8952
+ const TOLERANCE_PX = 10;
8953
+ const deduped = [];
8954
+ const isSameSize = (a, b) => Math.abs(a.width - b.width) <= TOLERANCE_PX && Math.abs(a.height - b.height) <= TOLERANCE_PX;
8955
+ const sorted = [...elements].sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height);
8956
+ for (const el of sorted) {
8957
+ const dupe = deduped.some(
8958
+ (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
8959
+ );
8960
+ if (!dupe) deduped.push(el);
8961
+ }
8962
+ deduped.sort((a, b) => b.rect.width * b.rect.height - a.rect.width * a.rect.height);
8963
+ const views = deduped.map((e, i) => ({
8738
8964
  id: "el-" + i + "-" + (e.id || e.tag),
8739
8965
  label: e.id || e.cls || e.tag,
8740
8966
  rect: e.rect
@@ -8793,10 +9019,10 @@ var SessionManager = class extends EventEmitter {
8793
9019
  this.screencasts.set(sessionId, {
8794
9020
  capturer: new ScreencastCapturer({
8795
9021
  interval: options?.interval ?? 100,
8796
- quality: options?.quality ?? 80,
9022
+ quality: options?.quality ?? 40,
8797
9023
  type: options?.type ?? "jpeg",
8798
- width: options?.width ?? 1920,
8799
- height: options?.height ?? 1080
9024
+ width: options?.width ?? 1024,
9025
+ height: options?.height ?? 576
8800
9026
  }),
8801
9027
  page,
8802
9028
  clientCount: 0
@@ -8937,6 +9163,8 @@ var SessionManager = class extends EventEmitter {
8937
9163
  this.resetStaticSnapshotTimer(sessionId);
8938
9164
  });
8939
9165
  await sc.capturer.startCapture(sc.page, sessionId, onFrame);
9166
+ this.streamCoordinator.takeStaticSnapshot(sessionId, sc.page, sc.clientCount).catch(() => {
9167
+ });
8940
9168
  }
8941
9169
  async stopCapturer(sessionId) {
8942
9170
  const sc = this.screencasts.get(sessionId);
@@ -9387,6 +9615,15 @@ var WSServer = class extends EventEmitter2 {
9387
9615
  registerSession(sessionId, page, options) {
9388
9616
  this.sessionManager.registerSession(sessionId, page, options);
9389
9617
  }
9618
+ /** Restart screencast for a session — used by viewer reconnect button. */
9619
+ async reconnectSession(sessionId) {
9620
+ try {
9621
+ await this.sessionManager.stopCapturer(sessionId);
9622
+ await new Promise((r) => setTimeout(r, 500));
9623
+ await this.sessionManager.startCapturer(sessionId);
9624
+ } catch {
9625
+ }
9626
+ }
9390
9627
  unregisterSession(sessionId) {
9391
9628
  this.sessionManager.unregisterSession(sessionId);
9392
9629
  }
@@ -9409,7 +9646,7 @@ var WSServer = class extends EventEmitter2 {
9409
9646
  });
9410
9647
  }
9411
9648
  this.sessionManager.incrementClientCount(sessionId);
9412
- if (this.streamCoordinator.getLastFrameViewport() && this.sessionManager.isCapturerActive(sessionId)) {
9649
+ if (this.streamCoordinator.getLastFrameViewport()) {
9413
9650
  this.streamCoordinator.replayLastFrame(sessionId).catch(() => {
9414
9651
  });
9415
9652
  }
@@ -9419,7 +9656,6 @@ var WSServer = class extends EventEmitter2 {
9419
9656
  if (count === 0 && this.sessionManager.isCapturerActive(sessionId)) {
9420
9657
  this.sessionManager.stopCapturer(sessionId).then(() => {
9421
9658
  this.streamCoordinator.resetFrameRate();
9422
- this.streamCoordinator.clearLastFrame();
9423
9659
  this.emit("screencast-stopped", sessionId);
9424
9660
  }).catch(() => {
9425
9661
  this.emit("screencast-stopped", sessionId);
@@ -9595,6 +9831,31 @@ var WSServer = class extends EventEmitter2 {
9595
9831
  this.emit("human-solved", { sessionId: sessionId ?? null, clientId });
9596
9832
  return;
9597
9833
  }
9834
+ if (msg.type === "reconnect") {
9835
+ this.emit("reconnect-request", { sessionId: sessionId ?? null, clientId });
9836
+ return;
9837
+ }
9838
+ if (msg.type === "health_ping") {
9839
+ this.sendToClient(clientId, { type: "health_pong", ts: msg.ts });
9840
+ return;
9841
+ }
9842
+ if (msg.type === "snapshot_request") {
9843
+ const page2 = sessionId ? this.sessionManager.getPageForSession(sessionId) : void 0;
9844
+ if (!page2) {
9845
+ this.sendToClient(clientId, { type: "snapshot_result", data: null, error: "no page" });
9846
+ return;
9847
+ }
9848
+ const fmt = msg.format === "webp" ? "webp" : "png";
9849
+ page2.screenshot({ type: fmt, quality: fmt === "webp" ? msg.quality ?? 90 : void 0 }).then((buf) => {
9850
+ this.sendToClient(clientId, {
9851
+ type: "snapshot_result",
9852
+ data: { data: buf.toString("base64"), format: fmt }
9853
+ });
9854
+ }).catch(() => {
9855
+ this.sendToClient(clientId, { type: "snapshot_result", data: null, error: "screenshot failed" });
9856
+ });
9857
+ return;
9858
+ }
9598
9859
  const ctx = {
9599
9860
  clientId,
9600
9861
  sessionId,
@@ -9681,7 +9942,7 @@ function alignHTML(sessionId, _host) {
9681
9942
  html,body{height:100%;overflow:hidden;background:#000}
9682
9943
  .viewport{position:absolute;top:0;left:0;right:0;bottom:0;overflow:hidden}
9683
9944
  .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}
9945
+ .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
9946
  .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
9947
  .grid{position:fixed;pointer-events:none;z-index:9998;top:0;left:0;right:0;bottom:0;display:none}
9687
9948
  </style></head><body>
@@ -9773,9 +10034,17 @@ viewportEl.addEventListener('mousemove',function(e){
9773
10034
  var r=viewerToRemote(e.clientX,e.clientY);
9774
10035
  var rx=Math.max(0,Math.min(remoteViewport.width,r.x));
9775
10036
  var ry=Math.max(0,Math.min(remoteViewport.height,r.y));
10037
+ // \u672C\u5730\u5149\u6807\u7ACB\u5373\u66F4\u65B0\uFF08\u96F6\u5EF6\u8FDF\u53CD\u9988\uFF09
9776
10038
  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}));
10039
+ // \u8282\u6D41\uFF1A\u6700\u591A\u6BCF 30ms \u53D1\u4E00\u6B21 move\uFF0C\u907F\u514D WS \u6D88\u606F\u6D2A\u6CDB
10040
+ if(!moveThrottled&&ws&&ws.readyState===1){
10041
+ moveThrottled=true;
10042
+ var action=e.buttons>0?'move':'hover';
10043
+ ws.send(JSON.stringify({type:'input_mouse',action:action,x:rx,y:ry}));
10044
+ setTimeout(function(){moveThrottled=false;},30);
10045
+ }
9778
10046
  });
10047
+ var moveThrottled=false;
9779
10048
  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
10049
  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
10050
  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 +10072,7 @@ body{display:flex;flex-direction:column;touch-action:manipulation}
9803
10072
  .bar .dot.ok{background:#2ecc71}
9804
10073
  .bar .url{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8899aa;font-size:12px}
9805
10074
  .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}
10075
+ .viewport{flex:1 1 0;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#1a1a2e;position:relative;min-height:0}
9807
10076
  .viewport canvas#screen{display:none;user-select:none;-webkit-user-drag:none}
9808
10077
  .waiting{color:#556;font-size:14px;text-align:center}
9809
10078
  .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 +10135,33 @@ body{display:flex;flex-direction:column;touch-action:manipulation}
9866
10135
  <span class="conn" id="conn"></span>
9867
10136
  <button class="mode-btn" id="mode-btn" title="Switch mode (Mobile/Tablet/PC)">\u{1F4F1}</button>
9868
10137
  <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>
10138
+ <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>
10139
+ <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>
10140
+ <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>
10141
+ </div>
10142
+ <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">
10143
+ <img id="snapshot-img" style="display:block;width:100%;height:auto" />
10144
+ <div style="padding:4px 8px;font:10px/1.4 monospace;color:#8af;display:flex;justify-content:space-between;align-items:center">
10145
+ <span>\u{1F4F7} \u9AD8\u6E05\u622A\u56FE \xB7 \u70B9\u51FB\u653E\u5927</span>
10146
+ <span id="snapshot-countdown" style="color:#f39c12"></span>
10147
+ </div>
10148
+ <div id="snapshot-progress" style="height:2px;background:#0f3460;width:100%"></div>
10149
+ </div>
10150
+ <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">
10151
+ <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)" />
10152
+ </div>
10153
+ </div>
10154
+ <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)">
10155
+ <div style="font-weight:bold;color:#cef;margin-bottom:4px;border-bottom:1px solid #0f3460;padding-bottom:4px">\u{1F4CA} Live Monitor</div>
10156
+ <div>FPS: <span id="stat-fps" style="color:#2ecc71">0</span></div>
10157
+ <div>Frame size: <span id="stat-frame-size" style="color:#2ecc71">0KB</span></div>
10158
+ <div>Latency: <span id="stat-latency" style="color:#2ecc71">0ms</span></div>
10159
+ <div>Bandwidth: <span id="stat-bandwidth" style="color:#2ecc71">0KB/s</span></div>
10160
+ <div>Frames: <span id="stat-frames" style="color:#8af">0</span></div>
10161
+ <div>Sent: <span id="stat-sent" style="color:#8af">0</span></div>
10162
+ <div>WS: <span id="stat-ws" style="color:#e74c3c">disconnected</span></div>
10163
+ <div>Viewport: <span id="stat-viewport" style="color:#8af">0x0</span></div>
10164
+ <div>Uptime: <span id="stat-uptime" style="color:#8af">0s</span></div>
9869
10165
  </div>
9870
10166
  <div class="view-tabs" id="view-tabs"></div>
9871
10167
  <div class="viewport" id="viewport">
@@ -9934,6 +10230,14 @@ let currentFocusedSelector='';
9934
10230
  let currentFocusedValue='';
9935
10231
  let suppressFocus=false;
9936
10232
  let deviceMode='desktop';
10233
+
10234
+ // === Live Monitor stats ===
10235
+ const startTime=Date.now();
10236
+ let statFrames=0,statSent=0,statBytesRcvd=0,statBytesSent=0;
10237
+ let statLastFrameTime=0,statLatency=0,statFrameSize=0,statFps=0;
10238
+ let statFpsFrames=0,statFpsTimer=Date.now();
10239
+ let statEl=null;
10240
+
9937
10241
  let lastHoverSent=0;
9938
10242
 
9939
10243
  const $=id=>document.getElementById(id);
@@ -9942,13 +10246,136 @@ const viewportEl=$('viewport'),cursorEl=$('cursor'),cursorLabelEl=$('cursor-labe
9942
10246
  const touchpadEl=$('touchpad'),toolbarEl=$('toolbar');
9943
10247
  const inputPanel=$('input-panel'),inputField=$('input-field'),inputLabel=$('input-label');
9944
10248
 
10249
+ // Init monitor elements after $ is defined
10250
+ statEl={
10251
+ fps:$('stat-fps'),frameSize:$('stat-frame-size'),latency:$('stat-latency'),
10252
+ bandwidth:$('stat-bandwidth'),frames:$('stat-frames'),sent:$('stat-sent'),
10253
+ ws:$('stat-ws'),viewport:$('stat-viewport'),uptime:$('stat-uptime'),
10254
+ };
10255
+ $('stats-btn').addEventListener('click',()=>{
10256
+ const panel=$('stats-panel');
10257
+ panel.style.display=panel.style.display==='none'?'block':'none';
10258
+ });
10259
+ $('reconnect-btn').addEventListener('click',()=>{
10260
+ const btn=$('reconnect-btn');
10261
+ btn.textContent='\u23F3';
10262
+ btn.style.background='#e74c3c';
10263
+ // Tell daemon to reconnect the remote CDP session
10264
+ sendMsg({type:'reconnect'});
10265
+ // Also reload the page after 2s to re-establish viewer WS
10266
+ setTimeout(()=>{location.reload();},2000);
10267
+ });
10268
+
10269
+ // === Snapshot feature ===
10270
+ // Click \u{1F4F7} \u2192 daemon sends CDP captureScreenshot \u2192 returns high-quality image
10271
+ // Preview shows in bottom-right for 5s with countdown \u2192 click to fullscreen
10272
+ var snapshotDataUrl=null;
10273
+ var snapshotCountdownTimer=null;
10274
+ $('snapshot-btn').addEventListener('click',function(){
10275
+ var btn=$('snapshot-btn');
10276
+ btn.textContent='\u23F3';
10277
+ // Request high-quality screenshot from daemon
10278
+ sendMsg({type:'snapshot_request',format:'webp',quality:90});
10279
+ });
10280
+ // Handle snapshot response
10281
+ // (added to ws.onmessage handler below)
10282
+ var snapshotPendingHandler=function(msg){
10283
+ if(msg.type==='snapshot_result'&&msg.data){
10284
+ var data=msg.data;
10285
+ snapshotDataUrl='data:image/'+(data.format||'png')+';base64,'+data.data;
10286
+ var img=$('snapshot-img');
10287
+ img.src=snapshotDataUrl;
10288
+ var preview=$('snapshot-preview');
10289
+ preview.style.display='block';
10290
+ $('snapshot-btn').textContent='\u{1F4F7}';
10291
+ // Start 5s countdown
10292
+ var remaining=5;
10293
+ var progress=$('snapshot-progress');
10294
+ var countdownEl=$('snapshot-countdown');
10295
+ if(snapshotCountdownTimer) clearInterval(snapshotCountdownTimer);
10296
+ progress.style.width='100%';
10297
+ progress.style.background='#2ecc71';
10298
+ countdownEl.textContent=remaining+'s';
10299
+ snapshotCountdownTimer=setInterval(function(){
10300
+ remaining--;
10301
+ countdownEl.textContent=remaining+'s';
10302
+ progress.style.width=(remaining/5*100)+'%';
10303
+ if(remaining<=0){
10304
+ clearInterval(snapshotCountdownTimer);
10305
+ preview.style.display='none';
10306
+ }
10307
+ },1000);
10308
+ // Hover to pause countdown
10309
+ preview.onmouseenter=function(){if(snapshotCountdownTimer){clearInterval(snapshotCountdownTimer);countdownEl.textContent='\u23F8';}};
10310
+ preview.onmouseleave=function(){
10311
+ if(snapshotCountdownTimer) clearInterval(snapshotCountdownTimer);
10312
+ progress.style.width=(remaining/5*100)+'%';
10313
+ snapshotCountdownTimer=setInterval(function(){
10314
+ remaining--;
10315
+ countdownEl.textContent=remaining+'s';
10316
+ progress.style.width=(remaining/5*100)+'%';
10317
+ if(remaining<=0){clearInterval(snapshotCountdownTimer);preview.style.display='none';}
10318
+ },1000);
10319
+ };
10320
+ }
10321
+ };
10322
+ // Click preview \u2192 fullscreen
10323
+ $('snapshot-preview').addEventListener('click',function(){
10324
+ if(!snapshotDataUrl) return;
10325
+ $('snapshot-full-img').src=snapshotDataUrl;
10326
+ $('snapshot-fullscreen').style.display='flex';
10327
+ if(snapshotCountdownTimer){clearInterval(snapshotCountdownTimer);}
10328
+ });
10329
+ // Click fullscreen \u2192 close
10330
+ $('snapshot-fullscreen').addEventListener('click',function(){
10331
+ this.style.display='none';
10332
+ });
10333
+ // Auto-detect dead connection: if no frames for 30s, show warning
10334
+ let lastFrameAt=Date.now();
10335
+ setInterval(()=>{
10336
+ if(connected && Date.now()-lastFrameAt>30000 && statFrames>0){
10337
+ const btn=$('reconnect-btn');
10338
+ btn.style.background='#e74c3c';
10339
+ btn.textContent='\u{1F504}!';
10340
+ btn.title='\u8FDE\u63A5\u53EF\u80FD\u5DF2\u65AD\u5F00\uFF0C\u70B9\u51FB\u91CD\u8FDE';
10341
+ }
10342
+ },5000);
10343
+ setInterval(()=>{
10344
+ const now=Date.now();
10345
+ const elapsed=now-statFpsTimer;
10346
+ if(elapsed>=1000){
10347
+ statFps=Math.round(statFpsFrames*1000/elapsed);
10348
+ statFpsFrames=0;
10349
+ statFpsTimer=now;
10350
+ const bw=Math.round((statBytesRcvd+statBytesSent)/1024);
10351
+ statEl.fps.textContent=statFps;
10352
+ statEl.fps.style.color=statFps>=15?'#2ecc71':statFps>=5?'#f39c12':'#e74c3c';
10353
+ statEl.frameSize.textContent=Math.round(statFrameSize/1024)+'KB';
10354
+ statEl.latency.textContent=statLatency+'ms';
10355
+ statEl.latency.style.color=statLatency<100?'#2ecc71':statLatency<300?'#f39c12':'#e74c3c';
10356
+ statEl.bandwidth.textContent=bw+'KB/s';
10357
+ statEl.frames.textContent=statFrames;
10358
+ statEl.sent.textContent=statSent;
10359
+ statEl.ws.textContent=connected?'connected':'disconnected';
10360
+ statEl.ws.style.color=connected?'#2ecc71':'#e74c3c';
10361
+ statEl.viewport.textContent=remoteViewport.width+'x'+remoteViewport.height;
10362
+ statEl.uptime.textContent=Math.round((now-startTime)/1000)+'s';
10363
+ statBytesRcvd=0;statBytesSent=0;
10364
+ }
10365
+ },200);
10366
+
9945
10367
  function resizeCanvas(){
9946
10368
  const box=viewportEl.getBoundingClientRect();
9947
- const bw=box.width;
9948
- if(!bw||!remoteViewport.width||!remoteViewport.height) return;
10369
+ const bw=box.width,bh=box.height;
10370
+ if(!bw||!bh||!remoteViewport.width||!remoteViewport.height) return;
9949
10371
  const vpAspect=remoteViewport.width/remoteViewport.height;
9950
- const cw=bw;
9951
- const ch=bw/vpAspect;
10372
+ // Fit the remote viewport inside the available box without cropping:
10373
+ // pick the scale that keeps both dimensions within bounds.
10374
+ const scaleByWidth=bw/remoteViewport.width;
10375
+ const scaleByHeight=bh/remoteViewport.height;
10376
+ const scale=Math.min(scaleByWidth,scaleByHeight);
10377
+ const cw=remoteViewport.width*scale;
10378
+ const ch=remoteViewport.height*scale;
9952
10379
  if(canvas.width!==Math.round(cw)||canvas.height!==Math.round(ch)){
9953
10380
  canvas.width=Math.round(cw);
9954
10381
  canvas.height=Math.round(ch);
@@ -9981,6 +10408,10 @@ function connectWS(){
9981
10408
  connEl.textContent='WS';
9982
10409
  if(deviceMode==='desktop') createPCKeyboard();
9983
10410
  checkFocus();
10411
+ // Apply crop from URL params (?crop=x,y,w,h or ?selector=.cls)
10412
+ setTimeout(applyUrlCrop,500);
10413
+ // Start connection health check \u2014 auto-reconnect on failure
10414
+ startHealthCheck();
9984
10415
  };
9985
10416
  ws.binaryType='arraybuffer';
9986
10417
  ws.onmessage=(e)=>{
@@ -9991,13 +10422,31 @@ function connectWS(){
9991
10422
  updateViewTabs(_m.views);
9992
10423
  return;
9993
10424
  }
10425
+ if(_m.type==='health_pong'){
10426
+ healthCheckPending=false;
10427
+ var btn=$('reconnect-btn');
10428
+ if(btn.textContent!=='\u{1F504}'){btn.style.background='#0f3460';btn.textContent='\u{1F504}';}
10429
+ return;
10430
+ }
10431
+ if(_m.type==='snapshot_result'){
10432
+ if(snapshotPendingHandler) snapshotPendingHandler(_m);
10433
+ return;
10434
+ }
9994
10435
  }
9995
10436
  if(e.data instanceof ArrayBuffer){
9996
10437
  const buf=new Uint8Array(e.data);
10438
+ statBytesRcvd+=buf.length;
9997
10439
  const headerLen=(buf[0]<<24)|(buf[1]<<16)|(buf[2]<<8)|buf[3];
9998
10440
  const header=JSON.parse(new TextDecoder().decode(buf.slice(4,4+headerLen)));
9999
10441
  const jpegData=buf.slice(4+headerLen);
10000
10442
  if(header.type==='screenshot'){
10443
+ // === Monitor: track frame stats ===
10444
+ statFrames++;statFpsFrames++;
10445
+ lastFrameAt=Date.now();
10446
+ statFrameSize=jpegData.length;
10447
+ if(header.data.timestamp){
10448
+ statLatency=Math.max(0,Date.now()-header.data.timestamp);
10449
+ }
10001
10450
  const blob=new Blob([jpegData],{type:'image/jpeg'});
10002
10451
  createImageBitmap(blob).then(function(bmp){
10003
10452
  drawFrame(bmp);
@@ -10139,6 +10588,8 @@ function updateViewTabs(views){
10139
10588
  selectView('main');
10140
10589
  }
10141
10590
  renderViewTabs();
10591
+ // Auto-select view matching ?selector= param
10592
+ if(pendingSelector) trySelectBySelector();
10142
10593
  }
10143
10594
  function renderViewTabs(){
10144
10595
  if(detectedViews.length===0){
@@ -10227,7 +10678,12 @@ function selectView(vid){
10227
10678
  }
10228
10679
 
10229
10680
  function sendMsg(obj){
10230
- if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify(obj));
10681
+ if(ws&&ws.readyState===WebSocket.OPEN){
10682
+ const data=JSON.stringify(obj);
10683
+ ws.send(data);
10684
+ statSent++;
10685
+ statBytesSent+=data.length;
10686
+ }
10231
10687
  }
10232
10688
 
10233
10689
  function parseFocusHash(){
@@ -10237,6 +10693,107 @@ function parseFocusHash(){
10237
10693
  return m?decodeURIComponent(m[1]):null;
10238
10694
  }
10239
10695
 
10696
+ // Parse crop/view parameters from URL query string.
10697
+ // Supports:
10698
+ // ?selector=.login-modal \u2014 crop to element matching CSS selector
10699
+ // ?crop=100,200,500,400 \u2014 crop to x,y,width,height (remote viewport coords)
10700
+ function parseUrlCrop(){
10701
+ var params=new URLSearchParams(location.search);
10702
+
10703
+ // ?crop=x,y,w,h
10704
+ var cropParam=params.get('crop');
10705
+ if(cropParam){
10706
+ var parts=cropParam.split(',').map(function(s){return parseInt(s.trim(),10)});
10707
+ if(parts.length===4&&parts.every(function(n){return!isNaN(n)})){
10708
+ return {type:'rect',rect:{x:parts[0],y:parts[1],width:parts[2],height:parts[3]}};
10709
+ }
10710
+ }
10711
+
10712
+ // ?selector=.login-modal
10713
+ var selParam=params.get('selector')||params.get('sel');
10714
+ if(selParam){
10715
+ return {type:'selector',selector:selParam};
10716
+ }
10717
+
10718
+ return null;
10719
+ }
10720
+
10721
+ // Apply URL crop on connect \u2014 sends select_view immediately after WS opens
10722
+ function applyUrlCrop(){
10723
+ var cropSpec=parseUrlCrop();
10724
+ if(!cropSpec) return;
10725
+
10726
+ if(cropSpec.type==='rect'){
10727
+ // Direct rect \u2014 send select_view immediately
10728
+ sendMsg({type:'select_view',rect:cropSpec.rect});
10729
+ if(!originalViewport) originalViewport={width:remoteViewport.width,height:remoteViewport.height};
10730
+ remoteViewport={width:cropSpec.rect.width,height:cropSpec.rect.height};
10731
+ viewportLocked=true;
10732
+ resizeCanvas();
10733
+ return;
10734
+ }
10735
+
10736
+ // Selector \u2014 wait for views_update from element-monitor, then auto-select matching view
10737
+ if(cropSpec.type==='selector'){
10738
+ pendingSelector=cropSpec.selector;
10739
+ // Try match immediately in case views already arrived
10740
+ trySelectBySelector();
10741
+ }
10742
+ }
10743
+ // Connection health check \u2014 send a ping every 15s.
10744
+ // If no response in 5s, auto-reconnect the session.
10745
+ var healthCheckTimer=null;
10746
+ var healthCheckPending=false;
10747
+ var autoReconnecting=false;
10748
+ function startHealthCheck(){
10749
+ if(healthCheckTimer) clearInterval(healthCheckTimer);
10750
+ healthCheckTimer=setInterval(function(){
10751
+ if(!connected) return;
10752
+ if(healthCheckPending){
10753
+ // Previous ping never got a response \u2192 connection is dead \u2192 auto-reconnect
10754
+ if(!autoReconnecting){
10755
+ autoReconnecting=true;
10756
+ var btn=$('reconnect-btn');
10757
+ btn.style.background='#e74c3c';
10758
+ btn.textContent='\u{1F504}!';
10759
+ btn.title='\u8FDE\u63A5\u5DF2\u65AD\u5F00\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u8FDE...';
10760
+ // Auto-reconnect: send reconnect then reload after 2s
10761
+ sendMsg({type:'reconnect'});
10762
+ setTimeout(function(){location.reload();},2000);
10763
+ }
10764
+ return;
10765
+ }
10766
+ healthCheckPending=true;
10767
+ sendMsg({type:'health_ping',ts:Date.now()});
10768
+ setTimeout(function(){
10769
+ if(healthCheckPending){
10770
+ // Still pending after 5s = dead
10771
+ healthCheckPending=false;
10772
+ var btn2=$('reconnect-btn');
10773
+ btn2.style.background='#e74c3c';
10774
+ btn2.textContent='\u{1F504}!';
10775
+ }
10776
+ },5000);
10777
+ },15000);
10778
+ }
10779
+ function trySelectBySelector(){
10780
+ if(!pendingSelector||!detectedViews||detectedViews.length===0) return;
10781
+ // Match by label containing selector text, or id/class match
10782
+ var target=detectedViews.find(function(v){
10783
+ var label=(v.label||'').toLowerCase();
10784
+ var id=(v.id||'').toLowerCase();
10785
+ return label.indexOf(pendingSelector.toLowerCase())>=0||id.indexOf(pendingSelector.toLowerCase())>=0;
10786
+ });
10787
+ if(target){
10788
+ sendMsg({type:'select_view',rect:target.rect});
10789
+ if(!originalViewport) originalViewport={width:remoteViewport.width,height:remoteViewport.height};
10790
+ remoteViewport={width:target.rect.width,height:target.rect.height};
10791
+ viewportLocked=true;
10792
+ resizeCanvas();
10793
+ pendingSelector=null; // matched, stop looking
10794
+ }
10795
+ }
10796
+
10240
10797
  function checkFocus(){
10241
10798
  var sel=parseFocusHash();
10242
10799
  if(sel&&ws&&ws.readyState===WebSocket.OPEN){
@@ -10336,10 +10893,16 @@ function createPCKeyboard(){
10336
10893
  document.addEventListener('keydown',function(e){
10337
10894
  if(deviceMode!=='desktop') return;
10338
10895
  if(e.isComposing||_pcComposing) return;
10339
- var tag=e.target&&e.target.tagName;
10340
- if(tag==='INPUT'||tag==='TEXTAREA') return;
10341
10896
  var key=e.key;
10342
10897
  if(!key) return;
10898
+
10899
+ // Control keys (Backspace, Delete, Enter, Tab, arrows, etc.) must always
10900
+ // be forwarded even when focus is in an input/textarea \u2014 otherwise the user
10901
+ // can't edit text in the remote browser's input fields.
10902
+ var isControlKey=key.length>1; // Backspace, Delete, Enter, Tab, ArrowX, etc.
10903
+ var tag=e.target&&e.target.tagName;
10904
+ if(!isControlKey&&(tag==='INPUT'||tag==='TEXTAREA')) return;
10905
+
10343
10906
  e.preventDefault();
10344
10907
  sendMsg({type:'input_keyboard',action:'down',key:key});
10345
10908
  if(key.length===1){
@@ -10350,10 +10913,13 @@ function createPCKeyboard(){
10350
10913
  document.addEventListener('keyup',function(e){
10351
10914
  if(deviceMode!=='desktop') return;
10352
10915
  if(e.isComposing||_pcComposing) return;
10353
- var tag=e.target&&e.target.tagName;
10354
- if(tag==='INPUT'||tag==='TEXTAREA') return;
10355
10916
  var key=e.key;
10356
10917
  if(!key) return;
10918
+
10919
+ var isControlKey=key.length>1;
10920
+ var tag=e.target&&e.target.tagName;
10921
+ if(!isControlKey&&(tag==='INPUT'||tag==='TEXTAREA')) return;
10922
+
10357
10923
  e.preventDefault();
10358
10924
  sendMsg({type:'input_keyboard',action:'up',key:key});
10359
10925
  sendUserActivity();
@@ -10406,9 +10972,20 @@ viewportEl.addEventListener('mouseleave',()=>{
10406
10972
  viewportEl.addEventListener('wheel',(e)=>{
10407
10973
  if(deviceMode!=='desktop') return;
10408
10974
  e.preventDefault();
10409
- sendMsg({type:'scroll',deltaX:e.deltaX,deltaY:e.deltaY});
10410
- sendUserActivity();
10975
+ // Accumulate wheel deltas and send at most every 50ms to avoid flooding
10976
+ // the remote with hundreds of scroll commands during a single swipe.
10977
+ pendingScrollX+=e.deltaX;
10978
+ pendingScrollY+=e.deltaY;
10979
+ if(!scrollThrottled){
10980
+ scrollThrottled=true;
10981
+ sendMsg({type:'scroll',deltaX:Math.round(pendingScrollX),deltaY:Math.round(pendingScrollY)});
10982
+ sendUserActivity();
10983
+ pendingScrollX=0;
10984
+ pendingScrollY=0;
10985
+ setTimeout(function(){scrollThrottled=false;},50);
10986
+ }
10411
10987
  },{passive:false});
10988
+ var scrollThrottled=false,pendingScrollX=0,pendingScrollY=0;
10412
10989
 
10413
10990
  // --- Mobile Touchpad (incremental, smooth) ---
10414
10991
  let tpStartPos=null;
@@ -10905,6 +11482,13 @@ async function main() {
10905
11482
  rpcHandler.setPreviewWS(previewWS);
10906
11483
  previewWS.on("screencast-started", (sid) => log(`Preview screencast started: ${sid}`));
10907
11484
  previewWS.on("screencast-stopped", (sid) => log(`Preview screencast stopped: ${sid}`));
11485
+ previewWS.on("reconnect-request", ({ sessionId }) => {
11486
+ if (!sessionId) return;
11487
+ log(`Reconnect request from viewer for session: ${sessionId}`);
11488
+ rpcHandler.handleReconnect(sessionId).catch((e) => {
11489
+ log(`Reconnect failed: ${e instanceof Error ? e.message : String(e)}`);
11490
+ });
11491
+ });
10908
11492
  mkdirSync5(CONFIG_DIR3, { recursive: true });
10909
11493
  writeFileSync5(join7(CONFIG_DIR3, "daemon.json"), JSON.stringify({
10910
11494
  port: daemonPort,
@@ -10913,6 +11497,13 @@ async function main() {
10913
11497
  }, null, 2));
10914
11498
  console.log(`xbrowser daemon started (pid: ${process.pid}, port: ${daemonPort})`);
10915
11499
  log("Daemon main started successfully");
11500
+ const loader = await getPluginLoader();
11501
+ const sites = loader.getCore().loader.getSites();
11502
+ log(`Loaded ${sites.length} plugins`);
11503
+ if (sites.length > 0) {
11504
+ const names = sites.map((s) => s.name || "unknown").join(", ");
11505
+ log(`Plugins: ${names}`);
11506
+ }
10916
11507
  const shutdown = () => {
10917
11508
  log("Received shutdown signal, stopping");
10918
11509
  previewWS.stop().catch(() => {