diff --git a/dwds/debug_extension_mv3/pubspec.yaml b/dwds/debug_extension_mv3/pubspec.yaml index e16dbcae3..2e13fcba1 100644 --- a/dwds/debug_extension_mv3/pubspec.yaml +++ b/dwds/debug_extension_mv3/pubspec.yaml @@ -14,9 +14,9 @@ dependencies: 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 4bd45d9a5..60d2477f0 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 'package:js/js.dart'; 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 main() { 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 _startDebugSession(Tab _) async { - // TODO(elliette): Start a debug session instead. +// TODO(elliette): Start a debug session instead. +Future _startDebugSession(Tab currentTab) async { + maybeCreateLifelinePort(currentTab.id); final devToolsOpener = await fetchStorageObject( 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 49a1bacc8..de0250499 100644 --- a/dwds/debug_extension_mv3/web/chrome_api.dart +++ b/dwds/debug_extension_mv3/web/chrome_api.dart @@ -12,6 +12,7 @@ external Chrome get chrome; 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,12 +48,37 @@ class IconInfo { @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 { @@ -69,6 +95,37 @@ class MessageSender { 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 { + external Target get target; + external T? get func; + external List? get args; + external List? get files; + external factory InjectDetails({ + Target target, + T? func, + List? args, + List? 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 @@ class MessageSender { @anonymous class Storage { external StorageArea get local; + + external StorageArea get session; } @JS() @@ -95,6 +154,14 @@ class Tabs { 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 000000000..ec32786b9 --- /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 000000000..a465cebbd --- /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 = {}; + +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 91698a367..92e53961e 100644 --- a/dwds/debug_extension_mv3/web/manifest.json +++ b/dwds/debug_extension_mv3/web/manifest.json @@ -14,17 +14,6 @@ "host_permissions": [ "" ], - "web_accessible_resources": [ - { - "matches": [ - "" - ], - "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 e97fcc5b2..016f8ddea 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 @@ void main() async { group('MV3 Debug Extension', () { setUpAll(() async { - extensionPath = await _buildDebugExtension(); + extensionPath = await buildDebugExtension(); }); for (var useSse in [true, false]) { @@ -63,13 +62,13 @@ void main() async { 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 @@ void main() async { 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 @@ void main() async { // 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 @@ void main() async { }); } -Iterable _getUrlsInBrowser(Browser browser) { - return browser.targets.map((target) => target.url); -} - -Future _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 _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 @@ String _windowIdForTabJs(String tabUrl) { } '''; } - -final _clickIconJs = ''' - async () => { - const activeTabs = await chrome.tabs.query({ active: true }); - const tab = activeTabs[0]; - chrome.action.onClicked.dispatch(tab); - } -'''; - -Future _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 000000000..8a9cf8169 --- /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(_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 000000000..567aee1b9 --- /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 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 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 _getUrlsInBrowser(Browser browser) { + return browser.targets.map((target) => target.url); +} + +Future _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); + } +''';