Building a Google Chrome Extension with Flutter and Stream

...

“Build apps for any screen.”

This is the first thing one sees on the Flutter website, and rightly so. Apart from the conventional form factors like mobile, tablet, and desktop, Flutter has also been run on gaming consoles, IoT devices and unconventional displays which is a testament to how flexible the framework is. This article will explore how to use Flutter to build chrome extensions with the Stream Chat SDK for a fun project.

How Browser Extensions Differ From Webpages

As the name suggests, extensions are supposed to be an “extension” of the functionalities a browser provides. Extensions have access to wide variety of APIs which are not accessible to websites. It is the responsibility of the browser to prevent an extension from abusing this. This makes extensions very similar to how apps work on mobile devices.

There needs to be an intro sentence to these bullets, why are they here?

  • An extension project has a manifest.json. This contains all the necessary information like permissions, service workers etc.
  • This is very similar to AndroidManifest.xml in Android and Info.plist and other config files in iOS.
  • Actions - This API decides what to do when user clicks on extension icon, whether to run some code or show a popup.
  • Content scripts - These are scripts which can manipulate the DOM of the current website.
  • Service workers - Scripts which don’t deal with DOM manipulation and can run in background.

For a browser extension, the engine is what Android and iOS is to a mobile app. Most browsers (Google Chrome, Edge, Opera, Brave etc) are Chromium-based. Although Firefox is based on Quantum and Safari is based on Webkit engine.

To keep this article easy to follow all references to browser extensions refer to Chrome extensions (unless specified) and Chrome APIs will be used to make the extension. You can find a comprehensive list of incompatibilities of Chrome APIs with others here.

Overview of the demo extension

In this tutorial you will build a chrome extension called “Webside Chat”. Using this simple extension, users who are on the same webpage can chat with each other as easily as clicking a button.

Setting Up the Project

Make a new Flutter project with the usual command.

flutter create webside_chat

Now open this project in your IDE. You will notice that there are android, iOS , web and perhaps more such platform specific folders. You can delete all such folders except web. The folder structure should look similar to this.

Upload Chrome Extension tooltip

This project can be run as-is on web using the following command

flutter run -d Chrome

This opens the default counter app in Chrome.

You need to make modifications to run this as a chrome extension.

Step 1 - index.html

Remove all the code in index.html and replace it with the following

<html style="height: 600px; width: 300px">
  <head>
    <meta charset="UTF-8" />
    <title>Webside Chat</title>
    <head>
      <script src="utils.js" defer></script>
  </head>
  </head>
  <body>
    <script src="main.dart.js" type="application/javascript"></script>
  </body>
</html>

All the script tags are removed, replaced by a single tag loading main.dart.js, this is the Flutter code compiled as Javascript code.

Also note that in html window height and width have been assigned explicitly. This is the size of window that opens on clicking over the extension.

Step 2 - manifest.json

As discussed previously, manifest.json serves an important function in Chrome extensions. Open the file and replace the contents with the following

{

  "name": "Webside chat",

  "description": "Chat with people who are on the same website",

  "version": "1.0.0",

  "content_security_policy": {

    "extension_pages": "script-src 'self' ; object-src 'self'"

  },

  "permissions": ["activeTab"],

  "action": {

    "default_popup": "index.html",

    "default_icon": "icons/Icon-192.png"

  },

  "manifest_version": 3

}

default_popup property defines which .html file to open when user clicks on the icon.

permissions define the permissions an extension requires to function. These are shown to a user before they install the extension. activeTab permission is required for this demo to get URL of the current page the user is on. You can learn more about this permission here.

Note about Content Security Policy

To prevent Cross-site scripting, every mainstream web product follows the same-origin-policy. It means code from a specific website (for eg - google.com) should have access only to data from google.com and not other websites. In the current manifest.json, "extension_pages": "script-src 'self' ; object-src 'self'" means that this extension can only execute scripts of its own and not from other origins. No bad actor can hijack this extension to run suspicious scripts.

Step 3 - Build

Flutter web has two types of renderers

  • Canvaskit - Better performance, more pixel-perfect UI, but adds 2 MB to the bundle
  • HTML Renderer - Significantly lower bundle size as it uses a combination of HTML, CSS and canvas elements to recreate the UI

Unless specified, Flutter web uses Canvaskit for desktop and html renderer for mobile devices. However currently Canvaskit renderer is not supported in Chrome extensions. So you need to add --web-renderer html flag to the build command.

Build command for flutter web also contains dynamically generated code. This gives CSP errors when loaded in Chrome. To prevent dynamically generated code, add --csp flag to the build command.

Final command to build this Flutter web project as a Chrome extension is

flutter build web --web-renderer html --csp

Step 4 - Load Extension in Chrome

Open the following URL in Chrome chrome://extensions/

  • First enable Developer Mode
  • Then click on Load unpacked, select the webside_chat/build/web folder.

  • Click on Extensions button in toolbar. It shows the extension.

Clicking on the extension reveals a familiar sight!

Create a new Stream App

Setting up Stream

Create a Stream Chat Account

Sign up for a Stream account, if you don’t have one already.

If you are a new user, you will see an app already created for you, by the same name as your organization. If you are an existing user, you can create a new app.

Webside project structure

The Stream Chat SDK uses JWT to authenticate users. In production applications, this JWT should always come from the server, as generating a JWT requires a secret key, and it is never a good idea to ship the secret key to client apps. For the purpose of this demo, we can enable development mode for our project with authentication disabled.

To enable development mode, from Apps, click on webside-chat. On the top bar, select App SettingsEdit App . Then toggle the switch to Development and save.

Chrome Extension permission

Next, we need to disable authentication for users. On the same page, scroll down to the Authentication section and toggle Disable Auth Checks switch to on. We also select Enforce Unique Usernames to No because we will be using a simple random username generator for usernames, which may produce non-unique results.

Refresh Chrome extension

Now open pubspec.yaml and add the following packages

  stream_chat_flutter: ^5.1.0
  unique_name_generator: ^1.0.0
  crypto: ^3.0.2

unique_name_generator - This package is used to generate a username for a user. It is generated when a user opens the extension for the first time and is stored in local storage for further use.

crypto - Used to generate unique IDs for creating channels.

Now open main.dart and replace all the code with the following, as mentioned in the tutorial

import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

const apiKey = 'b67pax5b2wdq';

void main() async {
  final client = StreamChatClient(
    apiKey,
    logLevel: Level.INFO,
  );

  await client.connectUser(
    User(id: 'tutorial-flutter'),
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZmx1dHRlciJ9.S-MJpoSwDiqyXpUURgO5wVqJ4vKlIVFLSEyrFYCOE1c',
  );

  final channel = client.channel('messaging', id: 'flutterdevs');
  await channel.watch();

  runApp(
    MyApp(
      client: client, 
      channel: channel,
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({
    Key? key,
    required this.client,
    required this.channel,
  }) : super(key: key);

  final StreamChatClient client;
  final Channel channel;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      builder: (context, widget) {
        return StreamChat(
          client: client,
          child: widget,
        );
      },
      home: StreamChannel(
        channel: channel,
        child: const ChannelPage(),
      ),
    );
  }
}

class ChannelPage extends StatelessWidget {
  const ChannelPage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const StreamChannelHeader(),
      body: Column(
        children: const <Widget>[
          Expanded(
            child: StreamMessageListView(),
          ),
          StreamMessageInput(),
        ],
      ),
    );
  }
}

Now build this project. Then open chrome://extensions page and refresh the extension. Opening the extension now

Screenshot of Stream chat running as an extension

The extension shows a proper chat UI, with some messages already there, which appear to be sent by you. We will address this issue, but first, let’s analyse the code above.

Google Chrome testing profile

Stream Chat Client

  final client = StreamChatClient(
    apiKey,
    logLevel: Level.INFO,
  );

This creates a client instance of your Stream chat project, in this case webside-chat. In the above example, the apiKey used is that of a demo account, which must have been used by many previously, and hence we see a list of messages already. Replace this apiKey with the API key found in your project details page before proceeding further.

Stream Chat User

await client.connectUser(
    User(id: 'tutorial-flutter'),
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZmx1dHRlciJ9.S-MJpoSwDiqyXpUURgO5wVqJ4vKlIVFLSEyrFYCOE1c',
  );

This logs a user into the stream chat project. Each User object has an id and a corresponding token, which here is a hardcoded demo token. As discussed previously, this token should always come from server in production apps, but we can use development tokens for the purposes of this tutorial. You can learn more about tokens here.

A token is mapped to a user’s id. So different user id’s cannot use the same token. Development tokens can be generated using

String token = client.devToken(username).rawValue;

Stream Chat Channel

  final channel = client.channel('messaging', id: 'flutterdevs');

All messages in Stream are sent in channels. A channel contains a list of all the members who are permanently associated with it and watchers who are just watching the channel. By default there are five types of channels:

  1. Livestream
  2. Messaging
  3. Team
  4. Gaming
  5. Commerce

In the above example, the channel type is messaging and the channel id is flutterdevs. For our purposes, livestream suits the best as it is a channel where everyone has equal access, so we change it to livestream. We also need to generate a different id for different channels as each website a user is on should have a channel of its own. This will be addressed ahead.

Stream chat UI components

  Widget build(BuildContext context) {
    return MaterialApp(
      builder: (context, widget) {
        return StreamChat(
          client: client,
          child: widget,
        );
      },
      home: StreamChannel(
        channel: channel,
        child: const ChannelPage(),
      ),
    );
  }

Stream Chat does a great job at providing many pre-built UI components required for making a messaging app. In this tutorial, we will be using StreamChannel, StreamMessageListView, StreamMessageInput and StreamChannelHeader. All these widgets are provided the necessary data using InheritedWidget, so it’s necessary to wrap the whole app with StreamChat, which is responsible for providing the data to all the child widgets.

StreamChannel takes care of providing all the data and operations necessary pertaining to a channel, like fetching messages, sending messages, pagination, etc.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const StreamChannelHeader(),
      body: Column(
        children: const <Widget>[
          Expanded(
            child: StreamMessageListView(),
          ),
          StreamMessageInput(),
        ],
      ),
    );
  }

This is the UI for the messaging screen. Because of all the pre-built widgets, it is very simple to implement such a complex messaging UI. Now that the messaging functionality is up and running, let’s customize it according to our use case.

Implementing Webside chat

The following flowchart shows the steps we need to follow to achieve our functionality.

Application flowchart

Using window.storage as a persistent storage
Why generate random usernames and store usernames in local storage?

  • Messages sent by the user appear on the right side, and messages by other users appear on the left hand side.
Open a Stream Account
  • If the user name is same for all, the tokens are also going to be same for all. So all the messages will appear only on the left, as seen in this picture before
Copy API Key on the Stream dashboard
  • If the generated username is not stored locally, every time user reopens the extension, a new token is generated when a new username is generated, and messages which were sent by the user previously appear like they were sent by someone else.

We usually use shared_preferences , hive etc for persistent local storage. But because we are developing on web, we get a nifty feature called localstorage. window is accessible natively in JS. In dart, this is provided by dart:html. This can be used for storing small amounts of data across sessions.

For generating random user names we are using unique_name_generator

  String getUsername() {
    if (window.localStorage.containsKey('username')) {
      return window.localStorage['username']!;
    } else {
      String username = UniqueNameGenerator(
          separator: '-',
          style: NameStyle.lowerCase,
          dictionaries: [
            adjectives,
            animals,
            colors,
          ]).generate();
      window.localStorage['username'] = username;
      return username;
    }
  }

Using JS interoperability to get URL of current window

Let’s say the user currently has two windows open, google.com and twitter.com. When a user is on the first website, they should see messages only of people who are on google.com and vice versa. This means that for each website, there should exist a separate channel.

Achieving this in JS is very straightforward

chrome.tabs.query({'active': true, 'currentWindow':true}, 
function(tabs){
        getCurrentURL(tabs[0].url);
});

All we need is to invoke this function from dart code. There are a couple of methods to achieve this.

  • Method 1 - Have this code in a javascript file, then invoke that code from dart as described in this snippet by fireship
  • Method 2 - Use JS interoperability capability of dart using dart:js to execute this code directly.

We get the current URL using the second method

import 'dart:js' as js;

void getUrl() {
  var queryInfo = js.JsObject.jsify({'active': true, 'currentWindow': true});
  js.context['chrome']['tabs']?.callMethod('query', [
    queryInfo,
    (tabs) async {
      var url = tabs[0]['url'];
    }
  ]);
}

From the API docs of dart:js

  1. This library provides access to JavaScript objects from Dart, allowing Dart code to get and set properties, and call methods of JavaScript objects and invoke JavaScript functions. The library takes care of converting between Dart and JavaScript objects where possible, or providing proxies if conversion isn't possible.

Data cannot be passed directly between Dart and JS, hence we need to use js.JsObject.jsify to convert data into JS Objects. This package, along with js package are very powerful tools to achieve complex functionalities on Flutter web.

Piecing everything together

For a cleaner structure, create a new file called chat_screen.dart. Now open main.dart and replace all the code with the following

import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:unique_name_generator/unique_name_generator.dart';
import 'dart:html';
import 'package:webside_chat/chat_screen.dart';

// Replace this with your API key

const apiKey = "my8m32m792hh";

String getUsername() {
  late String username;

  if (window.localStorage.containsKey('username')) {
    username = window.localStorage['username']!;
  } else {
    username = UniqueNameGenerator(
        separator: '-',
        style: NameStyle.lowerCase,

        dictionaries: [
          adjectives,
          animals,
          colors,
        ]).generate();
    window.localStorage['username'] = username;
  }
  return username;
}

void main() async {
  final client = StreamChatClient(
    apiKey,
    logLevel: Level.INFO,
  );

  final username = getUsername();

  await client.connectUser(
    User(id: username),
    client.devToken(username).rawValue,
  );

  runApp(MaterialApp(
    builder: (context, child) {
      return StreamChat(client: client, child: child);
    },
    home: WebsideApp(
      client: client,
    ),
  ));
}

Now open chat_screen.dart and copy the following code.

import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'dart:js' as js;

class WebsideApp extends StatefulWidget {
  const WebsideApp({super.key, required this.client});
  final StreamChatClient client;

  @override
  State <WebsideApp> createState() => _WebsideAppState();
}

class _WebsideAppState extends State<WebsideApp> {
  Channel? channel;

  @override
  void initState() {
    super.initState();
    getUrl();
  }

  void getUrl() {
    var queryInfo = js.JsObject.jsify({'active': true, 'currentWindow': true});
    js.context['chrome']['tabs']?.callMethod('query', [
      queryInfo,
      (tabs) async {
        var url = tabs[0]['url'];
        channel = widget.client.channel(
          'livestream',
          id: md5.convert(utf8.encode(url)).toString(),

          extraData: {
            'name': tabs[0]['title'],
          },
        );

        await channel!.watch();
        setState(() {});
      }
    ]);
  }

  @override
  Widget build(BuildContext context) {

    if (channel == null) {
      return const Center(child: CircularProgressIndicator());
    }

    return StreamChannel(
      channel: channel!,
      child: Scaffold(
        appBar: const StreamChannelHeader(
          showBackButton: false,
        ),
        body: Column(
          children: const [
            Expanded(child: StreamMessageListView()),
            StreamMessageInput()
          ],
        ),
      ),
    );
  }
}

The URL we get is being converted to a hashed string using crypto package. This is to ensure that each URL has a unique and deterministic ID, which is the same when generated on any device.

Now build the project and refresh. For testing, I created a new profile in Chrome and loaded the extension there as well.

Google Chrome testing profile

Open any website on both the windows and open the Webside chat extension and then send a message.

Wrapping up

This article is a practical demonstration of how flexible Flutter is as a framework andhow easy it is to implement complex messaging features using the Stream Chat SDK.