Launch DevTools from pub instead of devtools_server (#71737)

* Launch DevTools from pub instead of devtools_server
diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart
index 2bae0fb..f992b9e 100644
--- a/packages/flutter_tools/lib/executable.dart
+++ b/packages/flutter_tools/lib/executable.dart
@@ -5,6 +5,7 @@
 import 'package:meta/meta.dart';
 
 import 'runner.dart' as runner;
+import 'src/artifacts.dart';
 import 'src/base/context.dart';
 import 'src/base/file_system.dart';
 import 'src/base/io.dart';
@@ -44,10 +45,10 @@
 import 'src/commands/test.dart';
 import 'src/commands/update_packages.dart';
 import 'src/commands/upgrade.dart';
+import 'src/devtools_launcher.dart';
 import 'src/features.dart';
 import 'src/globals.dart' as globals;
 // Files in `isolated` are intentionally excluded from google3 tooling.
-import 'src/isolated/devtools_launcher.dart';
 import 'src/isolated/mustache_template.dart';
 import 'src/isolated/resident_web_runner.dart';
 import 'src/resident_runner.dart';
@@ -150,7 +151,11 @@
        TemplateRenderer: () => const MustacheTemplateRenderer(),
        // The devtools launcher is not supported in google3 because it depends on
        // devtools source code.
-       DevtoolsLauncher: () => DevtoolsServerLauncher(logger: globals.logger),
+       DevtoolsLauncher: () => DevtoolsServerLauncher(
+         processManager: globals.processManager,
+         pubExecutable: globals.artifacts.getArtifactPath(Artifact.pubExecutable),
+         logger: globals.logger,
+       ),
        Logger: () {
         final LoggerFactory loggerFactory = LoggerFactory(
           outputPreferences: globals.outputPreferences,
diff --git a/packages/flutter_tools/lib/src/artifacts.dart b/packages/flutter_tools/lib/src/artifacts.dart
index 9386b08..fce6e3b 100644
--- a/packages/flutter_tools/lib/src/artifacts.dart
+++ b/packages/flutter_tools/lib/src/artifacts.dart
@@ -85,6 +85,9 @@
   /// Tools related to subsetting or icon font files.
   fontSubset,
   constFinder,
+
+  /// The pub or pub.bat executable
+  pubExecutable,
 }
 
 String _artifactToFileName(Artifact artifact, [ TargetPlatform platform, BuildMode mode ]) {
@@ -178,6 +181,11 @@
     case Artifact.webPrecompiledCanvaskitSoundSdkSourcemaps:
     case Artifact.webPrecompiledCanvaskitAndHtmlSoundSdkSourcemaps:
       return 'dart_sdk.js.map';
+    case Artifact.pubExecutable:
+      if (platform == TargetPlatform.windows_x64) {
+        return 'pub.bat';
+      }
+      return 'pub';
   }
   assert(false, 'Invalid artifact $artifact.');
   return null;
@@ -457,6 +465,8 @@
         return _fileSystem.path.join(_getFlutterWebSdkPath(), 'kernel', 'amd-canvaskit-html-sound', _artifactToFileName(artifact, platform, mode));
       case Artifact.webPrecompiledCanvaskitAndHtmlSoundSdkSourcemaps:
         return _fileSystem.path.join(_getFlutterWebSdkPath(), 'kernel', 'amd-canvaskit-html-sound', _artifactToFileName(artifact, platform, mode));
+      case Artifact.pubExecutable:
+        return _fileSystem.path.join(_dartSdkPath(_fileSystem), 'bin',  _artifactToFileName(artifact, platform, mode));
       default:
         assert(false, 'Artifact $artifact not available for platform $platform.');
         return null;
@@ -699,6 +709,8 @@
         return _fileSystem.path.join(_getFlutterWebSdkPath(), 'kernel', 'amd-canvaskit-html-sound', artifactFileName);
       case Artifact.webPrecompiledCanvaskitAndHtmlSoundSdkSourcemaps:
         return _fileSystem.path.join(_getFlutterWebSdkPath(), 'kernel', 'amd-canvaskit-html-sound', artifactFileName);
+      case Artifact.pubExecutable:
+        return _fileSystem.path.join(_hostEngineOutPath, 'dart-sdk', 'bin', _artifactToFileName(artifact, platform, mode));
     }
     assert(false, 'Invalid artifact $artifact.');
     return null;
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart
index 527ab3a..7b28ea8 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -872,8 +872,8 @@
 
   Future<Map<String, dynamic>> serve([ Map<String, dynamic> args ]) async {
     _devtoolsLauncher ??= DevtoolsLauncher.instance;
-    final DevToolsServerAddress server = await _devtoolsLauncher.serve();
-
+    final bool openInBrowser = args != null && (args['openInBrowser'] == 'true');
+    final DevToolsServerAddress server = await _devtoolsLauncher.serve(openInBrowser: openInBrowser);
     return<String, dynamic>{
       'host': server?.host,
       'port': server?.port,
diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart
index 6f42bd5..24597a1 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -28,6 +28,7 @@
 import 'dart/pub.dart';
 import 'devfs.dart';
 import 'device.dart';
+import 'devtools_launcher.dart';
 import 'doctor.dart';
 import 'emulator.dart';
 import 'features.dart';
@@ -46,6 +47,7 @@
 import 'mdns_discovery.dart';
 import 'persistent_tool_state.dart';
 import 'reporting/reporting.dart';
+import 'resident_runner.dart';
 import 'run_hot.dart';
 import 'runner/local_engine.dart';
 import 'version.dart';
@@ -178,6 +180,11 @@
         operatingSystemUtils: globals.os,
         terminal: globals.terminal,
       ),
+      DevtoolsLauncher: () => DevtoolsServerLauncher(
+        processManager: globals.processManager,
+        pubExecutable: globals.artifacts.getArtifactPath(Artifact.pubExecutable),
+        logger: globals.logger,
+      ),
       Doctor: () => Doctor(logger: globals.logger),
       DoctorValidatorsProvider: () => DoctorValidatorsProvider.defaultInstance,
       EmulatorManager: () => EmulatorManager(
diff --git a/packages/flutter_tools/lib/src/devtools_launcher.dart b/packages/flutter_tools/lib/src/devtools_launcher.dart
new file mode 100644
index 0000000..7a69cd2
--- /dev/null
+++ b/packages/flutter_tools/lib/src/devtools_launcher.dart
@@ -0,0 +1,122 @@
+// 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:browser_launcher/browser_launcher.dart';
+import 'package:meta/meta.dart';
+import 'package:process/process.dart';
+
+import 'base/io.dart' as io;
+import 'base/logger.dart';
+import 'convert.dart';
+import 'resident_runner.dart';
+
+/// An implementation of the devtools launcher that uses the server package.
+///
+/// This is implemented in isolated to prevent the flutter_tool from needing
+/// a devtools dep in google3.
+class DevtoolsServerLauncher extends DevtoolsLauncher {
+  DevtoolsServerLauncher({
+    @required ProcessManager processManager,
+    @required String pubExecutable,
+    @required Logger logger,
+  })  : _processManager = processManager,
+        _pubExecutable = pubExecutable,
+        _logger = logger;
+
+  final ProcessManager _processManager;
+  final String _pubExecutable;
+  final Logger _logger;
+
+  io.Process _devToolsProcess;
+  Uri _devToolsUri;
+
+  static final RegExp _serveDevToolsPattern =
+      RegExp(r'Serving DevTools at ((http|//)[a-zA-Z0-9:/=_\-\.\[\]]+)');
+
+  @override
+  Future<void> launch(Uri vmServiceUri, {bool openInBrowser = false}) async {
+    if (_devToolsProcess != null && _devToolsUri != null) {
+      // DevTools is already running.
+      if (openInBrowser) {
+        await Chrome.start(<String>[_devToolsUri.toString()]);
+      }
+      return;
+    }
+
+    final Status status = _logger.startProgress(
+      'Activating Dart DevTools...',
+    );
+    try {
+      // TODO(kenz): https://github.com/dart-lang/pub/issues/2791 - calling `pub
+      // global activate` adds ~ 4.5 seconds of latency.
+      final io.ProcessResult _devToolsActivateProcess = await _processManager.run(<String>[
+        _pubExecutable,
+        'global',
+        'activate',
+        'devtools'
+      ]);
+      if (_devToolsActivateProcess.exitCode != 0) {
+        status.cancel();
+        _logger.printError('Error running `pub global activate '
+            'devtools`:\n${_devToolsActivateProcess.stderr}');
+        return;
+      }
+      status.stop();
+
+      _devToolsProcess = await _processManager.start(<String>[
+        _pubExecutable,
+        'global',
+        'run',
+        'devtools',
+        if (vmServiceUri != null) '--vm-uri=$vmServiceUri',
+      ]);
+      final Completer<Uri> completer = Completer<Uri>();
+      _devToolsProcess.stdout
+          .transform(utf8.decoder)
+          .transform(const LineSplitter())
+          .listen((String line) {
+            final Match match = _serveDevToolsPattern.firstMatch(line);
+            if (match != null) {
+              // We are trying to pull "http://127.0.0.1:9101" from "Serving
+              // DevTools at http://127.0.0.1:9101.". `match[1]` will return
+              // "http://127.0.0.1:9101.", and we need to trim the trailing period
+              // so that we don't throw an exception from `Uri.parse`.
+              String uri = match[1];
+              if (uri.endsWith('.')) {
+                uri = uri.substring(0, uri.length - 1);
+              }
+              completer.complete(Uri.parse(uri));
+            }
+            _logger.printStatus(line);
+         });
+      _devToolsProcess.stderr
+          .transform(utf8.decoder)
+          .transform(const LineSplitter())
+          .listen(_logger.printError);
+      _devToolsUri = await completer.future;
+    } on Exception catch (e, st) {
+      status.cancel();
+      _logger.printError('Failed to launch DevTools: $e', stackTrace: st);
+    }
+  }
+
+  @override
+  Future<DevToolsServerAddress> serve({bool openInBrowser = false}) async {
+    await launch(null, openInBrowser: openInBrowser);
+    if (_devToolsUri == null) {
+      return null;
+    }
+    return DevToolsServerAddress(_devToolsUri.host, _devToolsUri.port);
+  }
+
+  @override
+  Future<void> close() async {
+    if (_devToolsProcess != null) {
+      _devToolsProcess.kill();
+      await _devToolsProcess.exitCode;
+    }
+  }
+}
diff --git a/packages/flutter_tools/lib/src/isolated/devtools_launcher.dart b/packages/flutter_tools/lib/src/isolated/devtools_launcher.dart
deleted file mode 100644
index d4b2646..0000000
--- a/packages/flutter_tools/lib/src/isolated/devtools_launcher.dart
+++ /dev/null
@@ -1,61 +0,0 @@
-// 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 'package:devtools_server/devtools_server.dart' as devtools_server;
-import 'package:meta/meta.dart';
-
-import '../base/io.dart' as io;
-import '../base/logger.dart';
-import '../resident_runner.dart';
-
-/// An implementation of the devtools launcher that uses the server package.
-///
-/// This is implemented in isolated to prevent the flutter_tool from needing
-/// a devtools dep in google3.
-class DevtoolsServerLauncher extends DevtoolsLauncher {
-  DevtoolsServerLauncher({
-    @required Logger logger,
-  }) : _logger = logger;
-
-  final Logger _logger;
-
-  io.HttpServer _devtoolsServer;
-
-  @override
-  Future<void> launch(Uri observatoryAddress) async {
-    try {
-      await serve();
-      await devtools_server.launchDevTools(
-        <String, dynamic>{
-          'reuseWindows': true,
-        },
-        observatoryAddress,
-        'http://${_devtoolsServer.address.host}:${_devtoolsServer.port}',
-        false,  // headless mode,
-        false,  // machine mode
-      );
-    } on Exception catch (e, st) {
-      _logger.printTrace('Failed to launch DevTools: $e\n$st');
-    }
-  }
-
-  @override
-  Future<DevToolsServerAddress> serve() async {
-    try {
-      _devtoolsServer ??= await devtools_server.serveDevTools(
-        enableStdinCommands: false,
-      );
-      return DevToolsServerAddress(_devtoolsServer.address.host, _devtoolsServer.port);
-    } on Exception catch (e, st) {
-      _logger.printTrace('Failed to serve DevTools: $e\n$st');
-      return null;
-    }
-  }
-
-  @override
-  Future<void> close() async {
-    await _devtoolsServer?.close();
-    _devtoolsServer = null;
-  }
-}
diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart
index 55f74fa..893fd99 100644
--- a/packages/flutter_tools/lib/src/resident_runner.dart
+++ b/packages/flutter_tools/lib/src/resident_runner.dart
@@ -1250,13 +1250,16 @@
     }
   }
 
-  Future<bool> launchDevTools() async {
+  Future<bool> launchDevTools({bool openInBrowser = false}) async {
     if (!supportsServiceProtocol) {
       return false;
     }
     assert(supportsServiceProtocol);
     _devtoolsLauncher ??= DevtoolsLauncher.instance;
-    await _devtoolsLauncher.launch(flutterDevices.first.vmService.httpAddress);
+    unawaited(_devtoolsLauncher.launch(
+      flutterDevices.first.vmService.httpAddress,
+      openInBrowser: openInBrowser,
+    ));
     return true;
   }
 
@@ -1549,7 +1552,7 @@
       case 'U':
         return residentRunner.debugDumpSemanticsTreeInInverseHitTestOrder();
       case 'v':
-        return residentRunner.launchDevTools();
+        return residentRunner.launchDevTools(openInBrowser: true);
       case 'w':
       case 'W':
         return residentRunner.debugDumpApp();
@@ -1642,9 +1645,12 @@
 
 /// A launcher for the devtools debugger and analysis tool.
 abstract class DevtoolsLauncher {
-  Future<void> launch(Uri observatoryAddress);
+  /// Launch a Dart DevTools process, optionally targeting a specific VM Service
+  /// URI if [vmServiceUri] is non-null.
+  Future<void> launch(Uri vmServiceUri, {bool openInBrowser = false});
 
-  Future<DevToolsServerAddress> serve();
+  /// Serve Dart DevTools and return the host and port they are available on.
+  Future<DevToolsServerAddress> serve({bool openInBrowser = false});
 
   Future<void> close();
 
diff --git a/packages/flutter_tools/test/general.shard/devtools_launcher_test.dart b/packages/flutter_tools/test/general.shard/devtools_launcher_test.dart
new file mode 100644
index 0000000..9d06ec7
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/devtools_launcher_test.dart
@@ -0,0 +1,119 @@
+// 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:flutter_tools/src/base/io.dart';
+import 'package:flutter_tools/src/base/logger.dart';
+import 'package:flutter_tools/src/devtools_launcher.dart';
+import 'package:flutter_tools/src/resident_runner.dart';
+
+import '../src/common.dart';
+import '../src/context.dart';
+
+void main() {
+  testWithoutContext('DevtoolsLauncher launches DevTools through pub and saves the URI', () async {
+    final Completer<void> completer = Completer<void>();
+    final DevtoolsLauncher launcher = DevtoolsServerLauncher(
+      pubExecutable: 'pub',
+      logger: BufferLogger.test(),
+      processManager: FakeProcessManager.list(<FakeCommand>[
+        const FakeCommand(
+          command: <String>[
+            'pub',
+            'global',
+            'activate',
+            'devtools',
+          ],
+          stdout: 'Activated DevTools 0.9.5',
+        ),
+        FakeCommand(
+          command: const <String>[
+            'pub',
+            'global',
+            'run',
+            'devtools',
+          ],
+          stdout: 'Serving DevTools at http://127.0.0.1:9100\n',
+          completer: completer,
+        ),
+      ]),
+    );
+
+    final DevToolsServerAddress address = await launcher.serve();
+    expect(address.host, '127.0.0.1');
+    expect(address.port, 9100);
+  });
+
+  testWithoutContext('DevtoolsLauncher prints error if exception is thrown during activate', () async {
+    final BufferLogger logger = BufferLogger.test();
+    final DevtoolsLauncher launcher = DevtoolsServerLauncher(
+      pubExecutable: 'pub',
+      logger: logger,
+      processManager: FakeProcessManager.list(<FakeCommand>[
+        const FakeCommand(
+          command: <String>[
+            'pub',
+            'global',
+            'activate',
+            'devtools',
+          ],
+          stderr: 'Error - could not activate devtools',
+          exitCode: 1,
+        ),
+        FakeCommand(
+            command: const <String>[
+              'pub',
+              'global',
+              'run',
+              'devtools',
+              '--vm-uri=http://127.0.0.1:1234/abcdefg',
+            ],
+            onRun: () {
+              throw const ProcessException('pub', <String>[]);
+            }
+        )
+      ]),
+    );
+
+    await launcher.launch(Uri.parse('http://127.0.0.1:1234/abcdefg'));
+
+    expect(logger.errorText, contains('Error running `pub global activate devtools`:\nError - could not activate devtools'));
+  });
+
+  testWithoutContext('DevtoolsLauncher prints error if exception is thrown during launch', () async {
+    final BufferLogger logger = BufferLogger.test();
+    final DevtoolsLauncher launcher = DevtoolsServerLauncher(
+      pubExecutable: 'pub',
+      logger: logger,
+      processManager: FakeProcessManager.list(<FakeCommand>[
+        const FakeCommand(
+          command: <String>[
+            'pub',
+            'global',
+            'activate',
+            'devtools',
+          ],
+          stdout: 'Activated DevTools 0.9.5',
+        ),
+        FakeCommand(
+            command: const <String>[
+              'pub',
+              'global',
+              'run',
+              'devtools',
+              '--vm-uri=http://127.0.0.1:1234/abcdefg',
+            ],
+            onRun: () {
+              throw const ProcessException('pub', <String>[]);
+            }
+        )
+      ]),
+    );
+
+    await launcher.launch(Uri.parse('http://127.0.0.1:1234/abcdefg'));
+
+    expect(logger.errorText, contains('Failed to launch DevTools: ProcessException'));
+  });
+}
diff --git a/packages/flutter_tools/test/general.shard/terminal_handler_test.dart b/packages/flutter_tools/test/general.shard/terminal_handler_test.dart
index 0710fb8..7ec82d1 100644
--- a/packages/flutter_tools/test/general.shard/terminal_handler_test.dart
+++ b/packages/flutter_tools/test/general.shard/terminal_handler_test.dart
@@ -289,7 +289,7 @@
       when(mockResidentRunner.supportsServiceProtocol).thenReturn(true);
       await terminalHandler.processTerminalInput('v');
 
-      verify(mockResidentRunner.launchDevTools()).called(1);
+      verify(mockResidentRunner.launchDevTools(openInBrowser: true)).called(1);
     });
 
     testWithoutContext('w,W - debugDumpApp with service protocol', () async {
diff --git a/packages/flutter_tools/test/src/fake_process_manager.dart b/packages/flutter_tools/test/src/fake_process_manager.dart
index 140249b..75e6c27 100644
--- a/packages/flutter_tools/test/src/fake_process_manager.dart
+++ b/packages/flutter_tools/test/src/fake_process_manager.dart
@@ -118,16 +118,12 @@
   _FakeProcess(
     this._exitCode,
     Duration duration,
-    VoidCallback onRun,
     this.pid,
     this._stderr,
     this.stdin,
     this._stdout,
     this._completer,
   ) : exitCode = Future<void>.delayed(duration).then((void value) {
-        if (onRun != null) {
-          onRun();
-        }
         if (_completer != null) {
           return _completer.future.then((void _) => _exitCode);
         }
@@ -231,10 +227,12 @@
   ) {
     _pid += 1;
     final FakeCommand fakeCommand = findCommand(command, workingDirectory, environment, encoding);
+    if (fakeCommand.onRun != null) {
+      fakeCommand.onRun();
+    }
     return _FakeProcess(
       fakeCommand.exitCode,
       fakeCommand.duration,
-      fakeCommand.onRun,
       _pid,
       fakeCommand.stderr,
       fakeCommand.stdin,