blob: 8ee14f05d59a979a014be03da2a63123d9dce725 [file] [log] [blame] [edit]
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
const _chromeEnvironments = ['CHROME_EXECUTABLE', 'CHROME_PATH'];
const _linuxExecutable = 'google-chrome';
const _macOSExecutable =
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const _windowsExecutable = r'Google\Chrome\Application\chrome.exe';
String get _executable {
for (var chromeEnv in _chromeEnvironments) {
if (Platform.environment.containsKey(chromeEnv)) {
return Platform.environment[chromeEnv]!;
}
}
if (Platform.isLinux) return _linuxExecutable;
if (Platform.isMacOS) return _macOSExecutable;
if (Platform.isWindows) {
final windowsPrefixes = [
Platform.environment['LOCALAPPDATA'],
Platform.environment['PROGRAMFILES'],
Platform.environment['PROGRAMFILES(X86)'],
];
return p.join(
windowsPrefixes.firstWhere(
(prefix) {
if (prefix == null) return false;
final path = p.join(prefix, _windowsExecutable);
return File(path).existsSync();
},
orElse: () => '.',
)!,
_windowsExecutable,
);
}
throw StateError('Unexpected platform type.');
}
/// Manager for an instance of Chrome.
class Chrome {
static final _logger = Logger('BROWSER_LAUNCHER.CHROME');
Chrome._(
this.debugPort,
this.chromeConnection, {
Process? process,
Directory? dataDir,
this.deleteDataDir = false,
}) : _process = process,
_dataDir = dataDir;
final int debugPort;
final ChromeConnection chromeConnection;
final Process? _process;
final Directory? _dataDir;
final bool deleteDataDir;
/// Connects to an instance of Chrome with an open debug port.
static Future<Chrome> fromExisting(int port) async =>
_connect(Chrome._(port, ChromeConnection('localhost', port)));
/// Starts Chrome with the given arguments and a specific port.
///
/// Each url in [urls] will be loaded in a separate tab.
///
/// If [userDataDir] is `null`, a new temp directory will be
/// passed to chrome as a user data directory. Chrome will
/// start without sign in and with extensions disabled.
///
/// If [userDataDir] is not `null`, it will be passed to chrome
/// as a user data directory. Chrome will start signed into
/// the default profile with extensions enabled if [signIn]
/// is also true.
static Future<Chrome> startWithDebugPort(
List<String> urls, {
int debugPort = 0,
bool headless = false,
String? userDataDir,
bool signIn = false,
}) async {
Directory dataDir;
if (userDataDir == null) {
signIn = false;
dataDir = Directory.systemTemp.createTempSync();
} else {
dataDir = Directory(userDataDir);
}
final port = debugPort == 0 ? await findUnusedPort() : debugPort;
final args = [
// Using a tmp directory ensures that a new instance of chrome launches
// allowing for the remote debug port to be enabled.
'--user-data-dir=${dataDir.path}',
'--remote-debugging-port=$port',
// When the DevTools has focus we don't want to slow down the application.
'--disable-background-timer-throttling',
'--disable-blink-features=TimerThrottlingForBackgroundTabs',
'--disable-features=IntensiveWakeUpThrottling',
// Since we are using a temp profile, disable features that slow the
// Chrome launch.
if (!signIn) '--disable-extensions',
'--disable-popup-blocking',
if (!signIn) '--bwsi',
'--no-first-run',
'--no-default-browser-check',
'--disable-default-apps',
'--disable-translate',
'--start-maximized',
// When running on MacOS, Chrome may open system dialogs requesting
// credentials. This uses a mock keychain to avoid that dialog from
// blocking.
'--use-mock-keychain',
];
if (headless) {
args.add('--headless');
}
final process = await _startProcess(urls, args: args);
// Wait until the DevTools are listening before trying to connect.
final errorLines = <String>[];
try {
final stderr = process.stderr.asBroadcastStream();
stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(_logger.fine);
await stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.firstWhere((line) {
errorLines.add(line);
return line.startsWith('DevTools listening');
}).timeout(const Duration(seconds: 60));
} on TimeoutException catch (e, s) {
_logger.severe('Unable to connect to Chrome DevTools', e, s);
throw Exception(
'Unable to connect to Chrome DevTools: $e.\n\n'
'Chrome STDERR:\n${errorLines.join('\n')}',
);
}
return _connect(
Chrome._(
port,
ChromeConnection('localhost', port),
process: process,
dataDir: dataDir,
deleteDataDir: userDataDir == null,
),
);
}
/// Starts Chrome with the given arguments.
///
/// Each url in [urls] will be loaded in a separate tab.
static Future<Process> start(
List<String> urls, {
List<String> args = const [],
}) async =>
await _startProcess(urls, args: args);
static Future<Process> _startProcess(
List<String> urls, {
List<String> args = const [],
}) async {
final processArgs = args.toList()..addAll(urls);
return await Process.start(_executable, processArgs);
}
static Future<Chrome> _connect(Chrome chrome) async {
// The connection is lazy. Try a simple call to make sure the provided
// connection is valid.
try {
await chrome.chromeConnection.getTabs();
} catch (e) {
await chrome.close();
throw ChromeError(
'Unable to connect to Chrome debug port: ${chrome.debugPort}\n $e',
);
}
return chrome;
}
Future<void> close() async {
chromeConnection.close();
_process?.kill(ProcessSignal.sigkill);
await _process?.exitCode;
try {
// Chrome starts another process as soon as it dies that modifies the
// profile information. Give it some time before attempting to delete
// the directory.
if (deleteDataDir) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await _dataDir?.delete(recursive: true);
}
} catch (_) {
// Silently fail if we can't clean up the profile information.
// It is a system tmp directory so it should get cleaned up eventually.
}
}
}
class ChromeError extends Error {
final String details;
ChromeError(this.details);
@override
String toString() => 'ChromeError: $details';
}
/// Returns a port that is probably, but not definitely, not in use.
///
/// This has a built-in race condition: another process may bind this port at
/// any time after this call has returned.
Future<int> findUnusedPort() async {
int port;
ServerSocket socket;
try {
socket =
await ServerSocket.bind(InternetAddress.loopbackIPv6, 0, v6Only: true);
} on SocketException {
socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
}
port = socket.port;
await socket.close();
return port;
}