@shipvo/sim 0.1.5 → 0.1.6

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/nav.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // Navigation and lifecycle for the simulated phone: back, home, recents, kill. Pure state plus markup.
2
+ // The app sees these as AppState changes: react-native-web reads document.visibilityState, so "background" means hidden.
3
+
4
+ /** Typed error for a navigation action that cannot run now. */
5
+ export class NavError extends Error { constructor(code, message) { super(message); this.code = code; } }
6
+
7
+ /** Where the phone is: the app in front ("app"), the home screen ("home") or the app switcher ("recents"). */
8
+ export function createNav() {
9
+ return { where: "app", kills: 0, log: [] };
10
+ }
11
+
12
+ const note = (st, name) => { st.log.push({ at: Date.now(), name, source: "simulated" }); };
13
+
14
+ /** Home: app to background. From recents it also goes home. Returns the AppState the app should see ("background"), or null if nothing changes. */
15
+ export function pressHome(st) {
16
+ const was = st.where; st.where = "home"; note(st, "press_home");
17
+ return was === "app" ? "background" : null;
18
+ }
19
+
20
+ /** Recents: the app switcher card. From the app this backgrounds it. */
21
+ export function openRecents(st) {
22
+ const was = st.where; st.where = "recents"; note(st, "open_recents");
23
+ return was === "app" ? "background" : null;
24
+ }
25
+
26
+ /** Tap the app icon or its recents card. Returns "active" when the app comes back. Throws if the app is already in front. */
27
+ export function resume(st) {
28
+ if (st.where === "app") throw new NavError("app_in_foreground", "the app is already in front");
29
+ st.where = "app"; note(st, "resume"); return "active";
30
+ }
31
+
32
+ /** Swipe the recents card away. The app is killed; the next open is a cold start. Throws unless the switcher is open. */
33
+ export function killApp(st) {
34
+ if (st.where !== "recents") throw new NavError("recents_not_open", "open the app switcher first (open_recents), then kill_app");
35
+ st.where = "home"; st.kills++; note(st, "kill_app"); return st;
36
+ }
37
+
38
+ /** Throws app_not_foreground while the home screen or the app switcher is showing, so no input reaches a backgrounded app. */
39
+ export function assertForeground(st) {
40
+ if (st.where !== "app") throw new NavError("app_not_foreground", `the phone is on the ${st.where === "home" ? "home screen" : "app switcher"}, so nothing was sent to the app. Open the app first (resume_app).`);
41
+ }
42
+
43
+ /**
44
+ * Classify a swipe on the bottom bar or the left edge.
45
+ * s: { skin, fromEdge: "bottom"|"left"|null, dx, dy, ms } (dy negative is up).
46
+ * Returns "home", "recents", "switch-left", "switch-right", "back" or null.
47
+ */
48
+ export function classifySwipe({ skin = "android", fromEdge = null, dx = 0, dy = 0, ms = 0 } = {}) {
49
+ if (fromEdge === "left" && dx > 60 && Math.abs(dy) < 80) return "back";
50
+ if (fromEdge !== "bottom") return null;
51
+ if (dy < -60 && Math.abs(dx) < Math.abs(dy)) return ms >= 400 ? "recents" : "home";
52
+ if (skin === "ios" && Math.abs(dx) > 60 && Math.abs(dy) < 40) return dx > 0 ? "switch-right" : "switch-left";
53
+ return null;
54
+ }
55
+
56
+ /** Runs in the app page. Sets what AppState reads and fires the event. */
57
+ export function setVisibilityInPage(state) {
58
+ const hidden = state === "background";
59
+ try {
60
+ Object.defineProperty(document, "visibilityState", { configurable: true, get: () => (hidden ? "hidden" : "visible") });
61
+ Object.defineProperty(document, "hidden", { configurable: true, get: () => hidden });
62
+ } catch {}
63
+ document.dispatchEvent(new Event("visibilitychange"));
64
+ }
65
+
66
+ /** Home screen markup: the app icon. Hidden until the phone goes home. */
67
+ export function homeScreenHtml(appName = "App") {
68
+ const n = String(appName).replace(/[<>&"'\\]/g, "").slice(0, 40) || "App";
69
+ return `<div class="sf-home" data-part="home-screen" role="region" aria-label="Home screen" hidden><button type="button" class="sf-icon" data-part="app-icon" aria-label="${n}"><b>${n.slice(0, 1).toUpperCase()}</b><span>${n}</span></button></div>`;
70
+ }
71
+
72
+ /** App switcher markup: one card for the app. Click the card to open it, the close button (or swipe it up) to kill the app. */
73
+ export function recentsHtml(appName = "App") {
74
+ const n = String(appName).replace(/[<>&"'\\]/g, "").slice(0, 40) || "App";
75
+ return `<div class="sf-recents" data-part="recents" role="region" aria-label="Recent apps" hidden><div class="sf-card" data-part="recents-card"><button type="button" data-part="recents-open" aria-label="Open ${n}">${n}</button><button type="button" data-part="recents-close" aria-label="Close ${n}">Close</button></div></div>`;
76
+ }
77
+
78
+ /** The three buttons of Android 3-button navigation. */
79
+ export function navButtonsHtml() {
80
+ return `<div class="sf-navbtns" data-part="nav-buttons" hidden><button type="button" data-part="nav-back" aria-label="Back">◀</button><button type="button" data-part="nav-home" aria-label="Home">●</button><button type="button" data-part="nav-recents" aria-label="Recents">■</button></div>`;
81
+ }
82
+
83
+ /** CSS for the home screen and the app switcher. Colours follow the theme (data-theme on the phone body, or on any parent). Dark is the default. */
84
+ export function navScreensCss() {
85
+ return [
86
+ `.sf-home,.sf-recents{position:absolute;inset:0;z-index:4;background:linear-gradient(160deg,#1d3557,#0b1320);color:#fff;font-family:system-ui,sans-serif}`,
87
+ `[data-theme="light"] .sf-home{background:linear-gradient(160deg,#dbe7f5,#f4f7fb);color:#111}`,
88
+ `.sf-icon{position:absolute;left:28px;top:70px;width:76px;padding:0;border:0;background:none;color:inherit;display:flex;flex-direction:column;align-items:center;gap:6px;font:600 12px system-ui,sans-serif;cursor:pointer}.sf-icon b{display:flex;width:60px;height:60px;border-radius:16px;background:#f4a261;color:#102030;align-items:center;justify-content:center;font-size:28px}`,
89
+ `.sf-recents{display:flex;align-items:center;justify-content:center;background:#10151cee;color:#fff}[data-theme="light"] .sf-recents{background:#eef1f5ee;color:#111}`,
90
+ `.sf-card{width:230px;height:420px;border-radius:20px;background:#fff;color:#111;border:1px solid #8884;display:flex;flex-direction:column;justify-content:space-between;align-items:center;padding:14px;box-sizing:border-box;transition:transform .2s}[data-theme="dark"] .sf-card{background:#1c232d;color:#fff}.sf-card button{border:0;border-radius:10px;padding:8px 18px;background:#8883;color:inherit;font:600 14px system-ui,sans-serif;cursor:pointer}`,
91
+ ].join("");
92
+ }
93
+
94
+ /** The home screen or switcher as plain markup, shown (not hidden), for a screenshot of a headless session. */
95
+ export function awayScreenHtml(where, appName = "App") {
96
+ const inner = (where === "home" ? homeScreenHtml(appName) : recentsHtml(appName)).replace(" hidden>", ">");
97
+ return `<style>${navScreensCss()}</style><div data-theme="dark" style="position:fixed;inset:0;z-index:2147483647">${inner}</div>`;
98
+ }
@@ -0,0 +1,84 @@
1
+ // Network profiles for the simulator. Pure functions: no sim wiring.
2
+ //
3
+ // The numbers below are simulation settings, not measurements of any real
4
+ // network. `latencyMs` is a fixed wait per request, `downKbps` is the
5
+ // throughput used to turn bytes into wait time, and `failureRate` is the share
6
+ // of requests that fail with a network error (0 to 1).
7
+ //
8
+ // `low-end-4g` uses the download rate seen on the Android phone in
9
+ // docs/spike-results.md, section "Results so far": a 1.57 MB bundle in 1.4 to
10
+ // 2.0 s over 4G, which is about 6,300 kbps at the slow end. That section does
11
+ // not record a round-trip time, so its latency is a simulation setting.
12
+
13
+ export const PROFILES = Object.freeze({
14
+ online: Object.freeze({ latencyMs: 0, downKbps: Infinity, failureRate: 0 }),
15
+ offline: Object.freeze({ latencyMs: 0, downKbps: 0, failureRate: 1 }),
16
+ '2g': Object.freeze({ latencyMs: 800, downKbps: 50, failureRate: 0.02 }),
17
+ '3g': Object.freeze({ latencyMs: 300, downKbps: 750, failureRate: 0.01 }),
18
+ 'slow-4g': Object.freeze({ latencyMs: 150, downKbps: 1600, failureRate: 0 }),
19
+ spotty: Object.freeze({ latencyMs: 400, downKbps: 500, failureRate: 0.3 }),
20
+ 'low-end-4g': Object.freeze({ latencyMs: 100, downKbps: 6300, failureRate: 0.005 }),
21
+ });
22
+
23
+ export const PROFILE_NAMES = Object.freeze(Object.keys(PROFILES));
24
+
25
+ export function getProfile(name) {
26
+ if (typeof name !== 'string' || !Object.hasOwn(PROFILES, name)) {
27
+ const err = new Error(
28
+ `Unknown network profile "${String(name)}". Use one of: ${PROFILE_NAMES.join(', ')}.`,
29
+ );
30
+ err.code = 'unknown_profile';
31
+ throw err;
32
+ }
33
+ return PROFILES[name];
34
+ }
35
+
36
+ // mulberry32: small seeded PRNG, returns numbers in [0, 1).
37
+ function prng(seed) {
38
+ let a = seed >>> 0;
39
+ return () => {
40
+ a = (a + 0x6d2b79f5) >>> 0;
41
+ let t = a;
42
+ t = Math.imul(t ^ (t >>> 15), t | 1);
43
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
44
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
45
+ };
46
+ }
47
+
48
+ function bodyBytes(body) {
49
+ if (body == null) return 0;
50
+ if (typeof body === 'string') return new TextEncoder().encode(body).length;
51
+ if (typeof body.byteLength === 'number') return body.byteLength;
52
+ if (typeof body.size === 'number') return body.size;
53
+ return 0;
54
+ }
55
+
56
+ function transferMs(bytes, downKbps) {
57
+ if (!bytes || !Number.isFinite(downKbps)) return 0;
58
+ return (bytes * 8) / downKbps;
59
+ }
60
+
61
+ /**
62
+ * Wrap `fetchImpl` so requests behave as `profile` describes.
63
+ * Waits latencyMs + bytes*8/downKbps (request body bytes, then the response's
64
+ * content-length when present) through `clock.sleep(ms)`. Rejects with a
65
+ * network TypeError at `failureRate`, drawn from a PRNG seeded by `seed`.
66
+ */
67
+ export function applyProfile(fetchImpl, profile, { seed = 1, clock } = {}) {
68
+ if (profile === PROFILES.online) return fetchImpl;
69
+ const random = prng(seed);
70
+ const fail = () => new TypeError('fetch failed: network request failed');
71
+ return async function profiledFetch(input, init) {
72
+ if (profile.failureRate >= 1) throw fail();
73
+ // One draw per request keeps the failing set stable for a seed.
74
+ const failed = random() < profile.failureRate;
75
+ const reqBytes = bodyBytes(init?.body ?? input?.body);
76
+ await clock.sleep(profile.latencyMs + transferMs(reqBytes, profile.downKbps));
77
+ if (failed) throw fail();
78
+ const res = await fetchImpl(input, init);
79
+ const len = Number(res?.headers?.get?.('content-length'));
80
+ const extra = transferMs(Number.isFinite(len) ? len : 0, profile.downKbps);
81
+ if (extra > 0) await clock.sleep(extra);
82
+ return res;
83
+ };
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/sim",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "The Shipvo simulator: runs a Shipvo app's web build in a phone-sized headless browser and exposes it to agents (accessibility tree, tap/type/scroll, simulated device, mock backend). Used by @shipvo/mcp and `shipvo sim`.",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  "./server.mjs": "./server.mjs",
12
12
  "./screen-rules.mjs": "./screen-rules.mjs",
13
+ "./network-profiles.mjs": "./network-profiles.mjs",
13
14
  "./frame.mjs": {
14
15
  "types": "./frame.d.mts",
15
16
  "default": "./frame.mjs"
@@ -22,11 +23,24 @@
22
23
  "browser-resolver.mjs",
23
24
  "app-name.mjs",
24
25
  "frame.mjs",
26
+ "lumi.mjs",
27
+ "guide.mjs",
28
+ "phone-browser.mjs",
29
+ "browser-app.mjs",
30
+ "ws-lite.mjs",
25
31
  "lifecycle.mjs",
32
+ "shade.mjs",
33
+ "shade-actions.mjs",
34
+ "settings.mjs",
35
+ "settings-actions.mjs",
36
+ "nav.mjs",
37
+ "update.mjs",
26
38
  "frame.d.mts",
27
39
  "mock-rules.mjs",
28
40
  "screen-rules.mjs",
29
41
  "push-notification.mjs",
42
+ "network-profiles.mjs",
43
+ "apps.mjs",
30
44
  "README.md"
31
45
  ],
32
46
  "peerDependencies": {
@@ -0,0 +1,187 @@
1
+ // A real Chromium page behind the phone: one isolated browser context per phone, a mobile viewport and touch.
2
+ // Pages are untrusted web content. Private hosts are refused unless the caller opts in.
3
+ import { lookup } from "node:dns/promises";
4
+ import { isIP } from "node:net";
5
+
6
+ export const USER_AGENT_TAG = "ShipvoPhone";
7
+
8
+ export class BrowserError extends Error {
9
+ constructor(code, message) { super(message); this.code = code; }
10
+ }
11
+
12
+ function v4Private(a, b) {
13
+ return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
14
+ (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
15
+ }
16
+
17
+ /** True for loopback, link-local, RFC1918, CGNAT, `.local`, `.internal` and `localhost` names or addresses. */
18
+ export function isPrivateHost(host) {
19
+ let h = String(host).toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
20
+ if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal")) return true;
21
+ const kind = isIP(h);
22
+ if (kind === 4) { const [a, b] = h.split(".").map(Number); return v4Private(a, b); }
23
+ if (kind === 6) {
24
+ // IPv4-mapped (::ffff:a.b.c.d or ::ffff:7f00:1)
25
+ const m = /^::ffff:(?:(\d+)\.(\d+)\.(\d+)\.(\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/.exec(h);
26
+ if (m) {
27
+ if (m[1]) return v4Private(Number(m[1]), Number(m[2]));
28
+ const hi = parseInt(m[5], 16); return v4Private(hi >> 8, hi & 255);
29
+ }
30
+ if (h === "::" || h === "::1") return true;
31
+ const first = parseInt(h.split(":")[0] || "0", 16);
32
+ return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80; // fc00::/7 unique local, fe80::/10 link-local
33
+ }
34
+ return false;
35
+ }
36
+
37
+ /**
38
+ * Decides if a URL may be loaded. Throws BrowserError host_not_allowed. A public name that resolves to a
39
+ * private address is refused too, so a DNS record cannot reach the local network.
40
+ */
41
+ export async function checkUrl(raw, { allowPrivate = false, allowHosts = [] } = {}, resolve = (h) => lookup(h, { all: true })) {
42
+ let u; try { u = new URL(raw); } catch { throw new BrowserError("bad_url", `not a URL: ${raw}`); }
43
+ if (u.protocol === "about:" || u.protocol === "data:" || u.protocol === "blob:") return u;
44
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new BrowserError("bad_url", `only http and https pages can be opened, not ${u.protocol}`);
45
+ const host = u.hostname.toLowerCase();
46
+ if (allowPrivate || allowHosts.map((x) => x.toLowerCase()).includes(host)) return u;
47
+ const refuse = (why) => new BrowserError("host_not_allowed", `${host} is ${why}, so the phone browser did not open it. Pass allow_private: true, or allow_hosts: ["${host}"], to opt in.`);
48
+ if (isPrivateHost(host)) throw refuse("a private host");
49
+ if (!isIP(host.replace(/^\[|\]$/g, ""))) {
50
+ let addrs = []; try { addrs = await resolve(host); } catch {}
51
+ if (addrs.some((a) => isPrivateHost(a.address))) throw refuse("a name that points to a private address");
52
+ }
53
+ return u;
54
+ }
55
+
56
+ export class PhoneBrowser {
57
+ /** @param browser a Playwright Browser. @param opts {allowPrivate, allowHosts, device, resolve} */
58
+ constructor(browser, opts = {}) {
59
+ this.browser = browser; this.opts = { allowPrivate: !!opts.allowPrivate, allowHosts: opts.allowHosts ?? [] };
60
+ this.device = opts.device ?? {}; this.resolve = opts.resolve; this.navigations = [];
61
+ this.ctx = null; this.page = null; this.pages = []; this.refsFrom = null;
62
+ this.onDeepLink = opts.onDeepLink ?? null; // called with a link such as myapp://item/3 that a page tried to open
63
+ this.cdp = null; this.screencast = null; this.onChange = null;
64
+ }
65
+ async #start() {
66
+ if (this.ctx) { if (!this.page) await this.ctx.newPage(); return; }
67
+ const d = this.device;
68
+ this.ctx = await this.browser.newContext({ ...d, userAgent: `${d.userAgent ?? "Mozilla/5.0 (Linux; Android 11; Pixel 5)"} ${USER_AGENT_TAG}`, hasTouch: true, isMobile: true, viewport: d.viewport ?? { width: 393, height: 851 } });
69
+ // Every request, including redirects and sub-resources, goes through the same host check.
70
+ await this.ctx.route("**/*", async (route) => {
71
+ try { await checkUrl(route.request().url(), this.opts, this.resolve); } catch (e) {
72
+ if (route.request().isNavigationRequest()) this.#log(route.request().url(), false, e.code);
73
+ return route.abort("blockedbyclient");
74
+ }
75
+ return route.continue();
76
+ });
77
+ // A link to another app's scheme (myapp://...) cannot load in Chromium. Hand it to the phone, which gives it to the app.
78
+ await this.ctx.exposeFunction("__shipvoDeepLink", (u) => { if (isDeepLink(u)) { this.#log(String(u), true, undefined, "deep_link"); this.onDeepLink?.(String(u)); } });
79
+ await this.ctx.addInitScript(() => document.addEventListener("click", (e) => {
80
+ const a = e.target?.closest?.("a[href]"); if (!a) return;
81
+ const u = a.href; if (/^(https?|about|data|blob|javascript|file|mailto|tel):/i.test(u) || !/^[a-z][a-z0-9+.-]*:/i.test(u)) return;
82
+ e.preventDefault(); globalThis.__shipvoDeepLink(u);
83
+ }, true));
84
+ this.ctx.on("page", (p) => this.#adopt(p)); // a popup or target=_blank link is a new tab
85
+ await this.ctx.newPage();
86
+ }
87
+ #adopt(p) {
88
+ if (this.pages.includes(p)) return;
89
+ this.pages.push(p); this.page = p; this.refsFrom = null;
90
+ p.on("framenavigated", (f) => { if (f === p.mainFrame()) { this.#log(f.url(), true); this.onChange?.(); } });
91
+ p.on("close", () => { this.pages = this.pages.filter((x) => x !== p); if (this.page === p) { this.page = this.pages.at(-1) ?? null; this.refsFrom = null; if (this.page) this.#cast(); } this.onChange?.(); });
92
+ if (this.screencast) this.#cast();
93
+ }
94
+ #log(url, ok, code, kind = "browser_navigation") { this.navigations.push({ at: Date.now(), kind, detail: { url, allowed: ok, ...(code ? { code } : {}) }, source: "real", simulated: false }); }
95
+
96
+ async open(url) {
97
+ try { await checkUrl(url, this.opts, this.resolve); } catch (e) { this.#log(String(url), false, e.code); throw e; }
98
+ await this.#start();
99
+ try { await this.page.goto(url, { waitUntil: "load", timeout: 20000 }); }
100
+ catch (e) { throw new BrowserError(/blockedbyclient/i.test(e.message) ? "host_not_allowed" : "navigation_failed", String(e.message).split("\n")[0]); }
101
+ return this.snapshot();
102
+ }
103
+ #need() { if (!this.page) throw new BrowserError("no_page", "no page is open: call browser_open first"); return this.page; }
104
+ async snapshot() {
105
+ const page = this.#need();
106
+ await page.waitForTimeout(100);
107
+ const tree = await page.locator("body").ariaSnapshot({ mode: "ai" });
108
+ this.refsFrom = page.url();
109
+ return { source: "web", untrusted: true, url: page.url(), title: await page.title(), tree };
110
+ }
111
+ async #target(ref) {
112
+ const page = this.#need();
113
+ const stale = new BrowserError("stale_ref", `stale ref ${ref}: take a new browser_snapshot and use a ref from it.`);
114
+ if (!/^(?:f\d+)?e\d+$/.test(String(ref)) || this.refsFrom !== page.url()) throw stale;
115
+ const l = page.locator(`aria-ref=${ref}`);
116
+ if ((await l.count().catch(() => 0)) !== 1) throw stale;
117
+ return l;
118
+ }
119
+ async click(ref) { const l = await this.#target(ref); await l.click({ timeout: 3000 }); await this.page.waitForLoadState("load").catch(() => {}); return this.snapshot(); }
120
+ async type(ref, text, enter = false) {
121
+ const l = await this.#target(ref);
122
+ await l.fill(String(text ?? ""), { timeout: 3000 }); if (enter) await l.press("Enter");
123
+ await this.page.waitForLoadState("load").catch(() => {});
124
+ return this.snapshot();
125
+ }
126
+ async back() {
127
+ const page = this.#need();
128
+ if (!(await page.goBack({ timeout: 5000 }).catch(() => null))) throw new BrowserError("no_history", "there is no earlier page to go back to");
129
+ return this.snapshot();
130
+ }
131
+ async screenshot() { return this.#need().screenshot({ type: "png" }); }
132
+ async forward() {
133
+ const page = this.#need();
134
+ if (!(await page.goForward({ timeout: 5000 }).catch(() => null))) throw new BrowserError("no_history", "there is no later page to go forward to");
135
+ return this.snapshot();
136
+ }
137
+ async reload() { await this.#need().reload({ timeout: 20000 }).catch((e) => { throw new BrowserError("navigation_failed", String(e.message).split("\n")[0]); }); return this.snapshot(); }
138
+ async scroll(dy = 400, dx = 0) { await this.#need().mouse.wheel(dx, dy); return this.snapshot(); }
139
+ /** Waits for text to appear in the page (or to be gone, with gone: true). Resolves with a snapshot. */
140
+ async waitFor({ text, gone = false, timeoutMs = 5000 } = {}) {
141
+ const page = this.#need();
142
+ if (typeof text !== "string" || !text) throw new BrowserError("bad_args", "browser_wait_for needs text");
143
+ try { await page.waitForFunction(([t, g]) => document.body.innerText.includes(t) !== g, [text, gone], { timeout: Math.min(Math.max(timeoutMs, 100), 30000) }); }
144
+ catch { throw new BrowserError("timeout", `"${text}" ${gone ? "did not go away" : "did not appear"} within ${timeoutMs} ms`); }
145
+ return this.snapshot();
146
+ }
147
+ async tabs() { return Promise.all(this.pages.map(async (p, index) => ({ index, url: p.url(), title: await p.title().catch(() => ""), active: p === this.page }))); }
148
+ async selectTab(index) { const p = this.pages[index]; if (!p) throw new BrowserError("no_tab", `no tab ${index}`); this.page = p; this.refsFrom = null; await p.bringToFront(); if (this.screencast) await this.#cast(); this.onChange?.(); }
149
+ async newTab(url = "about:blank") { await this.#start(); await checkUrl(url, this.opts, this.resolve); const p = await this.ctx.newPage(); await p.goto(url).catch(() => {}); return this.tabs(); }
150
+ async closeTab(index) {
151
+ const p = this.pages[index]; if (!p) throw new BrowserError("no_tab", `no tab ${index}`);
152
+ await p.close(); return this.tabs();
153
+ }
154
+
155
+ // Live view. Chromium pushes JPEG frames (CDP Page.startScreencast); each one is acked once it has been handed on.
156
+ async #cast() {
157
+ if (!this.page || !this.ctx) return;
158
+ try { // a tab that is closing or not yet attached must not take the server down
159
+ await this.cdp?.send("Page.stopScreencast").catch(() => {}); this.cdp?.detach().catch(() => {});
160
+ const c = this.cdp = await this.ctx.newCDPSession(this.page);
161
+ c.on("Page.screencastFrame", (f) => { c.send("Page.screencastFrameAck", { sessionId: f.sessionId }).catch(() => {}); this.screencast?.({ data: f.data, width: f.metadata.deviceWidth, height: f.metadata.deviceHeight }); });
162
+ await c.send("Page.startScreencast", { format: "jpeg", quality: 60, maxWidth: 400, maxHeight: 860, everyNthFrame: 1 });
163
+ } catch {}
164
+ }
165
+ async startScreencast(onFrame) { await this.#need(); this.screencast = onFrame; await this.#cast(); }
166
+ async stopScreencast() { this.screencast = null; await this.cdp?.send("Page.stopScreencast").catch(() => {}); }
167
+ /** Input as a finger and keyboard would give it, in CSS px of the viewport. */
168
+ async input(m) {
169
+ this.#need(); const c = this.cdp ?? (this.cdp = await this.ctx.newCDPSession(this.page));
170
+ const touch = (type, x, y) => c.send("Input.dispatchTouchEvent", { type, touchPoints: type === "touchEnd" ? [] : [{ x, y }] });
171
+ const num = (...v) => { if (!v.every((n) => Number.isFinite(n))) throw new BrowserError("bad_args", "coordinates must be numbers"); };
172
+ if (m.type === "tap") { num(m.x, m.y); await touch("touchStart", m.x, m.y); await touch("touchEnd"); }
173
+ else if (m.type === "swipe") {
174
+ num(m.x1, m.y1, m.x2, m.y2); await touch("touchStart", m.x1, m.y1);
175
+ for (let i = 1; i <= 8; i++) await touch("touchMove", m.x1 + ((m.x2 - m.x1) * i) / 8, m.y1 + ((m.y2 - m.y1) * i) / 8);
176
+ await touch("touchEnd");
177
+ }
178
+ else if (m.type === "text") await c.send("Input.insertText", { text: String(m.text ?? "").slice(0, 2000) });
179
+ else if (m.type === "key") { const key = String(m.key); const code = { Enter: 13, Backspace: 8, Tab: 9 }[key]; if (!code) throw new BrowserError("bad_args", `unsupported key ${key}`);
180
+ for (const type of ["keyDown", "keyUp"]) await c.send("Input.dispatchKeyEvent", { type, key, windowsVirtualKeyCode: code, ...(key === "Enter" && type === "keyDown" ? { text: "\r" } : {}) }); }
181
+ else throw new BrowserError("bad_args", `unknown input ${m.type}`);
182
+ }
183
+ async close() { this.screencast = null; await this.ctx?.close().catch(() => {}); this.ctx = this.page = this.cdp = null; this.pages = []; }
184
+ }
185
+
186
+ /** True for a link into another app: a scheme that is not a web page scheme. */
187
+ export function isDeepLink(u) { return /^[a-z][a-z0-9+.-]*:/i.test(String(u)) && !/^(https?|about|data|blob|javascript|file|ftp|view-source|chrome|devtools|mailto|tel):/i.test(String(u)); }