14 de agosto de 2026

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.

lanzamiento N escr. 1: bookmark = ubicación nueva crash escr. 2: pista de ruta nunca se ejecuta lanzamiento N+1 bookmark: nuevo pista de ruta: antigua se lee: "carpeta movida" reescribe rutas de clips que nunca estuvieron mal
La ventana es pequeña, pero existe en cada cambio de carpeta — y el daño cae sobre clips que no tenían nada que ver con el traslado.

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

BugQué lo escondía
El bookmark nunca se resolvíaUna caché de miniaturas caliente
/var vs /private/varSolo los directorios temporales usan ese symlink
Un refresco de stale que no refrescaNadie mueve carpetas durante el desarrollo
Dos escrituras, un crashLa 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.