opera-devtools-mcp 0.7.0 → 0.8.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/README.md +1 -1
- package/build/src/ToolHandler.js +9 -2
- package/build/src/bin/chrome-devtools.js +30 -97
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +18 -9
- package/build/src/daemon/client.js +46 -40
- package/build/src/daemon/daemon.js +62 -39
- package/build/src/opera/branding.js +4 -2
- package/build/src/opera/browserActivity.js +62 -0
- package/build/src/opera/browserCleanup.js +123 -0
- package/build/src/opera/browserErrors.js +66 -0
- package/build/src/opera/browserFlags.js +184 -38
- package/build/src/opera/browserTarget.js +513 -0
- package/build/src/opera/cdpErrors.js +391 -0
- package/build/src/opera/cliCommands.js +378 -0
- package/build/src/opera/cliOutput.js +284 -0
- package/build/src/opera/compactSnapshot.js +525 -0
- package/build/src/opera/config.js +166 -0
- package/build/src/opera/daemonLifecycle.js +257 -0
- package/build/src/opera/daemonLog.js +103 -0
- package/build/src/opera/daemonPidFile.js +83 -0
- package/build/src/opera/daemonShutdown.js +66 -0
- package/build/src/opera/daemonSocket.js +87 -0
- package/build/src/opera/daemonStreaming.js +130 -0
- package/build/src/opera/daemonToolCall.js +26 -0
- package/build/src/opera/detect.js +114 -0
- package/build/src/opera/doctor.js +317 -0
- package/build/src/opera/envConfig.js +229 -0
- package/build/src/opera/launcherNotice.js +116 -0
- package/build/src/opera/legacyBridgeCleanup.js +297 -0
- package/build/src/opera/logs.js +133 -0
- package/build/src/opera/mcpServerSupervisor.js +128 -0
- package/build/src/opera/migrationShared.js +164 -0
- package/build/src/opera/operaPages.js +56 -0
- package/build/src/opera/pageIdRouting.js +35 -0
- package/build/src/opera/pageRecovery.js +53 -0
- package/build/src/opera/profile.js +270 -0
- package/build/src/opera/refArgs.js +36 -0
- package/build/src/opera/serviceWorkerRetry.js +46 -4
- package/build/src/opera/setup.js +290 -0
- package/build/src/opera/skills/SKILL.md +160 -0
- package/build/src/opera/streamingTools.js +73 -0
- package/build/src/opera/suggestions.js +67 -0
- package/build/src/opera/toolHandlerHooks.js +25 -1
- package/build/src/opera/tools/opera.js +107 -38
- package/build/src/opera/urlResolver.js +69 -0
- package/build/src/opera/webStorageWarning.js +92 -0
- package/build/src/third_party/devtools-formatter-worker.js +1 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
- package/build/src/third_party/index.js +2 -1
- package/build/src/utils/url.js +6 -0
- package/build/src/version.js +1 -1
- package/package.json +12 -10
- package/build/src/bin/opera-devtools.js +0 -10
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Opera Norway AS. All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* This file is an original work developed by Opera.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Which browser a command should drive, and what to do when the configured
|
|
9
|
+
* profile is already held by a browser that cannot be driven.
|
|
10
|
+
*
|
|
11
|
+
* Ported from opera-browser-cli's `src/browser-target.ts` and the
|
|
12
|
+
* `preflightBrowser` / `resolveBrowserConflict` half of its `src/cli.ts`. The
|
|
13
|
+
* split is kept: the mechanics (resolve a target, ask a running browser to
|
|
14
|
+
* quit, start one that can be attached to) live here, and the CLI only calls
|
|
15
|
+
* the preflight.
|
|
16
|
+
*
|
|
17
|
+
* The constraint that shapes all of this: `--remote-debugging-port` is a
|
|
18
|
+
* startup-only flag. A browser the user opened normally cannot be attached to,
|
|
19
|
+
* ever. So there is no way to "connect to the Opera that is already open" —
|
|
20
|
+
* only ways to arrange that the open Opera was started with a port in the first
|
|
21
|
+
* place, and a way to detect it when it was.
|
|
22
|
+
*
|
|
23
|
+
* That gives three states for a configured profile:
|
|
24
|
+
*
|
|
25
|
+
* free → let the daemon launch it, as before.
|
|
26
|
+
* locked, debug port live → attach. No prompt, no restart, nothing to do.
|
|
27
|
+
* locked, no debug port → a conflict only the user can resolve, by
|
|
28
|
+
* letting us restart their browser.
|
|
29
|
+
*
|
|
30
|
+
* The second case is the one that makes this feel automatic: once a browser has
|
|
31
|
+
* been started with a port — by us, or by the user following the flags in
|
|
32
|
+
* `docs/troubleshooting.md` — every later command finds it on its own via
|
|
33
|
+
* DevToolsActivePort.
|
|
34
|
+
*
|
|
35
|
+
* The third case is what the user sees as the `A browser is already running
|
|
36
|
+
* with the profile …` error. Resolving it restarts somebody's browser, so it
|
|
37
|
+
* happens only on an explicit yes: a terminal prompt, or `--takeover` for
|
|
38
|
+
* scripted callers. Everything else falls back to a separate profile, which
|
|
39
|
+
* always works and costs only a sign-in.
|
|
40
|
+
*/
|
|
41
|
+
import { spawn } from 'node:child_process';
|
|
42
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
43
|
+
import { join, resolve } from 'node:path';
|
|
44
|
+
import { createInterface } from 'node:readline';
|
|
45
|
+
import { sendCommand, stopDaemon } from '../daemon/client.js';
|
|
46
|
+
import { isDaemonRunning } from '../daemon/utils.js';
|
|
47
|
+
import { CLI_BIN_NAME } from './branding.js';
|
|
48
|
+
import { launchedUserDataDir, storedBrowserUrl } from './browserFlags.js';
|
|
49
|
+
import { CdpError } from './cdpErrors.js';
|
|
50
|
+
import { getStateDir } from './envConfig.js';
|
|
51
|
+
import { findAttachableEndpoint, inspectProfileLock, isProcessAlive, localHostNames, probeDevToolsEndpoint, readDevToolsPort, } from './profile.js';
|
|
52
|
+
/** Decide what the daemon should talk to, before it starts. */
|
|
53
|
+
export async function resolveBrowserTarget(ctx) {
|
|
54
|
+
// An explicit browser URL is the user telling us they manage the browser.
|
|
55
|
+
if (ctx.browserUrl) {
|
|
56
|
+
return { mode: 'attach', url: ctx.browserUrl, note: 'OPERA_CLI_BROWSER_URL' };
|
|
57
|
+
}
|
|
58
|
+
// No persistent profile means an isolated one, which nothing else can hold.
|
|
59
|
+
if (!ctx.userDataDir) {
|
|
60
|
+
return { mode: 'managed', note: 'isolated profile' };
|
|
61
|
+
}
|
|
62
|
+
// A live debug port wins outright: the browser is running and reachable, so
|
|
63
|
+
// there is no conflict to resolve regardless of what the lock says.
|
|
64
|
+
const attachable = await findAttachableEndpoint(ctx.userDataDir);
|
|
65
|
+
if (attachable !== null) {
|
|
66
|
+
return {
|
|
67
|
+
mode: 'attach',
|
|
68
|
+
url: attachable.url,
|
|
69
|
+
note: `running ${attachable.identity.browser}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const lock = inspectProfileLock(ctx.userDataDir);
|
|
73
|
+
if (lock.state === 'free') {
|
|
74
|
+
return { mode: 'managed', note: 'profile is free' };
|
|
75
|
+
}
|
|
76
|
+
return { mode: 'conflict', userDataDir: ctx.userDataDir, lock };
|
|
77
|
+
}
|
|
78
|
+
function sleep(ms) {
|
|
79
|
+
const { promise, resolve: resolveSleep } = Promise.withResolvers();
|
|
80
|
+
setTimeout(resolveSleep, ms);
|
|
81
|
+
return promise;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Ask a running browser to quit, and wait for it to let go of the profile.
|
|
85
|
+
*
|
|
86
|
+
* SIGTERM only. Chromium treats it as a clean shutdown — session saved, profile
|
|
87
|
+
* flushed — whereas SIGKILL risks a corrupted profile and loses the user's
|
|
88
|
+
* tabs. If it will not go, we say so rather than escalating: this is somebody's
|
|
89
|
+
* browser, and forcing it is not ours to decide.
|
|
90
|
+
*
|
|
91
|
+
* Two signals mean the profile is free, and the wait takes either: Chromium
|
|
92
|
+
* removes `SingletonLock` as it goes, and a browser that died without removing
|
|
93
|
+
* it leaves the lock naming a process that is gone. The names that make a lock
|
|
94
|
+
* this machine's are derived once, before the loop: on macOS that derivation is
|
|
95
|
+
* a `scutil` subprocess, and a 20-second wait would otherwise run it eighty
|
|
96
|
+
* times for an answer that cannot change while we wait.
|
|
97
|
+
*/
|
|
98
|
+
export async function quitBrowser(lock, userDataDir, timeoutMs = 20_000) {
|
|
99
|
+
if (lock.pid === null) {
|
|
100
|
+
return { ok: false, reason: 'no-pid' };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
process.kill(lock.pid, 'SIGTERM');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Already gone between inspection and now — that is a success.
|
|
107
|
+
return { ok: true };
|
|
108
|
+
}
|
|
109
|
+
const localNames = localHostNames();
|
|
110
|
+
const deadline = Date.now() + timeoutMs;
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
await sleep(250);
|
|
113
|
+
if (inspectProfileLock(userDataDir, isProcessAlive, process.platform, localNames).state === 'free') {
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { ok: false, reason: 'timeout' };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Start a browser we can attach to, and that outlives us.
|
|
121
|
+
*
|
|
122
|
+
* `--remote-debugging-port=0` has Chromium pick a free port itself and record
|
|
123
|
+
* it in `DevToolsActivePort`. That satisfies two requirements at once: we never
|
|
124
|
+
* squat a predictable port like 9222, and the port is discoverable by every
|
|
125
|
+
* later command without being written to any config.
|
|
126
|
+
*
|
|
127
|
+
* The browser is detached deliberately. Having just restarted the user's
|
|
128
|
+
* browser, closing it again when the daemon stops would be a poor trade.
|
|
129
|
+
*/
|
|
130
|
+
export async function launchAttachableBrowser(executablePath, userDataDir, extraArgs = [], timeoutMs = 30_000) {
|
|
131
|
+
if (!executablePath || !existsSync(executablePath)) {
|
|
132
|
+
return { ok: false, reason: 'no-executable' };
|
|
133
|
+
}
|
|
134
|
+
// Chromium rewrites this on startup, but clearing it first means a stale port
|
|
135
|
+
// from a previous run can never be mistaken for the new browser's.
|
|
136
|
+
const portFile = join(userDataDir, 'DevToolsActivePort');
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(portFile);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Absent already — fine.
|
|
142
|
+
}
|
|
143
|
+
const args = [
|
|
144
|
+
...browserLaunchArgs(userDataDir),
|
|
145
|
+
// We just took their browser away; give the tabs back.
|
|
146
|
+
'--restore-last-session',
|
|
147
|
+
...extraArgs,
|
|
148
|
+
];
|
|
149
|
+
let child;
|
|
150
|
+
try {
|
|
151
|
+
child = spawn(executablePath, args, { stdio: 'ignore', detached: true });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
reason: 'spawn-failed',
|
|
157
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
child.unref();
|
|
161
|
+
let spawnError = null;
|
|
162
|
+
child.on('error', error => {
|
|
163
|
+
spawnError = error.message;
|
|
164
|
+
});
|
|
165
|
+
const deadline = Date.now() + timeoutMs;
|
|
166
|
+
while (Date.now() < deadline) {
|
|
167
|
+
if (spawnError !== null) {
|
|
168
|
+
return { ok: false, reason: 'spawn-failed', detail: spawnError };
|
|
169
|
+
}
|
|
170
|
+
const port = readDevToolsPort(userDataDir);
|
|
171
|
+
if (port !== null && (await probeDevToolsEndpoint(port)) !== null) {
|
|
172
|
+
return { ok: true, url: `http://127.0.0.1:${port}` };
|
|
173
|
+
}
|
|
174
|
+
await sleep(250);
|
|
175
|
+
}
|
|
176
|
+
return { ok: false, reason: 'timeout' };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The flags a user needs to start Opera themselves so the CLI can attach.
|
|
180
|
+
*
|
|
181
|
+
* Deliberately not `--remote-allow-origins=*`: Chromium's default rejection of
|
|
182
|
+
* CDP WebSocket upgrades that carry an Origin header is what stops a web page
|
|
183
|
+
* from driving the browser, and this profile is logged into everything.
|
|
184
|
+
*/
|
|
185
|
+
export function browserLaunchArgs(userDataDir) {
|
|
186
|
+
const args = [
|
|
187
|
+
'--remote-debugging-port=0',
|
|
188
|
+
'--remote-debugging-address=127.0.0.1',
|
|
189
|
+
];
|
|
190
|
+
if (userDataDir) {
|
|
191
|
+
args.push(`--user-data-dir=${userDataDir}`);
|
|
192
|
+
}
|
|
193
|
+
return args;
|
|
194
|
+
}
|
|
195
|
+
/** The throwaway profile the fallback uses when the configured one is taken. */
|
|
196
|
+
export function separateProfileDir() {
|
|
197
|
+
return join(getStateDir(), 'profile');
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Preflight
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
/** Commands that never touch a browser, so never need a browser chosen. */
|
|
203
|
+
const BROWSER_SKIP_COMMANDS = {
|
|
204
|
+
setup: true,
|
|
205
|
+
doctor: true,
|
|
206
|
+
logs: true,
|
|
207
|
+
status: true,
|
|
208
|
+
stop: true,
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Flags that pick the browser explicitly. A command carrying one of these is
|
|
212
|
+
* the user deciding, and a decision is not something to second-guess — the same
|
|
213
|
+
* rule `applyEnvToArgv` follows, where an explicit flag beats the environment.
|
|
214
|
+
*/
|
|
215
|
+
function picksBrowserOnArgv(argv) {
|
|
216
|
+
const flags = [
|
|
217
|
+
'--browserUrl',
|
|
218
|
+
'--browser-url',
|
|
219
|
+
'-u',
|
|
220
|
+
'--wsEndpoint',
|
|
221
|
+
'--ws-endpoint',
|
|
222
|
+
'-w',
|
|
223
|
+
'--autoConnect',
|
|
224
|
+
'--auto-connect',
|
|
225
|
+
'--isolated',
|
|
226
|
+
'--userDataDir',
|
|
227
|
+
'--user-data-dir',
|
|
228
|
+
];
|
|
229
|
+
return argv.some(arg => flags.some(flag => arg === flag || arg.startsWith(`${flag}=`)));
|
|
230
|
+
}
|
|
231
|
+
/** True when the running daemon launched its browser on this very profile. */
|
|
232
|
+
function daemonOwnsProfile(daemonArgs, userDataDir) {
|
|
233
|
+
const launched = launchedUserDataDir(daemonArgs);
|
|
234
|
+
return launched !== undefined && resolve(launched) === resolve(userDataDir);
|
|
235
|
+
}
|
|
236
|
+
const CONFLICT_EXPLANATION = (userDataDir) => `\nOpera is already running on the profile ${CLI_BIN_NAME} is configured to use:\n` +
|
|
237
|
+
` ${userDataDir}\n\n` +
|
|
238
|
+
'A browser can only be automated if it was started with a debugging port,\n' +
|
|
239
|
+
'and that flag cannot be added to a browser that is already open.\n\n' +
|
|
240
|
+
' [1] Restart Opera now so the CLI can drive it (tabs are restored)\n' +
|
|
241
|
+
' [2] Use a separate profile instead (you will need to sign in there)\n\n' +
|
|
242
|
+
'Restarting opens a local debugging port for as long as that browser runs.\n';
|
|
243
|
+
/**
|
|
244
|
+
* The question, without the explanation: readline prints whatever it is given,
|
|
245
|
+
* so the explanation is written separately and this is the only part that would
|
|
246
|
+
* otherwise appear twice on a terminal.
|
|
247
|
+
*/
|
|
248
|
+
const CONFLICT_QUESTION = 'Select [1/2] (default 2): ';
|
|
249
|
+
async function askOnTerminal(question) {
|
|
250
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
251
|
+
const { promise, resolve: resolveAnswer } = Promise.withResolvers();
|
|
252
|
+
rl.question(question, resolveAnswer);
|
|
253
|
+
// Ctrl-D ends the question without an answer. The fallback is the wrong
|
|
254
|
+
// answer to hang on, and an empty answer selects it.
|
|
255
|
+
rl.once('close', () => resolveAnswer(''));
|
|
256
|
+
try {
|
|
257
|
+
return await promise;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
rl.close();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Long enough for a busy daemon to answer, short enough not to stall a command. */
|
|
264
|
+
const DAEMON_STATUS_TIMEOUT_MS = 5_000;
|
|
265
|
+
async function defaultDaemonArgs(sessionId) {
|
|
266
|
+
if (!isDaemonRunning(sessionId)) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const response = await sendCommand({ method: 'status' }, sessionId, DAEMON_STATUS_TIMEOUT_MS);
|
|
271
|
+
if (!response.success) {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
const status = JSON.parse(response.result);
|
|
275
|
+
return status.args;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// A daemon that cannot answer owns nothing we can compare against, so the
|
|
279
|
+
// profile it holds is treated as somebody else's — and a command that is
|
|
280
|
+
// about to use that daemon must not be failed by a probe about it.
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const defaultPreflightDeps = {
|
|
285
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
286
|
+
ask: askOnTerminal,
|
|
287
|
+
resolveTarget: resolveBrowserTarget,
|
|
288
|
+
daemonArgs: defaultDaemonArgs,
|
|
289
|
+
quit: quitBrowser,
|
|
290
|
+
launch: launchAttachableBrowser,
|
|
291
|
+
stopDaemon,
|
|
292
|
+
env: process.env,
|
|
293
|
+
writeOut: text => process.stdout.write(text),
|
|
294
|
+
write: text => process.stderr.write(text),
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Settle the conflict the prompt is about.
|
|
298
|
+
*
|
|
299
|
+
* `separate` is the answer that always works and needs nobody's browser to be
|
|
300
|
+
* touched, so it is the default for every caller that cannot ask: no terminal,
|
|
301
|
+
* or an answer that is not 1.
|
|
302
|
+
*/
|
|
303
|
+
async function resolveConflict(target, options) {
|
|
304
|
+
const { deps } = options;
|
|
305
|
+
let choice = options.takeover
|
|
306
|
+
? 'takeover'
|
|
307
|
+
: 'separate';
|
|
308
|
+
if (!options.takeover && deps.interactive) {
|
|
309
|
+
deps.writeOut(CONFLICT_EXPLANATION(target.userDataDir));
|
|
310
|
+
const answer = (await deps.ask(CONFLICT_QUESTION)).trim().toLowerCase();
|
|
311
|
+
if (answer === '1' || answer === 'y') {
|
|
312
|
+
choice = 'takeover';
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (choice === 'separate') {
|
|
316
|
+
const dir = separateProfileDir();
|
|
317
|
+
deps.env.OPERA_CLI_USER_DATA_DIR = dir;
|
|
318
|
+
deps.write(`note: Opera is running on the configured profile; using ${dir} for this run.\n` +
|
|
319
|
+
' Start Opera with a debugging port (--remote-debugging-port=0) so the CLI can attach to it instead.\n');
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
const quit = await deps.quit(target.lock, target.userDataDir);
|
|
323
|
+
if (!quit.ok) {
|
|
324
|
+
throw new CdpError(quit.reason === 'no-pid'
|
|
325
|
+
? 'Could not identify the process holding the profile, so it was not signalled.'
|
|
326
|
+
: 'Opera did not shut down within 20s.', 'BROWSER_ERROR', [
|
|
327
|
+
'Quit Opera yourself, then re-run the command',
|
|
328
|
+
'Or restart it with a debugging port (--remote-debugging-port=0) so the CLI can attach instead',
|
|
329
|
+
]);
|
|
330
|
+
}
|
|
331
|
+
const launched = await deps.launch(deps.env.OPERA_CLI_EXECUTABLE_PATH, target.userDataDir);
|
|
332
|
+
if (!launched.ok) {
|
|
333
|
+
throw new CdpError(`Opera was stopped but could not be restarted (${launched.reason}${launched.detail ? `: ${launched.detail}` : ''}).`, 'BROWSER_ERROR', [
|
|
334
|
+
'Start Opera yourself, then re-run the command',
|
|
335
|
+
'Or restart it with a debugging port (--remote-debugging-port=0) so the CLI can attach instead',
|
|
336
|
+
]);
|
|
337
|
+
}
|
|
338
|
+
deps.env.OPERA_CLI_BROWSER_URL = launched.url;
|
|
339
|
+
deps.write(`note: restarted Opera and attached at ${launched.url}\n`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Take down a daemon that would keep driving the browser it was started with.
|
|
344
|
+
*
|
|
345
|
+
* A daemon fixes its browser at startup — the attach URL, the profile, the
|
|
346
|
+
* flags — so a new selection only reaches the next one. Nothing here restarts
|
|
347
|
+
* it; the caller starts a daemon on the selection it now holds.
|
|
348
|
+
*/
|
|
349
|
+
async function retireDaemonForNewSelection(sessionId, deps) {
|
|
350
|
+
if ((await deps.daemonArgs(sessionId)) === null) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
deps.write('note: browser selection changed; restarting the daemon.\n');
|
|
354
|
+
await deps.stopDaemon(sessionId);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Settle whichever browser the environment points at, and report whether that
|
|
358
|
+
* changed it — so a caller that already tried something can try again.
|
|
359
|
+
*
|
|
360
|
+
* Two callers need this. The preflight runs it before a command starts a daemon
|
|
361
|
+
* (`preflightBrowser`), and the tool commands run it when a daemon reports the
|
|
362
|
+
* one failure this can fix (`cliCommands.ts`): a launch that never happened
|
|
363
|
+
* because a browser was already holding the profile, or an attach to a browser
|
|
364
|
+
* that is no longer there. The second exists because the first cannot see
|
|
365
|
+
* everything — a daemon started before that browser was opened, or by an older
|
|
366
|
+
* CLI, is pinned to the profile it was given, and no amount of looking at the
|
|
367
|
+
* environment reveals that its browser is missing.
|
|
368
|
+
*/
|
|
369
|
+
export async function settleBrowserConflict(sessionId, options, deps = defaultPreflightDeps) {
|
|
370
|
+
const env = deps.env;
|
|
371
|
+
const userDataDir = env.OPERA_CLI_USER_DATA_DIR;
|
|
372
|
+
const target = await deps.resolveTarget({
|
|
373
|
+
browserUrl: env.OPERA_CLI_BROWSER_URL,
|
|
374
|
+
userDataDir,
|
|
375
|
+
executablePath: env.OPERA_CLI_EXECUTABLE_PATH,
|
|
376
|
+
});
|
|
377
|
+
let changed = false;
|
|
378
|
+
if (target.mode === 'attach') {
|
|
379
|
+
if (env.OPERA_CLI_BROWSER_URL !== target.url) {
|
|
380
|
+
// A live debug port on the configured profile. Set the attach URL so the
|
|
381
|
+
// daemon the caller is about to start attaches to it rather than
|
|
382
|
+
// launching — and so the tools' page ownership follows the browser in
|
|
383
|
+
// use.
|
|
384
|
+
env.OPERA_CLI_BROWSER_URL = target.url;
|
|
385
|
+
changed = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else if (target.mode === 'conflict' ||
|
|
389
|
+
options.reason === 'profile-in-use') {
|
|
390
|
+
// An isolated profile cannot be held by anybody else, so a failure that
|
|
391
|
+
// says otherwise is not one this can settle.
|
|
392
|
+
if (!userDataDir) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
await resolveConflict(target.mode === 'conflict'
|
|
396
|
+
? target
|
|
397
|
+
: {
|
|
398
|
+
mode: 'conflict',
|
|
399
|
+
userDataDir,
|
|
400
|
+
lock: { state: 'unknown', pid: null, hostname: null },
|
|
401
|
+
}, { takeover: options.takeover, deps });
|
|
402
|
+
changed = true;
|
|
403
|
+
}
|
|
404
|
+
else if (options.reason !== 'unreachable') {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
if (!changed && !(await daemonPinnedToDiscoveredAttach(sessionId, deps))) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
await retireDaemonForNewSelection(sessionId, deps);
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Whether a daemon is attached to a URL the CLI discovered for it, and which
|
|
415
|
+
* the environment no longer names.
|
|
416
|
+
*
|
|
417
|
+
* That daemon cannot recover by itself: an attached browser is never restarted,
|
|
418
|
+
* so it keeps trying a port nothing answers. Retiring it lets the caller's
|
|
419
|
+
* restart re-derive the browser — attach to a live endpoint, launch when the
|
|
420
|
+
* profile is free, or ask when it is held. A URL the user configured, or a
|
|
421
|
+
* `--wsEndpoint` / `--autoConnect` they typed, is left alone: their browser is
|
|
422
|
+
* theirs to start again, which is what the failure says.
|
|
423
|
+
*/
|
|
424
|
+
async function daemonPinnedToDiscoveredAttach(sessionId, deps) {
|
|
425
|
+
if (deps.env.OPERA_CLI_BROWSER_URL !== undefined) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
const daemonArgs = await deps.daemonArgs(sessionId);
|
|
429
|
+
return daemonArgs !== null && storedBrowserUrl(daemonArgs) !== undefined;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Work out which browser the command about to run should drive.
|
|
433
|
+
*
|
|
434
|
+
* Runs in the CLI rather than the daemon because settling a conflict may need
|
|
435
|
+
* to ask the user something, and the daemon is detached with no terminal —
|
|
436
|
+
* which is also why the decision has to be written into the environment before
|
|
437
|
+
* the command starts the daemon: both processes inherit it from here.
|
|
438
|
+
*
|
|
439
|
+
* The browser is only re-selected when a daemon is not already driving one: a
|
|
440
|
+
* daemon fixes its browser at startup, so the question is settled for as long
|
|
441
|
+
* as it lives. `start` is the exception — its handler stops the daemon and
|
|
442
|
+
* picks fresh browser options — and even then the lock a restart can see
|
|
443
|
+
* belongs to the browser it is about to release, so it is not a conflict to
|
|
444
|
+
* resolve. A daemon whose browser never started is the other exception, and it
|
|
445
|
+
* is settled where its failure is reported instead.
|
|
446
|
+
*/
|
|
447
|
+
export async function preflightBrowser(argv, sessionId, takeover, deps = defaultPreflightDeps) {
|
|
448
|
+
const command = argv[0];
|
|
449
|
+
if (command === undefined || BROWSER_SKIP_COMMANDS[command] === true) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// `--help` on any command is a question about the command, not a job for the
|
|
453
|
+
// browser; asking about a profile conflict before printing help would be rude.
|
|
454
|
+
if (argv.some(arg => arg === '--help' ||
|
|
455
|
+
arg === '-h' ||
|
|
456
|
+
arg === '--version' ||
|
|
457
|
+
arg === '-v' ||
|
|
458
|
+
arg === '-V')) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const env = deps.env;
|
|
462
|
+
// An explicit browser URL, or no persistent profile, means no conflict is
|
|
463
|
+
// possible.
|
|
464
|
+
if (env.OPERA_CLI_BROWSER_URL || !env.OPERA_CLI_USER_DATA_DIR) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (picksBrowserOnArgv(argv)) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const userDataDir = env.OPERA_CLI_USER_DATA_DIR;
|
|
471
|
+
const daemonArgs = await deps.daemonArgs(sessionId);
|
|
472
|
+
if (daemonArgs !== null &&
|
|
473
|
+
(command !== 'start' || daemonOwnsProfile(daemonArgs, userDataDir))) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
await settleBrowserConflict(sessionId, { takeover }, deps);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Remove `--takeover` wherever it appears and report whether it was given. It
|
|
480
|
+
* belongs to the preflight, not to any command, and every command parser is
|
|
481
|
+
* strict — so the flag is consumed here rather than declared on each of them.
|
|
482
|
+
*/
|
|
483
|
+
export function extractTakeoverFlag(argv) {
|
|
484
|
+
let takeover = false;
|
|
485
|
+
for (let index = argv.length - 1; index >= 0; index--) {
|
|
486
|
+
if (argv[index] === '--takeover') {
|
|
487
|
+
takeover = true;
|
|
488
|
+
argv.splice(index, 1);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return takeover;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The `--sessionId` a command will be scoped to, read before yargs has parsed
|
|
495
|
+
* it: the daemon the preflight asks about has to be the session's own. `''` is
|
|
496
|
+
* the default yargs applies to a command that passes none — the two must agree,
|
|
497
|
+
* or the preflight would inspect an empty session nobody is using.
|
|
498
|
+
*/
|
|
499
|
+
export function sessionIdFromArgv(argv) {
|
|
500
|
+
for (let index = 0; index < argv.length; index++) {
|
|
501
|
+
const arg = argv[index];
|
|
502
|
+
for (const name of ['--sessionId', '--session-id']) {
|
|
503
|
+
if (arg === name && index + 1 < argv.length) {
|
|
504
|
+
return argv[index + 1];
|
|
505
|
+
}
|
|
506
|
+
if (arg.startsWith(`${name}=`)) {
|
|
507
|
+
return arg.slice(name.length + 1);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return '';
|
|
512
|
+
}
|
|
513
|
+
//# sourceMappingURL=browserTarget.js.map
|