// 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; }); }); } }