// 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) { if (prefix == null) { return false; } 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) { 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 ''; } /// 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 "headfull" 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. Future<Chromium> launch(String url, { bool headless = false, int? debugPort, bool skipCheck = false, Directory? cacheDir, }) async { if (currentCompleter.isCompleted) { throwToolExit('Only one instance of chrome can be started.'); } final String chromeExecutable = _browserFinder(_platform, _fileSystem); if (_logger.isVerbose && !_platform.isWindows) { // Note: --version 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', ], url, ]; final Process? process = await _spawnChromiumProcess(args, chromeExecutable); // When the process exits, copy the user settings back to the provided data-dir. if (process != null && cacheDir != null) { unawaited(process.exitCode.whenComplete(() { _cacheUserSessionInformation(userDataDir, cacheDir); })); } return _connect(Chromium( port, ChromeConnection('localhost', port), url: url, process: process, chromiumLauncher: this, ), skipCheck); } Future<Process?> _spawnChromiumProcess(List<String> args, String chromeExecutable) async { if (_operatingSystemUtils.hostPlatform == HostPlatform.darwin_arm) { 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. /// /// Note: 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'); } 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 chrome.chromeConnection.getTab( (ChromeTab tab) => true, retryFor: const Duration(seconds: 2)); } 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; } 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, Process? process, required ChromiumLauncher chromiumLauncher, }) : _process = process, _chromiumLauncher = chromiumLauncher; final String? url; final int debugPort; final Process? _process; final ChromeConnection chromeConnection; final ChromiumLauncher _chromiumLauncher; Future<int?> get onExit async => _process?.exitCode; Future<void> close() async { if (_chromiumLauncher.hasChromeInstance) { _chromiumLauncher.currentCompleter = Completer<Chromium>(); } chromeConnection.close(); _process?.kill(); await _process?.exitCode; } }