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つを
前方一致で比べても、絶対に当たらない。
厄介なのは、素直な正規化手段では直らないということ。
standardizedFileURL も resolvingSymlinksInPath() も、
/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 と、古い場所を 指したままのパスのヒントが残る。次回起動時、この組み合わせはフォルダ が移動したと解釈され、アプリは何も間違っていなかったクリップの パスまで書き換えてしまう。悪いタイミングでのクラッシュが、ライブラリを 壊す。
修正は、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 へ渡します。できることを見る。