ネイティブより先にPWA、Flutter Webで届けた判断と配信
英会話アプリ「イエタ」は、Windowsの利用者へネイティブ版ではなくFlutter WebのPWAを本体にして届けています。その理由と、紹介ページとアプリを分けた配信の構成、独自ドメイン、Webで困った点の記録です。
英会話アプリ「イエタ」は、Windows の利用者へ、ネイティブアプリではなく Flutter Web の PWA を本体にして届けています。PWA(Progressive Web App)は、ブラウザで開く Web アプリを、パソコンにインストールしてアプリのように使える形式です。2026年10月2日にこう決め、10月5日に紹介ページとアプリを分けた構成で本番に出し、独自ドメイン ieta.warp-studio.com(別タブで開きます) で公開しました。Web にしたことで、認証の作り直しとストア審査を後回しにできました。代わりに、入力欄のフォーカスやフォント、キャッシュ、音声といった問題が Web 版の操作で見つかっています。
決めたこと
再始動の初日、採算の評価で配布の道筋を比べました。Windows ネイティブ(MSIX というパッケージで Microsoft Store に出す形)、Web 版(PWA)、Android です。推奨は Web を本体にすることで、私もそれを選びました。目標の文書には「Windows ユーザーへは Web 版(PWA)を本体にして届ける。Store には PWA として掲載することも検討する。Windows ネイティブ(MSIX)は後回し」と残っています。それまでのリリース手順書は MSIX を作ってストアに出す前提でしたが、Microsoft Store の個人の開発者登録は2025年9月から無料で、登録の費用はどちらもほぼかかりません。差が出たのは、認証、審査、読み上げ、届く範囲でした。
ネイティブを後回しにした理由
当時の Windows 版は Firebase を無効にしていて、認証がありませんでした。使用回数も端末内だけで管理していて、書き換えれば上限なしで AI を呼べる状態です。Windows では Firebase の SDK が使えず、認証は REST API で自作する必要がありました。Microsoft Store のポリシーも、自前の決済には利用者の認証を求めています。Web なら SDK がそのまま動きます。
読み上げも違います。ネイティブ版は PowerShell 経由の SAPI で、古い機械音声に起動の遅れが加わります。Web ならブラウザの音声を使え、Edge には高品質なオンライン音声があります。Web 版は Android や iPhone のブラウザでも動く作りなので、Google Play のクローズドテスト(12人が14日続けて試す)を待たずに届けられます。
| 観点 | Windows ネイティブ(MSIX) | Web 版(PWA) |
|---|---|---|
| 認証 | Firebase の SDK が使えず REST で自作 | SDK がそのまま動く |
| 審査 | 本人確認、生成 AI の明記、自前決済の申告など | なし(特商法の表記は必要) |
| 決済の手数料 | 自前決済なら Microsoft 0%+Stripe | Stripe のみ |
| 読み上げ | PowerShell 経由の SAPI | ブラウザの音声 |
| 他の環境 | Windows のみ | Android・iOS のブラウザでも動く |
失うものもあります。Flutter Web は画面を canvas(ブラウザの描画領域)に絵として描くので、検索エンジンが文章を読めません。トップがアプリのままでは、検索に何も見せられません。
静的な紹介ページと /app/
そこで10月5日に配信物を組み直しました。Firebase Hosting が配る build/site に、静的な紹介ページ、シーン別のフレーズページ38本、404、robots.txt、sitemap.xml を置き、Flutter のアプリは /app/ の下に入れます。
アプリは flutter build web --release --base-href /app/ で作ります。base href は、アプリが読み込み先の基準にするパスです。サイトをまとめるスクリプトは、ビルドの index.html に <base href="/app/"> が無ければ止まります。Hosting の設定は次のとおりです(hosting の部分を、一部省いて改行しています)。
{
"public": "build/site",
"rewrites": [{ "source": "/app/**", "destination": "/app/index.html" }],
"headers": [
{ "source": "**/*.@(js|json|html|css|xml|txt)",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }] },
{ "source": "/",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }] }
]
}
書き換え(rewrites)は /app/ の下だけです。アプリの深い URL を直接開いてもアプリが返り、それ以外の存在しない URL は 404 になります。robots.txt では /app/ をクロールの対象から外しました。
シーン別ページの「練習する」は /app/?scenario=<シーンの ID> へのリンクです。初めて来た人は最初の説明画面を省き、そのシーンのロールプレイから始まります。紹介ページの回数・価格・シーン数は、アプリとサーバーの定義ファイルから読んで埋めます。宣伝の文と実際の上限をずらさないためです。
1年のキャッシュをやめた
上の no-cache には経緯があります。以前は JavaScript に1年のキャッシュ(max-age=31536000)が付いていました。Flutter Web の main.dart.js と flutter_bootstrap.js は、ファイル名に内容のハッシュが入りません。更新を出しても一度開いた人のブラウザには古いアプリが残り、新しいサーバーと食い違って壊れるおそれがありました。
気づいたのは、製品監査の3回目でアプリを操作していた評価用の AI です(評価の仕組みは改善ループのゲート設計の制作ノートに書きました)。設定を no-cache に変え、ブラウザが毎回 ETag(内容の識別子)で変更の有無を確かめ、変わっていなければ取り直さないようにしました。
出したあとに確かめる12項目
本番へは1コマンドで出します。出す前には、できあがった build/site を手元で開いて自動で操作し、本番には通信しません。
出したあとは、ログインしていない呼び出しの拒否、トップが紹介ページであること、/app/ の下の深いパス、存在しないページの 404 など、12項目を確かめます。10月5日の反映では全部通りました。本番のアプリは開かず、静的なページだけをスマホ幅で見ています。本番に利用者のデータを作らないためです。
独自ドメインと証明書
公開の URL は、改名と同じ日に ieta.warp-studio.com にしました(経緯は「はなせるAI」から「イエタ」へ改名した理由の制作ノート)。Firebase Hosting に独自ドメインを登録し、DNS を管理している Cloudflare に CNAME レコード(別名の指定)を足して、Firebase の既定のホスト名を指しました。Cloudflare のプロキシは OFF です。Firebase Authentication の承認済みドメインにも加えました。製品監査の5回目は、本番の Google ログインがこの設定次第で失敗し得ると書いていました。
DNS を足して約6分後と約18分後に確かめたときは、ドメインの所有の確認は済んだものの証明書はまだ検証中で、新しい URL は開けませんでした。約23分後、改名を本番に出し終えたときには発行されていて、新しい URL で紹介ページが開きました。出したあとの確認では、発行中の独自ドメインを失敗扱いにせず、注意だけを出します。
Web 版で見つかった問題
入力欄のフォーカス
会話の入力欄の不具合は、作者の確認で3回 FAIL してから原因にたどり着きました。AI の返事を待つあいだに次の文を打つと、文字が消え、フォーカスが外れたのです。原因は2つです。返事を待つあいだ入力欄そのものを無効にしていたこと。読み上げが始まると入力欄の直前に表示が差し込まれ、キー(Flutter が部品を見分ける印)のない入力エリアの要素がそちらに使い回されて、入力欄が作り直されていたことです。見た目はフォーカスが当たっているのに、キー入力が届きませんでした。原因自体は Web に限りませんが、見つかったのは Web 版の操作でした。
// 入力エリア: キーが無いと、直前に差し込まれた読み上げ中の表示に要素が再利用される
Container(
key: const ValueKey('conversation-input-area'),
// …
)
// 入力欄(TextField): 応答待ちでも有効のまま。送信だけを送信処理の側で止める
enabled: _isServiceInitialized && usage.canUse,
textInputAction: TextInputAction.send,
onEditingComplete: () {}, // Enter で送ってもフォーカスを外さない
アクセシビリティを有効にした状態(スクリーンリーダーを使うときの状態)では、Enter で送ると150ミリ秒以内にフォーカスが画面全体へ移り、2通目を打てない現象もありました。送ったあとに付け直すと、通常の利用ではスマホのキーボードが一瞬閉じるので、アクセシビリティが有効なときだけ付け直しています。Windows のナレーターの実機では、まだ確かめていません。
フォントの読み込み
Flutter Web は、日本語や絵文字のフォントを必要になった時点で読み込みます。読み込み前に並べたボタンは、フォントが届くと文字だけ測り直され、外枠は最初の幅のまま残りました。表現ノートの絞り込みが「誤り訂」で切れたのはこのためで、フォントが変わった通知を受けて行を作り直すようにしました。オフラインでは日本語の一部が「□」になる問題も残っていて、よく使う字を同梱するかを検討中です。
読み上げの声と音声入力
読み上げは Chrome の Google の声や Edge の Online Natural の声を優先していましたが、一覧の先頭を取る作りだったため、Edge では豪州英語、Chrome では英国英語の声が選ばれることがありました。品質と en-US かどうかで点数を付ける形に変えています。
int score(Map v) {
final name = v['name']?.toString() ?? '';
final locale = v['locale']?.toString() ?? '';
final highQuality = (name.contains('Google') && name.contains('English')) ||
(name.contains('Online') && name.contains('Natural'));
return (highQuality ? 2 : 0) + (locale == 'en-US' ? 1 : 0);
}
音声入力は、マイクを許可していないと押しても何も起きませんでした。ブラウザが返すエラーの種類(not-allowed、no-speech など)ごとに日本語の案内を出し、入力欄にフォーカスを移してキーボードで続けられるようにしました。
白い起動画面と、0時をまたぐタブ
main.dart.js は約3.6MB あり、読み込むあいだ画面が真っ白でした。index.html にアイコンと「読み込んでいます…」を置き、Flutter の最初の描画で消しています。3回目の監査では、手元の配信で0.3秒後に読み込み表示が出ました。
開いたまま0時をまたぐと、サーバーは新しい日なのに、アプリは「残り0回」のままという不具合もありました。評価者は「Windows の PC ではタブを開いたままにしておく使い方が多い」と書いています。今は日付が変わったら、今日の回数や目標を新しい日に切り替えます。
まだ確かめていないこと
自動の操作は画面を出さないブラウザで行うので、音そのものは確かめられません。目標の文書は、Windows の Edge と Chrome で音声認識から読み上げまでの1往復が実機で動くことを完了の条件にしています。 スマホは画面幅を 390×844 にしたヘッドレス Edge で代用しただけで、Android や iPhone の実機のブラウザでは試していません。PWA のインストール案内と Microsoft Store への掲載は、10月5日の時点では次の作業か検討の段階です。
アプリそのものの紹介はイエタの製品ページにあります。