| // Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:io'; |
| |
| import 'package:args/args.dart'; |
| import 'package:browser_launcher/browser_launcher.dart'; |
| import 'package:devtools_shared/devtools_shared.dart'; |
| import 'package:http_multi_server/http_multi_server.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:shelf/shelf.dart' as shelf; |
| import 'package:shelf/shelf_io.dart' as shelf; |
| |
| import 'src/devtools/client.dart'; |
| import 'src/devtools/handler.dart'; |
| import 'src/devtools/machine_mode_command_handler.dart'; |
| import 'src/devtools/memory_profile.dart'; |
| import 'src/devtools/utils.dart'; |
| import 'src/utils/console.dart'; |
| |
| class DevToolsServer { |
| static const protocolVersion = '1.1.0'; |
| static const defaultTryPorts = 10; |
| static const commandDescription = |
| 'Open DevTools (optionally connecting to an existing application).'; |
| |
| static const argHelp = 'help'; |
| static const argVmUri = 'vm-uri'; |
| static const argEnableNotifications = 'enable-notifications'; |
| static const argAllowEmbedding = 'allow-embedding'; |
| static const argAppSizeBase = 'appSizeBase'; |
| static const argAppSizeTest = 'appSizeTest'; |
| static const argHeadlessMode = 'headless'; |
| static const argDebugMode = 'debug'; |
| static const argLaunchBrowser = 'launch-browser'; |
| static const argMachine = 'machine'; |
| static const argHost = 'host'; |
| static const argPort = 'port'; |
| static const argProfileMemory = 'record-memory-profile'; |
| static const argTryPorts = 'try-ports'; |
| static const argVerbose = 'verbose'; |
| static const argVersion = 'version'; |
| static const launchDevToolsService = 'launchDevTools'; |
| |
| MachineModeCommandHandler? _machineModeCommandHandler; |
| late ClientManager clientManager; |
| final bool _isChromeOS = File('/dev/.cros_milestone').existsSync(); |
| |
| /// Builds an arg parser for the DevTools server. |
| /// |
| /// [includeHelpOption] should be set to false if this arg parser will be used |
| /// in a Command subclass. |
| static ArgParser buildArgParser({ |
| bool verbose = false, |
| bool includeHelpOption = true, |
| int? usageLineLength, |
| }) { |
| final argParser = ArgParser( |
| usageLineLength: usageLineLength, |
| ); |
| |
| if (includeHelpOption) { |
| argParser.addFlag( |
| argHelp, |
| negatable: false, |
| abbr: 'h', |
| help: 'Prints help output.', |
| ); |
| } |
| argParser |
| ..addFlag( |
| argVersion, |
| negatable: false, |
| help: 'Prints the DevTools version.', |
| ) |
| ..addFlag( |
| argVerbose, |
| negatable: false, |
| abbr: 'v', |
| help: 'Output more informational messages.', |
| ) |
| ..addOption( |
| argHost, |
| valueHelp: 'host', |
| help: 'Hostname to serve DevTools on (defaults to localhost).', |
| ) |
| ..addOption( |
| argPort, |
| defaultsTo: '9100', |
| valueHelp: 'port', |
| help: 'Port to serve DevTools on; specify 0 to automatically use any ' |
| 'available port.', |
| ) |
| ..addFlag( |
| argLaunchBrowser, |
| help: |
| 'Launches DevTools in a browser immediately at start.\n(defaults to on unless in --machine mode)', |
| ) |
| ..addFlag( |
| argMachine, |
| negatable: false, |
| help: 'Sets output format to JSON for consumption in tools.', |
| ) |
| ..addSeparator('Memory profiling options:') |
| ..addOption( |
| argProfileMemory, |
| valueHelp: 'file', |
| defaultsTo: 'memory_samples.json', |
| help: |
| 'Start devtools headlessly and write memory profiling samples to the ' |
| 'indicated file.', |
| ); |
| |
| if (verbose) { |
| argParser.addSeparator('App size options:'); |
| } |
| |
| // TODO(devoncarew): --appSizeBase and --appSizeTest should be renamed to |
| // something like --app-size-base and --app-size-test; #3146. |
| argParser |
| ..addOption( |
| argAppSizeBase, |
| valueHelp: 'appSizeBase', |
| help: 'Path to the base app size file used for app size debugging.', |
| hide: !verbose, |
| ) |
| ..addOption( |
| argAppSizeTest, |
| valueHelp: 'appSizeTest', |
| help: |
| 'Path to the test app size file used for app size debugging.\nThis ' |
| 'file should only be specified if --$argAppSizeBase is also specified.', |
| hide: !verbose, |
| ); |
| |
| if (verbose) { |
| argParser.addSeparator('Advanced options:'); |
| } |
| |
| // Args to show for verbose mode. |
| argParser |
| ..addOption( |
| argTryPorts, |
| defaultsTo: DevToolsServer.defaultTryPorts.toString(), |
| valueHelp: 'count', |
| help: 'The number of ascending ports to try binding to before failing ' |
| 'with an error. ', |
| hide: !verbose, |
| ) |
| ..addFlag( |
| argEnableNotifications, |
| negatable: false, |
| help: 'Requests notification permissions immediately when a client ' |
| 'connects back to the server.', |
| hide: !verbose, |
| ) |
| ..addFlag( |
| argAllowEmbedding, |
| help: 'Allow embedding DevTools inside an iframe.', |
| hide: !verbose, |
| ) |
| ..addFlag( |
| argHeadlessMode, |
| negatable: false, |
| help: 'Causes the server to spawn Chrome in headless mode for use in ' |
| 'automated testing.', |
| hide: !verbose, |
| ); |
| |
| // Deprecated and hidden args. |
| // TODO: Remove this - prefer that clients use the rest arg. |
| argParser |
| ..addOption( |
| argVmUri, |
| defaultsTo: '', |
| help: 'VM Service protocol URI.', |
| hide: true, |
| ) |
| |
| // Development only args. |
| ..addFlag( |
| argDebugMode, |
| negatable: false, |
| help: 'Run a debug build of the DevTools web frontend.', |
| hide: true, |
| ); |
| |
| return argParser; |
| } |
| |
| /// Serves DevTools. |
| /// |
| /// `handler` is the [shelf.Handler] that the server will use for all requests. |
| /// If null, [defaultHandler] will be used. Defaults to null. |
| /// |
| /// `customDevToolsPath` is a path to a directory containing a pre-built |
| /// DevTools application. |
| /// |
| // Note: this method is used by the Dart CLI and by package:dwds. |
| Future<HttpServer?> serveDevTools({ |
| bool enableStdinCommands = true, |
| bool machineMode = false, |
| bool debugMode = false, |
| bool launchBrowser = false, |
| bool enableNotifications = false, |
| bool allowEmbedding = true, |
| bool headlessMode = false, |
| bool verboseMode = false, |
| String? hostname, |
| String? customDevToolsPath, |
| int port = 0, |
| int numPortsToTry = defaultTryPorts, |
| shelf.Handler? handler, |
| String? serviceProtocolUri, |
| String? profileFilename, |
| String? appSizeBase, |
| String? appSizeTest, |
| }) async { |
| hostname ??= 'localhost'; |
| |
| // Collect profiling information. |
| if (profileFilename != null && serviceProtocolUri != null) { |
| final Uri? vmServiceUri = Uri.tryParse(serviceProtocolUri); |
| if (vmServiceUri != null) { |
| await _hookupMemoryProfiling( |
| vmServiceUri, |
| profileFilename, |
| verboseMode, |
| ); |
| } |
| return null; |
| } |
| |
| if (machineMode) { |
| assert( |
| enableStdinCommands, |
| 'machineMode only works with enableStdinCommands.', |
| ); |
| } |
| |
| clientManager = ClientManager( |
| requestNotificationPermissions: enableNotifications, |
| ); |
| handler ??= await defaultHandler( |
| buildDir: customDevToolsPath!, |
| clientManager: clientManager, |
| ); |
| |
| HttpServer? server; |
| SocketException? ex; |
| while (server == null && numPortsToTry >= 0) { |
| // If we have tried [numPortsToTry] ports and still have not been able to |
| // connect, try port 0 to find a random available port. |
| if (numPortsToTry == 0) port = 0; |
| |
| try { |
| server = await HttpMultiServer.bind(hostname, port); |
| } on SocketException catch (e) { |
| ex = e; |
| numPortsToTry--; |
| port++; |
| } |
| } |
| |
| // Re-throw the last exception if we failed to bind. |
| if (server == null && ex != null) { |
| throw ex; |
| } |
| |
| final _server = server!; |
| if (allowEmbedding) { |
| _server.defaultResponseHeaders.remove('x-frame-options', 'SAMEORIGIN'); |
| // The origin-agent-cluster header is required to support the embedding of |
| // Dart DevTools in Chrome DevTools. |
| _server.defaultResponseHeaders.add('origin-agent-cluster', '?1'); |
| } |
| |
| // Ensure browsers don't cache older versions of the app. |
| _server.defaultResponseHeaders.add( |
| HttpHeaders.cacheControlHeader, |
| 'max-age=0', |
| ); |
| |
| // Serve requests in an error zone to prevent failures |
| // when running from another error zone. |
| runZonedGuarded( |
| () => shelf.serveRequests(_server, handler!), |
| (e, _) => print('Error serving requests: $e'), |
| ); |
| |
| final devToolsUrl = 'http://${_server.address.host}:${_server.port}'; |
| |
| if (launchBrowser) { |
| if (serviceProtocolUri != null) { |
| serviceProtocolUri = |
| normalizeVmServiceUri(serviceProtocolUri).toString(); |
| } |
| |
| final queryParameters = { |
| if (serviceProtocolUri != null) 'uri': serviceProtocolUri, |
| if (appSizeBase != null) 'appSizeBase': appSizeBase, |
| if (appSizeTest != null) 'appSizeTest': appSizeTest, |
| }; |
| String url = Uri.parse(devToolsUrl) |
| .replace(queryParameters: queryParameters) |
| .toString(); |
| |
| // If app size parameters are present, open to the standalone `appsize` |
| // page, regardless if there is a vm service uri specified. We only check |
| // for the presence of [appSizeBase] here because [appSizeTest] may or may |
| // not be specified (it should only be present for diffs). If [appSizeTest] |
| // is present without [appSizeBase], we will ignore the parameter. |
| if (appSizeBase != null) { |
| final startQueryParamIndex = url.indexOf('?'); |
| if (startQueryParamIndex != -1) { |
| url = '${url.substring(0, startQueryParamIndex)}' |
| '/#/appsize' |
| '${url.substring(startQueryParamIndex)}'; |
| } |
| } |
| |
| try { |
| await Chrome.start([url]); |
| } catch (e) { |
| print('Unable to launch Chrome: $e\n'); |
| } |
| } |
| |
| if (enableStdinCommands) { |
| String message = '''Serving DevTools at $devToolsUrl. |
| |
| Hit ctrl-c to terminate the server.'''; |
| if (!machineMode && debugMode) { |
| // Add bold to help find the correct url to open. |
| message = ConsoleUtils.bold('$message\n'); |
| } |
| |
| DevToolsUtils.printOutput( |
| message, |
| { |
| 'event': 'server.started', |
| // TODO(dantup): Remove this `method` field when we're sure VS Code |
| // users are all on a newer version that uses `event`. We incorrectly |
| // used `method` for the original releases. |
| 'method': 'server.started', |
| 'params': { |
| 'host': _server.address.host, |
| 'port': _server.port, |
| 'pid': pid, |
| 'protocolVersion': protocolVersion, |
| } |
| }, |
| machineMode: machineMode, |
| ); |
| |
| if (machineMode) { |
| _machineModeCommandHandler = MachineModeCommandHandler(server: this); |
| await _machineModeCommandHandler!.initialize( |
| devToolsUrl: devToolsUrl, |
| headlessMode: headlessMode, |
| ); |
| } |
| } |
| |
| return server; |
| } |
| |
| void _printUsage(ArgParser argParser) { |
| print(commandDescription); |
| print('\nUsage: devtools [arguments] [service protocol uri]'); |
| print(argParser.usage); |
| } |
| |
| /// Wraps [serveDevTools] `arguments` parsed, as from the command line. |
| /// |
| /// For more information on `handler`, see [serveDevTools]. |
| // Note: this method is used in google3 as well as by DevTools' main method. |
| Future<HttpServer?> serveDevToolsWithArgs( |
| List<String> arguments, { |
| shelf.Handler? handler, |
| String? customDevToolsPath, |
| }) async { |
| ArgResults args; |
| final verbose = arguments.contains('-v') || arguments.contains('--verbose'); |
| final argParser = buildArgParser(verbose: verbose); |
| try { |
| args = argParser.parse(arguments); |
| } on FormatException catch (e) { |
| print(e.message); |
| print(''); |
| _printUsage(argParser); |
| return null; |
| } |
| |
| return await _serveDevToolsWithArgs( |
| args, |
| verbose, |
| handler: handler, |
| customDevToolsPath: customDevToolsPath, |
| ); |
| } |
| |
| Future<HttpServer?> _serveDevToolsWithArgs( |
| ArgResults args, |
| bool verbose, { |
| shelf.Handler? handler, |
| String? customDevToolsPath, |
| }) async { |
| final help = args[argHelp]; |
| final bool version = args[argVersion]; |
| final bool machineMode = args[argMachine]; |
| // launchBrowser defaults based on machine-mode if not explicitly supplied. |
| final bool launchBrowser = args.wasParsed(argLaunchBrowser) |
| ? args[argLaunchBrowser] |
| : !machineMode; |
| final bool enableNotifications = args[argEnableNotifications]; |
| final bool allowEmbedding = |
| args.wasParsed(argAllowEmbedding) ? args[argAllowEmbedding] : true; |
| |
| final port = args[argPort] != null ? int.tryParse(args[argPort]) ?? 0 : 0; |
| |
| final bool headlessMode = args[argHeadlessMode]; |
| final bool debugMode = args[argDebugMode]; |
| |
| final numPortsToTry = args[argTryPorts] != null |
| ? int.tryParse(args[argTryPorts]) ?? 0 |
| : defaultTryPorts; |
| |
| final bool verboseMode = args[argVerbose]; |
| final String? hostname = args[argHost]; |
| final String? appSizeBase = args[argAppSizeBase]; |
| final String? appSizeTest = args[argAppSizeTest]; |
| |
| if (help) { |
| print( |
| 'Dart DevTools version ${await DevToolsUtils.getVersion(customDevToolsPath ?? "")}'); |
| print(''); |
| _printUsage(buildArgParser(verbose: verbose)); |
| return null; |
| } |
| |
| if (version) { |
| final versionStr = |
| await DevToolsUtils.getVersion(customDevToolsPath ?? ''); |
| DevToolsUtils.printOutput( |
| 'Dart DevTools version $versionStr', |
| { |
| 'version': versionStr, |
| }, |
| machineMode: machineMode, |
| ); |
| return null; |
| } |
| |
| // Prefer getting the VM URI from the rest args; fall back on the 'vm-url' |
| // option otherwise. |
| String? serviceProtocolUri; |
| if (args.rest.isNotEmpty) { |
| serviceProtocolUri = args.rest.first; |
| } else if (args.wasParsed(argVmUri)) { |
| serviceProtocolUri = args[argVmUri]; |
| } |
| |
| // Support collecting profile data. |
| String? profileFilename; |
| if (args.wasParsed(argProfileMemory)) { |
| profileFilename = args[argProfileMemory]; |
| } |
| if (profileFilename != null && !path.isAbsolute(profileFilename)) { |
| profileFilename = path.absolute(profileFilename); |
| } |
| |
| return serveDevTools( |
| machineMode: machineMode, |
| debugMode: debugMode, |
| launchBrowser: launchBrowser, |
| enableNotifications: enableNotifications, |
| allowEmbedding: allowEmbedding, |
| port: port, |
| headlessMode: headlessMode, |
| numPortsToTry: numPortsToTry, |
| handler: handler, |
| customDevToolsPath: customDevToolsPath, |
| serviceProtocolUri: serviceProtocolUri, |
| profileFilename: profileFilename, |
| verboseMode: verboseMode, |
| hostname: hostname, |
| appSizeBase: appSizeBase, |
| appSizeTest: appSizeTest, |
| ); |
| } |
| |
| Future<Map<String, dynamic>> launchDevTools( |
| Map<String, dynamic> params, |
| Uri vmServiceUri, |
| String devToolsUrl, |
| bool headlessMode, |
| bool machineMode, |
| ) async { |
| // First see if we have an existing DevTools client open that we can |
| // reuse. |
| final canReuse = |
| params.containsKey('reuseWindows') && params['reuseWindows'] == true; |
| final shouldNotify = |
| params.containsKey('notify') && params['notify'] == true; |
| final page = params['page']; |
| if (canReuse && |
| _tryReuseExistingDevToolsInstance( |
| vmServiceUri, |
| page, |
| shouldNotify, |
| )) { |
| _emitLaunchEvent( |
| reused: true, |
| notified: shouldNotify, |
| pid: null, |
| machineMode: machineMode, |
| ); |
| return { |
| 'reused': true, |
| 'notified': shouldNotify, |
| }; |
| } |
| |
| final uriParams = <String, dynamic>{}; |
| |
| // Copy over queryParams passed by the client |
| params['queryParams']?.forEach((key, value) => uriParams[key] = value); |
| |
| // Add the URI to the VM service |
| uriParams['uri'] = vmServiceUri.toString(); |
| |
| final devToolsUri = Uri.parse(devToolsUrl); |
| final uriToLaunch = _buildUriToLaunch(uriParams, page, devToolsUri); |
| |
| // TODO(dantup): When ChromeOS has support for tunneling all ports we can |
| // change this to always use the native browser for ChromeOS and may wish to |
| // handle this inside `browser_launcher`; https://crbug.com/848063. |
| final useNativeBrowser = _isChromeOS && |
| _isAccessibleToChromeOSNativeBrowser(devToolsUri) && |
| _isAccessibleToChromeOSNativeBrowser(vmServiceUri); |
| int? browserPid; |
| if (useNativeBrowser) { |
| await Process.start('x-www-browser', [uriToLaunch.toString()]); |
| } else { |
| final args = headlessMode |
| ? [ |
| '--headless', |
| // When running headless, Chrome will quit immediately after loading |
| // the page unless we have the debug port open. |
| '--remote-debugging-port=9223', |
| '--disable-gpu', |
| '--no-sandbox', |
| ] |
| : <String>[]; |
| final proc = await Chrome.start([uriToLaunch.toString()], args: args); |
| browserPid = proc.pid; |
| } |
| _emitLaunchEvent( |
| reused: false, |
| notified: false, |
| pid: browserPid!, |
| machineMode: machineMode); |
| return { |
| 'reused': false, |
| 'notified': false, |
| 'pid': browserPid, |
| }; |
| } |
| |
| Future<void> _hookupMemoryProfiling( |
| Uri observatoryUri, |
| String profileFile, [ |
| bool verboseMode = false, |
| ]) async { |
| final service = await DevToolsUtils.connectToVmService(observatoryUri); |
| if (service == null) { |
| return; |
| } |
| |
| final memoryProfiler = MemoryProfile(service, profileFile, verboseMode); |
| memoryProfiler.startPolling(); |
| |
| print('Writing memory profile samples to $profileFile...'); |
| } |
| |
| bool _tryReuseExistingDevToolsInstance( |
| Uri vmServiceUri, |
| String? page, |
| bool notifyUser, |
| ) { |
| // First try to find a client that's already connected to this VM service, |
| // and just send the user a notification for that one. |
| final existingClient = |
| clientManager.findExistingConnectedReusableClient(vmServiceUri); |
| if (existingClient != null) { |
| try { |
| if (page != null) { |
| existingClient.showPage(page); |
| } |
| if (notifyUser) { |
| existingClient.notify(); |
| } |
| return true; |
| } catch (e) { |
| print('Failed to reuse existing connected DevTools client'); |
| print(e); |
| } |
| } |
| |
| final reusableClient = clientManager.findReusableClient(); |
| if (reusableClient != null) { |
| try { |
| reusableClient.connectToVmService(vmServiceUri, notifyUser); |
| return true; |
| } catch (e) { |
| print('Failed to reuse existing DevTools client'); |
| print(e); |
| } |
| } |
| return false; |
| } |
| |
| String _buildUriToLaunch( |
| Map<String, dynamic> uriParams, |
| String? page, |
| Uri devToolsUri, |
| ) { |
| final queryStringNameValues = []; |
| uriParams.forEach((key, value) => queryStringNameValues.add( |
| '${Uri.encodeQueryComponent(key)}=${Uri.encodeQueryComponent(value)}')); |
| |
| if (page != null) { |
| queryStringNameValues.add('page=${Uri.encodeQueryComponent(page)}'); |
| } |
| |
| return devToolsUri |
| .replace( |
| path: '${devToolsUri.path.isEmpty ? '/' : devToolsUri.path}', |
| fragment: '?${queryStringNameValues.join('&')}') |
| .toString(); |
| } |
| |
| /// Prints a launch event to stdout so consumers of the DevTools server |
| /// can see when clients are being launched/reused. |
| void _emitLaunchEvent( |
| {required bool reused, |
| required bool notified, |
| required int? pid, |
| required bool machineMode}) { |
| DevToolsUtils.printOutput( |
| null, |
| { |
| 'event': 'client.launch', |
| 'params': { |
| 'reused': reused, |
| 'notified': notified, |
| 'pid': pid, |
| }, |
| }, |
| machineMode: machineMode, |
| ); |
| } |
| |
| bool _isAccessibleToChromeOSNativeBrowser(Uri uri) { |
| const tunneledPorts = { |
| 8000, |
| 8008, |
| 8080, |
| 8085, |
| 8888, |
| 9005, |
| 3000, |
| 4200, |
| 5000 |
| }; |
| return uri.hasPort && tunneledPorts.contains(uri.port); |
| } |
| } |