@farmslot/adapter-web 0.5.0 → 0.6.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/CHANGELOG.md +10 -0
- package/README.md +25 -1
- package/dist/web-dapp/cleanup.d.mts +15 -0
- package/dist/web-dapp/index.d.mts +124 -0
- package/dist/web-dapp/inject.d.mts +12 -0
- package/dist/web-dapp/launch.d.mts +57 -0
- package/dist/web-dapp/lib/blocked-hosts.d.mts +3 -0
- package/dist/web-dapp/lib/cdp-client.d.mts +8 -0
- package/dist/web-dapp/lib/display.d.mts +26 -0
- package/dist/web-dapp/lib/hud.d.mts +14 -0
- package/dist/web-dapp/lib/paths.d.mts +11 -0
- package/dist/web-dapp/lib/policy-fence.d.mts +18 -0
- package/dist/web-dapp/lib/policy.d.mts +4 -0
- package/dist/web-dapp/lib/processes.d.mts +57 -0
- package/dist/web-dapp/lib/readiness.d.mts +85 -0
- package/dist/web-dapp/lib/runtime.d.mts +29 -0
- package/dist/web-dapp/lib/session-lock.d.mts +21 -0
- package/dist/web-dapp/lib/signers.d.mts +13 -0
- package/dist/web-dapp/lib/viem.d.mts +1 -0
- package/dist/web-dapp/stop.d.mts +2 -0
- package/dist/web-dapp/verify.d.mts +2 -0
- package/dist/web-dapp/wallet-host.d.mts +2 -0
- package/package.json +17 -10
- package/src/web-dapp/cleanup.mjs +74 -0
- package/src/web-dapp/index.mjs +596 -0
- package/src/web-dapp/inject.mjs +100 -0
- package/src/web-dapp/launch.mjs +818 -0
- package/src/web-dapp/lib/blocked-hosts.mjs +24 -0
- package/src/web-dapp/lib/cdp-client.mjs +86 -0
- package/src/web-dapp/lib/display.mjs +169 -0
- package/src/web-dapp/lib/hud.mjs +156 -0
- package/src/web-dapp/lib/paths.mjs +55 -0
- package/src/web-dapp/lib/policy-fence.mjs +128 -0
- package/src/web-dapp/lib/policy.mjs +108 -0
- package/src/web-dapp/lib/processes.mjs +215 -0
- package/src/web-dapp/lib/readiness.mjs +438 -0
- package/src/web-dapp/lib/runtime.mjs +195 -0
- package/src/web-dapp/lib/session-lock.mjs +56 -0
- package/src/web-dapp/lib/signers.mjs +105 -0
- package/src/web-dapp/lib/viem.mjs +26 -0
- package/src/web-dapp/stop.mjs +44 -0
- package/src/web-dapp/verify.mjs +68 -0
- package/src/web-dapp/wallet-host.mjs +728 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// launch.mjs — start (or reuse) the web-dapp slot browser and its wallet host.
|
|
3
|
+
//
|
|
4
|
+
// Purpose:
|
|
5
|
+
// One Chromium-family browser per slot, bound to 127.0.0.1:<cdp-port>, with a
|
|
6
|
+
// profile under <runtime>/<adapter id>/. signer=extension loads a wallet
|
|
7
|
+
// extension and seeds the fixture account through the extension signer hook
|
|
8
|
+
// (lib/signers.mjs: prepareProfile, then afterBrowserStart); this launcher
|
|
9
|
+
// knows no wallet product. signer=injected loads no extension; the wallet host
|
|
10
|
+
// provides the strict test wallet. Either way wallet-host.mjs then records
|
|
11
|
+
// wallet requests.
|
|
12
|
+
//
|
|
13
|
+
// Inputs (flags): --target <checkout> --cdp-port <port> --app-port <port>
|
|
14
|
+
// [--signer extension|injected] [--signer-module <path>] [--account <fixture name>]
|
|
15
|
+
// [--fresh-profile] [--headless | --headful] [--slow-mo <ms>] [--start-chain <id>]
|
|
16
|
+
// [--json] [--network mainnet --mainnet-confirmation REAL_FUNDS] (testnet
|
|
17
|
+
// otherwise, enforced in the browser from the venue policy)
|
|
18
|
+
// env: TERMINAL_CHROME_BIN, RECIPE_WEB_DAPP_SIGNER_MODULE, RECIPE_WALLET_FIXTURE,
|
|
19
|
+
// TERMINAL_HEADLESS, TERMINAL_SLOW_MO, TERMINAL_WINDOW, TERMINAL_SCREEN
|
|
20
|
+
// (plus whatever the signer module reads)
|
|
21
|
+
// The browser is headful by default, in a window placed per slot (see
|
|
22
|
+
// lib/display.mjs); --headless or TERMINAL_HEADLESS=1 hides it.
|
|
23
|
+
// Outputs: <runtime>/<adapter id>/browser.json (+ pid files, logs); JSON summary on stdout.
|
|
24
|
+
// Exit 0 ready; 1 launch/seed failure; 2 usage.
|
|
25
|
+
// Never prints: mnemonics, private keys, the wallet password.
|
|
26
|
+
|
|
27
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import {
|
|
30
|
+
appendFileSync,
|
|
31
|
+
chmodSync,
|
|
32
|
+
closeSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
openSync,
|
|
36
|
+
readdirSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
renameSync,
|
|
39
|
+
rmSync,
|
|
40
|
+
statSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from 'node:fs';
|
|
43
|
+
import { createRequire } from 'node:module';
|
|
44
|
+
import os from 'node:os';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { fileURLToPath } from 'node:url';
|
|
47
|
+
|
|
48
|
+
import { hostResolverRules } from './lib/blocked-hosts.mjs';
|
|
49
|
+
import { browserWebSocketUrl, CdpClient } from './lib/cdp-client.mjs';
|
|
50
|
+
import {
|
|
51
|
+
browserDisplayArgs,
|
|
52
|
+
resolveHeadless,
|
|
53
|
+
resolveSlowMo,
|
|
54
|
+
windowPlacement,
|
|
55
|
+
writeProfileZoom,
|
|
56
|
+
} from './lib/display.mjs';
|
|
57
|
+
import { clearHudState } from './lib/hud.mjs';
|
|
58
|
+
import {
|
|
59
|
+
cdpListenerPids,
|
|
60
|
+
ownedBrowserPids,
|
|
61
|
+
ownerMarker,
|
|
62
|
+
ownsProcess,
|
|
63
|
+
pidAlive,
|
|
64
|
+
stopWebDappBrowser,
|
|
65
|
+
terminate,
|
|
66
|
+
} from './lib/processes.mjs';
|
|
67
|
+
import { assertLaunchNetwork, devServerTestnetDiagnostic } from './lib/readiness.mjs';
|
|
68
|
+
import {
|
|
69
|
+
fixtureEntry,
|
|
70
|
+
loadWalletFixture,
|
|
71
|
+
resolveSigner,
|
|
72
|
+
viemAccountFromEntry,
|
|
73
|
+
webDappPolicy,
|
|
74
|
+
webDappRuntimeDir,
|
|
75
|
+
} from './lib/runtime.mjs';
|
|
76
|
+
import {
|
|
77
|
+
macosSessionLocked,
|
|
78
|
+
SESSION_LOCKED_MESSAGE,
|
|
79
|
+
SESSION_LOCKED_USER_ACTION,
|
|
80
|
+
} from './lib/session-lock.mjs';
|
|
81
|
+
import { defaultSigner, loadSigners, SIGNER_MODULE_ENV } from './lib/signers.mjs';
|
|
82
|
+
|
|
83
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
84
|
+
const macosFocus = createRequire(import.meta.url)('../macos-focus.cjs');
|
|
85
|
+
|
|
86
|
+
function usage() {
|
|
87
|
+
return 'Usage: launch.mjs --target <checkout> --cdp-port <port> --app-port <port> [--signer extension|injected] [--signer-module <path>] [--account <name>] [--fresh-profile] [--headless | --headful] [--slow-mo <ms>] [--network testnet|mainnet --mainnet-confirmation REAL_FUNDS] [--start-chain <id>] [--json]';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseLaunchArgs(argv, env = process.env) {
|
|
91
|
+
const args = {
|
|
92
|
+
account: 'dev1',
|
|
93
|
+
'fresh-profile': false,
|
|
94
|
+
headless: false,
|
|
95
|
+
headful: false,
|
|
96
|
+
json: false,
|
|
97
|
+
};
|
|
98
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
99
|
+
const flag = argv[index];
|
|
100
|
+
if (flag === '--help' || flag === '-h') return { help: true };
|
|
101
|
+
if (
|
|
102
|
+
flag === '--fresh-profile' ||
|
|
103
|
+
flag === '--headless' ||
|
|
104
|
+
flag === '--headful' ||
|
|
105
|
+
flag === '--json'
|
|
106
|
+
) {
|
|
107
|
+
args[flag.slice(2)] = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!flag.startsWith('--') || index + 1 >= argv.length)
|
|
111
|
+
throw Object.assign(new Error(usage()), { exitCode: 2 });
|
|
112
|
+
args[flag.slice(2)] = argv[index + 1];
|
|
113
|
+
index += 1;
|
|
114
|
+
}
|
|
115
|
+
for (const key of ['target', 'cdp-port', 'app-port']) {
|
|
116
|
+
if (!args[key]) throw Object.assign(new Error(`Missing --${key}\n${usage()}`), { exitCode: 2 });
|
|
117
|
+
}
|
|
118
|
+
args.signer = resolveSigner(args.signer, defaultSigner(args['signer-module'], env));
|
|
119
|
+
if (args.headless && args.headful)
|
|
120
|
+
throw Object.assign(new Error(`--headless and --headful are mutually exclusive\n${usage()}`), {
|
|
121
|
+
exitCode: 2,
|
|
122
|
+
});
|
|
123
|
+
return args;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function log(message) {
|
|
127
|
+
process.stderr.write(`[${webDappPolicy().adapterId}/launch] ${message}\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function browserStarts(bin) {
|
|
131
|
+
try {
|
|
132
|
+
const version = execFileSync(bin, ['--version'], {
|
|
133
|
+
encoding: 'utf8',
|
|
134
|
+
timeout: 15000,
|
|
135
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
136
|
+
}).trim();
|
|
137
|
+
return version || null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The slot browser is Chrome for Testing (Playwright's cache, newest build),
|
|
144
|
+
// launched through LaunchServices on macOS, where a direct spawn of it
|
|
145
|
+
// crashes. TERMINAL_CHROME_BIN is the only explicit
|
|
146
|
+
// override. No other browser is picked on its own: an ad-blocking browser
|
|
147
|
+
// (Brave Shields) changes the app's network behaviour and its console.
|
|
148
|
+
export function chromeForTestingCandidates(
|
|
149
|
+
home = os.homedir(),
|
|
150
|
+
platform = process.platform,
|
|
151
|
+
arch = process.arch,
|
|
152
|
+
) {
|
|
153
|
+
if (platform !== 'darwin') return [];
|
|
154
|
+
const cache = path.join(home, 'Library/Caches/ms-playwright');
|
|
155
|
+
if (!existsSync(cache)) return [];
|
|
156
|
+
const folder = arch === 'arm64' ? 'chrome-mac-arm64' : 'chrome-mac';
|
|
157
|
+
return readdirSync(cache)
|
|
158
|
+
.filter((entry) => /^chromium-\d+$/u.test(entry))
|
|
159
|
+
.sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]))
|
|
160
|
+
.map((entry) =>
|
|
161
|
+
path.join(
|
|
162
|
+
cache,
|
|
163
|
+
entry,
|
|
164
|
+
folder,
|
|
165
|
+
'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Only TERMINAL_CHROME_BIN overrides Chrome for Testing. The shared
|
|
171
|
+
// RECIPE_HARNESS_CHROME_BIN is ignored here: a farm default pointing at a
|
|
172
|
+
// browser that blocks requests itself (Brave) would make diagnostics lie.
|
|
173
|
+
export async function resolveBrowser(env = process.env, candidates = chromeForTestingCandidates()) {
|
|
174
|
+
const explicit = env.TERMINAL_CHROME_BIN;
|
|
175
|
+
if (explicit) {
|
|
176
|
+
if (!existsSync(explicit)) throw new Error(`TERMINAL_CHROME_BIN does not exist: ${explicit}`);
|
|
177
|
+
return { bin: explicit, version: browserStarts(explicit), source: 'TERMINAL_CHROME_BIN' };
|
|
178
|
+
}
|
|
179
|
+
if (env.RECIPE_HARNESS_CHROME_BIN)
|
|
180
|
+
log(
|
|
181
|
+
`ignoring RECIPE_HARNESS_CHROME_BIN (${env.RECIPE_HARNESS_CHROME_BIN}) for the ${webDappPolicy().adapterId}; set TERMINAL_CHROME_BIN to override Chrome for Testing`,
|
|
182
|
+
);
|
|
183
|
+
const bin = candidates.find((candidate) => existsSync(candidate));
|
|
184
|
+
if (bin) return { bin, version: browserStarts(bin), source: 'chrome-for-testing' };
|
|
185
|
+
throw new Error(
|
|
186
|
+
'Chrome for Testing is not installed (Playwright cache).\n' +
|
|
187
|
+
'Next: npx playwright install chromium, or set TERMINAL_CHROME_BIN to a Chromium-family browser that honours --load-extension.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// How the browser process is started, named as the shared browser resolver
|
|
192
|
+
// names them. An .app browser on macOS starts through LaunchServices in the
|
|
193
|
+
// background (`open -g -n -a`), as the Extension launcher does: a visible one
|
|
194
|
+
// never takes the operator's keyboard focus, and Chrome for Testing does not
|
|
195
|
+
// start when spawned directly on this macOS. A binary outside an .app bundle,
|
|
196
|
+
// or any browser elsewhere, is spawned directly.
|
|
197
|
+
export const SPAWN = 'spawn';
|
|
198
|
+
export const LAUNCH_SERVICES = 'launch-services';
|
|
199
|
+
|
|
200
|
+
export function macApplicationForExecutable(executable) {
|
|
201
|
+
let current = path.resolve(executable);
|
|
202
|
+
while (current !== path.dirname(current)) {
|
|
203
|
+
if (current.endsWith('.app')) return current;
|
|
204
|
+
current = path.dirname(current);
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function launchMethodFor(executable, { platform = process.platform } = {}) {
|
|
210
|
+
return platform === 'darwin' && macApplicationForExecutable(executable) ? LAUNCH_SERVICES : SPAWN;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// LaunchServices hands back no child: the browser's pid is the process that
|
|
214
|
+
// listens on the slot CDP port with this slot's profile.
|
|
215
|
+
async function launchInBackground(
|
|
216
|
+
application,
|
|
217
|
+
browserArgs,
|
|
218
|
+
{ runtime, cdpPort, logFile, pidFile, timeoutMs },
|
|
219
|
+
) {
|
|
220
|
+
const out = openSync(logFile, 'a');
|
|
221
|
+
try {
|
|
222
|
+
execFileSync('open', macosFocus.macBackgroundOpenArgs(application, browserArgs), {
|
|
223
|
+
stdio: ['ignore', out, out],
|
|
224
|
+
timeout: 30000,
|
|
225
|
+
});
|
|
226
|
+
} finally {
|
|
227
|
+
closeSync(out);
|
|
228
|
+
}
|
|
229
|
+
const deadline = Date.now() + timeoutMs;
|
|
230
|
+
while (Date.now() < deadline) {
|
|
231
|
+
const pid = cdpListenerPids(cdpPort).find((candidate) =>
|
|
232
|
+
ownsProcess('browser', candidate, runtime),
|
|
233
|
+
);
|
|
234
|
+
if (pid) {
|
|
235
|
+
writeFileSync(pidFile, `${pid}\n`);
|
|
236
|
+
return pid;
|
|
237
|
+
}
|
|
238
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
239
|
+
}
|
|
240
|
+
for (const pid of ownedBrowserPids(runtime)) await terminate(pid, 3000);
|
|
241
|
+
throw new Error(
|
|
242
|
+
`${path.basename(application)} started but no browser on this slot's profile listened on CDP ${cdpPort} within ${timeoutMs}ms.`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Focus decisions go to <runtime>/focus.log as well as stderr, so a run's
|
|
247
|
+
// focus behaviour can be read back after the fact.
|
|
248
|
+
const FOCUS_OUTCOMES = {
|
|
249
|
+
restored: (previous) =>
|
|
250
|
+
`our browser was in front; restored ${previous.name} (pid ${previous.pid}) once`,
|
|
251
|
+
kept: (previous) =>
|
|
252
|
+
`our browser was not in front, or ${previous?.name ?? 'the previous app'} could not be activated; left alone`,
|
|
253
|
+
off: () => 'focus handling is off, or no app was captured before the launch',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// The one focus check after a launch (@farmslot/adapter-web/macos-focus): the app in
|
|
257
|
+
// front before the launch gets the front back, once, by its pid, only if one
|
|
258
|
+
// of this slot's browser processes (by owner marker, never by bundle) is in
|
|
259
|
+
// front.
|
|
260
|
+
export function checkFocusAfterLaunch(
|
|
261
|
+
runtime,
|
|
262
|
+
previous,
|
|
263
|
+
{ ourPids = ownedBrowserPids(runtime), focus = macosFocus } = {},
|
|
264
|
+
) {
|
|
265
|
+
const outcome = focus.restoreFrontmostIfOurs(previous, ourPids);
|
|
266
|
+
focusLog(runtime, FOCUS_OUTCOMES[outcome](previous));
|
|
267
|
+
return outcome;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function focusLog(runtime, message) {
|
|
271
|
+
log(`focus: ${message}`);
|
|
272
|
+
try {
|
|
273
|
+
appendFileSync(path.join(runtime, 'focus.log'), `${new Date().toISOString()} ${message}\n`, {
|
|
274
|
+
mode: 0o600,
|
|
275
|
+
});
|
|
276
|
+
} catch {
|
|
277
|
+
// The log is advisory.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Browser-level CDP helpers for a background launch.
|
|
282
|
+
async function withBrowserClient(cdpPort, callback) {
|
|
283
|
+
const client = await CdpClient.connect(await browserWebSocketUrl(cdpPort, 15000));
|
|
284
|
+
try {
|
|
285
|
+
return await callback(client);
|
|
286
|
+
} finally {
|
|
287
|
+
client.close?.();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function backgroundWindowParams(url, placement) {
|
|
292
|
+
return {
|
|
293
|
+
url,
|
|
294
|
+
newWindow: true,
|
|
295
|
+
background: true,
|
|
296
|
+
...(placement
|
|
297
|
+
? { left: placement.x, top: placement.y, width: placement.width, height: placement.height }
|
|
298
|
+
: {}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function openBackgroundWindow(cdpPort, url, placement) {
|
|
303
|
+
return withBrowserClient(cdpPort, (client) =>
|
|
304
|
+
client.send('Target.createTarget', backgroundWindowParams(url, placement)),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function focusSettleMs(env) {
|
|
309
|
+
const value = Number(env.MM_HARNESS_FOCUS_SETTLE_MS);
|
|
310
|
+
return Number.isFinite(value) && value >= 0 ? value : 1000;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Resolves once the process is running, rejects on a spawn failure (EACCES,
|
|
314
|
+
// ENOENT) so the caller's rollback runs; only a running pid is recorded.
|
|
315
|
+
export function spawnDetached(command, args, { logFile, pidFile, env = process.env }) {
|
|
316
|
+
const out = openSync(logFile, 'a');
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
let child;
|
|
319
|
+
try {
|
|
320
|
+
child = spawn(command, args, { detached: true, stdio: ['ignore', out, out], env });
|
|
321
|
+
} catch (error) {
|
|
322
|
+
closeSync(out);
|
|
323
|
+
reject(error);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
child.once('error', (error) => {
|
|
327
|
+
closeSync(out);
|
|
328
|
+
reject(
|
|
329
|
+
new Error(`could not start ${path.basename(command)}: ${error.code ?? error.message}`),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
child.once('spawn', () => {
|
|
333
|
+
closeSync(out);
|
|
334
|
+
child.unref();
|
|
335
|
+
try {
|
|
336
|
+
writeFileSync(pidFile, `${child.pid}\n`);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
// Without a pid file nothing could stop this child later: stop it now.
|
|
339
|
+
try {
|
|
340
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
341
|
+
} catch {
|
|
342
|
+
try {
|
|
343
|
+
child.kill('SIGKILL');
|
|
344
|
+
} catch {
|
|
345
|
+
/* already gone */
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
reject(
|
|
349
|
+
new Error(
|
|
350
|
+
`could not record ${path.basename(command)} pid in ${pidFile}: ${error.code ?? error.message}`,
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
resolve(child.pid);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function waitFor(check, timeoutMs, label) {
|
|
361
|
+
const deadline = Date.now() + timeoutMs;
|
|
362
|
+
while (Date.now() < deadline) {
|
|
363
|
+
if (await check()) return;
|
|
364
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
365
|
+
}
|
|
366
|
+
throw new Error(`${label} did not happen within ${timeoutMs}ms`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function readStateFile(file) {
|
|
370
|
+
try {
|
|
371
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error?.code === 'ENOENT') return null;
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Write through a fresh temporary file and an atomic rename, owner-only.
|
|
379
|
+
export function writePrivateFile(file, text) {
|
|
380
|
+
// A failed write or rename must not leave key material in the temporary file.
|
|
381
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
382
|
+
try {
|
|
383
|
+
writeFileSync(temporary, text, { mode: 0o600, flag: 'wx' });
|
|
384
|
+
renameSync(temporary, file);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
rmSync(temporary, { force: true });
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// The host is ready once the app's first document has loaded with its hooks,
|
|
392
|
+
// which waits for a cold dev-server compile.
|
|
393
|
+
export const LAUNCH_TIMEOUTS = Object.freeze({ browserStartMs: 90000, hostReadyMs: 180000 });
|
|
394
|
+
|
|
395
|
+
// The venue policy comes from RECIPE_WEB_DAPP_POLICY (webDappPolicy), as for every web-dapp leaf.
|
|
396
|
+
// The signer hooks (lib/signers.mjs) come from the module --signer-module or
|
|
397
|
+
// RECIPE_WEB_DAPP_SIGNER_MODULE names, the same one the wallet host loads.
|
|
398
|
+
/**
|
|
399
|
+
* @param {Record<string, any>} args the parsed launch flags (see `parseLaunchArgs`)
|
|
400
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
401
|
+
* @param {{ timeouts?: { browserStartMs: number, hostReadyMs: number }, sessionLocked?: () => boolean | null }} [options]
|
|
402
|
+
*/
|
|
403
|
+
export async function launchWebDappBrowser(
|
|
404
|
+
args,
|
|
405
|
+
env = process.env,
|
|
406
|
+
{ timeouts = LAUNCH_TIMEOUTS, sessionLocked = macosSessionLocked } = {},
|
|
407
|
+
) {
|
|
408
|
+
const policy = webDappPolicy(env);
|
|
409
|
+
const signerModule = args['signer-module'] ?? env[SIGNER_MODULE_ENV];
|
|
410
|
+
const signerHooks = await loadSigners({
|
|
411
|
+
...env,
|
|
412
|
+
...(signerModule ? { [SIGNER_MODULE_ENV]: signerModule } : {}),
|
|
413
|
+
});
|
|
414
|
+
if (args.signer === 'extension' && !signerHooks.extension?.prepareProfile) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
'signer=extension needs an extension signer: pass --signer-module <path> or set RECIPE_WEB_DAPP_SIGNER_MODULE.',
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
// Without confirm the wallet host sees no wallet surface, and its startup
|
|
420
|
+
// sweep would close the extension's own side panel or popup tabs.
|
|
421
|
+
if (args.signer === 'extension' && !signerHooks.extension.confirm) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
'the extension signer must provide confirm: without it the wallet host cannot tell the wallet surfaces from app tabs and would close them.',
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
const target = path.resolve(args.target);
|
|
427
|
+
const cdpPort = Number(args['cdp-port']);
|
|
428
|
+
const appPort = Number(args['app-port']);
|
|
429
|
+
const signer = args.signer;
|
|
430
|
+
// Mainnet needs the real-funds confirmation. Testnet is enforced inside the
|
|
431
|
+
// browser: the SDK's mainnet venue hosts are unresolvable for the whole
|
|
432
|
+
// browser, and the wallet host blocks, logs and refuses anything mainnet.
|
|
433
|
+
const network = assertLaunchNetwork(target, {
|
|
434
|
+
network: args.network,
|
|
435
|
+
mainnetConfirmation: args['mainnet-confirmation'],
|
|
436
|
+
});
|
|
437
|
+
const venues = network === 'testnet' ? policy.venueHosts(target) : { blocked: [], served: [] };
|
|
438
|
+
const mainnetHosts = venues.blocked;
|
|
439
|
+
// The served-network check is skipped only when the policy says so for this
|
|
440
|
+
// checkout (venueHosts() returns servedCheck: 'not-applicable', for an app whose
|
|
441
|
+
// page makes no venue requests of its own). An empty served list without it
|
|
442
|
+
// keeps the check, which then fails closed: a policy that computes its served
|
|
443
|
+
// hosts can't drop the check by finding none.
|
|
444
|
+
const servedNotApplicable = network === 'testnet' && venues.servedCheck === 'not-applicable';
|
|
445
|
+
if (servedNotApplicable && venues.served.length > 0) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`the venue policy for ${target} lists served testnet hosts and servedCheck: 'not-applicable'; declare one or the other.`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
// What the running browser enforces; a browser enforcing anything else is
|
|
451
|
+
// never reused (its resolver rules are fixed at start).
|
|
452
|
+
const enforcementFingerprint = createHash('sha256')
|
|
453
|
+
.update(
|
|
454
|
+
JSON.stringify({
|
|
455
|
+
network,
|
|
456
|
+
mainnetHosts,
|
|
457
|
+
testnetHosts: venues.served,
|
|
458
|
+
// Only when opted out, so a policy that keeps the check fingerprints as
|
|
459
|
+
// before; a browser whose wallet host skips the check is never reused for
|
|
460
|
+
// a launch that needs it.
|
|
461
|
+
...(servedNotApplicable ? { servedCheck: 'not-applicable' } : {}),
|
|
462
|
+
}),
|
|
463
|
+
)
|
|
464
|
+
.digest('hex')
|
|
465
|
+
.slice(0, 16);
|
|
466
|
+
const devServer =
|
|
467
|
+
network === 'testnet'
|
|
468
|
+
? devServerTestnetDiagnostic(target, { appPort, variable: policy.testnetVariable })
|
|
469
|
+
: null;
|
|
470
|
+
if (devServer && !(devServer.known && devServer.forced)) {
|
|
471
|
+
log(
|
|
472
|
+
`WARN dev server testnet check: ${devServer.detail} (advisory; the browser enforces testnet itself)`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
const runtime = webDappRuntimeDir(target);
|
|
476
|
+
mkdirSync(runtime, { recursive: true, mode: 0o700 });
|
|
477
|
+
chmodSync(runtime, 0o700);
|
|
478
|
+
const stateFile = path.join(runtime, 'browser.json');
|
|
479
|
+
const appOrigin = `http://localhost:${appPort}`;
|
|
480
|
+
const headless = resolveHeadless({ headless: args.headless, headful: args.headful, env });
|
|
481
|
+
const slowMoMs = resolveSlowMo({ flag: args['slow-mo'], env });
|
|
482
|
+
// A headful browser can't paint while the macOS session is locked: refuse
|
|
483
|
+
// before starting, reusing or stopping one.
|
|
484
|
+
if (!headless && sessionLocked() === true) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`SESSION_LOCKED: ${SESSION_LOCKED_MESSAGE}\nNext: ${SESSION_LOCKED_USER_ACTION}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const { fixture } = await loadWalletFixture(target, env);
|
|
491
|
+
const accountAddress = viemAccountFromEntry(fixtureEntry(fixture, args.account), target).address;
|
|
492
|
+
|
|
493
|
+
const previous = readStateFile(stateFile);
|
|
494
|
+
const reusable =
|
|
495
|
+
previous &&
|
|
496
|
+
!args['fresh-profile'] &&
|
|
497
|
+
previous.signer === signer &&
|
|
498
|
+
previous.cdpPort === cdpPort &&
|
|
499
|
+
previous.appOrigin === appOrigin &&
|
|
500
|
+
previous.account?.name === args.account &&
|
|
501
|
+
previous.browser?.headless === headless &&
|
|
502
|
+
(previous.network ?? 'testnet') === network &&
|
|
503
|
+
previous.networkEnforcement?.fingerprint === enforcementFingerprint &&
|
|
504
|
+
ownsProcess('browser', previous.browserPid, runtime) &&
|
|
505
|
+
ownsProcess('wallet-host', previous.hostPid, runtime);
|
|
506
|
+
if (reusable) {
|
|
507
|
+
await browserWebSocketUrl(cdpPort, 5000);
|
|
508
|
+
log(`reusing browser pid ${previous.browserPid} (${signer}, ${args.account})`);
|
|
509
|
+
const state = { ...previous, slowMoMs };
|
|
510
|
+
if (previous.slowMoMs !== slowMoMs)
|
|
511
|
+
writePrivateFile(stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
512
|
+
return { ...state, reused: true };
|
|
513
|
+
}
|
|
514
|
+
await stopWebDappBrowser(target, { cdpPort, log });
|
|
515
|
+
|
|
516
|
+
const browser = await resolveBrowser(env);
|
|
517
|
+
const profile = path.join(
|
|
518
|
+
runtime,
|
|
519
|
+
`profile-${signer}-${args.account.replace(/[^A-Za-z0-9_-]/gu, '_')}`,
|
|
520
|
+
);
|
|
521
|
+
if (args['fresh-profile'] && existsSync(profile)) {
|
|
522
|
+
const retired = `${profile}.retired-${Date.now()}`;
|
|
523
|
+
renameSync(profile, retired);
|
|
524
|
+
rmSync(retired, { recursive: true, force: true });
|
|
525
|
+
}
|
|
526
|
+
const freshProfile = !existsSync(profile);
|
|
527
|
+
mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
528
|
+
const placement = headless ? null : windowPlacement({ cdpPort, env });
|
|
529
|
+
// Reset on every start so a profile reused across modes never keeps a zoom.
|
|
530
|
+
writeProfileZoom(profile, placement?.zoom ?? 1);
|
|
531
|
+
|
|
532
|
+
const browserArgs = [
|
|
533
|
+
`--user-data-dir=${profile}`,
|
|
534
|
+
'--remote-debugging-address=127.0.0.1',
|
|
535
|
+
`--remote-debugging-port=${cdpPort}`,
|
|
536
|
+
`--remote-allow-origins=http://127.0.0.1:${cdpPort}`,
|
|
537
|
+
'--no-first-run',
|
|
538
|
+
'--no-default-browser-check',
|
|
539
|
+
'--use-mock-keychain',
|
|
540
|
+
'--disable-background-timer-throttling',
|
|
541
|
+
'--disable-backgrounding-occluded-windows',
|
|
542
|
+
'--disable-renderer-backgrounding',
|
|
543
|
+
...browserDisplayArgs({ headless, placement }),
|
|
544
|
+
ownerMarker('browser', runtime),
|
|
545
|
+
...(network === 'testnet' ? [hostResolverRules(mainnetHosts)] : []),
|
|
546
|
+
];
|
|
547
|
+
|
|
548
|
+
// Key material (the slot fixture) and the encrypted vault (fixture state)
|
|
549
|
+
// never outlive this call; anything started here is stopped again when a
|
|
550
|
+
// later step fails.
|
|
551
|
+
const secrets = [];
|
|
552
|
+
const trackSecret = (file) => {
|
|
553
|
+
secrets.push(file);
|
|
554
|
+
return file;
|
|
555
|
+
};
|
|
556
|
+
let started = false;
|
|
557
|
+
const launchMethod = launchMethodFor(browser.bin);
|
|
558
|
+
// Focus is checked for a visible browser started in the background.
|
|
559
|
+
const guardFocus = launchMethod === LAUNCH_SERVICES && !headless;
|
|
560
|
+
// With no startup window, windows are created over CDP at these bounds; a
|
|
561
|
+
// headless browser keeps the tall viewport the recipes were proven on.
|
|
562
|
+
const windowBounds = placement ?? { x: 0, y: 0, width: 1600, height: 2400 };
|
|
563
|
+
let application = null;
|
|
564
|
+
let previousFrontmost = null;
|
|
565
|
+
try {
|
|
566
|
+
let extension = null;
|
|
567
|
+
if (signer === 'extension') {
|
|
568
|
+
extension = await signerHooks.extension.prepareProfile({
|
|
569
|
+
target,
|
|
570
|
+
runtime,
|
|
571
|
+
profile,
|
|
572
|
+
account: args.account,
|
|
573
|
+
fixture,
|
|
574
|
+
env,
|
|
575
|
+
freshProfile,
|
|
576
|
+
log,
|
|
577
|
+
writePrivateFile,
|
|
578
|
+
trackSecret,
|
|
579
|
+
});
|
|
580
|
+
if (
|
|
581
|
+
!extension ||
|
|
582
|
+
typeof extension !== 'object' ||
|
|
583
|
+
(extension.browserArgs !== undefined && !Array.isArray(extension.browserArgs)) ||
|
|
584
|
+
(extension.secrets !== undefined && !Array.isArray(extension.secrets)) ||
|
|
585
|
+
(extension.state !== undefined &&
|
|
586
|
+
(extension.state === null || typeof extension.state !== 'object'))
|
|
587
|
+
) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
'signer module prepareProfile must return { browserArgs, secrets, state } (browserArgs and secrets arrays, state an object; afterBrowserStart optional).',
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
secrets.push(...(extension.secrets ?? []));
|
|
593
|
+
browserArgs.push(...(extension.browserArgs ?? []));
|
|
594
|
+
}
|
|
595
|
+
// A background launch opens no window at startup: Chrome activates itself
|
|
596
|
+
// when its first window appears. Windows are created over CDP instead, in
|
|
597
|
+
// the background.
|
|
598
|
+
browserArgs.push(launchMethod === LAUNCH_SERVICES ? '--no-startup-window' : 'about:blank');
|
|
599
|
+
|
|
600
|
+
started = true;
|
|
601
|
+
// Where this browser's console begins in the slot capture, for
|
|
602
|
+
// assert_no_console_errors.
|
|
603
|
+
// With the file's inode: a replaced file is then read from its start.
|
|
604
|
+
const consoleStats = Object.fromEntries(
|
|
605
|
+
['page', 'extension'].map((source) => {
|
|
606
|
+
const file = path.join(
|
|
607
|
+
runtime,
|
|
608
|
+
source === 'page' ? 'app-console.log' : 'extension-console.log',
|
|
609
|
+
);
|
|
610
|
+
return [source, existsSync(file) ? statSync(file) : null];
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
const consoleOffsets = {
|
|
614
|
+
page: consoleStats.page?.size ?? 0,
|
|
615
|
+
extension: consoleStats.extension?.size ?? 0,
|
|
616
|
+
inodes: {
|
|
617
|
+
page: consoleStats.page?.ino ?? null,
|
|
618
|
+
extension: consoleStats.extension?.ino ?? null,
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
application =
|
|
622
|
+
launchMethod === LAUNCH_SERVICES ? macApplicationForExecutable(browser.bin) : null;
|
|
623
|
+
if (guardFocus) {
|
|
624
|
+
// Only recorded: the browser never activates itself, and the one check
|
|
625
|
+
// after the launch (finally) uses this to undo an activation by ours.
|
|
626
|
+
previousFrontmost = macosFocus.captureMacFrontmost();
|
|
627
|
+
focusLog(
|
|
628
|
+
runtime,
|
|
629
|
+
`front app before the launch: ${previousFrontmost?.name ?? 'unknown'} (pid ${previousFrontmost?.pid ?? '-'})`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
const browserPid =
|
|
633
|
+
launchMethod === LAUNCH_SERVICES
|
|
634
|
+
? await launchInBackground(application, browserArgs, {
|
|
635
|
+
runtime,
|
|
636
|
+
cdpPort,
|
|
637
|
+
logFile: path.join(runtime, 'browser.log'),
|
|
638
|
+
pidFile: path.join(runtime, 'browser.pid'),
|
|
639
|
+
timeoutMs: timeouts.browserStartMs,
|
|
640
|
+
})
|
|
641
|
+
: await spawnDetached(browser.bin, browserArgs, {
|
|
642
|
+
logFile: path.join(runtime, 'browser.log'),
|
|
643
|
+
pidFile: path.join(runtime, 'browser.pid'),
|
|
644
|
+
env,
|
|
645
|
+
});
|
|
646
|
+
await browserWebSocketUrl(cdpPort, timeouts.browserStartMs);
|
|
647
|
+
log(
|
|
648
|
+
`browser pid ${browserPid} on CDP ${cdpPort} (${browser.version ?? browser.bin}, ${headless ? 'headless' : `window ${placement.width}x${placement.height}+${placement.x}+${placement.y} zoom ${placement.zoom}`})`,
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
let extensionHostArgs = [];
|
|
652
|
+
let extensionState = extension?.state ?? null;
|
|
653
|
+
if (extension?.afterBrowserStart) {
|
|
654
|
+
const after = await extension.afterBrowserStart({
|
|
655
|
+
cdpPort,
|
|
656
|
+
launchMethod,
|
|
657
|
+
windowBounds,
|
|
658
|
+
withBrowserClient: (callback) => withBrowserClient(cdpPort, callback),
|
|
659
|
+
openBackgroundWindow: (url) => openBackgroundWindow(cdpPort, url, windowBounds),
|
|
660
|
+
log,
|
|
661
|
+
trackSecret,
|
|
662
|
+
});
|
|
663
|
+
extensionHostArgs = after?.hostArgs ?? [];
|
|
664
|
+
extensionState = { ...extensionState, ...after?.state };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
rmSync(path.join(runtime, 'wallet-host.ready'), { force: true });
|
|
668
|
+
// A new browser starts without a HUD; the runner's next update draws one.
|
|
669
|
+
clearHudState(runtime);
|
|
670
|
+
const hostArgs = [
|
|
671
|
+
path.join(SCRIPT_DIR, 'wallet-host.mjs'),
|
|
672
|
+
'--cdp-port',
|
|
673
|
+
String(cdpPort),
|
|
674
|
+
'--runtime-dir',
|
|
675
|
+
runtime,
|
|
676
|
+
'--signer',
|
|
677
|
+
signer,
|
|
678
|
+
'--app-origin',
|
|
679
|
+
appOrigin,
|
|
680
|
+
'--target',
|
|
681
|
+
target,
|
|
682
|
+
'--account',
|
|
683
|
+
args.account,
|
|
684
|
+
'--start-chain',
|
|
685
|
+
String(args['start-chain'] ?? policy.startChain),
|
|
686
|
+
'--network',
|
|
687
|
+
network,
|
|
688
|
+
ownerMarker('wallet-host', runtime),
|
|
689
|
+
];
|
|
690
|
+
if (network === 'testnet')
|
|
691
|
+
hostArgs.push(
|
|
692
|
+
'--mainnet-hosts',
|
|
693
|
+
mainnetHosts.join(','),
|
|
694
|
+
'--testnet-hosts',
|
|
695
|
+
venues.served.join(','),
|
|
696
|
+
...(servedNotApplicable ? ['--served-check', 'not-applicable'] : []),
|
|
697
|
+
);
|
|
698
|
+
hostArgs.push(...extensionHostArgs);
|
|
699
|
+
if (signerModule) hostArgs.push('--signer-module', signerModule);
|
|
700
|
+
if (guardFocus) hostArgs.push('--observe-focus', '1');
|
|
701
|
+
if (launchMethod === LAUNCH_SERVICES)
|
|
702
|
+
hostArgs.push(
|
|
703
|
+
'--window',
|
|
704
|
+
[windowBounds.x, windowBounds.y, windowBounds.width, windowBounds.height].join(','),
|
|
705
|
+
);
|
|
706
|
+
const hostPid = await spawnDetached(process.execPath, hostArgs, {
|
|
707
|
+
logFile: path.join(runtime, 'wallet-host.log'),
|
|
708
|
+
pidFile: path.join(runtime, 'wallet-host.pid'),
|
|
709
|
+
env,
|
|
710
|
+
});
|
|
711
|
+
await waitFor(
|
|
712
|
+
() => {
|
|
713
|
+
if (existsSync(path.join(runtime, 'wallet-host.ready'))) return true;
|
|
714
|
+
if (!pidAlive(hostPid))
|
|
715
|
+
throw new Error(
|
|
716
|
+
`wallet host exited before it was ready (see ${path.join(runtime, 'wallet-host.log')})`,
|
|
717
|
+
);
|
|
718
|
+
return false;
|
|
719
|
+
},
|
|
720
|
+
timeouts.hostReadyMs,
|
|
721
|
+
'wallet host ready',
|
|
722
|
+
);
|
|
723
|
+
// A window can activate the browser when it first paints, after the
|
|
724
|
+
// browser already answers: check once after that (MM_HARNESS_FOCUS_SETTLE_MS).
|
|
725
|
+
if (guardFocus) await new Promise((resolve) => setTimeout(resolve, focusSettleMs(env)));
|
|
726
|
+
|
|
727
|
+
const state = {
|
|
728
|
+
schemaVersion: 1,
|
|
729
|
+
signer,
|
|
730
|
+
network,
|
|
731
|
+
networkEnforcement:
|
|
732
|
+
network === 'testnet'
|
|
733
|
+
? {
|
|
734
|
+
mode: 'enforced',
|
|
735
|
+
mainnetHosts,
|
|
736
|
+
testnetHosts: venues.served,
|
|
737
|
+
fingerprint: enforcementFingerprint,
|
|
738
|
+
...(servedNotApplicable ? { served: 'not-applicable' } : {}),
|
|
739
|
+
layers: [
|
|
740
|
+
'host-resolver-rules',
|
|
741
|
+
'cdp-fetch-block',
|
|
742
|
+
'wallet-refusal',
|
|
743
|
+
...(servedNotApplicable ? [] : ['served-network-check']),
|
|
744
|
+
],
|
|
745
|
+
}
|
|
746
|
+
: {
|
|
747
|
+
mode: 'disabled',
|
|
748
|
+
reason: 'network=mainnet with mainnet_confirmation=REAL_FUNDS',
|
|
749
|
+
fingerprint: enforcementFingerprint,
|
|
750
|
+
},
|
|
751
|
+
devServerDiagnostic: devServer,
|
|
752
|
+
consoleOffsets,
|
|
753
|
+
// The app port's listeners at ready; wallet-surface actions re-check them
|
|
754
|
+
// (lsof only sees this user's sockets).
|
|
755
|
+
appListeners: cdpListenerPids(appPort),
|
|
756
|
+
cdpPort,
|
|
757
|
+
appOrigin,
|
|
758
|
+
account: { name: args.account, address: accountAddress },
|
|
759
|
+
browserPid,
|
|
760
|
+
hostPid,
|
|
761
|
+
browser: {
|
|
762
|
+
bin: browser.bin,
|
|
763
|
+
version: browser.version,
|
|
764
|
+
source: browser.source,
|
|
765
|
+
headless,
|
|
766
|
+
launchMethod,
|
|
767
|
+
},
|
|
768
|
+
window: placement,
|
|
769
|
+
slowMoMs,
|
|
770
|
+
profile,
|
|
771
|
+
freshProfile,
|
|
772
|
+
extension: extensionState,
|
|
773
|
+
startedAt: new Date().toISOString(),
|
|
774
|
+
};
|
|
775
|
+
writePrivateFile(stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
776
|
+
return { ...state, reused: false };
|
|
777
|
+
} catch (error) {
|
|
778
|
+
if (started) {
|
|
779
|
+
log(
|
|
780
|
+
`launch failed, stopping what it started: ${String(error?.message ?? error).split('\n')[0]}`,
|
|
781
|
+
);
|
|
782
|
+
await stopWebDappBrowser(target, { cdpPort, log }).catch((stopError) =>
|
|
783
|
+
log(`rollback incomplete: ${stopError.message}`),
|
|
784
|
+
);
|
|
785
|
+
for (const pid of ownedBrowserPids(runtime)) await terminate(pid, 3000);
|
|
786
|
+
}
|
|
787
|
+
throw error;
|
|
788
|
+
} finally {
|
|
789
|
+
for (const file of secrets) rmSync(file, { force: true });
|
|
790
|
+
if (guardFocus) checkFocusAfterLaunch(runtime, previousFrontmost);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
795
|
+
let args;
|
|
796
|
+
try {
|
|
797
|
+
args = parseLaunchArgs(process.argv.slice(2));
|
|
798
|
+
} catch (error) {
|
|
799
|
+
process.stderr.write(`${error.message}\n`);
|
|
800
|
+
process.exit(error.exitCode ?? 2);
|
|
801
|
+
}
|
|
802
|
+
if (args.help) {
|
|
803
|
+
process.stdout.write(`${usage()}\n`);
|
|
804
|
+
process.exit(0);
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
const state = await launchWebDappBrowser(args);
|
|
808
|
+
process.stdout.write(
|
|
809
|
+
`${JSON.stringify({ status: 'pass', ...state }, null, args.json ? 0 : 2)}\n`,
|
|
810
|
+
);
|
|
811
|
+
} catch (error) {
|
|
812
|
+
const message = String(error?.stderr ?? '').trim() || error.message;
|
|
813
|
+
process.stdout.write(
|
|
814
|
+
`${JSON.stringify({ status: 'fail', error: message.replace(/0x[0-9a-fA-F]{64}/gu, '<redacted>') })}\n`,
|
|
815
|
+
);
|
|
816
|
+
process.exit(1);
|
|
817
|
+
}
|
|
818
|
+
}
|