| // Copyright (c) 2025, 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:collection/collection.dart'; |
| import 'package:dart_mcp/server.dart'; |
| import 'package:dds_service_extensions/dds_service_extensions.dart'; |
| import 'package:dtd/dtd.dart'; |
| import 'package:json_rpc_2/json_rpc_2.dart'; |
| import 'package:meta/meta.dart'; |
| import 'package:unified_analytics/unified_analytics.dart' as ua; |
| import 'package:vm_service/vm_service.dart'; |
| import 'package:vm_service/vm_service_io.dart'; |
| import 'package:web_socket/web_socket.dart'; |
| |
| import '../features_configuration.dart'; |
| import '../utils/analytics.dart'; |
| import '../utils/names.dart'; |
| import '../utils/process_manager.dart'; |
| import '../utils/sdk.dart'; |
| import '../utils/uuid.dart'; |
| |
| /// Constants used by the MCP server to register services on DTD. |
| /// |
| /// TODO(elliette): Add these to package:dtd instead. |
| extension McpServiceConstants on Never { |
| /// Service name for the Dart MCP Server. |
| static const serviceName = 'DartMcpServer'; |
| |
| /// Service method name for the method to send a sampling request to the MCP |
| /// client. |
| static const samplingRequest = 'samplingRequest'; |
| } |
| |
| /// Mix this in to any MCPServer to add support for connecting to the Dart |
| /// Tooling Daemon and all of its associated functionality (see |
| /// https://pub.dev/packages/dtd). |
| /// |
| /// The MCPServer must already have the [ToolsSupport] mixin applied. |
| base mixin DartToolingDaemonSupport |
| on ToolsSupport, LoggingSupport, ResourcesSupport, SdkSupport |
| implements AnalyticsSupport, ProcessManagerSupport { |
| /// The DTD instances that this server is connected to. |
| final List<DartToolingDaemon> _dtds = []; |
| |
| @visibleForTesting |
| Iterable<DartToolingDaemon> get dtds => _dtds; |
| |
| /// A Map of [VmService] object [Future]s by their VM Service URI. |
| /// |
| /// [VmService] objects are automatically removed from the Map when they |
| /// are unregistered via DTD or when the VM service shuts down. |
| @visibleForTesting |
| final activeVmServices = <String, Future<VmService>>{}; |
| |
| /// Whether to await the disposal of all [VmService] objects in |
| /// [activeVmServices] upon server shutdown or loss of DTD connection. |
| /// |
| /// Defaults to false but can be flipped to true for testing purposes. |
| @visibleForTesting |
| static bool debugAwaitVmServiceDisposal = false; |
| |
| /// The id for the object group used when calling Flutter Widget |
| /// Inspector service extensions from DTD tools. |
| @visibleForTesting |
| static const inspectorObjectGroup = 'dart-tooling-mcp-server'; |
| |
| /// The prefix for Flutter Widget Inspector service extensions. |
| /// |
| /// See https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/service_extensions.dart#L126 |
| /// for full list of available Flutter Widget Inspector service extensions. |
| static const _inspectorServiceExtensionPrefix = 'ext.flutter.inspector'; |
| |
| /// A unique identifier for this server instance. |
| /// |
| /// This is generated on first access and then cached. It is used to create |
| /// a unique service name when registering services on DTD. |
| /// |
| /// Can only be accessed after `initialize` has been called. |
| String get clientId { |
| if (_clientId != null) return _clientId!; |
| final clientName = clientInfo.title ?? clientInfo.name; |
| _clientId = generateClientId(clientName); |
| return _clientId!; |
| } |
| |
| String? _clientId; |
| |
| @visibleForTesting |
| String generateClientId(String clientName) { |
| // Sanitizes the client name by: |
| // 1. replacing whitespace, '-', and '.' with '_' |
| // 2. removing all non-alphanumeric characters except '_' |
| final sanitizedClientName = clientName |
| .trim() |
| .toLowerCase() |
| .replaceAll(RegExp(r'[\s\.\-]+'), '_') |
| .replaceAll(RegExp(r'[^a-zA-Z0-9_]'), ''); |
| return '${sanitizedClientName}_${generateShortUUID()}'; |
| } |
| |
| /// Called when the DTD connection is lost or explicitly disconnected, resets |
| /// all associated state. |
| Future<void> _resetDtd(DartToolingDaemon dtd) async { |
| _dtds.remove(dtd); |
| await dtd.close(); |
| |
| // TODO: determine whether we need to dispose the [inspectorObjectGroup] on |
| // the Flutter Widget Inspector for each VM service instance. |
| final vmServiceUris = dtd.vmServiceUris; |
| |
| final future = Future.wait( |
| vmServiceUris.map((uri) async { |
| try { |
| await (await activeVmServices.remove(uri))?.dispose(); |
| } catch (_) {} |
| }), |
| ); |
| debugAwaitVmServiceDisposal ? await future : unawaited(future); |
| } |
| |
| @visibleForTesting |
| Future<void> updateActiveVmServices(DartToolingDaemon dtd) async { |
| if (!dtd.supportsConnectedApps) return; |
| |
| final vmServiceInfos = (await dtd.getVmServices()).vmServicesInfos; |
| if (vmServiceInfos.isEmpty) return; |
| |
| for (final vmServiceInfo in vmServiceInfos) { |
| final vmServiceUri = vmServiceInfo.uri; |
| if (activeVmServices.containsKey(vmServiceUri)) { |
| continue; |
| } |
| |
| dtd.vmServiceUris.add(vmServiceUri); |
| |
| final vmServiceFuture = activeVmServices[vmServiceUri] = |
| vmServiceConnectUri(vmServiceUri); |
| final vmService = await vmServiceFuture; |
| // Start listening for and collecting errors immediately. |
| final errorService = await _AppListener.forVmService(vmService, this); |
| final resource = Resource( |
| uri: '$runtimeErrorsScheme://${vmService.id}', |
| name: 'Errors for app ${vmServiceInfo.name}', |
| description: |
| 'Recent runtime errors seen for app "${vmServiceInfo.name}".', |
| ); |
| addResource(resource, (request) async { |
| final watch = Stopwatch()..start(); |
| final result = ReadResourceResult( |
| contents: [ |
| for (var error in errorService.errorLog.errors) |
| TextResourceContents(uri: resource.uri, text: error), |
| ], |
| ); |
| watch.stop(); |
| try { |
| analytics?.send( |
| ua.Event.dartMCPEvent( |
| client: clientInfo.name, |
| clientVersion: clientInfo.version, |
| serverVersion: implementation.version, |
| type: AnalyticsEvent.readResource.name, |
| additionalData: ReadResourceMetrics( |
| kind: ResourceKind.runtimeErrors, |
| length: result.contents.length, |
| elapsedMilliseconds: watch.elapsedMilliseconds, |
| ), |
| ), |
| ); |
| } catch (e) { |
| log(LoggingLevel.warning, 'Error sending analytics event: $e'); |
| } |
| return result; |
| }); |
| try { |
| errorService.errorsStream.listen((_) => updateResource(resource)); |
| } catch (_) {} |
| unawaited( |
| vmService.onDone.then((_) { |
| removeResource(resource.uri); |
| activeVmServices.remove(vmServiceUri); |
| }), |
| ); |
| } |
| } |
| |
| @override |
| FutureOr<InitializeResult> initialize(InitializeRequest request) async { |
| registerTool(dtdTool, _dtd); |
| registerTool(getRuntimeErrorsTool, runtimeErrors); |
| registerTool(getActiveLocationTool, _getActiveLocation); |
| registerTool(hotRestartTool, hotRestart); |
| registerTool(hotReloadTool, hotReload); |
| registerTool(widgetInspectorTool, _widgetInspector); |
| registerTool(flutterDriverTool, _callFlutterDriver); |
| |
| return super.initialize(request); |
| } |
| |
| @visibleForTesting |
| static final List<Tool> allTools = [ |
| dtdTool, |
| getRuntimeErrorsTool, |
| getActiveLocationTool, |
| hotRestartTool, |
| hotReloadTool, |
| widgetInspectorTool, |
| flutterDriverTool, |
| ]; |
| |
| @override |
| Future<void> shutdown() async { |
| await Future.wait(_dtds.toList().map(_resetDtd)); |
| await super.shutdown(); |
| } |
| |
| Future<CallToolResult> _callFlutterDriver(CallToolRequest request) async { |
| final appUri = request.arguments?[ParameterNames.appUri] as String?; |
| return _callOnVmService( |
| appUri: appUri, |
| callback: (vmService) async { |
| final appListener = await _AppListener.forVmService(vmService, this); |
| if (!appListener.registeredServices.containsKey( |
| _flutterDriverService, |
| )) { |
| return _flutterDriverNotRegistered; |
| } |
| final vm = await vmService.getVM(); |
| final timeout = request.arguments?['timeout'] as String?; |
| final isScreenshot = |
| request.arguments?[ParameterNames.command] == 'screenshot'; |
| if (isScreenshot) { |
| request.arguments?.putIfAbsent('format', () => '4' /*png*/); |
| } |
| // jsonEncode nested finder maps for Ancestor/Descendant finders. |
| for (final key in const ['of', 'matching']) { |
| if (request.arguments?[key] is Map) { |
| request.arguments![key] = jsonEncode(request.arguments![key]); |
| } |
| } |
| final result = await vmService |
| .callServiceExtension( |
| _flutterDriverService, |
| isolateId: vm.isolates!.first.id, |
| args: request.arguments, |
| ) |
| .timeout( |
| Duration( |
| milliseconds: timeout != null |
| ? int.parse(timeout) |
| : _defaultTimeoutMs, |
| ), |
| onTimeout: () => Response.parse({ |
| 'isError': true, |
| 'error': 'Timed out waiting for Flutter Driver response.', |
| })!, |
| ); |
| return CallToolResult( |
| content: [ |
| isScreenshot && result.json?['isError'] == false |
| ? Content.image( |
| data: |
| (result.json!['response'] |
| as Map<String, Object?>)['data'] |
| as String, |
| mimeType: 'image/png', |
| ) |
| : Content.text(text: jsonEncode(result.json)), |
| ], |
| isError: result.json?['isError'] as bool?, |
| ); |
| }, |
| ); |
| } |
| |
| /// Connects to the Dart Tooling Daemon. |
| /// Connects to a single DTD at [uri]. |
| Future<CallToolResult> _connectToDtdSingle(Uri uri) async { |
| if (_dtds.any((dtd) => dtd.uri == uri)) { |
| return _dtdAlreadyConnected; |
| } |
| |
| try { |
| final dtd = await DartToolingDaemon.connect(uri); |
| |
| // Verification step (check if it's VM service instead of DTD) |
| try { |
| await dtd.call(null, 'getVM'); |
| // If the call above succeeds, we were connected to the vm service, and |
| // should error. |
| await dtd.close(); |
| return _gotVmServiceUri; |
| } on RpcException catch (e) { |
| // Double check the failure was a method not found failure, if not |
| // rethrow it. |
| if (e.code != RpcErrorCodes.kMethodNotFound) { |
| await dtd.close(); |
| rethrow; |
| } |
| } |
| |
| _dtds.add(dtd); |
| dtd.uri = uri; |
| unawaited(dtd.done.then((_) async => await _resetDtd(dtd))); |
| |
| await _registerServices(dtd); |
| await _listenForServices(dtd); |
| |
| // Try to get the initial list of apps. |
| await updateActiveVmServices(dtd); |
| |
| final connectedApps = activeVmServices.keys.toList(); |
| final appListString = connectedApps.isEmpty |
| ? 'No apps currently connected.' |
| : 'Connected apps:\n${connectedApps.map((id) => '- $id').join('\n')}'; |
| |
| return CallToolResult( |
| content: [ |
| TextContent(text: 'Connection succeeded to $uri. $appListString'), |
| ], |
| ); |
| } on WebSocketException catch (_) { |
| return CallToolResult( |
| isError: true, |
| content: [ |
| Content.text( |
| text: 'Connection failed, make sure your DTD Uri is up to date.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.webSocketException; |
| } catch (e) { |
| return CallToolResult( |
| isError: true, |
| content: [Content.text(text: 'Connection failed: $e')], |
| )..failureReason = CallToolFailureReason.unhandledError; |
| } |
| } |
| |
| /// Connects to the Dart Tooling Daemon. |
| FutureOr<CallToolResult> _connect(CallToolRequest request) async { |
| final uriString = request.arguments?[ParameterNames.uri] as String?; |
| |
| if (uriString != null) { |
| return _connectToDtdSingle(Uri.parse(uriString)); |
| } |
| |
| // Attempt automatic discovery |
| final (instances, error) = await _listRunningDtdInstances(); |
| if (error != null) { |
| return error; |
| } |
| if (instances.isEmpty) { |
| return CallToolResult( |
| content: [ |
| Content.text( |
| text: |
| 'No running DTD instances found for automatic discovery. ' |
| 'Please provide a URI.', |
| ), |
| ], |
| ); |
| } |
| |
| final connectedUrls = <String>[]; |
| final failedUrls = <String>[]; |
| |
| for (final instance in instances) { |
| final selectedWsUri = instance['wsUri'] as String?; |
| if (selectedWsUri == null) continue; |
| |
| final uri = Uri.parse(selectedWsUri); |
| final result = await _connectToDtdSingle(uri); |
| |
| if (result.isError == true) { |
| if (result.failureReason == CallToolFailureReason.dtdAlreadyConnected) { |
| connectedUrls.add('$uri (Already connected)'); |
| } else { |
| final errorMessage = |
| result.content.isNotEmpty && result.content.first is TextContent |
| ? (result.content.first as TextContent).text |
| : 'Unknown error'; |
| failedUrls.add('$uri ($errorMessage)'); |
| } |
| } else { |
| connectedUrls.add('$uri'); |
| } |
| } |
| |
| final appUris = activeVmServices.keys.toList(); |
| final appListString = appUris.isEmpty |
| ? 'No apps currently connected.' |
| : 'Connected apps:\n${appUris.map((a) => '- $a').join('\n')}'; |
| |
| final textResult = StringBuffer(); |
| textResult.writeln('Automatic discovery finished.'); |
| if (connectedUrls.isNotEmpty) { |
| textResult.writeln( |
| 'Connected to:\n${connectedUrls.map((u) => '- $u').join('\n')}', |
| ); |
| } |
| if (failedUrls.isNotEmpty) { |
| textResult.writeln( |
| 'Failed to connect to:\n${failedUrls.map((u) => '- $u').join('\n')}', |
| ); |
| } |
| textResult.writeln(appListString); |
| |
| return CallToolResult( |
| content: [TextContent(text: textResult.toString().trim())], |
| ); |
| } |
| |
| /// Lists running DTD instances by querying the dart executable. |
| Future<(List<Map<String, Object?>>, CallToolResult?)> |
| _listRunningDtdInstances() async { |
| try { |
| final result = await processManager.run([ |
| sdk.dartExecutablePath, |
| 'tooling-daemon', |
| '--list', |
| '--machine', |
| ]); |
| if (result.exitCode != 0) { |
| log( |
| LoggingLevel.warning, |
| 'dart tooling-daemon --list failed: ${result.stderr}', |
| ); |
| return ( |
| <Map<String, Object?>>[], |
| CallToolResult( |
| isError: true, |
| content: [ |
| Content.text( |
| text: 'dart tooling-daemon --list failed: ${result.stderr}', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.nonZeroExitCode, |
| ); |
| } |
| final output = result.stdout as String; |
| if (output.trim().isEmpty) return (<Map<String, Object?>>[], null); |
| final parsed = jsonDecode(output); |
| if (parsed is List) { |
| return (parsed.cast<Map<String, Object?>>(), null); |
| } |
| return ( |
| <Map<String, Object?>>[], |
| CallToolResult( |
| isError: true, |
| content: [Content.text(text: 'Unexpected JSON format from DTD list')], |
| )..failureReason = CallToolFailureReason.unhandledError, |
| ); |
| } catch (e) { |
| log(LoggingLevel.warning, 'Error listing DTD instances: $e'); |
| return ( |
| <Map<String, Object?>>[], |
| CallToolResult( |
| isError: true, |
| content: [Content.text(text: 'Error listing DTD instances: $e')], |
| )..failureReason = CallToolFailureReason.unhandledError, |
| ); |
| } |
| } |
| |
| /// The [dtdTool] for managing DTD connections. |
| static final dtdTool = Tool( |
| name: ToolNames.dtd.name, |
| description: |
| 'Connects to, disconnects from, or lists apps connected to the ' |
| 'Dart Tooling Daemon.', |
| inputSchema: Schema.object( |
| properties: { |
| ParameterNames.command: EnumSchema.untitledSingleSelect( |
| description: 'The command to execute.', |
| values: [ |
| DtdCommand.connect, |
| DtdCommand.disconnect, |
| DtdCommand.listConnectedApps, |
| ], |
| ), |
| ParameterNames.uri: Schema.string( |
| description: |
| 'The DTD URI to connect to or disconnect from. ' |
| 'Optional for "connect" (triggers automatic discovery), ' |
| 'optional for "disconnect" (if only one DTD is connected).', |
| ), |
| }, |
| required: [ParameterNames.command], |
| additionalProperties: false, |
| ), |
| annotations: ToolAnnotations(title: 'Dart Tooling Daemon'), |
| )..categories = [FeatureCategory.dartToolingDaemon]; |
| |
| Future<CallToolResult> _dtd(CallToolRequest request) async { |
| final command = request.arguments![ParameterNames.command] as String; |
| switch (command) { |
| case DtdCommand.connect: |
| return _connect(request); |
| case DtdCommand.disconnect: |
| return _disconnect(request); |
| case DtdCommand.listConnectedApps: |
| return _listConnectedApps(request); |
| default: |
| return CallToolResult( |
| isError: true, |
| content: [TextContent(text: 'Unknown command: $command')], |
| ); |
| } |
| } |
| |
| Future<CallToolResult> _listConnectedApps(CallToolRequest request) async { |
| if (_dtds.isEmpty) return _dtdNotConnected; |
| |
| // Ensure lists are up to date |
| for (final dtd in _dtds) { |
| await updateActiveVmServices(dtd); |
| } |
| |
| final appUris = activeVmServices.keys.toList(); |
| return CallToolResult( |
| content: [ |
| TextContent( |
| text: appUris.isEmpty |
| ? 'No connected apps found.' |
| : 'Connected apps:\n' |
| '${appUris.map((a) => '- $a').join('\n')}', |
| ), |
| ], |
| structuredContent: {ParameterNames.apps: appUris}, |
| ); |
| } |
| |
| Future<CallToolResult> _disconnect(CallToolRequest request) async { |
| var uriString = request.arguments?[ParameterNames.uri] as String?; |
| if (uriString == null) { |
| if (_dtds.isEmpty) { |
| return CallToolResult( |
| content: [TextContent(text: 'No active DTD connections.')], |
| ); |
| } |
| if (_dtds.length > 1) { |
| return CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'Multiple DTD connections active. You must specify which one ' |
| 'to disconnect.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.mustSpecifyDtdUri; |
| } |
| uriString = _dtds.first.uri.toString(); |
| } |
| |
| final uri = Uri.parse(uriString); |
| final dtd = _dtds.firstWhereOrNull((dtd) => dtd.uri == uri); |
| if (dtd == null) { |
| return CallToolResult( |
| isError: true, |
| content: [TextContent(text: 'Not connected to DTD at $uri')], |
| )..failureReason = CallToolFailureReason.alreadyDisconnected; |
| } |
| await _resetDtd(dtd); |
| return CallToolResult(content: [TextContent(text: 'Disconnected.')]); |
| } |
| |
| /// Registers all MCP server-provided services on the connected DTD instance. |
| Future<void> _registerServices(DartToolingDaemon dtd) async { |
| if (clientCapabilities.sampling != null) { |
| await dtd.registerService( |
| '${McpServiceConstants.serviceName}_$clientId', |
| McpServiceConstants.samplingRequest, |
| _handleSamplingRequest, |
| ); |
| } |
| } |
| |
| Future<Map<String, Object?>> _handleSamplingRequest(Parameters params) async { |
| final result = await createMessage( |
| CreateMessageRequest.fromMap(params.asMap.cast<String, Object?>()), |
| ); |
| |
| return { |
| 'type': 'Success', // Type is required by DTD. |
| ...result.toJson(), |
| }; |
| } |
| |
| /// Listens to the `ConnectedApp` and `Editor` streams to get app and IDE |
| /// state information. |
| /// |
| /// The dart tooling daemon must be connected prior to calling this function. |
| Future<void> _listenForServices(DartToolingDaemon dtd) async { |
| dtd.supportsConnectedApps = false; |
| try { |
| final registeredServices = await dtd.getRegisteredServices(); |
| if (registeredServices.dtdServices.contains( |
| '${ConnectedAppServiceConstants.serviceName}.' |
| '${ConnectedAppServiceConstants.getVmServices}', |
| )) { |
| dtd.supportsConnectedApps = true; |
| } |
| } catch (_) {} |
| |
| if (dtd.supportsConnectedApps) { |
| await _listenForConnectedAppServiceEvents(dtd); |
| } |
| await _listenForEditorEvents(dtd); |
| } |
| |
| Future<void> _listenForConnectedAppServiceEvents( |
| DartToolingDaemon dtd, |
| ) async { |
| dtd.onVmServiceUpdate().listen((e) async { |
| log(LoggingLevel.debug, e.toString()); |
| switch (e.kind) { |
| case ConnectedAppServiceConstants.vmServiceRegistered: |
| await updateActiveVmServices(dtd); |
| case ConnectedAppServiceConstants.vmServiceUnregistered: |
| // We can remove it regardless of which DTD it came from since the URI |
| //is unique |
| await activeVmServices |
| .remove(e.data['uri'] as String) |
| ?.then((service) => service.dispose()); |
| default: |
| } |
| }); |
| await dtd.streamListen(ConnectedAppServiceConstants.serviceName); |
| } |
| |
| /// Listens for editor specific events. |
| Future<void> _listenForEditorEvents(DartToolingDaemon dtd) async { |
| dtd.onEvent('Editor').listen((e) async { |
| log(LoggingLevel.debug, e.toString()); |
| switch (e.kind) { |
| case 'activeLocationChanged': |
| dtd.activeLocation = e.data; |
| default: |
| } |
| }); |
| await dtd.streamListen('Editor'); |
| } |
| |
| /// Performs a hot restart on the currently running app. |
| /// |
| /// If more than one debug session is active, then it just uses the first |
| /// one. |
| // TODO: support passing a debug session id when there is more than one |
| // debug session. |
| Future<CallToolResult> hotRestart(CallToolRequest request) async { |
| final appUri = request.arguments?[ParameterNames.appUri] as String?; |
| return _callOnVmService( |
| appUri: appUri, |
| callback: (vmService) async { |
| final appListener = await _AppListener.forVmService(vmService, this); |
| appListener.errorLog.clear(); |
| |
| final vm = await vmService.getVM(); |
| var success = false; |
| try { |
| final hotRestartMethodName = |
| (await appListener.waitForServiceRegistration('hotRestart')) ?? |
| 'hotRestart'; |
| |
| /// If we haven't seen a specific one, we just call the default one. |
| final result = await vmService.callMethod( |
| hotRestartMethodName, |
| isolateId: vm.isolates!.first.id, |
| ); |
| final resultType = result.json?['type']; |
| success = resultType == 'Success'; |
| } catch (e) { |
| // Handle potential errors during the process |
| return CallToolResult( |
| isError: true, |
| content: [TextContent(text: 'Hot restart failed: $e')], |
| )..failureReason = CallToolFailureReason.unhandledError; |
| } |
| return CallToolResult( |
| isError: !success ? true : null, |
| content: [ |
| TextContent( |
| text: 'Hot restart ${success ? 'succeeded' : 'failed'}.', |
| ), |
| ], |
| ); |
| }, |
| ); |
| } |
| |
| /// Performs a hot reload on the currently running app. |
| /// |
| /// If more than one debug session is active, then it just uses the first one. |
| /// |
| // TODO: support passing a debug session id when there is more than one debug |
| // session. |
| Future<CallToolResult> hotReload(CallToolRequest request) async { |
| final appUri = request.arguments?[ParameterNames.appUri] as String?; |
| return _callOnVmService( |
| appUri: appUri, |
| callback: (vmService) async { |
| final appListener = await _AppListener.forVmService(vmService, this); |
| if (request.arguments?['clearRuntimeErrors'] == true) { |
| appListener.errorLog.clear(); |
| } |
| |
| final vm = await vmService.getVM(); |
| ReloadReport? report; |
| |
| try { |
| final hotReloadMethodName = await appListener |
| .waitForServiceRegistration('reloadSources'); |
| |
| /// If we haven't seen a specific one, we just call the default one. |
| if (hotReloadMethodName == null) { |
| report = await vmService.reloadSources(vm.isolates!.first.id!); |
| } else { |
| final result = await vmService.callMethod( |
| hotReloadMethodName, |
| isolateId: vm.isolates!.first.id, |
| ); |
| final resultType = result.json?['type']; |
| if (resultType == 'Success' || |
| (resultType == 'ReloadReport' && |
| result.json?['success'] == true)) { |
| report = ReloadReport(success: true); |
| } else { |
| report = ReloadReport(success: false); |
| } |
| } |
| } catch (e) { |
| // Handle potential errors during the process |
| return CallToolResult( |
| isError: true, |
| content: [TextContent(text: 'Hot reload failed: $e')], |
| )..failureReason = CallToolFailureReason.unhandledError; |
| } |
| final success = report.success == true; |
| return CallToolResult( |
| isError: !success ? true : null, |
| content: [ |
| TextContent( |
| text: 'Hot reload ${success ? 'succeeded' : 'failed'}.', |
| ), |
| ], |
| ) |
| ..failureReason = !success |
| ? CallToolFailureReason.wrappedServiceIssue |
| : null; |
| }, |
| ); |
| } |
| |
| /// Retrieves runtime errors from the currently running app. |
| /// |
| /// If more than one debug session is active, then it just uses the first one. |
| /// |
| // TODO: support passing a debug session id when there is more than one debug |
| // session. |
| Future<CallToolResult> runtimeErrors(CallToolRequest request) async { |
| final appUri = request.arguments?[ParameterNames.appUri] as String?; |
| return _callOnVmService( |
| appUri: appUri, |
| callback: (vmService) async { |
| try { |
| final errorService = await _AppListener.forVmService(vmService, this); |
| final errorLog = errorService.errorLog; |
| |
| if (errorLog.errors.isEmpty) { |
| return CallToolResult( |
| content: [TextContent(text: 'No runtime errors found.')], |
| ); |
| } |
| final result = CallToolResult( |
| content: [ |
| TextContent( |
| text: |
| 'Found ${errorLog.errors.length} ' |
| 'error${errorLog.errors.length == 1 ? '' : 's'}:\n', |
| ), |
| for (final e in errorLog.errors) TextContent(text: e.toString()), |
| ], |
| ); |
| if (request.arguments?['clearRuntimeErrors'] == true) { |
| errorService.errorLog.clear(); |
| } |
| return result; |
| } catch (e) { |
| return CallToolResult( |
| isError: true, |
| content: [TextContent(text: 'Failed to get runtime errors: $e')], |
| )..failureReason = CallToolFailureReason.unhandledError; |
| } |
| }, |
| ); |
| } |
| |
| /// Dispatches to the appropriate widget inspector command. |
| Future<CallToolResult> _widgetInspector(CallToolRequest request) async { |
| final command = request.arguments?[ParameterNames.command] as String?; |
| return switch (command) { |
| WidgetInspectorCommand.getWidgetTree => _widgetTree(request), |
| WidgetInspectorCommand.getSelectedWidget => _selectedWidget(request), |
| WidgetInspectorCommand.setWidgetSelectionMode => _setWidgetSelectionMode( |
| request, |
| ), |
| _ => CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'Unknown command "$command". Must be one of: ' |
| '${WidgetInspectorCommand.getWidgetTree}, ' |
| '${WidgetInspectorCommand.getSelectedWidget}, ' |
| '${WidgetInspectorCommand.setWidgetSelectionMode}.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.argumentError, |
| }; |
| } |
| |
| /// Retrieves the Flutter widget tree from the currently running app. |
| /// |
| /// If more than one debug session is active, then it just uses the first one. |
| /// |
| // TODO: support passing a debug session id when there is more than one debug |
| // session. |
| @visibleForTesting |
| Future<CallToolResult> widgetTree(CallToolRequest request) => |
| _widgetTree(request); |
| |
| Future<CallToolResult> _widgetTree(CallToolRequest request) async { |
| final appUri = request.arguments?[ParameterNames.appUri] as String?; |
| return _callOnVmService( |
| appUri: appUri, |
| callback: (vmService) async { |
| final vm = await vmService.getVM(); |
| final isolateId = vm.isolates!.first.id; |
| final summaryOnly = |
| request.arguments?[ParameterNames.summaryOnly] as bool? ?? false; |
| try { |
| final result = await vmService.callServiceExtension( |
| '$_inspectorServiceExtensionPrefix.getRootWidgetTree', |
| isolateId: isolateId, |
| args: { |
| 'groupName': inspectorObjectGroup, |
| 'isSummaryTree': summaryOnly ? 'true' : 'false', |
| 'withPreviews': 'true', |
| 'fullDetails': 'false', |
| }, |
| ); |
| final tree = result.json?['result']; |
| if (tree == null) { |
| return CallToolResult( |
| content: [ |
| TextContent( |
| text: |
| 'Could not get Widget tree. ' |
| 'Unexpected result: ${result.json}.', |
| ), |
| ], |
| ); |
| } |
| return CallToolResult(content: [TextContent(text: jsonEncode(tree))]); |
| } catch (e) { |
| return CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: 'Unknown error or bad response getting widget tree:\n$e', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.unhandledError; |
| } |
| }, |
| ); |
| } |
| |
| /// Retrieves the selected widget from the currently running app. |
| Future<CallToolResult> _selectedWidget(CallToolRequest request) async { |
| final appUri = request.arguments?[ParameterNames.appUri] as String?; |
| return _callOnVmService( |
| appUri: appUri, |
| callback: (vmService) async { |
| final vm = await vmService.getVM(); |
| final isolateId = vm.isolates!.first.id; |
| try { |
| final result = await vmService.callServiceExtension( |
| '$_inspectorServiceExtensionPrefix.getSelectedSummaryWidget', |
| isolateId: isolateId, |
| args: {'objectGroup': inspectorObjectGroup}, |
| ); |
| |
| final widget = result.json?['result']; |
| if (widget == null) { |
| return CallToolResult( |
| content: [TextContent(text: 'No Widget selected.')], |
| ); |
| } |
| return CallToolResult( |
| content: [TextContent(text: jsonEncode(widget))], |
| ); |
| } catch (e) { |
| return CallToolResult( |
| isError: true, |
| content: [TextContent(text: 'Failed to get selected widget: $e')], |
| )..failureReason = CallToolFailureReason.unhandledError; |
| } |
| }, |
| ); |
| } |
| |
| /// Enables or disables widget selection mode in the currently running app. |
| /// |
| /// If more than one debug session is active, then it just uses the first one. |
| Future<CallToolResult> _setWidgetSelectionMode( |
| CallToolRequest request, |
| ) async { |
| final enabled = request.arguments?[ParameterNames.enabled] as bool?; |
| if (enabled == null) { |
| return CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'Required parameter "enabled" was not provided or is not a ' |
| 'boolean.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.argumentError; |
| } |
| |
| final appUri = request.arguments?[ParameterNames.appUri] as String?; |
| return _callOnVmService( |
| appUri: appUri, |
| callback: (vmService) async { |
| final vm = await vmService.getVM(); |
| final isolateId = vm.isolates!.first.id; |
| try { |
| final result = await vmService.callServiceExtension( |
| '$_inspectorServiceExtensionPrefix.show', |
| isolateId: isolateId, |
| args: {'enabled': enabled.toString()}, |
| ); |
| |
| if (result.json?['enabled'] == enabled || |
| result.json?['enabled'] == enabled.toString()) { |
| return CallToolResult( |
| content: [ |
| TextContent( |
| text: |
| 'Widget selection mode ' |
| '${enabled ? 'enabled' : 'disabled'}.', |
| ), |
| ], |
| ); |
| } |
| return CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'Failed to set widget selection mode. Unexpected response: ' |
| '${result.json}', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.wrappedServiceIssue; |
| } catch (e) { |
| return CallToolResult( |
| isError: true, |
| content: [ |
| TextContent(text: 'Failed to set widget selection mode: $e'), |
| ], |
| )..failureReason = CallToolFailureReason.unhandledError; |
| } |
| }, |
| ); |
| } |
| |
| /// Calls [callback] on the first active debug session, if available. |
| Future<CallToolResult> _callOnVmService({ |
| required Future<CallToolResult> Function(VmService) callback, |
| String? appUri, |
| }) async { |
| if (_dtds.isEmpty) return _dtdNotConnected; |
| if (!_dtds.any((dtd) => dtd.supportsConnectedApps)) { |
| return _connectedAppsNotSupported; |
| } |
| |
| // Update active vm services for all connected DTDs, if we no active ones |
| // or if the requested appUri is not in the active vm services. |
| if (activeVmServices.isEmpty || |
| (appUri != null && !activeVmServices.containsKey(appUri))) { |
| for (final dtd in _dtds) { |
| await updateActiveVmServices(dtd); |
| } |
| } |
| |
| if (activeVmServices.isEmpty) return _noActiveDebugSession; |
| |
| final String selectedAppUri; |
| if (appUri != null) { |
| if (!activeVmServices.containsKey(appUri)) { |
| return CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'App with URI "$appUri" not found. Use "${dtdTool.name}" ' |
| 'with command "${DtdCommand.listConnectedApps}" to see ' |
| 'available apps.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.applicationNotFound; |
| } |
| selectedAppUri = appUri; |
| } else { |
| if (activeVmServices.length > 1) { |
| return CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'Multiple apps connected. You must provide an ' |
| '"${ParameterNames.appUri}". Use "${dtdTool.name}" with ' |
| 'command "${DtdCommand.listConnectedApps}" to see available ' |
| 'apps.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.mustSpecifyDtdUri; |
| } |
| selectedAppUri = activeVmServices.keys.first; |
| } |
| |
| return await callback(await activeVmServices[selectedAppUri]!); |
| } |
| |
| /// Retrieves the active location from the editor. |
| Future<CallToolResult> _getActiveLocation(CallToolRequest request) async { |
| if (_dtds.isEmpty) return _dtdNotConnected; |
| |
| Map<String, Object?>? activeLocation; |
| for (final dtd in _dtds) { |
| activeLocation = dtd.activeLocation; |
| if (activeLocation != null) break; |
| } |
| |
| if (activeLocation == null) { |
| return CallToolResult( |
| content: [TextContent(text: 'No active location found.')], |
| ); |
| } |
| return CallToolResult( |
| content: [TextContent(text: jsonEncode(activeLocation))], |
| structuredContent: activeLocation, |
| ); |
| } |
| |
| @visibleForTesting |
| static final flutterDriverTool = Tool( |
| name: ToolNames.flutterDriverCommand.name, |
| description: 'Run a flutter driver command', |
| annotations: ToolAnnotations(title: 'Flutter Driver', readOnlyHint: true), |
| inputSchema: Schema.object( |
| additionalProperties: true, |
| description: |
| 'Command arguments are passed as additional properties to this map.' |
| 'To specify a widget to interact with, you must first use the ' |
| '"${widgetInspectorTool.name}" tool (with "get_widget_tree" command) ' |
| 'to get the widget tree of the ' |
| 'current page so that you can see the available widgets. Do not ' |
| 'guess at how to select widgets, use the real text, tooltips, and ' |
| 'widget types that you see present in the tree.', |
| properties: { |
| ParameterNames.appUri: Schema.string( |
| description: |
| 'The app URI to execute the driver command on. Required if ' |
| 'multiple apps are connected.', |
| ), |
| ParameterNames.command: Schema.string( |
| // Commented out values are flutter_driver commands that are not |
| // supported, but may be in the future. |
| // ignore: deprecated_member_use |
| enumValues: [ |
| 'get_health', |
| // 'get_layer_tree', |
| // 'get_render_tree', |
| 'enter_text', |
| 'send_text_input_action', |
| 'get_text', |
| // 'request_data', |
| 'scroll', |
| 'scrollIntoView', |
| 'set_frame_sync', |
| 'set_semantics', |
| 'set_text_entry_emulation', |
| 'tap', |
| 'waitFor', |
| 'waitForAbsent', |
| 'waitForTappable', |
| // 'waitForCondition', |
| // 'waitUntilNoTransientCallbacks', |
| // 'waitUntilNoPendingFrame', |
| // 'waitUntilFirstFrameRasterized', |
| // 'get_semantics_id', |
| 'get_offset', |
| 'get_diagnostics_tree', |
| 'screenshot', |
| ], |
| description: 'The name of the driver command', |
| ), |
| 'alignment': Schema.string( |
| description: |
| 'Required for the scrollIntoView command, how the widget should ' |
| 'be aligned', |
| ), |
| 'duration': Schema.string( |
| description: |
| 'Required for the scroll command, the duration of the ' |
| 'scrolling action in MICROSECONDS as a stringified integer.', |
| ), |
| 'dx': Schema.string( |
| description: |
| 'Required for the scroll command, the delta X offset for move ' |
| 'event as a stringified double', |
| ), |
| 'dy': Schema.string( |
| description: |
| 'Required for the scroll command, the delta Y offset for move ' |
| 'event as a stringified double', |
| ), |
| 'frequency': Schema.string( |
| description: |
| 'Required for the scroll command, the frequency in Hz of the ' |
| 'generated move events as a stringified integer', |
| ), |
| 'finderType': Schema.string( |
| description: |
| 'Required for get_text, scroll, scroll_into_view, tap, waitFor, ' |
| 'waitForAbsent, waitForTappable, get_offset, and ' |
| 'get_diagnostics_tree. The kind of finder to use.', |
| // ignore: deprecated_member_use |
| enumValues: [ |
| 'ByType', |
| 'ByValueKey', |
| 'ByTooltipMessage', |
| 'BySemanticsLabel', |
| 'ByText', |
| 'PageBack', // This one seems to hang |
| 'Descendant', |
| 'Ancestor', |
| ], |
| ), |
| 'keyValueString': Schema.string( |
| description: |
| 'Required for the ByValueKey finder, the String value of the key', |
| ), |
| 'keyValueType': Schema.string( |
| // ignore: deprecated_member_use |
| enumValues: ['int', 'String'], |
| description: |
| 'Required for the ByValueKey finder, the type of the key', |
| ), |
| 'isRegExp': Schema.string( |
| description: |
| 'Used by the BySemanticsLabel finder, indicates whether ' |
| 'the value should be treated as a regex', |
| // ignore: deprecated_member_use |
| enumValues: ['true', 'false'], |
| ), |
| 'label': Schema.string( |
| description: |
| 'Required for the BySemanticsLabel finder, the label to search ' |
| 'for', |
| ), |
| 'text': Schema.string( |
| description: |
| 'Required for the ByText and ByTooltipMessage finders, as well ' |
| 'as the enter_text command. The relevant text for the command', |
| ), |
| 'type': Schema.string( |
| description: |
| 'Required for the ByType finder, the runtimeType of the widget ' |
| 'in String form', |
| ), |
| 'of': Schema.object( |
| description: |
| 'Required by the Descendent and Ancestor finders. ' |
| 'Value should be a nested finder for the widget to start the ' |
| 'match from', |
| additionalProperties: true, |
| ), |
| 'matching': Schema.object( |
| description: |
| 'Required by the Descendent and Ancestor finders. ' |
| 'Value should be a nested finder for the descendent or ancestor', |
| additionalProperties: true, |
| ), |
| // This is a boolean but uses the `true` and `false` strings. |
| 'matchRoot': Schema.string( |
| description: |
| 'Required by the Descendent and Ancestor finders. ' |
| 'Whether the widget matching `of` will be considered for a ' |
| 'match', |
| // ignore: deprecated_member_use |
| enumValues: ['true', 'false'], |
| ), |
| // This is a boolean but uses the `true` and `false` strings. |
| 'firstMatchOnly': Schema.string( |
| description: |
| 'Required by the Descendent and Ancestor finders. ' |
| 'If true then only the first ancestor or descendent matching ' |
| '`matching` will be returned.', |
| // ignore: deprecated_member_use |
| enumValues: ['true', 'false'], |
| ), |
| 'action': Schema.string( |
| description: |
| 'Required for send_text_input_action, the input action to send', |
| // ignore: deprecated_member_use |
| enumValues: [ |
| 'none', |
| 'unspecified', |
| 'done', |
| 'go', |
| 'search', |
| 'send', |
| 'next', |
| 'previous', |
| 'continueAction', |
| 'join', |
| 'route', |
| 'emergencyCall', |
| 'newline', |
| ], |
| ), |
| 'timeout': Schema.string( |
| description: |
| 'Maximum time in milliseconds to wait for the command to ' |
| 'complete. Defaults to "$_defaultTimeoutMs".', |
| ), |
| 'offsetType': Schema.string( |
| description: 'Required for get_offset, the offset type to get', |
| // ignore: deprecated_member_use |
| enumValues: [ |
| 'topLeft', |
| 'topRight', |
| 'bottomLeft', |
| 'bottomRight', |
| 'center', |
| ], |
| ), |
| 'diagnosticsType': Schema.string( |
| description: |
| 'Required for get_diagnostics_tree, the type of diagnostics tree ' |
| 'to request', |
| // ignore: deprecated_member_use |
| enumValues: ['renderObject', 'widget'], |
| ), |
| 'subtreeDepth': Schema.string( |
| description: |
| 'Required for get_diagnostics_tree, how many levels of children ' |
| 'to include in the result, as a stringified integer', |
| ), |
| 'includeProperties': Schema.string( |
| description: |
| 'Whether the properties of a diagnostics node should be included ' |
| 'in get_diagnostics_tree results', |
| // ignore: deprecated_member_use |
| enumValues: const ['true', 'false'], |
| ), |
| ParameterNames.enabled: Schema.string( |
| description: |
| 'Used by set_text_entry_emulation and ' |
| 'set_frame_sync, defaults to false', |
| // ignore: deprecated_member_use |
| enumValues: const ['true', 'false'], |
| ), |
| }, |
| required: [ParameterNames.command], |
| ), |
| )..categories = [FeatureCategory.flutterDriver]; |
| |
| @visibleForTesting |
| static final getRuntimeErrorsTool = Tool( |
| name: ToolNames.getRuntimeErrors.name, |
| description: |
| 'Retrieves the most recent runtime errors that have occurred in the ' |
| 'active Dart or Flutter application. ' |
| 'Requires an active DTD connection.', |
| annotations: ToolAnnotations( |
| title: 'Get runtime errors', |
| readOnlyHint: true, |
| ), |
| inputSchema: Schema.object( |
| properties: { |
| 'clearRuntimeErrors': Schema.bool( |
| title: 'Whether to clear the runtime errors after retrieving them.', |
| description: |
| 'This is useful to clear out old errors that may no longer be ' |
| 'relevant before reading them again.', |
| ), |
| ParameterNames.appUri: Schema.string( |
| description: |
| 'The app URI to get runtime errors from. Required if ' |
| 'multiple apps are connected.', |
| ), |
| }, |
| additionalProperties: false, |
| ), |
| )..categories = [FeatureCategory.dartToolingDaemon]; |
| |
| @visibleForTesting |
| static final hotReloadTool = Tool( |
| name: ToolNames.hotReload.name, |
| description: |
| 'Performs a hot reload of the active Flutter application. ' |
| 'This will apply the latest code changes to the running application, ' |
| 'while maintaining application state. Reload will not update const ' |
| 'definitions of global values. Requires an active DTD connection.', |
| annotations: ToolAnnotations(title: 'Hot reload', destructiveHint: true), |
| inputSchema: Schema.object( |
| properties: { |
| 'clearRuntimeErrors': Schema.bool( |
| title: 'Whether to clear runtime errors before hot reloading.', |
| description: |
| 'This is useful to clear out old errors that may no longer be ' |
| 'relevant.', |
| ), |
| ParameterNames.appUri: Schema.string( |
| description: |
| 'The app URI to perform the hot reload on. Required if ' |
| 'multiple apps are connected. This may also be referred to as ' |
| 'a VM Service URI.', |
| ), |
| }, |
| additionalProperties: false, |
| ), |
| )..categories = [FeatureCategory.flutter]; |
| |
| @visibleForTesting |
| static final hotRestartTool = Tool( |
| name: ToolNames.hotRestart.name, |
| description: |
| 'Performs a hot restart of the active Flutter application. ' |
| 'This applies the latest code changes to the running application, ' |
| 'including changes to global const values, while resetting ' |
| 'application state. Requires an active DTD connection. Doesn\'t work ' |
| 'for Non-Flutter Dart CLI programs.', |
| annotations: ToolAnnotations(title: 'Hot restart', destructiveHint: true), |
| inputSchema: Schema.object( |
| properties: { |
| ParameterNames.appUri: Schema.string( |
| description: |
| 'The app URI to perform the hot restart on. Required if multiple ' |
| 'apps are connected. This may also be referred to as a VM ' |
| 'Service URI.', |
| ), |
| }, |
| additionalProperties: false, |
| ), |
| )..categories = [FeatureCategory.flutter]; |
| |
| @visibleForTesting |
| static final widgetInspectorTool = Tool( |
| name: ToolNames.widgetInspector.name, |
| description: |
| 'Interact with the Flutter widget inspector in the active Flutter ' |
| 'application. Requires an active DTD connection.', |
| annotations: ToolAnnotations(title: 'Widget Inspector', readOnlyHint: true), |
| inputSchema: Schema.object( |
| properties: { |
| ParameterNames.command: EnumSchema.untitledSingleSelect( |
| description: 'The widget inspector command to run.', |
| values: [ |
| WidgetInspectorCommand.getWidgetTree, |
| WidgetInspectorCommand.getSelectedWidget, |
| WidgetInspectorCommand.setWidgetSelectionMode, |
| ], |
| ), |
| ParameterNames.summaryOnly: Schema.bool( |
| description: |
| 'Only for "${WidgetInspectorCommand.getWidgetTree}". Defaults to ' |
| 'false. If true, only widgets created by user code are ' |
| 'returned.', |
| ), |
| ParameterNames.enabled: Schema.bool( |
| title: 'New widget selection mode state', |
| description: |
| 'Required for "${WidgetInspectorCommand.setWidgetSelectionMode}"' |
| '.', |
| ), |
| ParameterNames.appUri: Schema.string( |
| description: |
| 'The app URI to use. Required if multiple apps are connected. ' |
| 'This may also be referred to as a VM Service URI.', |
| ), |
| }, |
| required: const [ParameterNames.command], |
| additionalProperties: false, |
| ), |
| )..categories = [FeatureCategory.flutter]; |
| |
| @visibleForTesting |
| static final getActiveLocationTool = |
| Tool( |
| name: ToolNames.getActiveLocation.name, |
| description: |
| 'Retrieves the current active location (e.g., cursor position) ' |
| 'in the connected editor. Requires an active DTD connection.', |
| annotations: ToolAnnotations( |
| title: 'Get Active Editor Location', |
| readOnlyHint: true, |
| ), |
| inputSchema: Schema.object(additionalProperties: false), |
| ) |
| ..categories = [FeatureCategory.dartToolingDaemon] |
| ..enabledByDefault = false; |
| |
| static final _connectedAppsNotSupported = CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'A Dart SDK of version 3.9.0-163.0.dev or greater is required to ' |
| 'connect to Dart and Flutter applications.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.connectedAppServiceNotSupported; |
| |
| static final _dtdNotConnected = CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'The dart tooling daemon is not connected, you need to call ' |
| '"${dtdTool.name}" with command "${DtdCommand.connect}" first.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.dtdNotConnected; |
| |
| static final _dtdAlreadyConnected = CallToolResult( |
| isError: true, |
| content: [ |
| TextContent( |
| text: |
| 'The dart tooling daemon is already connected to this URI, you ' |
| 'cannot connect again.', |
| ), |
| ], |
| )..failureReason = CallToolFailureReason.dtdAlreadyConnected; |
| |
| static final _noActiveDebugSession = CallToolResult( |
| content: [TextContent(text: 'No active debug session.')], |
| isError: true, |
| )..failureReason = CallToolFailureReason.noActiveDebugSession; |
| |
| static final _flutterDriverNotRegistered = CallToolResult( |
| content: [ |
| Content.text( |
| text: |
| 'The flutter driver extension is not enabled. You need to ' |
| 'import "package:flutter_driver/driver_extension.dart" ' |
| 'and then add a call to `enableFlutterDriverExtension();` ' |
| 'before calling `runApp` to use this tool. It is recommended ' |
| 'that you create a separate entrypoint file like ' |
| '`driver_main.dart` to do this.', |
| ), |
| ], |
| isError: true, |
| )..failureReason = CallToolFailureReason.flutterDriverNotEnabled; |
| |
| static final _gotVmServiceUri = CallToolResult( |
| content: [ |
| Content.text( |
| text: |
| 'Connected to a VM Service but expected to connect to a Dart ' |
| 'Tooling Daemon service. When launching apps from an IDE you ' |
| 'should have a "Copy DTD URI to clipboard" command pallete option, ' |
| 'or when directly launching apps from a terminal you can pass the ' |
| '"--print-dtd" command line option in order to get the DTD URI.', |
| ), |
| ], |
| isError: true, |
| )..failureReason = CallToolFailureReason.givenVmServiceUri; |
| |
| static final runtimeErrorsScheme = 'runtime-errors'; |
| |
| static const _defaultTimeoutMs = 5000; |
| |
| static const _flutterDriverService = 'ext.flutter.driver'; |
| } |
| |
| /// Listens on a VM service for relevant events, such as errors and registered |
| /// vm service methods. |
| class _AppListener { |
| /// All the errors recorded so far (may be cleared explicitly). |
| final ErrorLog errorLog; |
| |
| /// A broadcast stream of all errors that come in after you start listening. |
| Stream<String> get errorsStream => _errorsController.stream; |
| |
| /// A map of service names to the names of their methods. |
| final Map<String, String?> registeredServices; |
| |
| /// A map of service names to completers that should be fired when the service |
| /// is registered. |
| final _pendingServiceRequests = <String, List<Completer<String?>>>{}; |
| |
| /// Controller for the [errorsStream]. |
| final StreamController<String> _errorsController; |
| |
| /// Stream subscriptions we need to cancel on [shutdown]. |
| final Iterable<StreamSubscription<void>> _subscriptions; |
| |
| /// The vm service instance connected to the flutter app. |
| final VmService _vmService; |
| |
| _AppListener._( |
| this.errorLog, |
| this.registeredServices, |
| this._errorsController, |
| this._subscriptions, |
| this._vmService, |
| ) { |
| _vmService.onDone.then((_) => shutdown()); |
| } |
| |
| /// Maintain a cache of app listeners by [VmService] instance as an |
| /// [Expando] so we don't have to worry about explicit cleanup. |
| static final _appListeners = Expando<Future<_AppListener>>(); |
| |
| /// Returns the canonical [_AppListener] for the [vmService] instance, |
| /// which may be an already existing instance. |
| static Future<_AppListener> forVmService( |
| VmService vmService, |
| LoggingSupport logger, |
| ) async { |
| return _appListeners[vmService] ??= () async { |
| // Needs to be a broadcast stream because we use it to add errors to the |
| // list but also expose it to clients so they can know when new errors |
| // are added. |
| final errorsController = StreamController<String>.broadcast(); |
| final errorLog = ErrorLog(); |
| errorsController.stream.listen(errorLog.add); |
| final subscriptions = <StreamSubscription<void>>[]; |
| final registeredServices = <String, String?>{}; |
| final pendingServiceRequests = <String, List<Completer<String?>>>{}; |
| |
| try { |
| subscriptions.addAll([ |
| vmService.onServiceEvent.listen((Event e) { |
| switch (e.kind) { |
| case EventKind.kServiceRegistered: |
| final serviceName = e.service!; |
| registeredServices[serviceName] = e.method; |
| // If there are any pending requests for this service, complete |
| // them. |
| if (pendingServiceRequests.containsKey(serviceName)) { |
| for (final completer |
| in pendingServiceRequests[serviceName]!) { |
| completer.complete(e.method); |
| } |
| pendingServiceRequests.remove(serviceName); |
| } |
| case EventKind.kServiceUnregistered: |
| registeredServices.remove(e.service!); |
| } |
| }), |
| vmService.onIsolateEvent.listen((e) { |
| switch (e.kind) { |
| case EventKind.kServiceExtensionAdded: |
| registeredServices[e.extensionRPC!] = null; |
| } |
| }), |
| ]); |
| subscriptions.add( |
| vmService.onExtensionEventWithHistory.listen((Event e) { |
| if (e.extensionKind == 'Flutter.Error') { |
| // TODO(https://github.com/dart-lang/ai/issues/57): consider |
| // pruning this content down to only what is useful for the LLM to |
| // understand the error and its source. |
| errorsController.add(e.json.toString()); |
| } |
| }), |
| ); |
| Event? lastError; |
| subscriptions.add( |
| vmService.onStderrEventWithHistory.listen((Event e) { |
| if (lastError case final last? |
| when last.timestamp == e.timestamp && last.bytes == e.bytes) { |
| // Looks like a duplicate event, on Dart 3.7 stable we get these. |
| return; |
| } |
| lastError = e; |
| final message = decodeBase64(e.bytes!); |
| // TODO(https://github.com/dart-lang/ai/issues/57): consider |
| // pruning this content down to only what is useful for the LLM to |
| // understand the error and its source. |
| errorsController.add(message); |
| }), |
| ); |
| |
| await [ |
| vmService.streamListen(EventStreams.kExtension), |
| vmService.streamListen(EventStreams.kIsolate), |
| vmService.streamListen(EventStreams.kStderr), |
| vmService.streamListen(EventStreams.kService), |
| ].wait; |
| |
| final vm = await vmService.getVM(); |
| final isolate = await vmService.getIsolate(vm.isolates!.first.id!); |
| for (final extension in isolate.extensionRPCs ?? <String>[]) { |
| registeredServices[extension] = null; |
| } |
| } catch (e) { |
| logger.log(LoggingLevel.error, 'Error subscribing to app errors: $e'); |
| } |
| return _AppListener._( |
| errorLog, |
| registeredServices, |
| errorsController, |
| subscriptions, |
| vmService, |
| ); |
| }(); |
| } |
| |
| /// Returns a future that completes with the registered method name for the |
| /// given [serviceName]. |
| Future<String?> waitForServiceRegistration( |
| String serviceName, { |
| Duration timeout = const Duration(seconds: 1), |
| }) async { |
| if (registeredServices.containsKey(serviceName)) { |
| return registeredServices[serviceName]; |
| } |
| final completer = Completer<String?>(); |
| _pendingServiceRequests.putIfAbsent(serviceName, () => []).add(completer); |
| |
| return completer.future.timeout( |
| timeout, |
| onTimeout: () { |
| // Important: Clean up the completer from the list on timeout. |
| _pendingServiceRequests[serviceName]?.remove(completer); |
| if (_pendingServiceRequests[serviceName]?.isEmpty ?? false) { |
| _pendingServiceRequests.remove(serviceName); |
| } |
| return null; // Return null on timeout |
| }, |
| ); |
| } |
| |
| Future<void> shutdown() async { |
| errorLog.clear(); |
| registeredServices.clear(); |
| await _errorsController.close(); |
| await Future.wait(_subscriptions.map((s) => s.cancel())); |
| try { |
| await [ |
| _vmService.streamCancel(EventStreams.kExtension), |
| _vmService.streamCancel(EventStreams.kIsolate), |
| _vmService.streamCancel(EventStreams.kStderr), |
| _vmService.streamCancel(EventStreams.kService), |
| ].wait; |
| } on RPCError catch (_) { |
| // The vm service might already be disposed which could cause these to |
| // fail. |
| } |
| } |
| } |
| |
| /// Manages a log of errors with a maximum size in terms of total characters. |
| @visibleForTesting |
| class ErrorLog { |
| Iterable<String> get errors => _errors; |
| final List<String> _errors = []; |
| int _characters = 0; |
| |
| /// The number of characters used by all errors in the log. |
| @visibleForTesting |
| int get characters => _characters; |
| |
| final int _maxSize; |
| |
| ErrorLog({ |
| // One token is ~4 characters. Allow up to 5k tokens by default, so 20k |
| // characters. |
| int maxSize = 20000, |
| }) : _maxSize = maxSize; |
| |
| /// Adds a new [error] to the log. |
| void add(String error) { |
| if (error.length > _maxSize) { |
| // If we get a single error over the max size, just trim it and clear |
| // all other errors. |
| final trimmed = error.substring(0, _maxSize); |
| _errors.clear(); |
| _characters = trimmed.length; |
| _errors.add(trimmed); |
| } else { |
| // Otherwise, we append the error and then remove as many errors from the |
| // front as we need to in order to get under the max size. |
| _characters += error.length; |
| _errors.add(error); |
| var removeCount = 0; |
| while (_characters > _maxSize) { |
| _characters -= _errors[removeCount].length; |
| removeCount++; |
| } |
| _errors.removeRange(0, removeCount); |
| } |
| } |
| |
| /// Clears all errors. |
| void clear() { |
| _characters = 0; |
| _errors.clear(); |
| } |
| } |
| |
| extension on VmService { |
| static final _ids = Expando<String>(); |
| static int _nextId = 0; |
| String get id => _ids[this] ??= '${_nextId++}'; |
| } |
| |
| /// Extensions to attach extra metadata to [DartToolingDaemon] instances. |
| extension _DartToolingDaemonMetadata on DartToolingDaemon { |
| static final _dtdUris = Expando<Uri>(); |
| static final _vmServiceUris = Expando<Set<String>>(); |
| static final _supportsConnectedApps = Expando<bool>(); |
| static final _activeLocations = Expando<Map<String, Object?>>(); |
| |
| Uri? get uri => _dtdUris[this]; |
| set uri(Uri? value) => _dtdUris[this] = value; |
| |
| Set<String> get vmServiceUris => _vmServiceUris[this] ??= {}; |
| |
| bool get supportsConnectedApps => _supportsConnectedApps[this] ?? false; |
| set supportsConnectedApps(bool value) => _supportsConnectedApps[this] = value; |
| |
| Map<String, Object?>? get activeLocation => _activeLocations[this]; |
| set activeLocation(Map<String, Object?>? value) => |
| _activeLocations[this] = value; |
| } |
| |
| extension WidgetInspectorCommand on Never { |
| static const getWidgetTree = 'get_widget_tree'; |
| static const getSelectedWidget = 'get_selected_widget'; |
| static const setWidgetSelectionMode = 'set_widget_selection_mode'; |
| } |
| |
| extension DtdCommand on Never { |
| static const connect = 'connect'; |
| static const disconnect = 'disconnect'; |
| static const listConnectedApps = 'listConnectedApps'; |
| } |