| // Copyright 2014 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'dart:async'; | |
| import 'package:meta/meta.dart'; | |
| import 'package:process/process.dart'; | |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; | |
| import '../base/common.dart'; | |
| import '../base/file_system.dart'; | |
| import '../base/io.dart'; | |
| import '../base/logger.dart'; | |
| import '../base/os.dart'; | |
| import '../base/platform.dart'; | |
| import '../convert.dart'; | |
| /// An environment variable used to override the location of Google Chrome. | |
| const String kChromeEnvironment = 'CHROME_EXECUTABLE'; | |
| /// An environment variable used to override the location of Microsoft Edge. | |
| const String kEdgeEnvironment = 'EDGE_ENVIRONMENT'; | |
| /// 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 Chrome executable name on Windows. | |
| const String kWindowsExecutable = r'Google\Chrome\Application\chrome.exe'; | |
| /// The expected Edge executable name on Windows. | |
| const String kWindowsEdgeExecutable = r'Microsoft\Edge\Application\msedge.exe'; | |
| /// 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'; | |
| typedef BrowserFinder = String Function(Platform, FileSystem); | |
| /// Find the chrome executable on the current platform. | |
| /// | |
| /// Does not verify whether the executable exists. | |
| String findChromeExecutable(Platform platform, FileSystem fileSystem) { | |
| if (platform.environment.containsKey(kChromeEnvironment)) { | |
| return platform.environment[kChromeEnvironment]!; | |
| } | |
| if (platform.isLinux) { | |
| return kLinuxExecutable; | |
| } | |
| if (platform.isMacOS) { | |
| return kMacOSExecutable; | |
| } | |
| if (platform.isWindows) { | |
| /// The possible locations where the chrome executable can be located on windows. | |
| final List<String> kWindowsPrefixes = <String>[ | |
| 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)']!, | |
| ]; | |
| final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) { | |
| final String path = fileSystem.path.join(prefix, kWindowsExecutable); | |
| return fileSystem.file(path).existsSync(); | |
| }, orElse: () => '.'); | |
| return fileSystem.path.join(windowsPrefix, kWindowsExecutable); | |
| } | |
| throwToolExit('Platform ${platform.operatingSystem} is not supported.'); | |
| } | |
| /// 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)) { | |
| return platform.environment[kEdgeEnvironment]!; | |
| } | |
| if (platform.isWindows) { | |
| /// The possible locations where the Edge executable can be located on windows. | |
| final List<String> kWindowsPrefixes = <String>[ | |
| 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)']!, | |
| ]; | |
| final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) { | |
| 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 ''; | |
| } | |
| /// A launcher for Chromium browsers with devtools configured. | |
| class ChromiumLauncher { | |
| ChromiumLauncher({ | |
| required FileSystem fileSystem, | |
| required Platform platform, | |
| required ProcessManager processManager, | |
| required OperatingSystemUtils operatingSystemUtils, | |
| required BrowserFinder browserFinder, | |
| required Logger logger, | |
| }) : _fileSystem = fileSystem, | |
| _platform = platform, | |
| _processManager = processManager, | |
| _operatingSystemUtils = operatingSystemUtils, | |
| _browserFinder = browserFinder, | |
| _logger = logger; | |
| final FileSystem _fileSystem; | |
| final Platform _platform; | |
| final ProcessManager _processManager; | |
| final OperatingSystemUtils _operatingSystemUtils; | |
| final BrowserFinder _browserFinder; | |
| final Logger _logger; | |
| bool get hasChromeInstance => currentCompleter.isCompleted; | |
| @visibleForTesting | |
| Completer<Chromium> currentCompleter = Completer<Chromium>(); | |
| /// Whether we can locate the chrome executable. | |
| bool canFindExecutable() { | |
| final String chrome = _browserFinder(_platform, _fileSystem); | |
| try { | |
| return _processManager.canRun(chrome); | |
| } on ArgumentError { | |
| return false; | |
| } | |
| } | |
| /// The executable this launcher will use. | |
| String findExecutable() => _browserFinder(_platform, _fileSystem); | |
| /// Launch a Chromium browser to a particular `host` page. | |
| /// | |
| /// [headless] defaults to false, and controls whether we open a headless or | |
| /// a "headful" browser. | |
| /// | |
| /// [debugPort] is Chrome's debugging protocol port. If null, a random free | |
| /// port is picked automatically. | |
| /// | |
| /// [skipCheck] does not attempt to make a devtools connection before returning. | |
| /// | |
| /// [webBrowserFlags] add arbitrary browser flags. | |
| Future<Chromium> launch(String url, { | |
| bool headless = false, | |
| int? debugPort, | |
| bool skipCheck = false, | |
| Directory? cacheDir, | |
| List<String> webBrowserFlags = const <String>[], | |
| }) async { | |
| if (currentCompleter.isCompleted) { | |
| throwToolExit('Only one instance of chrome can be started.'); | |
| } | |
| if (_logger.isVerbose) { | |
| _logger.printTrace('Launching Chromium (url = $url, headless = $headless, skipCheck = $skipCheck, debugPort = $debugPort)'); | |
| } | |
| final String chromeExecutable = _browserFinder(_platform, _fileSystem); | |
| if (_logger.isVerbose) { | |
| _logger.printTrace('Will use Chromium executable at $chromeExecutable'); | |
| if (!_platform.isWindows) { | |
| // The "--version" argument is not supported on Windows. | |
| final ProcessResult versionResult = await _processManager.run(<String>[chromeExecutable, '--version']); | |
| _logger.printTrace('Using ${versionResult.stdout}'); | |
| } | |
| } | |
| final Directory userDataDir = _fileSystem.systemTempDirectory | |
| .createTempSync('flutter_tools_chrome_device.'); | |
| if (cacheDir != null) { | |
| // Seed data dir with previous state. | |
| _restoreUserSessionInformation(cacheDir, userDataDir); | |
| } | |
| final int port = debugPort ?? await _operatingSystemUtils.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. | |
| '--user-data-dir=${userDataDir.path}', | |
| '--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', | |
| if (headless) | |
| ...<String>[ | |
| '--headless', | |
| '--disable-gpu', | |
| '--no-sandbox', | |
| '--window-size=2400,1800', | |
| ], | |
| ...webBrowserFlags, | |
| url, | |
| ]; | |
| final Process process = await _spawnChromiumProcess(args, chromeExecutable); | |
| // When the process exits, copy the user settings back to the provided data-dir. | |
| if (cacheDir != null) { | |
| unawaited(process.exitCode.whenComplete(() { | |
| _cacheUserSessionInformation(userDataDir, cacheDir); | |
| // cleanup temp dir | |
| try { | |
| userDataDir.deleteSync(recursive: true); | |
| } on FileSystemException { | |
| // ignore | |
| } | |
| })); | |
| } | |
| return connect(Chromium( | |
| port, | |
| ChromeConnection('localhost', port), | |
| url: url, | |
| process: process, | |
| chromiumLauncher: this, | |
| logger: _logger, | |
| ), skipCheck); | |
| } | |
| Future<Process> _spawnChromiumProcess(List<String> args, String chromeExecutable) async { | |
| if (_operatingSystemUtils.hostPlatform == HostPlatform.darwin_arm64) { | |
| 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']); | |
| } | |
| } | |
| // Keep attempting to launch the browser until one of: | |
| // - Chrome launched successfully, in which case we just return from the loop. | |
| // - The tool reached the maximum retry count, in which case we throw ToolExit. | |
| const int kMaxRetries = 3; | |
| int retry = 0; | |
| 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; | |
| bool shouldRetry = false; | |
| final List<String> errors = <String>[]; | |
| await process.stderr | |
| .transform(utf8.decoder) | |
| .transform(const LineSplitter()) | |
| .map((String line) { | |
| _logger.printTrace('[CHROME]: $line'); | |
| errors.add('[CHROME]:$line'); | |
| if (line.contains(_kGlibcError)) { | |
| hitGlibcBug = true; | |
| shouldRetry = true; | |
| } | |
| 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.', | |
| ); | |
| // Return value unused. | |
| return ''; | |
| } | |
| if (retry >= kMaxRetries) { | |
| errors.forEach(_logger.printError); | |
| _logger.printError('Failed to launch browser after $kMaxRetries tries. Command used to launch it: ${args.join(' ')}'); | |
| 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 ''; | |
| }); | |
| if (!hitGlibcBug && !shouldRetry) { | |
| return process; | |
| } | |
| retry += 1; | |
| // 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(); | |
| // sigterm | |
| return 15; | |
| })); | |
| } | |
| } | |
| // This is a directory which Chrome uses to store cookies, preferences and | |
| // other session data. | |
| String get _chromeDefaultPath => _fileSystem.path.join('Default'); | |
| // 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. | |
| /// | |
| /// More detailed docs of the Chrome user preferences store exists here: | |
| /// https://www.chromium.org/developers/design-documents/preferences. | |
| /// | |
| /// 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. | |
| void _cacheUserSessionInformation(Directory userDataDir, Directory cacheDir) { | |
| final Directory targetChromeDefault = _fileSystem.directory(_fileSystem.path.join(cacheDir.path, _chromeDefaultPath)); | |
| final Directory sourceChromeDefault = _fileSystem.directory(_fileSystem.path.join(userDataDir.path, _chromeDefaultPath)); | |
| if (sourceChromeDefault.existsSync()) { | |
| targetChromeDefault.createSync(recursive: true); | |
| try { | |
| copyDirectory( | |
| sourceChromeDefault, | |
| targetChromeDefault, | |
| shouldCopyDirectory: _isNotCacheDirectory | |
| ); | |
| } 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'); | |
| } | |
| } | |
| final File targetPreferencesFile = _fileSystem.file(_fileSystem.path.join(cacheDir.path, _preferencesPath)); | |
| 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"')); | |
| } | |
| } | |
| /// Restore Chrome user information from a per-project cache into Chrome's | |
| /// user data directory. | |
| void _restoreUserSessionInformation(Directory cacheDir, Directory userDataDir) { | |
| final Directory sourceChromeDefault = _fileSystem.directory(_fileSystem.path.join(cacheDir.path, _chromeDefaultPath)); | |
| final Directory targetChromeDefault = _fileSystem.directory(_fileSystem.path.join(userDataDir.path, _chromeDefaultPath)); | |
| try { | |
| if (sourceChromeDefault.existsSync()) { | |
| targetChromeDefault.createSync(recursive: true); | |
| copyDirectory( | |
| sourceChromeDefault, | |
| targetChromeDefault, | |
| shouldCopyDirectory: _isNotCacheDirectory, | |
| ); | |
| } | |
| } on FileSystemException catch (err) { | |
| _logger.printError('Failed to restore Chrome preferences: $err'); | |
| } | |
| } | |
| // 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'); | |
| } | |
| /// Connect to the [chrome] instance, testing the connection if | |
| /// [skipCheck] is set to false. | |
| @visibleForTesting | |
| Future<Chromium> connect(Chromium chrome, bool skipCheck) async { | |
| // The connection is lazy. Try a simple call to make sure the provided | |
| // connection is valid. | |
| if (!skipCheck) { | |
| try { | |
| await _getFirstTab(chrome); | |
| } on Exception catch (error, stackTrace) { | |
| _logger.printError('$error', stackTrace: stackTrace); | |
| await chrome.close(); | |
| throwToolExit( | |
| 'Unable to connect to Chrome debug port: ${chrome.debugPort}\n $error'); | |
| } | |
| } | |
| currentCompleter.complete(chrome); | |
| return chrome; | |
| } | |
| /// Gets the first [chrome] tab. | |
| /// | |
| /// Retries getting tabs from Chrome for a few seconds and retries finding | |
| /// the tab a few times. This reduces flakes caused by Chrome not returning | |
| /// correct output if the call was too close to the start. | |
| // | |
| // TODO(ianh): remove the timeouts here, they violate our style guide. | |
| // (We should just keep waiting forever, and print a warning when it's | |
| // taking too long.) | |
| 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; | |
| } | |
| Future<Chromium> get connectedInstance => currentCompleter.future; | |
| } | |
| /// A class for managing an instance of a Chromium browser. | |
| class Chromium { | |
| Chromium( | |
| this.debugPort, | |
| this.chromeConnection, { | |
| this.url, | |
| required Process process, | |
| required ChromiumLauncher chromiumLauncher, | |
| required Logger logger, | |
| }) : _process = process, | |
| _chromiumLauncher = chromiumLauncher, | |
| _logger = logger; | |
| final String? url; | |
| final int debugPort; | |
| final Process _process; | |
| final ChromeConnection chromeConnection; | |
| final ChromiumLauncher _chromiumLauncher; | |
| final Logger _logger; | |
| /// Resolves to browser's main process' exit code, when the browser exits. | |
| Future<int> get onExit async => _process.exitCode; | |
| /// The main Chromium process that represents this instance of Chromium. | |
| /// | |
| /// Killing this process should result in the browser exiting. | |
| @visibleForTesting | |
| Process get process => _process; | |
| /// Closes all connections to the browser and asks the browser to exit. | |
| Future<void> close() async { | |
| if (_logger.isVerbose) { | |
| _logger.printTrace('Shutting down Chromium.'); | |
| } | |
| if (_chromiumLauncher.hasChromeInstance) { | |
| _chromiumLauncher.currentCompleter = Completer<Chromium>(); | |
| } | |
| chromeConnection.close(); | |
| // Try to exit Chromium nicely using SIGTERM, before exiting it rudely using | |
| // SIGKILL. Wait no longer than 5 seconds for Chromium to exit before | |
| // falling back to SIGKILL, and then to a warning message. | |
| ProcessSignal.sigterm.kill(_process); | |
| await _process.exitCode.timeout(const Duration(seconds: 5), onTimeout: () { | |
| _logger.printWarning( | |
| 'Failed to exit Chromium (pid: ${_process.pid}) using SIGTERM. Will try ' | |
| 'sending SIGKILL instead.' | |
| ); | |
| ProcessSignal.sigkill.kill(_process); | |
| return _process.exitCode.timeout(const Duration(seconds: 5), onTimeout: () async { | |
| _logger.printWarning( | |
| 'Failed to exit Chromium (pid: ${_process.pid}) using SIGKILL. Giving ' | |
| 'up. Will continue, assuming Chromium has exited successfully, but ' | |
| 'it is possible that this left a dangling Chromium process running ' | |
| 'on the system.' | |
| ); | |
| return 0; | |
| }); | |
| }); | |
| } | |
| } |