| // 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:async/async.dart'; |
| import 'package:meta/meta.dart'; |
| import 'package:uuid/uuid.dart'; |
| |
| import '../android/android_workflow.dart'; |
| import '../application_package.dart'; |
| import '../base/common.dart'; |
| import '../base/file_system.dart'; |
| import '../base/io.dart'; |
| import '../base/logger.dart'; |
| import '../base/terminal.dart'; |
| import '../base/utils.dart'; |
| import '../build_info.dart'; |
| import '../convert.dart'; |
| import '../daemon.dart'; |
| import '../device.dart'; |
| import '../device_port_forwarder.dart'; |
| import '../device_vm_service_discovery_for_attach.dart'; |
| import '../emulator.dart'; |
| import '../features.dart'; |
| import '../globals.dart' as globals; |
| import '../project.dart'; |
| import '../proxied_devices/debounce_data_stream.dart'; |
| import '../proxied_devices/file_transfer.dart'; |
| import '../resident_runner.dart'; |
| import '../run_cold.dart'; |
| import '../run_hot.dart'; |
| import '../runner/flutter_command.dart'; |
| import '../vmservice.dart'; |
| import '../web/web_runner.dart'; |
| |
| const protocolVersion = '0.6.1'; |
| |
| /// A server process command. This command will start up a long-lived server. |
| /// It reads JSON-RPC based commands from stdin, executes them, and returns |
| /// JSON-RPC based responses and events to stdout. |
| /// |
| /// It can be shutdown with a `daemon.shutdown` command (or by killing the |
| /// process). |
| class DaemonCommand extends FlutterCommand { |
| DaemonCommand({this.hidden = false}) { |
| argParser.addOption( |
| 'listen-on-tcp-port', |
| help: |
| 'If specified, the daemon will be listening for commands on the specified port instead of stdio.', |
| valueHelp: 'port', |
| ); |
| } |
| |
| @override |
| final name = 'daemon'; |
| |
| @override |
| final description = 'Run a persistent, JSON-RPC based server to communicate with devices.'; |
| |
| @override |
| final String category = FlutterCommandCategory.tools; |
| |
| @override |
| final bool hidden; |
| |
| @override |
| Future<FlutterCommandResult> runCommand() async { |
| if (argResults!['listen-on-tcp-port'] != null) { |
| int? port; |
| try { |
| port = int.parse(stringArg('listen-on-tcp-port')!); |
| } on FormatException catch (error) { |
| throwToolExit('Invalid port for `--listen-on-tcp-port`: $error'); |
| } |
| |
| await DaemonServer( |
| port: port, |
| logger: StdoutLogger( |
| terminal: globals.terminal, |
| stdio: globals.stdio, |
| outputPreferences: globals.outputPreferences, |
| ), |
| notifyingLogger: asLogger<NotifyingLogger>(globals.logger), |
| ).run(); |
| return FlutterCommandResult.success(); |
| } |
| final daemon = Daemon( |
| DaemonConnection( |
| daemonStreams: DaemonStreams.fromStdio(globals.stdio, logger: globals.logger), |
| logger: globals.logger, |
| ), |
| notifyingLogger: asLogger<NotifyingLogger>(globals.logger), |
| ); |
| globals.printStatus('Device daemon started.'); |
| final int code = await daemon.onExit; |
| if (code != 0) { |
| throwToolExit('Daemon exited with non-zero exit code: $code', exitCode: code); |
| } |
| return FlutterCommandResult.success(); |
| } |
| } |
| |
| @visibleForTesting |
| class DaemonServer { |
| DaemonServer({ |
| this.port, |
| required this.logger, |
| this.notifyingLogger, |
| @visibleForTesting |
| Future<ServerSocket> Function(InternetAddress address, int port) bind = ServerSocket.bind, |
| }) : _bind = bind; |
| |
| final int? port; |
| |
| /// Stdout logger used to print general server-related errors. |
| final Logger logger; |
| |
| // Logger that sends the message to the other end of daemon connection. |
| final NotifyingLogger? notifyingLogger; |
| |
| final Future<ServerSocket> Function(InternetAddress address, int port) _bind; |
| |
| Future<void> run() async { |
| ServerSocket? serverSocket; |
| try { |
| serverSocket = await _bind(InternetAddress.loopbackIPv4, port!); |
| } on SocketException { |
| logger.printTrace('Bind on $port failed with IPv4, retrying on IPv6'); |
| } |
| |
| // If binding on IPv4 failed, try binding on IPv6. |
| // Omit try catch here, let the failure fallthrough. |
| serverSocket ??= await _bind(InternetAddress.loopbackIPv6, port!); |
| |
| logger.printStatus('Daemon server listening on ${serverSocket.port}'); |
| |
| final StreamSubscription<Socket> subscription = serverSocket.listen((Socket socket) async { |
| // We have to listen to socket.done. Otherwise when the connection is |
| // reset, we will receive an uncatchable exception. |
| // https://github.com/dart-lang/sdk/issues/25518 |
| final Future<void> socketDone = socket.done.then<void>( |
| (_) {}, |
| onError: (Object error, StackTrace stackTrace) { |
| logger.printError('Socket error: $error'); |
| logger.printTrace('$stackTrace'); |
| }, |
| ); |
| final daemon = Daemon( |
| DaemonConnection( |
| daemonStreams: DaemonStreams.fromSocket(socket, logger: logger), |
| logger: logger, |
| ), |
| notifyingLogger: notifyingLogger, |
| ); |
| await daemon.onExit; |
| await socketDone; |
| }); |
| |
| // Wait indefinitely until the server closes. |
| await subscription.asFuture<void>(); |
| await subscription.cancel(); |
| } |
| } |
| |
| typedef CommandHandler = Future<Object?>? Function(Map<String, Object?> args); |
| typedef CommandHandlerWithBinary = |
| Future<Object?> Function(Map<String, Object?> args, Stream<List<int>>? binary); |
| |
| class Daemon { |
| Daemon( |
| this.connection, { |
| this.notifyingLogger, |
| this.logToStdout = false, |
| FileTransfer fileTransfer = const FileTransfer(), |
| }) { |
| // Set up domains. |
| registerDomain(daemonDomain = DaemonDomain(this)); |
| registerDomain(appDomain = AppDomain(this)); |
| registerDomain(deviceDomain = DeviceDomain(this)); |
| registerDomain(emulatorDomain = EmulatorDomain(this)); |
| registerDomain(devToolsDomain = DevToolsDomain(this)); |
| registerDomain(proxyDomain = ProxyDomain(this, fileTransfer: fileTransfer)); |
| |
| // Start listening. |
| _commandSubscription = connection.incomingCommands.listen( |
| _handleRequest, |
| onDone: () { |
| shutdown(); |
| if (!_onExitCompleter.isCompleted) { |
| _onExitCompleter.complete(0); |
| } |
| }, |
| ); |
| } |
| |
| factory Daemon.createMachineDaemon() { |
| final daemon = Daemon( |
| DaemonConnection( |
| daemonStreams: DaemonStreams.fromStdio(globals.stdio, logger: globals.logger), |
| logger: globals.logger, |
| ), |
| notifyingLogger: (globals.logger is NotifyingLogger) |
| ? globals.logger as NotifyingLogger |
| : NotifyingLogger(verbose: globals.logger.isVerbose, parent: globals.logger), |
| logToStdout: true, |
| ); |
| return daemon; |
| } |
| |
| final DaemonConnection connection; |
| |
| late DaemonDomain daemonDomain; |
| late AppDomain appDomain; |
| late DeviceDomain deviceDomain; |
| EmulatorDomain? emulatorDomain; |
| DevToolsDomain? devToolsDomain; |
| late ProxyDomain proxyDomain; |
| StreamSubscription<DaemonMessage>? _commandSubscription; |
| |
| final NotifyingLogger? notifyingLogger; |
| final bool logToStdout; |
| |
| final _onExitCompleter = Completer<int>(); |
| final _domainMap = <String, Domain>{}; |
| |
| @visibleForTesting |
| void registerDomain(Domain domain) { |
| _domainMap[domain.name] = domain; |
| } |
| |
| Future<int> get onExit => _onExitCompleter.future; |
| |
| void _handleRequest(DaemonMessage request) { |
| // {id, method, params} |
| |
| // [id] is an opaque type to us. |
| final Object? id = request.data['id']; |
| |
| if (id == null) { |
| globals.stdio.stderrWrite('no id for request: $request\n'); |
| return; |
| } |
| |
| try { |
| final method = request.data['method']! as String; |
| if (!method.contains('.')) { |
| throw DaemonException('method not understood: $method'); |
| } |
| |
| final String prefix = method.substring(0, method.indexOf('.')); |
| final String name = method.substring(method.indexOf('.') + 1); |
| if (_domainMap[prefix] == null) { |
| throw DaemonException('no domain for method: $method'); |
| } |
| |
| _domainMap[prefix]!.handleCommand( |
| name, |
| id, |
| castStringKeyedMap(request.data['params']) ?? const <String, Object?>{}, |
| request.binary, |
| ); |
| } on Exception catch (error, trace) { |
| connection.sendErrorResponse(id, _toJsonable(error), trace); |
| } |
| } |
| |
| Future<void> shutdown({Object? error}) async { |
| await devToolsDomain?.dispose(); |
| await _commandSubscription?.cancel(); |
| await connection.dispose(); |
| for (final Domain domain in _domainMap.values) { |
| await domain.dispose(); |
| } |
| if (!_onExitCompleter.isCompleted) { |
| if (error == null) { |
| _onExitCompleter.complete(0); |
| } else { |
| _onExitCompleter.completeError(error); |
| } |
| } |
| } |
| } |
| |
| abstract class Domain { |
| Domain(this.daemon, this.name); |
| |
| final Daemon daemon; |
| final String name; |
| final _handlers = <String, CommandHandler>{}; |
| final _handlersWithBinary = <String, CommandHandlerWithBinary>{}; |
| |
| void registerHandler(String name, CommandHandler handler) { |
| assert(!_handlers.containsKey(name)); |
| assert(!_handlersWithBinary.containsKey(name)); |
| _handlers[name] = handler; |
| } |
| |
| void registerHandlerWithBinary(String name, CommandHandlerWithBinary handler) { |
| assert(!_handlers.containsKey(name)); |
| assert(!_handlersWithBinary.containsKey(name)); |
| _handlersWithBinary[name] = handler; |
| } |
| |
| @override |
| String toString() => name; |
| |
| void handleCommand( |
| String command, |
| Object id, |
| Map<String, Object?> args, |
| Stream<List<int>>? binary, |
| ) { |
| Future<Object?>.sync(() { |
| if (_handlers.containsKey(command)) { |
| return _handlers[command]!(args); |
| } else if (_handlersWithBinary.containsKey(command)) { |
| return _handlersWithBinary[command]!(args, binary); |
| } |
| throw DaemonException('command not understood: $name.$command'); |
| }).then<Object?>( |
| (Object? result) { |
| daemon.connection.sendResponse(id, _toJsonable(result)); |
| return null; |
| }, |
| onError: (Object error, StackTrace stackTrace) { |
| daemon.connection.sendErrorResponse(id, _toJsonable(error), stackTrace); |
| return null; |
| }, |
| ); |
| } |
| |
| void sendEvent(String name, [Object? args, List<int>? binary]) { |
| daemon.connection.sendEvent(name, _toJsonable(args), binary); |
| } |
| |
| String? _getStringArg(Map<String, Object?> args, String name, {bool required = false}) { |
| if (required && !args.containsKey(name)) { |
| throw DaemonException('$name is required'); |
| } |
| final Object? val = args[name]; |
| if (val != null && val is! String) { |
| throw DaemonException('$name is not a String'); |
| } |
| return val as String?; |
| } |
| |
| bool? _getBoolArg(Map<String, Object?> args, String name, {bool required = false}) { |
| if (required && !args.containsKey(name)) { |
| throw DaemonException('$name is required'); |
| } |
| final Object? val = args[name]; |
| if (val != null && val is! bool) { |
| throw DaemonException('$name is not a bool'); |
| } |
| return val as bool?; |
| } |
| |
| int? _getIntArg(Map<String, Object?> args, String name, {bool required = false}) { |
| if (required && !args.containsKey(name)) { |
| throw DaemonException('$name is required'); |
| } |
| final Object? val = args[name]; |
| if (val != null && val is! int) { |
| throw DaemonException('$name is not an int'); |
| } |
| return val as int?; |
| } |
| |
| Future<void> dispose() async {} |
| } |
| |
| /// This domain responds to methods like [version] and [shutdown]. |
| /// |
| /// This domain fires the `daemon.logMessage` event. |
| class DaemonDomain extends Domain { |
| DaemonDomain(Daemon daemon) : super(daemon, 'daemon') { |
| registerHandler('version', version); |
| registerHandler('shutdown', shutdown); |
| registerHandler('getSupportedPlatforms', getSupportedPlatforms); |
| registerHandler('setNotifyVerbose', setNotifyVerbose); |
| |
| sendEvent('daemon.connected', <String, Object?>{'version': protocolVersion, 'pid': pid}); |
| |
| _subscription = daemon.notifyingLogger!.onMessage.listen((LogMessage message) { |
| if (daemon.logToStdout) { |
| if (message.level == 'status' || message.level == 'trace') { |
| // We use `print()` here instead of `stdout.writeln()` in order to |
| // capture the print output for testing. |
| // ignore: avoid_print |
| print(message.message); |
| } else if (message.level == 'error' || message.level == 'warning') { |
| globals.stdio.stderrWrite('${message.message}\n'); |
| if (message.stackTrace != null) { |
| globals.stdio.stderrWrite('${message.stackTrace.toString().trimRight()}\n'); |
| } |
| } |
| } else { |
| if (message.stackTrace != null) { |
| sendEvent('daemon.logMessage', <String, Object?>{ |
| 'level': message.level, |
| 'message': message.message, |
| 'stackTrace': message.stackTrace.toString(), |
| }); |
| } else { |
| sendEvent('daemon.logMessage', <String, Object?>{ |
| 'level': message.level, |
| 'message': message.message, |
| }); |
| } |
| } |
| }); |
| } |
| |
| StreamSubscription<LogMessage>? _subscription; |
| |
| Future<String> version(Map<String, Object?> args) { |
| return Future<String>.value(protocolVersion); |
| } |
| |
| /// Sends a request back to the client asking it to expose/tunnel a URL. |
| /// |
| /// This method should only be called if the client opted-in with the |
| /// --web-allow-expose-url switch. The client may return the same URL back if |
| /// tunnelling is not required for a given URL. |
| Future<String> exposeUrl(String url) async { |
| final Object? res = await daemon.connection.sendRequest('app.exposeUrl', <String, String>{ |
| 'url': url, |
| }); |
| if (res is Map<String, Object?> && res['url'] is String) { |
| return res['url']! as String; |
| } else { |
| globals.printError( |
| 'Invalid response to exposeUrl - params should include a String url field', |
| ); |
| return url; |
| } |
| } |
| |
| Future<void> shutdown(Map<String, Object?> args) { |
| Timer.run(daemon.shutdown); |
| return Future<void>.value(); |
| } |
| |
| @override |
| Future<void> dispose() async { |
| await _subscription?.cancel(); |
| } |
| |
| /// Enumerates the platforms supported by the provided project. |
| /// |
| /// This does not filter based on the current workflow restrictions, such |
| /// as whether command line tools are installed or whether the host platform |
| /// is correct. |
| Future<Map<String, Object>> getSupportedPlatforms(Map<String, Object?> args) async { |
| final String? projectRoot = _getStringArg(args, 'projectRoot', required: true); |
| final platformTypes = <String>[]; |
| final platformTypesMap = <String, Object>{}; |
| try { |
| final FlutterProject flutterProject = FlutterProject.fromDirectory( |
| globals.fs.directory(projectRoot), |
| ); |
| final Set<SupportedPlatform> supportedPlatforms = flutterProject |
| .getSupportedPlatforms() |
| .toSet(); |
| |
| void handlePlatformType(PlatformType platform) { |
| final reasons = <Map<String, Object>>[]; |
| switch (platform) { |
| case PlatformType.linux: |
| if (!featureFlags.isLinuxEnabled) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Linux feature is not enabled', |
| 'fixText': 'Run "flutter config --enable-linux-desktop"', |
| 'fixCode': _ReasonCode.config.name, |
| }); |
| } |
| if (!supportedPlatforms.contains(SupportedPlatform.linux)) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Linux platform is not enabled for this project', |
| 'fixText': 'Run "flutter create --platforms=linux ." in your application directory', |
| 'fixCode': _ReasonCode.create.name, |
| }); |
| } |
| case PlatformType.macos: |
| if (!featureFlags.isMacOSEnabled) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the macOS feature is not enabled', |
| 'fixText': 'Run "flutter config --enable-macos-desktop"', |
| 'fixCode': _ReasonCode.config.name, |
| }); |
| } |
| if (!supportedPlatforms.contains(SupportedPlatform.macos)) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the macOS platform is not enabled for this project', |
| 'fixText': 'Run "flutter create --platforms=macos ." in your application directory', |
| 'fixCode': _ReasonCode.create.name, |
| }); |
| } |
| case PlatformType.windows: |
| if (!featureFlags.isWindowsEnabled) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Windows feature is not enabled', |
| 'fixText': 'Run "flutter config --enable-windows-desktop"', |
| 'fixCode': _ReasonCode.config.name, |
| }); |
| } |
| if (!supportedPlatforms.contains(SupportedPlatform.windows)) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Windows platform is not enabled for this project', |
| 'fixText': |
| 'Run "flutter create --platforms=windows ." in your application directory', |
| 'fixCode': _ReasonCode.create.name, |
| }); |
| } |
| case PlatformType.ios: |
| if (!featureFlags.isIOSEnabled) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the iOS feature is not enabled', |
| 'fixText': 'Run "flutter config --enable-ios"', |
| 'fixCode': _ReasonCode.config.name, |
| }); |
| } |
| if (!supportedPlatforms.contains(SupportedPlatform.ios)) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the iOS platform is not enabled for this project', |
| 'fixText': 'Run "flutter create --platforms=ios ." in your application directory', |
| 'fixCode': _ReasonCode.create.name, |
| }); |
| } |
| case PlatformType.android: |
| if (!featureFlags.isAndroidEnabled) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Android feature is not enabled', |
| 'fixText': 'Run "flutter config --enable-android"', |
| 'fixCode': _ReasonCode.config.name, |
| }); |
| } |
| if (!supportedPlatforms.contains(SupportedPlatform.android)) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Android platform is not enabled for this project', |
| 'fixText': |
| 'Run "flutter create --platforms=android ." in your application directory', |
| 'fixCode': _ReasonCode.create.name, |
| }); |
| } |
| case PlatformType.web: |
| if (!featureFlags.isWebEnabled) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Web feature is not enabled', |
| 'fixText': 'Run "flutter config --enable-web"', |
| 'fixCode': _ReasonCode.config.name, |
| }); |
| } |
| if (!supportedPlatforms.contains(SupportedPlatform.web)) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Web platform is not enabled for this project', |
| 'fixText': 'Run "flutter create --platforms=web ." in your application directory', |
| 'fixCode': _ReasonCode.create.name, |
| }); |
| } |
| case PlatformType.fuchsia: |
| if (!featureFlags.isFuchsiaEnabled) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Fuchsia feature is not enabled', |
| 'fixText': 'Run "flutter config --enable-fuchsia"', |
| 'fixCode': _ReasonCode.config.name, |
| }); |
| } |
| if (!supportedPlatforms.contains(SupportedPlatform.fuchsia)) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the Fuchsia platform is not enabled for this project', |
| 'fixText': |
| 'Run "flutter create --platforms=fuchsia ." in your application directory', |
| 'fixCode': _ReasonCode.create.name, |
| }); |
| } |
| case PlatformType.custom: |
| if (!featureFlags.areCustomDevicesEnabled) { |
| reasons.add(<String, Object>{ |
| 'reasonText': 'the custom devices feature is not enabled', |
| 'fixText': 'Run "flutter config --enable-custom-devices"', |
| 'fixCode': _ReasonCode.config.name, |
| }); |
| } |
| } |
| |
| if (reasons.isEmpty) { |
| platformTypes.add(platform.name); |
| platformTypesMap[platform.name] = const <String, Object>{'isSupported': true}; |
| } else { |
| platformTypesMap[platform.name] = <String, Object>{ |
| 'isSupported': false, |
| 'reasons': reasons, |
| }; |
| } |
| } |
| |
| PlatformType.values.forEach(handlePlatformType); |
| |
| return <String, Object>{ |
| // TODO(fujino): delete this key https://github.com/flutter/flutter/issues/140473 |
| 'platforms': platformTypes, |
| 'platformTypes': platformTypesMap, |
| }; |
| } on Exception catch (err, stackTrace) { |
| sendEvent('log', <String, Object?>{ |
| 'log': 'Failed to parse project metadata', |
| 'stackTrace': stackTrace.toString(), |
| 'error': true, |
| }); |
| // On any sort of failure, fall back to Android and iOS for backwards |
| // compatibility. |
| return const <String, Object>{ |
| 'platforms': <String>['android', 'ios'], |
| 'platformTypes': <String, Object>{ |
| 'android': <String, Object>{'isSupported': true}, |
| 'ios': <String, Object>{'isSupported': true}, |
| }, |
| }; |
| } |
| } |
| |
| /// If notifyVerbose is set, the daemon will forward all verbose logs. |
| Future<void> setNotifyVerbose(Map<String, Object?> args) async { |
| daemon.notifyingLogger?.notifyVerbose = _getBoolArg(args, 'verbose') ?? true; |
| } |
| } |
| |
| /// The reason a [PlatformType] is not currently supported. |
| /// |
| /// The [name] of this value will be sent as a response to daemon client. |
| enum _ReasonCode { create, config } |
| |
| typedef RunOrAttach = |
| Future<void> Function({ |
| Completer<DebugConnectionInfo>? connectionInfoCompleter, |
| Completer<void>? appStartedCompleter, |
| }); |
| |
| /// This domain responds to methods like [startApp] and [stop]. |
| /// |
| /// It fires events for application start, stop, and stdout and stderr. |
| class AppDomain extends Domain { |
| AppDomain(Daemon daemon) : super(daemon, 'app') { |
| registerHandler('restart', restart); |
| registerHandler('callServiceExtension', callServiceExtension); |
| registerHandler('stop', stop); |
| registerHandler('detach', detach); |
| } |
| |
| static const _uuidGenerator = Uuid(); |
| |
| static String _getNewAppId() => _uuidGenerator.v4(); |
| |
| final _apps = <AppInstance>[]; |
| |
| final operationQueue = DebounceOperationQueue<OperationResult, OperationType>(); |
| |
| Future<AppInstance> startApp( |
| Device device, |
| String projectDirectory, |
| String target, |
| String? route, |
| DebuggingOptions options, |
| bool enableHotReload, { |
| File? applicationBinary, |
| required bool trackWidgetCreation, |
| String? projectRootPath, |
| String? packagesFilePath, |
| String? dillOutputPath, |
| String? isolateFilter, |
| bool machine = true, |
| String? userIdentifier, |
| }) async { |
| if (!await device.supportsRuntimeMode(options.buildInfo.mode)) { |
| throw Exception( |
| '${options.buildInfo.mode.uppercaseFriendlyName} ' |
| 'mode is not supported for ${device.displayName}.', |
| ); |
| } |
| |
| // We change the current working directory for the duration of the `start` command. |
| final Directory cwd = globals.fs.currentDirectory; |
| globals.fs.currentDirectory = globals.fs.directory(projectDirectory); |
| final FlutterProject flutterProject = FlutterProject.current(); |
| |
| final FlutterDevice flutterDevice = await FlutterDevice.create( |
| device, |
| target: target, |
| buildInfo: options.buildInfo, |
| platform: globals.platform, |
| userIdentifier: userIdentifier, |
| ); |
| |
| ResidentRunner runner; |
| |
| if (await device.targetPlatform == TargetPlatform.web_javascript) { |
| runner = webRunnerFactory!.createWebRunner( |
| flutterDevice, |
| flutterProject: flutterProject, |
| target: target, |
| debuggingOptions: options, |
| stayResident: true, |
| urlTunneller: options.webEnableExposeUrl! ? daemon.daemonDomain.exposeUrl : null, |
| machine: machine, |
| analytics: globals.analytics, |
| systemClock: globals.systemClock, |
| logger: globals.logger, |
| terminal: globals.terminal, |
| platform: globals.platform, |
| outputPreferences: globals.outputPreferences, |
| fileSystem: globals.fs, |
| ); |
| } else if (enableHotReload) { |
| runner = HotRunner( |
| <FlutterDevice>[flutterDevice], |
| target: target, |
| debuggingOptions: options, |
| applicationBinary: applicationBinary, |
| projectRootPath: projectRootPath, |
| dillOutputPath: dillOutputPath, |
| hostIsIde: true, |
| machine: machine, |
| analytics: globals.analytics, |
| logger: globals.logger, |
| ); |
| } else { |
| runner = ColdRunner( |
| <FlutterDevice>[flutterDevice], |
| target: target, |
| debuggingOptions: options, |
| applicationBinary: applicationBinary, |
| machine: machine, |
| ); |
| } |
| |
| return launch( |
| runner, |
| ({ |
| Completer<DebugConnectionInfo>? connectionInfoCompleter, |
| Completer<void>? appStartedCompleter, |
| }) { |
| return runner.run( |
| connectionInfoCompleter: connectionInfoCompleter, |
| appStartedCompleter: appStartedCompleter, |
| route: route, |
| ); |
| }, |
| device, |
| projectDirectory, |
| enableHotReload, |
| cwd, |
| LaunchMode.run, |
| asLogger<MachineOutputLogger>(globals.logger), |
| ); |
| } |
| |
| Future<AppInstance> launch( |
| ResidentRunner runner, |
| RunOrAttach runOrAttach, |
| Device device, |
| String? projectDirectory, |
| bool enableHotReload, |
| Directory cwd, |
| LaunchMode launchMode, |
| MachineOutputLogger logger, |
| ) async { |
| final app = AppInstance( |
| _getNewAppId(), |
| runner: runner, |
| logToStdout: daemon.logToStdout, |
| logger: logger, |
| ); |
| _apps.add(app); |
| |
| // Set the domain and app for the given AppRunLogger. This allows the logger |
| // to log messages containing the app ID to the host. |
| logger._domain = this; |
| logger._app = app; |
| |
| _sendAppEvent(app, 'start', <String, Object?>{ |
| 'deviceId': device.id, |
| 'directory': projectDirectory, |
| 'supportsRestart': isRestartSupported(enableHotReload, device), |
| 'launchMode': launchMode.toString(), |
| 'mode': runner.debuggingOptions.buildInfo.modeName, |
| }); |
| |
| Completer<DebugConnectionInfo>? connectionInfoCompleter; |
| |
| if (runner.supportsServiceProtocol && runner.debuggingEnabled) { |
| connectionInfoCompleter = Completer<DebugConnectionInfo>(); |
| // We don't want to wait for this future to complete and callbacks won't fail. |
| // As it just writes to stdout. |
| unawaited( |
| connectionInfoCompleter.future.then<void>((DebugConnectionInfo info) { |
| _sendAppEvent(app, 'debugPort', { |
| // The web vmservice proxy does not have an http address. |
| 'port': info.httpUri?.port ?? info.wsUri!.port, |
| 'wsUri': info.wsUri.toString(), |
| 'baseUri': ?info.baseUri, |
| }); |
| if (info.devToolsUri != null) { |
| _sendAppEvent(app, 'devTools', {'uri': info.devToolsUri!.toString()}); |
| } |
| if (info.dtdUri != null) { |
| _sendAppEvent(app, 'dtd', {'uri': info.dtdUri!.toString()}); |
| } |
| }), |
| ); |
| } |
| final appStartedCompleter = Completer<void>(); |
| |
| // This future won't complete until the application has shutdown, so we don't want to |
| // await it. However, we do need to listen to the future in order to handle possible |
| // tool exits |
| final Future<void> appRunFuture = app._runInZone<void>(this, () async { |
| try { |
| await runOrAttach( |
| connectionInfoCompleter: connectionInfoCompleter, |
| appStartedCompleter: appStartedCompleter, |
| ); |
| _sendAppEvent(app, 'stop'); |
| } on Exception catch (error, trace) { |
| _sendAppEvent(app, 'stop', <String, Object?>{ |
| 'error': _toJsonable(error), |
| 'trace': '$trace', |
| }); |
| } finally { |
| // If the full directory is used instead of the path then this causes |
| // a TypeError with the ErrorHandlingFileSystem. |
| globals.fs.currentDirectory = cwd.path; |
| _apps.remove(app); |
| } |
| }); |
| |
| await Future.any(<Future<void>>[ |
| appStartedCompleter.future.then<void>((void value) { |
| _sendAppEvent(app, 'started'); |
| }), |
| appRunFuture, |
| ]); |
| return app; |
| } |
| |
| bool isRestartSupported(bool enableHotReload, Device device) => |
| enableHotReload && device.supportsHotRestart; |
| |
| final _hotReloadDebounceDurationMs = 50; |
| |
| Future<OperationResult>? restart(Map<String, Object?> args) async { |
| final String? appId = _getStringArg(args, 'appId', required: true); |
| final bool fullRestart = _getBoolArg(args, 'fullRestart') ?? false; |
| final bool pauseAfterRestart = _getBoolArg(args, 'pause') ?? false; |
| final String? restartReason = _getStringArg(args, 'reason'); |
| final bool debounce = _getBoolArg(args, 'debounce') ?? false; |
| // This is an undocumented parameter used for integration tests. |
| final int? debounceDurationOverrideMs = _getIntArg(args, 'debounceDurationOverrideMs'); |
| |
| final AppInstance? app = _getApp(appId); |
| if (app == null) { |
| throw DaemonException("app '$appId' not found"); |
| } |
| |
| return _queueAndDebounceReloadAction( |
| app, |
| fullRestart ? OperationType.restart : OperationType.reload, |
| debounce, |
| debounceDurationOverrideMs, |
| () { |
| return app.restart( |
| fullRestart: fullRestart, |
| pause: pauseAfterRestart, |
| reason: restartReason, |
| ); |
| }, |
| )!; |
| } |
| |
| /// Debounce and queue reload actions. |
| /// |
| /// Only one reload action will run at a time. Actions requested in quick |
| /// succession (within [_hotReloadDebounceDurationMs]) will be merged together |
| /// and all return the same result. If an action is requested after an identical |
| /// action has already started, it will be queued and run again once the first |
| /// action completes. |
| Future<OperationResult>? _queueAndDebounceReloadAction( |
| AppInstance app, |
| OperationType operationType, |
| bool debounce, |
| int? debounceDurationOverrideMs, |
| Future<OperationResult> Function() action, |
| ) { |
| final Duration debounceDuration = debounce |
| ? Duration(milliseconds: debounceDurationOverrideMs ?? _hotReloadDebounceDurationMs) |
| : Duration.zero; |
| |
| return operationQueue.queueAndDebounce( |
| operationType, |
| debounceDuration, |
| () => app._runInZone<OperationResult>(this, action), |
| ); |
| } |
| |
| /// Returns an error, or the service extension result (a map with two fixed |
| /// keys, `type` and `method`). The result may have one or more additional keys, |
| /// depending on the specific service extension end-point. For example: |
| /// |
| /// { |
| /// "value":"android", |
| /// "type":"_extensionType", |
| /// "method":"ext.flutter.platformOverride" |
| /// } |
| Future<Map<String, Object?>> callServiceExtension(Map<String, Object?> args) async { |
| final String? appId = _getStringArg(args, 'appId', required: true); |
| final String methodName = _getStringArg(args, 'methodName')!; |
| final Map<String, Object?>? params = args['params'] == null |
| ? <String, Object?>{} |
| : castStringKeyedMap(args['params']); |
| |
| final AppInstance? app = _getApp(appId); |
| if (app == null) { |
| throw DaemonException("app '$appId' not found"); |
| } |
| final FlutterDevice device = app.runner.flutterDevices.first; |
| final List<FlutterView> views = await device.vmService!.getFlutterViews(); |
| final Map<String, Object?>? result = await device.vmService!.invokeFlutterExtensionRpcRaw( |
| methodName, |
| args: params, |
| isolateId: views.first.uiIsolate!.id, |
| ); |
| if (result == null) { |
| throw DaemonException('method not available: $methodName'); |
| } |
| |
| if (result.containsKey('error')) { |
| // ignore: only_throw_errors |
| throw result['error']!; |
| } |
| |
| return result; |
| } |
| |
| Future<bool> stop(Map<String, Object?> args) async { |
| final String? appId = _getStringArg(args, 'appId', required: true); |
| |
| final AppInstance? app = _getApp(appId); |
| if (app == null) { |
| throw DaemonException("app '$appId' not found"); |
| } |
| |
| return app.stop().then<bool>( |
| (void value) => true, |
| onError: (Object? error, StackTrace stack) { |
| _sendAppEvent(app, 'log', <String, Object?>{'log': '$error', 'error': true}); |
| app.closeLogger(); |
| _apps.remove(app); |
| return false; |
| }, |
| ); |
| } |
| |
| Future<bool> detach(Map<String, Object?> args) async { |
| final String? appId = _getStringArg(args, 'appId', required: true); |
| |
| final AppInstance? app = _getApp(appId); |
| if (app == null) { |
| throw DaemonException("app '$appId' not found"); |
| } |
| |
| return app.detach().then<bool>( |
| (void value) => true, |
| onError: (Object? error, StackTrace stack) { |
| _sendAppEvent(app, 'log', <String, Object?>{'log': '$error', 'error': true}); |
| app.closeLogger(); |
| _apps.remove(app); |
| return false; |
| }, |
| ); |
| } |
| |
| AppInstance? _getApp(String? id) { |
| for (final AppInstance app in _apps) { |
| if (app.id == id) { |
| return app; |
| } |
| } |
| return null; |
| } |
| |
| void _sendAppEvent(AppInstance app, String name, [Map<String, Object?>? args]) { |
| sendEvent('app.$name', <String, Object?>{'appId': app.id, ...?args}); |
| } |
| } |
| |
| typedef _DeviceEventHandler = void Function(Device device); |
| |
| /// This domain lets callers list and monitor connected devices. |
| /// |
| /// It exports a `getDevices()` call, as well as firing `device.added` and |
| /// `device.removed` events. |
| class DeviceDomain extends Domain { |
| DeviceDomain(Daemon daemon) : super(daemon, 'device') { |
| registerHandler('getDevices', getDevices); |
| registerHandler('discoverDevices', discoverDevices); |
| registerHandler('enable', enable); |
| registerHandler('disable', disable); |
| registerHandler('forward', forward); |
| registerHandler('unforward', unforward); |
| registerHandler('supportsRuntimeMode', supportsRuntimeMode); |
| registerHandler('uploadApplicationPackage', uploadApplicationPackage); |
| registerHandler('logReader.start', startLogReader); |
| registerHandler('logReader.stop', stopLogReader); |
| registerHandler('startApp', startApp); |
| registerHandler('stopApp', stopApp); |
| registerHandler('takeScreenshot', takeScreenshot); |
| registerHandler('startDartDevelopmentService', startDartDevelopmentService); |
| registerHandler('shutdownDartDevelopmentService', shutdownDartDevelopmentService); |
| registerHandler('getDiagnostics', getDiagnostics); |
| registerHandler('startVMServiceDiscoveryForAttach', startVMServiceDiscoveryForAttach); |
| registerHandler('stopVMServiceDiscoveryForAttach', stopVMServiceDiscoveryForAttach); |
| |
| // Use the device manager discovery so that client provided device types |
| // are usable via the daemon protocol. |
| globals.deviceManager!.deviceDiscoverers.forEach(addDeviceDiscoverer); |
| } |
| |
| /// An incrementing number used to generate unique ids. |
| var _id = 0; |
| final _applicationPackages = <String, ApplicationPackage?>{}; |
| final _logReaders = <String, DeviceLogReader>{}; |
| |
| void addDeviceDiscoverer(DeviceDiscovery discoverer) { |
| if (!discoverer.supportsPlatform) { |
| return; |
| } |
| |
| if (discoverer is PollingDeviceDiscovery) { |
| _discoverers.add(discoverer); |
| discoverer.onAdded.listen(_onDeviceEvent('device.added')); |
| discoverer.onRemoved.listen(_onDeviceEvent('device.removed')); |
| } |
| } |
| |
| var _serializeDeviceEvents = Future<void>.value(); |
| |
| _DeviceEventHandler _onDeviceEvent(String eventName) { |
| return (Device device) { |
| _serializeDeviceEvents = _serializeDeviceEvents.then<void>((_) async { |
| try { |
| final Map<String, Object?> response = await _deviceToMap(device); |
| sendEvent(eventName, response); |
| } on Exception catch (err) { |
| globals.printError('$err'); |
| } |
| }); |
| }; |
| } |
| |
| final _discoverers = <PollingDeviceDiscovery>[]; |
| |
| /// Return a list of the currently connected devices, with each device |
| /// represented as a map of properties (id, name, platform, ...). |
| Future<List<Map<String, Object?>>> getDevices([Map<String, Object?>? args]) async { |
| return <Map<String, Object?>>[ |
| for (final PollingDeviceDiscovery discoverer in _discoverers) |
| for (final Device device in await discoverer.devices(filter: DeviceDiscoveryFilter())) |
| await _deviceToMap(device), |
| ]; |
| } |
| |
| /// Return a list of the current devices, discarding existing cache of devices. |
| Future<List<Map<String, Object?>>> discoverDevices(Map<String, Object?> args) async { |
| final int? timeoutInMilliseconds = _getIntArg(args, 'timeoutInMilliseconds'); |
| final Duration? timeout = timeoutInMilliseconds != null |
| ? Duration(milliseconds: timeoutInMilliseconds) |
| : null; |
| |
| // Calling `discoverDevices()` and `_deviceToMap()` in parallel for better performance. |
| final List<List<Device>> devicesListList = await Future.wait(<Future<List<Device>>>[ |
| for (final PollingDeviceDiscovery discoverer in _discoverers) |
| discoverer.discoverDevices(timeout: timeout), |
| ]); |
| |
| final devices = <Device>[ |
| for (final List<Device> devicesList in devicesListList) ...devicesList, |
| ]; |
| return Future.wait(<Future<Map<String, Object?>>>[ |
| for (final Device device in devices) _deviceToMap(device), |
| ]); |
| } |
| |
| /// Enable device events. |
| Future<void> enable(Map<String, Object?> args) async { |
| for (final PollingDeviceDiscovery discoverer in _discoverers) { |
| discoverer.startPolling(); |
| } |
| } |
| |
| /// Disable device events. |
| Future<void> disable(Map<String, Object?> args) async { |
| for (final PollingDeviceDiscovery discoverer in _discoverers) { |
| discoverer.stopPolling(); |
| } |
| } |
| |
| /// Forward a host port to a device port. |
| Future<Map<String, Object?>> forward(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final int devicePort = _getIntArg(args, 'devicePort', required: true)!; |
| int? hostPort = _getIntArg(args, 'hostPort'); |
| |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| |
| hostPort = await device.portForwarder!.forward(devicePort, hostPort: hostPort); |
| |
| return <String, Object?>{'hostPort': hostPort}; |
| } |
| |
| /// Removes a forwarded port. |
| Future<void> unforward(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final int devicePort = _getIntArg(args, 'devicePort', required: true)!; |
| final int hostPort = _getIntArg(args, 'hostPort', required: true)!; |
| |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| |
| return device.portForwarder!.unforward(ForwardedPort(hostPort, devicePort)); |
| } |
| |
| /// Returns whether a device supports runtime mode. |
| Future<bool> supportsRuntimeMode(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| final String buildMode = _getStringArg(args, 'buildMode', required: true)!; |
| return await device.supportsRuntimeMode(BuildMode.fromCliName(buildMode)); |
| } |
| |
| /// Creates an application package from a file in the temp directory. |
| Future<String> uploadApplicationPackage(Map<String, Object?> args) async { |
| final TargetPlatform targetPlatform = getTargetPlatformForName( |
| _getStringArg(args, 'targetPlatform', required: true)!, |
| ); |
| final File applicationBinary = daemon.proxyDomain.tempDirectory.childFile( |
| _getStringArg(args, 'applicationBinary', required: true)!, |
| ); |
| final ApplicationPackage? applicationPackage = await ApplicationPackageFactory.instance! |
| .getPackageForPlatform(targetPlatform, applicationBinary: applicationBinary); |
| final id = 'application_package_${_id++}'; |
| _applicationPackages[id] = applicationPackage; |
| return id; |
| } |
| |
| /// Starts the log reader on the device. |
| Future<String> startLogReader(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| final String? applicationPackageId = _getStringArg(args, 'applicationPackageId'); |
| final ApplicationPackage? applicationPackage = applicationPackageId != null |
| ? _applicationPackages[applicationPackageId] |
| : null; |
| final id = '${deviceId}_${_id++}'; |
| |
| final DeviceLogReader logReader = await device.getLogReader(app: applicationPackage); |
| logReader.logLines.listen((String log) => sendEvent('device.logReader.logLines.$id', log)); |
| |
| _logReaders[id] = logReader; |
| |
| return id; |
| } |
| |
| /// Stops a log reader that was previously started. |
| Future<void> stopLogReader(Map<String, Object?> args) async { |
| final String? id = _getStringArg(args, 'id', required: true); |
| _logReaders.remove(id)?.dispose(); |
| } |
| |
| /// Starts an app on a device. |
| Future<Map<String, Object?>> startApp(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| final String? applicationPackageId = _getStringArg( |
| args, |
| 'applicationPackageId', |
| required: true, |
| ); |
| final ApplicationPackage applicationPackage = _applicationPackages[applicationPackageId!]!; |
| |
| final LaunchResult result = await device.startApp( |
| applicationPackage, |
| debuggingOptions: DebuggingOptions.fromJson( |
| castStringKeyedMap(args['debuggingOptions'])!, |
| // We are using prebuilts, build info does not matter here. |
| BuildInfo.dummy, |
| ), |
| mainPath: _getStringArg(args, 'mainPath'), |
| route: _getStringArg(args, 'route'), |
| platformArgs: castStringKeyedMap(args['platformArgs']) ?? const <String, Object>{}, |
| prebuiltApplication: _getBoolArg(args, 'prebuiltApplication') ?? false, |
| userIdentifier: _getStringArg(args, 'userIdentifier'), |
| ); |
| return <String, Object?>{ |
| 'started': result.started, |
| 'vmServiceUri': result.vmServiceUri?.toString(), |
| }; |
| } |
| |
| /// Stops an app. |
| Future<bool> stopApp(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| final String? applicationPackageId = _getStringArg(args, 'applicationPackageId'); |
| ApplicationPackage? applicationPackage; |
| if (applicationPackageId != null) { |
| applicationPackage = _applicationPackages[applicationPackageId]; |
| } |
| return device.stopApp( |
| applicationPackage, |
| userIdentifier: _getStringArg(args, 'userIdentifier'), |
| ); |
| } |
| |
| /// Takes a screenshot. |
| Future<String?> takeScreenshot(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| final tempFileName = 'screenshot_${_id++}'; |
| final File tempFile = daemon.proxyDomain.tempDirectory.childFile(tempFileName); |
| await device.takeScreenshot(tempFile); |
| if (await tempFile.exists()) { |
| final String imageBase64 = base64.encode(await tempFile.readAsBytes()); |
| return imageBase64; |
| } else { |
| return null; |
| } |
| } |
| |
| /// Starts DDS for the device. |
| Future<Map<String, Object?>> startDartDevelopmentService(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final bool? disableServiceAuthCodes = _getBoolArg(args, 'disableServiceAuthCodes'); |
| final String vmServiceUriStr = _getStringArg(args, 'vmServiceUri', required: true)!; |
| final bool enableDevTools = _getBoolArg(args, 'enableDevTools') ?? false; |
| final String? devToolsServerAddressStr = _getStringArg(args, 'devToolsServerAddress'); |
| |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| |
| Uri? devToolsServerAddress; |
| if (devToolsServerAddressStr != null) { |
| devToolsServerAddress = Uri.parse(devToolsServerAddressStr); |
| } |
| |
| await device.dds.startDartDevelopmentService( |
| Uri.parse(vmServiceUriStr), |
| disableServiceAuthCodes: disableServiceAuthCodes, |
| enableDevTools: enableDevTools, |
| devToolsServerAddress: devToolsServerAddress, |
| ); |
| unawaited(device.dds.done.whenComplete(() => sendEvent('device.dds.done.$deviceId'))); |
| return <String, Object?>{ |
| 'ddsUri': device.dds.uri?.toString(), |
| 'devToolsUri': device.dds.devToolsUri?.toString(), |
| 'dtdUri': device.dds.dtdUri?.toString(), |
| }; |
| } |
| |
| /// Starts DDS for the device. |
| Future<void> shutdownDartDevelopmentService(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| |
| device.dds.shutdown(); |
| } |
| |
| @override |
| Future<void> dispose() { |
| for (final PollingDeviceDiscovery discoverer in _discoverers) { |
| discoverer.dispose(); |
| } |
| return Future<void>.value(); |
| } |
| |
| /// Return the connected device matching the deviceId field in the args. |
| Future<Device?> _getDevice(String? deviceId) async { |
| for (final PollingDeviceDiscovery discoverer in _discoverers) { |
| final List<Device> devices = await discoverer.devices(filter: DeviceDiscoveryFilter()); |
| Device? device; |
| for (final localDevice in devices) { |
| if (localDevice.id == deviceId) { |
| device = localDevice; |
| } |
| } |
| if (device != null) { |
| return device; |
| } |
| } |
| return null; |
| } |
| |
| /// Gets a list of diagnostic messages pertaining to issues with any connected |
| /// devices. |
| Future<List<String>> getDiagnostics(Map<String, Object?> args) async { |
| // Call `getDiagnostics()` in parallel to improve performance. |
| final List<List<String>> diagnosticsLists = await Future.wait(<Future<List<String>>>[ |
| for (final PollingDeviceDiscovery discoverer in _discoverers) discoverer.getDiagnostics(), |
| ]); |
| |
| return <String>[for (final List<String> diagnostics in diagnosticsLists) ...diagnostics]; |
| } |
| |
| final _vmServiceDiscoverySubscriptions = <String, StreamSubscription<Uri>>{}; |
| |
| Future<String> startVMServiceDiscoveryForAttach(Map<String, Object?> args) async { |
| final String? deviceId = _getStringArg(args, 'deviceId', required: true); |
| final String? appId = _getStringArg(args, 'appId'); |
| final String? fuchsiaModule = _getStringArg(args, 'fuchsiaModule'); |
| final int? filterDevicePort = _getIntArg(args, 'filterDevicePort'); |
| final bool? ipv6 = _getBoolArg(args, 'ipv6'); |
| |
| final Device? device = await daemon.deviceDomain._getDevice(deviceId); |
| if (device == null) { |
| throw DaemonException("device '$deviceId' not found"); |
| } |
| |
| final id = '${_id++}'; |
| |
| final VMServiceDiscoveryForAttach discovery = device.getVMServiceDiscoveryForAttach( |
| appId: appId, |
| fuchsiaModule: fuchsiaModule, |
| filterDevicePort: filterDevicePort, |
| ipv6: ipv6 ?? false, |
| logger: globals.logger, |
| ); |
| _vmServiceDiscoverySubscriptions[id] = discovery.uris.listen( |
| (Uri uri) => sendEvent('device.VMServiceDiscoveryForAttach.$id', uri.toString()), |
| ); |
| |
| return id; |
| } |
| |
| Future<void> stopVMServiceDiscoveryForAttach(Map<String, Object?> args) async { |
| final String? id = _getStringArg(args, 'id', required: true); |
| await _vmServiceDiscoverySubscriptions.remove(id)?.cancel(); |
| } |
| } |
| |
| class DevToolsDomain extends Domain { |
| DevToolsDomain(Daemon daemon) : super(daemon, 'devtools') { |
| registerHandler('serve', serve); |
| } |
| |
| DevtoolsLauncher? _devtoolsLauncher; |
| |
| Future<Map<String, Object?>> serve([Map<String, Object?>? args]) async { |
| _devtoolsLauncher ??= DevtoolsLauncher.instance; |
| final DevToolsServerAddress? server = await _devtoolsLauncher?.serve(); |
| return <String, Object?>{'host': server?.host, 'port': server?.port}; |
| } |
| |
| @override |
| Future<void> dispose() async { |
| await _devtoolsLauncher?.close(); |
| } |
| } |
| |
| Future<Map<String, Object?>> _deviceToMap(Device device) async { |
| return <String, Object?>{ |
| 'id': device.id, |
| 'name': device.displayName, |
| 'platform': getNameForTargetPlatform(await device.targetPlatform), |
| 'emulator': await device.isLocalEmulator, |
| 'category': device.category?.toString(), |
| 'platformType': device.platformType?.toString(), |
| 'ephemeral': device.ephemeral, |
| 'emulatorId': await device.emulatorId, |
| 'sdk': await device.sdkNameAndVersion, |
| 'isConnected': device.isConnected, |
| 'connectionInterface': getNameForDeviceConnectionInterface(device.connectionInterface), |
| 'capabilities': <String, Object>{ |
| 'hotReload': device.supportsHotReload, |
| 'hotRestart': device.supportsHotRestart, |
| 'screenshot': device.supportsScreenshot, |
| // TODO(bkonyi): remove once fg3 is updated. |
| 'fastStart': false, |
| 'flutterExit': device.supportsFlutterExit, |
| 'hardwareRendering': await device.supportsHardwareRendering, |
| 'startPaused': device.supportsStartPaused, |
| }, |
| }; |
| } |
| |
| Map<String, Object?> _emulatorToMap(Emulator emulator) { |
| return <String, Object?>{ |
| 'id': emulator.id, |
| 'name': emulator.name, |
| 'category': emulator.category.toString(), |
| 'platformType': emulator.platformType.toString(), |
| }; |
| } |
| |
| Map<String, Object?> _operationResultToMap(OperationResult result) { |
| return <String, Object?>{'code': result.code, 'message': result.message}; |
| } |
| |
| Object? _toJsonable(Object? obj) { |
| return switch (obj) { |
| String() || int() || bool() || Map<Object?, Object?>() || List<Object?>() || null => obj, |
| OperationResult() => _operationResultToMap(obj), |
| ToolExit() => obj.message, |
| _ => obj.toString(), |
| }; |
| } |
| |
| class NotifyingLogger extends DelegatingLogger { |
| NotifyingLogger({required this.verbose, required Logger parent, this.notifyVerbose = false}) |
| : super(parent) { |
| _messageController = StreamController<LogMessage>.broadcast(onListen: _onListen); |
| } |
| |
| final bool verbose; |
| final messageBuffer = <LogMessage>[]; |
| late StreamController<LogMessage> _messageController; |
| |
| var notifyVerbose = false; |
| |
| void _onListen() { |
| if (messageBuffer.isNotEmpty) { |
| messageBuffer.forEach(_messageController.add); |
| messageBuffer.clear(); |
| } |
| } |
| |
| Stream<LogMessage> get onMessage => _messageController.stream; |
| |
| @override |
| void printError( |
| String message, { |
| StackTrace? stackTrace, |
| bool? emphasis = false, |
| TerminalColor? color, |
| int? indent, |
| int? hangingIndent, |
| bool? wrap, |
| }) { |
| _sendMessage(LogMessage('error', message, stackTrace)); |
| } |
| |
| @override |
| void printWarning( |
| String message, { |
| bool? emphasis = false, |
| TerminalColor? color, |
| int? indent, |
| int? hangingIndent, |
| bool? wrap, |
| bool fatal = true, |
| }) { |
| _sendMessage(LogMessage('warning', message)); |
| } |
| |
| @override |
| void printStatus( |
| String message, { |
| bool? emphasis = false, |
| TerminalColor? color, |
| bool? newline = true, |
| int? indent, |
| int? hangingIndent, |
| bool? wrap, |
| }) { |
| _sendMessage(LogMessage('status', message)); |
| } |
| |
| @override |
| void printBox(String message, {String? title}) { |
| _sendMessage(LogMessage('status', title == null ? message : '$title: $message')); |
| } |
| |
| @override |
| void printTrace(String message) { |
| if (notifyVerbose) { |
| _sendMessage(LogMessage('trace', message)); |
| return; |
| } |
| if (!verbose) { |
| return; |
| } |
| super.printError(message); |
| } |
| |
| @override |
| Status startProgress( |
| String message, { |
| Duration? timeout, |
| String? progressId, |
| bool multilineOutput = false, |
| bool includeTiming = true, |
| int progressIndicatorPadding = kDefaultStatusPadding, |
| }) { |
| assert(timeout != null); |
| printStatus(message); |
| return SilentStatus(stopwatch: Stopwatch()); |
| } |
| |
| void _sendMessage(LogMessage logMessage) { |
| if (_messageController.hasListener) { |
| return _messageController.add(logMessage); |
| } |
| messageBuffer.add(logMessage); |
| } |
| |
| void dispose() { |
| _messageController.close(); |
| } |
| |
| @override |
| void sendEvent(String name, [Map<String, Object?>? args]) {} |
| |
| @override |
| bool get supportsColor => false; |
| |
| @override |
| bool get hasTerminal => false; |
| |
| // This method is only relevant for terminals. |
| @override |
| void clear() {} |
| } |
| |
| /// A running application, started by this daemon. |
| class AppInstance { |
| AppInstance( |
| this.id, { |
| required this.runner, |
| this.logToStdout = false, |
| required MachineOutputLogger logger, |
| }) : _logger = logger; |
| |
| final String id; |
| final ResidentRunner runner; |
| final bool logToStdout; |
| final MachineOutputLogger _logger; |
| |
| Future<OperationResult> restart({bool fullRestart = false, bool pause = false, String? reason}) { |
| return runner.restart(fullRestart: fullRestart, pause: pause, reason: reason); |
| } |
| |
| Future<void> stop() => runner.exit(); |
| Future<void> detach() => runner.detach(); |
| |
| void closeLogger() { |
| _logger.close(); |
| } |
| |
| Future<T> _runInZone<T>(AppDomain domain, FutureOr<T> Function() method) async { |
| return method(); |
| } |
| } |
| |
| /// This domain responds to methods like [getEmulators] and [launch]. |
| class EmulatorDomain extends Domain { |
| EmulatorDomain(Daemon daemon) : super(daemon, 'emulator') { |
| registerHandler('getEmulators', getEmulators); |
| registerHandler('launch', launch); |
| registerHandler('create', create); |
| } |
| |
| var emulators = EmulatorManager( |
| fileSystem: globals.fs, |
| logger: globals.logger, |
| java: globals.java, |
| androidSdk: globals.androidSdk, |
| processManager: globals.processManager, |
| androidWorkflow: androidWorkflow!, |
| ); |
| |
| Future<List<Map<String, Object?>>> getEmulators([Map<String, Object?>? args]) async { |
| final List<Emulator> list = await emulators.getAllAvailableEmulators(); |
| return list.map<Map<String, Object?>>(_emulatorToMap).toList(); |
| } |
| |
| Future<void> launch(Map<String, Object?> args) async { |
| final String emulatorId = _getStringArg(args, 'emulatorId', required: true)!; |
| final bool coldBoot = _getBoolArg(args, 'coldBoot') ?? false; |
| final List<Emulator> matches = await emulators.getEmulatorsMatching(emulatorId); |
| if (matches.isEmpty) { |
| throw DaemonException("emulator '$emulatorId' not found"); |
| } else if (matches.length > 1) { |
| throw DaemonException("multiple emulators match '$emulatorId'"); |
| } else { |
| await matches.first.launch(coldBoot: coldBoot); |
| } |
| } |
| |
| Future<Map<String, Object?>> create(Map<String, Object?> args) async { |
| final String? name = _getStringArg(args, 'name'); |
| final CreateEmulatorResult res = await emulators.createEmulator(name: name); |
| return <String, Object?>{ |
| 'success': res.success, |
| 'emulatorName': res.emulatorName, |
| 'error': res.error, |
| }; |
| } |
| } |
| |
| class ProxyDomain extends Domain { |
| ProxyDomain(Daemon daemon, {required FileTransfer fileTransfer}) |
| : _fileTransfer = fileTransfer, |
| super(daemon, 'proxy') { |
| registerHandlerWithBinary('writeTempFile', writeTempFile); |
| registerHandler('calculateFileHashes', calculateFileHashes); |
| registerHandlerWithBinary('updateFile', updateFile); |
| registerHandler('connect', connect); |
| registerHandler('disconnect', disconnect); |
| registerHandlerWithBinary('write', write); |
| } |
| |
| final FileTransfer _fileTransfer; |
| |
| final _forwardedConnections = <String, Socket>{}; |
| var _id = 0; |
| |
| /// Writes to a file in a local temporary directory. |
| Future<void> writeTempFile(Map<String, Object?> args, Stream<List<int>>? binary) async { |
| final String path = _getStringArg(args, 'path', required: true)!; |
| final File file = tempDirectory.childFile(path); |
| await file.parent.create(recursive: true); |
| await file.openWrite().addStream(binary!); |
| } |
| |
| /// Calculate rolling hashes for a file in the local temporary directory. |
| Future<Map<String, Object?>?> calculateFileHashes(Map<String, Object?> args) async { |
| final String path = _getStringArg(args, 'path', required: true)!; |
| final bool cacheResult = _getBoolArg(args, 'cacheResult') ?? false; |
| final File file = tempDirectory.childFile(path); |
| if (!await file.exists()) { |
| return null; |
| } |
| final File hashFile = file.parent.childFile('${file.basename}.hashes'); |
| if (hashFile.existsSync() && hashFile.statSync().modified.isAfter(file.statSync().modified)) { |
| // If the cached hash file is newer than the file, assume that the cached |
| // is up to date. Return the cached result directly. |
| final String cachedJson = await hashFile.readAsString(); |
| return json.decode(cachedJson) as Map<String, Object?>; |
| } |
| final BlockHashes result = await _fileTransfer.calculateBlockHashesOfFile(file); |
| final Map<String, Object?> resultObject = result.toJson(); |
| |
| if (cacheResult) { |
| await hashFile.writeAsString(json.encode(resultObject)); |
| } |
| |
| return resultObject; |
| } |
| |
| Future<bool?> updateFile(Map<String, Object?> args, Stream<List<int>>? binary) async { |
| final String path = _getStringArg(args, 'path', required: true)!; |
| final File file = tempDirectory.childFile(path); |
| if (!await file.exists()) { |
| return null; |
| } |
| final List<Map<String, Object?>> deltaJson = (args['delta']! as List<Object?>) |
| .cast<Map<String, Object?>>(); |
| final List<FileDeltaBlock> delta = FileDeltaBlock.fromJsonList(deltaJson); |
| final bool result = await _fileTransfer.rebuildFile(file, delta, binary!); |
| return result; |
| } |
| |
| /// Opens a connection to a local port, and returns the connection id. |
| Future<String> connect(Map<String, Object?> args) async { |
| final int targetPort = _getIntArg(args, 'port', required: true)!; |
| final id = 'portForwarder_${targetPort}_${_id++}'; |
| |
| Socket? socket; |
| |
| try { |
| socket = await Socket.connect(InternetAddress.loopbackIPv4, targetPort); |
| } on SocketException { |
| globals.logger.printTrace('Connecting to localhost:$targetPort failed with IPv4'); |
| } |
| |
| try { |
| // If connecting to IPv4 loopback interface fails, try IPv6. |
| socket ??= await Socket.connect(InternetAddress.loopbackIPv6, targetPort); |
| } on SocketException { |
| globals.logger.printError('Connecting to localhost:$targetPort failed'); |
| } |
| |
| if (socket == null) { |
| throw Exception('Failed to connect to the port'); |
| } |
| |
| _forwardedConnections[id] = socket; |
| debounceDataStream(socket).listen( |
| (List<int> data) { |
| sendEvent('proxy.data.$id', null, data); |
| }, |
| onError: (Object error, StackTrace stackTrace) { |
| // Socket error, probably disconnected. |
| globals.logger.printTrace('Socket error: $error, $stackTrace'); |
| }, |
| ); |
| |
| unawaited( |
| socket.done |
| .then<Object?>( |
| (Object? obj) => obj, |
| onError: (Object error, StackTrace stackTrace) { |
| // Socket error, probably disconnected. |
| globals.logger.printTrace('Socket error: $error, $stackTrace'); |
| }, |
| ) |
| .then((Object? _) { |
| sendEvent('proxy.disconnected.$id'); |
| }), |
| ); |
| return id; |
| } |
| |
| /// Disconnects from a previously established connection. |
| Future<bool> disconnect(Map<String, Object?> args) async { |
| final String? id = _getStringArg(args, 'id', required: true); |
| if (_forwardedConnections.containsKey(id)) { |
| await _forwardedConnections.remove(id)?.close(); |
| return true; |
| } |
| return false; |
| } |
| |
| /// Writes to a previously established connection. |
| Future<bool> write(Map<String, Object?> args, Stream<List<int>>? binary) async { |
| final String? id = _getStringArg(args, 'id', required: true); |
| if (_forwardedConnections.containsKey(id)) { |
| final StreamSubscription<List<int>> subscription = binary!.listen( |
| _forwardedConnections[id!]!.add, |
| ); |
| await subscription.asFuture<void>(); |
| await subscription.cancel(); |
| return true; |
| } |
| return false; |
| } |
| |
| @override |
| Future<void> dispose() async { |
| for (final Socket connection in _forwardedConnections.values) { |
| connection.destroy(); |
| } |
| // We deliberately not clean up the tempDirectory here. The application package files that |
| // are transferred into this directory through ProxiedDevices are left in the directory |
| // to be reused on any subsequent runs. |
| } |
| |
| Directory? _tempDirectory; |
| Directory get tempDirectory => |
| _tempDirectory ??= globals.fs.systemTempDirectory.childDirectory('flutter_tool_daemon') |
| ..createSync(); |
| } |
| |
| /// A [Logger] which omits log messages to avoid breaking `--machine` formatting. |
| final class MachineOutputLogger extends DelegatingLogger { |
| MachineOutputLogger({required Logger parent}) : super(parent); |
| |
| AppDomain? _domain; |
| late final AppInstance _app; |
| var _nextProgressId = 0; |
| Status? _status; |
| |
| @override |
| Status startProgress( |
| String message, { |
| Duration? timeout, |
| String? progressId, |
| bool multilineOutput = false, |
| bool includeTiming = true, |
| int progressIndicatorPadding = kDefaultStatusPadding, |
| }) { |
| final int id = _nextProgressId++; |
| |
| _sendProgressEvent(eventId: id.toString(), eventType: progressId, message: message); |
| |
| _status = SilentStatus( |
| onFinish: () { |
| _status = null; |
| _sendProgressEvent(eventId: id.toString(), eventType: progressId, finished: true); |
| }, |
| stopwatch: Stopwatch(), |
| )..start(); |
| return _status!; |
| } |
| |
| void close() { |
| _domain = null; |
| } |
| |
| void _sendProgressEvent({ |
| required String eventId, |
| required String? eventType, |
| bool finished = false, |
| String? message, |
| }) { |
| if (_domain case final domain?) { |
| final event = <String, Object?>{ |
| 'id': eventId, |
| 'progressId': eventType, |
| 'message': ?message, |
| 'finished': finished, |
| }; |
| |
| domain._sendAppEvent(_app, 'progress', event); |
| } |
| } |
| |
| @override |
| void sendEvent(String name, [Map<String, Object?>? args, List<int>? binary]) { |
| if (_domain case final domain?) { |
| domain.sendEvent(name, args, binary); |
| } |
| } |
| |
| @override |
| bool get supportsColor => false; |
| |
| @override |
| bool get hasTerminal => false; |
| |
| // This method is only relevant for terminals. |
| @override |
| void clear() {} |
| } |
| |
| class LogMessage { |
| LogMessage(this.level, this.message, [this.stackTrace]); |
| |
| final String level; |
| final String message; |
| final StackTrace? stackTrace; |
| } |
| |
| /// The method by which the Flutter app was launched. |
| enum LaunchMode { |
| run._('run'), |
| attach._('attach'); |
| |
| const LaunchMode._(this._value); |
| |
| final String _value; |
| |
| @override |
| String toString() => _value; |
| } |
| |
| enum OperationType { reload, restart } |
| |
| /// A queue that debounces operations for a period and merges operations of the same type. |
| /// Only one action (or any type) will run at a time. Actions of the same type requested |
| /// in quick succession will be merged together and all return the same result. If an action |
| /// is requested after an identical action has already started, it will be queued |
| /// and run again once the first action completes. |
| class DebounceOperationQueue<T, K> { |
| final _debounceTimers = <K, RestartableTimer>{}; |
| final _operationQueue = <K, Future<T>>{}; |
| Future<void>? _inProgressAction; |
| |
| Future<T> queueAndDebounce( |
| K operationType, |
| Duration debounceDuration, |
| Future<T> Function() action, |
| ) { |
| // If there is already an operation of this type waiting to run, reset its |
| // debounce timer and return its future. |
| if (_operationQueue[operationType] != null) { |
| _debounceTimers[operationType]?.reset(); |
| return _operationQueue[operationType]!; |
| } |
| |
| // Otherwise, put one in the queue with a timer. |
| final completer = Completer<T>(); |
| _operationQueue[operationType] = completer.future; |
| _debounceTimers[operationType] = RestartableTimer(debounceDuration, () async { |
| // Remove us from the queue so we can't be reset now we've started. |
| unawaited(_operationQueue.remove(operationType)); |
| _debounceTimers.remove(operationType); |
| |
| // No operations should be allowed to run concurrently even if they're |
| // different types. |
| while (_inProgressAction != null) { |
| await _inProgressAction; |
| } |
| |
| _inProgressAction = action() |
| .then(completer.complete, onError: completer.completeError) |
| .whenComplete(() => _inProgressAction = null); |
| }); |
| |
| return completer.future; |
| } |
| } |
| |
| /// Specialized exception for returning errors to the daemon client. |
| class DaemonException implements Exception { |
| DaemonException(this.message); |
| |
| final String message; |
| |
| @override |
| String toString() => message; |
| } |