mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
1640 lines
53 KiB
Dart
1640 lines
53 KiB
Dart
// 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 '../emulator.dart';
|
|
import '../features.dart';
|
|
import '../globals.dart' as globals;
|
|
import '../project.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 String 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 String name = 'daemon';
|
|
|
|
@override
|
|
final String 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(stringArgDeprecated('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();
|
|
}
|
|
globals.printStatus('Starting device daemon...');
|
|
final Daemon daemon = Daemon(
|
|
DaemonConnection(
|
|
daemonStreams: DaemonStreams.fromStdio(globals.stdio, logger: globals.logger),
|
|
logger: globals.logger,
|
|
),
|
|
notifyingLogger: asLogger<NotifyingLogger>(globals.logger),
|
|
);
|
|
final int code = await daemon.onExit;
|
|
if (code != 0) {
|
|
throwToolExit('Daemon exited with non-zero exit code: $code', exitCode: code);
|
|
}
|
|
return FlutterCommandResult.success();
|
|
}
|
|
}
|
|
|
|
class _DaemonServer {
|
|
_DaemonServer({
|
|
this.port,
|
|
this.logger,
|
|
this.notifyingLogger,
|
|
});
|
|
|
|
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;
|
|
|
|
Future<void> run() async {
|
|
final ServerSocket serverSocket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 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.catchError((Object error, StackTrace stackTrace) {
|
|
logger!.printError('Socket error: $error');
|
|
logger!.printTrace('$stackTrace');
|
|
});
|
|
final Daemon 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,
|
|
}) {
|
|
// 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));
|
|
|
|
// Start listening.
|
|
_commandSubscription = connection.incomingCommands.listen(
|
|
_handleRequest,
|
|
onDone: () {
|
|
shutdown();
|
|
if (!_onExitCompleter.isCompleted) {
|
|
_onExitCompleter.complete(0);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
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 Completer<int> _onExitCompleter = Completer<int>();
|
|
final Map<String, Domain> _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 String method = request.data['method']! as String;
|
|
assert(method != null);
|
|
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 Map<String, CommandHandler> _handlers = <String, CommandHandler>{};
|
|
final Map<String, CommandHandlerWithBinary> _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;
|
|
}).catchError((Object error, StackTrace stackTrace) {
|
|
daemon.connection.sendErrorResponse(id, _toJsonable(error), stackTrace);
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
sendEvent(
|
|
'daemon.connected',
|
|
<String, Object?>{
|
|
'version': protocolVersion,
|
|
'pid': pid,
|
|
},
|
|
);
|
|
|
|
_subscription = daemon.notifyingLogger!.onMessage.listen((LogMessage message) {
|
|
if (daemon.logToStdout) {
|
|
if (message.level == 'status') {
|
|
// 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 List<String> result = <String>[];
|
|
try {
|
|
final FlutterProject flutterProject = FlutterProject.fromDirectory(globals.fs.directory(projectRoot));
|
|
final Set<SupportedPlatform> supportedPlatforms = flutterProject.getSupportedPlatforms().toSet();
|
|
if (featureFlags.isLinuxEnabled && supportedPlatforms.contains(SupportedPlatform.linux)) {
|
|
result.add('linux');
|
|
}
|
|
if (featureFlags.isMacOSEnabled && supportedPlatforms.contains(SupportedPlatform.macos)) {
|
|
result.add('macos');
|
|
}
|
|
if (featureFlags.isWindowsEnabled && supportedPlatforms.contains(SupportedPlatform.windows)) {
|
|
result.add('windows');
|
|
}
|
|
if (featureFlags.isIOSEnabled && supportedPlatforms.contains(SupportedPlatform.ios)) {
|
|
result.add('ios');
|
|
}
|
|
if (featureFlags.isAndroidEnabled && supportedPlatforms.contains(SupportedPlatform.android)) {
|
|
result.add('android');
|
|
}
|
|
if (featureFlags.isWebEnabled && supportedPlatforms.contains(SupportedPlatform.web)) {
|
|
result.add('web');
|
|
}
|
|
if (featureFlags.isFuchsiaEnabled && supportedPlatforms.contains(SupportedPlatform.fuchsia)) {
|
|
result.add('fuchsia');
|
|
}
|
|
if (featureFlags.areCustomDevicesEnabled) {
|
|
result.add('custom');
|
|
}
|
|
return <String, Object>{
|
|
'platforms': result,
|
|
};
|
|
} 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
|
|
// comparability.
|
|
return <String, Object>{
|
|
'platforms': <String>[
|
|
'android',
|
|
'ios',
|
|
],
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
typedef RunOrAttach = Future<void> Function({
|
|
Completer<DebugConnectionInfo>? connectionInfoCompleter,
|
|
Completer<void>? appStartedCompleter,
|
|
});
|
|
|
|
/// This domain responds to methods like [start] 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 Uuid _uuidGenerator = Uuid();
|
|
|
|
static String _getNewAppId() => _uuidGenerator.v4();
|
|
|
|
final List<AppInstance> _apps = <AppInstance>[];
|
|
|
|
final DebounceOperationQueue<OperationResult, OperationType> 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,
|
|
bool ipv6 = false,
|
|
bool multidexEnabled = false,
|
|
String? isolateFilter,
|
|
bool machine = true,
|
|
String? userIdentifier,
|
|
}) async {
|
|
if (!await device.supportsRuntimeMode(options.buildInfo.mode)) {
|
|
throw Exception(
|
|
'${sentenceCase(options.buildInfo.friendlyModeName)} '
|
|
'mode is not supported for ${device.name}.',
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
ipv6: ipv6,
|
|
stayResident: true,
|
|
urlTunneller: options.webEnableExposeUrl! ? daemon.daemonDomain.exposeUrl : null,
|
|
machine: machine,
|
|
usage: globals.flutterUsage,
|
|
systemClock: globals.systemClock,
|
|
logger: globals.logger,
|
|
fileSystem: globals.fs,
|
|
);
|
|
} else if (enableHotReload) {
|
|
runner = HotRunner(
|
|
<FlutterDevice>[flutterDevice],
|
|
target: target,
|
|
debuggingOptions: options,
|
|
applicationBinary: applicationBinary,
|
|
projectRootPath: projectRootPath,
|
|
dillOutputPath: dillOutputPath,
|
|
ipv6: ipv6,
|
|
multidexEnabled: multidexEnabled,
|
|
hostIsIde: true,
|
|
machine: machine,
|
|
);
|
|
} else {
|
|
runner = ColdRunner(
|
|
<FlutterDevice>[flutterDevice],
|
|
target: target,
|
|
debuggingOptions: options,
|
|
applicationBinary: applicationBinary,
|
|
ipv6: ipv6,
|
|
multidexEnabled: multidexEnabled,
|
|
machine: machine,
|
|
);
|
|
}
|
|
|
|
return launch(
|
|
runner,
|
|
({
|
|
Completer<DebugConnectionInfo>? connectionInfoCompleter,
|
|
Completer<void>? appStartedCompleter,
|
|
}) {
|
|
return runner.run(
|
|
connectionInfoCompleter: connectionInfoCompleter,
|
|
appStartedCompleter: appStartedCompleter,
|
|
enableDevTools: true,
|
|
route: route,
|
|
);
|
|
},
|
|
device,
|
|
projectDirectory,
|
|
enableHotReload,
|
|
cwd,
|
|
LaunchMode.run,
|
|
asLogger<AppRunLogger>(globals.logger),
|
|
);
|
|
}
|
|
|
|
Future<AppInstance> launch(
|
|
ResidentRunner runner,
|
|
RunOrAttach runOrAttach,
|
|
Device device,
|
|
String? projectDirectory,
|
|
bool enableHotReload,
|
|
Directory cwd,
|
|
LaunchMode launchMode,
|
|
AppRunLogger logger,
|
|
) async {
|
|
final AppInstance 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(),
|
|
});
|
|
|
|
Completer<DebugConnectionInfo>? connectionInfoCompleter;
|
|
|
|
if (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) {
|
|
final Map<String, Object?> params = <String, Object?>{
|
|
// The web vmservice proxy does not have an http address.
|
|
'port': info.httpUri?.port ?? info.wsUri!.port,
|
|
'wsUri': info.wsUri.toString(),
|
|
};
|
|
if (info.baseUri != null) {
|
|
params['baseUri'] = info.baseUri;
|
|
}
|
|
_sendAppEvent(app, 'debugPort', params);
|
|
},
|
|
));
|
|
}
|
|
final Completer<void> appStartedCompleter = Completer<void>();
|
|
// We don't want to wait for this future to complete, and callbacks won't fail,
|
|
// as it just writes to stdout.
|
|
unawaited(appStartedCompleter.future.then<void>((void value) {
|
|
_sendAppEvent(app, 'started');
|
|
}));
|
|
|
|
await 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);
|
|
}
|
|
});
|
|
return app;
|
|
}
|
|
|
|
bool isRestartSupported(bool enableHotReload, Device device) =>
|
|
enableHotReload && device.supportsHotRestart;
|
|
|
|
final int _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 [_hotReloadDebounceDuration]) 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);
|
|
|
|
// 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.
|
|
int _id = 0;
|
|
final Map<String, ApplicationPackage?> _applicationPackages = <String, ApplicationPackage?>{};
|
|
final Map<String, DeviceLogReader> _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'));
|
|
}
|
|
}
|
|
|
|
Future<void> _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 List<PollingDeviceDiscovery> _discoverers = <PollingDeviceDiscovery>[];
|
|
|
|
/// Return a list of the current 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)
|
|
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 {
|
|
return <Map<String, Object?>>[
|
|
for (final PollingDeviceDiscovery discoverer in _discoverers)
|
|
for (final Device device in await discoverer.discoverDevices())
|
|
await _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(getBuildModeForName(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 String 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 String 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.debug,
|
|
),
|
|
mainPath: _getStringArg(args, 'mainPath'),
|
|
route: _getStringArg(args, 'route'),
|
|
platformArgs: castStringKeyedMap(args['platformArgs']) ?? const <String, Object>{},
|
|
prebuiltApplication: _getBoolArg(args, 'prebuiltApplication') ?? false,
|
|
ipv6: _getBoolArg(args, 'ipv6') ?? false,
|
|
userIdentifier: _getStringArg(args, 'userIdentifier'),
|
|
);
|
|
return <String, Object?>{
|
|
'started': result.started,
|
|
'observatoryUri': result.observatoryUri?.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', required: true);
|
|
final ApplicationPackage 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 String 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;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() {
|
|
for (final PollingDeviceDiscovery discoverer in _discoverers) {
|
|
discoverer.dispose();
|
|
}
|
|
return Future<void>.value();
|
|
}
|
|
|
|
/// Return the 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;
|
|
Device? device;
|
|
for (final Device localDevice in devices) {
|
|
if (localDevice.id == deviceId) {
|
|
device = localDevice;
|
|
}
|
|
}
|
|
if (device != null) {
|
|
return device;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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.name,
|
|
'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,
|
|
'capabilities': <String, Object>{
|
|
'hotReload': device.supportsHotReload,
|
|
'hotRestart': device.supportsHotRestart,
|
|
'screenshot': device.supportsScreenshot,
|
|
'fastStart': device.supportsFastStart,
|
|
'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) {
|
|
if (obj is String || obj is int || obj is bool || obj is Map<Object?, Object?> || obj is List<Object?> || obj == null) {
|
|
return obj;
|
|
}
|
|
if (obj is OperationResult) {
|
|
return _operationResultToMap(obj);
|
|
}
|
|
if (obj is ToolExit) {
|
|
return obj.message;
|
|
}
|
|
return '$obj';
|
|
}
|
|
|
|
class NotifyingLogger extends DelegatingLogger {
|
|
NotifyingLogger({ required this.verbose, required Logger parent }) : super(parent) {
|
|
_messageController = StreamController<LogMessage>.broadcast(
|
|
onListen: _onListen,
|
|
);
|
|
}
|
|
|
|
final bool verbose;
|
|
final List<LogMessage> messageBuffer = <LogMessage>[];
|
|
late StreamController<LogMessage> _messageController;
|
|
|
|
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,
|
|
}) {
|
|
_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 (!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 => throw UnimplementedError();
|
|
|
|
@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, { this.runner, this.logToStdout = false, required AppRunLogger logger })
|
|
: _logger = logger;
|
|
|
|
final String id;
|
|
final ResidentRunner? runner;
|
|
final bool logToStdout;
|
|
final AppRunLogger _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);
|
|
}
|
|
|
|
EmulatorManager emulators = EmulatorManager(
|
|
fileSystem: globals.fs,
|
|
logger: globals.logger,
|
|
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) : super(daemon, 'proxy') {
|
|
registerHandlerWithBinary('writeTempFile', writeTempFile);
|
|
registerHandler('calculateFileHashes', calculateFileHashes);
|
|
registerHandlerWithBinary('updateFile', updateFile);
|
|
registerHandler('connect', connect);
|
|
registerHandler('disconnect', disconnect);
|
|
registerHandlerWithBinary('write', write);
|
|
}
|
|
|
|
final Map<String, Socket> _forwardedConnections = <String, Socket>{};
|
|
int _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 File file = tempDirectory.childFile(path);
|
|
if (!await file.exists()) {
|
|
return null;
|
|
}
|
|
final BlockHashes result = await FileTransfer().calculateBlockHashesOfFile(file);
|
|
return result.toJson();
|
|
}
|
|
|
|
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 String 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;
|
|
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.catchError((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 sends log messages to a listening daemon client.
|
|
///
|
|
/// This class can either:
|
|
/// 1) Send stdout messages and progress events to the client IDE
|
|
/// 1) Log messages to stdout and send progress events to the client IDE
|
|
//
|
|
// TODO(devoncarew): To simplify this code a bit, we could choose to specialize
|
|
// this class into two, one for each of the above use cases.
|
|
class AppRunLogger extends DelegatingLogger {
|
|
AppRunLogger({ required Logger parent }) : super(parent);
|
|
|
|
AppDomain? domain;
|
|
late AppInstance app;
|
|
int _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 == null) {
|
|
// If we're sending progress events before an app has started, send the
|
|
// progress messages as plain status messages.
|
|
if (message != null) {
|
|
printStatus(message);
|
|
}
|
|
} else {
|
|
final Map<String, Object?> event = <String, Object?>{
|
|
'id': eventId,
|
|
'progressId': eventType,
|
|
if (message != null) 'message': message,
|
|
if (finished != null) 'finished': finished,
|
|
};
|
|
|
|
domain!._sendAppEvent(app, 'progress', event);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void sendEvent(String name, [Map<String, Object?>? args, List<int>? binary]) {
|
|
if (domain == null) {
|
|
printStatus('event sent after app closed: $name');
|
|
} else {
|
|
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.
|
|
class LaunchMode {
|
|
const LaunchMode._(this._value);
|
|
|
|
/// The app was launched via `flutter run`.
|
|
static const LaunchMode run = LaunchMode._('run');
|
|
|
|
/// The app was launched via `flutter attach`.
|
|
static const LaunchMode attach = LaunchMode._('attach');
|
|
|
|
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 Map<K, RestartableTimer> _debounceTimers = <K, RestartableTimer>{};
|
|
final Map<K, Future<T>> _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<T> 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;
|
|
}
|