@mind-harness/mh-browser 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
2
3
  import z from "@deepseek-ai/schemastery";
3
4
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
@@ -7,10 +8,9 @@ import { assertSupportedJsonSchema, defineTool, validateJsonSchemaValue } from "
7
8
  import { createHash, randomUUID } from "node:crypto";
8
9
  import { parse } from "yaml";
9
10
  import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs";
10
- import { lstat, mkdir, open, readFile, readlink, realpath, unlink, writeFile } from "node:fs/promises";
11
+ import { access, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, unlink, writeFile } from "node:fs/promises";
11
12
  import { z as z$1 } from "zod";
12
- import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
13
- import { chromium } from "playwright";
13
+ import { Service } from "@deepseek-ai/cordis";
14
14
  import { execFile } from "node:child_process";
15
15
  import { homedir } from "node:os";
16
16
  import { fileURLToPath } from "node:url";
@@ -19,10 +19,11 @@ import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
19
19
  const browserConfig = z.object({
20
20
  presence: z.object({ maxBytes: z.number().step(1).min(1).required().description("Most bytes of page text one presence reminder may carry.") }).required().description("Pages kept in context across steps."),
21
21
  browser: z.union([
22
+ "auto",
22
23
  "chrome",
23
24
  "msedge",
24
25
  "chromium"
25
- ]).default("chrome").volatile().description("Which browser to drive. chromium is the one Playwright downloads. A new value restarts the browser once it is idle."),
26
+ ]).default("auto").volatile().description("Which browser to drive. auto keeps the one that launched last, or Chrome and then Edge. chromium is the one Playwright downloads. A new value restarts the browser once it is idle."),
26
27
  executablePath: z.string().volatile().description("Absolute path of the browser executable. `~` is expanded. Leave empty to locate one. A new value restarts the browser once it is idle."),
27
28
  profileDir: z.string().description("Browser profile directory, kept between runs. Defaults to mh/profiles/<dsh profile>/browser under the dsh home."),
28
29
  headless: z.boolean().default(false).description("Run the browser without a window.")
@@ -35,16 +36,22 @@ const browserConfig = z.object({
35
36
  function defaultProfileDir(profile, home = resolveDshHome()) {
36
37
  return profile === void 0 ? join(home, "mh", "browser") : join(home, "mh", "profiles", profile.name, "browser");
37
38
  }
39
+ /** One subdirectory of the default profile root per browser, so logins do not cross browsers. */
40
+ function profileDirFor(root, browser) {
41
+ return join(root, browser);
42
+ }
38
43
  //#endregion
39
44
  //#region lib/types/types.js
40
45
  var MhBrowserError = class extends Error {
41
46
  code;
42
47
  page;
43
- constructor(code, message, page) {
48
+ notFound;
49
+ constructor(code, message, page, notFound) {
44
50
  super(message);
45
51
  this.name = "MhBrowserError";
46
52
  this.code = code;
47
53
  if (page !== void 0) this.page = page;
54
+ if (notFound !== void 0) this.notFound = notFound;
48
55
  }
49
56
  };
50
57
  /** Owner of the person's own window. It belongs to no Session. */
@@ -557,13 +564,13 @@ function pageAttrs(view, screenshot) {
557
564
  screenshot === void 0 ? "" : attr$1("screenshot", screenshot)
558
565
  ].join("");
559
566
  }
560
- const BODY_LIMIT = 2e5;
567
+ const BODY_LIMIT$1 = 2e5;
561
568
  function pageBody(view) {
562
569
  const representation = view.representation;
563
570
  if (view.mediaType === void 0 || representation.kind !== "snapshot") return representation.text;
564
571
  if (view.body !== void 0) {
565
- if (view.body.length <= BODY_LIMIT) return view.body;
566
- return `${view.body.slice(0, BODY_LIMIT)}\nCut at ${String(BODY_LIMIT)} characters; use browser_download to save the whole file into the workspace.`;
572
+ if (view.body.length <= BODY_LIMIT$1) return view.body;
573
+ return `${view.body.slice(0, BODY_LIMIT$1)}\nCut at ${String(BODY_LIMIT$1)} characters; use browser_download to save the whole file into the workspace.`;
567
574
  }
568
575
  if (view.status !== void 0) return `The site answered HTTP ${String(view.status)}; the ${view.mediaType} body is not shown.`;
569
576
  return "Not an HTML page; use browser_download to save it into the workspace.";
@@ -2370,6 +2377,302 @@ function plainObject(keys) {
2370
2377
  return z$1.custom((value) => value !== null && typeof value === "object" && keys.every((key) => key in value));
2371
2378
  }
2372
2379
  //#endregion
2380
+ //#region lib/types/routes.js
2381
+ /** Requests larger than this are refused before the service method runs. */
2382
+ const BODY_LIMIT = 65536;
2383
+ const PREFIX = "/mh-browser/api";
2384
+ var RouteFailure = class extends Error {
2385
+ status;
2386
+ body;
2387
+ constructor(status, body) {
2388
+ super(body.message);
2389
+ this.name = "RouteFailure";
2390
+ this.status = status;
2391
+ this.body = body;
2392
+ }
2393
+ };
2394
+ function method(spec) {
2395
+ return {
2396
+ verb: spec.verb,
2397
+ input: spec.input,
2398
+ run: (input, signal) => spec.run(input, signal)
2399
+ };
2400
+ }
2401
+ const locateInput = z$1.object({ browser: z$1.enum([
2402
+ "auto",
2403
+ "chrome",
2404
+ "msedge",
2405
+ "chromium"
2406
+ ]) }).strict();
2407
+ const revealInput = z$1.object({ sessionId: z$1.string() }).strict();
2408
+ /**
2409
+ * Routes for the browser client. A composition without `webServer` never
2410
+ * starts this plugin; the browser tools do not depend on it.
2411
+ */
2412
+ const browserRoutesPlugin = {
2413
+ name: "mh-browser-routes",
2414
+ inject: [
2415
+ "mhBrowser",
2416
+ "webServer",
2417
+ "connection"
2418
+ ],
2419
+ apply(inner) {
2420
+ registerBrowserRoutes(inner);
2421
+ }
2422
+ };
2423
+ function registerBrowserRoutes(ctx) {
2424
+ registerApiRoutes(ctx, {
2425
+ prefix: PREFIX,
2426
+ packageName: "mh-browser",
2427
+ methods: {
2428
+ "locate-for": method({
2429
+ verb: "POST",
2430
+ input: locateInput,
2431
+ run: (input, signal) => ctx.mhBrowser.locateFor(input.browser, signal)
2432
+ }),
2433
+ "reveal-handover": method({
2434
+ verb: "POST",
2435
+ input: revealInput,
2436
+ run: (input, signal) => ctx.mhBrowser.revealHandover(input.sessionId, signal)
2437
+ })
2438
+ },
2439
+ mapError: browserFailure
2440
+ });
2441
+ }
2442
+ function registerApiRoutes(host, routes) {
2443
+ host.effect(() => host.webServer.register({
2444
+ kind: "prefix",
2445
+ path: routes.prefix,
2446
+ handler: (req, res) => handle(req, res, host, routes)
2447
+ }));
2448
+ }
2449
+ async function handle(req, res, host, routes) {
2450
+ const name = routes.packageName;
2451
+ try {
2452
+ const admission = host.connection.admit({ headers: req.headers });
2453
+ if ("rejection" in admission) {
2454
+ const status = admission.rejection;
2455
+ writeJson(res, status, fail(name, status === 401 ? "unadmitted" : "forbidden", status === 401 ? "sign in required" : "forbidden"), name);
2456
+ return;
2457
+ }
2458
+ const found = methodOf(routes, segmentOf(req.url, routes.prefix));
2459
+ if (found === void 0) {
2460
+ writeJson(res, 404, fail(name, "not-found", "no such route"), name);
2461
+ return;
2462
+ }
2463
+ if (req.method !== found.verb) {
2464
+ writeJson(res, 405, fail(name, "method-not-allowed", "method not allowed"), name, { allow: found.verb });
2465
+ return;
2466
+ }
2467
+ const controller = new AbortController();
2468
+ const onClose = () => {
2469
+ if (!res.writableEnded) controller.abort();
2470
+ };
2471
+ res.on("close", onClose);
2472
+ if (req.destroyed || res.destroyed) controller.abort();
2473
+ try {
2474
+ if (signalAborted(controller, res, name)) return;
2475
+ const input = found.verb === "GET" ? queryInput(req.url) : await readJson(req, name);
2476
+ if (signalAborted(controller, res, name)) return;
2477
+ const parsed = found.input.safeParse(input);
2478
+ if (!parsed.success) {
2479
+ writeJson(res, 400, {
2480
+ ok: false,
2481
+ error: {
2482
+ code: `${name}/invalid-input`,
2483
+ message: "invalid input",
2484
+ details: { issues: parsed.error.issues.map((issue) => ({
2485
+ path: issue.path.map((part) => String(part)).join("."),
2486
+ message: issue.message
2487
+ })) }
2488
+ }
2489
+ }, name);
2490
+ return;
2491
+ }
2492
+ const value = await found.run(parsed.data, controller.signal);
2493
+ writeJson(res, 200, {
2494
+ ok: true,
2495
+ value: value === void 0 ? null : value
2496
+ }, name);
2497
+ } finally {
2498
+ res.removeListener("close", onClose);
2499
+ }
2500
+ } catch (error) {
2501
+ const failed = error instanceof RouteFailure ? {
2502
+ status: error.status,
2503
+ error: error.body
2504
+ } : routes.mapError(error);
2505
+ writeJson(res, failed.status, {
2506
+ ok: false,
2507
+ error: failed.error
2508
+ }, name);
2509
+ }
2510
+ }
2511
+ /** Names on `Object.prototype` are not routes. */
2512
+ function methodOf(routes, segment) {
2513
+ if (segment === void 0 || segment === "" || !Object.hasOwn(routes.methods, segment)) return;
2514
+ return routes.methods[segment];
2515
+ }
2516
+ function signalAborted(controller, res, packageName) {
2517
+ if (!controller.signal.aborted) return false;
2518
+ writeJson(res, 400, fail(packageName, "cancelled", "cancelled"), packageName);
2519
+ return true;
2520
+ }
2521
+ function browserFailure(error) {
2522
+ if (error instanceof MhBrowserError) {
2523
+ if (error.code === "INTERRUPTED") return {
2524
+ status: 400,
2525
+ error: {
2526
+ code: "mh-browser/cancelled",
2527
+ message: error.message
2528
+ }
2529
+ };
2530
+ return {
2531
+ status: error.code === "INVALID_ARGS" || error.code === "INVALID_INPUT" ? 400 : 500,
2532
+ error: {
2533
+ code: error.code,
2534
+ message: error.message
2535
+ }
2536
+ };
2537
+ }
2538
+ return {
2539
+ status: 500,
2540
+ error: {
2541
+ code: "mh-browser/failed",
2542
+ message: error instanceof Error ? error.message : "request failed"
2543
+ }
2544
+ };
2545
+ }
2546
+ function fail(packageName, code, message) {
2547
+ return {
2548
+ ok: false,
2549
+ error: {
2550
+ code: `${packageName}/${code}`,
2551
+ message
2552
+ }
2553
+ };
2554
+ }
2555
+ function segmentOf(url, prefix) {
2556
+ let pathname;
2557
+ try {
2558
+ pathname = new URL(url ?? "/", "http://127.0.0.1").pathname;
2559
+ } catch {
2560
+ return;
2561
+ }
2562
+ if (pathname.length > 1 && pathname.endsWith("/")) pathname = pathname.slice(0, -1);
2563
+ if (pathname === prefix) return "";
2564
+ const head = `${prefix}/`;
2565
+ if (!pathname.startsWith(head)) return void 0;
2566
+ const rest = pathname.slice(head.length);
2567
+ if (rest === "" || rest.includes("/")) return void 0;
2568
+ try {
2569
+ return decodeURIComponent(rest);
2570
+ } catch {
2571
+ return;
2572
+ }
2573
+ }
2574
+ function queryInput(url) {
2575
+ const params = new URL(url ?? "/", "http://127.0.0.1").searchParams;
2576
+ const input = {};
2577
+ for (const [key, value] of params) input[key] = value;
2578
+ return input;
2579
+ }
2580
+ async function readJson(req, packageName) {
2581
+ const chunks = await readChunks(req);
2582
+ if (chunks === void 0) throw new RouteFailure(400, {
2583
+ code: `${packageName}/invalid-input`,
2584
+ message: "request is too large"
2585
+ });
2586
+ const text = Buffer.concat(chunks).toString("utf8");
2587
+ if (text.trim() === "") return {};
2588
+ try {
2589
+ return JSON.parse(text);
2590
+ } catch {
2591
+ throw new RouteFailure(400, {
2592
+ code: `${packageName}/invalid-input`,
2593
+ message: "request is not json"
2594
+ });
2595
+ }
2596
+ }
2597
+ /** `undefined` means the body is over the limit. The socket stays open. */
2598
+ function readChunks(req) {
2599
+ const declared = declaredLength(req);
2600
+ if (declared !== void 0 && declared > BODY_LIMIT) {
2601
+ req.on("error", () => void 0);
2602
+ req.resume();
2603
+ return Promise.resolve(void 0);
2604
+ }
2605
+ const chunks = [];
2606
+ let size = 0;
2607
+ return new Promise((resolve, reject) => {
2608
+ const stop = () => {
2609
+ req.off("data", onData);
2610
+ req.off("end", onEnd);
2611
+ req.off("error", onError);
2612
+ };
2613
+ const onData = (chunk) => {
2614
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2615
+ size += buffer.length;
2616
+ if (size > BODY_LIMIT) {
2617
+ stop();
2618
+ req.on("error", () => void 0);
2619
+ req.pause();
2620
+ req.resume();
2621
+ resolve(void 0);
2622
+ return;
2623
+ }
2624
+ chunks.push(buffer);
2625
+ };
2626
+ const onEnd = () => {
2627
+ stop();
2628
+ resolve(chunks);
2629
+ };
2630
+ const onError = (error) => {
2631
+ stop();
2632
+ reject(error);
2633
+ };
2634
+ req.on("data", onData);
2635
+ req.on("end", onEnd);
2636
+ req.on("error", onError);
2637
+ });
2638
+ }
2639
+ function declaredLength(req) {
2640
+ const raw = req.headers["content-length"];
2641
+ if (typeof raw !== "string" || !/^\d+$/.test(raw)) return void 0;
2642
+ const length = Number(raw);
2643
+ return Number.isSafeInteger(length) ? length : void 0;
2644
+ }
2645
+ function writeJson(res, status, body, packageName, extra) {
2646
+ if (res.writableEnded || res.destroyed) return;
2647
+ let payload;
2648
+ let nextStatus = status;
2649
+ let nextExtra = extra;
2650
+ try {
2651
+ payload = JSON.stringify(body);
2652
+ } catch {
2653
+ payload = JSON.stringify({
2654
+ ok: false,
2655
+ error: {
2656
+ code: `${packageName}/failed`,
2657
+ message: "request failed"
2658
+ }
2659
+ });
2660
+ nextStatus = 500;
2661
+ nextExtra = void 0;
2662
+ }
2663
+ const headers = {
2664
+ "content-type": "application/json; charset=utf-8",
2665
+ "cache-control": "no-store"
2666
+ };
2667
+ if (nextExtra !== void 0) Object.assign(headers, nextExtra);
2668
+ try {
2669
+ res.writeHead(nextStatus, headers);
2670
+ res.end(payload);
2671
+ } catch {
2672
+ res.destroy();
2673
+ }
2674
+ }
2675
+ //#endregion
2373
2676
  //#region lib/types/presence/book.js
2374
2677
  /** In-memory presence set for one process. The log is the source of a set that outlives the process. */
2375
2678
  var PresenceBook = class {
@@ -3166,6 +3469,48 @@ function configuredPathError(path, code) {
3166
3469
  if (code === "not-absolute") return /* @__PURE__ */ new Error(`The configured browser path must be absolute: ${path}`);
3167
3470
  return /* @__PURE__ */ new Error(`The configured browser path cannot be run (${code}): ${path}`);
3168
3471
  }
3472
+ /** Chrome and Edge were both missed. The message asks for Chrome only. */
3473
+ var BrowserNotFoundError = class extends Error {
3474
+ notFound;
3475
+ constructor(misses) {
3476
+ const tried = misses.flatMap((item) => item.missing.map((miss) => ({
3477
+ browser: item.browser,
3478
+ path: miss.path,
3479
+ reason: miss.reason
3480
+ })));
3481
+ const listed = tried.map((item) => `${item.path} (${item.reason})`).join("; ");
3482
+ const detail = listed === "" ? "" : ` Tried: ${listed}.`;
3483
+ super(`Neither Google Chrome nor Microsoft Edge was found.${detail} Ask the person to install Google Chrome, or set the browser's path in the configuration.`);
3484
+ this.name = "BrowserNotFoundError";
3485
+ this.notFound = {
3486
+ browsers: misses.map((item) => item.browser),
3487
+ tried
3488
+ };
3489
+ }
3490
+ };
3491
+ /**
3492
+ * The installed browser to launch when the choice is automatic.
3493
+ * The remembered browser wins while it can still be found; otherwise Chrome, then Edge.
3494
+ */
3495
+ async function resolveAuto(env, remembered, signal) {
3496
+ const order = [];
3497
+ if (remembered === "chrome" || remembered === "msedge") order.push(remembered);
3498
+ for (const name of ["chrome", "msedge"]) if (!order.includes(name)) order.push(name);
3499
+ const misses = [];
3500
+ for (const name of order) {
3501
+ if (signal?.aborted === true) break;
3502
+ const located = await locateInstalled(name, env, signal);
3503
+ if ("found" in located) return {
3504
+ browser: name,
3505
+ path: located.found
3506
+ };
3507
+ misses.push({
3508
+ browser: name,
3509
+ missing: located.missing
3510
+ });
3511
+ }
3512
+ throw new BrowserNotFoundError(misses);
3513
+ }
3169
3514
  /** Failure listing every path that was tried. */
3170
3515
  function browserNotFoundError(browser, missing) {
3171
3516
  const tried = missing.map((item) => `${item.path} (${item.reason})`).join("; ");
@@ -3333,6 +3678,7 @@ function command(file, args, signal) {
3333
3678
  }
3334
3679
  //#endregion
3335
3680
  //#region lib/types/service/browser.js
3681
+ const { chromium } = createRequire(import.meta.url)("playwright");
3336
3682
  /**
3337
3683
  * Playwright appends `--no-sandbox` unless this is true, and branded Chrome
3338
3684
  * then warns that stability and security will suffer. An installed browser
@@ -3366,7 +3712,7 @@ async function launchBrowser(options, env = hostLocateEnv()) {
3366
3712
  "--test-type"
3367
3713
  ]
3368
3714
  },
3369
- ...options.browser === "chromium" ? {} : { channel: options.browser },
3715
+ ...options.browser !== "chromium" && options.channel !== false ? { channel: options.browser } : {},
3370
3716
  ...executablePath !== void 0 ? { executablePath } : {}
3371
3717
  });
3372
3718
  }
@@ -3428,6 +3774,126 @@ async function closeOrKill(close, pid, waitMs = 1e3, kill = (target) => {
3428
3774
  return "killed";
3429
3775
  }
3430
3776
  //#endregion
3777
+ //#region lib/types/service/profile.js
3778
+ const LAST_BROWSER = "last-browser.json";
3779
+ /** Subdirectories a split profile root keeps beside the browser that is launching. */
3780
+ const KEPT = /* @__PURE__ */ new Set([
3781
+ "chrome",
3782
+ "msedge",
3783
+ "chromium",
3784
+ "custom",
3785
+ LAST_BROWSER
3786
+ ]);
3787
+ /** The browser recorded after the last successful launch, if the file names one. */
3788
+ async function readLastBrowser(root) {
3789
+ try {
3790
+ const browser = JSON.parse(await readFile(join(root, LAST_BROWSER), "utf8")).browser;
3791
+ if (browser === "chrome" || browser === "msedge" || browser === "chromium") return browser;
3792
+ } catch {
3793
+ return;
3794
+ }
3795
+ }
3796
+ /** Remember which browser actually launched, so automatic selection keeps using it. */
3797
+ async function writeLastBrowser(root, browser) {
3798
+ await mkdir(root, { recursive: true });
3799
+ await writeFile(join(root, LAST_BROWSER), `${JSON.stringify({ browser })}\n`);
3800
+ }
3801
+ /**
3802
+ * Move a profile that older versions kept in the root into `browser`'s subdirectory.
3803
+ * A root without Chrome's `Local State`, or one whose subdirectory already exists, stays as it is.
3804
+ */
3805
+ async function migrateLegacyProfile(root, browser) {
3806
+ if (!KEPT.has(browser) || browser === LAST_BROWSER) return;
3807
+ const target = join(root, browser);
3808
+ try {
3809
+ await access(join(root, "Local State"));
3810
+ } catch {
3811
+ return;
3812
+ }
3813
+ try {
3814
+ await access(target);
3815
+ return;
3816
+ } catch {}
3817
+ await mkdir(target, { recursive: true });
3818
+ const names = await readdir(root);
3819
+ for (const name of names) {
3820
+ if (KEPT.has(name)) continue;
3821
+ await rename(join(root, name), join(target, name));
3822
+ }
3823
+ }
3824
+ //#endregion
3825
+ //#region lib/types/service/launch-plan.js
3826
+ /** Subdirectory for a configured path while automatic selection does not name a browser. */
3827
+ const CUSTOM = "custom";
3828
+ /**
3829
+ * Which browser, path, and profile directory to launch.
3830
+ * A configured path is checked and never replaced. Automatic selection keeps the
3831
+ * remembered browser while it can be found, then Chrome, then Edge.
3832
+ * Playwright's Chromium is used only when it is selected, or when it is the remembered one.
3833
+ */
3834
+ async function planLaunch(input) {
3835
+ const configured = input.configured?.trim() ?? "";
3836
+ if (configured !== "") {
3837
+ const checked = checkConfigured(configured, input.env);
3838
+ if (!checked.ok) throw configuredPathError(checked.path, checked.code);
3839
+ const named = input.choice === "auto" ? void 0 : input.choice;
3840
+ if (named !== void 0) await settle(input, named);
3841
+ return {
3842
+ browser: named ?? "chrome",
3843
+ channel: named !== void 0 && named !== "chromium",
3844
+ profileDir: folder(input, named ?? CUSTOM),
3845
+ executablePath: checked.path,
3846
+ ...named !== void 0 ? { remember: named } : {}
3847
+ };
3848
+ }
3849
+ if (input.choice === "auto" && input.remembered === "chromium" && "found" in input.bundled) {
3850
+ await settle(input, "chromium");
3851
+ return {
3852
+ browser: "chromium",
3853
+ channel: false,
3854
+ profileDir: folder(input, "chromium"),
3855
+ remember: "chromium"
3856
+ };
3857
+ }
3858
+ if (input.choice === "auto") {
3859
+ const remembered = input.remembered === "chromium" ? void 0 : input.remembered;
3860
+ const resolved = await resolveAuto(input.env, remembered, input.signal);
3861
+ await settle(input, resolved.browser);
3862
+ return {
3863
+ browser: resolved.browser,
3864
+ channel: true,
3865
+ profileDir: folder(input, resolved.browser),
3866
+ executablePath: resolved.path,
3867
+ remember: resolved.browser
3868
+ };
3869
+ }
3870
+ if (input.choice === "chromium") {
3871
+ await settle(input, "chromium");
3872
+ return {
3873
+ browser: "chromium",
3874
+ channel: false,
3875
+ profileDir: folder(input, "chromium"),
3876
+ remember: "chromium"
3877
+ };
3878
+ }
3879
+ const located = await locateInstalled(input.choice, input.env, input.signal);
3880
+ if (!("found" in located)) throw browserNotFoundError(input.choice, located.missing);
3881
+ await settle(input, input.choice);
3882
+ return {
3883
+ browser: input.choice,
3884
+ channel: true,
3885
+ profileDir: folder(input, input.choice),
3886
+ executablePath: located.found,
3887
+ remember: input.choice
3888
+ };
3889
+ }
3890
+ function folder(input, name) {
3891
+ return input.split ? profileDirFor(input.root, name) : input.root;
3892
+ }
3893
+ async function settle(input, name) {
3894
+ if (input.split) await migrateLegacyProfile(input.root, name);
3895
+ }
3896
+ //#endregion
3431
3897
  //#region lib/types/service/control.js
3432
3898
  /**
3433
3899
  * Who holds each tab of one session. Person input outside a service dispatch
@@ -4607,16 +5073,18 @@ function extensionDir() {
4607
5073
  var Extension = class Extension {
4608
5074
  context;
4609
5075
  id;
5076
+ browserSession;
4610
5077
  workerPage;
4611
- constructor(context, id, worker) {
5078
+ constructor(context, id, browserSession, worker) {
4612
5079
  this.context = context;
4613
5080
  this.id = id;
5081
+ this.browserSession = browserSession;
4614
5082
  this.workerPage = worker;
4615
5083
  }
4616
5084
  static async load(context, browserSession) {
4617
5085
  const loaded = await browserSession.send("Extensions.loadUnpacked", { path: extensionDir() });
4618
5086
  const worker = await findWorker(context, loaded.id);
4619
- return new Extension(context, loaded.id, worker);
5087
+ return new Extension(context, loaded.id, browserSession, worker);
4620
5088
  }
4621
5089
  /** Address of a page shipped in the extension directory. */
4622
5090
  pageUrl(name) {
@@ -4700,11 +5168,32 @@ var Extension = class Extension {
4700
5168
  async worker(signal) {
4701
5169
  const current = this.workerPage;
4702
5170
  if (current !== void 0 && this.context.serviceWorkers().includes(current)) return current;
4703
- const worker = await untilAborted(findWorker(this.context, this.id), capped(signal, CALL_MS$1));
5171
+ const worker = await restoreWorker(this.context, this.id, async () => {
5172
+ await this.browserSession.send("Extensions.loadUnpacked", { path: extensionDir() });
5173
+ }, signal);
4704
5174
  this.workerPage = worker;
4705
5175
  return worker;
4706
5176
  }
4707
5177
  };
5178
+ /**
5179
+ * The service worker, reloading the extension once when it is gone.
5180
+ * A worker that comes back on its own during the first wait is used as it is.
5181
+ * After a reload that still has no worker, the extension was turned off.
5182
+ */
5183
+ async function restoreWorker(context, id, reload, signal, appearMs = 1e3) {
5184
+ try {
5185
+ return await untilAborted(findWorker(context, id, appearMs), signal);
5186
+ } catch (error) {
5187
+ if (isInterrupted(error)) throw error;
5188
+ }
5189
+ try {
5190
+ await reload();
5191
+ return await untilAborted(findWorker(context, id), signal);
5192
+ } catch (error) {
5193
+ if (isInterrupted(error)) throw error;
5194
+ throw new Error("The agent extension in the browser was turned off.", { cause: error });
5195
+ }
5196
+ }
4708
5197
  /** The extension's service worker. Looks once more after the wait times out. */
4709
5198
  async function findWorker(context, id, timeoutMs = WORKER_MS) {
4710
5199
  const find = () => context.serviceWorkers().find((worker) => worker.url().includes(id));
@@ -5522,46 +6011,6 @@ async function writeCounter(profileDir, next) {
5522
6011
  const testHooks = Symbol("mh-browser test hooks");
5523
6012
  //#endregion
5524
6013
  //#region lib/types/service/service.js
5525
- var __runInitializers = function(thisArg, initializers, value) {
5526
- var useValue = arguments.length > 2;
5527
- for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
5528
- return useValue ? value : void 0;
5529
- };
5530
- var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
5531
- function accept(f) {
5532
- if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
5533
- return f;
5534
- }
5535
- var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
5536
- var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
5537
- var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
5538
- var _, done = false;
5539
- for (var i = decorators.length - 1; i >= 0; i--) {
5540
- var context = {};
5541
- for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
5542
- for (var p in contextIn.access) context.access[p] = contextIn.access[p];
5543
- context.addInitializer = function(f) {
5544
- if (done) throw new TypeError("Cannot add initializers after decoration has completed");
5545
- extraInitializers.push(accept(f || null));
5546
- };
5547
- var result = (0, decorators[i])(kind === "accessor" ? {
5548
- get: descriptor.get,
5549
- set: descriptor.set
5550
- } : descriptor[key], context);
5551
- if (kind === "accessor") {
5552
- if (result === void 0) continue;
5553
- if (result === null || typeof result !== "object") throw new TypeError("Object expected");
5554
- if (_ = accept(result.get)) descriptor.get = _;
5555
- if (_ = accept(result.set)) descriptor.set = _;
5556
- if (_ = accept(result.init)) initializers.unshift(_);
5557
- } else if (_ = accept(result)) {
5558
- if (kind === "field") initializers.unshift(_);
5559
- else descriptor[key] = _;
5560
- }
5561
- }
5562
- if (target) Object.defineProperty(target, contextIn.name, descriptor);
5563
- done = true;
5564
- };
5565
6014
  /** Navigation and back share this limit (§3.7). */
5566
6015
  const NAVIGATION_MS = 1e4;
5567
6016
  /** How long an open that started a download waits for the download event. */
@@ -5589,1581 +6038,1585 @@ const NO_WORKSPACE_REASON = "this session has no working directory";
5589
6038
  * the session's browser. `act` returns a snapshot, including on a page that
5590
6039
  * also has an Agent View.
5591
6040
  */
5592
- let MhBrowserService = (() => {
5593
- let _classSuper = TypertRemoteService;
5594
- let _instanceExtraInitializers = [];
5595
- let _locateFor_decorators;
5596
- let _revealHandover_decorators;
5597
- return class MhBrowserService extends _classSuper {
5598
- static {
5599
- const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
5600
- _locateFor_decorators = [Remote];
5601
- _revealHandover_decorators = [Remote];
5602
- __esDecorate(this, null, _locateFor_decorators, {
5603
- kind: "method",
5604
- name: "locateFor",
5605
- static: false,
5606
- private: false,
5607
- access: {
5608
- has: (obj) => "locateFor" in obj,
5609
- get: (obj) => obj.locateFor
5610
- },
5611
- metadata: _metadata
5612
- }, null, _instanceExtraInitializers);
5613
- __esDecorate(this, null, _revealHandover_decorators, {
5614
- kind: "method",
5615
- name: "revealHandover",
5616
- static: false,
5617
- private: false,
5618
- access: {
5619
- has: (obj) => "revealHandover" in obj,
5620
- get: (obj) => obj.revealHandover
5621
- },
5622
- metadata: _metadata
5623
- }, null, _instanceExtraInitializers);
5624
- if (_metadata) Object.defineProperty(this, Symbol.metadata, {
5625
- enumerable: true,
5626
- configurable: true,
5627
- writable: true,
5628
- value: _metadata
6041
+ var MhBrowserService = class extends Service {
6042
+ windows = new Windows();
6043
+ headless;
6044
+ /** Profile root. The default root gets one subdirectory per browser at launch. */
6045
+ profileRoot;
6046
+ /** A configured profile directory is used as given, without a per-browser subdirectory. */
6047
+ splitProfile;
6048
+ browserName;
6049
+ executablePath;
6050
+ /** Tests replace the machine lookup. Unset uses this machine. */
6051
+ locator;
6052
+ context;
6053
+ pids = /* @__PURE__ */ new WeakMap();
6054
+ ready = Promise.resolve();
6055
+ closing = false;
6056
+ /** A browser restart was requested and has not run yet. */
6057
+ relaunchWanted = false;
6058
+ /** Counts browser and path changes. A start that sampled the latest one clears the restart. */
6059
+ configEpoch = 0;
6060
+ /** The running browser is being closed so the new configuration can start. */
6061
+ replacing = false;
6062
+ /** The browser is not running. The next call that needs a page starts it. */
6063
+ exited = true;
6064
+ exits = [];
6065
+ /** One queue per owner: a slow wait in one Session does not hold up another. */
6066
+ queues = /* @__PURE__ */ new Map();
6067
+ shown = /* @__PURE__ */ new Map();
6068
+ book = new PresenceBook();
6069
+ maxBytes;
6070
+ workspaces = /* @__PURE__ */ new Map();
6071
+ control = new Control();
6072
+ personTouch = /* @__PURE__ */ new Set();
6073
+ /** Origins that have served a readable Agent View. Memory only; a restart clears it. */
6074
+ agentViewOrigins = /* @__PURE__ */ new Set();
6075
+ /** The last Agent View read for a page address, so a later read can send If-None-Match. */
6076
+ documents = /* @__PURE__ */ new Map();
6077
+ constructor(ctx, config) {
6078
+ super(ctx, "mhBrowser");
6079
+ this.maxBytes = config.presence.maxBytes;
6080
+ this.headless = config.headless;
6081
+ const configuredDir = config.profileDir;
6082
+ this.splitProfile = configuredDir === void 0 || configuredDir === "";
6083
+ this.profileRoot = this.splitProfile ? defaultProfileDir(ctx.get("profileContext")) : configuredDir;
6084
+ this.browserName = config.browser;
6085
+ this.executablePath = config.executablePath ?? { get: () => void 0 };
6086
+ Object.getPrototypeOf(ctx).on("loader/volatile-update", (paths) => {
6087
+ if (!paths.some((path) => path.includes("browser") || path.includes("executablePath"))) return;
6088
+ this.configEpoch += 1;
6089
+ this.relaunchWanted = true;
6090
+ this.relaunchIfIdle();
6091
+ });
6092
+ this.windows.setPersonInput((owner, tab) => {
6093
+ const live = tab === void 0 ? void 0 : this.windows.get(owner, tab);
6094
+ this.control.note(owner, tab);
6095
+ if (live !== void 0) this.windows.markMayHaveChanged(live);
6096
+ });
6097
+ this.windows.onTabClosed((tab) => {
6098
+ this.shown.delete(tab.id);
6099
+ });
6100
+ this.up().catch(() => void 0);
6101
+ ctx.on("session/disposed", (session) => {
6102
+ const owner = session.id;
6103
+ if (owner === void 0) return;
6104
+ this.exclusive(owner, () => this.windows.closeOwner(owner, AbortSignal.timeout(DISPOSE_MS)), AbortSignal.timeout(DISPOSE_MS)).catch((error) => {
6105
+ ctx.logger.warn("closing the windows of %s failed: %o", owner, error);
5629
6106
  });
6107
+ this.book.forget(owner);
6108
+ this.control.forget(owner);
6109
+ this.workspaces.delete(owner);
6110
+ });
6111
+ ctx.effect(() => async () => {
6112
+ this.closing = true;
6113
+ await this.stop();
6114
+ });
6115
+ }
6116
+ /** Remember an origin whose pages offer Agent Views. A missing page does not remove it. */
6117
+ declareAgentViewOrigin(origin) {
6118
+ try {
6119
+ const parsed = new URL(origin);
6120
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
6121
+ this.agentViewOrigins.add(parsed.origin);
6122
+ } catch {}
6123
+ }
6124
+ async open(owner, items, signal, options) {
6125
+ const reveal = options?.reveal === true;
6126
+ try {
6127
+ return await this.exclusive(owner, async () => {
6128
+ await this.up(owner === PERSON_OWNER);
6129
+ const views = [];
6130
+ for (const item of items) {
6131
+ if (signal?.aborted) {
6132
+ views.push({
6133
+ url: item.url,
6134
+ reason: WAITING_STOPPED
6135
+ });
6136
+ continue;
6137
+ }
6138
+ views.push(await this.openAddress(owner, item, signal, reveal));
6139
+ }
6140
+ await this.enforceCap(owner);
6141
+ return views;
6142
+ }, signal);
6143
+ } catch (error) {
6144
+ if (!isInterrupted(error)) throw error;
6145
+ return items.map((item) => ({
6146
+ url: item.url,
6147
+ reason: WAITING_STOPPED
6148
+ }));
5630
6149
  }
5631
- windows = (__runInitializers(this, _instanceExtraInitializers), new Windows());
5632
- headless;
5633
- profileDir;
5634
- browserName;
5635
- executablePath;
5636
- /** Tests replace the machine lookup. Unset uses this machine. */
5637
- locator;
5638
- context;
5639
- pids = /* @__PURE__ */ new WeakMap();
5640
- ready = Promise.resolve();
5641
- closing = false;
5642
- /** A browser restart was requested and has not run yet. */
5643
- relaunchWanted = false;
5644
- /** Counts browser and path changes. A start that sampled the latest one clears the restart. */
5645
- configEpoch = 0;
5646
- /** The running browser is being closed so the new configuration can start. */
5647
- replacing = false;
5648
- /** The browser is not running. The next call that needs a page starts it. */
5649
- exited = true;
5650
- exits = [];
5651
- /** One queue per owner: a slow wait in one Session does not hold up another. */
5652
- queues = /* @__PURE__ */ new Map();
5653
- shown = /* @__PURE__ */ new Map();
5654
- book = new PresenceBook();
5655
- maxBytes;
5656
- workspaces = /* @__PURE__ */ new Map();
5657
- control = new Control();
5658
- personTouch = /* @__PURE__ */ new Set();
5659
- /** Origins that have served a readable Agent View. Memory only; a restart clears it. */
5660
- agentViewOrigins = /* @__PURE__ */ new Set();
5661
- /** The last Agent View read for a page address, so a later read can send If-None-Match. */
5662
- documents = /* @__PURE__ */ new Map();
5663
- constructor(ctx, config) {
5664
- super(ctx, "mhBrowser");
5665
- this.maxBytes = config.presence.maxBytes;
5666
- this.headless = config.headless;
5667
- this.profileDir = config.profileDir ?? defaultProfileDir(ctx.get("profileContext"));
5668
- this.browserName = config.browser;
5669
- this.executablePath = config.executablePath ?? { get: () => void 0 };
5670
- Object.getPrototypeOf(ctx).on("loader/volatile-update", (paths) => {
5671
- if (!paths.some((path) => path.includes("browser") || path.includes("executablePath"))) return;
5672
- this.configEpoch += 1;
5673
- this.relaunchWanted = true;
5674
- this.relaunchIfIdle();
5675
- });
5676
- this.windows.setPersonInput((owner, tab) => {
5677
- const live = tab === void 0 ? void 0 : this.windows.get(owner, tab);
5678
- this.control.note(owner, tab);
5679
- if (live !== void 0) this.windows.markMayHaveChanged(live);
5680
- });
5681
- this.windows.onTabClosed((tab) => {
5682
- this.shown.delete(tab.id);
5683
- });
5684
- this.up().catch(() => void 0);
5685
- ctx.on("session/disposed", (session) => {
5686
- const owner = session.id;
5687
- if (owner === void 0) return;
5688
- this.exclusive(owner, () => this.windows.closeOwner(owner, AbortSignal.timeout(DISPOSE_MS)), AbortSignal.timeout(DISPOSE_MS)).catch((error) => {
5689
- ctx.logger.warn("closing the windows of %s failed: %o", owner, error);
6150
+ }
6151
+ async view(owner, tab, signal) {
6152
+ return this.exclusive(owner, async () => {
6153
+ await this.ready;
6154
+ const live = this.windows.get(owner, tab);
6155
+ if (live === void 0) return unknownTab();
6156
+ if (signal?.aborted) return {
6157
+ tab,
6158
+ url: live.url,
6159
+ reason: WAITING_STOPPED
6160
+ };
6161
+ return this.refreshView(live, signal);
6162
+ }, signal);
6163
+ }
6164
+ /** Pages whose current representation is already in the model's presence. */
6165
+ rememberPresence(owner, urls) {
6166
+ this.book.remember(owner, urls.map((url) => canonical(url)));
6167
+ }
6168
+ presenceList(owner) {
6169
+ return this.book.entries(owner);
6170
+ }
6171
+ presencePut(owner, urls, source) {
6172
+ return this.book.put(owner, urls.map((url) => canonical(url)), source);
6173
+ }
6174
+ presenceRemove(owner, urls) {
6175
+ return this.book.remove(owner, urls.map((url) => canonical(url)));
6176
+ }
6177
+ /** The set the session log folds to; taken once per process. */
6178
+ presenceLoad(owner, folded) {
6179
+ this.book.load(owner, folded);
6180
+ }
6181
+ presenceTakeResume(owner) {
6182
+ return this.book.takeResume(owner);
6183
+ }
6184
+ /**
6185
+ * Re-read presence pages and build the next reminder.
6186
+ * A tab already on the address is read in place. A check that has to open
6187
+ * one closes it before returning. Removals are included, and they cause
6188
+ * every remaining page to be read so a page kept out by the byte budget
6189
+ * can come back. An empty set has nothing to read and does not wait for the
6190
+ * browser; a browser that is down makes every page absent.
6191
+ */
6192
+ async presenceCheck(owner, which, opts) {
6193
+ if (this.book.entries(owner).length === 0 && this.book.peekRemovals(owner).length === 0) return;
6194
+ return this.exclusive(owner, async () => {
6195
+ const down = await this.up().then(() => void 0, (error) => error instanceof Error ? error.message : String(error));
6196
+ const pages = this.book.entries(owner);
6197
+ const removals = this.book.peekRemovals(owner);
6198
+ const wanted = new Set(which === "all" ? pages.map((page) => page.url) : which.map(canonical));
6199
+ if (removals.length > 0) for (const page of pages) wanted.add(page.url);
6200
+ const observations = [];
6201
+ for (const page of pages) {
6202
+ if (!wanted.has(page.url)) continue;
6203
+ observations.push(down === void 0 ? await this.observePresence(owner, page.url, opts?.signal) : {
6204
+ url: page.url,
6205
+ ok: false,
6206
+ reason: `browser not running: ${down}`,
6207
+ title: "",
6208
+ observedAt: (/* @__PURE__ */ new Date()).toISOString(),
6209
+ bytes: 0
5690
6210
  });
5691
- this.book.forget(owner);
5692
- this.control.forget(owner);
5693
- this.workspaces.delete(owner);
6211
+ }
6212
+ const planned = planReminder({
6213
+ pages,
6214
+ observations,
6215
+ removals,
6216
+ maxBytes: this.maxBytes,
6217
+ baseline: opts?.baseline === true,
6218
+ replaces: opts?.replaces === true,
6219
+ ...opts?.force === true ? { force: true } : {}
5694
6220
  });
5695
- ctx.effect(() => async () => {
5696
- this.closing = true;
5697
- await this.stop();
6221
+ if (planned === void 0) return void 0;
6222
+ this.book.apply(owner, planned.next);
6223
+ this.book.clearRemovals(owner);
6224
+ return {
6225
+ text: planned.text,
6226
+ record: planned.record
6227
+ };
6228
+ }, opts?.signal);
6229
+ }
6230
+ /**
6231
+ * Where a download the page starts may be written: the session's writable
6232
+ * root, or why it may not write at all.
6233
+ */
6234
+ noteWorkspace(owner, target) {
6235
+ this.workspaces.set(owner, target);
6236
+ }
6237
+ tabs(owner) {
6238
+ return this.windows.list(owner);
6239
+ }
6240
+ takeClosed(owner) {
6241
+ return this.windows.takeClosed(owner);
6242
+ }
6243
+ windowId(owner, tab) {
6244
+ return this.exclusive(owner, async () => {
6245
+ await this.ready;
6246
+ return this.windows.windowId(owner, tab);
6247
+ });
6248
+ }
6249
+ async fetch(url, opts) {
6250
+ try {
6251
+ await this.up();
6252
+ const context = this.context;
6253
+ if (context === void 0) return {
6254
+ kind: "error",
6255
+ reason: "browser is not running"
6256
+ };
6257
+ const headers = {};
6258
+ if (opts?.accept !== void 0) headers.accept = opts.accept;
6259
+ if (opts?.ifNoneMatch !== void 0) headers["if-none-match"] = opts.ifNoneMatch;
6260
+ const response = await context.request.get(url, {
6261
+ headers,
6262
+ failOnStatusCode: false,
6263
+ ...opts?.signal !== void 0 ? { signal: opts.signal } : {}
5698
6264
  });
6265
+ const headerMap = response.headers();
6266
+ const body = {
6267
+ kind: "response",
6268
+ finalUrl: response.url(),
6269
+ status: response.status(),
6270
+ body: new Uint8Array(await untilAborted(response.body(), capped(opts?.signal, READ_MS)))
6271
+ };
6272
+ const mediaType = headerMap["content-type"];
6273
+ const etag = headerMap.etag;
6274
+ const link = headerMap.link;
6275
+ if (mediaType !== void 0) body.mediaType = mediaType;
6276
+ if (etag !== void 0) body.etag = etag;
6277
+ if (link !== void 0) body.link = link;
6278
+ return body;
6279
+ } catch (error) {
6280
+ return {
6281
+ kind: "error",
6282
+ reason: error instanceof Error ? error.message : String(error)
6283
+ };
5699
6284
  }
5700
- /** Remember an origin whose pages offer Agent Views. A missing page does not remove it. */
5701
- declareAgentViewOrigin(origin) {
5702
- try {
5703
- const parsed = new URL(origin);
5704
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
5705
- this.agentViewOrigins.add(parsed.origin);
5706
- } catch {}
5707
- }
5708
- async open(owner, items, signal, options) {
5709
- const reveal = options?.reveal === true;
5710
- try {
5711
- return await this.exclusive(owner, async () => {
5712
- await this.up(owner === PERSON_OWNER);
5713
- const views = [];
5714
- for (const item of items) {
5715
- if (signal?.aborted) {
5716
- views.push({
5717
- url: item.url,
5718
- reason: WAITING_STOPPED
5719
- });
5720
- continue;
5721
- }
5722
- views.push(await this.openAddress(owner, item, signal, reveal));
5723
- }
5724
- await this.enforceCap(owner);
5725
- return views;
5726
- }, signal);
5727
- } catch (error) {
5728
- if (!isInterrupted(error)) throw error;
5729
- return items.map((item) => ({
5730
- url: item.url,
5731
- reason: WAITING_STOPPED
5732
- }));
6285
+ }
6286
+ async pushPath(owner, tab, path) {
6287
+ await this.ready;
6288
+ const live = this.windows.get(owner, tab);
6289
+ if (live === void 0) return;
6290
+ await live.page.evaluate((next) => {
6291
+ history.pushState({}, "", next);
6292
+ }, path);
6293
+ }
6294
+ async started() {
6295
+ await this.ready;
6296
+ }
6297
+ /** The person asked for the browser: start it if it exited, past the exit limit. */
6298
+ startForPerson() {
6299
+ return this.up(true);
6300
+ }
6301
+ [testHooks]() {
6302
+ return {
6303
+ crash: async () => {
6304
+ this.windows.suppressPersonInput();
6305
+ await (this.context?.browser())?.close();
6306
+ },
6307
+ page: (owner, tab) => this.windows.get(owner, tab)?.page,
6308
+ running: () => this.context !== void 0,
6309
+ pages: () => this.context?.pages() ?? [],
6310
+ pid: () => this.context === void 0 ? void 0 : this.pids.get(this.context),
6311
+ useLocator: (env) => {
6312
+ this.locator = env;
5733
6313
  }
6314
+ };
6315
+ }
6316
+ /**
6317
+ * Where `browser` would be launched from when no path is configured.
6318
+ * The configured path is not consulted: the answer is what an empty path uses.
6319
+ * `auto` reports the browser automatic selection would launch.
6320
+ */
6321
+ async locateFor(browser, signal) {
6322
+ const stopped = () => signal?.aborted === true;
6323
+ if (stopped()) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6324
+ if (browser !== "auto" && browser !== "chrome" && browser !== "msedge" && browser !== "chromium") throw new MhBrowserError("INVALID_ARGS", "browser must be auto, chrome, msedge, or chromium");
6325
+ const env = this.locateEnv();
6326
+ if (browser === "auto") return this.locateAuto(env, signal);
6327
+ if (browser === "chromium") return bundledChromiumLocation(env.access);
6328
+ const located = await locateInstalled(browser, env, signal);
6329
+ if (stopped()) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6330
+ return located;
6331
+ }
6332
+ async locateAuto(env, signal) {
6333
+ const stopped = () => signal?.aborted === true;
6334
+ const remembered = await readLastBrowser(this.profileRoot);
6335
+ if (remembered === "chromium") {
6336
+ const bundled = bundledChromiumLocation(env.access);
6337
+ if ("found" in bundled) return {
6338
+ found: bundled.found,
6339
+ browser: "chromium"
6340
+ };
5734
6341
  }
5735
- async view(owner, tab, signal) {
5736
- return this.exclusive(owner, async () => {
5737
- await this.ready;
5738
- const live = this.windows.get(owner, tab);
5739
- if (live === void 0) return unknownTab();
5740
- if (signal?.aborted) return {
5741
- tab,
5742
- url: live.url,
5743
- reason: WAITING_STOPPED
5744
- };
5745
- return this.refreshView(live, signal);
5746
- }, signal);
6342
+ try {
6343
+ const resolved = await resolveAuto(env, remembered === "chromium" ? void 0 : remembered, signal);
6344
+ if (stopped()) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6345
+ return {
6346
+ found: resolved.path,
6347
+ browser: resolved.browser
6348
+ };
6349
+ } catch (error) {
6350
+ if (stopped()) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6351
+ if (error instanceof BrowserNotFoundError) return { missing: error.notFound.tried };
6352
+ throw error;
5747
6353
  }
5748
- /** Pages whose current representation is already in the model's presence. */
5749
- rememberPresence(owner, urls) {
5750
- this.book.remember(owner, urls.map((url) => canonical(url)));
5751
- }
5752
- presenceList(owner) {
5753
- return this.book.entries(owner);
5754
- }
5755
- presencePut(owner, urls, source) {
5756
- return this.book.put(owner, urls.map((url) => canonical(url)), source);
5757
- }
5758
- presenceRemove(owner, urls) {
5759
- return this.book.remove(owner, urls.map((url) => canonical(url)));
5760
- }
5761
- /** The set the session log folds to; taken once per process. */
5762
- presenceLoad(owner, folded) {
5763
- this.book.load(owner, folded);
5764
- }
5765
- presenceTakeResume(owner) {
5766
- return this.book.takeResume(owner);
5767
- }
5768
- /**
5769
- * Re-read presence pages and build the next reminder.
5770
- * A tab already on the address is read in place. A check that has to open
5771
- * one closes it before returning. Removals are included, and they cause
5772
- * every remaining page to be read so a page kept out by the byte budget
5773
- * can come back. An empty set has nothing to read and does not wait for the
5774
- * browser; a browser that is down makes every page absent.
5775
- */
5776
- async presenceCheck(owner, which, opts) {
5777
- if (this.book.entries(owner).length === 0 && this.book.peekRemovals(owner).length === 0) return;
5778
- return this.exclusive(owner, async () => {
5779
- const down = await this.up().then(() => void 0, (error) => error instanceof Error ? error.message : String(error));
5780
- const pages = this.book.entries(owner);
5781
- const removals = this.book.peekRemovals(owner);
5782
- const wanted = new Set(which === "all" ? pages.map((page) => page.url) : which.map(canonical));
5783
- if (removals.length > 0) for (const page of pages) wanted.add(page.url);
5784
- const observations = [];
5785
- for (const page of pages) {
5786
- if (!wanted.has(page.url)) continue;
5787
- observations.push(down === void 0 ? await this.observePresence(owner, page.url, opts?.signal) : {
5788
- url: page.url,
5789
- ok: false,
5790
- reason: `browser not running: ${down}`,
5791
- title: "",
5792
- observedAt: (/* @__PURE__ */ new Date()).toISOString(),
5793
- bytes: 0
5794
- });
6354
+ }
6355
+ async act(owner, tab, op, signal) {
6356
+ return this.exclusive(owner, async () => {
6357
+ await this.ready;
6358
+ this.refuseIfPerson(owner, tab);
6359
+ const live = this.windows.get(owner, tab);
6360
+ if (live === void 0) throw new MhBrowserError(UNKNOWN_TAB, UNKNOWN_TAB_REASON);
6361
+ if (signal?.aborted) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6362
+ const linked = this.control.link(owner, signal, tab);
6363
+ try {
6364
+ if (live.dialogHeld && op.kind !== "handle_dialog") throw new MhBrowserError("MODAL_STATE", "a dialog is open; use browser_handle_dialog", await this.blockedView(live));
6365
+ if (live.fileChooser !== void 0 && op.kind !== "file_upload") {
6366
+ const blocked = await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" }, signal);
6367
+ blocked.dialog = {
6368
+ type: "filechooser",
6369
+ message: live.fileChooser.isMultiple() ? "choose files (several allowed) with browser_file_upload" : "choose one file with browser_file_upload"
6370
+ };
6371
+ throw new MhBrowserError("MODAL_STATE", "a file chooser is open; use browser_file_upload", blocked);
5795
6372
  }
5796
- const planned = planReminder({
5797
- pages,
5798
- observations,
5799
- removals,
5800
- maxBytes: this.maxBytes,
5801
- baseline: opts?.baseline === true,
5802
- replaces: opts?.replaces === true,
5803
- ...opts?.force === true ? { force: true } : {}
5804
- });
5805
- if (planned === void 0) return void 0;
5806
- this.book.apply(owner, planned.next);
5807
- this.book.clearRemovals(owner);
5808
- return {
5809
- text: planned.text,
5810
- record: planned.record
6373
+ const outcome = await this.perform(live, op, linked.signal);
6374
+ if (op.kind === "fill_form" || op.kind === "select_option" || op.kind === "type" || op.kind === "file_upload") {
6375
+ if (outcome.navigated) this.windows.onNavigated(live);
6376
+ else this.windows.holdInput(live);
6377
+ } else if (outcome.navigated) this.windows.onNavigated(live);
6378
+ this.windows.touch(live);
6379
+ const shown = outcome.navigated ? await this.discover(live, live.page.url(), null, signal) : await this.refreshSnapshot(live, signal);
6380
+ const scoped = op.kind === "snapshot" && (op.target !== void 0 || op.depth !== void 0 || op.boxes === true) ? await this.scopedSnapshot(live, op, signal) : void 0;
6381
+ this.shown.set(live.id, shown);
6382
+ this.windows.noteUrl(live, live.page.url());
6383
+ const view = await this.pageFromShown(live, shown, {
6384
+ surface: "snapshot",
6385
+ ...outcome.dialog !== void 0 ? { dialog: outcome.dialog } : {},
6386
+ ...outcome.incomplete !== void 0 ? { incomplete: outcome.incomplete } : {},
6387
+ ...outcome.waited !== void 0 ? { waited: outcome.waited } : {}
6388
+ }, signal);
6389
+ if (scoped !== void 0 && view.representation.kind === "snapshot") view.representation = {
6390
+ ...view.representation,
6391
+ text: scoped
5811
6392
  };
5812
- }, opts?.signal);
5813
- }
5814
- /**
5815
- * Where a download the page starts may be written: the session's writable
5816
- * root, or why it may not write at all.
5817
- */
5818
- noteWorkspace(owner, target) {
5819
- this.workspaces.set(owner, target);
5820
- }
5821
- tabs(owner) {
5822
- return this.windows.list(owner);
5823
- }
5824
- takeClosed(owner) {
5825
- return this.windows.takeClosed(owner);
5826
- }
5827
- windowId(owner, tab) {
5828
- return this.exclusive(owner, async () => {
5829
- await this.ready;
5830
- return this.windows.windowId(owner, tab);
5831
- });
5832
- }
5833
- async fetch(url, opts) {
5834
- try {
5835
- await this.up();
5836
- const context = this.context;
5837
- if (context === void 0) return {
5838
- kind: "error",
5839
- reason: "browser is not running"
5840
- };
5841
- const headers = {};
5842
- if (opts?.accept !== void 0) headers.accept = opts.accept;
5843
- if (opts?.ifNoneMatch !== void 0) headers["if-none-match"] = opts.ifNoneMatch;
5844
- const response = await context.request.get(url, {
5845
- headers,
5846
- failOnStatusCode: false,
5847
- ...opts?.signal !== void 0 ? { signal: opts.signal } : {}
5848
- });
5849
- const headerMap = response.headers();
5850
- const body = {
5851
- kind: "response",
5852
- finalUrl: response.url(),
5853
- status: response.status(),
5854
- body: new Uint8Array(await untilAborted(response.body(), capped(opts?.signal, READ_MS)))
5855
- };
5856
- const mediaType = headerMap["content-type"];
5857
- const etag = headerMap.etag;
5858
- const link = headerMap.link;
5859
- if (mediaType !== void 0) body.mediaType = mediaType;
5860
- if (etag !== void 0) body.etag = etag;
5861
- if (link !== void 0) body.link = link;
5862
- return body;
6393
+ if (!showsSnapshot(op)) {
6394
+ const summary = outcome.summary ?? summaryOf(op);
6395
+ if (summary !== void 0) view.summary = summary;
6396
+ if (outcome.detail !== void 0 && outcome.detail !== "") view.detail = outcome.detail;
6397
+ }
6398
+ if (op.kind === "take_screenshot") view.summary = "";
6399
+ if (outcome.shot !== void 0) view.shot = outcome.shot;
6400
+ if (outcome.writes !== void 0) view.writes = outcome.writes;
6401
+ return this.attachDownload(live, view, false);
5863
6402
  } catch (error) {
5864
- return {
5865
- kind: "error",
5866
- reason: error instanceof Error ? error.message : String(error)
5867
- };
6403
+ if (error instanceof MhBrowserError && error.code === "TAKEN_OVER") throw error;
6404
+ if (this.control.ofTab(owner, tab) === "person") throw await this.takenOver(live);
6405
+ if (error instanceof MhBrowserError && error.code === "STALE_REF" && error.page === void 0) throw new MhBrowserError(error.code, error.message, await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" }, signal));
6406
+ throw error;
6407
+ } finally {
6408
+ linked.release();
5868
6409
  }
5869
- }
5870
- async pushPath(owner, tab, path) {
5871
- await this.ready;
5872
- const live = this.windows.get(owner, tab);
5873
- if (live === void 0) return;
5874
- await live.page.evaluate((next) => {
5875
- history.pushState({}, "", next);
5876
- }, path);
5877
- }
5878
- async started() {
6410
+ }, signal);
6411
+ }
6412
+ holdHandover(owner, tab) {
6413
+ const live = this.windows.get(owner, tab);
6414
+ if (live === void 0) throw new MhBrowserError(UNKNOWN_TAB, UNKNOWN_TAB_REASON);
6415
+ this.windows.holdHandover(live);
6416
+ }
6417
+ releaseHandover(owner) {
6418
+ this.windows.releaseHandover(owner);
6419
+ this.windows.markWaiting(owner, false);
6420
+ }
6421
+ /** `undefined` when no screenshot can be taken now: a dialog is open, or the page refused. */
6422
+ capture(owner, tab, signal) {
6423
+ return this.exclusive(owner, async () => {
5879
6424
  await this.ready;
5880
- }
5881
- /** The person asked for the browser: start it if it exited, past the exit limit. */
5882
- startForPerson() {
5883
- return this.up(true);
5884
- }
5885
- [testHooks]() {
5886
- return {
5887
- crash: async () => {
5888
- this.windows.suppressPersonInput();
5889
- await (this.context?.browser())?.close();
5890
- },
5891
- page: (owner, tab) => this.windows.get(owner, tab)?.page,
5892
- running: () => this.context !== void 0,
5893
- pages: () => this.context?.pages() ?? [],
5894
- pid: () => this.context === void 0 ? void 0 : this.pids.get(this.context),
5895
- useLocator: (env) => {
5896
- this.locator = env;
5897
- }
5898
- };
5899
- }
5900
- /**
5901
- * Where `browser` would be launched from when no path is configured.
5902
- * The configured path is not consulted: the answer is what an empty path uses.
5903
- */
5904
- async locateFor(browser, signal) {
5905
- const stopped = () => signal?.aborted === true;
5906
- if (stopped()) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
5907
- if (browser !== "chrome" && browser !== "msedge" && browser !== "chromium") throw new MhBrowserError("INVALID_ARGS", "browser must be chrome, msedge, or chromium");
5908
- const env = this.locateEnv();
5909
- if (browser === "chromium") return bundledChromiumLocation(env.access);
5910
- const located = await locateInstalled(browser, env, signal);
5911
- if (stopped()) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
5912
- return located;
5913
- }
5914
- async act(owner, tab, op, signal) {
5915
- return this.exclusive(owner, async () => {
5916
- await this.ready;
5917
- this.refuseIfPerson(owner, tab);
5918
- const live = this.windows.get(owner, tab);
5919
- if (live === void 0) throw new MhBrowserError(UNKNOWN_TAB, UNKNOWN_TAB_REASON);
5920
- if (signal?.aborted) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
5921
- const linked = this.control.link(owner, signal, tab);
5922
- try {
5923
- if (live.dialogHeld && op.kind !== "handle_dialog") throw new MhBrowserError("MODAL_STATE", "a dialog is open; use browser_handle_dialog", await this.blockedView(live));
5924
- if (live.fileChooser !== void 0 && op.kind !== "file_upload") {
5925
- const blocked = await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" }, signal);
5926
- blocked.dialog = {
5927
- type: "filechooser",
5928
- message: live.fileChooser.isMultiple() ? "choose files (several allowed) with browser_file_upload" : "choose one file with browser_file_upload"
5929
- };
5930
- throw new MhBrowserError("MODAL_STATE", "a file chooser is open; use browser_file_upload", blocked);
5931
- }
5932
- const outcome = await this.perform(live, op, linked.signal);
5933
- if (op.kind === "fill_form" || op.kind === "select_option" || op.kind === "type" || op.kind === "file_upload") {
5934
- if (outcome.navigated) this.windows.onNavigated(live);
5935
- else this.windows.holdInput(live);
5936
- } else if (outcome.navigated) this.windows.onNavigated(live);
5937
- this.windows.touch(live);
5938
- const shown = outcome.navigated ? await this.discover(live, live.page.url(), null, signal) : await this.refreshSnapshot(live, signal);
5939
- const scoped = op.kind === "snapshot" && (op.target !== void 0 || op.depth !== void 0 || op.boxes === true) ? await this.scopedSnapshot(live, op, signal) : void 0;
5940
- this.shown.set(live.id, shown);
5941
- this.windows.noteUrl(live, live.page.url());
5942
- const view = await this.pageFromShown(live, shown, {
5943
- surface: "snapshot",
5944
- ...outcome.dialog !== void 0 ? { dialog: outcome.dialog } : {},
5945
- ...outcome.incomplete !== void 0 ? { incomplete: outcome.incomplete } : {},
5946
- ...outcome.waited !== void 0 ? { waited: outcome.waited } : {}
5947
- }, signal);
5948
- if (scoped !== void 0 && view.representation.kind === "snapshot") view.representation = {
5949
- ...view.representation,
5950
- text: scoped
5951
- };
5952
- if (!showsSnapshot(op)) {
5953
- const summary = outcome.summary ?? summaryOf(op);
5954
- if (summary !== void 0) view.summary = summary;
5955
- if (outcome.detail !== void 0 && outcome.detail !== "") view.detail = outcome.detail;
5956
- }
5957
- if (op.kind === "take_screenshot") view.summary = "";
5958
- if (outcome.shot !== void 0) view.shot = outcome.shot;
5959
- if (outcome.writes !== void 0) view.writes = outcome.writes;
5960
- return this.attachDownload(live, view, false);
5961
- } catch (error) {
5962
- if (error instanceof MhBrowserError && error.code === "TAKEN_OVER") throw error;
5963
- if (this.control.ofTab(owner, tab) === "person") throw await this.takenOver(live);
5964
- if (error instanceof MhBrowserError && error.code === "STALE_REF" && error.page === void 0) throw new MhBrowserError(error.code, error.message, await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" }, signal));
5965
- throw error;
5966
- } finally {
5967
- linked.release();
5968
- }
5969
- }, signal);
5970
- }
5971
- holdHandover(owner, tab) {
5972
6425
  const live = this.windows.get(owner, tab);
5973
- if (live === void 0) throw new MhBrowserError(UNKNOWN_TAB, UNKNOWN_TAB_REASON);
5974
- this.windows.holdHandover(live);
5975
- }
5976
- releaseHandover(owner) {
5977
- this.windows.releaseHandover(owner);
5978
- this.windows.markWaiting(owner, false);
5979
- }
5980
- /** `undefined` when no screenshot can be taken now: a dialog is open, or the page refused. */
5981
- capture(owner, tab, signal) {
5982
- return this.exclusive(owner, async () => {
5983
- await this.ready;
5984
- const live = this.windows.get(owner, tab);
5985
- if (live === void 0 || live.dialogHeld) return void 0;
5986
- try {
5987
- const limit = capped(signal, READ_MS);
5988
- const bytes = await untilAborted(live.page.screenshot({
5989
- type: "png",
5990
- signal: limit
5991
- }), limit);
5992
- return new Uint8Array(bytes);
5993
- } catch {
5994
- return;
5995
- }
5996
- }, signal);
5997
- }
5998
- invoke(owner, _page, call, opts) {
5999
- return this.exclusive(owner, async () => {
6000
- await this.up();
6001
- if (opts?.signal?.aborted) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6002
- const signal = opts?.signal;
6003
- const sent = await runInvoke({
6004
- open: (url) => this.openAddress(owner, { url }, signal),
6005
- refreshIdle: (url, except) => this.refreshIdle(owner, url, except, signal),
6006
- send: (request) => this.sendAction(request, signal),
6007
- readFile: opts?.readFile ?? (async () => {
6008
- throw new MhBrowserError("NO_WORKSPACE", NO_WORKSPACE_REASON);
6009
- })
6010
- }, call);
6011
- await this.enforceCap(owner);
6012
- return sent;
6013
- }, opts?.signal);
6014
- }
6015
- /**
6016
- * Not queued: it opens no tab, and a long body must not hold up the
6017
- * Session's other calls. `root` is the directory the session may write.
6018
- */
6019
- async download(_owner, url, root, signal) {
6426
+ if (live === void 0 || live.dialogHeld) return void 0;
6427
+ try {
6428
+ const limit = capped(signal, READ_MS);
6429
+ const bytes = await untilAborted(live.page.screenshot({
6430
+ type: "png",
6431
+ signal: limit
6432
+ }), limit);
6433
+ return new Uint8Array(bytes);
6434
+ } catch {
6435
+ return;
6436
+ }
6437
+ }, signal);
6438
+ }
6439
+ invoke(owner, _page, call, opts) {
6440
+ return this.exclusive(owner, async () => {
6020
6441
  await this.up();
6021
- return this.saveResponse(url, root, signal);
6022
- }
6023
- controlOf(owner) {
6024
- return this.control.of(owner);
6025
- }
6026
- /** Presence registers this. A hand-back calls it with the touched presence pages. */
6027
- bindPersonTouch(fn) {
6028
- this.personTouch.add(fn);
6029
- return () => {
6030
- this.personTouch.delete(fn);
6031
- };
6032
- }
6033
- /**
6034
- * Give the browser to the person. The named tab stays in use until hand-back.
6035
- * This does not wait; the tool waits on the question card.
6036
- */
6037
- handover(owner, req, signal) {
6038
- return this.exclusive(owner, async () => {
6039
- await this.up();
6040
- if (signal?.aborted === true) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6041
- if (req.tab !== void 0) {
6042
- const live = this.windows.get(owner, req.tab);
6043
- if (live === void 0) throw new MhBrowserError(UNKNOWN_TAB, UNKNOWN_TAB_REASON);
6044
- this.windows.holdHandover(live);
6045
- await this.windows.activate(live, signal).catch(() => void 0);
6046
- }
6047
- this.control.handover(owner, req.tab);
6048
- this.windows.markWaiting(owner, true);
6049
- }, signal);
6050
- }
6051
- /** Remember the Session title for its tab group. Empty when the host has none. */
6052
- noteSessionTitle(owner, title) {
6053
- this.windows.setTitle(owner, title);
6054
- }
6055
- /** Raise the window of this owner's latest tab. False when it has no tab. */
6056
- async bringToFront(owner) {
6057
- await this.ready;
6058
- const live = this.windows.latest(owner);
6059
- if (live === void 0) return false;
6060
- await untilAborted(live.page.bringToFront(), AbortSignal.timeout(READ_MS));
6061
- return true;
6062
- }
6063
- /** The person asked to see the page this Session handed over. */
6064
- async revealHandover(sessionId, signal) {
6442
+ if (opts?.signal?.aborted) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6443
+ const signal = opts?.signal;
6444
+ const sent = await runInvoke({
6445
+ open: (url) => this.openAddress(owner, { url }, signal),
6446
+ refreshIdle: (url, except) => this.refreshIdle(owner, url, except, signal),
6447
+ send: (request) => this.sendAction(request, signal),
6448
+ readFile: opts?.readFile ?? (async () => {
6449
+ throw new MhBrowserError("NO_WORKSPACE", NO_WORKSPACE_REASON);
6450
+ })
6451
+ }, call);
6452
+ await this.enforceCap(owner);
6453
+ return sent;
6454
+ }, opts?.signal);
6455
+ }
6456
+ /**
6457
+ * Not queued: it opens no tab, and a long body must not hold up the
6458
+ * Session's other calls. `root` is the directory the session may write.
6459
+ */
6460
+ async download(_owner, url, root, signal) {
6461
+ await this.up();
6462
+ return this.saveResponse(url, root, signal);
6463
+ }
6464
+ controlOf(owner) {
6465
+ return this.control.of(owner);
6466
+ }
6467
+ /** Presence registers this. A hand-back calls it with the touched presence pages. */
6468
+ bindPersonTouch(fn) {
6469
+ this.personTouch.add(fn);
6470
+ return () => {
6471
+ this.personTouch.delete(fn);
6472
+ };
6473
+ }
6474
+ /**
6475
+ * Give the browser to the person. The named tab stays in use until hand-back.
6476
+ * This does not wait; the tool waits on the question card.
6477
+ */
6478
+ handover(owner, req, signal) {
6479
+ return this.exclusive(owner, async () => {
6480
+ await this.up();
6481
+ if (signal?.aborted === true) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
6482
+ if (req.tab !== void 0) {
6483
+ const live = this.windows.get(owner, req.tab);
6484
+ if (live === void 0) throw new MhBrowserError(UNKNOWN_TAB, UNKNOWN_TAB_REASON);
6485
+ this.windows.holdHandover(live);
6486
+ await this.windows.activate(live, signal).catch(() => void 0);
6487
+ }
6488
+ this.control.handover(owner, req.tab);
6489
+ this.windows.markWaiting(owner, true);
6490
+ }, signal);
6491
+ }
6492
+ /** Remember the Session title for its tab group. Empty when the host has none. */
6493
+ noteSessionTitle(owner, title) {
6494
+ this.windows.setTitle(owner, title);
6495
+ }
6496
+ /** Raise the window of this owner's latest tab. False when it has no tab. */
6497
+ async bringToFront(owner) {
6498
+ await this.ready;
6499
+ const live = this.windows.latest(owner);
6500
+ if (live === void 0) return false;
6501
+ await untilAborted(live.page.bringToFront(), AbortSignal.timeout(READ_MS));
6502
+ return true;
6503
+ }
6504
+ /** The person asked to see the page this Session handed over. */
6505
+ async revealHandover(sessionId, signal) {
6506
+ await this.ready;
6507
+ if (signal?.aborted === true) return { status: "not-handing-over" };
6508
+ if (!this.control.holds(sessionId)) return { status: "not-handing-over" };
6509
+ const named = this.control.namedTab(sessionId);
6510
+ const live = (named === void 0 ? void 0 : this.windows.get(sessionId, named)) ?? this.windows.latest(sessionId);
6511
+ if (live === void 0) return { status: "no-tab" };
6512
+ await untilAborted(live.page.bringToFront(), capped(signal, READ_MS));
6513
+ return { status: "revealed" };
6514
+ }
6515
+ /** The person answered. Returns the note, the operation count, and the named tab. */
6516
+ async handBack(owner, note) {
6517
+ const signal = capped(void 0, READ_MS);
6518
+ return this.exclusive(owner, async () => {
6065
6519
  await this.ready;
6066
- if (signal?.aborted === true) return { status: "not-handing-over" };
6067
- if (!this.control.holds(sessionId)) return { status: "not-handing-over" };
6068
- const named = this.control.namedTab(sessionId);
6069
- const live = (named === void 0 ? void 0 : this.windows.get(sessionId, named)) ?? this.windows.latest(sessionId);
6070
- if (live === void 0) return { status: "no-tab" };
6071
- await untilAborted(live.page.bringToFront(), capped(signal, READ_MS));
6072
- return { status: "revealed" };
6073
- }
6074
- /** The person answered. Returns the note, the operation count, and the named tab. */
6075
- async handBack(owner, note) {
6076
- const signal = capped(void 0, READ_MS);
6077
- return this.exclusive(owner, async () => {
6078
- await this.ready;
6079
- const taken = this.control.finish(owner);
6080
- if (taken === void 0) throw new MhBrowserError("INVALID_ARGS", "the browser is not with the person");
6081
- this.windows.releaseHandover(owner);
6082
- this.windows.markWaiting(owner, false);
6083
- const urls = this.presenceUrls(owner, taken.tabs);
6084
- for (const touch of this.personTouch) touch(owner, urls);
6085
- const page = await this.snapshotNamed(owner, taken.tab, signal);
6086
- return {
6087
- ...taken,
6088
- urls,
6089
- ...note !== void 0 && note !== "" ? { note } : {},
6090
- ...page !== void 0 ? { page } : {}
6091
- };
6092
- }, signal);
6093
- }
6094
- /** A stopped handover gives control back. A finished hand-back is left alone. */
6095
- releaseIfHeld(owner) {
6096
- if (!this.control.releaseIfHeld(owner)) return;
6520
+ const taken = this.control.finish(owner);
6521
+ if (taken === void 0) throw new MhBrowserError("INVALID_ARGS", "the browser is not with the person");
6097
6522
  this.windows.releaseHandover(owner);
6098
6523
  this.windows.markWaiting(owner, false);
6099
- this.relaunchIfIdle();
6100
- }
6101
- /**
6102
- * The person took over between turns. The next message they send is the
6103
- * hand-back. A handover that is still waiting on its card is not.
6104
- * The addresses are read before a waiting restart closes the pages.
6105
- */
6106
- takePersonHandback(owner) {
6107
- const taken = this.control.takePerson(owner);
6108
- if (taken === void 0) return void 0;
6109
6524
  const urls = this.presenceUrls(owner, taken.tabs);
6110
- this.windows.releaseHandover(owner);
6111
- this.relaunchIfIdle();
6525
+ for (const touch of this.personTouch) touch(owner, urls);
6526
+ const page = await this.snapshotNamed(owner, taken.tab, signal);
6112
6527
  return {
6113
6528
  ...taken,
6114
- urls
6529
+ urls,
6530
+ ...note !== void 0 && note !== "" ? { note } : {},
6531
+ ...page !== void 0 ? { page } : {}
6115
6532
  };
6116
- }
6117
- /**
6118
- * Read one address. A known origin is tried on the request channel first.
6119
- * A readable Agent View found on a tab this call opened is returned without
6120
- * the tab, and that tab is not listed as closed.
6121
- */
6122
- async openAddress(owner, item, signal, reveal = false) {
6123
- if (!openableAddress(item.url)) return {
6533
+ }, signal);
6534
+ }
6535
+ /** A stopped handover gives control back. A finished hand-back is left alone. */
6536
+ releaseIfHeld(owner) {
6537
+ if (!this.control.releaseIfHeld(owner)) return;
6538
+ this.windows.releaseHandover(owner);
6539
+ this.windows.markWaiting(owner, false);
6540
+ this.relaunchIfIdle();
6541
+ }
6542
+ /**
6543
+ * The person took over between turns. The next message they send is the
6544
+ * hand-back. A handover that is still waiting on its card is not.
6545
+ * The addresses are read before a waiting restart closes the pages.
6546
+ */
6547
+ takePersonHandback(owner) {
6548
+ const taken = this.control.takePerson(owner);
6549
+ if (taken === void 0) return void 0;
6550
+ const urls = this.presenceUrls(owner, taken.tabs);
6551
+ this.windows.releaseHandover(owner);
6552
+ this.relaunchIfIdle();
6553
+ return {
6554
+ ...taken,
6555
+ urls
6556
+ };
6557
+ }
6558
+ /**
6559
+ * Read one address. A known origin is tried on the request channel first.
6560
+ * A readable Agent View found on a tab this call opened is returned without
6561
+ * the tab, and that tab is not listed as closed.
6562
+ */
6563
+ async openAddress(owner, item, signal, reveal = false) {
6564
+ if (!openableAddress(item.url)) return {
6565
+ url: item.url,
6566
+ reason: OPEN_ADDRESS_REASON
6567
+ };
6568
+ const wantsTab = owner === "person" || item.snapshot === true || item.waitFor !== void 0;
6569
+ if (!wantsTab && this.knownOrigin(item.url)) {
6570
+ const via = await this.readKnown(owner, item.url, signal);
6571
+ if (via === STOPPED) return {
6124
6572
  url: item.url,
6125
- reason: OPEN_ADDRESS_REASON
6573
+ reason: WAITING_STOPPED
6126
6574
  };
6127
- const wantsTab = owner === "person" || item.snapshot === true || item.waitFor !== void 0;
6128
- if (!wantsTab && this.knownOrigin(item.url)) {
6129
- const via = await this.readKnown(owner, item.url, signal);
6130
- if (via === STOPPED) return {
6131
- url: item.url,
6132
- reason: WAITING_STOPPED
6133
- };
6134
- if (via !== void 0) return via;
6135
- }
6136
- const opened = await this.openOne(owner, item.url, item.waitFor, signal, item.snapshot === true, reveal);
6137
- this.keepAgentView(opened);
6138
- if (opened.live !== void 0 && !wantsTab && opened.fresh && this.closable(owner, opened.live) && "representation" in opened.view && opened.view.representation.kind === "agent-view") {
6139
- await this.windows.closeTab(opened.live);
6140
- const view = { ...opened.view };
6141
- delete view.tab;
6142
- return view;
6143
- }
6144
- return opened.view;
6575
+ if (via !== void 0) return via;
6576
+ }
6577
+ const opened = await this.openOne(owner, item.url, item.waitFor, signal, item.snapshot === true, reveal);
6578
+ this.keepAgentView(opened);
6579
+ if (opened.live !== void 0 && !wantsTab && opened.fresh && this.closable(owner, opened.live) && "representation" in opened.view && opened.view.representation.kind === "agent-view") {
6580
+ await this.windows.closeTab(opened.live);
6581
+ const view = { ...opened.view };
6582
+ delete view.tab;
6583
+ return view;
6145
6584
  }
6146
- /** A tab this call opened can be closed again unless the person took it or it holds input. */
6147
- closable(owner, live) {
6148
- return this.control.ofTab(owner, live.id) !== "person" && !live.occupied;
6585
+ return opened.view;
6586
+ }
6587
+ /** A tab this call opened can be closed again unless the person took it or it holds input. */
6588
+ closable(owner, live) {
6589
+ return this.control.ofTab(owner, live.id) !== "person" && !live.occupied;
6590
+ }
6591
+ knownOrigin(url) {
6592
+ try {
6593
+ return this.agentViewOrigins.has(new URL(url).origin);
6594
+ } catch {
6595
+ return false;
6149
6596
  }
6150
- knownOrigin(url) {
6151
- try {
6152
- return this.agentViewOrigins.has(new URL(url).origin);
6153
- } catch {
6154
- return false;
6155
- }
6597
+ }
6598
+ rememberOrigin(url) {
6599
+ try {
6600
+ this.agentViewOrigins.add(new URL(url).origin);
6601
+ } catch {}
6602
+ }
6603
+ /** A readable Agent View puts its origin on the list and keeps the document for the next read. */
6604
+ keepAgentView(opened) {
6605
+ if (opened.live === void 0) return;
6606
+ if (!("representation" in opened.view)) return;
6607
+ const representation = opened.view.representation;
6608
+ if (!(representation.kind === "agent-view" || representation.kind === "snapshot" && representation.agentView !== void 0)) return;
6609
+ this.rememberOrigin(opened.view.url);
6610
+ const shown = this.shown.get(opened.live.id);
6611
+ if (shown?.kind === "agent-view") this.rememberDocument(shown.pageUrl, shown);
6612
+ }
6613
+ /**
6614
+ * Read a known origin without a tab. `undefined` means this page has to be
6615
+ * opened in a tab: nothing declared, the declaration could not be read, or
6616
+ * the request failed. The origin stays on the list either way. A stop
6617
+ * ends the read without falling back to a tab.
6618
+ */
6619
+ async readKnown(owner, url, signal) {
6620
+ const key = canonical(url);
6621
+ const previous = this.documents.get(key);
6622
+ const servedAtPage = previous?.kind === "agent-view" && previous.href !== void 0 && canonical(previous.href) === canonical(previous.pageUrl);
6623
+ let response;
6624
+ try {
6625
+ const got = await this.fetchSameOrigin(url, PAGE_ACCEPT, servedAtPage ? previous.etag : void 0, signal);
6626
+ if (signal?.aborted === true) return STOPPED;
6627
+ if (got === void 0) return void 0;
6628
+ response = got;
6629
+ } catch {
6630
+ return signal?.aborted === true ? STOPPED : void 0;
6631
+ }
6632
+ if (response.status === 304 && previous?.kind === "agent-view") return this.pageOfShown(owner, previous);
6633
+ const text = new TextDecoder().decode(response.body);
6634
+ const pageUrl = canonical(response.finalUrl === "" ? url : response.finalUrl);
6635
+ const cached = this.documents.get(pageUrl);
6636
+ const shown = await loadShown({
6637
+ pageUrl,
6638
+ ...optionalFragment(response.finalUrl === "" ? url : response.finalUrl),
6639
+ snapshot: "",
6640
+ main: {
6641
+ status: response.status,
6642
+ ...response.mediaType !== void 0 ? { mediaType: response.mediaType } : {},
6643
+ finalUrl: response.finalUrl === "" ? url : response.finalUrl,
6644
+ ...response.link !== void 0 ? { link: response.link } : {},
6645
+ ...response.etag !== void 0 ? { etag: response.etag } : {},
6646
+ body: text
6647
+ },
6648
+ domLinks: linksInHtml(text, response.finalUrl === "" ? url : response.finalUrl),
6649
+ ...cached !== void 0 ? { previous: cached } : {},
6650
+ relocate: false,
6651
+ fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch, signal)
6652
+ });
6653
+ if (stopped(signal)) return STOPPED;
6654
+ if (shown.kind !== "agent-view") return void 0;
6655
+ this.rememberDocument(pageUrl, shown);
6656
+ this.rememberOrigin(pageUrl);
6657
+ return this.pageOfShown(owner, shown);
6658
+ }
6659
+ /** Keep the newest Agent Views. A dropped one is read in full the next time. */
6660
+ rememberDocument(url, shown) {
6661
+ const key = canonical(url);
6662
+ this.documents.delete(key);
6663
+ this.documents.set(key, shown);
6664
+ while (this.documents.size > DOCUMENT_CAP) {
6665
+ const oldest = this.documents.keys().next().value;
6666
+ if (oldest === void 0) return;
6667
+ this.documents.delete(oldest);
6668
+ }
6669
+ }
6670
+ /** One page from a document, with no tab. */
6671
+ pageOfShown(owner, shown) {
6672
+ const view = {
6673
+ url: shown.pageUrl,
6674
+ title: "",
6675
+ observedAt: (/* @__PURE__ */ new Date()).toISOString(),
6676
+ representation: shown.kind === "agent-view" ? {
6677
+ kind: "agent-view",
6678
+ text: shown.text,
6679
+ ...shown.version !== void 0 ? { version: shown.version } : {},
6680
+ ...shown.href !== void 0 ? { href: shown.href } : {}
6681
+ } : {
6682
+ kind: "snapshot",
6683
+ text: shown.snapshot,
6684
+ ...shown.agentViewError !== void 0 ? { agentViewError: shown.agentViewError } : {}
6685
+ },
6686
+ inPresence: this.book.isShown(owner, canonical(shown.pageUrl))
6687
+ };
6688
+ if (shown.fragment !== void 0) view.fragment = shown.fragment;
6689
+ if (shown.status !== void 0) view.status = shown.status;
6690
+ if (shown.mediaType !== void 0) view.mediaType = shown.mediaType;
6691
+ if (shown.body !== void 0) view.body = shown.body;
6692
+ return view;
6693
+ }
6694
+ /**
6695
+ * GET that follows same-origin redirects only. `undefined` when it cannot.
6696
+ * A body that is neither text nor an Agent View is not read: the page then
6697
+ * goes to a tab, which loads it anyway.
6698
+ */
6699
+ async fetchSameOrigin(url, accept, ifNoneMatch, signal) {
6700
+ const context = this.context;
6701
+ if (context === void 0) return void 0;
6702
+ let current = url;
6703
+ let origin;
6704
+ try {
6705
+ origin = new URL(url).origin;
6706
+ } catch {
6707
+ return;
6156
6708
  }
6157
- rememberOrigin(url) {
6158
- try {
6159
- this.agentViewOrigins.add(new URL(url).origin);
6160
- } catch {}
6161
- }
6162
- /** A readable Agent View puts its origin on the list and keeps the document for the next read. */
6163
- keepAgentView(opened) {
6164
- if (opened.live === void 0) return;
6165
- if (!("representation" in opened.view)) return;
6166
- const representation = opened.view.representation;
6167
- if (!(representation.kind === "agent-view" || representation.kind === "snapshot" && representation.agentView !== void 0)) return;
6168
- this.rememberOrigin(opened.view.url);
6169
- const shown = this.shown.get(opened.live.id);
6170
- if (shown?.kind === "agent-view") this.rememberDocument(shown.pageUrl, shown);
6171
- }
6172
- /**
6173
- * Read a known origin without a tab. `undefined` means this page has to be
6174
- * opened in a tab: nothing declared, the declaration could not be read, or
6175
- * the request failed. The origin stays on the list either way. A stop
6176
- * ends the read without falling back to a tab.
6177
- */
6178
- async readKnown(owner, url, signal) {
6179
- const key = canonical(url);
6180
- const previous = this.documents.get(key);
6181
- const servedAtPage = previous?.kind === "agent-view" && previous.href !== void 0 && canonical(previous.href) === canonical(previous.pageUrl);
6182
- let response;
6183
- try {
6184
- const got = await this.fetchSameOrigin(url, PAGE_ACCEPT, servedAtPage ? previous.etag : void 0, signal);
6185
- if (signal?.aborted === true) return STOPPED;
6186
- if (got === void 0) return void 0;
6187
- response = got;
6188
- } catch {
6189
- return signal?.aborted === true ? STOPPED : void 0;
6190
- }
6191
- if (response.status === 304 && previous?.kind === "agent-view") return this.pageOfShown(owner, previous);
6192
- const text = new TextDecoder().decode(response.body);
6193
- const pageUrl = canonical(response.finalUrl === "" ? url : response.finalUrl);
6194
- const cached = this.documents.get(pageUrl);
6195
- const shown = await loadShown({
6196
- pageUrl,
6197
- ...optionalFragment(response.finalUrl === "" ? url : response.finalUrl),
6198
- snapshot: "",
6199
- main: {
6200
- status: response.status,
6201
- ...response.mediaType !== void 0 ? { mediaType: response.mediaType } : {},
6202
- finalUrl: response.finalUrl === "" ? url : response.finalUrl,
6203
- ...response.link !== void 0 ? { link: response.link } : {},
6204
- ...response.etag !== void 0 ? { etag: response.etag } : {},
6205
- body: text
6206
- },
6207
- domLinks: linksInHtml(text, response.finalUrl === "" ? url : response.finalUrl),
6208
- ...cached !== void 0 ? { previous: cached } : {},
6209
- relocate: false,
6210
- fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch, signal)
6709
+ for (let hop = 0; hop < 10; hop += 1) {
6710
+ const headers = { accept };
6711
+ if (ifNoneMatch !== void 0 && hop === 0) headers["if-none-match"] = ifNoneMatch;
6712
+ const response = await context.request.get(current, {
6713
+ headers,
6714
+ maxRedirects: 0,
6715
+ failOnStatusCode: false,
6716
+ ...signal !== void 0 ? { signal } : {}
6211
6717
  });
6212
- if (stopped(signal)) return STOPPED;
6213
- if (shown.kind !== "agent-view") return void 0;
6214
- this.rememberDocument(pageUrl, shown);
6215
- this.rememberOrigin(pageUrl);
6216
- return this.pageOfShown(owner, shown);
6217
- }
6218
- /** Keep the newest Agent Views. A dropped one is read in full the next time. */
6219
- rememberDocument(url, shown) {
6220
- const key = canonical(url);
6221
- this.documents.delete(key);
6222
- this.documents.set(key, shown);
6223
- while (this.documents.size > DOCUMENT_CAP) {
6224
- const oldest = this.documents.keys().next().value;
6225
- if (oldest === void 0) return;
6226
- this.documents.delete(oldest);
6227
- }
6228
- }
6229
- /** One page from a document, with no tab. */
6230
- pageOfShown(owner, shown) {
6231
- const view = {
6232
- url: shown.pageUrl,
6233
- title: "",
6234
- observedAt: (/* @__PURE__ */ new Date()).toISOString(),
6235
- representation: shown.kind === "agent-view" ? {
6236
- kind: "agent-view",
6237
- text: shown.text,
6238
- ...shown.version !== void 0 ? { version: shown.version } : {},
6239
- ...shown.href !== void 0 ? { href: shown.href } : {}
6240
- } : {
6241
- kind: "snapshot",
6242
- text: shown.snapshot,
6243
- ...shown.agentViewError !== void 0 ? { agentViewError: shown.agentViewError } : {}
6244
- },
6245
- inPresence: this.book.isShown(owner, canonical(shown.pageUrl))
6246
- };
6247
- if (shown.fragment !== void 0) view.fragment = shown.fragment;
6248
- if (shown.status !== void 0) view.status = shown.status;
6249
- if (shown.mediaType !== void 0) view.mediaType = shown.mediaType;
6250
- if (shown.body !== void 0) view.body = shown.body;
6251
- return view;
6252
- }
6253
- /**
6254
- * GET that follows same-origin redirects only. `undefined` when it cannot.
6255
- * A body that is neither text nor an Agent View is not read: the page then
6256
- * goes to a tab, which loads it anyway.
6257
- */
6258
- async fetchSameOrigin(url, accept, ifNoneMatch, signal) {
6259
- const context = this.context;
6260
- if (context === void 0) return void 0;
6261
- let current = url;
6262
- let origin;
6263
- try {
6264
- origin = new URL(url).origin;
6265
- } catch {
6266
- return;
6267
- }
6268
- for (let hop = 0; hop < 10; hop += 1) {
6269
- const headers = { accept };
6270
- if (ifNoneMatch !== void 0 && hop === 0) headers["if-none-match"] = ifNoneMatch;
6271
- const response = await context.request.get(current, {
6272
- headers,
6273
- maxRedirects: 0,
6274
- failOnStatusCode: false,
6275
- ...signal !== void 0 ? { signal } : {}
6276
- });
6277
- const status = response.status();
6278
- const location = response.headers().location;
6279
- if (status >= 300 && status < 400 && location !== void 0 && location !== "") {
6280
- let next;
6281
- try {
6282
- next = new URL(location, current);
6283
- } catch {
6284
- return;
6285
- }
6286
- if (next.origin !== origin) return void 0;
6287
- current = next.href;
6288
- continue;
6289
- }
6290
- const headerMap = response.headers();
6291
- const mediaType = headerMap["content-type"];
6292
- const body = {
6293
- kind: "response",
6294
- finalUrl: current,
6295
- status,
6296
- body: readsAsText(mediaType) ? new Uint8Array(await untilAborted(response.body(), capped(signal, READ_MS))) : /* @__PURE__ */ new Uint8Array()
6297
- };
6298
- const etag = headerMap.etag;
6299
- const link = headerMap.link;
6300
- if (mediaType !== void 0) body.mediaType = mediaType;
6301
- if (etag !== void 0) body.etag = etag;
6302
- if (link !== void 0) body.link = link;
6303
- return body;
6304
- }
6305
- }
6306
- async openOne(owner, url, waitFor, signal, snapshot = false, reveal = false) {
6307
- assertWaitFor(waitFor);
6308
- const reused = this.idleTabOn(owner, url);
6309
- const fresh = reused === void 0;
6310
- let live;
6311
- try {
6312
- live = reused ?? await this.windows.openBlank(owner, signal);
6313
- } catch (error) {
6314
- if (signal?.aborted === true || isInterrupted(error)) return {
6315
- view: {
6316
- url,
6317
- reason: WAITING_STOPPED
6318
- },
6319
- fresh
6320
- };
6321
- throw error;
6322
- }
6323
- const linked = this.control.link(owner, signal, live.id);
6324
- if (reveal && !linked.signal.aborted) await live.page.bringToFront().catch(() => void 0);
6325
- const nextDialog = this.windows.nextDialog(live);
6326
- try {
6327
- let seenDialog;
6328
- const dialogDuringGoto = nextDialog.dialog.then((dialog) => {
6329
- seenDialog = dialog;
6330
- return dialog;
6331
- });
6332
- let navigationError;
6333
- let response = null;
6334
- const raced = this.goto(live.page, url, linked.signal).then((value) => {
6335
- response = value;
6336
- return "navigated";
6337
- }, (error) => {
6338
- navigationError = error;
6339
- return "failed";
6340
- });
6341
- const winner = await Promise.race([raced, dialogDuringGoto.then((dialog) => ({ dialog }))]);
6342
- nextDialog.cancel();
6343
- if (winner === "failed" && seenDialog === void 0) {
6344
- if (isDownloadStart(navigationError) || live.pendingDownload !== void 0) {
6345
- const view = await this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
6346
- if (view.url === "" || view.url === "about:blank") view.url = canonical(url);
6347
- this.applyDocumentType(live, view);
6348
- return {
6349
- view: await this.attachDownload(live, view, true),
6350
- fresh,
6351
- live
6352
- };
6353
- }
6354
- throw navigationError;
6355
- }
6356
- if (linked.signal.aborted) return {
6357
- view: {
6358
- tab: live.id,
6359
- url,
6360
- reason: WAITING_STOPPED
6361
- },
6362
- fresh,
6363
- live
6364
- };
6365
- const current = live.page.url();
6366
- const address = current === "about:blank" ? url : current;
6367
- this.windows.noteUrl(live, address);
6368
- const held = seenDialog ?? (typeof winner === "object" ? winner.dialog : void 0);
6369
- const dialog = held === void 0 ? void 0 : {
6370
- type: held.type(),
6371
- message: held.message()
6372
- };
6373
- if (held !== void 0) this.windows.holdDialog(live, held);
6374
- else this.windows.onNavigated(live);
6375
- const load = dialog !== void 0 ? {
6376
- load: false,
6377
- pendingRequests: 0,
6378
- dialog
6379
- } : await waitForLoad(live.page, linked.signal);
6380
- if (load.dialog !== void 0) this.windows.holdDialog(live);
6381
- let waited;
6382
- if (waitFor !== void 0 && load.dialog === void 0) {
6383
- const text = await waitForText(live.page, waitFor, linked.signal);
6384
- waited = {
6385
- met: text.met,
6386
- seconds: text.seconds
6387
- };
6388
- if (text.dialog !== void 0) this.windows.holdDialog(live);
6718
+ const status = response.status();
6719
+ const location = response.headers().location;
6720
+ if (status >= 300 && status < 400 && location !== void 0 && location !== "") {
6721
+ let next;
6722
+ try {
6723
+ next = new URL(location, current);
6724
+ } catch {
6725
+ return;
6389
6726
  }
6390
- this.windows.touch(live);
6391
- const shown = load.dialog !== void 0 ? await loadShown({
6392
- pageUrl: canonical(address),
6393
- ...optionalFragment(address),
6394
- snapshot: "",
6395
- relocate: false,
6396
- fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch, signal)
6397
- }) : await this.discover(live, address, response, signal);
6398
- this.shown.set(live.id, shown);
6399
- const view = await this.pageFromShown(live, shown, {
6400
- ...snapshot ? { surface: "snapshot" } : {},
6401
- ...load.load ? {} : { incomplete: {
6402
- load: load.load,
6403
- pendingRequests: load.pendingRequests
6404
- } },
6405
- ...load.dialog !== void 0 ? { dialog: load.dialog } : {},
6406
- ...waited !== void 0 ? { waited } : {}
6407
- }, signal);
6408
- return {
6409
- view: await this.attachDownload(live, view, false),
6410
- fresh,
6411
- live
6412
- };
6413
- } catch (error) {
6414
- nextDialog.cancel();
6415
- if (this.control.ofTab(owner, live.id) === "person") throw await this.takenOver(live);
6416
- if (linked.signal.aborted || error instanceof MhBrowserError && error.code === "INTERRUPTED") return {
6417
- view: {
6418
- tab: live.id,
6419
- url,
6420
- reason: WAITING_STOPPED
6421
- },
6422
- fresh,
6423
- live
6424
- };
6425
- return {
6426
- view: {
6427
- tab: live.id,
6428
- url,
6429
- reason: error instanceof Error ? error.message : String(error)
6430
- },
6431
- fresh,
6432
- live
6433
- };
6434
- } finally {
6435
- linked.release();
6727
+ if (next.origin !== origin) return void 0;
6728
+ current = next.href;
6729
+ continue;
6436
6730
  }
6437
- }
6438
- async perform(live, op, signal) {
6439
- if (op.kind === "snapshot") return { navigated: false };
6440
- if (op.kind === "find") return {
6441
- navigated: false,
6442
- summary: findInSnapshot(await this.aria(live, signal), op)
6731
+ const headerMap = response.headers();
6732
+ const mediaType = headerMap["content-type"];
6733
+ const body = {
6734
+ kind: "response",
6735
+ finalUrl: current,
6736
+ status,
6737
+ body: readsAsText(mediaType) ? new Uint8Array(await untilAborted(response.body(), capped(signal, READ_MS))) : /* @__PURE__ */ new Uint8Array()
6443
6738
  };
6444
- if (op.kind === "evaluate") {
6445
- const snapshot = this.shown.get(live.id)?.snapshot ?? "";
6446
- const target = op.target === void 0 ? void 0 : await locate(live.page, op.target, snapshot);
6447
- return this.evaluateOn(live, op, target, signal);
6448
- }
6449
- if (op.kind === "take_screenshot") return this.shoot(live, op, signal);
6450
- if (op.kind === "file_upload") return this.uploadHeld(live, op, signal);
6451
- if (op.kind === "wait_for") {
6452
- const waitFor = waitFrom(op);
6453
- assertWaitFor(waitFor);
6454
- const text = await waitForText(live.page, waitFor, signal);
6455
- if (text.dialog !== void 0) this.windows.holdDialog(live);
6456
- return {
6457
- navigated: false,
6458
- waited: {
6459
- met: text.met,
6460
- seconds: text.seconds
6461
- },
6462
- ...text.dialog !== void 0 ? { dialog: text.dialog } : {}
6463
- };
6464
- }
6465
- if (op.kind === "handle_dialog") {
6466
- const dialog = live.pendingDialog;
6467
- if (dialog === void 0) throw new MhBrowserError("INVALID_ARGS", "this page has no dialog");
6468
- const before = live.page.url();
6469
- if (op.accept) await (op.promptText !== void 0 ? dialog.accept(op.promptText) : dialog.accept());
6470
- else await dialog.dismiss();
6471
- this.windows.clearDialog(live);
6472
- const navigated = live.page.url() !== before;
6473
- return {
6474
- navigated,
6475
- ...settled(await settleAction(live.page, navigated, void 0, signal))
6476
- };
6477
- }
6478
- const requests = trackActionRequests(live.page);
6479
- const nextDialog = this.windows.nextDialog(live);
6480
- const chooser = watchFileChooser(live.page);
6481
- const snapshot = this.shown.get(live.id)?.snapshot ?? "";
6482
- this.windows.beginDispatch(live.page);
6483
- try {
6484
- const finished = runAction(live.page, op, snapshot, signal).then((navigated) => ({
6485
- kind: "done",
6486
- navigated
6487
- }));
6488
- const appeared = nextDialog.dialog.then((dialog) => ({
6489
- kind: "dialog",
6490
- dialog
6491
- }));
6492
- const chose = chooser.opened.then((opened) => ({
6493
- kind: "chooser",
6494
- opened
6495
- }));
6496
- const winner = await Promise.race([
6497
- finished,
6498
- appeared,
6499
- chose
6500
- ]).finally(() => {
6501
- this.windows.endDispatch(live.page);
6502
- });
6503
- if (winner.kind === "dialog") {
6504
- this.windows.holdDialog(live, winner.dialog);
6505
- finished.catch(() => void 0);
6506
- return {
6507
- navigated: false,
6508
- dialog: {
6509
- type: winner.dialog.type(),
6510
- message: winner.dialog.message()
6511
- }
6512
- };
6513
- }
6514
- if (winner.kind === "chooser") {
6515
- finished.catch(() => void 0);
6516
- return {
6517
- navigated: false,
6518
- dialog: this.chooserHeld(live, winner.opened)
6519
- };
6520
- }
6521
- const settledLoad = await Promise.race([settleAction(live.page, winner.navigated, requests, signal), chooser.opened.then((opened) => ({
6522
- load: true,
6523
- pendingRequests: 0,
6524
- dialog: this.chooserHeld(live, opened)
6525
- }))]);
6526
- return {
6527
- navigated: winner.navigated,
6528
- ...settled(settledLoad)
6529
- };
6530
- } finally {
6531
- requests.stop();
6532
- nextDialog.cancel();
6533
- chooser.stop();
6534
- }
6535
- }
6536
- /**
6537
- * `browser_evaluate`. A dialog the function opens is held like any other; a
6538
- * stop or a takeover ends the wait and still reports the writes already sent.
6539
- */
6540
- async evaluateOn(live, op, target, signal) {
6541
- const nextDialog = this.windows.nextDialog(live);
6542
- let navigated = false;
6543
- const onNav = (frame) => {
6544
- if (frame.parentFrame() === null) navigated = true;
6739
+ const etag = headerMap.etag;
6740
+ const link = headerMap.link;
6741
+ if (mediaType !== void 0) body.mediaType = mediaType;
6742
+ if (etag !== void 0) body.etag = etag;
6743
+ if (link !== void 0) body.link = link;
6744
+ return body;
6745
+ }
6746
+ }
6747
+ async openOne(owner, url, waitFor, signal, snapshot = false, reveal = false) {
6748
+ assertWaitFor(waitFor);
6749
+ const reused = this.idleTabOn(owner, url);
6750
+ const fresh = reused === void 0;
6751
+ let live;
6752
+ try {
6753
+ live = reused ?? await this.windows.openBlank(owner, signal);
6754
+ } catch (error) {
6755
+ if (signal?.aborted === true || isInterrupted(error)) return {
6756
+ view: {
6757
+ url,
6758
+ reason: WAITING_STOPPED
6759
+ },
6760
+ fresh
6545
6761
  };
6546
- live.page.on("framenavigated", onNav);
6547
- try {
6548
- const evaluated = await evaluateMarked(live.page, op.function, target, op.callId, signal, nextDialog.dialog);
6549
- const writes = formatWrites(evaluated.writes);
6550
- const counted = evaluated.writes.length === 0 ? {} : { writes: evaluated.writes };
6551
- if (evaluated.kind === "stopped") {
6552
- const note = writes === "" ? "" : `\n${writes}`;
6553
- if (this.control.ofTab(live.owner, live.id) === "person") throw await this.takenOver(live, note);
6554
- throw new MhBrowserError(INTERRUPTED, `${WAITING_STOPPED}${note}`);
6555
- }
6556
- if (evaluated.kind === "dialog") {
6557
- this.windows.holdDialog(live, evaluated.dialog);
6762
+ throw error;
6763
+ }
6764
+ const linked = this.control.link(owner, signal, live.id);
6765
+ if (reveal && !linked.signal.aborted) await live.page.bringToFront().catch(() => void 0);
6766
+ const nextDialog = this.windows.nextDialog(live);
6767
+ try {
6768
+ let seenDialog;
6769
+ const dialogDuringGoto = nextDialog.dialog.then((dialog) => {
6770
+ seenDialog = dialog;
6771
+ return dialog;
6772
+ });
6773
+ let navigationError;
6774
+ let response = null;
6775
+ const raced = this.goto(live.page, url, linked.signal).then((value) => {
6776
+ response = value;
6777
+ return "navigated";
6778
+ }, (error) => {
6779
+ navigationError = error;
6780
+ return "failed";
6781
+ });
6782
+ const winner = await Promise.race([raced, dialogDuringGoto.then((dialog) => ({ dialog }))]);
6783
+ nextDialog.cancel();
6784
+ if (winner === "failed" && seenDialog === void 0) {
6785
+ if (isDownloadStart(navigationError) || live.pendingDownload !== void 0) {
6786
+ const view = await this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
6787
+ if (view.url === "" || view.url === "about:blank") view.url = canonical(url);
6788
+ this.applyDocumentType(live, view);
6558
6789
  return {
6559
- navigated: false,
6560
- dialog: {
6561
- type: evaluated.dialog.type(),
6562
- message: evaluated.dialog.message()
6563
- },
6564
- summary: "",
6565
- detail: writes,
6566
- ...counted
6790
+ view: await this.attachDownload(live, view, true),
6791
+ fresh,
6792
+ live
6567
6793
  };
6568
6794
  }
6569
- if (evaluated.kind === "navigated") return {
6570
- navigated: true,
6571
- summary: NAVIGATED_DURING_EVALUATE,
6572
- detail: writes,
6573
- ...counted
6574
- };
6575
- return {
6576
- navigated,
6577
- summary: evaluated.value,
6578
- detail: writes,
6579
- ...counted
6580
- };
6581
- } finally {
6582
- live.page.off("framenavigated", onNav);
6583
- nextDialog.cancel();
6795
+ throw navigationError;
6584
6796
  }
6585
- }
6586
- async uploadHeld(live, op, signal) {
6587
- const chooser = live.fileChooser;
6588
- if (chooser === void 0) throw new MhBrowserError("INVALID_ARGS", "No file chooser visible");
6589
- const paths = op.paths;
6590
- if (paths === void 0 || paths.length === 0) {
6591
- live.fileChooser = void 0;
6592
- return { navigated: false };
6593
- }
6594
- const requests = trackActionRequests(live.page);
6595
- let navigated = false;
6596
- const onNav = (frame) => {
6597
- if (frame.parentFrame() === null) navigated = true;
6598
- };
6599
- live.page.on("framenavigated", onNav);
6600
- this.windows.beginDispatch(live.page);
6601
- try {
6602
- await chooser.setFiles(paths, signal === void 0 ? {} : { signal });
6603
- live.fileChooser = void 0;
6604
- await settleAction(live.page, navigated, requests, signal);
6605
- return { navigated };
6606
- } finally {
6607
- live.page.off("framenavigated", onNav);
6608
- this.windows.endDispatch(live.page);
6609
- requests.stop();
6610
- }
6611
- }
6612
- async shoot(live, op, signal) {
6613
- if (op.fullPage === true && op.target !== void 0) throw new MhBrowserError("INVALID_ARGS", "fullPage cannot be used with element screenshots.");
6614
- const type = op.type ?? "png";
6615
- const snapshot = this.shown.get(live.id)?.snapshot ?? "";
6616
- const locator = op.target === void 0 ? void 0 : await locate(live.page, op.target, snapshot);
6617
- const options = {
6618
- type,
6619
- ...type === "jpeg" ? { quality: 90 } : {},
6620
- ...op.scale !== void 0 ? { scale: op.scale } : {},
6621
- ...op.fullPage === true ? { fullPage: true } : {},
6622
- ...signal !== void 0 ? { signal } : {}
6623
- };
6624
- const bytes = locator === void 0 ? await live.page.screenshot(options) : await locator.screenshot(options);
6625
- return {
6626
- navigated: false,
6627
- shot: {
6628
- mediaType: `image/${type}`,
6629
- bytes: new Uint8Array(bytes)
6630
- }
6631
- };
6632
- }
6633
- /** A snapshot limited to a target, a depth, or boxes. Refs still resolve against the full one. */
6634
- async scopedSnapshot(tab, op, signal) {
6635
- const options = {
6636
- mode: "ai",
6637
- ...op.depth !== void 0 ? { depth: op.depth } : {},
6638
- ...op.boxes === true ? { boxes: true } : {}
6639
- };
6640
- const snapshot = this.shown.get(tab.id)?.snapshot ?? "";
6641
- const limit = capped(signal, READ_MS);
6642
- if (op.target !== void 0) return untilAborted((await locate(tab.page, op.target, snapshot)).ariaSnapshot(options), limit);
6643
- return untilAborted(tab.page.ariaSnapshot(options), limit);
6644
- }
6645
- /** A file chooser the action opened. The tab stays in use until it navigates. */
6646
- chooserHeld(live, chooser) {
6647
- live.fileChooser = chooser;
6648
- this.windows.holdInput(live);
6649
- return {
6650
- type: "filechooser",
6651
- message: chooser.isMultiple() ? "choose files (several allowed) with browser_file_upload" : "choose one file with browser_file_upload"
6652
- };
6653
- }
6654
- async refreshSnapshot(tab, signal) {
6655
- const previous = this.shown.get(tab.id);
6656
- const snapshot = tab.dialogHeld ? "" : await this.aria(tab, signal);
6657
- if (previous === void 0) return {
6658
- pageUrl: canonical(tab.page.url()),
6659
- digest: "",
6660
- kind: "snapshot",
6661
- text: snapshot,
6662
- snapshot
6797
+ if (linked.signal.aborted) return {
6798
+ view: {
6799
+ tab: live.id,
6800
+ url,
6801
+ reason: WAITING_STOPPED
6802
+ },
6803
+ fresh,
6804
+ live
6663
6805
  };
6664
- return {
6665
- ...previous,
6666
- snapshot
6806
+ const current = live.page.url();
6807
+ const address = current === "about:blank" ? url : current;
6808
+ this.windows.noteUrl(live, address);
6809
+ const held = seenDialog ?? (typeof winner === "object" ? winner.dialog : void 0);
6810
+ const dialog = held === void 0 ? void 0 : {
6811
+ type: held.type(),
6812
+ message: held.message()
6667
6813
  };
6668
- }
6669
- async blockedView(tab) {
6670
- const dialog = tab.pendingDialog;
6671
- return this.pageFromShown(tab, this.shown.get(tab.id), {
6672
- surface: "snapshot",
6673
- ...dialog !== void 0 ? { dialog: {
6674
- type: dialog.type(),
6675
- message: dialog.message()
6676
- } } : {}
6677
- });
6678
- }
6679
- async goto(page, url, signal) {
6680
- this.windows.beginDispatch(page);
6681
- try {
6682
- return await page.goto(url, {
6683
- waitUntil: "domcontentloaded",
6684
- timeout: NAVIGATION_MS,
6685
- ...signal !== void 0 ? { signal } : {}
6686
- });
6687
- } finally {
6688
- this.windows.endDispatch(page);
6814
+ if (held !== void 0) this.windows.holdDialog(live, held);
6815
+ else this.windows.onNavigated(live);
6816
+ const load = dialog !== void 0 ? {
6817
+ load: false,
6818
+ pendingRequests: 0,
6819
+ dialog
6820
+ } : await waitForLoad(live.page, linked.signal);
6821
+ if (load.dialog !== void 0) this.windows.holdDialog(live);
6822
+ let waited;
6823
+ if (waitFor !== void 0 && load.dialog === void 0) {
6824
+ const text = await waitForText(live.page, waitFor, linked.signal);
6825
+ waited = {
6826
+ met: text.met,
6827
+ seconds: text.seconds
6828
+ };
6829
+ if (text.dialog !== void 0) this.windows.holdDialog(live);
6689
6830
  }
6690
- }
6691
- async discover(tab, address, response, signal) {
6692
- const main = response === null ? void 0 : await mainDocument(response, signal);
6693
- const relocated = main !== void 0 && !samePage(main.finalUrl, address);
6694
- const domLinks = relocated ? [] : await readDomLinks(tab.page, signal);
6695
- const previous = this.shown.get(tab.id);
6696
- return loadShown({
6831
+ this.windows.touch(live);
6832
+ const shown = load.dialog !== void 0 ? await loadShown({
6697
6833
  pageUrl: canonical(address),
6698
6834
  ...optionalFragment(address),
6699
- snapshot: await this.aria(tab, signal),
6700
- ...main !== void 0 && !relocated ? { main } : {},
6701
- ...domLinks.length > 0 ? { domLinks } : {},
6702
- ...previous !== void 0 ? { previous } : {},
6703
- relocate: relocated,
6835
+ snapshot: "",
6836
+ relocate: false,
6704
6837
  fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch, signal)
6705
- });
6706
- }
6707
- fetchForRead(url, accept, ifNoneMatch, signal) {
6708
- return this.fetch(url, {
6709
- accept,
6710
- ...ifNoneMatch !== void 0 ? { ifNoneMatch } : {},
6711
- ...signal !== void 0 ? { signal } : {}
6712
- });
6713
- }
6714
- async aria(tab, signal) {
6715
- return untilAborted(tab.page.ariaSnapshot({ mode: "ai" }).catch((error) => {
6716
- this.ctx.logger.debug("aria snapshot failed: %s", errorText(error));
6717
- return "";
6718
- }), capped(signal, READ_MS));
6719
- }
6720
- async pageFromShown(tab, shown, extra, signal) {
6721
- const current = shown ?? {
6722
- pageUrl: canonical(tab.url),
6723
- digest: "",
6724
- kind: "snapshot",
6725
- text: "",
6726
- snapshot: ""
6838
+ }) : await this.discover(live, address, response, signal);
6839
+ this.shown.set(live.id, shown);
6840
+ const view = await this.pageFromShown(live, shown, {
6841
+ ...snapshot ? { surface: "snapshot" } : {},
6842
+ ...load.load ? {} : { incomplete: {
6843
+ load: load.load,
6844
+ pendingRequests: load.pendingRequests
6845
+ } },
6846
+ ...load.dialog !== void 0 ? { dialog: load.dialog } : {},
6847
+ ...waited !== void 0 ? { waited } : {}
6848
+ }, signal);
6849
+ return {
6850
+ view: await this.attachDownload(live, view, false),
6851
+ fresh,
6852
+ live
6727
6853
  };
6728
- const title = tab.dialogHeld ? "" : await untilAborted(tab.page.title().catch((error) => {
6729
- this.ctx.logger.debug("page title failed: %s", errorText(error));
6730
- return "";
6731
- }), capped(signal, READ_MS));
6732
- this.windows.noteTitle(tab, title);
6733
- const view = {
6734
- tab: tab.id,
6735
- url: current.pageUrl,
6736
- title,
6737
- observedAt: (/* @__PURE__ */ new Date()).toISOString(),
6738
- representation: current.kind === "agent-view" ? {
6739
- kind: "agent-view",
6740
- text: current.text,
6741
- ...current.version !== void 0 ? { version: current.version } : {},
6742
- ...current.href !== void 0 ? { href: current.href } : {}
6743
- } : {
6744
- kind: "snapshot",
6745
- text: current.snapshot,
6746
- ...current.agentViewError !== void 0 ? { agentViewError: current.agentViewError } : {}
6854
+ } catch (error) {
6855
+ nextDialog.cancel();
6856
+ if (this.control.ofTab(owner, live.id) === "person") throw await this.takenOver(live);
6857
+ if (linked.signal.aborted || error instanceof MhBrowserError && error.code === "INTERRUPTED") return {
6858
+ view: {
6859
+ tab: live.id,
6860
+ url,
6861
+ reason: WAITING_STOPPED
6747
6862
  },
6748
- inPresence: this.book.isShown(tab.owner, canonical(current.pageUrl))
6863
+ fresh,
6864
+ live
6749
6865
  };
6750
- tab.inPresence = view.inPresence;
6751
- if (extra?.surface === "snapshot") view.representation = snapshotSurface(current);
6752
- if (current.fragment !== void 0) view.fragment = current.fragment;
6753
- if (current.status !== void 0) view.status = current.status;
6754
- if (current.mediaType !== void 0) view.mediaType = current.mediaType;
6755
- if (current.body !== void 0) view.body = current.body;
6756
- if (extra?.dialog !== void 0) view.dialog = extra.dialog;
6757
- if (extra?.incomplete !== void 0) view.incomplete = extra.incomplete;
6758
- if (extra?.waited !== void 0) view.waited = extra.waited;
6759
- return view;
6760
- }
6761
- /**
6762
- * Read one presence page. A known origin is read on the request channel,
6763
- * with If-None-Match when the document was read before. Anything else uses
6764
- * a tab already on the address, or opens one and closes it again.
6765
- */
6766
- async observePresence(owner, url, signal) {
6767
- if (this.knownOrigin(url)) {
6768
- const via = await this.readKnown(owner, url, signal);
6769
- if (via === STOPPED) return observationOf(url, {
6866
+ return {
6867
+ view: {
6868
+ tab: live.id,
6770
6869
  url,
6771
- reason: WAITING_STOPPED
6772
- });
6773
- if (via !== void 0) return observationOf(url, via);
6774
- }
6775
- const existing = this.tabOn(owner, url);
6776
- if (existing !== void 0) return observationOf(url, await this.refreshView(existing, signal));
6777
- const opened = await this.openOne(owner, url, void 0, signal, false);
6778
- this.keepAgentView(opened);
6779
- const view = opened.view;
6780
- if (opened.fresh && "tab" in view && view.tab !== void 0) {
6781
- const live = this.windows.get(owner, view.tab);
6782
- if (live !== void 0 && this.closable(owner, live)) {
6783
- const agent = "representation" in view && view.representation.kind === "agent-view";
6784
- await this.windows.closeTab(live, agent ? void 0 : `${live.id} (idle)`);
6785
- if (agent) {
6786
- const shown = { ...view };
6787
- delete shown.tab;
6788
- return observationOf(url, shown);
6789
- }
6790
- }
6791
- }
6792
- return observationOf(url, view);
6870
+ reason: error instanceof Error ? error.message : String(error)
6871
+ },
6872
+ fresh,
6873
+ live
6874
+ };
6875
+ } finally {
6876
+ linked.release();
6877
+ }
6878
+ }
6879
+ async perform(live, op, signal) {
6880
+ if (op.kind === "snapshot") return { navigated: false };
6881
+ if (op.kind === "find") return {
6882
+ navigated: false,
6883
+ summary: findInSnapshot(await this.aria(live, signal), op)
6884
+ };
6885
+ if (op.kind === "evaluate") {
6886
+ const snapshot = this.shown.get(live.id)?.snapshot ?? "";
6887
+ const target = op.target === void 0 ? void 0 : await locate(live.page, op.target, snapshot);
6888
+ return this.evaluateOn(live, op, target, signal);
6889
+ }
6890
+ if (op.kind === "take_screenshot") return this.shoot(live, op, signal);
6891
+ if (op.kind === "file_upload") return this.uploadHeld(live, op, signal);
6892
+ if (op.kind === "wait_for") {
6893
+ const waitFor = waitFrom(op);
6894
+ assertWaitFor(waitFor);
6895
+ const text = await waitForText(live.page, waitFor, signal);
6896
+ if (text.dialog !== void 0) this.windows.holdDialog(live);
6897
+ return {
6898
+ navigated: false,
6899
+ waited: {
6900
+ met: text.met,
6901
+ seconds: text.seconds
6902
+ },
6903
+ ...text.dialog !== void 0 ? { dialog: text.dialog } : {}
6904
+ };
6793
6905
  }
6794
- tabOn(owner, url) {
6795
- const pageUrl = canonical(url);
6796
- const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((tab) => tab !== void 0 && canonical(tab.url) === pageUrl);
6797
- tabs.sort((left, right) => right.touchedAt - left.touchedAt);
6798
- return tabs[0];
6906
+ if (op.kind === "handle_dialog") {
6907
+ const dialog = live.pendingDialog;
6908
+ if (dialog === void 0) throw new MhBrowserError("INVALID_ARGS", "this page has no dialog");
6909
+ const before = live.page.url();
6910
+ if (op.accept) await (op.promptText !== void 0 ? dialog.accept(op.promptText) : dialog.accept());
6911
+ else await dialog.dismiss();
6912
+ this.windows.clearDialog(live);
6913
+ const navigated = live.page.url() !== before;
6914
+ return {
6915
+ navigated,
6916
+ ...settled(await settleAction(live.page, navigated, void 0, signal))
6917
+ };
6799
6918
  }
6800
- async refreshView(live, signal) {
6801
- this.windows.touch(live);
6802
- if (live.dialogHeld) return this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
6803
- const address = live.page.url();
6804
- const previous = this.shown.get(live.id);
6805
- const shown = await loadShown({
6806
- pageUrl: canonical(address),
6807
- ...optionalFragment(address),
6808
- snapshot: await this.aria(live, signal),
6809
- ...previous !== void 0 ? { previous } : {},
6810
- relocate: previous !== void 0 && !samePage(previous.pageUrl, address),
6811
- fetch: (url, accept, ifNoneMatch) => this.fetchForRead(url, accept, ifNoneMatch, signal)
6919
+ const requests = trackActionRequests(live.page);
6920
+ const nextDialog = this.windows.nextDialog(live);
6921
+ const chooser = watchFileChooser(live.page);
6922
+ const snapshot = this.shown.get(live.id)?.snapshot ?? "";
6923
+ this.windows.beginDispatch(live.page);
6924
+ try {
6925
+ const finished = runAction(live.page, op, snapshot, signal).then((navigated) => ({
6926
+ kind: "done",
6927
+ navigated
6928
+ }));
6929
+ const appeared = nextDialog.dialog.then((dialog) => ({
6930
+ kind: "dialog",
6931
+ dialog
6932
+ }));
6933
+ const chose = chooser.opened.then((opened) => ({
6934
+ kind: "chooser",
6935
+ opened
6936
+ }));
6937
+ const winner = await Promise.race([
6938
+ finished,
6939
+ appeared,
6940
+ chose
6941
+ ]).finally(() => {
6942
+ this.windows.endDispatch(live.page);
6812
6943
  });
6813
- this.shown.set(live.id, shown);
6814
- this.windows.noteUrl(live, address);
6815
- return this.pageFromShown(live, shown, void 0, signal);
6816
- }
6817
- /** The most recently touched idle tab on this address. A tab the person holds is left alone. */
6818
- idleTabOn(owner, url) {
6819
- const pageUrl = canonical(url);
6820
- const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((tab) => tab !== void 0 && canonical(tab.url) === pageUrl && !tab.occupied && this.control.ofTab(owner, tab.id) !== "person");
6821
- tabs.sort((left, right) => right.touchedAt - left.touchedAt);
6822
- return tabs[0];
6823
- }
6824
- /** Only Session windows are capped; the person's window is theirs to close. */
6825
- async enforceCap(owner) {
6826
- if (owner === "person") return;
6827
- await this.windows.enforceCap(owner);
6828
- }
6829
- /** Other tabs on this address: idle ones reload, occupied or taken ones are marked. */
6830
- async refreshIdle(owner, url, except, signal) {
6831
- const pageUrl = canonical(url);
6832
- const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((item) => item !== void 0 && canonical(item.url) === pageUrl);
6833
- for (const item of tabs) {
6834
- if (item.id === except) continue;
6835
- if (item.occupied || this.control.ofTab(owner, item.id) === "person") this.windows.markMayHaveChanged(item);
6836
- else await this.navigateTab(item, item.page.url(), signal);
6944
+ if (winner.kind === "dialog") {
6945
+ this.windows.holdDialog(live, winner.dialog);
6946
+ finished.catch(() => void 0);
6947
+ return {
6948
+ navigated: false,
6949
+ dialog: {
6950
+ type: winner.dialog.type(),
6951
+ message: winner.dialog.message()
6952
+ }
6953
+ };
6837
6954
  }
6838
- }
6839
- async navigateTab(live, url, signal) {
6840
- try {
6841
- const response = await this.goto(live.page, url, signal);
6842
- this.windows.onNavigated(live);
6843
- const address = live.page.url();
6844
- this.windows.noteUrl(live, address === "about:blank" ? url : address);
6845
- const load = await waitForLoad(live.page, signal);
6846
- if (load.dialog !== void 0) this.windows.holdDialog(live);
6847
- this.windows.touch(live);
6848
- const shown = await this.discover(live, live.page.url(), response, signal);
6849
- this.shown.set(live.id, shown);
6850
- const view = await this.pageFromShown(live, shown, {
6851
- ...load.load ? {} : { incomplete: {
6852
- load: load.load,
6853
- pendingRequests: load.pendingRequests
6854
- } },
6855
- ...load.dialog !== void 0 ? { dialog: load.dialog } : {}
6856
- }, signal);
6857
- return this.attachDownload(live, view, false);
6858
- } catch (error) {
6859
- if (isDownloadStart(error) || live.pendingDownload !== void 0) {
6860
- const view = await this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
6861
- if (view.url === "" || view.url === "about:blank") view.url = canonical(url);
6862
- this.applyDocumentType(live, view);
6863
- return this.attachDownload(live, view, true);
6864
- }
6955
+ if (winner.kind === "chooser") {
6956
+ finished.catch(() => void 0);
6865
6957
  return {
6866
- tab: live.id,
6867
- url,
6868
- reason: error instanceof Error ? error.message : String(error)
6958
+ navigated: false,
6959
+ dialog: this.chooserHeld(live, winner.opened)
6869
6960
  };
6870
6961
  }
6962
+ const settledLoad = await Promise.race([settleAction(live.page, winner.navigated, requests, signal), chooser.opened.then((opened) => ({
6963
+ load: true,
6964
+ pendingRequests: 0,
6965
+ dialog: this.chooserHeld(live, opened)
6966
+ }))]);
6967
+ return {
6968
+ navigated: winner.navigated,
6969
+ ...settled(settledLoad)
6970
+ };
6971
+ } finally {
6972
+ requests.stop();
6973
+ nextDialog.cancel();
6974
+ chooser.stop();
6871
6975
  }
6872
- async sendAction(request, signal) {
6873
- const context = this.context;
6874
- if (context === void 0) return { kind: "lost" };
6875
- try {
6876
- const response = await context.request.fetch(request.url, {
6877
- method: request.method,
6878
- headers: request.headers,
6879
- maxRedirects: 0,
6880
- maxRetries: 0,
6881
- failOnStatusCode: false,
6882
- ...signal !== void 0 ? { signal } : {},
6883
- ...request.multipart !== void 0 ? { multipart: request.multipart } : {},
6884
- ...request.data !== void 0 ? { data: request.data } : {}
6885
- });
6886
- const headers = response.headers();
6887
- const location = headers.location;
6888
- const mediaType = headers["content-type"];
6976
+ }
6977
+ /**
6978
+ * `browser_evaluate`. A dialog the function opens is held like any other; a
6979
+ * stop or a takeover ends the wait and still reports the writes already sent.
6980
+ */
6981
+ async evaluateOn(live, op, target, signal) {
6982
+ const nextDialog = this.windows.nextDialog(live);
6983
+ let navigated = false;
6984
+ const onNav = (frame) => {
6985
+ if (frame.parentFrame() === null) navigated = true;
6986
+ };
6987
+ live.page.on("framenavigated", onNav);
6988
+ try {
6989
+ const evaluated = await evaluateMarked(live.page, op.function, target, op.callId, signal, nextDialog.dialog);
6990
+ const writes = formatWrites(evaluated.writes);
6991
+ const counted = evaluated.writes.length === 0 ? {} : { writes: evaluated.writes };
6992
+ if (evaluated.kind === "stopped") {
6993
+ const note = writes === "" ? "" : `\n${writes}`;
6994
+ if (this.control.ofTab(live.owner, live.id) === "person") throw await this.takenOver(live, note);
6995
+ throw new MhBrowserError(INTERRUPTED, `${WAITING_STOPPED}${note}`);
6996
+ }
6997
+ if (evaluated.kind === "dialog") {
6998
+ this.windows.holdDialog(live, evaluated.dialog);
6889
6999
  return {
6890
- kind: "response",
6891
- status: response.status(),
6892
- body: new Uint8Array(await untilAborted(response.body(), capped(signal, READ_MS))),
6893
- ...location === void 0 || location === "" ? {} : { location },
6894
- ...mediaType === void 0 ? {} : { mediaType }
7000
+ navigated: false,
7001
+ dialog: {
7002
+ type: evaluated.dialog.type(),
7003
+ message: evaluated.dialog.message()
7004
+ },
7005
+ summary: "",
7006
+ detail: writes,
7007
+ ...counted
6895
7008
  };
6896
- } catch (error) {
6897
- if (signal?.aborted === true) throw new MhBrowserError(INTERRUPTED, "stopped after sending; it may have taken effect");
6898
- this.ctx.logger.debug("declared action was not answered: %s", errorText(error));
6899
- return { kind: "lost" };
6900
7009
  }
7010
+ if (evaluated.kind === "navigated") return {
7011
+ navigated: true,
7012
+ summary: NAVIGATED_DURING_EVALUATE,
7013
+ detail: writes,
7014
+ ...counted
7015
+ };
7016
+ return {
7017
+ navigated,
7018
+ summary: evaluated.value,
7019
+ detail: writes,
7020
+ ...counted
7021
+ };
7022
+ } finally {
7023
+ live.page.off("framenavigated", onNav);
7024
+ nextDialog.cancel();
7025
+ }
7026
+ }
7027
+ async uploadHeld(live, op, signal) {
7028
+ const chooser = live.fileChooser;
7029
+ if (chooser === void 0) throw new MhBrowserError("INVALID_ARGS", "No file chooser visible");
7030
+ const paths = op.paths;
7031
+ if (paths === void 0 || paths.length === 0) {
7032
+ live.fileChooser = void 0;
7033
+ return { navigated: false };
7034
+ }
7035
+ const requests = trackActionRequests(live.page);
7036
+ let navigated = false;
7037
+ const onNav = (frame) => {
7038
+ if (frame.parentFrame() === null) navigated = true;
7039
+ };
7040
+ live.page.on("framenavigated", onNav);
7041
+ this.windows.beginDispatch(live.page);
7042
+ try {
7043
+ await chooser.setFiles(paths, signal === void 0 ? {} : { signal });
7044
+ live.fileChooser = void 0;
7045
+ await settleAction(live.page, navigated, requests, signal);
7046
+ return { navigated };
7047
+ } finally {
7048
+ live.page.off("framenavigated", onNav);
7049
+ this.windows.endDispatch(live.page);
7050
+ requests.stop();
6901
7051
  }
6902
- async saveResponse(url, root, signal) {
6903
- const context = this.context;
6904
- if (context === void 0) return {
6905
- kind: "error",
7052
+ }
7053
+ async shoot(live, op, signal) {
7054
+ if (op.fullPage === true && op.target !== void 0) throw new MhBrowserError("INVALID_ARGS", "fullPage cannot be used with element screenshots.");
7055
+ const type = op.type ?? "png";
7056
+ const snapshot = this.shown.get(live.id)?.snapshot ?? "";
7057
+ const locator = op.target === void 0 ? void 0 : await locate(live.page, op.target, snapshot);
7058
+ const options = {
7059
+ type,
7060
+ ...type === "jpeg" ? { quality: 90 } : {},
7061
+ ...op.scale !== void 0 ? { scale: op.scale } : {},
7062
+ ...op.fullPage === true ? { fullPage: true } : {},
7063
+ ...signal !== void 0 ? { signal } : {}
7064
+ };
7065
+ const bytes = locator === void 0 ? await live.page.screenshot(options) : await locator.screenshot(options);
7066
+ return {
7067
+ navigated: false,
7068
+ shot: {
7069
+ mediaType: `image/${type}`,
7070
+ bytes: new Uint8Array(bytes)
7071
+ }
7072
+ };
7073
+ }
7074
+ /** A snapshot limited to a target, a depth, or boxes. Refs still resolve against the full one. */
7075
+ async scopedSnapshot(tab, op, signal) {
7076
+ const options = {
7077
+ mode: "ai",
7078
+ ...op.depth !== void 0 ? { depth: op.depth } : {},
7079
+ ...op.boxes === true ? { boxes: true } : {}
7080
+ };
7081
+ const snapshot = this.shown.get(tab.id)?.snapshot ?? "";
7082
+ const limit = capped(signal, READ_MS);
7083
+ if (op.target !== void 0) return untilAborted((await locate(tab.page, op.target, snapshot)).ariaSnapshot(options), limit);
7084
+ return untilAborted(tab.page.ariaSnapshot(options), limit);
7085
+ }
7086
+ /** A file chooser the action opened. The tab stays in use until it navigates. */
7087
+ chooserHeld(live, chooser) {
7088
+ live.fileChooser = chooser;
7089
+ this.windows.holdInput(live);
7090
+ return {
7091
+ type: "filechooser",
7092
+ message: chooser.isMultiple() ? "choose files (several allowed) with browser_file_upload" : "choose one file with browser_file_upload"
7093
+ };
7094
+ }
7095
+ async refreshSnapshot(tab, signal) {
7096
+ const previous = this.shown.get(tab.id);
7097
+ const snapshot = tab.dialogHeld ? "" : await this.aria(tab, signal);
7098
+ if (previous === void 0) return {
7099
+ pageUrl: canonical(tab.page.url()),
7100
+ digest: "",
7101
+ kind: "snapshot",
7102
+ text: snapshot,
7103
+ snapshot
7104
+ };
7105
+ return {
7106
+ ...previous,
7107
+ snapshot
7108
+ };
7109
+ }
7110
+ async blockedView(tab) {
7111
+ const dialog = tab.pendingDialog;
7112
+ return this.pageFromShown(tab, this.shown.get(tab.id), {
7113
+ surface: "snapshot",
7114
+ ...dialog !== void 0 ? { dialog: {
7115
+ type: dialog.type(),
7116
+ message: dialog.message()
7117
+ } } : {}
7118
+ });
7119
+ }
7120
+ async goto(page, url, signal) {
7121
+ this.windows.beginDispatch(page);
7122
+ try {
7123
+ return await page.goto(url, {
7124
+ waitUntil: "domcontentloaded",
7125
+ timeout: NAVIGATION_MS,
7126
+ ...signal !== void 0 ? { signal } : {}
7127
+ });
7128
+ } finally {
7129
+ this.windows.endDispatch(page);
7130
+ }
7131
+ }
7132
+ async discover(tab, address, response, signal) {
7133
+ const main = response === null ? void 0 : await mainDocument(response, signal);
7134
+ const relocated = main !== void 0 && !samePage(main.finalUrl, address);
7135
+ const domLinks = relocated ? [] : await readDomLinks(tab.page, signal);
7136
+ const previous = this.shown.get(tab.id);
7137
+ return loadShown({
7138
+ pageUrl: canonical(address),
7139
+ ...optionalFragment(address),
7140
+ snapshot: await this.aria(tab, signal),
7141
+ ...main !== void 0 && !relocated ? { main } : {},
7142
+ ...domLinks.length > 0 ? { domLinks } : {},
7143
+ ...previous !== void 0 ? { previous } : {},
7144
+ relocate: relocated,
7145
+ fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch, signal)
7146
+ });
7147
+ }
7148
+ fetchForRead(url, accept, ifNoneMatch, signal) {
7149
+ return this.fetch(url, {
7150
+ accept,
7151
+ ...ifNoneMatch !== void 0 ? { ifNoneMatch } : {},
7152
+ ...signal !== void 0 ? { signal } : {}
7153
+ });
7154
+ }
7155
+ async aria(tab, signal) {
7156
+ return untilAborted(tab.page.ariaSnapshot({ mode: "ai" }).catch((error) => {
7157
+ this.ctx.logger.debug("aria snapshot failed: %s", errorText(error));
7158
+ return "";
7159
+ }), capped(signal, READ_MS));
7160
+ }
7161
+ async pageFromShown(tab, shown, extra, signal) {
7162
+ const current = shown ?? {
7163
+ pageUrl: canonical(tab.url),
7164
+ digest: "",
7165
+ kind: "snapshot",
7166
+ text: "",
7167
+ snapshot: ""
7168
+ };
7169
+ const title = tab.dialogHeld ? "" : await untilAborted(tab.page.title().catch((error) => {
7170
+ this.ctx.logger.debug("page title failed: %s", errorText(error));
7171
+ return "";
7172
+ }), capped(signal, READ_MS));
7173
+ this.windows.noteTitle(tab, title);
7174
+ const view = {
7175
+ tab: tab.id,
7176
+ url: current.pageUrl,
7177
+ title,
7178
+ observedAt: (/* @__PURE__ */ new Date()).toISOString(),
7179
+ representation: current.kind === "agent-view" ? {
7180
+ kind: "agent-view",
7181
+ text: current.text,
7182
+ ...current.version !== void 0 ? { version: current.version } : {},
7183
+ ...current.href !== void 0 ? { href: current.href } : {}
7184
+ } : {
7185
+ kind: "snapshot",
7186
+ text: current.snapshot,
7187
+ ...current.agentViewError !== void 0 ? { agentViewError: current.agentViewError } : {}
7188
+ },
7189
+ inPresence: this.book.isShown(tab.owner, canonical(current.pageUrl))
7190
+ };
7191
+ tab.inPresence = view.inPresence;
7192
+ if (extra?.surface === "snapshot") view.representation = snapshotSurface(current);
7193
+ if (current.fragment !== void 0) view.fragment = current.fragment;
7194
+ if (current.status !== void 0) view.status = current.status;
7195
+ if (current.mediaType !== void 0) view.mediaType = current.mediaType;
7196
+ if (current.body !== void 0) view.body = current.body;
7197
+ if (extra?.dialog !== void 0) view.dialog = extra.dialog;
7198
+ if (extra?.incomplete !== void 0) view.incomplete = extra.incomplete;
7199
+ if (extra?.waited !== void 0) view.waited = extra.waited;
7200
+ return view;
7201
+ }
7202
+ /**
7203
+ * Read one presence page. A known origin is read on the request channel,
7204
+ * with If-None-Match when the document was read before. Anything else uses
7205
+ * a tab already on the address, or opens one and closes it again.
7206
+ */
7207
+ async observePresence(owner, url, signal) {
7208
+ if (this.knownOrigin(url)) {
7209
+ const via = await this.readKnown(owner, url, signal);
7210
+ if (via === STOPPED) return observationOf(url, {
6906
7211
  url,
6907
- reason: "browser is not running"
6908
- };
6909
- try {
6910
- const { response, finalUrl } = await getWithCookies(context, url, signal);
6911
- const status = response.status;
6912
- const mediaType = response.headers.get("content-type") ?? void 0;
6913
- if (status < 200 || status >= 300) {
6914
- await response.body?.cancel().catch(() => void 0);
6915
- return {
6916
- kind: "error",
6917
- url: finalUrl,
6918
- status,
6919
- reason: `HTTP ${String(status)}`
6920
- };
7212
+ reason: WAITING_STOPPED
7213
+ });
7214
+ if (via !== void 0) return observationOf(url, via);
7215
+ }
7216
+ const existing = this.tabOn(owner, url);
7217
+ if (existing !== void 0) return observationOf(url, await this.refreshView(existing, signal));
7218
+ const opened = await this.openOne(owner, url, void 0, signal, false);
7219
+ this.keepAgentView(opened);
7220
+ const view = opened.view;
7221
+ if (opened.fresh && "tab" in view && view.tab !== void 0) {
7222
+ const live = this.windows.get(owner, view.tab);
7223
+ if (live !== void 0 && this.closable(owner, live)) {
7224
+ const agent = "representation" in view && view.representation.kind === "agent-view";
7225
+ await this.windows.closeTab(live, agent ? void 0 : `${live.id} (idle)`);
7226
+ if (agent) {
7227
+ const shown = { ...view };
7228
+ delete shown.tab;
7229
+ return observationOf(url, shown);
6921
7230
  }
6922
- const written = await writeDownload(root, filenameFrom(response.headers.get("content-disposition") ?? void 0, finalUrl), response.body ?? emptyBody());
6923
- return {
6924
- kind: "saved",
6925
- path: written.path,
6926
- url: finalUrl,
6927
- bytes: written.bytes,
6928
- sha256: written.sha256,
6929
- observed: (/* @__PURE__ */ new Date()).toISOString(),
6930
- ...mediaType === void 0 ? {} : { mediaType }
6931
- };
6932
- } catch (error) {
6933
- if (error instanceof MhBrowserError) throw error;
6934
- return {
6935
- kind: "error",
6936
- url,
6937
- reason: error instanceof Error ? error.message : String(error)
6938
- };
6939
7231
  }
6940
7232
  }
6941
- async attachDownload(live, view, wait) {
6942
- const saved = await this.takePendingDownload(live, wait);
6943
- if (saved !== void 0) view.download = saved;
6944
- return view;
7233
+ return observationOf(url, view);
7234
+ }
7235
+ tabOn(owner, url) {
7236
+ const pageUrl = canonical(url);
7237
+ const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((tab) => tab !== void 0 && canonical(tab.url) === pageUrl);
7238
+ tabs.sort((left, right) => right.touchedAt - left.touchedAt);
7239
+ return tabs[0];
7240
+ }
7241
+ async refreshView(live, signal) {
7242
+ this.windows.touch(live);
7243
+ if (live.dialogHeld) return this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
7244
+ const address = live.page.url();
7245
+ const previous = this.shown.get(live.id);
7246
+ const shown = await loadShown({
7247
+ pageUrl: canonical(address),
7248
+ ...optionalFragment(address),
7249
+ snapshot: await this.aria(live, signal),
7250
+ ...previous !== void 0 ? { previous } : {},
7251
+ relocate: previous !== void 0 && !samePage(previous.pageUrl, address),
7252
+ fetch: (url, accept, ifNoneMatch) => this.fetchForRead(url, accept, ifNoneMatch, signal)
7253
+ });
7254
+ this.shown.set(live.id, shown);
7255
+ this.windows.noteUrl(live, address);
7256
+ return this.pageFromShown(live, shown, void 0, signal);
7257
+ }
7258
+ /** The most recently touched idle tab on this address. A tab the person holds is left alone. */
7259
+ idleTabOn(owner, url) {
7260
+ const pageUrl = canonical(url);
7261
+ const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((tab) => tab !== void 0 && canonical(tab.url) === pageUrl && !tab.occupied && this.control.ofTab(owner, tab.id) !== "person");
7262
+ tabs.sort((left, right) => right.touchedAt - left.touchedAt);
7263
+ return tabs[0];
7264
+ }
7265
+ /** Only Session windows are capped; the person's window is theirs to close. */
7266
+ async enforceCap(owner) {
7267
+ if (owner === "person") return;
7268
+ await this.windows.enforceCap(owner);
7269
+ }
7270
+ /** Other tabs on this address: idle ones reload, occupied or taken ones are marked. */
7271
+ async refreshIdle(owner, url, except, signal) {
7272
+ const pageUrl = canonical(url);
7273
+ const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((item) => item !== void 0 && canonical(item.url) === pageUrl);
7274
+ for (const item of tabs) {
7275
+ if (item.id === except) continue;
7276
+ if (item.occupied || this.control.ofTab(owner, item.id) === "person") this.windows.markMayHaveChanged(item);
7277
+ else await this.navigateTab(item, item.page.url(), signal);
6945
7278
  }
6946
- async takePendingDownload(live, wait) {
6947
- if (live.pendingDownload === void 0 && wait) await live.page.waitForEvent("download", { timeout: DOWNLOAD_EVENT_MS }).catch(() => void 0);
6948
- const download = live.pendingDownload;
6949
- if (download === void 0) return void 0;
6950
- live.pendingDownload = void 0;
6951
- return this.savePlaywrightDownload(live.owner, download);
6952
- }
6953
- async savePlaywrightDownload(owner, download) {
6954
- const target = this.workspaces.get(owner);
6955
- const url = download.url();
6956
- if (target === void 0) return {
6957
- kind: "error",
7279
+ }
7280
+ async navigateTab(live, url, signal) {
7281
+ try {
7282
+ const response = await this.goto(live.page, url, signal);
7283
+ this.windows.onNavigated(live);
7284
+ const address = live.page.url();
7285
+ this.windows.noteUrl(live, address === "about:blank" ? url : address);
7286
+ const load = await waitForLoad(live.page, signal);
7287
+ if (load.dialog !== void 0) this.windows.holdDialog(live);
7288
+ this.windows.touch(live);
7289
+ const shown = await this.discover(live, live.page.url(), response, signal);
7290
+ this.shown.set(live.id, shown);
7291
+ const view = await this.pageFromShown(live, shown, {
7292
+ ...load.load ? {} : { incomplete: {
7293
+ load: load.load,
7294
+ pendingRequests: load.pendingRequests
7295
+ } },
7296
+ ...load.dialog !== void 0 ? { dialog: load.dialog } : {}
7297
+ }, signal);
7298
+ return this.attachDownload(live, view, false);
7299
+ } catch (error) {
7300
+ if (isDownloadStart(error) || live.pendingDownload !== void 0) {
7301
+ const view = await this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
7302
+ if (view.url === "" || view.url === "about:blank") view.url = canonical(url);
7303
+ this.applyDocumentType(live, view);
7304
+ return this.attachDownload(live, view, true);
7305
+ }
7306
+ return {
7307
+ tab: live.id,
6958
7308
  url,
6959
- reason: NO_WORKSPACE_REASON
7309
+ reason: error instanceof Error ? error.message : String(error)
7310
+ };
7311
+ }
7312
+ }
7313
+ async sendAction(request, signal) {
7314
+ const context = this.context;
7315
+ if (context === void 0) return { kind: "lost" };
7316
+ try {
7317
+ const response = await context.request.fetch(request.url, {
7318
+ method: request.method,
7319
+ headers: request.headers,
7320
+ maxRedirects: 0,
7321
+ maxRetries: 0,
7322
+ failOnStatusCode: false,
7323
+ ...signal !== void 0 ? { signal } : {},
7324
+ ...request.multipart !== void 0 ? { multipart: request.multipart } : {},
7325
+ ...request.data !== void 0 ? { data: request.data } : {}
7326
+ });
7327
+ const headers = response.headers();
7328
+ const location = headers.location;
7329
+ const mediaType = headers["content-type"];
7330
+ return {
7331
+ kind: "response",
7332
+ status: response.status(),
7333
+ body: new Uint8Array(await untilAborted(response.body(), capped(signal, READ_MS))),
7334
+ ...location === void 0 || location === "" ? {} : { location },
7335
+ ...mediaType === void 0 ? {} : { mediaType }
6960
7336
  };
6961
- if ("denied" in target) {
6962
- await download.cancel().catch(() => void 0);
7337
+ } catch (error) {
7338
+ if (signal?.aborted === true) throw new MhBrowserError(INTERRUPTED, "stopped after sending; it may have taken effect");
7339
+ this.ctx.logger.debug("declared action was not answered: %s", errorText(error));
7340
+ return { kind: "lost" };
7341
+ }
7342
+ }
7343
+ async saveResponse(url, root, signal) {
7344
+ const context = this.context;
7345
+ if (context === void 0) return {
7346
+ kind: "error",
7347
+ url,
7348
+ reason: "browser is not running"
7349
+ };
7350
+ try {
7351
+ const { response, finalUrl } = await getWithCookies(context, url, signal);
7352
+ const status = response.status;
7353
+ const mediaType = response.headers.get("content-type") ?? void 0;
7354
+ if (status < 200 || status >= 300) {
7355
+ await response.body?.cancel().catch(() => void 0);
6963
7356
  return {
6964
7357
  kind: "error",
6965
- url,
6966
- reason: target.denied
7358
+ url: finalUrl,
7359
+ status,
7360
+ reason: `HTTP ${String(status)}`
6967
7361
  };
6968
7362
  }
6969
- const written = await writeDownload(target.root, download.suggestedFilename(), await download.createReadStream());
7363
+ const written = await writeDownload(root, filenameFrom(response.headers.get("content-disposition") ?? void 0, finalUrl), response.body ?? emptyBody());
6970
7364
  return {
6971
7365
  kind: "saved",
6972
7366
  path: written.path,
6973
- url,
7367
+ url: finalUrl,
6974
7368
  bytes: written.bytes,
6975
7369
  sha256: written.sha256,
6976
- observed: (/* @__PURE__ */ new Date()).toISOString()
7370
+ observed: (/* @__PURE__ */ new Date()).toISOString(),
7371
+ ...mediaType === void 0 ? {} : { mediaType }
7372
+ };
7373
+ } catch (error) {
7374
+ if (error instanceof MhBrowserError) throw error;
7375
+ return {
7376
+ kind: "error",
7377
+ url,
7378
+ reason: error instanceof Error ? error.message : String(error)
6977
7379
  };
6978
7380
  }
6979
- applyDocumentType(live, view) {
6980
- const type = live.documentType;
6981
- if (view.mediaType === void 0 && type !== void 0) {
6982
- const bare = type.split(";")[0]?.trim().toLowerCase() ?? "";
6983
- if (bare !== "" && bare !== "text/html") view.mediaType = type;
6984
- }
6985
- if (view.status === void 0 && live.documentStatus !== void 0 && live.documentStatus >= 300) view.status = live.documentStatus;
6986
- }
6987
- /** A failed launch is logged here; calls that need the browser see it as `BROWSER_UNAVAILABLE`. */
6988
- launch() {
6989
- const started = this.start().catch((error) => {
6990
- const reason = error instanceof Error ? error.message : String(error);
6991
- this.ctx.logger.warn("browser did not start: %s", reason);
6992
- throw new MhBrowserError(BROWSER_UNAVAILABLE, `the browser did not start: ${reason}`);
6993
- });
6994
- started.catch(() => void 0);
6995
- return started;
6996
- }
6997
- async start() {
6998
- const choice = this.launchChoice();
6999
- const epoch = this.configEpoch;
7000
- const context = await launchBrowser(choice, this.locateEnv());
7001
- const pid = await browserPid(context, choice.profileDir);
7002
- if (pid !== void 0) this.pids.set(context, pid);
7003
- if (this.closing) {
7004
- await this.closeContext(context);
7005
- return;
7006
- }
7007
- this.context = context;
7008
- try {
7009
- await this.windows.attach(context, choice.profileDir, { headed: !choice.headless });
7010
- } catch (error) {
7011
- this.context = void 0;
7012
- this.windows.dropPages();
7013
- await this.closeContext(context);
7014
- throw error;
7015
- }
7016
- if (this.configEpoch === epoch) this.relaunchWanted = false;
7017
- context.browser()?.on("disconnected", () => {
7018
- if (this.closing || this.replacing || this.context !== context) return;
7019
- this.onExit();
7020
- });
7381
+ }
7382
+ async attachDownload(live, view, wait) {
7383
+ const saved = await this.takePendingDownload(live, wait);
7384
+ if (saved !== void 0) view.download = saved;
7385
+ return view;
7386
+ }
7387
+ async takePendingDownload(live, wait) {
7388
+ if (live.pendingDownload === void 0 && wait) await live.page.waitForEvent("download", { timeout: DOWNLOAD_EVENT_MS }).catch(() => void 0);
7389
+ const download = live.pendingDownload;
7390
+ if (download === void 0) return void 0;
7391
+ live.pendingDownload = void 0;
7392
+ return this.savePlaywrightDownload(live.owner, download);
7393
+ }
7394
+ async savePlaywrightDownload(owner, download) {
7395
+ const target = this.workspaces.get(owner);
7396
+ const url = download.url();
7397
+ if (target === void 0) return {
7398
+ kind: "error",
7399
+ url,
7400
+ reason: NO_WORKSPACE_REASON
7401
+ };
7402
+ if ("denied" in target) {
7403
+ await download.cancel().catch(() => void 0);
7404
+ return {
7405
+ kind: "error",
7406
+ url,
7407
+ reason: target.denied
7408
+ };
7021
7409
  }
7022
- /** No window opens on exit: the person may have quit it. Calls on old tabs find none. */
7023
- onExit() {
7024
- this.windows.suppressPersonInput();
7025
- this.windows.dropPages();
7026
- this.context = void 0;
7027
- this.exits.push(Date.now());
7028
- this.exited = true;
7029
- this.ready = Promise.resolve();
7030
- }
7031
- /**
7032
- * The browser for a call that needs a page. One that is not running starts
7033
- * again here. The exit limit stops unattended restarts; a person's request
7034
- * is one start per click and skips it.
7035
- */
7036
- up(byPerson = false) {
7037
- if (!this.exited || this.closing) return this.ready;
7038
- const now = Date.now();
7039
- while (this.exits.length > 0 && now - (this.exits[0] ?? now) > RELAUNCH_WINDOW_MS) this.exits.shift();
7040
- if (!byPerson && this.exits.length > RELAUNCH_LIMIT) {
7041
- this.ctx.logger.warn("browser keeps stopping; not starting it again yet");
7042
- return Promise.reject(new MhBrowserError(BROWSER_UNAVAILABLE, `the browser stopped or did not start ${String(this.exits.length)} times within ${String(RELAUNCH_WINDOW_MS / 1e3)} s; try again in a minute`));
7043
- }
7044
- this.exited = false;
7045
- const started = this.launch();
7046
- started.then(() => this.windows.resumePersonInput(), () => {
7047
- this.exited = true;
7048
- if (!this.closing) this.exits.push(Date.now());
7049
- });
7050
- this.ready = started;
7051
- return started;
7410
+ const written = await writeDownload(target.root, download.suggestedFilename(), await download.createReadStream());
7411
+ return {
7412
+ kind: "saved",
7413
+ path: written.path,
7414
+ url,
7415
+ bytes: written.bytes,
7416
+ sha256: written.sha256,
7417
+ observed: (/* @__PURE__ */ new Date()).toISOString()
7418
+ };
7419
+ }
7420
+ applyDocumentType(live, view) {
7421
+ const type = live.documentType;
7422
+ if (view.mediaType === void 0 && type !== void 0) {
7423
+ const bare = type.split(";")[0]?.trim().toLowerCase() ?? "";
7424
+ if (bare !== "" && bare !== "text/html") view.mediaType = type;
7425
+ }
7426
+ if (view.status === void 0 && live.documentStatus !== void 0 && live.documentStatus >= 300) view.status = live.documentStatus;
7427
+ }
7428
+ /** A failed launch is logged here; calls that need the browser see it as `BROWSER_UNAVAILABLE`. */
7429
+ launch() {
7430
+ const started = this.start().catch((error) => {
7431
+ const reason = error instanceof Error ? error.message : String(error);
7432
+ this.ctx.logger.warn("browser did not start: %s", reason);
7433
+ const notFound = error instanceof BrowserNotFoundError ? error.notFound : void 0;
7434
+ throw new MhBrowserError(BROWSER_UNAVAILABLE, `the browser did not start: ${reason}`, void 0, notFound);
7435
+ });
7436
+ started.catch(() => void 0);
7437
+ return started;
7438
+ }
7439
+ async start() {
7440
+ const env = this.locateEnv();
7441
+ const remembered = await readLastBrowser(this.profileRoot);
7442
+ const configured = this.executablePath.get();
7443
+ const plan = await planLaunch({
7444
+ choice: this.browserName.get(),
7445
+ configured,
7446
+ root: this.profileRoot,
7447
+ split: this.splitProfile,
7448
+ env,
7449
+ remembered,
7450
+ bundled: bundledChromiumLocation(env.access)
7451
+ });
7452
+ const epoch = this.configEpoch;
7453
+ const context = await launchBrowser({
7454
+ browser: plan.browser,
7455
+ headless: this.headless,
7456
+ profileDir: plan.profileDir,
7457
+ ...plan.channel ? {} : { channel: false },
7458
+ ...plan.executablePath !== void 0 ? { executablePath: plan.executablePath } : {}
7459
+ }, env);
7460
+ const pid = await browserPid(context, plan.profileDir);
7461
+ if (pid !== void 0) this.pids.set(context, pid);
7462
+ if (this.closing) {
7463
+ await this.closeContext(context);
7464
+ return;
7052
7465
  }
7053
- async stop() {
7054
- this.windows.suppressPersonInput();
7055
- await Promise.race([this.ready.catch(() => void 0), new Promise((resolve) => setTimeout(resolve, STOP_WAIT_MS))]);
7056
- const context = this.context;
7466
+ this.context = context;
7467
+ try {
7468
+ await this.windows.attach(context, plan.profileDir, { headed: !this.headless });
7469
+ } catch (error) {
7057
7470
  this.context = void 0;
7058
- if (context !== void 0) await this.closeContext(context);
7059
- }
7060
- /** Close a context the service is done with, and kill it if close stalls. */
7061
- async closeContext(context) {
7062
- await closeOrKill(() => context.close(), this.pids.get(context));
7063
- this.pids.delete(context);
7064
- }
7065
- refuseIfPerson(owner, tab) {
7066
- if (this.control.ofTab(owner, tab) !== "person") return;
7067
- throw new MhBrowserError("PERSON_IN_CONTROL", "the person is using this tab; call browser_handover with this tab to wait until they hand it back, or work in another tab");
7068
- }
7069
- async takenOver(live, note = "") {
7070
- try {
7071
- const page = await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" });
7072
- return new MhBrowserError("TAKEN_OVER", `${MAY_HAVE_TAKEN_EFFECT}${note}`, page);
7073
- } catch {
7074
- return new MhBrowserError("TAKEN_OVER", `${MAY_HAVE_TAKEN_EFFECT}${note}`);
7075
- }
7076
- }
7077
- presenceUrls(owner, tabs) {
7078
- const known = new Set(this.book.entries(owner).map((page) => page.url));
7079
- const urls = [];
7080
- for (const id of tabs) {
7081
- const address = this.windows.urlOf(owner, id);
7082
- if (address === void 0) continue;
7083
- const url = canonical(address);
7084
- if (!known.has(url) || urls.includes(url)) continue;
7085
- urls.push(url);
7086
- }
7087
- return urls;
7471
+ this.windows.dropPages();
7472
+ await this.closeContext(context);
7473
+ throw error;
7088
7474
  }
7089
- async snapshotNamed(owner, tab, signal) {
7090
- if (tab === void 0) return void 0;
7091
- const live = this.windows.get(owner, tab);
7092
- if (live === void 0) return void 0;
7093
- const shown = await this.refreshSnapshot(live, signal);
7094
- this.shown.set(live.id, shown);
7095
- return this.pageFromShown(live, shown, { surface: "snapshot" }, signal);
7096
- }
7097
- exclusive(owner, op, signal) {
7098
- const queued = enqueue(this.queues.get(owner) ?? Promise.resolve(), op, signal);
7099
- this.queues.set(owner, queued.tail);
7100
- queued.tail.then(() => {
7101
- if (this.queues.get(owner) === queued.tail) this.queues.delete(owner);
7102
- if (this.queues.size === 0) this.relaunchIfIdle();
7103
- });
7104
- return queued.run;
7475
+ if (plan.remember !== void 0) await writeLastBrowser(this.profileRoot, plan.remember).catch((error) => {
7476
+ const reason = error instanceof Error ? error.message : String(error);
7477
+ this.ctx.logger.warn("browser choice was not recorded: %s", reason);
7478
+ });
7479
+ if (this.configEpoch === epoch) this.relaunchWanted = false;
7480
+ context.browser()?.on("disconnected", () => {
7481
+ if (this.closing || this.replacing || this.context !== context) return;
7482
+ this.onExit();
7483
+ });
7484
+ }
7485
+ /** No window opens on exit: the person may have quit it. Calls on old tabs find none. */
7486
+ onExit() {
7487
+ this.windows.suppressPersonInput();
7488
+ this.windows.dropPages();
7489
+ this.context = void 0;
7490
+ this.exits.push(Date.now());
7491
+ this.exited = true;
7492
+ this.ready = Promise.resolve();
7493
+ }
7494
+ /**
7495
+ * The browser for a call that needs a page. One that is not running starts
7496
+ * again here. The exit limit stops unattended restarts; a person's request
7497
+ * is one start per click and skips it.
7498
+ */
7499
+ up(byPerson = false) {
7500
+ if (!this.exited || this.closing) return this.ready;
7501
+ const now = Date.now();
7502
+ while (this.exits.length > 0 && now - (this.exits[0] ?? now) > RELAUNCH_WINDOW_MS) this.exits.shift();
7503
+ if (!byPerson && this.exits.length > RELAUNCH_LIMIT) {
7504
+ this.ctx.logger.warn("browser keeps stopping; not starting it again yet");
7505
+ return Promise.reject(new MhBrowserError(BROWSER_UNAVAILABLE, `the browser stopped or did not start ${String(this.exits.length)} times within ${String(RELAUNCH_WINDOW_MS / 1e3)} s; try again in a minute`));
7506
+ }
7507
+ this.exited = false;
7508
+ const started = this.launch();
7509
+ started.then(() => this.windows.resumePersonInput(), () => {
7510
+ this.exited = true;
7511
+ if (!this.closing) this.exits.push(Date.now());
7512
+ });
7513
+ this.ready = started;
7514
+ return started;
7515
+ }
7516
+ async stop() {
7517
+ this.windows.suppressPersonInput();
7518
+ await Promise.race([this.ready.catch(() => void 0), new Promise((resolve) => setTimeout(resolve, STOP_WAIT_MS))]);
7519
+ const context = this.context;
7520
+ this.context = void 0;
7521
+ if (context !== void 0) await this.closeContext(context);
7522
+ }
7523
+ /** Close a context the service is done with, and kill it if close stalls. */
7524
+ async closeContext(context) {
7525
+ await closeOrKill(() => context.close(), this.pids.get(context));
7526
+ this.pids.delete(context);
7527
+ }
7528
+ refuseIfPerson(owner, tab) {
7529
+ if (this.control.ofTab(owner, tab) !== "person") return;
7530
+ throw new MhBrowserError("PERSON_IN_CONTROL", "the person is using this tab; call browser_handover with this tab to wait until they hand it back, or work in another tab");
7531
+ }
7532
+ async takenOver(live, note = "") {
7533
+ try {
7534
+ const page = await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" });
7535
+ return new MhBrowserError("TAKEN_OVER", `${MAY_HAVE_TAKEN_EFFECT}${note}`, page);
7536
+ } catch {
7537
+ return new MhBrowserError("TAKEN_OVER", `${MAY_HAVE_TAKEN_EFFECT}${note}`);
7105
7538
  }
7106
- launchChoice() {
7107
- const configured = this.executablePath.get();
7108
- return {
7109
- browser: this.browserName.get(),
7110
- headless: this.headless,
7111
- profileDir: this.profileDir,
7112
- ...configured !== void 0 && configured !== "" ? { executablePath: configured } : {}
7113
- };
7539
+ }
7540
+ presenceUrls(owner, tabs) {
7541
+ const known = new Set(this.book.entries(owner).map((page) => page.url));
7542
+ const urls = [];
7543
+ for (const id of tabs) {
7544
+ const address = this.windows.urlOf(owner, id);
7545
+ if (address === void 0) continue;
7546
+ const url = canonical(address);
7547
+ if (!known.has(url) || urls.includes(url)) continue;
7548
+ urls.push(url);
7114
7549
  }
7115
- locateEnv() {
7116
- return this.locator ?? hostLocateEnv();
7117
- }
7118
- idle() {
7119
- return this.queues.size === 0 && !this.control.busy();
7120
- }
7121
- /**
7122
- * Close and start again when a configuration change is waiting and nothing
7123
- * is using the browser. A browser that is not running keeps the new values
7124
- * for the next start. The close is not counted as a crash.
7125
- */
7126
- relaunchIfIdle() {
7127
- if (!this.relaunchWanted || this.closing || this.replacing) return;
7128
- if (!this.idle()) return;
7129
- if (this.context === void 0) {
7130
- if (this.exited) {
7131
- this.relaunchWanted = false;
7132
- return;
7133
- }
7134
- this.ready.then(() => this.relaunchIfIdle(), () => {
7135
- this.relaunchWanted = false;
7136
- });
7550
+ return urls;
7551
+ }
7552
+ async snapshotNamed(owner, tab, signal) {
7553
+ if (tab === void 0) return void 0;
7554
+ const live = this.windows.get(owner, tab);
7555
+ if (live === void 0) return void 0;
7556
+ const shown = await this.refreshSnapshot(live, signal);
7557
+ this.shown.set(live.id, shown);
7558
+ return this.pageFromShown(live, shown, { surface: "snapshot" }, signal);
7559
+ }
7560
+ exclusive(owner, op, signal) {
7561
+ const queued = enqueue(this.queues.get(owner) ?? Promise.resolve(), op, signal);
7562
+ this.queues.set(owner, queued.tail);
7563
+ queued.tail.then(() => {
7564
+ if (this.queues.get(owner) === queued.tail) this.queues.delete(owner);
7565
+ if (this.queues.size === 0) this.relaunchIfIdle();
7566
+ });
7567
+ return queued.run;
7568
+ }
7569
+ locateEnv() {
7570
+ return this.locator ?? hostLocateEnv();
7571
+ }
7572
+ idle() {
7573
+ return this.queues.size === 0 && !this.control.busy();
7574
+ }
7575
+ /**
7576
+ * Close and start again when a configuration change is waiting and nothing
7577
+ * is using the browser. A browser that is not running keeps the new values
7578
+ * for the next start. The close is not counted as a crash.
7579
+ */
7580
+ relaunchIfIdle() {
7581
+ if (!this.relaunchWanted || this.closing || this.replacing) return;
7582
+ if (!this.idle()) return;
7583
+ if (this.context === void 0) {
7584
+ if (this.exited) {
7585
+ this.relaunchWanted = false;
7137
7586
  return;
7138
7587
  }
7139
- this.relaunchWanted = false;
7140
- this.replacing = true;
7141
- const run = this.replaceBrowser();
7142
- this.ready = run;
7143
- const finish = () => {
7144
- this.replacing = false;
7145
- if (this.relaunchWanted) this.relaunchIfIdle();
7146
- };
7147
- run.then(finish, finish);
7588
+ this.ready.then(() => this.relaunchIfIdle(), () => {
7589
+ this.relaunchWanted = false;
7590
+ });
7591
+ return;
7148
7592
  }
7149
- async replaceBrowser() {
7150
- this.windows.suppressPersonInput();
7151
- const context = this.context;
7152
- this.context = void 0;
7153
- this.windows.dropPages();
7154
- if (context !== void 0) await this.closeContext(context);
7155
- if (this.closing) return;
7156
- this.exited = false;
7157
- try {
7158
- await this.launch();
7159
- this.windows.resumePersonInput();
7160
- } catch (error) {
7161
- this.exited = true;
7162
- throw error;
7163
- }
7593
+ this.relaunchWanted = false;
7594
+ this.replacing = true;
7595
+ const run = this.replaceBrowser();
7596
+ this.ready = run;
7597
+ const finish = () => {
7598
+ this.replacing = false;
7599
+ if (this.relaunchWanted) this.relaunchIfIdle();
7600
+ };
7601
+ run.then(finish, finish);
7602
+ }
7603
+ async replaceBrowser() {
7604
+ this.windows.suppressPersonInput();
7605
+ const context = this.context;
7606
+ this.context = void 0;
7607
+ this.windows.dropPages();
7608
+ if (context !== void 0) await this.closeContext(context);
7609
+ if (this.closing) return;
7610
+ this.exited = false;
7611
+ try {
7612
+ await this.launch();
7613
+ this.windows.resumePersonInput();
7614
+ } catch (error) {
7615
+ this.exited = true;
7616
+ throw error;
7164
7617
  }
7165
- };
7166
- })();
7618
+ }
7619
+ };
7167
7620
  const MAX_REDIRECTS = 10;
7168
7621
  /** GET with the profile's cookies for each hop, so the body can be streamed. */
7169
7622
  async function getWithCookies(context, url, signal) {
@@ -7936,6 +8389,7 @@ async function apply(ctx, config) {
7936
8389
  }
7937
8390
  });
7938
8391
  await ctx.plugin(MhBrowserService, resolved);
8392
+ ctx.plugin(browserRoutesPlugin);
7939
8393
  await ctx.plugin({
7940
8394
  name: "mh-browser-runtime",
7941
8395
  inject: ["mhBrowser", ...inject],