| // Copyright 2014 The Flutter Authors. 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 'package:args/args.dart'; |
| import 'package:collection/collection.dart'; |
| import 'package:meta/meta.dart'; |
| import 'package:package_config/package_config.dart'; |
| import 'package:process/process.dart'; |
| |
| import '../artifacts.dart'; |
| import '../base/common.dart'; |
| import '../base/file_system.dart'; |
| import '../base/io.dart'; |
| import '../base/logger.dart'; |
| import '../base/os.dart'; |
| import '../base/platform.dart'; |
| import '../base/process.dart'; |
| import '../base/terminal.dart'; |
| import '../build_info.dart'; |
| import '../bundle.dart' as bundle; |
| import '../cache.dart'; |
| import '../convert.dart'; |
| import '../device.dart'; |
| import '../globals.dart' as globals; |
| import '../isolated/resident_web_runner.dart'; |
| import '../project.dart'; |
| import '../resident_runner.dart'; |
| import '../runner/flutter_command.dart'; |
| import '../web/web_device.dart'; |
| import '../widget_preview/analytics.dart'; |
| import '../widget_preview/dependency_graph.dart'; |
| import '../widget_preview/dtd_services.dart'; |
| import '../widget_preview/preview_code_generator.dart'; |
| import '../widget_preview/preview_detector.dart'; |
| import '../widget_preview/preview_manifest.dart'; |
| import '../widget_preview/preview_pubspec_builder.dart'; |
| import 'create_base.dart'; |
| |
| class WidgetPreviewCommand extends FlutterCommand { |
| WidgetPreviewCommand({ |
| required bool verboseHelp, |
| required Logger logger, |
| required FileSystem fs, |
| required FlutterProjectFactory projectFactory, |
| required Cache cache, |
| required Platform platform, |
| required ShutdownHooks shutdownHooks, |
| required OperatingSystemUtils os, |
| required ProcessManager processManager, |
| required Artifacts artifacts, |
| @visibleForTesting WidgetPreviewDtdServices? dtdServicesOverride, |
| }) { |
| addSubcommand( |
| WidgetPreviewStartCommand( |
| verbose: verboseHelp, |
| logger: logger, |
| fs: fs, |
| projectFactory: projectFactory, |
| cache: cache, |
| platform: platform, |
| shutdownHooks: shutdownHooks, |
| os: os, |
| processManager: processManager, |
| artifacts: artifacts, |
| dtdServicesOverride: dtdServicesOverride, |
| ), |
| ); |
| addSubcommand( |
| WidgetPreviewCleanCommand(logger: logger, fs: fs, projectFactory: projectFactory), |
| ); |
| } |
| |
| @override |
| String get description => 'Manage the widget preview environment.'; |
| |
| @override |
| String get name => kWidgetPreview; |
| static const kWidgetPreview = 'widget-preview'; |
| |
| @override |
| String get category => FlutterCommandCategory.tools; |
| |
| @override |
| Future<FlutterCommandResult> runCommand() async => FlutterCommandResult.fail(); |
| } |
| |
| abstract base class WidgetPreviewSubCommandBase extends FlutterCommand { |
| FileSystem get fs; |
| Logger get logger; |
| FlutterProjectFactory get projectFactory; |
| |
| FlutterProject getRootProject() { |
| final ArgResults results = argResults!; |
| final Directory projectDir; |
| if (results.rest case <String>[final String directory]) { |
| projectDir = fs.directory(directory); |
| if (!projectDir.existsSync()) { |
| throwToolExit('Could not find ${projectDir.path}.'); |
| } |
| } else if (results.rest.length > 1) { |
| throwToolExit('Only one directory should be provided.'); |
| } else { |
| projectDir = fs.currentDirectory; |
| } |
| return validateFlutterProjectForPreview(projectDir); |
| } |
| |
| FlutterProject validateFlutterProjectForPreview(Directory directory) { |
| logger.printTrace('Verifying that ${directory.path} is a Flutter project.'); |
| final FlutterProject flutterProject = projectFactory.fromDirectory(directory); |
| if (!flutterProject.dartTool.existsSync()) { |
| throwToolExit('${flutterProject.directory.path} is not a valid Flutter project.'); |
| } |
| return flutterProject; |
| } |
| } |
| |
| final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with CreateBase { |
| WidgetPreviewStartCommand({ |
| this.verbose = false, |
| required Logger logger, |
| required this.fs, |
| required this.projectFactory, |
| required this.cache, |
| required this.platform, |
| required this.shutdownHooks, |
| required this.os, |
| required this.processManager, |
| required this.artifacts, |
| @visibleForTesting WidgetPreviewDtdServices? dtdServicesOverride, |
| }) : _logger = logger { |
| if (dtdServicesOverride != null) { |
| _dtdService = dtdServicesOverride; |
| } |
| addPubOptions(); |
| addMachineOutputFlag(verboseHelp: verbose); |
| addDevToolsOptions(verboseHelp: verbose); |
| argParser |
| ..addFlag( |
| kWebServer, |
| help: |
| 'Serve the widget preview environment using the web-server device instead of the ' |
| 'browser.', |
| ) |
| ..addOption( |
| kDtdUrl, |
| help: |
| 'The address of an existing Dart Tooling Daemon instance to be used by the Flutter CLI.', |
| hide: !verbose, |
| ) |
| ..addFlag( |
| kLaunchPreviewer, |
| defaultsTo: true, |
| help: 'Launches the widget preview environment.', |
| // Should only be used for testing. |
| hide: !verbose, |
| ) |
| ..addFlag(kHeadless, help: 'Launches Chrome in headless mode for testing.', hide: !verbose) |
| ..addOption( |
| kWidgetPreviewScaffoldOutputDir, |
| help: |
| 'Generated the widget preview environment scaffolding at a given location ' |
| 'for testing purposes.', |
| hide: !verbose, |
| ); |
| } |
| |
| static const kDtdUrl = 'dtd-url'; |
| static const kWidgetPreviewScaffoldName = 'widget_preview_scaffold'; |
| static const kLaunchPreviewer = 'launch-previewer'; |
| static const kHeadless = 'headless'; |
| static const kWebServer = 'web-server'; |
| static const kWidgetPreviewScaffoldOutputDir = 'scaffold-output-dir'; |
| |
| /// Environment variable used to pass the DTD URI to the widget preview scaffold. |
| static const kWidgetPreviewDtdUriEnvVar = 'WIDGET_PREVIEW_DTD_URI'; |
| |
| @visibleForTesting |
| static const kBrowserNotFoundErrorMessage = |
| 'Failed to locate browser. Make sure you are using an up-to-date Chrome or Edge. ' |
| 'Otherwise, consider running with --$kWebServer instead.'; |
| |
| @override |
| Future<Set<DevelopmentArtifact>> get requiredArtifacts async => const <DevelopmentArtifact>{ |
| // Ensure the Flutter Web SDK is installed. |
| DevelopmentArtifact.web, |
| }; |
| |
| @override |
| String get description => 'Starts the widget preview environment.'; |
| |
| @override |
| String get name => 'start'; |
| |
| final bool verbose; |
| |
| @override |
| final FileSystem fs; |
| |
| @override |
| WidgetPreviewMachineAwareLogger get logger => _logger as WidgetPreviewMachineAwareLogger; |
| final Logger _logger; |
| |
| @override |
| final FlutterProjectFactory projectFactory; |
| |
| final Cache cache; |
| |
| final Platform platform; |
| |
| final ShutdownHooks shutdownHooks; |
| |
| final OperatingSystemUtils os; |
| |
| final ProcessManager processManager; |
| |
| final Artifacts artifacts; |
| |
| late final previewAnalytics = WidgetPreviewAnalytics(analytics: analytics); |
| |
| late final FlutterProject rootProject = getRootProject(); |
| |
| late final _previewPubspecBuilder = PreviewPubspecBuilder( |
| logger: logger, |
| verbose: verbose, |
| offline: offline, |
| rootProject: rootProject, |
| previewManifest: _previewManifest, |
| ); |
| |
| late final _previewDetector = PreviewDetector( |
| platform: platform, |
| previewAnalytics: previewAnalytics, |
| projectRoot: rootProject.directory, |
| logger: logger, |
| fs: fs, |
| onChangeDetected: onChangeDetected, |
| onPubspecChangeDetected: _previewPubspecBuilder.onPubspecChangeDetected, |
| ); |
| |
| late final PreviewCodeGenerator _previewCodeGenerator; |
| late final _previewManifest = PreviewManifest( |
| logger: logger, |
| rootProject: rootProject, |
| fs: fs, |
| cache: cache, |
| ); |
| |
| late var _dtdService = WidgetPreviewDtdServices( |
| fs: fs, |
| logger: logger, |
| shutdownHooks: shutdownHooks, |
| onHotRestartPreviewerRequest: onHotRestartRequest, |
| dtdLauncher: DtdLauncher(logger: logger, artifacts: artifacts, processManager: processManager), |
| project: rootProject.widgetPreviewScaffoldProject, |
| ); |
| |
| /// The currently running instance of the widget preview scaffold. |
| ResidentRunner? _widgetPreviewApp; |
| |
| @override |
| Future<FlutterCommandResult> runCommand() async { |
| assert(_logger is WidgetPreviewMachineAwareLogger); |
| |
| // Start the timer tracking how long it takes to launch the preview environment. |
| previewAnalytics.initializeLaunchStopwatch(); |
| logger.sendInitializingEvent(); |
| |
| final String? customPreviewScaffoldOutput = stringArg(kWidgetPreviewScaffoldOutputDir); |
| final Directory widgetPreviewScaffold = customPreviewScaffoldOutput != null |
| ? fs.directory(customPreviewScaffoldOutput) |
| : rootProject.widgetPreviewScaffold; |
| |
| // Check to see if a preview scaffold has already been generated. If not, |
| // generate one. |
| final bool generateScaffoldProject = |
| customPreviewScaffoldOutput != null || _previewManifest.shouldGenerateProject(); |
| // TODO(bkonyi): can this be moved? |
| widgetPreviewScaffold.createSync(); |
| fs.currentDirectory = widgetPreviewScaffold; |
| |
| if (generateScaffoldProject) { |
| // WARNING: this log message is used by test/integration.shard/widget_preview_test.dart |
| logger.printStatus( |
| 'Creating widget preview scaffolding at: ${widgetPreviewScaffold.absolute.path}', |
| ); |
| await generateApp( |
| <String>['app', kWidgetPreviewScaffoldName], |
| widgetPreviewScaffold, |
| createTemplateContext( |
| organization: 'flutter', |
| projectName: kWidgetPreviewScaffoldName, |
| titleCaseProjectName: 'Widget Preview Scaffold', |
| flutterRoot: Cache.flutterRoot!, |
| dartSdkVersionBounds: '^${cache.dartSdkBuild}', |
| web: true, |
| ), |
| overwrite: true, |
| generateMetadata: false, |
| printStatusWhenWriting: verbose, |
| ); |
| if (customPreviewScaffoldOutput != null) { |
| return FlutterCommandResult.success(); |
| } |
| _previewManifest.generate(); |
| |
| // Make the analytics instance aware that we generated the widget preview scaffold as part of |
| // launching the previewer. |
| previewAnalytics.generatedProject(); |
| } |
| |
| // WARNING: this access of widgetPreviewScaffoldProject needs to happen |
| // after we generate the scaffold project as invoking the getter triggers |
| // lazy initialization of the preview scaffold's FlutterManifest before |
| // the scaffold project's pubspec has been generated. |
| _previewCodeGenerator = PreviewCodeGenerator( |
| widgetPreviewScaffoldProject: rootProject.widgetPreviewScaffoldProject, |
| fs: fs, |
| ); |
| |
| if (generateScaffoldProject || _previewManifest.shouldRegeneratePubspec()) { |
| if (!generateScaffoldProject) { |
| logger.printStatus( |
| 'Detected changes in pubspec.yaml. Regenerating pubspec.yaml for the ' |
| 'widget preview scaffold.', |
| ); |
| } |
| await _previewPubspecBuilder.populatePreviewPubspec(rootProject: rootProject); |
| } |
| |
| shutdownHooks.addShutdownHook(() async { |
| await _widgetPreviewApp?.exitApp(); |
| await _previewDetector.dispose(); |
| }); |
| |
| final PreviewDependencyGraph graph = await _previewDetector.initialize(); |
| _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(graph); |
| |
| await configureDtd(); |
| final int result = await runPreviewEnvironment( |
| widgetPreviewScaffoldProject: rootProject.widgetPreviewScaffoldProject, |
| ); |
| if (result != 0) { |
| throwToolExit('Failed to launch the widget previewer.', exitCode: result); |
| } |
| |
| return FlutterCommandResult.success(); |
| } |
| |
| void onChangeDetected(PreviewDependencyGraph previews) { |
| _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(previews); |
| logger.printStatus('Triggering reload based on change to preview set: $previews'); |
| _widgetPreviewApp?.restart(); |
| } |
| |
| void onHotRestartRequest() { |
| logger.printStatus('Triggering restart based on request from preview environment.'); |
| _widgetPreviewApp?.restart(fullRestart: true); |
| } |
| |
| /// Configures the Dart Tooling Daemon connection. |
| /// |
| /// If --dtd-uri is provided, the existing DTD instance will be used. If the tool fails to |
| /// connect to this URI, it will start its own DTD instance. |
| /// |
| /// If --dtd-uri is not provided, a DTD instance managed by the tool will be started. |
| Future<void> configureDtd() async { |
| final String? existingDtdUriStr = stringArg(kDtdUrl); |
| Uri? existingDtdUri; |
| try { |
| if (existingDtdUriStr != null) { |
| existingDtdUri = Uri.parse(existingDtdUriStr); |
| } |
| } on FormatException { |
| logger.printWarning('Failed to parse value of --dtd-uri: $existingDtdUriStr.'); |
| } |
| if (existingDtdUri == null) { |
| logger.printTrace('Launching a fresh DTD instance...'); |
| await _dtdService.launchAndConnect(); |
| } else { |
| logger.printTrace('Connecting to existing DTD instance at: $existingDtdUri...'); |
| await _dtdService.connect(dtdWsUri: existingDtdUri); |
| } |
| } |
| |
| Future<int> runPreviewEnvironment({required FlutterProject widgetPreviewScaffoldProject}) async { |
| try { |
| final Device device; |
| if (boolArg(kWebServer)) { |
| final List<Device> devices; |
| try { |
| // The web-server device is hidden by default, make it visible before trying to look it up. |
| WebServerDevice.showWebServerDevice = true; |
| devices = await deviceManager!.getDevicesById(WebServerDevice.kWebServerDeviceId); |
| } finally { |
| // Reset the flag to false to avoid affecting other commands. |
| WebServerDevice.showWebServerDevice = false; |
| } |
| assert(devices.length == 1); |
| device = devices.single; |
| } else { |
| // Since the only target supported by the widget preview scaffold is the web |
| // device, only a single web device should be returned. |
| final List<Device> devices = await deviceManager!.getDevices( |
| filter: DeviceDiscoveryFilter( |
| supportFilter: DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutterOrProject( |
| flutterProject: widgetPreviewScaffoldProject, |
| ), |
| deviceConnectionInterface: DeviceConnectionInterface.attached, |
| ), |
| ); |
| |
| if (devices.isEmpty) { |
| throwToolExit(kBrowserNotFoundErrorMessage); |
| } |
| if (devices.length > 1) { |
| // Prefer Google Chrome as the target browser. |
| device = |
| devices.firstWhereOrNull((device) => device is GoogleChromeDevice) ?? devices.first; |
| |
| logger.printTrace( |
| 'Detected ${devices.length} web devices (${devices.map((e) => e.displayName).join(', ')}). ' |
| 'Defaulting to ${device.displayName}.', |
| ); |
| } else { |
| device = devices.single; |
| } |
| } |
| |
| // WARNING: this log message is used by test/integration.shard/widget_preview_test.dart |
| logger.printStatus('Launching the Widget Preview Scaffold on ${device.displayName}...'); |
| |
| final debuggingOptions = DebuggingOptions.enabled( |
| BuildInfo( |
| BuildMode.debug, |
| null, |
| treeShakeIcons: false, |
| // Provide the DTD connection information directly to the preview scaffold. |
| // This could, in theory, be provided via a follow up call to a service extension |
| // registered by the preview scaffold, but there's some uncertainty around how service |
| // extensions will work with Flutter web embedded in VSCode without a Chrome debugger |
| // connection. |
| dartDefines: <String>['$kWidgetPreviewDtdUriEnvVar=${_dtdService.dtdUri}'], |
| packageConfigPath: widgetPreviewScaffoldProject.packageConfig.path, |
| packageConfig: PackageConfig.parseBytes( |
| widgetPreviewScaffoldProject.packageConfig.readAsBytesSync(), |
| widgetPreviewScaffoldProject.packageConfig.uri, |
| ), |
| trackWidgetCreation: true, |
| // Don't try and download canvaskit from the CDN. |
| useLocalCanvasKit: true, |
| webEnableHotReload: true, |
| ), |
| webEnableExposeUrl: false, |
| webRunHeadless: boolArg(kHeadless), |
| enableDevTools: boolArg(FlutterCommand.kEnableDevTools), |
| devToolsServerAddress: devToolsServerAddress, |
| ); |
| final String target = bundle.defaultMainPath; |
| final FlutterDevice flutterDevice = await FlutterDevice.create( |
| device, |
| target: target, |
| buildInfo: debuggingOptions.buildInfo, |
| platform: platform, |
| ); |
| |
| if (boolArg(kLaunchPreviewer)) { |
| final appStarted = Completer<void>(); |
| _widgetPreviewApp = ResidentWebRunner( |
| flutterDevice, |
| target: target, |
| debuggingOptions: debuggingOptions, |
| analytics: analytics, |
| flutterProject: widgetPreviewScaffoldProject, |
| fileSystem: fs, |
| logger: logger, |
| terminal: globals.terminal, |
| platform: platform, |
| outputPreferences: globals.outputPreferences, |
| systemClock: globals.systemClock, |
| ); |
| unawaited(_widgetPreviewApp!.run(appStartedCompleter: appStarted)); |
| await appStarted.future; |
| logger.sendStartedEvent(applicationUrl: flutterDevice.devFS!.baseUri!); |
| } |
| } on Exception catch (error) { |
| throwToolExit(error.toString()); |
| } |
| |
| // WARNING: this log message is used by test/integration.shard/widget_preview_test.dart |
| logger.printStatus('Done loading previews.'); |
| |
| // Send an analytics event reporting how long it took for the widget previewer to start. |
| previewAnalytics.reportLaunchTiming(); |
| |
| // If _widgetPreviewApp is null --no-launch-previewer was provided so return success. |
| return _widgetPreviewApp?.waitForAppToFinish() ?? 0; |
| } |
| } |
| |
| final class WidgetPreviewCleanCommand extends WidgetPreviewSubCommandBase { |
| WidgetPreviewCleanCommand({required this.fs, required this.logger, required this.projectFactory}); |
| |
| @override |
| String get description => 'Cleans up widget preview state.'; |
| |
| @override |
| String get name => 'clean'; |
| |
| @override |
| final FileSystem fs; |
| |
| @override |
| final Logger logger; |
| |
| @override |
| final FlutterProjectFactory projectFactory; |
| |
| @override |
| Future<FlutterCommandResult> runCommand() async { |
| final Directory widgetPreviewScaffold = getRootProject().widgetPreviewScaffold; |
| if (widgetPreviewScaffold.existsSync()) { |
| final String scaffoldPath = widgetPreviewScaffold.path; |
| logger.printStatus('Deleting widget preview scaffold at $scaffoldPath.'); |
| widgetPreviewScaffold.deleteSync(recursive: true); |
| } else { |
| logger.printStatus('Nothing to clean up.'); |
| } |
| return FlutterCommandResult.success(); |
| } |
| } |
| |
| /// A custom logger for the widget-preview commands that disables non-event output to stdio when |
| /// machine mode is enabled. |
| final class WidgetPreviewMachineAwareLogger extends DelegatingLogger { |
| WidgetPreviewMachineAwareLogger(super.delegate, {required this.machine, required this.verbose}); |
| |
| final bool machine; |
| final bool verbose; |
| |
| @override |
| void printError( |
| String message, { |
| StackTrace? stackTrace, |
| bool? emphasis, |
| TerminalColor? color, |
| int? indent, |
| int? hangingIndent, |
| bool? wrap, |
| }) { |
| if (machine) { |
| sendEvent('logMessage', <String, Object?>{ |
| 'level': 'error', |
| 'message': message, |
| 'stackTrace': ?stackTrace?.toString(), |
| }); |
| return; |
| } |
| super.printError( |
| message, |
| stackTrace: stackTrace, |
| emphasis: emphasis, |
| color: color, |
| indent: indent, |
| hangingIndent: hangingIndent, |
| wrap: wrap, |
| ); |
| } |
| |
| @override |
| void printWarning( |
| String message, { |
| bool? emphasis, |
| TerminalColor? color, |
| int? indent, |
| int? hangingIndent, |
| bool? wrap, |
| bool fatal = true, |
| }) { |
| if (machine) { |
| sendEvent('logMessage', <String, Object?>{'level': 'warning', 'message': message}); |
| return; |
| } |
| super.printWarning( |
| message, |
| emphasis: emphasis, |
| color: color, |
| indent: indent, |
| hangingIndent: hangingIndent, |
| wrap: wrap, |
| fatal: fatal, |
| ); |
| } |
| |
| @override |
| void printStatus( |
| String message, { |
| bool? emphasis, |
| TerminalColor? color, |
| bool? newline, |
| int? indent, |
| int? hangingIndent, |
| bool? wrap, |
| }) { |
| if (machine) { |
| sendEvent('logMessage', <String, Object?>{'level': 'status', 'message': message}); |
| return; |
| } |
| super.printStatus( |
| message, |
| emphasis: emphasis, |
| color: color, |
| newline: newline, |
| indent: indent, |
| hangingIndent: hangingIndent, |
| wrap: wrap, |
| ); |
| } |
| |
| @override |
| void printBox(String message, {String? title}) { |
| if (machine) { |
| return; |
| } |
| super.printBox(message, title: title); |
| } |
| |
| @override |
| void printTrace(String message) { |
| if (!verbose) { |
| return; |
| } |
| if (machine) { |
| sendEvent('logMessage', <String, Object?>{'level': 'trace', 'message': message}); |
| return; |
| } |
| super.printTrace(message); |
| } |
| |
| /// Notifies tooling that the widget previewer is initializing. |
| void sendInitializingEvent() { |
| sendEvent('initializing', {'pid': pid}); |
| } |
| |
| /// Notifies tooling that the widget previewer has started and is being |
| /// served at [applicationUrl]. |
| void sendStartedEvent({required Uri applicationUrl}) { |
| sendEvent('started', {'url': applicationUrl.toString()}); |
| } |
| |
| @override |
| void sendEvent(String name, [Map<String, dynamic>? args]) { |
| if (!machine) { |
| return; |
| } |
| super.printStatus( |
| json.encode([ |
| {'event': 'widget_preview.$name', 'params': ?args}, |
| ]), |
| ); |
| } |
| |
| @override |
| Status startProgress( |
| String message, { |
| String? progressId, |
| int progressIndicatorPadding = kDefaultStatusPadding, |
| }) { |
| if (machine) { |
| printStatus(message); |
| return SilentStatus(stopwatch: Stopwatch()); |
| } |
| return super.startProgress( |
| message, |
| progressId: progressId, |
| progressIndicatorPadding: progressIndicatorPadding, |
| ); |
| } |
| |
| @override |
| Status startSpinner({ |
| VoidCallback? onFinish, |
| Duration? timeout, |
| SlowWarningCallback? slowWarningCallback, |
| TerminalColor? warningColor, |
| }) { |
| if (machine) { |
| return SilentStatus(stopwatch: Stopwatch()); |
| } |
| return super.startSpinner( |
| onFinish: onFinish, |
| timeout: timeout, |
| slowWarningCallback: slowWarningCallback, |
| warningColor: warningColor, |
| ); |
| } |
| } |