Allow launching DevTools directly over the JSON stdin API (#1365)

* Extract method for launching DevTools

* Extract validation of VM service URI

* Add support for launching DevTools directly over the server API

* Split docs for devTools.launch and launchDevTools VM service

* Move emitLaunchEvent out of launchDevTools method

* Rename ambigious method

* Skip devTools.launch tests when using existing server from pub
diff --git a/packages/devtools_app/test/integration_tests/server_test.dart b/packages/devtools_app/test/integration_tests/server_test.dart
index 693c236..0d65d77 100644
--- a/packages/devtools_app/test/integration_tests/server_test.dart
+++ b/packages/devtools_app/test/integration_tests/server_test.dart
@@ -7,6 +7,7 @@
 import 'dart:io';
 
 import 'package:devtools_testing/support/file_utils.dart';
+import 'package:meta/meta.dart';
 import 'package:test/test.dart';
 import 'package:vm_service/vm_service.dart';
 
@@ -111,158 +112,185 @@
     }
   }, timeout: const Timeout.factor(10));
 
-  group('Server API', () {
-    test(
-        'DevTools connects back to server API and registers that it is connected',
-        () async {
-      // Register the VM.
-      await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
+  // TODO(dantup): We can't run tests using the stdin API for devTools.launch unless
+  // we're running with a new server version. This check can be removed (and always use
+  // both) after the next server release (after the PR lands).
+  for (final bool useVmService
+      in serverDevToolsLaunchViaStdin ? [true, false] : [true]) {
+    group('Server (${useVmService ? 'VM Service' : 'API'})', () {
+      test(
+          'DevTools connects back to server API and registers that it is connected',
+          () async {
+        // Register the VM.
+        await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
 
-      // Send a request to launch DevTools in a browser.
-      await launchDevTools();
+        // Send a request to launch DevTools in a browser.
+        await _sendLaunchDevToolsRequest(useVmService: useVmService);
 
-      final serverResponse =
-          await _waitForClients(requiredConnectionState: true);
-      expect(serverResponse, isNotNull);
-      expect(serverResponse['clients'], hasLength(1));
-      expect(serverResponse['clients'][0]['hasConnection'], isTrue);
-      expect(serverResponse['clients'][0]['vmServiceUri'],
-          equals(appFixture.serviceUri.toString()));
-    }, timeout: const Timeout.factor(10));
+        final serverResponse =
+            await _waitForClients(requiredConnectionState: true);
+        expect(serverResponse, isNotNull);
+        expect(serverResponse['clients'], hasLength(1));
+        expect(serverResponse['clients'][0]['hasConnection'], isTrue);
+        expect(serverResponse['clients'][0]['vmServiceUri'],
+            equals(appFixture.serviceUri.toString()));
+      }, timeout: const Timeout.factor(10));
 
-    test('can launch on a specific page', () async {
-      // Register the VM.
-      await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
+      test('can launch on a specific page', () async {
+        // Register the VM.
+        await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
 
-      // Send a request to launch at a certain page.
-      await launchDevTools(page: 'memory');
+        // Send a request to launch at a certain page.
+        await _sendLaunchDevToolsRequest(
+            useVmService: useVmService, page: 'memory');
 
-      final serverResponse = await _waitForClients(requiredPage: 'memory');
-      expect(serverResponse, isNotNull);
-      expect(serverResponse['clients'], hasLength(1));
-      expect(serverResponse['clients'][0]['hasConnection'], isTrue);
-      expect(serverResponse['clients'][0]['vmServiceUri'],
-          equals(appFixture.serviceUri.toString()));
-      expect(serverResponse['clients'][0]['currentPage'], equals('memory'));
-    }, timeout: const Timeout.factor(10));
+        final serverResponse = await _waitForClients(requiredPage: 'memory');
+        expect(serverResponse, isNotNull);
+        expect(serverResponse['clients'], hasLength(1));
+        expect(serverResponse['clients'][0]['hasConnection'], isTrue);
+        expect(serverResponse['clients'][0]['vmServiceUri'],
+            equals(appFixture.serviceUri.toString()));
+        expect(serverResponse['clients'][0]['currentPage'], equals('memory'));
+      }, timeout: const Timeout.factor(10));
 
-    test('can switch page', () async {
-      await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
+      test('can switch page', () async {
+        await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
 
-      // Launch on the memory page and wait for the connection.
-      await launchDevTools(page: 'memory');
-      await _waitForClients(requiredPage: 'memory');
+        // Launch on the memory page and wait for the connection.
+        await _sendLaunchDevToolsRequest(
+            useVmService: useVmService, page: 'memory');
+        await _waitForClients(requiredPage: 'memory');
 
-      // Re-launch, allowing reuse and with a different page.
-      await launchDevTools(reuseWindows: true, page: 'performance');
+        // Re-launch, allowing reuse and with a different page.
+        await _sendLaunchDevToolsRequest(
+            useVmService: useVmService,
+            reuseWindows: true,
+            page: 'performance');
 
-      final serverResponse = await _waitForClients(requiredPage: 'performance');
-      expect(serverResponse, isNotNull);
-      expect(serverResponse['clients'], hasLength(1));
-      expect(serverResponse['clients'][0]['hasConnection'], isTrue);
-      expect(serverResponse['clients'][0]['vmServiceUri'],
-          equals(appFixture.serviceUri.toString()));
-      expect(
-          serverResponse['clients'][0]['currentPage'], equals('performance'));
-    }, timeout: const Timeout.factor(10));
+        final serverResponse =
+            await _waitForClients(requiredPage: 'performance');
+        expect(serverResponse, isNotNull);
+        expect(serverResponse['clients'], hasLength(1));
+        expect(serverResponse['clients'][0]['hasConnection'], isTrue);
+        expect(serverResponse['clients'][0]['vmServiceUri'],
+            equals(appFixture.serviceUri.toString()));
+        expect(
+            serverResponse['clients'][0]['currentPage'], equals('performance'));
+      }, timeout: const Timeout.factor(10));
 
-    test('DevTools reports disconnects from a VM', () async {
-      // Register the VM.
-      await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
+      test('DevTools reports disconnects from a VM', () async {
+        // Register the VM.
+        await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
 
-      // Send a request to launch DevTools in a browser.
-      await launchDevTools();
+        // Send a request to launch DevTools in a browser.
+        await _sendLaunchDevToolsRequest(useVmService: useVmService);
 
-      // Wait for the DevTools to inform server that it's connected.
-      await _waitForClients(requiredConnectionState: true);
+        // Wait for the DevTools to inform server that it's connected.
+        await _waitForClients(requiredConnectionState: true);
 
-      // Terminate the VM.
-      await appFixture.teardown();
+        // Terminate the VM.
+        await appFixture.teardown();
 
-      // Ensure the client is marked as disconnected.
-      final serverResponse =
-          await _waitForClients(requiredConnectionState: false);
-      expect(serverResponse['clients'], hasLength(1));
-      expect(serverResponse['clients'][0]['hasConnection'], isFalse);
-      expect(serverResponse['clients'][0]['vmServiceUri'], isNull);
-    }, timeout: const Timeout.factor(10));
+        // Ensure the client is marked as disconnected.
+        final serverResponse =
+            await _waitForClients(requiredConnectionState: false);
+        expect(serverResponse['clients'], hasLength(1));
+        expect(serverResponse['clients'][0]['hasConnection'], isFalse);
+        expect(serverResponse['clients'][0]['vmServiceUri'], isNull);
+      }, timeout: const Timeout.factor(10));
 
-    test('server removes clients that disconnect from the API', () async {
-      // TODO(dantup): This requires the ability for us to shut down Chrome,
-      // probably via a command to the server, which needs
-      // https://github.com/dart-lang/browser_launcher/pull/12
-    }, timeout: const Timeout.factor(10), skip: true);
+      test('server removes clients that disconnect from the API', () async {
+        // TODO(dantup): This requires the ability for us to shut down Chrome,
+        // probably via a command to the server, which needs
+        // https://github.com/dart-lang/browser_launcher/pull/12
+      }, timeout: const Timeout.factor(10), skip: true);
 
-    test('Server reuses DevTools instance if already connected to same VM',
-        () async {
-      // Register the VM.
-      await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
+      test('Server reuses DevTools instance if already connected to same VM',
+          () async {
+        // Register the VM.
+        await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
 
-      // Send a request to launch DevTools in a browser.
-      await launchDevTools();
+        // Send a request to launch DevTools in a browser.
+        await _sendLaunchDevToolsRequest(useVmService: useVmService);
 
-      {
+        {
+          final serverResponse =
+              await _waitForClients(requiredConnectionState: true);
+          expect(serverResponse['clients'], hasLength(1));
+        }
+
+        // Request again, allowing reuse, and server emits an event saying the
+        // window was reused.
+        final launchResponse = await _sendLaunchDevToolsRequest(
+            useVmService: useVmService, reuseWindows: true);
+        expect(launchResponse['reused'], isTrue);
+
+        // Ensure there's still only one connection (eg. we didn't spawn a new one
+        // we reused the existing one).
         final serverResponse =
             await _waitForClients(requiredConnectionState: true);
         expect(serverResponse['clients'], hasLength(1));
-      }
+      }, timeout: const Timeout.factor(10));
 
-      // Request again, allowing reuse, and server emits an event saying the
-      // window was reused.
-      final launchResponse = await launchDevTools(reuseWindows: true);
-      expect(launchResponse['reused'], isTrue);
+      test('Server reuses DevTools instance if not connected to a VM',
+          () async {
+        // Register the VM.
+        await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
 
-      // Ensure there's still only one connection (eg. we didn't spawn a new one
-      // we reused the existing one).
-      final serverResponse =
-          await _waitForClients(requiredConnectionState: true);
-      expect(serverResponse['clients'], hasLength(1));
-    }, timeout: const Timeout.factor(10));
+        // Send a request to launch DevTools in a browser.
+        await _sendLaunchDevToolsRequest(useVmService: useVmService);
 
-    test('Server reuses DevTools instance if not connected to a VM', () async {
-      // Register the VM.
-      await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
+        // Wait for the DevTools to inform server that it's connected.
+        await _waitForClients(requiredConnectionState: true);
 
-      // Send a request to launch DevTools in a browser.
-      await launchDevTools();
+        // Terminate the VM.
+        await appFixture.teardown();
 
-      // Wait for the DevTools to inform server that it's connected.
-      await _waitForClients(requiredConnectionState: true);
+        // Ensure the client is marked as disconnected.
+        await _waitForClients(requiredConnectionState: false);
 
-      // Terminate the VM.
-      await appFixture.teardown();
+        // Start up a new app.
+        await _startApp();
+        await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
 
-      // Ensure the client is marked as disconnected.
-      await _waitForClients(requiredConnectionState: false);
+        // Send a new request to launch.
+        await _sendLaunchDevToolsRequest(
+            useVmService: useVmService, reuseWindows: true);
 
-      // Start up a new app.
-      await _startApp();
-      await _send('vm.register', {'uri': appFixture.serviceUri.toString()});
-
-      // Send a new request to launch.
-      await launchDevTools(reuseWindows: true);
-
-      // Ensure we now have a single connected client.
-      final serverResponse =
-          await _waitForClients(requiredConnectionState: true);
-      expect(serverResponse['clients'], hasLength(1));
-      expect(serverResponse['clients'][0]['hasConnection'], isTrue);
-      expect(serverResponse['clients'][0]['vmServiceUri'],
-          equals(appFixture.serviceUri.toString()));
-    }, timeout: const Timeout.factor(10));
-    // The API only works in release mode.
-  }, skip: !testInReleaseMode);
+        // Ensure we now have a single connected client.
+        final serverResponse =
+            await _waitForClients(requiredConnectionState: true);
+        expect(serverResponse['clients'], hasLength(1));
+        expect(serverResponse['clients'][0]['hasConnection'], isTrue);
+        expect(serverResponse['clients'][0]['vmServiceUri'],
+            equals(appFixture.serviceUri.toString()));
+      }, timeout: const Timeout.factor(10));
+      // The API only works in release mode.
+    }, skip: !testInReleaseMode);
+  }
 }
 
-Future<Map<String, dynamic>> launchDevTools({
+Future<Map<String, dynamic>> _sendLaunchDevToolsRequest({
+  @required bool useVmService,
   String page,
   bool reuseWindows = false,
 }) async {
   final launchEvent = events.where((e) => e['event'] == 'client.launch').first;
-  await appFixture.serviceConnection.callMethod(
-    registeredServices['launchDevTools'],
-    args: {'reuseWindows': reuseWindows, 'page': page},
-  );
+  if (useVmService) {
+    await appFixture.serviceConnection.callMethod(
+      registeredServices['launchDevTools'],
+      args: {
+        'reuseWindows': reuseWindows,
+        'page': page,
+      },
+    );
+  } else {
+    await _send('devTools.launch', {
+      'vmServiceUri': appFixture.serviceUri.toString(),
+      'reuseWindows': reuseWindows,
+      'page': page,
+    });
+  }
   final response = await launchEvent;
   return response['params'];
 }
diff --git a/packages/devtools_app/test/support/devtools_server_driver.dart b/packages/devtools_app/test/support/devtools_server_driver.dart
index cd6b568..3977d19 100644
--- a/packages/devtools_app/test/support/devtools_server_driver.dart
+++ b/packages/devtools_app/test/support/devtools_server_driver.dart
@@ -10,6 +10,10 @@
 
 const verbose = true;
 
+// TODO(dantup): Remove this when the live Pub version supports devTools.launch.
+final bool serverDevToolsLaunchViaStdin =
+    Platform.environment['USE_LOCAL_DEPENDENCIES'] == 'true';
+
 class DevToolsServerDriver {
   DevToolsServerDriver._(this._process, this._stdin, Stream<String> _stdout,
       Stream<String> _stderr)
diff --git a/packages/devtools_server/docs/daemon.md b/packages/devtools_server/docs/daemon.md
index d0a2294..75f71b5 100644
--- a/packages/devtools_server/docs/daemon.md
+++ b/packages/devtools_server/docs/daemon.md
@@ -69,10 +69,11 @@
 This request lists all DevTools instances that are currently connected back to the server along with which VM services they're connected to and the pages they are showing. The request requires no `params`.
 -->
 
-### launchDevTools Request (VM Service)
+### devTools.launch Request
 
-`launchDevTools` is registered as a VM service (it is *not* sent over stdin to the server) and takes the following params:
+DevTools can be launched in a browser using the `devTools.launch` request with the following parameters.
 
+- `vmServiceUri` - the URI of the VM service that DevTools should connect to
 - `reuseWindows` - whether an existing DevTools instance that is not connected to a VM (or is connected to the same one) should be reused
 - `notify` - whether to send a browser notification to the user in the case where a DevTools instance is reused, to help them find the window
 - `page` - the page to launch DevTools on (matches the IDs used in DevTools that show in the URL fragments) or - if reusing a window - to switch to
@@ -86,6 +87,30 @@
 ```js
 {
 	'id': '123',
+	'method': 'devTools.launch',
+	'params': {
+		'vmServiceUri': 'ws://127.0.0.1/ABCDEF=/ws',
+		'notify': true,
+		'page': 'inspector',
+		'queryParams': {
+			'hide': 'debugger',
+			'ide': 'VSCode',
+			'theme': 'dark'
+		},
+		'reuseWindows': true
+	}
+}
+```
+
+### launchDevTools VM Service
+
+DevTools can also be launched via the VM Service protocol by calling the `launchDevTools` service. It takes the same parameters as the `devTools.launch` request, except without the `vmServiceUri` parameter since that's already known by the service.
+
+#### Example
+
+```js
+{
+	'id': '123',
 	// The `method` field is populated based on the ServiceRegistered VM event
 	'method': 's2.launchDevTools',
 	'params': {
@@ -103,4 +128,5 @@
 
 ## Changelog
 
+- 1.1.0: Add a `devTools.launch` request to launch DevTools directly via the server API
 - 1.0.0: Initial documentation for DevTools server API
diff --git a/packages/devtools_server/lib/src/server.dart b/packages/devtools_server/lib/src/server.dart
index 067a147..dfa77cb 100644
--- a/packages/devtools_server/lib/src/server.dart
+++ b/packages/devtools_server/lib/src/server.dart
@@ -20,7 +20,7 @@
 import 'client_manager.dart';
 import 'handlers.dart';
 
-const protocolVersion = '1.0.0';
+const protocolVersion = '1.1.0';
 const argHelp = 'help';
 const argEnableNotifications = 'enable-notifications';
 const argLaunchBrowser = 'launch-browser';
@@ -218,6 +218,15 @@
             devToolsUrl,
           );
           break;
+        case 'devTools.launch':
+          await _handleDevToolsLaunch(
+            id,
+            params,
+            machineMode,
+            headlessMode,
+            devToolsUrl,
+          );
+          break;
         case 'client.list':
           await _handleClientsList(id, params, machineMode);
           break;
@@ -255,18 +264,10 @@
     );
   }
 
-  // json['uri'] should contain a vm service uri.
+  // params['uri'] should contain a vm service uri.
   final uri = Uri.tryParse(params['uri']);
 
-  // Lots of things are considered valid URIs (including empty strings
-  // and single letters) since they can be relative, so we need to do some
-  // extra checks.
-  if (uri != null &&
-      uri.isAbsolute &&
-      (uri.isScheme('ws') ||
-          uri.isScheme('wss') ||
-          uri.isScheme('http') ||
-          uri.isScheme('https'))) {
+  if (_isValidVmServiceUri(uri)) {
     await registerLaunchDevToolsService(
         uri, id, devToolsUrl, machineMode, headlessMode);
   } else {
@@ -281,6 +282,57 @@
   }
 }
 
+Future<void> _handleDevToolsLaunch(
+  dynamic id,
+  Map<String, dynamic> params,
+  bool machineMode,
+  bool headlessMode,
+  String devToolsUrl,
+) async {
+  if (!params.containsKey('vmServiceUri')) {
+    printOutput(
+      'Invalid input: $params does not contain the key \'vmServiceUri\'',
+      {
+        'id': id,
+        'error':
+            'Invalid input: $params does not contain the key \'vmServiceUri\'',
+      },
+      machineMode: machineMode,
+    );
+  }
+
+  // params['vmServiceUri'] should contain a vm service uri.
+  final vmServiceUri = Uri.tryParse(params['vmServiceUri']);
+
+  if (_isValidVmServiceUri(vmServiceUri)) {
+    try {
+      final result = await launchDevTools(
+          params, vmServiceUri, devToolsUrl, headlessMode, machineMode);
+      printOutput(
+        'DevTools launched',
+        {'id': id, 'result': result},
+        machineMode: machineMode,
+      );
+    } catch (e, s) {
+      printOutput(
+        'Failed to launch browser: $e\n$s',
+        {'id': id, 'error': 'Failed to launch browser: $e\n$s'},
+        machineMode: machineMode,
+      );
+    }
+  } else {
+    printOutput(
+      'VM Service URI must be absolute with a http, https, ws or wss scheme',
+      {
+        'id': id,
+        'error':
+            'VM Service Uri must be absolute with a http, https, ws or wss scheme',
+      },
+      machineMode: machineMode,
+    );
+  }
+}
+
 Future<void> _handleClientsList(
     dynamic id, Map<String, dynamic> params, bool machineMode) async {
   final connectedClients = clients.allClients;
@@ -352,86 +404,14 @@
     final VmService service = await _connectToVmService(vmServiceUri);
 
     service.registerServiceCallback(launchDevToolsService, (params) async {
-      // 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}) {
-        printOutput(
-          null,
-          {
-            'event': 'client.launch',
-            'params': {'reused': reused, 'notified': notified},
-          },
-          machineMode: machineMode,
-        );
-      }
-
       try {
-        // First see if we have an existing DevTools client open that we can
-        // reuse.
-        final canReuse = params != null &&
-            params.containsKey('reuseWindows') &&
-            params['reuseWindows'] == true;
-        final shouldNotify = params != null &&
-            params.containsKey('notify') &&
-            params['notify'] == true;
-        final page = params != null ? params['page'] : null;
-        if (canReuse &&
-            await _tryReuseExistingDevToolsInstance(
-              vmServiceUri,
-              page,
-              shouldNotify,
-            )) {
-          emitLaunchEvent(reused: true, notified: shouldNotify);
-          return {'result': Success().toJson()};
-        }
-
-        final uriParams = <String, dynamic>{};
-
-        // Copy over queryParams passed by the client
-        if (params != null) {
-          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 = devToolsUri.replace(
-          // If path is empty, we generate 'http://foo:8000?uri=' (missing `/`) and
-          // ChromeOS fails to detect that it's a port that's tunneled, and will
-          // quietly replace the IP with "penguin.linux.test". This is not valid
-          // for us since the server isn't bound to the containers IP (it's bound
-          // to the containers loopback IP).
-          path: devToolsUri.path.isEmpty ? '/' : devToolsUri.path,
-          queryParameters: uriParams,
-          fragment: page,
+        await launchDevTools(
+          params,
+          vmServiceUri,
+          devToolsUrl,
+          headlessMode,
+          machineMode,
         );
-
-        // 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(Uri.parse(devToolsUrl)) &&
-            _isAccessibleToChromeOSNativeBrowser(vmServiceUri);
-        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>[];
-          await Chrome.start([uriToLaunch.toString()], args: args);
-        }
-
-        emitLaunchEvent(reused: false, notified: false);
         return {'result': Success().toJson()};
       } catch (e, s) {
         // Note: It's critical that we return responses in exactly the right format
@@ -476,6 +456,96 @@
   }
 }
 
+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 != null &&
+      params.containsKey('reuseWindows') &&
+      params['reuseWindows'] == true;
+  final shouldNotify = params != null &&
+      params.containsKey('notify') &&
+      params['notify'] == true;
+  final page = params != null ? params['page'] : null;
+  if (canReuse &&
+      await _tryReuseExistingDevToolsInstance(
+        vmServiceUri,
+        page,
+        shouldNotify,
+      )) {
+    _emitLaunchEvent(
+        reused: true, notified: shouldNotify, machineMode: machineMode);
+    return {'reused': true, 'notified': shouldNotify};
+  }
+
+  final uriParams = <String, dynamic>{};
+
+  // Copy over queryParams passed by the client
+  if (params != null) {
+    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 = devToolsUri.replace(
+    // If path is empty, we generate 'http://foo:8000?uri=' (missing `/`) and
+    // ChromeOS fails to detect that it's a port that's tunneled, and will
+    // quietly replace the IP with "penguin.linux.test". This is not valid
+    // for us since the server isn't bound to the containers IP (it's bound
+    // to the containers loopback IP).
+    path: devToolsUri.path.isEmpty ? '/' : devToolsUri.path,
+    queryParameters: uriParams,
+    fragment: page,
+  );
+
+  // 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(Uri.parse(devToolsUrl)) &&
+      _isAccessibleToChromeOSNativeBrowser(vmServiceUri);
+  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>[];
+    await Chrome.start([uriToLaunch.toString()], args: args);
+  }
+  _emitLaunchEvent(reused: false, notified: false, machineMode: machineMode);
+  return {'reused': false, 'notified': false};
+}
+
+/// 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 bool machineMode}) {
+  printOutput(
+    null,
+    {
+      'event': 'client.launch',
+      'params': {'reused': reused, 'notified': notified},
+    },
+    machineMode: machineMode,
+  );
+}
+
 // TODO(dantup): This method was adapted from devtools and should be upstreamed
 // in some form into vm_service_lib.
 bool isVersionLessThan(
@@ -495,6 +565,17 @@
   return uri != null && uri.hasPort && tunneledPorts.contains(uri.port);
 }
 
+bool _isValidVmServiceUri(Uri uri) =>
+    // Lots of things are considered valid URIs (including empty strings
+    // and single letters) since they can be relative, so we need to do some
+    // extra checks.
+    uri != null &&
+    uri.isAbsolute &&
+    (uri.isScheme('ws') ||
+        uri.isScheme('wss') ||
+        uri.isScheme('http') ||
+        uri.isScheme('https'));
+
 Future<VmService> _connectToVmService(Uri uri) async {
   // Fix up the various acceptable URI formats into a WebSocket URI to connect.
   uri = convertToWebSocketUrl(serviceProtocolUrl: uri);