blob: 9cf20baabb834d6fe031a79a00ad42d6cf8f8626 [file]
// 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';
}