Volver al blog

De Electron a Swift

Ocho años de Electron, luego lo tiré todo y empecé de nuevo en Swift. 890 MB se convirtieron en 14 MB. Y al final, la cuadrícula de fotos tuvo que pasar a AppKit igualmente.

Phiewer hoy: 14 MB, nativa. La cuadrícula funciona con NSCollectionView, las miniaturas son SwiftUI.

Phiewer hoy: 14 MB, nativa. La cuadrícula funciona con NSCollectionView, las miniaturas son SwiftUI.

Phiewer está en el Mac App Store desde 2017. Un visor de fotos que trabaja directamente con tus carpetas, sin biblioteca y sin importar nada. Hasta finales de 2025, era una app de Electron.

En julio de 2025 borré el proyecto antiguo y empecé de cero en Xcode. En enero de 2026 salió la versión 6.0, escrita en Swift, 14 MB en lugar de 890 MB. Unos meses después tuve que rehacer justo la pieza clave, la cuadrícula de miniaturas. Pero vayamos por partes.

¿Por qué Electron?

Vengo del desarrollo web. Ya había hecho apps y juegos para iOS con Objective-C y SpriteKit, pero una app de escritorio completa en Objective-C me venía grande. Electron lo hacía posible: crear una app para Mac con HTML, CSS y JavaScript, con lo que ya sabía. Y luego estaba el argumento que todo el mundo conoce: más adelante también puedes sacarla para Windows y Linux.

Nunca lo hice. Ni siquiera habría sido posible, el código se había vuelto demasiado específico del Mac con los años. Así que aguanté todos esos problemas por una ventaja que nunca aproveché.

Lo que me agotó de Electron

No un único gran problema. Más bien muchos pequeños, que se fueron acumulando con los años:

  • •El tamaño. 890 MB para un visor de fotos. Cada descarga y cada actualización incluía un navegador completo.
  • •Nunca se sintió como una app de Mac de verdad. Daba igual cuánto trabajo le dedicara.
  • •Módulos de Node con código nativo. Hay que recompilarlos para cada versión de Electron. Un módulo funcionaba con la nueva versión de Electron, otro todavía no, o nunca. Al final ya no podía actualizar Electron: la última versión de Phiewer seguía funcionando con Electron 24, de abril de 2023. Cuando empecé de nuevo en verano de 2025, Electron ya iba por la versión 37. A eso se sumaban paquetes que querían cada uno una versión distinta de Node.
  • •Electron cambió bajo mis pies. Mi ventana accedía directamente al proceso principal mediante el módulo remote. Se marcó como obsoleto en Electron 12 y se eliminó en Electron 14, por seguridad y rendimiento. Comprensible, pero para mí significó reescribir todas las llamadas en diez archivos para usar el paquete sustituto @electron/remote. Y la actualización a Electron 12 la deshice el mismo día, porque al pasar por los vídeos todo iba a tirones.
  • •Firmar para el App Store. Dos archivos de entitlements, uno para la app y otro para todo lo que lleva dentro: helpers, frameworks, librerías. Además, un script que lo firma todo en el orden correcto. Al final, mi script llamaba a codesign 33 veces, una por cada librería y programa: ffmpeg, HandBrake, dcraw, sharp, libvips, etc. Si añadía un módulo nuevo y me olvidaba de meterlo en el script, la subida volvía con una librería sin firmar. Incluso quité un módulo porque al subir solo daba errores de firma.
  • •Todo por duplicado. La versión gratuita lite era una segunda app aparte. Cada build, cada firma, cada subida, dos veces. Y cuando una nueva función PRO, que lite ni siquiera tenía, recibía una corrección, los números de versión y de build de las dos apps se desincronizaban.
  • •Los archivos RAW. Para eso usaba dcraw, una herramienta de línea de comandos que ya entonces apenas se mantenía (la última versión es de 2018, desde entonces las cámaras nuevas las soporta LibRaw). Para cada imagen RAW: crear una carpeta de caché, copiar el original dentro, pasarle dcraw, renombrar el resultado y cargar el TIFF en la ventana. Todo con llamadas separadas a la línea de comandos, incluso mkdir, cp y mv. Las imágenes HEIC pasaban por sips. Las miniaturas las generaba por adelantado con el módulo sharp y las guardaba en mi propia caché. Te puedes imaginar cuántas incidencias llegaban porque algún formato RAW concreto no se veía bien.

Así se veía en el código, algo recortado:

var mkdir = spawn("mkdir", [rawFilesCacheFolder]);
mkdir.on("close", function () {
  var copyOriginalFile = spawn("cp", [filepath, rawFilesCacheFolder]);
  copyOriginalFile.on("close", function () {
    // -T TIFF output, -w camera white balance, -h half size (50% faster)
    var dcraw = spawn(dcrawpath, ["-T", "-w", "-h", cachedRawFile]);
    dcraw.on("close", function (code) {
      var mv = spawn("mv", [cachedTiff, renamedTiff]);
      mv.on("close", function () {
        // load the TIFF via XMLHttpRequest and draw it into a canvas ...
      });
    });
  });
});

El comando de build de mi antiguo package.json muestra mejor que nada en qué se había convertido: 43 dependencias, de jQuery a sharp pasando por HandBrake. dcraw y ExifTool copiados a mano en node_modules, sharp recompilado para cada arquitectura, algunos módulos parcheados con patch-package y, al final, mi propio script de firma:

"build": "rm -rf node_modules && rm -rf yarn.lock && yarn install
  && npm rebuild --platform=darwin --arch=arm64 sharp
  && mkdir node_modules/dcraw && cp ../phiewer-src/dcraw/* node_modules/dcraw/
  && mkdir node_modules/exiftool && cp ../phiewer-src/exiftool/* node_modules/exiftool/
  && gulp && npx patch-package
  && yarn package-mac
  && mv \"Phiewer PRO-darwin-arm64/Phiewer PRO.app\" ~/Desktop/
  && cp -f buildscripts/For_AppStore/sign.sh ~/Desktop/ && sh ~/Desktop/sign.sh"

Pero lo que más nervios me costó fue el sandbox. Durante el desarrollo la app funciona sin sandbox, en el App Store con él. Cosas que funcionaban sin problema durante el desarrollo fallaban en silencio en la app final, por ejemplo leer una carpeta. Así que tocaba meter mensajes de depuración en la versión final, compilar, firmar, probar y buscar en Stack Overflow qué significa EPERM y por qué solo aparece en la app empaquetada. Un ejemplo: si abres un solo archivo dentro del sandbox, la app solo puede leer ese archivo, nada más de la carpeta. Pero un visor de fotos necesita la carpeta entera para las miniaturas y para pasar de una foto a otra. Así que Phiewer capturaba el error EPERM y mostraba un segundo diálogo para dar acceso a la carpeta. Lo mismo con sips, la herramienta de imágenes de macOS para la línea de comandos: quería usarla para que el sistema generara las miniaturas. En desarrollo funcionaba, en la app final no, y esta vez sin ningún mensaje de error.

Y luego estaban los security-scoped bookmarks. Con ellos una app del Mac App Store recuerda a qué carpetas puede acceder, incluso después de reiniciar. Para un visor de fotos que trabaja con carpetas, sin ellos no funciona nada. Solo podía probarlos en la versión firmada del App Store, durante el desarrollo no existen. Electron tenía una función para ello, yo lo había conectado todo y debería haber funcionado. No funcionaba. En algún momento abrí una incidencia y sí, era un fallo de Electron que nadie había notado todavía. Después, a esperar a que lo arreglaran. Parchearlo yo mismo, sin IA en aquella época, era demasiado complicado para mí. Lo odiaba.

Luego llegaron los chips de Apple

Al principio Phiewer solo funcionaba en Mac con Intel. Los primeros Mac con chip de Apple salieron a finales de 2020. Tuve un build ARM separado un año después, a finales de 2021, y mi primer binario universal no llegó hasta enero de 2024. Las herramientas eran sencillamente malas al principio, y ahora tenía los problemas de dependencias de antes por partida doble: sharp y ffmpeg, por ejemplo, había que compilarlos y firmarlos por separado para cada arquitectura.

Lo más molesto: por el desarrollo para iOS sabía que podía ser distinto. Allí pulsas «Archive» en Xcode y listo.

Borrarlo todo y empezar de nuevo

En algún momento me cansé de poner parches. En julio de 2025 borré todos los archivos, creé un proyecto nuevo en Xcode y empecé de cero, desde una hoja en blanco, el sueño de cualquier desarrollador. Esta vez con Swift y SwiftUI. No reutilicé nada del código antiguo. Solo lo que sabía sobre lo que la app tiene que hacer y cómo tiene que verse.

No era solo cuestión de tamaño. Por fin quería programar como Apple lo tiene pensado:

  • •890 MB se convirtieron en 14 MB. Ya no hay un navegador dentro, solo la app.
  • •Controles de Apple de verdad. La barra lateral es una barra lateral de ventana real, no una capa de JavaScript y CSS que se despliega y se oculta. Todo se ve como Apple lo prevé para cada versión de macOS.
  • •Modo oscuro sin colores propios. Ya no hay definiciones de color mías, de eso se encarga el sistema.
  • •RAW sin línea de comandos. macOS sabe leer RAW por sí mismo, y LibRaw está integrada directamente como librería en lugar de llamarla con spawn. Las miniaturas vienen del sistema en lugar de mi propia caché.
  • •Nunca más módulos de Node que no encajan con la versión de Electron. Y nada de VS Code con HTML, CSS y JavaScript, sino Xcode. Archive, subir, listo.
  • •Una app en lugar de dos. PRO (compra única) y lite (gratis) existieron siempre. Hoy es la misma app con dos targets. En lite todo está ahí y a la vista, pero las funciones PRO están desactivadas y llevan una insignia PRO.

Hasta el lanzamiento pasaron unos seis meses. En enero de 2026 salió Phiewer 6.0, gratis para todos los que ya lo habían comprado.

En la versión antigua no medí nada. Pero la sensación es que la app arranca el doble de rápido, y las miniaturas aparecen al instante. Las reacciones fueron fantásticas, y las ventas son claramente mejores desde entonces. Seguro que también ayudaron la nueva versión lite, que enseña lo que puede hacer PRO, y un precio algo más alto.

Y al final, AppKit

Programar como Apple lo tiene pensado tampoco es siempre la panacea. SwiftUI todavía tiene sus límites con listas muy grandes. Apple también lo sabe: en la WWDC 2025 anunció que las listas de más de 100.000 elementos ahora cargan seis veces más rápido en el Mac. Y para algunas cosas, incluso las apps hechas solo con SwiftUI siguen recurriendo hoy a AppKit. Entonces usas AppKit, y en cuanto a estabilidad, AppKit es un tanque.

En mi caso fue la cuadrícula de miniaturas. Estaba montada de la forma más clásica: un LazyVGrid dentro de una ScrollView, rodeado de un ScrollViewReader, para que la cuadrícula se desplace cuando recorres la carpeta con las flechas.

ScrollViewReader { proxy in
    ScrollView {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: thumbnailSize))], spacing: 8) {
            ForEach(fileBrowser.currentItems) { item in
                FilesSectionItemView(item: item, size: thumbnailSize, ...)
                    .id(item.id)
            }
        }
        .onChange(of: fileBrowser.selectedFile?.id) { selectedId in
            if let selectedId {
                withAnimation { proxy.scrollTo(selectedId, anchor: .center) }
            }
        }
    }
}

Iba fluida también con carpetas grandes. O eso creía yo. Hasta que, en una carpeta con 14.000 fotos, pulsé la flecha izquierda en la primera imagen y salté al final de la lista. El salto en sí fue rápido. Pero después, cada pulsación tardaba unos dos segundos hasta que algo se movía en la pantalla. Y lo raro: de vuelta al principio de la lista, todo volvía a ir normal.

Mi suposición (si conoces mejor las tripas, corrígeme): LazyVGrid solo construye las vistas que se ven en ese momento. Pero tampoco sabe dónde está un elemento que todavía no ha colocado. Para desplazarse hasta el elemento 13.500, tiene que calcular el layout de todo lo anterior. Así que scrollTo se vuelve más caro cuanto más abajo está el destino. Mala cosa para un visor de fotos que no hace otra cosa en todo el día que desplazarse.

NSCollectionView con un flow layout conoce el tamaño de cada elemento de antemano. Dónde está el elemento n es un simple cálculo. Por eso scrollToItems(at:scrollPosition:) cuesta siempre lo mismo, da igual si el destino es el elemento 10 o el 100.000.

Las celdas en sí no quería reescribirlas, ya lo hacían todo: miniaturas, selección, menú contextual, renombrar directamente en la miniatura, arrastrar y soltar. Así que solo cambié el contenedor por AppKit. Las celdas siguen siendo SwiftUI y van dentro de una NSHostingView en cada NSCollectionViewItem:

final class FilesGridHostingItem: NSCollectionViewItem {
    private var hostingView: NSHostingView<AnyView>?

    override func loadView() {
        view = NSView()
        view.wantsLayer = true
    }

    func host<Content: View>(_ rootView: Content) {
        let wrapped = AnyView(rootView)
        if let hostingView {
            hostingView.rootView = wrapped   // reuse: just swap the root view
        } else {
            let hv = NSHostingView(rootView: wrapped)
            // ... pin to all four edges
            hostingView = hv
        }
    }
}

// Scrolling to the selection is now a single call, no matter how far down it is
collectionView.scrollToItems(at: [IndexPath(item: index, section: 0)],
                             scrollPosition: .centeredVertically)

Desde entonces, el mismo salto en la misma carpeta ya no tiene ningún retraso, y también las carpetas con varios cientos de miles de imágenes se desplazan con fluidez. Cómo se nota esto para los usuarios está en el artículo Mejora de rendimiento en Phiewer.

Lo que seguía roto después

El cambio en sí fue rápido. Dejar todo lo demás bien otra vez llevó hasta septiembre. Seis cosas de las que antes se ocupaba SwiftUI sin que yo me diera cuenta:

  • •Las etiquetas nuevas no aparecían. FileItem solo compara la URL para Equatable. Así que, tras cambiar una etiqueta, SwiftUI creía que la celda no había cambiado. La solución: una identidad propia hecha de URL, etiquetas y comentarios (código abajo).
  • •reloadData() salta arriba del todo. Con cada archivo nuevo o borrado. Si la nueva lista no comparte ni un solo ID con la anterior, fue un cambio de carpeta y saltar arriba es lo correcto. Si no, antes guardo el elemento visible más alto y cuánto está cortado, y después lo restauro.
  • •withAnimation llega hasta AppKit. Un viejo withAnimation alrededor de un interruptor hacía que AppKit animara el nuevo layout y la corrección del desplazamiento. La cuadrícula temblaba.
  • •Las miniaturas parpadeaban en gris. Una celda que se vuelve a vincular empieza con el @State vacío. Ahora además mira directamente en la caché.
  • •Al hacer clic, la cuadrícula se escapaba bajo el ratón, porque se centraba en la selección. Ahora los clics solo desplazan lo necesario, las flechas siguen centrando. Pequeña trampa: para arriba o abajo hace falta .nearestHorizontalEdge.
  • •Los layouts propios fueron más fáciles de lo esperado. El mosaico «Exposé» calcula todas las posiciones una sola vez en prepare(). El tablón no necesita ningún layout propio, porque la rotación y el desplazamiento están en la celda de SwiftUI.
private struct FileCellIdentity: Hashable {
    let url: URL
    let tags: [String]
    let comments: String
}

cell.host(FilesGridCellWrapper(item: fileItem, ...).id(FileCellIdentity(fileItem)))

Qué haría distinto hoy

Desarrollar desde el principio para la plataforma en la que la app funciona de verdad. Me peleé con Electron durante años para dejar abiertos Windows y Linux, y nunca necesité ninguno de los dos.

SwiftUI no es lento, pero no sirve para todo. Las celdas siguen siendo SwiftUI, y está bien así. Pero en listas con decenas de miles de elementos, en las que saltas de un sitio a otro todo el rato, el contenedor debe saber de antemano dónde está cada cosa. En el Mac, eso se llama NSCollectionView.

Los usuarios notan la diferencia. Nunca nadie me escribió «por favor, nada de apps de Electron». Lo notaron igualmente.

Si alguien ha conseguido que LazyVGrid vaya rápido con listas tan grandes: tengo curiosidad por saber cómo.

Sobre Phiewer

Phiewer es un visor de fotos y vídeos para macOS. La versión gratuita (lite) incluye el visor de imágenes completo. Phiewer PRO es una compra única, sin suscripción.