Skip to content

Conversation

@Frank3K
Copy link
Contributor

@Frank3K Frank3K commented Apr 6, 2023

This pull request implements JavaScript channels for webview_flutter_web, allowing postMessage communication from a web application to Flutter.

The regular webview_flutter package already supports JavaScript channels for iOS and Android, making is possible to send messages from a web application to Flutter.

Related issues

Flutter issue asking for support: flutter/flutter#101758.

How it works

On iOS and Android it is possible to send messages on a JavaScript channel using the postMessage API. This API is very simple: it only allows a single string argument (docs). On web, there's the postMessage API on window, on which arbitrary data can be sent. Since the postMessage API for Android and iOS is a subset (string vs object), the web postMessage API can be used to implement the JavaScript channel.

Demo-videos

The following demo show a small application which sends a postMessage to the Flutter application.

iOS

Screen.Recording.2023-04-06.at.12.42.57.mov

Web

Screen.Recording.2023-04-06.at.20.33.57.mov

PoC code

Flutter side

    ...
    webViewController = WebViewController();
    webViewController.addJavaScriptChannel(
      'flutterApp',
      onMessageReceived: (message) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(message.message),
          ),
        );
      },
    );
   ...

Web side

HTML
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>HTML5 Example Page</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="description" content="HTML5 Example Page" />
    <style type="text/css">
      body {
        font-family: sans-serif;
      }
    </style>
    <script type="text/javascript" src="demo.js"></script>
  </head>
  <body>
    <header>
      <h1>HTML5 Example Page</h1>
    </header>
    <button onclick="helloWorld()">Hello world</button>
    <button onclick="userAgent()">Show userAgent</button>
  </body>
</html>
JavaScript
function sendMessage(message) {
  const channel = window["flutterApp"] || window.parent;
  if (!channel) {
    return;
  }

  channel.postMessage(message, '*');
}

function helloWorld() {
  sendMessage("Hello world");
}

function userAgent() {
  sendMessage(window.navigator.userAgent);
}

Web implementation notes and limitations

Security

In the above PoC the postMessage is sent (on web) with * as target origin. This is considered bad practice. The web application should use the origin of the flutter web application as the target origin.

Named JavaScript channels

On iOS and Android it is possible to name a JavaScript channel. On web, this is not possible since that would require injecting JavaScript code into the iframe (which is not possible cross-origin). Therefore the web application uses window.parent (using window["flutterApp"] || window.parent) to get to the flutter application. Therefore the web implementation only allows a single channel.

Tests

No tests have been added yet since we first like to see whether the current approach is a good one, or if there is a better approach. Once the approach is greenlit, we want to add tests.

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the relevant style guides and ran the auto-formatter. (Unlike the flutter/flutter repo, the flutter/packages repo does use dart format.)
  • I signed the CLA.
  • The title of the PR starts with the name of the package surrounded by square brackets, e.g. [shared_preferences]
  • I listed at least one issue that this PR fixes in the description above.
  • I updated pubspec.yaml with an appropriate new version according to the pub versioning philosophy, or this PR is exempt from version changes.
  • I updated CHANGELOG.md to add a description of the change, following repository CHANGELOG style.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is test-exempt.
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@Frank3K Frank3K marked this pull request as ready for review April 18, 2023 14:02
@Frank3K Frank3K requested a review from ditman as a code owner April 18, 2023 14:02
@stuartmorgan-g stuartmorgan-g self-requested a review May 9, 2023 19:49
@RobotJohns
Copy link

how to postMessage to webview and reture value to html

@Frank3K
Copy link
Contributor Author

Frank3K commented May 25, 2023

how to postMessage to webview and reture value to html

This PR is about sending messages from a webview to Flutter. I think you're asking how to send messages the other way: i.e. from Flutter to webview, right?

To send messages from Flutter to web, one can use something like:

  1. For Android/iOS:
    runJavaScript(
      "window.postMessage(${jsonEncode(message)}, '$targetOrigin')",
    );
  1. For web:
    import 'dart:html' as html;
    ....
    final iframe =
        html.querySelector('iframe') as html.IFrameElement?;
    iframe?.contentWindow?.postMessage(message, targetOrigin);

@stuartmorgan-g stuartmorgan-g changed the title [webview_flutter_web] Add support for JavaScript channels [webview_flutter_web] Add support for webview->host JavaScript channels Jun 14, 2023
@flutter-dashboard
Copy link

It looks like this pull request may not have tests. Please make sure to add tests before merging. If you need an exemption to this rule, contact Hixie on the #hackers channel in Chat (don't just cc him here, he won't see it! He's on Discord!).

If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix?

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

Copy link
Collaborator

@stuartmorgan-g stuartmorgan-g left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fundamentally doesn't match the semantics of plugin API; it doesn't behave the same way on either the Dart or JS side, so trying to fit it into this construction seems more harmful than beneficial.

I think the right way to handle this is to define a new set of webview_flutter_web APIs specific to postMessage, to address bidirectional communication. For this direction, a setWindowMessageHandler (or addWindowMessageHandler and a corresponding remove... if we want to track multiple handlers) seems less confusing than trying to repurpose addJavaScriptChannel. We can override that to provide a specific UnimplementedError that points to the alternate API.

@ditman Does that sound good to you?

@stuartmorgan-g
Copy link
Collaborator

@ditman Ping on the API question above.

Copy link
Member

@ditman ditman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @stuartmorgan in that this is a little bit too much shoe-horning of the existing API into postMessage.

On top of the caveats mentioned by the author, this has an issue where every message will end up being "handled" by every possible registered channel.

IMO this feature should be designed a little bit more to address some of the concerns:

  • How to prevent the * accepting messages?
  • How to prevent handlers from reading messages that they don't care about?
  • How to make this extensible so 2-way communication is doable? (we don't need to implement it at first, but at least have a plausible plan to get it)
  • How to not rely on static objects present on the page, like window.flutterApp or similar.

Some of the issues can be mitigated by creating new methods for the web version to align the API closer to what the web can do, but others need a little bit more thought, for example:

  • Can we define a message format that must be passed to postMessage that the plugin (and receiving iframe) will refuse to handle if it's not correct (instead of calling .toString on the data)?
  • Can we provide utilities for the iframed content to be easily setup in "collaborative mode" with the parent flutter App? This is to ensure that the message handlers in the iframe only listens to the messages we send from flutter.

I think we need a small writeup to solve this (a detailed Issue would be probably enough), rather than jumping the gun. We'll have to live with these APIs for a long time, after all :/

}

javascriptChannels[javaScriptChannelParams.name] = handler;
html.window.addEventListener('message', handler);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a programmer registers multiple 'message' handlers (one per name), this means that every onMessageReceived registered will be called for every message received, right? I think this is potentially expensive. Also calling .toString() on the object that is being passed around is not great, you lose the ability to move around Transferable objects, which might be interesting to users of this API.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a programmer registers multiple 'message' handlers (one per name), this means that every onMessageReceived registered will be called for every message received, right? I think this is potentially expensive.

That is true. This can easily happen when you have multiple webviews in a single application. Since the webviews do not know anything from each other, we did not see another option. I think if you have a low amount of handlers (e.g. a handful), there is no performance hit.

Also calling .toString() on the object that is being passed around is not great, you lose the ability to move around Transferable objects, which might be interesting to users of this API.

On Android and iOS it is only possible to send string messages. We tried to mimic that API by limiting the allowed message types to string-values only. This way everything can be handled the same way in Flutter (i.e. all incoming messages are always string). If one wants to send complex messages one can always send a stringified object.

`flutterApp`, the following construction can be used in the web application:

```ts
if (window.flutterApp) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is window.flutterApp? Who's setting this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

webview_flutter is setting this on iOS and Android. If it's not available we assume we are on web and use the alternative.

@Frank3K
Copy link
Contributor Author

Frank3K commented Jul 10, 2023

Thanks for your replies.

On top of the caveats mentioned by the author, this has an issue where every message will end up being "handled" by every possible registered channel.

That is true. We improved this in a follow-up PR, while awaiting a response on this PR. By using unique channel names it is possible to ensure a message is only processed once. The improved version can be found in gynzy#2.

IMO this feature should be designed a little bit more to address some of the concerns:

  • How to prevent the * accepting messages?

An additional security improvement is included here on the dart side: https://github.com/gynzy/flutter-packages/blob/cd8452c89454ff24677aebf2795cb5704afc7ceb/packages/webview_flutter/webview_flutter_web/lib/src/web_webview_controller.dart#L126-L129.

On the web side, when sending a message, the following code can be used:

const HOST_NAME_ALLOWLIST = [...]; // Allowed hosts
...
function getTargetOrigin() {
    if (!document.referrer) {
        return;
    }

    const parentUrl = new URL(document.referrer);
    if (!HOST_NAME_ALLOWLIST.includes(parentUrl.hostname)) {
        return;
    }

    return parentUrl.origin;
}

...

const targetOrigin = getTargetOrigin();
if (targetOrigin) {
    window.parent.postMessage(message, targetOrigin);
}

Since the webview is implemented as an iframe on web, document.referrer equals the domain of the application (i.e. the iframe parent).

  • How to prevent handlers from reading messages that they don't care about?

I think this is solved by gynzy#2.

  • How to make this extensible so 2-way communication is doable? (we don't need to implement it at first, but at least have a plausible plan to get it)

If I understand correctly, on Android and iOS the JavaScript channels can also only be used for one-way communication. This PR makes it possible to do that same communication on web. Communication in the other direction can be done using something like: #3655 (comment).

  • How to not rely on static objects present on the page, like window.flutterApp or similar.

The window.flutterApp comes from the existing webview_flutter package and was not introduced in this PR. From the codelab: For each JavaScript Channel in the Set, a channel object is made available in the JavaScript context as a window property named with the same name as the JavaScript Channel name.

Some of the issues can be mitigated by creating new methods for the web version to align the API closer to what the web can do, but others need a little bit more thought, for example:

  • Can we define a message format that must be passed to postMessage that the plugin (and receiving iframe) will refuse to handle if it's not correct (instead of calling .toString on the data)?
  • Can we provide utilities for the iframed content to be easily setup in "collaborative mode" with the parent flutter App? This is to ensure that the message handlers in the iframe only listens to the messages we send from flutter.

I think we need a small writeup to solve this (a detailed Issue would be probably enough), rather than jumping the gun. We'll have to live with these APIs for a long time, after all :/

I agree that a well-thought solution is the way to go. What we tried to do is to implement the JavaScript channels in such a way that they equal the iOS and Android implementation as close as possible. A result of this is that we restricted postMessage to only send string values, since only string values are supported on iOS and Android. As mentioned, some improvements have been included in a follow-up PR.

Curious to hear your thoughts on how to proceed. Should we go back to the drawing board or is the improvements PR a step in the right direction and can we build upon that?

@stuartmorgan-g
Copy link
Collaborator

Curious to hear your thoughts on how to proceed. Should we go back to the drawing board or is the improvements PR a step in the right direction and can we build upon that?

A short design document listing options with pros and cons would be the best place to discuss and evaluate that.

@stuartmorgan-g
Copy link
Collaborator

@Frank3K Are you still interested in moving this forward via the design doc process described above?

@Frank3K
Copy link
Contributor Author

Frank3K commented Aug 7, 2023

@Frank3K Are you still interested in moving this forward via the design doc process described above?

I'm definitely interested in bringing this functionality forward. I'd be glad to help out on the design document, but don't feel confident in setting up the design document myself. I understand your reasoning for an alternative API; we are however already using the initial proposal (implementing the JavaScript channel on web using postMessage) on production.

@stuartmorgan-g
Copy link
Collaborator

I'd be glad to help out on the design document, but don't feel confident in setting up the design document myself.

There are instructions in the template linked from that page; we're happy to answer any questions about those instructions.

(I'm going to mark this PR as a draft for now, so it doesn't show up in our review queue, pending a design doc where there's agreement on the approach to take.)

@stuartmorgan-g stuartmorgan-g marked this pull request as draft August 7, 2023 15:12
@stuartmorgan-g
Copy link
Collaborator

Since this is marked as a draft and hasn't been updated in several months I'm going to close it to clean out our review queue. Please don't hesitate to submit a new PR if you decide to revisit this. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants