Connect to a `chrome.runtime` port to keep the service worker alive (#1789)
diff --git a/dwds/debug_extension_mv3/pubspec.yaml b/dwds/debug_extension_mv3/pubspec.yaml
index e16dbca..2e13fcb 100644
--- a/dwds/debug_extension_mv3/pubspec.yaml
+++ b/dwds/debug_extension_mv3/pubspec.yaml
@@ -14,9 +14,9 @@
dev_dependencies:
build: ^2.0.0
- build_web_compilers: ^3.0.0
build_runner: ^2.0.6
built_value_generator: ^8.3.0
+ build_web_compilers: ^3.0.0
dwds: ^16.0.0
dependency_overrides:
diff --git a/dwds/debug_extension_mv3/web/background.dart b/dwds/debug_extension_mv3/web/background.dart
index 4bd45d9..60d2477 100644
--- a/dwds/debug_extension_mv3/web/background.dart
+++ b/dwds/debug_extension_mv3/web/background.dart
@@ -5,6 +5,7 @@
@JS()
library background;
+import 'dart:async';
import 'dart:html';
import 'package:dwds/data/debug_info.dart';
@@ -12,6 +13,7 @@
import 'chrome_api.dart';
import 'data_types.dart';
+import 'lifeline_ports.dart';
import 'messaging.dart';
import 'storage.dart';
import 'web_api.dart';
@@ -22,13 +24,16 @@
void _registerListeners() {
chrome.runtime.onMessage.addListener(allowInterop(_handleRuntimeMessages));
+ chrome.tabs.onRemoved
+ .addListener(allowInterop((tabId, _) => maybeRemoveLifelinePort(tabId)));
// Detect clicks on the Dart Debug Extension icon.
chrome.action.onClicked.addListener(allowInterop(_startDebugSession));
}
-Future<void> _startDebugSession(Tab _) async {
- // TODO(elliette): Start a debug session instead.
+// TODO(elliette): Start a debug session instead.
+Future<void> _startDebugSession(Tab currentTab) async {
+ maybeCreateLifelinePort(currentTab.id);
final devToolsOpener = await fetchStorageObject<DevToolsOpener>(
type: StorageObject.devToolsOpener);
await _createTab('https://dart.dev/',
diff --git a/dwds/debug_extension_mv3/web/chrome_api.dart b/dwds/debug_extension_mv3/web/chrome_api.dart
index 49a1bac..de02504 100644
--- a/dwds/debug_extension_mv3/web/chrome_api.dart
+++ b/dwds/debug_extension_mv3/web/chrome_api.dart
@@ -12,6 +12,7 @@
class Chrome {
external Action get action;
external Runtime get runtime;
+ external Scripting get scripting;
external Storage get storage;
external Tabs get tabs;
external Windows get windows;
@@ -47,14 +48,39 @@
@JS()
@anonymous
class Runtime {
+ external void connect(String? extensionId, ConnectInfo info);
+
external void sendMessage(
String? id, Object? message, Object? options, Function? callback);
+ external ConnectionHandler get onConnect;
+
external OnMessageHandler get onMessage;
}
@JS()
@anonymous
+class ConnectInfo {
+ external String? get name;
+ external factory ConnectInfo({String? name});
+}
+
+@JS()
+@anonymous
+class Port {
+ external String? get name;
+ external void disconnect();
+ external ConnectionHandler get onDisconnect;
+}
+
+@JS()
+@anonymous
+class ConnectionHandler {
+ external void addListener(void Function(Port) callback);
+}
+
+@JS()
+@anonymous
class OnMessageHandler {
external void addListener(
void Function(dynamic, MessageSender, Function) callback);
@@ -69,6 +95,37 @@
external factory MessageSender({String? id, String? url, Tab? tab});
}
+/// chrome.scripting APIs
+/// https://developer.chrome.com/docs/extensions/reference/scripting
+
+@JS()
+@anonymous
+class Scripting {
+ external executeScript(InjectDetails details, Function? callback);
+}
+
+@JS()
+@anonymous
+class InjectDetails<T, U> {
+ external Target get target;
+ external T? get func;
+ external List<U?>? get args;
+ external List<String>? get files;
+ external factory InjectDetails({
+ Target target,
+ T? func,
+ List<U>? args,
+ List<String>? files,
+ });
+}
+
+@JS()
+@anonymous
+class Target {
+ external int get tabId;
+ external factory Target({int tabId});
+}
+
/// chrome.storage APIs
/// https://developer.chrome.com/docs/extensions/reference/storage
@@ -76,6 +133,8 @@
@anonymous
class Storage {
external StorageArea get local;
+
+ external StorageArea get session;
}
@JS()
@@ -95,6 +154,14 @@
external Object query(QueryInfo queryInfo);
external Object create(TabInfo tabInfo);
+
+ external OnRemovedHandler get onRemoved;
+}
+
+@JS()
+@anonymous
+class OnRemovedHandler {
+ external void addListener(void Function(int tabId, dynamic info) callback);
}
@JS()
diff --git a/dwds/debug_extension_mv3/web/lifeline_connection.dart b/dwds/debug_extension_mv3/web/lifeline_connection.dart
new file mode 100644
index 0000000..ec32786
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/lifeline_connection.dart
@@ -0,0 +1,24 @@
+// 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 'chrome_api.dart';
+import 'web_api.dart';
+
+void main() async {
+ _connectToLifelinePort();
+}
+
+void _connectToLifelinePort() {
+ console.log(
+ '[Dart Debug Extension] Connecting to lifeline port at ${_currentTime()}.');
+ chrome.runtime.connect(
+ /*extensionId=*/ null,
+ ConnectInfo(name: 'keepAlive'),
+ );
+}
+
+String _currentTime() {
+ final date = DateTime.now();
+ return '${date.hour}:${date.minute}::${date.second}';
+}
diff --git a/dwds/debug_extension_mv3/web/lifeline_ports.dart b/dwds/debug_extension_mv3/web/lifeline_ports.dart
new file mode 100644
index 0000000..a465ceb
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/lifeline_ports.dart
@@ -0,0 +1,119 @@
+// 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.
+
+// Keeps the background service worker alive for the duration of a Dart debug
+// session by using the workaround described in:
+// https://bugs.chromium.org/p/chromium/issues/detail?id=1152255#c21
+@JS()
+library lifeline_ports;
+
+import 'dart:async';
+import 'package:js/js.dart';
+
+import 'chrome_api.dart';
+import 'web_api.dart';
+
+// Switch to true to enable debug logs.
+// TODO(elliette): Enable / disable with flag while building the extension.
+final enableDebugLogging = true;
+
+Port? lifelinePort;
+int? lifelineTab;
+final dartTabs = <int>{};
+
+void maybeCreateLifelinePort(int tabId) {
+ // Keep track of current Dart tabs that are being debugged. This way if one of
+ // them is closed, we can reconnect the lifeline port to another one:
+ dartTabs.add(tabId);
+ _debugLog('Dart tabs are: $dartTabs');
+ // Don't create a lifeline port if we already have one (meaning another Dart
+ // app is currently being debugged):
+ if (lifelinePort != null) {
+ _debugWarn('Port already exists.');
+ return;
+ }
+ // Start the keep-alive logic when the port connects:
+ chrome.runtime.onConnect.addListener(allowInterop(_keepLifelinePortAlive));
+ // Inject the connection script into the current Dart tab, that way the tab
+ // will connect to the port:
+ _debugLog('Creating lifeline port.');
+ lifelineTab = tabId;
+ chrome.scripting.executeScript(
+ InjectDetails(
+ target: Target(tabId: tabId),
+ files: ['lifeline_connection.dart.js'],
+ ),
+ /*callback*/ null,
+ );
+}
+
+void maybeRemoveLifelinePort(int removedTabId) {
+ final removedDartTab = dartTabs.remove(removedTabId);
+ // If the removed tab was not a Dart tab, return early.
+ if (!removedDartTab) return;
+ _debugLog('Removed tab $removedTabId, Dart tabs are now $dartTabs.');
+ // If the removed Dart tab hosted the lifeline port connection, see if there
+ // are any other Dart tabs to connect to. Otherwise disconnect the port.
+ if (lifelineTab == removedTabId) {
+ if (dartTabs.isEmpty) {
+ lifelineTab = null;
+ _debugLog('No more Dart tabs, disconnecting from lifeline port.');
+ _disconnectFromLifelinePort();
+ } else {
+ lifelineTab = dartTabs.last;
+ _debugLog('Reconnecting lifeline port to a new Dart tab: $lifelineTab.');
+ _reconnectToLifelinePort();
+ }
+ }
+}
+
+void _keepLifelinePortAlive(Port port) {
+ final portName = port.name ?? '';
+ if (portName != 'keepAlive') return;
+ lifelinePort = port;
+ // Reconnect to the lifeline port every 5 minutes, as per:
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=1146434#c6
+ Timer(Duration(minutes: 5), () {
+ _debugLog('5 minutes have elapsed, therefore reconnecting.');
+ _reconnectToLifelinePort();
+ });
+}
+
+void _reconnectToLifelinePort() {
+ _debugLog('Reconnecting...');
+ if (lifelinePort == null) {
+ _debugWarn('Could not find a lifeline port.');
+ return;
+ }
+ if (lifelineTab == null) {
+ _debugWarn('Could not find a lifeline tab.');
+ return;
+ }
+ // Disconnect from the port, and then recreate the connection with the current
+ // Dart tab:
+ _disconnectFromLifelinePort();
+ maybeCreateLifelinePort(lifelineTab!);
+ _debugLog('Reconnection complete.');
+}
+
+void _disconnectFromLifelinePort() {
+ _debugLog('Disconnecting...');
+ if (lifelinePort != null) {
+ lifelinePort!.disconnect();
+ lifelinePort = null;
+ _debugLog('Disconnection complete.');
+ }
+}
+
+void _debugLog(String msg) {
+ if (enableDebugLogging) {
+ console.log(msg);
+ }
+}
+
+void _debugWarn(String msg) {
+ if (enableDebugLogging) {
+ console.warn(msg);
+ }
+}
diff --git a/dwds/debug_extension_mv3/web/manifest.json b/dwds/debug_extension_mv3/web/manifest.json
index 91698a3..92e5396 100644
--- a/dwds/debug_extension_mv3/web/manifest.json
+++ b/dwds/debug_extension_mv3/web/manifest.json
@@ -14,17 +14,6 @@
"host_permissions": [
"<all_urls>"
],
- "web_accessible_resources": [
- {
- "matches": [
- "<all_urls>"
- ],
- "resources": [
- "iframe.html",
- "iframe_injector.dart.js"
- ]
- }
- ],
"background": {
"service_worker": "background.dart.js"
},
diff --git a/dwds/test/puppeteer/extension_test.dart b/dwds/test/puppeteer/extension_test.dart
index e97fcc5..016f8dd 100644
--- a/dwds/test/puppeteer/extension_test.dart
+++ b/dwds/test/puppeteer/extension_test.dart
@@ -10,13 +10,12 @@
})
@Timeout(Duration(seconds: 60))
import 'dart:async';
-import 'dart:io';
import 'package:puppeteer/puppeteer.dart';
-import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import '../fixtures/context.dart';
+import 'test_utils.dart';
final context = TestContext();
@@ -27,7 +26,7 @@
group('MV3 Debug Extension', () {
setUpAll(() async {
- extensionPath = await _buildDebugExtension();
+ extensionPath = await buildDebugExtension();
});
for (var useSse in [true, false]) {
@@ -63,13 +62,13 @@
final windowIdForAppJs = _windowIdForTabJs(appUrl);
final windowIdForDevToolsJs = _windowIdForTabJs(devToolsUrl);
// Navigate to the Dart app:
- await _navigateToPage(browser, url: appUrl, isNew: true);
+ await navigateToPage(browser, url: appUrl, isNew: true);
// Click on the Dart Debug Extension icon:
final worker = (await serviceWorkerTarget.worker)!;
// Note: The following delay is required to reduce flakiness (it makes
// sure the execution context is ready):
await Future.delayed(Duration(seconds: 1));
- await worker.evaluate(_clickIconJs);
+ await worker.evaluate(clickIconJs);
// Verify the extension opened the Dart docs in the same window:
var devToolsTabTarget = await browser
.waitForTarget((target) => target.url.contains(devToolsUrl));
@@ -81,8 +80,8 @@
var devToolsTab = await devToolsTabTarget.page;
await devToolsTab.close();
// Navigate to the extension settings page:
- final extensionOrigin = _getExtensionOrigin(browser);
- final settingsTab = await _navigateToPage(
+ final extensionOrigin = getExtensionOrigin(browser);
+ final settingsTab = await navigateToPage(
browser,
url: '$extensionOrigin/settings.html',
isNew: true,
@@ -95,9 +94,9 @@
// Close the settings tab:
await settingsTab.close();
// Navigate to the Dart app:
- await _navigateToPage(browser, url: appUrl);
+ await navigateToPage(browser, url: appUrl);
// Click on the Dart Debug Extension icon:
- await worker.evaluate(_clickIconJs);
+ await worker.evaluate(clickIconJs);
// Verify the extension opened DevTools in a different window:
devToolsTabTarget = await browser
.waitForTarget((target) => target.url.contains(devToolsUrl));
@@ -114,42 +113,6 @@
});
}
-Iterable<String> _getUrlsInBrowser(Browser browser) {
- return browser.targets.map((target) => target.url);
-}
-
-Future<Page> _getPageForUrl(Browser browser, {required String url}) {
- final pageTarget = browser.targets.firstWhere((target) => target.url == url);
- return pageTarget.page;
-}
-
-String _getExtensionOrigin(Browser browser) {
- final chromeExtension = 'chrome-extension:';
- final extensionUrl = _getUrlsInBrowser(browser)
- .firstWhere((url) => url.contains(chromeExtension));
- final urlSegments = p.split(extensionUrl);
- final extensionId = urlSegments[urlSegments.indexOf(chromeExtension) + 1];
- return '$chromeExtension//$extensionId';
-}
-
-Future<Page> _navigateToPage(
- Browser browser, {
- required String url,
- bool isNew = false,
-}) async {
- final page = isNew
- ? await browser.newPage()
- : await _getPageForUrl(
- browser,
- url: url,
- );
- if (isNew) {
- await page.goto(url, wait: Until.domContentLoaded);
- }
- await page.bringToFront();
- return page;
-}
-
String _windowIdForTabJs(String tabUrl) {
return '''
async () => {
@@ -159,27 +122,3 @@
}
''';
}
-
-final _clickIconJs = '''
- async () => {
- const activeTabs = await chrome.tabs.query({ active: true });
- const tab = activeTabs[0];
- chrome.action.onClicked.dispatch(tab);
- }
-''';
-
-Future<String> _buildDebugExtension() async {
- final currentDir = Directory.current.path;
- if (!currentDir.endsWith('dwds')) {
- throw StateError(
- 'Expected to be in /dwds directory, instead path was $currentDir.');
- }
- final extensionDir = '$currentDir/debug_extension_mv3';
- // TODO(elliette): This doesn't work on Windows, see https://github.com/dart-lang/webdev/issues/1724.
- await Process.run(
- 'tool/build_extension.sh',
- [],
- workingDirectory: extensionDir,
- );
- return '$extensionDir/compiled';
-}
diff --git a/dwds/test/puppeteer/lifeline_test.dart b/dwds/test/puppeteer/lifeline_test.dart
new file mode 100644
index 0000000..8a9cf81
--- /dev/null
+++ b/dwds/test/puppeteer/lifeline_test.dart
@@ -0,0 +1,91 @@
+// 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.
+
+@Timeout(Duration(minutes: 10))
+@Skip('https://github.com/dart-lang/webdev/issues/1788')
+import 'dart:async';
+
+import 'package:puppeteer/puppeteer.dart';
+import 'package:test/test.dart';
+
+import '../fixtures/context.dart';
+import 'test_utils.dart';
+
+final context = TestContext();
+
+void main() async {
+ late Target serviceWorkerTarget;
+ late Browser browser;
+ late String extensionPath;
+
+ int connectionCount = 0;
+
+ group('MV3 Debug Extension Lifeline Connection', () {
+ setUpAll(() async {
+ extensionPath = await buildDebugExtension();
+ await context.setUp(launchChrome: false);
+ browser = await puppeteer.launch(
+ headless: false,
+ timeout: Duration(seconds: 60),
+ args: [
+ '--load-extension=$extensionPath',
+ '--disable-extensions-except=$extensionPath',
+ '--disable-features=DialMediaRouteProvider',
+ ],
+ );
+
+ serviceWorkerTarget = await browser
+ .waitForTarget((target) => target.type == 'service_worker');
+ });
+
+ tearDownAll(() async {
+ await browser.close();
+ });
+
+ test('connects to a lifeline port', () async {
+ // Navigate to the Dart app:
+ final appTab =
+ await navigateToPage(browser, url: context.appUrl, isNew: true);
+ // Click on the Dart Debug Extension icon:
+ final worker = (await serviceWorkerTarget.worker)!;
+ // Note: The following delay is required to reduce flakiness (it makes
+ // sure the execution context is ready):
+ await Future.delayed(Duration(seconds: 1));
+ // Initiate listeners for the port connection event and the subsequent
+ // reconnection logs:
+ final portConnectionPromise = worker.evaluate<bool>(_portConnectionJs);
+ appTab.onConsole.listen((ConsoleMessage message) {
+ final messageText = message.text ?? '';
+ if (messageText
+ .contains('[Dart Debug Extension] Connecting to lifeline port')) {
+ connectionCount++;
+ }
+ });
+ // Click on the Dart Debug Extension icon to intiate a debug session:
+ await worker.evaluate(clickIconJs);
+ final connectedToPort = await portConnectionPromise;
+ // Verify that we have connected to the port:
+ expect(connectedToPort, isTrue);
+ expect(connectionCount, equals(1));
+ // Wait for a little over 5 minutes, and verify that we have reconnected
+ // to the port again:
+ await Future.delayed(Duration(minutes: 5) + Duration(seconds: 15));
+ expect(connectionCount, equals(2));
+ });
+ });
+}
+
+final _portConnectionJs = '''
+ async () => {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.onConnect.addListener((port) => {
+ if (port.name == 'keepAlive') {
+ resolve(true);
+ } else {
+ reject(false);
+ }
+ });
+ });
+ }
+''';
diff --git a/dwds/test/puppeteer/test_utils.dart b/dwds/test/puppeteer/test_utils.dart
new file mode 100644
index 0000000..567aee1
--- /dev/null
+++ b/dwds/test/puppeteer/test_utils.dart
@@ -0,0 +1,69 @@
+// 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:path/path.dart' as p;
+import 'package:puppeteer/puppeteer.dart';
+
+Future<String> buildDebugExtension() async {
+ final currentDir = Directory.current.path;
+ if (!currentDir.endsWith('dwds')) {
+ throw StateError(
+ 'Expected to be in /dwds directory, instead path was $currentDir.');
+ }
+ final extensionDir = p.join(currentDir, 'debug_extension_mv3');
+ // TODO(elliette): This doesn't work on Windows, see https://github.com/dart-lang/webdev/issues/1724.
+ await Process.run(
+ p.join('tool', 'build_extension.sh'),
+ [],
+ workingDirectory: extensionDir,
+ );
+ return '$extensionDir/compiled';
+}
+
+Future<Page> navigateToPage(
+ Browser browser, {
+ required String url,
+ bool isNew = false,
+}) async {
+ final page = isNew
+ ? await browser.newPage()
+ : await _getPageForUrl(
+ browser,
+ url: url,
+ );
+ if (isNew) {
+ await page.goto(url, wait: Until.domContentLoaded);
+ }
+ await page.bringToFront();
+ return page;
+}
+
+String getExtensionOrigin(Browser browser) {
+ final chromeExtension = 'chrome-extension:';
+ final extensionUrl = _getUrlsInBrowser(browser)
+ .firstWhere((url) => url.contains(chromeExtension));
+ final urlSegments = p.split(extensionUrl);
+ final extensionId = urlSegments[urlSegments.indexOf(chromeExtension) + 1];
+ return '$chromeExtension//$extensionId';
+}
+
+Iterable<String> _getUrlsInBrowser(Browser browser) {
+ return browser.targets.map((target) => target.url);
+}
+
+Future<Page> _getPageForUrl(Browser browser, {required String url}) {
+ final pageTarget = browser.targets.firstWhere((target) => target.url == url);
+ return pageTarget.page;
+}
+
+final clickIconJs = '''
+ async () => {
+ const activeTabs = await chrome.tabs.query({ active: true });
+ const tab = activeTabs[0];
+ chrome.action.onClicked.dispatch(tab);
+ }
+''';