綴Photo は、もともとモバイル(iOS / Android)向けに作っていて、後から macOS / Windows のデスクトップ対応を足した。
モバイルは端末のカメラロールから写真を取ります。デスクトップにはカメラロールが無いので、ユーザーがフォルダやファイルを選んで、そのファイルを直接読む作りになっています。
この「ファイルを直接読む」に切り替えたことで、モバイルでは一度も踏まなかった 「ファイルが思い通りにない」ケース が3つ噴き出した。今回それをまとめて直したので、記録として残す。
この記事の前提: Codex のコードレビューで拾えた落とし穴だった
デスクトップ対応は、実装を進めながら節目で Codex(別の AI)にコードレビューを走らせていた。今回の3つは、そのレビューを通したからこそ表に出てきたもの。
File を直接読むようになると、macOS のサンドボックス権限のような OS 固有の設定 や、「選んだファイルがもう無い・壊れている」という ファイルシステムの現実 に向き合うことになります。完成したと思っていても、レビューを走らせると穴は見つかります。今回もその3つだった。
ケース1: サンドボックスでファイルが読めない(macOS)
何が起きるか
macOS アプリは「サンドボックス」という仕組みの中で動きます。アプリが勝手に何でも読み書きできないように、OS が檻に入れているイメージ。
綴Photo のデスクトップ版は file_picker でユーザーにフォルダを選ばせて、その中の画像を File で直接読みます。ところが、サンドボックスの設定(entitlements)に「ユーザーが選んだファイルを読む権限」が書かれていなかった。
開発中(Debug ビルド)は気づきにくいです。署名済みの Release ビルドにしたときに、サンドボックスがちゃんと効いて「ユーザーがフォルダを選んだのに中身が読めない」が起きます。
学び
サンドボックス系の穴は Debug ビルドだと再現しない ことがあります。「動いてるから大丈夫」が Release で崩れるタイプ。entitlements は最初から Release/Debug 両方に揃えておくのが安全です。
ケース2: ファイルが移動されて見つからない
何が起きるか
綴Photo には「また今度」という機能があります。今は綴らない写真をスキップしておいて、後でまとめて見返せます。
デスクトップ版だと、この「また今度」リストは ファイルパスを DB に保存しています。
元のコードの問題
元のコードはこうなっていた(簡略化):
final candidates = <UnprocessedPhoto>[];
for (final row in rows) {
final file = File(row.assetId);
if (!await file.exists()) continue; // 無いファイルは黙って飛ばす
candidates.add(/* ... */);
}
if (candidates.isEmpty) {
// 「綴り終わった」と同じ扱いになってしまう
return TsuzuruSessionState.empty(emptyReason: noAvailablePhotos);
}ファイルが全部見つからなくても candidates が空になるだけで、「この月は綴り終えました」と同じ画面が出ます。実際には1枚も綴ってないのに「完了」と表示されます。ユーザーからしたら意味がわかりません。
直し方
「ファイルが何個欠損したか」を数えて、欠損が原因で空になったときは専用の状態を返すようにした。
final candidates = <UnprocessedPhoto>[];
var missingFiles = 0;
for (final row in rows) {
final file = File(row.assetId);
if (!await file.exists()) {
missingFiles++; // 黙って飛ばさず数える
continue;
}
candidates.add(/* ... */);
}
if (candidates.isEmpty) {
return TsuzuruSessionState.empty(
emptyReason: missingFiles > 0
? TsuzuruEmptyReason.missingFiles // ← 専用の状態
: TsuzuruEmptyReason.noAvailablePhotos,
);
}UI 側で missingFiles のときは「この月の写真が見つかりませんでした。フォルダの場所が変わった可能性があります」と案内を出します。
学び
if (!await file.exists()) continue; みたいな「無かったら黙って飛ばす」は、一見安全に見えて 「全部無かった」と「最初から空だった」を区別できなくしています。状態を潰す前に「なぜ空なのか」を1個の変数で持っておくだけで、UI で正しい案内が出せます。
ケース3: 画像のデコードに失敗してスピナーから戻れない
何が起きるか
デスクトップの「自分で選ぶ」ピッカーは、画面を開くと自動でファイルダイアログが出て、選んだファイルを読み込みます。読み込み中はスピナー(くるくる)を表示します。
ところが、選んだファイルがそもそも読めない(壊れて開けない、読み込み中にエラーになる等)と、画像のデコード(ファイルの中身を画面に映せる絵に変換すること)に失敗して null が返ります。
元のコードは、失敗したときに一瞬 SnackBar(画面下にちょっと出る通知)を出して前の画面に戻すだけだった。再選択する手段が無いし、SnackBar はすぐ消えるので「何が起きたか」が伝わりにくいです。
直し方
失敗したらスピナーのままにせず、エラー画面に切り替えて「もう一度選ぶ」「戻る」を明示する ようにした。
if (photo == null) {
setState(() {
_picking = false;
_errorMessage = 'この画像は読み込めませんでした。\n別の画像を選び直してください。';
});
return;
}build 側で _errorMessage があればエラー画面、無ければスピナー、と出し分けます。
学び
「処理中はスピナー」を書くとき、スピナーから抜ける経路を全部用意したか を意識する必要があります。成功 → 次の画面、は誰でも書きます。でも「失敗 → どこに戻る?」「キャンセル → どこに戻る?」が抜けると、ユーザーは永遠にくるくるを見続けることになります。
全体の学び: AI は「正常系」に強く「異常系」に弱い
3つ並べてみて、共通点がはっきりした。どれも「ファイルが期待どおりにそこにある」前提が崩れたケース だった。
そして、これらが全部抜けていた理由は、たぶん AI に「フォルダから写真を読む処理」を頼むと、AI は「フォルダがあって、中に正常な画像があって、それを読む」という幸福な世界を綺麗に書く からです。頼んだこちらも「写真を読む処理」としか言ってないので、異常系まで指示していない。
これは AI が悪いというより、指示する側(自分)が「正常系の話しかしていない」 という構造の問題だと思う。「フォルダから写真を読んで」じゃなくて「フォルダから写真を読んで、ファイルが無いとき・読めないとき・壊れているときも UI が破綻しないように」と頼むべきだった。
非エンジニアが AI に実装を任せるスタイルで開発していると、こういう「異常系の指示漏れ」は構造的に起きやすいです。第三者レビュー(Codex)を回すのは、この漏れを後から拾うための保険として、けっこう効いています。
おわりに
デスクトップで File を直接扱うようにした瞬間に、「ファイルシステムは思い通りにならない」という当たり前の現実が一気に表面化した感じ。
ファイルを直接触るコードを AI に書かせるときは、「無い・読めない・壊れてる」の3点セットを最初から指示に入れておくと、後から拾う手間が減ると思う。
この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。