chrome.dart 17.7 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
import 'package:process/process.dart';
9 10 11 12 13
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';

import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
14 15
import '../base/logger.dart';
import '../base/os.dart';
16
import '../base/platform.dart';
17 18
import '../convert.dart';

19
/// An environment variable used to override the location of Google Chrome.
20 21
const String kChromeEnvironment = 'CHROME_EXECUTABLE';

22 23 24
/// An environment variable used to override the location of Microsoft Edge.
const String kEdgeEnvironment = 'EDGE_ENVIRONMENT';

25 26 27 28 29 30 31
/// 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';

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

35 36 37
/// The expected Edge executable name on Windows.
const String kWindowsEdgeExecutable = r'Microsoft\Edge\Application\msedge.exe';

38 39 40 41 42 43 44 45 46 47 48 49
/// Used by [ChromiumLauncher] to detect a glibc bug and retry launching the
/// browser.
///
/// Once every few thousands of launches we hit this glibc bug:
///
/// https://sourceware.org/bugzilla/show_bug.cgi?id=19329.
///
/// When this happens Chrome spits out something like the following then exits with code 127:
///
///     Inconsistency detected by ld.so: ../elf/dl-tls.c: 493: _dl_allocate_tls_init: Assertion `listp->slotinfo[cnt].gen <= GL(dl_tls_generation)' failed!
const String _kGlibcError = 'Inconsistency detected by ld.so';

50 51
typedef BrowserFinder = String Function(Platform, FileSystem);

52 53 54
/// Find the chrome executable on the current platform.
///
/// Does not verify whether the executable exists.
55 56
String findChromeExecutable(Platform platform, FileSystem fileSystem) {
  if (platform.environment.containsKey(kChromeEnvironment)) {
57
    return platform.environment[kChromeEnvironment]!;
58
  }
59
  if (platform.isLinux) {
60 61
    return kLinuxExecutable;
  }
62
  if (platform.isMacOS) {
63 64
    return kMacOSExecutable;
  }
65 66 67
  if (platform.isWindows) {
    /// The possible locations where the chrome executable can be located on windows.
    final List<String> kWindowsPrefixes = <String>[
68 69 70 71 72 73
      if (platform.environment.containsKey('LOCALAPPDATA'))
        platform.environment['LOCALAPPDATA']!,
      if (platform.environment.containsKey('PROGRAMFILES'))
        platform.environment['PROGRAMFILES']!,
      if (platform.environment.containsKey('PROGRAMFILES(X86)'))
        platform.environment['PROGRAMFILES(X86)']!,
74
    ];
75 76 77 78
    final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) {
      if (prefix == null) {
        return false;
      }
79 80
      final String path = fileSystem.path.join(prefix, kWindowsExecutable);
      return fileSystem.file(path).existsSync();
81
    }, orElse: () => '.');
82
    return fileSystem.path.join(windowsPrefix, kWindowsExecutable);
83
  }
84
  throwToolExit('Platform ${platform.operatingSystem} is not supported.');
85 86
}

87 88 89 90 91
/// Find the Microsoft Edge executable on the current platform.
///
/// Does not verify whether the executable exists.
String findEdgeExecutable(Platform platform, FileSystem fileSystem) {
  if (platform.environment.containsKey(kEdgeEnvironment)) {
92
    return platform.environment[kEdgeEnvironment]!;
93 94 95 96
  }
  if (platform.isWindows) {
    /// The possible locations where the Edge executable can be located on windows.
    final List<String> kWindowsPrefixes = <String>[
97 98 99 100 101 102
      if (platform.environment.containsKey('LOCALAPPDATA'))
        platform.environment['LOCALAPPDATA']!,
      if (platform.environment.containsKey('PROGRAMFILES'))
        platform.environment['PROGRAMFILES']!,
      if (platform.environment.containsKey('PROGRAMFILES(X86)'))
        platform.environment['PROGRAMFILES(X86)']!,
103 104 105 106 107 108 109 110 111 112 113 114
    ];
    final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) {
      if (prefix == null) {
        return false;
      }
      final String path = fileSystem.path.join(prefix, kWindowsEdgeExecutable);
      return fileSystem.file(path).existsSync();
    }, orElse: () => '.');
    return fileSystem.path.join(windowsPrefix, kWindowsEdgeExecutable);
  }
  // Not yet supported for macOS and Linux.
  return '';
115 116
}

117 118 119
/// A launcher for Chromium browsers with devtools configured.
class ChromiumLauncher {
  ChromiumLauncher({
120 121 122 123 124 125
    required FileSystem fileSystem,
    required Platform platform,
    required ProcessManager processManager,
    required OperatingSystemUtils operatingSystemUtils,
    required BrowserFinder browserFinder,
    required Logger logger,
126 127 128 129
  }) : _fileSystem = fileSystem,
       _platform = platform,
       _processManager = processManager,
       _operatingSystemUtils = operatingSystemUtils,
130
       _browserFinder = browserFinder,
131
       _logger = logger;
132 133 134 135 136

  final FileSystem _fileSystem;
  final Platform _platform;
  final ProcessManager _processManager;
  final OperatingSystemUtils _operatingSystemUtils;
137
  final BrowserFinder _browserFinder;
138
  final Logger _logger;
139

140
  bool get hasChromeInstance => currentCompleter.isCompleted;
141 142

  @visibleForTesting
143
  Completer<Chromium> currentCompleter = Completer<Chromium>();
144

145
  /// Whether we can locate the chrome executable.
146 147
  bool canFindExecutable() {
    final String chrome = _browserFinder(_platform, _fileSystem);
148 149 150 151 152
    try {
      return _processManager.canRun(chrome);
    } on ArgumentError {
      return false;
    }
153 154
  }

155 156 157 158
  /// The executable this launcher will use.
  String findExecutable() =>  _browserFinder(_platform, _fileSystem);

  /// Launch a Chromium browser to a particular `host` page.
159
  ///
160 161
  /// [headless] defaults to false, and controls whether we open a headless or
  /// a "headfull" browser.
162
  ///
163
  /// [debugPort] is Chrome's debugging protocol port. If null, a random free
164 165
  /// port is picked automatically.
  ///
166
  /// [skipCheck] does not attempt to make a devtools connection before returning.
167 168
  ///
  /// [webBrowserFlags] add arbitrary browser flags.
169 170
  Future<Chromium> launch(String url, {
    bool headless = false,
171
    int? debugPort,
172
    bool skipCheck = false,
173
    Directory? cacheDir,
174
    List<String> webBrowserFlags = const <String>[],
175
  }) async {
176
    if (currentCompleter.isCompleted) {
177 178 179
      throwToolExit('Only one instance of chrome can be started.');
    }

180
    final String chromeExecutable = _browserFinder(_platform, _fileSystem);
181

182 183
    if (_logger.isVerbose && !_platform.isWindows) {
      // Note: --version is not supported on windows.
184 185 186 187
      final ProcessResult versionResult = await _processManager.run(<String>[chromeExecutable, '--version']);
      _logger.printTrace('Using ${versionResult.stdout}');
    }

188 189
    final Directory userDataDir = _fileSystem.systemTempDirectory
      .createTempSync('flutter_tools_chrome_device.');
190 191 192 193

    if (cacheDir != null) {
      // Seed data dir with previous state.
      _restoreUserSessionInformation(cacheDir, userDataDir);
194 195
    }

196
    final int port = debugPort ?? await _operatingSystemUtils.findFreePort();
197 198 199 200
    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.
201
      '--user-data-dir=${userDataDir.path}',
202 203 204 205 206 207 208 209 210 211 212 213
      '--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',
214
      if (headless)
215 216 217 218 219 220
        ...<String>[
          '--headless',
          '--disable-gpu',
          '--no-sandbox',
          '--window-size=2400,1800',
        ],
221
      ...webBrowserFlags,
222 223
      url,
    ];
224

225
    final Process? process = await _spawnChromiumProcess(args, chromeExecutable);
226 227

    // When the process exits, copy the user settings back to the provided data-dir.
228
    if (process != null && cacheDir != null) {
229 230 231 232
      unawaited(process.exitCode.whenComplete(() {
        _cacheUserSessionInformation(userDataDir, cacheDir);
      }));
    }
233
    return connect(Chromium(
234 235
      port,
      ChromeConnection('localhost', port),
236
      url: url,
237
      process: process,
238
      chromiumLauncher: this,
239
    ), skipCheck);
240 241
  }

242
  Future<Process?> _spawnChromiumProcess(List<String> args, String chromeExecutable) async {
243
    if (_operatingSystemUtils.hostPlatform == HostPlatform.darwin_arm64) {
244 245 246 247 248 249 250 251 252 253
      final ProcessResult result = _processManager.runSync(<String>['file', chromeExecutable]);
      // Check if ARM Chrome is installed.
      // Mach-O 64-bit executable arm64
      if ((result.stdout as String).contains('arm64')) {
        _logger.printTrace('Found ARM Chrome installation at $chromeExecutable, forcing native launch.');
        // If so, force Chrome to launch natively.
        args.insertAll(0, <String>['/usr/bin/arch', '-arm64']);
      }
    }

254 255
    // Keep attempting to launch the browser until one of:
    // - Chrome launched successfully, in which case we just return from the loop.
256 257 258
    // - The tool reached the maximum retry count, in which case we throw ToolExit.
    const int kMaxRetries = 3;
    int retry = 0;
259 260 261 262 263 264 265 266 267 268 269 270 271
    while (true) {
      final Process process = await _processManager.start(args);

      process.stdout
        .transform(utf8.decoder)
        .transform(const LineSplitter())
        .listen((String line) {
          _logger.printTrace('[CHROME]: $line');
        });

      // Wait until the DevTools are listening before trying to connect. This is
      // only required for flutter_test --platform=chrome and not flutter run.
      bool hitGlibcBug = false;
272
      bool shouldRetry = false;
273
      final List<String> errors = <String>[];
274 275 276 277
      await process.stderr
        .transform(utf8.decoder)
        .transform(const LineSplitter())
        .map((String line) {
278 279
          _logger.printTrace('[CHROME]: $line');
          errors.add('[CHROME]:$line');
280 281
          if (line.contains(_kGlibcError)) {
            hitGlibcBug = true;
282
            shouldRetry = true;
283 284 285 286 287 288 289 290 291
          }
          return line;
        })
        .firstWhere((String line) => line.startsWith('DevTools listening'), orElse: () {
          if (hitGlibcBug) {
            _logger.printTrace(
              'Encountered glibc bug https://sourceware.org/bugzilla/show_bug.cgi?id=19329. '
              'Will try launching browser again.',
            );
292 293
            // Return value unused.
            return '';
294
          }
295
          if (retry >= kMaxRetries) {
296 297
            errors.forEach(_logger.printError);
            _logger.printError('Failed to launch browser after $kMaxRetries tries. Command used to launch it: ${args.join(' ')}');
298 299 300 301 302 303 304 305
            throw ToolExit(
              'Failed to launch browser. Make sure you are using an up-to-date '
              'Chrome or Edge. Otherwise, consider using -d web-server instead '
              'and filing an issue at https://github.com/flutter/flutter/issues.',
            );
          }
          shouldRetry = true;
          return '';
306 307
        });

308
      if (!hitGlibcBug && !shouldRetry) {
309 310
        return process;
      }
311
      retry += 1;
312 313 314 315 316 317

      // A precaution that avoids accumulating browser processes, in case the
      // glibc bug doesn't cause the browser to quit and we keep looping and
      // launching more processes.
      unawaited(process.exitCode.timeout(const Duration(seconds: 1), onTimeout: () {
        process.kill();
318 319
        // sigterm
        return 15;
320 321 322 323
      }));
    }
  }

324 325 326 327
  // This is a directory which Chrome uses to store cookies, preferences and
  // other session data.
  String get _chromeDefaultPath => _fileSystem.path.join('Default');

328 329 330 331 332 333 334 335 336
  // 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.
  String get _preferencesPath => _fileSystem.path.join('Default', 'preferences');

  /// Copy Chrome user information from a Chrome session into a per-project
  /// cache.
  ///
  /// Note: more detailed docs of the Chrome user preferences store exists here:
  /// https://www.chromium.org/developers/design-documents/preferences.
337 338 339 340 341 342 343
  ///
  /// This intentionally skips the Cache, Code Cache, and GPUCache directories.
  /// While we're not sure exactly what is in them, this constitutes nearly 1 GB
  /// of data for a fresh flutter run and adds significant overhead to all startups.
  /// For workflows that may require this data, using the start-paused flag and
  /// dart debug extension with a user controlled browser profile will lead to a
  /// better experience.
344
  void _cacheUserSessionInformation(Directory userDataDir, Directory cacheDir) {
345
    final Directory targetChromeDefault = _fileSystem.directory(_fileSystem.path.join(cacheDir.path, _chromeDefaultPath));
346 347 348
    final Directory sourceChromeDefault = _fileSystem.directory(_fileSystem.path.join(userDataDir.path, _chromeDefaultPath));
    if (sourceChromeDefault.existsSync()) {
      targetChromeDefault.createSync(recursive: true);
349
      try {
350 351 352 353 354
        copyDirectory(
          sourceChromeDefault,
          targetChromeDefault,
          shouldCopyDirectory: _isNotCacheDirectory
        );
355 356 357 358 359
      } on FileSystemException catch (err) {
        // This is a best-effort update. Display the message in case the failure is relevant.
        // one possible example is a file lock due to multiple running chrome instances.
        _logger.printError('Failed to save Chrome preferences: $err');
      }
360
    }
361

362
    final File targetPreferencesFile = _fileSystem.file(_fileSystem.path.join(cacheDir.path, _preferencesPath));
363 364 365 366 367 368 369 370 371
    final File sourcePreferencesFile = _fileSystem.file(_fileSystem.path.join(userDataDir.path, _preferencesPath));

    if (sourcePreferencesFile.existsSync()) {
       targetPreferencesFile.parent.createSync(recursive: true);
       // If the file contains a crash string, remove it to hide the popup on next run.
       final String contents = sourcePreferencesFile.readAsStringSync();
       targetPreferencesFile.writeAsStringSync(contents
           .replaceFirst('"exit_type":"Crashed"', '"exit_type":"Normal"'));
    }
372 373 374 375 376
  }

  /// Restore Chrome user information from a per-project cache into Chrome's
  /// user data directory.
  void _restoreUserSessionInformation(Directory cacheDir, Directory userDataDir) {
377
    final Directory sourceChromeDefault = _fileSystem.directory(_fileSystem.path.join(cacheDir.path, _chromeDefaultPath));
378
    final Directory targetChromeDefault = _fileSystem.directory(_fileSystem.path.join(userDataDir.path, _chromeDefaultPath));
379 380 381
    try {
      if (sourceChromeDefault.existsSync()) {
        targetChromeDefault.createSync(recursive: true);
382 383 384 385 386
        copyDirectory(
          sourceChromeDefault,
          targetChromeDefault,
          shouldCopyDirectory: _isNotCacheDirectory,
        );
387 388 389
      }
    } on FileSystemException catch (err) {
      _logger.printError('Failed to restore Chrome preferences: $err');
390 391 392
    }
  }

393 394 395 396 397 398 399
  // Cache, Code Cache, and GPUCache are nearly 1GB of data
  bool _isNotCacheDirectory(Directory directory) {
    return !directory.path.endsWith('Cache') &&
           !directory.path.endsWith('Code Cache') &&
           !directory.path.endsWith('GPUCache');
  }

400 401 402 403
  /// Connect to the [chrome] instance, testing the connection if
  /// [skipCheck] is set to false.
  @visibleForTesting
  Future<Chromium> connect(Chromium chrome, bool skipCheck) async {
404 405
    // The connection is lazy. Try a simple call to make sure the provided
    // connection is valid.
406 407
    if (!skipCheck) {
      try {
408
        await _getFirstTab(chrome);
409 410
      } on Exception catch (error, stackTrace) {
        _logger.printError('$error', stackTrace: stackTrace);
411 412
        await chrome.close();
        throwToolExit(
413
            'Unable to connect to Chrome debug port: ${chrome.debugPort}\n $error');
414
      }
415
    }
416
    currentCompleter.complete(chrome);
417 418 419
    return chrome;
  }

420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449
  /// Gets the first [chrome] tab.
  ///
  /// Note: Retry getting tabs from Chrome for a few seconds and retry finding
  /// the tab a few times. This prevents flakes caused by Chrome not returning
  /// correct output if the call was too close to the start.
  Future<ChromeTab?> _getFirstTab(Chromium chrome) async {
    const Duration retryFor = Duration(seconds: 2);
    const int attempts = 5;

    for (int i = 1; i <= attempts; i++) {
      try {
        final List<ChromeTab> tabs =
          await chrome.chromeConnection.getTabs(retryFor: retryFor);

        if (tabs.isNotEmpty) {
          return tabs.first;
        }
        if (i == attempts) {
          return null;
        }
      } on ConnectionException catch (_) {
        if (i == attempts) {
          rethrow;
        }
      }
      await Future<void>.delayed(const Duration(milliseconds: 25));
    }
    return null;
  }

450
  Future<Chromium> get connectedInstance => currentCompleter.future;
451 452
}

453 454
/// A class for managing an instance of a Chromium browser.
class Chromium {
455
  Chromium(
456 457
    this.debugPort,
    this.chromeConnection, {
458
    this.url,
459 460
    Process? process,
    required ChromiumLauncher chromiumLauncher,
461 462
  })  : _process = process,
        _chromiumLauncher = chromiumLauncher;
463

464
  final String? url;
465
  final int debugPort;
466
  final Process? _process;
467
  final ChromeConnection chromeConnection;
468
  final ChromiumLauncher _chromiumLauncher;
469

470
  Future<int?> get onExit async => _process?.exitCode;
471

472
  Future<void> close() async {
473
    if (_chromiumLauncher.hasChromeInstance) {
474
      _chromiumLauncher.currentCompleter = Completer<Chromium>();
475 476
    }
    chromeConnection.close();
477
    _process?.kill();
478 479 480
    await _process?.exitCode;
  }
}