| // 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:process/process.dart'; | |
| import 'application_package.dart'; | |
| import 'base/file_system.dart'; | |
| import 'base/io.dart'; | |
| import 'base/logger.dart'; | |
| import 'base/os.dart'; | |
| import 'base/utils.dart'; | |
| import 'build_info.dart'; | |
| import 'devfs.dart'; | |
| import 'device.dart'; | |
| import 'device_port_forwarder.dart'; | |
| import 'globals.dart' as globals; | |
| import 'macos/macos_device.dart'; | |
| import 'protocol_discovery.dart'; | |
| import 'vmservice.dart'; | |
| /// A partial implementation of Device for desktop-class devices to inherit | |
| /// from, containing implementations that are common to all desktop devices. | |
| abstract class DesktopDevice extends Device { | |
| DesktopDevice( | |
| super.id, { | |
| required PlatformType super.platformType, | |
| required super.ephemeral, | |
| required super.logger, | |
| required this._processManager, | |
| required this._fileSystem, | |
| required this._operatingSystemUtils, | |
| }) : _logger = logger, | |
| super(category: Category.desktop); | |
| final Logger _logger; | |
| final ProcessManager _processManager; | |
| final FileSystem _fileSystem; | |
| final OperatingSystemUtils _operatingSystemUtils; | |
| final _runningProcesses = <Process>{}; | |
| final _deviceLogReader = DesktopLogReader(); | |
| @override | |
| DevFSWriter createDevFSWriter(ApplicationPackage? app, String? userIdentifier) { | |
| return LocalDevFSWriter(fileSystem: _fileSystem); | |
| } | |
| // Since the host and target devices are the same, no work needs to be done | |
| // to install the application. | |
| @override | |
| Future<bool> isAppInstalled(ApplicationPackage app, {String? userIdentifier}) async => true; | |
| // Since the host and target devices are the same, no work needs to be done | |
| // to install the application. | |
| @override | |
| Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => true; | |
| // Since the host and target devices are the same, no work needs to be done | |
| // to install the application. | |
| @override | |
| Future<bool> installApp(ApplicationPackage app, {String? userIdentifier}) async => true; | |
| // Since the host and target devices are the same, no work needs to be done | |
| // to uninstall the application. | |
| @override | |
| Future<bool> uninstallApp(ApplicationPackage app, {String? userIdentifier}) async => true; | |
| @override | |
| Future<bool> get isLocalEmulator async => false; | |
| @override | |
| Future<String?> get emulatorId async => null; | |
| @override | |
| DevicePortForwarder get portForwarder => const NoOpDevicePortForwarder(); | |
| @override | |
| Future<String> get sdkNameAndVersion async => _operatingSystemUtils.name; | |
| @override | |
| bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease; | |
| @override | |
| DeviceLogReader getLogReader({ApplicationPackage? app, bool includePastLogs = false}) { | |
| assert(!includePastLogs, 'Past log reading not supported on desktop.'); | |
| return _deviceLogReader; | |
| } | |
| @override | |
| void clearLogs() {} | |
| @override | |
| Future<LaunchResult> startApp( | |
| ApplicationPackage package, { | |
| String? mainPath, | |
| String? route, | |
| required DebuggingOptions debuggingOptions, | |
| Map<String, dynamic> platformArgs = const <String, dynamic>{}, | |
| bool prebuiltApplication = false, | |
| String? userIdentifier, | |
| }) async { | |
| if (!prebuiltApplication) { | |
| await buildForDevice( | |
| buildInfo: debuggingOptions.buildInfo, | |
| mainPath: mainPath, | |
| usingCISystem: debuggingOptions.usingCISystem, | |
| ); | |
| } | |
| // Ensure that the executable is locatable. | |
| final BuildInfo buildInfo = debuggingOptions.buildInfo; | |
| final bool traceStartup = platformArgs['trace-startup'] as bool? ?? false; | |
| final String? executable = executablePathForDevice(package, buildInfo); | |
| if (executable == null) { | |
| _logger.printError('Unable to find executable to run'); | |
| return LaunchResult.failed(); | |
| } | |
| Process process; | |
| final command = <String>[executable, ...debuggingOptions.dartEntrypointArgs]; | |
| try { | |
| process = await _processManager.start( | |
| command, | |
| environment: _computeEnvironment(debuggingOptions, traceStartup, route), | |
| ); | |
| } on ProcessException catch (e) { | |
| _logger.printError('Unable to start executable "${command.join(' ')}": $e'); | |
| rethrow; | |
| } | |
| _runningProcesses.add(process); | |
| unawaited(process.exitCode.then((_) => _runningProcesses.remove(process))); | |
| _deviceLogReader.listenToProcessOutput(process); | |
| if (debuggingOptions.buildInfo.isRelease) { | |
| return LaunchResult.succeeded(); | |
| } | |
| final vmServiceDiscovery = ProtocolDiscovery.vmService( | |
| SingleLaunchLogReader(_deviceLogReader.logLines, process.exitCode), | |
| devicePort: debuggingOptions.deviceVmServicePort, | |
| hostPort: debuggingOptions.hostVmServicePort, | |
| ipv6: debuggingOptions.ipv6, | |
| logger: _logger, | |
| ); | |
| try { | |
| Timer? timer; | |
| if (this is MacOSDevice) { | |
| if (await globals.isRunningOnBot) { | |
| const defaultTimeout = 5; | |
| timer = Timer(const Duration(minutes: defaultTimeout), () { | |
| // As of macOS 14, if sandboxing is enabled and the app is not codesigned, | |
| // a dialog will prompt the user to allow the app to run. This will | |
| // cause tests in CI to hang. In CI, we workaround this by setting | |
| // the CODE_SIGN_ENTITLEMENTS build setting to a version with | |
| // sandboxing disabled. | |
| final String sandboxingMessage; | |
| if (debuggingOptions.usingCISystem) { | |
| sandboxingMessage = | |
| 'Ensure sandboxing is disabled by checking ' | |
| 'the set CODE_SIGN_ENTITLEMENTS.'; | |
| } else { | |
| sandboxingMessage = | |
| 'Consider codesigning your app or disabling ' | |
| 'sandboxing. Flutter will attempt to disable sandboxing if ' | |
| 'the `--ci` flag is provided.'; | |
| } | |
| _logger.printError( | |
| 'The Dart VM Service was not discovered after $defaultTimeout ' | |
| 'minutes. If the app has sandboxing enabled and is not ' | |
| 'codesigned or codesigning changed, this may be caused by a ' | |
| 'system prompt asking for access. $sandboxingMessage\n' | |
| 'See https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox ' | |
| 'for more information.', | |
| ); | |
| }); | |
| } | |
| } | |
| final Uri? vmServiceUri = await vmServiceDiscovery.uri; | |
| if (vmServiceUri != null) { | |
| timer?.cancel(); | |
| onAttached(package, buildInfo, process); | |
| return LaunchResult.succeeded(vmServiceUri: vmServiceUri); | |
| } | |
| _logger.printError( | |
| 'Error waiting for a debug connection: ' | |
| 'The log reader stopped unexpectedly, or never started.', | |
| ); | |
| } on Exception catch (error) { | |
| _logger.printError('Error waiting for a debug connection: $error'); | |
| } finally { | |
| await vmServiceDiscovery.cancel(); | |
| } | |
| return LaunchResult.failed(); | |
| } | |
| @override | |
| Future<bool> stopApp(ApplicationPackage? app, {String? userIdentifier}) async { | |
| var succeeded = true; | |
| // Walk a copy of _runningProcesses, since the exit handler removes from the | |
| // set. | |
| for (final process in Set<Process>.of(_runningProcesses)) { | |
| succeeded &= _processManager.killPid(process.pid); | |
| } | |
| return succeeded; | |
| } | |
| @override | |
| Future<void> dispose() async { | |
| await portForwarder.dispose(); | |
| } | |
| /// Builds the current project for this device, with the given options. | |
| Future<void> buildForDevice({ | |
| required BuildInfo buildInfo, | |
| String? mainPath, | |
| bool usingCISystem = false, | |
| }); | |
| /// Returns the path to the executable to run for [package] on this device for | |
| /// the given [BuildInfo.mode]. | |
| String? executablePathForDevice(ApplicationPackage package, BuildInfo buildInfo); | |
| /// Called after a process is attached, allowing any device-specific extra | |
| /// steps to be run. | |
| void onAttached(ApplicationPackage package, BuildInfo buildInfo, Process process) {} | |
| /// Computes a set of environment variables used to pass debugging information | |
| /// to the engine without interfering with application level command line | |
| /// arguments. | |
| /// | |
| /// The format of the environment variables is: | |
| /// * `FLUTTER_ENGINE_SWITCHES` to the number of switches. | |
| /// * `FLUTTER_ENGINE_SWITCH_<N>` (indexing from 1) to the individual switches. | |
| Map<String, String> _computeEnvironment( | |
| DebuggingOptions debuggingOptions, | |
| bool traceStartup, | |
| String? route, | |
| ) { | |
| var flags = 0; | |
| final environment = <String, String>{}; | |
| void addFlag(String value) { | |
| flags += 1; | |
| environment['FLUTTER_ENGINE_SWITCH_$flags'] = value; | |
| } | |
| void finish() { | |
| environment['FLUTTER_ENGINE_SWITCHES'] = flags.toString(); | |
| } | |
| addFlag('enable-dart-profiling=true'); | |
| if (debuggingOptions.profileStartup) { | |
| addFlag('profile-startup=true'); | |
| } | |
| if (traceStartup) { | |
| addFlag('trace-startup=true'); | |
| } | |
| if (route != null) { | |
| addFlag('route=$route'); | |
| } | |
| if (debuggingOptions.enableSoftwareRendering) { | |
| addFlag('enable-software-rendering=true'); | |
| } | |
| if (debuggingOptions.skiaDeterministicRendering) { | |
| addFlag('skia-deterministic-rendering=true'); | |
| } | |
| if (debuggingOptions.traceSkia) { | |
| addFlag('trace-skia=true'); | |
| } | |
| if (debuggingOptions.traceAllowlist != null) { | |
| addFlag('trace-allowlist=${debuggingOptions.traceAllowlist}'); | |
| } | |
| if (debuggingOptions.traceSkiaAllowlist != null) { | |
| addFlag('trace-skia-allowlist=${debuggingOptions.traceSkiaAllowlist}'); | |
| } | |
| if (debuggingOptions.traceSystrace) { | |
| addFlag('trace-systrace=true'); | |
| } | |
| if (debuggingOptions.traceToFile != null) { | |
| addFlag('trace-to-file=${debuggingOptions.traceToFile}'); | |
| } | |
| if (debuggingOptions.endlessTraceBuffer) { | |
| addFlag('endless-trace-buffer=true'); | |
| } | |
| if (debuggingOptions.profileMicrotasks) { | |
| addFlag('profile-microtasks=true'); | |
| } | |
| if (debuggingOptions.purgePersistentCache) { | |
| addFlag('purge-persistent-cache=true'); | |
| } | |
| switch (debuggingOptions.enableImpeller) { | |
| case ImpellerStatus.enabled: | |
| addFlag('enable-impeller=true'); | |
| case ImpellerStatus.disabled: | |
| addFlag('enable-impeller=false'); | |
| case ImpellerStatus.platformDefault: | |
| break; | |
| } | |
| if (debuggingOptions.enableFlutterGpu) { | |
| addFlag('enable-flutter-gpu=true'); | |
| } | |
| // Options only supported when there is a VM Service connection between the | |
| // tool and the device, usually in debug or profile mode. | |
| if (debuggingOptions.debuggingEnabled) { | |
| if (debuggingOptions.deviceVmServicePort != null) { | |
| addFlag('vm-service-port=${debuggingOptions.deviceVmServicePort}'); | |
| } | |
| if (debuggingOptions.buildInfo.isDebug) { | |
| addFlag('enable-checked-mode=true'); | |
| addFlag('verify-entry-points=true'); | |
| } | |
| if (debuggingOptions.startPaused) { | |
| addFlag('start-paused=true'); | |
| } | |
| if (debuggingOptions.disableServiceAuthCodes) { | |
| addFlag('disable-service-auth-codes=true'); | |
| } | |
| if (debuggingOptions.disableServiceOriginCheck) { | |
| addFlag('disable-service-origin-check=true'); | |
| } | |
| final String dartVmFlags = debuggingOptions.dartFlags; | |
| if (dartVmFlags.isNotEmpty) { | |
| addFlag('dart-flags=$dartVmFlags'); | |
| } | |
| if (debuggingOptions.useTestFonts) { | |
| addFlag('use-test-fonts=true'); | |
| } | |
| if (debuggingOptions.verboseSystemLogs) { | |
| addFlag('verbose-logging=true'); | |
| } | |
| } | |
| finish(); | |
| return environment; | |
| } | |
| } | |
| /// A log reader for desktop applications that delegates to a [Process] stdout | |
| /// and stderr streams. | |
| /// | |
| /// A single instance of this reader is kept for the lifetime of a | |
| /// [DesktopDevice], returned by [DesktopDevice.getLogReader], so that | |
| /// external callers (e.g. `flutter drive`, `flutter logs`) can subscribe to | |
| /// [logLines] once and keep receiving output across multiple `startApp` | |
| /// launches on the same device. Because of that, [logLines] is never closed | |
| /// when a given `process` exits: `Device.dispose()` (and therefore | |
| /// [dispose]) is called far more often than "the device is really done" — | |
| /// e.g. once per test file for desktop integration tests — so this reader | |
| /// deliberately has nothing for [dispose] to do, exactly like the upstream | |
| /// implementation this is based on. (Mirrors | |
| /// `CustomDeviceLogReader.listenToProcessOutput`, though that reader's | |
| /// [dispose] closes it — its callers dispose it at true end-of-life only.) | |
| /// | |
| /// This reader is intentionally *not* used for a single launch's VM Service | |
| /// discovery — see [SingleLaunchLogReader] for that. | |
| class DesktopLogReader extends DeviceLogReader { | |
| final _inputController = StreamController<List<int>>.broadcast(); | |
| /// Adds the stdout and stderr streams of the provided [process] to [logLines]. | |
| void listenToProcessOutput(Process process) { | |
| process.stdout.listen(_inputController.add, onError: _inputController.addError); | |
| process.stderr.listen(_inputController.add, onError: _inputController.addError); | |
| } | |
| @override | |
| Stream<String> get logLines { | |
| return _inputController.stream.transform(utf8LineDecoder); | |
| } | |
| @override | |
| String get name => 'desktop'; | |
| @override | |
| void dispose() { | |
| // Nothing to dispose. | |
| } | |
| @override | |
| Future<void> provideVmService(FlutterVmService connectedVmService) async {} | |
| } | |
| /// A [DeviceLogReader] that mirrors `source` but closes [logLines] as soon | |
| /// as `scope` completes. | |
| /// | |
| /// [ProtocolDiscovery] relies on [logLines] reaching "done" to detect that a | |
| /// launched process exited without ever exposing a VM Service, so it can | |
| /// give up instead of waiting forever. The device-scoped [DesktopLogReader] | |
| /// returned by `getLogReader()` can't provide that signal — it must survive | |
| /// across relaunches — so a fresh, throwaway [SingleLaunchLogReader] is | |
| /// created for each [DesktopDevice.startApp] call instead, scoped to that | |
| /// single process via `scope` (typically `process.exitCode`). This mirrors | |
| /// how `AndroidDevice.startApp` avoids reusing its cached `getLogReader()` | |
| /// singleton for the same reason, constructing a fresh `AdbLogReader` for VM | |
| /// Service discovery on each launch. | |
| class SingleLaunchLogReader extends DeviceLogReader { | |
| SingleLaunchLogReader(Stream<String> source, Future<void> scope) { | |
| _subscription = source.listen(_controller.add, onError: _controller.addError); | |
| // Ignore how `scope` completed — only that it did — so an error from it | |
| // (e.g. an unexpected failure reading `process.exitCode`) can't escape | |
| // as an unhandled Future error. | |
| scope.then<void>((_) {}, onError: (Object _, StackTrace _) {}).whenComplete(() { | |
| unawaited(_subscription.cancel()); | |
| unawaited(_controller.close()); | |
| }); | |
| } | |
| final _controller = StreamController<String>.broadcast(); | |
| late final StreamSubscription<String> _subscription; | |
| @override | |
| Stream<String> get logLines => _controller.stream; | |
| @override | |
| String get name => 'desktop (single launch)'; | |
| @override | |
| void dispose() { | |
| unawaited(_subscription.cancel()); | |
| unawaited(_controller.close()); | |
| } | |
| @override | |
| Future<void> provideVmService(FlutterVmService connectedVmService) async {} | |
| } |