@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/server.mjs CHANGED
@@ -10,11 +10,22 @@ 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";
16
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";
17
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";
18
29
  import { createLifecycle, lock, unlock, restart, assertUnlocked, LifecycleError } from "./lifecycle.mjs";
19
30
  import { applyScreenRules, clearScreenRules, filterTree } from "./screen-rules.mjs";
20
31
  // Playwright: the app's own install first (its browsers match it), then this package's, then a global one.
@@ -30,6 +41,13 @@ const here = dirname(fileURLToPath(import.meta.url));
30
41
  const arg = (k, d) => { const i = process.argv.indexOf(`--${k}`); return i > 0 ? process.argv[i + 1] : d; };
31
42
  let CFG = {}; try { CFG = JSON.parse(readFileSync(join(process.cwd(), "shipvo.json"), "utf8")); } catch {}
32
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 || "") };
33
51
  const DEFAULT_APP = arg("app", CFG.appId || "tide"), APP_NAME = CFG.name || "Tide & Tackle";
34
52
  // Published releases: open_app("tide@r13") loads the web artifact that `shipvo publish` stored next to the phone bundle.
35
53
  const CONTROL = (process.env.PN_CONTROL || process.env.CONTENT_FROM || CFG.controlPlane || "https://api.shipvo.dev").replace(/\/$/, "");
@@ -61,21 +79,68 @@ const sessions = new Map();
61
79
 
62
80
  const phone = { ...devices["Pixel 5"], viewport: { width: 393, height: 851 } };
63
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
+
64
122
  async function newSession(id, opts = {}) {
65
- if (sessions.has(id)) await sessions.get(id).ctx.close();
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(); }
66
124
  const target = await resolveApp(opts.app);
67
125
  writeFileSync(join(DIR, `${id}.json`), JSON.stringify({ orders: [] })); writeFileSync(join(DIR, `${id}.json.log`), "");
68
126
  const browser = await browserReady;
69
127
  // Optional width, height (output pixels) and scale (device pixel ratio) make a session render at a store screenshot size.
70
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 } : {};
71
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)));
72
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 });
73
133
  const page = await ctx.newPage();
74
134
  const errors = [];
75
- page.on("pageerror", (e) => errors.push(String(e?.message ?? e).split("\n")[0]));
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]); });
76
139
  await page.goto(target.url, { waitUntil: "networkidle" });
77
140
  await page.waitForTimeout(300);
78
- sessions.set(id, { ctx, page, errors, app: target.app, release: target.release, startedAt: new Date().toISOString(), life: createLifecycle() });
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() });
79
144
  }
80
145
 
81
146
  // Refs are only meaningful for the document that issued them: a full page load restarts Playwright's counter, so an
@@ -87,6 +152,60 @@ async function epochOf(page) {
87
152
  if (!d) DOCS.set(page, { id, epoch: 0 }); else if (d.id !== id) { d.id = id; d.epoch++; }
88
153
  return DOCS.get(page).epoch;
89
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
+
90
209
  class SimError extends Error { constructor(code, message) { super(message); this.code = code; } }
91
210
 
92
211
  async function screen(page) {
@@ -139,20 +258,85 @@ async function act(id, body) {
139
258
  if (covered) throw fail("covered", String(body.ref));
140
259
  };
141
260
  // A locked phone takes no input: say so with a typed error before anything reaches the app.
142
- if (["tap", "type", "scroll", "back", "open_url", "open_notification"].includes(body.action)) { try { assertUnlocked(s.life); } catch (e) { throw new SimError(e.code, e.message); } }
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).`);
143
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
+ }
144
303
  // Simulated lock, unlock and restart. Restart reloads the page (a cold start of the app); the browser context keeps storage.
145
304
  case "lock": case "unlock": {
305
+ if (body.action === "lock") s.shade.open = "none";
146
306
  try { (body.action === "lock" ? lock : unlock)(s.life); } catch (e) { if (e instanceof LifecycleError) throw new SimError(e.code, e.message); throw e; }
147
307
  return { locked: s.life.locked, screen: s.life.locked ? "Lock screen" : await screen(page) };
148
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
+ }
149
321
  case "restart": {
150
- restart(s.life);
151
- await page.reload({ waitUntil: "networkidle" }); await page.waitForTimeout(300);
322
+ restart(s.life); s.shade.open = "none"; s.nav.where = "app";
323
+ await boot(s);
152
324
  return { reloads: s.life.reloads, restarts: s.life.restarts, screen: await screen(page) };
153
325
  }
154
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;
155
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
+ }
156
340
  case "type": {
157
341
  // A labelled text field may resolve to its wrapper; type into the real input inside it, as a phone focuses the field.
158
342
  let t = await resolve(body.ref);
@@ -176,10 +360,16 @@ async function act(id, body) {
176
360
  break;
177
361
  }
178
362
  // Simulated device: what the phone "has" and what happens to it. See @shipvo/device.
179
- 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
+ }
180
370
  case "push_notification": return { ...(await pushNotification(page, body)), screen: await screen(page) };
181
371
  case "notifications": return { notifications: await page.evaluate(() => globalThis.__pnDevice?.notifications() ?? []), screen: await screen(page) };
182
- 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; }
183
373
  case "open_url": {
184
374
  if (typeof body.url !== "string" || !body.url) throw new SimError("bad_url", "open_url needs a url, such as myapp://item/3");
185
375
  const handled = await page.evaluate((u) => globalThis.__pnDevice?.openUrl(u), body.url);
@@ -190,21 +380,31 @@ async function act(id, body) {
190
380
  case "set_network_mocks": {
191
381
  let rules; try { rules = parseMockRules(body.rules ?? []); } catch (e) { throw new SimError(e.code ?? "mock_invalid", e.message); }
192
382
  s.mocks = rules;
193
- if (!s.mockRoute) {
194
- s.mockRoute = true;
195
- await page.route("**/*", async (route) => {
196
- const req = route.request();
197
- const type = req.resourceType();
198
- const rule = (type === "fetch" || type === "xhr") && (s.mocks ?? []).find((m) => (!m.method || m.method === req.method()) && m.match(req.url()));
199
- if (!rule) return route.fallback();
200
- if (rule.delayMs) await new Promise((r) => setTimeout(r, rule.delayMs));
201
- // The app page may come from another origin, so the stub must pass the browser's CORS check.
202
- await route.fulfill({ status: rule.status, body: rule.body, headers: { "access-control-allow-origin": "*", ...rule.headers } }).catch(() => {});
203
- });
204
- }
383
+ await ensureNetworkRoute(s, page);
205
384
  break;
206
385
  }
207
- case "device_log": return { log: [...(await page.evaluate(() => globalThis.__pnDevice?.log() ?? [])), ...s.life.log], capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
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) };
208
408
  case "diag": {
209
409
  const r = await page.evaluate(async () => {
210
410
  const cfg = globalThis.__PN_SIM__ ?? {}; const out = { baseUrl: cfg.baseUrl, origin: location.origin };
@@ -214,17 +414,30 @@ async function act(id, body) {
214
414
  });
215
415
  return { diag: r, screen: await screen(page) };
216
416
  }
217
- case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); break;
417
+ case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); await syncMedia(page); s.shade.open = "none"; break;
218
418
  // Uncaught page errors since the last call to this action. Reading them clears the list.
219
419
  case "errors": return { errors: s.errors.splice(0), screen: await screen(page) };
220
- case "back": { 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; }
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; }
221
431
  case "screenshot": {
222
432
  // format "jpeg" has no alpha channel, which the stores require. inline: true also returns the bytes as base64 for clients on another machine.
223
433
  const jpeg = body.format === "jpeg"; const p = join(DIR, `${id}-${Date.now()}.${jpeg ? "jpg" : "png"}`);
224
- const buf = await page.screenshot(jpeg ? { path: p, type: "jpeg", quality: 90 } : { path: p });
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()); }
225
438
  return { path: p, ...(body.inline ? { data: buf.toString("base64") } : {}), screen: await screen(page) };
226
439
  }
227
- default: throw new Error(`unknown action '${body.action}'. Actions: screen, lock, unlock, restart, tap, type, scroll, back, screenshot, set_device, push_notification, notifications, open_notification, open_url, errors, device_log, device_reset, set_network_mocks`);
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"];
package/settings.mjs ADDED
@@ -0,0 +1,95 @@
1
+ // The phone's Settings screen: dark mode, font size, bold text, reduce motion, language and RTL.
2
+ // Pure: no DOM, no timers. The frame page draws the screen; the server uses the same rules for the MCP tools get_settings and set_setting.
3
+ // Each setting is a field of the simulated device (@shipvo/device), so the app sees it through Appearance, PixelRatio, AccessibilityInfo, I18nManager and Intl.
4
+
5
+ /** Typed error for a setting that cannot be read or changed. */
6
+ export class SettingsError extends Error { constructor(code, message) { super(message); this.code = code; } }
7
+
8
+ export const FONT_SIZES = ["small", "default", "large", "largest"];
9
+ export const LANGUAGES = { en: "English", fr: "Français", ar: "العربية" };
10
+
11
+ /** Setting key -> label, device field and kind. */
12
+ export const SETTINGS = {
13
+ dark_mode: { label: "Dark mode", field: "darkMode", kind: "boolean" },
14
+ font_size: { label: "Font size", field: "fontSize", kind: "choice", values: FONT_SIZES },
15
+ bold_text: { label: "Bold text", field: "boldText", kind: "boolean" },
16
+ reduce_motion: { label: "Reduce motion", field: "reduceMotion", kind: "boolean" },
17
+ language: { label: "Language", field: "language", kind: "choice", values: Object.keys(LANGUAGES) },
18
+ rtl: { label: "Right-to-left layout", field: "rtl", kind: "boolean" },
19
+ };
20
+ export const SETTING_KEYS = Object.keys(SETTINGS);
21
+
22
+ /** The settings as an object keyed by setting name, from the device's `system()`. */
23
+ export function readSettings(device = {}) {
24
+ const defaults = { dark_mode: false, font_size: "default", bold_text: false, reduce_motion: false, language: "en", rtl: false };
25
+ return Object.fromEntries(SETTING_KEYS.map((k) => [k, device[SETTINGS[k].field] ?? defaults[k]]));
26
+ }
27
+
28
+ /** The `set_device` patch for one setting. Setting the language to Arabic turns RTL on, and to any other language turns it off. Throws SettingsError. */
29
+ export function settingPatch(key, value) {
30
+ const s = Object.prototype.hasOwnProperty.call(SETTINGS, key) ? SETTINGS[key] : null;
31
+ if (!s) throw new SettingsError("unknown_setting", `unknown setting '${key}'. Settings: ${SETTING_KEYS.join(", ")}`);
32
+ if (s.kind === "boolean") {
33
+ if (typeof value !== "boolean") throw new SettingsError("bad_value", `'${key}' takes true or false`);
34
+ return { [s.field]: value };
35
+ }
36
+ if (typeof value !== "string" || !s.values.includes(value)) throw new SettingsError("bad_value", `'${key}' takes one of: ${s.values.join(", ")}`);
37
+ return key === "language" ? { language: value, rtl: value === "ar" } : { [s.field]: value };
38
+ }
39
+
40
+ /** Plain-text view of the Settings screen for an agent: one row per setting. */
41
+ export function describeSettings(values) {
42
+ const lines = ["Settings"];
43
+ for (const k of SETTING_KEYS) { const s = SETTINGS[k], v = values[k]; lines.push(`- ${s.kind === "boolean" ? "switch" : "choice"} ${k} (${s.label}): ${s.kind === "boolean" ? (v ? "on" : "off") : v}`); }
44
+ return lines.join("\n");
45
+ }
46
+
47
+ /** Markup for the Settings screen. Hidden until opened. The rows are drawn by the client script. */
48
+ export function settingsHtml() {
49
+ return `<div class="sf-settings" data-part="settings-screen" role="dialog" aria-label="Settings" tabindex="-1" hidden><h2 class="sf-sh">Settings</h2><div data-part="settings-rows" role="group" aria-label="Settings"></div><button type="button" class="sf-close" data-part="settings-close">Close</button></div>`;
50
+ }
51
+ /** The Settings icon for the home screen. */
52
+ export function settingsIconHtml() {
53
+ return `<button type="button" class="sf-icon" data-part="settings-icon" aria-label="Settings" style="left:124px"><b>⚙</b><span>Settings</span></button>`;
54
+ }
55
+ /** The gear for the shade panels. */
56
+ export function gearHtml() {
57
+ return `<button type="button" class="sf-gear" data-part="settings-gear" aria-label="Settings">⚙ Settings</button>`;
58
+ }
59
+
60
+ export const settingsCss = [
61
+ `.sf-settings{position:absolute;inset:0;z-index:8;overflow:auto;padding:48px 14px 14px;background:#10151c;color:#fff;font:15px system-ui,sans-serif}.sf-settings[hidden]{display:none}.sf-sh{margin:0 0 12px;font-size:22px}`,
62
+ `.sf-srow{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;box-sizing:border-box;margin:6px 0;padding:12px;border:0;border-radius:14px;background:#2c3036;color:#fff;font:inherit;text-align:start}.sf-srow[role=switch]::after{content:"Off";opacity:.7}.sf-srow[aria-checked=true]::after{content:"On";color:#4da3ff;font-weight:700}`,
63
+ `.sf-group2{margin:6px 0;padding:8px 12px;border-radius:14px;background:#2c3036}.sf-group2 b{display:block;margin-bottom:6px}.sf-opts{display:flex;flex-wrap:wrap;gap:6px}.sf-opt{padding:8px 12px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff;font:inherit}.sf-opt[aria-checked=true]{background:#4da3ff;color:#001;border-color:#4da3ff}`,
64
+ `.sf-gear{margin:0 8px 8px 0;padding:8px 16px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff}`,
65
+ `.sf-settings :focus-visible,.sf-gear:focus-visible{outline:3px solid #4da3ff;outline-offset:2px}`,
66
+ ].join("\n");
67
+
68
+ /** Browser script for the frame page: opens Settings from the home icon or the shade gear, draws the rows, and writes changes to the app's `__pnDevice`. */
69
+ export function settingsClientScript(iframeId = "app") {
70
+ return `(function(){
71
+ var SETTINGS=${JSON.stringify(SETTINGS)},SETTING_KEYS=${JSON.stringify(SETTING_KEYS)},LANGS=${JSON.stringify(LANGUAGES)},FONT=${JSON.stringify(FONT_SIZES)},settingPatch=${settingPatch.toString()},readSettings=${readSettings.toString()};
72
+ function SettingsError(c,m){var e=new Error(m);e.code=c;return e}
73
+ var r=document.querySelector('[data-part="phone-body"]');if(!r)return;
74
+ var q=function(p){return r.querySelector('[data-part="'+p+'"]')},sc=q("settings-screen");if(!sc)return;
75
+ function dev(){var f=document.getElementById(${JSON.stringify(iframeId)});var w=f&&f.contentWindow;return w&&w.__pnDevice&&w.__pnDevice.system?w.__pnDevice:null}
76
+ function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]})}
77
+ var SIZE={small:"Small",default:"Default",large:"Large",largest:"Largest"};
78
+ function draw(){var d=dev(),rows=q("settings-rows");if(!d||!rows||sc.hidden)return;var v=readSettings(d.system()),h="";
79
+ SETTING_KEYS.forEach(function(k){var s=SETTINGS[k];
80
+ if(s.kind==="boolean")h+='<button type="button" class="sf-srow" role="switch" data-setting="'+k+'" aria-checked="'+(!!v[k])+'">'+esc(s.label)+'</button>';
81
+ else{h+='<div class="sf-group2" role="radiogroup" aria-label="'+esc(s.label)+'"><b>'+esc(s.label)+'</b><div class="sf-opts">'+s.values.map(function(o){return '<button type="button" class="sf-opt" role="radio" data-setting="'+k+'" data-value="'+o+'" aria-checked="'+(v[k]===o)+'">'+esc(k==="language"?LANGS[o]:SIZE[o])+'</button>'}).join("")+'</div></div>'}});
82
+ var keep=document.activeElement&&document.activeElement.getAttribute&&document.activeElement.getAttribute("data-setting"),kv=document.activeElement&&document.activeElement.getAttribute&&document.activeElement.getAttribute("data-value");
83
+ rows.innerHTML=h;if(keep){var again=rows.querySelector('[data-setting="'+keep+'"]'+(kv?'[data-value="'+kv+'"]':''));if(again)again.focus({preventScroll:true})}}
84
+ var back=null;
85
+ function show(on){if(on){back=document.activeElement;sc.hidden=false;draw();sc.focus({preventScroll:true});try{window.parent.postMessage({type:"pn:settings-open",open:true},"*")}catch(x){}}else{sc.hidden=true;if(back&&back.focus)back.focus({preventScroll:true});back=null}}
86
+ r.addEventListener("click",function(e){var t=e.target;if(!t.closest)return;
87
+ if(t.closest('[data-part="settings-icon"],[data-part="settings-gear"]')){show(true);return}
88
+ if(t.closest('[data-part="settings-close"]')){show(false);return}
89
+ var row=t.closest("[data-setting]");if(row){var d=dev();if(!d)return;var k=row.getAttribute("data-setting"),s=SETTINGS[k],val=s.kind==="boolean"?row.getAttribute("aria-checked")!=="true":row.getAttribute("data-value");
90
+ try{d.set(settingPatch(k,val))}catch(x){}draw()}});
91
+ document.addEventListener("keydown",function(e){if(e.key==="Escape"&&!sc.hidden)show(false)});
92
+ var f=document.getElementById(${JSON.stringify(iframeId)});function wire(){var w=f&&f.contentWindow;if(w&&!w.__shipvoSettingsHooked){w.__shipvoSettingsHooked=true;w.addEventListener("pn:system",draw)}}
93
+ if(f){f.addEventListener("load",wire);wire()}
94
+ })();`;
95
+ }
@@ -0,0 +1,52 @@
1
+ // The server side of the notification shade, Notification Center and Control Center: MCP actions that read and change
2
+ // the simulated device in the page. `shade` is per-session state `{ open }`. Errors are ShadeError with a typed `code`.
3
+ import { ShadeError, TILES, tileStates, tilePatch, describePanel } from "./shade.mjs";
4
+
5
+ const read = async (page) => page.evaluate(() => {
6
+ const d = globalThis.__pnDevice; if (!d) return null;
7
+ return { platform: d.state.platform, settings: d.settings(), notifications: d.notifications() };
8
+ });
9
+ const need = (info) => { if (!info) throw new ShadeError("no_device", "the app has no simulated device in this session"); return info; };
10
+ const text = (info, shade) => describePanel(info.platform, shade.open, info.settings, info.notifications);
11
+ const view = (info, shade) => ({ panel: shade.open, tiles: shade.open === "notifications" ? [] : tileStates(info.platform, info.settings, shade.open !== "shade"), notifications: info.notifications, screen: text(info, shade) });
12
+
13
+ /** Dark mode reaches the app as its colour scheme (prefers-color-scheme). */
14
+ export async function applyScheme(page, dark) { await page.emulateMedia({ colorScheme: dark ? "dark" : "light" }); }
15
+
16
+ /** Runs open_notifications, open_control_center, toggle_tile, dismiss_notification, close_panels. Returns the result fields. */
17
+ export async function shadeAction(page, shade, body) {
18
+ const info = need(await read(page));
19
+ switch (body.action) {
20
+ case "open_notifications": {
21
+ // Android: the first pull shows quick-settings tiles and notifications, the second expands all tiles. iPhone: Notification Center.
22
+ shade.open = info.platform === "ios" ? "notifications" : (shade.open === "shade" || shade.open === "shade-expanded" || body.expand) ? "shade-expanded" : "shade";
23
+ return view(info, shade);
24
+ }
25
+ case "open_control_center": {
26
+ if (info.platform !== "ios") throw new ShadeError("no_control_center", "an Android phone has no Control Center: its quick-settings tiles are in the notification shade (open_notifications)");
27
+ shade.open = "control"; return view(info, shade);
28
+ }
29
+ case "close_panels": shade.open = "none"; return view(info, shade);
30
+ case "toggle_tile": {
31
+ if (typeof body.tile !== "string" || !body.tile) throw new ShadeError("bad_tile", "toggle_tile needs a tile, such as wifi or dark_mode");
32
+ const patch = tilePatch(info.platform, body.tile, info.settings, body.value); // unknown tile and bad value fail first
33
+ const visible = shade.open === "none" || shade.open === "notifications" ? [] : tileStates(info.platform, info.settings, shade.open !== "shade").map((t) => t.id);
34
+ if (!visible.includes(body.tile)) throw new ShadeError("tile_not_visible", info.platform === "ios" ? `open_control_center first: '${body.tile}' is in Control Center` : `open_notifications first (twice to expand all tiles): '${body.tile}' is not showing`);
35
+ await page.evaluate((p) => globalThis.__pnDevice.set(p), patch);
36
+ if ("darkMode" in patch) await applyScheme(page, patch.darkMode);
37
+ const next = need(await read(page)); return view(next, shade);
38
+ }
39
+ case "dismiss_notification": {
40
+ if (shade.open === "none" || shade.open === "control") throw new ShadeError("panel_closed", "open the notification shade first (open_notifications)");
41
+ if (body.all) { await page.evaluate(() => globalThis.__pnDevice.clearAll()); }
42
+ else {
43
+ const id = body.id ?? info.notifications.at(-1)?.id;
44
+ if (!id || !(await page.evaluate((i) => globalThis.__pnDevice.dismiss(i), id))) throw new ShadeError("no_such_notification", id ? `no notification '${id}' in the shade` : "no notifications to dismiss");
45
+ }
46
+ return view(need(await read(page)), shade);
47
+ }
48
+ }
49
+ throw new ShadeError("unknown_action", body.action);
50
+ }
51
+ export const SHADE_ACTIONS = ["open_notifications", "open_control_center", "close_panels", "toggle_tile", "dismiss_notification"];
52
+ export { TILES };