@shipvo/cli 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/adopt.js +1 -2
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +68 -9
- package/dist/approvals.d.ts +9 -0
- package/dist/approvals.js +106 -0
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +28 -11
- 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 +8 -0
- package/dist/ci.js +64 -6
- package/dist/cli.js +23 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.js +43 -3
- package/dist/doctor.js +63 -7
- package/dist/ensure-playwright.d.ts +4 -0
- package/dist/ensure-playwright.js +7 -2
- package/dist/explore-issues.d.ts +12 -0
- package/dist/explore-issues.js +50 -0
- package/dist/explore.d.ts +83 -7
- package/dist/explore.js +326 -77
- package/dist/flows.d.ts +31 -0
- package/dist/flows.js +264 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +19 -10
- package/dist/hermes.d.ts +10 -0
- package/dist/hermes.js +50 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +41 -6
- 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/preview.d.ts +1 -0
- package/dist/preview.js +4 -2
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +38 -16
- package/dist/publish.d.ts +14 -2
- package/dist/publish.js +126 -62
- package/dist/qr.d.ts +9 -0
- package/dist/qr.js +213 -0
- package/dist/report-md.d.ts +1 -0
- package/dist/report-md.js +1 -1
- package/dist/rotation.d.ts +57 -0
- package/dist/rotation.js +157 -0
- package/dist/run-bridge.js +356 -2
- package/dist/run-net.d.ts +39 -0
- package/dist/run-net.js +266 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +101 -17
- package/dist/signing.js +13 -0
- package/dist/sim.js +7 -3
- 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 +26 -4
- package/dist/status.js +69 -5
- package/dist/templates/github-action/shipvo-check.yml +18 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.js +81 -18
- package/package.json +4 -3
- package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/run-bridge.js
CHANGED
|
@@ -237,6 +237,7 @@ function bridgeMain(win, token) {
|
|
|
237
237
|
}
|
|
238
238
|
function tap(el, ref) {
|
|
239
239
|
const { x, y, target } = ready(el, ref);
|
|
240
|
+
mark(x, y, el);
|
|
240
241
|
const w = el.ownerDocument.defaultView;
|
|
241
242
|
const init = { bubbles: true, cancelable: true, composed: true, view: w, clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: "mouse", isPrimary: true };
|
|
242
243
|
const fire = (Ctor, type, buttons) => target.dispatchEvent(new Ctor(type, { ...init, buttons }));
|
|
@@ -295,6 +296,152 @@ function bridgeMain(win, token) {
|
|
|
295
296
|
}
|
|
296
297
|
throw new Error("no Back button on this screen");
|
|
297
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
|
+
});
|
|
298
445
|
const sleep = (ms) => new Promise((r) => win.setTimeout(r, ms));
|
|
299
446
|
/** Give the app a moment to react, and wait for a page load a tap started. */
|
|
300
447
|
async function settle() {
|
|
@@ -334,6 +481,184 @@ function bridgeMain(win, token) {
|
|
|
334
481
|
await settle();
|
|
335
482
|
return { delivery: "delivered", screen: tree() };
|
|
336
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
|
+
}
|
|
337
662
|
const ws = new win.WebSocket((win.location.protocol === "https:" ? "wss://" : "ws://") + win.location.host + "/__shipvo/agent?role=screen&token=" + encodeURIComponent(token));
|
|
338
663
|
ws.onmessage = (e) => {
|
|
339
664
|
let m;
|
|
@@ -345,11 +670,40 @@ function bridgeMain(win, token) {
|
|
|
345
670
|
}
|
|
346
671
|
if (!m || typeof m.id !== "number")
|
|
347
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
|
+
}
|
|
348
700
|
try {
|
|
349
|
-
if (m.type === "
|
|
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")
|
|
350
704
|
ws.send(JSON.stringify({ id: m.id, type: "tree", ok: true, tree: tree() }));
|
|
351
705
|
else if (/^(tap|type|scroll|back)$/.test(m.type)) {
|
|
352
|
-
act(m).then((r) => ws.send(JSON.stringify({ id: m.id, type: m.type, ok: true, ...r })), (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() })));
|
|
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() })); });
|
|
353
707
|
}
|
|
354
708
|
else
|
|
355
709
|
ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, error: "unknown request: " + m.type }));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export declare const NET_PATH = "/__shipvo/net";
|
|
3
|
+
export type NetProfile = {
|
|
4
|
+
latencyMs: number;
|
|
5
|
+
downKbps: number;
|
|
6
|
+
failureRate: number;
|
|
7
|
+
};
|
|
8
|
+
export type NetRecord = {
|
|
9
|
+
at: number;
|
|
10
|
+
session: string;
|
|
11
|
+
method: string;
|
|
12
|
+
host: string;
|
|
13
|
+
status: number;
|
|
14
|
+
ms: number;
|
|
15
|
+
profile: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
/** One cookie jar per phone session, like the cookies of one installed app. */
|
|
19
|
+
export declare class CookieJar {
|
|
20
|
+
private list;
|
|
21
|
+
set(url: URL, header: string, now?: number): void;
|
|
22
|
+
header(url: URL, now?: number): string;
|
|
23
|
+
}
|
|
24
|
+
/** The profile table from @shipvo/sim (one source of truth); a built-in copy if that file cannot be loaded. */
|
|
25
|
+
export declare function loadProfiles(): Promise<Record<string, NetProfile>>;
|
|
26
|
+
export type NetProxy = {
|
|
27
|
+
handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
28
|
+
log: NetRecord[];
|
|
29
|
+
};
|
|
30
|
+
/** The request handler for `NET_PATH`. `tokens` are the credentials it accepts; `port()` is the bound port. */
|
|
31
|
+
export declare function createNetProxy(o: {
|
|
32
|
+
tokens: string[];
|
|
33
|
+
port: () => number;
|
|
34
|
+
profiles: Record<string, NetProfile>;
|
|
35
|
+
onRecord?: (r: NetRecord) => void;
|
|
36
|
+
fetchImpl?: typeof fetch;
|
|
37
|
+
}): NetProxy;
|
|
38
|
+
/** The script that runs inside the app's page: sends cross-origin fetch and XMLHttpRequest through `NET_PATH`. */
|
|
39
|
+
export declare function netClientSource(): string;
|