blob: 251cd1a5111e1722fa2ac358c8e714e49afb9500 [file] [log] [blame]
// Copyright 2015 Google. All rights reserved. Use of this source code is
// governed by a BSD-style license that can be found in the LICENSE file.
/// A library to connect to a Webkit Inspection Protocol server (like Chrome).
library wip;
import 'dart:async';
import 'dart:convert';
import 'dart:io' show HttpClient, HttpClientResponse, IOException, WebSocket;
import 'src/console.dart';
import 'src/debugger.dart';
import 'src/dom.dart';
import 'src/log.dart';
import 'src/page.dart';
import 'src/runtime.dart';
import 'src/target.dart';
export 'src/console.dart';
export 'src/debugger.dart';
export 'src/dom.dart';
export 'src/log.dart';
export 'src/page.dart';
export 'src/runtime.dart';
export 'src/target.dart';
/// A class to connect to a Chrome instance and reflect on its available tabs.
///
/// This assumes the browser has been started with the `--remote-debugging-port`
/// flag. The data is read from the `http://{host}:{port}/json` url.
class ChromeConnection {
final HttpClient _client = HttpClient();
final Uri url;
ChromeConnection(String host, [int port = 9222])
: url = Uri.parse('http://$host:$port/');
/// Return all the available tabs.
///
/// This method can potentially throw a [ConnectionException] on some protocol
/// issues.
///
/// An optional [retryFor] duration can be used to automatically re-try
/// connections for some period of time. Anecdotally, Chrome can return errors
/// when trying to list the available tabs very early in its startup sequence.
Future<List<ChromeTab>> getTabs({
Duration? retryFor,
}) async {
final start = DateTime.now();
DateTime? end = retryFor == null ? null : start.add(retryFor);
var response = await getUrl('/json');
var responseBody = await utf8.decodeStream(response.cast<List<int>>());
late List decoded;
while (true) {
try {
decoded = jsonDecode(responseBody);
return List<ChromeTab>.from(decoded.map((m) => ChromeTab(m as Map)));
} on FormatException catch (formatException) {
if (end != null && end.isBefore(DateTime.now())) {
// Delay for retryFor / 4 milliseconds.
await Future.delayed(
Duration(milliseconds: retryFor!.inMilliseconds ~/ 4),
);
} else {
throw ConnectionException(
formatException: formatException,
responseStatus: '${response.statusCode} ${response.reasonPhrase}',
responseBody: responseBody,
);
}
}
}
}
Future<ChromeTab?> getTab(
bool Function(ChromeTab tab) accept, {
Duration? retryFor,
}) async {
var start = DateTime.now();
var end = start;
if (retryFor != null) {
end = start.add(retryFor);
}
while (true) {
try {
for (var tab in await getTabs()) {
if (accept(tab)) {
return tab;
}
}
if (end.isBefore(DateTime.now())) {
return null;
}
} catch (e) {
if (end.isBefore(DateTime.now())) {
rethrow;
}
}
await Future.delayed(const Duration(milliseconds: 25));
}
}
Future<HttpClientResponse> getUrl(String path) async {
var request = await _client.getUrl(url.resolve(path));
return await request.close();
}
void close() => _client.close(force: true);
}
/// An exception that can be thrown early in the connection sequence for a
/// [ChromeConnection].
///
/// This exception includes the underlying exception, as well as the http
/// response from the browser that we failed on. The [toString] implementation
/// includes a summary of the response.
class ConnectionException implements IOException {
final FormatException formatException;
final String responseStatus;
final String responseBody;
ConnectionException({
required this.formatException,
required this.responseStatus,
required this.responseBody,
});
@override
String toString() {
final buf = StringBuffer('${formatException.message}\n');
buf.writeln('$responseStatus; body:');
var lines = responseBody.split('\n');
if (lines.length > 10) {
lines = [
...lines.take(10),
'...',
];
}
buf.writeAll(lines, '\n');
return buf.toString();
}
}
class ChromeTab {
final Map _map;
ChromeTab(this._map);
String? get description => _map['description'] as String?;
String? get devtoolsFrontendUrl => _map['devtoolsFrontendUrl'] as String?;
String? get faviconUrl => _map['faviconUrl'] as String?;
/// Ex. `E1999E8A-EE27-0450-9900-5BFF4C69CA83`.
String get id => _map['id'] as String;
String? get title => _map['title'] as String?;
/// Ex. `background_page`, `page`.
String get type => _map['type'] as String;
String get url => _map['url'] as String;
/// Ex. `ws://localhost:1234/devtools/page/4F98236D-4EB0-7C6C-5DD1-AF9B6BE4BC71`.
String get webSocketDebuggerUrl => _map['webSocketDebuggerUrl'] as String;
bool get hasIcon => _map.containsKey('faviconUrl');
bool get isChromeExtension => url.startsWith('chrome-extension://');
bool get isBackgroundPage => type == 'background_page';
/// Connect to the debug connection for this tab and return a [WipConnection].
///
/// On errors from this stream, the [onError] handler is called with the error
/// object and possibly a stack trace. The [onError] callback must be of type
/// `void Function(Object error)` or `void Function(Object error, StackTrace)`.
Future<WipConnection> connect({Function? onError}) {
return WipConnection.connect(webSocketDebuggerUrl, onError: onError);
}
@override
String toString() => url;
}
/// A Webkit Inspection Protocol (WIP) connection.
class WipConnection {
/// The WebSocket URL.
final String url;
final WebSocket _ws;
int _nextId = 0;
@Deprecated('This domain is deprecated - use Runtime or Log instead')
late final WipConsole console = WipConsole(this);
late final WipDebugger debugger = WipDebugger(this);
late final WipDom dom = WipDom(this);
late final WipPage page = WipPage(this);
late final WipTarget target = WipTarget(this);
late final WipLog log = WipLog(this);
late final WipRuntime runtime = WipRuntime(this);
final StreamController<String> _onSend =
StreamController.broadcast(sync: true);
final StreamController<String> _onReceive =
StreamController.broadcast(sync: true);
final Map<int, Completer<WipResponse>> _completers = {};
final _closeController = StreamController<WipConnection>.broadcast();
final _notificationController = StreamController<WipEvent>.broadcast();
/// Connect to the given url and return a [WipConnection].
///
/// On errors from this stream, the [onError] handler is called with the error
/// object and possibly a stack trace. The [onError] callback must be of type
/// `void Function(Object error)` or `void Function(Object error, StackTrace)`.
static Future<WipConnection> connect(String url, {Function? onError}) {
return WebSocket.connect(url).then((socket) {
return WipConnection._(url, socket, onError: onError);
});
}
WipConnection._(this.url, this._ws, {Function? onError}) {
void onData(dynamic /*String|List<int>*/ data) {
_onReceive.add(data);
var json = jsonDecode(data as String) as Map<String, dynamic>;
if (json.containsKey('id')) {
_handleResponse(json);
} else {
_handleNotification(json);
}
}
_ws.listen(
onData,
onError: onError,
onDone: _handleClose,
);
}
Stream<WipConnection> get onClose => _closeController.stream;
Stream<WipEvent> get onNotification => _notificationController.stream;
Future close() => _ws.close();
@override
String toString() => url;
Future<WipResponse> sendCommand(
String method, [
Map<String, dynamic>? params,
]) {
var completer = Completer<WipResponse>();
var json = {'id': _nextId++, 'method': method};
if (params != null) {
json['params'] = params;
}
_completers[json['id'] as int] = completer;
String message = jsonEncode(json);
_ws.add(message);
_onSend.add(message);
return completer.future;
}
void _handleNotification(Map<String, dynamic> json) {
_notificationController.add(WipEvent(json));
}
void _handleResponse(Map<String, dynamic> event) {
var completer = _completers.remove(event['id'])!;
if (event.containsKey('error')) {
completer.completeError(WipError(event));
} else {
completer.complete(WipResponse(event));
}
}
void _handleClose() {
_closeController.add(this);
_closeController.close();
_notificationController.close();
}
/// Listen for all traffic sent on this WipConnection.
Stream<String> get onSend => _onSend.stream;
/// Listen for all traffic received by this WipConnection.
Stream<String> get onReceive => _onReceive.stream;
}
class WipEvent {
final Map<String, dynamic> json;
final String method;
final Map<String, dynamic>? params;
WipEvent(this.json)
: method = json['method'] as String,
params = json['params'] as Map<String, dynamic>?;
@override
String toString() => 'WipEvent: $method($params)';
}
class WipError implements Exception {
final Map<String, dynamic> json;
final int id;
final Map<String, dynamic>? error;
WipError(this.json)
: id = json['id'] as int,
error = json['error'] as Map<String, dynamic>?;
int? get code => error == null ? null : error!['code'];
String? get message => error == null ? null : error!['message'];
@override
String toString() => 'WipError $code $message';
}
class WipResponse {
final Map<String, dynamic> json;
final int id;
final Map<String, dynamic>? result;
WipResponse(this.json)
: id = json['id'] as int,
result = json['result'] as Map<String, dynamic>?;
@override
String toString() => 'WipResponse $id: $result';
}
typedef WipEventTransformer<T> = T Function(WipEvent event);
/// @optional
const String optional = 'optional';
abstract class WipDomain {
final Map<String, Stream> _eventStreams = {};
final WipConnection connection;
late final Stream<WipDomain> onClosed = StreamTransformer.fromHandlers(
handleData: (event, EventSink<WipDomain> sink) {
sink.add(this);
}).bind(connection.onClose);
WipDomain(this.connection);
Stream<T> eventStream<T>(String method, WipEventTransformer<T> transformer) {
return _eventStreams
.putIfAbsent(
method,
() => StreamTransformer.fromHandlers(
handleData: (WipEvent event, EventSink<T> sink) {
if (event.method == method) {
sink.add(transformer(event));
}
},
).bind(connection.onNotification),
)
.cast();
}
Future<WipResponse> sendCommand(
String method, {
Map<String, dynamic>? params,
}) {
return connection.sendCommand(method, params);
}
}
// ignore: library_private_types_in_public_api
const _Experimental experimental = _Experimental();
class _Experimental {
const _Experimental();
}