WebExtensionsによるFirefox用の拡張機能で設定の読み書きを容易にするライブラリ：Configs.js

（この記事は、Firefoxの従来型アドオン（XULアドオン）の開発経験がある人向けに、WebExtensionsでの拡張機能開発でのノウハウを紹介する物で、[所属会社のブログの2018年6月12日の記事](http://www.clear-code.com/blog/2018/6/12.html)の再掲です。）

XULアドオンでは、設定の保存や読み書きにはpreferencesという仕組みを使うのが一般的でした。これはFirefoxの基本的な設定データベースとなっているkey-value storeで、保持されている設定の一覧は`about:config`で閲覧することができます。

一方、WebExtensionsベースの拡張機能の場合はこのような「設定を保存するための仕組み」は特に用意されていません。Indexed DB、Cookie、[Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API)などのWebページ用の一般的な仕組みや、あるいは[WebExtensionsのstorage API](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage)のように、データを永続的に保存する様々な仕組みの中から好みの物を選んで使えます。とはいえ、選択肢が多すぎると却って判断に迷うもので、何らかの指針や一般的な方法があればそれに従っておきたい所でしょう。

一般的には、WebExtensionsベースの拡張機能では設定の保存先として[storage API](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage)が使われるケースが多いようです。storage APIはさらに`storage.local`、`storage.sync`、`storage.managed`の3種類が存在し、Firefox Syncとの連携を考慮する場合は`storage.sync`、そうでない場合は`storage.local`が使われるという具合の使い分けがなされます。ただ、これらのAPIは非常に低レベルのAPIと言う事ができ、設定画面・バックグラウンドスクリプト・コンテントスクリプトの間での値の同期のような場面まで考慮すると、上手く整合性を保つのはなかなか大変です。

そこで、preferencesの代替として使いやすいAPIを備えた、設定の読み書きに特化した軽量ライブラリとして、[`Configs.js`](https://github.com/piroor/webextensions-lib-configs)という物を開発しました。


# 基本的な使い方

## 必要な権限

このライブラリは設定値をstorage APIで保存するため、使用にあたっては`manifest.json`の`permissions`の宣言に`storage`を加える必要があります。

```javascript
{
  ...
  "permissions": [
    "storage", 
    ...
  ],
  ...
}
```

## 設定オブジェクトの作成と読み込み

このライブラリは単一のファイル`Configs.js`のみで構成されており、読み込むと、その名前空間で`Configs`という名前のクラスを参照できるようになります。実際に設定を読み書きするためには、これを使って*設定オブジェクト*（`Configs`クラスのインスタンス）を作る必要があります。具体的には以下の要領です。

```javascript
var configs = new Configs({
  enabled: true,
  count:   0,
  url:     'http://example.com/',
  items:   ['open', 'close', 'edit']
});
```

設定のキーと既定値はこの例の通り、`Configs`クラスの引数に渡すオブジェクトで定義します。オブジェクトのプロパティ名が設定のキー、値が既定値になり、値はJSON形式が許容する物であれば何でも保持できます。

これを`common.js`のような名前で保存し、以下のように`Configs.js`と併せて読み込むようにします。

HTMLファイルから読み込む場合：

```javascript
<script type="application/javascript" src="./Configs.js"></script>
<script type="application/javascript" src="./common.js"></script>
```

manifest.jsonで指定する場合：

```javascript
{
  ...
  "background": {
    "scripts": [
      "./Configs.js",
      "./common.js",
      ...
    ]
  },
  ...
  "content_scripts": [
    {
      "matches": [
        "*://*.example.com/*"
      ],
      "js": [
        "./Configs.js",
        "./common.js",
        ...
      ]
    }
  ],
  ...
}
```

manifest.jsonの記述例から分かる通り、設定を読み書きしたい名前空間のそれぞれで個別に`Configs.js`と設定オブジェクトの作成用スクリプトを読み込む必要があります。

## 保存された設定値の読み込み

各スクリプトを読み込んだそれぞれの名前空間では、設定オブジェクトのインスタンスが作成されると同時に、保存された設定値が自動的に読み込まれます。設定オブジェクトは値がPromiseであるプロパティ `$loaded`を持っており、このPromiseは設定値の読み込みが完了した時点で解決されます。例えば保存された設定値を使ってページの初期化処理を行いたい場合は、以下のようにする事になります。

```javascript
window.addEventListener('DOMContentLoaded', async () => {
  await configs.$loaded;
  // ...
  // 読み込まれた設定値を使った初期化処理
  // ...
}, { once: true });
```

## 設定値の参照と変更

設定オブジェクトは、インスタンス作成時に指定された各設定のキーと同名のプロパティを持っており、プロパティの値が設定値となっています。`$loaded`のPromiseの解決後に各プロパティを参照すると、読み込まれたユーザー設定値または設定オブジェクト作成時の既定値が返されます。

```javascript
console.log(configs.enabled); // => true
console.log(configs.count);   // => 0
```

また、設定値を変更するには、設定オブジェクトの各プロパティに値を代入します。

```javascript
configs.enabled = false;
configs.count   = 1;
```

*設定値は型情報を持ちません*。初期値と異なる型の値を設定した場合、値は初期値と同じ型に変換されるのではなく、設定値の型のまま保存されます。例えば真偽値だった設定の`configs.enabled`に数値として`0`を代入した場合、次に取得した時に返される値は`false`ではなく`0`となります。

値が`Object`や`Array`である場合、以下の例のように必ず、*元のオブジェクトの複製を作り、そちらを書き換えて、新しい値として`configs`のプロパティに設定する*必要があります。

```javascript
var newEntries = JSON.parse(JSON.stringify(configs.entries)); // deep clone
entries.push('added item');
configs.entries = newEntries; // 設定値の変更

var newCache = JSON.parse(JSON.stringify(configs.cache)); // deep clone
newCache.addedItem = true;
configs.cache = newCache; // 設定値の変更
```

言い換えると、*`configs.entries.push('added item')`や`configs.cache.addedItem = true`のように値のオブジェクトそのものを変更する方法では、変更結果は保存されません*。`var entries = configs.entries`のように「値を参照した時に取得したオブジェクト」そのものを扱う場面では、値の変更前に必ず`JSON.parse(JSON.stringify(entries))`などの方法で[ディープコピー](https://kuroeveryday.blogspot.com/2017/05/deep-clone-object-in-javascript.html)してから変更するように気をつけて下さい。

また、*未知のプロパティに値を設定した場合、その値は保存されません*。設定のキーを増やしたい場合は、必ず設定オブジェクト作成時に既定値とセットで定義する必要があります。


## 設定値の変更の監視

設定値の変更は、ライブラリ自身によって各名前空間の間で暗黙的に通知・共有されます。`if (configs.enabled) { ... }`のように処理の過程で設定値を参照している場合、参照する時点で最新の設定値が返されますので、特に何かする必要はありません。

一方、「設定値が変わったらボタンのバッジを変更する」といった風に、設定値の変更を検知して何らかの処理を実行したい場合、設定オブジェクトの`$addObserver()`メソッドでオブザーバーを登録することができます。オブザーバーには関数を指定でき、第1引数として変更が行われた設定のキーが文字列として渡されます。以下は、関数（アロー関数）をオブザーバーとして登録する例です。

```javascript
configs.$addObserver(aKey => {
  const newValue = configs[aKey];
  switch (aKey) {
    case 'enabled':
      ...
    case 'count':
      ...
  }
});
```


## Firefox Syncで同期する設定、同期しない設定

初期状態では、設定オブジェクト作成時に定義した各設定はFirefox Syncでは同期されません。同期の対象にするには、「同期したい設定のキーの一覧」あるいは「同期させたくない設定のキーの一覧」を設定オブジェクト作成時にオプションで指定する必要があります。

`Configs`クラスは第2引数として各種オプションの指定のためのオブジェクトを受け取ります。基本的には設定を同期せず、一部の設定のみを同期したいという場合、同期したい設定のキーの配列を`syncKeys`オプションとして指定します。

```javascript
var configs = new Configs({ /* 既定値の指定 */ }, {
  syncKeys: [
    'enabled',
    'url',
    'items',
  ]
});
```

このように指定すると、`syncKeys`に列挙された設定のみFirefox Syncで同期されるようになります。

また、基本的には設定を同期し、一部の設定のみを同期対象外にしたいという場合、同期したくない設定のキーの配列を`locakKeys`オプションとして指定します。

```javascript
var configs = new Configs({ /* 既定値の指定 */ }, {
  localKeys: [
    'count',
  ]
});
```

このように指定すると、`localKeys`に列挙されなかったすべての設定がFirefox Syncで同期されるようになります。

Firefox Syncで同期された設定は、その実行環境で保持されたユーザー設定値よりも優先的に反映されます。
ただし、システム管理者によって定義された値がある設定は、それが最も優先されます。

## システム管理者が設定を指定できるようにするには

企業等の組織でアドオンを使用する場合、システム管理者が設定を指定し固定したい場合があります。普通のアドオンでそのような事をしたい場合には、アドオンの中に書き込まれた既定値を書き換えた改造版を作る必要がある場合が結構ありますが、`Configs.js`を使って設定を管理しているアドオンでは、そのような改造をせずとも、システム管理者が任意の設定値を指定できます。

システム管理者が設定値を指定するには、[Managed Storageマニフェスト](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Native_manifests#Managed_storage_manifests)という特殊なマニフェストファイルを作成し、[Windowsではさらにレジストリに情報を登録する必要があります。Windows以外のプラットフォームでは、特定の位置にファイルを配置するだけです](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Native_manifests#Manifest_location)。`Configs.js`を使用しているアドオンの一つである[IE View WE](https://addons.mozilla.org/firefox/addon/ie-view-we/)を例として、設定の手順を説明します。

まず、以下のような内容でJSON形式のファイルを作成します。

```javascript
{
  "name": "ieview-we@clear-code.com",
  "description": "Managed Storage for IE View WE",
  "type": "storage",
  "data": {
    "forceielist"      : "http://www.example.com/*",
    "disableForce"     : false,
    "closeReloadPage"  : true,
    "contextMenu"      : true,
    "onlyMainFrame"    : true,
    "ignoreQueryString": false,
    "sitesOpenedBySelf": "",
    "disableException" : false,
    "logging"          : true,
    "debug"            : false
  }
}
```

`name`にはアドオンの識別子（WebExtensionsでの内部IDではなく、Mozilla Add-onsに登録する際に必要となるIDの方）を、`description`には何か知らの説明文を、`type`には`storage`を記入します。設定値として使用する情報は`data`に記述し、記述の仕方は`new Configs()`の第1引数に指定する既定値と同様の形式です（JavaScriptではなくJSONなので、キーは明示的に文字列として書く必要がある点に注意して下さい）。

内容を準備できたら、ファイル名を`（アドオンの識別子）.json`として保存します。この例であれば`ieview-we@clear-code.com.json`となります。

次に、Windowsではレジストリの `HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\ManagedStorage\（アドオンの識別子）`または`HKEY_CURRENT_USER\SOFTWARE\Mozilla\ManagedStorage\（アドオンの識別子）`の位置にキーを作り、「標準」の値として先のJSONファイルのフルパスを文字列型のデータとして保存します。キーの位置は32bit版Firefoxでも64bit版Firefoxでも同一である（`WOW6432Node`配下ではない）という事に注意して下さい。
LinuxやmacOSでは、[MDNに記載があるパスのディレクトリ](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Native_manifests#Manifest_location)配下にJSONファイルを置くだけで充分です。

当然ですが、管理者でないユーザーがファイルを書き換えて設定を変更してしまう事がないように、このJSONファイルは一般ユーザーでは読み取り専用・ファイルの書き込みを禁止するようにアクセス権を設定しておく必要があります。

このようにして管理者が設定値を定めた項目は、`Configs.js`で参照する時は「ロックされた」状態になり、値を変更できなくなります[^locked]。

[^locked]: 値を設定しても特に例外等は発生しませんが、変更の内容は無視されます。


# まとめ

以上、Firefox用のWebExtensionsベースのアドオンにおける設定の読み書きを容易にするライブラリである`Configs.js`の使い方を解説しました。

XULアドオンでの感覚に近い開発を支援する軽量ライブラリは他にもいくつかあります。以下の解説も併せてご覧下さい。

 * [キーボードショートカットの変更用UIを提供する`ShortcutCustomizeUIMenuUI.js`](https://qiita.com/piroor/items/a9f0a1bb5cebb7f3ad42)
 * [メニューやポップアップパネル風のUIを提供する`MenuUI.js`](https://qiita.com/piroor/items/b7a3a983ba4bdd22bdb9)
 * [「以後確認しない」というチェックボックス付きの確認ダイアログ風UIを提供する`RichConfirm.js`](https://qiita.com/piroor/items/588fcb9ef94f913a849d)