• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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