diff --git a/dwds/CHANGELOG.md b/dwds/CHANGELOG.md index c85939924..e87b3ff5a 100644 --- a/dwds/CHANGELOG.md +++ b/dwds/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +- Add support for the Dart Development Service (DDS). Introduces 'single + client mode', which prevents additional direct connections to DWDS when + DDS is connected. + ## 6.0.0 - Depend on the latest `package:devtools` and `package:devtools_server`. diff --git a/dwds/lib/src/dwds_vm_client.dart b/dwds/lib/src/dwds_vm_client.dart index 23194d1ee..805e4df16 100644 --- a/dwds/lib/src/dwds_vm_client.dart +++ b/dwds/lib/src/dwds_vm_client.dart @@ -21,6 +21,9 @@ class DwdsVmClient { final StreamController> _requestController; final StreamController> _responseController; + static const int kFeatureDisabled = 100; + static const String kFeatureDisabledMessage = 'Feature is disabled.'; + /// Null until [close] is called. /// /// All subsequent calls to [close] will return this future. @@ -100,6 +103,28 @@ class DwdsVmClient { }); await client.registerService('ext.dwds.screenshot', 'DWDS'); + client.registerServiceCallback('_yieldControlToDDS', (request) async { + final ddsUri = request['uri'] as String; + if (ddsUri == null) { + return RPCError( + request['method'] as String, + RPCError.kInvalidParams, + "'Missing parameter: 'uri'", + ).toMap(); + } + return DebugService.yieldControlToDDS(ddsUri) + ? {'result': Success().toJson()} + : { + 'error': { + 'code': kFeatureDisabled, + 'message': kFeatureDisabledMessage, + 'details': + 'Existing VM service clients prevent DDS from taking control.', + }, + }; + }); + await client.registerService('_yieldControlToDDS', 'DWDS'); + return DwdsVmClient(client, requestController, responseController); } } diff --git a/dwds/lib/src/services/debug_service.dart b/dwds/lib/src/services/debug_service.dart index c0b59d4e7..78db366a8 100644 --- a/dwds/lib/src/services/debug_service.dart +++ b/dwds/lib/src/services/debug_service.dart @@ -27,6 +27,9 @@ import '../utilities/shared.dart'; import 'chrome_proxy_service.dart'; import 'expression_compiler.dart'; +bool _acceptNewConnections = true; +int _clientsConnected = 0; + void Function(WebSocketChannel, String) _createNewConnectionHandler( ChromeProxyService chromeProxyService, ServiceExtensionRegistry serviceExtensionRegistry, { @@ -51,9 +54,18 @@ void Function(WebSocketChannel, String) _createNewConnectionHandler( if (onRequest != null) onRequest(request); return request; }); - + ++_clientsConnected; VmServerConnection(inputStream, responseController.sink, - serviceExtensionRegistry, chromeProxyService); + serviceExtensionRegistry, chromeProxyService) + .done + .whenComplete(() async { + --_clientsConnected; + if (!_acceptNewConnections && _clientsConnected == 0) { + // DDS has disconnected so we can allow for clients to connect directly + // to DWDS. + _acceptNewConnections = true; + } + }); }; } @@ -80,9 +92,18 @@ Future _handleSseConnections( if (onRequest != null) onRequest(request); return request; }); + ++_clientsConnected; var vmServerConnection = VmServerConnection(inputStream, responseController.sink, serviceExtensionRegistry, chromeProxyService); - unawaited(vmServerConnection.done.whenComplete(sub.cancel)); + unawaited(vmServerConnection.done.whenComplete(() { + --_clientsConnected; + if (!_acceptNewConnections && _clientsConnected == 0) { + // DDS has disconnected so we can allow for clients to connect directly + // to DWDS. + _acceptNewConnections = true; + } + return sub.cancel(); + })); } } @@ -90,6 +111,8 @@ Future _handleSseConnections( /// /// Creates a [ChromeProxyService] from an existing Chrome instance. class DebugService { + static String _ddsUri; + final VmServiceInterface chromeProxyService; final String hostname; final ServiceExtensionRegistry serviceExtensionRegistry; @@ -124,6 +147,15 @@ class DebugService { : Uri(scheme: 'ws', host: hostname, port: port, path: '$_authToken') .toString(); + static bool yieldControlToDDS(String uri) { + if (_clientsConnected > 1) { + return false; + } + _ddsUri = uri; + _acceptNewConnections = false; + return true; + } + static Future start( String hostname, RemoteDebugger remoteDebugger, @@ -163,6 +195,13 @@ class DebugService { chromeProxyService, serviceExtensionRegistry, onRequest: onRequest, onResponse: onResponse)); handler = (shelf.Request request) { + if (!_acceptNewConnections) { + return shelf.Response.forbidden( + 'Cannot connect directly to the VM service as a Dart Development ' + 'Service (DDS) instance has taken control and can be found at ' + '$_ddsUri.', + ); + } if (request.url.pathSegments.first != authToken) { return shelf.Response.forbidden('Incorrect auth token'); } diff --git a/dwds/test/debug_service_test.dart b/dwds/test/debug_service_test.dart index efabf0e64..47f177a4f 100644 --- a/dwds/test/debug_service_test.dart +++ b/dwds/test/debug_service_test.dart @@ -3,6 +3,8 @@ // BSD-style license that can be found in the LICENSE file. @TestOn('vm') +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:test/test.dart'; @@ -27,6 +29,46 @@ void main() { }); test('Accepts connections with the auth token', () async { - expect(WebSocket.connect('${context.debugConnection.uri}/ws'), completes); + expect( + WebSocket.connect('${context.debugConnection.uri}/ws') + .then((ws) => ws.close()), + completes); + }); + + test('Refuses additional connections when in single client mode', () async { + final ddsWs = await WebSocket.connect( + '${context.debugConnection.uri}/ws', + ); + final completer = Completer(); + ddsWs.listen((event) { + final response = json.decode(event as String); + expect(response['id'], '0'); + expect(response.containsKey('result'), isTrue); + final result = response['result'] as Map; + expect(result['type'], 'Success'); + completer.complete(); + }); + + const yieldControlToDDS = { + 'jsonrpc': '2.0', + 'id': '0', + 'method': '_yieldControlToDDS', + 'params': { + 'uri': 'http://localhost:123', + }, + }; + ddsWs.add(json.encode(yieldControlToDDS)); + await completer.future; + + // While DDS is connected, expect additional connections to fail. + await expectLater(WebSocket.connect('${context.debugConnection.uri}/ws'), + throwsA(isA())); + + // However, once DDS is disconnected, additional clients can connect again. + await ddsWs.close(); + expect( + WebSocket.connect('${context.debugConnection.uri}/ws') + .then((ws) => ws.close()), + completes); }); }