blob: 210a498f3422b2382f26ea19735882795b683804 [file] [log] [blame]
// Copyright (c) 2019, 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 background;
import 'dart:async';
import 'dart:convert';
import 'dart:html';
import 'package:built_collection/built_collection.dart';
// TODO(elliette): The format_and_analyze Github actions complains about this
// import because it is looking for it in DWDS' pubspec, not in the extension's
// pubspec. We should fix the Github action and / or unnest the extension from
// the DWDS directory.
// ignore: depend_on_referenced_packages
import 'package:collection/collection.dart' show IterableExtension;
import 'package:dwds/data/devtools_request.dart';
import 'package:dwds/data/extension_request.dart';
import 'package:dwds/data/serializers.dart';
import 'package:dwds/shared/batched_stream.dart';
import 'package:dwds/src/sockets.dart';
import 'package:js/js.dart';
import 'package:js/js_util.dart' as js_util;
import 'package:pub_semver/pub_semver.dart';
import 'package:sse/client/sse_client.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'chrome_api.dart';
import 'web_api.dart';
const _notADartAppAlert = 'No Dart application detected.'
' Are you trying to debug an application that includes a Chrome hosted app'
' (an application listed in chrome://apps)? If so, debugging is disabled.'
' You can fix this by removing the application from chrome://apps. Please'
' see https://bugs.chromium.org/p/chromium/issues/detail?id=885025#c11.';
// Extensions allowed for cross-extension communication.
//
// This is only used to forward outgoing messages, as incoming messages are
// restricted by `externally_connectable` in the extension manijest.json.
const _allowedExtensions = {
'nbkbficgbembimioedhceniahniffgpl', // AngularDart DevTools
};
// Events forwarded to allowed extensions.
const _allowedEvents = {'Overlay.inspectNodeRequested'};
// Map of Chrome tab ID to encoded vm service protocol URI.
final _tabIdToEncodedUri = <int, String>{};
// Map of Chrome tab ID to warnings for that tab.
final _tabIdToWarning = <int, String>{};
final _debuggableTabs = <int>{};
final _tabsToAttach = <Tab>{};
final _debugSessions = <DebugSession>[];
final _devToolsPanelsNotifier =
Notifier<List<DevToolsPanel>>(<DevToolsPanel>[]);
// Keeps track of the most recent Dart tab that was opened. This is a heuristic
// to let us guess which tab the user is trying to debug if they start debugging
// from the Chrome DevTools Dart panel (which doesn't have a tab ID).
Tab? _mostRecentDartTab;
// Keeps track of how debugging was triggered. This lets us know if we should
// open Dart DevTools in a new window/tab or embed it in Chrome DevTools.
DebuggerTrigger? _debuggerTrigger;
class DebugSession {
// The tab ID that contains the running Dart application.
final int appTabId;
// The Dart app ID.
final String appId;
// The tab ID that contains the corresponding Dart DevTools.
int? devtoolsTabId;
// Socket client for communication with dwds extension backend.
final SocketClient _socketClient;
// How often to send batched events.
static const int _batchDelayMilliseconds = 1000;
// Collect events into batches to be send periodically to the server.
final _batchController =
BatchedStreamController<ExtensionEvent>(delay: _batchDelayMilliseconds);
late final StreamSubscription<List<ExtensionEvent>> _batchSubscription;
DebugSession(this._socketClient, this.appTabId, this.appId) {
// Collect extension events and send them periodically to the server.
_batchSubscription = _batchController.stream.listen((events) {
_socketClient.sink.add(jsonEncode(serializers.serialize(BatchedEvents(
(b) => b.events = ListBuilder<ExtensionEvent>(events)))));
});
}
void sendEvent(ExtensionEvent event) {
_socketClient.sink.add(jsonEncode(serializers.serialize(event)));
}
void sendBatchedEvent(ExtensionEvent event) {
_batchController.sink.add(event);
}
void close() {
_socketClient.close();
_batchSubscription.cancel();
_batchController.close();
}
}
class DevToolsPanel {
// The Dart app ID.
final String? appId;
// The Chrome DevTools panel ID.
String? panelId;
// The URI for the embedded Dart DevTools, or an empty string if the debugger
// is disconnected.
String devToolsUri = '';
DevToolsPanel(this.appId);
}
enum DebuggerTrigger { extensionIcon, dartPanel, dwds }
void main() {
// Start debugging when a user clicks the Dart Debug Extension:
chrome.browserAction.onClicked.addListener(allowInterop((_) {
_startDebugging(DebuggerTrigger.extensionIcon);
}));
// Handles any incoming messages from the content scripts (detector, panel).
chrome.runtime.onMessage
.addListener(allowInterop(_handleMessageFromContentScripts));
// Attaches a debug session to the app when the extension receives a
// Runtime.executionContextCreated event from Chrome:
chrome.debugger.onEvent.addListener(allowInterop(_maybeAttachDebugSession));
// When a Dart application tab is closed, detach the corresponding debug
// session:
chrome.tabs.onRemoved
.addListener(allowInterop(_removeAndDetachDebugSessionForTab));
// When a debug session is detached, remove the reference to it:
chrome.debugger.onDetach
.addListener(allowInterop((Debuggee source, String reason) {
_removeDebugSessionForTab(source.tabId);
}));
// Save the tab ID for the opened DevTools.
chrome.tabs.onCreated.addListener(allowInterop(_maybeSaveDevToolsTabId));
// Forward debugger events to the backend if applicable.
chrome.debugger.onEvent.addListener(allowInterop(_filterAndForwardToBackend));
// Maybe update the extension icon when a user clicks the tab:
chrome.tabs.onActivated.addListener(allowInterop((ActiveInfo info) {
_updateIcon();
}));
// Message handler enabling communication with external Chrome extensions:
chrome.runtime.onMessageExternal
.addListener(allowInterop(_handleMessageFromExternalExtensions));
// Message forwarder enabling communication with external Chrome extensions:
chrome.debugger.onEvent
.addListener(allowInterop(_forwardMessageToExternalExtensions));
// Maybe update the extension icon when the window focus changes:
chrome.windows.onFocusChanged.addListener(allowInterop((_) {
_updateIcon();
}));
// Maybe update the extension icon during tab navigation:
chrome.webNavigation.onCommitted
.addListener(allowInterop(_updateIconOnNavigation));
// Notify the panel script controlling the Dart DevTools panel in Chrome
// DevTools of any relevant changes so that it can update accordingly:
final devToolsPanelListener =
Listener<List<DevToolsPanel>>(_notifyPanelScriptOfChanges);
_devToolsPanelsNotifier.addListener(devToolsPanelListener);
/// Everything after this is for testing only.
/// TODO(elliette): Figure out if there is a workaround that would allow us to
/// remove this.
///
/// An automated click on the extension icon is not supported by WebDriver.
/// We initiate a fake click from the `debug_extension_test`
/// after the extension is loaded.
onFakeClick = allowInterop(() {
_startDebugging(DebuggerTrigger.extensionIcon);
});
/// This is how we determine the extension tab to connect to during E2E tests.
isDartDebugExtension = true;
}
// Gets the current tab, then attaches the debugger to it:
void _startDebugging(DebuggerTrigger debuggerTrigger) {
// Set how the debugging request was triggered. This will determine whether we
// launch Dart DevTools in a new Chrome window / tab or in the Chrome DevTools
// Dart panel:
_debuggerTrigger = debuggerTrigger;
final getCurrentTabQuery = QueryInfo(active: true, currentWindow: true);
// Sends commands to debugger attached to the current tab.
// Extracts the extension backend port from the injected JS.
final attachDebuggerToTab = allowInterop(_attachDebuggerToTab);
chrome.tabs.query(getCurrentTabQuery, allowInterop((List tabs) {
if (tabs.isNotEmpty) {
attachDebuggerToTab(tabs.first as Tab);
} else if (_mostRecentDartTab != null) {
attachDebuggerToTab(_mostRecentDartTab!);
} else {
window.alert('''
Could not find a Dart app to start debugging.
The Dart Debug Extension will turn blue when
a Dart application is detected.
''');
}
}));
}
Future<void> _attachDebuggerToTab(Tab currentTab) async {
if (!_debuggableTabs.contains(currentTab.id)) return;
if (_tabIdToWarning.containsKey(currentTab.id)) {
window.alert(_tabIdToWarning[currentTab.id]);
return;
}
chrome.debugger.attach(Debuggee(tabId: currentTab.id), '1.3',
allowInterop(() async {
if (chrome.runtime.lastError != null) {
String alertMessage;
if (chrome.runtime.lastError!.message.contains('Cannot access') ||
chrome.runtime.lastError!.message.contains('Cannot attach')) {
alertMessage = _notADartAppAlert;
} else {
alertMessage = 'DevTools is already opened on a different window.';
}
window.alert(alertMessage);
return;
}
_tabsToAttach.add(currentTab);
chrome.debugger.sendCommand(Debuggee(tabId: currentTab.id),
'Runtime.enable', EmptyParam(), allowInterop((e) {}));
}));
}
void _handleMessageFromContentScripts(
dynamic jsRequest, MessageSender sender, Function sendResponse) {
if (jsRequest == null) return;
final request = jsRequest as Request;
switch (request.sender) {
case 'detector-script':
_maybeMarkTabAsDebuggable(request, sender, sendResponse);
break;
case 'panel-script':
_handleMessageFromPanelScript(request, sender);
break;
}
}
void _handleMessageFromPanelScript(Request request, MessageSender sender) {
switch (request.message) {
case 'devtools-open':
_updateOrCreateDevToolsPanel(request.dartAppId, (panel) {
panel.panelId = sender.id;
});
break;
case 'start-debugging':
_startDebugging(DebuggerTrigger.dartPanel);
break;
}
}
Future<void> _maybeMarkTabAsDebuggable(
Request request, MessageSender sender, Function sendResponse) async {
// Register any warnings for the tab:
if (request.warning != '') {
_tabIdToWarning[sender.tab!.id] = request.warning;
}
_debuggableTabs.add(sender.tab!.id);
_updateIcon();
// TODO(grouma) - We can conditionally auto start debugging here.
// For example: _startDebugging(null);
sendResponse(true);
}
Future<void> _maybeAttachDebugSession(
Debuggee source,
String method,
Object? params,
) async {
// Return early if it's not a Runtime.executionContextCreated event (sent from
// Chrome):
if (method != 'Runtime.executionContextCreated') return;
if (params == null) return;
final context = json.decode(JSON.stringify(params))['context'];
final tab = _tabsToAttach.firstWhereOrNull((tab) => tab.id == source.tabId);
final contextId = context['id'] as int?;
if (tab != null && contextId != null) {
final launchInChromeDevTools =
_debuggerTrigger == DebuggerTrigger.dartPanel;
if (await _tryAttach(contextId, tab, launchInChromeDevTools)) {
_tabsToAttach.remove(tab);
}
}
}
// Tries to remove the debug session for the specified tab, and detach the
// debugger associated with that debug session.
void _removeAndDetachDebugSessionForTab(int tabId, _) {
final removedTabId = _removeDebugSessionForTab(tabId);
if (removedTabId != -1) {
chrome.debugger.detach(Debuggee(tabId: removedTabId), allowInterop(() {}));
}
}
// Tries to remove the debug session for the specified tab. If no session is
// found, returns -1. Otherwise returns the tab ID.
int _removeDebugSessionForTab(int tabId) {
final session = _debugSessions.firstWhereOrNull(
(session) => session.appTabId == tabId || session.devtoolsTabId == tabId);
if (session != null) {
// 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
// DWDS that we should close the connection, instead of relying on the done
// event sent when the client is closed. See details:
// https://github.com/dart-lang/webdev/pull/1595#issuecomment-1116773378
final event =
_extensionEventFor('DebugExtension.detached', js_util.jsify({}));
session.sendEvent(event);
session.close();
_debugSessions.remove(session);
// Notify the Dart DevTools panel that the session has been detached by
// setting the URI to an empty string:
_updateOrCreateDevToolsPanel(session.appId, (panel) {
panel.devToolsUri = '';
});
return session.appTabId;
} else {
return -1;
}
}
Future<void> _maybeSaveDevToolsTabId(Tab tab) async {
// Remembers the ID of the DevTools tab.
//
// This assumes that the next launched tab after a session is created is the
// DevTools tab.
if (_debugSessions.isNotEmpty) _debugSessions.last.devtoolsTabId ??= tab.id;
}
Future<void> _handleMessageFromExternalExtensions(
dynamic jsRequest, MessageSender sender, Function sendResponse) async {
if (jsRequest == null) return;
final request = jsRequest as Request;
if (request.name == 'chrome.debugger.sendCommand') {
try {
final options = request.options as SendCommandOptions;
void sendResponseOrError([e]) {
// No arguments indicate that an error occurred.
if (e == null) {
sendResponse(ErrorResponse()
..error = JSON.stringify(chrome.runtime.lastError));
} else {
sendResponse(e);
}
}
chrome.debugger.sendCommand(
Debuggee(tabId: request.tabId),
options.method,
options.commandParams,
allowInterop(sendResponseOrError));
} catch (e) {
sendResponse(ErrorResponse()..error = '$e');
}
} else if (request.name == 'dwds.encodedUri') {
sendResponse(_tabIdToEncodedUri[request.tabId] ?? '');
} else if (request.name == 'dwds.startDebugging') {
_startDebugging(DebuggerTrigger.dwds);
// TODO(grouma) - Actually determine if debugging initiated
// successfully.
sendResponse(true);
} else {
sendResponse(
ErrorResponse()..error = 'Unknown request name: ${request.name}');
}
}
Future<void> _forwardMessageToExternalExtensions(
Debuggee source, String method, Object? params) async {
if (_allowedEvents.contains(method)) {
sendMessageToExtensions(ExternalExtensionRequest(
name: 'chrome.debugger.event',
tabId: source.tabId,
options: DebugEvent(method: method, params: params)));
}
}
void _notifyPanelScriptOfChanges(List panels) {
final panelsList = List<DevToolsPanel>.from(panels);
for (final panel in panelsList) {
chrome.runtime.sendMessage(
panel.panelId,
SimpleMessage(recipient: 'panel-script', body: panel.devToolsUri),
null,
null);
}
}
void sendMessageToExtensions(ExternalExtensionRequest request) {
for (var extensionId in _allowedExtensions) {
try {
chrome.runtime.sendMessage(extensionId, request, null,
allowInterop(([e]) {
if (e == null) {
// Error sending message. Check lastError to silently fail.
chrome.runtime.lastError;
}
}));
} catch (_) {}
}
}
/// Attempts to attach to the Dart application in the provided Tab and execution
/// context.
Future<bool> _tryAttach(
int contextId, Tab tab, bool launchInChromeDevTools) async {
final successCompleter = Completer<bool>();
chrome.debugger.sendCommand(
Debuggee(tabId: tab.id),
'Runtime.evaluate',
InjectedParams(
expression:
'[window.\$dartExtensionUri, window.\$dartAppId, window.\$dartAppInstanceId, window.\$dwdsVersion]',
returnByValue: true,
contextId: contextId), allowInterop((dynamic response) {
final evalResponse = response as EvalResponse;
final value = evalResponse.result.value;
final extensionUri = value?[0];
final appId = value?[1];
final instanceId = value?[2];
final dwdsVersion = value?[3];
if (extensionUri == null || appId == null || instanceId == null) {
window.console
.warn('Unable to debug app. Missing Dart debugging global variables');
successCompleter.complete(false);
return;
}
_startSseClient(
Uri.parse(extensionUri),
appId,
instanceId,
contextId,
tab,
dwdsVersion ?? '0.0.0',
launchInChromeDevTools,
);
successCompleter.complete(true);
}));
return successCompleter.future;
}
// Starts an SSE client.
//
// Initiates a [DevToolsRequest], handles an [ExtensionRequest],
// and sends an [ExtensionEvent].
Future<void> _startSseClient(
Uri uri,
String appId,
String instanceId,
int contextId,
Tab currentTab,
String dwdsVersion,
bool launchInChromeDevTools,
) async {
if (Version.parse(dwdsVersion) >= Version.parse('9.1.0')) {
var authUri = uri.replace(path: authenticationPath);
if (authUri.scheme == 'ws') authUri = authUri.replace(scheme: 'http');
if (authUri.scheme == 'wss') authUri = authUri.replace(scheme: 'https');
final authUrl = authUri.toString();
try {
final response = await HttpRequest.request(authUrl,
method: 'GET', withCredentials: true);
final responseText = response.responseText ?? '';
if (!responseText.contains('Dart Debug Authentication Success!')) {
throw Exception('Not authenticated.');
}
} catch (_) {
if (window.confirm(
'Authentication required.\n\nClick OK to authenticate then try again.')) {
// TODO(grouma) - see if we can get a callback on a successful auth
// and automatically reinitiate the dev workflow.
window.open(authUrl, 'Dart DevTools Authentication');
chrome.debugger
.detach(Debuggee(tabId: currentTab.id), allowInterop(() {}));
}
return;
}
}
// Specifies whether the debugger is attached.
//
// A debugger is detached if it is closed by user or the target is closed.
final client = uri.isScheme('ws') || uri.isScheme('wss')
? WebSocketClient(WebSocketChannel.connect(uri))
: SseSocketClient(SseClient(uri.toString()));
_debugSessions.add(DebugSession(client, currentTab.id, appId));
print('Connected to DWDS version $dwdsVersion with appId=$appId');
client.stream.listen((data) {
final message = serializers.deserialize(jsonDecode(data));
if (message is ExtensionRequest) {
final messageParams = message.commandParams ?? '{}';
final params =
BuiltMap<String, Object>(json.decode(messageParams)).toMap();
chrome.debugger.sendCommand(Debuggee(tabId: currentTab.id),
message.command, js_util.jsify(params), allowInterop(([e]) {
// No arguments indicate that an error occurred.
if (e == null) {
client.sink
.add(jsonEncode(serializers.serialize(ExtensionResponse((b) => b
..id = message.id
..success = false
..result = JSON.stringify(chrome.runtime.lastError)))));
} else {
client.sink
.add(jsonEncode(serializers.serialize(ExtensionResponse((b) => b
..id = message.id
..success = true
..result = JSON.stringify(e)))));
}
}));
} else if (message is ExtensionEvent) {
if (message.method == 'dwds.encodedUri') {
sendMessageToExtensions(ExternalExtensionRequest(
name: 'dwds.encodedUri',
tabId: currentTab.id,
options: message.params));
_tabIdToEncodedUri[currentTab.id] = message.params;
}
if (message.method == 'dwds.devtoolsUri') {
_updateOrCreateDevToolsPanel(appId, (panel) {
panel.devToolsUri = message.params;
});
}
}
}, onDone: () {
_tabIdToEncodedUri.remove(currentTab.id);
_removeAndDetachDebugSessionForTab(currentTab.id, null);
return;
}, onError: (_) {
_tabIdToEncodedUri.remove(currentTab.id);
window.alert('Lost app connection.');
_removeAndDetachDebugSessionForTab(currentTab.id, null);
}, cancelOnError: true);
client.sink.add(jsonEncode(serializers.serialize(DevToolsRequest((b) => b
..appId = appId
..instanceId = instanceId
..contextId = contextId
..tabUrl = currentTab.url
..uriOnly = launchInChromeDevTools))));
chrome.debugger.sendCommand(Debuggee(tabId: currentTab.id), 'Runtime.enable',
EmptyParam(), allowInterop((e) {}));
}
void _updateOrCreateDevToolsPanel(
String appId, void Function(DevToolsPanel panel) update) {
final devToolsPanels =
List<DevToolsPanel>.from(_devToolsPanelsNotifier.value);
var panelAlreadyExists = false;
for (final panel in devToolsPanels) {
if (panel.appId == appId) {
panelAlreadyExists = true;
update(panel);
}
}
if (!panelAlreadyExists) {
final newPanel = DevToolsPanel(appId);
update(newPanel);
devToolsPanels.add(newPanel);
}
_devToolsPanelsNotifier.setValue(devToolsPanels);
}
void _updateIcon() {
final query = QueryInfo(active: true, currentWindow: true);
chrome.tabs.query(query, allowInterop((List tabs) {
// If tabList is empty, the user has likely navigated to a different window.
// Therefore, do not update the icon:
if (tabs.isEmpty) return;
final tab = tabs.first as Tab;
if (_tabIdToWarning.containsKey(tab.id)) {
// Set the warning icon (red):
chrome.browserAction.setIcon(IconInfo(path: 'dart_warning.png'), null);
} else if (_debuggableTabs.contains(tab.id)) {
// Set the debuggable icon (blue):
_mostRecentDartTab = tab;
chrome.browserAction.setIcon(IconInfo(path: 'dart.png'), null);
} else {
// Set the default icon (grey):
chrome.browserAction.setIcon(IconInfo(path: 'dart_grey.png'), null);
}
}));
}
void _updateIconOnNavigation(NavigationInfo navigationInfo) {
if (navigationInfo.transitionType != 'auto_subframe' &&
_debuggableTabs.remove(navigationInfo.tabId)) {
_updateIcon();
}
}
/// Construct an [ExtensionEvent] from [method] and [params].
ExtensionEvent _extensionEventFor(String method, dynamic params) =>
ExtensionEvent((b) => b
..params = jsonEncode(json.decode(JSON.stringify(params)))
..method = jsonEncode(method));
/// Forward debugger events to the backend if applicable.
void _filterAndForwardToBackend(
Debuggee source, String method, dynamic params) {
final debugSession = _debugSessions
.firstWhereOrNull((session) => session.appTabId == source.tabId);
if (debugSession == null) return;
final event = _extensionEventFor(method, params);
if (method == 'Debugger.scriptParsed') {
debugSession.sendBatchedEvent(event);
} else {
debugSession.sendEvent(event);
}
}
class Notifier<T> {
Notifier(T value) : _value = value;
T _value;
final List<Listener<T>> _listeners = <Listener<T>>[];
T get value => _value;
void setValue(T value) {
_value = value;
notifyListeners();
}
void addListener(Listener<T> listener) {
_listeners.add(listener);
}
void notifyListeners() {
for (final listener in _listeners) {
listener.onChange(_value);
}
}
}
class Listener<T> {
Listener(this.onChange);
void Function(T value) onChange;
}
@JS()
@anonymous
class SimpleMessage {
external String get recipient;
external String get body;
external factory SimpleMessage(
{required String recipient, required String body});
}
@JS()
@anonymous
class ExternalExtensionRequest {
external int get tabId;
external String get name;
external dynamic get options;
external factory ExternalExtensionRequest(
{required int tabId, required String name, required dynamic options});
}
@JS()
@anonymous
class DebugEvent {
external factory DebugEvent({String method, Object? params});
}
@JS()
@anonymous
class ErrorResponse {
external set error(String error);
}
@JS()
@anonymous
class EmptyParam {
external factory EmptyParam();
}
@JS()
@anonymous
class InjectedParams {
external String get expresion;
external bool get returnByValue;
external int get contextId;
external factory InjectedParams(
{String? expression, bool? returnByValue, int? contextId});
}
@JS()
@anonymous
class SendCommandOptions {
external String get method;
external Object get commandParams;
}
@JS()
@anonymous
class Request {
external String get dartAppId;
external String get sender;
external int get tabId;
external String get name;
external dynamic get options;
external String get warning;
external String get message;
external factory Request(
{required int tabId, required String name, required dynamic options});
}
@JS()
@anonymous
class EvalResponse {
external EvalResult get result;
}
@JS()
@anonymous
class EvalResult {
external List<String?>? get value;
}
/// For testing only.
//
/// An automated click on the extension icon is not supported by WebDriver.
/// We initiate a fake click from the `debug_extension_test`
/// after the extension is loaded.
@JS('fakeClick')
external set onFakeClick(void Function() f);
@JS('window.isDartDebugExtension')
external set isDartDebugExtension(_);