// 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 'dart:typed_data'; import 'package:meta/meta.dart'; import '../application_package.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../build_info.dart'; import '../convert.dart'; import '../daemon.dart'; import '../device.dart'; import '../device_port_forwarder.dart'; import '../project.dart'; import 'file_transfer.dart'; bool _isNullable<T>() => null is T; T _cast<T>(Object? object) { if (!_isNullable<T>() && object == null) { throw Exception('Expected $T, received null!'); } else { return object as T; } } /// A [DeviceDiscovery] that will connect to a flutter daemon and connects to /// the devices remotely. /// /// If [deltaFileTransfer] is true, the proxy will use an rsync-like algorithm that /// only transfers the changed part of the application package for deployment. class ProxiedDevices extends DeviceDiscovery { ProxiedDevices(this.connection, { bool deltaFileTransfer = true, required Logger logger, }) : _deltaFileTransfer = deltaFileTransfer, _logger = logger; /// [DaemonConnection] used to communicate with the daemon. final DaemonConnection connection; final Logger _logger; final bool _deltaFileTransfer; @override bool get supportsPlatform => true; @override bool get canListAnything => true; List<Device>? _devices; @override Future<List<Device>> get devices async => _devices ?? await discoverDevices(); @override Future<List<Device>> discoverDevices({Duration? timeout}) async { final List<Map<String, Object?>> discoveredDevices = _cast<List<dynamic>>(await connection.sendRequest('device.discoverDevices')).cast<Map<String, Object?>>(); final List<ProxiedDevice> devices = <ProxiedDevice>[ for (final Map<String, Object?> device in discoveredDevices) _deviceFromDaemonResult(device), ]; _devices = devices; return devices; } @override List<String> get wellKnownIds => const <String>[]; ProxiedDevice _deviceFromDaemonResult(Map<String, Object?> device) { final Map<String, Object?> capabilities = _cast<Map<String, Object?>>(device['capabilities']); return ProxiedDevice( connection, _cast<String>(device['id']), deltaFileTransfer: _deltaFileTransfer, category: Category.fromString(_cast<String>(device['category'])), platformType: PlatformType.fromString(_cast<String>(device['platformType'])), targetPlatform: getTargetPlatformForName(_cast<String>(device['platform'])), ephemeral: _cast<bool>(device['ephemeral']), name: 'Proxied ${device['name']}', isLocalEmulator: _cast<bool>(device['emulator']), emulatorId: _cast<String?>(device['emulatorId']), sdkNameAndVersion: _cast<String>(device['sdk']), supportsHotReload: _cast<bool>(capabilities['hotReload']), supportsHotRestart: _cast<bool>(capabilities['hotRestart']), supportsFlutterExit: _cast<bool>(capabilities['flutterExit']), supportsScreenshot: _cast<bool>(capabilities['screenshot']), supportsFastStart: _cast<bool>(capabilities['fastStart']), supportsHardwareRendering: _cast<bool>(capabilities['hardwareRendering']), logger: _logger, ); } } /// A [Device] that acts as a proxy to remotely connected device. /// /// The communication happens via a flutter daemon. /// /// If [deltaFileTransfer] is true, the proxy will use an rsync-like algorithm that /// only transfers the changed part of the application package for deployment. class ProxiedDevice extends Device { ProxiedDevice(this.connection, String id, { bool deltaFileTransfer = true, required Category? category, required PlatformType? platformType, required TargetPlatform targetPlatform, required bool ephemeral, required this.name, required bool isLocalEmulator, required String? emulatorId, required String sdkNameAndVersion, required this.supportsHotReload, required this.supportsHotRestart, required this.supportsFlutterExit, required this.supportsScreenshot, required this.supportsFastStart, required bool supportsHardwareRendering, required Logger logger, }): _deltaFileTransfer = deltaFileTransfer, _isLocalEmulator = isLocalEmulator, _emulatorId = emulatorId, _sdkNameAndVersion = sdkNameAndVersion, _supportsHardwareRendering = supportsHardwareRendering, _targetPlatform = targetPlatform, _logger = logger, super(id, category: category, platformType: platformType, ephemeral: ephemeral); /// [DaemonConnection] used to communicate with the daemon. final DaemonConnection connection; final Logger _logger; final bool _deltaFileTransfer; @override final String name; final bool _isLocalEmulator; @override Future<bool> get isLocalEmulator async => _isLocalEmulator; final String? _emulatorId; @override Future<String?> get emulatorId async => _emulatorId; @override Future<bool> supportsRuntimeMode(BuildMode buildMode) async => _cast<bool>(await connection.sendRequest('device.supportsRuntimeMode', <String, Object>{ 'deviceId': id, 'buildMode': buildMode.toString(), })); final bool _supportsHardwareRendering; @override Future<bool> get supportsHardwareRendering async => _supportsHardwareRendering; // ProxiedDevice is intended to be used with prebuilt projects. No building // is required, so we returns true for all projects. @override bool isSupportedForProject(FlutterProject flutterProject) => true; @override Future<bool> isAppInstalled( covariant ApplicationPackage app, { String? userIdentifier, }) => throw UnimplementedError(); @override Future<bool> isLatestBuildInstalled(covariant ApplicationPackage app) => throw UnimplementedError(); @override Future<bool> installApp( covariant ApplicationPackage app, { String? userIdentifier, }) => throw UnimplementedError(); @override Future<bool> uninstallApp( covariant ApplicationPackage app, { String? userIdentifier, }) => throw UnimplementedError(); @override bool isSupported() => true; final TargetPlatform _targetPlatform; @override Future<TargetPlatform> get targetPlatform async => _targetPlatform; TargetPlatform get targetPlatformSync => _targetPlatform; final String _sdkNameAndVersion; @override Future<String> get sdkNameAndVersion async => _sdkNameAndVersion; @override FutureOr<DeviceLogReader> getLogReader({ covariant PrebuiltApplicationPackage? app, bool includePastLogs = false, }) => _ProxiedLogReader(connection, this, app); ProxiedPortForwarder? _proxiedPortForwarder; /// [proxiedPortForwarder] forwards a port from the remote host to local host. ProxiedPortForwarder get proxiedPortForwarder => _proxiedPortForwarder ??= ProxiedPortForwarder(connection, logger: _logger); DevicePortForwarder? _portForwarder; /// [portForwarder] forwards a port from the remote device to remote host, and /// then forward the port from remote host to local host. @override DevicePortForwarder get portForwarder => _portForwarder ??= ProxiedPortForwarder(connection, deviceId: id, logger: _logger); @override void clearLogs() => throw UnimplementedError(); @override Future<LaunchResult> startApp( covariant PrebuiltApplicationPackage package, { String? mainPath, String? route, required DebuggingOptions debuggingOptions, Map<String, Object?> platformArgs = const <String, Object?>{}, bool prebuiltApplication = false, bool ipv6 = false, String? userIdentifier, }) async { final Map<String, Object?> result = _cast<Map<String, Object?>>(await connection.sendRequest('device.startApp', <String, Object?>{ 'deviceId': id, 'applicationPackageId': await applicationPackageId(package), 'mainPath': mainPath, 'route': route, 'debuggingOptions': debuggingOptions.toJson(), 'platformArgs': platformArgs, 'prebuiltApplication': prebuiltApplication, 'ipv6': ipv6, 'userIdentifier': userIdentifier, })); final bool started = _cast<bool>(result['started']); final String? observatoryUriStr = _cast<String?>(result['observatoryUri']); final Uri? observatoryUri = observatoryUriStr == null ? null : Uri.parse(observatoryUriStr); if (started) { if (observatoryUri != null) { final int hostPort = await proxiedPortForwarder.forward(observatoryUri.port); return LaunchResult.succeeded(observatoryUri: observatoryUri.replace(port: hostPort)); } else { return LaunchResult.succeeded(); } } else { return LaunchResult.failed(); } } @override final bool supportsHotReload; @override final bool supportsHotRestart; @override final bool supportsFlutterExit; @override final bool supportsScreenshot; @override final bool supportsFastStart; @override Future<bool> stopApp( covariant PrebuiltApplicationPackage app, { String? userIdentifier, }) async { return _cast<bool>(await connection.sendRequest('device.stopApp', <String, Object?>{ 'deviceId': id, 'applicationPackageId': await applicationPackageId(app), 'userIdentifier': userIdentifier, })); } @override Future<MemoryInfo> queryMemoryInfo() => throw UnimplementedError(); @override Future<void> takeScreenshot(File outputFile) async { final String imageBase64 = _cast<String>(await connection.sendRequest('device.takeScreenshot', <String, Object?>{ 'deviceId': id, })); await outputFile.writeAsBytes(base64.decode(imageBase64)); } @override Future<void> dispose() async {} final Map<String, Future<String>> _applicationPackageMap = <String, Future<String>>{}; Future<String> applicationPackageId(PrebuiltApplicationPackage package) async { final File binary = package.applicationPackage as File; final String path = binary.absolute.path; if (_applicationPackageMap.containsKey(path)) { return _applicationPackageMap[path]!; } final String fileName = binary.basename; final Completer<String> idCompleter = Completer<String>(); _applicationPackageMap[path] = idCompleter.future; final Map<String, Object> args = <String, Object>{'path': fileName}; Map<String, Object?>? rollingHashResultJson; if (_deltaFileTransfer) { rollingHashResultJson = _cast<Map<String, Object?>?>(await connection.sendRequest('proxy.calculateFileHashes', args)); } if (rollingHashResultJson == null) { // Either file not found on the remote end, or deltaFileTransfer is set to false, transfer the file directly. if (_deltaFileTransfer) { _logger.printTrace('Delta file transfer is enabled but file is not found on the remote end, do a full transfer.'); } await connection.sendRequest('proxy.writeTempFile', args, await binary.readAsBytes()); } else { final BlockHashes rollingHashResult = BlockHashes.fromJson(rollingHashResultJson); final List<FileDeltaBlock> delta = await FileTransfer().computeDelta(binary, rollingHashResult); // Delta is empty if the file does not need to be updated if (delta.isNotEmpty) { final List<Map<String, Object>> deltaJson = delta.map((FileDeltaBlock block) => block.toJson()).toList(); final Uint8List buffer = await FileTransfer().binaryForRebuilding(binary, delta); await connection.sendRequest('proxy.updateFile', <String, Object>{ 'path': fileName, 'delta': deltaJson, }, buffer); } } final String id = _cast<String>(await connection.sendRequest('device.uploadApplicationPackage', <String, Object>{ 'targetPlatform': getNameForTargetPlatform(_targetPlatform), 'applicationBinary': fileName, })); idCompleter.complete(id); return id; } } /// A [DeviceLogReader] for a proxied device. class _ProxiedLogReader extends DeviceLogReader { _ProxiedLogReader(this.connection, this.device, this.applicationPackage); final DaemonConnection connection; final ProxiedDevice device; final PrebuiltApplicationPackage? applicationPackage; @override String get name => device.name; final StreamController<String> _logLinesStreamController = StreamController<String>(); Stream<String>? _logLines; String? _id; @override Stream<String> get logLines => _logLines ??= _start(); Stream<String> _start() { final PrebuiltApplicationPackage? package = applicationPackage; final Future<String?> applicationPackageId = package != null ? device.applicationPackageId(package) : Future<String?>.value(); final Future<String> idFuture = applicationPackageId.then((String? applicationPackageId) async => _cast<String>(await connection.sendRequest('device.logReader.start', <String, Object>{ 'deviceId': device.id, if (applicationPackageId != null) 'applicationPackageId': applicationPackageId, }))); idFuture.then((String id) { _id = id; final Stream<String> stream = connection.listenToEvent('device.logReader.logLines.$_id').map((DaemonEventData event) => event.data! as String); _logLinesStreamController.addStream(stream); }); return _logLinesStreamController.stream; } @override void dispose() { if (_id != null) { connection.sendRequest('device.logReader.stop', <String, Object?>{ 'id': _id, }); } } } /// A port forwarded by a [ProxiedPortForwarder]. class _ProxiedForwardedPort extends ForwardedPort { _ProxiedForwardedPort(this.connection, { required int hostPort, required int devicePort, required this.remoteDevicePort, required this.deviceId, required this.serverSocket }): super(hostPort, devicePort); /// [DaemonConnection] used to communicate with the daemon. final DaemonConnection connection; /// The forwarded port on the remote device. final int? remoteDevicePort; /// The device identifier of the remote device. final String? deviceId; /// The [ServerSocket] that is serving the local forwarded port. final ServerSocket serverSocket; @override void dispose() { unforward(); } /// Unforwards the remote port, and stops the local server. Future<void> unforward() async { await serverSocket.close(); if (remoteDevicePort != null && deviceId != null) { await connection.sendRequest('device.unforward', <String, Object>{ 'deviceId': deviceId!, 'devicePort': remoteDevicePort!, 'hostPort': devicePort, }); } } } typedef CreateSocketServer = Future<ServerSocket> Function(Logger logger, int? hostPort); /// A [DevicePortForwarder] for a proxied device. /// /// If [deviceId] is not null, the port forwarder forwards ports from the remote /// device, to the remote host, and then to the local host. /// /// If [deviceId] is null, then the port forwarder only forwards ports from the /// remote host to the local host. @visibleForTesting class ProxiedPortForwarder extends DevicePortForwarder { ProxiedPortForwarder(this.connection, { String? deviceId, required Logger logger, @visibleForTesting CreateSocketServer createSocketServer = _defaultCreateServerSocket, }) : _logger = logger, _deviceId = deviceId, _createSocketServer = createSocketServer; final String? _deviceId; DaemonConnection connection; final Logger _logger; final CreateSocketServer _createSocketServer; @override List<ForwardedPort> get forwardedPorts => _hostPortToForwardedPorts.values.toList(); final Map<int, _ProxiedForwardedPort> _hostPortToForwardedPorts = <int, _ProxiedForwardedPort>{}; final List<Socket> _connectedSockets = <Socket>[]; @override Future<int> forward(int devicePort, { int? hostPort }) async { int? remoteDevicePort; final String? deviceId = _deviceId; // If deviceId is set, we need to forward the remote device port to remote host as well. // And then, forward the remote host port to a local host port. if (deviceId != null) { final Map<String, Object?> result = _cast<Map<String, Object?>>( await connection.sendRequest('device.forward', <String, Object>{ 'deviceId': deviceId, 'devicePort': devicePort, })); remoteDevicePort = devicePort; devicePort = result['hostPort']! as int; } final ServerSocket serverSocket = await _startProxyServer(devicePort, hostPort); _hostPortToForwardedPorts[serverSocket.port] = _ProxiedForwardedPort( connection, hostPort: serverSocket.port, devicePort: devicePort, remoteDevicePort: remoteDevicePort, deviceId: deviceId, serverSocket: serverSocket, ); return serverSocket.port; } Future<ServerSocket> _startProxyServer(int devicePort, int? hostPort) async { final ServerSocket serverSocket = await _createSocketServer(_logger, hostPort); serverSocket.listen((Socket socket) async { final String id = _cast<String>(await connection.sendRequest('proxy.connect', <String, Object>{ 'port': devicePort, })); final Stream<List<int>> dataStream = connection.listenToEvent('proxy.data.$id').asyncExpand((DaemonEventData event) => event.binary); dataStream.listen(socket.add); final Future<DaemonEventData> disconnectFuture = connection.listenToEvent('proxy.disconnected.$id').first; unawaited(disconnectFuture.then((_) { socket.close(); }).catchError((_) { // The event is not guaranteed to be sent if we initiated the disconnection. // Do nothing here. })); socket.listen((Uint8List data) { unawaited(connection.sendRequest('proxy.write', <String, Object>{ 'id': id, }, data).catchError((Object error, StackTrace stackTrace) { // Log the error, but proceed normally. Network failure should not // crash the tool. If this is critical, the place where the connection // is being used would crash. _logger.printWarning('Write to remote proxy error: $error'); _logger.printTrace('Write to remote proxy error: $error, stack trace: $stackTrace'); })); }); _connectedSockets.add(socket); unawaited(socket.done.catchError((Object error, StackTrace stackTrace) { // Do nothing here. Everything will be handled in the `then` block below. return false; }).whenComplete(() { // Send a proxy disconnect event just in case. unawaited(connection.sendRequest('proxy.disconnect', <String, Object>{ 'id': id, }).catchError((Object error, StackTrace stackTrace) { // Ignore the error here. There might be a race condition when the // remote end also disconnects. In any case, this request is just to // notify the remote end to disconnect and we should not crash when // there is an error here. })); _connectedSockets.remove(socket); })); }, onError: (Object error, StackTrace stackTrace) { _logger.printWarning('Server socket error: $error'); _logger.printTrace('Server socket error: $error, stack trace: $stackTrace'); }); return serverSocket; } @override Future<void> unforward(ForwardedPort forwardedPort) async { // Look for the forwarded port entry in our own map. final _ProxiedForwardedPort? proxiedForwardedPort = _hostPortToForwardedPorts.remove(forwardedPort.hostPort); await proxiedForwardedPort?.unforward(); } @override Future<void> dispose() async { for (final _ProxiedForwardedPort forwardedPort in _hostPortToForwardedPorts.values) { await forwardedPort.unforward(); } for (final Socket socket in _connectedSockets) { await socket.close(); } } } Future<ServerSocket> _defaultCreateServerSocket(Logger logger, int? hostPort) async { try { return await ServerSocket.bind(InternetAddress.loopbackIPv4, hostPort ?? 0); } on SocketException { logger.printTrace('Bind on $hostPort failed with IPv4, retrying on IPv6'); } // If binding on ipv4 failed, try binding on ipv6. // Omit try catch here, let the failure fallthrough. return ServerSocket.bind(InternetAddress.loopbackIPv6, hostPort ?? 0); }