Dart
chrome-extension
AngularDart

Chrome Extension inline installationをDartから扱う

More than 1 year has passed since last update.

MyLexiconというWebアプリとそのChrome ExtensionをDartで作ったのですが、Chrome Extensionのinline installationの実装にすこし苦労したのでまとめておきます。
Dartから扱う場合のノウハウを主に記載します。Chrome Extension inline installation自体の方法については、公式のドキュメンテーションおよびQiitaの以下のよくまとまった記事を参照すると良いです。

https://developer.chrome.com/webstore/inline_installation
https://qiita.com/komasshu/items/f70984b3ecc985e916f3

chrome.webstore.install

JS Interop

chrome.webstore.installはchromeブラウザのAPIなので、chrome appおよびchrome extensionのinteropを提供するpackage:chromeには当然ながらAPIが用意されていません。またchromeブラウザ専用のAPIであるからなのか、dart:htmlにもAPIはありませんでした。
そのためDartからこのAPIをcallするために、interopコードを以下のように書きました。

chrome_interop/webstore
@JS('chrome')
library chrome_interop;

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

/// See https://developer.chrome.com/extensions/webstore
@JS('webstore')
abstract class Webstore {
  external static void install(
      [String extensionId,
      VoidFunc0 successCallback,
      VoidFunc2<String, String> failureCallback]);
}

上記の単なるinteropコードのままでは扱いづらいため、さらにラッパー関数を定義しました。

install_chrome_extension.dart
import 'dart:async';

import 'package:async/async.dart' show Result;
import 'package:js/js.dart';
import 'package:tuple/tuple.dart';

import 'chrome_interop/webstore.dart';

/// Process a Chrome Extension inline installation.
///
/// If the extensionId argument is empty, a web app MUST have
/// "link rel="chrome-webstore-item" meta tag with an extension id
/// for the installation.
///
/// The returned value [Result]'s tuple2 is null because the original API
/// returns no information.
/// The returned error [Result]'s tuple2 contains the errorMessage and errorCode.
///
/// See https://developer.chrome.com/webstore/inline_installation#triggering
/// TODO: Define enum for the ErrorCode
Future<Result<Tuple2<String, String>>> installChromeExtension(
    [String extensionId = '']) async {
  final completer = new Completer<Result<Tuple2<String, String>>>();

  Webstore.install(
      extensionId,
      allowInterop(() => completer.complete(new Result.value(null))),
      allowInterop((String errorMessage, String errorCode) => completer
          .complete(new Result.error(new Tuple2(errorMessage, errorCode)))));

  return completer.future;
}

Interop関数の引数にDartの関数をそのまま渡すことはできないので、allowInterop関数でくるんでいます。
また、コールバックのAPIは扱いづらいため、Futureを返すようにしました。さらに、インストールエラーのケースを非同期の例外処理で扱うのではなく、Resultを使用しています。Dartでは非同期処理の例外処理も同期処理と同様にtry-cachで統一的に扱えるので苦ではないのですが、Resultオブジェクトを返すことでこのAPI使用側にエラーケースを処理する必要があることを明確に伝えることができます。
その他詳細は上記Doc Commentをごらんください。

UI Component

UI側はここではAngularDartを前提に説明します。
Inline installationはひとつの独立した関心事であるので、mixin用のclassを定義してcomponent classにmixinするようにしました。

chrome_extension_inline_installer.dart
import 'dart:async';
import 'dart:html';

import 'package:web_app/home/chrome_extension_inline_installer/install_chrome_extension.dart';

abstract class ChromeExtensionInlineInstaller {
  bool isChromeExtensionInstalled = true;

  void checkChromeExtensionIsInstalled() {
    isChromeExtensionInstalled = document.body
            .querySelector('#mylexicon-chrome-extension-is-installed') !=
        null;
  }

  Future handleInstallingChromeExtension() async {
    final result = await installChromeExtension();
    if (result.isValue) {
      // The installation is successful.
      // TODO: Invoke success UI such as SnackBar.
      isChromeExtensionInstalled = true;
    } else {
      /// TODO: Handle the error cases.
    }
  }
}

まずはcheckChromeExtensionIsInstalled()ですでにextensionがインストール済みであるかどうかを判定します。公式のドキュメンテーションで紹介されている方法に従い、extensionがインストール済みである場合は指定のelementが存在するようにChrome Extensionのcontent scriptを書きます。(後述)

handleInstallingChromeExtension()においてinline installationの実行および実行結果を受けてのインストール成功後の処理、またはインストールエラーのハンドリングを行います。

このmixin用classをAngularのcomponentにmixinします。

some_component.dart
@Component(/*略*/)
class SomeComponent extends Object
    with ChromeExtensionInlineInstaller
    implements OnInit {

  @override
  ngOnInit() {
    new Timer(
        const Duration(seconds: 5), () => checkChromeExtensionIsInstalled());    
}    

ngOnInit()において、new Timer(const Duration(seconds: 5), () => checkChromeExtensionIsInstalled());と5秒のdelayを入れていますが、これはcontent scriptの実行によるChrome Extensionがインストール済みかどうか判定するelementの追加のタイミングをwebアプリ側で知ることができないため、十分と思われる間隔を空けているものです。もしlocalStorage等にインストール済み情報を保存したらこのdelayは必要ありませんが、そうすると今度はExtensionのアンインストール、再インストールのイベントを監視する必要がある等処理が複雑化するため、避けました。UIの要件的にも、webアプリ起動時に即必要となる情報でもないと判断しました(もっと良い方法があれば教えてください)。なお、chrome.app.isInstalledというAPIはchrome appがインストール済みかを判定するAPIであり、chrome extensionについては判定されません。もし、chrome.extension.isInstalledなどというAPIがあれば楽なのですが。

いよいよ仕上げとして、インストール済みでない場合のみ、インストールボタンを表示します。このボタンを押下すると、chromeブラウザが提供するinline installationのpop upが画面上部に表示され、installできます。

some_component.html
  <div class="chrome-extension-install-button-container" *ngIf="!isChromeExtensionInstalled">
    <material-button
        id="chrome-extension-install-button"
        raised
        (trigger)="handleInstallingChromeExtension()">
      <glyph icon="extension"></glyph>&nbsp;
      Install MyLexicon Chrome Extension Now For Free
   </material-button>
  </div>

Screen Shot 2017-09-28 at 21.35.58.png

Extension側の対応

Content Script

公式ドキュメンテーションでの説明の通り、webアプリが特定のextensionがインストール済みであるかどうかを判定するには、extensionのcontent scriptを通じて判定用のelementをDOMに追加します。

以下のcontent scriptを書きました。ごく簡単なコードなので、DartでなくJSで書いています。

js/content_script_extension_installed.js
// This content script MUST be only executed at the web app's origin pages.
// Its injection rule is written in the manifest.json and the event pages onInstalled event handler.
const installedId = 'mylexicon-chrome-extension-is-installed';
if (!document.getElementById(installedId)) {
  let installedSignElement = document.createElement('div');
  installedSignElement.id = installedId;
  document.body.appendChild(installedSignElement);
}

このcontent scriptは特定のOriginでのみ挿入して実行してほしいので、以下のようにmanifest.jsonにおいて実行するURLを制限します。

manifest.json
    {
      "matches": ["https://mylexicon.io/*"],
      "js": ["js/content_script_extension_installed.js"],
      "run_at": "document_end",
      "all_frames": false
    }

manifest.jsonのcontent_security_policyおよびpermissionについては公式ドキュメンテーションを参照して適切に設定してください。

Event Page

これでextensionインストール後に新規に対象webアプリを開いた場合は上記content scriptが実行され、意図した動作になりますが、このままではインストール直後の既に開いているtabでは実行されません。そのため、インストールボタンが表示され続けてしまいます。

Chrome Extensionをインストールしたタイミングですでに起動しているtabにcontent scriptを挿入して実行させるには、extensionのEvent Pageにおいてchrome.runtime.onInstalledイベントを監視して対象のcontent scriptを実行させます。
せっかくなのでMyLexiconでの実際のコードの抜粋を載せておきます。

event_page.dart
library event_page;

import 'dart:async';

import 'package:chrome/chrome_ext.dart' as chrome;

void runEventPage() {
  new BadgeHandler().run();
  chrome.runtime.onInstalled.listen((_) {
    _executeOnInstalledContentScriptOnlyAtMyLexiconIo();
    _executeContentScripts();
  });
}

Future _executeOnInstalledContentScriptOnlyAtMyLexiconIo() async {
  final tabs = await chrome.tabs
      .query(new chrome.TabsQueryParams(url: "https://mylexicon.io/*"));
  final injectDetails = new chrome.InjectDetails(
      file: "/js/content_script_extension_installed.js",
      runAt: chrome.RunAt.DOCUMENT_END,
      allFrames: false);
  for (final tab in tabs) {
    chrome.tabs.executeScript(injectDetails, tab.id);
  }
}

その他、install時に既存のtabに対して挿入したいcontent scriptがある場合も、このイベントで実行させます。さもないと、インストールした直後に既存のtabでextensionの動作を確認してもうまく動かず、tabの更新後に動くという悪いUXになりユーザーを混乱させてしまいます。

event_page.dart
Future _executeContentScripts() async {
  final urlMatchPatternWithHttpOrHttpsScheme = "*://*/*";
  final tabs = await chrome.tabs.query(
      new chrome.TabsQueryParams(url: urlMatchPatternWithHttpOrHttpsScheme));

  // Injecting content script to the URL below is NOT allowed by Chrome's design.
  // Excluding the url is more graceful,
  // otherwise the event page raises an error below.
  // "Uncaught Error: The extensions gallery cannot be scripted."
  // See https://stackoverflow.com/questions/11613371/chrome-extension-content-script-on-https-chrome-google-com-webstore.
  final urlFragmentForExcluding = 'https://chrome.google.com/webstore/';
  final filteredTabs =
      tabs.where((tab) => !tab.url.startsWith(urlFragmentForExcluding));

  final injectDetails1 = new chrome.InjectDetails(
      file: "/js/get_selection_string.js",
      runAt: chrome.RunAt.DOCUMENT_END,
      allFrames: true);
  final injectDetails2 = new chrome.InjectDetails(
      file: "/content_script.dart.js",
      runAt: chrome.RunAt.DOCUMENT_END,
      allFrames: true);
  for (final tab in filteredTabs) {
    chrome.tabs
      ..executeScript(injectDetails1, tab.id)
      ..executeScript(injectDetails2, tab.id);
  }
}

以上でchrome extensionのinine installationが完成しました。