Cuatro formas en las que me equivoqué con los security-scoped bookmarks
Divecut es una app de macOS en sandbox que apunta a una carpeta de material y sigue apuntando a ella después de que la cierras y la vuelves a abrir. El mecanismo para eso es un security-scoped bookmark: le pides al usuario una carpeta una vez, guardas un blob, y lo resuelves en el siguiente lanzamiento.
La API es pequeña. Aun así me equivoqué cuatro veces. Cada bug tenía la misma forma — se quedaba invisible hasta que cambiaba algo sin relación, y entonces salía a la luz de golpe. Ese patrón es la parte útil, más que las correcciones.
1. Tener un bookmark no es lo mismo que tener acceso
Al arrancar comprobaba que existiera un bookmark y seguía adelante. Las miniaturas cargaban, la cuadrícula se llenaba, todo se veía bien.
Entonces subí la versión de la caché de miniaturas. Todas las celdas se convirtieron en una etiqueta de archivo roto de golpe.
El bookmark nunca se había resuelto. resolve() y
startAccessingSecurityScopedResource() nunca se llamaban, así que la
app no tenía permiso para leer los originales. No importaba mientras la caché
estuviera caliente, porque nada tocaba los archivos de origen. Invalidar la caché
quitó lo que había estado escondiendo el bug.
Una caché delante de un bug de permisos lo mantiene invisible mientras la caché siga caliente.
La solución no tiene ningún misterio: al arrancar, pasar por todo el camino resolve → start accessing, y mantener ese acceso mientras la app esté corriendo.
2. /var y /private/var no coinciden
Importar una carpeta bajo el directorio temporal del sistema producía cero clips. Sin error, sin crash — una cuadrícula vacía.
FileManager.enumerator devuelve rutas con los symlinks ya
resueltos, así que los archivos volvían como /private/var/…. La
ruta de la carpeta con la que estaba comparando era /var/…. Una
coincidencia de prefijo entre las dos nunca acierta.
Lo que hace que este sea especialmente feo es que los normalizadores obvios no
lo arreglan. Ni standardizedFileURL ni
resolvingSymlinksInPath() resuelven el enlace especial
/var — si acaso empujan en la dirección contraria y quitan
/private. El que sí funciona es el valor de recurso
canonicalPath:
let canonical = try? standardized
.resourceValues(forKeys: [.canonicalPathKey])
.canonicalPath
La trampa después de la corrección: la normalización tiene que pasar en cada ruta que se compara. Normalicé en la importación pero no en la comprobación de carpeta movida, y el desajuste volvió enseguida.
3. Un bookmark obsoleto (stale) no se puede refrescar desde fuera
Cuando un usuario mueve la carpeta en el Finder, el bookmark todavía se resuelve
pero vuelve marcado como obsoleto, y se espera que escribas uno nuevo. Hice
exactamente eso: detecté isStale, volví a llamar a
bookmarkData(), lo guardé.
Eso en realidad no refresca nada. Un bookmark creado a partir de una URL reconstituida a la que no estás accediendo en ese momento simplemente vuelve a quedar obsoleto — esto está reportado en los foros de desarrolladores de Apple, y coincide con lo que vi.
El refresco tiene que ocurrir dentro de un scope de acceso: empezar a acceder, escribir el nuevo bookmark, dejar de acceder — todo antes de volver de resolve.
4. Dos escrituras, un crash, la carpeta equivocada
Para detectar «el usuario movió la carpeta», guardaba la última ruta resuelta con éxito junto al bookmark, bajo su propia clave. Dos valores, dos escrituras.
Si la app muere entre medias, te quedas con un bookmark que apunta a la ubicación nueva y una pista de ruta que apunta a la antigua. En el siguiente lanzamiento esa combinación se lee como que la carpeta se movió, y la app reescribe rutas de clips que nunca estuvieron mal. Un crash en el momento equivocado corrompe la biblioteca.
La solución fue dejar de tener dos escrituras. Los dos valores van en un único
blob guardado bajo una sola clave. Una sola escritura de UserDefaults
no queda a medias, así que quien lee ve o el valor antiguo completo o el nuevo
completo — nunca una mezcla.
La pareja que evita la versión aburrida de todo esto
Todo acceso temporal pasa por un único helper, así que un error lanzado no puede filtrar el scope:
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)
}
La carpeta de biblioteca es la excepción deliberada. Se queda abierta durante
toda la vida de la app, así que no pasa por este helper — cerrarla con
defer es exactamente el comportamiento equivocado ahí.
Lo que me diría a mí mismo antes de empezar
| Bug | Qué lo escondía |
|---|---|
| El bookmark nunca se resolvía | Una caché de miniaturas caliente |
/var vs /private/var | Solo los directorios temporales usan ese symlink |
| Un refresco de stale que no refresca | Nadie mueve carpetas durante el desarrollo |
| Dos escrituras, un crash | La app tiene que morir en un milisegundo concreto |
Ninguno de estos se detectó escribiendo código más cuidadoso la primera vez. Se detectaron cambiando otra cosa — una versión de caché, una carpeta de prueba, una revisión de código — y viendo qué se caía. Si estás construyendo sobre esta API, merece la pena invalidar tus cachés a propósito una vez, solo para ver qué se sostenía sobre nada.
Divecut es un navegador de material para macOS y iPad: clips Log en el color correcto, con búsqueda en palabras normales, entregados a DaVinci Resolve con el Log intacto. Ver qué hace.