chrome.dart 8.57 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

7
import 'package:meta/meta.dart';
8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';

import '../base/common.dart';
import '../base/context.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/os.dart';
import '../base/platform.dart';
import '../base/process_manager.dart';
import '../convert.dart';

/// The [ChromeLauncher] instance.
ChromeLauncher get chromeLauncher => context.get<ChromeLauncher>();

/// An environment variable used to override the location of chrome.
const String kChromeEnvironment = 'CHROME_EXECUTABLE';

/// The expected executable name on linux.
const String kLinuxExecutable = 'google-chrome';

/// The expected executable name on macOS.
const String kMacOSExecutable =
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';

/// The expected executable name on Windows.
const String kWindowsExecutable = r'Google\Chrome\Application\chrome.exe';

/// The possible locations where the chrome executable can be located on windows.
final List<String> kWindowsPrefixes = <String>[
  platform.environment['LOCALAPPDATA'],
  platform.environment['PROGRAMFILES'],
39
  platform.environment['PROGRAMFILES(X86)'],
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
];

/// Find the chrome executable on the current platform.
///
/// Does not verify whether the executable exists.
String findChromeExecutable() {
  if (platform.environment.containsKey(kChromeEnvironment)) {
    return platform.environment[kChromeEnvironment];
  }
  if (platform.isLinux) {
    return kLinuxExecutable;
  }
  if (platform.isMacOS) {
    return kMacOSExecutable;
  }
  if (platform.isWindows) {
    final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) {
      if (prefix == null) {
        return false;
      }
      final String path = fs.path.join(prefix, kWindowsExecutable);
      return fs.file(path).existsSync();
    }, orElse: () => '.');
    return fs.path.join(windowsPrefix, kWindowsExecutable);
  }
  throwToolExit('Platform ${platform.operatingSystem} is not supported.');
  return null;
}

69 70 71 72 73
@visibleForTesting
void resetChromeForTesting() {
  ChromeLauncher._currentCompleter = Completer<Chrome>();
}

74 75 76 77 78
@visibleForTesting
void launchChromeInstance(Chrome chrome) {
  ChromeLauncher._currentCompleter.complete(chrome);
}

79 80 81 82
/// Responsible for launching chrome with devtools configured.
class ChromeLauncher {
  const ChromeLauncher();

83 84
  static bool get hasChromeInstance => _currentCompleter.isCompleted;

85
  static Completer<Chrome> _currentCompleter = Completer<Chrome>();
86

87 88 89 90 91 92 93 94 95 96
  /// Whether we can locate the chrome executable.
  bool canFindChrome() {
    final String chrome = findChromeExecutable();
    try {
      return processManager.canRun(chrome);
    } on ArgumentError {
      return false;
    }
  }

97
  /// Launch the chrome browser to a particular `host` page.
98 99 100
  ///
  /// `headless` defaults to false, and controls whether we open a headless or
  /// a `headfull` browser.
101 102
  ///
  /// `skipCheck` does not attempt to make a devtools connection before returning.
103 104 105 106 107 108
  Future<Chrome> launch(String url, { bool headless = false, bool skipCheck = false, Directory dataDir }) async {
    // This is a JSON file which contains configuration from the
    // browser session, such as window position. It is located
    // under the Chrome data-dir folder.
    final String preferencesPath = fs.path.join('Default', 'preferences');

109
    final String chromeExecutable = findChromeExecutable();
110 111 112 113 114 115 116 117 118 119 120 121
    final Directory activeDataDir = fs.systemTempDirectory.createTempSync('flutter_tool.');
    // Seed data dir with previous state.

    final File savedPreferencesFile = fs.file(fs.path.join(dataDir?.path ?? '', preferencesPath));
    final File destinationFile = fs.file(fs.path.join(activeDataDir.path, preferencesPath));
    if (dataDir != null) {
      if (savedPreferencesFile.existsSync()) {
        destinationFile.parent.createSync(recursive: true);
        savedPreferencesFile.copySync(destinationFile.path);
      }
    }

122 123 124 125 126
    final int port = await os.findFreePort();
    final List<String> args = <String>[
      chromeExecutable,
      // Using a tmp directory ensures that a new instance of chrome launches
      // allowing for the remote debug port to be enabled.
127
      '--user-data-dir=${activeDataDir.path}',
128 129 130 131 132 133 134 135 136 137 138 139
      '--remote-debugging-port=$port',
      // When the DevTools has focus we don't want to slow down the application.
      '--disable-background-timer-throttling',
      // Since we are using a temp profile, disable features that slow the
      // Chrome launch.
      '--disable-extensions',
      '--disable-popup-blocking',
      '--bwsi',
      '--no-first-run',
      '--no-default-browser-check',
      '--disable-default-apps',
      '--disable-translate',
140
      '--window-size=2400,1800',
141
      if (headless)
142
        ...<String>['--headless', '--disable-gpu', '--no-sandbox'],
143 144
      url,
    ];
145

146
    final Process process = await processManager.start(args);
147

148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
    // When the process exits, copy the user settings back to the provided
    // data-dir.
    if (dataDir != null) {
      unawaited(process.exitCode.whenComplete(() {
        if (destinationFile.existsSync()) {
          savedPreferencesFile.parent.createSync(recursive: true);
          // If the file contains a crash string, remove it to hide
          // the popup on next run.
          final String contents = destinationFile.readAsStringSync();
          savedPreferencesFile.writeAsStringSync(contents
            .replaceFirst('"exit_type":"Crashed"', '"exit_type":"Normal"'));
        }
      }));
    }

163 164 165 166
    // Wait until the DevTools are listening before trying to connect.
    await process.stderr
        .transform(utf8.decoder)
        .transform(const LineSplitter())
167 168 169
        .firstWhere((String line) => line.startsWith('DevTools listening'), orElse: () {
          return 'Failed to spawn stderr';
        })
170 171 172 173
        .timeout(const Duration(seconds: 60), onTimeout: () {
          throwToolExit('Unable to connect to Chrome DevTools.');
          return null;
        });
174
    final Uri remoteDebuggerUri = await _getRemoteDebuggerUrl(Uri.parse('http://localhost:$port'));
175 176 177
    return _connect(Chrome._(
      port,
      ChromeConnection('localhost', port),
178
      url: url,
179
      process: process,
180
      remoteDebuggerUri: remoteDebuggerUri,
181
    ), skipCheck);
182 183
  }

184
  static Future<Chrome> _connect(Chrome chrome, bool skipCheck) async {
185 186 187 188 189
    if (_currentCompleter.isCompleted) {
      throwToolExit('Only one instance of chrome can be started.');
    }
    // The connection is lazy. Try a simple call to make sure the provided
    // connection is valid.
190 191 192 193 194 195 196 197
    if (!skipCheck) {
      try {
        await chrome.chromeConnection.getTabs();
      } catch (e) {
        await chrome.close();
        throwToolExit(
            'Unable to connect to Chrome debug port: ${chrome.debugPort}\n $e');
      }
198 199 200 201 202 203
    }
    _currentCompleter.complete(chrome);
    return chrome;
  }

  static Future<Chrome> get connectedInstance => _currentCompleter.future;
204 205

  /// Returns the full URL of the Chrome remote debugger for the main page.
206 207 208 209 210 211 212 213 214
  ///
  /// This takes the [base] remote debugger URL (which points to a browser-wide
  /// page) and uses its JSON API to find the resolved URL for debugging the host
  /// page.
  Future<Uri> _getRemoteDebuggerUrl(Uri base) async {
    try {
      final HttpClient client = HttpClient();
      final HttpClientRequest request = await client.getUrl(base.resolve('/json/list'));
      final HttpClientResponse response = await request.close();
215 216
      final List<dynamic> jsonObject = await json.fuse(utf8).decoder.bind(response).single as List<dynamic>;
      return base.resolve(jsonObject.first['devtoolsFrontendUrl'] as String);
217 218 219 220 221
    } catch (_) {
      // If we fail to talk to the remote debugger protocol, give up and return
      // the raw URL rather than crashing.
      return base;
    }
222 223 224
  }
}

225 226
/// A class for managing an instance of Chrome.
class Chrome {
227
  Chrome._(
228 229
    this.debugPort,
    this.chromeConnection, {
230
    this.url,
231
    Process process,
232
    this.remoteDebuggerUri,
233
  })  : _process = process;
234

235
  final String url;
236 237 238
  final int debugPort;
  final Process _process;
  final ChromeConnection chromeConnection;
239
  final Uri remoteDebuggerUri;
240 241 242

  static Completer<Chrome> _currentCompleter = Completer<Chrome>();

243 244
  Future<void> get onExit => _currentCompleter.future;

245 246 247 248 249
  Future<void> close() async {
    if (_currentCompleter.isCompleted) {
      _currentCompleter = Completer<Chrome>();
    }
    chromeConnection.close();
250
    _process?.kill();
251 252 253
    await _process?.exitCode;
  }
}