【Flutter Web】File System Access APIをpackage:webに移行した件

## はじめに

dart2wasm対応のために自作Flutter Webアプリを`dart:html`から`package:web`に移行しようとしたところ、サードパーティのパッケージと名前のコンフリクトが多数発生しました。これはむしろサードパーティパッケージが不要になったという良いシグナルだろうと考え、これも`package:web`他に移行したという話です。

## File System Access APIとは

[File System Access API](https://wicg.github.io/file-system-access/#api-showdirectorypicker)はWebアプリ(ブラウザアプリ)からローカルのファイルシステムへのアクセスを提供します。自作アプリではディレクトリピッカで選択させたディレクトリの中のファイルを読み書きしています。これまでは、公式パッケージのサポートがなかったため、サードパーティの[`package:file_system_accsess_api`](https://pub.dev/packages/file_system_access_api "file_system_access_api | Dart package")を利用していました。

## 公式新パッケージへの移行

Dart SDK 3.3.0で`extension type`とそれを用いた`package:web`が利用できるようになったのでこちらに移行します。これはブラウザAPIへのオーバヘッドの無いバインディングをWeb IDLから自動生成したものだそうです。
Dart PMのkevmooさん曰く[package:webはほとんどクレイジー](https://twitter.com/kevmoo/status/1757464245720297598)。

> The new package:web is pretty crazy. We use Dart code + JS code (NPM modules), compiled to JS to generate Dart code (pkg:web /lib contents) that we then compile to JS (and WebAssembly).

JSにコンパイルしたDartコードとJSコード(NPMモジュール)でDartコードを生成し(これが`package:web`の本体)、後にこれをJS(およびWASM)にコンパイルして利用する...

なお、後述の通り`window.showDirectoryPicker()`が提供されていないのでごにょごにょするために`dart:js_interop`も利用しています。

### パッケージの変更

#### pubspac.yamlの変更

```yaml:before
environment:
  sdk: '>=2.18.0 <3.0.0'
dependencies:
  file_system_access_api: ^2.0.0
```

```yaml:after
environment:
  sdk: '>=3.3.0 <4.0.0'
dependencies:
```

なお、ごにょごにょするために、Dart SDKの下限も3.3.0にしております。

#### importの変更

```dart:before
import 'dart:html';
import 'package:file_system_access_api/file_system_access_api.dart';
```

```dart:after
import 'dart:js_interop';
import 'package:web/web.dart' as web;
```

#### プレフィクス追加

Fultterと`package:web`でクラス名`Text`がコンフリクトしました。当然(?)Flutterを優先して`package:web`を`as web`としました。その結果として未解決となった箇所にプレフィクス`web.`を追加していきます。

```dart:before
FileSystemWritableFileStream? logStream;
```

```dart:after
web.FileSystemWritableFileStream? logStream;
```

### JS型とDart型の相互変換

残る静的エラーの大半はDart型とJS型の差異によるものです。エラーメッセージとIDEの推奨に従い`toJS`または`toDart`を追加することで簡単に解消できます。

```dart:before
    await logStream!.writeAsText('$message\n');
```

```dart:after
    await logStream!.write('$message\n'.toJS).toDart;
```

なお、この例ではメソッドの変更も必要でした。

### 名前付きオプション引数を`extension type`に変更

ブラウザAPIはオプション群としてJSのオブジェクトリテラルを受け取ることがあります。`package:file_system_access_api`では手作りでこれらをDartらしい名前付きオプションパラメタに展開していました。`package:web`移行に伴い、オブジェクトリテラルに戻す必要があります。 

こちらもIDEの推奨に従い、それっぽい名前の`extension type`を選ぶだけの簡単な作業です。

```dart:before
    await dirHandle.getFileHandle('lockfile', create: true);
```

```dart:after
    await dirHandle
        .getFileHandle('lockfile', web.FileSystemGetFileOptions(create: true))
        .toDart;
 ```

とはいえ、`extension type`の名前が長いのが気になりますね。
 
## ディレクトリピッカのコール

### ディレクトリピッカの露出

[Compatibility](https://pub.dev/packages/web#compatibility)によると、experimentalである`window.showDirectoryPicker()`をSafariとFirefoxがサポートしていないために、`package:web`の`Window`に露出していません。

これを利用するためには、[公式ドキュメントInterop type members](https://dart.dev/interop/js-interop/usage#interop-type-members)に従い、`Window`の`extension type`をもう一つ(例:`@JS('Window')Window2`)作るという方法が有ります。`external`なメソッドを宣言すると、あとはDart SDKが良いように計らってくれます。

#### extensionで回避

とはいえ、`package:web`が既に`Window`を提供してるので、これにメソッドを追加することを考えました。同公式ドキュメントを読み進めると、下記の様に`extension`に`external`なメソッドを宣言してあげることで`window.showDirectoryPicker()`にアクセスできます。
```dart
extension on web.Window {
  external JSPromise<web.FileSystemDirectoryHandle> showDirectoryPicker(
      [JSAny? options]);
}
```

#### options

`window.showDirectoryPicker()`はJavascriptオブジェクトリテラルの形でオプションを受け取ります。ここではディレクトリへの書き込み権限が欲しかったので、なんとかオブジェクトリテラルを渡す必要が有ります。以下の様にすることで、`{mode: 'readwrite'}`を生成できるようになりました。

```dart
@JS()
extension type Options._(JSObject _) implements JSObject {
  external Options({String mode});
}
```

なお、Dart SDK 3.3.0ではそのバグ回避に下記のおまじないも必要です。

```dart
@JS()
library;
```

と言った直後に、Dart SDK 3.3.1がリリースされ、[そのバグが解消](https://github.com/dart-lang/sdk/issues/55057 "[CP] Handle object literal constructors with no library @JS annotation in dart2js · Issue #55057 · dart-lang/sdk")されました。

### ディレクトリピッカのコール

これで晴れて、`showDirectoryPicker()`が呼べるようになりました。
```dart:before
    dirHandle =
        await window.showDirectoryPicker(mode: PermissionMode.readwrite);
```
```dart:after
    dirHandle = await web.window
        .showDirectoryPicker(Options(mode: 'readwrite'))
        .toDart;
```

## マイグレーション全容

マイグレーション全容を下記に示しておきます。
[migrate from package:file_system_access_api to package:web · Cat-sushi/fmscreen_flutter@1a58e30](https://github.com/Cat-sushi/fmscreen_flutter/commit/1a58e30bded3e610a8ba4dc6163415171ed13487?diff=split&w=0 "migrate from package:file_system_access_api to package:web · Cat-sushi/fmscreen_flutter@1a58e30")

## おわりに

実際にやってみると、たいして難しくはありません。
また、`dart:js_interop`のおかげでJS FFIが自分で作れるのが良いですね。今回の様に、Dart SDKのサポートも、サードパーティAPIの出現も、W3Cにおけるexperimentalの卒業すらも待つ必要がありませんので。

### 課題

#### JSオブジェクトリテラル生成が面倒

汎用的にMapからオブジェクトリテラルを作成できる手段があると良いと思いました。Web APIのoptions実現のためなら`Map<String, dynamic>`からオブジェクトリテラルが作成できるだけでもだいぶ違うと思います。
いやいや、[Dartにおける名前付きパラメタ群をJSにける最終パラメタ(オブジェクトリテラルを期待)](https://github.com/dart-lang/sdk/issues/55141)にすれば良いんではないでしょうか？

#### `package:web`で警告が出ない

Web向けプラグイン以外で`dart:html`をインポートすると警告が出ていましたが、`package:web`にマイグレーションしたところ警告が出なくなりました。
まあ、自作アプリはWeb専用なので警告は出ないほうが良いのですが...