Da Electron a Swift
Otto anni di Electron, poi ho buttato via tutto e ricominciato in Swift. 890 MB sono diventati 14 MB. E alla fine la griglia delle foto è dovuta passare comunque ad AppKit.
Phiewer oggi: 14 MB, nativa. La griglia gira su NSCollectionView, le miniature sono SwiftUI.
Phiewer è sul Mac App Store dal 2017. Un visualizzatore di foto che lavora direttamente sulle tue cartelle, senza libreria e senza importazione. Fino alla fine del 2025 era un'app Electron.
A luglio 2025 ho cancellato il vecchio progetto e ho ricominciato da capo in Xcode. A gennaio 2026 è uscita la versione 6.0, scritta in Swift, 14 MB invece di 890 MB. Qualche mese dopo ho dovuto rifare proprio il pezzo più importante, la griglia delle miniature. Ma andiamo con ordine.
Perché proprio Electron?
Vengo dallo sviluppo web. Avevo già fatto app e giochi per iOS con Objective-C e SpriteKit, ma un'intera app desktop in Objective-C per me era troppo. Electron lo rendeva possibile: creare un'app per Mac con HTML, CSS e JavaScript, cioè con quello che sapevo già fare. E poi c'era l'argomento che conoscono tutti: più avanti si può pubblicarla anche per Windows e Linux.
Non l'ho mai fatto. Non sarebbe stato nemmeno possibile, col tempo il codice era diventato troppo specifico per il Mac. Quindi ho sopportato tutti quei fastidi per un vantaggio che non ho mai usato.
Cosa mi ha logorato di Electron
Non un unico grande problema. Piuttosto tanti piccoli, che negli anni si sono accumulati:
- •Le dimensioni. 890 MB per un visualizzatore di foto. Ogni download e ogni aggiornamento si portava dietro un browser intero.
- •Non è mai sembrata una vera app per Mac. Non importa quanto lavoro ci mettessi.
- •Moduli Node con codice nativo. Vanno ricompilati per ogni versione di Electron. Un modulo funzionava con la nuova versione di Electron, un altro non ancora, o mai. Alla fine non potevo più aggiornare Electron: l'ultima versione di Phiewer girava ancora su Electron 24, di aprile 2023. Quando ho ricominciato nell'estate 2025, Electron era già alla versione 37. In più c'erano pacchetti che volevano ognuno una versione diversa di Node.
- •Electron è cambiato sotto i miei piedi. La mia finestra accedeva direttamente al processo principale tramite il modulo
remote. È stato deprecato con Electron 12 e rimosso con Electron 14, per motivi di sicurezza e prestazioni. Comprensibile, ma per me ha significato riscrivere tutte le chiamate in dieci file per usare il pacchetto sostitutivo@electron/remote. E l'aggiornamento a Electron 12 l'ho annullato lo stesso giorno, perché scorrere i video andava a scatti. - •Firmare per l'App Store. Due file di entitlements, uno per l'app e uno per tutto quello che contiene: helper, framework, librerie. Più uno script che firma tutto nell'ordine giusto. Alla fine il mio script chiamava
codesign33 volte, una per ogni libreria e programma: ffmpeg, HandBrake, dcraw, sharp, libvips e così via. Se aggiungevo un nuovo modulo e mi dimenticavo di inserirlo nello script, il caricamento tornava indietro con una libreria non firmata. Ho perfino tolto un modulo perché al caricamento dava solo errori di firma. - •Tutto doppio. La versione gratuita lite era una seconda app separata. Ogni build, ogni firma, ogni caricamento, due volte. E quando una nuova funzione PRO, che lite non aveva nemmeno, riceveva una correzione, i numeri di versione e di build delle due app non coincidevano più.
- •I file RAW. Per quelli usavo dcraw, uno strumento a riga di comando che già allora era poco mantenuto (l'ultima versione è del 2018, da allora le nuove fotocamere le supporta LibRaw). Per ogni immagine RAW significava: creare una cartella di cache, copiarci dentro l'originale, farci girare dcraw, rinominare il risultato e caricare il TIFF nella finestra. Tutto con chiamate separate alla riga di comando, perfino
mkdir,cpemv. Le immagini HEIC passavano per sips. Le miniature le generavo in anticipo con il modulo sharp e le mettevo in una cache mia. Potete immaginare quante segnalazioni arrivavano perché qualche formato RAW particolare non si vedeva bene.
Ecco come appariva nel codice, leggermente accorciato:
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 ...
});
});
});
}); Il comando di build del mio vecchio package.json mostra meglio di tutto in cosa si era trasformato: 43 dipendenze, da jQuery a sharp fino a HandBrake. dcraw ed ExifTool copiati a mano in node_modules, sharp ricompilato per ogni architettura, qualche modulo rattoppato con patch-package e, alla fine, il mio script di 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" Ma la cosa che mi ha fatto perdere più nervi è stata la sandbox. Durante lo sviluppo l'app gira senza sandbox, sull'App Store con. Cose che durante lo sviluppo funzionavano senza problemi nell'app finale fallivano semplicemente in silenzio, per esempio leggere una cartella. Quindi: inserire messaggi di debug nella build finale, compilare, firmare, provare e cercare su Stack Overflow cosa significa EPERM e perché compare solo nell'app impacchettata. Un esempio: se nella sandbox apri un singolo file, l'app può leggere esattamente quel file e nient'altro della cartella. Ma un visualizzatore di foto ha bisogno di tutta la cartella per le miniature e per sfogliare. Così Phiewer intercettava l'errore EPERM e mostrava una seconda finestra per autorizzare l'accesso alla cartella. Stessa storia con sips, lo strumento per immagini di macOS da riga di comando: volevo usarlo per far generare le miniature al sistema. Durante lo sviluppo funzionava, nell'app finale no, e questa volta senza nessun messaggio di errore.
E poi c'erano i security-scoped bookmark. Sono il modo in cui un'app del Mac App Store ricorda a quali cartelle può accedere, anche dopo un riavvio. Per un visualizzatore di foto che lavora sulle cartelle, senza di loro non funziona niente. Potevo provarli solo nella versione firmata per l'App Store, durante lo sviluppo non esistono. Electron aveva una funzione apposta, avevo collegato tutto e avrebbe dovuto funzionare. Non funzionava. A un certo punto ho aperto una segnalazione, e sì, era un bug di Electron che nessuno aveva ancora notato. Poi si trattava di aspettare la correzione. Sistemarlo da solo, senza IA a quei tempi, era troppo complicato per me. Lo odiavo.
Poi sono arrivati i chip Apple
All'inizio Phiewer girava solo sui Mac Intel. I primi Mac con chip Apple sono usciti alla fine del 2020. Ho avuto una build ARM separata un anno dopo, alla fine del 2021, e il mio primo universal binary solo a gennaio 2024. All'inizio gli strumenti erano semplicemente scarsi, e ora avevo i problemi di dipendenze di prima moltiplicati per due: sharp e ffmpeg, per esempio, andavano compilati e firmati separatamente per ogni architettura.
La cosa più fastidiosa: dallo sviluppo iOS sapevo che si poteva fare diversamente. Lì premi «Archive» in Xcode e basta.
Cancellare tutto e ricominciare
A un certo punto non avevo più voglia di mettere toppe. A luglio 2025 ho cancellato tutti i file, creato un nuovo progetto in Xcode e ricominciato da zero, su un foglio bianco, il sogno di ogni sviluppatore. Questa volta con Swift e SwiftUI. Dal vecchio codice non ho preso niente. Solo quello che sapevo su cosa deve fare l'app e su come deve essere.
Non era solo una questione di dimensioni. Volevo finalmente programmare come lo prevede Apple:
- •890 MB sono diventati 14 MB. Niente più browser nel pacchetto, solo l'app.
- •Veri controlli Apple. La barra laterale è una vera barra laterale della finestra, non uno strato di JavaScript e CSS che si apre e si chiude. Tutto ha l'aspetto previsto da Apple per ogni versione di macOS.
- •Modalità scura senza colori miei. Basta definizioni di colore personali, se ne occupa il sistema.
- •RAW senza riga di comando. macOS sa leggere i RAW da solo, e LibRaw è integrata direttamente come libreria invece di essere chiamata con
spawn. Le miniature arrivano dal sistema invece che da una cache mia. - •Mai più moduli Node che non vanno d'accordo con la versione di Electron. E niente più VS Code con HTML, CSS e JavaScript, ma Xcode. Archive, caricamento, fatto.
- •Un'app invece di due. PRO (acquisto una tantum) e lite (gratuita) ci sono sempre state. Oggi è la stessa app con due target. In lite c'è tutto ed è tutto visibile, ma le funzioni PRO sono disattivate e contrassegnate da un badge PRO.
Fino all'uscita ci è voluto circa mezzo anno. A gennaio 2026 è uscito Phiewer 6.0, gratis per tutti quelli che l'avevano già comprato.
Sulla vecchia versione non ho misurato niente. Ma ho l'impressione che l'app si avvii due volte più velocemente, e le miniature ci sono subito. Le reazioni sono state fantastiche, e da allora le vendite vanno nettamente meglio. Ci hanno sicuramente contribuito anche la nuova versione lite, che mostra cosa sa fare PRO, e un prezzo un po' più alto.
E alla fine AppKit
Programmare come lo prevede Apple, però, non è sempre il massimo. SwiftUI ha ancora i suoi limiti con liste molto grandi. Anche Apple lo sa: alla WWDC 2025 ha annunciato che sul Mac le liste con oltre 100.000 elementi ora si caricano sei volte più velocemente. E per alcune cose, perfino le app fatte solo in SwiftUI ancora oggi ricorrono ad AppKit. Allora usi AppKit, e in quanto a stabilità AppKit è un carro armato.
Nel mio caso era la griglia delle miniature. Era costruita nel modo più classico: un LazyVGrid in una ScrollView, avvolto da uno ScrollViewReader, così la griglia scorre insieme quando ti muovi nella cartella con le frecce.
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) }
}
}
}
} Andava fluida anche con cartelle grandi. Almeno così credevo. Finché, in una cartella con 14.000 foto, sulla prima immagine ho premuto la freccia a sinistra e sono saltato alla fine della lista. Il salto in sé era veloce. Ma poi ogni pressione di un tasto impiegava circa due secondi prima che sullo schermo si muovesse qualcosa. E la cosa strana: tornato all'inizio della lista, tutto era di nuovo normale.
La mia ipotesi (se conoscete meglio i meccanismi interni, correggetemi pure): LazyVGrid costruisce solo le view visibili in quel momento. Ma non sa nemmeno dove si trova un elemento che non ha ancora disposto. Per scorrere fino all'elemento 13.500, deve calcolare il layout di tutto quello che viene prima. Quindi scrollTo diventa più costoso quanto più in basso si trova la destinazione. Non il massimo per un visualizzatore di foto che tutto il giorno non fa altro che scorrere.
NSCollectionView con un flow layout conosce in anticipo la dimensione di ogni elemento. Dove si trova l'elemento n è un semplice calcolo. Per questo scrollToItems(at:scrollPosition:) costa sempre lo stesso, che la destinazione sia l'elemento 10 o l'elemento 100.000.
Le celle in sé non volevo riscriverle, facevano già tutto: miniature, selezione, menu contestuale, rinomina direttamente nella miniatura, drag & drop. Quindi ho sostituito solo il contenitore con AppKit. Le celle sono rimaste SwiftUI e stanno in una NSHostingView dentro ogni 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) Da allora lo stesso salto nella stessa cartella non ha più alcun ritardo, e anche cartelle con diverse centinaia di migliaia di immagini scorrono fluide. Come si percepisce per gli utenti è spiegato nell'articolo Prestazioni potenziate in Phiewer.
Cosa era ancora rotto dopo
Il cambio in sé è stato veloce. Rimettere a posto tutto il resto ha richiesto fino a settembre. Sei cose di cui prima si occupava SwiftUI senza che me ne accorgessi:
- •I nuovi tag non comparivano.
FileItemconfronta solo l'URL perEquatable. Così, dopo una modifica ai tag, SwiftUI pensava che la cella non fosse cambiata. La soluzione: un'identità propria fatta di URL, tag e commenti (codice qui sotto). - •
reloadData()salta in cima. A ogni file aggiunto o cancellato. Se la nuova lista non ha nemmeno un ID in comune con quella vecchia, è stato un cambio di cartella e saltare in cima è giusto. Altrimenti prima memorizzo l'elemento visibile più in alto e quanto è tagliato, e dopo ripristino quella posizione. - •
withAnimationarriva fino ad AppKit. Un vecchiowithAnimationattorno a un interruttore faceva sì che AppKit animasse il nuovo layout e la correzione dello scorrimento. La griglia tremava. - •Le miniature lampeggiavano in grigio. Una cella ricollegata riparte con lo
@Statevuoto. Ora guarda anche direttamente nella cache. - •Cliccando, la griglia scivolava via sotto il mouse, perché si centrava sulla selezione. Ora i clic scorrono solo quanto serve, le frecce continuano a centrare. Piccola trappola: per sopra o sotto serve
.nearestHorizontalEdge. - •I layout personalizzati sono stati più semplici del previsto. Il mosaico «Exposé» calcola tutte le posizioni una sola volta in
prepare(). La bacheca non ha bisogno di nessun layout proprio, perché rotazione e spostamento stanno nella cella SwiftUI.
private struct FileCellIdentity: Hashable {
let url: URL
let tags: [String]
let comments: String
}
cell.host(FilesGridCellWrapper(item: fileItem, ...).id(FileCellIdentity(fileItem)))
Cosa farei diversamente oggi
Sviluppare subito per la piattaforma su cui l'app gira davvero. Ho lottato per anni con Electron per tenermi aperti Windows e Linux, e non mi sono mai serviti.
SwiftUI non è lento, ma non va bene ovunque. Le celle sono ancora SwiftUI, ed è giusto così. Ma per liste con decine di migliaia di elementi in cui si salta continuamente da un punto all'altro, il contenitore deve sapere in anticipo dove si trova cosa. Sul Mac, questo si chiama NSCollectionView.
Gli utenti notano la differenza. Nessuno mi ha mai scritto «per favore, niente app Electron». Se ne sono accorti lo stesso.
Se qualcuno è riuscito a rendere veloce LazyVGrid con liste così grandi: sono curioso di sapere come.
Su Phiewer
Phiewer è un visualizzatore di foto e video per macOS. La versione gratuita (lite) include il visualizzatore di immagini completo. Phiewer PRO è un acquisto una tantum, senza abbonamento.
