Claude の多段レビュー(7観点の並列レビュー+反証検証)で浮かんだ「サンドボックスと永続ファイルアクセス」まわりの観察を、勉強のためにまとめたものです。
綴Photo のデスクトップ版は、ユーザーが選んだフォルダを再帰的にスキャンして、画像ファイルの絶対パスをデータベースに保存し、次回以降はそのパスを直接読んで写真を表示する設計になっている。一見すると「パスを覚えておけば再起動後も読めるはず」と思える。
ところが、デスクトップ OS のサンドボックスはそう単純ではありません。ファイルピッカーで一度許可を得ても、その許可はアプリを閉じると基本的に消えます。 同じ Flutter のコードで動いていても、この「永続アクセス」の扱いは Windows と macOS で別物だった。
この記事で見ていく点
- ファイルピッカーの許可は「その場限り」が OS の原則
- Windows(ストア配布)は「制限付き機能」を宣言して対処済み
- macOS は再起動後にパスを読み直す仕組みが未実装で、検証待ちの課題が残っている
- 「一度動いた」が「ずっと動く」を意味しない、セッション内検証の罠
- iOS / Android はそもそもこの問題が発生しない(別の権限モデル)
保存したパスは、許可までは保存していない
コードで起きていたこと
綴Photo のデスクトップ版の流れはこうだ。
- ユーザーがファイルピッカーでフォルダを選ぶ
- そのフォルダ配下を再帰的に走査し、画像ファイルの絶対パスをデータベースに保存する
- 一覧画面(本棚)や詳細画面では、保存しておいたパスからファイルを直接開いて表示する
コードで言うと、保存時はパスを文字列としてそのまま記録し、表示時には次のようにそのパスからファイルを開く。
// 保存しておいた絶対パスを、再起動後もそのまま開こうとする
final file = File(row.localPath); // ← row.localPath にはデスクトップでの絶対パスが入っている
if (!await file.exists()) {
// ファイルが見つからない扱いになる
}ここで見落としやすいのが、「パスを保存しておくこと」と「そのパスを読む許可を保存しておくこと」は別物だという点です。
なぜそうなりやすいか
「絶対パスを保存して再読込する」という発想は、サンドボックスのない世界(昔ながらのデスクトップアプリや、自分のローカルで flutter run するデバッグ実行)では完全に正しいです。デバッグ実行はサンドボックスの制約が緩い/無いことが多く、そこでは何の問題もなく動きます。
だから開発中は問題が一切見えません。サンドボックスが本気で効くのは、パッケージ版(開発中のナマの状態ではなく、ストアに並べて配れる"箱詰め"をした完成品。Windows ではこの箱の形式を MSIX と呼びます)、つまり macOS の App Sandbox を有効にしたビルドや Windows の MSIX であって、そこで初めて「パスはあるのに読めない」が表面化します。「ローカルで動いたから大丈夫」と思い込みやすい構造になっています。
改善の方向
許可そのものを保存して、次回起動時に復元する仕組みが要ります。OS ごとに作法が違うので、それぞれ別に対処する必要があります(このあと詳しく見る)。
学びポイント
サンドボックスの制約はデバッグ実行では見えにくく、パッケージ版で初めて効きます。
同じ Flutter コードでも、永続アクセスの作法は OS ごとに別物
同じ Dart のコードがそのまま 4 プラットフォームで動くのが Flutter の強みだが、「ファイルへの永続アクセス」に関しては OS ごとに事情が分かれていた。
Windows(MSIX / ストア配布): 宣言で対処済み
Windows のストアアプリ(MSIX でパッケージ化したもの)もサンドボックスのコンテナの中で動きます。ファイルピッカー経由の一時的なアクセス権だけでは、再起動後に保存したパスを読み直すには足りません。
綴Photo では、パッケージ設定に broadFileSystemAccess という制限付き機能(restricted capability)を宣言することで対処している。これは「ユーザーが選んだファイルだけ」ではなく「より広いファイルシステムへのアクセス」を申請する宣言で、これによって保存しておいた任意パスを再起動後も読めるようになります。
ただしこれはタダではありません。
- ストアに申請するとき、なぜこの強い権限が必要かを申請フォームに記載する必要がある
- 最終的にユーザー側が Windows のプライバシー設定でファイルアクセスを ON にすることにも依存する
「広い権限を宣言すれば動くが、その分だけ申請とユーザーの同意のハードルが上がる」というトレードオフがあります。ここは綴Photo では宣言済みで、実機(MSIX 版)でフォルダスキャンと再表示が動くことも確認できている。
macOS(App Sandbox): ここが未解決の課題
macOS 版は App Sandbox を有効にしていて、宣言している権限は次の2つだけだ。
macOS では「この権限を使います」という宣言を、設定ファイルにこう書いておきます(↓の XML がそれ)。
<!-- app-sandbox: 個室の中で動かす -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- user-selected.read-only: ユーザーがピッカーで選んだファイル/フォルダを読み取り専用で開ける。
ただしこれは「その場限りの一時許可」であり、引換券(security-scoped bookmark)を
発行・保存する権限ではない。つまりプロセスが終わると許可ごと消える -->
<key>com.apple.security.files.user-selected.read-only</key>
<true/>user-selected.read-only は「ユーザーがピッカーで明示的に選んだファイル/フォルダだけ、読み取り専用で開ける」という権限。これでフォルダを選んでスキャンするところまでは動きます。再起動後に同じパスを読む許可は、この宣言だけでは残りません。
綴Photo の macOS 版には、この security-scoped bookmark を保存・復元する処理は現状入っていない。コードベースを検索しても、bookmark を発行・解決する処理は見当たらず、保存しているのは絶対パスの文字列だけだった。
つまりコードの構造上は、macOS 版で一度フォルダを綴じて、アプリを完全に再起動すると、本棚の写真が読めなくなる可能性が否定できない。これは 2026 年 6 月時点で実機検証待ちの未解決課題であって、「確実に壊れる」と断定しているわけではない(App Sandbox の挙動には細かい条件があり、実際にどう転ぶかは実機で確かめる必要がある)。ここは正直に「まだ確かめきれていない」と書いておく。
iOS / Android: そもそもこの問題が起きない
モバイル版(iOS / Android)は、ファイルパスを直接扱う設計ではない。photo_manager というライブラリ経由でカメラロール(端末の写真ライブラリ)にアクセスし、写真をID で参照する。
これはサンドボックス下のファイルパス問題とは別の権限モデルで、「写真ライブラリへのアクセス許可」をユーザーが与えると、その範囲の写真に ID でアクセスできます。「絶対パスを保存して再起動後に読み直す」という構図そのものが存在しないので、この記事で扱っている永続アクセスの問題は発生しません。
綴Photo のコードでは、この差を NewPhotoData という共通の型が吸収している。モバイル用の assetId(カメラロールの ID)とデスクトップ用の localPath(ファイルパス)を両方 nullable で持たせて、どちらの世界からでも同じ型で写真を扱えるようにしている。便利な抽象化だが、抽象化の下では権限モデルが完全に別物だという点は意識しておく必要があります。
「一度動いた」は「ずっと動く」を意味しない
この課題には、検証の進め方そのものに落とし穴があった。
綴Photo には「macOS で一度、フォルダを選んで写真が表示されるのを確認した」という記録がある。普通はこれで「動いた、OK」と判断したくなります。だが、ここには罠がある。
同じセッション(アプリを起動してから終了するまで)の中では、フォルダを選び直すたびに許可が復活します。 だから「フォルダを選ぶ → 表示される」を同じ起動中に確認している限り、たとえ永続アクセスの仕組みが入っていなくても問題がマスクされてしまいます。
本当に確かめたいのは「アプリを完全に終了して、もう一度起動した後、何も選び直さずに本棚の写真が読めるか」だ。この「再起動後」を挟まない検証は、永続アクセスのバグを素通りさせてしまいます。
学びポイント
- 永続性(再起動をまたいで保たれるか)を確かめたいなら、検証手順に「プロセスを完全に落としてから再起動」を必ず挟む
- 「一度表示された」という確認は、それが同じセッション内なら永続アクセスの保証にはならない
- 動作確認のログを残すときは「いつ・どの起動で確認したか」までセットで記録しておくと、後からこの罠に気づける
このテーマの括り
サンドボックスは「アプリごとの個室」という形でユーザーを守る仕組みで、その代償として「外のファイルに触る許可は、その都度・一時的に」という制約を課します。この制約を前提に置くと、「絶対パスをデータベースに保存して再読込する」という素朴な設計は、サンドボックスのない開発環境では動くのに、パッケージ版では破綻しうる、という非対称が生まれます。
おわりに
この種の問題は、「複数プラットフォーム対応」と「ストア配布用のパッケージ化」が重なるところで出やすいです。共通コードがそのまま全 OS で動くように見えるぶん、各 OS のサンドボックスが課す制約の差が見えにくくなります。しかもその差は、ローカルのデバッグ実行では表に出ず、パッケージ化して初めて顔を出します。
綴Photo では、Windows は権限宣言で対処済み、macOS は引換券(security-scoped bookmark)が未対応、という状態だった。その macOS を実機で検証した結果と、そこで入れた bookmark の対処は、このあとの追記にまとめた。
追記(2026-06-11): 実機検証の結果と解消
この記事を書いた直後に macOS の実機で検証した。結果は問題が実在するだった。
検証は前述の『再起動を挟む』手順どおりに行った。フォルダを選んでスキャン → 1 枚綴る → その場では写真が表示される(同一セッション内なので当然)→ アプリを完全終了 → 再起動 → 何も選び直さずに本棚を開く。すると、ファイルはフォルダに存在したままなのに、写真は灰色のエラー表示になった。「許可はプロセス終了で消える」という教科書どおりの挙動が、そのまま再現された形だ。
対処として、security-scoped bookmark の保存・復元を実装した。要点は 3 つ:
- 選んだ瞬間に引換券を保存する: ピッカーでフォルダ/ファイルの選択が成功した直後(まだ許可が生きているうち)に bookmark を作成して保存する。OS が bookmark を発行するには「今まさに有効な一時許可」が材料として必要で、許可が切れた後からでは引換券そのものを作れない
- 起動時に全部復元する: アプリ起動の最初(画面が写真を読みに行くより前)に、保存済みの bookmark をすべて resolve して許可を再取得する
- 引換券はそのマシン固有のデータとして扱う: bookmark は「その Mac・そのアプリ署名」に紐づくバイト列で、別の Mac に持ち出しても無効になる。将来クラウド同期するデータベースに混ぜると、同期先で壊れたデータになるため、端末ローカルの保存領域に分離した
コードにするとこうだ。使ったのは macos_secure_bookmarks(startAccessingSecurityScopedResource などの macOS 側 API を Dart から呼ぶラッパ)。
// ① 選んだ瞬間に引換券(bookmark)を発行して保存する。
// ピッカーでフォルダ/ファイルの選択が成功した直後に呼ぶ(まだ許可が生きている)。
final bookmark = await SecureBookmarks().bookmark(File(path)); // base64 文字列が返る
// 保存先は Drift の DB ではなく SharedPreferences に path → bookmark のマップで持つ
// (bookmark はマシン固有なので、クラウド同期する DB に混ぜると同期先で壊れる)
// ② 起動時、画面が写真を読みに行くより前に、保存した券を resolve して許可を取り直す。
final entity = await SecureBookmarks().resolveBookmark(bookmark);
await SecureBookmarks().startAccessingSecurityScopedResource(entity);有効化には entitlement が 1 行要ります。これが無いと bookmark の発行自体が権限エラーになります。
<!-- user-selected だけでは券を「作る」権限が無い。これで発行・保存できるようになる -->
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>一つハマったのが復元時の受け渡しだ。resolveBookmark が返したオブジェクトを、作り直さずそのまま startAccessingSecurityScopedResource へ渡さないといけません。自分で File(path) を作り直して渡すと、パス文字列は同じでも別インスタンス扱いになり、許可の取得が黙って空振りします。エラーも出ないので、公式ドキュメントに明記のないこの挙動は、実機で「resolve は通っているのに写真が読めない」を追ってようやく分かった。
実装後、同じ手順で再検証し、再起動後も本棚の写真が表示されることを確認した。前述の「再起動を挟む検証手順」が、問題の発見と修正の確認の両方でそのまま役に立った。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。特にサンドボックスと security-scoped bookmark の挙動は OS バージョンや配布形態で細かく変わる領域なので、最新の公式ドキュメントも合わせて参照してください。