@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/guide.mjs ADDED
@@ -0,0 +1,44 @@
1
+ // Lumi's first-run guide on the Shipvo Phone: three short steps (home screen, the Store, connecting an assistant).
2
+ // It can be skipped, and once it is finished or skipped it never shows again on this browser. It sits in the phone frame
3
+ // over the bottom of the screen, never in the app's own page, so the simulator's screenshots and screen tree do not see it.
4
+ // It is drawn only when Lumi is drawn and the page asks for it (`guide: true`).
5
+
6
+ export const GUIDE_KEY = "shipvo.lumi.guide";
7
+
8
+ export const GUIDE_STEPS = [
9
+ { title: "Your home screen", text: "Go home to see your apps. Go home at any time to come back." },
10
+ { title: "The Store", text: "The Store icon on the home screen installs more apps on this phone." },
11
+ { title: "Connect an assistant", text: "To let an AI assistant drive this phone, run: claude mcp add shipvo -- npx -y @shipvo/mcp" },
12
+ ];
13
+
14
+ /** Markup for the guide. Hidden until the page script finds that this browser has not seen it. */
15
+ export function guideHtml() {
16
+ const steps = GUIDE_STEPS.map((s, i) => `<div class="sf-guide-step" data-step="${i + 1}"${i ? " hidden" : ""}><b>${s.title}</b><p>${s.text.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</p></div>`).join("");
17
+ return `<div class="sf-guide" data-part="lumi-guide" role="dialog" aria-live="polite" aria-label="Welcome to Shipvo Phone" hidden>`
18
+ + `<span class="sf-guide-count" data-part="guide-count">1 of ${GUIDE_STEPS.length}</span>${steps}`
19
+ + `<div class="sf-guide-actions"><button type="button" data-part="guide-skip">Skip</button><button type="button" data-part="guide-next">Next</button></div></div>`;
20
+ }
21
+
22
+ export const guideCss = [
23
+ `.sf-guide{position:absolute;left:12px;right:12px;bottom:40px;z-index:4;box-sizing:border-box;padding:12px 14px;border-radius:16px;background:#10151c;color:#fff;border:1px solid #7fd6f5aa;box-shadow:0 0 14px #7fd6f555,0 6px 20px #0008;font:14px/1.4 system-ui,sans-serif}`,
24
+ `.sf-guide[hidden],.sf-guide [hidden]{display:none}`,
25
+ `.sf-guide p{margin:4px 0 0;overflow-wrap:anywhere}.sf-guide-count{float:right;font-size:11px;opacity:.7}`,
26
+ `.sf-guide-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:10px}`,
27
+ `.sf-guide button{padding:6px 16px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff;font:600 13px system-ui,sans-serif;cursor:pointer}`,
28
+ `.sf-guide [data-part=guide-next]{background:#7fd6f5;border-color:#7fd6f5;color:#10151c}.sf-guide button:focus-visible{outline:3px solid #4da3ff;outline-offset:2px}`,
29
+ ].join("\n");
30
+
31
+ /** Browser script. Shows the guide on first launch, steps through it, and remembers a finish or a skip in localStorage. */
32
+ export function guideClientScript() {
33
+ return `(function(){var K=${JSON.stringify(GUIDE_KEY)},g=document.querySelector('[data-part="lumi-guide"]');if(!g)return;
34
+ function seen(){try{return localStorage.getItem(K)==="done"}catch(x){return false}}
35
+ function save(){try{localStorage.setItem(K,"done")}catch(x){}}
36
+ var s=g.querySelectorAll(".sf-guide-step"),i=0,c=g.querySelector('[data-part="guide-count"]'),n=g.querySelector('[data-part="guide-next"]');
37
+ function lumi(st){if(window.shipvoLumi)window.shipvoLumi.set(st)}
38
+ function draw(){for(var j=0;j<s.length;j++)s[j].hidden=j!==i;c.textContent=(i+1)+" of "+s.length;n.textContent=i===s.length-1?"Done":"Next"}
39
+ function close(happy){save();g.hidden=true;lumi(happy?"happy":"idle")}
40
+ g.querySelector('[data-part="guide-skip"]').addEventListener("click",function(){close(false)});
41
+ n.addEventListener("click",function(){if(i>=s.length-1)return close(true);i++;draw()});
42
+ document.addEventListener("keydown",function(e){if(e.key==="Escape"&&!g.hidden)close(false)});
43
+ if(seen())return;draw();g.hidden=false})();`;
44
+ }
package/lifecycle.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // Lock, unlock and restart for the simulated phone. Pure state: no DOM, no timers.
2
+ // The server keeps one of these per session and logs each step; the frame page draws the lock screen and boot screen.
3
+
4
+ /** Typed error for a lifecycle action that cannot run now. */
5
+ export class LifecycleError extends Error { constructor(code, message) { super(message); this.code = code; } }
6
+
7
+ /** A new phone state: unlocked, never restarted. `reloads` counts cold starts of the app (the first load is 1). */
8
+ export function createLifecycle() {
9
+ return { locked: false, reloads: 1, restarts: 0, log: [] };
10
+ }
11
+
12
+ const note = (st, name, extra = {}) => { st.log.push({ at: Date.now(), name, source: "simulated", ...extra }); };
13
+
14
+ /** Power press. Locks an unlocked phone. Throws already_locked if it is locked. */
15
+ export function lock(st) {
16
+ if (st.locked) throw new LifecycleError("already_locked", "the phone is already locked: call unlock first");
17
+ st.locked = true; note(st, "lock"); return st;
18
+ }
19
+
20
+ /** Swipe up on the lock screen. Throws not_locked if the phone is unlocked. The app and its screen are untouched. */
21
+ export function unlock(st) {
22
+ if (!st.locked) throw new LifecycleError("not_locked", "the phone is not locked");
23
+ st.locked = false; note(st, "unlock"); return st;
24
+ }
25
+
26
+ /** Restart from the power menu: the app cold-starts (the caller reloads it), storage is kept, the phone ends unlocked. */
27
+ export function restart(st) {
28
+ st.locked = false; st.restarts++; st.reloads++; note(st, "restart", { reloads: st.reloads }); return st;
29
+ }
30
+
31
+ /** Throws a typed error when the phone is locked, so no input reaches the app behind the lock screen. */
32
+ export function assertUnlocked(st) {
33
+ if (st.locked) throw new LifecycleError("phone_locked", "the phone is locked, so nothing was sent to the app. Call unlock first.");
34
+ }
35
+
36
+ /** Lock screen markup (time and a swipe hint). `time` is plain text such as "9:41". */
37
+ export function lockScreenHtml(time = "9:41") {
38
+ const t = String(time).replace(/[<>&"'\\]/g, "").slice(0, 12);
39
+ return `<div class="sf-lock" data-part="lock-screen" role="dialog" aria-label="Lock screen" tabindex="-1" hidden><div class="sf-lock-time" data-part="lock-time">${t}</div><div class="sf-lock-hint">Swipe up to unlock</div><button type="button" data-part="unlock-button">Unlock</button></div>`;
40
+ }
41
+
42
+ /** Boot screen markup: the Shipvo name on black. Shown for a moment during restart. */
43
+ export function bootScreenHtml() {
44
+ return `<div class="sf-boot" data-part="boot-screen" hidden><div class="sf-boot-logo">Shipvo</div></div>`;
45
+ }
46
+
47
+ /** Screen shown after "Power off": black. A click on it, or the power button, turns the phone on again. */
48
+ export function offScreenHtml() {
49
+ return `<div class="sf-off" data-part="off-screen" role="button" tabindex="0" aria-label="Phone is off. Press to power on" hidden></div>`;
50
+ }
package/lumi.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Lumi, the glowing orb, drawn in the phone frame beside the camera cutout. Our own artwork.
2
+ // Lumi lives in the frame's status bar, never in the app's screen: it is not inside `.sf-content`, it is hidden from
3
+ // assistive tech and the labelled screen tree, and it takes no pointer input. The simulator's own page (screens, screenshots,
4
+ // recordings for agents) never draws it, so agents and tests see the same app with Lumi on or off.
5
+
6
+ export const LUMI_STATES = ["idle", "working", "happy", "worried"];
7
+
8
+ /** True when Lumi is drawn by default. SHIPVO_LUMI=off|0|false|no hides it anywhere; on|1|true|yes shows it. With no setting it is off in CI and on otherwise. */
9
+ export function lumiDefault(env = typeof process !== "undefined" ? process.env : {}) {
10
+ const v = String(env.SHIPVO_LUMI ?? "").trim().toLowerCase();
11
+ if (["off", "0", "false", "no"].includes(v)) return false;
12
+ if (["on", "1", "true", "yes"].includes(v)) return true;
13
+ const ci = String(env.CI ?? "").trim().toLowerCase();
14
+ return !(ci && ci !== "0" && ci !== "false");
15
+ }
16
+
17
+ const FACE = {
18
+ idle: `<path d="M8.6 15.4q2.4 1.6 4.8 0" fill="none" stroke="#10151c" stroke-width="1.3" stroke-linecap="round"/>`,
19
+ working: `<ellipse cx="11" cy="15.6" rx="1.3" ry="1.1" fill="#10151c"/>`,
20
+ happy: `<path d="M8 14.6q3 3.6 6 0z" fill="#10151c"/>`,
21
+ worried: `<path d="M8.8 16.6q2.2-1.8 4.4 0" fill="none" stroke="#10151c" stroke-width="1.3" stroke-linecap="round"/><path d="M6.6 8.2l3 .8M15.4 8.2l-3 .8" stroke="#10151c" stroke-width="1" stroke-linecap="round"/>`,
22
+ };
23
+
24
+ /**
25
+ * What Lumi shows for a result. `event` is { kind, error?, name?, detail? }:
26
+ * check-passed and flow-done give happy; page-error, crash, action-failed and check-failed give worried.
27
+ * The reason is one line taken from the real error or result (first line, spaces collapsed, at most 90 characters).
28
+ * Anything else, or an unusable event, returns null and Lumi stays as it is. Pure and self-contained, so the page can run it.
29
+ */
30
+ export function lumiReaction(event) {
31
+ if (!event || typeof event !== "object") return null;
32
+ const one = (v) => {
33
+ const t = String(v && v.message ? v.message : v == null ? "" : v).split(/\r?\n/).map((x) => x.replace(/\s+/g, " ").trim()).find(Boolean) || "";
34
+ return t.length > 90 ? t.slice(0, 89) + "…" : t;
35
+ };
36
+ const name = one(event.name), err = one(event.error), detail = one(event.detail);
37
+ const why = (fallback) => err || detail || fallback;
38
+ switch (event.kind) {
39
+ case "check-passed": return { state: "happy", reason: name ? "Check passed: " + name : "Check passed" };
40
+ case "flow-done": return { state: "happy", reason: name ? "Flow finished: " + name : "Flow finished" };
41
+ case "page-error": return { state: "worried", reason: "Page error: " + why("the page threw an error") };
42
+ case "crash": return { state: "worried", reason: "Crash: " + why("the app stopped") };
43
+ case "action-failed": return { state: "worried", reason: (name ? "Action failed (" + name + "): " : "Action failed: ") + why("no reason given") };
44
+ case "check-failed": return { state: "worried", reason: (name ? "Check failed (" + name + "): " : "Check failed: ") + why("no reason given") };
45
+ default: return null;
46
+ }
47
+ }
48
+
49
+ /** Markup for Lumi. `state` is one of idle, working, happy, worried. */
50
+ export function lumiHtml(state = "idle") {
51
+ const st = LUMI_STATES.includes(state) ? state : "idle";
52
+ const faces = LUMI_STATES.map((s) => `<g data-face="${s}"${s === st ? "" : " hidden"}>${FACE[s]}</g>`).join("");
53
+ return `<span class="sf-lumi" data-part="lumi" data-state="${st}" aria-hidden="true" inert>`
54
+ + `<svg viewBox="0 0 22 22" width="22" height="22" focusable="false"><defs><radialGradient id="sf-lumi-g" cx="40%" cy="35%" r="70%"><stop offset="0" stop-color="#e9fbff"/><stop offset=".6" stop-color="#7fd6f5"/><stop offset="1" stop-color="#3b9ad9"/></radialGradient></defs>`
55
+ + `<circle cx="11" cy="11" r="10" fill="url(#sf-lumi-g)"/>`
56
+ + `<g class="sf-lumi-eyes"><ellipse cx="7.8" cy="11" rx="1.4" ry="1.9" fill="#10151c"/><ellipse cx="14.2" cy="11" rx="1.4" ry="1.9" fill="#10151c"/></g>${faces}</svg></span>`;
57
+ }
58
+
59
+ /** The caption pill Lumi shows for the agent's current step. Hidden until a step is shown. Outside the app's screen tree like Lumi. */
60
+ export const lumiCaptionHtml = () => `<span class="sf-lumi-cap" data-part="lumi-caption" aria-hidden="true" inert hidden></span>`;
61
+
62
+ /** Style for Lumi. `top` and `offset` place it beside the cutout. Motion stops for people who ask for less of it. */
63
+ export const lumiCss = (k) => [
64
+ // The glow must never paint over the app's screen below the status bar.
65
+ `.sf-status{overflow:hidden}`,
66
+ `.sf-lumi{position:absolute;left:50%;top:${Math.round((k.status - 22) / 2)}px;margin-left:${k.part === "dynamic-island" ? 66 : 20}px;width:22px;height:22px;pointer-events:none;z-index:3;animation:sf-lumi-float 3.6s ease-in-out infinite}`,
67
+ `.sf-lumi svg{display:block;border-radius:50%;box-shadow:0 0 6px 1px #7fd6f5aa;animation:sf-lumi-glow 2.8s ease-in-out infinite}`,
68
+ `.sf-lumi-eyes{transform-box:fill-box;transform-origin:center;animation:sf-lumi-blink 4.5s infinite}`,
69
+ `.sf-lumi [hidden]{display:none}`,
70
+ `.sf-lumi[data-state=working] svg{animation-duration:1.1s}.sf-lumi[data-state=working]{animation-duration:1.6s}`,
71
+ `.sf-lumi[data-state=happy] svg{box-shadow:0 0 9px 3px #ffe08aaa}.sf-lumi[data-state=worried] svg{box-shadow:0 0 7px 2px #ff9a8aaa}`,
72
+ `.sf-lumi-cap{position:absolute;left:50%;top:${k.status + 6}px;transform:translateX(-50%);max-width:80%;box-sizing:border-box;padding:4px 12px;border-radius:14px;background:#10151ce6;color:#fff;font:600 12px system-ui,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;z-index:4}.sf-lumi-cap[hidden]{display:none}`,
73
+ `@keyframes sf-lumi-float{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}`,
74
+ `@keyframes sf-lumi-glow{0%,100%{opacity:.88}50%{opacity:1;filter:brightness(1.12)}}`,
75
+ `@keyframes sf-lumi-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.1)}}`,
76
+ `@media (prefers-reduced-motion:reduce){.sf-lumi,.sf-lumi svg,.sf-lumi-eyes{animation:none}}`,
77
+ ].join("\n");
78
+
79
+ /**
80
+ * Browser script. `window.shipvoLumi.set(state, reason?)` changes Lumi's state; `react(event)` picks the state and reason from a result (see lumiReaction).
81
+ * A `{ type: "pn:lumi", state, reason? }` or `{ type: "pn:lumi", event }` message does the same. The reason sits in Lumi's `data-reason` and `title`
82
+ * and is cleared when the state has no reason.
83
+ * `window.shipvoLumi.say(text, state, holdMs)` (or `{ type: "pn:lumi", state, caption, hold }`) also shows a short caption.
84
+ * With `holdMs` > 0 the caption goes away and Lumi goes back to idle after that long. Touches only Lumi and its caption.
85
+ */
86
+ export function lumiClientScript() {
87
+ return `(function(){var S=${JSON.stringify(LUMI_STATES)},T=0;var lumiReaction=${lumiReaction.toString()};
88
+ function set(state,reason){if(S.indexOf(state)<0)return false;var l=document.querySelector('[data-part="lumi"]');if(!l)return false;l.setAttribute("data-state",state);
89
+ if(typeof reason==="string"&&reason){l.setAttribute("data-reason",reason);l.setAttribute("title",reason)}else{l.removeAttribute("data-reason");l.removeAttribute("title")}
90
+ var g=l.querySelectorAll("[data-face]");for(var i=0;i<g.length;i++)g[i].hidden=g[i].getAttribute("data-face")!==state;return true}
91
+ function react(ev){var r=lumiReaction(ev);if(!r)return null;return set(r.state,r.reason)?r:null}
92
+ function say(text,state,hold){clearTimeout(T);var c=document.querySelector('[data-part="lumi-caption"]');var ok=set(state||"working");
93
+ if(c){var t=String(text==null?"":text).replace(/\\s+/g," ").trim().slice(0,80);c.textContent=t;c.hidden=!t}
94
+ if(hold>0)T=setTimeout(function(){if(c){c.hidden=true;c.textContent=""}set("idle")},hold);return ok}
95
+ if(typeof window==="undefined")return;window.shipvoLumi={set:set,react:react,say:say,states:S};
96
+ window.addEventListener("message",function(e){var d=e.data;if(!d||d.type!=="pn:lumi")return;if(d.event)react(d.event);else if(typeof d.caption==="string")say(d.caption,d.state,Number(d.hold)||0);else set(d.state,d.reason)})})();`;
97
+ }
package/nav.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // Navigation and lifecycle for the simulated phone: back, home, recents, kill. Pure state plus markup.
2
+ // The app sees these as AppState changes: react-native-web reads document.visibilityState, so "background" means hidden.
3
+
4
+ /** Typed error for a navigation action that cannot run now. */
5
+ export class NavError extends Error { constructor(code, message) { super(message); this.code = code; } }
6
+
7
+ /** Where the phone is: the app in front ("app"), the home screen ("home") or the app switcher ("recents"). */
8
+ export function createNav() {
9
+ return { where: "app", kills: 0, log: [] };
10
+ }
11
+
12
+ const note = (st, name) => { st.log.push({ at: Date.now(), name, source: "simulated" }); };
13
+
14
+ /** Home: app to background. From recents it also goes home. Returns the AppState the app should see ("background"), or null if nothing changes. */
15
+ export function pressHome(st) {
16
+ const was = st.where; st.where = "home"; note(st, "press_home");
17
+ return was === "app" ? "background" : null;
18
+ }
19
+
20
+ /** Recents: the app switcher card. From the app this backgrounds it. */
21
+ export function openRecents(st) {
22
+ const was = st.where; st.where = "recents"; note(st, "open_recents");
23
+ return was === "app" ? "background" : null;
24
+ }
25
+
26
+ /** Tap the app icon or its recents card. Returns "active" when the app comes back. Throws if the app is already in front. */
27
+ export function resume(st) {
28
+ if (st.where === "app") throw new NavError("app_in_foreground", "the app is already in front");
29
+ st.where = "app"; note(st, "resume"); return "active";
30
+ }
31
+
32
+ /** Swipe the recents card away. The app is killed; the next open is a cold start. Throws unless the switcher is open. */
33
+ export function killApp(st) {
34
+ if (st.where !== "recents") throw new NavError("recents_not_open", "open the app switcher first (open_recents), then kill_app");
35
+ st.where = "home"; st.kills++; note(st, "kill_app"); return st;
36
+ }
37
+
38
+ /** Throws app_not_foreground while the home screen or the app switcher is showing, so no input reaches a backgrounded app. */
39
+ export function assertForeground(st) {
40
+ if (st.where !== "app") throw new NavError("app_not_foreground", `the phone is on the ${st.where === "home" ? "home screen" : "app switcher"}, so nothing was sent to the app. Open the app first (resume_app).`);
41
+ }
42
+
43
+ /**
44
+ * Classify a swipe on the bottom bar or the left edge.
45
+ * s: { skin, fromEdge: "bottom"|"left"|null, dx, dy, ms } (dy negative is up).
46
+ * Returns "home", "recents", "switch-left", "switch-right", "back" or null.
47
+ */
48
+ export function classifySwipe({ skin = "android", fromEdge = null, dx = 0, dy = 0, ms = 0 } = {}) {
49
+ if (fromEdge === "left" && dx > 60 && Math.abs(dy) < 80) return "back";
50
+ if (fromEdge !== "bottom") return null;
51
+ if (dy < -60 && Math.abs(dx) < Math.abs(dy)) return ms >= 400 ? "recents" : "home";
52
+ if (skin === "ios" && Math.abs(dx) > 60 && Math.abs(dy) < 40) return dx > 0 ? "switch-right" : "switch-left";
53
+ return null;
54
+ }
55
+
56
+ /** Runs in the app page. Sets what AppState reads and fires the event. */
57
+ export function setVisibilityInPage(state) {
58
+ const hidden = state === "background";
59
+ try {
60
+ Object.defineProperty(document, "visibilityState", { configurable: true, get: () => (hidden ? "hidden" : "visible") });
61
+ Object.defineProperty(document, "hidden", { configurable: true, get: () => hidden });
62
+ } catch {}
63
+ document.dispatchEvent(new Event("visibilitychange"));
64
+ }
65
+
66
+ /** Home screen markup: the app icon. Hidden until the phone goes home. */
67
+ export function homeScreenHtml(appName = "App") {
68
+ const n = String(appName).replace(/[<>&"'\\]/g, "").slice(0, 40) || "App";
69
+ return `<div class="sf-home" data-part="home-screen" role="region" aria-label="Home screen" hidden><button type="button" class="sf-icon" data-part="app-icon" aria-label="${n}"><b>${n.slice(0, 1).toUpperCase()}</b><span>${n}</span></button></div>`;
70
+ }
71
+
72
+ /** App switcher markup: one card for the app. Click the card to open it, the close button (or swipe it up) to kill the app. */
73
+ export function recentsHtml(appName = "App") {
74
+ const n = String(appName).replace(/[<>&"'\\]/g, "").slice(0, 40) || "App";
75
+ return `<div class="sf-recents" data-part="recents" role="region" aria-label="Recent apps" hidden><div class="sf-card" data-part="recents-card"><button type="button" data-part="recents-open" aria-label="Open ${n}">${n}</button><button type="button" data-part="recents-close" aria-label="Close ${n}">Close</button></div></div>`;
76
+ }
77
+
78
+ /** The three buttons of Android 3-button navigation. */
79
+ export function navButtonsHtml() {
80
+ return `<div class="sf-navbtns" data-part="nav-buttons" hidden><button type="button" data-part="nav-back" aria-label="Back">◀</button><button type="button" data-part="nav-home" aria-label="Home">●</button><button type="button" data-part="nav-recents" aria-label="Recents">■</button></div>`;
81
+ }
82
+
83
+ /** CSS for the home screen and the app switcher. Colours follow the theme (data-theme on the phone body, or on any parent). Dark is the default. */
84
+ export function navScreensCss() {
85
+ return [
86
+ `.sf-home,.sf-recents{position:absolute;inset:0;z-index:4;background:linear-gradient(160deg,#1d3557,#0b1320);color:#fff;font-family:system-ui,sans-serif}`,
87
+ `[data-theme="light"] .sf-home{background:linear-gradient(160deg,#dbe7f5,#f4f7fb);color:#111}`,
88
+ `.sf-icon{position:absolute;left:28px;top:70px;width:76px;padding:0;border:0;background:none;color:inherit;display:flex;flex-direction:column;align-items:center;gap:6px;font:600 12px system-ui,sans-serif;cursor:pointer}.sf-icon b{display:flex;width:60px;height:60px;border-radius:16px;background:#f4a261;color:#102030;align-items:center;justify-content:center;font-size:28px}`,
89
+ `.sf-recents{display:flex;align-items:center;justify-content:center;background:#10151cee;color:#fff}[data-theme="light"] .sf-recents{background:#eef1f5ee;color:#111}`,
90
+ `.sf-card{width:230px;height:420px;border-radius:20px;background:#fff;color:#111;border:1px solid #8884;display:flex;flex-direction:column;justify-content:space-between;align-items:center;padding:14px;box-sizing:border-box;transition:transform .2s}[data-theme="dark"] .sf-card{background:#1c232d;color:#fff}.sf-card button{border:0;border-radius:10px;padding:8px 18px;background:#8883;color:inherit;font:600 14px system-ui,sans-serif;cursor:pointer}`,
91
+ ].join("");
92
+ }
93
+
94
+ /** The home screen or switcher as plain markup, shown (not hidden), for a screenshot of a headless session. */
95
+ export function awayScreenHtml(where, appName = "App") {
96
+ const inner = (where === "home" ? homeScreenHtml(appName) : recentsHtml(appName)).replace(" hidden>", ">");
97
+ return `<style>${navScreensCss()}</style><div data-theme="dark" style="position:fixed;inset:0;z-index:2147483647">${inner}</div>`;
98
+ }
@@ -0,0 +1,84 @@
1
+ // Network profiles for the simulator. Pure functions: no sim wiring.
2
+ //
3
+ // The numbers below are simulation settings, not measurements of any real
4
+ // network. `latencyMs` is a fixed wait per request, `downKbps` is the
5
+ // throughput used to turn bytes into wait time, and `failureRate` is the share
6
+ // of requests that fail with a network error (0 to 1).
7
+ //
8
+ // `low-end-4g` uses the download rate seen on the Android phone in
9
+ // docs/spike-results.md, section "Results so far": a 1.57 MB bundle in 1.4 to
10
+ // 2.0 s over 4G, which is about 6,300 kbps at the slow end. That section does
11
+ // not record a round-trip time, so its latency is a simulation setting.
12
+
13
+ export const PROFILES = Object.freeze({
14
+ online: Object.freeze({ latencyMs: 0, downKbps: Infinity, failureRate: 0 }),
15
+ offline: Object.freeze({ latencyMs: 0, downKbps: 0, failureRate: 1 }),
16
+ '2g': Object.freeze({ latencyMs: 800, downKbps: 50, failureRate: 0.02 }),
17
+ '3g': Object.freeze({ latencyMs: 300, downKbps: 750, failureRate: 0.01 }),
18
+ 'slow-4g': Object.freeze({ latencyMs: 150, downKbps: 1600, failureRate: 0 }),
19
+ spotty: Object.freeze({ latencyMs: 400, downKbps: 500, failureRate: 0.3 }),
20
+ 'low-end-4g': Object.freeze({ latencyMs: 100, downKbps: 6300, failureRate: 0.005 }),
21
+ });
22
+
23
+ export const PROFILE_NAMES = Object.freeze(Object.keys(PROFILES));
24
+
25
+ export function getProfile(name) {
26
+ if (typeof name !== 'string' || !Object.hasOwn(PROFILES, name)) {
27
+ const err = new Error(
28
+ `Unknown network profile "${String(name)}". Use one of: ${PROFILE_NAMES.join(', ')}.`,
29
+ );
30
+ err.code = 'unknown_profile';
31
+ throw err;
32
+ }
33
+ return PROFILES[name];
34
+ }
35
+
36
+ // mulberry32: small seeded PRNG, returns numbers in [0, 1).
37
+ function prng(seed) {
38
+ let a = seed >>> 0;
39
+ return () => {
40
+ a = (a + 0x6d2b79f5) >>> 0;
41
+ let t = a;
42
+ t = Math.imul(t ^ (t >>> 15), t | 1);
43
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
44
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
45
+ };
46
+ }
47
+
48
+ function bodyBytes(body) {
49
+ if (body == null) return 0;
50
+ if (typeof body === 'string') return new TextEncoder().encode(body).length;
51
+ if (typeof body.byteLength === 'number') return body.byteLength;
52
+ if (typeof body.size === 'number') return body.size;
53
+ return 0;
54
+ }
55
+
56
+ function transferMs(bytes, downKbps) {
57
+ if (!bytes || !Number.isFinite(downKbps)) return 0;
58
+ return (bytes * 8) / downKbps;
59
+ }
60
+
61
+ /**
62
+ * Wrap `fetchImpl` so requests behave as `profile` describes.
63
+ * Waits latencyMs + bytes*8/downKbps (request body bytes, then the response's
64
+ * content-length when present) through `clock.sleep(ms)`. Rejects with a
65
+ * network TypeError at `failureRate`, drawn from a PRNG seeded by `seed`.
66
+ */
67
+ export function applyProfile(fetchImpl, profile, { seed = 1, clock } = {}) {
68
+ if (profile === PROFILES.online) return fetchImpl;
69
+ const random = prng(seed);
70
+ const fail = () => new TypeError('fetch failed: network request failed');
71
+ return async function profiledFetch(input, init) {
72
+ if (profile.failureRate >= 1) throw fail();
73
+ // One draw per request keeps the failing set stable for a seed.
74
+ const failed = random() < profile.failureRate;
75
+ const reqBytes = bodyBytes(init?.body ?? input?.body);
76
+ await clock.sleep(profile.latencyMs + transferMs(reqBytes, profile.downKbps));
77
+ if (failed) throw fail();
78
+ const res = await fetchImpl(input, init);
79
+ const len = Number(res?.headers?.get?.('content-length'));
80
+ const extra = transferMs(Number.isFinite(len) ? len : 0, profile.downKbps);
81
+ if (extra > 0) await clock.sleep(extra);
82
+ return res;
83
+ };
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/sim",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "The Shipvo simulator: runs a Shipvo app's web build in a phone-sized headless browser and exposes it to agents (accessibility tree, tap/type/scroll, simulated device, mock backend). Used by @shipvo/mcp and `shipvo sim`.",
@@ -9,13 +9,38 @@
9
9
  },
10
10
  "exports": {
11
11
  "./server.mjs": "./server.mjs",
12
+ "./screen-rules.mjs": "./screen-rules.mjs",
13
+ "./network-profiles.mjs": "./network-profiles.mjs",
14
+ "./frame.mjs": {
15
+ "types": "./frame.d.mts",
16
+ "default": "./frame.mjs"
17
+ },
12
18
  "./package.json": "./package.json"
13
19
  },
14
20
  "files": [
15
21
  "server.mjs",
22
+ "chromium-hint.mjs",
23
+ "browser-resolver.mjs",
16
24
  "app-name.mjs",
25
+ "frame.mjs",
26
+ "lumi.mjs",
27
+ "guide.mjs",
28
+ "phone-browser.mjs",
29
+ "browser-app.mjs",
30
+ "ws-lite.mjs",
31
+ "lifecycle.mjs",
32
+ "shade.mjs",
33
+ "shade-actions.mjs",
34
+ "settings.mjs",
35
+ "settings-actions.mjs",
36
+ "nav.mjs",
37
+ "update.mjs",
38
+ "frame.d.mts",
17
39
  "mock-rules.mjs",
40
+ "screen-rules.mjs",
18
41
  "push-notification.mjs",
42
+ "network-profiles.mjs",
43
+ "apps.mjs",
19
44
  "README.md"
20
45
  ],
21
46
  "peerDependencies": {
@@ -0,0 +1,187 @@
1
+ // A real Chromium page behind the phone: one isolated browser context per phone, a mobile viewport and touch.
2
+ // Pages are untrusted web content. Private hosts are refused unless the caller opts in.
3
+ import { lookup } from "node:dns/promises";
4
+ import { isIP } from "node:net";
5
+
6
+ export const USER_AGENT_TAG = "ShipvoPhone";
7
+
8
+ export class BrowserError extends Error {
9
+ constructor(code, message) { super(message); this.code = code; }
10
+ }
11
+
12
+ function v4Private(a, b) {
13
+ return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
14
+ (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
15
+ }
16
+
17
+ /** True for loopback, link-local, RFC1918, CGNAT, `.local`, `.internal` and `localhost` names or addresses. */
18
+ export function isPrivateHost(host) {
19
+ let h = String(host).toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
20
+ if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal")) return true;
21
+ const kind = isIP(h);
22
+ if (kind === 4) { const [a, b] = h.split(".").map(Number); return v4Private(a, b); }
23
+ if (kind === 6) {
24
+ // IPv4-mapped (::ffff:a.b.c.d or ::ffff:7f00:1)
25
+ const m = /^::ffff:(?:(\d+)\.(\d+)\.(\d+)\.(\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/.exec(h);
26
+ if (m) {
27
+ if (m[1]) return v4Private(Number(m[1]), Number(m[2]));
28
+ const hi = parseInt(m[5], 16); return v4Private(hi >> 8, hi & 255);
29
+ }
30
+ if (h === "::" || h === "::1") return true;
31
+ const first = parseInt(h.split(":")[0] || "0", 16);
32
+ return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80; // fc00::/7 unique local, fe80::/10 link-local
33
+ }
34
+ return false;
35
+ }
36
+
37
+ /**
38
+ * Decides if a URL may be loaded. Throws BrowserError host_not_allowed. A public name that resolves to a
39
+ * private address is refused too, so a DNS record cannot reach the local network.
40
+ */
41
+ export async function checkUrl(raw, { allowPrivate = false, allowHosts = [] } = {}, resolve = (h) => lookup(h, { all: true })) {
42
+ let u; try { u = new URL(raw); } catch { throw new BrowserError("bad_url", `not a URL: ${raw}`); }
43
+ if (u.protocol === "about:" || u.protocol === "data:" || u.protocol === "blob:") return u;
44
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new BrowserError("bad_url", `only http and https pages can be opened, not ${u.protocol}`);
45
+ const host = u.hostname.toLowerCase();
46
+ if (allowPrivate || allowHosts.map((x) => x.toLowerCase()).includes(host)) return u;
47
+ const refuse = (why) => new BrowserError("host_not_allowed", `${host} is ${why}, so the phone browser did not open it. Pass allow_private: true, or allow_hosts: ["${host}"], to opt in.`);
48
+ if (isPrivateHost(host)) throw refuse("a private host");
49
+ if (!isIP(host.replace(/^\[|\]$/g, ""))) {
50
+ let addrs = []; try { addrs = await resolve(host); } catch {}
51
+ if (addrs.some((a) => isPrivateHost(a.address))) throw refuse("a name that points to a private address");
52
+ }
53
+ return u;
54
+ }
55
+
56
+ export class PhoneBrowser {
57
+ /** @param browser a Playwright Browser. @param opts {allowPrivate, allowHosts, device, resolve} */
58
+ constructor(browser, opts = {}) {
59
+ this.browser = browser; this.opts = { allowPrivate: !!opts.allowPrivate, allowHosts: opts.allowHosts ?? [] };
60
+ this.device = opts.device ?? {}; this.resolve = opts.resolve; this.navigations = [];
61
+ this.ctx = null; this.page = null; this.pages = []; this.refsFrom = null;
62
+ this.onDeepLink = opts.onDeepLink ?? null; // called with a link such as myapp://item/3 that a page tried to open
63
+ this.cdp = null; this.screencast = null; this.onChange = null;
64
+ }
65
+ async #start() {
66
+ if (this.ctx) { if (!this.page) await this.ctx.newPage(); return; }
67
+ const d = this.device;
68
+ this.ctx = await this.browser.newContext({ ...d, userAgent: `${d.userAgent ?? "Mozilla/5.0 (Linux; Android 11; Pixel 5)"} ${USER_AGENT_TAG}`, hasTouch: true, isMobile: true, viewport: d.viewport ?? { width: 393, height: 851 } });
69
+ // Every request, including redirects and sub-resources, goes through the same host check.
70
+ await this.ctx.route("**/*", async (route) => {
71
+ try { await checkUrl(route.request().url(), this.opts, this.resolve); } catch (e) {
72
+ if (route.request().isNavigationRequest()) this.#log(route.request().url(), false, e.code);
73
+ return route.abort("blockedbyclient");
74
+ }
75
+ return route.continue();
76
+ });
77
+ // A link to another app's scheme (myapp://...) cannot load in Chromium. Hand it to the phone, which gives it to the app.
78
+ await this.ctx.exposeFunction("__shipvoDeepLink", (u) => { if (isDeepLink(u)) { this.#log(String(u), true, undefined, "deep_link"); this.onDeepLink?.(String(u)); } });
79
+ await this.ctx.addInitScript(() => document.addEventListener("click", (e) => {
80
+ const a = e.target?.closest?.("a[href]"); if (!a) return;
81
+ const u = a.href; if (/^(https?|about|data|blob|javascript|file|mailto|tel):/i.test(u) || !/^[a-z][a-z0-9+.-]*:/i.test(u)) return;
82
+ e.preventDefault(); globalThis.__shipvoDeepLink(u);
83
+ }, true));
84
+ this.ctx.on("page", (p) => this.#adopt(p)); // a popup or target=_blank link is a new tab
85
+ await this.ctx.newPage();
86
+ }
87
+ #adopt(p) {
88
+ if (this.pages.includes(p)) return;
89
+ this.pages.push(p); this.page = p; this.refsFrom = null;
90
+ p.on("framenavigated", (f) => { if (f === p.mainFrame()) { this.#log(f.url(), true); this.onChange?.(); } });
91
+ p.on("close", () => { this.pages = this.pages.filter((x) => x !== p); if (this.page === p) { this.page = this.pages.at(-1) ?? null; this.refsFrom = null; if (this.page) this.#cast(); } this.onChange?.(); });
92
+ if (this.screencast) this.#cast();
93
+ }
94
+ #log(url, ok, code, kind = "browser_navigation") { this.navigations.push({ at: Date.now(), kind, detail: { url, allowed: ok, ...(code ? { code } : {}) }, source: "real", simulated: false }); }
95
+
96
+ async open(url) {
97
+ try { await checkUrl(url, this.opts, this.resolve); } catch (e) { this.#log(String(url), false, e.code); throw e; }
98
+ await this.#start();
99
+ try { await this.page.goto(url, { waitUntil: "load", timeout: 20000 }); }
100
+ catch (e) { throw new BrowserError(/blockedbyclient/i.test(e.message) ? "host_not_allowed" : "navigation_failed", String(e.message).split("\n")[0]); }
101
+ return this.snapshot();
102
+ }
103
+ #need() { if (!this.page) throw new BrowserError("no_page", "no page is open: call browser_open first"); return this.page; }
104
+ async snapshot() {
105
+ const page = this.#need();
106
+ await page.waitForTimeout(100);
107
+ const tree = await page.locator("body").ariaSnapshot({ mode: "ai" });
108
+ this.refsFrom = page.url();
109
+ return { source: "web", untrusted: true, url: page.url(), title: await page.title(), tree };
110
+ }
111
+ async #target(ref) {
112
+ const page = this.#need();
113
+ const stale = new BrowserError("stale_ref", `stale ref ${ref}: take a new browser_snapshot and use a ref from it.`);
114
+ if (!/^(?:f\d+)?e\d+$/.test(String(ref)) || this.refsFrom !== page.url()) throw stale;
115
+ const l = page.locator(`aria-ref=${ref}`);
116
+ if ((await l.count().catch(() => 0)) !== 1) throw stale;
117
+ return l;
118
+ }
119
+ async click(ref) { const l = await this.#target(ref); await l.click({ timeout: 3000 }); await this.page.waitForLoadState("load").catch(() => {}); return this.snapshot(); }
120
+ async type(ref, text, enter = false) {
121
+ const l = await this.#target(ref);
122
+ await l.fill(String(text ?? ""), { timeout: 3000 }); if (enter) await l.press("Enter");
123
+ await this.page.waitForLoadState("load").catch(() => {});
124
+ return this.snapshot();
125
+ }
126
+ async back() {
127
+ const page = this.#need();
128
+ if (!(await page.goBack({ timeout: 5000 }).catch(() => null))) throw new BrowserError("no_history", "there is no earlier page to go back to");
129
+ return this.snapshot();
130
+ }
131
+ async screenshot() { return this.#need().screenshot({ type: "png" }); }
132
+ async forward() {
133
+ const page = this.#need();
134
+ if (!(await page.goForward({ timeout: 5000 }).catch(() => null))) throw new BrowserError("no_history", "there is no later page to go forward to");
135
+ return this.snapshot();
136
+ }
137
+ async reload() { await this.#need().reload({ timeout: 20000 }).catch((e) => { throw new BrowserError("navigation_failed", String(e.message).split("\n")[0]); }); return this.snapshot(); }
138
+ async scroll(dy = 400, dx = 0) { await this.#need().mouse.wheel(dx, dy); return this.snapshot(); }
139
+ /** Waits for text to appear in the page (or to be gone, with gone: true). Resolves with a snapshot. */
140
+ async waitFor({ text, gone = false, timeoutMs = 5000 } = {}) {
141
+ const page = this.#need();
142
+ if (typeof text !== "string" || !text) throw new BrowserError("bad_args", "browser_wait_for needs text");
143
+ try { await page.waitForFunction(([t, g]) => document.body.innerText.includes(t) !== g, [text, gone], { timeout: Math.min(Math.max(timeoutMs, 100), 30000) }); }
144
+ catch { throw new BrowserError("timeout", `"${text}" ${gone ? "did not go away" : "did not appear"} within ${timeoutMs} ms`); }
145
+ return this.snapshot();
146
+ }
147
+ async tabs() { return Promise.all(this.pages.map(async (p, index) => ({ index, url: p.url(), title: await p.title().catch(() => ""), active: p === this.page }))); }
148
+ async selectTab(index) { const p = this.pages[index]; if (!p) throw new BrowserError("no_tab", `no tab ${index}`); this.page = p; this.refsFrom = null; await p.bringToFront(); if (this.screencast) await this.#cast(); this.onChange?.(); }
149
+ async newTab(url = "about:blank") { await this.#start(); await checkUrl(url, this.opts, this.resolve); const p = await this.ctx.newPage(); await p.goto(url).catch(() => {}); return this.tabs(); }
150
+ async closeTab(index) {
151
+ const p = this.pages[index]; if (!p) throw new BrowserError("no_tab", `no tab ${index}`);
152
+ await p.close(); return this.tabs();
153
+ }
154
+
155
+ // Live view. Chromium pushes JPEG frames (CDP Page.startScreencast); each one is acked once it has been handed on.
156
+ async #cast() {
157
+ if (!this.page || !this.ctx) return;
158
+ try { // a tab that is closing or not yet attached must not take the server down
159
+ await this.cdp?.send("Page.stopScreencast").catch(() => {}); this.cdp?.detach().catch(() => {});
160
+ const c = this.cdp = await this.ctx.newCDPSession(this.page);
161
+ c.on("Page.screencastFrame", (f) => { c.send("Page.screencastFrameAck", { sessionId: f.sessionId }).catch(() => {}); this.screencast?.({ data: f.data, width: f.metadata.deviceWidth, height: f.metadata.deviceHeight }); });
162
+ await c.send("Page.startScreencast", { format: "jpeg", quality: 60, maxWidth: 400, maxHeight: 860, everyNthFrame: 1 });
163
+ } catch {}
164
+ }
165
+ async startScreencast(onFrame) { await this.#need(); this.screencast = onFrame; await this.#cast(); }
166
+ async stopScreencast() { this.screencast = null; await this.cdp?.send("Page.stopScreencast").catch(() => {}); }
167
+ /** Input as a finger and keyboard would give it, in CSS px of the viewport. */
168
+ async input(m) {
169
+ this.#need(); const c = this.cdp ?? (this.cdp = await this.ctx.newCDPSession(this.page));
170
+ const touch = (type, x, y) => c.send("Input.dispatchTouchEvent", { type, touchPoints: type === "touchEnd" ? [] : [{ x, y }] });
171
+ const num = (...v) => { if (!v.every((n) => Number.isFinite(n))) throw new BrowserError("bad_args", "coordinates must be numbers"); };
172
+ if (m.type === "tap") { num(m.x, m.y); await touch("touchStart", m.x, m.y); await touch("touchEnd"); }
173
+ else if (m.type === "swipe") {
174
+ num(m.x1, m.y1, m.x2, m.y2); await touch("touchStart", m.x1, m.y1);
175
+ for (let i = 1; i <= 8; i++) await touch("touchMove", m.x1 + ((m.x2 - m.x1) * i) / 8, m.y1 + ((m.y2 - m.y1) * i) / 8);
176
+ await touch("touchEnd");
177
+ }
178
+ else if (m.type === "text") await c.send("Input.insertText", { text: String(m.text ?? "").slice(0, 2000) });
179
+ else if (m.type === "key") { const key = String(m.key); const code = { Enter: 13, Backspace: 8, Tab: 9 }[key]; if (!code) throw new BrowserError("bad_args", `unsupported key ${key}`);
180
+ for (const type of ["keyDown", "keyUp"]) await c.send("Input.dispatchKeyEvent", { type, key, windowsVirtualKeyCode: code, ...(key === "Enter" && type === "keyDown" ? { text: "\r" } : {}) }); }
181
+ else throw new BrowserError("bad_args", `unknown input ${m.type}`);
182
+ }
183
+ async close() { this.screencast = null; await this.ctx?.close().catch(() => {}); this.ctx = this.page = this.cdp = null; this.pages = []; }
184
+ }
185
+
186
+ /** True for a link into another app: a scheme that is not a web page scheme. */
187
+ export function isDeepLink(u) { return /^[a-z][a-z0-9+.-]*:/i.test(String(u)) && !/^(https?|about|data|blob|javascript|file|ftp|view-source|chrome|devtools|mailto|tel):/i.test(String(u)); }