Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/bridge/lib/inspector/flutter_inspector_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ abstract class FlutterInspectorClient {
Stream<SelectWidgetModeStatus> watchSelectWidgetModeStatus(
BridgeSession session,
);

Stream<WidgetSelectionStatus> watchWidgetSelectionStatus(
BridgeSession session,
);
}

/// Flutter Inspector client backed by Dart VM Service extension calls.
Expand Down Expand Up @@ -93,6 +97,8 @@ class VmServiceFlutterInspectorClient implements FlutterInspectorClient {
final _selectWidgetMonitorServices = <String, FlutterInspectorVmService>{};
final _selectWidgetModeControllers =
<String, StreamController<SelectWidgetModeStatus>>{};
final _widgetSelectionControllers =
<String, StreamController<WidgetSelectionStatus>>{};

@override
Future<WidgetTreeNode> fetchRootWidgetTree(BridgeSession session) async {
Expand Down Expand Up @@ -261,6 +267,57 @@ class VmServiceFlutterInspectorClient implements FlutterInspectorClient {
'monitor_update extension=${change.extension} enabled=$enabled',
);
});
_logger.info(
'widget_selection session=${session.id} monitor_listen_inspect_stream start',
);
try {
await vmService.listenToInspectorSelectionChanges(() async {
_logger.info(
'widget_selection session=${session.id} inspect_event received',
);
try {
final isolateId = await vmService.findMainIsolateId();
final response = await vmService.callServiceExtension(
'ext.flutter.inspector.getSelectedSummaryWidget',
isolateId: isolateId,
args: {
'objectGroup': 'ask_ui_widget_tree',
},
);
final result = _decodeInspectorResult(response);
if (result is! Map<String, Object?>) {
_logger.info(
'widget_selection session=${session.id} monitor_ignore reason=selected_summary_not_object',
);
return;
}

final widgetId = result['valueId']?.toString().trim();
if (widgetId == null || widgetId.isEmpty) {
_logger.info(
'widget_selection session=${session.id} monitor_ignore reason=selected_summary_missing_value_id',
);
return;
}

_publishWidgetSelectionStatus(session.id, widgetId);
_logger.info(
'widget_selection session=${session.id} monitor_update widget=$widgetId',
);
} catch (error) {
_logger.info(
'widget_selection session=${session.id} monitor_failed error=$error',
);
}
});
_logger.info(
'widget_selection session=${session.id} monitor_listen_inspect_stream ready',
);
} catch (error) {
_logger.info(
'widget_selection session=${session.id} monitor_listen_inspect_stream failed error=$error',
);
}
});
}

Expand Down Expand Up @@ -308,8 +365,36 @@ class VmServiceFlutterInspectorClient implements FlutterInspectorClient {

controller.add(SelectWidgetModeStatus(enabled: enabled));
}

@override
Stream<WidgetSelectionStatus> watchWidgetSelectionStatus(
BridgeSession session,
) {
unawaited(_ensureSelectWidgetModeMonitor(session));
return _widgetSelectionController(session.id).stream;
}

StreamController<WidgetSelectionStatus> _widgetSelectionController(
String sessionId,
) {
return _widgetSelectionControllers.putIfAbsent(
sessionId,
() => StreamController<WidgetSelectionStatus>.broadcast(),
);
}

void _publishWidgetSelectionStatus(String sessionId, String widgetId) {
final controller = _widgetSelectionControllers[sessionId];
if (controller == null || controller.isClosed) {
return;
}

controller.add(WidgetSelectionStatus(widgetId: widgetId));
}
}

const flutterInspectorSelectionEventStream = EventStreams.kDebug;

Future<Map<String, WidgetBounds>> _fetchWidgetBoundsById(
FlutterInspectorVmService vmService, {
required String isolateId,
Expand Down Expand Up @@ -530,6 +615,14 @@ class WidgetSelectionResult {
}
}

class WidgetSelectionStatus {
const WidgetSelectionStatus({
required this.widgetId,
});

final String widgetId;
}

/// Cached Select Widget mode state observed from Flutter Inspector.
///
/// It contains the last known `enabled` value reported by the app. When the
Expand Down Expand Up @@ -657,6 +750,10 @@ abstract class FlutterInspectorVmService {
void Function(FlutterServiceExtensionStateChange change) onChange,
);

Future<void> listenToInspectorSelectionChanges(
FutureOr<void> Function() onSelectionChanged,
);

Future<void> dispose();
}

Expand Down Expand Up @@ -810,6 +907,20 @@ class VmServiceAdapter implements FlutterInspectorVmService {
await _vmService.streamListen(EventStreams.kExtension);
}

@override
Future<void> listenToInspectorSelectionChanges(
FutureOr<void> Function() onSelectionChanged,
) async {
_vmService.onEvent(flutterInspectorSelectionEventStream).listen((event) {
if (event.kind != EventKind.kInspect) {
return;
}
unawaited(Future<void>.sync(onSelectionChanged));
});

await _vmService.streamListen(flutterInspectorSelectionEventStream);
}

@override
Future<void> dispose() => _vmService.dispose();
}
17 changes: 17 additions & 0 deletions apps/bridge/lib/sessions/bridge_session_event_stream.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class BridgeSessionEventStream {

Timer? heartbeat;
StreamSubscription<SelectWidgetModeStatus>? selectWidgetSubscription;
StreamSubscription<WidgetSelectionStatus>? widgetSelectionSubscription;
StreamSubscription<ChatSessionEvent>? chatSubscription;
var streamClosed = false;
var writeQueue = Future<void>.value();
Expand All @@ -50,6 +51,7 @@ class BridgeSessionEventStream {
streamClosed = true;
heartbeat?.cancel();
await selectWidgetSubscription?.cancel();
await widgetSelectionSubscription?.cancel();
await chatSubscription?.cancel();
_logger.info('events stream session=$sessionId close');
}
Expand Down Expand Up @@ -121,6 +123,21 @@ class BridgeSessionEventStream {
);
}));
});
widgetSelectionSubscription =
_inspectorClient.watchWidgetSelectionStatus(session).listen((status) {
unawaited(enqueueSseWrite(() {
transport.writeEvent(
event: 'bridge_session_event',
data: {
'type': 'widget_selection_changed',
'sessionId': sessionId,
'payload': {
'widgetId': status.widgetId,
},
},
);
}));
});
chatSubscription = session.chat.events.listen((event) {
unawaited(enqueueSseWrite(() {
transport.writeEvent(
Expand Down
9 changes: 9 additions & 0 deletions apps/bridge/test/flutter_app_controller_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:ask_ui_bridge/app_controller/flutter_app_controller.dart';
import 'package:ask_ui_bridge/inspector/flutter_inspector_client.dart';
import 'package:ask_ui_bridge/logging/bridge_logger.dart';
Expand Down Expand Up @@ -334,6 +336,13 @@ class RecordingHotReloadVmService implements FlutterInspectorVmService {
) {
throw UnimplementedError();
}

@override
Future<void> listenToInspectorSelectionChanges(
FutureOr<void> Function() onSelectionChanged,
) {
throw UnimplementedError();
}
}

class RecordedRegisteredServiceWait {
Expand Down
77 changes: 77 additions & 0 deletions apps/bridge/test/flutter_inspector_client_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:ask_ui_bridge/inspector/flutter_inspector_client.dart';
import 'package:ask_ui_bridge/logging/bridge_logger.dart';
import 'package:ask_ui_bridge/sessions/session_store.dart';
Expand Down Expand Up @@ -390,6 +392,67 @@ void main() {
),
);
});

test('publishes Widget Selection status from Inspector inspect events',
() async {
final vmService = RecordingFlutterInspectorVmService(
{'result': null},
serviceExtensionResponses: {
'ext.flutter.inspector.getSelectedSummaryWidget': [
{
'result': {
'valueId': 'inspector-2',
'description': 'PrimaryButton',
'children': <Object?>[],
},
},
],
},
);
final logs = <String>[];
final client = VmServiceFlutterInspectorClient(
vmServiceFactory: RecordingFlutterInspectorVmServiceFactory(vmService),
logger: BridgeLogger(write: logs.add),
);
final session = BridgeSession(
id: 'session-1',
vmServiceUri: 'ws://127.0.0.1:12345/ws',
projectRoot: '/Users/example/app',
deviceId: '19271FDF6007TY',
);
final emittedSelections = <WidgetSelectionStatus>[];
final subscription = client
.watchWidgetSelectionStatus(session)
.listen(emittedSelections.add);

await Future<void>.delayed(Duration.zero);
await vmService.emitInspectorSelectionChange();
await Future<void>.delayed(Duration.zero);
await subscription.cancel();

expect(
emittedSelections.map((status) => status.widgetId),
['inspector-2'],
);
expect(
vmService.calls,
contains(
const FlutterInspectorVmServiceCall(
method: 'ext.flutter.inspector.getSelectedSummaryWidget',
isolateId: 'isolates/main',
args: {
'objectGroup': 'ask_ui_widget_tree',
},
),
),
);
expect(
logs,
contains(
'[ask_ui_bridge] widget_selection session=session-1 monitor_update widget=inspector-2',
),
);
});
});
}

Expand Down Expand Up @@ -423,6 +486,7 @@ class RecordingFlutterInspectorVmService implements FlutterInspectorVmService {
final calls = <FlutterInspectorVmServiceCall>[];
final serviceExtensionStateListeners =
<void Function(FlutterServiceExtensionStateChange change)>[];
final inspectorSelectionListeners = <FutureOr<void> Function()>[];
bool didFindMainIsolateId = false;
bool disposed = false;

Expand Down Expand Up @@ -493,6 +557,13 @@ class RecordingFlutterInspectorVmService implements FlutterInspectorVmService {
serviceExtensionStateListeners.add(onChange);
}

@override
Future<void> listenToInspectorSelectionChanges(
FutureOr<void> Function() onSelectionChanged,
) async {
inspectorSelectionListeners.add(onSelectionChanged);
}

@override
Future<void> dispose() async {
disposed = true;
Expand All @@ -505,6 +576,12 @@ class RecordingFlutterInspectorVmService implements FlutterInspectorVmService {
listener(change);
}
}

Future<void> emitInspectorSelectionChange() async {
for (final listener in inspectorSelectionListeners) {
await listener();
}
}
}

class FlutterInspectorVmServiceCall {
Expand Down
14 changes: 14 additions & 0 deletions apps/bridge/test/server/bridge_server_test_harness.dart
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ class RecordingFlutterInspectorClient implements FlutterInspectorClient {
final selectedWidgets = <RecordedWidgetSelectionRequest>[];
final _selectWidgetModeControllers =
<String, StreamController<SelectWidgetModeStatus>>{};
final _widgetSelectionControllers =
<String, StreamController<WidgetSelectionStatus>>{};
Exception? failure;
bool? selectWidgetModeStatus;

Expand Down Expand Up @@ -394,6 +396,18 @@ class RecordingFlutterInspectorClient implements FlutterInspectorClient {
)
.stream;
}

@override
Stream<WidgetSelectionStatus> watchWidgetSelectionStatus(
BridgeSession session,
) {
return _widgetSelectionControllers
.putIfAbsent(
session.id,
() => StreamController<WidgetSelectionStatus>.broadcast(),
)
.stream;
}
}

class RecordingFlutterAppController implements FlutterAppController {
Expand Down
Loading
Loading