@origingame/engine 0.0.0-stage → 0.1.1
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/bin/og.mjs +25 -0
- package/dist/web/origin-engine.d.ts +1233 -0
- package/dist/web/origin-engine.js +8628 -0
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
- package/dist/web-publish/origin-engine-lanes.js +8509 -0
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +1176 -0
- package/dist/web-publish/origin-engine.js +8313 -0
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
- package/dist/web-publish-2d/origin-engine.js +8313 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/knowledge/README.md +31 -0
- package/knowledge/knowledge.json +13 -0
- package/knowledge/looks/catalogue.json +1871 -0
- package/knowledge/package.json +33 -0
- package/knowledge/rules/platform.json +329 -0
- package/knowledge/scaffold/README.md +27 -0
- package/knowledge/scaffold/distribution.json +48 -0
- package/knowledge/scaffold/origin-engine.json +19 -0
- package/knowledge/scaffold/template/.origin/setup +14 -0
- package/knowledge/scaffold/template/LICENSES.md +23 -0
- package/knowledge/scaffold/template/README.md +37 -0
- package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
- package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
- package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
- package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
- package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
- package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
- package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
- package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
- package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
- package/knowledge/scaffold/template/gitattributes +22 -0
- package/knowledge/scaffold/template/gitignore +8 -0
- package/knowledge/scaffold/template/package.json +18 -0
- package/knowledge/scaffold/template/project.ogt +25 -0
- package/knowledge/scaffold/template/src/main.ts +246 -0
- package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
- package/knowledge/scaffold/template/tsconfig.json +17 -0
- package/knowledge/scripts/lib.ts +207 -0
- package/knowledge/scripts/mount.ts +210 -0
- package/knowledge/scripts/pack.ts +25 -0
- package/knowledge/scripts/scaffold.ts +72 -0
- package/knowledge/scripts/validate.ts +749 -0
- package/knowledge/skills/og-ai/SKILL.md +386 -0
- package/knowledge/skills/og-audio/SKILL.md +191 -0
- package/knowledge/skills/og-build/SKILL.md +330 -0
- package/knowledge/skills/og-core/SKILL.md +285 -0
- package/knowledge/skills/og-feel/SKILL.md +385 -0
- package/knowledge/skills/og-game/SKILL.md +1479 -0
- package/knowledge/skills/og-i18n/SKILL.md +220 -0
- package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
- package/knowledge/skills/og-taste/SKILL.md +537 -0
- package/knowledge/skills/og-ui/SKILL.md +444 -0
- package/knowledge/wiki/ai.md +627 -0
- package/knowledge/wiki/anim.md +1187 -0
- package/knowledge/wiki/atmosphere.md +636 -0
- package/knowledge/wiki/audio.md +732 -0
- package/knowledge/wiki/camera.md +711 -0
- package/knowledge/wiki/commands.md +357 -0
- package/knowledge/wiki/game.md +220 -0
- package/knowledge/wiki/i18n.md +213 -0
- package/knowledge/wiki/index.md +1027 -0
- package/knowledge/wiki/input.md +1714 -0
- package/knowledge/wiki/load.md +1280 -0
- package/knowledge/wiki/math.md +166 -0
- package/knowledge/wiki/nav.md +1154 -0
- package/knowledge/wiki/physics.md +3142 -0
- package/knowledge/wiki/platform.md +857 -0
- package/knowledge/wiki/probe.md +1692 -0
- package/knowledge/wiki/reflect.md +285 -0
- package/knowledge/wiki/render.md +3511 -0
- package/knowledge/wiki/rng.md +393 -0
- package/knowledge/wiki/stream.md +376 -0
- package/knowledge/wiki/system.md +197 -0
- package/knowledge/wiki/systems.md +43 -0
- package/knowledge/wiki/terrain.md +727 -0
- package/knowledge/wiki/tilemap.md +422 -0
- package/knowledge/wiki/time.md +150 -0
- package/knowledge/wiki/transform.md +230 -0
- package/knowledge/wiki/tween.md +580 -0
- package/knowledge/wiki/ui.md +8000 -0
- package/knowledge/wiki/world.md +909 -0
- package/package.json +68 -4
- package/schema/api.schema.json +29675 -0
- package/shell/README.md +22 -0
- package/shell/brand/README.md +23 -0
- package/shell/brand/icon-192.png +0 -0
- package/shell/brand/icon-512.png +0 -0
- package/shell/brand/mark-on-paper.svg +6 -0
- package/shell/brand/origin-mark.svg +5 -0
- package/shell/brand/origin-motion.css +251 -0
- package/shell/brand/tokens.css +20 -0
- package/shell/brand.ts +132 -0
- package/shell/page.ts +715 -0
- package/shell/platform.ts +203 -0
- package/shell/player.ts +238 -0
- package/shell/shell.css +185 -0
- package/shell/sw.ts +177 -0
- package/shell/transport.ts +515 -0
- package/shell/wire.ts +369 -0
- package/src/e6.ts +69 -0
- package/src/env.d.ts +100 -0
- package/src/generated/abi.ts +980 -0
- package/src/index.ts +193 -0
- package/src/node/audio.ts +86 -0
- package/src/node/build.ts +109 -0
- package/src/node/device.ts +981 -0
- package/src/node/doctor.ts +60 -0
- package/src/node/index.ts +102 -0
- package/src/node/platform.ts +24 -0
- package/src/node/playtest-run.ts +249 -0
- package/src/node/png.ts +130 -0
- package/src/node/polish.ts +976 -0
- package/src/node/run.ts +373 -0
- package/src/node/web-build.ts +808 -0
- package/src/p2.ts +35 -0
- package/src/platform-local.ts +211 -0
- package/src/playtest.ts +131 -0
- package/src/runtime.ts +1509 -0
- package/src/systems/cameras.ts +247 -0
- package/src/ui-kit/bind.ts +141 -0
- package/src/ui-kit/huds.ts +303 -0
- package/src/ui-kit/index.ts +36 -0
- package/src/ui-kit/menus.ts +187 -0
- package/src/ui-kit/settings.ts +131 -0
- package/src/ui-kit/store.ts +222 -0
- package/src/ui-kit/story.ts +89 -0
- package/src/ui-kit/util.ts +106 -0
- package/src/web/a11y.ts +429 -0
- package/src/web/audio-worklet.ts +193 -0
- package/src/web/audio.ts +307 -0
- package/src/web/basis.ts +239 -0
- package/src/web/gamepad.ts +123 -0
- package/src/web/host.ts +166 -0
- package/src/web/input.ts +237 -0
- package/src/web/lanes.ts +21 -0
- package/src/web/play.ts +20 -0
- package/src/web/rpc-ring.ts +110 -0
- package/src/web/settle.ts +122 -0
- package/src/web/shell.ts +643 -0
- package/src/web/text-input.ts +248 -0
- package/src/web/worker.ts +1002 -0
- package/types/bindings.json +13654 -0
- package/types/index.d.ts +20090 -0
- package/vendor/basis/LICENSE +201 -0
- package/vendor/basis/NOTICE +15 -0
- package/vendor/basis/README.md +18 -0
- package/vendor/basis/basis_transcoder.js +19 -0
- package/vendor/basis/basis_transcoder.wasm +0 -0
- package/README.md +0 -3
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
// `og run --device`: the publish build played in a real browser, timed.
|
|
2
|
+
//
|
|
3
|
+
// bun src/node/device.ts --dist <publish build> --project <root> --device <target> --out <dir>
|
|
4
|
+
// [--seconds N (default 5)] [--port P] [--headed] [--webgl] [--chrome <path>]
|
|
5
|
+
// [--cert <pem> --key <pem>] [--host <ip>] [--size WxH] [--json]
|
|
6
|
+
// bun src/node/device.ts --preflight --device <target> [options] (og runs it before the build:
|
|
7
|
+
// the options and the target's tools only)
|
|
8
|
+
//
|
|
9
|
+
// The build (og build --publish, which og made) is served with its `_headers`
|
|
10
|
+
// and COOP/COEP/CORP on 127.0.0.1, or on 0.0.0.0 for the iPhone, the one
|
|
11
|
+
// target that reaches it over the network; the URLs are printed. `<target>`:
|
|
12
|
+
//
|
|
13
|
+
// chrome this machine's Chrome (--chrome, $CHROME, $CHROME_PATH, the
|
|
14
|
+
// agent-browser install, /Applications, PATH), headless unless
|
|
15
|
+
// --headed, over the DevTools protocol at http://127.0.0.1:<port>/
|
|
16
|
+
// android[:serial] Chrome on a phone over adb: `adb reverse` serves the page at the
|
|
17
|
+
// phone's own localhost (a secure context, so isolation holds) and
|
|
18
|
+
// `adb forward ... localabstract:chrome_devtools_remote` reaches
|
|
19
|
+
// its DevTools; the same driver, with touch
|
|
20
|
+
// iphone[:udid] Safari on an iPhone over WebDriver (safaridriver, macOS), at the
|
|
21
|
+
// ios[:udid] LAN URL (a private address on en*/eth*/wlan* first; --host <ip>
|
|
22
|
+
// names another); isolation and WebGPU need a secure context
|
|
23
|
+
// there, so pass --cert/--key (mkcert) to serve HTTPS
|
|
24
|
+
//
|
|
25
|
+
// chrome and android open the page at localhost, already a secure context, so
|
|
26
|
+
// --cert/--key (and --host) are the iPhone's only.
|
|
27
|
+
//
|
|
28
|
+
// The page is opened cold (no-store, no sw.js served, and on a DevTools target
|
|
29
|
+
// the origin's storage cleared first), the play gate passed with a real tap or click,
|
|
30
|
+
// and the engine's own telemetry read: `og:first-frame` (ms from navigation
|
|
31
|
+
// to the first frame drawn behind the gate), `__runtime_probe__.hello()`
|
|
32
|
+
// (the tier), and after a second of warm-up `__og_timings__` (the Worker's
|
|
33
|
+
// played frames: display interval and engine work) over --seconds. Writes
|
|
34
|
+
// <out>/device.json and <out>/report.md in og polish's shape (scores,
|
|
35
|
+
// findings with a fix each, then the measures) and prints one line (or one
|
|
36
|
+
// JSON line with --json). A missing prerequisite exits 1 with one line
|
|
37
|
+
// naming the fix; a page that fails (error card, no first frame, the gate
|
|
38
|
+
// not passing) exits 1 with what it saw. SIGINT, SIGTERM and SIGHUP undo what the
|
|
39
|
+
// run set up (adb reverse/forward, the Chrome profile, the WebDriver
|
|
40
|
+
// session, the server) and exit 130, 143 or 129.
|
|
41
|
+
|
|
42
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
43
|
+
import { basename, join, resolve } from "node:path";
|
|
44
|
+
import { networkInterfaces, tmpdir } from "node:os";
|
|
45
|
+
|
|
46
|
+
declare module "node:fs" {
|
|
47
|
+
export function mkdtempSync(prefix: string): string;
|
|
48
|
+
export function readdirSync(path: string): string[];
|
|
49
|
+
}
|
|
50
|
+
declare module "node:path" {
|
|
51
|
+
export const posix: { normalize(path: string): string };
|
|
52
|
+
}
|
|
53
|
+
import { posix } from "node:path";
|
|
54
|
+
|
|
55
|
+
interface Proc {
|
|
56
|
+
kill(): void;
|
|
57
|
+
readonly exited: Promise<number>;
|
|
58
|
+
}
|
|
59
|
+
declare const Bun: {
|
|
60
|
+
serve(o: {
|
|
61
|
+
port: number;
|
|
62
|
+
hostname: string;
|
|
63
|
+
development?: boolean;
|
|
64
|
+
tls?: { cert: string; key: string };
|
|
65
|
+
fetch(req: Request): Response | Promise<Response>;
|
|
66
|
+
}): { port: number; stop(force?: boolean): void };
|
|
67
|
+
file(path: string): Blob & { exists(): Promise<boolean> };
|
|
68
|
+
spawn(cmd: string[], o?: { stdout?: string; stderr?: string }): Proc;
|
|
69
|
+
spawnSync(cmd: string[], o?: { stdout?: string; stderr?: string }): { exitCode: number; stdout: Uint8Array; stderr: Uint8Array };
|
|
70
|
+
which(cmd: string): string | null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const argv = process.argv.slice(2);
|
|
74
|
+
/** The last occurrence wins, so a caller can override a forwarded option. */
|
|
75
|
+
const opt = (name: string) => {
|
|
76
|
+
const i = argv.lastIndexOf(name);
|
|
77
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
78
|
+
};
|
|
79
|
+
const flag = (name: string) => argv.includes(name);
|
|
80
|
+
|
|
81
|
+
/** A missing prerequisite or a failed page: one line, exit 1. */
|
|
82
|
+
class Refusal extends Error {}
|
|
83
|
+
const refuse = (line: string): never => {
|
|
84
|
+
throw new Refusal(line);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
88
|
+
const decode = (b: Uint8Array) => new TextDecoder().decode(b);
|
|
89
|
+
|
|
90
|
+
/** How long one call to a browser (a DevTools command, a WebDriver request) may take unless it says otherwise. */
|
|
91
|
+
const CALL_MS = 30_000;
|
|
92
|
+
|
|
93
|
+
/** What the run set up, newest last: undone newest first by cleanUp (main's end, SIGINT, SIGTERM, SIGHUP). */
|
|
94
|
+
const undo: Array<() => Promise<void>> = [];
|
|
95
|
+
let cleaning: Promise<void> | null = null;
|
|
96
|
+
|
|
97
|
+
/** Register `f` to run once on the way out; the returned function runs it now (still once). */
|
|
98
|
+
function onExit(f: () => unknown): () => Promise<void> {
|
|
99
|
+
let done: Promise<void> | null = null;
|
|
100
|
+
const once = () =>
|
|
101
|
+
(done ??= Promise.resolve()
|
|
102
|
+
.then(f)
|
|
103
|
+
.then(
|
|
104
|
+
() => {},
|
|
105
|
+
() => {},
|
|
106
|
+
));
|
|
107
|
+
undo.push(once);
|
|
108
|
+
return once;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const cleanUp = () =>
|
|
112
|
+
(cleaning ??= (async () => {
|
|
113
|
+
while (undo.length) await undo.pop()!();
|
|
114
|
+
})());
|
|
115
|
+
|
|
116
|
+
// ── serving ───────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
const TYPES: Record<string, string> = {
|
|
119
|
+
".html": "text/html; charset=utf-8",
|
|
120
|
+
".js": "text/javascript",
|
|
121
|
+
".wasm": "application/wasm",
|
|
122
|
+
".json": "application/json",
|
|
123
|
+
".webmanifest": "application/manifest+json",
|
|
124
|
+
".png": "image/png",
|
|
125
|
+
".ogb": "application/octet-stream",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const ISOLATION: Record<string, string> = {
|
|
129
|
+
"cross-origin-opener-policy": "same-origin",
|
|
130
|
+
"cross-origin-embedder-policy": "require-corp",
|
|
131
|
+
"cross-origin-resource-policy": "cross-origin",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The build's `_headers` (Netlify/Cloudflare Pages format) as pattern → headers. */
|
|
135
|
+
function readHeaders(dist: string): Array<{ pattern: string; headers: Record<string, string> }> {
|
|
136
|
+
const file = join(dist, "_headers");
|
|
137
|
+
if (!existsSync(file)) return [];
|
|
138
|
+
const rules: Array<{ pattern: string; headers: Record<string, string> }> = [];
|
|
139
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
140
|
+
if (!line.trim()) continue;
|
|
141
|
+
if (!/^\s/.test(line)) rules.push({ pattern: line.trim(), headers: {} });
|
|
142
|
+
else if (rules.length) {
|
|
143
|
+
const [k, ...v] = line.trim().split(":");
|
|
144
|
+
rules[rules.length - 1]!.headers[k!.trim().toLowerCase()] = v.join(":").trim();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return rules;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const matches = (pattern: string, path: string) =>
|
|
151
|
+
pattern === path || (pattern.endsWith("*") && path.startsWith(pattern.slice(0, -1))) || (pattern.startsWith("/*.") && path.endsWith(pattern.slice(2)));
|
|
152
|
+
|
|
153
|
+
interface Served {
|
|
154
|
+
readonly port: number;
|
|
155
|
+
readonly scheme: "http" | "https";
|
|
156
|
+
/** The headers sent for the page and the wasm, as the device received them. */
|
|
157
|
+
readonly sent: Record<string, Record<string, string>>;
|
|
158
|
+
/** Paths answered, in order (the first load's). */
|
|
159
|
+
readonly paths: string[];
|
|
160
|
+
stop(): void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function serveBuild(dist: string, port: number, tls: { cert: string; key: string } | null, hostname: string): Served {
|
|
164
|
+
const rules = readHeaders(dist);
|
|
165
|
+
const sent: Record<string, Record<string, string>> = {};
|
|
166
|
+
const paths: string[] = [];
|
|
167
|
+
const answer = (status: number, body: string) => new Response(body, { status, headers: { "cache-control": "no-store", ...ISOLATION } });
|
|
168
|
+
const server = Bun.serve({
|
|
169
|
+
port,
|
|
170
|
+
hostname,
|
|
171
|
+
// A bad request never shows Bun's development error page (with this machine's paths).
|
|
172
|
+
development: false,
|
|
173
|
+
...(tls ? { tls } : {}),
|
|
174
|
+
async fetch(req) {
|
|
175
|
+
const url = new URL(req.url);
|
|
176
|
+
let rel: string;
|
|
177
|
+
try {
|
|
178
|
+
rel = posix.normalize(decodeURIComponent(url.pathname));
|
|
179
|
+
} catch {
|
|
180
|
+
return answer(400, "bad request");
|
|
181
|
+
}
|
|
182
|
+
if (rel.endsWith("/")) rel += "index.html";
|
|
183
|
+
const path = join(dist, rel);
|
|
184
|
+
if (paths.length < 400) paths.push(url.pathname);
|
|
185
|
+
// No service worker: on a secure context (Android's localhost, the iPhone
|
|
186
|
+
// over HTTPS) the shell registers sw.js, which answers game.js, the wasm
|
|
187
|
+
// and the packs from its cache, so a later run on the same --port would
|
|
188
|
+
// be timed warm and could pair a new index.html with an old build's
|
|
189
|
+
// files. Its registration fails on the 404 and the page plays on.
|
|
190
|
+
if (rel === "/sw.js" || !path.startsWith(dist) || !(await Bun.file(path).exists())) return answer(404, "not found");
|
|
191
|
+
const headers: Record<string, string> = {};
|
|
192
|
+
for (const r of rules) if (matches(r.pattern, rel)) Object.assign(headers, r.headers);
|
|
193
|
+
Object.assign(headers, ISOLATION);
|
|
194
|
+
// The device's HTTP cache never answers (and with no sw.js, no service worker does).
|
|
195
|
+
headers["cache-control"] = "no-store";
|
|
196
|
+
headers["content-type"] = TYPES[rel.slice(rel.lastIndexOf("."))] ?? "application/octet-stream";
|
|
197
|
+
if (rel === "/index.html" || rel.endsWith(".wasm")) sent[rel === "/index.html" ? "/" : rel] = { ...headers };
|
|
198
|
+
return new Response(Bun.file(path), { headers });
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
return { port: server.port, scheme: tls ? "https" : "http", sent, paths, stop: () => server.stop(true) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Interfaces a phone's Wi-Fi is not on: VPN tunnels, bridges, VMs, containers. */
|
|
205
|
+
const VIRTUAL = /^(utun|tun|tap|ppp|ipsec|gif|stf|bridge|br-|virbr|vmnet|vboxnet|docker|veth|awdl|llw|anpi|zt|tailscale|wg)/;
|
|
206
|
+
/** Ethernet and Wi-Fi interface names (macOS en*, Linux eth*, en*, wlan*, wl*). */
|
|
207
|
+
const PHYSICAL = /^(en|eth|wlan|wl)/;
|
|
208
|
+
/** 10/8, 172.16/12, 192.168/16. */
|
|
209
|
+
const isPrivate = (address: string) => {
|
|
210
|
+
const [a, b] = address.split(".").map(Number);
|
|
211
|
+
return a === 10 || (a === 172 && b! >= 16 && b! <= 31) || (a === 192 && b === 168);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* This machine's LAN IPv4 addresses, the likeliest for a phone on the same
|
|
216
|
+
* Wi-Fi first: link-local 169.254/16 dropped, tunnels, bridges and VM or
|
|
217
|
+
* container interfaces last, private (RFC 1918) addresses before public
|
|
218
|
+
* ones, and Ethernet or Wi-Fi interface names before others.
|
|
219
|
+
*/
|
|
220
|
+
function lanAddresses(): Array<{ address: string; iface: string }> {
|
|
221
|
+
const out: Array<{ address: string; iface: string }> = [];
|
|
222
|
+
for (const [iface, list] of Object.entries(networkInterfaces()))
|
|
223
|
+
for (const a of list ?? []) if (!a.internal && (a.family === "IPv4" || a.family === 4) && !a.address.startsWith("169.254.")) out.push({ address: a.address, iface });
|
|
224
|
+
const rank = (x: { address: string; iface: string }) => (VIRTUAL.test(x.iface) ? 4 : 0) + (isPrivate(x.address) ? 0 : 2) + (PHYSICAL.test(x.iface) ? 0 : 1);
|
|
225
|
+
return out.sort((x, y) => rank(x) - rank(y));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── drivers ───────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/** What the measurement needs of a browser: open a page, evaluate, tap at a point or an element, close. */
|
|
231
|
+
interface Driver {
|
|
232
|
+
readonly name: string;
|
|
233
|
+
open(url: string): Promise<void>;
|
|
234
|
+
/** An expression's value (Promises awaited), by value; rejected after `ms` (default CALL_MS). */
|
|
235
|
+
eval<T>(expression: string, ms?: number): Promise<T>;
|
|
236
|
+
/** A real tap (or click) on the element `selector` matches. */
|
|
237
|
+
tap(selector: string): Promise<void>;
|
|
238
|
+
/** Console errors and uncaught exceptions seen so far (CDP only). */
|
|
239
|
+
errors(): string[];
|
|
240
|
+
close(): Promise<void>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A DevTools protocol client on a browser endpoint, with one page attached (flattened sessions). */
|
|
244
|
+
class Cdp implements Driver {
|
|
245
|
+
readonly name: string;
|
|
246
|
+
readonly #ws: WebSocket;
|
|
247
|
+
readonly #pending = new Map<number, { method: string; timer: ReturnType<typeof setTimeout>; resolve(v: unknown): void; reject(e: Error): void }>();
|
|
248
|
+
/** Why the connection is gone (every later call is refused with it), or null while it is open. */
|
|
249
|
+
#lost: string | null = null;
|
|
250
|
+
readonly #errors: string[] = [];
|
|
251
|
+
readonly #touch: boolean;
|
|
252
|
+
readonly #onClose: () => Promise<void>;
|
|
253
|
+
#next = 1;
|
|
254
|
+
#session: string | null = null;
|
|
255
|
+
#target: string | null = null;
|
|
256
|
+
|
|
257
|
+
private constructor(name: string, ws: WebSocket, touch: boolean, onClose: () => Promise<void>) {
|
|
258
|
+
this.name = name;
|
|
259
|
+
this.#ws = ws;
|
|
260
|
+
this.#touch = touch;
|
|
261
|
+
this.#onClose = onClose;
|
|
262
|
+
// A closed or failed connection answers every call still waiting, and every later one.
|
|
263
|
+
const lose = (how: string) => {
|
|
264
|
+
if (this.#lost) return;
|
|
265
|
+
this.#lost = `the DevTools connection to ${name} ${how}`;
|
|
266
|
+
for (const p of this.#pending.values()) {
|
|
267
|
+
clearTimeout(p.timer);
|
|
268
|
+
p.reject(new Error(`${p.method}: ${this.#lost}`));
|
|
269
|
+
}
|
|
270
|
+
this.#pending.clear();
|
|
271
|
+
};
|
|
272
|
+
ws.addEventListener("close", () => lose("closed"));
|
|
273
|
+
ws.addEventListener("error", () => lose("failed"));
|
|
274
|
+
ws.addEventListener("message", (e) => {
|
|
275
|
+
const m = JSON.parse(String((e as MessageEvent).data)) as { id?: number; result?: unknown; error?: { message: string }; method?: string; params?: Record<string, unknown>; sessionId?: string };
|
|
276
|
+
if (m.id !== undefined) {
|
|
277
|
+
const p = this.#pending.get(m.id);
|
|
278
|
+
this.#pending.delete(m.id);
|
|
279
|
+
if (p) clearTimeout(p.timer);
|
|
280
|
+
if (m.error) p?.reject(new Error(m.error.message));
|
|
281
|
+
else p?.resolve(m.result);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (m.sessionId !== this.#session) return;
|
|
285
|
+
if (m.method === "Runtime.consoleAPICalled" && m.params?.["type"] === "error") {
|
|
286
|
+
const args = (m.params["args"] as Array<{ value?: unknown; description?: string }>) ?? [];
|
|
287
|
+
this.#errors.push(args.map((a) => (a.value !== undefined ? String(a.value) : (a.description ?? ""))).join(" ").slice(0, 400));
|
|
288
|
+
} else if (m.method === "Runtime.exceptionThrown") {
|
|
289
|
+
const d = m.params?.["exceptionDetails"] as { text?: string; exception?: { description?: string } } | undefined;
|
|
290
|
+
this.#errors.push((d?.exception?.description ?? d?.text ?? "exception").split("\n")[0]!.slice(0, 400));
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
static async connect(name: string, url: string, touch: boolean, onClose: () => Promise<void>): Promise<Cdp> {
|
|
296
|
+
const ws = new WebSocket(url);
|
|
297
|
+
await new Promise<void>((ok, fail) => {
|
|
298
|
+
const timer = setTimeout(() => {
|
|
299
|
+
fail(new Error(`DevTools at ${url} did not answer in 15 s`));
|
|
300
|
+
ws.close();
|
|
301
|
+
}, 15_000);
|
|
302
|
+
ws.addEventListener("open", () => {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
ok();
|
|
305
|
+
});
|
|
306
|
+
ws.addEventListener("error", () => {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
fail(new Error(`could not connect to DevTools at ${url}`));
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
return new Cdp(name, ws, touch, onClose);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** One command; rejected when no answer comes in `ms` or the connection goes. */
|
|
315
|
+
send<T = unknown>(method: string, params: Record<string, unknown> = {}, session: string | null = this.#session, ms = CALL_MS): Promise<T> {
|
|
316
|
+
if (this.#lost) return Promise.reject(new Error(`${method}: ${this.#lost}`));
|
|
317
|
+
const id = this.#next++;
|
|
318
|
+
return new Promise<T>((resolve, reject) => {
|
|
319
|
+
const timer = setTimeout(() => {
|
|
320
|
+
this.#pending.delete(id);
|
|
321
|
+
reject(new Error(`${method}: ${this.name} did not answer in ${ms / 1000} s`));
|
|
322
|
+
}, ms);
|
|
323
|
+
this.#pending.set(id, { method, timer, resolve: resolve as (v: unknown) => void, reject });
|
|
324
|
+
try {
|
|
325
|
+
this.#ws.send(JSON.stringify({ id, method, params, ...(session ? { sessionId: session } : {}) }));
|
|
326
|
+
} catch (e) {
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
this.#pending.delete(id);
|
|
329
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async open(url: string): Promise<void> {
|
|
335
|
+
const { targetId } = await this.send<{ targetId: string }>("Target.createTarget", { url: "about:blank" }, null);
|
|
336
|
+
this.#target = targetId;
|
|
337
|
+
const { sessionId } = await this.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true }, null);
|
|
338
|
+
this.#session = sessionId;
|
|
339
|
+
await this.send("Page.enable");
|
|
340
|
+
await this.send("Runtime.enable");
|
|
341
|
+
await this.send("Page.bringToFront");
|
|
342
|
+
// Cold means cold on a browser that has seen this origin before (a phone's
|
|
343
|
+
// Chrome, an earlier run on the same --port): its service worker, caches
|
|
344
|
+
// and storage go first.
|
|
345
|
+
const origin = new URL(url).origin;
|
|
346
|
+
try {
|
|
347
|
+
await this.send("Storage.clearDataForOrigin", { origin, storageTypes: "all" });
|
|
348
|
+
} catch (e) {
|
|
349
|
+
refuse(`could not clear ${origin}'s storage before the cold load (${(e as Error).message})`);
|
|
350
|
+
}
|
|
351
|
+
await this.send("Page.navigate", { url });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async eval<T>(expression: string, ms = CALL_MS): Promise<T> {
|
|
355
|
+
const r = await this.send<{ result: { value?: T }; exceptionDetails?: { text: string; exception?: { description?: string } } }>(
|
|
356
|
+
"Runtime.evaluate",
|
|
357
|
+
{
|
|
358
|
+
expression,
|
|
359
|
+
awaitPromise: true,
|
|
360
|
+
returnByValue: true,
|
|
361
|
+
userGesture: false,
|
|
362
|
+
},
|
|
363
|
+
this.#session,
|
|
364
|
+
ms,
|
|
365
|
+
);
|
|
366
|
+
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
|
|
367
|
+
return r.result.value as T;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async tap(selector: string): Promise<void> {
|
|
371
|
+
const at = await this.eval<{ x: number; y: number } | null>(
|
|
372
|
+
`(() => { const e = document.querySelector(${JSON.stringify(selector)}); if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`,
|
|
373
|
+
);
|
|
374
|
+
if (!at) refuse(`the page has no ${selector} to tap`);
|
|
375
|
+
const { x, y } = at!;
|
|
376
|
+
if (this.#touch) {
|
|
377
|
+
await this.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
378
|
+
await sleep(60);
|
|
379
|
+
await this.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
380
|
+
} else {
|
|
381
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
382
|
+
await this.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: 1 });
|
|
383
|
+
await sleep(40);
|
|
384
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: 1 });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
errors(): string[] {
|
|
389
|
+
return [...this.#errors];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async close(): Promise<void> {
|
|
393
|
+
if (this.#target) await this.send("Target.closeTarget", { targetId: this.#target }, null, 5_000).catch(() => {});
|
|
394
|
+
this.#ws.close();
|
|
395
|
+
await this.#onClose();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The local Chrome: --chrome, $CHROME, $CHROME_PATH, the agent-browser install, /Applications, PATH. */
|
|
400
|
+
function findChrome(): string {
|
|
401
|
+
const given = opt("--chrome") ?? process.env.CHROME ?? process.env.CHROME_PATH;
|
|
402
|
+
if (given) {
|
|
403
|
+
if (!existsSync(given)) refuse(`no Chrome at ${given}: point --chrome (or CHROME) at the browser's executable`);
|
|
404
|
+
return given;
|
|
405
|
+
}
|
|
406
|
+
const home = process.env.HOME ?? "";
|
|
407
|
+
const candidates: string[] = [];
|
|
408
|
+
const installs = join(home, ".agent-browser", "browsers");
|
|
409
|
+
if (existsSync(installs))
|
|
410
|
+
for (const d of readdirSync(installs).filter((n) => n.startsWith("chrome-")).sort().reverse())
|
|
411
|
+
candidates.push(
|
|
412
|
+
process.platform === "darwin"
|
|
413
|
+
? join(installs, d, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing")
|
|
414
|
+
: join(installs, d, "chrome"),
|
|
415
|
+
);
|
|
416
|
+
if (process.platform === "darwin")
|
|
417
|
+
candidates.push(
|
|
418
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
419
|
+
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
420
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
421
|
+
);
|
|
422
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
423
|
+
for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]) {
|
|
424
|
+
const p = Bun.which(name);
|
|
425
|
+
if (p) return p;
|
|
426
|
+
}
|
|
427
|
+
return refuse("no Chrome found: install Google Chrome, or pass --chrome <path> (or set CHROME)");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function launchChrome(width: number, height: number): Promise<Driver> {
|
|
431
|
+
const chrome = findChrome();
|
|
432
|
+
const profile = mkdtempSync(join(tmpdir(), "og-device-"));
|
|
433
|
+
let proc: Proc | null = null;
|
|
434
|
+
const quit = onExit(async () => {
|
|
435
|
+
if (proc) {
|
|
436
|
+
proc.kill();
|
|
437
|
+
await proc.exited;
|
|
438
|
+
}
|
|
439
|
+
rmSync(profile, { recursive: true, force: true });
|
|
440
|
+
});
|
|
441
|
+
proc = Bun.spawn(
|
|
442
|
+
[
|
|
443
|
+
chrome,
|
|
444
|
+
...(flag("--headed") ? [] : ["--headless=new"]),
|
|
445
|
+
"--no-first-run",
|
|
446
|
+
"--no-default-browser-check",
|
|
447
|
+
`--user-data-dir=${profile}`,
|
|
448
|
+
`--window-size=${width},${height}`,
|
|
449
|
+
"--hide-scrollbars",
|
|
450
|
+
...(process.platform === "darwin" ? ["--use-angle=metal"] : []),
|
|
451
|
+
"--enable-unsafe-webgpu",
|
|
452
|
+
"--enable-features=WebGPU",
|
|
453
|
+
"--ignore-gpu-blocklist",
|
|
454
|
+
"--enable-unsafe-swiftshader",
|
|
455
|
+
"--autoplay-policy=user-gesture-required",
|
|
456
|
+
"--remote-debugging-port=0",
|
|
457
|
+
"about:blank",
|
|
458
|
+
],
|
|
459
|
+
{ stdout: "ignore", stderr: "ignore" },
|
|
460
|
+
);
|
|
461
|
+
const portFile = join(profile, "DevToolsActivePort");
|
|
462
|
+
const t0 = performance.now();
|
|
463
|
+
while (!existsSync(portFile) || readFileSync(portFile, "utf8").split("\n").length < 2) {
|
|
464
|
+
if (performance.now() - t0 > 20_000) refuse(`${chrome} did not open its DevTools port in 20 s: is it a Chrome (or Chromium) executable?`);
|
|
465
|
+
await sleep(50);
|
|
466
|
+
}
|
|
467
|
+
const [port, path] = readFileSync(portFile, "utf8").trim().split("\n");
|
|
468
|
+
return Cdp.connect(`chrome (${basename(chrome)}${flag("--headed") ? ", headed" : ", headless"})`, `ws://127.0.0.1:${port}${path}`, false, quit);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const NO_ADB = "no adb on PATH: install Android platform-tools (`brew install android-platform-tools`, or the SDK's platform-tools)";
|
|
472
|
+
const NO_SAFARIDRIVER = "og run --device iphone needs macOS with safaridriver (it ships with Safari; run `safaridriver --enable` once)";
|
|
473
|
+
|
|
474
|
+
function adb(serial: string | null, ...args: string[]): string {
|
|
475
|
+
const r = Bun.spawnSync(["adb", ...(serial ? ["-s", serial] : []), ...args], { stdout: "pipe", stderr: "pipe" });
|
|
476
|
+
if (r.exitCode !== 0) refuse(`adb ${args.join(" ")} failed: ${decode(r.stderr).trim().split("\n")[0]} (is the phone unlocked and USB debugging allowed?)`);
|
|
477
|
+
return decode(r.stdout).trim();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Chrome on an Android phone: the page at its own localhost (adb reverse), DevTools forwarded to this machine. */
|
|
481
|
+
async function connectAndroid(wanted: string | null, port: number, scheme: string): Promise<{ driver: Driver; url: string }> {
|
|
482
|
+
if (!Bun.which("adb")) refuse(NO_ADB);
|
|
483
|
+
const devices = adb(null, "devices")
|
|
484
|
+
.split("\n")
|
|
485
|
+
.slice(1)
|
|
486
|
+
.map((l) => l.trim().split(/\s+/))
|
|
487
|
+
.filter((p) => p[1] === "device")
|
|
488
|
+
.map((p) => p[0]!);
|
|
489
|
+
if (devices.length === 0) refuse("no Android device: connect one with USB debugging on (Settings > Developer options) and accept the prompt; `adb devices` lists it");
|
|
490
|
+
if (wanted && !devices.includes(wanted)) refuse(`no Android device ${wanted}: adb lists ${devices.join(", ")}`);
|
|
491
|
+
if (!wanted && devices.length > 1) refuse(`several Android devices (${devices.join(", ")}): pass --device android:<serial>`);
|
|
492
|
+
const serial = wanted ?? devices[0]!;
|
|
493
|
+
let forwarded = 0;
|
|
494
|
+
const cleanup = onExit(() => {
|
|
495
|
+
if (forwarded) Bun.spawnSync(["adb", "-s", serial, "forward", "--remove", `tcp:${forwarded}`], { stdout: "ignore", stderr: "ignore" });
|
|
496
|
+
Bun.spawnSync(["adb", "-s", serial, "reverse", "--remove", `tcp:${port}`], { stdout: "ignore", stderr: "ignore" });
|
|
497
|
+
});
|
|
498
|
+
adb(serial, "reverse", `tcp:${port}`, `tcp:${port}`);
|
|
499
|
+
adb(serial, "shell", "am", "start", "-n", "com.android.chrome/com.google.android.apps.chrome.Main", "-d", "about:blank");
|
|
500
|
+
forwarded = Number(adb(serial, "forward", "tcp:0", "localabstract:chrome_devtools_remote"));
|
|
501
|
+
let ws: string | null = null;
|
|
502
|
+
for (let i = 0; i < 50 && !ws; i++) {
|
|
503
|
+
try {
|
|
504
|
+
ws = ((await (await fetch(`http://127.0.0.1:${forwarded}/json/version`, { signal: AbortSignal.timeout(2000) })).json()) as { webSocketDebuggerUrl?: string }).webSocketDebuggerUrl ?? null;
|
|
505
|
+
} catch {
|
|
506
|
+
await sleep(200);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (!ws) refuse(`Chrome on ${serial} does not answer DevTools: open Chrome on the phone once, and allow USB debugging`);
|
|
510
|
+
const model = adb(serial, "shell", "getprop", "ro.product.model");
|
|
511
|
+
const driver = await Cdp.connect(`android ${model} (${serial})`, ws!.replace(/^ws:\/\/[^/]+/, `ws://127.0.0.1:${forwarded}`), true, cleanup);
|
|
512
|
+
return { driver, url: `${scheme}://localhost:${port}/` };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const ELEMENT = "element-6066-11e4-a52e-4f735466cecf";
|
|
516
|
+
const IPHONE_FIX =
|
|
517
|
+
"on the iPhone turn on Settings > Apps > Safari > Advanced > Web Inspector and Remote Automation; on the Mac run `safaridriver --enable` once and pair the phone in Xcode (Window > Devices and Simulators)";
|
|
518
|
+
|
|
519
|
+
/** Safari on an iPhone over WebDriver (safaridriver). */
|
|
520
|
+
async function connectIphone(udid: string | null): Promise<Driver> {
|
|
521
|
+
if (process.platform !== "darwin" || !Bun.which("safaridriver")) refuse(NO_SAFARIDRIVER);
|
|
522
|
+
const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") });
|
|
523
|
+
const port = probe.port;
|
|
524
|
+
probe.stop(true);
|
|
525
|
+
const base = `http://127.0.0.1:${port}`;
|
|
526
|
+
/** One WebDriver request; rejected when safaridriver does not answer in `ms`. */
|
|
527
|
+
const call = async <T>(method: string, path: string, body?: unknown, ms = CALL_MS): Promise<T> => {
|
|
528
|
+
let r: Response;
|
|
529
|
+
try {
|
|
530
|
+
r = await fetch(`${base}${path}`, {
|
|
531
|
+
method,
|
|
532
|
+
headers: { "content-type": "application/json" },
|
|
533
|
+
signal: AbortSignal.timeout(ms),
|
|
534
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
535
|
+
});
|
|
536
|
+
} catch (e) {
|
|
537
|
+
throw new Error((e as Error).name === "TimeoutError" ? `${method} ${path}: safaridriver did not answer in ${ms / 1000} s` : `${method} ${path}: ${(e as Error).message}`);
|
|
538
|
+
}
|
|
539
|
+
const j = (await r.json()) as { value: T & { error?: string; message?: string } };
|
|
540
|
+
if (!r.ok) throw new Error(`${j.value.error ?? r.status}: ${j.value.message ?? ""}`);
|
|
541
|
+
return j.value;
|
|
542
|
+
};
|
|
543
|
+
let session: string | null = null;
|
|
544
|
+
let proc: Proc | null = null;
|
|
545
|
+
const quit = onExit(async () => {
|
|
546
|
+
if (session) await call("DELETE", `/session/${session}`, undefined, 10_000).catch(() => {});
|
|
547
|
+
if (proc) {
|
|
548
|
+
proc.kill();
|
|
549
|
+
await proc.exited;
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
proc = Bun.spawn(["safaridriver", "-p", String(port)], { stdout: "ignore", stderr: "ignore" });
|
|
553
|
+
let up = false;
|
|
554
|
+
for (let i = 0; i < 50 && !up; i++) {
|
|
555
|
+
try {
|
|
556
|
+
up = (await fetch(`${base}/status`)).ok;
|
|
557
|
+
} catch {
|
|
558
|
+
await sleep(100);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (!up) refuse(`safaridriver did not start: ${IPHONE_FIX}`);
|
|
562
|
+
try {
|
|
563
|
+
const s = await call<{ sessionId: string }>(
|
|
564
|
+
"POST",
|
|
565
|
+
"/session",
|
|
566
|
+
{ capabilities: { alwaysMatch: { browserName: "safari", platformName: "iOS", ...(udid ? { "safari:deviceUDID": udid } : {}) } } },
|
|
567
|
+
120_000,
|
|
568
|
+
);
|
|
569
|
+
session = s.sessionId;
|
|
570
|
+
} catch (e) {
|
|
571
|
+
return refuse(`safaridriver could not open Safari on the iPhone (${(e as Error).message.split("\n")[0]}): ${IPHONE_FIX}`);
|
|
572
|
+
}
|
|
573
|
+
await call("POST", `/session/${session}/timeouts`, { script: 120_000, pageLoad: 120_000 });
|
|
574
|
+
return {
|
|
575
|
+
name: `iphone${udid ? ` (${udid})` : ""}`,
|
|
576
|
+
open: async (url) => void (await call("POST", `/session/${session}/url`, { url }, 130_000)),
|
|
577
|
+
eval: async <T>(expression: string, ms = CALL_MS) => {
|
|
578
|
+
const r = await call<{ ok?: T; err?: string }>(
|
|
579
|
+
"POST",
|
|
580
|
+
`/session/${session}/execute/async`,
|
|
581
|
+
{
|
|
582
|
+
script: `const done = arguments[arguments.length - 1]; Promise.resolve().then(() => (${expression})).then((v) => done({ ok: v }), (e) => done({ err: String(e && e.stack || e) }));`,
|
|
583
|
+
args: [],
|
|
584
|
+
},
|
|
585
|
+
ms,
|
|
586
|
+
);
|
|
587
|
+
if (r.err !== undefined) throw new Error(r.err);
|
|
588
|
+
return r.ok as T;
|
|
589
|
+
},
|
|
590
|
+
tap: async (selector) => {
|
|
591
|
+
const el = await call<Record<string, string>>("POST", `/session/${session}/element`, { using: "css selector", value: selector });
|
|
592
|
+
await call("POST", `/session/${session}/element/${el[ELEMENT]}/click`, {});
|
|
593
|
+
},
|
|
594
|
+
errors: () => [],
|
|
595
|
+
close: quit,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── measuring ─────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
interface Stats {
|
|
602
|
+
count: number;
|
|
603
|
+
mean: number;
|
|
604
|
+
p50: number;
|
|
605
|
+
p95: number;
|
|
606
|
+
p99: number;
|
|
607
|
+
max: number;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Nearest-rank percentiles, ms to 0.01. */
|
|
611
|
+
function stats(xs: readonly number[]): Stats {
|
|
612
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
613
|
+
const r = (x: number) => Math.round(x * 100) / 100;
|
|
614
|
+
const at = (p: number) => r(s[Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1))] ?? 0);
|
|
615
|
+
return { count: s.length, mean: r(s.reduce((a, b) => a + b, 0) / Math.max(1, s.length)), p50: at(50), p95: at(95), p99: at(99), max: r(s[s.length - 1] ?? 0) };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
interface DeviceInfo {
|
|
619
|
+
userAgent: string;
|
|
620
|
+
platform: string;
|
|
621
|
+
mobile: boolean;
|
|
622
|
+
dpr: number;
|
|
623
|
+
viewport: { width: number; height: number };
|
|
624
|
+
screen: { width: number; height: number };
|
|
625
|
+
canvas: { width: number; height: number } | null;
|
|
626
|
+
hardwareConcurrency: number | null;
|
|
627
|
+
deviceMemory: number | null;
|
|
628
|
+
crossOriginIsolated: boolean;
|
|
629
|
+
isSecureContext: boolean;
|
|
630
|
+
gpu: { vendor: string; architecture: string; description: string } | null;
|
|
631
|
+
webgl: { vendor: string; renderer: string } | null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const DEVICE_INFO = `(async () => {
|
|
635
|
+
const nav = navigator;
|
|
636
|
+
let gpu = null;
|
|
637
|
+
try {
|
|
638
|
+
const a = nav.gpu ? await nav.gpu.requestAdapter() : null;
|
|
639
|
+
const i = a ? (a.info || (a.requestAdapterInfo ? await a.requestAdapterInfo() : null)) : null;
|
|
640
|
+
if (i) gpu = { vendor: i.vendor || "", architecture: i.architecture || "", description: i.description || "" };
|
|
641
|
+
} catch {}
|
|
642
|
+
let webgl = null;
|
|
643
|
+
try {
|
|
644
|
+
const gl = document.createElement("canvas").getContext("webgl2");
|
|
645
|
+
if (gl) {
|
|
646
|
+
const x = gl.getExtension("WEBGL_debug_renderer_info");
|
|
647
|
+
webgl = { vendor: String(gl.getParameter(x ? x.UNMASKED_VENDOR_WEBGL : gl.VENDOR)), renderer: String(gl.getParameter(x ? x.UNMASKED_RENDERER_WEBGL : gl.RENDERER)) };
|
|
648
|
+
const lose = gl.getExtension("WEBGL_lose_context");
|
|
649
|
+
if (lose) lose.loseContext();
|
|
650
|
+
}
|
|
651
|
+
} catch {}
|
|
652
|
+
const c = document.querySelector("canvas");
|
|
653
|
+
const ua = nav.userAgentData;
|
|
654
|
+
return {
|
|
655
|
+
userAgent: nav.userAgent,
|
|
656
|
+
platform: (ua && ua.platform) || nav.platform || "",
|
|
657
|
+
mobile: ua ? !!ua.mobile : /Mobi|iPhone|Android/.test(nav.userAgent),
|
|
658
|
+
dpr: devicePixelRatio,
|
|
659
|
+
viewport: { width: innerWidth, height: innerHeight },
|
|
660
|
+
screen: { width: screen.width, height: screen.height },
|
|
661
|
+
canvas: c ? { width: Math.round(c.clientWidth * devicePixelRatio), height: Math.round(c.clientHeight * devicePixelRatio) } : null,
|
|
662
|
+
hardwareConcurrency: nav.hardwareConcurrency || null,
|
|
663
|
+
deviceMemory: nav.deviceMemory || null,
|
|
664
|
+
crossOriginIsolated: !!globalThis.crossOriginIsolated,
|
|
665
|
+
isSecureContext: !!globalThis.isSecureContext,
|
|
666
|
+
gpu,
|
|
667
|
+
webgl,
|
|
668
|
+
};
|
|
669
|
+
})()`;
|
|
670
|
+
|
|
671
|
+
/** Poll `expression` until it is truthy (its value) or `ms` pass (null); a stuck evaluation counts against `ms`. */
|
|
672
|
+
async function until<T>(d: Driver, expression: string, ms: number): Promise<T | null> {
|
|
673
|
+
const t0 = performance.now();
|
|
674
|
+
for (;;) {
|
|
675
|
+
let v: T | null = null;
|
|
676
|
+
try {
|
|
677
|
+
v = await d.eval<T>(expression, Math.max(1000, Math.min(CALL_MS, ms - (performance.now() - t0))));
|
|
678
|
+
} catch {
|
|
679
|
+
// The page is navigating (or did not answer in time); ask again.
|
|
680
|
+
}
|
|
681
|
+
if (v) return v;
|
|
682
|
+
if (performance.now() - t0 > ms) return null;
|
|
683
|
+
await sleep(100);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const mark = (name: string) => `(performance.getEntriesByName(${JSON.stringify(name)})[0] || {}).startTime || 0`;
|
|
688
|
+
const FAILED = `(() => { const c = document.getElementById("og-error"); return c && !c.hidden ? (c.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 1500) : ""; })()`;
|
|
689
|
+
|
|
690
|
+
interface Timings {
|
|
691
|
+
frames: number;
|
|
692
|
+
intervals: number[];
|
|
693
|
+
work: number[];
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
interface Measured {
|
|
697
|
+
firstFrameMs: number;
|
|
698
|
+
readyMs: number;
|
|
699
|
+
startMs: number;
|
|
700
|
+
tier: string;
|
|
701
|
+
seconds: number;
|
|
702
|
+
frames: number;
|
|
703
|
+
ticks: number;
|
|
704
|
+
interval: Stats;
|
|
705
|
+
work: Stats;
|
|
706
|
+
device: DeviceInfo;
|
|
707
|
+
errors: string[];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function measure(d: Driver, url: string, seconds: number): Promise<Measured> {
|
|
711
|
+
await d.open(url);
|
|
712
|
+
const failed = async () => (await d.eval<string>(FAILED).catch(() => "")) || "";
|
|
713
|
+
const probed = await until<boolean>(d, `typeof __runtime_probe__ === "object" && typeof __og_timings__ === "function" && (${mark("og:first-frame")}) > 0 || !!(${FAILED})`, 90_000);
|
|
714
|
+
const card = await failed();
|
|
715
|
+
if (card) refuse(`the page failed: ${card}`);
|
|
716
|
+
if (!probed) refuse(`no first frame in 90 s at ${url}${d.errors().length ? `: ${d.errors()[0]}` : ""}`);
|
|
717
|
+
const firstFrameMs = Math.round(await d.eval<number>(mark("og:first-frame")));
|
|
718
|
+
const readyMs = Math.round(await d.eval<number>(mark("og:ready")));
|
|
719
|
+
const hello = await d.eval<{ tier?: string }>("__runtime_probe__.hello()");
|
|
720
|
+
// The play gate: a real tap passes it (a project with `gate: none` has started already).
|
|
721
|
+
const gated = await until<string>(d, `(${mark("og:start")}) > 0 ? "started" : (document.getElementById("og-gate") && !document.getElementById("og-gate").hidden ? "gate" : "")`, 30_000);
|
|
722
|
+
if (!gated) refuse("the play gate never showed and play never started");
|
|
723
|
+
if (gated === "gate") {
|
|
724
|
+
await d.tap("#og-gate button");
|
|
725
|
+
if (!(await until<number>(d, mark("og:start"), 10_000))) refuse(`a tap on the play gate did not start play${(await failed()) ? `: ${await failed()}` : ""}`);
|
|
726
|
+
}
|
|
727
|
+
const startMs = Math.round(await d.eval<number>(mark("og:start")));
|
|
728
|
+
await sleep(1000);
|
|
729
|
+
await d.eval("__og_timings__(true)");
|
|
730
|
+
const t0 = await d.eval<number>("__runtime_probe__.tick()");
|
|
731
|
+
const began = performance.now();
|
|
732
|
+
await sleep(seconds * 1000);
|
|
733
|
+
const t = await d.eval<Timings>("__og_timings__()");
|
|
734
|
+
const t1 = await d.eval<number>("__runtime_probe__.tick()");
|
|
735
|
+
const elapsed = (performance.now() - began) / 1000;
|
|
736
|
+
const after = await failed();
|
|
737
|
+
if (after) refuse(`the page failed while playing: ${after}`);
|
|
738
|
+
if (t.intervals.length === 0) refuse(`no frame played in ${seconds} s after the gate (the Worker's clock did not run)`);
|
|
739
|
+
const device = await d.eval<DeviceInfo>(DEVICE_INFO);
|
|
740
|
+
return {
|
|
741
|
+
firstFrameMs,
|
|
742
|
+
readyMs,
|
|
743
|
+
startMs,
|
|
744
|
+
tier: hello.tier ?? "unknown",
|
|
745
|
+
seconds: Math.round(elapsed * 100) / 100,
|
|
746
|
+
frames: t.frames,
|
|
747
|
+
ticks: t1 - t0,
|
|
748
|
+
interval: stats(t.intervals),
|
|
749
|
+
work: stats(t.work),
|
|
750
|
+
device,
|
|
751
|
+
errors: d.errors(),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ── the report ────────────────────────────────────────────────────────
|
|
756
|
+
|
|
757
|
+
interface Finding {
|
|
758
|
+
measure: string;
|
|
759
|
+
where: string;
|
|
760
|
+
detail: string;
|
|
761
|
+
fix: string;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** 100 at `good`, 0 at `bad`, linear between (og polish's band). */
|
|
765
|
+
const band = (x: number, good: number, bad: number) => Math.round(100 * Math.max(0, Math.min(1, (bad - x) / (bad - good))));
|
|
766
|
+
|
|
767
|
+
/** The design's first-frame budgets (docs/design.md §7): mid-range Android on 4G 2.0 s, iPhone 13 1.5 s. */
|
|
768
|
+
const BUDGET_MS: Record<string, number> = { chrome: 2000, android: 2000, iphone: 1500 };
|
|
769
|
+
|
|
770
|
+
function judge(kind: string, m: Measured, served: Served): { scores: { firstFrame: number; frameTime: number }; findings: Finding[] } {
|
|
771
|
+
const budget = BUDGET_MS[kind]!;
|
|
772
|
+
const findings: Finding[] = [];
|
|
773
|
+
if (m.firstFrameMs > budget)
|
|
774
|
+
findings.push({
|
|
775
|
+
measure: "first frame",
|
|
776
|
+
where: "boot",
|
|
777
|
+
detail: `${m.firstFrameMs} ms from navigation to the first frame, over the ${budget} ms budget`,
|
|
778
|
+
fix: "og-game §6: keep the first group small (stream the rest), load in `start`, and check the size table of og build --publish",
|
|
779
|
+
});
|
|
780
|
+
if (m.interval.p95 > 20)
|
|
781
|
+
findings.push({
|
|
782
|
+
measure: "frame time",
|
|
783
|
+
where: m.tier,
|
|
784
|
+
detail: `p95 display interval ${m.interval.p95} ms (p99 ${m.interval.p99}, max ${m.interval.max}): frames are missed`,
|
|
785
|
+
fix: "og-game §14: fewer draws and particles, less per-tick TypeScript; og polish names the step and render cost",
|
|
786
|
+
});
|
|
787
|
+
if (m.work.p95 > 16.7)
|
|
788
|
+
findings.push({
|
|
789
|
+
measure: "engine work",
|
|
790
|
+
where: m.tier,
|
|
791
|
+
detail: `p95 engine work per frame ${m.work.p95} ms, over a 60 Hz frame`,
|
|
792
|
+
fix: "og-game §14: the tick or the render submission is too heavy for this device",
|
|
793
|
+
});
|
|
794
|
+
if (!m.device.crossOriginIsolated)
|
|
795
|
+
findings.push({
|
|
796
|
+
measure: "isolation",
|
|
797
|
+
where: m.device.isSecureContext ? "headers" : `${served.scheme} at the LAN address`,
|
|
798
|
+
detail: m.device.isSecureContext ? "the page is not cross-origin isolated although COOP/COEP were sent" : "not a secure context, so no cross-origin isolation (and no WebGPU)",
|
|
799
|
+
fix: m.device.isSecureContext ? "a resource is missing CORP or COEP; read device.json's serve headers" : "serve over localhost (Android: adb reverse, which --device android does) or HTTPS with --cert/--key (mkcert)",
|
|
800
|
+
});
|
|
801
|
+
if (m.errors.length)
|
|
802
|
+
findings.push({ measure: "console", where: "page", detail: `${m.errors.length} console error(s); first: ${m.errors[0]}`, fix: "read device.json's errors and fix the first" });
|
|
803
|
+
return { scores: { firstFrame: band(m.firstFrameMs, budget / 2, budget * 2), frameTime: band(m.interval.p95, 17, 33.3) }, findings };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function markdown(r: Report): string {
|
|
807
|
+
const m = r.measured;
|
|
808
|
+
const d = m.device;
|
|
809
|
+
const L: string[] = [];
|
|
810
|
+
L.push(`# og run --device: ${r.project}`, "");
|
|
811
|
+
L.push(`${r.date}, ${r.driver}, ${m.tier}. The publish build served at ${r.url}${r.lan.length ? ` (LAN ${r.lan.map((a) => `${r.scheme}://${a}:${r.port}/`).join(", ")})` : ""}, opened cold, the gate tapped, then ${m.seconds} s of play timed in the Worker.`, "");
|
|
812
|
+
L.push("## Scores", "");
|
|
813
|
+
L.push("| | Score | Measure |", "|---|---|---|");
|
|
814
|
+
L.push(`| First frame | ${r.scores.firstFrame} | ms from navigation to the first frame (${r.budgetMs / 2} ms: 100, ${r.budgetMs * 2} ms: 0; budget ${r.budgetMs} ms) |`);
|
|
815
|
+
L.push(`| Frame time | ${r.scores.frameTime} | p95 display interval of the played frames (17 ms: 100, 33.3 ms: 0) |`, "");
|
|
816
|
+
L.push("## Fixes", "");
|
|
817
|
+
if (r.findings.length === 0) L.push("No finding crossed a threshold.", "");
|
|
818
|
+
else {
|
|
819
|
+
L.push("| Measure | Where | Found | Fix |", "|---|---|---|---|");
|
|
820
|
+
for (const f of r.findings) L.push(`| ${f.measure} | ${f.where} | ${f.detail.replace(/\|/g, "\\|")} | ${f.fix} |`);
|
|
821
|
+
L.push("");
|
|
822
|
+
}
|
|
823
|
+
L.push("## Device", "", "| | |", "|---|---|");
|
|
824
|
+
L.push(`| User agent | ${d.userAgent} |`);
|
|
825
|
+
L.push(`| Platform | ${d.platform}${d.mobile ? ", mobile" : ""} |`);
|
|
826
|
+
L.push(`| Tier | ${m.tier} |`);
|
|
827
|
+
if (d.gpu) L.push(`| WebGPU adapter | ${[d.gpu.vendor, d.gpu.architecture, d.gpu.description].filter(Boolean).join(", ") || "(no info)"} |`);
|
|
828
|
+
if (d.webgl) L.push(`| WebGL2 renderer | ${d.webgl.renderer} (${d.webgl.vendor}) |`);
|
|
829
|
+
L.push(`| DPR, viewport, screen | ${d.dpr}, ${d.viewport.width}×${d.viewport.height} CSS px, ${d.screen.width}×${d.screen.height} |`);
|
|
830
|
+
if (d.canvas) L.push(`| Canvas | ${d.canvas.width}×${d.canvas.height} device px |`);
|
|
831
|
+
L.push(`| Cores, memory | ${d.hardwareConcurrency ?? "n/a"}, ${d.deviceMemory === null ? "n/a" : `${d.deviceMemory} GB`} |`);
|
|
832
|
+
L.push(`| Isolated, secure | ${d.crossOriginIsolated ? "yes" : "no"}, ${d.isSecureContext ? "yes" : "no"} |`, "");
|
|
833
|
+
L.push("## Frames", "", "| | count | mean | p50 | p95 | p99 | max |", "|---|---|---|---|---|---|---|");
|
|
834
|
+
for (const [name, s] of [["Display interval (ms)", m.interval], ["Engine work (ms)", m.work]] as const) L.push(`| ${name} | ${s.count} | ${s.mean} | ${s.p50} | ${s.p95} | ${s.p99} | ${s.max} |`);
|
|
835
|
+
L.push("");
|
|
836
|
+
L.push(`First frame ${m.firstFrameMs} ms, ready ${m.readyMs} ms, play started ${m.startMs} ms after navigation; ${m.frames} frames and ${m.ticks} ticks in ${m.seconds} s (${r.fps} fps, ${r.ticksPerSecond} ticks/s).`, "");
|
|
837
|
+
L.push("## Served", "", "| Path | COOP | COEP | CORP |", "|---|---|---|---|");
|
|
838
|
+
for (const [path, h] of Object.entries(r.served)) L.push(`| ${path} | ${h["cross-origin-opener-policy"] ?? "-"} | ${h["cross-origin-embedder-policy"] ?? "-"} | ${h["cross-origin-resource-policy"] ?? "-"} |`);
|
|
839
|
+
L.push("");
|
|
840
|
+
return L.join("\n") + "\n";
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
interface Report {
|
|
844
|
+
project: string;
|
|
845
|
+
date: string;
|
|
846
|
+
target: string;
|
|
847
|
+
driver: string;
|
|
848
|
+
url: string;
|
|
849
|
+
scheme: string;
|
|
850
|
+
port: number;
|
|
851
|
+
lan: string[];
|
|
852
|
+
budgetMs: number;
|
|
853
|
+
scores: { firstFrame: number; frameTime: number };
|
|
854
|
+
findings: Finding[];
|
|
855
|
+
fps: number;
|
|
856
|
+
ticksPerSecond: number;
|
|
857
|
+
measured: Measured;
|
|
858
|
+
served: Record<string, Record<string, string>>;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function main(): Promise<void> {
|
|
862
|
+
const dist = opt("--dist");
|
|
863
|
+
const project = opt("--project");
|
|
864
|
+
const target = opt("--device");
|
|
865
|
+
const out = opt("--out");
|
|
866
|
+
const preflight = flag("--preflight");
|
|
867
|
+
if (!target || (!preflight && (!dist || !project || !out)))
|
|
868
|
+
refuse("usage: bun src/node/device.ts --dist <build> --project <root> --device chrome|android[:serial]|iphone[:udid] --out <dir> [--seconds N] [--port P] [--headed] [--webgl] [--chrome <path>] [--cert <pem> --key <pem>] [--host <ip>] [--size WxH] [--json] (--preflight: only check the options and the target's tools)");
|
|
869
|
+
const [kindRaw, ...rest] = target!.split(":");
|
|
870
|
+
const kind = kindRaw === "ios" ? "iphone" : kindRaw!;
|
|
871
|
+
if (!(kind in BUDGET_MS)) refuse(`--device ${target}: the targets are chrome, android[:serial] and iphone[:udid] (or ios[:udid])`);
|
|
872
|
+
const id = rest.join(":") || null;
|
|
873
|
+
const seconds = Number(opt("--seconds") ?? 5);
|
|
874
|
+
if (!(seconds > 0)) refuse(`--seconds ${opt("--seconds")}: a positive number of seconds`);
|
|
875
|
+
const [w, h] = (opt("--size") ?? "1280x720").split("x").map(Number);
|
|
876
|
+
if (!(w! > 0 && h! > 0)) refuse(`--size ${opt("--size")}: WxH, as 1280x720`);
|
|
877
|
+
const cert = opt("--cert");
|
|
878
|
+
const key = opt("--key");
|
|
879
|
+
if (!!cert !== !!key) refuse("--cert and --key go together: a PEM certificate and its key (mkcert <LAN address> makes both)");
|
|
880
|
+
if (cert && kind !== "iphone") refuse(`--cert/--key serve HTTPS to the iPhone; --device ${kind} opens the page at localhost, already a secure context`);
|
|
881
|
+
const host = opt("--host");
|
|
882
|
+
if (host !== undefined && kind !== "iphone") refuse(`--host is the address the iPhone opens; --device ${kind} opens the page at localhost`);
|
|
883
|
+
if (host !== undefined && !/^[A-Za-z0-9.:-]+$/.test(host)) refuse(`--host ${host}: an IP address (or a host name) of this machine`);
|
|
884
|
+
for (const f of [cert, key]) if (f && !existsSync(f)) refuse(`no file ${f}`);
|
|
885
|
+
const tls = cert && key ? { cert: readFileSync(cert, "utf8"), key: readFileSync(key, "utf8") } : null;
|
|
886
|
+
// The target's tools, before og spends a build on it.
|
|
887
|
+
if (kind === "chrome") findChrome();
|
|
888
|
+
else if (kind === "android" && !Bun.which("adb")) refuse(NO_ADB);
|
|
889
|
+
else if (kind === "iphone" && (process.platform !== "darwin" || !Bun.which("safaridriver"))) refuse(NO_SAFARIDRIVER);
|
|
890
|
+
if (preflight) return;
|
|
891
|
+
if (!existsSync(join(dist!, "index.html"))) refuse(`${dist} has no index.html: og run --device builds it with og build --publish`);
|
|
892
|
+
|
|
893
|
+
// Only the iPhone reaches the server over the network; chrome opens it at
|
|
894
|
+
// 127.0.0.1 and android at its own localhost, which adb reverse brings here.
|
|
895
|
+
const served = serveBuild(resolve(dist!), Number(opt("--port") ?? 0), tls, kind === "iphone" ? "0.0.0.0" : "127.0.0.1");
|
|
896
|
+
onExit(() => served.stop());
|
|
897
|
+
const lanFound = kind === "iphone" ? lanAddresses() : [];
|
|
898
|
+
const lan = lanFound.map((a) => a.address);
|
|
899
|
+
const query = flag("--webgl") ? "?webgl=1" : "";
|
|
900
|
+
process.stderr.write(`og run --device: serving ${dist} at ${served.scheme}://127.0.0.1:${served.port}/${lan.map((a) => `, ${served.scheme}://${a}:${served.port}/`).join("")} (COOP/COEP)\n`);
|
|
901
|
+
let driver: Driver | null = null;
|
|
902
|
+
try {
|
|
903
|
+
let url = `${served.scheme}://127.0.0.1:${served.port}/${query}`;
|
|
904
|
+
if (kind === "chrome") driver = await launchChrome(w!, h!);
|
|
905
|
+
else if (kind === "android") {
|
|
906
|
+
const a = await connectAndroid(id, served.port, served.scheme);
|
|
907
|
+
driver = a.driver;
|
|
908
|
+
url = a.url + query;
|
|
909
|
+
} else {
|
|
910
|
+
const chosen = host ?? lanFound[0]?.address;
|
|
911
|
+
if (!chosen) refuse("no LAN address: join the iPhone's Wi-Fi network, or pass --host <ip>");
|
|
912
|
+
url = `${served.scheme}://${chosen}:${served.port}/${query}`;
|
|
913
|
+
process.stderr.write(
|
|
914
|
+
`og run --device: the iPhone opens ${url} (${host ? "--host" : `${lanFound[0]!.iface}; --host <ip> names another`})${tls ? `; the certificate has to name ${chosen}` : ""}\n`,
|
|
915
|
+
);
|
|
916
|
+
if (!tls) process.stderr.write("og run --device: an http LAN page is not a secure context on the iPhone (no isolation, WebGL2 only); --cert/--key serve HTTPS\n");
|
|
917
|
+
driver = await connectIphone(id);
|
|
918
|
+
}
|
|
919
|
+
const opened = driver;
|
|
920
|
+
onExit(() => opened.close());
|
|
921
|
+
const m = await measure(driver, url, seconds);
|
|
922
|
+
const { scores, findings } = judge(kind, m, served);
|
|
923
|
+
const report: Report = {
|
|
924
|
+
project: basename(resolve(project!)),
|
|
925
|
+
date: new Date().toISOString().slice(0, 10),
|
|
926
|
+
target: target!,
|
|
927
|
+
driver: driver.name,
|
|
928
|
+
url,
|
|
929
|
+
scheme: served.scheme,
|
|
930
|
+
port: served.port,
|
|
931
|
+
lan,
|
|
932
|
+
budgetMs: BUDGET_MS[kind]!,
|
|
933
|
+
scores,
|
|
934
|
+
findings,
|
|
935
|
+
fps: Math.round((m.frames / m.seconds) * 10) / 10,
|
|
936
|
+
ticksPerSecond: Math.round((m.ticks / m.seconds) * 10) / 10,
|
|
937
|
+
measured: m,
|
|
938
|
+
served: served.sent,
|
|
939
|
+
};
|
|
940
|
+
const dir = resolve(out!);
|
|
941
|
+
mkdirSync(dir, { recursive: true });
|
|
942
|
+
writeFileSync(join(dir, "device.json"), JSON.stringify(report, null, 1) + "\n");
|
|
943
|
+
writeFileSync(join(dir, "report.md"), markdown(report));
|
|
944
|
+
if (flag("--json")) process.stdout.write(JSON.stringify({ project: report.project, target, tier: m.tier, firstFrameMs: m.firstFrameMs, interval: m.interval, scores, findings: findings.length, report: join(dir, "report.md") }) + "\n");
|
|
945
|
+
else
|
|
946
|
+
process.stdout.write(
|
|
947
|
+
`og run --device ${target}: ${m.tier}, first frame ${m.firstFrameMs} ms, frame p50 ${m.interval.p50} / p95 ${m.interval.p95} / p99 ${m.interval.p99} ms; ${findings.length} finding(s); ${join(dir, "report.md")}\n`,
|
|
948
|
+
);
|
|
949
|
+
} finally {
|
|
950
|
+
await cleanUp();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (import.meta.main) {
|
|
955
|
+
// The signal's exit code (130 SIGINT, 143 SIGTERM, 129 SIGHUP) once one came: the run's
|
|
956
|
+
// own failure, which the cleanup provokes, is not reported over it.
|
|
957
|
+
let signalled = 0;
|
|
958
|
+
const leave = (code: number) => void cleanUp().then(() => process.exit(signalled || code));
|
|
959
|
+
const signals = process as unknown as { on(event: "SIGINT" | "SIGTERM" | "SIGHUP", listener: () => void): void };
|
|
960
|
+
for (const [sig, code] of [
|
|
961
|
+
["SIGINT", 130],
|
|
962
|
+
["SIGTERM", 143],
|
|
963
|
+
["SIGHUP", 129],
|
|
964
|
+
] as const)
|
|
965
|
+
signals.on(sig, () => {
|
|
966
|
+
// A second one (og passes on the terminal's Ctrl-C, which reached this
|
|
967
|
+
// process too) waits for the same cleanup; 20 s is its limit.
|
|
968
|
+
if (signalled) return;
|
|
969
|
+
signalled = code;
|
|
970
|
+
process.stderr.write(`og run --device: ${sig}, undoing the run's setup\n`);
|
|
971
|
+
setTimeout(() => process.exit(code), 20_000);
|
|
972
|
+
leave(code);
|
|
973
|
+
});
|
|
974
|
+
main().then(
|
|
975
|
+
() => leave(0),
|
|
976
|
+
(e) => {
|
|
977
|
+
if (!signalled) process.stderr.write(`og run --device: ${e instanceof Refusal ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e)}\n`);
|
|
978
|
+
leave(1);
|
|
979
|
+
},
|
|
980
|
+
);
|
|
981
|
+
}
|