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コードを以下のように書きました。
@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コードのままでは扱いづらいため、さらにラッパー関数を定義しました。
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するようにしました。
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します。
@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できます。
<div class="chrome-extension-install-button-container" *ngIf="!isChromeExtensionInstalled">
<material-button
id="chrome-extension-install-button"
raised
(trigger)="handleInstallingChromeExtension()">
<glyph icon="extension"></glyph>
Install MyLexicon Chrome Extension Now For Free
</material-button>
</div>
Extension側の対応
Content Script
公式ドキュメンテーションでの説明の通り、webアプリが特定のextensionがインストール済みであるかどうかを判定するには、extensionのcontent scriptを通じて判定用のelementをDOMに追加します。
以下のcontent scriptを書きました。ごく簡単なコードなので、Dartでなく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を制限します。
{
"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での実際のコードの抜粋を載せておきます。
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になりユーザーを混乱させてしまいます。
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が完成しました。