自作ブラウザの Omoikane に、Fullscreen APIとTextEncoderStreamTextDecoderStreamを追加した。

前回はPopover APIとtop layerを実装した。今回はその描画経路をFullscreenへつなぎ、別の作業として文字コード変換をStreams APIから使えるようにした。

全画面表示と文字コード変換なので、機能としてはあまり似ていない。ただ、どちらもJavaScriptのメソッドを追加するだけでは終わらなかった。

FullscreenではDOM・描画・OSウィンドウの状態を揃える必要がある。文字コード変換では、入力が途中で分割されても状態を維持し、終了やエラーをストリームの両側へ伝える必要がある。

FullscreenのPR #765 と、 文字コード変換ストリームのPR #764 の内容を、実装寄りにまとめておく。以下は9月19日時点、fd29659eの実装についての話。

Fullscreenは要素を大きくするだけではない

ページから使う Fullscreen API の入口は小さい。

<button id="enter">全画面表示</button>
<main id="screen">表示する内容</main>

<script>
  const screen = document.getElementById("screen");
  document.getElementById("enter").addEventListener("click", async () => {
    try {
      await screen.requestFullscreen();
    } catch (error) {
      console.error(error);
    }
  });
</script>

しかし、width: 100%height: 100%を付けるだけでは全画面表示にはならない。

DOM側には、どの要素が全画面表示中なのかという状態が必要になる。描画側では通常の重なり順から外し、ホスト側ではウィンドウを全画面表示へ切り替える。さらにEscや画面遷移で終了したとき、それぞれの状態を戻さなければならない。

今回追加した主な入口は、Element.requestFullscreen()Document.exitFullscreen()fullscreenElementfullscreenEnabledfullscreenchangefullscreenerror。CSSの:fullscreenもDOMの状態を見るようにした。

ユーザー操作の途中という状態を持つ

ページを開いただけで勝手に全画面表示へ移行してほしくないので、要求にはユーザー操作に由来する一時的な許可が必要になる。

Omoikaneでは入力経路でその状態を付与し、許可できるrequestFullscreen()の呼び出しで消費する。接続されていない要素、許可されていないDocument、開いているpopoverなども検査する。

このとき、イベントハンドラを抜けた瞬間に許可を消してしまうと、同じ入力処理から続くmicrotaskでの要求まで拒否してしまう。

button.addEventListener("click", () => {
  Promise.resolve()
    .then(() => target.requestFullscreen())
    .catch(console.error);
});

現実装では、失効処理をタイマーへ回し、新しい入力が来た場合は世代番号で古い失効処理を無効化する。同じ入力タスクから続くmicrotaskでは要求でき、失効後には拒否されることをテストした。これは現在の実装方法であって、すべてのブラウザが同じタイマー処理をしているという話ではない。

要求を受けた時点だけでなく、非同期処理へ進んだあとにも、要素が同じDocumentに接続されているかを確認する。呼び出し直後にページ側が要素を削除することもあるため。

テストでは、入力なしの拒否、入力に続く成功、ホストによる拒否、それぞれのPromiseとイベントの結果を確認している。APIが存在するかだけを見るテストにはしていない。

top layerへ載せ、描画上の制約を変える

全画面表示の要素は、通常のz-indexによる重なり順より上のtop layerで扱う。ここはPopoverで用意した共通経路を使っている。

ただし、top layerへ移すだけでも足りない。もともと小さな幅や高さ、transformが指定されている要素を、そのまま最前面へ描いても全画面にはならない。

現在のスタイル処理では、Fullscreen中の要素へ次のような上書きを行う。

/* 実装している上書きの要点。ページ側に書くCSSではない */
position: fixed;
inset: 0;
margin: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
max-width: none;
max-height: none;
transform: none;

::backdropの既定背景も設定し、表示だけでなくhit testが全画面表示中の要素を扱うことも確認している。

描画の回帰テストには160×120の固定fixtureを作った。通常の要素にz-index: 2147483647を指定して画面を覆わせ、その上へ、もともと12×12だったFullscreen対象を表示する。

対象には移動用のtransformも付けてある。全画面表示後はそれを無効化してviewportを埋め、内部のマーカーは所定の位置へ出なければならない。

このfixtureは保存済みbaselineと全画素を比較している。PR時の結果は差分0だった。ただし、これはOmoikane内の固定回帰画像との一致であり、Firefoxとの画像一致や、OS上の全画面表示を確認した結果ではない。

DOMの状態とネイティブウィンドウをつなぐ

Rust側ではDocumentごとにFullscreen要素のスタックを持ち、ホストへ渡す状態変更を別に保持している。

ホストへ伝える値は、現在は次の二つ。

pub enum FullscreenTransition {
    Enter,
    Exit,
}

JavaScriptから直接ウィンドウを操作するのではなく、JS runtimeからCDP sessionを経由して、GUIがこの要求を取り出す。

requestFullscreen()
    -> DOMのFullscreen状態を更新
    -> FullscreenTransition::Enterを保持
    -> GUIが状態変更を取り出す
    -> winitのウィンドウへ適用

GUI側ではEnterを受けたときに、次の呼び出しへつなぐ。

window.set_fullscreen(Some(WindowFullscreen::Borderless(None)));

逆方向も必要になる。OS側で全画面表示が終了したのに、document.fullscreenElementだけ残っていたらページが見ている状態と食い違う。

そのためホスト側から終了を通知する経路を用意し、DOM状態の解除とfullscreenchangeへつなげた。Esc、対象要素の削除、CDP経由のnavigationでも終了処理を通す。

ここは確認範囲を分けておきたい。今回の検証環境はDockerで、ネイティブGUIを実際に表示しての確認は行っていない。状態遷移の回帰テストとcargo check --locked --features guiは通したが、OS上の表示切り替えが完了するまでPromiseが待つ、といった保証を今回の実装から主張することはできない。現在はDOM側の受理と、GUIが後から適用する要求を分けた構成になっている。

iframeとShadow DOMにも境界がある

iframe内の要素が全画面表示になる場合、子Documentだけで完結しない。子から親へたどり、それぞれのDocumentとiframeの関係を管理する必要がある。

Omoikaneでは祖先のiframeをたどって許可を検査し、子の対象要素と、それを含む親側のiframeをFullscreenの管理対象へ入れる。

同一オリジン、allowfullscreenallow内のfullscreen指定を扱い、fullscreen 'none'による拒否もテストしている。ただし、Permissions Policy全体を完全実装したという意味ではない。

Shadow DOMでは、内部の要素をそのまま外へ返すのではなく、問い合わせ元に応じて対象を読み替える。

今回の回帰テストでは、ShadowRoot内の要素がFullscreenになったとき、document.fullscreenElementはhostを、shadowRoot.fullscreenElementは内部の対象要素を返すことを確認した。

このあたりまで入ると、単純な全画面フラグ一つでは扱えなくなる。なお、現実装の終了経路は保持しているFullscreenスタック全体を解除する構成で、複数要素を段階的に解除する場合まで標準との完全な一致を確認したわけではない。

文字コード変換ではチャンク境界が問題になる

もう一方が TextEncoderStream・TextDecoderStream

TextEncoderStreamは文字列からUTF-8のバイト列へ、TextDecoderStreamは指定された文字コードのバイト列から文字列へ変換する。入力を一度に揃えるのではなく、ストリームとして少しずつ渡せる。

ここで、入力のチャンク境界と文字の境界が一致するとは限らない。

例えば「水」のUTF-8は、16進数でE6 B0 B4になる。

chunk 1: E6 B0
chunk 2: B4

最初のチャンクだけでは文字が完成していない。チャンクごとに独立したデコーダーを作ると、途中で切れた不正な文字列として扱ってしまう。

必要なのは、最初の2バイトを保持し、次のチャンクで続きを処理すること。入力がまだ続く状態と、本当に終了した状態も区別しなければならない。

読み取りと書き込みを並行して進める

実際にチャンクを分割して渡す例は、次のようになる。

async function collect(stream, chunks) {
  const reader = stream.readable.getReader();
  const reading = (async () => {
    const output = [];
    for (;;) {
      const { value, done } = await reader.read();
      if (done) return output;
      output.push(value);
    }
  })();

  const writer = stream.writable.getWriter();
  for (const chunk of chunks) await writer.write(chunk);
  await writer.close();
  return reading;
}

const decoded = await collect(new TextDecoderStream(), [
  new Uint8Array([0xe6, 0xb0]),
  new Uint8Array([0xb4]),
]);
console.log(decoded.join("")); // 水

少し長いが、先に読み取りループを開始してから書き込んでいるところが重要になる。

TransformStreamのバックプレッシャー は、読み取り側の需要に応じて書き込み側を待たせる仕組み。標準の既定ではreadable側のhigh-water markが0で、読み取り要求がないまま変換を進めない。

writer.write()の完了を待ち切ってから初めて読み取りを始めると、互いに待つ形になり得る。

今回、OmoikaneのTransformStreamも変換前に読み取り要求を待つよう修正した。出力がまだ出ない不完全な文字のチャンクについても、この待機を通す。

テストでは、toString()に副作用を持つオブジェクトを書き込み、readの要求前には文字列化自体が実行されないことを確認している。出力キューの長さだけ見ていると、変換が先走る問題を見落とすため。

エンコーダー側にも持ち越す状態がある

入力がJavaScriptの文字列でも、境界の問題はある。

JavaScriptの文字列では、UTF-16のサロゲートペアが別のチャンクに分かれることがある。

const encoded = await collect(new TextEncoderStream(), [
  "\uD83D",
  "\uDE00",
  "\uD800",
]);
console.log(encoded.flatMap(chunk => Array.from(chunk)));
// [240, 159, 152, 128, 239, 191, 189]

この例は前のcollect()を使う。最初の二つの文字列は、組み合わせるとU+1F600になる。末尾の\uD800には対応する後続がない。

Omoikaneでは、チャンク末尾の上位サロゲートをいったん保留し、次のチャンクへつなぐ。保留したままストリームが閉じられた場合は、置換文字U+FFFDとして出力する。

出力の最初の4バイトがU+1F600、最後の3バイトがU+FFFDのUTF-8表現になる。

空文字列のチャンクを挟んだ場合にも、保留したサロゲートを失わないようにしている。単に各チャンクをTextEncoder.encode()へ渡すだけでは、この動作にならない。

Rustのデコーダー状態を誰が持つか

デコード側は、既存依存のencoding_rsを使っている。UTF-8だけでなく、Shift_JIS、UTF-16、ISO-2022-JPなども、状態を保ったデコーダーで処理する。

Rust側の状態は小さく、要点は次の構造になる。

#[derive(Trace, Finalize, JsData)]
struct DecoderState {
    #[unsafe_ignore_trace]
    decoder: Option<Decoder>,
    fatal: bool,
}

DecoderはJavaScriptのオブジェクトを参照しないネイティブな状態なので、BoaのGCで内部をたどる対象から外している。これを保持するJsObject自体は、ストリームのクロージャから参照する。

ホスト側の永続的なID管理表へデコーダーを置くのではなく、ストリームから到達できる間だけ生かす構成にした。終了・エラー・cancel・abortでは保持している参照を解放する。

ここを間違えると、次のチャンクが来る前にデコーダーが消えるか、捨てたストリームの状態が残り続ける。

回帰テストでは4バイトのUTF-8文字の前半を書き込み、途中でboa_gc::force_collect()を呼び、そのあと後半を書き込んで正しく復号できることを確認している。GCが起きない短い正常系だけでは確認できない部分。

デコーダーの実装 では、入力チャンクを処理するときと、終了時のflushをlastで区別している。

flush・エラー・キャンセルを別々に扱う

例えばUTF-8文字が途中まで届いて、そのまま入力が終了した場合。通常設定では残った不完全な文字をU+FFFDへ置換するが、fatal: trueならエラーにする。

fatalな変換エラーは、書き込み側だけ失敗させても不十分。読み取り側が新しいデータを待ったままになってしまうため、read・writeと双方のclosedへ失敗を伝える。

逆に正常終了の場合は、入力が終わっても出力を読み終わったとは限らない。サロゲートの置換文字など、flushで最後のデータが出ることがある。

入力側をclose
    -> flushで残りを出力
    -> 出力キューを読み切る
    -> readable側のclosedが完了

そのためReadableStreamにはcloseの要求とcloseの完了を分けて持たせ、キューが空になるまで読み取り側の終了を確定させないようにした。

この修正は文字コード変換だけでなく、既存のFetchやWebTransportにも影響する。そこには残ったキューを捨てて終了する経路があるので、破棄後に待機が残らないよう利用側も更新した。

cancelやabortでは、渡された理由をそのまま伝える。理由がundefinedの場合もあるので、値の有無だけでエラー状態を判定しない。回帰テストでもundefinedErrorオブジェクトの両方を使っている。

ページから見えるプロパティを内部状態の代わりにしない

ブラウザ組み込みのAPIを実装していると、ページ側が組み込み関数を書き換える場合も考える必要がある。

Array.prototype[Symbol.iterator]String.prototype.slice、グローバルのTextDecoderなどが置き換えられていても、内部処理までその関数へ依存してほしくない。

今回の実装では、文字列処理に使う関数を初期化時に保持し、Rustが返す内部ハンドルの配列も分割代入のiteratorに依存せず取り出している。

入力バッファも、ページ側に見えるbufferbyteLengthのgetterから推測せず、Boa側の型付き配列やDataViewの内部情報を使って読み取る。ビューのoffset・lengthもここで反映する。

ページによる関数の置き換え、共有バッファ、切り離し済みの入力などは、 文字コード変換ストリームの回帰テスト へ入れている。

WPTで残った失敗は別の機能だった

今回の文字コード変換では、Web Platform Testsのencoding streamsから11ファイル・114サブテストを追加した。

その中で、MessagePort.postMessage(buffer, [buffer])を使ってバッファを切り離すテストが失敗した。

これはデコーダーが文字を間違えているのではなく、その前のArrayBuffer転送が未対応だった。現状のMessagePortでは、配列形式のtransfer listが転送として処理されず、送信元のバッファが残ってしまう。

切り離したはずの入力が残っていれば、デコーダーがそこから文字を出す結果になる。

この問題は ArrayBuffer転送とdetachmentのIssue #763 へ分離した。デコーダー単体については、エンジンのdetach APIで実際に切り離したバッファを作り、別途確認している。

WPTでは対象ファイル全体を雑に除外せず、失敗する2サブテストの名前を完全一致で登録している。それ以外の失敗やtimeoutが増えた場合、または登録している失敗が解消した場合も検出する。既知失敗の記録がそのまま放置されないようにした。

なお、共有バッファ用helperの一部には、WebAssembly経由の生成を既存のSharedArrayBufferへ接続するsmoke用adapterがある。この結果をWebAssemblyの対応確認としては扱っていない。

今回どこまで確認したか

Fullscreenを取り込んだ時点の、PRに記録した検証結果は次のとおり。

検証結果と範囲
CI相当の全体テストライブラリ2,517件を含む各suiteが成功
固定revisionのWPT smoke134ファイル中133成功、既知失敗1ファイル、回帰0
Fullscreenの追加WPT4ファイル成功
Web API surface probe108項目中106対応、既存未対応2項目、エラー・回帰0
Fullscreenの描画fixture保存済みbaselineとの画素差分0
GUI構成cargo check --locked --features gui成功。実ウィンドウの目視確認は未実施

WPTの件数は固定したsmoke対象の数で、WPT全体を通したという意味ではない。Web API surfaceの106/108も、Omoikaneが用意したprobe内の集計であって、Web API全体の対応率ではない。

最新mainの CI に加えて、Browser behavior・Native JIT targets・JIT release gateも成功している。

9月19日には開発手順も整理したが、今回の機能として大きかったのはこの二つ。FullscreenはDOMからネイティブウィンドウへ、文字コード変換はJavaScriptのストリームからRustのデコーダーへ接続した。

メソッドが呼べて正常系が一度動くところから先に、状態の持ち越しや終了処理がかなりある。今回はそこをテストと一緒に埋めた。ArrayBuffer転送とGUIの実画面確認は、まだ残っている。