@shipvo/sim 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/apps.mjs ADDED
@@ -0,0 +1,138 @@
1
+ // Installed apps on the simulated phone: state, the phone.json profile, and the Store screen. Pure: no DOM, no timers.
2
+ // The server uses these rules for the MCP tools install_app, uninstall_app and switch_app. The frame page uses the same
3
+ // functions (serialised into the page) to draw the home screen icons and the Store. Our own artwork: no vendor marks.
4
+ import { SETTINGS, settingPatch } from "./settings.mjs";
5
+
6
+ /** Typed error: unknown_app, already_installed, not_installed or bad_profile. */
7
+ export class AppsError extends Error { constructor(code, message) { super(message); this.code = code; } }
8
+
9
+ /** The apps the Store lists when there is no network or no marketplace. Used by tests and offline runs. */
10
+ export const STORE_FIXTURE = [
11
+ { id: "tide", name: "Tide & Tackle", url: "fixture://tide", version: "1.0.0" },
12
+ { id: "notes", name: "Pocket Notes", url: "fixture://notes", version: "0.3.1" },
13
+ { id: "weather", name: "Harbour Weather", url: "fixture://weather", version: "2.1.0" },
14
+ ];
15
+
16
+ const text = (v, n) => String(v ?? "").replace(/[<>"'\\]/g, "").slice(0, n);
17
+ const entry = (a) => ({ id: text(a.id, 60), name: text(a.name || a.id, 40), url: String(a.url ?? ""), version: text(a.version ?? "0.0.0", 20) });
18
+
19
+ /** Phone state for apps. `installed_apps` is a list of { id, name, url, version }; `current_app` is an id or null (the app the phone was started with). */
20
+ export function createApps(profile = {}) {
21
+ return { installed_apps: [], current_app: null, store: STORE_FIXTURE.map(entry), settings: {}, log: [], ...profile };
22
+ }
23
+
24
+ /**
25
+ * Parse a phone.json profile: { "apps": [{id,name,url?,version?}], "current_app"?: id, "settings"?: { dark_mode: true, ... }, "store"?: [...] }.
26
+ * The same profile always gives the same phone. Throws AppsError("bad_profile") with the reason. Contacts and photos come later.
27
+ */
28
+ export function loadProfile(json) {
29
+ let p = json;
30
+ if (typeof json === "string") { try { p = JSON.parse(json); } catch { throw new AppsError("bad_profile", "phone.json is not valid JSON"); } }
31
+ if (!p || typeof p !== "object" || Array.isArray(p)) throw new AppsError("bad_profile", "phone.json must be an object");
32
+ const apps = p.apps ?? [];
33
+ if (!Array.isArray(apps)) throw new AppsError("bad_profile", "'apps' must be a list");
34
+ const st = createApps();
35
+ if (p.store !== undefined) { if (!Array.isArray(p.store)) throw new AppsError("bad_profile", "'store' must be a list"); st.store = p.store.map((a) => entry(a)); }
36
+ const seen = new Set();
37
+ for (const a of apps) {
38
+ if (!a || typeof a.id !== "string" || !/^[a-z0-9-]+$/.test(a.id)) throw new AppsError("bad_profile", "every app needs an id of lowercase letters, digits and dashes");
39
+ if (seen.has(a.id)) throw new AppsError("bad_profile", `app '${a.id}' is listed twice`);
40
+ seen.add(a.id); st.installed_apps.push(entry(a));
41
+ }
42
+ if (p.current_app != null) { if (!seen.has(p.current_app)) throw new AppsError("bad_profile", `current_app '${p.current_app}' is not in 'apps'`); st.current_app = p.current_app; }
43
+ if (p.settings !== undefined) {
44
+ if (!p.settings || typeof p.settings !== "object") throw new AppsError("bad_profile", "'settings' must be an object");
45
+ for (const [k, v] of Object.entries(p.settings)) { try { settingPatch(k, v); } catch (e) { throw new AppsError("bad_profile", `settings: ${e.message}`); } st.settings[k] = v; }
46
+ }
47
+ return st;
48
+ }
49
+
50
+ /** The device patch that applies a profile's settings (the same for every run). */
51
+ export function profilePatch(st) {
52
+ return Object.assign({}, ...Object.entries(st.settings).map(([k, v]) => settingPatch(k, v)));
53
+ }
54
+
55
+ const note = (st, name, id) => { st.log.push({ at: Date.now(), name, app: id, source: "simulated" }); };
56
+
57
+ /** Install an app from the catalog (default: the phone's Store list). Throws unknown_app or already_installed. */
58
+ export function installApp(st, id, catalog = st.store) {
59
+ const found = (catalog ?? []).find((a) => a.id === id);
60
+ if (!found) throw new AppsError("unknown_app", `no app '${id}' in the Store. Apps: ${(catalog ?? []).map((a) => a.id).join(", ") || "none"}`);
61
+ if (st.installed_apps.some((a) => a.id === id)) throw new AppsError("already_installed", `'${id}' is already installed`);
62
+ st.installed_apps.push(entry(found)); note(st, "install_app", id); return st;
63
+ }
64
+
65
+ /** Remove an app. If it was in front, the phone goes home and current_app is empty. Throws not_installed. */
66
+ export function uninstallApp(st, id, nav) {
67
+ if (!st.installed_apps.some((a) => a.id === id)) throw new AppsError("not_installed", `'${id}' is not installed`);
68
+ st.installed_apps = st.installed_apps.filter((a) => a.id !== id);
69
+ if (st.current_app === id) { st.current_app = null; if (nav) nav.where = "home"; }
70
+ note(st, "uninstall_app", id); return st;
71
+ }
72
+
73
+ /** Open an installed app (tap its icon). Throws not_installed. */
74
+ export function switchApp(st, id, nav) {
75
+ if (!st.installed_apps.some((a) => a.id === id)) throw new AppsError("not_installed", `'${id}' is not installed`);
76
+ st.current_app = id; if (nav) nav.where = "app"; note(st, "switch_app", id); return st;
77
+ }
78
+
79
+ /** Plain text for an agent: the installed apps, then the Store. */
80
+ export function describeApps(st) {
81
+ const l = ["Installed apps"];
82
+ if (!st.installed_apps.length) l.push("- none");
83
+ for (const a of st.installed_apps) l.push(`- button "${a.name}" (${a.id} ${a.version}${st.current_app === a.id ? ", open" : ""}: switch_app opens it)`);
84
+ l.push("Store");
85
+ for (const a of st.store) l.push(st.installed_apps.some((i) => i.id === a.id) ? `- "${a.name}" (${a.id}): installed` : `- button "Install ${a.name}" (${a.id}: install_app installs it)`);
86
+ return l.join("\n");
87
+ }
88
+
89
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
90
+ /** Home screen icons for installed apps. */
91
+ export function installedIconsHtml(st) {
92
+ return st.installed_apps.map((a) => `<button type="button" class="sf-icon sf-appicon" data-part="installed-app" data-app="${esc(a.id)}" aria-label="${esc(a.name)}"><b>${esc(a.name.slice(0, 1).toUpperCase())}</b><span>${esc(a.name)}</span></button>`).join("");
93
+ }
94
+ /** Store rows: one button per app that is not installed yet. */
95
+ export function storeRowsHtml(st) {
96
+ return st.store.map((a) => {
97
+ const have = st.installed_apps.some((i) => i.id === a.id);
98
+ return `<div class="sf-store-row" data-app="${esc(a.id)}"><span class="sf-store-name">${esc(a.name)} <small>${esc(a.version)}</small></span>${have ? `<span class="sf-store-have">Installed</span>` : `<button type="button" data-part="store-install" data-app="${esc(a.id)}" aria-label="Install ${esc(a.name)}">Install</button>`}</div>`;
99
+ }).join("");
100
+ }
101
+
102
+ /** The Store icon for the home screen and the Store screen. Hidden until opened. */
103
+ export function storeIconHtml() {
104
+ return `<button type="button" class="sf-icon" data-part="store-icon" aria-label="Store" style="left:220px"><b>▤</b><span>Store</span></button>`;
105
+ }
106
+ export function storeScreenHtml() {
107
+ return `<div class="sf-settings sf-store" data-part="store-screen" role="dialog" aria-label="Store" tabindex="-1" hidden><h2 class="sf-sh">Store</h2><div data-part="store-rows" role="list" aria-label="Apps"></div><button type="button" class="sf-close" data-part="store-close">Close</button></div>`;
108
+ }
109
+ export const storeCss = [
110
+ `.sf-store{position:absolute;inset:0;z-index:8;overflow:auto;padding:70px 20px 40px;box-sizing:border-box;background:#0b1320;color:#fff;font-family:system-ui,sans-serif}[data-theme="light"] .sf-store{background:#f4f7fb;color:#111}`,
111
+ `.sf-store[hidden]{display:none}.sf-store-row{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid #8883;font:600 15px system-ui,sans-serif}.sf-store-row small{opacity:.6;font-weight:400}`,
112
+ `.sf-store-row button,.sf-store .sf-close{border:0;border-radius:999px;padding:12px 18px;min-height:44px;background:#f4a261;color:#102030;font:600 14px system-ui,sans-serif;cursor:pointer}.sf-store-have{color:#8a94a6;font-size:13px}[data-theme="light"] .sf-store-have{color:#4a5568}.sf-store .sf-close{margin-top:18px;background:#8883;color:inherit}`,
113
+ `.sf-apps{position:absolute;left:28px;right:28px;top:180px;display:flex;flex-wrap:wrap;gap:20px 24px}.sf-apps .sf-icon{position:static}`,
114
+ ].join("");
115
+
116
+ /** Client script: draws installed icons and the Store in the frame page. `seed` is a phone.json object. */
117
+ export function appsClientScript(iframeId = "app", seed = {}) {
118
+ return `(function(){
119
+ var AppsError=function(c,m){var e=new Error(m);e.code=c;return e},STORE_FIXTURE=${JSON.stringify(STORE_FIXTURE)};
120
+ var entry=${entry.toString()},text=${text.toString()},esc=${esc.toString()},note=${note.toString()},installApp=${installApp.toString()},switchApp=${switchApp.toString()},installedIconsHtml=${installedIconsHtml.toString()},storeRowsHtml=${storeRowsHtml.toString()},createApps=${createApps.toString()};
121
+ var loadProfile=${loadProfile.toString()},settingPatch=function(){};
122
+ var r=document.querySelector('[data-part="phone-body"]');if(!r)return;
123
+ var q=function(p){return r.querySelector('[data-part="'+p+'"]')},sc=q("store-screen"),home=q("home-screen");if(!sc||!home)return;
124
+ var st;try{st=loadProfile(${JSON.stringify(seed)})}catch(x){st=createApps()}
125
+ st.settings={};
126
+ var box=document.createElement("div");box.className="sf-apps";box.setAttribute("data-part","installed-apps");home.appendChild(box);
127
+ function drawHome(){box.innerHTML=installedIconsHtml(st)}
128
+ function drawStore(){q("store-rows").innerHTML=storeRowsHtml(st)}
129
+ function open(on){sc.hidden=!on;if(on){drawStore();sc.focus({preventScroll:true})}}
130
+ drawHome();
131
+ r.addEventListener("click",function(e){var t=e.target;if(!t.closest)return;
132
+ if(t.closest('[data-part="store-icon"]'))return open(true);
133
+ if(t.closest('[data-part="store-close"]'))return open(false);
134
+ var i=t.closest('[data-part="store-install"]');if(i){try{installApp(st,i.getAttribute("data-app"))}catch(x){}drawStore();drawHome();return}
135
+ var a=t.closest('[data-part="installed-app"]');if(a){try{switchApp(st,a.getAttribute("data-app"))}catch(x){}window.postMessage({type:"pn:nav",where:"app"},"*")}});
136
+ document.addEventListener("keydown",function(e){if(e.key==="Escape"&&!sc.hidden)open(false)});
137
+ })();`;
138
+ }
@@ -0,0 +1,83 @@
1
+ // The Browser app of Shipvo Phone, drawn inside the phone body. Android style: controls on top, like a Chrome tab bar.
2
+ // iPhone style: address pill on top, toolbar at the bottom. The page below is the live view of the phone's Chromium:
3
+ // frames come over a WebSocket, taps, swipes and typing go back as input. Our own artwork, no vendor icons.
4
+ import { renderFrame } from "./frame.mjs";
5
+
6
+ const I = {
7
+ back: '<path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>',
8
+ forward: '<path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>',
9
+ reload: '<path d="M19 12a7 7 0 1 1-2.2-5.1M19 4v4.5h-4.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>',
10
+ };
11
+ const btn = (part, label, body) => `<button type="button" class="ba-btn" data-part="${part}" aria-label="${label}"><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">${body}</svg></button>`;
12
+ const tabsBtn = '<button type="button" class="ba-btn ba-tabs" data-part="tabs" aria-label="Tabs"><span data-part="tab-count">1</span></button>';
13
+ const url = '<form class="ba-url" data-part="address-form"><input data-part="address" aria-label="Address" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="Search or type a URL"></form>';
14
+
15
+ /** The markup and CSS of the Browser app for one skin. */
16
+ export function renderBrowserApp(skin = "android") {
17
+ const nav = btn("back", "Back", I.back) + btn("forward", "Forward", I.forward) + btn("reload", "Reload", I.reload);
18
+ const ios = skin === "ios";
19
+ const html = `<div class="ba" data-part="browser-app" data-skin="${skin}">`
20
+ + (ios ? `<div class="ba-top">${url}</div>` : `<div class="ba-top">${nav}${url}${tabsBtn}</div>`)
21
+ + `<div class="ba-view" tabindex="0" data-part="browser-view"><img data-part="screen" alt="Web page" draggable="false"><p class="ba-empty" data-part="empty">No page open</p><p class="ba-toast" data-part="toast" role="status" hidden></p></div>`
22
+ + (ios ? `<div class="ba-bottom">${nav}${tabsBtn}</div>` : "")
23
+ + `<div class="ba-sheet" data-part="tab-list" hidden><ul></ul><button type="button" data-part="new-tab">New tab</button></div></div>`;
24
+ const css = [
25
+ `.ba{--ba-bg:#fff;--ba-fg:#111;--ba-bar:${ios ? "#f2f2f7" : "#f1f3f4"};--ba-field:${ios ? "#e3e3e8" : "#fff"};--ba-icon:${ios ? "#0a64d6" : "#444"};--ba-line:#d0d0d5;--ba-muted:#595959;--ba-card:#eee;--ba-sheet:#fffe;--ba-focus:#0a64d6;--ba-toast:#222;--ba-toast-fg:#fff}`,
26
+ `@media (prefers-color-scheme:dark){.ba{--ba-bg:#121212;--ba-fg:#f1f1f1;--ba-bar:${ios ? "#1c1c1e" : "#202124"};--ba-field:${ios ? "#2c2c2e" : "#303134"};--ba-icon:${ios ? "#5eb0ff" : "#d0d0d0"};--ba-line:#3a3a3c;--ba-muted:#b0b0b0;--ba-card:#2a2a2c;--ba-sheet:#121212ee;--ba-focus:#8ab4f8;--ba-toast:#f1f1f1;--ba-toast-fg:#111}}`,
27
+ `.ba{position:absolute;inset:0;display:flex;flex-direction:column;background:var(--ba-bg);color:var(--ba-fg);font:14px system-ui,sans-serif}`,
28
+ `.ba button:focus-visible,.ba input:focus-visible,.ba-view:focus-visible{outline:3px solid var(--ba-focus);outline-offset:-3px}`,
29
+ `.ba-top,.ba-bottom{flex:none;display:flex;align-items:center;gap:4px;padding:${ios ? "6px 16px" : "6px 8px"};background:var(--ba-bar)}`,
30
+ `.ba-bottom{justify-content:space-around;padding:6px 10px 4px;border-top:1px solid var(--ba-line)}`,
31
+ `.ba-btn{flex:none;width:40px;height:40px;border:0;border-radius:${ios ? "10px" : "50%"};background:transparent;color:var(--ba-icon);display:flex;align-items:center;justify-content:center;cursor:pointer}`,
32
+ `.ba-tabs span{display:block;min-width:20px;padding:1px 3px;box-sizing:border-box;border:2px solid currentColor;border-radius:${ios ? "5px" : "6px"};font:700 12px system-ui,sans-serif;text-align:center}`,
33
+ `.ba-url{flex:1;min-width:0}.ba-url input{width:100%;box-sizing:border-box;height:38px;border:0;border-radius:${ios ? "10px" : "19px"};padding:0 14px;background:var(--ba-field);font:15px system-ui,sans-serif;color:var(--ba-fg);${ios ? "text-align:center" : ""}}`,
34
+ // The page keeps its own aspect ratio: contain letterboxes it, and pointer positions are mapped against the drawn image.
35
+ `.ba-view{flex:1;min-height:0;position:relative;background:var(--ba-bg);overflow:hidden}.ba-view img{display:block;width:100%;height:100%;object-fit:contain;touch-action:none;user-select:none;-webkit-user-drag:none}.ba-view img:not([src]){display:none}`,
36
+ `.ba-empty{position:absolute;inset:0;margin:0;display:flex;align-items:center;justify-content:center;color:var(--ba-muted)}.ba-view.has-frame .ba-empty{display:none}`,
37
+ `.ba-toast{position:absolute;left:12px;right:12px;bottom:12px;margin:0;padding:10px 14px;border-radius:12px;background:var(--ba-toast);color:var(--ba-toast-fg)}.ba-toast[hidden],.ba-sheet[hidden]{display:none}`,
38
+ `.ba-sheet{position:absolute;inset:0;background:var(--ba-sheet);padding:12px;overflow:auto}.ba-sheet ul{list-style:none;margin:0 0 10px;padding:0}.ba-sheet li{display:flex;gap:8px;align-items:center;padding:2px 2px 2px 10px;margin-bottom:6px;border-radius:12px;background:var(--ba-card)}.ba-sheet li[aria-current=true]{outline:2px solid var(--ba-focus)}`,
39
+ `.ba-sheet button{min-height:44px;border:0;border-radius:10px;background:none;color:inherit;font:inherit;cursor:pointer}.ba-sheet li .pick{flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ba-sheet li .close{min-width:44px;font-size:22px}.ba-sheet>button{padding:0 14px;background:var(--ba-card)}`,
40
+ ].join("\n");
41
+ return { html, css };
42
+ }
43
+
44
+ /** Browser script for the page that holds the app. `session` names the sim session whose Chromium it shows. */
45
+ export function browserAppScript(session) {
46
+ return `(function(){
47
+ var $=function(p){return document.querySelector('[data-part="'+p+'"]')},img=$("screen"),view=$("browser-view"),vw=393,vh=851,ws,st={tabs:[]};
48
+ function send(m){if(ws&&ws.readyState===1)ws.send(JSON.stringify(m))}
49
+ function toast(t){var e=$("toast");e.textContent=t;e.hidden=false;clearTimeout(toast.t);toast.t=setTimeout(function(){e.hidden=true},3500)}
50
+ function draw(){var a=$("address");if(document.activeElement!==a){var t=st.tabs.filter(function(x){return x.active})[0];a.value=st.url&&st.url!=="about:blank"?st.url:""}
51
+ $("tab-count").textContent=st.tabs.length||1;
52
+ var l=$("tab-list").querySelector("ul");l.innerHTML="";st.tabs.forEach(function(t){var li=document.createElement("li");if(t.active)li.setAttribute("aria-current","true");
53
+ var b=document.createElement("button");b.className="pick";b.textContent=t.title||t.url||"New tab";b.onclick=function(){send({type:"tab",index:t.index});$("tab-list").hidden=true};
54
+ var x=document.createElement("button");x.className="close";x.textContent="\\u00d7";x.setAttribute("aria-label","Close tab");x.onclick=function(){send({type:"closetab",index:t.index})};li.append(b,x);l.append(li)})}
55
+ function connect(){ws=new WebSocket((location.protocol==="https:"?"wss://":"ws://")+location.host+"/browser/ws?session="+encodeURIComponent(${JSON.stringify(session)}));
56
+ ws.onmessage=function(e){var m=JSON.parse(e.data);
57
+ if(m.type==="frame"){vw=m.width||vw;vh=m.height||vh;img.src="data:image/jpeg;base64,"+m.data;view.classList.add("has-frame")}
58
+ else if(m.type==="state"){st=m;draw()}
59
+ else if(m.type==="browser"){toast("Opened in Browser: "+m.url)}
60
+ else if(m.type==="app"){toast("Opened in the app: "+m.url);window.dispatchEvent(new CustomEvent("shipvo:open-app",{detail:m.url}))}
61
+ else if(m.type==="error")toast(m.message)};
62
+ ws.onclose=function(){setTimeout(connect,1000)}}
63
+ connect();
64
+ function pt(e){var r=img.getBoundingClientRect(),k=Math.min(r.width/vw,r.height/vh),ox=r.left+(r.width-vw*k)/2,oy=r.top+(r.height-vh*k)/2;
65
+ return{x:Math.max(0,Math.min(vw,(e.clientX-ox)/k)),y:Math.max(0,Math.min(vh,(e.clientY-oy)/k))}}
66
+ var down=null;
67
+ img.addEventListener("pointerdown",function(e){down=pt(e);view.focus();e.preventDefault()});
68
+ img.addEventListener("pointerup",function(e){if(!down)return;var u=pt(e),d=Math.hypot(u.x-down.x,u.y-down.y);
69
+ send(d<10?{type:"tap",x:down.x,y:down.y}:{type:"swipe",x1:down.x,y1:down.y,x2:u.x,y2:u.y});down=null});
70
+ view.addEventListener("keydown",function(e){if(e.key==="Enter"||e.key==="Backspace"||e.key==="Tab"){e.preventDefault();send({type:"key",key:e.key})}
71
+ else if(e.key.length===1&&!e.ctrlKey&&!e.metaKey){e.preventDefault();send({type:"text",text:e.key})}});
72
+ $("back").onclick=function(){send({type:"back"})};$("forward").onclick=function(){send({type:"forward"})};$("reload").onclick=function(){send({type:"reload"})};
73
+ $("tabs").onclick=function(){var t=$("tab-list");t.hidden=!t.hidden};$("new-tab").onclick=function(){send({type:"newtab"});$("tab-list").hidden=true};
74
+ $("address-form").onsubmit=function(e){e.preventDefault();var v=$("address").value.trim();if(!v)return;if(!/^[a-z][a-z0-9+.-]*:/i.test(v))v="https://"+v;send({type:"open",url:v});$("address").blur()};
75
+ })();`;
76
+ }
77
+
78
+ /** A full page: the phone body in the given skin with the Browser app in it. */
79
+ export function browserAppPage(skin, session) {
80
+ const app = renderBrowserApp(skin), frame = renderFrame({ skin, screen: app.html });
81
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Browser (simulated ${skin === "ios" ? "iPhone" : "Android"})</title>
82
+ <style>html,body{margin:0;height:100%;background:#e8eaed;overflow:hidden}body{display:flex;align-items:center;justify-content:center}${frame.css}\n${app.css}</style></head><body>${frame.html}<script>${browserAppScript(session).replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
83
+ }
@@ -0,0 +1,52 @@
1
+ // Picks the browser the simulator launches: system Chrome, system Edge, Playwright's cached Chromium,
2
+ // then a one-time `npx playwright install chromium`. Pure: fs and process access are injected.
3
+ import { existsSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ export const INSTALL_COMMAND = "npx playwright install chromium";
7
+
8
+ /** Where each Playwright channel puts its binary, per platform. */
9
+ export function systemBrowserPaths(channel, platform, env) {
10
+ if (platform === "win32") {
11
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
12
+ const rel = channel === "chrome" ? "\\Google\\Chrome\\Application\\chrome.exe" : "\\Microsoft\\Edge\\Application\\msedge.exe";
13
+ return roots.map((r) => r + rel);
14
+ }
15
+ if (platform === "darwin") {
16
+ return channel === "chrome"
17
+ ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
18
+ : ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"];
19
+ }
20
+ return channel === "chrome"
21
+ ? ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/opt/google/chrome/chrome"]
22
+ : ["/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable", "/opt/microsoft/msedge/msedge"];
23
+ }
24
+
25
+ const defaultRun = (cmd, args) => spawnSync(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
26
+
27
+ /**
28
+ * Returns the options to pass to `chromium.launch`.
29
+ * Order: system Chrome, system Edge, Playwright cache, one-time install.
30
+ */
31
+ export function resolveBrowser({ pw, executablePath, env = process.env, fileExists = existsSync, run = defaultRun, platform = process.platform, log = (m) => console.error(m) }) {
32
+ if (executablePath) return { executablePath };
33
+ for (const channel of ["chrome", "msedge"]) {
34
+ if (systemBrowserPaths(channel, platform, env).some((p) => fileExists(p))) return { channel };
35
+ }
36
+ let cached;
37
+ try { cached = pw.chromium.executablePath(); } catch { cached = undefined; }
38
+ if (cached && fileExists(cached)) return {};
39
+ if (env.SHIPVO_NO_AUTO_INSTALL === "1") {
40
+ throw new Error(`no browser found (Chrome, Edge or Playwright Chromium) and SHIPVO_NO_AUTO_INSTALL=1 is set. Run \`${INSTALL_COMMAND}\``);
41
+ }
42
+ log(`sim: no Chrome, Edge or Chromium found; running \`${INSTALL_COMMAND}\` once (this downloads a browser)...`);
43
+ const [cmd, ...args] = INSTALL_COMMAND.split(" ");
44
+ const res = run(cmd, args);
45
+ if (!res || res.status !== 0 || res.error) {
46
+ const why = res && res.error ? `: ${res.error.message}` : "";
47
+ throw new Error(`\`${INSTALL_COMMAND}\` failed${why}. Run it yourself and check the output`);
48
+ }
49
+ let after; try { after = pw.chromium.executablePath(); } catch { after = undefined; }
50
+ if (!after || !fileExists(after)) throw new Error(`\`${INSTALL_COMMAND}\` failed: it exited 0 but no browser was installed. Run it yourself and check the output`);
51
+ return {};
52
+ }
@@ -0,0 +1,19 @@
1
+ // The fix to print when Playwright cannot find the Chromium build it wants.
2
+ export const CHROMIUM_MISSING = /Executable doesn't exist/;
3
+ // Linux: the browser exists but shared libraries are missing.
4
+ export const LIBS_MISSING = /error while loading shared libraries|Host system is missing dependencies/i;
5
+
6
+ /** The one-line install command for the resolved playwright version, plus the Linux hint. */
7
+ export function chromiumInstallFix(version) {
8
+ const v = version ? ` (playwright ${version})` : "";
9
+ return `run \`npx playwright install chromium\` in the app folder${v}. On Linux, if Chromium then fails to start, run \`npx playwright install --with-deps chromium\` or install the system packages it lists.`;
10
+ }
11
+
12
+ /** The message to print for a launch error. Returns the first line, plus the fix when the browser is not installed. */
13
+ export function launchErrorMessage(message, version) {
14
+ const first = String(message).split("\n")[0];
15
+ const base = `could not launch Chromium: ${first}`;
16
+ if (CHROMIUM_MISSING.test(message)) return `${base}\nFix: ${chromiumInstallFix(version)}`;
17
+ if (LIBS_MISSING.test(message)) return `${base}\nFix: system libraries are missing. Run \`npx playwright install-deps chromium\``;
18
+ return base;
19
+ }
package/frame.d.mts ADDED
@@ -0,0 +1,40 @@
1
+ export type FrameSkin = "android" | "ios";
2
+ export type FrameStatus = {
3
+ /** Text shown on the left, for example "9:41". */
4
+ time?: string;
5
+ /** Bars filled, 0 to 4. Default 4. */
6
+ signal?: number;
7
+ /** Default true. */
8
+ wifi?: boolean;
9
+ /** Shows an airplane icon and hides signal and wifi. */
10
+ airplane?: boolean;
11
+ /** Level 0 to 100, or an object. Default 100. */
12
+ battery?: number | { level?: number; charging?: boolean };
13
+ };
14
+ export type FrameOptions = { skin?: FrameSkin; dark?: boolean; status?: FrameStatus; /** HTML for the screen area. */ screen?: string; /** Draw Lumi beside the cutout. Default: on, off in CI; SHIPVO_LUMI=off|on overrides. */ lumi?: boolean; /** Also draw Lumi's first-run guide. Needs lumi. Default: off. */ guide?: boolean };
15
+ export type Frame = {
16
+ skin: FrameSkin;
17
+ /** Screen size in CSS pixels. */
18
+ width: number;
19
+ height: number;
20
+ /** Size of the whole body, bezel included. */
21
+ outerWidth: number;
22
+ outerHeight: number;
23
+ html: string;
24
+ css: string;
25
+ };
26
+ export const SKINS: Record<FrameSkin, { label: string; width: number; height: number; radius: number }>;
27
+ export function normalizeStatus(status?: FrameStatus): { time: string; signal: number; wifi: boolean; airplane: boolean; battery: { level: number; charging: boolean } };
28
+ export function renderFrame(options?: FrameOptions): Frame;
29
+ export type DeviceStatusInput = { online?: boolean; airplane?: boolean; battery?: number | { level?: number; charging?: boolean } };
30
+ /** Maps the simulated device state and a clock to a status object. */
31
+ export function statusFromDevice(device?: DeviceStatusInput, now?: Date): FrameStatus;
32
+ /** True when a CSS colour is dark, so the status text should be light. */
33
+ export function isDarkBackground(color: string): boolean;
34
+ /** The clock text and icons markup, for redrawing the status bar. */
35
+ export function statusParts(status?: FrameStatus): { time: string; icons: string };
36
+ /** Browser script that keeps the status bar live from the app's `pn:status` events. */
37
+ export function statusClientScript(iframeId?: string, skin?: FrameSkin, phone?: object): string;
38
+
39
+ export type LumiState = "idle" | "working" | "happy" | "worried";
40
+ export type LumiEvent = { kind: "check-passed" | "flow-done" | "page-error" | "crash" | "action-failed" | "check-failed"; name?: string; error?: unknown; detail?: unknown };
package/frame.mjs ADDED
@@ -0,0 +1,240 @@
1
+ // The phone body: bezel, cutout, status bar and a slot for the screen. Pure: no DOM, no timers, no dependencies.
2
+ // Our own artwork. It does not copy any vendor's icons, fonts or exact device outlines.
3
+ // Scaling to fit the window is done by the page with a CSS transform on the element this markup sits in.
4
+
5
+ import { lockScreenHtml, bootScreenHtml, offScreenHtml } from "./lifecycle.mjs";
6
+ import { shadeHtml, shadeCss, shadeClientScript } from "./shade.mjs";
7
+ import { settingsHtml, settingsIconHtml, settingsCss, settingsClientScript } from "./settings.mjs";
8
+ import { storeIconHtml, storeScreenHtml, storeCss, appsClientScript } from "./apps.mjs";
9
+ import { updateBannerHtml, updateCss, updateClientScript } from "./update.mjs";
10
+ import { lumiHtml, lumiCaptionHtml, lumiCss, lumiClientScript, lumiDefault } from "./lumi.mjs";
11
+ import { guideHtml, guideCss, guideClientScript } from "./guide.mjs";
12
+ import { homeScreenHtml, recentsHtml, navButtonsHtml, navScreensCss, classifySwipe, setVisibilityInPage } from "./nav.mjs";
13
+
14
+ export const SKINS = {
15
+ android: { label: "Android", width: 412, height: 915, radius: 36, bezel: 10, status: 36, bar: 24, pad: 22, indicator: 108, part: "punch-hole", bottom: "gesture-bar" },
16
+ ios: { label: "iPhone", width: 393, height: 852, radius: 55, bezel: 12, status: 54, bar: 34, pad: 36, indicator: 134, part: "dynamic-island", bottom: "home-indicator" },
17
+ };
18
+
19
+ const FOOT = 14; // extra bezel under the screen, holds the "Simulated" label
20
+ const clean = (s) => String(s).replace(/[<>&"'\\]/g, "").slice(0, 12);
21
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
22
+
23
+ /** Fills in anything missing from a status object. Never throws on odd input. */
24
+ export function normalizeStatus(status = {}) {
25
+ const s = status && typeof status === "object" ? status : {};
26
+ const bat = typeof s.battery === "number" ? { level: s.battery } : (s.battery && typeof s.battery === "object" ? s.battery : {});
27
+ const num = (v, d) => (Number.isFinite(Number(v)) && v !== null && v !== "" ? Number(v) : d);
28
+ return {
29
+ time: s.time == null ? "9:41" : clean(s.time),
30
+ signal: Math.round(clamp(num(s.signal, 4), 0, 4)),
31
+ wifi: s.wifi !== false,
32
+ airplane: s.airplane === true,
33
+ battery: { level: Math.round(clamp(num(bat.level, 100), 0, 100)), charging: bat.charging === true },
34
+ };
35
+ }
36
+
37
+ /** Maps the simulated device ({ online, airplane, battery } from @shipvo/device) and a clock to a status object. Network off shows no signal and no wifi. */
38
+ export function statusFromDevice(device = {}, now = new Date()) {
39
+ const d = device && typeof device === "object" ? device : {};
40
+ const online = d.online !== false && d.airplane !== true && (d.wifi !== false || d.mobileData !== false);
41
+ const h = now.getHours() % 12 || 12, m = String(now.getMinutes()).padStart(2, "0");
42
+ return { time: `${h}:${m}`, signal: online && d.mobileData !== false ? 4 : 0, wifi: online && d.wifi !== false, airplane: d.airplane === true, battery: d.battery };
43
+ }
44
+
45
+ /** True when a CSS colour (#rgb, #rrggbb, rgb(), rgba()) is dark, so the status text should be light. Unknown or transparent colours count as light. */
46
+ export function isDarkBackground(color) {
47
+ const c = String(color ?? "").trim().toLowerCase();
48
+ let r, g, b, a = 1, m;
49
+ if ((m = /^#([0-9a-f]{3})$/.exec(c))) [r, g, b] = [...m[1]].map((x) => parseInt(x + x, 16));
50
+ else if ((m = /^#([0-9a-f]{6})/.exec(c))) [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
51
+ else if ((m = /^rgba?\(\s*([\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?/.exec(c))) { [r, g, b] = [+m[1], +m[2], +m[3]]; if (m[4] != null) a = m[4].endsWith("%") ? parseFloat(m[4]) / 100 : +m[4]; }
52
+ else return false;
53
+ return a >= 0.5 && 0.2126 * r + 0.7152 * g + 0.0722 * b < 128;
54
+ }
55
+
56
+ /** The parts of the status bar that change: the clock text and the icons markup. */
57
+ export function statusParts(status) {
58
+ const st = normalizeStatus(status);
59
+ return { time: st.time, icons: icons(st) };
60
+ }
61
+
62
+ /**
63
+ * Browser script for the page (online is shown as 4 bars; the simulated device has no signal strength) that holds the frame. It watches the app's "pn:status" events (sent by @shipvo/device when network,
64
+ * airplane or battery change), redraws the status bar, ticks the clock each minute and flips the text colour from the app's top background.
65
+ * `iframeId` is the app iframe. It expects the markup from renderFrame and the functions below inlined by this script.
66
+ */
67
+ export function statusClientScript(iframeId = "app", skin = "android", phone = {}) {
68
+ return `(function(){
69
+ var statusParts=${statusParts.toString()},normalizeStatus=${normalizeStatus.toString()},icons=${icons.toString()},battery=${battery.toString()},svg=${svg.toString()},isDarkBackground=${isDarkBackground.toString()},statusFromDevice=${statusFromDevice.toString()};
70
+ var clean=${clean.toString()},clamp=${clamp.toString()};
71
+ var dev={online:true,airplane:false,battery:{level:100,charging:false}};
72
+ function root(){return document.querySelector('[data-part="phone-body"]')}
73
+ function draw(){var r=root();if(!r)return;var p=statusParts(statusFromDevice(dev,new Date()));
74
+ r.querySelector('[data-part="time"]').textContent=p.time;var lt=r.querySelector('[data-part="lock-time"]');if(lt)lt.textContent=p.time;r.querySelector(".sf-icons").innerHTML=p.icons}
75
+ function topColor(w){try{var d=w.document,e=d.elementFromPoint(Math.floor(w.innerWidth/2),1);
76
+ while(e){var c=w.getComputedStyle(e).backgroundColor;if(c&&c!=="transparent"&&!/rgba\\(.*,\\s*0\\)$/.test(c))return c;e=e.parentElement}var b=d.body&&w.getComputedStyle(d.body).backgroundColor;if(b&&!/rgba\\(.*,\\s*0\\)$/.test(b)&&b!=="transparent")return b}catch(x){}return"#fff"}
77
+ function theme(w){var r=root();if(!r)return;var c=topColor(w),dark=isDarkBackground(c);
78
+ r.setAttribute("data-theme",dark?"dark":"light");r.style.setProperty("--sf-status-fg",dark?"#fff":"#111");r.style.setProperty("--sf-status-bg",c)}
79
+ function hook(){var f=document.getElementById(${JSON.stringify(iframeId)});var w=f&&f.contentWindow;if(!w)return;
80
+ if(w.__pnDevice&&w.__pnDevice.status)dev=Object.assign({},w.__pnDevice.status(),w.__pnDevice.settings?w.__pnDevice.settings():{});draw();theme(w);
81
+ if(!w.__shipvoStatusHooked){w.__shipvoStatusHooked=true;w.addEventListener("pn:status",function(e){dev=Object.assign({},dev,e.detail);draw();theme(w)});w.addEventListener("pn:settings",function(e){dev=Object.assign({},dev,e.detail);draw()})}}
82
+ var f=document.getElementById(${JSON.stringify(iframeId)});if(f){f.addEventListener("load",hook);hook()}
83
+ (function buttons(){var r=root();if(!r)return;var q=function(p){return r.querySelector('[data-part="'+p+'"]')},vol=50,hold,volT,bootT;
84
+ if(!q("power-button")||!q("lock-screen")||!q("power-menu"))return;
85
+ function show(el,on){if(el)el.hidden=!on}
86
+ function shown(p){var e=q(p);return !!e&&!e.hidden}
87
+ function lockNow(){show(q("power-menu"),false);show(q("lock-screen"),true);var u=q("unlock-button");if(u)u.focus()}
88
+ function unlockNow(){show(q("lock-screen"),false);var p=q("power-button");if(p)p.focus()}
89
+ function closeMenu(){if(shown("power-menu")){show(q("power-menu"),false);q("power-button").focus()}}
90
+ function powerOn(){show(q("off-screen"),false);show(q("boot-screen"),true);clearTimeout(bootT);bootT=setTimeout(function(){show(q("boot-screen"),false)},1200)}
91
+ function openMenu(){show(q("power-menu"),true);var b=q("power-menu").querySelector("button");if(b)b.focus()}
92
+ var sy=null,ls=q("lock-screen");
93
+ ls.addEventListener("pointerdown",function(e){sy=e.clientY});
94
+ ls.addEventListener("pointerup",function(e){if(sy!=null&&sy-e.clientY>60)unlockNow();sy=null});
95
+ ls.addEventListener("keydown",function(e){if(e.key==="ArrowUp"||e.key==="Enter"){e.preventDefault();unlockNow()}});
96
+ if(q("unlock-button"))q("unlock-button").addEventListener("click",unlockNow);
97
+ var pw=q("power-button");
98
+ function stopHold(){if(hold){clearTimeout(hold);hold=null}}
99
+ pw.addEventListener("pointerdown",function(){stopHold();hold=setTimeout(function(){hold=null;if(shown("off-screen"))return;openMenu();pw.setAttribute("data-held","1")},700)});
100
+ pw.addEventListener("pointerleave",stopHold);pw.addEventListener("pointercancel",stopHold);
101
+ pw.addEventListener("pointerup",stopHold);
102
+ pw.addEventListener("click",function(e){
103
+ if(pw.getAttribute("data-held")){pw.removeAttribute("data-held");return}
104
+ if(shown("off-screen")){powerOn();return}
105
+ if(e.detail===0&&e.shiftKey){shown("power-menu")?closeMenu():openMenu();return}
106
+ if(shown("power-menu")){closeMenu();return}
107
+ if(shown("lock-screen")){return}
108
+ lockNow()});
109
+ function bump(d){vol=Math.max(0,Math.min(100,vol+d));q("volume-level").textContent=vol;q("volume-bar").firstChild.style.width=vol+"%";show(q("volume-overlay"),true);clearTimeout(volT);volT=setTimeout(function(){show(q("volume-overlay"),false)},1500)}
110
+ q("volume-up-button").addEventListener("click",function(){bump(10)});q("volume-down-button").addEventListener("click",function(){bump(-10)});
111
+ q("off-screen").addEventListener("click",powerOn);q("off-screen").addEventListener("keydown",function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();powerOn()}});
112
+ r.addEventListener("keydown",function(e){if(e.key==="Escape")closeMenu()});
113
+ document.addEventListener("pointerdown",function(e){if(shown("power-menu")&&!q("power-menu").contains(e.target)&&e.target!==pw)closeMenu()});
114
+ q("power-menu").addEventListener("click",function(e){var a=e.target.getAttribute&&e.target.getAttribute("data-act");if(!a)return;show(q("power-menu"),false);
115
+ if(a==="cancel"){pw.focus();return}
116
+ if(a==="navstyle")return;
117
+ if(a==="restart"){show(q("boot-screen"),true);var f=document.getElementById(${JSON.stringify(iframeId)});if(f&&f.contentWindow)f.contentWindow.location.reload();clearTimeout(bootT);bootT=setTimeout(function(){show(q("boot-screen"),false)},1200)}
118
+ else{show(q("lock-screen"),false);show(q("off-screen"),true);pw.focus()}})})();
119
+ ${shadeClientScript(iframeId, skin)}
120
+ ${settingsClientScript(iframeId)}
121
+ ${appsClientScript(iframeId, phone)}
122
+ ${updateClientScript()}
123
+ ${lumiClientScript()}
124
+ ${guideClientScript()}
125
+ (function nav(){var r=root();if(!r)return;var q=function(p){return r.querySelector('[data-part="'+p+'"]')};
126
+ if(!q("home-screen")||!q("recents"))return;
127
+ var classifySwipe=${classifySwipe.toString()},setVisibilityInPage=${setVisibilityInPage.toString()};
128
+ var where="app",style="gesture",skin=${JSON.stringify(skin)};
129
+ function show(el,on){if(el)el.hidden=!on}
130
+ function appWin(){var f=document.getElementById(${JSON.stringify(iframeId)});return f&&f.contentWindow}
131
+ function fire(state){var w=appWin();if(!w)return;try{w.eval("("+setVisibilityInPage.toString()+")("+JSON.stringify(state)+")")}catch(x){}}
132
+ function go(next){var was=where;where=next;show(q("home-screen"),next==="home");show(q("recents"),next==="recents");r.setAttribute("data-where",next);
133
+ if(was==="app"&&next!=="app")fire("background");if(was!=="app"&&next==="app")fire("active");
134
+ if(was!==next){try{window.parent.postMessage({type:"pn:nav-state",where:next},"*")}catch(x){}}}
135
+ window.addEventListener("message",function(e){var d=e.data;if(d&&d.type==="pn:nav"&&(d.where==="app"||d.where==="home"||d.where==="recents"))go(d.where)});
136
+ var af=document.getElementById(${JSON.stringify(iframeId)});if(af)af.addEventListener("load",function(){if(where!=="app")fire("background")});
137
+ function home(){go("home")}function recents(){go("recents")}
138
+ function back(){if(where!=="app")return go("app");var w=appWin();if(!w)return;var b=w.document.querySelector('[aria-label="Back"],[role=button][aria-label="Back"]');if(b)b.click()}
139
+ function kill(){var w=appWin();go("home");if(w)w.location.reload()}
140
+ function setStyle(st){style=st;r.setAttribute("data-nav",st);show(q("nav-buttons"),st==="buttons");var t=q("nav-style-toggle");if(t)t.textContent=st==="buttons"?"Gesture navigation":"3-button navigation"}
141
+ q("app-icon").addEventListener("click",function(){go("app")});
142
+ q("recents-open").addEventListener("click",function(){go("app")});
143
+ q("recents-close").addEventListener("click",kill);
144
+ var cd=q("recents-card"),cy=null;cd.addEventListener("pointerdown",function(e){cy=e.clientY});cd.addEventListener("pointerup",function(e){if(cy!=null&&cy-e.clientY>80)kill();cy=null});
145
+ if(q("nav-back")){q("nav-back").addEventListener("click",back);q("nav-home").addEventListener("click",home);q("nav-recents").addEventListener("click",recents)}
146
+ var tg=q("nav-style-toggle");if(tg)tg.addEventListener("click",function(){setStyle(style==="buttons"?"gesture":"buttons")});
147
+ function swipe(el,edge){var x0=null,y0,t0;
148
+ el.addEventListener("pointerdown",function(e){if(e.target.closest&&e.target.closest("button"))return;x0=e.clientX;y0=e.clientY;t0=Date.now();try{el.setPointerCapture(e.pointerId)}catch(x){}});
149
+ el.addEventListener("pointercancel",function(){x0=null});
150
+ el.addEventListener("pointerup",function(e){if(x0==null)return;var a=classifySwipe({skin:skin,fromEdge:edge,dx:e.clientX-x0,dy:e.clientY-y0,ms:Date.now()-t0});x0=null;
151
+ if(a==="home")home();else if(a==="recents")recents();else if(a==="back")back()})}
152
+ swipe(q("edge-swipe"),"left");
153
+ var bar=r.querySelector(".sf-bar");if(bar&&!(skin==="android"&&style==="buttons"))swipe(bar,"bottom");
154
+ document.addEventListener("keydown",function(e){if(e.key==="Escape"&&where!=="app")go("app")});
155
+ })();
156
+ (function tick(){draw();var n=new Date();setTimeout(tick,(60-n.getSeconds())*1000-n.getMilliseconds()+50)})();
157
+ // The app can change its top colour (a theme switch) without any event we can hear, so look again every 2 seconds. Skipped while the page is hidden.
158
+ setInterval(function(){if(document.hidden)return;var f=document.getElementById(${JSON.stringify(iframeId)});if(f&&f.contentWindow)theme(f.contentWindow)},2000);
159
+ })();`;
160
+ }
161
+
162
+ const svg = (w, h, body, extra = "") => `<svg class="sf-ico" ${extra} width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" aria-hidden="true">${body}</svg>`;
163
+
164
+ function icons(st) {
165
+ if (st.airplane) {
166
+ return svg(16, 16, `<path d="M8 1.5 9.4 6.5 15 9.5v1.4l-5.6-1.5-.4 3 1.6 1.2v1L8 14.7 5.4 15.6v-1L7 13.4l-.4-3L1 10.9V9.5l5.6-3z" fill="currentColor"/>`, 'data-part="airplane"');
167
+ }
168
+ let bars = "";
169
+ for (let i = 0; i < 4; i++) bars += `<rect x="${i * 4}" y="${10 - i * 3}" width="3" height="${3 + i * 3}" rx="1" fill="currentColor"${i < st.signal ? "" : ' opacity=".3"'}/>`;
170
+ const signal = svg(15, 12, bars, `data-part="signal" data-level="${st.signal}"`);
171
+ const wifi = st.wifi
172
+ ? svg(16, 12, `<path d="M1 4.5a10 10 0 0 1 14 0M3.5 7.2a6.4 6.4 0 0 1 9 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="8" cy="10" r="1.4" fill="currentColor"/>`, 'data-part="wifi"')
173
+ : "";
174
+ return signal + wifi + battery(st.battery);
175
+ }
176
+
177
+ function battery(b) {
178
+ const w = Math.round((b.level / 100) * 15);
179
+ const bolt = b.charging ? `<path d="M12 2 7.5 7.5h3L9 11l4.5-5.5h-3z" fill="#000" opacity=".75"/>` : "";
180
+ return svg(25, 12, `<rect x=".75" y=".75" width="21.5" height="10.5" rx="3" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".6"/><rect x="3.5" y="3.5" width="${w}" height="5" rx="1.5" fill="currentColor"/><rect x="23" y="4" width="2" height="4" rx="1" fill="currentColor" opacity=".6"/>${bolt}`, `data-part="battery" data-level="${b.level}"${b.charging ? ' data-charging="true"' : ""}`);
181
+ }
182
+
183
+ /**
184
+ * Renders the phone body.
185
+ * - skin: "android" (412x915, punch-hole camera) or "ios" (393x852, island cutout).
186
+ * - dark: true gives light status text on a dark bar, false gives dark text on a light bar.
187
+ * - status: { time, signal 0-4, wifi, airplane, battery (0-100, or { level, charging }) }.
188
+ * - screen: optional HTML placed in the screen area (the page puts its iframe there).
189
+ * - lumi: draw Lumi beside the cutout (outside the app's screen). Default: on, but off in CI; SHIPVO_LUMI=off hides it anywhere.
190
+ * - guide: also draw Lumi's first-run guide (3 short steps, skippable, shown once per browser). Needs lumi. Default: off.
191
+ * Returns { skin, width, height, outerWidth, outerHeight, html, css }. width and height are the screen size.
192
+ */
193
+ export function renderFrame({ skin = "android", dark = false, status, screen = "", lumi = lumiDefault(), guide = false } = {}) {
194
+ const k = SKINS[skin];
195
+ if (!k) throw new Error(`Unknown skin "${skin}". Use "android" or "ios".`);
196
+ const st = normalizeStatus(status);
197
+ const outerWidth = k.width + k.bezel * 2, outerHeight = k.height + k.bezel + k.bezel + FOOT;
198
+ const shape = skin === "ios"
199
+ ? `<span class="sf-cut sf-island" data-part="dynamic-island"></span>`
200
+ : `<span class="sf-cut sf-hole" data-part="punch-hole"></span>`;
201
+ const html = `<div class="sf-body" data-skin="${skin}" data-theme="${dark ? "dark" : "light"}" data-part="phone-body">`
202
+ + `<div class="sf-screen"><div class="sf-status" data-part="status-bar"><span class="sf-time" data-part="time">${st.time}</span>${shape}${lumi ? lumiHtml() : ""}<span class="sf-icons">${icons(st)}</span></div>`
203
+ + `<div class="sf-content">${screen}</div>` + (lumi ? lumiCaptionHtml() : "") + updateBannerHtml()
204
+ + shadeHtml(skin) + `<span class="sf-edge" data-part="edge-swipe"></span>` + homeScreenHtml().replace(/<\/div>$/, settingsIconHtml() + storeIconHtml() + "</div>") + settingsHtml() + storeScreenHtml() + recentsHtml() + lockScreenHtml(st.time) + bootScreenHtml() + offScreenHtml() + (lumi && guide ? guideHtml() : "")
205
+ + `<div class="sf-vol" data-part="volume-overlay" role="status" aria-live="polite" hidden><span data-part="volume-level">50</span><i class="sf-vbar" data-part="volume-bar"><b style="width:50%"></b></i></div><div class="sf-menu" data-part="power-menu" role="menu" aria-label="Power menu" hidden><button role="menuitem" data-act="restart">Restart</button><button role="menuitem" data-act="poweroff">Power off</button>${skin === "android" ? `<button role="menuitem" data-act="navstyle" data-part="nav-style-toggle">3-button navigation</button>` : ""}<button role="menuitem" data-act="cancel">Cancel</button></div>`
206
+ + `<div class="sf-bar" data-part="${k.bottom}"><i></i>${skin === "android" ? navButtonsHtml() : ""}</div></div>`
207
+ + `<button class="sf-btn sf-power" data-part="power-button" aria-label="Power"></button><button class="sf-btn sf-volup" data-part="volume-up-button" aria-label="Volume up"></button><button class="sf-btn sf-voldown" data-part="volume-down-button" aria-label="Volume down"></button>`
208
+ + `<span class="sf-label" data-part="simulated-label">Simulated</span></div>`;
209
+ const css = [
210
+ `.sf-body{--sf-bg:${dark ? "#000" : "#fff"};--sf-fg:${dark ? "#fff" : "#111"};position:relative;box-sizing:border-box;width:${outerWidth}px;height:${outerHeight}px;padding:${k.bezel}px ${k.bezel}px ${k.bezel + FOOT}px;`
211
+ + `border-radius:${k.radius + k.bezel}px;background:linear-gradient(145deg,#3a3d42,#15171a);box-shadow:0 0 0 1px #000,0 10px 34px #0007;touch-action:manipulation;user-select:none;-webkit-user-select:none}`,
212
+ `.sf-screen{width:${k.width}px;height:${k.height}px;border-radius:${k.radius}px;background:var(--sf-bg);color:var(--sf-fg);overflow:hidden;display:flex;flex-direction:column;position:relative}`,
213
+ `.sf-status{flex:none;height:${k.status}px;display:flex;justify-content:space-between;align-items:center;padding:0 ${k.pad}px;font:600 14px system-ui,sans-serif;position:relative;z-index:2;color:var(--sf-status-fg,var(--sf-fg));background:var(--sf-status-bg,transparent)}`,
214
+ `.sf-icons{display:flex;align-items:center;gap:5px}.sf-ico{display:block}`,
215
+ `.sf-cut{position:absolute;left:50%;background:#000}`,
216
+ `.sf-hole{top:10px;width:14px;height:14px;margin-left:-7px;border-radius:50%;box-shadow:0 0 0 2px #1c1e21}`,
217
+ `.sf-island{top:11px;width:122px;height:34px;margin-left:-61px;border-radius:20px}`,
218
+ `.sf-content{flex:1;min-height:0;position:relative;background:#fff}`,
219
+ `.sf-content iframe{border:0;background:#fff;width:100%;height:100%;display:block}`,
220
+ `.sf-bar{flex:none;height:${k.bar}px;display:flex;justify-content:center;align-items:center;background:var(--sf-bg)}`,
221
+ `.sf-bar i{display:block;width:${k.indicator}px;height:5px;border-radius:3px;background:var(--sf-fg);opacity:.85}`,
222
+ `.sf-btn{position:absolute;right:-5px;width:5px;padding:0;border:0;border-radius:0 2px 2px 0;background:#5b6068;box-shadow:0 0 0 1px #000;cursor:pointer}.sf-btn::before{content:"";position:absolute;top:-6px;bottom:-6px;left:-4px;right:-10px}.sf-btn:focus-visible,.sf-lock button:focus-visible,.sf-menu button:focus-visible{outline:3px solid #4da3ff;outline-offset:2px}.sf-power{top:200px;height:80px}.sf-volup{top:310px;height:56px}.sf-voldown{top:376px;height:56px}`,
223
+ `.sf-lock,.sf-boot,.sf-off,.sf-menu,.sf-vol{position:absolute;z-index:5}.sf-lock[hidden],.sf-boot[hidden],.sf-off[hidden],.sf-menu[hidden],.sf-vol[hidden]{display:none}.sf-off{inset:0;z-index:7;background:#000;cursor:pointer}`,
224
+ `.sf-lock{inset:0;background:#10151c;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;font-family:system-ui,sans-serif}.sf-lock-time{font-size:64px;font-weight:300}.sf-lock-hint{font-size:14px;opacity:.7}.sf-lock button{margin-top:8px;padding:10px 28px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff;font:600 15px system-ui,sans-serif;cursor:pointer}`,
225
+ `.sf-boot{inset:0;z-index:6;background:#000;color:#fff;display:flex;align-items:center;justify-content:center;font:700 34px system-ui,sans-serif;letter-spacing:.04em}`,
226
+ `.sf-vbar{display:block;width:90px;height:4px;margin-top:6px;border-radius:2px;background:#fff4}.sf-vbar b{display:block;height:100%;border-radius:2px;background:#fff}.sf-vol{top:80px;right:12px;padding:8px 14px;border-radius:14px;background:#222d;color:#fff;font:600 14px system-ui,sans-serif}`,
227
+ `.sf-menu{left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;gap:8px;padding:14px;border-radius:16px;background:#222;box-shadow:0 8px 30px #0008}.sf-menu button{padding:10px 24px;border:0;border-radius:10px;background:#3a3d42;color:#fff;font:600 15px system-ui,sans-serif}`,
228
+ ...(lumi ? [lumiCss(k)] : []),
229
+ ...(lumi && guide ? [guideCss] : []),
230
+ shadeCss,
231
+ settingsCss,
232
+ storeCss,
233
+ updateCss,
234
+ `.sf-edge{position:absolute;left:0;top:${k.status}px;bottom:${k.bar}px;width:14px;z-index:3}.sf-home[hidden],.sf-recents[hidden],.sf-navbtns[hidden]{display:none}`,
235
+ navScreensCss(),
236
+ `.sf-bar{position:relative}.sf-navbtns{position:absolute;inset:0;display:flex;justify-content:space-around;align-items:center;background:var(--sf-bg)}.sf-navbtns button{border:0;background:none;color:var(--sf-fg);font-size:16px;padding:2px 24px;cursor:pointer}.sf-body[data-nav="buttons"] .sf-bar i{display:none}`,
237
+ `.sf-label{position:absolute;left:0;right:0;bottom:3px;text-align:center;font:600 9px system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:#9aa0a6}`,
238
+ ].join("\n");
239
+ return { skin, width: k.width, height: k.height, outerWidth, outerHeight, html, css };
240
+ }