diff --git a/dwds/debug_extension_mv3/web/background.dart b/dwds/debug_extension_mv3/web/background.dart index 923e9d3a4..f18235745 100644 --- a/dwds/debug_extension_mv3/web/background.dart +++ b/dwds/debug_extension_mv3/web/background.dart @@ -89,6 +89,10 @@ void _handleRuntimeMessages( debugWarn('Received debug info but tab is missing.'); return; } + // If this is a new Dart app, we need to clear old debug session data: + if (!await _matchesAppInStorage(debugInfo.appId, tabId: dartTab.id)) { + await clearStaleDebugSession(dartTab.id); + } // Save the debug info for the Dart app in storage: await setStorageObject( type: StorageObject.debugInfo, value: debugInfo, tabId: dartTab.id); @@ -119,8 +123,9 @@ void _detectNavigationAwayFromDartApp(NavigationInfo navigationInfo) async { if (debugInfo == null) return; if (debugInfo.appUrl != navigationInfo.url) { _setDefaultIcon(); + await clearStaleDebugSession(tabId); await removeStorageObject(type: StorageObject.debugInfo, tabId: tabId); - detachDebugger( + await detachDebugger( tabId, type: TabType.dartApp, reason: DetachReason.navigatedAwayFromApp, @@ -153,3 +158,8 @@ Future _fetchDebugInfo(int tabId) { tabId: tabId, ); } + +Future _matchesAppInStorage(String? appId, {required int tabId}) async { + final debugInfo = await _fetchDebugInfo(tabId); + return appId != null && appId == debugInfo?.appId; +} diff --git a/dwds/debug_extension_mv3/web/debug_session.dart b/dwds/debug_extension_mv3/web/debug_session.dart index 50e289384..29800cdbd 100644 --- a/dwds/debug_extension_mv3/web/debug_session.dart +++ b/dwds/debug_extension_mv3/web/debug_session.dart @@ -49,6 +49,7 @@ enum DetachReason { connectionDoneEvent, devToolsTabClosed, navigatedAwayFromApp, + staleDebugSession, unknown; factory DetachReason.fromString(String value) { @@ -127,40 +128,60 @@ void attachDebugger(int dartAppTabId, {required Trigger trigger}) async { ); } -void detachDebugger( +Future detachDebugger( int tabId, { required TabType type, required DetachReason reason, }) async { final debugSession = _debugSessionForTab(tabId, type: type); - if (debugSession == null) return; + if (debugSession == null) return false; final debuggee = Debuggee(tabId: debugSession.appTabId); + final completer = Completer(); chrome.debugger.detach(debuggee, allowInterop(() { final error = chrome.runtime.lastError; if (error != null) { debugWarn( 'Error detaching tab for reason: $reason. Error: ${error.message}'); + completer.complete(false); } else { _handleDebuggerDetach(debuggee, reason); + completer.complete(true); } })); + return completer.future; +} + +bool isActiveDebugSession(int tabId) => + _debugSessionForTab(tabId, type: TabType.dartApp) != null; + +Future clearStaleDebugSession(int tabId) async { + final debugSession = _debugSessionForTab(tabId, type: TabType.dartApp); + if (debugSession != null) { + await detachDebugger( + tabId, + type: TabType.dartApp, + reason: DetachReason.staleDebugSession, + ); + } else { + await _removeDebugSessionDataInStorage(tabId); + } } void _registerDebugEventListeners() { chrome.debugger.onEvent.addListener(allowInterop(_onDebuggerEvent)); - chrome.debugger.onDetach.addListener(allowInterop( - (source, _) => _handleDebuggerDetach( + chrome.debugger.onDetach.addListener(allowInterop((source, _) async { + await _handleDebuggerDetach( source, DetachReason.canceledByUser, - ), - )); - chrome.tabs.onRemoved.addListener(allowInterop( - (tabId, _) => detachDebugger( + ); + })); + chrome.tabs.onRemoved.addListener(allowInterop((tabId, _) async { + await detachDebugger( tabId, type: TabType.devTools, reason: DetachReason.devToolsTabClosed, - ), - )); + ); + })); } _enableExecutionContextReporting(int tabId) { @@ -222,7 +243,7 @@ Future _maybeConnectToDwds(int tabId, Object? params) async { ); if (!connected) { debugWarn('Failed to connect to DWDS for $contextOrigin.'); - _sendConnectFailureMessage(ConnectFailureReason.unknown, + await _sendConnectFailureMessage(ConnectFailureReason.unknown, dartAppTabId: tabId); } } @@ -247,16 +268,16 @@ Future _connectToDwds({ appTabId: dartAppTabId, trigger: trigger, onIncoming: (data) => _routeDwdsEvent(data, client, dartAppTabId), - onDone: () { - detachDebugger( + onDone: () async { + await detachDebugger( dartAppTabId, type: TabType.dartApp, reason: DetachReason.connectionDoneEvent, ); }, - onError: (err) { + onError: (err) async { debugWarn('Connection error: $err', verbose: true); - detachDebugger( + await detachDebugger( dartAppTabId, type: TabType.dartApp, reason: DetachReason.connectionErrorEvent, @@ -377,7 +398,7 @@ void _openDevTools(String devToolsUri, {required int dartAppTabId}) async { } } -void _handleDebuggerDetach(Debuggee source, DetachReason reason) async { +Future _handleDebuggerDetach(Debuggee source, DetachReason reason) async { final tabId = source.tabId; debugLog( 'Debugger detached due to: $reason', @@ -385,16 +406,18 @@ void _handleDebuggerDetach(Debuggee source, DetachReason reason) async { prefix: '$tabId', ); final debugSession = _debugSessionForTab(tabId, type: TabType.dartApp); - if (debugSession == null) return; - debugLog('Removing debug session...'); - _removeDebugSession(debugSession); - // Notify the extension panels that the debug session has ended: - _sendStopDebuggingMessage(reason, dartAppTabId: source.tabId); - // Remove the DevTools URI and encoded URI from storage: - await removeStorageObject(type: StorageObject.devToolsUri, tabId: tabId); - await removeStorageObject(type: StorageObject.encodedUri, tabId: tabId); - // Maybe close the associated DevTools tab as well: - final devToolsTabId = debugSession.devToolsTabId; + if (debugSession != null) { + debugLog('Removing debug session...'); + _removeDebugSession(debugSession); + // Notify the extension panels that the debug session has ended: + await _sendStopDebuggingMessage(reason, dartAppTabId: tabId); + // Maybe close the associated DevTools tab as well: + await _maybeCloseDevTools(debugSession.devToolsTabId); + } + await _removeDebugSessionDataInStorage(tabId); +} + +Future _maybeCloseDevTools(int? devToolsTabId) async { if (devToolsTabId == null) return; final devToolsTab = await getTab(devToolsTabId); if (devToolsTab != null) { @@ -403,6 +426,12 @@ void _handleDebuggerDetach(Debuggee source, DetachReason reason) async { } } +Future _removeDebugSessionDataInStorage(int tabId) async { + // Remove the DevTools URI and encoded URI from storage: + await removeStorageObject(type: StorageObject.devToolsUri, tabId: tabId); + await removeStorageObject(type: StorageObject.encodedUri, tabId: tabId); +} + void _removeDebugSession(_DebugSession debugSession) { // Note: package:sse will try to keep the connection alive, even after the // client has been closed. Therefore the extension sends an event to notify @@ -422,25 +451,25 @@ void _removeDebugSession(_DebugSession debugSession) { } } -void _sendConnectFailureMessage(ConnectFailureReason reason, +Future _sendConnectFailureMessage(ConnectFailureReason reason, {required int dartAppTabId}) async { final json = jsonEncode(serializers.serialize(ConnectFailure((b) => b ..tabId = dartAppTabId ..reason = reason.name))); - sendRuntimeMessage( + return await sendRuntimeMessage( type: MessageType.connectFailure, body: json, sender: Script.background, recipient: Script.debuggerPanel); } -void _sendStopDebuggingMessage(DetachReason reason, +Future _sendStopDebuggingMessage(DetachReason reason, {required int dartAppTabId}) async { final json = jsonEncode(serializers.serialize(DebugStateChange((b) => b ..tabId = dartAppTabId ..reason = reason.name ..newState = DebugStateChange.stopDebugging))); - sendRuntimeMessage( + return await sendRuntimeMessage( type: MessageType.debugStateChange, body: json, sender: Script.background, @@ -478,7 +507,7 @@ Future _authenticateUser(int tabId) async { tabId: tabId, ); } else { - _sendConnectFailureMessage( + await _sendConnectFailureMessage( ConnectFailureReason.authentication, dartAppTabId: tabId, ); @@ -622,8 +651,20 @@ class _DebugSession { } void close() { - _socketClient.close(); - _batchSubscription.cancel(); - _batchController.close(); + try { + _socketClient.close(); + } catch (error) { + debugError('Error closing socket client: $error'); + } + try { + _batchSubscription.cancel(); + } catch (error) { + debugError('Error canceling batch subscription: $error'); + } + try { + _batchController.close(); + } catch (error) { + debugError('Error closing batch controller: $error'); + } } } diff --git a/dwds/debug_extension_mv3/web/detector.dart b/dwds/debug_extension_mv3/web/detector.dart index 74c8aa055..ec75374c1 100644 --- a/dwds/debug_extension_mv3/web/detector.dart +++ b/dwds/debug_extension_mv3/web/detector.dart @@ -26,14 +26,14 @@ void _registerListeners() { document.addEventListener('dart-auth-response', _onDartAuthEvent); } -void _onDartAppReadyEvent(Event event) { +Future _onDartAppReadyEvent(Event event) async { final debugInfo = getProperty(event, 'detail') as String?; if (debugInfo == null) { debugWarn( 'No debug info sent with ready event, instead reading from Window.'); _injectDebugInfoScript(); } else { - _sendMessageToBackgroundScript( + await _sendMessageToBackgroundScript( type: MessageType.debugInfo, body: debugInfo, ); @@ -41,10 +41,10 @@ void _onDartAppReadyEvent(Event event) { } } -void _onDartAuthEvent(Event event) { +Future _onDartAuthEvent(Event event) async { final isAuthenticated = getProperty(event, 'detail') as String?; if (isAuthenticated == null) return; - _sendMessageToBackgroundScript( + await _sendMessageToBackgroundScript( type: MessageType.isAuthenticated, body: isAuthenticated, ); @@ -61,11 +61,11 @@ void _injectDebugInfoScript() { document.head?.append(script); } -void _sendMessageToBackgroundScript({ +Future _sendMessageToBackgroundScript({ required MessageType type, required String body, -}) { - sendRuntimeMessage( +}) async { + await sendRuntimeMessage( type: type, body: body, sender: Script.detector, diff --git a/dwds/debug_extension_mv3/web/messaging.dart b/dwds/debug_extension_mv3/web/messaging.dart index a40d755fc..c7345fe26 100644 --- a/dwds/debug_extension_mv3/web/messaging.dart +++ b/dwds/debug_extension_mv3/web/messaging.dart @@ -5,6 +5,7 @@ @JS() library messaging; +import 'dart:async'; import 'dart:convert'; import 'package:js/js.dart'; @@ -100,7 +101,7 @@ void interceptMessage({ } } -void sendRuntimeMessage( +Future sendRuntimeMessage( {required MessageType type, required String body, required Script sender, @@ -111,10 +112,19 @@ void sendRuntimeMessage( type: type, body: body, ); + final completer = Completer(); chrome.runtime.sendMessage( /*id*/ null, message.toJSON(), /*options*/ null, - /*callback*/ null, + allowInterop(() { + final error = chrome.runtime.lastError; + if (error != null) { + debugError( + 'Error sending $type to $recipient from $sender: ${error.message}'); + } + completer.complete(error != null); + }), ); + return completer.future; } diff --git a/dwds/debug_extension_mv3/web/panel.dart b/dwds/debug_extension_mv3/web/panel.dart index f04d0b726..f5a44e4e2 100644 --- a/dwds/debug_extension_mv3/web/panel.dart +++ b/dwds/debug_extension_mv3/web/panel.dart @@ -5,6 +5,7 @@ @JS() library panel; +import 'dart:async'; import 'dart:convert'; import 'dart:html'; @@ -40,22 +41,32 @@ const _showClass = 'show'; const _warningBannerId = 'warningBanner'; const _warningMsgId = 'warningMsg'; +const _noAppDetectedMsg = 'No app detected.'; +const _lostConnectionMsg = 'Lost connection.'; +const _connectionTimeoutMsg = 'Connection timed out.'; +const _failedToConnectMsg = 'Failed to connect, please try again.'; +const _pleaseAuthenticateMsg = 'Please re-authenticate and try again.'; + int get _tabId => chrome.devtools.inspectedWindow.tabId; void main() { - _registerListeners(); + unawaited( + _registerListeners().catchError((error) { + debugWarn('Error registering listeners in panel: $error'); + }), + ); _setColorThemeToMatchChromeDevTools(); _maybeUpdateFileABugLink(); } -void _registerListeners() { +Future _registerListeners() async { chrome.storage.onChanged.addListener(allowInterop(_handleStorageChanges)); chrome.runtime.onMessage.addListener(allowInterop(_handleRuntimeMessages)); final launchDebugConnectionButton = document.getElementById(_launchDebugConnectionButtonId) as ButtonElement; launchDebugConnectionButton.addEventListener('click', _launchDebugConnection); - _maybeInjectDevToolsIframe(); + await _maybeInjectDevToolsIframe(); } void _handleRuntimeMessages( @@ -114,11 +125,15 @@ void _handleStorageChanges(Object storageObj, String storageArea) { void _handleDebugInfoChanges(DebugInfo? debugInfo) async { if (debugInfo == null && _isDartApp) { _isDartApp = false; - _showWarningBanner('Dart app is no longer open.'); + if (!_warningBannerIsVisible()) { + _showWarningBanner(_noAppDetectedMsg); + } } if (debugInfo != null && !_isDartApp) { _isDartApp = true; - _hideWarningBanner(); + if (_warningBannerIsVisible()) { + _hideWarningBanner(); + } } } @@ -171,33 +186,45 @@ void _handleDebugConnectionLost(String? reason) { final detachReason = DetachReason.fromString(reason ?? 'unknown'); _removeDevToolsIframe(); _updateElementVisibility(_landingPageId, visible: true); - if (detachReason != DetachReason.canceledByUser) { - _showWarningBanner('Lost connection.'); + switch (detachReason) { + case DetachReason.canceledByUser: + return; + case DetachReason.staleDebugSession: + case DetachReason.navigatedAwayFromApp: + _showWarningBanner(_noAppDetectedMsg); + break; + default: + _showWarningBanner(_lostConnectionMsg); + break; } } void _handleConnectFailure(ConnectFailureReason reason) { switch (reason) { case ConnectFailureReason.authentication: - _showWarningBanner('Please re-authenticate and try again.'); + _showWarningBanner(_pleaseAuthenticateMsg); break; case ConnectFailureReason.noDartApp: - _showWarningBanner('No Dart app detected.'); + _showWarningBanner(_noAppDetectedMsg); break; case ConnectFailureReason.timeout: - _showWarningBanner('Connection timed out.'); + _showWarningBanner(_connectionTimeoutMsg); break; default: - _showWarningBanner('Failed to connect, please try again.'); + _showWarningBanner(_failedToConnectMsg); } _updateElementVisibility(_launchDebugConnectionButtonId, visible: true); _updateElementVisibility(_loadingSpinnerId, visible: false); } +bool _warningBannerIsVisible() { + final warningBanner = document.getElementById(_warningBannerId); + return warningBanner != null && warningBanner.classes.contains(_showClass); +} + void _showWarningBanner(String message) { final warningMsg = document.getElementById(_warningMsgId); warningMsg?.setInnerHtml(message); - print(warningMsg); final warningBanner = document.getElementById(_warningBannerId); warningBanner?.classes.add(_showClass); } @@ -213,7 +240,7 @@ void _launchDebugConnection(Event _) async { final json = jsonEncode(serializers.serialize(DebugStateChange((b) => b ..tabId = _tabId ..newState = DebugStateChange.startDebugging))); - sendRuntimeMessage( + await sendRuntimeMessage( type: MessageType.debugStateChange, body: json, sender: Script.debuggerPanel, @@ -229,10 +256,15 @@ void _maybeHandleConnectionTimeout() async { } } -void _maybeInjectDevToolsIframe() async { +Future _maybeInjectDevToolsIframe() async { final devToolsUri = await fetchStorageObject( type: StorageObject.devToolsUri, tabId: _tabId); - if (devToolsUri != null) { + if (devToolsUri == null) return; + if (isActiveDebugSession(_tabId)) { + debugWarn('Unexpected state. Stale DevTools URI.'); + await clearStaleDebugSession(_tabId); + _updateElementVisibility(_landingPageId, visible: true); + } else { _injectDevToolsIframe(devToolsUri); } } @@ -250,7 +282,7 @@ void _injectDevToolsIframe(String devToolsUri) { 'ide': 'ChromeDevTools', 'embed': 'true', 'page': panelType, - '_backgroundColor': _backgroundColor, + 'backgroundColor': _backgroundColor, }, ); iframe.setAttribute('src', iframeSrc); diff --git a/dwds/test/puppeteer/extension_test.dart b/dwds/test/puppeteer/extension_test.dart index 2c8e08c68..8731dfce5 100644 --- a/dwds/test/puppeteer/extension_test.dart +++ b/dwds/test/puppeteer/extension_test.dart @@ -608,7 +608,7 @@ void main() async { // Expect the Dart DevTools IFRAME to be added: final devToolsUrlFragment = 'ide=ChromeDevTools&embed=true&page=debugger'; - final iframeTarget = await browser.waitForTarget( + var iframeTarget = await browser.waitForTarget( (target) => target.url.contains(devToolsUrlFragment), ); var iframeDestroyed = false; @@ -635,8 +635,25 @@ void main() async { screenshotName: 'debuggerPanelDisconnected_${isFlutterApp ? 'flutterApp' : 'dartApp'}', ); + // Navigate back to the Dart app: + await appTab.goto(context.appUrl, wait: Until.domContentLoaded); + // Click the launch button again + await _clickLaunchButton( + browser, + panel: Panel.debugger, + ); + // Expect the Dart DevTools IFRAME to be added again: + iframeTarget = await browser.waitForTarget( + (target) => target.url.contains(devToolsUrlFragment), + ); + expect(iframeTarget, isNotNull); }); + // TODO(elliette): Pull TestServer out of TestContext, so we can add + // a test case for starting another test app, loading that app in + // the tab we were debugging, and be able to reconnect to that one. + // See https://github.com/dart-lang/webdev/issues/1779 + test('The Dart DevTools IFRAME has the correct query parameters', () async { final chromeDevToolsPage = await getChromeDevToolsPage(browser); diff --git a/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_dartApp.png b/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_dartApp.png index 1e0ad8b7a..96ac4caa6 100644 Binary files a/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_dartApp.png and b/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_dartApp.png differ diff --git a/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_flutterApp.png b/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_flutterApp.png index 1e0ad8b7a..c8040bfe3 100644 Binary files a/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_flutterApp.png and b/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_flutterApp.png differ