From 712bca083d4773b83867945a00f2dae26b0334d6 Mon Sep 17 00:00:00 2001 From: Kenzie Schmoll Date: Fri, 25 Aug 2023 06:46:47 -0700 Subject: [PATCH 1/7] Add a simulated DevTools environment for developing extensions --- packages/devtools_extensions/CHANGELOG.md | 3 + .../foo_devtools_extension/lib/main.dart | 4 +- .../lib/src/api/model.dart | 20 +- .../_connect_ui.dart | 136 ++++++++++++++ .../_simulated_devtools_controller.dart | 102 ++++++++++ .../_simulated_devtools_environment.dart | 174 ++++++++++++++++++ .../lib/src/template/devtools_extension.dart | 66 ++++++- .../lib/src/template/extension_manager.dart | 31 +++- packages/devtools_extensions/pubspec.yaml | 2 +- 9 files changed, 517 insertions(+), 21 deletions(-) create mode 100644 packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_connect_ui.dart create mode 100644 packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart create mode 100644 packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart diff --git a/packages/devtools_extensions/CHANGELOG.md b/packages/devtools_extensions/CHANGELOG.md index 1dc8d7adc14..8cab6f6d469 100644 --- a/packages/devtools_extensions/CHANGELOG.md +++ b/packages/devtools_extensions/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.0.3-wip +* Add a simulated DevTools environment that for easier development. + ## 0.0.2-dev.0 * Add missing dependency on `package:devtools_shared`. diff --git a/packages/devtools_extensions/example/foo_devtools_extension/lib/main.dart b/packages/devtools_extensions/example/foo_devtools_extension/lib/main.dart index bdac56b8769..5e303706224 100644 --- a/packages/devtools_extensions/example/foo_devtools_extension/lib/main.dart +++ b/packages/devtools_extensions/example/foo_devtools_extension/lib/main.dart @@ -11,8 +11,8 @@ class FooPackageDevToolsExtension extends StatelessWidget { @override Widget build(BuildContext context) { - return const DevToolsExtension( - child: FooExtensionHomePage(), + return DevToolsExtension( + child: const FooExtensionHomePage(), ); } } diff --git a/packages/devtools_extensions/lib/src/api/model.dart b/packages/devtools_extensions/lib/src/api/model.dart index 7f13105fb4a..cf8577c1fae 100644 --- a/packages/devtools_extensions/lib/src/api/model.dart +++ b/packages/devtools_extensions/lib/src/api/model.dart @@ -10,13 +10,18 @@ import 'api.dart'; /// See [DevToolsExtensionEventType] for different types of events that are /// supported over this communication channel. class DevToolsExtensionEvent { - DevToolsExtensionEvent(this.type, {this.data}); + DevToolsExtensionEvent( + this.type, { + this.data, + this.source, + }); factory DevToolsExtensionEvent.parse(Map json) { final eventType = DevToolsExtensionEventType.from(json[_typeKey]! as String); final data = (json[_dataKey] as Map?)?.cast(); - return DevToolsExtensionEvent(eventType, data: data); + final source = json[sourceKey] as String?; + return DevToolsExtensionEvent(eventType, data: data, source: source); } static DevToolsExtensionEvent? tryParse(Object data) { @@ -30,17 +35,14 @@ class DevToolsExtensionEvent { static const _typeKey = 'type'; static const _dataKey = 'data'; - - static DevToolsExtensionEvent ping = - DevToolsExtensionEvent(DevToolsExtensionEventType.ping); - - static DevToolsExtensionEvent pong = - DevToolsExtensionEvent(DevToolsExtensionEventType.pong); + static const sourceKey = 'source'; final DevToolsExtensionEventType type; - final Map? data; + /// Optional field to describe the source that created and sent this event. + final String? source; + Map toJson() { return { _typeKey: type.name, diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_connect_ui.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_connect_ui.dart new file mode 100644 index 00000000000..76e207ac27e --- /dev/null +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_connect_ui.dart @@ -0,0 +1,136 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '_simulated_devtools_environment.dart'; + +class _VmServiceConnection extends StatelessWidget { + const _VmServiceConnection({ + required this.simController, + required this.connected, + }); + + static const _totalControlsHeight = 45.0; + static const _totalControlsWidth = 415.0; + + final _SimulatedDevToolsController simController; + final bool connected; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _totalControlsHeight, + child: connected + ? const _ConnectedVmServiceDisplay() + : _DisconnectedVmServiceDisplay( + simController: simController, + ), + ); + } +} + +class _ConnectedVmServiceDisplay extends StatelessWidget { + const _ConnectedVmServiceDisplay(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Debugging:', + style: theme.regularTextStyle, + ), + const Text(''), + // TODO(kenz): uncomment once we can bump to vm_service ^11.10.0 + // Text( + // serviceManager.service!.wsUri ?? '--', + // style: theme.boldTextStyle, + // ), + ], + ), + const Expanded( + child: SizedBox(width: denseSpacing), + ), + DevToolsButton( + elevated: true, + label: 'Disconnect', + onPressed: serviceManager.manuallyDisconnect, + ), + ], + ); + } +} + +class _DisconnectedVmServiceDisplay extends StatefulWidget { + const _DisconnectedVmServiceDisplay({required this.simController}); + + final _SimulatedDevToolsController simController; + + @override + State<_DisconnectedVmServiceDisplay> createState() => + _DisconnectedVmServiceDisplayState(); +} + +class _DisconnectedVmServiceDisplayState + extends State<_DisconnectedVmServiceDisplay> { + static const _connectFieldWidth = 300.0; + + late final TextEditingController _connectTextFieldController; + + @override + void initState() { + super.initState(); + _connectTextFieldController = TextEditingController(); + } + + @override + void dispose() { + _connectTextFieldController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: _connectFieldWidth, + child: TextField( + autofocus: true, + style: theme.regularTextStyle, + decoration: InputDecoration( + // contentPadding: const EdgeInsets.all(denseSpacing), + isDense: true, + border: const OutlineInputBorder(), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(width: 0.5, color: theme.focusColor), + ), + labelText: 'Dart VM Service URL', + labelStyle: theme.regularTextStyle, + hintText: '(e.g., http://127.0.0.1:60851/fH-kAEXc7MQ=/)', + hintStyle: theme.regularTextStyle, + ), + onSubmitted: (value) => + widget.simController.vmServiceConnectionChanged(uri: value), + controller: _connectTextFieldController, + ), + ), + const SizedBox(width: denseSpacing), + DevToolsButton( + elevated: true, + label: 'Connect', + onPressed: () => widget.simController.vmServiceConnectionChanged( + uri: _connectTextFieldController.text, + ), + ), + ], + ); + } +} diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart new file mode 100644 index 00000000000..cb23fed0dbc --- /dev/null +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart @@ -0,0 +1,102 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '_simulated_devtools_environment.dart'; + +class _SimulatedDevToolsController extends DisposableController + implements DevToolsExtensionHostInterface { + /// Logs of the post message communication that goes back and forth between + /// the extension and the simulated DevTools environment. + final messageLogs = ListValueNotifier<_PostMessageLogEntry>([]); + + void init() { + html.window.addEventListener('message', _handleMessage); + } + + void _handleMessage(html.Event e) { + if (e is html.MessageEvent) { + final extensionEvent = DevToolsExtensionEvent.tryParse(e.data); + if (extensionEvent != null) { + // Do not handle messages that come from the + // [_SimulatedDevToolsController] itself. + if (extensionEvent.source == '$_SimulatedDevToolsController') return; + + onEventReceived(extensionEvent); + } + } + } + + @override + void dispose() { + html.window.removeEventListener('message', _handleMessage); + super.dispose(); + } + + @override + void ping() { + _postMessageToExtension( + DevToolsExtensionEvent(DevToolsExtensionEventType.ping), + ); + } + + @override + void vmServiceConnectionChanged({String? uri}) { + uri = 'http://127.0.0.1:60851/fH-kAEXc7MQ=/'; + // TODO(kenz): add some validation and error handling if [uri] is bad input. + final normalizedUri = normalizeVmServiceUri(uri!); + final event = DevToolsExtensionEvent( + DevToolsExtensionEventType.vmServiceConnection, + data: {'uri': normalizedUri.toString()}, + ); + _postMessageToExtension(event); + } + + @override + void onEventReceived( + DevToolsExtensionEvent event, { + void Function()? onUnknownEvent, + }) { + messageLogs.add( + _PostMessageLogEntry( + source: _PostMessageSource.extension, + data: event.toJson(), + ), + ); + } + + void _postMessageToExtension(DevToolsExtensionEvent event) { + final eventJson = event.toJson(); + html.window.postMessage( + { + ...eventJson, + DevToolsExtensionEvent.sourceKey: '$_SimulatedDevToolsController', + }, + html.window.origin!, + ); + messageLogs.add( + _PostMessageLogEntry( + source: _PostMessageSource.devtools, + data: eventJson, + ), + ); + } +} + +class _PostMessageLogEntry { + _PostMessageLogEntry({required this.source, required this.data}) + : timestamp = DateTime.now(); + + final _PostMessageSource source; + final Map data; + final DateTime timestamp; +} + +enum _PostMessageSource { + devtools, + extension; + + String get display { + return name.toUpperCase(); + } +} diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart new file mode 100644 index 00000000000..d62fff94183 --- /dev/null +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart @@ -0,0 +1,174 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore: avoid_web_libraries_in_flutter, as designed +import 'dart:html' as html; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_shared/devtools_shared.dart'; +import 'package:flutter/material.dart'; + +import '../../api/api.dart'; +import '../../api/model.dart'; +import '../devtools_extension.dart'; + +part '_connect_ui.dart'; +part '_simulated_devtools_controller.dart'; + +/// Wraps [child] in a simulated DevTools environment. +/// +/// The simulated environment implements and exposes the same extension host +/// APIs that DevTools does. +/// +/// To use this wrapper, set the [useSimulatedEnvironment] to true when +/// constructing a [DevToolsExtension] widget. +class SimulatedDevToolsWrapper extends StatefulWidget { + const SimulatedDevToolsWrapper({ + super.key, + required this.child, + required this.connected, + }); + + final Widget child; + + final bool connected; + + @override + State createState() => + _SimulatedDevToolsWrapperState(); +} + +class _SimulatedDevToolsWrapperState extends State { + late final _SimulatedDevToolsController simController; + + @override + void initState() { + super.initState(); + simController = _SimulatedDevToolsController()..init(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Split( + axis: Axis.horizontal, + initialFractions: const [0.8, 0.2], + minSizes: const [ + 100.0, + _VmServiceConnection._totalControlsWidth + 2 * defaultSpacing, + ], + children: [ + OutlineDecoration.onlyRight( + child: Padding( + padding: const EdgeInsets.all(defaultSpacing), + child: widget.child, + ), + ), + OutlineDecoration.onlyLeft( + child: Padding( + padding: const EdgeInsets.all(defaultSpacing), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Simulated DevTools Environment', + style: theme.textTheme.titleMedium, + ), + const PaddedDivider(), + _VmServiceConnection( + connected: widget.connected, + simController: simController, + ), + if (widget.connected) + Padding( + padding: const EdgeInsets.symmetric(vertical: denseSpacing), + child: _SimulatedApi(simController: simController), + ), + const PaddedDivider(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Logs:', + style: theme.textTheme.titleMedium, + ), + const PaddedDivider.thin(), + Expanded( + child: _LogMessages(simController: simController), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _SimulatedApi extends StatelessWidget { + const _SimulatedApi({required this.simController}); + + final _SimulatedDevToolsController simController; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + DevToolsButton( + label: 'PING', + onPressed: simController.ping, + ), + // TODO(kenz): add buttons for other simulated events as the extension + // API expands. + ], + ); + } +} + +class _LogMessages extends StatelessWidget { + const _LogMessages({required this.simController}); + + final _SimulatedDevToolsController simController; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ValueListenableBuilder( + valueListenable: simController.messageLogs, + builder: (context, logs, _) { + return ListView.builder( + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + Widget logEntry = Padding( + padding: const EdgeInsets.symmetric(vertical: densePadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '[${log.timestamp.toString()}] from ${log.source.display}', + style: theme.fixedFontStyle, + ), + FormattedJson( + json: log.data, + ), + ], + ), + ); + if (index != 0) { + logEntry = OutlineDecoration.onlyTop(child: logEntry); + } + return logEntry; + }, + ); + }, + ); + } +} diff --git a/packages/devtools_extensions/lib/src/template/devtools_extension.dart b/packages/devtools_extensions/lib/src/template/devtools_extension.dart index d48d7d82666..740b7933092 100644 --- a/packages/devtools_extensions/lib/src/template/devtools_extension.dart +++ b/packages/devtools_extensions/lib/src/template/devtools_extension.dart @@ -10,14 +10,18 @@ import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/service.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:vm_service/vm_service.dart'; import '../../api.dart'; +import '_simulated_devtools_environment/_simulated_devtools_environment.dart'; part 'extension_manager.dart'; +bool _debugUseSimulatedEnvironment = false; + /// A manager that allows extensions to interact with DevTools or the DevTools /// extensions framework. /// @@ -34,13 +38,23 @@ ServiceManager get serviceManager => globals[ServiceManager] as ServiceManager; /// A wrapper widget that initializes the [extensionManager] and establishes a /// connection with DevTools for this extension to interact over. +/// +/// If [useSimulatedEnvironment] is true for a non-release build, a simulated +/// DevTools environment will wrap [child] so that the extension can be +/// developed outside of an embedded iFrame environment. class DevToolsExtension extends StatefulWidget { - const DevToolsExtension({ + DevToolsExtension({ super.key, required this.child, this.eventHandlers = const {}, this.requiresRunningApplication = true, - }); + bool useSimulatedEnvironment = false, + }) { + // Guarantee that we cannot use the simulated environment for release mode. + if (!kReleaseMode) { + _debugUseSimulatedEnvironment = useSimulatedEnvironment; + } + } /// The root of the extension Flutter web app that is wrapped by this /// [DevToolsExtension] wrapper. @@ -57,7 +71,10 @@ class DevToolsExtension extends StatefulWidget { State createState() => _DevToolsExtensionState(); } -class _DevToolsExtensionState extends State { +class _DevToolsExtensionState extends State + with AutoDisposeMixin { + late ConnectedState connectionState; + @override void initState() { super.initState(); @@ -68,6 +85,13 @@ class _DevToolsExtensionState extends State { for (final handler in widget.eventHandlers.entries) { extensionManager.registerEventHandler(handler.key, handler.value); } + + connectionState = serviceManager.connectedState.value; + addAutoDisposeListener(serviceManager.connectedState, () { + setState(() { + connectionState = serviceManager.connectedState.value; + }); + }); } void _initGlobals() { @@ -85,6 +109,11 @@ class _DevToolsExtensionState extends State { @override Widget build(BuildContext context) { + final child = _ConnectionAwareWrapper( + requiresRunningApplication: widget.requiresRunningApplication, + connected: connectionState.connected, + child: widget.child, + ); return MaterialApp( theme: themeFor( isDarkTheme: false, @@ -97,8 +126,37 @@ class _DevToolsExtensionState extends State { theme: ThemeData(useMaterial3: true, colorScheme: darkColorScheme), ), home: Scaffold( - body: widget.child, + body: _debugUseSimulatedEnvironment + ? SimulatedDevToolsWrapper( + connected: connectionState.connected, + child: child, + ) + : child, ), ); } } + +class _ConnectionAwareWrapper extends StatelessWidget { + const _ConnectionAwareWrapper({ + required this.child, + required this.requiresRunningApplication, + required this.connected, + }); + + final bool requiresRunningApplication; + + final bool connected; + + final Widget child; + + @override + Widget build(BuildContext context) { + if (requiresRunningApplication && !connected) { + return const Center( + child: Text('Please connect an app to use this DevTools Extension'), + ); + } + return child; + } +} diff --git a/packages/devtools_extensions/lib/src/template/extension_manager.dart b/packages/devtools_extensions/lib/src/template/extension_manager.dart index dee1c4ad5cf..c6da0c32c83 100644 --- a/packages/devtools_extensions/lib/src/template/extension_manager.dart +++ b/packages/devtools_extensions/lib/src/template/extension_manager.dart @@ -43,11 +43,14 @@ class ExtensionManager { if (e is html.MessageEvent) { final extensionEvent = DevToolsExtensionEvent.tryParse(e.data); if (extensionEvent != null) { + // Do not handle messages that come from the [ExtensionManager] itself. + if (extensionEvent.source == '$ExtensionManager') return; + switch (extensionEvent.type) { case DevToolsExtensionEventType.ping: - html.window.parent?.postMessage( - DevToolsExtensionEvent.pong.toJson(), - e.origin, + postMessageToDevTools( + DevToolsExtensionEvent(DevToolsExtensionEventType.pong), + targetOrigin: e.origin, ); break; case DevToolsExtensionEventType.pong: @@ -76,8 +79,26 @@ class ExtensionManager { } } - void postMessageToDevTools(DevToolsExtensionEvent event) { - html.window.parent?.postMessage(event.toJson(), html.window.origin!); + /// Posts a [DevToolsExtensionEvent] to the DevTools extension host. + /// + /// If [targetOrigin] is null, the message will be posed to + /// [html.window.origin]. + /// + /// When [_debugUseSimulatedEnvironment] is true, this message will be posted + /// to the same [html.window] that the extension is hosted in. + void postMessageToDevTools( + DevToolsExtensionEvent event, { + String? targetOrigin, + }) { + final postWindow = + _debugUseSimulatedEnvironment ? html.window : html.window.parent; + postWindow?.postMessage( + { + ...event.toJson(), + DevToolsExtensionEvent.sourceKey: '$ExtensionManager', + }, + targetOrigin ?? html.window.origin!, + ); } Future connectToVmService(String? vmServiceUri) async { diff --git a/packages/devtools_extensions/pubspec.yaml b/packages/devtools_extensions/pubspec.yaml index 6b9b0297bbe..85d4fc634b7 100644 --- a/packages/devtools_extensions/pubspec.yaml +++ b/packages/devtools_extensions/pubspec.yaml @@ -1,6 +1,6 @@ name: devtools_extensions description: A package for building and supporting extensions for Dart DevTools. -version: 0.0.2-dev.0 +version: 0.0.3-wip repository: https://github.com/flutter/devtools/tree/master/packages/devtools_extensions environment: From e7cbff1ca53067c1e1090875156a13aefbd4ca5c Mon Sep 17 00:00:00 2001 From: Kenzie Schmoll Date: Fri, 25 Aug 2023 07:03:24 -0700 Subject: [PATCH 2/7] use an environment variable instead --- .../foo_devtools_extension/lib/main.dart | 4 +-- .../_simulated_devtools_environment.dart | 5 +-- .../lib/src/template/devtools_extension.dart | 31 +++++++++++-------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/devtools_extensions/example/foo_devtools_extension/lib/main.dart b/packages/devtools_extensions/example/foo_devtools_extension/lib/main.dart index 5e303706224..bdac56b8769 100644 --- a/packages/devtools_extensions/example/foo_devtools_extension/lib/main.dart +++ b/packages/devtools_extensions/example/foo_devtools_extension/lib/main.dart @@ -11,8 +11,8 @@ class FooPackageDevToolsExtension extends StatelessWidget { @override Widget build(BuildContext context) { - return DevToolsExtension( - child: const FooExtensionHomePage(), + return const DevToolsExtension( + child: FooExtensionHomePage(), ); } } diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart index d62fff94183..6515604eebe 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart @@ -22,8 +22,9 @@ part '_simulated_devtools_controller.dart'; /// The simulated environment implements and exposes the same extension host /// APIs that DevTools does. /// -/// To use this wrapper, set the [useSimulatedEnvironment] to true when -/// constructing a [DevToolsExtension] widget. +/// To use this wrapper, set the 'use_simulated_environment' environment +/// variable to true. See [_simulatedEnvironmentEnabled] from +/// `devtools_extension.dart`. class SimulatedDevToolsWrapper extends StatefulWidget { const SimulatedDevToolsWrapper({ super.key, diff --git a/packages/devtools_extensions/lib/src/template/devtools_extension.dart b/packages/devtools_extensions/lib/src/template/devtools_extension.dart index 740b7933092..d7e93631d88 100644 --- a/packages/devtools_extensions/lib/src/template/devtools_extension.dart +++ b/packages/devtools_extensions/lib/src/template/devtools_extension.dart @@ -20,7 +20,22 @@ import '_simulated_devtools_environment/_simulated_devtools_environment.dart'; part 'extension_manager.dart'; -bool _debugUseSimulatedEnvironment = false; +/// If true, a simulated DevTools environment will be wrapped around the +/// extension (see [SimulatedDevToolsWrapper]). +/// +/// By default, the constant is false. +/// To enable it, pass the compilation flag +/// `--dart-define=use_simulated_environment=true`. +/// +/// To enable the flag in debug configuration of VSCode, add value: +/// "args": [ +/// "--dart-define=use_simulated_environment=true" +/// ] +const bool _simulatedEnvironmentEnabled = + bool.fromEnvironment('use_simulated_environment'); + +bool get _debugUseSimulatedEnvironment => + !kReleaseMode && _simulatedEnvironmentEnabled; /// A manager that allows extensions to interact with DevTools or the DevTools /// extensions framework. @@ -38,23 +53,13 @@ ServiceManager get serviceManager => globals[ServiceManager] as ServiceManager; /// A wrapper widget that initializes the [extensionManager] and establishes a /// connection with DevTools for this extension to interact over. -/// -/// If [useSimulatedEnvironment] is true for a non-release build, a simulated -/// DevTools environment will wrap [child] so that the extension can be -/// developed outside of an embedded iFrame environment. class DevToolsExtension extends StatefulWidget { - DevToolsExtension({ + const DevToolsExtension({ super.key, required this.child, this.eventHandlers = const {}, this.requiresRunningApplication = true, - bool useSimulatedEnvironment = false, - }) { - // Guarantee that we cannot use the simulated environment for release mode. - if (!kReleaseMode) { - _debugUseSimulatedEnvironment = useSimulatedEnvironment; - } - } + }); /// The root of the extension Flutter web app that is wrapped by this /// [DevToolsExtension] wrapper. From caaad8a46cf9b933a03a3ec8f1e69ed815370eec Mon Sep 17 00:00:00 2001 From: Kenzie Schmoll Date: Fri, 25 Aug 2023 07:04:32 -0700 Subject: [PATCH 3/7] formatting --- .../_simulated_devtools_environment.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart index 6515604eebe..6cfb3c7b75c 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart @@ -18,10 +18,10 @@ part '_connect_ui.dart'; part '_simulated_devtools_controller.dart'; /// Wraps [child] in a simulated DevTools environment. -/// +/// /// The simulated environment implements and exposes the same extension host /// APIs that DevTools does. -/// +/// /// To use this wrapper, set the 'use_simulated_environment' environment /// variable to true. See [_simulatedEnvironmentEnabled] from /// `devtools_extension.dart`. From e27eee836bde7dab7ad75fd0fdcef20d69795a4b Mon Sep 17 00:00:00 2001 From: Kenzie Schmoll Date: Fri, 25 Aug 2023 11:10:24 -0700 Subject: [PATCH 4/7] fixes --- .../integration_test/test_infra/run/_test_app_driver.dart | 6 +++--- packages/devtools_app_shared/lib/src/ui/common.dart | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart b/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart index 95daf317fd2..c57aab774be 100644 --- a/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart +++ b/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart @@ -301,6 +301,7 @@ abstract class IntegrationTestApp with IOMixin { Future manuallyStopApp() async {} Future start() async { + _debugPrint('starting the test app process...'); await startProcess(); assert( runProcess != null, @@ -319,6 +320,7 @@ abstract class IntegrationTestApp with IOMixin { listenToProcessOutput(runProcess!, printCallback: _debugPrint); + _debugPrint('waiting for app start...'); await waitForAppStart(); } @@ -363,9 +365,7 @@ abstract class IntegrationTestApp with IOMixin { final truncatedMsg = msg.length > maxLength ? '${msg.substring(0, maxLength)}...' : msg; _allMessages.add(truncatedMsg); - if (debugTestScript) { - print('_TestApp - $truncatedMsg'); - } + debugLog('_TestApp - $truncatedMsg'); return msg; } } diff --git a/packages/devtools_app_shared/lib/src/ui/common.dart b/packages/devtools_app_shared/lib/src/ui/common.dart index aef6f3d4799..95f4302ff12 100644 --- a/packages/devtools_app_shared/lib/src/ui/common.dart +++ b/packages/devtools_app_shared/lib/src/ui/common.dart @@ -688,7 +688,7 @@ Widget maybeWrapWithTooltip({ } /// Displays a [json] map as selectable, formatted text. -class FormattedJson extends StatelessWidget { +final class FormattedJson extends StatelessWidget { const FormattedJson({ super.key, this.json, From f0c2fe52e105c7c111fd5076085578d4533fc44c Mon Sep 17 00:00:00 2001 From: Kenzie Schmoll Date: Fri, 25 Aug 2023 13:52:13 -0700 Subject: [PATCH 5/7] Fix state management bug --- .../src/extensions/embedded/_view_web.dart | 2 +- .../lib/src/shared/common_widgets.dart | 31 ----- .../lib/src/ui/common.dart | 31 +++++ .../lib/src/utils/globals.dart | 4 + .../devtools_extensions/lib/src/api/api.dart | 2 +- .../_simulated_devtools_controller.dart | 15 ++- .../_simulated_devtools_environment.dart | 119 ++++++++++++------ .../lib/src/template/devtools_extension.dart | 48 ++++--- .../lib/src/template/extension_manager.dart | 4 +- 9 files changed, 157 insertions(+), 99 deletions(-) diff --git a/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart b/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart index ac006b44410..d4a55016463 100644 --- a/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart +++ b/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart @@ -172,7 +172,7 @@ class _ExtensionIFrameController extends DisposableController } @override - void vmServiceConnectionChanged({String? uri}) { + void vmServiceConnectionChanged({required String? uri}) { _postMessage( DevToolsExtensionEvent( DevToolsExtensionEventType.vmServiceConnection, diff --git a/packages/devtools_app/lib/src/shared/common_widgets.dart b/packages/devtools_app/lib/src/shared/common_widgets.dart index 5ee808bb6b5..ad9dfa2c16e 100644 --- a/packages/devtools_app/lib/src/shared/common_widgets.dart +++ b/packages/devtools_app/lib/src/shared/common_widgets.dart @@ -1021,37 +1021,6 @@ class CenteredCircularProgressIndicator extends StatelessWidget { } } -/// An extension on [ScrollController] to facilitate having the scrolling widget -/// auto scroll to the bottom on new content. -extension ScrollControllerAutoScroll on ScrollController { -// TODO(devoncarew): We lose dock-to-bottom when we receive content when we're -// off screen. - - /// Return whether the view is currently scrolled to the bottom. - bool get atScrollBottom { - final pos = position; - return pos.pixels == pos.maxScrollExtent; - } - - /// Scroll the content to the bottom using the app's default animation - /// duration and curve.. - Future autoScrollToBottom() async { - await animateTo( - position.maxScrollExtent, - duration: rapidDuration, - curve: defaultCurve, - ); - - // Scroll again if we've received new content in the interim. - if (hasClients) { - final pos = position; - if (pos.pixels != pos.maxScrollExtent) { - jumpTo(pos.maxScrollExtent); - } - } - } -} - /// An extension on [LinkedScrollControllerGroup] to facilitate having the /// scrolling widgets auto scroll to the bottom on new content. /// diff --git a/packages/devtools_app_shared/lib/src/ui/common.dart b/packages/devtools_app_shared/lib/src/ui/common.dart index 95f4302ff12..bedb43ec6f9 100644 --- a/packages/devtools_app_shared/lib/src/ui/common.dart +++ b/packages/devtools_app_shared/lib/src/ui/common.dart @@ -713,3 +713,34 @@ final class FormattedJson extends StatelessWidget { ); } } + +/// An extension on [ScrollController] to facilitate having the scrolling widget +/// auto scroll to the bottom on new content. +extension ScrollControllerAutoScroll on ScrollController { +// TODO(devoncarew): We lose dock-to-bottom when we receive content when we're +// off screen. + + /// Return whether the view is currently scrolled to the bottom. + bool get atScrollBottom { + final pos = position; + return pos.pixels == pos.maxScrollExtent; + } + + /// Scroll the content to the bottom using the app's default animation + /// duration and curve.. + Future autoScrollToBottom() async { + await animateTo( + position.maxScrollExtent, + duration: rapidDuration, + curve: defaultCurve, + ); + + // Scroll again if we've received new content in the interim. + if (hasClients) { + final pos = position; + if (pos.pixels != pos.maxScrollExtent) { + jumpTo(pos.maxScrollExtent); + } + } + } +} diff --git a/packages/devtools_app_shared/lib/src/utils/globals.dart b/packages/devtools_app_shared/lib/src/utils/globals.dart index aec2e5189e4..7137939b92c 100644 --- a/packages/devtools_app_shared/lib/src/utils/globals.dart +++ b/packages/devtools_app_shared/lib/src/utils/globals.dart @@ -20,3 +20,7 @@ final Map globals = {}; void setGlobal(Type clazz, Object instance) { globals[clazz] = instance; } + +void removeGlobal(Type clazz) { + globals.remove(clazz); +} diff --git a/packages/devtools_extensions/lib/src/api/api.dart b/packages/devtools_extensions/lib/src/api/api.dart index d0cec7c8d02..3dbafe382b7 100644 --- a/packages/devtools_extensions/lib/src/api/api.dart +++ b/packages/devtools_extensions/lib/src/api/api.dart @@ -44,7 +44,7 @@ abstract interface class DevToolsExtensionHostInterface { /// This method should send a [DevToolsExtensionEventType.vmServiceConnection] /// event to the extension to notify it of the vm service uri it should /// establish a connection to. - void vmServiceConnectionChanged({String? uri}); + void vmServiceConnectionChanged({required String? uri}); /// Handles events sent by the extension. /// diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart index cb23fed0dbc..392b70632e9 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart @@ -5,6 +5,7 @@ part of '_simulated_devtools_environment.dart'; class _SimulatedDevToolsController extends DisposableController + with AutoDisposeControllerMixin implements DevToolsExtensionHostInterface { /// Logs of the post message communication that goes back and forth between /// the extension and the simulated DevTools environment. @@ -12,6 +13,12 @@ class _SimulatedDevToolsController extends DisposableController void init() { html.window.addEventListener('message', _handleMessage); + addAutoDisposeListener(serviceManager.connectedState, () { + if (!serviceManager.connectedState.value.connected) { + vmServiceConnectionChanged(uri: null); + messageLogs.clear(); + } + }); } void _handleMessage(html.Event e) { @@ -41,13 +48,13 @@ class _SimulatedDevToolsController extends DisposableController } @override - void vmServiceConnectionChanged({String? uri}) { - uri = 'http://127.0.0.1:60851/fH-kAEXc7MQ=/'; + void vmServiceConnectionChanged({required String? uri}) { // TODO(kenz): add some validation and error handling if [uri] is bad input. - final normalizedUri = normalizeVmServiceUri(uri!); + final normalizedUri = + uri != null ? normalizeVmServiceUri(uri).toString() : null; final event = DevToolsExtensionEvent( DevToolsExtensionEventType.vmServiceConnection, - data: {'uri': normalizedUri.toString()}, + data: {'uri': normalizedUri}, ); _postMessageToExtension(event); } diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart index 6cfb3c7b75c..420321d1ae6 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart @@ -3,8 +3,10 @@ // found in the LICENSE file. // ignore: avoid_web_libraries_in_flutter, as designed +import 'dart:async'; import 'dart:html' as html; +import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; @@ -29,25 +31,40 @@ class SimulatedDevToolsWrapper extends StatefulWidget { const SimulatedDevToolsWrapper({ super.key, required this.child, - required this.connected, }); final Widget child; - final bool connected; - @override State createState() => _SimulatedDevToolsWrapperState(); } -class _SimulatedDevToolsWrapperState extends State { +class _SimulatedDevToolsWrapperState extends State + with AutoDisposeMixin { late final _SimulatedDevToolsController simController; + late ConnectedState connectionState; + + bool get connected => connectionState.connected; + @override void initState() { super.initState(); simController = _SimulatedDevToolsController()..init(); + + connectionState = serviceManager.connectedState.value; + addAutoDisposeListener(serviceManager.connectedState, () { + setState(() { + connectionState = serviceManager.connectedState.value; + }); + }); + } + + @override + void dispose() { + simController.dispose(); + super.dispose(); } @override @@ -79,10 +96,10 @@ class _SimulatedDevToolsWrapperState extends State { ), const PaddedDivider(), _VmServiceConnection( - connected: widget.connected, + connected: connected, simController: simController, ), - if (widget.connected) + if (connected) Padding( padding: const EdgeInsets.symmetric(vertical: denseSpacing), child: _SimulatedApi(simController: simController), @@ -92,9 +109,20 @@ class _SimulatedDevToolsWrapperState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Logs:', - style: theme.textTheme.titleMedium, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Logs:', + style: theme.textTheme.titleMedium, + ), + DevToolsButton.iconOnly( + icon: Icons.clear, + outlined: false, + tooltip: 'Clear logs', + onPressed: () => simController.messageLogs.clear(), + ), + ], ), const PaddedDivider.thin(), Expanded( @@ -133,41 +161,62 @@ class _SimulatedApi extends StatelessWidget { } } -class _LogMessages extends StatelessWidget { +class _LogMessages extends StatefulWidget { const _LogMessages({required this.simController}); final _SimulatedDevToolsController simController; + @override + State<_LogMessages> createState() => _LogMessagesState(); +} + +class _LogMessagesState extends State<_LogMessages> { + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); return ValueListenableBuilder( - valueListenable: simController.messageLogs, + valueListenable: widget.simController.messageLogs, builder: (context, logs, _) { - return ListView.builder( - itemCount: logs.length, - itemBuilder: (context, index) { - final log = logs[index]; - Widget logEntry = Padding( - padding: const EdgeInsets.symmetric(vertical: densePadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '[${log.timestamp.toString()}] from ${log.source.display}', - style: theme.fixedFontStyle, - ), - FormattedJson( - json: log.data, - ), - ], - ), - ); - if (index != 0) { - logEntry = OutlineDecoration.onlyTop(child: logEntry); - } - return logEntry; - }, + if (_scrollController.hasClients && _scrollController.atScrollBottom) { + unawaited(_scrollController.autoScrollToBottom()); + } + return Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: ListView.builder( + controller: _scrollController, + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + Widget logEntry = Padding( + padding: const EdgeInsets.symmetric(vertical: densePadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '[${log.timestamp.toString()}] from ${log.source.display}', + style: theme.fixedFontStyle, + ), + FormattedJson( + json: log.data, + ), + ], + ), + ); + if (index != 0) { + logEntry = OutlineDecoration.onlyTop(child: logEntry); + } + return logEntry; + }, + ), ); }, ); diff --git a/packages/devtools_extensions/lib/src/template/devtools_extension.dart b/packages/devtools_extensions/lib/src/template/devtools_extension.dart index d7e93631d88..529a3e08ac4 100644 --- a/packages/devtools_extensions/lib/src/template/devtools_extension.dart +++ b/packages/devtools_extensions/lib/src/template/devtools_extension.dart @@ -76,10 +76,7 @@ class DevToolsExtension extends StatefulWidget { State createState() => _DevToolsExtensionState(); } -class _DevToolsExtensionState extends State - with AutoDisposeMixin { - late ConnectedState connectionState; - +class _DevToolsExtensionState extends State { @override void initState() { super.initState(); @@ -90,13 +87,6 @@ class _DevToolsExtensionState extends State for (final handler in widget.eventHandlers.entries) { extensionManager.registerEventHandler(handler.key, handler.value); } - - connectionState = serviceManager.connectedState.value; - addAutoDisposeListener(serviceManager.connectedState, () { - setState(() { - connectionState = serviceManager.connectedState.value; - }); - }); } void _initGlobals() { @@ -106,9 +96,17 @@ class _DevToolsExtensionState extends State setGlobal(IdeTheme, IdeTheme()); } + void _removeGlobals() { + removeGlobal(ExtensionManager); + removeGlobal(ServiceManager); + removeGlobal(IdeTheme); + } + @override - void dispose() { + void dispose() async { extensionManager._dispose(); + await serviceManager.manuallyDisconnect(); + _removeGlobals(); super.dispose(); } @@ -116,7 +114,6 @@ class _DevToolsExtensionState extends State Widget build(BuildContext context) { final child = _ConnectionAwareWrapper( requiresRunningApplication: widget.requiresRunningApplication, - connected: connectionState.connected, child: widget.child, ); return MaterialApp( @@ -132,10 +129,7 @@ class _DevToolsExtensionState extends State ), home: Scaffold( body: _debugUseSimulatedEnvironment - ? SimulatedDevToolsWrapper( - connected: connectionState.connected, - child: child, - ) + ? SimulatedDevToolsWrapper(child: child) : child, ), ); @@ -146,22 +140,24 @@ class _ConnectionAwareWrapper extends StatelessWidget { const _ConnectionAwareWrapper({ required this.child, required this.requiresRunningApplication, - required this.connected, }); final bool requiresRunningApplication; - final bool connected; - final Widget child; @override Widget build(BuildContext context) { - if (requiresRunningApplication && !connected) { - return const Center( - child: Text('Please connect an app to use this DevTools Extension'), - ); - } - return child; + return ValueListenableBuilder( + valueListenable: serviceManager.connectedState, + builder: (context, connectedState, _) { + if (requiresRunningApplication && !connectedState.connected) { + return const Center( + child: Text('Please connect an app to use this DevTools Extension'), + ); + } + return child; + }, + ); } } diff --git a/packages/devtools_extensions/lib/src/template/extension_manager.dart b/packages/devtools_extensions/lib/src/template/extension_manager.dart index 657ee11759f..e72dace51c6 100644 --- a/packages/devtools_extensions/lib/src/template/extension_manager.dart +++ b/packages/devtools_extensions/lib/src/template/extension_manager.dart @@ -96,7 +96,9 @@ class ExtensionManager { } Future _connectToVmService(String? vmServiceUri) async { - if (vmServiceUri == null) return; + // TODO(kenz): investigate. this is weird but `vmServiceUri` != null even + // when the `toString()` representation is 'null'. + if (vmServiceUri == null || '$vmServiceUri' == 'null') return; try { final finishedCompleter = Completer(); From 4636bd41fc05413e415a2333179a13f76a157950 Mon Sep 17 00:00:00 2001 From: Kenzie Schmoll Date: Fri, 25 Aug 2023 13:52:48 -0700 Subject: [PATCH 6/7] formatting --- packages/devtools_extensions/lib/src/api/api.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devtools_extensions/lib/src/api/api.dart b/packages/devtools_extensions/lib/src/api/api.dart index 3dbafe382b7..281be7caaf6 100644 --- a/packages/devtools_extensions/lib/src/api/api.dart +++ b/packages/devtools_extensions/lib/src/api/api.dart @@ -33,7 +33,7 @@ enum DevToolsExtensionEventType { } /// Interface that a DevTools extension host should implement. -/// +/// /// This interface is implemented by DevTools itself as well as by a simulated /// DevTools environment for simplifying extension development. abstract interface class DevToolsExtensionHostInterface { @@ -47,7 +47,7 @@ abstract interface class DevToolsExtensionHostInterface { void vmServiceConnectionChanged({required String? uri}); /// Handles events sent by the extension. - /// + /// /// If an unknown event is recevied, this handler should call [onUnknownEvent] /// if non-null. void onEventReceived( From a9db1f89b05fad2fee44a4f3968ef2bd9eb1cbca Mon Sep 17 00:00:00 2001 From: Kenzie Schmoll Date: Fri, 25 Aug 2023 14:51:34 -0700 Subject: [PATCH 7/7] add note about memory leak --- .../devtools_app_shared/lib/src/utils/globals.dart | 1 + .../lib/src/template/devtools_extension.dart | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/devtools_app_shared/lib/src/utils/globals.dart b/packages/devtools_app_shared/lib/src/utils/globals.dart index 7137939b92c..c3ed9cfc682 100644 --- a/packages/devtools_app_shared/lib/src/utils/globals.dart +++ b/packages/devtools_app_shared/lib/src/utils/globals.dart @@ -23,4 +23,5 @@ void setGlobal(Type clazz, Object instance) { void removeGlobal(Type clazz) { globals.remove(clazz); + assert(globals[clazz] == null); } diff --git a/packages/devtools_extensions/lib/src/template/devtools_extension.dart b/packages/devtools_extensions/lib/src/template/devtools_extension.dart index 529a3e08ac4..52f66b47360 100644 --- a/packages/devtools_extensions/lib/src/template/devtools_extension.dart +++ b/packages/devtools_extensions/lib/src/template/devtools_extension.dart @@ -96,17 +96,18 @@ class _DevToolsExtensionState extends State { setGlobal(IdeTheme, IdeTheme()); } - void _removeGlobals() { + void _shutdown() { + (globals[ExtensionManager] as ExtensionManager?)?._dispose(); removeGlobal(ExtensionManager); removeGlobal(ServiceManager); removeGlobal(IdeTheme); } @override - void dispose() async { - extensionManager._dispose(); - await serviceManager.manuallyDisconnect(); - _removeGlobals(); + void dispose() { + // TODO(https://github.com/flutter/flutter/issues/10437): dispose is never + // called on hot restart, so these resources leak for local development. + _shutdown(); super.dispose(); }