// 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_extensions_io.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/dtd.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.2.0'; static const defaultTryPorts = 10; static const defaultDdsHost = '127.0.0.1'; static const defaultDdsPort = 0; 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 = 'app-size-base'; static const argAppSizeTest = 'app-size-test'; static const argHeadlessMode = 'headless'; static const argDdsHost = 'dds-host'; static const argDdsPort = 'dds-port'; static const argDebugMode = 'debug'; static const argDisableCors = 'disable-cors'; static const argDtdUri = 'dtd-uri'; static const argDtdExposedUri = 'dtd-exposed-uri'; static const argPrintDtd = 'print-dtd'; 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.', ) ..addOption( argDtdUri, valueHelp: 'uri', help: 'A URI pointing to a Dart Tooling Daemon that DevTools should ' 'interface with.', ) ..addOption( argDtdExposedUri, valueHelp: 'uri', help: 'An optional URI for the DartTooling Daemon (--dtd-uri) that has ' 'been exposed to the front-end to support environments split across ' 'machines such as a web-based editor.', ) ..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.', ); argParser.addSeparator('App size options:'); argParser ..addOption( argAppSizeBase, aliases: ['appSizeBase'], valueHelp: 'file', help: 'Path to the base app size file used for app size debugging.', ) ..addOption( argAppSizeTest, aliases: ['appSizeTest'], valueHelp: 'file', 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, ) ..addOption( argDdsHost, defaultsTo: DevToolsServer.defaultDdsHost, valueHelp: 'bind-address', help: "The address the Dart Development Service (DDS) should attempt to " "bind to if a DDS instance isn't active and a VM service URI is " "provided.", hide: !verbose, ) ..addOption( argDdsPort, defaultsTo: DevToolsServer.defaultDdsPort.toString(), valueHelp: 'port', help: "The address the Dart Development Service (DDS) should attempt to " "bind to if a DDS instance isn't active and a VM service URI is " "provided.", 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( argDisableCors, help: 'Disable CORS so that the DevTools server can communicate with the ' 'DevTools front end served on a different origin. Use caution when ' 'passing this flag since allowing access to the DevTools server ' 'from other origins may have security implications.', defaultsTo: false, hide: verbose, ) ..addFlag( argHeadlessMode, negatable: false, help: 'Causes the server to spawn Chrome in headless mode for use in ' 'automated testing.', hide: !verbose, ) ..addFlag( argPrintDtd, negatable: false, help: 'Print the address of the Dart Tooling Daemon, if one is hosted ' 'by the DevTools server.', 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 serveDevTools({ bool enableStdinCommands = true, bool machineMode = false, bool debugMode = false, bool launchBrowser = false, bool enableNotifications = false, bool allowEmbedding = true, bool disableCors = false, bool headlessMode = false, bool verboseMode = false, bool printDtdUri = false, String? hostname, String? customDevToolsPath, int port = 0, int numPortsToTry = defaultTryPorts, shelf.Handler? handler, String? serviceProtocolUri, String? profileFilename, String? appSizeBase, String? appSizeTest, DtdInfo? dtdInfo, }) 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, ); dtdInfo ??= await startDtd( machineMode: machineMode, printDtdUri: printDtdUri, ); handler ??= await defaultHandler( buildDir: customDevToolsPath, clientManager: clientManager, dtd: dtdInfo, devtoolsExtensionsManager: ExtensionsManager(), ); 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; } // Type promote 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'); } // CORS restrictions may be disabled for the purposes of debugging or // testing when it is useful to allow connecting a debug instance of // DevTools app to a running DevTools server. if (disableCors) { server.defaultResponseHeaders.add( HttpHeaders.accessControlAllowOriginHeader, '*', ); } // Ensure browsers don't cache older versions of the app. server.defaultResponseHeaders.add( HttpHeaders.cacheControlHeader, 'no-store', ); // Add the headers required to serve with wasm. server.defaultResponseHeaders ..add('Cross-Origin-Embedder-Policy', 'credentialless') ..add('Cross-Origin-Opener-Policy', 'same-origin') ..add('Cross-Origin-Resource-Policy', 'cross-origin'); // 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 `app-size` // 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)}' '/#/app-size' '${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 serveDevToolsWithArgs( List 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 _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 bool disableCors = args.wasParsed(argDisableCors) ? args[argDisableCors] : false; 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]; // A helper to print a message and usage information that can be used in // a return statement. Null printUsage(String message) { print(message); print(''); _printUsage(buildArgParser(verbose: verbose)); } Uri? dtdUri; if (args.wasParsed(argDtdUri)) { dtdUri = Uri.tryParse(args[argDtdUri]); if (dtdUri == null || !dtdUri.hasScheme) { return printUsage('--dtd-uri must be a valid URI'); } } Uri? dtdExposedUri; if (args.wasParsed(argDtdExposedUri)) { if (dtdUri == null) { return printUsage( '--dtd-exposed-uri can only be supplied with --dtd-uri'); } dtdExposedUri = Uri.tryParse(args[argDtdExposedUri]); if (dtdExposedUri == null || !dtdExposedUri.hasScheme) { return printUsage('--dtd-exposed-uri must be a valid URI'); } } final printDtdUri = args.wasParsed(argPrintDtd); if (help) { return printUsage( 'Dart DevTools version ${await DevToolsUtils.getVersion(customDevToolsPath ?? "")}'); } 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); } // App size info. String? appSizeBase = args[argAppSizeBase]; if (appSizeBase != null && !path.isAbsolute(appSizeBase)) { appSizeBase = path.absolute(appSizeBase); } String? appSizeTest = args[argAppSizeTest]; if (appSizeTest != null && !path.isAbsolute(appSizeTest)) { appSizeTest = path.absolute(appSizeTest); } return serveDevTools( machineMode: machineMode, debugMode: debugMode, launchBrowser: launchBrowser, enableNotifications: enableNotifications, allowEmbedding: allowEmbedding, disableCors: disableCors, port: port, headlessMode: headlessMode, numPortsToTry: numPortsToTry, handler: handler, customDevToolsPath: customDevToolsPath, serviceProtocolUri: serviceProtocolUri, profileFilename: profileFilename, verboseMode: verboseMode, hostname: hostname, appSizeBase: appSizeBase, appSizeTest: appSizeTest, dtdInfo: dtdUri != null ? DtdInfo(dtdUri, exposedUri: dtdExposedUri) : null, printDtdUri: printDtdUri, ); } Future> launchDevTools( Map 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 && await _tryReuseExistingDevToolsInstance( vmServiceUri, page, shouldNotify, )) { _emitLaunchEvent( reused: true, notified: shouldNotify, pid: null, machineMode: machineMode, ); return { 'reused': true, 'notified': shouldNotify, }; } final uriParams = {}; // 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( devToolsUri, page, uriParams, ); // 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', // When running on MacOS, Chrome may open system dialogs // requesting credentials. This uses a mock keychain to avoid that // dialog from blocking. '--use-mock-keychain', ] : []; 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 _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...'); } /// Tries to reuse an existing DevTools instance. /// /// Because SSE connections have timeouts that prevent us knowing if a client /// has really gone away for up to 30s, this method will attempt to ping /// candidate first to see if it is still responsive. Future _tryReuseExistingDevToolsInstance( Uri vmServiceUri, String? page, bool notifyUser, ) async { // 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 = await 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 = await 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; } static String buildUriToLaunch( Uri devToolsUri, String? page, Map? params, ) { page ??= ''; var pathSep = devToolsUri.path.endsWith('/') ? '' : '/'; var newPath = '${devToolsUri.path}$pathSep$page'; var newParams = { ...devToolsUri.queryParameters, ...?params, }; return devToolsUri .replace( path: newPath, queryParameters: newParams.isNotEmpty ? newParams : null) .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); } }