@shipvo/sim 0.1.4 → 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/apps.mjs +138 -0
- package/browser-app.mjs +83 -0
- package/browser-resolver.mjs +52 -0
- package/chromium-hint.mjs +19 -0
- package/frame.d.mts +40 -0
- package/frame.mjs +240 -0
- package/guide.mjs +44 -0
- package/lifecycle.mjs +50 -0
- package/lumi.mjs +97 -0
- package/nav.mjs +98 -0
- package/network-profiles.mjs +84 -0
- package/package.json +26 -1
- package/phone-browser.mjs +187 -0
- package/screen-rules.mjs +45 -0
- package/server.mjs +306 -61
- package/settings-actions.mjs +28 -0
- package/settings.mjs +95 -0
- package/shade-actions.mjs +52 -0
- package/shade.mjs +141 -0
- package/update.mjs +126 -0
- package/ws-lite.mjs +40 -0
package/screen-rules.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The rules that decide what is "on screen" for an agent. Plain ES module, no imports, so the simulator
|
|
2
|
+
// (`server.mjs`) and the `shipvo run` frame page share one copy. The DOM functions are self-contained:
|
|
3
|
+
// they call no other function, so they can be serialised and run inside a page with `(${fn})(document)`.
|
|
4
|
+
|
|
5
|
+
/** Remove the markers that `applyScreenRules` set. */
|
|
6
|
+
export function clearScreenRules(document) {
|
|
7
|
+
for (const el of document.querySelectorAll("[data-pn-offscreen]")) { el.removeAttribute("aria-hidden"); el.removeAttribute("data-pn-offscreen"); }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Call `clearScreenRules` first. Hide what a phone would not expose: content behind a modal sheet and off-screen items of a horizontal list. */
|
|
11
|
+
export function applyScreenRules(document) {
|
|
12
|
+
const win = document.defaultView;
|
|
13
|
+
// A modal sheet (accessibilityViewIsModal / aria-modal) hides everything behind it from assistive tech on phones.
|
|
14
|
+
const modal = [...document.querySelectorAll('[aria-modal="true"]')].pop();
|
|
15
|
+
if (modal) for (let node = modal; node && node !== document.body; node = node.parentElement)
|
|
16
|
+
for (const sib of node.parentElement?.children ?? []) if (sib !== node && !sib.hasAttribute("aria-hidden")) { sib.setAttribute("aria-hidden", "true"); sib.setAttribute("data-pn-offscreen", "1"); }
|
|
17
|
+
// Phones only expose the items of a horizontal list that are on screen.
|
|
18
|
+
for (const sc of document.querySelectorAll("div")) {
|
|
19
|
+
const cs = win.getComputedStyle(sc);
|
|
20
|
+
if (!/(auto|scroll)/.test(cs.overflowX) || sc.scrollWidth <= sc.clientWidth + 1) continue;
|
|
21
|
+
const box = sc.getBoundingClientRect();
|
|
22
|
+
const row = sc.firstElementChild; if (!row) continue;
|
|
23
|
+
for (const item of row.children) {
|
|
24
|
+
const r = item.getBoundingClientRect();
|
|
25
|
+
if (r.right <= box.left + 1 || r.left >= box.right - 1) { item.setAttribute("aria-hidden", "true"); item.setAttribute("data-pn-offscreen", "1"); }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Post-process an accessibility snapshot: drop empty generic lines, `aria-hidden` subtrees and the inner text of labelled controls. */
|
|
31
|
+
export function filterTree(tree) {
|
|
32
|
+
const lines = tree.split("\n").filter((l) => !/^\s*- generic \[ref=e\d+\]:?\s*$/.test(l));
|
|
33
|
+
// A screen reader announces a control's label, not the words inside it (Android and iOS group a pressable's
|
|
34
|
+
// children into one element). Drop the inner text lines of labelled controls, as the phones do.
|
|
35
|
+
const out = []; let hideBelow = -1;
|
|
36
|
+
for (const l of lines) {
|
|
37
|
+
const indent = l.search(/\S/);
|
|
38
|
+
if (hideBelow >= 0 && indent > hideBelow) continue;
|
|
39
|
+
hideBelow = -1;
|
|
40
|
+
if (/\[aria-hidden\]/.test(l)) { hideBelow = indent; continue; } // hidden from assistive tech: not on screen
|
|
41
|
+
out.push(l);
|
|
42
|
+
if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
|
|
43
|
+
}
|
|
44
|
+
return out.join("\n");
|
|
45
|
+
}
|
package/server.mjs
CHANGED
|
@@ -10,14 +10,29 @@ import { join, extname, dirname } from "node:path";
|
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { withAppName } from "./app-name.mjs";
|
|
12
12
|
import { parseMockRules } from "./mock-rules.mjs";
|
|
13
|
+
import { applyProfile, getProfile } from "./network-profiles.mjs";
|
|
13
14
|
import { pushNotification } from "./push-notification.mjs";
|
|
14
15
|
import { createRequire } from "node:module";
|
|
15
16
|
import { execSync } from "node:child_process";
|
|
17
|
+
import { resolveBrowser } from "./browser-resolver.mjs";
|
|
18
|
+
import { PhoneBrowser, BrowserError, checkUrl } from "./phone-browser.mjs";
|
|
19
|
+
import { acceptWebSocket } from "./ws-lite.mjs";
|
|
20
|
+
import { browserAppPage } from "./browser-app.mjs";
|
|
21
|
+
import { launchErrorMessage } from "./chromium-hint.mjs";
|
|
22
|
+
import { shadeAction, SHADE_ACTIONS } from "./shade-actions.mjs";
|
|
23
|
+
import { settingsAction, syncMedia, SETTINGS_ACTIONS } from "./settings-actions.mjs";
|
|
24
|
+
import { SettingsError } from "./settings.mjs";
|
|
25
|
+
import { AppsError, loadProfile, profilePatch, installApp, uninstallApp, switchApp, describeApps } from "./apps.mjs";
|
|
26
|
+
import { UpdateError, createUpdate, checkForUpdate, applyUpdate, bootDone, updateView, bannerText } from "./update.mjs";
|
|
27
|
+
import { ShadeError } from "./shade.mjs";
|
|
28
|
+
import { createNav, pressHome, openRecents, resume, killApp, assertForeground, setVisibilityInPage, awayScreenHtml, NavError } from "./nav.mjs";
|
|
29
|
+
import { createLifecycle, lock, unlock, restart, assertUnlocked, LifecycleError } from "./lifecycle.mjs";
|
|
30
|
+
import { applyScreenRules, clearScreenRules, filterTree } from "./screen-rules.mjs";
|
|
16
31
|
// Playwright: the app's own install first (its browsers match it), then this package's, then a global one.
|
|
17
32
|
const require_ = createRequire(import.meta.url);
|
|
18
|
-
let pw;
|
|
19
|
-
for (const
|
|
20
|
-
try { pw =
|
|
33
|
+
let pw, pwVersion;
|
|
34
|
+
for (const from of [() => createRequire(join(process.cwd(), "package.json")), () => require_, () => { const root = execSync("npm root -g").toString().trim(); return (id) => require_(join(root, id)); }]) {
|
|
35
|
+
try { const r = from(); pw = r("playwright"); try { pwVersion = r("playwright/package.json").version; } catch {} break; } catch {}
|
|
21
36
|
}
|
|
22
37
|
if (!pw) { console.error("playwright is not installed: npm i -D playwright && npx playwright install chromium"); process.exit(2); }
|
|
23
38
|
const { chromium, devices } = pw;
|
|
@@ -26,6 +41,13 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
26
41
|
const arg = (k, d) => { const i = process.argv.indexOf(`--${k}`); return i > 0 ? process.argv[i + 1] : d; };
|
|
27
42
|
let CFG = {}; try { CFG = JSON.parse(readFileSync(join(process.cwd(), "shipvo.json"), "utf8")); } catch {}
|
|
28
43
|
const PORT = Number(arg("port", 4545)), DIR = arg("dir", "/tmp/pn-ui"), DIST = arg("dist", join(process.cwd(), "sim-dist"));
|
|
44
|
+
// phone.json: only a missing default ./phone.json is ignored. A bad --phone path or a bad file fails sessions with bad_profile.
|
|
45
|
+
let PROFILE = null, PROFILE_ERR = null;
|
|
46
|
+
{ const given = arg("phone", null);
|
|
47
|
+
try { PROFILE = readFileSync(given ?? join(process.cwd(), "phone.json"), "utf8"); }
|
|
48
|
+
catch (e) { if (given || e?.code !== "ENOENT") PROFILE_ERR = `cannot read ${given ?? "phone.json"}: ${e?.code ?? e?.message}`; } }
|
|
49
|
+
// Simulated OTA source: a control plane stub that serves <url>/pointer.json (signed) and the bundle. The key is the app's public key.
|
|
50
|
+
const UPDATE = { url: arg("update-url", process.env.PN_UPDATE_URL || ""), key: arg("update-key", process.env.PN_UPDATE_KEY || "") };
|
|
29
51
|
const DEFAULT_APP = arg("app", CFG.appId || "tide"), APP_NAME = CFG.name || "Tide & Tackle";
|
|
30
52
|
// Published releases: open_app("tide@r13") loads the web artifact that `shipvo publish` stored next to the phone bundle.
|
|
31
53
|
const CONTROL = (process.env.PN_CONTROL || process.env.CONTENT_FROM || CFG.controlPlane || "https://api.shipvo.dev").replace(/\/$/, "");
|
|
@@ -46,26 +68,79 @@ const MIME = { ".html": "text/html", ".js": "text/javascript", ".json": "applica
|
|
|
46
68
|
const LAUNCH_TIMEOUT_MS = 60_000;
|
|
47
69
|
let launchBrowser;
|
|
48
70
|
const browserReady = new Promise((resolve, reject) => { launchBrowser = () => {
|
|
71
|
+
// Resolve first (it may download a browser, which can take longer than the launch timeout), then time the launch only.
|
|
72
|
+
let opts;
|
|
73
|
+
try { opts = resolveBrowser({ pw, executablePath: arg("chromium", undefined) }); } catch (e) { reject(e); return; }
|
|
49
74
|
const timer = setTimeout(() => reject(new Error(`Chromium did not start within ${LAUNCH_TIMEOUT_MS / 1000} s`)), LAUNCH_TIMEOUT_MS);
|
|
50
|
-
|
|
51
|
-
chromium.launch(executablePath ? { executablePath } : {}).then(resolve, reject).finally(() => clearTimeout(timer));
|
|
75
|
+
chromium.launch(opts).then(resolve, reject).finally(() => clearTimeout(timer));
|
|
52
76
|
}; });
|
|
53
|
-
browserReady.catch((e) => { console.error(`sim:
|
|
77
|
+
browserReady.catch((e) => { console.error(`sim: ${launchErrorMessage(e.message, pwVersion)}`); process.exit(1); });
|
|
54
78
|
const sessions = new Map();
|
|
55
79
|
|
|
56
80
|
const phone = { ...devices["Pixel 5"], viewport: { width: 393, height: 851 } };
|
|
57
81
|
|
|
82
|
+
// The phone's Browser app. Chromium starts on the first open. Pages that link to the app's own scheme hand the link to the app.
|
|
83
|
+
// The instance is shared through s.browserP, so two opens at once make one Chromium context.
|
|
84
|
+
function phoneBrowser(s) {
|
|
85
|
+
s.browserP ??= (async () => {
|
|
86
|
+
s.peers = s.peers ?? new Set();
|
|
87
|
+
const b = new PhoneBrowser(await browserReady, { allowPrivate: false, allowHosts: [], device: phone, onDeepLink: (u) => {
|
|
88
|
+
s.page.evaluate((x) => globalThis.__pnDevice?.openUrl(x), u).catch(() => {});
|
|
89
|
+
for (const peer of s.peers) peer.send(JSON.stringify({ type: "app", url: u })); // the phone goes back to the app
|
|
90
|
+
} });
|
|
91
|
+
b.onChange = () => pushState(s);
|
|
92
|
+
s.browser = b;
|
|
93
|
+
return b;
|
|
94
|
+
})();
|
|
95
|
+
s.browserP.catch(() => { s.browserP = undefined; });
|
|
96
|
+
return s.browserP;
|
|
97
|
+
}
|
|
98
|
+
// One screencast feeds every viewer of the Browser app.
|
|
99
|
+
function ensureCast(s) {
|
|
100
|
+
const b = s.browser; if (!b?.page || b.screencast || !s.peers?.size) return;
|
|
101
|
+
b.startScreencast((f) => { const msg = JSON.stringify({ type: "frame", ...f }); for (const p of s.peers) p.send(msg); }).catch(() => {});
|
|
102
|
+
pushState(s);
|
|
103
|
+
}
|
|
104
|
+
async function pushState(s) {
|
|
105
|
+
const b = s.browser; if (!b || !s.peers?.size) return;
|
|
106
|
+
const tabs = await b.tabs().catch(() => []);
|
|
107
|
+
const msg = JSON.stringify({ type: "state", tabs, url: b.page?.url() ?? "" });
|
|
108
|
+
for (const peer of s.peers) peer.send(msg);
|
|
109
|
+
}
|
|
110
|
+
/** Opens a web page in the Browser app. Resolves { ok: true }, or { ok: false, code, message } when the page is refused. Viewers get an `error` message too. */
|
|
111
|
+
async function openInBrowserApp(id, url) {
|
|
112
|
+
const s = sessions.get(id); if (!s) return { ok: false, code: "no_session", message: "no session" };
|
|
113
|
+
const b = await phoneBrowser(s);
|
|
114
|
+
for (const peer of s.peers ?? []) peer.send(JSON.stringify({ type: "browser", url })); // the phone shows the Browser app
|
|
115
|
+
try { await b.open(url); ensureCast(s); return { ok: true }; } catch (e) {
|
|
116
|
+
const r = { ok: false, code: e.code ?? "error", message: String(e.message).split("\n")[0] };
|
|
117
|
+
for (const peer of s.peers ?? []) peer.send(JSON.stringify({ type: "error", code: r.code, message: r.message }));
|
|
118
|
+
return r;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
58
122
|
async function newSession(id, opts = {}) {
|
|
59
|
-
if (sessions.has(id))
|
|
123
|
+
if (sessions.has(id)) { const old = sessions.get(id); for (const p of old.peers ?? []) p.close(); await (await old.browserP?.catch(() => undefined))?.close(); await old.ctx.close(); }
|
|
60
124
|
const target = await resolveApp(opts.app);
|
|
61
125
|
writeFileSync(join(DIR, `${id}.json`), JSON.stringify({ orders: [] })); writeFileSync(join(DIR, `${id}.json.log`), "");
|
|
62
126
|
const browser = await browserReady;
|
|
63
|
-
|
|
127
|
+
// Optional width, height (output pixels) and scale (device pixel ratio) make a session render at a store screenshot size.
|
|
128
|
+
const size = Number.isInteger(opts.width) && Number.isInteger(opts.height) && opts.width > 0 && opts.height > 0 ? { viewport: { width: Math.round(opts.width / (opts.scale || 1)), height: Math.round(opts.height / (opts.scale || 1)) }, deviceScaleFactor: opts.scale || 1 } : {};
|
|
129
|
+
const ctx = await browser.newContext({ ...phone, ...size, colorScheme: opts.dark ? "dark" : "light" });
|
|
130
|
+
// The app's Linking.openURL for a web page lands here (see @shipvo/device): it opens in the Browser app, not in the app.
|
|
131
|
+
await ctx.exposeFunction("__pnOpenBrowser", (url) => openInBrowserApp(id, String(url)));
|
|
64
132
|
await ctx.addInitScript((cfg) => { globalThis.__PN_SIM__ = cfg; }, { baseUrl: `http://localhost:${PORT}/s/${id}`, appId: target.app, appName: APP_NAME, seed_cart: opts.seed_cart ?? null, device: opts.device ?? null });
|
|
65
133
|
const page = await ctx.newPage();
|
|
134
|
+
const errors = [];
|
|
135
|
+
// phone.json: the apps and settings this phone starts with. The same profile gives the same phone every run.
|
|
136
|
+
let apps; try { if (PROFILE_ERR && opts.phone === undefined) throw new AppsError("bad_profile", PROFILE_ERR); apps = loadProfile(opts.phone ?? PROFILE ?? {}); } catch (e) { await ctx.close(); throw e instanceof AppsError ? new SimError(e.code, e.message) : e; }
|
|
137
|
+
const pageErrors = { n: 0 };
|
|
138
|
+
page.on("pageerror", (e) => { pageErrors.n++; errors.push(String(e?.message ?? e).split("\n")[0]); });
|
|
66
139
|
await page.goto(target.url, { waitUntil: "networkidle" });
|
|
67
140
|
await page.waitForTimeout(300);
|
|
68
|
-
|
|
141
|
+
const patch = profilePatch(apps);
|
|
142
|
+
if (Object.keys(patch).length) { await page.evaluate((p) => globalThis.__pnDevice?.set?.(p), patch); await syncMedia(page); }
|
|
143
|
+
sessions.set(id, { pageErrors, update: createUpdate({ release: target.release, url: target.url }), updateSrc: { url: opts.update_url ?? UPDATE.url, key: opts.update_key ?? UPDATE.key }, apps, ctx, page, errors, app: target.app, release: target.release, startedAt: new Date().toISOString(), life: createLifecycle(), shade: { open: "none" }, nav: createNav() });
|
|
69
144
|
}
|
|
70
145
|
|
|
71
146
|
// Refs are only meaningful for the document that issued them: a full page load restarts Playwright's counter, so an
|
|
@@ -77,48 +152,73 @@ async function epochOf(page) {
|
|
|
77
152
|
if (!d) DOCS.set(page, { id, epoch: 0 }); else if (d.id !== id) { d.id = id; d.epoch++; }
|
|
78
153
|
return DOCS.get(page).epoch;
|
|
79
154
|
}
|
|
155
|
+
// The update banner is a status element in the page, so it is in the accessibility tree. A reload drops it, so boot puts it back.
|
|
156
|
+
async function showBanner(s) {
|
|
157
|
+
const text = bannerText(s.update);
|
|
158
|
+
await s.page.evaluate((t) => { document.querySelector("[data-pn-update-banner]")?.remove(); if (!t) return;
|
|
159
|
+
const d = document.createElement("div"); d.setAttribute("data-pn-update-banner", ""); d.setAttribute("role", "status"); d.setAttribute("aria-label", t); d.textContent = t;
|
|
160
|
+
d.style.cssText = "position:fixed;left:8px;right:8px;top:8px;z-index:2147483647;padding:8px 12px;border-radius:12px;background:#1d2733;color:#fff;font:600 13px system-ui,sans-serif;pointer-events:none";
|
|
161
|
+
document.body.appendChild(d); }, text).catch(() => {});
|
|
162
|
+
}
|
|
163
|
+
// One boot of the app. `url` loads that bundle, else the page reloads (a cold start). A page error while loading is a failed boot: three in a row
|
|
164
|
+
// on a new release send the phone back to the release it had.
|
|
165
|
+
async function boot(s, url) {
|
|
166
|
+
const before = s.pageErrors.n;
|
|
167
|
+
if (url) await s.page.goto(url, { waitUntil: "networkidle" }); else await s.page.reload({ waitUntil: "networkidle" });
|
|
168
|
+
await s.page.waitForTimeout(300);
|
|
169
|
+
const r = bootDone(s.update, s.pageErrors.n > before);
|
|
170
|
+
if (r.revert) { await s.page.goto(r.url, { waitUntil: "networkidle" }); await s.page.waitForTimeout(300); }
|
|
171
|
+
await showBanner(s);
|
|
172
|
+
}
|
|
173
|
+
// Fetch layer. Mock rules win; otherwise fetch/XHR run under the session's network profile (default online, untouched).
|
|
174
|
+
async function ensureNetworkRoute(s, page) {
|
|
175
|
+
if (s.mockRoute) return;
|
|
176
|
+
s.mockRoute = true;
|
|
177
|
+
await page.route("**/*", async (route) => {
|
|
178
|
+
const req = route.request();
|
|
179
|
+
const type = req.resourceType();
|
|
180
|
+
if (type !== "fetch" && type !== "xhr") return route.fallback();
|
|
181
|
+
const rule = (s.mocks ?? []).find((m) => (!m.method || m.method === req.method()) && m.match(req.url()));
|
|
182
|
+
if (rule) {
|
|
183
|
+
if (rule.delayMs) await new Promise((r) => setTimeout(r, rule.delayMs));
|
|
184
|
+
// The app page may come from another origin, so the stub must pass the browser's CORS check.
|
|
185
|
+
return route.fulfill({ status: rule.status, body: rule.body, headers: { "access-control-allow-origin": "*", ...rule.headers } }).catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
if (!s.profiled) return route.fallback();
|
|
188
|
+
try {
|
|
189
|
+
const { res } = await s.profiled(req.url(), { method: req.method(), body: req.postDataBuffer() ?? undefined, route });
|
|
190
|
+
await route.fulfill({ response: res }).catch(() => {});
|
|
191
|
+
} catch { await route.abort("internetdisconnected").catch(() => {}); }
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Set the session's network profile. Throws unknown_profile for a bad name. Logged in device_log as simulated.
|
|
196
|
+
async function setNetworkProfile(s, page, name) {
|
|
197
|
+
let profile; try { profile = getProfile(name); } catch (e) { throw new SimError(e.code, e.message); }
|
|
198
|
+
const from = s.netProfile ?? "online";
|
|
199
|
+
s.netProfile = name;
|
|
200
|
+
s.profiled = name === "online" ? null : applyProfile(async (_url, init) => {
|
|
201
|
+
// Playwright's APIResponse has headers() as a method. A chunked reply has no content-length, so use the body size.
|
|
202
|
+
const res = await init.route.fetch(); const headers = res.headers(); const size = String((await res.body()).length);
|
|
203
|
+
return { res, headers: { get: (n) => { const k = String(n).toLowerCase(); return headers[k] ?? (k === "content-length" ? size : null); } } };
|
|
204
|
+
}, profile, { clock: { sleep: (ms) => new Promise((r) => setTimeout(r, ms)) } });
|
|
205
|
+
if (s.profiled) await ensureNetworkRoute(s, page);
|
|
206
|
+
(s.netLog ??= []).push({ at: Date.now(), name: "network_profile", profile: name, from, source: "simulated" });
|
|
207
|
+
}
|
|
208
|
+
|
|
80
209
|
class SimError extends Error { constructor(code, message) { super(message); this.code = code; } }
|
|
81
210
|
|
|
82
211
|
async function screen(page) {
|
|
83
212
|
await page.waitForTimeout(150);
|
|
84
213
|
const epoch = await epochOf(page);
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
await page.evaluate(() => {
|
|
88
|
-
for (const el of document.querySelectorAll("[data-pn-offscreen]")) { el.removeAttribute("aria-hidden"); el.removeAttribute("data-pn-offscreen"); }
|
|
89
|
-
// A modal sheet (accessibilityViewIsModal / aria-modal) hides everything behind it from assistive tech on phones.
|
|
90
|
-
const modal = [...document.querySelectorAll('[aria-modal="true"]')].pop();
|
|
91
|
-
if (modal) for (let node = modal; node && node !== document.body; node = node.parentElement)
|
|
92
|
-
for (const sib of node.parentElement?.children ?? []) if (sib !== node && !sib.hasAttribute("aria-hidden")) { sib.setAttribute("aria-hidden", "true"); sib.setAttribute("data-pn-offscreen", "1"); }
|
|
93
|
-
for (const sc of document.querySelectorAll("div")) {
|
|
94
|
-
const cs = getComputedStyle(sc);
|
|
95
|
-
if (!/(auto|scroll)/.test(cs.overflowX) || sc.scrollWidth <= sc.clientWidth + 1) continue;
|
|
96
|
-
const box = sc.getBoundingClientRect();
|
|
97
|
-
const row = sc.firstElementChild; if (!row) continue;
|
|
98
|
-
for (const item of row.children) {
|
|
99
|
-
const r = item.getBoundingClientRect();
|
|
100
|
-
if (r.right <= box.left + 1 || r.left >= box.right - 1) { item.setAttribute("aria-hidden", "true"); item.setAttribute("data-pn-offscreen", "1"); }
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
});
|
|
214
|
+
// Hide what a phone would not expose, so an agent learns to swipe a rail exactly as it must on a device.
|
|
215
|
+
await page.evaluate(`(${clearScreenRules})(document); (${applyScreenRules})(document)`);
|
|
104
216
|
// What is hidden from assistive tech must not appear at all: this Playwright keeps aria-hidden nodes but strips
|
|
105
217
|
// their names. `inert` removes them from the accessibility tree; it is set only for the snapshot and removed after.
|
|
106
218
|
await page.evaluate(() => { for (const el of document.querySelectorAll('[aria-hidden="true"]')) { el.setAttribute("data-pn-prev-display", el.style.display || ""); el.style.setProperty("display", "none", "important"); } });
|
|
107
219
|
const tree = await page.locator("body").ariaSnapshot({ mode: "ai" });
|
|
108
220
|
await page.evaluate(() => { for (const el of document.querySelectorAll("[data-pn-prev-display]")) { const prev = el.getAttribute("data-pn-prev-display"); if (prev) el.style.display = prev; else el.style.removeProperty("display"); el.removeAttribute("data-pn-prev-display"); } });
|
|
109
|
-
const
|
|
110
|
-
// A screen reader announces a control's label, not the words inside it (Android and iOS group a pressable's
|
|
111
|
-
// children into one element). Drop the inner text lines of labelled controls, as the phones do.
|
|
112
|
-
const out = []; let hideBelow = -1;
|
|
113
|
-
for (const l of lines) {
|
|
114
|
-
const indent = l.search(/\S/);
|
|
115
|
-
if (hideBelow >= 0 && indent > hideBelow) continue;
|
|
116
|
-
hideBelow = -1;
|
|
117
|
-
if (/\[aria-hidden\]/.test(l)) { hideBelow = indent; continue; } // hidden from assistive tech: not on screen
|
|
118
|
-
out.push(l);
|
|
119
|
-
if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
|
|
120
|
-
}
|
|
121
|
-
const text = out.join("\n");
|
|
221
|
+
const text = filterTree(tree);
|
|
122
222
|
return epoch ? text.replace(/\[ref=(e\d+)\]/g, `[ref=p${epoch}$1]`) : text;
|
|
123
223
|
}
|
|
124
224
|
|
|
@@ -157,9 +257,86 @@ async function act(id, body) {
|
|
|
157
257
|
});
|
|
158
258
|
if (covered) throw fail("covered", String(body.ref));
|
|
159
259
|
};
|
|
260
|
+
// A locked phone takes no input: say so with a typed error before anything reaches the app.
|
|
261
|
+
if (["tap", "tap_xy", "type_keys", "type", "scroll", "back", "open_url", "open_notification", "install_app", "uninstall_app", "switch_app", ...SHADE_ACTIONS, ...SETTINGS_ACTIONS].includes(body.action)) { try { assertUnlocked(s.life); } catch (e) { throw new SimError(e.code, e.message); } }
|
|
262
|
+
// Home screen or app switcher showing: the app is in the background and takes no input.
|
|
263
|
+
if (["tap", "tap_xy", "type_keys", "type", "scroll", "back", "open_url", "open_notification", "open_notifications", "open_control_center", "toggle_tile", "dismiss_notification"].includes(body.action)) { try { assertForeground(s.nav); } catch (e) { throw new SimError(e.code, e.message); } }
|
|
264
|
+
// An open panel covers the app on a phone, so input to the app is refused until it is closed.
|
|
265
|
+
if (["tap", "tap_xy", "type_keys", "type", "scroll"].includes(body.action) && s.shade.open !== "none") throw new SimError("panel_open", `a panel (${s.shade.open}) is open and covers the app, so nothing was sent to it. Close it first (close_panels).`);
|
|
160
266
|
switch (body.action) {
|
|
267
|
+
// Home, recents, resume and kill. The app hears them as AppState changes (document.visibilityState). Kill is a cold start.
|
|
268
|
+
case "press_home": case "open_recents": case "resume_app": case "kill_app": {
|
|
269
|
+
let ev;
|
|
270
|
+
try { ev = body.action === "press_home" ? pressHome(s.nav) : body.action === "open_recents" ? openRecents(s.nav) : body.action === "resume_app" ? resume(s.nav) : (killApp(s.nav), null); }
|
|
271
|
+
catch (e) { if (e instanceof NavError) throw new SimError(e.code, e.message); throw e; }
|
|
272
|
+
if (ev) await page.evaluate(setVisibilityInPage, ev);
|
|
273
|
+
if (body.action === "kill_app") { s.life.reloads++; await boot(s); await page.evaluate(setVisibilityInPage, "background"); }
|
|
274
|
+
const where = s.nav.where;
|
|
275
|
+
return { where, appState: where === "app" ? "active" : "background", reloads: s.life.reloads, screen: where === "app" ? await screen(page) : where === "home" ? `Home screen\n- button "${APP_NAME}" (the app icon: resume_app opens it)\n${describeApps(s.apps)}` : `App switcher\n- card "${APP_NAME}" (resume_app opens it, kill_app closes it)` };
|
|
276
|
+
}
|
|
277
|
+
// Installed apps. Errors are typed: unknown_app, already_installed, not_installed. Logged in device_log as simulated.
|
|
278
|
+
case "install_app": case "uninstall_app": case "switch_app": {
|
|
279
|
+
const id = String(body.id ?? "");
|
|
280
|
+
try {
|
|
281
|
+
if (body.action === "install_app") installApp(s.apps, id, Array.isArray(body.catalog) ? body.catalog : s.apps.store);
|
|
282
|
+
else if (body.action === "uninstall_app") uninstallApp(s.apps, id, s.nav);
|
|
283
|
+
else {
|
|
284
|
+
const url = s.apps.installed_apps.find((a) => a.id === id)?.url ?? "";
|
|
285
|
+
// fixture: pages are stand-ins (no navigation). app://<id>[@rN] loads a published release like open_app. Anything else
|
|
286
|
+
// must pass the same private-host guard as the Browser app, so a profile cannot point Chromium at the local network.
|
|
287
|
+
let dest = null;
|
|
288
|
+
const am = /^app:\/\/([a-z0-9-]+(?:@(?:r?\d+|latest))?)$/.exec(url);
|
|
289
|
+
if (am) dest = (await resolveApp(am[1])).url;
|
|
290
|
+
else if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !/^fixture:/i.test(url)) {
|
|
291
|
+
try { dest = (await checkUrl(url, { allowPrivate: false, allowHosts: [] })).href; }
|
|
292
|
+
catch (e) { if (e instanceof BrowserError) throw new SimError(e.code, e.message); throw e; }
|
|
293
|
+
}
|
|
294
|
+
switchApp(s.apps, id, s.nav);
|
|
295
|
+
if (dest && page.url() !== dest) { await page.goto(dest, { waitUntil: "networkidle" }); await page.waitForTimeout(300); }
|
|
296
|
+
await page.evaluate(setVisibilityInPage, "active");
|
|
297
|
+
}
|
|
298
|
+
} catch (e) { if (e instanceof AppsError) throw new SimError(e.code, e.message); throw e; }
|
|
299
|
+
if (body.action === "uninstall_app" && s.nav.where === "home") await page.evaluate(setVisibilityInPage, "background");
|
|
300
|
+
const where = s.nav.where;
|
|
301
|
+
return { where, current_app: s.apps.current_app, installed_apps: s.apps.installed_apps, screen: where === "app" ? await screen(page) : `Home screen\n${describeApps(s.apps)}` };
|
|
302
|
+
}
|
|
303
|
+
// Simulated lock, unlock and restart. Restart reloads the page (a cold start of the app); the browser context keeps storage.
|
|
304
|
+
case "lock": case "unlock": {
|
|
305
|
+
if (body.action === "lock") s.shade.open = "none";
|
|
306
|
+
try { (body.action === "lock" ? lock : unlock)(s.life); } catch (e) { if (e instanceof LifecycleError) throw new SimError(e.code, e.message); throw e; }
|
|
307
|
+
return { locked: s.life.locked, screen: s.life.locked ? "Lock screen" : await screen(page) };
|
|
308
|
+
}
|
|
309
|
+
// Simulated OTA: check (signed pointer, hash), apply (restart on the new bundle), state. Errors: no_update, not_ready. Every change is in device_log.
|
|
310
|
+
case "get_update_state": case "check_for_update": case "apply_update": {
|
|
311
|
+
try {
|
|
312
|
+
if (body.action === "check_for_update") { await checkForUpdate(s.update, { url: s.updateSrc.url, publicKey: s.updateSrc.key }); await showBanner(s); }
|
|
313
|
+
else if (body.action === "apply_update") {
|
|
314
|
+
applyUpdate(s.update, (n) => `http://localhost:${PORT}/u/${body.session}/r${n}/index.html`);
|
|
315
|
+
s.life.reloads++; s.shade.open = "none"; s.nav.where = "app"; s.life.locked = false;
|
|
316
|
+
await boot(s, s.update.running.url);
|
|
317
|
+
}
|
|
318
|
+
} catch (e) { if (e instanceof UpdateError) throw new SimError(e.code, e.message); throw e; }
|
|
319
|
+
return { update: updateView(s.update), banner: bannerText(s.update), reloads: s.life.reloads, screen: await screen(page) };
|
|
320
|
+
}
|
|
321
|
+
case "restart": {
|
|
322
|
+
restart(s.life); s.shade.open = "none"; s.nav.where = "app";
|
|
323
|
+
await boot(s);
|
|
324
|
+
return { reloads: s.life.reloads, restarts: s.life.restarts, screen: await screen(page) };
|
|
325
|
+
}
|
|
161
326
|
case "screen": break;
|
|
327
|
+
// Coordinate input, for the "screenshots and coordinates only" side of scripts/agent-efficiency. Not an MCP tool: agents using
|
|
328
|
+
// the labelled screen tap by ref. x and y are CSS pixels from the top left of the screenshot.
|
|
329
|
+
case "tap_xy": { const x = Number(body.x), y = Number(body.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("tap_xy needs numbers x and y"); await page.mouse.click(x, y); break; }
|
|
330
|
+
case "type_keys": await page.keyboard.type(String(body.text ?? ""), { delay: 5 }); if (body.enter) await page.keyboard.press("Enter"); break;
|
|
162
331
|
case "tap": { const t = await resolve(body.ref); await ready(t); await t.click({ timeout: 3000 }); break; }
|
|
332
|
+
// Where an element is: centre and size as 0..1 of the viewport. Used by the recorder to draw taps. Does not act.
|
|
333
|
+
case "locate": {
|
|
334
|
+
let box = null; const vp = page.viewportSize();
|
|
335
|
+
try { box = await (await resolve(body.ref)).boundingBox({ timeout: 1000 }); } catch {}
|
|
336
|
+
if (!box || !vp) throw new SimError("not_found", `no element ${body.ref} on this screen.`);
|
|
337
|
+
const c = (v) => Math.min(1, Math.max(0, v));
|
|
338
|
+
return { locate: { x: c((box.x + box.width / 2) / vp.width), y: c((box.y + box.height / 2) / vp.height), w: c(box.width / vp.width), h: c(box.height / vp.height) } };
|
|
339
|
+
}
|
|
163
340
|
case "type": {
|
|
164
341
|
// A labelled text field may resolve to its wrapper; type into the real input inside it, as a phone focuses the field.
|
|
165
342
|
let t = await resolve(body.ref);
|
|
@@ -183,10 +360,16 @@ async function act(id, body) {
|
|
|
183
360
|
break;
|
|
184
361
|
}
|
|
185
362
|
// Simulated device: what the phone "has" and what happens to it. See @shipvo/device.
|
|
186
|
-
case "set_device": await page.evaluate((d) => globalThis.__pnDevice?.set(d), body.device ?? {}); break;
|
|
363
|
+
case "set_device": await page.evaluate((d) => globalThis.__pnDevice?.set(d), body.device ?? {}); await syncMedia(page); break;
|
|
364
|
+
case "open_notifications": case "open_control_center": case "close_panels": case "toggle_tile": case "dismiss_notification": {
|
|
365
|
+
try { return { ...(await shadeAction(page, s.shade, body)) }; } catch (e) { if (e instanceof ShadeError) throw new SimError(e.code, e.message); throw e; }
|
|
366
|
+
}
|
|
367
|
+
case "get_settings": case "set_setting": {
|
|
368
|
+
try { return { ...(await settingsAction(page, body)) }; } catch (e) { if (e instanceof SettingsError) throw new SimError(e.code, e.message); throw e; }
|
|
369
|
+
}
|
|
187
370
|
case "push_notification": return { ...(await pushNotification(page, body)), screen: await screen(page) };
|
|
188
371
|
case "notifications": return { notifications: await page.evaluate(() => globalThis.__pnDevice?.notifications() ?? []), screen: await screen(page) };
|
|
189
|
-
case "open_notification": { const ok = await page.evaluate((nid) => globalThis.__pnDevice?.openNotification(nid), body.id); if (!ok) throw new Error("no such notification"); await page.waitForTimeout(400); break; }
|
|
372
|
+
case "open_notification": { s.shade.open = "none"; const ok = await page.evaluate((nid) => globalThis.__pnDevice?.openNotification(nid), body.id); if (!ok) throw new Error("no such notification"); await page.waitForTimeout(400); break; }
|
|
190
373
|
case "open_url": {
|
|
191
374
|
if (typeof body.url !== "string" || !body.url) throw new SimError("bad_url", "open_url needs a url, such as myapp://item/3");
|
|
192
375
|
const handled = await page.evaluate((u) => globalThis.__pnDevice?.openUrl(u), body.url);
|
|
@@ -197,21 +380,31 @@ async function act(id, body) {
|
|
|
197
380
|
case "set_network_mocks": {
|
|
198
381
|
let rules; try { rules = parseMockRules(body.rules ?? []); } catch (e) { throw new SimError(e.code ?? "mock_invalid", e.message); }
|
|
199
382
|
s.mocks = rules;
|
|
200
|
-
|
|
201
|
-
s.mockRoute = true;
|
|
202
|
-
await page.route("**/*", async (route) => {
|
|
203
|
-
const req = route.request();
|
|
204
|
-
const type = req.resourceType();
|
|
205
|
-
const rule = (type === "fetch" || type === "xhr") && (s.mocks ?? []).find((m) => (!m.method || m.method === req.method()) && m.match(req.url()));
|
|
206
|
-
if (!rule) return route.fallback();
|
|
207
|
-
if (rule.delayMs) await new Promise((r) => setTimeout(r, rule.delayMs));
|
|
208
|
-
// The app page may come from another origin, so the stub must pass the browser's CORS check.
|
|
209
|
-
await route.fulfill({ status: rule.status, body: rule.body, headers: { "access-control-allow-origin": "*", ...rule.headers } }).catch(() => {});
|
|
210
|
-
});
|
|
211
|
-
}
|
|
383
|
+
await ensureNetworkRoute(s, page);
|
|
212
384
|
break;
|
|
213
385
|
}
|
|
214
|
-
case "
|
|
386
|
+
case "set_network_profile": { await setNetworkProfile(s, page, body.profile); break; }
|
|
387
|
+
// The phone's Chromium (isolated from the app page). Page text is untrusted: results carry source "web".
|
|
388
|
+
case "browser_open": case "browser_snapshot": case "browser_click": case "browser_type": case "browser_back": case "browser_screenshot": case "browser_tabs": case "browser_close_tab": case "browser_scroll": case "browser_wait_for": {
|
|
389
|
+
if (body.action === "browser_open" && body.url) { await phoneBrowser(s); if (body.allow_private !== undefined) s.browser.opts.allowPrivate = body.allow_private === true; if (Array.isArray(body.allow_hosts)) s.browser.opts.allowHosts = body.allow_hosts.map(String); }
|
|
390
|
+
if (!s.browser) { if (body.action === "browser_open") throw new SimError("bad_url", "browser_open needs a url"); throw new SimError("no_page", "no page is open: call browser_open first"); }
|
|
391
|
+
const b = s.browser;
|
|
392
|
+
try {
|
|
393
|
+
switch (body.action) {
|
|
394
|
+
case "browser_open": { const r = await b.open(String(body.url ?? "")); ensureCast(s); return { ...r }; }
|
|
395
|
+
case "browser_snapshot": return { ...(await b.snapshot()) };
|
|
396
|
+
case "browser_click": return { ...(await b.click(body.ref)) };
|
|
397
|
+
case "browser_type": return { ...(await b.type(body.ref, body.text, body.enter)) };
|
|
398
|
+
case "browser_back": return { ...(await b.back()) };
|
|
399
|
+
case "browser_tabs": return { source: "web", untrusted: true, tabs: await b.tabs() };
|
|
400
|
+
case "browser_close_tab": return { source: "web", untrusted: true, tabs: await b.closeTab(Number(body.index)) };
|
|
401
|
+
case "browser_scroll": return { ...(await b.scroll(Number.isFinite(body.dy) ? body.dy : 400, Number.isFinite(body.dx) ? body.dx : 0)) };
|
|
402
|
+
case "browser_wait_for": return { ...(await b.waitFor({ text: body.text, gone: body.gone === true, timeoutMs: body.timeout_ms })) };
|
|
403
|
+
default: { const p = join(DIR, `${id}-web-${Date.now()}.png`); writeFileSync(p, await b.screenshot()); return { path: p, source: "web", untrusted: true }; }
|
|
404
|
+
}
|
|
405
|
+
} catch (e) { throw e.code ? new SimError(e.code, e.message) : e; }
|
|
406
|
+
}
|
|
407
|
+
case "device_log": return { log: [...(await page.evaluate(() => globalThis.__pnDevice?.log() ?? [])), ...s.life.log, ...s.apps.log, ...s.update.log, ...(s.netLog ?? []), ...(s.browser?.navigations ?? [])], capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
|
|
215
408
|
case "diag": {
|
|
216
409
|
const r = await page.evaluate(async () => {
|
|
217
410
|
const cfg = globalThis.__PN_SIM__ ?? {}; const out = { baseUrl: cfg.baseUrl, origin: location.origin };
|
|
@@ -221,10 +414,30 @@ async function act(id, body) {
|
|
|
221
414
|
});
|
|
222
415
|
return { diag: r, screen: await screen(page) };
|
|
223
416
|
}
|
|
224
|
-
case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); break;
|
|
225
|
-
|
|
226
|
-
case "
|
|
227
|
-
|
|
417
|
+
case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); await syncMedia(page); s.shade.open = "none"; break;
|
|
418
|
+
// Uncaught page errors since the last call to this action. Reading them clears the list.
|
|
419
|
+
case "errors": return { errors: s.errors.splice(0), screen: await screen(page) };
|
|
420
|
+
// Rendered size in CSS px of each ref on the current screen: { sizes: { ref: [width, height] } }. Refs that are gone are left out.
|
|
421
|
+
case "sizes": {
|
|
422
|
+
const text = await screen(page); const sizes = {};
|
|
423
|
+
for (const m of text.matchAll(/\[ref=(\w+)\]/g)) {
|
|
424
|
+
const raw = m[1].replace(/^p\d+(?=e\d+$)/, "");
|
|
425
|
+
const box = await page.locator(`aria-ref=${raw}`).first().boundingBox().catch(() => null);
|
|
426
|
+
if (box) sizes[m[1]] = [Math.round(box.width * 10) / 10, Math.round(box.height * 10) / 10];
|
|
427
|
+
}
|
|
428
|
+
return { sizes, screen: text };
|
|
429
|
+
}
|
|
430
|
+
case "back": { if (s.shade.open !== "none") { s.shade.open = "none"; break; } const b = page.getByRole("button", { name: "Back", exact: true }); if (await b.count()) { await ready(b.first()); await b.first().click(); } else throw new Error("no Back button on this screen"); break; }
|
|
431
|
+
case "screenshot": {
|
|
432
|
+
// format "jpeg" has no alpha channel, which the stores require. inline: true also returns the bytes as base64 for clients on another machine.
|
|
433
|
+
const jpeg = body.format === "jpeg"; const p = join(DIR, `${id}-${Date.now()}.${jpeg ? "jpg" : "png"}`);
|
|
434
|
+
// Home screen or switcher showing: draw it over the app for the shot, then take it away. The app stays loaded underneath.
|
|
435
|
+
const away = s.nav.where !== "app";
|
|
436
|
+
if (away) await page.evaluate((h) => { const d = document.createElement("div"); d.id = "__pn_away"; d.innerHTML = h; document.body.appendChild(d); }, awayScreenHtml(s.nav.where, APP_NAME));
|
|
437
|
+
let buf; try { buf = await page.screenshot(jpeg ? { path: p, type: "jpeg", quality: 90 } : { path: p }); } finally { if (away) await page.evaluate(() => document.getElementById("__pn_away")?.remove()); }
|
|
438
|
+
return { path: p, ...(body.inline ? { data: buf.toString("base64") } : {}), screen: await screen(page) };
|
|
439
|
+
}
|
|
440
|
+
default: throw new Error(`unknown action '${body.action}'. Actions: screen, get_update_state, check_for_update, apply_update, install_app, uninstall_app, switch_app, lock, unlock, restart, press_home, open_recents, resume_app, kill_app, open_notifications, open_control_center, close_panels, toggle_tile, dismiss_notification, get_settings, set_setting, tap, locate, type, scroll, back, screenshot, set_device, push_notification, notifications, open_notification, open_url, errors, sizes, device_log, device_reset, set_network_mocks, set_network_profile, browser_open, browser_snapshot, browser_click, browser_type, browser_back, browser_screenshot, browser_tabs, browser_close_tab, browser_scroll, browser_wait_for`);
|
|
228
441
|
}
|
|
229
442
|
return { ...(["tap", "scroll", "back"].includes(body.action) ? { delivery: "delivered" } : {}), screen: await screen(page) };
|
|
230
443
|
}
|
|
@@ -250,6 +463,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
250
463
|
if (!hit) { const r = await fetch(`${CONTROL}${key}`); if (!r.ok) return send(r.status, { error: "artifact" }); hit = { type: r.headers.get("content-type") ?? "application/octet-stream", body: Buffer.from(await r.arrayBuffer()) }; WCACHE.set(key, hit); }
|
|
251
464
|
return send(200, hit.body, hit.type);
|
|
252
465
|
}
|
|
466
|
+
// a bundle this session downloaded and verified: the page that runs is the page that was checked
|
|
467
|
+
{ const um = url.pathname.match(/^\/u\/([\w-]+)\/r(\d+)\/index\.html$/);
|
|
468
|
+
if (um) { const b = sessions.get(um[1])?.update.bundles.get(Number(um[2])); return b ? send(200, b, MIME[".html"]) : send(404, { error: "no_bundle" }); } }
|
|
253
469
|
// a founder opening the printed link lands on the app
|
|
254
470
|
if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/") { res.writeHead(302, { location: "/app/", ...CORS }); return res.end(); }
|
|
255
471
|
if (url.pathname.startsWith("/app/")) {
|
|
@@ -274,12 +490,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
274
490
|
}
|
|
275
491
|
return send(202, { ok: true }); // telemetry etc.
|
|
276
492
|
}
|
|
493
|
+
// the Browser app, in a phone frame
|
|
494
|
+
if (req.method === "GET" && url.pathname === "/browser") return send(200, browserAppPage(url.searchParams.get("skin") === "ios" ? "ios" : "android", url.searchParams.get("session") ?? ""), MIME[".html"]);
|
|
277
495
|
// agent API
|
|
278
496
|
if (req.method !== "POST") return send(404, { error: "not found" });
|
|
279
497
|
let raw = ""; for await (const ch of req) raw += ch; const body = raw ? JSON.parse(raw) : {};
|
|
280
498
|
if (url.pathname === "/apps") return send(200, { ok: true, apps: [{ id: DEFAULT_APP, name: APP_NAME }] });
|
|
281
499
|
if (url.pathname === "/sessions") return send(200, { ok: true, sessions: [...sessions.entries()].map(([id, s]) => ({ session: id, app: s.app, release: s.release, started_at: s.startedAt ?? null, url: s.page.url() })) });
|
|
282
|
-
if (url.pathname === "/session") { await newSession(body.session, body); const s = sessions.get(body.session); log(body.session, "start", true, { app: s.app, release: s.release }); return send(200, { ok: true, app: s.app, release: s.release, screen: await screen(s.page) }); }
|
|
500
|
+
if (url.pathname === "/session") { try { await newSession(body.session, body); } catch (e) { if (e instanceof SimError && e.code === "bad_profile") return send(200, { ok: false, code: e.code, error: e.message }); throw e; } const s = sessions.get(body.session); log(body.session, "start", true, { app: s.app, release: s.release }); return send(200, { ok: true, app: s.app, release: s.release, screen: await screen(s.page) }); }
|
|
283
501
|
if (url.pathname === "/act") {
|
|
284
502
|
try { const r = await act(body.session, body); log(body.session, body.action, true, body.ref ? { ref: body.ref } : {}); return send(200, { ok: true, ...r }); }
|
|
285
503
|
catch (e) { log(body.session, body.action, false); const s = sessions.get(body.session); return send(200, { ok: false, error: String(e.message).split("\n")[0], ...(e.code ? { code: e.code } : {}), ...(DELIVERY.has(body.action) && REASONS.has(e.code) ? { delivery: "not_delivered", reason: e.code, safe_to_retry: true } : {}), screen: s ? await screen(s.page) : null }); }
|
|
@@ -287,6 +505,33 @@ const server = http.createServer(async (req, res) => {
|
|
|
287
505
|
send(404, { error: "not found" });
|
|
288
506
|
} catch (e) { send(500, { error: String(e) }); }
|
|
289
507
|
});
|
|
508
|
+
// The Browser app's live view: frames out, taps, swipes and typing in. Same trust as the HTTP API (loopback), plus an Origin check
|
|
509
|
+
// so another web page open on this machine cannot drive the phone.
|
|
510
|
+
server.on("upgrade", (req, socket) => {
|
|
511
|
+
const url = new URL(req.url, "http://x");
|
|
512
|
+
const refuse = (line) => socket.end(`HTTP/1.1 ${line}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
513
|
+
if (url.pathname !== "/browser/ws") return refuse("404 Not Found");
|
|
514
|
+
let originHost = null; try { if (req.headers.origin) originHost = new URL(req.headers.origin).host; } catch { originHost = "invalid"; }
|
|
515
|
+
// A missing Origin is allowed (non-browser clients). The Host comparison does not stop DNS rebinding, since both
|
|
516
|
+
// values come from the requester; the HTTP API has the same trust level.
|
|
517
|
+
if (originHost && originHost !== req.headers.host) return refuse("403 Forbidden");
|
|
518
|
+
const s = sessions.get(url.searchParams.get("session") ?? ""); if (!s) return refuse("404 Not Found");
|
|
519
|
+
const peer = acceptWebSocket(req, socket, async (text) => {
|
|
520
|
+
let m; try { m = JSON.parse(text); } catch { return; }
|
|
521
|
+
const b = s.browser;
|
|
522
|
+
try {
|
|
523
|
+
if (m.type === "open") { await openInBrowserApp(url.searchParams.get("session"), String(m.url)); return; } // a refusal reaches every viewer as an `error` message
|
|
524
|
+
if (!b) return;
|
|
525
|
+
if (m.type === "back") await b.back(); else if (m.type === "forward") await b.forward(); else if (m.type === "reload") await b.reload();
|
|
526
|
+
else if (m.type === "tab") await b.selectTab(Number(m.index)); else if (m.type === "newtab") await b.newTab(); else if (m.type === "closetab") await b.closeTab(Number(m.index));
|
|
527
|
+
else await b.input(m);
|
|
528
|
+
} catch (e) { peer.send(JSON.stringify({ type: "error", code: e.code ?? "error", message: String(e.message).split("\n")[0] })); }
|
|
529
|
+
}, () => { s.peers?.delete(peer); if (!s.peers?.size) s.browser?.stopScreencast(); });
|
|
530
|
+
if (!peer) return;
|
|
531
|
+
s.peers = s.peers ?? new Set(); s.peers.add(peer);
|
|
532
|
+
peer.send(JSON.stringify({ type: "hello", browser: !!s.browser }));
|
|
533
|
+
ensureCast(s);
|
|
534
|
+
});
|
|
290
535
|
// Agents and parity runs pause between actions; keep idle connections open well beyond Node's 5 s default.
|
|
291
536
|
server.keepAliveTimeout = 300_000; server.headersTimeout = 310_000; server.requestTimeout = 0;
|
|
292
537
|
server.listen(PORT, () => console.log(`Shipvo UI simulator on http://localhost:${PORT} (sessions in ${DIR})`));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The server side of the Settings screen: the MCP actions get_settings and set_setting. Errors are SettingsError with a typed `code`.
|
|
2
|
+
import { SettingsError, settingPatch, readSettings, describeSettings } from "./settings.mjs";
|
|
3
|
+
|
|
4
|
+
const read = async (page) => {
|
|
5
|
+
const sys = await page.evaluate(() => globalThis.__pnDevice?.system?.() ?? null);
|
|
6
|
+
if (!sys) throw new SettingsError("no_device", "the app has no simulated device in this session");
|
|
7
|
+
return sys;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Dark mode and reduce motion also reach the app's CSS media queries (prefers-color-scheme, prefers-reduced-motion). */
|
|
11
|
+
export async function syncMedia(page) {
|
|
12
|
+
const sys = await page.evaluate(() => globalThis.__pnDevice?.system?.() ?? null);
|
|
13
|
+
if (sys) await page.emulateMedia({ colorScheme: sys.darkMode ? "dark" : "light", reducedMotion: sys.reduceMotion ? "reduce" : "no-preference" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const view = (sys) => { const settings = readSettings(sys); return { settings, screen: describeSettings(settings) }; };
|
|
17
|
+
|
|
18
|
+
/** Runs get_settings and set_setting. Returns the result fields. */
|
|
19
|
+
export async function settingsAction(page, body) {
|
|
20
|
+
if (body.action === "get_settings") return view(await read(page));
|
|
21
|
+
if (typeof body.key !== "string" || !body.key) throw new SettingsError("unknown_setting", "set_setting needs a key, such as dark_mode or font_size");
|
|
22
|
+
const patch = settingPatch(body.key, body.value); // unknown key and bad value fail before anything changes
|
|
23
|
+
await read(page);
|
|
24
|
+
await page.evaluate((p) => globalThis.__pnDevice.set(p), patch);
|
|
25
|
+
await syncMedia(page);
|
|
26
|
+
return view(await read(page));
|
|
27
|
+
}
|
|
28
|
+
export const SETTINGS_ACTIONS = ["get_settings", "set_setting"];
|