| // Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file |
| // for details. 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:convert'; |
| |
| import 'package:json_rpc_2/json_rpc_2.dart'; |
| import 'package:stream_channel/stream_channel.dart'; |
| import 'package:web_socket_channel/web_socket_channel.dart'; |
| |
| import '../dtd.dart'; |
| |
| typedef DTDServiceCallback = Future<Map<String, Object?>> Function( |
| Parameters params, |
| ); |
| |
| // TODO(danchevalier): add a serviceMethodIsAvailable experience. it will listen |
| // to a stream that announces servicemethods getting registered and |
| // unregistered. The state can then be presented as a listenable so that clients |
| // can gate their behaviour on a serviceMethod going up/down. |
| |
| /// A connection to a Dart Tooling Daemon instance. |
| /// |
| /// The base interactions for Dart Tooling Daemon are found here. |
| class DartToolingDaemon { |
| /// Connects to a Dart Tooling Daemon instance over the provided |
| /// [streamChannel]. |
| /// |
| /// To over a WebSocket, the [DartToolingDaemon.connect] helper can be used. |
| DartToolingDaemon.fromStreamChannel(StreamChannel<String> streamChannel) |
| : _clientPeer = Peer(streamChannel) { |
| _clientPeer.registerMethod('streamNotify', (Parameters params) { |
| final streamId = params['streamId'].asString; |
| final eventKind = params['eventKind'].asString; |
| final eventData = params['eventData'].asMap as Map<String, Object?>; |
| final timestamp = params['timestamp'].asInt; |
| |
| _subscribedStreamControllers[streamId]?.add( |
| DTDEvent( |
| streamId, |
| eventKind, |
| eventData, |
| timestamp, |
| ), |
| ); |
| }); |
| |
| _done = _clientPeer.listen(); |
| } |
| |
| /// Connects to a Dart Tooling Daemon instance. |
| /// |
| /// ```dart |
| /// final uri = Uri.parse('ws://127.0.0.1:59247/em6ZgeqMpvV8tOKg'); |
| /// final client = DartToolingDaemon.connect(uri); |
| /// ``` |
| static Future<DartToolingDaemon> connect(Uri uri) async { |
| final channel = WebSocketChannel.connect(uri); |
| await channel.ready; |
| return DartToolingDaemon.fromStreamChannel(channel.cast<String>()); |
| } |
| |
| late final Peer _clientPeer; |
| late final Future<void> _done; |
| final _subscribedStreamControllers = <String, StreamController<DTDEvent>>{}; |
| |
| /// Terminates the connection with the Dart Tooling Daemon. |
| Future<void> close() => _clientPeer.close(); |
| |
| /// A [Future] that completes when the connection with the Dart Tooling Daemon |
| /// is terminated. |
| Future<void> get done => _done; |
| |
| /// Whether or not the connection is closed. |
| bool get isClosed => _clientPeer.isClosed; |
| |
| /// Registers this client as the handler for the [service].[method] service |
| /// method. |
| /// |
| /// An optional map of [capabilities] can be supplied that will be provided |
| /// to clients listening for `ServiceRegistered` events. The use of this field |
| /// is service method specific. |
| /// |
| /// If the [service] has already been registered by another client, then an |
| /// [RpcException] with [RpcErrorCodes.kServiceAlreadyRegistered] is thrown. |
| /// Only one client at a time may register to a [service]. Once a client |
| /// disconnects then another client may register services under than name. |
| /// |
| /// If the [method] has already been registered on the [service], then an |
| /// [RpcException] with [RpcErrorCodes.kServiceMethodAlreadyRegistered] is |
| /// thrown. |
| Future<void> registerService( |
| String service, |
| String method, |
| DTDServiceCallback callback, { |
| Map<String, Object?>? capabilities, |
| }) async { |
| final combinedName = '$service.$method'; |
| await _clientPeer.sendRequest('registerService', { |
| 'service': service, |
| 'method': method, |
| if (capabilities != null) 'capabilities': capabilities, |
| }); |
| |
| _clientPeer.registerMethod( |
| combinedName, |
| callback, |
| ); |
| } |
| |
| /// Subscribes this client to events posted on [streamId]. |
| /// |
| /// Once called, the Dart Tooling Daemon will then send any events on the |
| /// [streamId] to this instance of [DartToolingDaemon]. See [onEvent] for |
| /// details on how to get access to that [Stream] of [DTDEvent]s. |
| /// |
| /// If this client is already subscribed to [streamId], an [RpcException] with |
| /// [RpcErrorCodes.kStreamAlreadySubscribed] will be thrown. |
| Future<void> streamListen(String streamId) { |
| return _clientPeer.sendRequest( |
| 'streamListen', |
| { |
| 'streamId': streamId, |
| }, |
| ); |
| } |
| |
| /// Cancel the subscription to [streamId]. |
| /// |
| /// Once called, this connection will no longer receive events posted on |
| /// [streamId]. |
| /// |
| /// If this client was not subscribed to [streamId], an [RpcException] with |
| /// [RpcErrorCodes.kStreamNotSubscribed] will be thrown. |
| Future<void> streamCancel(String streamId) { |
| return _clientPeer.sendRequest( |
| 'streamCancel', |
| { |
| 'streamId': streamId, |
| }, |
| ); |
| } |
| |
| /// Returns a broadcast [Stream] for events received on [streamId]. |
| /// |
| /// This method should be called and a listener added before calling |
| /// [streamListen] to ensure events aren't dropped. |
| Stream<DTDEvent> onEvent(String streamId) { |
| return _subscribedStreamControllers |
| .putIfAbsent( |
| streamId, |
| StreamController<DTDEvent>.broadcast, |
| ) |
| .stream; |
| } |
| |
| /// Posts a [DTDEvent] with [eventData] to [streamId]. |
| /// |
| /// The Dart Tooling Daemon will forward the [DTDEvent] to all clients that |
| /// have subscribed to [streamId] by calling [streamListen]. |
| /// |
| /// If no clients are listening to [streamId], the event will be dropped. |
| Future<void> postEvent( |
| String streamId, |
| String eventKind, |
| Map<String, Object?> eventData, |
| ) async { |
| await _clientPeer.sendRequest( |
| 'postEvent', |
| { |
| 'streamId': streamId, |
| 'eventKind': eventKind, |
| 'eventData': eventData, |
| }, |
| ); |
| } |
| |
| /// Invokes the service method registered with the name |
| /// `[serviceName].[methodName]`. |
| /// |
| /// If provided, [params] will be sent as the set of parameters used when |
| /// invoking the service. |
| /// |
| /// If `[serviceName].[methodName]` is not a registered service method, an |
| /// [RpcException] will be thrown with [RpcErrorCodes.kMethodNotFound]. |
| /// |
| /// If the parameters included in [params] are invalid, an [RpcException] will |
| /// be thrown with [RpcErrorCodes.kInvalidParams]. |
| Future<DTDResponse> call( |
| String serviceName, |
| String methodName, { |
| Map<String, Object?>? params, |
| }) async { |
| final json = await _clientPeer.sendRequest( |
| '$serviceName.$methodName', |
| params ?? <String, Object?>{}, |
| ) as Map<String, Object?>; |
| |
| final type = json['type'] as String?; |
| if (type == null) { |
| throw DartToolingDaemonConnectionException.callResponseMissingType(json); |
| } |
| |
| // TODO(danchevalier): Find out how to get access to the id. |
| return DTDResponse('-1', type, json); |
| } |
| |
| /// Reads the file at [uri] from disk in the environment where the Dart |
| /// Tooling Daemon is running. |
| /// |
| /// If [uri] is not contained in the IDE workspace roots, then an |
| /// [RpcException] with [RpcErrorCodes.kPermissionDenied] is thrown. |
| /// |
| /// If [uri] does not exist, then an [RpcException] exception with error |
| /// code [RpcErrorCodes.kFileDoesNotExist] is thrown. |
| /// |
| /// If [uri] does not have a file scheme, then an [RpcException] with |
| /// [RpcErrorCodes.kExpectsUriParamWithFileScheme] is thrown. |
| Future<FileContent> readFileAsString( |
| Uri uri, { |
| Encoding encoding = utf8, |
| }) async { |
| final result = await call( |
| kFileSystemServiceName, |
| 'readFileAsString', |
| params: { |
| 'uri': uri.toString(), |
| 'encoding': encoding.name, |
| }, |
| ); |
| return FileContent.fromDTDResponse(result); |
| } |
| |
| /// Writes [contents] to the file at [uri] in the environment where the Dart |
| /// Tooling Daemon is running. |
| /// |
| /// The file will be created if it does not exist, and it will be overwritten |
| /// if it already exist. |
| /// |
| /// If [uri] is not contained in the IDE workspace roots, then an |
| /// [RpcException] with [RpcErrorCodes.kPermissionDenied] is thrown. |
| /// |
| /// If [uri] does not have a file scheme, then an [RpcException] with |
| /// [RpcErrorCodes.kExpectsUriParamWithFileScheme] is thrown. |
| Future<void> writeFileAsString( |
| Uri uri, |
| String contents, { |
| Encoding encoding = utf8, |
| }) async { |
| await call( |
| kFileSystemServiceName, |
| 'writeFileAsString', |
| params: { |
| 'uri': uri.toString(), |
| 'contents': contents, |
| 'encoding': encoding.name, |
| }, |
| ); |
| } |
| |
| /// Lists the directories and files under the directory at [uri] in the |
| /// environment where the Dart Tooling Daemon is running. |
| /// |
| /// If [uri] is not a directory, throws an [RpcException] exception with error |
| /// code [RpcErrorCodes.kDirectoryDoesNotExist]. |
| /// |
| /// If [uri] is not contained in the IDE workspace roots, then an |
| /// [RpcException] with [RpcErrorCodes.kPermissionDenied] is thrown. |
| /// |
| /// If [uri] does not have a file scheme, then an [RpcException] with |
| /// [RpcErrorCodes.kExpectsUriParamWithFileScheme] is thrown. |
| /// |
| /// The returned uris will be `file://` Uris. |
| Future<UriList> listDirectoryContents(Uri uri) async { |
| final result = await call( |
| kFileSystemServiceName, |
| 'listDirectoryContents', |
| params: { |
| 'uri': uri.toString(), |
| }, |
| ); |
| return UriList.fromDTDResponse(result); |
| } |
| |
| /// Sets the IDE workspace roots for the FileSystem service. |
| /// |
| /// This is a privileged RPC that require's a [secret], which is provided by |
| /// the Dart Tooling Daemon, to be called successfully. This secret is |
| /// generated by the daemon and provided to its spawner to ensure only trusted |
| /// clients can set workspace roots. If [secret] is invalid, an [RpcException] |
| /// with error code [RpcErrorCodes.kPermissionDenied] is thrown. |
| /// |
| /// If [secret] does not match the secret created when Dart Tooling Daemon was |
| /// created, then an [RpcException] with [RpcErrorCodes.kPermissionDenied] is |
| /// thrown. |
| /// |
| /// If one of the [roots] is missing a "file" scheme then an [RpcException] |
| /// with [RpcErrorCodes.kExpectsUriParamWithFileScheme] is thrown. |
| Future<void> setIDEWorkspaceRoots(String secret, List<Uri> roots) async { |
| await call( |
| kFileSystemServiceName, |
| 'setIDEWorkspaceRoots', |
| params: { |
| 'roots': roots.map<String>((e) => e.toString()).toList(), |
| 'secret': secret, |
| }, |
| ); |
| } |
| |
| /// Gets the IDE workspace roots for the FileSystem service. |
| /// |
| /// The returned uris will be `file://` Uris. |
| Future<IDEWorkspaceRoots> getIDEWorkspaceRoots() async { |
| final result = await call( |
| kFileSystemServiceName, |
| 'getIDEWorkspaceRoots', |
| ); |
| return IDEWorkspaceRoots.fromDTDResponse(result); |
| } |
| |
| /// Gets the project roots contained within the current set of IDE workspace |
| /// roots. |
| /// |
| /// A project root is any directory that contains a 'pubspec.yaml' file. If |
| /// IDE workspace roots are not set, or if there are no project roots within |
| /// the IDE workspace roots, this method will return an empty [UriList]. |
| /// |
| /// [depth] is the maximum depth that each IDE workspace root directory tree |
| /// will be searched for project roots. |
| /// |
| /// The returned uris will be `file://` Uris. |
| Future<UriList> getProjectRoots({ |
| int depth = defaultGetProjectRootsDepth, |
| }) async { |
| final result = await call( |
| kFileSystemServiceName, |
| 'getProjectRoots', |
| params: {'depth': depth}, |
| ); |
| return UriList.fromDTDResponse(result); |
| } |
| } |
| |
| /// Represents the response of an RPC call to the Dart Tooling Daemon. |
| class DTDResponse { |
| DTDResponse(this._id, this._type, this._result); |
| |
| DTDResponse.fromDTDResponse(DTDResponse other) |
| : this( |
| other.id, |
| other.type, |
| other.result, |
| ); |
| final String _id; |
| final String _type; |
| final Map<String, Object?> _result; |
| |
| String get id => _id; |
| |
| String get type => _type; |
| |
| Map<String, Object?> get result => _result; |
| } |
| |
| /// A Dart Tooling Daemon stream event. |
| class DTDEvent { |
| DTDEvent(this.stream, this.kind, this.data, this.timestamp); |
| String stream; |
| int timestamp; |
| String kind; |
| Map<String, Object?> data; |
| |
| @override |
| String toString() { |
| return jsonEncode({ |
| 'stream': stream, |
| 'timestamp': timestamp, |
| 'kind': kind, |
| 'data': data, |
| }); |
| } |
| } |
| |
| class DartToolingDaemonConnectionException implements Exception { |
| static const int callParamsMissingTypeError = 1; |
| |
| /// The response to a call method is missing the top level type parameter. |
| factory DartToolingDaemonConnectionException.callResponseMissingType( |
| Map<String, Object?> json, |
| ) { |
| return DartToolingDaemonConnectionException._( |
| callParamsMissingTypeError, |
| 'call received an invalid response, ' |
| "it is missing the 'type' param. Got: $json", |
| ); |
| } |
| DartToolingDaemonConnectionException._(this.errorCode, this.message); |
| |
| @override |
| String toString() => 'DartToolingDaemonConnectionException: $message'; |
| |
| final int errorCode; |
| final String message; |
| } |