diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart index 4025da083e3..44dd48ca10b 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart @@ -50,6 +50,24 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter { @override bool get supportsRestartRequest => true; + /// A list of reverse-requests from `flutter run --machine` that should be forwarded to the client. + final Set _requestsToForwardToClient = { + // The 'app.exposeUrl' request is sent by Flutter to request the client + // exposes a URL to the user and return the public version of that URL. + // + // This supports some web scenarios where the `flutter` tool may be running + // on a different machine to the user (for example a cloud IDE or in VS Code + // remote workspace) so we cannot just use the raw URL because the hostname + // and/or port might not be available to the machine the user is using. + // Instead, the IDE/infrastructure can set up port forwarding/proxying and + // return a user-facing URL that will map to the original (localhost) URL + // Flutter provided. + 'app.exposeUrl', + }; + + /// Completers for reverse requests from Flutter that may need to be handled by the client. + final Map> _reverseRequestCompleters = >{}; + /// Whether or not the user requested debugging be enabled. /// /// For debugging to be enabled, the user must have chosen "Debug" (and not @@ -151,6 +169,13 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter { sendResponse(null); break; + // Handle requests (from the client) that provide responses to reverse-requests + // that we forwarded from `flutter run --machine`. + case 'flutter.sendForwardedRequestResponse': + _handleForwardedResponse(args); + sendResponse(null); + break; + default: await super.customRequest(request, args, sendResponse); } @@ -275,44 +300,43 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter { sendResponse(); } - /// Sends a request to the Flutter daemon that is running/attaching to the app and waits for a response. + /// Sends a request to the Flutter run daemon that is running/attaching to the app and waits for a response. /// - /// If [failSilently] is `true` (the default) and there is no process, the - /// message will be silently ignored (this is common during the application - /// being stopped, where async messages may be processed). Setting it to - /// `false` will cause a [DebugAdapterException] to be thrown in that case. + /// If there is no process, the message will be silently ignored (this is + /// common during the application being stopped, where async messages may be + /// processed). Future sendFlutterRequest( String method, - Map? params, { - bool failSilently = true, - }) async { - final Process? process = this.process; - - if (process == null) { - if (failSilently) { - return null; - } else { - throw DebugAdapterException( - 'Unable to Restart because Flutter process is not available', - ); - } - } - + Map? params, + ) async { final Completer completer = Completer(); final int id = _flutterRequestId++; _flutterRequestCompleters[id] = completer; - // Flutter requests are always wrapped in brackets as an array. - final String messageString = jsonEncode( - {'id': id, 'method': method, 'params': params}, - ); - final String payload = '[$messageString]\n'; - - process.stdin.writeln(payload); + sendFlutterMessage({ + 'id': id, + 'method': method, + 'params': params, + }); return completer.future; } + /// Sends a message to the Flutter run daemon. + /// + /// Throws `DebugAdapterException` if a Flutter process is not yet running. + void sendFlutterMessage(Map message) { + final Process? process = this.process; + if (process == null) { + throw DebugAdapterException('Flutter process has not yet started'); + } + + final String messageString = jsonEncode(message); + // Flutter requests are always wrapped in brackets as an array. + final String payload = '[$messageString]\n'; + process.stdin.writeln(payload); + } + /// Called by [terminateRequest] to request that we gracefully shut down the app being run (or in the case of an attach, disconnect). @override Future terminateImpl() async { @@ -432,6 +456,62 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter { } } + /// Handles incoming reverse requests from `flutter run --machine`. + /// + /// These requests are usually just forwarded to the client via an event + /// (`flutter.forwardedRequest`) and responses are provided by the client in a + /// custom event (`flutter.forwardedRequestResponse`). + void _handleJsonRequest( + Object id, + String method, + Map? params, + ) { + /// A helper to send a client response to Flutter. + void sendResponseToFlutter(Object? id, Object? value, { bool error = false }) { + sendFlutterMessage({ + 'id': id, + if (error) + 'error': value + else + 'result': value + }); + } + + // Set up a completer to forward the response back to `flutter` when it arrives. + final Completer completer = Completer(); + _reverseRequestCompleters[id] = completer; + completer.future + .then((Object? value) => sendResponseToFlutter(id, value)) + .catchError((Object? e) => sendResponseToFlutter(id, e.toString(), error: true)); + + if (_requestsToForwardToClient.contains(method)) { + // Forward the request to the client in an event. + sendEvent( + RawEventBody({ + 'id': id, + 'method': method, + 'params': params, + }), + eventType: 'flutter.forwardedRequest', + ); + } else { + completer.completeError(ArgumentError.value(method, 'Unknown request method.')); + } + } + + /// Handles client responses to reverse-requests that were forwarded from Flutter. + void _handleForwardedResponse(RawRequestArguments? args) { + final Object? id = args?.args['id']; + final Object? result = args?.args['result']; + final Object? error = args?.args['error']; + final Completer? completer = _reverseRequestCompleters[id]; + if (error != null) { + completer?.completeError(DebugAdapterException('Client reported an error handling reverse-request $error')); + } else { + completer?.complete(result); + } + } + /// Handles incoming JSON messages from `flutter run --machine` that are responses to requests that we sent. void _handleJsonResponse(int id, Map response) { final Completer? handler = _flutterRequestCompleters.remove(id); @@ -509,10 +589,13 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter { } final Object? event = payload['event']; + final Object? method = payload['method']; final Object? params = payload['params']; final Object? id = payload['id']; if (event is String && params is Map?) { _handleJsonEvent(event, params); + } else if (id != null && method is String && params is Map?) { + _handleJsonRequest(id, method, params); } else if (id is int && _flutterRequestCompleters.containsKey(id)) { _handleJsonResponse(id, payload); } else { diff --git a/packages/flutter_tools/test/general.shard/dap/flutter_adapter_test.dart b/packages/flutter_tools/test/general.shard/dap/flutter_adapter_test.dart index 8a7d44a31ac..e2b2a9c9016 100644 --- a/packages/flutter_tools/test/general.shard/dap/flutter_adapter_test.dart +++ b/packages/flutter_tools/test/general.shard/dap/flutter_adapter_test.dart @@ -214,6 +214,35 @@ void main() { }); }); + group('handles reverse requests', () { + test('app.exposeUrl', () async { + final MockFlutterDebugAdapter adapter = MockFlutterDebugAdapter( + fileSystem: MemoryFileSystem.test(style: fsStyle), + platform: platform, + ); + + // Pretend to be the client, handling any reverse-requests for exposeUrl + // and mapping the host to 'mapped-host'. + adapter.exposeUrlHandler = (String url) => Uri.parse(url).replace(host: 'mapped-host').toString(); + + // Simulate Flutter asking for a URL to be exposed. + const int requestId = 12345; + adapter.simulateStdoutMessage({ + 'id': requestId, + 'method': 'app.exposeUrl', + 'params': { + 'url': 'http://localhost:123/', + } + }); + + // Allow the handler to be processed. + await pumpEventQueue(times: 5000); + + final Map message = adapter.flutterMessages.singleWhere((Map data) => data['id'] == requestId); + expect(message['result'], 'http://mapped-host:123/'); + }); + }); + group('--start-paused', () { test('is passed for debug mode', () async { final MockFlutterDebugAdapter adapter = MockFlutterDebugAdapter( diff --git a/packages/flutter_tools/test/general.shard/dap/mocks.dart b/packages/flutter_tools/test/general.shard/dap/mocks.dart index e53296153a3..c304b2cffd7 100644 --- a/packages/flutter_tools/test/general.shard/dap/mocks.dart +++ b/packages/flutter_tools/test/general.shard/dap/mocks.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:dds/dap.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/platform.dart'; @@ -21,11 +22,11 @@ class MockFlutterDebugAdapter extends FlutterDebugAdapter { final StreamController> stdinController = StreamController>(); final StreamController> stdoutController = StreamController>(); final ByteStreamServerChannel channel = ByteStreamServerChannel(stdinController.stream, stdoutController.sink, null); + final ByteStreamServerChannel clientChannel = ByteStreamServerChannel(stdoutController.stream, stdinController.sink, null); return MockFlutterDebugAdapter._( - stdinController.sink, - stdoutController.stream, channel, + clientChannel: clientChannel, fileSystem: fileSystem, platform: platform, simulateAppStarted: simulateAppStarted, @@ -33,22 +34,36 @@ class MockFlutterDebugAdapter extends FlutterDebugAdapter { } MockFlutterDebugAdapter._( - this.stdin, - this.stdout, - ByteStreamServerChannel channel, { - required FileSystem fileSystem, - required Platform platform, + super.channel, { + required this.clientChannel, + required super.fileSystem, + required super.platform, this.simulateAppStarted = true, - }) : super(channel, fileSystem: fileSystem, platform: platform); + }) { + clientChannel.listen((ProtocolMessage message) { + _handleDapToClientMessage(message); + }); + } - final StreamSink> stdin; - final Stream> stdout; + int _seq = 1; + final ByteStreamServerChannel clientChannel; final bool simulateAppStarted; late String executable; late List processArgs; late Map? env; - final List flutterRequests = []; + + /// A list of all messages sent to the `flutter run` processes `stdin`. + final List> flutterMessages = >[]; + + /// The `method`s of all requests send to the `flutter run` processes `stdin`. + List get flutterRequests => flutterMessages + .map((Map message) => message['method'] as String?) + .whereNotNull() + .toList(); + + /// A handler for the 'app.exposeUrl' reverse-request. + String Function(String)? exposeUrlHandler; @override Future launchAsProcess({ @@ -75,6 +90,39 @@ class MockFlutterDebugAdapter extends FlutterDebugAdapter { } } + /// Handles messages sent from the debug adapter back to the client. + void _handleDapToClientMessage(ProtocolMessage message) { + // Pretend to be the client, delegating any reverse-requests to the relevant + // handler that is provided by the test. + if (message is Event && message.event == 'flutter.forwardedRequest') { + final Map body = (message.body as Map?)!; + final String method = (body['method'] as String?)!; + final Map? params = body['params'] as Map?; + + final Object? result = _handleReverseRequest(method, params); + + // Send the result back in the same way the client would. + clientChannel.sendRequest(Request( + seq: _seq++, + command: 'flutter.sendForwardedRequestResponse', + arguments: { + 'id': body['id'], + 'result': result, + }, + )); + } + } + + Object? _handleReverseRequest(String method, Map? params) { + switch (method) { + case 'app.exposeUrl': + final String url = (params!['url'] as String?)!; + return exposeUrlHandler!(url); + default: + throw ArgumentError('Reverse-request $method is unknown'); + } + } + /// Simulates a message emitted by the `flutter run` process by directly /// calling the debug adapters [handleStdout] method. void simulateStdoutMessage(Map message) { @@ -84,13 +132,10 @@ class MockFlutterDebugAdapter extends FlutterDebugAdapter { } @override - Future sendFlutterRequest( - String method, - Map? params, { - bool failSilently = true, - }) { - flutterRequests.add(method); - return super.sendFlutterRequest(method, params, failSilently: failSilently); + void sendFlutterMessage(Map message) { + flutterMessages.add(message); + // Don't call super because it will try to write to the process that we + // didn't actually spawn. } @override