blob: 394d27fdac8777d08ef9c194081d99a320de7c51 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert' show utf8;
import 'dart:developer';
import 'package:fidl/fidl.dart' as fidl;
import 'package:fidl_fuchsia_web/fidl_async.dart' as fidl_web;
import 'package:fidl_fuchsia_net_http/fidl_async.dart' as fidl_net;
import 'package:webview_flutter/platform_interface.dart';
import 'package:fuchsia_logger/logger.dart';
import 'fuchsia_web_services.dart';
import 'utils.dart' as utils;
class _ChannelSubscription {
StreamSubscription<String> subscription;
final int id;
_ChannelSubscription(this.id, {this.subscription});
}
/// Fuchsia [WebViewPlatformController] implementation that serves as the entry
/// point for all [fuchsia_webview_flutter/webview.dart]'s apis
class FuchsiaWebViewPlatformController extends WebViewPlatformController {
/// Helper class to interact with fuchsia web services
FuchsiaWebServices _fuchsiaWebServices;
int _nextId = 0;
var _currentState = fidl_web.NavigationState();
// Reason: sdk_version_set_literal unsupported until version 2.2
// ignore: prefer_collection_literals
var _beforeLoadChannels = Set<String>();
final WebViewPlatformCallbacksHandler _platformCallbacksHandler;
final _javascriptChannelSubscriptions = <String, _ChannelSubscription>{};
/// Initializes [FuchsiaWebViewPlatformController]
FuchsiaWebViewPlatformController(this._platformCallbacksHandler,
CreationParams creationParams, this._fuchsiaWebServices)
: assert(_platformCallbacksHandler != null),
super(_platformCallbacksHandler) {
fuchsiaWebServices.setNavigationEventListener(
_WebviewNavigationEventListener(_onNavigationStateChanged));
updateSettings(creationParams.webSettings);
_addBeforeLoadChannels(creationParams.javascriptChannelNames);
if (creationParams.initialUrl != null) {
loadUrl(creationParams.initialUrl, {});
}
}
/// Returns [FuchsiaWebServices]
FuchsiaWebServices get fuchsiaWebServices {
return _fuchsiaWebServices ??= FuchsiaWebServices();
}
@override
Future<void> addJavascriptChannels(Set<String> javascriptChannelNames) async {
await _createChannelSubscriptions(javascriptChannelNames);
}
@override
Future<bool> canGoBack() async {
return _currentState.canGoBack;
}
@override
Future<bool> canGoForward() async {
return _currentState.canGoForward;
}
@override
Future<void> clearCache() {
throw UnimplementedError(
'FuchsiaWebView clearCache is not implemented on the current platform');
}
@override
Future<String> currentUrl() async {
return _currentState.url;
}
@override
Future<String> getTitle() async {
return _currentState.title;
}
@override
Future<String> evaluateJavascript(String javascriptString) async {
return fuchsiaWebServices.evaluateJavascript(['*'], javascriptString);
}
@override
Future<void> goBack() {
return fuchsiaWebServices.navigationController.goBack();
}
@override
Future<void> goForward() {
return fuchsiaWebServices.navigationController.goForward();
}
@override
Future<void> loadUrl(
String url,
Map<String, String> headers,
) async {
assert(url != null);
final headersList = <fidl_net.Header>[];
if (headers != null) {
headers.forEach((k, v) {
headersList
.add(fidl_net.Header(name: utf8.encode(k), value: utf8.encode(v)));
});
}
return fuchsiaWebServices.navigationController.loadUrl(
url,
fidl_web.LoadUrlParams(
type: fidl_web.LoadUrlReason.typed,
headers: headersList,
));
}
@override
Future<void> reload() {
return fuchsiaWebServices.navigationController
.reload(fidl_web.ReloadType.partialCache);
}
@override
Future<void> removeJavascriptChannels(
Set<String> javascriptChannelNames) async {
for (final channelName in javascriptChannelNames) {
if (_javascriptChannelSubscriptions.containsKey(channelName)) {
await _javascriptChannelSubscriptions[channelName]
.subscription
.cancel();
_javascriptChannelSubscriptions.remove(channelName);
await fuchsiaWebServices
.evaluateJavascript(['*'], 'window.$channelName = undefined;');
}
}
}
@override
Future<void> updateSettings(WebSettings settings) {
if (settings.debuggingEnabled != null) {
return fuchsiaWebServices.setJavaScriptLogLevel(settings.debuggingEnabled
? fidl_web.ConsoleLogLevel.debug
: fidl_web.ConsoleLogLevel.none);
}
return Future.value();
}
/// Clears all cookies for all [WebView] instances.
///
/// Returns true if cookies were present before clearing, else false.
static Future<bool> clearCookies() {
throw UnimplementedError(
'FuchsiaWebView clearCookies is not implemented on the current platform');
}
/// Close all remaining subscriptions and connections.
void dispose() {
for (final entry in _javascriptChannelSubscriptions.entries) {
entry.value.subscription.cancel();
}
fuchsiaWebServices.dispose();
}
/// Called when a navigation state event is received from the webview.
Future<void> _onNavigationStateChanged(fidl_web.NavigationState state) async {
_updateCurrentStateDiff(state);
if (state.isMainDocumentLoaded != null && state.isMainDocumentLoaded) {
await _establishCommunication(_beforeLoadChannels);
_platformCallbacksHandler.onPageFinished(_currentState.url);
}
}
/// Updates the current state with each field that is set in the new
/// navigation state.
void _updateCurrentStateDiff(fidl_web.NavigationState state) {
_currentState = fidl_web.NavigationState(
title: state.title ?? _currentState.title,
url: state.url ?? _currentState.url,
canGoBack: state.canGoBack ?? _currentState.canGoBack,
canGoForward: state.canGoForward ?? _currentState.canGoForward,
isMainDocumentLoaded:
state.isMainDocumentLoaded ?? _currentState.isMainDocumentLoaded,
pageType: state.pageType ?? _currentState.pageType,
);
}
/// Registers the javascript channels that will be loaded when the page loads.
/// Connection will be established when the page loads.
Future<void> _addBeforeLoadChannels(
Set<String> javascriptChannelNames) async {
_beforeLoadChannels = javascriptChannelNames;
await _createChannelSubscriptions(javascriptChannelNames, beforeLoad: true);
}
/// For each channel in [javascriptChannelNames] creates an object with the
/// channel name on window in the frame. That object will contain a
/// `postMessage` method. Messages sent through that method will be received
/// here and notified back to the client of the webview.
/// The process for each channel is:
/// 1. Inject the script that will create the object on window to the
/// webview. This script will initially wait for a 'share-port' message.
/// 2. postMessage 'share-port' to the frame.
/// 3. The frame has a listener on window and will reply with a
/// share-port-ack and a port to which the frame will send messages.
/// 4. Bind that port and start listening on it.
/// 5. Notify the frame that we are ready to receive messages.
/// 6. When a message arrives on that port it is sent to the client through
/// the platform callback.
Future<void> _createChannelSubscriptions(Set<String> javascriptChannelNames,
{bool beforeLoad}) async {
for (final channelName in javascriptChannelNames) {
// Close any connections to that object (if any existed)
if (_javascriptChannelSubscriptions.containsKey(channelName)) {
await _javascriptChannelSubscriptions[channelName]
.subscription
.cancel();
} else {
_javascriptChannelSubscriptions[channelName] =
_ChannelSubscription(_nextId);
_nextId += 1;
}
}
// Create a JavaScript object with one postMessage method. This object
// will be exposed on window.$channelName when the FIDL communication is
// established. Any window.$channelName already set will be removed.
// If before load, the connection will happen once the page loads.
if (beforeLoad) {
await Future.wait(javascriptChannelNames.map((channel) {
// Reason: not supported until v2.2 and we need to support earlier
// versions.
// ignore: prefer_collection_literals
final script = _scriptForChannels(Set.from([channel]));
return fuchsiaWebServices.evaluateJavascriptBeforeLoad(
_javascriptChannelSubscriptions[channel].id, ['*'], script);
}));
return;
}
final script = _scriptForChannels(javascriptChannelNames);
await evaluateJavascript(script);
await _establishCommunication(javascriptChannelNames);
}
Future<void> _establishCommunication(Set<String> javascriptChannelNames) {
return Future.wait(javascriptChannelNames.map((channelName) async {
// Creates the message channel connection.
fidl_web.MessagePortProxy incomingPort;
try {
incomingPort = await _bindIncomingPort(channelName);
} on Exception catch (e) {
log.warning('Failed to bind incoming port for $channelName: $e');
return;
}
// Subscribe for incoming messages.
final incomingMessagesStream = _startReceivingMessages(incomingPort);
_javascriptChannelSubscriptions[channelName].subscription =
incomingMessagesStream.listen(
(message) async {
_platformCallbacksHandler.onJavaScriptChannelMessage(
channelName, message);
},
);
// Notify of readiness
await fuchsiaWebServices.postMessage(
'*', 'share-port-$channelName-ready');
}));
}
/// Communicates with the script injected by `_scriptForChannels` to get a port
/// from the web page with which to communicate with the page. See comments on
/// `_createChannelSubscriptions` for details on the process.
Future<fidl_web.MessagePortProxy> _bindIncomingPort(String channel) async {
final messagePort = fidl_web.MessagePortProxy();
await fuchsiaWebServices.postMessage('*', 'share-port-$channel',
outgoingMessagePortRequest: messagePort.ctrl.request());
final msg = await messagePort.receiveMessage();
final ackMsg = utils.bufferToString(msg.data);
if (ackMsg != 'share-port-ack-$channel') {
throw Exception('Expected "share-port-ack-$channel", got: "$ackMsg"');
}
if (msg.incomingTransfer == null || msg.incomingTransfer.isEmpty) {
throw Exception('failed to provide an incoming message port');
}
final incomingMessagePort = fidl_web.MessagePortProxy();
incomingMessagePort.ctrl.bind(msg.incomingTransfer[0].messagePort);
return incomingMessagePort;
}
/// Script injected to the frame to create an object with the given name on
/// window. See comments on `_createChannelSubscriptions` for details on the
/// process.
String _scriptForChannels(Set<String> channelNames) {
return channelNames.map((channel) => '''
(function() {
class $channel {
constructor() {
this._messageChannel = null;
this._pendingMessages = [];
this._isReady = false;
}
postMessage(message) {
if (this._isReady) {
this._messageChannel.port1.postMessage(message);
} else {
this._pendingMessages.push(message);
}
}
_ready() {
for (const pendingMessage of this._pendingMessages) {
this._messageChannel.port1.postMessage(pendingMessage);
}
this._isReady = true;
this._pendingMessages = [];
}
_setMessageChannel(channel) {
this._messageChannel = channel;
}
}
window.$channel = new $channel();
function initializer$channel(event) {
if (event.data) {
if (event.data == 'share-port-$channel' && event.ports && event.ports.length > 0) {
console.log('Registering channel $channel');
const messageChannel = new MessageChannel();
window.$channel._setMessageChannel(messageChannel);
event.ports[0].postMessage('share-port-ack-$channel', [messageChannel.port2]);
}
if (event.data == 'share-port-$channel-ready') {
console.log('Channel $channel ready');
window.$channel._ready();
window.removeEventListener('message', initializer$channel, true);
}
}
}
window.addEventListener('message', initializer$channel, true);
})();
''').join('\n');
}
/// Listens for messages on the incoming port and streams them.
Stream<String> _startReceivingMessages(
fidl_web.MessagePortProxy incomingMessagePort) async* {
// ignore: literal_only_boolean_expressions
while (true) {
try {
final msg = await incomingMessagePort.receiveMessage();
yield utils.bufferToString(msg.data);
} on fidl.FidlError {
// Occurs when the incoming port is closed (ie navigate to another page).
break;
}
}
}
}
class _WebviewNavigationEventListener extends fidl_web.NavigationEventListener {
final Future<void> Function(fidl_web.NavigationState state)
navigationStateCallback;
_WebviewNavigationEventListener(this.navigationStateCallback);
@override
Future<void> onNavigationStateChanged(fidl_web.NavigationState state) async {
await navigationStateCallback(state);
}
}