Add basic web device and run support (#28302)

diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart
index 3822ea2..6cd1314 100644
--- a/packages/flutter_tools/lib/src/application_package.dart
+++ b/packages/flutter_tools/lib/src/application_package.dart
@@ -21,6 +21,7 @@
 import 'macos/application_package.dart';
 import 'project.dart';
 import 'tester/flutter_tester.dart';
+import 'web/web_device.dart';
 
 class ApplicationPackageFactory {
   static ApplicationPackageFactory get instance => context[ApplicationPackageFactory];
@@ -50,10 +51,11 @@
         return applicationBinary != null
           ? MacOSApp.fromPrebuiltApp(applicationBinary)
           : null;
+      case TargetPlatform.web:
+        return WebApplicationPackage(await FlutterProject.current());
       case TargetPlatform.linux_x64:
       case TargetPlatform.windows_x64:
       case TargetPlatform.fuchsia:
-      case TargetPlatform.web:
         return null;
     }
     assert(platform != null);
diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart
index af2976d..0f23de0 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -41,6 +41,7 @@
 import 'usage.dart';
 import 'version.dart';
 import 'web/compile.dart';
+import 'web/web_device.dart';
 import 'windows/windows_workflow.dart';
 
 Future<T> runInContext<T>(
@@ -65,6 +66,7 @@
       CocoaPods: () => CocoaPods(),
       CocoaPodsValidator: () => const CocoaPodsValidator(),
       Config: () => Config(),
+      ChromeLauncher: () => const ChromeLauncher(),
       DevFSConfig: () => DevFSConfig(),
       DeviceManager: () => DeviceManager(),
       Doctor: () => const Doctor(),
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index 52a3153..cad682f 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -20,6 +20,7 @@
 import 'linux/linux_device.dart';
 import 'macos/macos_device.dart';
 import 'tester/flutter_tester.dart';
+import 'web/web_device.dart';
 import 'windows/windows_device.dart';
 
 DeviceManager get deviceManager => context[DeviceManager];
@@ -36,7 +37,7 @@
     IOSSimulators(),
     FuchsiaDevices(),
     FlutterTesterDevices(),
-  ] + _conditionalDesktopDevices);
+  ] + _conditionalDesktopDevices + _conditionalWebDevices);
 
   /// Only add desktop devices if the flag is enabled.
   static List<DeviceDiscovery> get _conditionalDesktopDevices {
@@ -47,6 +48,13 @@
     ] : <DeviceDiscovery>[];
   }
 
+  /// Only add web devices if the flag is enabled.
+  static List<DeviceDiscovery> get _conditionalWebDevices {
+    return flutterWebEnabled ? <DeviceDiscovery>[
+      WebDevices(),
+    ] : <DeviceDiscovery>[];
+  }
+
   String _specifiedDeviceId;
 
   /// A user-specified device ID.
diff --git a/packages/flutter_tools/lib/src/project.dart b/packages/flutter_tools/lib/src/project.dart
index dc7587c..b2a0fde 100644
--- a/packages/flutter_tools/lib/src/project.dart
+++ b/packages/flutter_tools/lib/src/project.dart
@@ -19,6 +19,7 @@
 import 'ios/xcodeproj.dart' as xcode;
 import 'plugins.dart';
 import 'template.dart';
+import 'web/web_device.dart';
 
 /// Represents the contents of a Flutter project at the specified [directory].
 ///
@@ -95,6 +96,9 @@
   /// The Android sub project of this project.
   AndroidProject get android => AndroidProject._(this);
 
+  /// The web sub project of this project.
+  WebProject get web => WebProject._(this);
+
   /// The `pubspec.yaml` file of this project.
   File get pubspecFile => directory.childFile('pubspec.yaml');
 
@@ -150,6 +154,9 @@
     refreshPluginsList(this);
     await android.ensureReadyForPlatformSpecificTooling();
     await ios.ensureReadyForPlatformSpecificTooling();
+    if (flutterWebEnabled) {
+      await web.ensureReadyForPlatformSpecificTooling();
+    }
     await injectPlugins(this);
   }
 
@@ -454,6 +461,31 @@
   }
 }
 
+/// Represents the web sub-project of a Flutter project.
+class WebProject {
+  WebProject._(this.parent);
+
+  final FlutterProject parent;
+
+   Future<void> ensureReadyForPlatformSpecificTooling() async {
+    /// Generate index.html in build/web. Eventually we could support
+    /// a custom html under the web sub directory.
+    final Directory outputDir = fs.directory(getWebBuildDirectory());
+    if (!outputDir.existsSync()) {
+      outputDir.createSync(recursive: true);
+    }
+    final Template template = Template.fromName('web/index.html.tmpl');
+    template.render(
+      outputDir,
+      <String, dynamic>{
+        'appName': parent.manifest.appName,
+      },
+      printStatusWhenWriting: false,
+      overwriteExisting: true,
+    );
+   }
+}
+
 /// Deletes [directory] with all content.
 void _deleteIfExistsSync(Directory directory) {
   if (directory.existsSync())
diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart
index 8db4102..2addb59 100644
--- a/packages/flutter_tools/lib/src/resident_runner.dart
+++ b/packages/flutter_tools/lib/src/resident_runner.dart
@@ -326,7 +326,11 @@
       await stopEchoingDeviceLog();
       return 2;
     }
-    observatoryUris = <Uri>[result.observatoryUri];
+    if (result.hasObservatory) {
+      observatoryUris = <Uri>[result.observatoryUri];
+    } else {
+      observatoryUris = <Uri>[];
+    }
     return 0;
   }
 
@@ -384,8 +388,11 @@
       await stopEchoingDeviceLog();
       return 2;
     }
-    if (result.hasObservatory)
+    if (result.hasObservatory) {
       observatoryUris = <Uri>[result.observatoryUri];
+    } else {
+      observatoryUris = <Uri>[];
+    }
     return 0;
   }
 
diff --git a/packages/flutter_tools/lib/src/web/compile.dart b/packages/flutter_tools/lib/src/web/compile.dart
index 4dab748..51febc0 100644
--- a/packages/flutter_tools/lib/src/web/compile.dart
+++ b/packages/flutter_tools/lib/src/web/compile.dart
@@ -38,6 +38,7 @@
     if (!processManager.canRun(engineDartPath)) {
       throwToolExit('Unable to find Dart binary at $engineDartPath');
     }
+    /// Compile Dart to JavaScript.
     final List<String> command = <String>[
       engineDartPath,
       dart2jsPath,
diff --git a/packages/flutter_tools/lib/src/web/web_device.dart b/packages/flutter_tools/lib/src/web/web_device.dart
new file mode 100644
index 0000000..d98bb97
--- /dev/null
+++ b/packages/flutter_tools/lib/src/web/web_device.dart
@@ -0,0 +1,206 @@
+// Copyright 2019 The Chromium 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 '../application_package.dart';
+import '../base/context.dart';
+import '../base/file_system.dart';
+import '../base/io.dart';
+import '../base/logger.dart';
+import '../base/platform.dart';
+import '../base/process_manager.dart';
+import '../build_info.dart';
+import '../device.dart';
+import '../globals.dart';
+import '../project.dart';
+import '../web/compile.dart';
+
+ChromeLauncher get chromeLauncher => context[ChromeLauncher];
+
+/// Only launch or display web devices if `FLUTTER_WEB`
+/// environment variable is set to true.
+bool get flutterWebEnabled {
+  _flutterWebEnabled = platform.environment['FLUTTER_WEB']?.toLowerCase() == 'true';
+  return _flutterWebEnabled;
+}
+bool _flutterWebEnabled;
+
+
+class WebApplicationPackage extends ApplicationPackage {
+  WebApplicationPackage(this._flutterProject) : super(id: _flutterProject.manifest.appName);
+
+  final FlutterProject _flutterProject;
+
+  @override
+  String get name => _flutterProject.manifest.appName;
+
+  /// The location of the web source assets.
+  Directory get webSourcePath => _flutterProject.directory.childDirectory('web');
+}
+
+
+class WebDevice extends Device {
+  WebDevice() : super('web');
+
+  HttpServer _server;
+  WebApplicationPackage _package;
+
+  @override
+  bool get supportsHotReload => false;
+
+  @override
+  bool get supportsHotRestart => false;
+
+  @override
+  bool get supportsStartPaused => true;
+
+  @override
+  bool get supportsStopApp => true;
+
+  @override
+  bool get supportsScreenshot => false;
+
+  @override
+  void clearLogs() {}
+
+  @override
+  DeviceLogReader getLogReader({ApplicationPackage app}) {
+    return NoOpDeviceLogReader(app.name);
+  }
+
+  @override
+  Future<bool> installApp(ApplicationPackage app) async => true;
+
+  @override
+  Future<bool> isAppInstalled(ApplicationPackage app) async => true;
+
+  @override
+  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => true;
+
+  @override
+  Future<bool> get isLocalEmulator async => false;
+
+  @override
+  bool isSupported() => flutterWebEnabled;
+
+  @override
+  String get name => 'web';
+
+  @override
+  DevicePortForwarder get portForwarder => const NoOpDevicePortForwarder();
+
+  @override
+  Future<String> get sdkNameAndVersion async => 'web';
+
+  @override
+  Future<LaunchResult> startApp(
+    covariant WebApplicationPackage package, {
+    String mainPath,
+    String route,
+    DebuggingOptions debuggingOptions,
+    Map<String, Object> platformArgs,
+    bool prebuiltApplication = false,
+    bool applicationNeedsRebuild = false,
+    bool usesTerminalUi = true,
+    bool ipv6 = false,
+  }) async {
+    final Status status = logger.startProgress('Compiling ${package.name} to JavaScript...', timeout: null);
+    final int result = await webCompiler.compile(target: mainPath, minify: false, enabledAssertions: true);
+    status.stop();
+    if (result != 0) {
+      printError('Failed to compile ${package.name} to JavaScript');
+      return LaunchResult.failed();
+    }
+    _package = package;
+    _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
+    _server.listen(_basicAssetServer);
+    printStatus('Serving assets from http:localhost:${_server.port}');
+    await chromeLauncher.launch('http:localhost:${_server.port}');
+    return LaunchResult.succeeded(observatoryUri: null);
+  }
+
+  // Note: we don't currently have a way to track which chrome processes
+  // belong to the flutter tool, so we'll err on the side of caution by
+  // keeping these open.
+  @override
+  Future<bool> stopApp(ApplicationPackage app) async {
+    await _server?.close();
+    _server = null;
+    return true;
+  }
+
+  @override
+  Future<TargetPlatform> get targetPlatform async => TargetPlatform.web;
+
+  @override
+  Future<bool> uninstallApp(ApplicationPackage app) async => true;
+
+  Future<void> _basicAssetServer(HttpRequest request) async {
+    if (request.method != 'GET') {
+      request.response.statusCode = HttpStatus.forbidden;
+      await request.response.close();
+      return;
+    }
+    // Resolve all get requests to the build/web/asset directory.
+    final Uri uri = request.uri;
+    File file;
+    String contentType;
+    if (uri.path == '/') {
+      file = _package.webSourcePath.childFile('index.html');
+      contentType = 'text/html';
+    } else if (uri.path == '/main.dart.js') {
+      file = fs.file(fs.path.join(getWebBuildDirectory(), 'main.dart.js'));
+      contentType = 'text/javascript';
+    } else {
+      file = fs.file(fs.path.join(getAssetBuildDirectory(), uri.path));
+    }
+    if (!file.existsSync()) {
+      request.response.statusCode = HttpStatus.notFound;
+      await request.response.close();
+      return;
+    }
+    request.response.statusCode = HttpStatus.ok;
+    if (contentType != null) {
+      request.response.headers.add(HttpHeaders.contentTypeHeader, contentType);
+    }
+    await request.response.addStream(file.openRead());
+    await request.response.close();
+  }
+}
+
+class WebDevices extends PollingDeviceDiscovery {
+  WebDevices() : super('web');
+
+  final WebDevice _webDevice = WebDevice();
+
+  @override
+  bool get canListAnything => flutterWebEnabled;
+
+  @override
+  Future<List<Device>> pollingGetDevices() async {
+    return <Device>[
+      _webDevice,
+    ];
+  }
+
+  @override
+  bool get supportsPlatform => flutterWebEnabled;
+
+}
+
+// Responsible for launching chrome with devtools configured.
+class ChromeLauncher {
+  const ChromeLauncher();
+
+  static const String _kMacosLocation = '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome';
+
+  Future<void> launch(String host) async {
+    if (platform.isMacOS) {
+      await processManager.start(<String>[
+        _kMacosLocation,
+        host,
+      ]);
+    }
+    throw UnsupportedError('$platform is not supported');
+  }
+}