blob: 43c72cd6b7c287cdf3acf9a1cddaea0f958026c9 [file] [log] [blame]
// Copyright 2019 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
/// @docImport 'package:vm_service/vm_service.dart';
library;
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:devtools_app_shared/shared.dart';
import 'package:devtools_app_shared/ui.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'extensions/extension_screen.dart';
import 'framework/framework_core.dart';
import 'framework/home_screen.dart';
import 'framework/initializer.dart';
import 'framework/notifications_view.dart';
import 'framework/observer/disconnect_observer.dart';
import 'framework/release_notes.dart';
import 'framework/scaffold/scaffold.dart';
import 'screens/app_size/app_size_controller.dart';
import 'screens/app_size/app_size_screen.dart';
import 'screens/debugger/debugger_controller.dart';
import 'screens/debugger/debugger_screen.dart';
import 'screens/deep_link_validation/deep_links_controller.dart';
import 'screens/deep_link_validation/deep_links_screen.dart';
import 'screens/dtd/dtd_tools_controller.dart';
import 'screens/dtd/dtd_tools_screen.dart';
import 'screens/inspector_shared/inspector_screen.dart';
import 'screens/inspector_shared/inspector_screen_controller.dart';
import 'screens/logging/logging_controller.dart';
import 'screens/logging/logging_screen.dart';
import 'screens/memory/framework/memory_controller.dart';
import 'screens/memory/framework/memory_screen.dart';
import 'screens/network/network_controller.dart';
import 'screens/network/network_screen.dart';
import 'screens/performance/performance_controller.dart';
import 'screens/performance/performance_screen.dart';
import 'screens/profiler/profiler_screen.dart';
import 'screens/profiler/profiler_screen_controller.dart';
import 'screens/provider/provider_screen.dart';
import 'screens/vm_developer/vm_developer_tools_controller.dart';
import 'screens/vm_developer/vm_developer_tools_screen.dart';
import 'service/service_extension_widgets.dart';
import 'shared/analytics/analytics_controller.dart';
import 'shared/feature_flags.dart';
import 'shared/framework/framework_controller.dart';
import 'shared/framework/routing.dart';
import 'shared/framework/screen.dart';
import 'shared/framework/screen_controllers.dart';
import 'shared/globals.dart';
import 'shared/offline/offline_data.dart';
import 'shared/offline/offline_screen.dart';
import 'shared/primitives/query_parameters.dart';
import 'shared/primitives/utils.dart';
import 'shared/ui/common_widgets.dart';
import 'shared/ui/hover.dart';
import 'shared/utils/focus_utils.dart';
import 'shared/utils/utils.dart';
import 'standalone_ui/standalone_screen.dart';
/// Top-level configuration for the app.
@immutable
class DevToolsApp extends StatefulWidget {
const DevToolsApp(
this.originalScreens,
this.analyticsController, {
super.key,
});
final List<DevToolsScreen> originalScreens;
final AnalyticsController analyticsController;
@override
State<DevToolsApp> createState() => DevToolsAppState();
}
/// Initializer for the [FrameworkCore] and the app's navigation.
///
/// This manages the route generation, and marshals URL query parameters into
/// flutter route parameters.
class DevToolsAppState extends State<DevToolsApp> with AutoDisposeMixin {
List<Screen> get _screens {
if (FeatureFlags.devToolsExtensions.isEnabled) {
// TODO(https://github.com/flutter/devtools/issues/6273): stop special
// casing the package:provider extension.
final containsProviderExtension = extensionService
.currentExtensions
.value
.visibleExtensions
.where((e) => e.name == 'provider')
.isNotEmpty;
final devToolsScreens = containsProviderExtension
? _originalScreens
.where((s) => s.screenId != ScreenMetaData.provider.id)
.toList()
: _originalScreens;
return [...devToolsScreens, ..._extensionScreens];
}
return _originalScreens;
}
List<Screen> get _originalScreens =>
widget.originalScreens.map((s) => s.screen).toList();
Iterable<Screen> get _extensionScreens =>
extensionService.visibleExtensions.map(
(e) =>
DevToolsScreen<DevToolsScreenController>(ExtensionScreen(e)).screen,
);
final hoverCardController = HoverCardController();
late ReleaseNotesController releaseNotesController;
late final routerDelegate = DevToolsRouterDelegate(_getPage);
@override
void initState() {
super.initState();
setGlobal(GlobalKey<NavigatorState>, routerDelegate.navigatorKey);
autoDisposeStreamSubscription(
frameworkController.onConnectVmEvent.listen(_connectVm),
);
_initScreenControllers(
connected:
serviceConnection.serviceManager.connectedState.value.connected,
offline: offlineDataController.showingOfflineData.value,
);
addAutoDisposeListener(offlineDataController.showingOfflineData, () {
final offlineMode = offlineDataController.showingOfflineData.value;
// Dispose the current offline controllers, if any, for any change to the
// showing offline data value.
screenControllers.disposeOfflineControllers();
if (offlineMode) {
_initScreenControllers(offline: offlineMode);
}
});
addAutoDisposeListener(serviceConnection.serviceManager.connectedState, () {
final connectionState =
serviceConnection.serviceManager.connectedState.value;
// When disconnecting from an app, prepare the offline state for reviewing
// history.
screenControllers.forEachInitialized((screenController) {
if (screenController is OfflineScreenControllerMixin &&
!connectionState.connected &&
!connectionState.userInitiatedConnectionState) {
screenController.maybePrepareDataForReviewingHistory();
}
});
// Dispose the current connected controllers, if any, for any change to
// the connected state.
screenControllers.disposeConnectedControllers();
_initScreenControllers(connected: connectionState.connected);
});
// TODO(https://github.com/flutter/devtools/issues/6018): Once
// https://github.com/flutter/flutter/issues/129692 is fixed, disable the
// browser's native context menu on secondary-click, and instead use the
// menu provided by Flutter:
// if (kIsWeb) {
// unawaited(BrowserContextMenu.disableContextMenu());
// }
void clearRoutesAndSetState() {
setState(() {
_clearCachedRoutes();
});
}
if (FeatureFlags.devToolsExtensions.isEnabled) {
addAutoDisposeListener(
extensionService.currentExtensions,
clearRoutesAndSetState,
);
}
addAutoDisposeListener(
serviceConnection.serviceManager.isolateManager.mainIsolate,
clearRoutesAndSetState,
);
addAutoDisposeListener(preferences.darkModeEnabled);
releaseNotesController = ReleaseNotesController();
// Workaround for https://github.com/flutter/flutter/issues/155265.
setUpTextFieldFocusFixHandler();
}
@override
void dispose() {
FrameworkCore.dispose();
// Workaround for https://github.com/flutter/flutter/issues/155265.
removeTextFieldFocusFixHandler();
super.dispose();
}
@override
void didUpdateWidget(DevToolsApp oldWidget) {
super.didUpdateWidget(oldWidget);
_clearCachedRoutes();
}
/// Connects to the VM with the given URI.
///
/// This request usually comes from the IDE via the server API to reuse the
/// DevTools window after being disconnected (for example if the user stops
/// a debug session then launches a new one).
Future<void> _connectVm(ConnectVmEvent event) async {
await routerDelegate.updateArgsIfChanged({
'uri': event.serviceProtocolUri.toString(),
if (event.notify) 'notify': 'true',
});
}
/// Gets the page for a given page/path and args.
Page _getPage(
BuildContext context,
String? page,
DevToolsQueryParams params,
DevToolsNavigationState? state,
) {
// `page` will initially be null while the router is set up, then we will
// be called again with an empty string for the root.
if (FrameworkCore.vmServiceInitializationInProgress || page == null) {
return const MaterialPage(child: CenteredCircularProgressIndicator());
}
// Provide the appropriate page route.
if (pages.containsKey(page)) {
Widget widget = pages[page]!(context, page, params, state);
assert(() {
widget = _AlternateCheckedModeBanner(
builder: (context) => pages[page]!(context, page, params, state),
);
return true;
}());
return MaterialPage(child: widget);
}
// Return a page not found.
return MaterialPage(
child: DevToolsScaffold.withChild(
key: const Key('not-found'),
embedMode: params.embedMode,
child: ScreenUnavailable(
title: "The '$page' page cannot be found.",
embedMode: params.embedMode,
routerDelegate: routerDelegate,
),
),
);
}
Widget _buildTabbedPage(
BuildContext _,
String? page,
DevToolsQueryParams queryParams,
DevToolsNavigationState? _,
) {
final vmServiceUri = queryParams.vmServiceUri;
final embedMode = queryParams.embedMode;
// TODO(dantup): We should be able simplify this a little, removing params['page']
// and only supporting /inspector (etc.) instead of also &page=inspector if
// all IDEs switch over to those URLs.
if (page?.isEmpty ?? true) {
page = queryParams.legacyPage;
}
final paramsContainVmServiceUri =
vmServiceUri != null && vmServiceUri.isNotEmpty;
Widget scaffoldBuilder() {
// Force regeneration of visible screens when Advanced Developer Mode is
// enabled and when the list of available extensions change.
return MultiValueListenableBuilder(
listenables: [
preferences.advancedDeveloperModeEnabled,
extensionService.currentExtensions,
],
builder: (_, _, child) {
final screensInScaffold = _visibleScreens()
.where(
(s) => maybeIncludeOnlyEmbeddedScreen(
s,
page: page,
embedMode: embedMode,
),
)
.toList();
removeHiddenScreens(screensInScaffold, queryParams);
DevToolsScaffold scaffold;
final originalScreen = _screens.firstWhereOrNull(
(s) => s.screenId == page,
);
final screenInOriginalScreens = originalScreen != null;
final screenInScaffoldScreens = screensInScaffold.any(
(s) => s.screenId == page,
);
if (page != null &&
screenInOriginalScreens &&
!screenInScaffoldScreens) {
// The requested [page] is in the list of DevTools screens, but is
// not available in list of available screens for this scaffold.
scaffold = DevToolsScaffold.withChild(
key: const Key('screen-disabled'),
embedMode: embedMode,
child: ScreenUnavailable(
title: "The '$page' screen is unavailable.",
description: _screenDisabledMessage(originalScreen),
routerDelegate: routerDelegate,
embedMode: embedMode,
),
);
} else if (screensInScaffold.isEmpty) {
// TODO(https://github.com/dart-lang/pub-dev/issues/7216): add an
// extensions store or a link to a pub.dev query for packages with
// extensions.
scaffold = DevToolsScaffold.withChild(
embedMode: embedMode,
child: CenteredMessage(
message:
'No DevTools '
'${queryParams.hideAllExceptExtensions ? 'extensions' : 'screens'} '
'available for your project.',
),
);
} else {
final connectedToFlutterApp =
serviceConnection
.serviceManager
.connectedApp
?.isFlutterAppNow ??
false;
final connectedToDartWebApp =
serviceConnection
.serviceManager
.connectedApp
?.isDartWebAppNow ??
false;
scaffold = DevToolsScaffold(
embedMode: embedMode,
page: page,
screens: screensInScaffold,
actions: isEmbedded()
? []
: [
if (paramsContainVmServiceUri) ...[
// Hide the hot reload button for Dart web apps, where the
// hot reload service extension is not avilable and where the
// [service.reloadServices] RPC is not implemented.
// TODO(https://github.com/flutter/devtools/issues/6441): find
// a way to show this for Dart web apps when supported.
if (!connectedToDartWebApp)
HotReloadButton(
callOnVmServiceDirectly: !connectedToFlutterApp,
),
// This button will hide itself based on whether the
// hot restart service is available for the connected app.
const HotRestartButton(),
],
...DevToolsScaffold.defaultActions(),
],
);
}
return scaffold;
},
);
}
return paramsContainVmServiceUri
? Initializer(builder: (_) => scaffoldBuilder())
: scaffoldBuilder();
}
/// The pages that the app exposes.
Map<String, UrlParametersBuilder> get pages {
return _routes ??= {
homeScreenId: _buildTabbedPage,
for (final screen in _screens) screen.screenId: _buildTabbedPage,
snapshotScreenId: (_, _, params, _) {
// TODO(kenz): support multiple offline routes, or prevent an offline
// route from being pushed on top of another one.
// If an offline route is pushed on top of another offline route, this
// will cause the oldest set of controllers to be destroyed.
screenControllers.disposeOfflineControllers();
_initScreenControllers(offline: true);
return DevToolsScaffold.withChild(
key: UniqueKey(),
embedMode: params.embedMode,
child: OfflineScreenBody(params.offlineScreenId, _screens),
);
},
..._standaloneScreens,
};
}
Map<String, UrlParametersBuilder>? _routes;
void _clearCachedRoutes() {
_routes = null;
routerDelegate.refreshPages();
}
Map<String, UrlParametersBuilder> get _standaloneScreens {
// TODO(dantup): Standalone screens do not use DevToolsScaffold which means
// they do not currently send an initial "currentPage" event to inform
// the server which page they are rendering.
return {
for (final type in StandaloneScreenType.values)
type.name: (_, _, args, _) => type.screen,
};
}
// TODO(kenz): consider showing all screens and displaying the reason why they
// are not available instead of hiding screens.
List<Screen> _visibleScreens() =>
_screens.where((screen) => shouldShowScreen(screen).show).toList();
void _initScreenControllers({bool connected = false, bool offline = false}) {
// We use [widget.originalScreens] here instead of [_screens] because
// extension screens do not provide a controller through this mechanism.
final screensThatProvideController = widget.originalScreens.where(
(s) => s.providesController,
);
var screens = screensThatProvideController;
if (offline) {
screens = screensThatProvideController.where(
(s) => s.screen.worksWithOfflineData,
);
} else if (!connected) {
screens = screensThatProvideController.where(
(s) => !s.screen.requiresConnection,
);
}
for (final s in screens) {
s.registerController(routerDelegate, offline: offline);
}
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
themeMode: isDarkThemeEnabled() ? ThemeMode.dark : ThemeMode.light,
theme: themeFor(
isDarkTheme: false,
ideTheme: ideTheme,
theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme),
),
darkTheme: themeFor(
isDarkTheme: true,
ideTheme: ideTheme,
theme: ThemeData(useMaterial3: true, colorScheme: darkColorScheme),
),
builder: (context, child) {
if (child == null) {
return const CenteredMessage(
message: 'Uh-oh, something went wrong. Please refresh the page.',
);
}
return MultiProvider(
providers: [
Provider<AnalyticsController>.value(
value: widget.analyticsController,
),
Provider<HoverCardController>.value(value: hoverCardController),
Provider<ReleaseNotesController>.value(
value: releaseNotesController,
),
],
child: NotificationsView(
child: ReleaseNotesViewer(
controller: releaseNotesController,
child: DisconnectObserver(
routerDelegate: routerDelegate,
child: child,
),
),
),
);
},
routerDelegate: routerDelegate,
routeInformationParser: DevToolsRouteInformationParser(),
// Disable default scrollbar behavior on web to fix duplicate scrollbars
// bug, see https://github.com/flutter/flutter/issues/90697:
scrollBehavior: const MaterialScrollBehavior().copyWith(
scrollbars: !kIsWeb,
),
);
}
/// Helper function that will be used in a 'List.where' call to generate a
/// list of [Screen]s to pass to a [DevToolsScaffold].
///
/// When [embedMode] is [EmbedMode.embedOne], this method will return true
/// only when [screen] matches the specified [page]. Otherwise, this method
/// will return true for any [screen].
@visibleForTesting
static bool maybeIncludeOnlyEmbeddedScreen(
Screen screen, {
required String? page,
required EmbedMode embedMode,
}) {
if (embedMode == EmbedMode.embedOne && page != null) {
return screen.screenId == page;
}
return true;
}
/// Helper function that removes any hidden screens from [screens] based on
/// the value of the 'hide' query parameter in [params].
@visibleForTesting
static void removeHiddenScreens(
List<Screen> screens,
DevToolsQueryParams params,
) {
screens.removeWhere((s) => params.hiddenScreens.contains(s.screenId));
// When 'hide=extensions' is in the query parameters, this remove all
// extension screens.
if (params.hideExtensions) {
screens.removeWhere((s) => s is ExtensionScreen);
}
// When 'hide=all-except-extensions' is in the query parameters, remove all
// non-extension screens.
if (params.hideAllExceptExtensions) {
screens.removeWhere((s) => s is! ExtensionScreen);
}
}
String? _screenDisabledMessage(Screen screen) {
final reason = shouldShowScreen(screen).disabledReason;
String? disabledMessage;
if (reason == ScreenDisabledReason.requiresDartLibrary) {
// Special case for screens that require a library since the message
// needs to be generated dynamically.
disabledMessage =
'The ${screen.title} screen requires library '
'${screen.requiresLibrary}, but the library was not detected.';
} else if (reason?.message case final String message) {
disabledMessage = 'The ${screen.title} screen $message';
}
return disabledMessage;
}
}
/// DevTools screen wrapper that is responsible for creating and providing the
/// screen's controller, if one exists, as well as enabling offline support.
///
/// [C] corresponds to the type of the screen's controller, which is created by
/// [createController].
class DevToolsScreen<C extends DevToolsScreenController> {
const DevToolsScreen(this.screen, {this.createController});
final Screen screen;
/// Responsible for creating the controller for this screen, if non-null.
///
/// If [createController] and `controller` are both null, [screen] will be
/// responsible for creating and maintaining its own controller.
///
/// In the controller initialization, if logic requires a connected [VmService]
/// object (`serviceConnection.serviceManager.service`), then the controller should first await
/// the `serviceConnection.serviceManager.onServiceAvailable` future to ensure the service has
/// been initialized.
/// The controller does not need to handle re-connection to the application. When reconnected,
/// DevTools will create a new controller. However, the controller should make sure
/// not to fail if the connection is lost.
final C Function(DevToolsRouterDelegate)? createController;
/// Returns true if a controller was provided for [screen]. If false,
/// [screen] is responsible for creating and maintaining its own controller.
bool get providesController => createController != null;
/// Registers a screen controller with the [ScreenControllers] manager.
void registerController(
DevToolsRouterDelegate routerDelegate, {
required bool offline,
}) {
screenControllers.register<C>(
() => createController!(routerDelegate),
offline: offline,
);
}
}
/// A [WidgetBuilder] that takes an additional map of URL query parameters and
/// args, as well a state not included in the URL.
typedef UrlParametersBuilder =
Widget Function(
BuildContext,
String?,
DevToolsQueryParams,
DevToolsNavigationState?,
);
/// Displays the checked mode banner in the bottom end corner instead of the
/// top end corner.
///
/// This avoids issues with widgets in the appbar being hidden by the banner
/// in a web or desktop app.
class _AlternateCheckedModeBanner extends StatelessWidget {
const _AlternateCheckedModeBanner({required this.builder});
final WidgetBuilder builder;
@override
Widget build(BuildContext context) {
return Banner(
message: 'DEBUG',
textDirection: TextDirection.ltr,
location: BannerLocation.bottomEnd,
child: Builder(builder: builder),
);
}
}
class ScreenUnavailable extends StatelessWidget {
const ScreenUnavailable({
super.key,
required this.title,
required this.embedMode,
required this.routerDelegate,
this.description,
});
final String title;
final DevToolsRouterDelegate routerDelegate;
final EmbedMode embedMode;
final String? description;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: theme.textTheme.titleLarge),
const SizedBox(height: denseSpacing),
if (description != null)
Text(description!, style: theme.regularTextStyle),
if (embedMode == EmbedMode.none) ...[
const SizedBox(height: defaultSpacing),
ElevatedButton(
onPressed: () =>
routerDelegate.navigateHome(clearScreenParam: true),
child: const Text('Go to Home screen'),
),
],
],
),
);
}
}
/// Screens to initialize DevTools with.
///
/// If the screen depends on a provided controller, the provider should be
/// provided here.
///
/// Conditional screens can be added to this list, and they will automatically
/// be shown or hidden based on the [Screen] conditionalLibrary provided.
List<DevToolsScreen> defaultScreens({
List<DevToolsJsonFile> sampleData = const [],
}) {
return devtoolsScreens ??= <DevToolsScreen>[
DevToolsScreen<DevToolsScreenController>(
HomeScreen(sampleData: sampleData),
),
// TODO(https://github.com/flutter/devtools/issues/7860): Clean-up after
// Inspector V2 has been released.
DevToolsScreen<InspectorScreenController>(
InspectorScreen(),
createController: (_) => InspectorScreenController(),
),
DevToolsScreen<PerformanceController>(
PerformanceScreen(),
createController: (_) => PerformanceController(),
),
DevToolsScreen<ProfilerScreenController>(
ProfilerScreen(),
createController: (_) => ProfilerScreenController(),
),
DevToolsScreen<MemoryController>(
MemoryScreen(),
createController: (_) => MemoryController(),
),
DevToolsScreen<DebuggerController>(
DebuggerScreen(),
createController: (routerDelegate) =>
DebuggerController(routerDelegate: routerDelegate),
),
DevToolsScreen<NetworkController>(
NetworkScreen(),
createController: (_) => NetworkController(),
),
DevToolsScreen<LoggingController>(
LoggingScreen(),
createController: (_) => LoggingController(),
),
DevToolsScreen<DevToolsScreenController>(ProviderScreen()),
DevToolsScreen<AppSizeController>(
AppSizeScreen(),
createController: (_) => AppSizeController(),
),
DevToolsScreen<DeepLinksController>(
DeepLinksScreen(),
createController: (_) => DeepLinksController(),
),
DevToolsScreen<VMDeveloperToolsController>(
VMDeveloperToolsScreen(),
createController: (_) => VMDeveloperToolsController(),
),
DevToolsScreen<DTDToolsController>(
DTDToolsScreen(),
createController: (_) => DTDToolsController(),
),
];
}
@visibleForTesting
List<DevToolsScreen>? devtoolsScreens;