|
1 | 1 | // Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
|
2 | 2 | // for details. All rights reserved. Use of this source code is governed by a
|
3 | 3 | // BSD-style license that can be found in the LICENSE file.
|
| 4 | + |
| 5 | +import 'dart:async'; |
| 6 | +import 'dart:convert'; |
| 7 | +import 'dart:io'; |
| 8 | + |
| 9 | +import 'package:path/path.dart' as p; |
| 10 | +import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; |
| 11 | + |
| 12 | +const _chromeEnvironment = 'CHROME_EXECUTABLE'; |
| 13 | +const _linuxExecutable = 'google-chrome'; |
| 14 | +const _macOSExecutable = |
| 15 | + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; |
| 16 | +const _windowsExecutable = r'Google\Chrome\Application\chrome.exe'; |
| 17 | + |
| 18 | +String get _executable { |
| 19 | + if (Platform.environment.containsKey(_chromeEnvironment)) { |
| 20 | + return Platform.environment[_chromeEnvironment]; |
| 21 | + } |
| 22 | + if (Platform.isLinux) return _linuxExecutable; |
| 23 | + if (Platform.isMacOS) return _macOSExecutable; |
| 24 | + if (Platform.isWindows) { |
| 25 | + final windowsPrefixes = [ |
| 26 | + Platform.environment['LOCALAPPDATA'], |
| 27 | + Platform.environment['PROGRAMFILES'], |
| 28 | + Platform.environment['PROGRAMFILES(X86)'] |
| 29 | + ]; |
| 30 | + return p.join( |
| 31 | + windowsPrefixes.firstWhere((prefix) { |
| 32 | + if (prefix == null) return false; |
| 33 | + final path = p.join(prefix, _windowsExecutable); |
| 34 | + return File(path).existsSync(); |
| 35 | + }, orElse: () => '.'), |
| 36 | + _windowsExecutable, |
| 37 | + ); |
| 38 | + } |
| 39 | + throw StateError('Unexpected platform type.'); |
| 40 | +} |
| 41 | + |
| 42 | +/// Manager for an instance of Chrome. |
| 43 | +class Chrome { |
| 44 | + Chrome._( |
| 45 | + this.debugPort, |
| 46 | + this.chromeConnection, { |
| 47 | + Process process, |
| 48 | + Directory dataDir, |
| 49 | + }) : _process = process, |
| 50 | + _dataDir = dataDir; |
| 51 | + |
| 52 | + final int debugPort; |
| 53 | + final ChromeConnection chromeConnection; |
| 54 | + final Process _process; |
| 55 | + final Directory _dataDir; |
| 56 | + |
| 57 | + /// Connects to an instance of Chrome with an open debug port. |
| 58 | + static Future<Chrome> fromExisting(int port) async => |
| 59 | + _connect(Chrome._(port, ChromeConnection('localhost', port))); |
| 60 | + |
| 61 | + /// Starts Chrome with the given arguments and a specific port. |
| 62 | + /// |
| 63 | + /// Only one instance of Chrome can run at a time. Each url in [urls] will be |
| 64 | + /// loaded in a separate tab. |
| 65 | + static Future<Chrome> startWithDebugPort( |
| 66 | + List<String> urls, { |
| 67 | + int debugPort, |
| 68 | + bool headless = false, |
| 69 | + }) async { |
| 70 | + final dataDir = Directory.systemTemp.createTempSync(); |
| 71 | + final port = debugPort == null || debugPort == 0 |
| 72 | + ? await findUnusedPort() |
| 73 | + : debugPort; |
| 74 | + final args = [ |
| 75 | + // Using a tmp directory ensures that a new instance of chrome launches |
| 76 | + // allowing for the remote debug port to be enabled. |
| 77 | + '--user-data-dir=${dataDir.path}', |
| 78 | + '--remote-debugging-port=$port', |
| 79 | + // When the DevTools has focus we don't want to slow down the application. |
| 80 | + '--disable-background-timer-throttling', |
| 81 | + // Since we are using a temp profile, disable features that slow the |
| 82 | + // Chrome launch. |
| 83 | + '--disable-extensions', |
| 84 | + '--disable-popup-blocking', |
| 85 | + '--bwsi', |
| 86 | + '--no-first-run', |
| 87 | + '--no-default-browser-check', |
| 88 | + '--disable-default-apps', |
| 89 | + '--disable-translate', |
| 90 | + ]; |
| 91 | + if (headless) { |
| 92 | + args.add('--headless'); |
| 93 | + } |
| 94 | + |
| 95 | + final process = await _startProcess(urls, args: args); |
| 96 | + |
| 97 | + // Wait until the DevTools are listening before trying to connect. |
| 98 | + await process.stderr |
| 99 | + .transform(utf8.decoder) |
| 100 | + .transform(const LineSplitter()) |
| 101 | + .firstWhere((line) => line.startsWith('DevTools listening')) |
| 102 | + .timeout(Duration(seconds: 60), |
| 103 | + onTimeout: () => |
| 104 | + throw Exception('Unable to connect to Chrome DevTools.')); |
| 105 | + |
| 106 | + return _connect(Chrome._( |
| 107 | + port, |
| 108 | + ChromeConnection('localhost', port), |
| 109 | + process: process, |
| 110 | + dataDir: dataDir, |
| 111 | + )); |
| 112 | + } |
| 113 | + |
| 114 | + /// Starts Chrome with the given arguments. |
| 115 | + /// |
| 116 | + /// Each url in [urls] will be loaded in a separate tab. |
| 117 | + static Future<void> start( |
| 118 | + List<String> urls, { |
| 119 | + List<String> args = const [], |
| 120 | + }) async { |
| 121 | + await _startProcess(urls, args: args); |
| 122 | + } |
| 123 | + |
| 124 | + static Future<Process> _startProcess( |
| 125 | + List<String> urls, { |
| 126 | + List<String> args = const [], |
| 127 | + }) async { |
| 128 | + final processArgs = args.toList()..addAll(urls); |
| 129 | + return await Process.start(_executable, processArgs); |
| 130 | + } |
| 131 | + |
| 132 | + static Future<Chrome> _connect(Chrome chrome) async { |
| 133 | + // The connection is lazy. Try a simple call to make sure the provided |
| 134 | + // connection is valid. |
| 135 | + try { |
| 136 | + await chrome.chromeConnection.getTabs(); |
| 137 | + } catch (e) { |
| 138 | + await chrome.close(); |
| 139 | + throw ChromeError( |
| 140 | + 'Unable to connect to Chrome debug port: ${chrome.debugPort}\n $e'); |
| 141 | + } |
| 142 | + return chrome; |
| 143 | + } |
| 144 | + |
| 145 | + Future<void> close() async { |
| 146 | + chromeConnection.close(); |
| 147 | + _process?.kill(ProcessSignal.sigkill); |
| 148 | + await _process?.exitCode; |
| 149 | + try { |
| 150 | + // Chrome starts another process as soon as it dies that modifies the |
| 151 | + // profile information. Give it some time before attempting to delete |
| 152 | + // the directory. |
| 153 | + await Future.delayed(Duration(milliseconds: 500)); |
| 154 | + await _dataDir?.delete(recursive: true); |
| 155 | + } catch (_) { |
| 156 | + // Silently fail if we can't clean up the profile information. |
| 157 | + // It is a system tmp directory so it should get cleaned up eventually. |
| 158 | + } |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +class ChromeError extends Error { |
| 163 | + final String details; |
| 164 | + ChromeError(this.details); |
| 165 | + |
| 166 | + @override |
| 167 | + String toString() => 'ChromeError: $details'; |
| 168 | +} |
| 169 | + |
| 170 | +/// Returns a port that is probably, but not definitely, not in use. |
| 171 | +/// |
| 172 | +/// This has a built-in race condition: another process may bind this port at |
| 173 | +/// any time after this call has returned. |
| 174 | +Future<int> findUnusedPort() async { |
| 175 | + int port; |
| 176 | + ServerSocket socket; |
| 177 | + try { |
| 178 | + socket = |
| 179 | + await ServerSocket.bind(InternetAddress.loopbackIPv6, 0, v6Only: true); |
| 180 | + } on SocketException { |
| 181 | + socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); |
| 182 | + } |
| 183 | + port = socket.port; |
| 184 | + await socket.close(); |
| 185 | + return port; |
| 186 | +} |
0 commit comments