@xbrowser/cli 1.9.7 → 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-JXIJF4VL.js → browser-FFYPFTBV.js} +1 -1
  3. package/dist/{browser-ALPLJNH2.js → browser-RHWUAMTB.js} +2 -2
  4. package/dist/{browser-ZPQP6VW7.js → browser-YCO4GJMZ.js} +2 -2
  5. package/dist/{cdp-driver-MI7VCE44.js → cdp-driver-2T4P4ZE2.js} +103 -2
  6. package/dist/{cdp-driver-6EVYLHL2.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-CH5ZQV64.js → chunk-4452SPFI.js} +928 -18
  10. package/dist/{chunk-C4O73DQL.js → chunk-5UGR6MUK.js} +928 -18
  11. package/dist/{chunk-37SHZSUR.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-74S7UUI2.js → chunk-GUTBIYFW.js} +117 -17
  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-VJNMAWPZ.js → chunk-RRBXV7KE.js} +103 -2
  20. package/dist/{chunk-5JSOGINQ.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 +661 -70
  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 +172 -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-7GXI42FO.js → session-recorder-H3KEYU26.js} +1 -1
  37. package/dist/{session-recorder-RFOPX4SF.js → session-recorder-QRZMKFVL.js} +1 -1
  38. package/dist/{session-replayer-3HEIJBJ2.js → session-replayer-LJUC4TI7.js} +25 -8
  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-C4O73DQL.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-37SHZSUR.js";
33
- import "./chunk-VJNMAWPZ.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-ALPLJNH2.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) {
@@ -8167,7 +8318,7 @@ function createRPCHandler() {
8167
8318
  const sessionName = params.session || "default";
8168
8319
  const slowMo = params.slowMo || 1;
8169
8320
  if (!file) {
8170
- return { ok: false, success: false, duration: 0, eventsPlayed: 0, totalEvents: 0, errors: [{ eventIndex: -1, error: "Missing file parameter" }] };
8321
+ return normalizeReplayResult({ ok: false, errors: [{ eventIndex: -1, error: "Missing file parameter" }] });
8171
8322
  }
8172
8323
  let rawContent;
8173
8324
  let parsed;
@@ -8180,55 +8331,54 @@ function createRPCHandler() {
8180
8331
  parsed = yaml2.parse(rawContent);
8181
8332
  }
8182
8333
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8183
- return { ok: false, success: false, duration: 0, eventsPlayed: 0, totalEvents: 0, errors: [{ eventIndex: -1, error: `File "${file}" does not contain a valid recording. Expected a JSON/YAML object with an "actions" or "events" array, got ${parsed === null ? "empty content" : typeof parsed}.` }] };
8334
+ return normalizeReplayResult({ ok: false, errors: [{ eventIndex: -1, error: `File "${file}" does not contain a valid recording. Expected a JSON/YAML object with an "actions" or "events" array, got ${parsed === null ? "empty content" : typeof parsed}.` }] });
8184
8335
  }
8185
8336
  } catch (e) {
8186
8337
  const msg = e instanceof Error ? e.message : String(e);
8187
- return { ok: false, success: false, duration: 0, eventsPlayed: 0, totalEvents: 0, errors: [{ eventIndex: -1, error: `Failed to parse "${file}" as JSON or YAML: ${msg.split("\n")[0]}` }] };
8338
+ return normalizeReplayResult({ ok: false, errors: [{ eventIndex: -1, error: `Failed to parse "${file}" as JSON or YAML: ${msg.split("\n")[0]}` }] });
8188
8339
  }
8189
8340
  const session = findSession(sessionName);
8190
8341
  if (!session) {
8191
- return { ok: false, success: false, duration: 0, eventsPlayed: 0, totalEvents: 0, errors: [{ eventIndex: -1, error: `Session not found: ${sessionName}. Open a browser first: xbrowser goto <url> --session ${sessionName}` }] };
8342
+ return normalizeReplayResult({ ok: false, errors: [{ eventIndex: -1, error: `Session not found: ${sessionName}. Open a browser first: xbrowser goto <url> --session ${sessionName}` }] });
8192
8343
  }
8193
8344
  if (!session.page) {
8194
- return { ok: false, success: false, duration: 0, eventsPlayed: 0, totalEvents: 0, errors: [{ eventIndex: -1, error: "Session has no page: " + sessionName }] };
8345
+ return normalizeReplayResult({ ok: false, errors: [{ eventIndex: -1, error: "Session has no page: " + sessionName }] });
8195
8346
  }
8196
8347
  const isNewFormat = Array.isArray(parsed.actions);
8197
8348
  if (isNewFormat) {
8198
8349
  try {
8199
8350
  const replayErrors = [];
8200
- const { SessionReplayer } = await import("./session-replayer-3HEIJBJ2.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,
8204
8355
  onStep: (action, index, total) => {
8205
8356
  console.log(`[replay] Step ${index + 1}/${total}: ${action.type} ${action.element?.selector || action.url || ""}`);
8206
8357
  },
8207
- onError: (action, error) => {
8358
+ onError: (action, error, index) => {
8208
8359
  const msg = `[${action?.type || "unknown"}] ${error?.message || String(error)}`;
8209
8360
  console.error("[replay] Error at step:", msg);
8210
- replayErrors.push({ eventIndex: -1, error: msg });
8361
+ replayErrors.push({ eventIndex: index, error: msg });
8211
8362
  }
8212
8363
  });
8213
8364
  if (!Array.isArray(parsed.actions) || typeof parsed.startUrl !== "string") {
8214
- return { ok: false, success: false, duration: 0, eventsPlayed: 0, totalEvents: 0, errors: [{ eventIndex: -1, error: "Invalid recording format: missing actions or startUrl" }] };
8365
+ return normalizeReplayResult({ ok: false, errors: [{ eventIndex: -1, error: "Invalid recording format: missing actions or startUrl" }] });
8215
8366
  }
8216
8367
  await replayer.load(parsed);
8217
8368
  const startTime = Date.now();
8218
8369
  const result2 = await replayer.run();
8219
8370
  const duration = Date.now() - startTime;
8220
- return {
8371
+ return normalizeReplayResult({
8221
8372
  ok: result2.failed === 0,
8222
- success: result2.failed === 0,
8223
8373
  duration,
8224
8374
  eventsPlayed: result2.success,
8225
8375
  totalEvents: result2.success + result2.failed + result2.skipped,
8226
8376
  errors: replayErrors
8227
- };
8377
+ });
8228
8378
  } catch (e) {
8229
8379
  const msg = e instanceof Error ? e.message : String(e);
8230
8380
  console.error("[replay] SessionReplayer error:", msg);
8231
- return { ok: false, success: false, duration: 0, eventsPlayed: 0, totalEvents: 0, errors: [{ eventIndex: -1, error: "Replay failed: " + msg }] };
8381
+ return normalizeReplayResult({ ok: false, errors: [{ eventIndex: -1, error: "Replay failed: " + msg }] });
8232
8382
  }
8233
8383
  }
8234
8384
  const engine = PlaybackEngine.fromFile(session.page, file);
@@ -8242,7 +8392,21 @@ function createRPCHandler() {
8242
8392
  });
8243
8393
  }
8244
8394
  });
8245
- return { ok: result.success, ...result };
8395
+ return normalizeReplayResult({ ok: result.success, ...result });
8396
+ }
8397
+ function normalizeReplayResult(input) {
8398
+ const errors = Array.isArray(input.errors) ? input.errors : [];
8399
+ const eventsPlayed = typeof input.eventsPlayed === "number" ? input.eventsPlayed : 0;
8400
+ const totalEvents = typeof input.totalEvents === "number" ? input.totalEvents : eventsPlayed + errors.length;
8401
+ const ok26 = input.ok ?? errors.length === 0;
8402
+ return {
8403
+ ok: ok26,
8404
+ success: ok26,
8405
+ duration: typeof input.duration === "number" ? input.duration : 0,
8406
+ eventsPlayed,
8407
+ totalEvents,
8408
+ errors
8409
+ };
8246
8410
  }
8247
8411
  async function handleViewerCheckSelector(params) {
8248
8412
  const name = params.name || "default";
@@ -8268,10 +8432,37 @@ import { EventEmitter as EventEmitter2 } from "events";
8268
8432
  // src/stream/frame-processor.ts
8269
8433
  import sharp from "sharp";
8270
8434
  var STATE_CONFIGS = {
8271
- user_interacting: { format: "jpeg", quality: 85, maxFps: 60, scale: 0.8 },
8272
- screen_moving: { format: "jpeg", quality: 85, maxFps: 8, scale: 0.8 },
8273
- 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 }
8274
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
+ }
8275
8466
  var StreamStateManager = class {
8276
8467
  currentState = "static";
8277
8468
  isUserInteracting = false;
@@ -8280,9 +8471,11 @@ var StreamStateManager = class {
8280
8471
  frameInterval = Infinity;
8281
8472
  onStateChange = null;
8282
8473
  staticTimer = null;
8283
- USER_INTERACTION_TIMEOUT_MS = 1e3;
8474
+ USER_INTERACTION_TIMEOUT_MS = 2e3;
8475
+ // 1s → 2s: linger in interacting longer
8284
8476
  SCREEN_MOVING_THRESHOLD_MS = 1e3;
8285
- STATIC_TIMEOUT_MS = 1500;
8477
+ STATIC_TIMEOUT_MS = 3e3;
8478
+ // 1.5s → 3s: don't rush to static
8286
8479
  setStateChangeCallback(callback) {
8287
8480
  this.onStateChange = callback;
8288
8481
  }
@@ -8391,12 +8584,25 @@ var FrameProcessor = class {
8391
8584
  }
8392
8585
  let processed = sharp(buffer);
8393
8586
  if (cropConfig) {
8394
- processed = processed.extract({
8395
- left: Math.round(cropConfig.x),
8396
- top: Math.round(cropConfig.y),
8397
- width: Math.round(cropConfig.width),
8398
- height: Math.round(cropConfig.height)
8399
- });
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
+ }
8400
8606
  }
8401
8607
  if (needsResize) {
8402
8608
  const newWidth = Math.round((viewportWidth ?? 0) * config.scale);
@@ -8545,11 +8751,20 @@ var StreamCoordinator = class {
8545
8751
  const crop = this.sessionCrops.get(sessionId);
8546
8752
  const cropConfig = crop ? crop.box : void 0;
8547
8753
  const effectiveViewport = crop ? { width: crop.box.width, height: crop.box.height } : frameViewport;
8548
- const processedBuffer = await this.frameProcessor.process(
8549
- frameData,
8754
+ const adjustedConfig = adjustConfigForSize(
8550
8755
  config,
8551
8756
  effectiveViewport.width,
8552
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,
8553
8768
  cropConfig
8554
8769
  );
8555
8770
  const header = Buffer.from(JSON.stringify({
@@ -8721,7 +8936,18 @@ var ElementMonitor = class {
8721
8936
  }
8722
8937
  return results;
8723
8938
  });
8724
- 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) => ({
8725
8951
  id: "el-" + i + "-" + (e.id || e.tag),
8726
8952
  label: e.id || e.cls || e.tag,
8727
8953
  rect: e.rect
@@ -8780,10 +9006,10 @@ var SessionManager = class extends EventEmitter {
8780
9006
  this.screencasts.set(sessionId, {
8781
9007
  capturer: new ScreencastCapturer({
8782
9008
  interval: options?.interval ?? 100,
8783
- quality: options?.quality ?? 80,
9009
+ quality: options?.quality ?? 40,
8784
9010
  type: options?.type ?? "jpeg",
8785
- width: options?.width ?? 1920,
8786
- height: options?.height ?? 1080
9011
+ width: options?.width ?? 1024,
9012
+ height: options?.height ?? 576
8787
9013
  }),
8788
9014
  page,
8789
9015
  clientCount: 0
@@ -8924,6 +9150,8 @@ var SessionManager = class extends EventEmitter {
8924
9150
  this.resetStaticSnapshotTimer(sessionId);
8925
9151
  });
8926
9152
  await sc.capturer.startCapture(sc.page, sessionId, onFrame);
9153
+ this.streamCoordinator.takeStaticSnapshot(sessionId, sc.page, sc.clientCount).catch(() => {
9154
+ });
8927
9155
  }
8928
9156
  async stopCapturer(sessionId) {
8929
9157
  const sc = this.screencasts.get(sessionId);
@@ -9374,6 +9602,15 @@ var WSServer = class extends EventEmitter2 {
9374
9602
  registerSession(sessionId, page, options) {
9375
9603
  this.sessionManager.registerSession(sessionId, page, options);
9376
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
+ }
9377
9614
  unregisterSession(sessionId) {
9378
9615
  this.sessionManager.unregisterSession(sessionId);
9379
9616
  }
@@ -9396,7 +9633,7 @@ var WSServer = class extends EventEmitter2 {
9396
9633
  });
9397
9634
  }
9398
9635
  this.sessionManager.incrementClientCount(sessionId);
9399
- if (this.streamCoordinator.getLastFrameViewport() && this.sessionManager.isCapturerActive(sessionId)) {
9636
+ if (this.streamCoordinator.getLastFrameViewport()) {
9400
9637
  this.streamCoordinator.replayLastFrame(sessionId).catch(() => {
9401
9638
  });
9402
9639
  }
@@ -9406,7 +9643,6 @@ var WSServer = class extends EventEmitter2 {
9406
9643
  if (count === 0 && this.sessionManager.isCapturerActive(sessionId)) {
9407
9644
  this.sessionManager.stopCapturer(sessionId).then(() => {
9408
9645
  this.streamCoordinator.resetFrameRate();
9409
- this.streamCoordinator.clearLastFrame();
9410
9646
  this.emit("screencast-stopped", sessionId);
9411
9647
  }).catch(() => {
9412
9648
  this.emit("screencast-stopped", sessionId);
@@ -9582,6 +9818,31 @@ var WSServer = class extends EventEmitter2 {
9582
9818
  this.emit("human-solved", { sessionId: sessionId ?? null, clientId });
9583
9819
  return;
9584
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
+ }
9585
9846
  const ctx = {
9586
9847
  clientId,
9587
9848
  sessionId,
@@ -9668,7 +9929,7 @@ function alignHTML(sessionId, _host) {
9668
9929
  html,body{height:100%;overflow:hidden;background:#000}
9669
9930
  .viewport{position:absolute;top:0;left:0;right:0;bottom:0;overflow:hidden}
9670
9931
  .viewport img#screen{display:block;width:100%;height:100%;object-fit:contain;background:#111}
9671
- .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)}
9672
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}
9673
9934
  .grid{position:fixed;pointer-events:none;z-index:9998;top:0;left:0;right:0;bottom:0;display:none}
9674
9935
  </style></head><body>
@@ -9760,9 +10021,17 @@ viewportEl.addEventListener('mousemove',function(e){
9760
10021
  var r=viewerToRemote(e.clientX,e.clientY);
9761
10022
  var rx=Math.max(0,Math.min(remoteViewport.width,r.x));
9762
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
9763
10025
  setCrosshair(rx,ry);
9764
- 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
+ }
9765
10033
  });
10034
+ var moveThrottled=false;
9766
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}));});
9767
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}));});
9768
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});
@@ -9790,7 +10059,7 @@ body{display:flex;flex-direction:column;touch-action:manipulation}
9790
10059
  .bar .dot.ok{background:#2ecc71}
9791
10060
  .bar .url{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8899aa;font-size:12px}
9792
10061
  .bar .conn{color:#556;font-size:11px;flex-shrink:0}
9793
- .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}
9794
10063
  .viewport canvas#screen{display:none;user-select:none;-webkit-user-drag:none}
9795
10064
  .waiting{color:#556;font-size:14px;text-align:center}
9796
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}
@@ -9853,6 +10122,33 @@ body{display:flex;flex-direction:column;touch-action:manipulation}
9853
10122
  <span class="conn" id="conn"></span>
9854
10123
  <button class="mode-btn" id="mode-btn" title="Switch mode (Mobile/Tablet/PC)">\u{1F4F1}</button>
9855
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>
9856
10152
  </div>
9857
10153
  <div class="view-tabs" id="view-tabs"></div>
9858
10154
  <div class="viewport" id="viewport">
@@ -9921,6 +10217,14 @@ let currentFocusedSelector='';
9921
10217
  let currentFocusedValue='';
9922
10218
  let suppressFocus=false;
9923
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
+
9924
10228
  let lastHoverSent=0;
9925
10229
 
9926
10230
  const $=id=>document.getElementById(id);
@@ -9929,13 +10233,136 @@ const viewportEl=$('viewport'),cursorEl=$('cursor'),cursorLabelEl=$('cursor-labe
9929
10233
  const touchpadEl=$('touchpad'),toolbarEl=$('toolbar');
9930
10234
  const inputPanel=$('input-panel'),inputField=$('input-field'),inputLabel=$('input-label');
9931
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
+
9932
10354
  function resizeCanvas(){
9933
10355
  const box=viewportEl.getBoundingClientRect();
9934
- const bw=box.width;
9935
- if(!bw||!remoteViewport.width||!remoteViewport.height) return;
10356
+ const bw=box.width,bh=box.height;
10357
+ if(!bw||!bh||!remoteViewport.width||!remoteViewport.height) return;
9936
10358
  const vpAspect=remoteViewport.width/remoteViewport.height;
9937
- const cw=bw;
9938
- 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;
9939
10366
  if(canvas.width!==Math.round(cw)||canvas.height!==Math.round(ch)){
9940
10367
  canvas.width=Math.round(cw);
9941
10368
  canvas.height=Math.round(ch);
@@ -9968,6 +10395,10 @@ function connectWS(){
9968
10395
  connEl.textContent='WS';
9969
10396
  if(deviceMode==='desktop') createPCKeyboard();
9970
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();
9971
10402
  };
9972
10403
  ws.binaryType='arraybuffer';
9973
10404
  ws.onmessage=(e)=>{
@@ -9978,13 +10409,31 @@ function connectWS(){
9978
10409
  updateViewTabs(_m.views);
9979
10410
  return;
9980
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
+ }
9981
10422
  }
9982
10423
  if(e.data instanceof ArrayBuffer){
9983
10424
  const buf=new Uint8Array(e.data);
10425
+ statBytesRcvd+=buf.length;
9984
10426
  const headerLen=(buf[0]<<24)|(buf[1]<<16)|(buf[2]<<8)|buf[3];
9985
10427
  const header=JSON.parse(new TextDecoder().decode(buf.slice(4,4+headerLen)));
9986
10428
  const jpegData=buf.slice(4+headerLen);
9987
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
+ }
9988
10437
  const blob=new Blob([jpegData],{type:'image/jpeg'});
9989
10438
  createImageBitmap(blob).then(function(bmp){
9990
10439
  drawFrame(bmp);
@@ -10126,6 +10575,8 @@ function updateViewTabs(views){
10126
10575
  selectView('main');
10127
10576
  }
10128
10577
  renderViewTabs();
10578
+ // Auto-select view matching ?selector= param
10579
+ if(pendingSelector) trySelectBySelector();
10129
10580
  }
10130
10581
  function renderViewTabs(){
10131
10582
  if(detectedViews.length===0){
@@ -10214,7 +10665,12 @@ function selectView(vid){
10214
10665
  }
10215
10666
 
10216
10667
  function sendMsg(obj){
10217
- 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
+ }
10218
10674
  }
10219
10675
 
10220
10676
  function parseFocusHash(){
@@ -10224,6 +10680,107 @@ function parseFocusHash(){
10224
10680
  return m?decodeURIComponent(m[1]):null;
10225
10681
  }
10226
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
+
10227
10784
  function checkFocus(){
10228
10785
  var sel=parseFocusHash();
10229
10786
  if(sel&&ws&&ws.readyState===WebSocket.OPEN){
@@ -10323,10 +10880,16 @@ function createPCKeyboard(){
10323
10880
  document.addEventListener('keydown',function(e){
10324
10881
  if(deviceMode!=='desktop') return;
10325
10882
  if(e.isComposing||_pcComposing) return;
10326
- var tag=e.target&&e.target.tagName;
10327
- if(tag==='INPUT'||tag==='TEXTAREA') return;
10328
10883
  var key=e.key;
10329
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
+
10330
10893
  e.preventDefault();
10331
10894
  sendMsg({type:'input_keyboard',action:'down',key:key});
10332
10895
  if(key.length===1){
@@ -10337,10 +10900,13 @@ function createPCKeyboard(){
10337
10900
  document.addEventListener('keyup',function(e){
10338
10901
  if(deviceMode!=='desktop') return;
10339
10902
  if(e.isComposing||_pcComposing) return;
10340
- var tag=e.target&&e.target.tagName;
10341
- if(tag==='INPUT'||tag==='TEXTAREA') return;
10342
10903
  var key=e.key;
10343
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
+
10344
10910
  e.preventDefault();
10345
10911
  sendMsg({type:'input_keyboard',action:'up',key:key});
10346
10912
  sendUserActivity();
@@ -10393,9 +10959,20 @@ viewportEl.addEventListener('mouseleave',()=>{
10393
10959
  viewportEl.addEventListener('wheel',(e)=>{
10394
10960
  if(deviceMode!=='desktop') return;
10395
10961
  e.preventDefault();
10396
- sendMsg({type:'scroll',deltaX:e.deltaX,deltaY:e.deltaY});
10397
- 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
+ }
10398
10974
  },{passive:false});
10975
+ var scrollThrottled=false,pendingScrollX=0,pendingScrollY=0;
10399
10976
 
10400
10977
  // --- Mobile Touchpad (incremental, smooth) ---
10401
10978
  let tpStartPos=null;
@@ -10892,6 +11469,13 @@ async function main() {
10892
11469
  rpcHandler.setPreviewWS(previewWS);
10893
11470
  previewWS.on("screencast-started", (sid) => log(`Preview screencast started: ${sid}`));
10894
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
+ });
10895
11479
  mkdirSync5(CONFIG_DIR3, { recursive: true });
10896
11480
  writeFileSync5(join7(CONFIG_DIR3, "daemon.json"), JSON.stringify({
10897
11481
  port: daemonPort,
@@ -10900,6 +11484,13 @@ async function main() {
10900
11484
  }, null, 2));
10901
11485
  console.log(`xbrowser daemon started (pid: ${process.pid}, port: ${daemonPort})`);
10902
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
+ }
10903
11494
  const shutdown = () => {
10904
11495
  log("Received shutdown signal, stopping");
10905
11496
  previewWS.stop().catch(() => {