Attaches a debug connection to the tab when clicked (#1740)

diff --git a/dwds/debug_extension_mv3/build.yaml b/dwds/debug_extension_mv3/build.yaml
index cdffb3a..b5aaf6d 100644
--- a/dwds/debug_extension_mv3/build.yaml
+++ b/dwds/debug_extension_mv3/build.yaml
@@ -20,6 +20,7 @@
       {
         "web/{{}}.dart.js": ["compiled/{{}}.dart.js"],
         "web/{{}}.png": ["compiled/{{}}.png"],
+        "web/{{}}.html": ["compiled/{{}}.html"],
         "web/manifest.json": ["compiled/manifest.json"],
       }
     auto_apply: none
diff --git a/dwds/debug_extension_mv3/pubspec.yaml b/dwds/debug_extension_mv3/pubspec.yaml
index b94ad15..a1167d1 100644
--- a/dwds/debug_extension_mv3/pubspec.yaml
+++ b/dwds/debug_extension_mv3/pubspec.yaml
@@ -6,7 +6,7 @@
   A Chrome extension for Dart debugging.
 
 environment:
-  sdk: '>=2.12.0 <3.0.0'
+  sdk: '>=2.18.0 <3.0.0'
 
 dependencies:
   js: ^0.6.1+1
diff --git a/dwds/debug_extension_mv3/tool/copy_builder.dart b/dwds/debug_extension_mv3/tool/copy_builder.dart
index 07a0c79..5f0760f 100644
--- a/dwds/debug_extension_mv3/tool/copy_builder.dart
+++ b/dwds/debug_extension_mv3/tool/copy_builder.dart
@@ -1,4 +1,4 @@
-// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
+// 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.
 
@@ -12,6 +12,7 @@
   Map<String, List<String>> get buildExtensions => {
         "web/{{}}.dart.js": ["compiled/{{}}.dart.js"],
         "web/{{}}.png": ["compiled/{{}}.png"],
+        "web/{{}}.html": ["compiled/{{}}.html"],
         "web/manifest.json": ["compiled/manifest.json"],
       };
 
diff --git a/dwds/debug_extension_mv3/web/background.dart b/dwds/debug_extension_mv3/web/background.dart
index b1478cb..43e490f 100644
--- a/dwds/debug_extension_mv3/web/background.dart
+++ b/dwds/debug_extension_mv3/web/background.dart
@@ -5,15 +5,44 @@
 @JS()
 library background;
 
+import 'dart:html';
+
 import 'package:js/js.dart';
 
 import 'chrome_api.dart';
-import 'web_api.dart';
 
 void main() {
-  console.log('Running Dart Debug Extension.');
   // Detect clicks on the Dart Debug Extension icon.
-  chrome.action.onClicked.addListener(allowInterop((_) {
-    console.log('Detected click on the Dart Debug Extension icon.');
+  chrome.action.onClicked.addListener(allowInterop((_) async {
+    await _createDebugTab();
+    await _executeInjectorScript();
   }));
 }
+
+Future<Tab> _createDebugTab() async {
+  final url = chrome.runtime.getURL('debug_tab.html');
+  final tabPromise = chrome.tabs.create(TabInfo(
+    active: false,
+    pinned: true,
+    url: url,
+  ));
+  return promiseToFuture<Tab>(tabPromise);
+}
+
+Future<void> _executeInjectorScript() async {
+  final tabId = await _getTabId();
+  if (tabId != null) {
+    chrome.scripting.executeScript(
+      InjectDetails(
+          target: Target(tabId: tabId), files: ['iframe_injector.dart.js']),
+      /*callback*/ null,
+    );
+  }
+}
+
+Future<int?> _getTabId() async {
+  final query = QueryInfo(active: true, currentWindow: true);
+  final tabs = List<Tab>.from(await promiseToFuture(chrome.tabs.query(query)));
+  final tab = tabs.isNotEmpty ? tabs.first : null;
+  return tab?.id;
+}
diff --git a/dwds/debug_extension_mv3/web/chrome_api.dart b/dwds/debug_extension_mv3/web/chrome_api.dart
index 9bc20b0..505b2b3 100644
--- a/dwds/debug_extension_mv3/web/chrome_api.dart
+++ b/dwds/debug_extension_mv3/web/chrome_api.dart
@@ -11,6 +11,10 @@
 @anonymous
 class Chrome {
   external Action get action;
+  external Debugger get debugger;
+  external Runtime get runtime;
+  external Scripting get scripting;
+  external Tabs get tabs;
 }
 
 @JS()
@@ -28,7 +32,107 @@
 
 @JS()
 @anonymous
+class Debugger {
+  // https://developer.chrome.com/docs/extensions/reference/debugger/#method-attach
+  external void attach(
+      Debuggee target, String requiredVersion, Function? callback);
+
+  // https://developer.chrome.com/docs/extensions/reference/debugger/#method-sendCommand
+  external void sendCommand(Debuggee target, String method,
+      Object? commandParams, Function? callback);
+}
+
+@JS()
+@anonymous
+class Runtime {
+  // https://developer.chrome.com/docs/extensions/reference/runtime/#method-sendMessage
+  external void sendMessage(
+      String? id, Object? message, Object? options, Function? callback);
+
+  // https://developer.chrome.com/docs/extensions/reference/runtime/#method-getURL
+  external String getURL(String path);
+
+  // https://developer.chrome.com/docs/extensions/reference/runtime/#event-onMessage
+  external OnMessageHandler get onMessage;
+}
+
+@JS()
+@anonymous
+class OnMessageHandler {
+  external void addListener(
+      void Function(dynamic, MessageSender, Function) callback);
+}
+
+@JS()
+@anonymous
+class Scripting {
+  // https://developer.chrome.com/docs/extensions/reference/scripting/#method-executeScript
+  external executeScript(InjectDetails details, Function? callback);
+}
+
+@JS()
+@anonymous
+class Tabs {
+  // https://developer.chrome.com/docs/extensions/reference/tabs/#method-query
+  external Object query(QueryInfo queryInfo);
+
+  // https://developer.chrome.com/docs/extensions/reference/tabs/#method-create
+  external Object create(TabInfo tabInfo);
+}
+
+@JS()
+@anonymous
+class Debuggee {
+  external int get tabId;
+  external String get extensionId;
+  external String get targetId;
+  external factory Debuggee({int tabId, String? extensionId, String? targetId});
+}
+
+@JS()
+@anonymous
+class MessageSender {
+  external String? get id;
+  external Tab? get tab;
+  external String? get url;
+  external factory MessageSender({String? id, String? url, Tab? tab});
+}
+
+@JS()
+@anonymous
+class TabInfo {
+  external bool? get active;
+  external bool? get pinned;
+  external String? get url;
+  external factory TabInfo({bool? active, bool? pinned, String? url});
+}
+
+@JS()
+@anonymous
+class QueryInfo {
+  external bool get active;
+  external bool get currentWindow;
+  external factory QueryInfo({bool? active, bool? currentWindow});
+}
+
+@JS()
+@anonymous
 class Tab {
   external int get id;
   external String get url;
 }
+
+@JS()
+@anonymous
+class InjectDetails {
+  external Target get target;
+  external List<String>? get files;
+  external factory InjectDetails({Target target, List<String> files});
+}
+
+@JS()
+@anonymous
+class Target {
+  external int get tabId;
+  external factory Target({int tabId});
+}
diff --git a/dwds/debug_extension_mv3/web/debug_tab.dart b/dwds/debug_extension_mv3/web/debug_tab.dart
new file mode 100644
index 0000000..338f0e6
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/debug_tab.dart
@@ -0,0 +1,59 @@
+// 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.
+
+@JS()
+library debug_tab;
+
+import 'dart:html';
+
+import 'package:js/js.dart';
+
+import 'chrome_api.dart';
+import 'messaging.dart';
+
+final _channel = BroadcastChannel(debugTabChannelName);
+
+void main() {
+  _registerListeners();
+}
+
+void _registerListeners() {
+  _channel.addEventListener(
+    'message',
+    allowInterop(_handleChannelMessageEvents),
+  );
+}
+
+void _handleChannelMessageEvents(Event event) {
+  final messageData =
+      jsEventToMessageData(event, expectedOrigin: chrome.runtime.getURL(''));
+  if (messageData == null) return;
+
+  interceptMessage<DebugInfo>(
+    message: messageData,
+    expectedType: MessageType.debugInfo,
+    expectedSender: Script.iframe,
+    expectedRecipient: Script.debugTab,
+    messageHandler: _debugInfoMessageHandler,
+  );
+}
+
+void _debugInfoMessageHandler(DebugInfo message) {
+  final tabId = message.tabId;
+  _startDebugging(tabId);
+}
+
+void _startDebugging(int tabId) {
+  final debuggee = Debuggee(tabId: tabId);
+  chrome.debugger.attach(debuggee, '1.3', allowInterop(() async {
+    chrome.debugger.sendCommand(
+        debuggee, 'Runtime.enable', EmptyParam(), allowInterop((e) {}));
+  }));
+}
+
+@JS()
+@anonymous
+class EmptyParam {
+  external factory EmptyParam();
+}
diff --git a/dwds/debug_extension_mv3/web/debug_tab.html b/dwds/debug_extension_mv3/web/debug_tab.html
new file mode 100644
index 0000000..063f933
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/debug_tab.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+
+<body>
+    <h1>Dart Debug Tab</h1>
+    <h2>Closing will disconnect the debug connection.</h2>
+    <script type="module" src="debug_tab.dart.js"></script>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/dwds/debug_extension_mv3/web/iframe.dart b/dwds/debug_extension_mv3/web/iframe.dart
new file mode 100644
index 0000000..9da4144
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/iframe.dart
@@ -0,0 +1,75 @@
+// 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.
+
+@JS()
+library iframe;
+
+import 'dart:html';
+
+import 'package:js/js.dart';
+
+import 'chrome_api.dart';
+import 'messaging.dart';
+
+final _channel = BroadcastChannel(debugTabChannelName);
+
+void main() {
+  _registerListeners();
+
+  // Send a message to the injector script that the IFRAME has loaded.
+  _sendMessageToIframeInjector(
+    type: MessageType.iframeReady,
+    encodedBody: IframeReady(isReady: true).toJSON(),
+  );
+}
+
+void _registerListeners() {
+  chrome.runtime.onMessage.addListener(allowInterop(_handleRuntimeMessages));
+}
+
+void _sendMessageToIframeInjector({
+  required MessageType type,
+  required String encodedBody,
+}) {
+  final message = Message(
+    to: Script.iframeInjector,
+    from: Script.iframe,
+    type: type,
+    encodedBody: encodedBody,
+  );
+  window.parent?.postMessage(message.toJSON(), '*');
+}
+
+void _handleRuntimeMessages(
+    dynamic jsRequest, MessageSender sender, Function sendResponse) {
+  if (jsRequest is! String) return;
+
+  interceptMessage<DebugState>(
+      message: jsRequest,
+      expectedType: MessageType.debugState,
+      expectedSender: Script.iframeInjector,
+      expectedRecipient: Script.iframe,
+      messageHandler: (DebugState message) {
+        final tabId = sender.tab?.id;
+        if (tabId == null) return;
+
+        _sendMessageToDebugTab(
+          type: MessageType.debugInfo,
+          encodedBody: DebugInfo(tabId: tabId).toJSON(),
+        );
+      });
+}
+
+void _sendMessageToDebugTab({
+  required MessageType type,
+  required String encodedBody,
+}) {
+  final message = Message(
+    to: Script.debugTab,
+    from: Script.iframe,
+    type: type,
+    encodedBody: encodedBody,
+  );
+  _channel.postMessage(message.toJSON());
+}
diff --git a/dwds/debug_extension_mv3/web/iframe.html b/dwds/debug_extension_mv3/web/iframe.html
new file mode 100644
index 0000000..1b4ba17
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/iframe.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+
+<body>
+    <h1>Dart Debug IFRAME</h1>
+    <script type="module" src="iframe.dart.js"></script>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/dwds/debug_extension_mv3/web/iframe_injector.dart b/dwds/debug_extension_mv3/web/iframe_injector.dart
new file mode 100644
index 0000000..a83f145
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/iframe_injector.dart
@@ -0,0 +1,72 @@
+// 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:html';
+import 'dart:js';
+
+import 'chrome_api.dart';
+import 'messaging.dart';
+
+void main() {
+  _registerListeners();
+
+  // Inject the IFRAME into the current tab.
+  _injectIframe();
+}
+
+void _registerListeners() {
+  window.addEventListener(
+    'message',
+    allowInterop(_handleWindowMessageEvents),
+  );
+}
+
+void _injectIframe() {
+  final iframe = document.createElement('iframe');
+  final iframeSrc = chrome.runtime.getURL('iframe.html');
+  iframe.setAttribute('src', iframeSrc);
+  document.body?.append(iframe);
+}
+
+void _handleWindowMessageEvents(Event event) {
+  final messageData =
+      jsEventToMessageData(event, expectedOrigin: chrome.runtime.getURL(''));
+  if (messageData == null) return;
+
+  interceptMessage<IframeReady>(
+    message: messageData,
+    expectedType: MessageType.iframeReady,
+    expectedSender: Script.iframe,
+    expectedRecipient: Script.iframeInjector,
+    messageHandler: _iframeReadyMessageHandler,
+  );
+}
+
+void _iframeReadyMessageHandler(IframeReady message) {
+  if (message.isReady != true) return;
+  // TODO(elliette): Inject a script to fetch debug info global variables.
+
+  // Send a message back to IFRAME so that it has access to the tab ID.
+  _sendMessageToIframe(
+      type: MessageType.debugState,
+      encodedBody: DebugState(shouldDebug: true).toJSON());
+}
+
+void _sendMessageToIframe({
+  required MessageType type,
+  required String encodedBody,
+}) {
+  final message = Message(
+    to: Script.iframe,
+    from: Script.iframeInjector,
+    type: type,
+    encodedBody: encodedBody,
+  );
+  chrome.runtime.sendMessage(
+    /*id*/ null,
+    message.toJSON(),
+    /*options*/ null,
+    /*callback*/ null,
+  );
+}
diff --git a/dwds/debug_extension_mv3/web/manifest.json b/dwds/debug_extension_mv3/web/manifest.json
index 4434a26..06fc0ca 100644
--- a/dwds/debug_extension_mv3/web/manifest.json
+++ b/dwds/debug_extension_mv3/web/manifest.json
@@ -5,7 +5,26 @@
     "action": {
         "default_icon": "dart_dev.png"
     },
+    "permissions": [
+        "scripting",
+        "tabs",
+        "debugger"
+    ],
+    "host_permissions": [
+        "<all_urls>"
+    ],
+    "web_accessible_resources": [
+        {
+            "matches": [
+                "<all_urls>"
+            ],
+            "resources": [
+                "iframe.html",
+                "iframe_injector.dart.js"
+            ]
+        }
+    ],
     "background": {
         "service_worker": "background.dart.js"
     }
-}
+}
\ No newline at end of file
diff --git a/dwds/debug_extension_mv3/web/messaging.dart b/dwds/debug_extension_mv3/web/messaging.dart
new file mode 100644
index 0000000..1593a3b
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/messaging.dart
@@ -0,0 +1,189 @@
+// 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.
+
+@JS()
+library messaging;
+
+import 'dart:convert';
+import 'dart:html';
+
+import 'package:js/js.dart';
+
+import 'web_api.dart';
+
+const debugTabChannelName = 'DEBUG_TAB_CHANNEL';
+
+enum Script {
+  background,
+  debugTab,
+  iframe,
+  iframeInjector;
+
+  factory Script.fromString(String value) {
+    return Script.values.byName(value);
+  }
+}
+
+enum MessageType {
+  debugInfo,
+  debugState,
+  iframeReady;
+
+  factory MessageType.fromString(String value) {
+    return MessageType.values.byName(value);
+  }
+}
+
+class Message {
+  final Script to;
+  final Script from;
+  final MessageType type;
+  final String encodedBody;
+  final String? error;
+
+  Message({
+    required this.to,
+    required this.from,
+    required this.type,
+    required this.encodedBody,
+    this.error,
+  });
+
+  factory Message.fromJSON(String json) {
+    final decoded = jsonDecode(json) as Map<String, dynamic>;
+
+    return Message(
+      to: Script.fromString(decoded['to'] as String),
+      from: Script.fromString(decoded['from'] as String),
+      type: MessageType.fromString(decoded['type'] as String),
+      encodedBody: decoded['encodedBody'] as String,
+      error: decoded['error'] as String?,
+    );
+  }
+
+  String toJSON() {
+    return jsonEncode({
+      'type': type.name,
+      'to': to.name,
+      'from': from.name,
+      'encodedBody': encodedBody,
+      if (error != null) 'error': error,
+    });
+  }
+}
+
+void interceptMessage<T>({
+  required String? message,
+  required MessageType expectedType,
+  required Script expectedSender,
+  required Script expectedRecipient,
+  required void Function(T message) messageHandler,
+}) {
+  try {
+    if (message == null) return;
+    final decodedMessage = Message.fromJSON(message);
+    if (decodedMessage.type != expectedType ||
+        decodedMessage.to != expectedRecipient ||
+        decodedMessage.from != expectedSender) {
+      return;
+    }
+    final messageType = decodedMessage.type;
+    final messageBody = decodedMessage.encodedBody;
+    switch (messageType) {
+      case MessageType.debugInfo:
+        messageHandler(DebugInfo.fromJSON(messageBody) as T);
+        break;
+      case MessageType.debugState:
+        messageHandler(DebugState.fromJSON(messageBody) as T);
+        break;
+      case MessageType.iframeReady:
+        messageHandler(IframeReady.fromJSON(messageBody) as T);
+        break;
+    }
+  } catch (error) {
+    console.warn('Error intercepting expected message: $error');
+    return;
+  }
+}
+
+String? jsEventToMessageData(
+  Event event, {
+  required String expectedOrigin,
+}) {
+  try {
+    final messageEvent = event as MessageEvent;
+    final origin = messageEvent.origin;
+    if (origin.removeTrailingSlash() != expectedOrigin.removeTrailingSlash()) {
+      return null;
+    }
+    return messageEvent.data as String;
+  } catch (error) {
+    console.warn('Error converting event to message data: $error');
+    return null;
+  }
+}
+
+class IframeReady {
+  final bool isReady;
+
+  IframeReady({required this.isReady});
+
+  factory IframeReady.fromJSON(String json) {
+    final decoded = jsonDecode(json) as Map<String, dynamic>;
+    final isReady = decoded['isReady'] as bool;
+    return IframeReady(isReady: isReady);
+  }
+
+  String toJSON() {
+    return jsonEncode({
+      'isReady': isReady,
+    });
+  }
+}
+
+class DebugState {
+  final bool shouldDebug;
+
+  DebugState({required this.shouldDebug});
+
+  factory DebugState.fromJSON(String json) {
+    final decoded = jsonDecode(json) as Map<String, dynamic>;
+    final shouldDebug = decoded['shouldDebug'] as bool;
+    return DebugState(shouldDebug: shouldDebug);
+  }
+
+  String toJSON() {
+    return jsonEncode({
+      'shouldDebug': shouldDebug,
+    });
+  }
+}
+
+class DebugInfo {
+  final int tabId;
+
+  DebugInfo({required this.tabId});
+
+  factory DebugInfo.fromJSON(String json) {
+    final decoded = jsonDecode(json) as Map<String, dynamic>;
+    final tabId = decoded['tabId'] as int;
+    return DebugInfo(tabId: tabId);
+  }
+
+  String toJSON() {
+    return jsonEncode({
+      'tabId': tabId,
+    });
+  }
+}
+
+extension RemoveTrailingSlash on String {
+  String removeTrailingSlash() {
+    final trailingSlash = '/';
+    if (endsWith(trailingSlash)) {
+      return substring(0, length - 1);
+    }
+    return this;
+  }
+}
diff --git a/dwds/debug_extension_mv3/web/web_api.dart b/dwds/debug_extension_mv3/web/web_api.dart
index 8b927b4..938a0a1 100644
--- a/dwds/debug_extension_mv3/web/web_api.dart
+++ b/dwds/debug_extension_mv3/web/web_api.dart
@@ -12,4 +12,7 @@
 class Console {
   external void log(String header,
       [String style1, String style2, String style3]);
+
+  external void warn(String header,
+      [String style1, String style2, String style3]);
 }