FlutterでGoogle Chromeの拡張機能を作ってみた

Flutterエンジニアの井戸田です。 今回はFlutterでGoogleChrome拡張機能を作ることができることを知ったので、その方法を紹介します。

はじめに

この記事はMobility Technologies Advent Calendar 2022の9日目です。

プロジェクトの作成

まずはFlutterでGoogleChrome拡張機能を作るため、Javascript系で動作するFlutter Webのプロジェクトを生成します。

$ flutter create --platforms web chrome_extension_sample

上記のコマンド実行した結果、下記のプロジェクトが生成されます。

.
├── README.md
├── analysis_options.yaml
├── chrome_extension_sample.iml
├── lib
│   └── main.dart
├── pubspec.lock
├── pubspec.yaml
├── test
│   └── widget_test.dart
└── web
    ├── favicon.png
    ├── icons
    │   ├── Icon-192.png
    │   ├── Icon-512.png
    │   ├── Icon-maskable-192.png
    │   └── Icon-maskable-512.png
    ├── index.html
    └── manifest.json

Flutter Webとしてプロジェクトを生成したので、Webアプリとして実行してみます。

$ flutter run -d chrome

home.png

manifest.jsonの編集

/web フォルダを見てみると manifest.json がありますが、こちらはPWA機能のために用意されています。(PWAとはProgressive Web Appsの略で、ウェブアプリをネイティブアプリのような体験を提供するための技術です。) 拡張機能でも manifest.json が必要なので、書き換えていきます。

Before

{
    "name": "chrome_extension_sample",
    "short_name": "chrome_extension_sample",
    "start_url": ".",
    "display": "standalone",
    "background_color": "#0175C2",
    "theme_color": "#0175C2",
    "description": "A new Flutter project.",
    "orientation": "portrait-primary",
    "prefer_related_applications": false,
    "icons": [
        {
            "src": "icons/Icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "icons/Icon-512.png",
            "sizes": "512x512",
            "type": "image/png"
        },
        {
            "src": "icons/Icon-maskable-192.png",
            "sizes": "192x192",
            "type": "image/png",
            "purpose": "maskable"
        },
        {
            "src": "icons/Icon-maskable-512.png",
            "sizes": "512x512",
            "type": "image/png",
            "purpose": "maskable"
        }
    ]
}

After

{
  "manifest_version": 3,
  "version": "1.0.0",
  "name": "chrome extension sample",
  "description": "A Flutter chrome extension",
  "content_security_policy": {
    "extension_pages": "script-src 'self' ; object-src 'self'"
  },
  "action": {
    "default_popup": "index.html",
    "default_icon": {
      "16": "icons/Icon-192.png",
      "32": "icons/Icon-192.png",
      "48": "icons/Icon-192.png",
      "128": "icons/Icon-192.png"
    }
  },
  "icons": {
    "16": "icons/Icon-192.png",     
    "32": "icons/Icon-192.png",
    "48": "icons/Icon-192.png",
    "128": "icons/Icon-192.png"
  }
}
  • manifest_version
    • manifest.jsonのバージョンを指定。最新は 3 です
  • version
    • 拡張機能のバージョンを指定。Chromeウェブストアにも表示されます
  • name
  • description
  • content_security_policy
    • Content Security Policyを指定する(後述)
  • action
    • default_icon
      • 拡張機能ツールバーに表示されるアイコン
      • 上の指定ではFlutterプロジェクト作成時から用意されている192pxのアイコンを使用していますが、実際に公開する際は指定にあったサイズのアイコンを設定する必要があります。
    • default_popup
  • icons
    • ウェブストアやfaviconなどに使われます
    • 上の指定ではdefault_iconと同様にFlutterプロジェクト作成時から用意されている192pxのアイコンを使用していますが、16x16px, 32x32px, 48x48px, 128x128pxのアイコンを用意する必要があります。

詳しくは公式ドキュメントを参照してください。

Content Security Policy(CSP)

CSPはXSSやデータインジェクション攻撃などを検知し、ユーザーを保護するためのブラウザのセキュリティ層です。 manifest.jsonでは content_security_policy"extension_pages": "script-src 'self' ; object-src 'self'" と指定しました。 その結果ブラウザはインラインスクリプトや別のオリジンからのスクリプトを実行しません。今回はindex.htmlにインラインスプリントが含まれているためCSPエラーが発生します。

エラーを解消するために web/index.html 内のscriptタグを全て削除し、bodyタグ内に <script src="main.dart.js" type="application/javascript"></script> を埋め込みます。 main.dart.js はビルド時に生成されるトランスパイルされたFlutterのコードです。

Before

<!DOCTYPE html>
<html>
<head>
  <base href="$FLUTTER_BASE_HREF">

  <meta charset="UTF-8">
  <meta content="IE=Edge" http-equiv="X-UA-Compatible">
  <meta name="description" content="A new Flutter project.">

  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="chrome_extension_sample">
  <link rel="apple-touch-icon" href="icons/Icon-192.png">
  <link rel="icon" type="image/png" href="favicon.png"/>
  <title>chrome_extension_sample</title>
  <link rel="manifest" href="manifest.json">

  <script>
    var serviceWorkerVersion = null;
  </script>
  <script src="flutter.js" defer></script>
</head>
<body>
  <script>
    window.addEventListener('load', function(ev) {
      _flutter.loader.loadEntrypoint({
        serviceWorker: {
          serviceWorkerVersion: serviceWorkerVersion,
        }
      }).then(function(engineInitializer) {
        return engineInitializer.initializeEngine();
      }).then(function(appRunner) {
        return appRunner.runApp();
      });
    });
  </script>
</body>
</html>

After

<!DOCTYPE html>
<html>
<head>
  <base href="$FLUTTER_BASE_HREF">
  <meta charset="UTF-8">
  <meta content="IE=Edge" http-equiv="X-UA-Compatible">
  <meta name="description" content="A new Flutter project.">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="chrome_extension_sample">
  <link rel="apple-touch-icon" href="icons/Icon-192.png">
  <link rel="icon" type="image/png" href="favicon.png"/>
  <title>chrome_extension_sample</title>
  <link rel="manifest" href="manifest.json">
</head>
<body>
  <script src="main.dart.js" type="application/javascript"></script>
</body>
</html>

web/index.htmlの整理の続き

CSPのエラーは発生しなくなりました。続いて下記の2つを修正していきます。

  • 不要なメタタグなどを削除
  • htmlタグにサイズを指定

不要なメタタグなどを削除

Safari用のメタタグなど拡張機能を実装する上では不要なタグがあるので削除します。

Before

<!DOCTYPE html>
<html>
<head>
  <base href="$FLUTTER_BASE_HREF">
  <meta charset="UTF-8">
  <meta content="IE=Edge" http-equiv="X-UA-Compatible">
  <meta name="description" content="A new Flutter project.">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="chrome_extension_sample">
  <link rel="apple-touch-icon" href="icons/Icon-192.png">
  <link rel="icon" type="image/png" href="favicon.png"/>
  <title>chrome_extension_sample</title>
  <link rel="manifest" href="manifest.json">
</head>
<body>
  <script src="main.dart.js" type="application/javascript"></script>
</body>
</html>

After

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>chrome_extension_sample</title>
</head>
<body>
  <script src="main.dart.js" type="application/javascript"></script>
</body>
</html>

htmlタグにサイズを指定

index.htmlは「manifest.jsonの編集」で記載した通り、アイコンをタップした時に表示されるHTMLです。 表示させたいサイズを指定します。

<html style="height: 500px; width: 300px">

ちなみにサイズを指定しないと下の画像の様に表示されます。

index_failed.png

flutter buildコマンドを叩き必要なファイルを生成する

$ flutter build web を叩くと、モバイルブラウザ場合HTML renderer、PCブラウザの場合CanvasKit rendererが使用されます。 しかしCanvasKitでは既知の問題があるため --web-renderer html オプションをつけ、常にHTML rendererを使用するようにします。実際のissueがこちらです。

またCSP の制限を満たすために --csp をつけます。

よって下記のコマンドを実行します。

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

拡張機能デバッグする

1. Google ChromeでURL入力欄に chrome://extensions を入力

2. 右上のデベロッパーモードをONにする

developer-mode.png

3. 「パッケージ化されていない拡張機能を読み込む」をクリック

embed_package.png

4. chrome_extension_sample/build/web/ を選択する

上記を行えば下の画像が表示されると思います。 ちなみに一度追加するとリンクされるので、再度 $ flutter build をした時は、右下のリフレッシュアイコンをクリックすれば読み込まれます。

スクリーンショット 2022-11-18 10.37.13.png

右上の拡張機能のアイコンをクリックすると、追加した拡張機能をクリックすると表示されると思います。

index_successfull.png

FlutterでChrome ExtensionのAPIを使う

最後にJavascriptで提供しているChrome ExtensionのAPIをFlutterで使う方法を解説します。 公式ドキュメントはこちらです。

今回は現在開いているタブのURLを表示していきます。

web側の実装

タブのURLを取得するためには Tabs APIを使用します。

Tabs APIドキュメントはこちらです。

1. manifest.jsonにpermissionを追加

manifest.jsonpermissionsを追加し、そこに tabs を追加。

{
    "manifest_version": 3,
    "version": "1.0.0",
    "name": "chrome extension sample",
    "description": "A Flutter chrome extension",
    "content_security_policy": {
        "extension_pages": "script-src 'self' ; object-src 'self'"
    },
    "action": {
        "default_popup": "index.html",
        "default_icon": {
            "16": "icons/Icon-192.png",
            "32": "icons/Icon-192.png",
            "48": "icons/Icon-192.png",
            "128": "icons/Icon-192.png"
        }
    },
    "icons": {
      "16": "icons/Icon-192.png",     
      "32": "icons/Icon-192.png",
      "48": "icons/Icon-192.png",
      "128": "icons/Icon-192.png"
    },
    // 追加
    "permissions": [
        "tabs"
    ]
}

2. 現在開いているタブのURLを取得するコードを追加

/web ディレクトリに chrome_api.js を追加します。

async function getUrl() {
  let queryOptions = { active: true, currentWindow: true };
  let [tab] = await chrome.tabs.query(queryOptions);
  return tab.url;
}

3. index.htmlでchrome_api.jsを読み込む

index.htmlchrome_api.js を読み込みます。

<!DOCTYPE html>
<html style="height: 500px; width: 300px">

<head>
  <meta charset="UTF-8">
  <title>chrome_extension_sample</title>
  <link rel="manifest" href="manifest.json">
</head>

<body>
  <!-- 追加 -->
  <script src="chrome_api.js" type="application/javascript"></script>
  <script src="main.dart.js" type="application/javascript"></script>
</body>

</html>

これでweb側の実装は終わりです!

Flutter側の実装

js をインストール

Flutter側からJavaScript側のコードを呼び出すために js というライブラリを使います。

https://pub.dev/packages/js

dependencies:
  js: ^0.6.5

chrome_api.dartを作成

/libchrome_api.dartを作成し、下記のように実装します。

// JavaScriptのファイル名を指定
@JS()
library chrome_api;

import 'package:js/js.dart';
import 'package:js/js_util.dart';

// JavaScriptのメソッドを指定
@JS('getUrl')
external Object _getUrl();

Future<String> getUrl() async {
  // `promiseToFuture` メソッドを使い、JavaScriptのPromiseをDartのFutureに変換
  return promiseToFuture<String>(_getUrl());
}

MyHomePageにURLを表示する

今回はFutureBuilderを使って、URLを表示させます。

import 'package:chrome_extension_sample/chrome_api.dart';

class _MyHomePageState extends State<MyHomePage> {

  // ...

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
      body: Center(
        child: FutureBuilder(
          initialData: 'initial data',
          future: getUrl(),
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              return Text(snapshot.data!);
            } else {
              return const Text('no data');
            }
          },
        ),
      ),
    );
  }
}

実行

まずはflutter buildします。

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

次に chrome://extensionsに表示されているリフレッシュアイコンをクリック。

スクリーンショット 2022-11-18 10.37.13 2.png

MyHomePageに現在開いているURLが表示されることを確認しました。

スクリーンショット 2022-11-28 18.01.42.png

※ もし「no data」と表示されてしまう場合はGoogle Chromeを再起動してみてください

最後に

Chrome拡張の申請に関しては、色々な記事があるので省略させていただきますが、ChromeWebStoreというサイトで申請します。会員登録で初回だけ5$がかかります。また拡張機能の審査には最大30日間かかると書かれており、私が申請した時には審査が通るまで約1週間弱かかりました。

Flutterでも作るれることが分かり試してみましたが、意外と簡単に作ることができると知りました。皆さんも拡張機能を作りたいと思ったら、Flutterで作ってみるのはいかがでしょうか。

ここまで読んでいただきありがとうございました!