1// Copyright 2019 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6 7import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; 8 9import '../base/common.dart'; 10import '../base/context.dart'; 11import '../base/file_system.dart'; 12import '../base/io.dart'; 13import '../base/os.dart'; 14import '../base/platform.dart'; 15import '../base/process_manager.dart'; 16import '../convert.dart'; 17import '../globals.dart'; 18 19/// The [ChromeLauncher] instance. 20ChromeLauncher get chromeLauncher => context.get<ChromeLauncher>(); 21 22/// An environment variable used to override the location of chrome. 23const String kChromeEnvironment = 'CHROME_EXECUTABLE'; 24 25/// The expected executable name on linux. 26const String kLinuxExecutable = 'google-chrome'; 27 28/// The expected executable name on macOS. 29const String kMacOSExecutable = 30 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 31 32/// The expected executable name on Windows. 33const String kWindowsExecutable = r'Google\Chrome\Application\chrome.exe'; 34 35/// The possible locations where the chrome executable can be located on windows. 36final List<String> kWindowsPrefixes = <String>[ 37 platform.environment['LOCALAPPDATA'], 38 platform.environment['PROGRAMFILES'], 39 platform.environment['PROGRAMFILES(X86)'] 40]; 41 42/// Find the chrome executable on the current platform. 43/// 44/// Does not verify whether the executable exists. 45String findChromeExecutable() { 46 if (platform.environment.containsKey(kChromeEnvironment)) { 47 return platform.environment[kChromeEnvironment]; 48 } 49 if (platform.isLinux) { 50 return kLinuxExecutable; 51 } 52 if (platform.isMacOS) { 53 return kMacOSExecutable; 54 } 55 if (platform.isWindows) { 56 final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) { 57 if (prefix == null) { 58 return false; 59 } 60 final String path = fs.path.join(prefix, kWindowsExecutable); 61 return fs.file(path).existsSync(); 62 }, orElse: () => '.'); 63 return fs.path.join(windowsPrefix, kWindowsExecutable); 64 } 65 throwToolExit('Platform ${platform.operatingSystem} is not supported.'); 66 return null; 67} 68 69/// Responsible for launching chrome with devtools configured. 70class ChromeLauncher { 71 const ChromeLauncher(); 72 73 static final Completer<Chrome> _currentCompleter = Completer<Chrome>(); 74 75 /// Launch the chrome browser to a particular `host` page. 76 /// 77 /// `headless` defaults to false, and controls whether we open a headless or 78 /// a `headfull` browser. 79 Future<Chrome> launch(String url, { bool headless = false }) async { 80 final String chromeExecutable = findChromeExecutable(); 81 final Directory dataDir = fs.systemTempDirectory.createTempSync(); 82 final int port = await os.findFreePort(); 83 final List<String> args = <String>[ 84 chromeExecutable, 85 // Using a tmp directory ensures that a new instance of chrome launches 86 // allowing for the remote debug port to be enabled. 87 '--user-data-dir=${dataDir.path}', 88 '--remote-debugging-port=$port', 89 // When the DevTools has focus we don't want to slow down the application. 90 '--disable-background-timer-throttling', 91 // Since we are using a temp profile, disable features that slow the 92 // Chrome launch. 93 '--disable-extensions', 94 '--disable-popup-blocking', 95 '--bwsi', 96 '--no-first-run', 97 '--no-default-browser-check', 98 '--disable-default-apps', 99 '--disable-translate', 100 if (headless) 101 ...<String>['--headless', '--disable-gpu', '--no-sandbox'], 102 url, 103 ]; 104 105 final Process process = await processManager.start(args, runInShell: true); 106 107 // Wait until the DevTools are listening before trying to connect. 108 await process.stderr 109 .transform(utf8.decoder) 110 .transform(const LineSplitter()) 111 .firstWhere((String line) => line.startsWith('DevTools listening'), orElse: () { 112 return 'Failed to spawn stderr'; 113 }) 114 .timeout(const Duration(seconds: 60), onTimeout: () { 115 throwToolExit('Unable to connect to Chrome DevTools.'); 116 return null; 117 }); 118 final Uri remoteDebuggerUri = await _getRemoteDebuggerUrl(Uri.parse('http://localhost:$port')); 119 120 return _connect(Chrome._( 121 port, 122 ChromeConnection('localhost', port), 123 process: process, 124 dataDir: dataDir, 125 remoteDebuggerUri: remoteDebuggerUri, 126 )); 127 } 128 129 static Future<Chrome> _connect(Chrome chrome) async { 130 if (_currentCompleter.isCompleted) { 131 throwToolExit('Only one instance of chrome can be started.'); 132 } 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 throwToolExit( 140 'Unable to connect to Chrome debug port: ${chrome.debugPort}\n $e'); 141 } 142 _currentCompleter.complete(chrome); 143 return chrome; 144 } 145 146 /// Connects to an instance of Chrome with an open debug port. 147 static Future<Chrome> fromExisting(int port) async => 148 _connect(Chrome._(port, ChromeConnection('localhost', port))); 149 150 static Future<Chrome> get connectedInstance => _currentCompleter.future; 151 152 /// Returns the full URL of the Chrome remote debugger for the main page. 153/// 154/// This takes the [base] remote debugger URL (which points to a browser-wide 155/// page) and uses its JSON API to find the resolved URL for debugging the host 156/// page. 157Future<Uri> _getRemoteDebuggerUrl(Uri base) async { 158 try { 159 final HttpClient client = HttpClient(); 160 final HttpClientRequest request = await client.getUrl(base.resolve('/json/list')); 161 final HttpClientResponse response = await request.close(); 162 final List<dynamic> jsonObject = await json.fuse(utf8).decoder.bind(response).single; 163 return base.resolve(jsonObject.first['devtoolsFrontendUrl']); 164 } catch (_) { 165 // If we fail to talk to the remote debugger protocol, give up and return 166 // the raw URL rather than crashing. 167 return base; 168 } 169} 170 171} 172 173/// A class for managing an instance of Chrome. 174class Chrome { 175 Chrome._( 176 this.debugPort, 177 this.chromeConnection, { 178 Process process, 179 Directory dataDir, 180 this.remoteDebuggerUri, 181 }) : _process = process, 182 _dataDir = dataDir; 183 184 final int debugPort; 185 final Process _process; 186 final Directory _dataDir; 187 final ChromeConnection chromeConnection; 188 final Uri remoteDebuggerUri; 189 190 static Completer<Chrome> _currentCompleter = Completer<Chrome>(); 191 192 Future<void> get onExit => _currentCompleter.future; 193 194 Future<void> close() async { 195 if (_currentCompleter.isCompleted) { 196 _currentCompleter = Completer<Chrome>(); 197 } 198 chromeConnection.close(); 199 _process?.kill(); 200 await _process?.exitCode; 201 try { 202 // Chrome starts another process as soon as it dies that modifies the 203 // profile information. Give it some time before attempting to delete 204 // the directory. 205 await Future<void>.delayed(const Duration(milliseconds: 500)); 206 } catch (_) { 207 // Silently fail if we can't clean up the profile information. 208 } finally { 209 try { 210 await _dataDir?.delete(recursive: true); 211 } on FileSystemException { 212 printError('failed to delete temporary profile at ${_dataDir.path}'); 213 } 214 } 215 } 216} 217