綴Photo の勉強ノートに戻る
開発記録2026.05.14

AI が書くコードは「ファイルは普通にある」前提だった — デスクトップ対応の異常系3つ

FlutterDartデスクトップmacOSサンドボックス異常系綴Photo

綴Photo は、もともとモバイル(iOS / Android)向けに作っていて、後から macOS / Windows のデスクトップ対応を足した。

モバイルは端末のカメラロールから写真を取ります。デスクトップにはカメラロールが無いので、ユーザーがフォルダやファイルを選んで、そのファイルを直接読む作りになっています。

実ファイルは DB に入らない。端末 SQLite に残るのは ID かパスの文字列。モバイル実ファイルは写真ライブラリ指す札は assetIdアプリの外。photo_manager 経由デスクトップ実ファイルはディスク上住所は localPathユーザーが選んだフォルダ端末 SQLite Photos / PassedPhotosassetId か localPath を保存する。実ファイルは入らない。閉じても残る。

この「ファイルを直接読む」に切り替えたことで、モバイルでは一度も踏まなかった 「ファイルが思い通りにない」ケース が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 に保存しています。

また今度のパスは端末 SQLite の PassedPhotos。実ファイルはディスク。候補はメモリで保存しない。端末 SQLite PassedPhotos10行の localPath が残る。閉じても残る。開くたび existsディスク上の実ファイルフォルダ移動・リネームで、パスの先が空になる。開いている間だけメモリ candidates 0件保存しないメモリ missingFiles 10保存しない直し前 綴り終わった画面直し後 欠損の案内

元のコードの問題

元のコードはこうなっていた(簡略化):

dart
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枚も綴ってないのに「完了」と表示されます。ユーザーからしたら意味がわかりません。

直し方

「ファイルが何個欠損したか」を数えて、欠損が原因で空になったときは専用の状態を返すようにした。

dart
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 はすぐ消えるので「何が起きたか」が伝わりにくいです。

直し方

失敗したらスピナーのままにせず、エラー画面に切り替えて「もう一度選ぶ」「戻る」を明示する ようにした。

dart
if (photo == null) {
  setState(() {
    _picking = false;
    _errorMessage = 'この画像は読み込めませんでした。\n別の画像を選び直してください。';
  });
  return;
}

build 側で _errorMessage があればエラー画面、無ければスピナー、と出し分けます。

学び

「処理中はスピナー」を書くとき、スピナーから抜ける経路を全部用意したか を意識する必要があります。成功 → 次の画面、は誰でも書きます。でも「失敗 → どこに戻る?」「キャンセル → どこに戻る?」が抜けると、ユーザーは永遠にくるくるを見続けることになります。


全体の学び: AI は「正常系」に強く「異常系」に弱い

3つ並べてみて、共通点がはっきりした。どれも「ファイルが期待どおりにそこにある」前提が崩れたケース だった。

3つの外れは、ファイルに対する位置が違う。権限ディスク: ある許可リストに扉が無いRelease.entitlementsDB ではないOS の檻が拒むアプリ同梱の plist欠損PassedPhotos: パスがあるディスク: 無いパスは端末 SQLite実ファイルはディスク閉じても行だけ残る壊れディスク: あるデコード: メモリで null失敗は保存しない画面のメモリだけ_errorMessage許可は entitlements。パスは PassedPhotos。失敗の null は画面メモリで、保存しない。

そして、これらが全部抜けていた理由は、たぶん AI に「フォルダから写真を読む処理」を頼むと、AI は「フォルダがあって、中に正常な画像があって、それを読む」という幸福な世界を綺麗に書く からです。頼んだこちらも「写真を読む処理」としか言ってないので、異常系まで指示していない。

これは AI が悪いというより、指示する側(自分)が「正常系の話しかしていない」 という構造の問題だと思う。「フォルダから写真を読んで」じゃなくて「フォルダから写真を読んで、ファイルが無いとき・読めないとき・壊れているときも UI が破綻しないように」と頼むべきだった。

非エンジニアが AI に実装を任せるスタイルで開発していると、こういう「異常系の指示漏れ」は構造的に起きやすいです。第三者レビュー(Codex)を回すのは、この漏れを後から拾うための保険として、けっこう効いています。


おわりに

デスクトップで File を直接扱うようにした瞬間に、「ファイルシステムは思い通りにならない」という当たり前の現実が一気に表面化した感じ。

ファイルを直接触るコードを AI に書かせるときは、「無い・読めない・壊れてる」の3点セットを最初から指示に入れておくと、後から拾う手間が減ると思う。

この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。