Quatre erreurs que j’ai commises avec les security-scoped bookmarks
Divecut est une appli macOS sandboxée qui pointe vers un dossier de rushes et continue de pointer vers lui après avoir quitté puis relancé. Le mécanisme pour ça, c’est un security-scoped bookmark : on demande un dossier à l’utilisateur une fois, on stocke un blob, et on le résout au lancement suivant.
L’API est réduite. Je me suis quand même trompé quatre fois. Chaque bug avait la même forme — il restait invisible jusqu’à ce qu’autre chose, sans rapport, change, et alors il refaisait surface d’un coup. Ce schéma est la partie utile, plus que les correctifs eux-mêmes.
1. Détenir un bookmark n’est pas la même chose qu’avoir l’accès
Au lancement, je vérifiais qu’un bookmark existait et je passais à la suite. Les vignettes se chargeaient, la grille se remplissait, tout avait l’air normal.
Puis j’ai fait évoluer la version du cache de vignettes. Chaque case s’est transformée d’un coup en badge de fichier cassé.
Le bookmark n’avait jamais été résolu. resolve() et
startAccessingSecurityScopedResource() n’étaient jamais appelés,
donc l’appli n’avait aucune permission pour lire les originaux. Ça n’avait pas
d’importance tant que le cache était chaud, parce que rien ne touchait aux
fichiers source. Invalider le cache a retiré ce qui avait caché le bug.
Un cache placé devant un bug de permission le gardera invisible aussi longtemps que le cache reste chaud.
Le correctif n’a rien de glorieux : au lancement, aller jusqu’au bout de resolve → start accessing, et garder cet accès aussi longtemps que l’appli tourne.
2. /var et /private/var ne sont pas d’accord
Importer un dossier situé sous le répertoire temporaire du système produisait zéro clip. Pas d’erreur, pas de crash — une grille vide.
FileManager.enumerator renvoie des chemins dont les liens
symboliques sont déjà résolus, donc les fichiers revenaient sous la forme
/private/var/…. Le chemin de dossier auquel je les comparais
était /var/…. Une comparaison de préfixe entre les deux ne
correspond jamais.
Ce qui rend celui-ci vicieux, c’est que les normalisateurs évidents ne le
corrigent pas. Ni standardizedFileURL ni
resolvingSymlinksInPath() ne résolvent le lien spécial
/var — si quoi que ce soit, ils vont dans la direction opposée
et retirent /private. Celui qui fonctionne est la valeur de
ressource canonicalPath :
let canonical = try? standardized
.resourceValues(forKeys: [.canonicalPathKey])
.canonicalPath
Le piège après le correctif : la normalisation doit se produire sur chaque chemin comparé. Je normalisais à l’import mais pas dans la vérification de dossier déplacé, et le décalage est revenu directement.
3. Un bookmark périmé ne peut pas être rafraîchi de l’extérieur
Quand un utilisateur déplace le dossier dans le Finder, le bookmark se résout
toujours mais revient marqué comme périmé, et on est censé en écrire un
nouveau. J’ai fait exactement ça : détecté isStale, rappelé
bookmarkData(), et sauvegardé.
Ça ne rafraîchit en réalité rien du tout. Un bookmark créé à partir d’une URL reconstituée à laquelle on n’accède pas en ce moment redevient simplement périmé — c’est signalé sur les Apple Developer Forums, et ça correspond à ce que j’ai observé.
Le rafraîchissement doit se produire à l’intérieur d’une portée d’accès : démarrer l’accès, écrire le nouveau bookmark, arrêter l’accès — le tout avant de sortir de resolve.
4. Deux écritures, un crash, le mauvais dossier
Pour détecter « l’utilisateur a déplacé le dossier », je stockais le dernier chemin résolu avec succès à côté du bookmark, sous sa propre clé. Deux valeurs, deux écritures.
Si l’appli meurt entre les deux, on se retrouve avec un bookmark pointant vers le nouvel emplacement et un indice de chemin pointant vers l’ancien. Au lancement suivant, cette combinaison se lit comme le dossier a été déplacé, et l’appli réécrit des chemins de clips qui n’avaient jamais été faux. Un crash au mauvais moment corrompt la bibliothèque.
Le correctif a été d’arrêter d’avoir deux écritures. Les deux valeurs vont dans
un seul blob sauvegardé sous une seule clé. Une écriture UserDefaults
unique n’est pas déchirée, donc un lecteur voit soit l’ancienne valeur complète,
soit la nouvelle complète — jamais un mélange.
Le duo qui évite la version bête de ce bug
Chaque accès temporaire passe par un seul helper, pour qu’une erreur levée ne puisse jamais faire fuir la portée d’accès :
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)
}
Le dossier de la bibliothèque est l’exception délibérée. Il reste ouvert pendant
toute la durée de vie de l’appli, donc il ne passe pas par ce helper — le fermer
avec defer serait exactement le mauvais comportement ici.
Ce que je me dirais avant de commencer
| Bug | Ce qui l’a caché |
|---|---|
| Bookmark jamais résolu | Un cache de vignettes chaud |
/var contre /private/var | Seuls les répertoires temp utilisent ce lien symbolique |
| Un rafraîchissement périmé qui ne rafraîchit rien | Personne ne déplace de dossiers pendant le développement |
| Deux écritures, un crash | L’appli doit mourir à une milliseconde précise |
Aucun de ces bugs n’a été attrapé en écrivant du code plus soigné dès le départ. Ils ont été attrapés en changeant autre chose — une version de cache, un dossier de test, une revue de code — et en regardant ce qui s’effondrait. Si vous construisez sur cette API, ça vaut le coup d’invalider vos caches exprès, une fois, juste pour voir ce qui ne tenait sur rien.
Divecut est un navigateur de rushes pour macOS et iPad : des clips Log dans la bonne couleur, cherchables en langage courant, transmis à DaVinci Resolve avec le Log intact. Voir ce qu’il fait.