blob: ee451faf35d666f151655c41e23898a58bdfef7e [file] [log] [blame]
// 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:browser_launcher/browser_launcher.dart';
import 'package:http_multi_server/http_multi_server.dart';
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;
MachineModeCommandHandler? _machineModeCommandHandler;
late ClientManager clientManager;
final bool _isChromeOS = File('/dev/.cros_milestone').existsSync();
/// 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 = false,
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');
}
// Ensure browsers don't cache older versions of the app.
_server.defaultResponseHeaders.add(
HttpHeaders.cacheControlHeader,
'max-age=900',
);
// 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;
}
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 {
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,
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);
}
// TODO(https://github.com/flutter/devtools/issues/3571): move to devtools_shared.
// Note: please keep this copy of normalizeVmServiceUri() in sync with the one
// in devtools_app.
Uri? _normalizeVmServiceUri(String value) {
value = value.trim();
// Cleanup encoded urls likely copied from the uri of an existing running
// DevTools app.
if (value.contains('%3A%2F%2F')) {
value = Uri.decodeFull(value);
}
final uri = Uri.parse(value.trim()).removeFragment();
if (!uri.isAbsolute) {
return null;
}
if (uri.path.endsWith('/')) return uri;
return uri.replace(path: uri.path);
}
}