LoginSignup
6
1

More than 5 years have passed since last update.

Chrome Extension inline installationをDartから扱う

Last updated at Posted at 2017-09-28

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が完成しました。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1