2026年8月14日

security-scoped bookmark で間違えた4つのこと

Divecut はサンドボックス化された macOS アプリで、素材フォルダを指し続ける — 終了して起動し直したあとも。その仕組みがsecurity-scoped bookmarkだ。 ユーザーに一度フォルダを選んでもらい、そのデータを保存して、次回起動時に 解決(resolve)する。

API 自体は小さい。それでも4回間違えた。どのバグも形は同じだった — 何か無関係なものが変わるまで見えないまま潜んでいて、変わった 瞬間に一気に表面化する。修正そのものより、このパターンのほうが学びになる。

1. bookmark を持っていることと、アクセスできることは別物

起動時に bookmark が存在することだけ確認して、そのまま先へ進んでいた。 サムネイルは読み込まれ、グリッドは埋まり、何も問題なさそうに見えた。

そしてサムネイルキャッシュのバージョンを上げた。すべてのセルが一斉に、 壊れたファイルを示すバッジに変わった。

bookmark は一度も resolve されていなかった。resolve()startAccessingSecurityScopedResource() が一度も呼ばれていなかった ので、アプリには原本を読む権限がそもそも無かった。キャッシュが温まっている 間は問題にならなかった — ソースファイルに一切触れないからだ。キャッシュを 無効化したことで、バグを隠していたものが取り除かれた。

権限まわりのバグの手前にキャッシュがあると、キャッシュが温かい 限りそのバグは見えないままになる。

修正は地味だ。起動時に resolve → アクセス開始まで一通りやりきって、 アプリが動いている間ずっとそのアクセスを保持する。

2. /var/private/var が食い違う

システムの temp ディレクトリ配下のフォルダを取り込むと、クリップが0件に なった。エラーもクラッシュもなく、ただ空のグリッドが出るだけ。

FileManager.enumerator はシンボリックリンクを解決済みのパスを 返すので、ファイルは /private/var/… という形で戻ってくる。 一方、比較していたフォルダのパスは /var/… だった。この2つを 前方一致で比べても、絶対に当たらない。

厄介なのは、素直な正規化手段では直らないということ。 standardizedFileURLresolvingSymlinksInPath() も、 /var の特殊なシンボリックリンクは解決してくれない — むしろ逆 方向に働いて /private を剥がしてしまう。効くのは canonicalPath リソース値のほう。

let canonical = try? standardized
    .resourceValues(forKeys: [.canonicalPathKey])
    .canonicalPath

修正したあとの落とし穴はここ。比較するすべてのパスで 正規化しないといけない。取り込み時には正規化していたが、フォルダ移動 チェックのほうではしていなくて、食い違いがそのまま戻ってきた。

3. 古くなった bookmark は、外部からは更新できない

ユーザーが Finder でフォルダを移動すると、bookmark は resolve はできる ものの stale(古くなった)マークが付いて返ってくる。想定されている対応は、 新しい bookmark を書き直すこと。実際そのとおりにした — isStale を検知し、bookmarkData() をもう一度呼んで、保存した。

だが、それでは実際には何も更新されない。いまアクセス中でない、再構築 された URL から作った bookmark は、また stale に戻るだけだ — Apple Developer Forums にも同様の報告があり、自分が見た挙動とも一致する。

更新はアクセススコープので行う必要がある。アクセスを開始し、 新しい bookmark を書き、アクセスを終了する — これを resolve から返る 前に、すべて済ませる。

4. 2回の書き込み、1回のクラッシュ、間違ったフォルダ

「ユーザーがフォルダを移動した」ことを検知するために、最後に resolve に成功したパスを、bookmark とは別のキーで保存していた。2つの値、 2回の書き込み。

この2回の間でアプリが死ぬと、新しい場所を指す bookmark と、古い場所を 指したままのパスのヒントが残る。次回起動時、この組み合わせはフォルダ が移動したと解釈され、アプリは何も間違っていなかったクリップの パスまで書き換えてしまう。悪いタイミングでのクラッシュが、ライブラリを 壊す。

起動 N 書き込み1: bookmark = 新しい場所 クラッシュ 書き込み2: パスのヒント 実行されない 起動 N+1 bookmark: 新 パスのヒント: 旧 「フォルダ移動」と判定 クリップパスを書換え 壊れていなかったのに
この隙間は小さいが、フォルダの変更のたびに存在する — そして被害は、移動とは何の関係もなかったクリップに及ぶ。

修正は、2回の書き込みをやめることだった。両方の値を1つの blob にまとめて、 1つのキーで保存する。UserDefaults への単発の書き込みは 分割(tear)されないので、読み手は完全な旧値か完全な新値のどちらかしか 見えない — 混ざることは絶対にない。

この種のありがちなミスを防ぐ組み合わせ

一時的なアクセスはすべて1つのヘルパーを経由させる。こうすればエラー が投げられても、スコープが漏れることはない。

public func withAccess<T>(to url: URL, _ body: (URL) throws -> T) throws -> T {
    let granted = url.startAccessingSecurityScopedResource()
    defer {
        if granted { url.stopAccessingSecurityScopedResource() }
    }
    guard granted else { throw StoreError.accessDenied }
    return try body(url)
}

ライブラリフォルダは意図的な例外だ。アプリが動いている間ずっと開いた ままにするので、このヘルパーは通さない — そこで defer による クローズをやると、まさに間違った挙動になる。

始める前の自分に言っておきたいこと

バグ何が隠していたか
bookmark を一度も resolve していなかった温まったサムネイルキャッシュ
/var vs /private/varそのシンボリックリンクを使うのは temp ディレクトリだけ
更新したつもりが更新されない stale リフレッシュ開発中は誰もフォルダを移動しない
2回の書き込み、1回のクラッシュ特定のミリ秒にアプリが死ぬ必要がある

どれも、最初にもっと慎重にコードを書いていれば防げた、というものでは なかった。見つかったのは、何か別のもの — キャッシュのバージョン、 テスト用フォルダ、コードレビュー — を変えて、何が崩れるか観察した 結果だ。この API の上に何かを作るなら、一度わざとキャッシュを無効化 してみる価値がある。何が何の裏付けもなく立っていたのか、それだけでも 分かる。

Divecut は macOS と iPad 向けの素材ブラウザです。Log クリップを正しい 色で表示し、言葉で検索でき、選んだ素材を Log のまま DaVinci Resolve へ渡します。できることを見る