@shipvo/cli 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/README.md +3 -0
- package/dist/adopt.js +1 -2
- package/dist/agents.d.ts +29 -0
- package/dist/agents.js +183 -0
- package/dist/approvals.d.ts +9 -0
- package/dist/approvals.js +106 -0
- package/dist/audit.d.ts +13 -0
- package/dist/audit.js +92 -0
- package/dist/autohalt.d.ts +1 -1
- package/dist/autohalt.js +28 -16
- package/dist/bsdiff.d.ts +4 -0
- package/dist/bsdiff.js +465 -0
- package/dist/ci-comment.d.ts +38 -0
- package/dist/ci-comment.js +169 -0
- package/dist/ci-compare.d.ts +10 -0
- package/dist/ci-compare.js +25 -0
- package/dist/ci-shots.d.ts +9 -0
- package/dist/ci-shots.js +81 -0
- package/dist/ci.d.ts +11 -0
- package/dist/ci.js +100 -0
- package/dist/cli.js +51 -1
- package/dist/config.d.ts +15 -0
- package/dist/config.js +68 -13
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +88 -8
- package/dist/eas.d.ts +29 -0
- package/dist/eas.js +258 -0
- package/dist/ensure-playwright.d.ts +25 -0
- package/dist/ensure-playwright.js +69 -0
- package/dist/explore-issues.d.ts +12 -0
- package/dist/explore-issues.js +50 -0
- package/dist/explore.d.ts +160 -0
- package/dist/explore.js +496 -0
- package/dist/flows.d.ts +31 -0
- package/dist/flows.js +264 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +37 -7
- package/dist/hermes.d.ts +10 -0
- package/dist/hermes.js +50 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +71 -19
- package/dist/json-out.d.ts +53 -0
- package/dist/json-out.js +91 -0
- package/dist/keys-json.d.ts +1 -0
- package/dist/keys-json.js +71 -0
- package/dist/login.js +27 -11
- package/dist/lumi.d.ts +104 -0
- package/dist/lumi.js +364 -0
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +521 -0
- package/dist/policy.d.ts +2 -16
- package/dist/policy.js +2 -18
- package/dist/preview-recorder.d.ts +1 -0
- package/dist/preview-recorder.js +62 -0
- package/dist/preview.d.ts +4 -3
- package/dist/preview.js +114 -15
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +63 -14
- package/dist/publish.d.ts +14 -2
- package/dist/publish.js +131 -56
- package/dist/qr.d.ts +9 -0
- package/dist/qr.js +213 -0
- package/dist/report-md.d.ts +27 -0
- package/dist/report-md.js +58 -0
- package/dist/rotation.d.ts +57 -0
- package/dist/rotation.js +157 -0
- package/dist/run-bridge.d.ts +7 -0
- package/dist/run-bridge.js +728 -0
- package/dist/run-net.d.ts +39 -0
- package/dist/run-net.js +266 -0
- package/dist/run-protocol.d.ts +32 -0
- package/dist/run-protocol.js +22 -0
- package/dist/run-ws.d.ts +8 -0
- package/dist/run-ws.js +89 -0
- package/dist/run.d.ts +109 -0
- package/dist/run.js +385 -0
- package/dist/signing.js +13 -0
- package/dist/sim.d.ts +4 -0
- package/dist/sim.js +25 -7
- package/dist/smoke.d.ts +28 -0
- package/dist/smoke.js +117 -0
- package/dist/spawn-bin.d.ts +11 -0
- package/dist/spawn-bin.js +21 -0
- package/dist/status.d.ts +71 -1
- package/dist/status.js +162 -5
- package/dist/store.d.ts +36 -0
- package/dist/store.js +266 -3
- package/dist/templates/capacitor/github-actions/build.yml +122 -0
- package/dist/templates/github-action/shipvo-check.yml +18 -0
- package/dist/test.d.ts +46 -0
- package/dist/test.js +253 -0
- package/package.json +7 -4
- package/skills/shipvo-cli/SKILL.md +31 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
// The page script of `shipvo run`. The frame page serves it at `/__shipvo/bridge.js`. It connects to the agent
|
|
2
|
+
// channel as role `screen` and answers `tree` requests from the same-origin app iframe. No Playwright: the tree is
|
|
3
|
+
// built from the DOM, in the shape of the simulator's `- role "name" [ref=eN]` lines (slice 3b of #448).
|
|
4
|
+
// Differences from the simulator tree are listed in docs/run-bridge.md.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
/**
|
|
8
|
+
* The shared screen rules (`@shipvo/sim`), as source that the page can run. Same copy the simulator uses.
|
|
9
|
+
* Only plain `export function` / `export const` lines are supported: `export default` and `export {…}` would not be stripped.
|
|
10
|
+
*/
|
|
11
|
+
export function screenRulesSource() {
|
|
12
|
+
const path = createRequire(import.meta.url).resolve("@shipvo/sim/screen-rules.mjs");
|
|
13
|
+
const src = readFileSync(path, "utf8");
|
|
14
|
+
if (/^export\s+(default\b|\{|\*)/m.test(src))
|
|
15
|
+
throw new Error("screen-rules.mjs may only use `export function` or `export const`");
|
|
16
|
+
return src.replace(/^export /gm, "");
|
|
17
|
+
}
|
|
18
|
+
/** Runs in the page. Everything it needs is nested inside, because it is serialised with `String(fn)`. */
|
|
19
|
+
function bridgeMain(win, token) {
|
|
20
|
+
const doc = win.document;
|
|
21
|
+
// A ref names one element with one role and name. If the element's content changes, it gets a new ref and the old one is stale.
|
|
22
|
+
const refs = new WeakMap();
|
|
23
|
+
const byRef = new Map();
|
|
24
|
+
let counter = 0;
|
|
25
|
+
const refOf = (el, sig) => {
|
|
26
|
+
let r = refs.get(el);
|
|
27
|
+
if (!r || r.sig !== sig) {
|
|
28
|
+
r = { n: ++counter, sig };
|
|
29
|
+
refs.set(el, r);
|
|
30
|
+
}
|
|
31
|
+
byRef.set("e" + r.n, { el, sig });
|
|
32
|
+
return "e" + r.n;
|
|
33
|
+
};
|
|
34
|
+
const INPUT_ROLE = { checkbox: "checkbox", radio: "radio", range: "slider", button: "button", submit: "button", reset: "button", image: "button", search: "searchbox", number: "spinbutton" };
|
|
35
|
+
const TAG_ROLE = { button: "button", textarea: "textbox", select: "combobox", img: "img", ul: "list", ol: "list", li: "listitem", nav: "navigation", main: "main", dialog: "dialog", form: "form", table: "table", tr: "row", td: "cell", th: "columnheader", p: "paragraph", progress: "progressbar", article: "article", aside: "complementary" };
|
|
36
|
+
const FROM_CONTENT = new Set(["button", "link", "heading", "tab", "checkbox", "radio", "switch", "menuitem", "option", "cell", "columnheader"]);
|
|
37
|
+
const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
38
|
+
const hidden = (el, cs) => el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true" || cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse";
|
|
39
|
+
function roleOf(el) {
|
|
40
|
+
const explicit = norm(el.getAttribute("role")).split(" ")[0];
|
|
41
|
+
if (explicit && explicit !== "presentation" && explicit !== "none")
|
|
42
|
+
return explicit;
|
|
43
|
+
const tag = el.tagName.toLowerCase();
|
|
44
|
+
if (tag === "a")
|
|
45
|
+
return el.hasAttribute("href") ? "link" : "";
|
|
46
|
+
if (/^h[1-6]$/.test(tag))
|
|
47
|
+
return "heading";
|
|
48
|
+
if (tag === "input") {
|
|
49
|
+
const t = (el.getAttribute("type") || "text").toLowerCase();
|
|
50
|
+
return t === "hidden" ? "" : INPUT_ROLE[t] || "textbox";
|
|
51
|
+
}
|
|
52
|
+
if (tag === "section")
|
|
53
|
+
return el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby") ? "region" : "";
|
|
54
|
+
if (tag === "img" && el.getAttribute("alt") === "")
|
|
55
|
+
return "";
|
|
56
|
+
return TAG_ROLE[tag] || "";
|
|
57
|
+
}
|
|
58
|
+
function textOf(node) {
|
|
59
|
+
if (node.nodeType === 3)
|
|
60
|
+
return node.nodeValue || "";
|
|
61
|
+
if (node.nodeType !== 1)
|
|
62
|
+
return "";
|
|
63
|
+
const tag = node.tagName.toLowerCase();
|
|
64
|
+
if (tag === "script" || tag === "style" || tag === "noscript")
|
|
65
|
+
return "";
|
|
66
|
+
if (hidden(node, win.getComputedStyle(node)))
|
|
67
|
+
return "";
|
|
68
|
+
if (tag === "img")
|
|
69
|
+
return node.getAttribute("alt") || "";
|
|
70
|
+
const own = node.getAttribute("aria-label");
|
|
71
|
+
if (own && roleOf(node))
|
|
72
|
+
return own;
|
|
73
|
+
let out = "";
|
|
74
|
+
for (const c of node.childNodes)
|
|
75
|
+
out += textOf(c) + (c.nodeType === 1 && win.getComputedStyle(c).display !== "inline" ? " " : "");
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** Accessible name and whether it came from the element's own content. */
|
|
79
|
+
function nameOf(el, role) {
|
|
80
|
+
const by = norm(el.getAttribute("aria-labelledby"));
|
|
81
|
+
if (by) {
|
|
82
|
+
const t = by.split(" ").map((id) => { const r = doc.getElementById(id); return r ? norm(textOf(r)) : ""; }).filter(Boolean).join(" ");
|
|
83
|
+
if (t)
|
|
84
|
+
return { name: t, fromContent: false };
|
|
85
|
+
}
|
|
86
|
+
const label = norm(el.getAttribute("aria-label"));
|
|
87
|
+
if (label)
|
|
88
|
+
return { name: label, fromContent: false };
|
|
89
|
+
const tag = el.tagName.toLowerCase();
|
|
90
|
+
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
91
|
+
const ls = el.labels ? [...el.labels] : [];
|
|
92
|
+
const t = norm(ls.map((l) => textOf(l)).join(" "));
|
|
93
|
+
if (t)
|
|
94
|
+
return { name: t, fromContent: false };
|
|
95
|
+
if (tag === "input" && /^(button|submit|reset)$/.test(el.type) && el.value)
|
|
96
|
+
return { name: norm(el.value), fromContent: false };
|
|
97
|
+
const ph = norm(el.getAttribute("placeholder")) || norm(el.getAttribute("title"));
|
|
98
|
+
return { name: ph, fromContent: false };
|
|
99
|
+
}
|
|
100
|
+
if (tag === "img")
|
|
101
|
+
return { name: norm(el.getAttribute("alt")) || norm(el.getAttribute("title")), fromContent: false };
|
|
102
|
+
if (FROM_CONTENT.has(role)) {
|
|
103
|
+
const t = norm(textOf(el));
|
|
104
|
+
if (t)
|
|
105
|
+
return { name: t, fromContent: true };
|
|
106
|
+
}
|
|
107
|
+
return { name: norm(el.getAttribute("title")), fromContent: false };
|
|
108
|
+
}
|
|
109
|
+
function states(el, role) {
|
|
110
|
+
let s = "";
|
|
111
|
+
const aria = (n) => el.getAttribute(n);
|
|
112
|
+
const on = (n) => aria(n) === "true";
|
|
113
|
+
if (role === "heading") {
|
|
114
|
+
const l = aria("aria-level") || (/^h([1-6])$/i.exec(el.tagName)?.[1] ?? "2");
|
|
115
|
+
s += " [level=" + l + "]";
|
|
116
|
+
}
|
|
117
|
+
if (/^(checkbox|radio|switch|menuitemcheckbox|menuitemradio)$/.test(role)) {
|
|
118
|
+
if (el.tagName === "INPUT" ? el.checked : on("aria-checked"))
|
|
119
|
+
s += " [checked]";
|
|
120
|
+
else if (aria("aria-checked") === "mixed")
|
|
121
|
+
s += " [checked=mixed]";
|
|
122
|
+
}
|
|
123
|
+
if (el.disabled === true || on("aria-disabled") || (el.closest && el.closest("fieldset[disabled]")))
|
|
124
|
+
s += " [disabled]";
|
|
125
|
+
if (on("aria-expanded"))
|
|
126
|
+
s += " [expanded]";
|
|
127
|
+
if (/^(tab|option|treeitem|row|gridcell)$/.test(role) && on("aria-selected"))
|
|
128
|
+
s += " [selected]";
|
|
129
|
+
if (on("aria-pressed"))
|
|
130
|
+
s += " [pressed]";
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
const quote = (s) => JSON.stringify(s);
|
|
134
|
+
// The simulator's snapshot is YAML-like: a key with ": " inside is wrapped in single quotes, a value with ": " in double quotes.
|
|
135
|
+
const needs = (t) => /: | #|:$|^[\[\]{}&*!|>'"%@`#?,-]/.test(t);
|
|
136
|
+
const keyLine = (pad, key, tail) => pad + "- " + (needs(key) ? "'" + key.replace(/'/g, "''") + "'" : key) + tail;
|
|
137
|
+
const val = (t) => (needs(t) ? JSON.stringify(t) : t);
|
|
138
|
+
function walk(el, depth, out) {
|
|
139
|
+
const tag = el.tagName.toLowerCase();
|
|
140
|
+
if (tag === "script" || tag === "style" || tag === "noscript" || tag === "template")
|
|
141
|
+
return;
|
|
142
|
+
if (hidden(el, win.getComputedStyle(el)))
|
|
143
|
+
return;
|
|
144
|
+
const role = roleOf(el);
|
|
145
|
+
const pad = " ".repeat(depth);
|
|
146
|
+
if (role) {
|
|
147
|
+
const { name, fromContent } = nameOf(el, role);
|
|
148
|
+
const key = role + (name ? " " + quote(name) : "") + states(el, role) + " [ref=" + refOf(el, role + "|" + name) + "]";
|
|
149
|
+
if (tag === "input" || tag === "textarea") {
|
|
150
|
+
const v = el.value;
|
|
151
|
+
out.push(keyLine(pad, key, v && !/^(checkbox|radio|button|submit|reset)$/.test(el.type) ? ": " + val(v) : ""));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (fromContent || tag === "img") {
|
|
155
|
+
out.push(keyLine(pad, key, ""));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const mark = out.length;
|
|
159
|
+
out.push("");
|
|
160
|
+
for (const c of el.childNodes)
|
|
161
|
+
child(c, depth + 1, out);
|
|
162
|
+
out[mark] = keyLine(pad, key, out.length > mark + 1 ? ":" : "");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// No role: keep the text, drop the wrapper. A wrapper with only text becomes one generic line.
|
|
166
|
+
const hasEl = [...el.children].length > 0;
|
|
167
|
+
if (!hasEl) {
|
|
168
|
+
const t = norm(el.textContent);
|
|
169
|
+
if (t)
|
|
170
|
+
out.push(pad + "- generic [ref=" + refOf(el, "generic|" + t) + "]: " + val(t));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
for (const c of el.childNodes)
|
|
174
|
+
child(c, depth, out);
|
|
175
|
+
}
|
|
176
|
+
function child(c, depth, out) {
|
|
177
|
+
if (c.nodeType === 1)
|
|
178
|
+
walk(c, depth, out);
|
|
179
|
+
else if (c.nodeType === 3) {
|
|
180
|
+
const t = norm(c.nodeValue);
|
|
181
|
+
if (t)
|
|
182
|
+
out.push(" ".repeat(depth) + "- text: " + val(t));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function tree() {
|
|
186
|
+
const frame = doc.getElementById("app");
|
|
187
|
+
const d = frame && frame.contentDocument;
|
|
188
|
+
if (!d || !d.body)
|
|
189
|
+
throw new Error("the app is not loaded yet");
|
|
190
|
+
globalThis.__shipvoRules.clearScreenRules(d);
|
|
191
|
+
globalThis.__shipvoRules.applyScreenRules(d);
|
|
192
|
+
try {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const c of d.body.childNodes)
|
|
195
|
+
child(c, 0, out);
|
|
196
|
+
return globalThis.__shipvoRules.filterTree(out.join("\n"));
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
globalThis.__shipvoRules.clearScreenRules(d);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Same code and message shape as the simulator's typed errors. */
|
|
203
|
+
class ActError extends Error {
|
|
204
|
+
code;
|
|
205
|
+
constructor(code, msg) { super(msg); this.code = code; }
|
|
206
|
+
}
|
|
207
|
+
const sigOf = (el) => { const role = roleOf(el); return role ? role + "|" + nameOf(el, role).name : "generic|" + norm(el.textContent); };
|
|
208
|
+
const appDoc = () => { const f = doc.getElementById("app"); return f && f.contentDocument; };
|
|
209
|
+
/** The element for a ref from a snapshot, or `stale_ref` if it is unknown, gone, left the page, or changed role or name. */
|
|
210
|
+
function resolve(ref) {
|
|
211
|
+
const stale = () => new ActError("stale_ref", `stale ref ${ref}: the screen changed. Take a new snapshot (action "screen") and use a ref from it.`);
|
|
212
|
+
const d = appDoc();
|
|
213
|
+
const rec = byRef.get(String(ref));
|
|
214
|
+
if (!d || !rec || !rec.el.isConnected || rec.el.ownerDocument !== d)
|
|
215
|
+
throw stale();
|
|
216
|
+
if (sigOf(rec.el) !== rec.sig || refs.get(rec.el)?.sig !== rec.sig)
|
|
217
|
+
throw stale();
|
|
218
|
+
return rec.el;
|
|
219
|
+
}
|
|
220
|
+
/** Say why an element cannot receive a tap. Nothing has reached the app when this throws. */
|
|
221
|
+
function ready(el, ref) {
|
|
222
|
+
const fail = (code, why) => new ActError(code, `${ref} ${why}, so nothing was sent to the app.`);
|
|
223
|
+
if (hidden(el, win.getComputedStyle(el)) || el.getClientRects().length === 0)
|
|
224
|
+
throw fail("not_visible", "is not visible");
|
|
225
|
+
if (el.disabled === true || el.getAttribute("aria-disabled") === "true" || (el.closest && el.closest("fieldset[disabled]")))
|
|
226
|
+
throw fail("disabled", "is disabled");
|
|
227
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
228
|
+
const r = el.getBoundingClientRect();
|
|
229
|
+
const w = el.ownerDocument.defaultView;
|
|
230
|
+
if (r.right <= 0 || r.bottom <= 0 || r.left >= w.innerWidth || r.top >= w.innerHeight)
|
|
231
|
+
throw fail("not_visible", "is not visible");
|
|
232
|
+
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
233
|
+
const top = el.ownerDocument.elementFromPoint(x, y);
|
|
234
|
+
if (top && top !== el && !el.contains(top) && !top.contains(el))
|
|
235
|
+
throw fail("covered", "is covered by another element");
|
|
236
|
+
return { x, y, target: top && el.contains(top) ? top : el };
|
|
237
|
+
}
|
|
238
|
+
function tap(el, ref) {
|
|
239
|
+
const { x, y, target } = ready(el, ref);
|
|
240
|
+
mark(x, y, el);
|
|
241
|
+
const w = el.ownerDocument.defaultView;
|
|
242
|
+
const init = { bubbles: true, cancelable: true, composed: true, view: w, clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: "mouse", isPrimary: true };
|
|
243
|
+
const fire = (Ctor, type, buttons) => target.dispatchEvent(new Ctor(type, { ...init, buttons }));
|
|
244
|
+
fire(w.PointerEvent, "pointerdown", 1);
|
|
245
|
+
fire(w.MouseEvent, "mousedown", 1);
|
|
246
|
+
if (typeof el.focus === "function")
|
|
247
|
+
el.focus({ preventScroll: true });
|
|
248
|
+
fire(w.PointerEvent, "pointerup", 0);
|
|
249
|
+
fire(w.MouseEvent, "mouseup", 0);
|
|
250
|
+
fire(w.MouseEvent, "click", 0);
|
|
251
|
+
}
|
|
252
|
+
function type(el, ref, text, enter) {
|
|
253
|
+
const field = /^(INPUT|TEXTAREA)$/.test(el.tagName) ? el : el.querySelector("input, textarea");
|
|
254
|
+
if (!field)
|
|
255
|
+
throw new ActError("not_a_field", `${ref} is not a text field. Type into a "textbox" ref.`);
|
|
256
|
+
ready(field, ref);
|
|
257
|
+
const w = field.ownerDocument.defaultView;
|
|
258
|
+
field.focus();
|
|
259
|
+
const proto = field.tagName === "TEXTAREA" ? w.HTMLTextAreaElement.prototype : w.HTMLInputElement.prototype;
|
|
260
|
+
Object.getOwnPropertyDescriptor(proto, "value").set.call(field, text);
|
|
261
|
+
field.dispatchEvent(new w.InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
|
|
262
|
+
field.dispatchEvent(new w.Event("change", { bubbles: true }));
|
|
263
|
+
if (enter)
|
|
264
|
+
for (const t of ["keydown", "keypress", "keyup"])
|
|
265
|
+
field.dispatchEvent(new w.KeyboardEvent(t, { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true }));
|
|
266
|
+
return field.value;
|
|
267
|
+
}
|
|
268
|
+
function scroll(ref, direction) {
|
|
269
|
+
const d = appDoc();
|
|
270
|
+
const w = d.defaultView;
|
|
271
|
+
const dx = direction === "right" ? 300 : direction === "left" ? -300 : 0;
|
|
272
|
+
const dy = direction === "down" ? 600 : direction === "up" ? -600 : 0;
|
|
273
|
+
if (!dx && !dy)
|
|
274
|
+
throw new ActError("bad_direction", "scroll needs a direction: up, down, left or right.");
|
|
275
|
+
let el = ref ? resolve(ref) : d.body;
|
|
276
|
+
// The nearest ancestor that can scroll that way. Fall back to the page.
|
|
277
|
+
for (; el && el !== d.body && el !== d.documentElement; el = el.parentElement) {
|
|
278
|
+
const cs = w.getComputedStyle(el);
|
|
279
|
+
if (dy && /auto|scroll/.test(cs.overflowY) && el.scrollHeight > el.clientHeight)
|
|
280
|
+
break;
|
|
281
|
+
if (dx && /auto|scroll/.test(cs.overflowX) && el.scrollWidth > el.clientWidth)
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
if (!el || el === d.body || el === d.documentElement)
|
|
285
|
+
el = d.scrollingElement || d.documentElement;
|
|
286
|
+
el.scrollBy({ left: dx, top: dy, behavior: "instant" });
|
|
287
|
+
}
|
|
288
|
+
function back() {
|
|
289
|
+
const d = appDoc();
|
|
290
|
+
for (const el of d.querySelectorAll("button, [role=button], input[type=button], input[type=submit]")) {
|
|
291
|
+
const role = roleOf(el);
|
|
292
|
+
if (role === "button" && !hidden(el, win.getComputedStyle(el)) && nameOf(el, role).name === "Back") {
|
|
293
|
+
tap(el, "Back");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
throw new Error("no Back button on this screen");
|
|
298
|
+
}
|
|
299
|
+
// The person can see and stop the agent (slice 4 of #448). Overlay elements live in the frame page, outside the app iframe.
|
|
300
|
+
let seenAgent = false, taken = false, hideTimer;
|
|
301
|
+
const css = doc.createElement("style");
|
|
302
|
+
css.textContent = "#shipvo-agent{position:fixed;left:50%;top:40px;transform:translateX(-50%);z-index:2147483000;display:none;align-items:center;gap:8px;padding:4px 12px;border-radius:14px;background:#4f46e5;color:#fff;font:12px system-ui,sans-serif}" +
|
|
303
|
+
"#shipvo-agent.paused{background:#b45309}#shipvo-agent button{border:0;border-radius:10px;padding:2px 10px;font:inherit;cursor:pointer;display:none}#shipvo-agent.paused button{display:inline-block}" +
|
|
304
|
+
"#shipvo-capture{position:fixed;left:50%;top:84px;transform:translateX(-50%);z-index:2147483001;display:none;align-items:center;gap:8px;padding:6px 12px;border-radius:14px;background:#111;color:#fff;font:12px system-ui,sans-serif;white-space:nowrap;max-width:calc(100vw - 24px);box-sizing:border-box}#shipvo-capture button{border:0;border-radius:10px;padding:2px 10px;font:inherit;cursor:pointer}#shipvo-capture button:focus-visible{outline:2px solid #fff;outline-offset:2px}" +
|
|
305
|
+
"#shipvo-dot{position:fixed;width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;background:#4f46e5aa;border:2px solid #fff;box-shadow:0 0 0 2px #4f46e5;pointer-events:none;z-index:2147483000;display:none}" +
|
|
306
|
+
"#shipvo-outline{position:fixed;border:2px solid #4f46e5;border-radius:4px;pointer-events:none;z-index:2147483000;display:none}" +
|
|
307
|
+
"#shipvo-log{position:fixed;right:12px;bottom:44px;width:240px;max-width:calc(100vw - 24px);box-sizing:border-box;max-height:25%;overflow:auto;margin:0;padding:8px 8px 8px 26px;border-radius:8px;background:#111d;color:#fff;font:12px system-ui,sans-serif;z-index:2147483000;display:none}#shipvo-log li{margin:2px 0}" +
|
|
308
|
+
"#shipvo-log-toggle{position:fixed;right:12px;bottom:12px;z-index:2147483000;display:none;border:0;border-radius:10px;padding:4px 10px;background:#111d;color:#fff;font:12px system-ui,sans-serif;cursor:pointer}";
|
|
309
|
+
doc.head.appendChild(css);
|
|
310
|
+
const make = (tag, id) => { const e = doc.createElement(tag); e.id = id; doc.body.appendChild(e); return e; };
|
|
311
|
+
const banner = make("div", "shipvo-agent");
|
|
312
|
+
banner.setAttribute("role", "status");
|
|
313
|
+
const bannerText = doc.createElement("span");
|
|
314
|
+
banner.appendChild(bannerText);
|
|
315
|
+
const handBack = doc.createElement("button");
|
|
316
|
+
handBack.type = "button";
|
|
317
|
+
handBack.textContent = "Hand back";
|
|
318
|
+
banner.appendChild(handBack);
|
|
319
|
+
const dot = make("div", "shipvo-dot"), outline = make("div", "shipvo-outline"), log = make("ol", "shipvo-log");
|
|
320
|
+
log.setAttribute("aria-label", "Agent steps");
|
|
321
|
+
// The log can be hidden so it never covers what the agent is tapping.
|
|
322
|
+
let logHidden = false;
|
|
323
|
+
const logToggle = make("button", "shipvo-log-toggle");
|
|
324
|
+
logToggle.type = "button";
|
|
325
|
+
logToggle.textContent = "Hide steps";
|
|
326
|
+
logToggle.setAttribute("aria-expanded", "true");
|
|
327
|
+
const showLog = () => { log.style.display = logHidden ? "none" : "block"; logToggle.style.display = "block"; logToggle.textContent = logHidden ? "Show steps" : "Hide steps"; logToggle.setAttribute("aria-expanded", String(!logHidden)); };
|
|
328
|
+
logToggle.onclick = () => { logHidden = !logHidden; showLog(); };
|
|
329
|
+
const showBanner = () => {
|
|
330
|
+
banner.style.display = "flex";
|
|
331
|
+
banner.classList.toggle("paused", taken);
|
|
332
|
+
bannerText.textContent = taken ? "You took over. The agent is paused." : "Agent is driving";
|
|
333
|
+
};
|
|
334
|
+
const step = (text, simulated = false) => {
|
|
335
|
+
const li = doc.createElement("li");
|
|
336
|
+
li.textContent = text + (simulated ? " (simulated)" : "");
|
|
337
|
+
if (simulated)
|
|
338
|
+
li.setAttribute("data-simulated", "true");
|
|
339
|
+
log.appendChild(li);
|
|
340
|
+
showLog();
|
|
341
|
+
log.scrollTop = log.scrollHeight;
|
|
342
|
+
while (log.children.length > 50)
|
|
343
|
+
log.removeChild(log.firstChild);
|
|
344
|
+
};
|
|
345
|
+
/** Where the scaled app iframe is on the frame page. */
|
|
346
|
+
const toPage = () => { const f = doc.getElementById("app"); const r = f.getBoundingClientRect(); const k = f.offsetWidth ? r.width / f.offsetWidth : 1; return { left: r.left, top: r.top, k }; };
|
|
347
|
+
function mark(x, y, el) {
|
|
348
|
+
const o = toPage(), b = el.getBoundingClientRect();
|
|
349
|
+
dot.style.left = o.left + x * o.k + "px";
|
|
350
|
+
dot.style.top = o.top + y * o.k + "px";
|
|
351
|
+
dot.style.display = "block";
|
|
352
|
+
Object.assign(outline.style, { left: o.left + b.left * o.k + "px", top: o.top + b.top * o.k + "px", width: b.width * o.k + "px", height: b.height * o.k + "px", display: "block" });
|
|
353
|
+
win.clearTimeout(hideTimer);
|
|
354
|
+
hideTimer = win.setTimeout(() => { dot.style.display = "none"; outline.style.display = "none"; }, 1500);
|
|
355
|
+
}
|
|
356
|
+
/** Plain words for a step that worked. Taken before the action runs, so the name is the one the agent saw. */
|
|
357
|
+
const describe = (m) => {
|
|
358
|
+
const target = () => { try {
|
|
359
|
+
const e = byRef.get(String(m.ref))?.el;
|
|
360
|
+
if (!e)
|
|
361
|
+
return "an element";
|
|
362
|
+
const r = roleOf(e);
|
|
363
|
+
const n = r ? nameOf(e, r).name : norm(e.textContent);
|
|
364
|
+
return (r || "element") + (n ? " " + quote(n) : "");
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return "an element";
|
|
368
|
+
} };
|
|
369
|
+
if (m.type === "tap")
|
|
370
|
+
return "Tapped " + target();
|
|
371
|
+
if (m.type === "type")
|
|
372
|
+
return "Typed " + quote(String(m.text ?? "")) + " into " + target() + (m.enter ? " and pressed Enter" : "");
|
|
373
|
+
if (m.type === "scroll")
|
|
374
|
+
return "Scrolled " + m.direction + (m.ref ? " in " + target() : "");
|
|
375
|
+
return "Pressed Back";
|
|
376
|
+
};
|
|
377
|
+
/** Lumi's caption for a step, from the same element the agent named: "Tapping Checkout". Nothing is invented; an unnamed element is "an element". */
|
|
378
|
+
const caption = (m) => {
|
|
379
|
+
const target = () => { try {
|
|
380
|
+
const e = byRef.get(String(m.ref))?.el;
|
|
381
|
+
if (!e)
|
|
382
|
+
return "an element";
|
|
383
|
+
const r = roleOf(e);
|
|
384
|
+
const n = r ? nameOf(e, r).name : norm(e.textContent);
|
|
385
|
+
return n || "an element";
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return "an element";
|
|
389
|
+
} };
|
|
390
|
+
if (m.type === "tap")
|
|
391
|
+
return "Tapping " + target();
|
|
392
|
+
if (m.type === "type")
|
|
393
|
+
return "Typing into " + target();
|
|
394
|
+
if (m.type === "scroll")
|
|
395
|
+
return "Scrolling " + m.direction + (m.ref ? " in " + target() : "");
|
|
396
|
+
return "Going back";
|
|
397
|
+
};
|
|
398
|
+
/** Shows a caption on Lumi in the phone frame. Does nothing when the frame has no Lumi (it is off). */
|
|
399
|
+
const lumiSay = (text, state, hold = 0) => { try {
|
|
400
|
+
win.shipvoLumi && win.shipvoLumi.say(text, state, hold);
|
|
401
|
+
}
|
|
402
|
+
catch { } };
|
|
403
|
+
function takeOver(e) {
|
|
404
|
+
if (!e.isTrusted || taken || !seenAgent)
|
|
405
|
+
return;
|
|
406
|
+
if (e.target && e.target.closest && e.target.closest("#shipvo-agent, #shipvo-log-toggle, #shipvo-capture"))
|
|
407
|
+
return;
|
|
408
|
+
taken = true;
|
|
409
|
+
showBanner();
|
|
410
|
+
step("You took over. The agent is paused.");
|
|
411
|
+
}
|
|
412
|
+
handBack.onclick = () => { taken = false; showBanner(); step("You handed back. The agent can continue."); };
|
|
413
|
+
const watch = (d) => { if (!d)
|
|
414
|
+
return; for (const t of ["pointerdown", "touchstart", "keydown"])
|
|
415
|
+
d.addEventListener(t, takeOver, true); };
|
|
416
|
+
watch(doc);
|
|
417
|
+
const frameEl = doc.getElementById("app");
|
|
418
|
+
const watchApp = () => { try {
|
|
419
|
+
watch(frameEl.contentDocument);
|
|
420
|
+
}
|
|
421
|
+
catch { } };
|
|
422
|
+
frameEl.addEventListener("load", watchApp);
|
|
423
|
+
watchApp();
|
|
424
|
+
// Lumi (when the frame draws it) reacts to a page error in the app and to a failed action, with the real message as the reason.
|
|
425
|
+
const lumi = (ev) => { try {
|
|
426
|
+
win.shipvoLumi?.react(ev);
|
|
427
|
+
}
|
|
428
|
+
catch { } };
|
|
429
|
+
const watchErrors = () => {
|
|
430
|
+
try {
|
|
431
|
+
const w = frameEl.contentWindow;
|
|
432
|
+
w.addEventListener("error", (e) => lumi({ kind: "page-error", error: e.message || (e.error && e.error.message) }));
|
|
433
|
+
w.addEventListener("unhandledrejection", (e) => lumi({ kind: "page-error", error: e.reason && e.reason.message ? e.reason.message : e.reason }));
|
|
434
|
+
}
|
|
435
|
+
catch { }
|
|
436
|
+
};
|
|
437
|
+
frameEl.addEventListener("load", watchErrors);
|
|
438
|
+
watchErrors();
|
|
439
|
+
// Device events from the simulated phone are not the app's own: the log marks them.
|
|
440
|
+
win.addEventListener("message", (e) => {
|
|
441
|
+
const d = e.data;
|
|
442
|
+
if (d && d.type === "shipvo:system-sheet" && seenAgent)
|
|
443
|
+
step(d.open !== false ? "System permission sheet opened" : "System permission sheet closed", true);
|
|
444
|
+
});
|
|
445
|
+
const sleep = (ms) => new Promise((r) => win.setTimeout(r, ms));
|
|
446
|
+
/** Give the app a moment to react, and wait for a page load a tap started. */
|
|
447
|
+
async function settle() {
|
|
448
|
+
await sleep(250);
|
|
449
|
+
for (let i = 0; i < 30; i++) {
|
|
450
|
+
const d = appDoc();
|
|
451
|
+
if (d && d.readyState === "complete" && d.body)
|
|
452
|
+
break;
|
|
453
|
+
await sleep(100);
|
|
454
|
+
}
|
|
455
|
+
await sleep(50);
|
|
456
|
+
}
|
|
457
|
+
const screenOrUndefined = () => { try {
|
|
458
|
+
return tree();
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return undefined;
|
|
462
|
+
} };
|
|
463
|
+
async function act(m) {
|
|
464
|
+
switch (m.type) {
|
|
465
|
+
case "tap":
|
|
466
|
+
tap(resolve(m.ref), m.ref);
|
|
467
|
+
break;
|
|
468
|
+
case "type": {
|
|
469
|
+
const value = type(resolve(m.ref), m.ref, String(m.text ?? ""), !!m.enter);
|
|
470
|
+
await settle();
|
|
471
|
+
return { delivery: "delivered", value, screen: tree() };
|
|
472
|
+
}
|
|
473
|
+
case "scroll":
|
|
474
|
+
scroll(m.ref, m.direction);
|
|
475
|
+
break;
|
|
476
|
+
case "back":
|
|
477
|
+
back();
|
|
478
|
+
break;
|
|
479
|
+
default: throw new Error("unknown request: " + m.type);
|
|
480
|
+
}
|
|
481
|
+
await settle();
|
|
482
|
+
return { delivery: "delivered", screen: tree() };
|
|
483
|
+
}
|
|
484
|
+
// Screenshots (slice 5 of #448). Order: tab capture if the person said yes, else the iframe drawn to an image.
|
|
485
|
+
// The permission is asked once, on the first request from the agent. getDisplayMedia needs a click, so the question is a button.
|
|
486
|
+
let capture = "ask", stream, video, asked = false;
|
|
487
|
+
const bar = make("div", "shipvo-capture");
|
|
488
|
+
bar.setAttribute("role", "group");
|
|
489
|
+
bar.setAttribute("aria-label", "Let the agent see this tab?");
|
|
490
|
+
const barText = doc.createElement("span");
|
|
491
|
+
barText.textContent = "Let the agent see this tab?";
|
|
492
|
+
bar.appendChild(barText);
|
|
493
|
+
const allow = doc.createElement("button");
|
|
494
|
+
allow.type = "button";
|
|
495
|
+
allow.textContent = "Allow";
|
|
496
|
+
bar.appendChild(allow);
|
|
497
|
+
const deny = doc.createElement("button");
|
|
498
|
+
deny.type = "button";
|
|
499
|
+
deny.textContent = "Not now";
|
|
500
|
+
bar.appendChild(deny);
|
|
501
|
+
const askCapture = () => { if (asked)
|
|
502
|
+
return; asked = true; bar.style.display = "flex"; };
|
|
503
|
+
deny.onclick = () => { capture = "refused"; bar.style.display = "none"; step("The agent cannot see this tab. Screenshots are drawn from the page."); };
|
|
504
|
+
allow.onclick = async () => {
|
|
505
|
+
bar.style.display = "none";
|
|
506
|
+
try {
|
|
507
|
+
stream = await win.navigator.mediaDevices.getDisplayMedia({ video: true, audio: false, preferCurrentTab: true });
|
|
508
|
+
video = doc.createElement("video");
|
|
509
|
+
video.muted = true;
|
|
510
|
+
video.srcObject = stream;
|
|
511
|
+
await video.play();
|
|
512
|
+
const surface = stream.getVideoTracks()[0]?.getSettings?.().displaySurface;
|
|
513
|
+
if (surface && surface !== "browser") {
|
|
514
|
+
for (const t of stream.getTracks())
|
|
515
|
+
t.stop();
|
|
516
|
+
stream = undefined;
|
|
517
|
+
capture = "refused";
|
|
518
|
+
step("The shared surface is not this tab. Screenshots are drawn from the page.");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
for (const t of stream.getTracks())
|
|
522
|
+
t.addEventListener("ended", () => { capture = "ask"; asked = false; stream = undefined; step("Tab sharing stopped. Screenshots are drawn from the page; the next agent action asks again."); });
|
|
523
|
+
capture = "granted";
|
|
524
|
+
step("The agent can see this tab.");
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
capture = "refused";
|
|
528
|
+
step("Tab capture was refused. Screenshots are drawn from the page.");
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
// The socket drops any message over 1 MiB, so a screenshot is kept under SHOT_MAX base64 characters.
|
|
532
|
+
const SHOT_MAX = 700_000;
|
|
533
|
+
const blob64 = (c, type, q) => new Promise((ok, fail) => c.toBlob((b) => {
|
|
534
|
+
if (!b)
|
|
535
|
+
return fail(new Error("could not make an image"));
|
|
536
|
+
const r = new win.FileReader();
|
|
537
|
+
r.onload = () => ok(String(r.result).split(",")[1]);
|
|
538
|
+
r.onerror = () => fail(new Error("could not read the image"));
|
|
539
|
+
r.readAsDataURL(b);
|
|
540
|
+
}, type, q));
|
|
541
|
+
/** PNG if it fits. Otherwise a smaller PNG, then JPEG at lower scale and quality. Throws if nothing fits. */
|
|
542
|
+
async function png(c) {
|
|
543
|
+
const steps = [["image/png", 1, 1], ["image/png", 0.5, 1], ["image/jpeg", 1, 0.8], ["image/jpeg", 0.5, 0.6], ["image/jpeg", 0.3, 0.5]];
|
|
544
|
+
for (const [mime, scale, q] of steps) {
|
|
545
|
+
let src = c;
|
|
546
|
+
if (scale < 1) {
|
|
547
|
+
src = doc.createElement("canvas");
|
|
548
|
+
src.width = Math.max(1, Math.round(c.width * scale));
|
|
549
|
+
src.height = Math.max(1, Math.round(c.height * scale));
|
|
550
|
+
const g = src.getContext("2d");
|
|
551
|
+
g.fillStyle = "#fff";
|
|
552
|
+
g.fillRect(0, 0, src.width, src.height);
|
|
553
|
+
g.drawImage(c, 0, 0, src.width, src.height);
|
|
554
|
+
}
|
|
555
|
+
const image = await blob64(src, mime, q);
|
|
556
|
+
if (image.length <= SHOT_MAX)
|
|
557
|
+
return { image, mime, width: src.width, height: src.height };
|
|
558
|
+
}
|
|
559
|
+
throw new Error("the screenshot is too large to send, even scaled down");
|
|
560
|
+
}
|
|
561
|
+
/** A frame of the live tab, cropped to the phone. */
|
|
562
|
+
async function tabShot() {
|
|
563
|
+
for (let i = 0; i < 20 && !video.videoWidth; i++)
|
|
564
|
+
await sleep(50);
|
|
565
|
+
await new Promise((r) => win.requestAnimationFrame(() => r(0)));
|
|
566
|
+
await sleep(60);
|
|
567
|
+
const k = video.videoWidth / win.innerWidth, r = doc.getElementById("phone").getBoundingClientRect();
|
|
568
|
+
const c = doc.createElement("canvas");
|
|
569
|
+
c.width = Math.max(1, Math.round(r.width * k));
|
|
570
|
+
c.height = Math.max(1, Math.round(r.height * k));
|
|
571
|
+
c.getContext("2d").drawImage(video, r.left * k, r.top * k, r.width * k, r.height * k, 0, 0, c.width, c.height);
|
|
572
|
+
return await png(c);
|
|
573
|
+
}
|
|
574
|
+
/** The app iframe's DOM drawn to a canvas through an SVG foreignObject. Says what it could not draw. */
|
|
575
|
+
async function renderedShot() {
|
|
576
|
+
const d = appDoc();
|
|
577
|
+
if (!d)
|
|
578
|
+
throw new Error("the app is not loaded");
|
|
579
|
+
const w = d.defaultView, W = w.innerWidth, H = w.innerHeight, skipped = new Set();
|
|
580
|
+
const clone = d.documentElement.cloneNode(true);
|
|
581
|
+
const pair = (sel) => { const a = Array.from(d.querySelectorAll(sel)), b = Array.from(clone.querySelectorAll(sel)); return a.map((x, i) => [x, b[i]]); };
|
|
582
|
+
const placeholder = (src, dst, what) => {
|
|
583
|
+
skipped.add(what);
|
|
584
|
+
const ph = d.createElement("div");
|
|
585
|
+
const r = src.getBoundingClientRect();
|
|
586
|
+
ph.setAttribute("style", "display:inline-block;width:" + r.width + "px;height:" + r.height + "px;background:#8883");
|
|
587
|
+
dst.replaceWith(ph);
|
|
588
|
+
};
|
|
589
|
+
for (const n of Array.from(clone.querySelectorAll("script, noscript, template")))
|
|
590
|
+
n.remove();
|
|
591
|
+
for (const [a, b] of pair("video"))
|
|
592
|
+
placeholder(a, b, "video");
|
|
593
|
+
for (const [a, b] of pair("iframe"))
|
|
594
|
+
placeholder(a, b, "iframe");
|
|
595
|
+
for (const [a, b] of pair("canvas")) {
|
|
596
|
+
try {
|
|
597
|
+
b.replaceWith(Object.assign(d.createElement("img"), { src: a.toDataURL(), width: a.width, height: a.height }));
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
placeholder(a, b, "canvas");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
for (const [a, b] of pair("img")) {
|
|
604
|
+
try {
|
|
605
|
+
if (!a.complete || !a.naturalWidth)
|
|
606
|
+
throw new Error("not loaded");
|
|
607
|
+
const c = d.createElement("canvas");
|
|
608
|
+
c.width = a.naturalWidth;
|
|
609
|
+
c.height = a.naturalHeight;
|
|
610
|
+
c.getContext("2d").drawImage(a, 0, 0);
|
|
611
|
+
b.removeAttribute("srcset");
|
|
612
|
+
b.setAttribute("src", c.toDataURL());
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
skipped.add("cross-origin images");
|
|
616
|
+
b.removeAttribute("srcset");
|
|
617
|
+
b.removeAttribute("src");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
for (const [a, b] of pair("link[rel~=stylesheet]")) {
|
|
621
|
+
try {
|
|
622
|
+
const st = d.createElement("style");
|
|
623
|
+
st.textContent = Array.from(a.sheet.cssRules).map((r) => r.cssText).join("\n");
|
|
624
|
+
b.replaceWith(st);
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
skipped.add("cross-origin stylesheets");
|
|
628
|
+
b.remove();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
for (const [a, b] of pair("input, textarea")) {
|
|
632
|
+
if (a.tagName === "TEXTAREA")
|
|
633
|
+
b.textContent = a.value;
|
|
634
|
+
else
|
|
635
|
+
b.setAttribute("value", a.value);
|
|
636
|
+
if (a.checked)
|
|
637
|
+
b.setAttribute("checked", "");
|
|
638
|
+
}
|
|
639
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H + '"><foreignObject width="100%" height="100%">' + new win.XMLSerializer().serializeToString(clone) + "</foreignObject></svg>";
|
|
640
|
+
const img = new win.Image();
|
|
641
|
+
await new Promise((ok, fail) => { img.onload = ok; img.onerror = () => fail(new Error("the page could not be drawn")); img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); });
|
|
642
|
+
const c = doc.createElement("canvas");
|
|
643
|
+
c.width = W;
|
|
644
|
+
c.height = H;
|
|
645
|
+
const g = c.getContext("2d");
|
|
646
|
+
g.fillStyle = "#fff";
|
|
647
|
+
g.fillRect(0, 0, W, H);
|
|
648
|
+
g.drawImage(img, 0, 0);
|
|
649
|
+
return { ...(await png(c)), unsupported: Array.from(skipped) };
|
|
650
|
+
}
|
|
651
|
+
async function shot() {
|
|
652
|
+
if (capture === "granted" && stream) {
|
|
653
|
+
try {
|
|
654
|
+
return { source: "tab-capture", ...(await tabShot()) };
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
capture = "refused";
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { source: "rendered", ...(await renderedShot()) };
|
|
661
|
+
}
|
|
662
|
+
const ws = new win.WebSocket((win.location.protocol === "https:" ? "wss://" : "ws://") + win.location.host + "/__shipvo/agent?role=screen&token=" + encodeURIComponent(token));
|
|
663
|
+
ws.onmessage = (e) => {
|
|
664
|
+
let m;
|
|
665
|
+
try {
|
|
666
|
+
m = JSON.parse(String(e.data));
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (!m || typeof m.id !== "number")
|
|
672
|
+
return;
|
|
673
|
+
// Reading the screen changes nothing, so it neither counts as the agent arriving nor is blocked by a take-over.
|
|
674
|
+
if (m.type !== "tree")
|
|
675
|
+
seenAgent = true;
|
|
676
|
+
if (m.type !== "tree" && m.type !== "screen")
|
|
677
|
+
askCapture();
|
|
678
|
+
if (m.type === "screenshot") {
|
|
679
|
+
const out = (o) => ws.send(JSON.stringify(o));
|
|
680
|
+
shot().then((r) => { const body = JSON.stringify({ id: m.id, type: "screenshot", ok: true, ...r, screen: screenOrUndefined() }); if (body.length > 1000000)
|
|
681
|
+
throw new Error("the screenshot reply is too large to send"); ws.send(body); }).catch((err) => out({ id: m.id, type: "screenshot", ok: false, error: String(err && err.message || err), screen: screenOrUndefined() }));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (taken && m.type !== "tree") {
|
|
685
|
+
ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, code: "taken_over", error: "the person took over" }));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const words = /^(tap|type|scroll|back)$/.test(m.type) ? (showBanner(), describe(m)) : "";
|
|
689
|
+
const cap = /^(tap|type|scroll|back)$/.test(m.type) ? caption(m) : "";
|
|
690
|
+
if (cap)
|
|
691
|
+
lumiSay(cap, "working");
|
|
692
|
+
if (m.type === "lumi") {
|
|
693
|
+
// The MCP server names a step the page cannot see itself (opening the app). The text is cut to 80 characters in the frame.
|
|
694
|
+
const ok = typeof m.caption === "string" && ["working", "idle", "happy", "worried"].includes(m.state);
|
|
695
|
+
if (ok)
|
|
696
|
+
lumiSay(m.caption, m.state, Number(m.hold) > 0 ? Math.min(Number(m.hold), 10000) : 0);
|
|
697
|
+
ws.send(JSON.stringify({ id: m.id, type: "lumi", ok }));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
if (m.type === "screen")
|
|
702
|
+
ws.send(JSON.stringify({ id: m.id, type: "screen", ok: true, screen: tree() }));
|
|
703
|
+
else if (m.type === "tree")
|
|
704
|
+
ws.send(JSON.stringify({ id: m.id, type: "tree", ok: true, tree: tree() }));
|
|
705
|
+
else if (/^(tap|type|scroll|back)$/.test(m.type)) {
|
|
706
|
+
act(m).then((r) => { step(words); lumiSay(cap, "idle", 1800); return r; }).then((r) => ws.send(JSON.stringify({ id: m.id, type: m.type, ok: true, ...r })), (err) => { lumiSay("Failed: " + cap, "worried", 2500); lumi({ kind: "action-failed", name: m.type, error: err && err.message || err }); ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, ...(err && err.code ? { code: err.code } : {}), error: String(err && err.message || err), screen: screenOrUndefined() })); });
|
|
707
|
+
}
|
|
708
|
+
else
|
|
709
|
+
ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, error: "unknown request: " + m.type }));
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, error: String(err && err.message || err) }));
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/** The script text served at `/__shipvo/bridge.js`. It reads the session token from its own `data-token` attribute and then removes the attribute. */
|
|
717
|
+
export function bridgeScript(rules = screenRulesSource()) {
|
|
718
|
+
return `(function(){
|
|
719
|
+
var __rules=(function(){${rules}
|
|
720
|
+
return {clearScreenRules:clearScreenRules,applyScreenRules:applyScreenRules,filterTree:filterTree};})();
|
|
721
|
+
globalThis.__shipvoRules=__rules;
|
|
722
|
+
var tag=document.currentScript;
|
|
723
|
+
var tok=tag?tag.getAttribute("data-token")||"":"";
|
|
724
|
+
if(tag)tag.removeAttribute("data-token");
|
|
725
|
+
(${bridgeMain.toString()})(window, tok);
|
|
726
|
+
})();
|
|
727
|
+
`;
|
|
728
|
+
}
|