@bitmagic/cli 0.1.54-dev.0 → 0.1.54-dev.2

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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * The mobile bridge: the one server `bitmagic dev` exposes to the local network.
3
+ *
4
+ * `dev` normally binds nothing outside loopback. vite serves the game on 127.0.0.1 and the editor
5
+ * sidecar deliberately stays there too — it writes files in the creator's project, and no phone
6
+ * needs the shell. So rather than opening either of them up, `--mobile` puts THIS in front: a
7
+ * reverse proxy that terminates the phone's connection, forwards the game, and does the two things
8
+ * a plain `--host` on vite could not.
9
+ *
10
+ * 1. It serves the CDN. A LAN origin is on no bucket's CORS allowlist, so the phone asking the
11
+ * bucket directly gets nothing (`../local-port.ts` documents what that looks like from the
12
+ * creator's side: a level with no terrain). Same-origin `/__bm/cdn/<host>/<path>` requests
13
+ * land here instead and are fetched server-side, where CORS does not apply.
14
+ * 2. It injects `rewrite-shim.ts` into every HTML page it passes through, which is what makes
15
+ * the engine ask for (1) in the first place.
16
+ *
17
+ * WebSocket upgrades are piped straight through to vite, so its HMR client connects from the phone
18
+ * and the page reloads on a rebuild exactly as the desktop shell does.
19
+ *
20
+ * What is NOT here is as deliberate: no route to the sidecar, and therefore no way for anything on
21
+ * the network to save a scene, start a generation, or read the editor journal. The phone gets the
22
+ * game and the game's assets.
23
+ */
24
+ import * as http from 'http';
25
+ import * as https from 'https';
26
+ import * as net from 'net';
27
+ import { Readable } from 'stream';
28
+ import { CDN_PROXY_PREFIX, fromProxyPath } from './asset-hosts.js';
29
+ import { RELOAD_STREAM_PATH } from './rewrite-shim.js';
30
+ import { injectMobileShim } from './rewrite-shim.js';
31
+ /**
32
+ * The name vite is reached by — a NAME, not a literal address, and that is the whole point.
33
+ *
34
+ * vite binds `localhost`, which on a Mac resolves to ::1 first: a bridge dialling 127.0.0.1 gets
35
+ * ECONNREFUSED from a dev server that is plainly running, and every page comes back 502. Handing
36
+ * Node the name lets it try both families, so this works whichever one vite ended up on.
37
+ */
38
+ const LOOPBACK = 'localhost';
39
+ /** Request headers worth carrying to the bucket. Everything else is the browser's business. */
40
+ const FORWARDED_REQUEST_HEADERS = ['range', 'if-none-match', 'if-modified-since', 'accept'];
41
+ /** Response headers worth carrying back. Notably NOT the CORS ones: this response is same-origin. */
42
+ const FORWARDED_RESPONSE_HEADERS = [
43
+ 'content-type', 'content-length', 'content-range', 'accept-ranges',
44
+ 'etag', 'last-modified', 'cache-control',
45
+ ];
46
+ function copyHeaders(from, names) {
47
+ const headers = {};
48
+ for (const name of names) {
49
+ const value = from[name];
50
+ if (typeof value === 'string')
51
+ headers[name] = value;
52
+ }
53
+ return headers;
54
+ }
55
+ /**
56
+ * Serve one `/__bm/cdn/...` request by fetching it ourselves.
57
+ *
58
+ * `range` is forwarded because a browser asks for one on media, and answering a range request with
59
+ * a whole body is one of the few ways to make a video simply never start.
60
+ */
61
+ async function proxyAsset(req, res, fetchImpl, log) {
62
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
63
+ res.writeHead(405, { 'Content-Type': 'text/plain' }).end('Only GET and HEAD are proxied.\n');
64
+ return;
65
+ }
66
+ const target = fromProxyPath((req.url ?? '/').split('#')[0]);
67
+ if (target === null) {
68
+ // Refused rather than fetched: this route is reachable by anything on the local network, and a
69
+ // proxy that will fetch any host for any caller is an open relay on the creator's machine.
70
+ res.writeHead(403, { 'Content-Type': 'text/plain' }).end('Not a bitmagic asset host.\n');
71
+ return;
72
+ }
73
+ let upstream;
74
+ try {
75
+ upstream = await fetchImpl(target, {
76
+ method: req.method,
77
+ headers: copyHeaders(req.headers, FORWARDED_REQUEST_HEADERS),
78
+ redirect: 'follow',
79
+ });
80
+ }
81
+ catch (error) {
82
+ // Worth a line in the terminal: from the phone this looks exactly like the CORS failure the
83
+ // bridge exists to remove, and the creator cannot see the reason from there.
84
+ log(`[mobile] could not fetch ${target}: ${error.message}`);
85
+ res.writeHead(502, { 'Content-Type': 'text/plain' }).end('Upstream asset fetch failed.\n');
86
+ return;
87
+ }
88
+ const headers = {};
89
+ for (const name of FORWARDED_RESPONSE_HEADERS) {
90
+ const value = upstream.headers.get(name);
91
+ if (value !== null)
92
+ headers[name] = value;
93
+ }
94
+ // fetch() DECODES the body but leaves the encoded representation's headers on the response, so a
95
+ // brotli'd .glb arrives carrying a content-length that describes the COMPRESSED bytes. Forwarded,
96
+ // it frames the decoded body short, and what the browser gets is a GLB truncated to a fraction of
97
+ // itself - "Invalid typed array length" out of GLTFBinaryExtension, on every model and every
98
+ // animation, with a 200 on each request and nothing anywhere saying the bytes are short.
99
+ // Dropping it lets Node chunk a body whose length we no longer know.
100
+ if (upstream.headers.get('content-encoding') !== null)
101
+ delete headers['content-length'];
102
+ res.writeHead(upstream.status, headers);
103
+ if (req.method === 'HEAD' || upstream.body === null) {
104
+ res.end();
105
+ return;
106
+ }
107
+ Readable.fromWeb(upstream.body).pipe(res);
108
+ }
109
+ /**
110
+ * Forward everything that is not an asset to vite, injecting the shim into HTML on the way back.
111
+ *
112
+ * The Host header is REWRITTEN to loopback. vite refuses requests whose Host it does not recognise,
113
+ * and the phone's is a LAN address it has never heard of — left alone, every request would come
114
+ * back "Blocked request. This host is not allowed." `accept-encoding` is dropped for the same class
115
+ * of reason: a gzipped HTML body is one the shim cannot be spliced into, and on loopback the
116
+ * compression buys nothing.
117
+ */
118
+ function proxyGame(req, res, gamePort, log) {
119
+ const headers = { ...req.headers, host: `localhost:${gamePort}` };
120
+ delete headers['accept-encoding'];
121
+ const upstream = http.request({ host: LOOPBACK, port: gamePort, method: req.method, path: req.url, headers }, (proxied) => {
122
+ const type = proxied.headers['content-type'] ?? '';
123
+ if (!type.includes('text/html')) {
124
+ res.writeHead(proxied.statusCode ?? 502, proxied.headers);
125
+ proxied.pipe(res);
126
+ return;
127
+ }
128
+ const chunks = [];
129
+ proxied.on('data', (chunk) => chunks.push(chunk));
130
+ proxied.on('end', () => {
131
+ const body = Buffer.from(injectMobileShim(Buffer.concat(chunks).toString('utf-8')), 'utf-8');
132
+ const outgoing = { ...proxied.headers, 'content-length': String(body.byteLength) };
133
+ // The body was buffered to splice it, so whatever framing the upstream chose no longer
134
+ // describes it — a stale transfer-encoding here hangs the phone on a chunk that never comes.
135
+ delete outgoing['transfer-encoding'];
136
+ res.writeHead(proxied.statusCode ?? 502, outgoing);
137
+ res.end(body);
138
+ });
139
+ });
140
+ upstream.on('error', (error) => {
141
+ log(`[mobile] game server did not answer: ${error.message}`);
142
+ if (!res.headersSent)
143
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
144
+ res.end('The game server is not answering.\n');
145
+ });
146
+ req.pipe(upstream);
147
+ }
148
+ /**
149
+ * Start the bridge and return the URL to hand the phone.
150
+ *
151
+ * Listens on every interface — that is the point of the command — which is why `dev` prints what
152
+ * that means alongside the URL.
153
+ */
154
+ export async function startMobileBridge(options) {
155
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
156
+ const handle = (req, res) => {
157
+ if ((req.url ?? '/').split('?')[0] === RELOAD_STREAM_PATH) {
158
+ if (options.subscribe === undefined) {
159
+ res.writeHead(404, { 'Content-Type': 'text/plain' }).end('No reload channel.\n');
160
+ return;
161
+ }
162
+ options.subscribe(req, res);
163
+ return;
164
+ }
165
+ if ((req.url ?? '/').startsWith(CDN_PROXY_PREFIX)) {
166
+ void proxyAsset(req, res, fetchImpl, options.log);
167
+ return;
168
+ }
169
+ proxyGame(req, res, options.gamePort, options.log);
170
+ };
171
+ const server = options.tls === null
172
+ ? http.createServer(handle)
173
+ : https.createServer({ key: options.tls.key, cert: options.tls.cert }, handle);
174
+ // vite's HMR client opens a WebSocket back to whatever origin served the page, so the phone's
175
+ // connects HERE. Piped at the socket rather than proxied at the HTTP layer: an upgrade is only
176
+ // the handshake, and after it the two ends speak a protocol this has no business parsing.
177
+ server.on('upgrade', (req, socket, head) => {
178
+ const upstream = net.connect(options.gamePort, LOOPBACK, () => {
179
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
180
+ for (const [name, value] of Object.entries({ ...req.headers, host: `localhost:${options.gamePort}` })) {
181
+ for (const one of Array.isArray(value) ? value : [value ?? ''])
182
+ lines.push(`${name}: ${one}`);
183
+ }
184
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
185
+ if (head.length > 0)
186
+ upstream.write(head);
187
+ upstream.pipe(socket);
188
+ socket.pipe(upstream);
189
+ });
190
+ // A dead HMR socket is not worth a line of output: the browser retries on its own, and the
191
+ // page works without it.
192
+ upstream.on('error', () => socket.destroy());
193
+ socket.on('error', () => upstream.destroy());
194
+ });
195
+ // Sockets are tracked so close() actually finishes: an idle keep-alive connection from a phone
196
+ // that has since gone to sleep would otherwise hold Ctrl-C open until it times out.
197
+ const sockets = new Set();
198
+ const track = (socket) => {
199
+ sockets.add(socket);
200
+ socket.on('close', () => sockets.delete(socket));
201
+ };
202
+ server.on('connection', track);
203
+ server.on('secureConnection', track);
204
+ await new Promise((resolve, reject) => {
205
+ server.once('error', reject);
206
+ server.listen(options.port, '0.0.0.0', () => {
207
+ server.removeListener('error', reject);
208
+ resolve();
209
+ });
210
+ });
211
+ // The port is read back off the socket rather than echoed from the options: passing 0 is how the
212
+ // test asks the OS for one, and a URL naming port 0 would be a URL nothing can open.
213
+ const bound = server.address();
214
+ const port = bound !== null && typeof bound !== 'string' ? bound.port : options.port;
215
+ return {
216
+ url: `${options.tls === null ? 'http' : 'https'}://${options.address}:${port}/`,
217
+ close: () => new Promise((resolve) => {
218
+ for (const socket of sockets)
219
+ socket.destroy();
220
+ server.close(() => resolve());
221
+ }),
222
+ };
223
+ }
224
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.js","sourceRoot":"","sources":["../../src/mobile/bridge.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ export interface DevCert {
2
+ key: string;
3
+ cert: string;
4
+ /** Where the pair lives, so the banner can point at it if a creator wants to trust it properly. */
5
+ dir: string;
6
+ }
7
+ export interface DevCertDeps {
8
+ /** `~/.bitmagic` by default. Injected by the test — see the note below. */
9
+ baseDir?: string;
10
+ /**
11
+ * Runs openssl. Returns its exit status and whatever it said, never throws.
12
+ *
13
+ * A seam rather than a spy on `child_process`, and `baseDir` above is a parameter rather than a
14
+ * `$HOME` redirect, for the same reason: `os.homedir()` ignores `$HOME` on some platforms, so a
15
+ * test that only moved the env var would generate into the creator's REAL `~/.bitmagic`.
16
+ */
17
+ run?: (command: string, args: string[]) => {
18
+ status: number | null;
19
+ stderr: string;
20
+ };
21
+ now?: () => number;
22
+ }
23
+ /**
24
+ * The certificate for `address`, generated if there is not a usable one already.
25
+ *
26
+ * Returns null when openssl is missing or refuses — a machine without it still gets a working
27
+ * `--mobile`, over http, minus WebGPU. The caller says so; nothing here fails the dev server, and
28
+ * nothing here is worth an exception: this is a capability, and its absence has a fallback.
29
+ */
30
+ export declare function ensureDevCert(address: string, deps?: DevCertDeps): DevCert | null;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The self-signed certificate the mobile bridge serves the phone over.
3
+ *
4
+ * TLS is not decoration here. A page on `http://192.168.x.y:3012` is NOT a secure context, and
5
+ * `navigator.gpu` is only exposed to secure contexts — so over plain http the engine's renderer
6
+ * pick downgrades to classic WebGL (`RendererPreference.ts`, `withoutUnavailableWebGpu`) and the
7
+ * one thing a real device is uniquely able to tell us about, Safari's WebGPU pipeline, is exactly
8
+ * the thing that cannot be observed. https on the LAN address is what gets it back, along with
9
+ * device orientation and anything else gated the same way.
10
+ *
11
+ * The certificate is self-signed, so the phone shows an interstitial once per address. That is the
12
+ * price and it is worth naming in the banner rather than hiding: the alternative is a WebGL-only
13
+ * device test that quietly disagrees with production.
14
+ *
15
+ * Two details are not arbitrary:
16
+ * - the address goes in a subjectAltName as an IP. Safari has not honoured a CN-only certificate
17
+ * for years; without the SAN the interstitial has no "visit anyway" to offer at all.
18
+ * - 397 days. Apple platforms reject TLS certificates valid for more than 398.
19
+ *
20
+ * The pair is CACHED under `~/.bitmagic/dev-certs/` per address, because the exception the creator
21
+ * tapped through is bound to the certificate: regenerating one per run would make them do it again
22
+ * every single time.
23
+ */
24
+ import * as fs from 'fs';
25
+ import * as os from 'os';
26
+ import * as path from 'path';
27
+ import { spawnSync } from 'child_process';
28
+ import { defaultBaseDir } from '../config/credentials.js';
29
+ /** Apple's ceiling is 398 days; sitting just under it leaves room for clock skew. */
30
+ const VALID_DAYS = 397;
31
+ /**
32
+ * When a cached pair is considered too old to reuse. Comfortably inside `VALID_DAYS`, so a creator
33
+ * meets a fresh certificate before an expired one — an expired certificate on iOS is a harder
34
+ * interstitial than an untrusted one, and on some versions has no way through at all.
35
+ */
36
+ const REUSE_DAYS = 300;
37
+ function defaultRun(command, args) {
38
+ const result = spawnSync(command, args, { encoding: 'utf-8' });
39
+ if (result.error)
40
+ return { status: null, stderr: result.error.message };
41
+ return { status: result.status, stderr: result.stderr ?? '' };
42
+ }
43
+ /** OpenSSL and LibreSSL both read this; `-addext` is OpenSSL-only, which is why it is not used. */
44
+ function opensslConfig(address) {
45
+ return [
46
+ '[req]',
47
+ 'distinguished_name = dn',
48
+ 'x509_extensions = ext',
49
+ 'prompt = no',
50
+ '',
51
+ '[dn]',
52
+ `CN = Bitmagic dev ${address}`,
53
+ '',
54
+ '[ext]',
55
+ `subjectAltName = IP:${address}`,
56
+ 'basicConstraints = critical, CA:FALSE',
57
+ 'keyUsage = digitalSignature, keyEncipherment',
58
+ 'extendedKeyUsage = serverAuth',
59
+ '',
60
+ ].join('\n');
61
+ }
62
+ /**
63
+ * The certificate for `address`, generated if there is not a usable one already.
64
+ *
65
+ * Returns null when openssl is missing or refuses — a machine without it still gets a working
66
+ * `--mobile`, over http, minus WebGPU. The caller says so; nothing here fails the dev server, and
67
+ * nothing here is worth an exception: this is a capability, and its absence has a fallback.
68
+ */
69
+ export function ensureDevCert(address, deps = {}) {
70
+ const run = deps.run ?? defaultRun;
71
+ const now = deps.now ?? Date.now;
72
+ const dir = path.join(deps.baseDir ?? defaultBaseDir(), 'dev-certs');
73
+ const keyPath = path.join(dir, `${address}.key.pem`);
74
+ const certPath = path.join(dir, `${address}.crt.pem`);
75
+ try {
76
+ const age = now() - fs.statSync(certPath).mtimeMs;
77
+ if (age < REUSE_DAYS * 24 * 60 * 60 * 1000) {
78
+ return { key: fs.readFileSync(keyPath, 'utf-8'), cert: fs.readFileSync(certPath, 'utf-8'), dir };
79
+ }
80
+ }
81
+ catch {
82
+ // No pair yet, or half of one. Either way the answer is to make it.
83
+ }
84
+ const configPath = path.join(os.tmpdir(), `bitmagic-dev-cert-${address}-${process.pid}.cnf`);
85
+ try {
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ fs.writeFileSync(configPath, opensslConfig(address));
88
+ const result = run('openssl', [
89
+ 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes',
90
+ '-days', String(VALID_DAYS),
91
+ '-keyout', keyPath,
92
+ '-out', certPath,
93
+ '-config', configPath,
94
+ ]);
95
+ if (result.status !== 0)
96
+ return null;
97
+ // The key is a secret even though it protects nothing but a dev server: a world-readable
98
+ // private key in a home directory is the kind of thing that outlives the reason for it.
99
+ fs.chmodSync(keyPath, 0o600);
100
+ return { key: fs.readFileSync(keyPath, 'utf-8'), cert: fs.readFileSync(certPath, 'utf-8'), dir };
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ finally {
106
+ try {
107
+ fs.unlinkSync(configPath);
108
+ }
109
+ catch {
110
+ // A leftover config in the temp dir is not a reason to fail, and the OS clears it.
111
+ }
112
+ }
113
+ }
114
+ //# sourceMappingURL=dev-cert.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dev-cert.js","sourceRoot":"","sources":["../../src/mobile/dev-cert.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The address a phone on the same network can reach this machine at.
3
+ *
4
+ * `bitmagic dev --mobile` has to put a URL on screen that a creator scans and their phone resolves,
5
+ * and the only thing that can answer "which of my addresses is that" is the host's own interface
6
+ * list. There is no discovery here and no mDNS: a `.local` name works on Apple devices and fails
7
+ * quietly on many Android ones, whereas a literal IPv4 works everywhere and is what the certificate
8
+ * has to name anyway (`dev-cert.ts` puts it in the SAN).
9
+ *
10
+ * Only PRIVATE IPv4 addresses are offered. A machine with a public address on an interface would
11
+ * still bind it — the bridge listens on 0.0.0.0 — but printing it, and drawing a QR of it, would
12
+ * invite a creator to hand out a URL that is not merely LAN-visible. Loopback is excluded for the
13
+ * obvious reason, and 169.254/16 because an interface that self-assigned has no network to be on.
14
+ */
15
+ import type * as os from 'os';
16
+ export interface LanAddress {
17
+ /** The interface it was found on, so a machine with several can be told which is which. */
18
+ name: string;
19
+ address: string;
20
+ }
21
+ /** What `os.networkInterfaces()` returns, as a parameter so the pick is testable off a fixture. */
22
+ export type NetworkInterfaces = ReturnType<typeof os.networkInterfaces>;
23
+ /**
24
+ * Every address a phone could plausibly reach, best candidate first.
25
+ *
26
+ * Returns all of them rather than one, because a laptop on Wi-Fi and Ethernet at once has two right
27
+ * answers and only the creator knows which network the phone is on — `dev` prints the rest under
28
+ * the one it drew a QR for.
29
+ */
30
+ export declare function lanAddresses(interfaces: NetworkInterfaces): LanAddress[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Ranking, best first. Home and office Wi-Fi is 192.168/16 far more often than anything else, and a
3
+ * 10/8 address on a laptop is as likely to be a VPN's as the LAN's — a tunnel the phone is not on.
4
+ * 172.16/12 sits last because Docker's default bridge lives there and reaches nothing.
5
+ */
6
+ const PREFIXES = [
7
+ (a) => a[0] === 192 && a[1] === 168,
8
+ (a) => a[0] === 10,
9
+ (a) => a[0] === 172 && a[1] >= 16 && a[1] <= 31,
10
+ ];
11
+ /** Physical-looking interfaces first: a VPN's utun/tun is a private address the phone cannot use. */
12
+ const PHYSICAL = /^(en|eth|wlan|wl|wifi)/i;
13
+ function octets(address) {
14
+ const parts = address.split('.');
15
+ if (parts.length !== 4)
16
+ return null;
17
+ const values = parts.map((part) => Number(part));
18
+ if (values.some((value) => !Number.isInteger(value) || value < 0 || value > 255))
19
+ return null;
20
+ return values;
21
+ }
22
+ /**
23
+ * Every address a phone could plausibly reach, best candidate first.
24
+ *
25
+ * Returns all of them rather than one, because a laptop on Wi-Fi and Ethernet at once has two right
26
+ * answers and only the creator knows which network the phone is on — `dev` prints the rest under
27
+ * the one it drew a QR for.
28
+ */
29
+ export function lanAddresses(interfaces) {
30
+ const found = [];
31
+ for (const [name, infos] of Object.entries(interfaces)) {
32
+ for (const info of infos ?? []) {
33
+ // Node <18.4 reported `family` as the number 4; both forms are accepted so a creator on an
34
+ // older runtime than package.json asks for does not get a silently empty list.
35
+ const isIPv4 = info.family === 'IPv4' || info.family === 4;
36
+ if (!isIPv4 || info.internal)
37
+ continue;
38
+ const parts = octets(info.address);
39
+ if (parts === null)
40
+ continue;
41
+ const prefix = PREFIXES.findIndex((matches) => matches(parts));
42
+ if (prefix === -1)
43
+ continue;
44
+ found.push({
45
+ entry: { name, address: info.address },
46
+ rank: prefix * 2 + (PHYSICAL.test(name) ? 0 : 1),
47
+ });
48
+ }
49
+ }
50
+ return found
51
+ .sort((a, b) => a.rank - b.rank || a.entry.name.localeCompare(b.entry.name))
52
+ .map((candidate) => candidate.entry);
53
+ }
54
+ //# sourceMappingURL=lan-address.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lan-address.js","sourceRoot":"","sources":["../../src/mobile/lan-address.ts"],"names":[],"mappings":"AAyBA;;;;GAIG;AACH,MAAM,QAAQ,GAAG;IACf,CAAC,CAAW,EAAW,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG;IACtD,CAAC,CAAW,EAAW,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE;IACrC,CAAC,CAAW,EAAW,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE;CACnE,CAAC;AAEF,qGAAqG;AACrG,MAAM,QAAQ,GAAG,yBAAyB,CAAC;AAE3C,SAAS,MAAM,CAAC,OAAe;IAC7B,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACjC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACpC,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;IACjD,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9F,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,UAA6B;IACxD,MAAM,KAAK,GAA0C,EAAE,CAAC;IACxD,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC;QACvD,KAAK,MAAM,IAAI,IAAI,KAAK,IAAI,EAAE,EAAE,CAAC;YAC/B,2FAA2F;YAC3F,+EAA+E;YAC/E,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,KAAK,MAAM,IAAK,IAAI,CAAC,MAA4B,KAAK,CAAC,CAAC;YAClF,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,QAAQ;gBAAE,SAAS;YACvC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACnC,IAAI,KAAK,KAAK,IAAI;gBAAE,SAAS;YAC7B,MAAM,MAAM,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;YAC/D,IAAI,MAAM,KAAK,CAAC,CAAC;gBAAE,SAAS;YAC5B,KAAK,CAAC,IAAI,CAAC;gBACT,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE;gBACtC,IAAI,EAAE,MAAM,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;aACjD,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IACD,OAAO,KAAK;SACT,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;SAC3E,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;AACzC,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where the bridge relays the reload bus, and where the shim listens for it.
3
+ *
4
+ * The phone is otherwise outside the dev loop: the auto-reload the desktop enjoys lives in the
5
+ * shell, which is loopback-only and which no phone loads. Without this, every agent edit ends in
6
+ * "now pick the phone back up and pull to refresh".
7
+ *
8
+ * `/stream` because every streaming endpoint in this codebase ends that way.
9
+ */
10
+ export declare const RELOAD_STREAM_PATH = "/__bm/reload/stream";
11
+ /**
12
+ * The shim body. Kept in step with `asset-hosts.ts` by INTERPOLATING the prefix and the suffix list
13
+ * rather than restating them: the browser half and the server half disagreeing would mean assets
14
+ * rewritten to a route the bridge does not serve, or served on a route nobody asks for.
15
+ */
16
+ export declare const MOBILE_SHIM_SOURCE: string;
17
+ /** The shim as a tag, ready to splice into a document. */
18
+ export declare const MOBILE_SHIM_TAG: string;
19
+ /**
20
+ * `html` with the shim installed ahead of everything else on the page.
21
+ *
22
+ * Prefers `</head>`, falls back to the opening `<body>` tag and finally to the front of the
23
+ * document — an index.html a creator has rewritten still has to get the shim, and a page that
24
+ * loads its assets before the shim is a page with no terrain.
25
+ */
26
+ export declare function injectMobileShim(html: string): string;