// 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 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 startWithDebugPort( List 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 = []; 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 start( List urls, { List args = const [], }) async => await _startProcess(urls, args: args); static Future _startProcess( List urls, { List args = const [], }) async { final processArgs = args.toList()..addAll(urls); return await Process.start(_executable, processArgs); } static Future _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 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.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 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; }