diff --git a/dwds/debug_extension_mv3/web/background.dart b/dwds/debug_extension_mv3/web/background.dart index 87cd29f1f..a119d1a6b 100644 --- a/dwds/debug_extension_mv3/web/background.dart +++ b/dwds/debug_extension_mv3/web/background.dart @@ -12,6 +12,7 @@ import 'package:dwds/data/debug_info.dart'; import 'package:dwds/data/extension_request.dart'; import 'package:js/js.dart'; +import 'data_types.dart'; import 'debug_session.dart'; import 'chrome_api.dart'; import 'lifeline_ports.dart'; @@ -45,23 +46,36 @@ void _registerListeners() { .addListener(allowInterop(_detectNavigationAwayFromDartApp)); // Detect clicks on the Dart Debug Extension icon. - chrome.action.onClicked.addListener(allowInterop(_startDebugSession)); + chrome.action.onClicked.addListener(allowInterop( + (Tab tab) => _startDebugSession( + tab.id, + trigger: Trigger.extensionIcon, + ), + )); } -// TODO(elliette): Start a debug session instead. -Future _startDebugSession(Tab currentTab) async { - final tabId = currentTab.id; +Future _startDebugSession(int tabId, {required Trigger trigger}) async { final debugInfo = await _fetchDebugInfo(tabId); final extensionUrl = debugInfo?.extensionUrl; if (extensionUrl == null) { _showWarningNotification('Can\'t debug Dart app. Extension URL not found.'); + sendConnectFailureMessage( + ConnectFailureReason.noDartApp, + dartAppTabId: tabId, + ); return; } final isAuthenticated = await _authenticateUser(extensionUrl, tabId); - if (!isAuthenticated) return; + if (!isAuthenticated) { + sendConnectFailureMessage( + ConnectFailureReason.authentication, + dartAppTabId: tabId, + ); + return; + } - maybeCreateLifelinePort(currentTab.id); - attachDebugger(tabId); + maybeCreateLifelinePort(tabId); + attachDebugger(tabId, trigger: trigger); } Future _authenticateUser(String extensionUrl, int tabId) async { @@ -113,6 +127,19 @@ void _handleRuntimeMessages( _setDebuggableIcon(); } }); + + interceptMessage( + message: jsRequest, + expectedType: MessageType.debugStateChange, + expectedSender: Script.debuggerPanel, + expectedRecipient: Script.background, + messageHandler: (DebugStateChange debugStateChange) { + final newState = debugStateChange.newState; + final tabId = debugStateChange.tabId; + if (newState == DebugStateChange.startDebugging) { + _startDebugSession(tabId, trigger: Trigger.extensionPanel); + } + }); } void _detectNavigationAwayFromDartApp(NavigationInfo navigationInfo) async { @@ -125,7 +152,7 @@ void _detectNavigationAwayFromDartApp(NavigationInfo navigationInfo) async { detachDebugger( tabId, type: TabType.dartApp, - reason: 'Navigated away from Dart app.', + reason: DetachReason.navigatedAwayFromApp, ); } } diff --git a/dwds/debug_extension_mv3/web/chrome_api.dart b/dwds/debug_extension_mv3/web/chrome_api.dart index b3468f7f3..76114fd4a 100644 --- a/dwds/debug_extension_mv3/web/chrome_api.dart +++ b/dwds/debug_extension_mv3/web/chrome_api.dart @@ -111,6 +111,8 @@ class InspectedWindow { @JS() @anonymous class Panels { + external String get themeName; + external void create(String title, String iconPath, String pagePath, void Function(ExtensionPanel)? callback); } diff --git a/dwds/debug_extension_mv3/web/data_serializers.dart b/dwds/debug_extension_mv3/web/data_serializers.dart index 1cb6268ae..9c4e31460 100644 --- a/dwds/debug_extension_mv3/web/data_serializers.dart +++ b/dwds/debug_extension_mv3/web/data_serializers.dart @@ -15,8 +15,11 @@ part 'data_serializers.g.dart'; /// Serializers for all the data types used in the Dart Debug Extension. @SerializersFor([ BatchedEvents, + ConnectFailure, DebugInfo, + DebugStateChange, DevToolsOpener, + DevToolsUrl, DevToolsRequest, ExtensionEvent, ExtensionRequest, diff --git a/dwds/debug_extension_mv3/web/data_serializers.g.dart b/dwds/debug_extension_mv3/web/data_serializers.g.dart index 5dc6bb9b1..7c15ed146 100644 --- a/dwds/debug_extension_mv3/web/data_serializers.g.dart +++ b/dwds/debug_extension_mv3/web/data_serializers.g.dart @@ -8,9 +8,12 @@ part of 'data_serializers.dart'; Serializers _$serializers = (new Serializers().toBuilder() ..add(BatchedEvents.serializer) + ..add(ConnectFailure.serializer) ..add(DebugInfo.serializer) + ..add(DebugStateChange.serializer) ..add(DevToolsOpener.serializer) ..add(DevToolsRequest.serializer) + ..add(DevToolsUrl.serializer) ..add(ExtensionEvent.serializer) ..add(ExtensionRequest.serializer) ..add(ExtensionResponse.serializer) diff --git a/dwds/debug_extension_mv3/web/data_types.dart b/dwds/debug_extension_mv3/web/data_types.dart index 69df9d70a..eb22eb4e7 100644 --- a/dwds/debug_extension_mv3/web/data_types.dart +++ b/dwds/debug_extension_mv3/web/data_types.dart @@ -7,6 +7,21 @@ import 'package:built_value/serializer.dart'; part 'data_types.g.dart'; +abstract class ConnectFailure + implements Built { + static Serializer get serializer => + _$connectFailureSerializer; + + factory ConnectFailure([Function(ConnectFailureBuilder) updates]) = + _$ConnectFailure; + + ConnectFailure._(); + + int get tabId; + + String? get reason; +} + abstract class DevToolsOpener implements Built { static Serializer get serializer => @@ -19,3 +34,37 @@ abstract class DevToolsOpener bool get newWindow; } + +abstract class DevToolsUrl implements Built { + static Serializer get serializer => _$devToolsUrlSerializer; + + factory DevToolsUrl([Function(DevToolsUrlBuilder) updates]) = _$DevToolsUrl; + + DevToolsUrl._(); + + int get tabId; + + String get url; +} + +abstract class DebugStateChange + implements Built { + static const startDebugging = 'start-debugging'; + static const stopDebugging = 'stop-debugging'; + static const failedToConnect = 'failed-to-connect'; + + static Serializer get serializer => + _$debugStateChangeSerializer; + + factory DebugStateChange([Function(DebugStateChangeBuilder) updates]) = + _$DebugStateChange; + + DebugStateChange._(); + + int get tabId; + + /// Can only be [startDebugging] or [stopDebugging]. + String get newState; + + String? get reason; +} diff --git a/dwds/debug_extension_mv3/web/data_types.g.dart b/dwds/debug_extension_mv3/web/data_types.g.dart index cfdbbe5f5..34c78b418 100644 --- a/dwds/debug_extension_mv3/web/data_types.g.dart +++ b/dwds/debug_extension_mv3/web/data_types.g.dart @@ -6,8 +6,65 @@ part of 'data_types.dart'; // BuiltValueGenerator // ************************************************************************** +Serializer _$connectFailureSerializer = + new _$ConnectFailureSerializer(); Serializer _$devToolsOpenerSerializer = new _$DevToolsOpenerSerializer(); +Serializer _$devToolsUrlSerializer = new _$DevToolsUrlSerializer(); +Serializer _$debugStateChangeSerializer = + new _$DebugStateChangeSerializer(); + +class _$ConnectFailureSerializer + implements StructuredSerializer { + @override + final Iterable types = const [ConnectFailure, _$ConnectFailure]; + @override + final String wireName = 'ConnectFailure'; + + @override + Iterable serialize(Serializers serializers, ConnectFailure object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'tabId', + serializers.serialize(object.tabId, specifiedType: const FullType(int)), + ]; + Object? value; + value = object.reason; + if (value != null) { + result + ..add('reason') + ..add(serializers.serialize(value, + specifiedType: const FullType(String))); + } + return result; + } + + @override + ConnectFailure deserialize( + Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new ConnectFailureBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'tabId': + result.tabId = serializers.deserialize(value, + specifiedType: const FullType(int))! as int; + break; + case 'reason': + result.reason = serializers.deserialize(value, + specifiedType: const FullType(String)) as String?; + break; + } + } + + return result.build(); + } +} class _$DevToolsOpenerSerializer implements StructuredSerializer { @@ -51,6 +108,202 @@ class _$DevToolsOpenerSerializer } } +class _$DevToolsUrlSerializer implements StructuredSerializer { + @override + final Iterable types = const [DevToolsUrl, _$DevToolsUrl]; + @override + final String wireName = 'DevToolsUrl'; + + @override + Iterable serialize(Serializers serializers, DevToolsUrl object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'tabId', + serializers.serialize(object.tabId, specifiedType: const FullType(int)), + 'url', + serializers.serialize(object.url, specifiedType: const FullType(String)), + ]; + + return result; + } + + @override + DevToolsUrl deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new DevToolsUrlBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'tabId': + result.tabId = serializers.deserialize(value, + specifiedType: const FullType(int))! as int; + break; + case 'url': + result.url = serializers.deserialize(value, + specifiedType: const FullType(String))! as String; + break; + } + } + + return result.build(); + } +} + +class _$DebugStateChangeSerializer + implements StructuredSerializer { + @override + final Iterable types = const [DebugStateChange, _$DebugStateChange]; + @override + final String wireName = 'DebugStateChange'; + + @override + Iterable serialize(Serializers serializers, DebugStateChange object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'tabId', + serializers.serialize(object.tabId, specifiedType: const FullType(int)), + 'newState', + serializers.serialize(object.newState, + specifiedType: const FullType(String)), + ]; + Object? value; + value = object.reason; + if (value != null) { + result + ..add('reason') + ..add(serializers.serialize(value, + specifiedType: const FullType(String))); + } + return result; + } + + @override + DebugStateChange deserialize( + Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new DebugStateChangeBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'tabId': + result.tabId = serializers.deserialize(value, + specifiedType: const FullType(int))! as int; + break; + case 'newState': + result.newState = serializers.deserialize(value, + specifiedType: const FullType(String))! as String; + break; + case 'reason': + result.reason = serializers.deserialize(value, + specifiedType: const FullType(String)) as String?; + break; + } + } + + return result.build(); + } +} + +class _$ConnectFailure extends ConnectFailure { + @override + final int tabId; + @override + final String? reason; + + factory _$ConnectFailure([void Function(ConnectFailureBuilder)? updates]) => + (new ConnectFailureBuilder()..update(updates))._build(); + + _$ConnectFailure._({required this.tabId, this.reason}) : super._() { + BuiltValueNullFieldError.checkNotNull(tabId, r'ConnectFailure', 'tabId'); + } + + @override + ConnectFailure rebuild(void Function(ConnectFailureBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ConnectFailureBuilder toBuilder() => + new ConnectFailureBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ConnectFailure && + tabId == other.tabId && + reason == other.reason; + } + + @override + int get hashCode { + return $jf($jc($jc(0, tabId.hashCode), reason.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'ConnectFailure') + ..add('tabId', tabId) + ..add('reason', reason)) + .toString(); + } +} + +class ConnectFailureBuilder + implements Builder { + _$ConnectFailure? _$v; + + int? _tabId; + int? get tabId => _$this._tabId; + set tabId(int? tabId) => _$this._tabId = tabId; + + String? _reason; + String? get reason => _$this._reason; + set reason(String? reason) => _$this._reason = reason; + + ConnectFailureBuilder(); + + ConnectFailureBuilder get _$this { + final $v = _$v; + if ($v != null) { + _tabId = $v.tabId; + _reason = $v.reason; + _$v = null; + } + return this; + } + + @override + void replace(ConnectFailure other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$ConnectFailure; + } + + @override + void update(void Function(ConnectFailureBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + ConnectFailure build() => _build(); + + _$ConnectFailure _build() { + final _$result = _$v ?? + new _$ConnectFailure._( + tabId: BuiltValueNullFieldError.checkNotNull( + tabId, r'ConnectFailure', 'tabId'), + reason: reason); + replace(_$result); + return _$result; + } +} + class _$DevToolsOpener extends DevToolsOpener { @override final bool newWindow; @@ -133,4 +386,203 @@ class DevToolsOpenerBuilder } } +class _$DevToolsUrl extends DevToolsUrl { + @override + final int tabId; + @override + final String url; + + factory _$DevToolsUrl([void Function(DevToolsUrlBuilder)? updates]) => + (new DevToolsUrlBuilder()..update(updates))._build(); + + _$DevToolsUrl._({required this.tabId, required this.url}) : super._() { + BuiltValueNullFieldError.checkNotNull(tabId, r'DevToolsUrl', 'tabId'); + BuiltValueNullFieldError.checkNotNull(url, r'DevToolsUrl', 'url'); + } + + @override + DevToolsUrl rebuild(void Function(DevToolsUrlBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DevToolsUrlBuilder toBuilder() => new DevToolsUrlBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DevToolsUrl && tabId == other.tabId && url == other.url; + } + + @override + int get hashCode { + return $jf($jc($jc(0, tabId.hashCode), url.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DevToolsUrl') + ..add('tabId', tabId) + ..add('url', url)) + .toString(); + } +} + +class DevToolsUrlBuilder implements Builder { + _$DevToolsUrl? _$v; + + int? _tabId; + int? get tabId => _$this._tabId; + set tabId(int? tabId) => _$this._tabId = tabId; + + String? _url; + String? get url => _$this._url; + set url(String? url) => _$this._url = url; + + DevToolsUrlBuilder(); + + DevToolsUrlBuilder get _$this { + final $v = _$v; + if ($v != null) { + _tabId = $v.tabId; + _url = $v.url; + _$v = null; + } + return this; + } + + @override + void replace(DevToolsUrl other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$DevToolsUrl; + } + + @override + void update(void Function(DevToolsUrlBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DevToolsUrl build() => _build(); + + _$DevToolsUrl _build() { + final _$result = _$v ?? + new _$DevToolsUrl._( + tabId: BuiltValueNullFieldError.checkNotNull( + tabId, r'DevToolsUrl', 'tabId'), + url: BuiltValueNullFieldError.checkNotNull( + url, r'DevToolsUrl', 'url')); + replace(_$result); + return _$result; + } +} + +class _$DebugStateChange extends DebugStateChange { + @override + final int tabId; + @override + final String newState; + @override + final String? reason; + + factory _$DebugStateChange( + [void Function(DebugStateChangeBuilder)? updates]) => + (new DebugStateChangeBuilder()..update(updates))._build(); + + _$DebugStateChange._( + {required this.tabId, required this.newState, this.reason}) + : super._() { + BuiltValueNullFieldError.checkNotNull(tabId, r'DebugStateChange', 'tabId'); + BuiltValueNullFieldError.checkNotNull( + newState, r'DebugStateChange', 'newState'); + } + + @override + DebugStateChange rebuild(void Function(DebugStateChangeBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DebugStateChangeBuilder toBuilder() => + new DebugStateChangeBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DebugStateChange && + tabId == other.tabId && + newState == other.newState && + reason == other.reason; + } + + @override + int get hashCode { + return $jf( + $jc($jc($jc(0, tabId.hashCode), newState.hashCode), reason.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DebugStateChange') + ..add('tabId', tabId) + ..add('newState', newState) + ..add('reason', reason)) + .toString(); + } +} + +class DebugStateChangeBuilder + implements Builder { + _$DebugStateChange? _$v; + + int? _tabId; + int? get tabId => _$this._tabId; + set tabId(int? tabId) => _$this._tabId = tabId; + + String? _newState; + String? get newState => _$this._newState; + set newState(String? newState) => _$this._newState = newState; + + String? _reason; + String? get reason => _$this._reason; + set reason(String? reason) => _$this._reason = reason; + + DebugStateChangeBuilder(); + + DebugStateChangeBuilder get _$this { + final $v = _$v; + if ($v != null) { + _tabId = $v.tabId; + _newState = $v.newState; + _reason = $v.reason; + _$v = null; + } + return this; + } + + @override + void replace(DebugStateChange other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$DebugStateChange; + } + + @override + void update(void Function(DebugStateChangeBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DebugStateChange build() => _build(); + + _$DebugStateChange _build() { + final _$result = _$v ?? + new _$DebugStateChange._( + tabId: BuiltValueNullFieldError.checkNotNull( + tabId, r'DebugStateChange', 'tabId'), + newState: BuiltValueNullFieldError.checkNotNull( + newState, r'DebugStateChange', 'newState'), + reason: reason); + replace(_$result); + return _$result; + } +} + // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,no_leading_underscores_for_local_identifiers,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new,unnecessary_lambdas diff --git a/dwds/debug_extension_mv3/web/debug_session.dart b/dwds/debug_extension_mv3/web/debug_session.dart index 4e265c392..02977643f 100644 --- a/dwds/debug_extension_mv3/web/debug_session.dart +++ b/dwds/debug_extension_mv3/web/debug_session.dart @@ -27,6 +27,7 @@ import 'chrome_api.dart'; import 'data_serializers.dart'; import 'data_types.dart'; import 'logger.dart'; +import 'messaging.dart'; import 'storage.dart'; import 'utils.dart'; import 'web_api.dart'; @@ -41,13 +42,44 @@ const _devToolsAlreadyOpenedAlert = 'DevTools is already opened on a different window.'; final _debugSessions = <_DebugSession>[]; +final _tabIdToTrigger = {}; + +enum DetachReason { + canceledByUser, + connectionErrorEvent, + connectionDoneEvent, + devToolsTabClosed, + navigatedAwayFromApp, + unknown; + + factory DetachReason.fromString(String value) { + return DetachReason.values.byName(value); + } +} + +enum ConnectFailureReason { + authentication, + noDartApp, + timeout, + unknown; + + factory ConnectFailureReason.fromString(String value) { + return ConnectFailureReason.values.byName(value); + } +} enum TabType { dartApp, devTools, } -void attachDebugger(int dartAppTabId) { +enum Trigger { + extensionPanel, + extensionIcon, +} + +void attachDebugger(int dartAppTabId, {required Trigger trigger}) { + _tabIdToTrigger[dartAppTabId] = trigger; _registerDebugEventListeners(); chrome.debugger.attach( Debuggee(tabId: dartAppTabId), @@ -61,7 +93,7 @@ void attachDebugger(int dartAppTabId) { void detachDebugger( int tabId, { required TabType type, - required String reason, + required DetachReason reason, }) async { final debugSession = _debugSessionForTab(tabId, type: type); if (debugSession == null) return; @@ -79,12 +111,17 @@ void detachDebugger( void _registerDebugEventListeners() { chrome.debugger.onEvent.addListener(allowInterop(_onDebuggerEvent)); - chrome.debugger.onDetach.addListener(allowInterop(_handleDebuggerDetach)); + chrome.debugger.onDetach.addListener(allowInterop( + (source, _) => _handleDebuggerDetach( + source, + DetachReason.canceledByUser, + ), + )); chrome.tabs.onRemoved.addListener(allowInterop( (tabId, _) => detachDebugger( tabId, type: TabType.devTools, - reason: 'DevTools tab closed.', + reason: DetachReason.devToolsTabClosed, ), )); } @@ -141,6 +178,8 @@ Future _maybeConnectToDwds(int tabId, Object? params) async { ); if (!connected) { debugWarn('Failed to connect to DWDS for $contextOrigin.'); + sendConnectFailureMessage(ConnectFailureReason.unknown, + dartAppTabId: tabId); } } @@ -158,22 +197,25 @@ Future _connectToDwds({ final client = uri.isScheme('ws') || uri.isScheme('wss') ? WebSocketClient(WebSocketChannel.connect(uri)) : SseSocketClient(SseClient(uri.toString())); + final trigger = _tabIdToTrigger[dartAppTabId]; final debugSession = _DebugSession( client: client, appTabId: dartAppTabId, + trigger: trigger, onIncoming: (data) => _routeDwdsEvent(data, client, dartAppTabId), onDone: () { detachDebugger( dartAppTabId, type: TabType.dartApp, - reason: 'Done event in DWDS stream.', + reason: DetachReason.connectionDoneEvent, ); }, onError: (err) { + debugWarn('Connection error: $err', verbose: true); detachDebugger( dartAppTabId, type: TabType.dartApp, - reason: 'Error in DWDS stream: $err', + reason: DetachReason.connectionErrorEvent, ); }, cancelOnError: true, @@ -200,7 +242,7 @@ void _routeDwdsEvent(String eventData, SocketClient client, int tabId) { // TODO(elliette): Forward to external extensions. break; case 'dwds.devtoolsUri': - _openDevTools(message.params, dartTabId: tabId); + _openDevTools(message.params, dartAppTabId: tabId); break; } } @@ -243,26 +285,31 @@ void _forwardChromeDebuggerEventToDwds( } } -void _openDevTools(String devToolsUrl, {required int dartTabId}) async { +void _openDevTools(String devToolsUrl, {required int dartAppTabId}) async { if (devToolsUrl.isEmpty) { debugError('DevTools URL is empty.'); return; } - final debugSession = _debugSessionForTab(dartTabId, type: TabType.dartApp); + final debugSession = _debugSessionForTab(dartAppTabId, type: TabType.dartApp); if (debugSession == null) { debugError('Debug session not found.'); return; } - final devToolsOpener = await fetchStorageObject( - type: StorageObject.devToolsOpener); - final devToolsTab = await createTab( - devToolsUrl, - inNewWindow: devToolsOpener?.newWindow ?? false, - ); - debugSession.devToolsTabId = devToolsTab.id; + // Send the DevTools URL to the extension panels: + _sendDevToolsUrlMessage(devToolsUrl, dartAppTabId: dartAppTabId); + // Open a separate tab / window if triggered through the extension icon: + if (debugSession.trigger == Trigger.extensionIcon) { + final devToolsOpener = await fetchStorageObject( + type: StorageObject.devToolsOpener); + final devToolsTab = await createTab( + devToolsUrl, + inNewWindow: devToolsOpener?.newWindow ?? false, + ); + debugSession.devToolsTabId = devToolsTab.id; + } } -void _handleDebuggerDetach(Debuggee source, String reason) async { +void _handleDebuggerDetach(Debuggee source, DetachReason reason) async { debugLog( 'Debugger detached due to: $reason', verbose: true, @@ -272,8 +319,11 @@ void _handleDebuggerDetach(Debuggee source, String reason) async { 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); // Maybe close the associated DevTools tab as well: final devToolsTabId = debugSession.devToolsTabId; + if (devToolsTabId == null) return; final devToolsTab = await getTab(devToolsTabId); if (devToolsTab != null) { debugLog('Closing DevTools tab...'); @@ -297,6 +347,43 @@ void _removeDebugSession(_DebugSession debugSession) { } } +void sendConnectFailureMessage(ConnectFailureReason reason, + {required int dartAppTabId}) async { + final json = jsonEncode(serializers.serialize(ConnectFailure((b) => b + ..tabId = dartAppTabId + ..reason = reason.name))); + sendRuntimeMessage( + type: MessageType.connectFailure, + body: json, + sender: Script.background, + recipient: Script.debuggerPanel); +} + +void _sendDevToolsUrlMessage(String devToolsUrl, + {required int dartAppTabId}) async { + final json = jsonEncode(serializers.serialize(DevToolsUrl((b) => b + ..tabId = dartAppTabId + ..url = devToolsUrl))); + sendRuntimeMessage( + type: MessageType.devToolsUrl, + body: json, + sender: Script.background, + recipient: Script.debuggerPanel); +} + +void _sendStopDebuggingMessage(DetachReason reason, + {required int dartAppTabId}) async { + final json = jsonEncode(serializers.serialize(DebugStateChange((b) => b + ..tabId = dartAppTabId + ..reason = reason.name + ..newState = DebugStateChange.stopDebugging))); + sendRuntimeMessage( + type: MessageType.debugStateChange, + body: json, + sender: Script.background, + recipient: Script.debuggerPanel); +} + _DebugSession? _debugSessionForTab(tabId, {required TabType type}) { switch (type) { case TabType.dartApp: @@ -330,8 +417,11 @@ class _DebugSession { // The tab ID that contains the running Dart application. final int appTabId; + // What triggered the debug session (debugger panel, extension icon, etc.) + final Trigger? trigger; + // The tab ID that contains the corresponding Dart DevTools. - late final int devToolsTabId; + late final int? devToolsTabId; // Socket client for communication with dwds extension backend. late final SocketClient _socketClient; @@ -347,6 +437,7 @@ class _DebugSession { _DebugSession({ required client, required this.appTabId, + required this.trigger, required void Function(String data) onIncoming, required void Function() onDone, required void Function(dynamic error) onError, diff --git a/dwds/debug_extension_mv3/web/detector.dart b/dwds/debug_extension_mv3/web/detector.dart index fdd3fcb35..0b9357844 100644 --- a/dwds/debug_extension_mv3/web/detector.dart +++ b/dwds/debug_extension_mv3/web/detector.dart @@ -9,7 +9,6 @@ import 'dart:html'; import 'dart:js_util'; import 'package:js/js.dart'; -import 'chrome_api.dart'; import 'logger.dart'; import 'messaging.dart'; @@ -37,16 +36,10 @@ void _sendMessageToBackgroundScript({ required MessageType type, required String body, }) { - final message = Message( - to: Script.background, - from: Script.detector, + sendRuntimeMessage( type: type, body: body, - ); - chrome.runtime.sendMessage( - /*id*/ null, - message.toJSON(), - /*options*/ null, - /*callback*/ null, + sender: Script.detector, + recipient: Script.background, ); } diff --git a/dwds/debug_extension_mv3/web/messaging.dart b/dwds/debug_extension_mv3/web/messaging.dart index 8041e4ade..5aa551ab8 100644 --- a/dwds/debug_extension_mv3/web/messaging.dart +++ b/dwds/debug_extension_mv3/web/messaging.dart @@ -7,13 +7,15 @@ library messaging; import 'dart:convert'; -import 'package:dwds/data/serializers.dart'; import 'package:js/js.dart'; +import 'data_serializers.dart'; +import 'chrome_api.dart'; import 'logger.dart'; enum Script { background, + debuggerPanel, detector; factory Script.fromString(String value) { @@ -22,7 +24,10 @@ enum Script { } enum MessageType { - debugInfo; + connectFailure, + debugInfo, + debugStateChange, + devToolsUrl; factory MessageType.fromString(String value) { return MessageType.values.byName(value); @@ -89,3 +94,22 @@ void interceptMessage({ 'Error intercepting $expectedType from $expectedSender to $expectedRecipient: $error'); } } + +void sendRuntimeMessage( + {required MessageType type, + required String body, + required Script sender, + required Script recipient}) { + final message = Message( + to: recipient, + from: sender, + type: type, + body: body, + ); + chrome.runtime.sendMessage( + /*id*/ null, + message.toJSON(), + /*options*/ null, + /*callback*/ null, + ); +} diff --git a/dwds/debug_extension_mv3/web/panel.dart b/dwds/debug_extension_mv3/web/panel.dart new file mode 100644 index 000000000..cd2067649 --- /dev/null +++ b/dwds/debug_extension_mv3/web/panel.dart @@ -0,0 +1,259 @@ +// 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 panel; + +import 'dart:convert'; +import 'dart:html'; + +import 'package:dwds/data/debug_info.dart'; +import 'package:js/js.dart'; + +import 'chrome_api.dart'; +import 'data_serializers.dart'; +import 'data_types.dart'; +import 'debug_session.dart'; +import 'logger.dart'; +import 'messaging.dart'; +import 'storage.dart'; + +bool connecting = false; +String devToolsBackgroundColor = darkColor; +bool isDartApp = true; + +const bugLinkId = 'bugLink'; +const darkColor = '202125'; +const darkThemeClass = 'dark-theme'; +const hiddenClass = 'hidden'; +const iframeContainerId = 'iframeContainer'; +const landingPageId = 'landingPage'; +const launchDebugConnectionButtonId = 'launchDebugConnectionButton'; +const lightColor = 'ffffff'; +const lightThemeClass = 'light-theme'; +const loadingSpinnerId = 'loadingSpinner'; +const panelAttribute = 'data-panel'; +const panelBodyId = 'panelBody'; +const showClass = 'show'; +const warningBannerId = 'warningBanner'; +const warningMsgId = 'warningMsg'; + +void main() { + _registerListeners(); + _setColorThemeToMatchChromeDevTools(); + _maybeUpdateFileABugLink(); +} + +void _registerListeners() { + chrome.storage.onChanged.addListener(allowInterop(_handleDebugInfoChanges)); + chrome.runtime.onMessage.addListener(allowInterop(_handleRuntimeMessages)); + final launchDebugConnectionButton = + document.getElementById(launchDebugConnectionButtonId) as ButtonElement; + launchDebugConnectionButton.addEventListener('click', _launchDebugConnection); +} + +void _handleRuntimeMessages( + dynamic jsRequest, MessageSender sender, Function sendResponse) async { + if (jsRequest is! String) return; + final tabId = chrome.devtools.inspectedWindow.tabId; + interceptMessage( + message: jsRequest, + expectedType: MessageType.devToolsUrl, + expectedSender: Script.background, + expectedRecipient: Script.debuggerPanel, + messageHandler: (DevToolsUrl devToolsUrl) async { + if (devToolsUrl.tabId != tabId) { + debugWarn( + 'Received DevTools URL, but Dart app tab does not match current tab.'); + return; + } + connecting = false; + _injectDevToolsIframe(devToolsUrl.url); + }); + + interceptMessage( + message: jsRequest, + expectedType: MessageType.debugStateChange, + expectedSender: Script.background, + expectedRecipient: Script.debuggerPanel, + messageHandler: (DebugStateChange debugStateChange) async { + if (debugStateChange.tabId != tabId) { + debugWarn( + 'Received debug state change request, but Dart app tab does not match current tab.'); + return; + } + if (debugStateChange.newState == DebugStateChange.stopDebugging) { + _handleDebugConnectionLost(debugStateChange.reason); + } + }); + + interceptMessage( + message: jsRequest, + expectedType: MessageType.connectFailure, + expectedSender: Script.background, + expectedRecipient: Script.debuggerPanel, + messageHandler: (ConnectFailure connectFailure) async { + debugLog( + 'Received connect failure for ${connectFailure.tabId} vs $tabId'); + if (connectFailure.tabId != tabId) { + return; + } + connecting = false; + _handleConnectFailure( + ConnectFailureReason.fromString(connectFailure.reason ?? 'unknown'), + ); + }); +} + +void _handleDebugInfoChanges(Object _, String storageArea) async { + if (storageArea != 'session') return; + final debugInfo = await fetchStorageObject( + type: StorageObject.debugInfo, + tabId: chrome.devtools.inspectedWindow.tabId, + ); + if (debugInfo == null && isDartApp) { + isDartApp = false; + _showWarningBanner('Dart app is no longer open.'); + } + if (debugInfo != null && !isDartApp) { + isDartApp = true; + _hideWarningBanner(); + } +} + +void _maybeUpdateFileABugLink() async { + final debugInfo = await fetchStorageObject( + type: StorageObject.debugInfo, + tabId: chrome.devtools.inspectedWindow.tabId, + ); + final isInternal = debugInfo?.isInternalBuild ?? false; + if (isInternal) { + final bugLink = document.getElementById(bugLinkId); + if (bugLink == null) return; + bugLink.setAttribute( + 'href', 'http://b/issues/new?component=775375&template=1369639'); + } +} + +void _setColorThemeToMatchChromeDevTools() async { + final chromeTheme = chrome.devtools.panels.themeName; + final panelBody = document.getElementById(panelBodyId); + if (chromeTheme == 'dark') { + devToolsBackgroundColor = darkColor; + _updateColorThemeForElement(panelBody, isDarkTheme: true); + } else { + devToolsBackgroundColor = lightColor; + _updateColorThemeForElement(panelBody, isDarkTheme: false); + } +} + +void _updateColorThemeForElement( + Element? element, { + required bool isDarkTheme, +}) { + if (element == null) return; + final classToRemove = isDarkTheme ? lightThemeClass : darkThemeClass; + if (element.classes.contains(classToRemove)) { + element.classes.remove(classToRemove); + final classToAdd = isDarkTheme ? darkThemeClass : lightThemeClass; + element.classes.add(classToAdd); + } +} + +void _handleDebugConnectionLost(String? reason) { + final detachReason = DetachReason.fromString(reason ?? 'unknown'); + _removeDevToolsIframe(); + _updateElementVisibility(landingPageId, visible: true); + if (detachReason != DetachReason.canceledByUser) { + _showWarningBanner('Lost connection.'); + } +} + +void _handleConnectFailure(ConnectFailureReason reason) { + switch (reason) { + case ConnectFailureReason.authentication: + _showWarningBanner('Please re-authenticate and try again.'); + break; + case ConnectFailureReason.noDartApp: + _showWarningBanner('No Dart app detected.'); + break; + case ConnectFailureReason.timeout: + _showWarningBanner('Connection timed out.'); + break; + default: + _showWarningBanner('Failed to connect, please try again.'); + } + _updateElementVisibility(launchDebugConnectionButtonId, visible: true); + _updateElementVisibility(loadingSpinnerId, visible: false); +} + +void _showWarningBanner(String message) { + final warningMsg = document.getElementById(warningMsgId); + warningMsg?.setInnerHtml(message); + print(warningMsg); + final warningBanner = document.getElementById(warningBannerId); + warningBanner?.classes.add(showClass); +} + +void _hideWarningBanner() { + final warningBanner = document.getElementById(warningBannerId); + warningBanner?.classes.remove(showClass); +} + +void _launchDebugConnection(Event _) async { + _updateElementVisibility(launchDebugConnectionButtonId, visible: false); + _updateElementVisibility(loadingSpinnerId, visible: true); + final dartAppTabId = chrome.devtools.inspectedWindow.tabId; + final json = jsonEncode(serializers.serialize(DebugStateChange((b) => b + ..tabId = dartAppTabId + ..newState = DebugStateChange.startDebugging))); + sendRuntimeMessage( + type: MessageType.debugStateChange, + body: json, + sender: Script.debuggerPanel, + recipient: Script.background); + _maybeHandleConnectionTimeout(); +} + +void _maybeHandleConnectionTimeout() async { + connecting = true; + await Future.delayed(Duration(seconds: 10)); + if (connecting == true) { + _handleConnectFailure(ConnectFailureReason.timeout); + } +} + +void _injectDevToolsIframe(String devToolsUrl) { + final iframeContainer = document.getElementById(iframeContainerId); + if (iframeContainer == null) return; + final panelBody = document.getElementById(panelBodyId); + final panelType = panelBody?.getAttribute(panelAttribute) ?? 'debugger'; + final iframe = document.createElement('iframe'); + iframe.setAttribute( + 'src', + '$devToolsUrl&embed=true&page=$panelType&backgroundColor=$devToolsBackgroundColor', + ); + _hideWarningBanner(); + _updateElementVisibility(landingPageId, visible: false); + _updateElementVisibility(loadingSpinnerId, visible: false); + _updateElementVisibility(launchDebugConnectionButtonId, visible: true); + iframeContainer.append(iframe); +} + +void _removeDevToolsIframe() { + final iframeContainer = document.getElementById(iframeContainerId); + final iframe = iframeContainer?.firstChild; + if (iframe == null) return; + iframe.remove(); +} + +void _updateElementVisibility(String elementId, {required bool visible}) { + final element = document.getElementById(elementId); + if (element == null) return; + if (visible) { + element.classes.remove(hiddenClass); + } else { + element.classes.add(hiddenClass); + } +} diff --git a/dwds/debug_extension_mv3/web/static_assets/debugger_panel.html b/dwds/debug_extension_mv3/web/static_assets/debugger_panel.html index d3055e16c..dc3cd3c52 100644 --- a/dwds/debug_extension_mv3/web/static_assets/debugger_panel.html +++ b/dwds/debug_extension_mv3/web/static_assets/debugger_panel.html @@ -1,46 +1,72 @@ + + + + + - - - - - - - - -
+ +
+
+
+ Dart Debugger +
+
+
-
-
- Dart Debugger -
+
+
+
+
+
+

+ Before debugging, please disable focus on the + Sources panel for breakpoints: +

+ Settings > Preferences > Sources > uncheck "Focus Sources panel + when triggering a breakpoint"
-
- -
-
- -
-
-
-

Before debugging, please disable focus on the Sources panel for breakpoints: -

- Settings > Preferences > Sources > uncheck "Focus Sources panel when triggering a breakpoint" -
-
- -
-
- +
+ +
+
- +
- +
+
+ Error. If something is broken, please + file a bug. +
+ + + diff --git a/dwds/debug_extension_mv3/web/static_assets/inspector_panel.html b/dwds/debug_extension_mv3/web/static_assets/inspector_panel.html index f5ef32f77..f9a9f9109 100644 --- a/dwds/debug_extension_mv3/web/static_assets/inspector_panel.html +++ b/dwds/debug_extension_mv3/web/static_assets/inspector_panel.html @@ -1,44 +1,62 @@ - - - - - - - - - + + + + + + +
- -
-
- Flutter Inspector -
+
+
+ Flutter Inspector +
+
+
+ +
+
+
+
+
+ The Flutter Inspector allows you to inspect the widgets in your + Flutter app.
-
- -
-
- -
-
-
- The Flutter Inspector allows you to inspect the widgets in your Flutter app. -
-
- -
-
- +
+
+
+
+ +
+
+ Error. If something is broken, please + file a bug.
- - + + + diff --git a/dwds/debug_extension_mv3/web/static_assets/styles.css b/dwds/debug_extension_mv3/web/static_assets/styles.css index acc0d7c99..f5da6a7f6 100644 --- a/dwds/debug_extension_mv3/web/static_assets/styles.css +++ b/dwds/debug_extension_mv3/web/static_assets/styles.css @@ -1,3 +1,12 @@ +iframe { + border: 0pt none; + height: 100%; + left: 0px; + position: absolute; + top: 0px; + width: 100%; +} + .dark-theme { background-color: #262626; color: #eeeeee; @@ -69,16 +78,19 @@ h6 { border-radius: 2px; bottom: 0px; color: #eeeeee; - left: 50%; - margin-left: -125px; padding: 16px; position: fixed; text-align: center; visibility: hidden; - width: 250px; + width: 100%; z-index: 1; } +.snackbar > a { + font-weight: bold; + color: #eeeeee; +} + .snackbar--info { background-color: #303030; } @@ -88,11 +100,15 @@ h6 { } .show { - -webkit-animation: fadein 0.5s, fadeout 0.5s 2.5s; - animation: fadein 0.5s, fadeout 0.5s 2.5s; + -webkit-animation: fadein 0.5s; + animation: fadein 0.5s; visibility: visible; } +.hidden { + visibility: none; +} + @-webkit-keyframes fadein { from { bottom: 0; diff --git a/dwds/test/puppeteer/extension_test.dart b/dwds/test/puppeteer/extension_test.dart index 66a62b15d..711ef8426 100644 --- a/dwds/test/puppeteer/extension_test.dart +++ b/dwds/test/puppeteer/extension_test.dart @@ -296,6 +296,8 @@ void main() async { }); group('connected to an internally-built', () { + late Page appTab; + for (var isFlutterApp in [true, false]) { group(isFlutterApp ? 'Flutter app:' : 'Dart app:', () { late Browser browser; @@ -314,6 +316,17 @@ void main() async { worker = await getServiceWorker(browser); }); + setUp(() async { + for (final page in await browser.pages) { + await page.close(); + } + appTab = await navigateToPage( + browser, + url: context.appUrl, + isNew: true, + ); + }); + tearDown(() async { await workerEvalDelay(); await worker.evaluate(_clearStorageJs()); @@ -327,9 +340,6 @@ void main() async { 'isFlutterApp=$isFlutterApp and isInternalBuild=true are saved in storage', () async { final appUrl = context.appUrl; - // Navigate to the Dart app: - final appTab = - await navigateToPage(browser, url: appUrl, isNew: true); // Verify that we have debug info for the Dart app: await workerEvalDelay(); final appTabId = await _getTabId(appUrl, worker: worker); @@ -341,29 +351,21 @@ void main() async { ); expect(debugInfo.isInternalBuild, equals(true)); expect(debugInfo.isFlutterApp, equals(isFlutterApp)); - await appTab.close(); }); test('the correct extension panels are added to Chrome DevTools', () async { - final appUrl = context.appUrl; - // This is the blank page automatically opened by Chrome: - final blankTab = await navigateToPage(browser, url: 'about:blank'); - // Navigate to the Dart app: - await blankTab.goto(appUrl, wait: Until.domContentLoaded); - final appTab = blankTab; - await appTab.bringToFront(); - final chromeDevToolsTarget = browser.targets.firstWhere( - (target) => target.url.startsWith('devtools://devtools')); - chromeDevToolsTarget.type = 'page'; - final chromeDevToolsPage = await chromeDevToolsTarget.page; + final chromeDevToolsPage = await _getChromeDevToolsPage(browser); // There are no hooks for when a panel is added to Chrome DevTools, // therefore we rely on a slight delay: await Future.delayed(Duration(seconds: 1)); if (isFlutterApp) { _tabLeft(chromeDevToolsPage); - final inspectorPanelElement = - await _getPanelElement(browser, panel: Panel.inspector); + final inspectorPanelElement = await _getPanelElement( + browser, + panel: Panel.inspector, + elementSelector: '#panelBody', + ); expect(inspectorPanelElement, isNotNull); await _takeScreenshot( chromeDevToolsPage, @@ -371,8 +373,11 @@ void main() async { ); } _tabLeft(chromeDevToolsPage); - final debuggerPanelElement = - await _getPanelElement(browser, panel: Panel.debugger); + final debuggerPanelElement = await _getPanelElement( + browser, + panel: Panel.debugger, + elementSelector: '#panelBody', + ); expect(debuggerPanelElement, isNotNull); await _takeScreenshot( chromeDevToolsPage, @@ -380,25 +385,108 @@ void main() async { 'debuggerPanelLandingPage_${isFlutterApp ? 'flutterApp' : 'dartApp'}', ); }); + + test('Dart DevTools is embedded for debug session lifetime', + () async { + final chromeDevToolsPage = await _getChromeDevToolsPage(browser); + // There are no hooks for when a panel is added to Chrome DevTools, + // therefore we rely on a slight delay: + await Future.delayed(Duration(seconds: 1)); + // Navigate to the Dart Debugger panel: + _tabLeft(chromeDevToolsPage); + if (isFlutterApp) { + _tabLeft(chromeDevToolsPage); + } + await _clickLaunchButton( + browser, + panel: Panel.debugger, + ); + // Expect the Dart DevTools IFRAME to be added: + final devToolsUrlFragment = + 'ide=ChromeDevTools&embed=true&page=debugger'; + final iframeTarget = await browser.waitForTarget( + (target) => target.url.contains(devToolsUrlFragment), + ); + var iframeDestroyed = false; + unawaited(iframeTarget.onClose.whenComplete(() { + iframeDestroyed = true; + })); + // TODO(elliette): Figure out how to reliably verify that Dart + // DevTools has loaded, and take screenshot. + expect(iframeTarget, isNotNull); + // Navigate away from the Dart app: + await appTab.goto('https://dart.dev/', + wait: Until.domContentLoaded); + // Expect the Dart DevTools IFRAME to be destroyed: + expect(iframeDestroyed, isTrue); + // Expect the connection lost banner to be visible: + final connectionLostBanner = await _getPanelElement( + browser, + panel: Panel.debugger, + elementSelector: '#warningBanner', + ); + expect(connectionLostBanner, isNotNull); + await _takeScreenshot( + chromeDevToolsPage, + screenshotName: + 'debuggerPanelDisconnected_${isFlutterApp ? 'flutterApp' : 'dartApp'}', + ); + }); }); } }); }); } -Future _getPanelElement( +Future _clickLaunchButton( + Browser browser, { + required Panel panel, +}) async { + try { + final launchButton = await _getPanelElement( + browser, + panel: panel, + elementSelector: '#launchDebugConnectionButton', + ); + // Slight delay to guarantee button is clickable: + await Future.delayed(Duration(seconds: 1)); + await launchButton!.click(); + return true; + } catch (_) { + return false; + } +} + +Future _getChromeDevToolsPage(Browser browser) async { + final chromeDevToolsTarget = browser.targets + .firstWhere((target) => target.url.startsWith('devtools://devtools')); + chromeDevToolsTarget.type = 'page'; + return await chromeDevToolsTarget.page; +} + +Future _getPanelPage( Browser browser, { required Panel panel, }) async { final panelName = panel == Panel.inspector ? 'inspector_panel' : 'debugger_panel'; - final panelTarget = + var panelTarget = browser.targets + .firstWhereOrNull((target) => target.url.contains(panelName)); + panelTarget ??= await browser.waitForTarget((target) => target.url.contains(panelName)); panelTarget.type = 'page'; - final panelPage = await panelTarget.page; + return await panelTarget.page; +} + +Future _getPanelElement( + Browser browser, { + required Panel panel, + required String elementSelector, +}) async { + final panelPage = await _getPanelPage(browser, panel: panel); final frames = panelPage.frames; final mainFrame = frames[0]; - final panelElement = await mainFrame.$OrNull('#panelBody'); + final panelElement = await mainFrame.$OrNull(elementSelector); return panelElement; } diff --git a/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_dartApp.png b/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_dartApp.png new file mode 100644 index 000000000..ddc23f3c6 Binary files /dev/null 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 new file mode 100644 index 000000000..6a3bd6c0a Binary files /dev/null and b/dwds/test/puppeteer/test_images/debuggerPanelDisconnected_flutterApp.png differ