Firefox 拡張機能「Container Tab Groups」で使われている技術

## Container Tab Groups とは

Container Tab Groups とは以下のような拡張機能です。この記事ではこの私が中心となって開発した拡張機能について，技術的にどう実現されているかを解説します。新しい拡張機能の開発に役立てていただけると幸いです。

https://addons.mozilla.org/ja/firefox/addon/container-tab-groups/

https://github.com/menhera-org/TabArray

### コンテナー

Firefox にはコンテナータブの機能があります。コンテナーはそれぞれが Cookie などが分離されそれぞれ別々にサイトにログインなどができる，いわば「ブラウザの中のブラウザ」です。

Container Tab Groups はこのコンテナー機能を便利なタブグループとして利用できるようにし，パノラマ・グリッド表示 (以下) やコンテナーごとの言語やユーザエージェントの設定など，コンテナーに関連する機能が充実した拡張機能です。

![Screenshot 2023-05-01 at 0.18.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/653437/7884b1a5-b047-deb1-8417-7685806a7e75.png)

### 言語

現在この拡張機能は英語や日本語の他，エスペラント，スペイン語，フランス語，中国語，ドイツ語，ロシア語など多くの言語で利用可能になっていますが，これだけの言語をサポートするには私の語学力と時間では無理なので，翻訳者を募っています。GitHub やコードエディタを全員に使ってもらうのは不便で入力ミスなどもあるので，オンラインで翻訳ができる [Weblate](https://weblate.org/) を使用しています。

## 機能

#### コンテナーの実装

コンテナーは WebExtensions API では， `cookieStoreId` 属性と `contextualIdentities` API で実現されています。`cookieStoreId` 属性は， `firefox-default` や `firefox-private`，`firefox-container-193` などといった値を取りますが，これらは Firefox の内部的には `userContextId` と `privateBrowsingId` というふたつの Uint32 値で表現されています。この拡張機能でも内部ではこのふたつの値を計算し，使用しています。 (公式の _Firefox Multi-Account Containers_ 拡張機能に倣った) Firefox の中でこれらの値が異なるリソースはそれぞれ区画されて扱われ，混ざらないようになっています。(Cookie はもちろん，キャッシュ，OSCP，favicon キャッシュなども含めて)

| `cookieStoreId` | `userContextId` | `privateBrowsingId` |
|:-----------|:------------|:------------|
| firefox-default | 0 | 0 |
| firefox-private | 0 | 1 |
| firefox-container-1 | 1 | 0 |
| firefox-container-333 | 333 | 0 |

##### Uint32 値の TypeScript での表現

以下の実装を使用しています。(https://github.com/menhera-org/weeg-types/blob/63247c0574568d5f60e97e37d89d8228aa6c3dac/src/Uint32.ts)

```typescript
declare const UINT32: unique symbol;

/**
 * Unsigned 32-bit integer. This effectively extends the number type.
 */
export type Uint32 = number & { [UINT32]: never };

export function Uint32 (value: unknown): Uint32 {
  if (new.target != null) {
    throw new TypeError("Not a constructor");
  }
  if (typeof value == 'bigint') {
    value = BigInt.asUintN(32, value);
  }
  const numberValue = Number(value);
  if (isNaN(numberValue)) {
    throw new Error(`Not a number: ${value}`);
  }
  return (numberValue >>> 0) as Uint32;
}

type _Uint32 = Uint32;

export namespace Uint32 {
  /**
   * Just a type alias for Uint32.
   */
  export type Uint32 = _Uint32;

  /**
   * The minimum value (0) of an Uint32.
   */
  export const MIN: Uint32 = 0 as Uint32;

  /**
   * The maximum value (2^32 - 1) of an Uint32.
   */
  export const MAX: Uint32 = (-1 >>> 0) as Uint32;

  /**
   * Test if a value is a Uint32. Any value can be passed, but only number values return true.
   * @param value The number to test.
   * @returns true if the value is a Uint32.
   */
  export const isUint32 = (value: unknown): value is Uint32 => typeof value == 'number' && Object.is(value, Uint32(value));

  /**
   * Converts a value to a Uint32.
   * @param value The number to convert to a Uint32.
   * @returns the Uint32 value.
   */
  export const toUint32 = (value: number): Uint32 => Uint32(value);

  /**
   * Converts a string to a Uint32.
   * @param value the value to convert to a Uint32.
   * @returns the converted value.
   */
  export const fromString = (value: string): Uint32 => {
    const result = parseInt(value, 10);
    if (!Uint32.isUint32(result)) {
      throw new Error(`Invalid Uint32: ${value}`);
    }
    return result;
  };
}
```

#### ContextualIdentities

Contextual identities とは，コンテナーのことですが，この API で取得可能ないわゆる「コンテナー」は `userContextId` が正(0でない) cookieStoreId のことです。つまり，普通のコンテナーなしのタブ (firefox-default) とプライベートタブ (firefox-private) は狭義の「コンテナー」ではない。

この狭義のコンテナーは API で作ったり削除したりすることができます。(デフォルトのコンテナーなしコンテナーは削除できない。)

狭義のコンテナーには名前，アイコン，色が設定されます。デフォルトのコンテナーなしコンテナーには名前が無いので，この拡張機能では「コンテナーなし」と表現しています。コンテナーで開いたタブには色と名前が表示されます。

### コンテナーごとの設定

この拡張機能ではコンテナーごとに以下の設定を変更できるようになっています。

- Web ページを表示する言語の希望
- ユーザエージェント文字列
- 接続に使うプロキシ設定

#### コンテナーごとのリクエストヘッダの上書き

このうち，言語とユーザエージェントの設定においては， `webRequest` API を使用して，リクエストヘッダを上書きしています。

#### コンテントスクリプト

Firefox には，拡張機能向けにリクエストヘッダの `user-agent` を上書きすると自動で JavaScript から取得可能な `navigator.userAgent` も同じ値にしてくれる機能があるので，これはありがたく利用します。

ところで多くのユーザは Chrome 以外を拒否するサイトの対策として Chrome にユーザエージェントを偽装したがります。ここでリクエストヘッダの `user-agent` と JavaScript の `navigator.userAgent` を偽装するだけだと少し不安が残ります。

理由は，最近の Chrome-based ブラウザは， [navigator.userAgentData プロパティ](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData) をサポートしていて，Google はユーザエージェント文字列の代わりにこちらの API を使ってブラウザを判別するように推奨しているからです。 Firefox は (ヴァージョン 112 現在では) この API をサポートしておらず，このままでは Chrome であると判定してもらえない可能性が高くなります。従って，この拡張機能では `navigator.userAgentData` プロパティ (及び関連するリクエストヘッダ) をエミュレートします。

エミュレートには，コンテントスクリプトを使用します。コンテントスクリプトからは，`wrappedJSObject` プロパティを使って，生の Web ページ側に晒される API を弄って `navigator.userAgentData` プロパティを錬成します。

また，言語 (`navigator.language`, `navigator.languages`) には同様に自動で書き換えてくれる機能は存在しないので，これもコンテントスクリプトを使用して，生の Web API を書き換えています。

ここにひとつの問題があります。コンテントスクリプトから言語設定は非同期でしか取得できないので，言語設定を読み込んでから API を書き換えているとその間にページが読み込まれてしまうリスクがあるのです。多くのサイトはページの読み込み時にしか言語を取得しないでしょうから，つまり言語の上書きが意味無くなってしまうのです。

これを防ぐため，Manifest V2 の現在の拡張機能では，言語設定の JSON を含むコンテントスクリプトを動的に生成し，あらかじめ読み込ませておく方式を採用しています。言語設定が変わると当然コンテントスクリプトも更新します。

次期の WebExtensions API である Manifest V3 では動的に生成した内容のコンテントスクリプトを読ませることができないので，どうしたものかと思っているところです。言語の数は有限しかありませんから，有名な言語ごとに静的なコンテントスクリプトを予め(数百ファイル)用意することを考えています。

#### コンテナーごとのプロキシの設定

`proxy` API を使用し， `proxy.onRequest` ハンドラを使用し，リクエストのたびに `cookieStoreId` ごとにプロキシ設定を割り振っています。

https://github.com/menhera-org/TabArray/blob/2050aa24701300c4d6445448b8ba2d65e157531d/src/pages/background/background-proxy.ts

### First-party Isolate

この拡張機能はおすすめに沿ってセットアップすると Firefox のFirst-Party Isolate (FPI) 機能を自動で有効化するようになっています。(当然ですが手動でユーザが切ることもできる) 以下に理由を説明します。

ちまたには，サイトごとに別なコンテナーに振り分ける拡張機能がごまんとあります。人間，サイト間のトラッキングはされたくないものなので，これはいい機能に思えます。ところがこの実装は汚く，無理矢理タブを閉じて別なコンテナーで開き直していたりするので，ログインの連携などのリダイレクトが壊れることがよくあります。

Firefox には，手動でコンテナーなどを使わなくてもサイトごとにリソースを分離する機能がすでにあって，それが FPI です。これは内部では コンテナーに使われる `userContextId` と同様に， `firstpartyDomain` というキーがあって，Cookie やキャッシュなどを分離しているので，根源的なしくみは一緒です。

ですから，利便性とコンテナーを利用したプライヴァスィーを最大限両立してほしいので，この拡張機能ではデフォルトでこの機能を有効化しています。

### 設定を保存する

WebExtensions API には設定を保存する専用の API はありません。したがって，通例 設定を保存するには `storage` API を使用します。

### タブをサイトごとに分類する

何を「サイト」とするかは難しい問題です。ここでは Firefox の「ファーストパーティ」の概念で使われている registrable domain という概念を使用します。

#### Registrable domain とは

Registrable domain (登録可能なドメインの意) とは， public suffix + 1 階層のドメインです。

Public suffix とは，いわばだいたい誰でもサブドメインの登録ができる親ドメインのことです。example.com などが登録できますから，`com.` は public suffix です。 menhera-org.github.io などが登録されている `github.io.` (GitHub Pages) も public suffix です。public suffix にはそのレヴェルで cookie を設定できないなどの制限があります。(\*.github.io で全部 cookie が共有されてしまったら恐しいことになる)

Firefox などのブラウザは，概ね registrable domain が異なるサイトを完全なる別サイトとみなします。ファーストパーティ分離 (FPI) などの機能も，registrable domain ごとにサードパーティ Cookie などを分離します。これは Web 標準の Same-Origin ポリシーで有名な Origin (オリジン) の概念より少し緩い概念です。 (Origin では， (scheme, hostname, port, (domain → 通常空)) からなるタプルで管理します。) 概ねセキュリティ分野では Origin のほうを使用することが多いと思います。Firefox などのブラウザは Origin が異なるけど registrable domain からなる First-party domain が同じサイトはなんとなく緩く仲間のサイトとみなしているわけです。

この概念は便利で汎用性がありそうなので採用します。

#### Registrable domain を計算する

Registrable domain を計算するには，その元となる public suffix のドメインが何であるかを知る必要があります。幸い， Mozilla が中心となって [Public Suffix List (PSL)](https://publicsuffix.org/) を公開していて，これが Firefox などで使用されているものとなっています。これは WebExtensions API から直接は使用できないので，拡張機能自体が手動でネットからこのリストを取得して，パース(構文解析)しています。

このアルゴリズムでは，サイトのドメインから PSL にのっているドメイン + 1階層のドメインを取りだしてそれを registrable domain とします。実際には例外などがいろいろあるので，それらを計算する必要があります。

ライブラリを Apache-2.0 ライセンスで公開しているので自由に利用ください。

https://github.com/menhera-org/weeg-domains/blob/08e940621bece1eb8c768481cc04f90dc1e89451/src/RegistrableDomainService.ts

#### サイト別表示

このようにして取り出した registrable domain が同じ一連のページを「サイト」として，まとめて表示するようにしています。

### 外部から開いたタブを検出してリダイレクトする

これは，手動で開いたタブをあらかじめ記録しておいて，あてはまらないタブが開かれたときに `webRequest` API を使ってそのページを拡張機能のページにリダイレクトしています。

https://github.com/menhera-org/TabArray/blob/c6c0e83d080ea7fba71128389702ee464764020d/src/pages/background/background-redirector.ts

### タブグループ (ここではコンテナー) ごとにタブの隠された状態を同期する

コンテナーを隠すときには，コンテナーのタブを全て隠します。他にタブが無いときなど，隠せないときには隠す動作は失敗し，そのままになります。

コンテナーのタブがアクティヴになると，そのコンテナーに残された隠されたタブがあればそれらを全て再表示します。

### タブをソートする

ここでは以下の方法をお借りしています。

https://qiita.com/piroor/items/e9b0a815231619b7e905

この拡張機能ではコンテナーごとにタブが並び替えられるようになっています。

### タブにタグ付けする

この拡張機能では，タブにタグ (最大1つ) を付けることができます。複数を排除しているのはこれがタブグループの働きをするからです。

タグの実装は， `sessions.setTabValue()` API を使用しています。

タグのシステムでは，あるタグに属するタブが開いた子タブも同じタグを継承するような仕様になっています。

## おわりに

- もっとこうしたほうがいいのでは? などあれば是非お気軽に [GitHub issues](https://github.com/menhera-org/TabArray/issues) または Twitter [@vericava](https://twitter.com/vericava) までどうぞ。

英語の解説記事：

https://www.menhera.org/container-tab-groups/

## 追記

インターネット掲示板サイト上で「英語にしますか」と聞いてきて「いいえ」としたのにフォントを変えられてしまったとの声がありましたが，これは誤って Firefox 設定 の privacy.resistFingerprinting を有効にしてしまったためと思われます。このオプションは高度なプライヴァスィーを求めるユーザ向けで，一般人が有効にすることはおすすめしません。