D'Electron à Swift
Huit ans d'Electron, puis tout jeter et repartir de zéro en Swift. 890 Mo sont devenus 14 Mo. Et à la fin, la grille de photos a quand même dû passer à AppKit.
Phiewer aujourd'hui : 14 Mo, natif. La grille tourne sur NSCollectionView, les vignettes sont en SwiftUI.
Phiewer est sur le Mac App Store depuis 2017. Une visionneuse photo qui travaille directement sur vos dossiers, sans bibliothèque ni importation. Jusqu'à fin 2025, c'était une app Electron.
En juillet 2025, j'ai supprimé l'ancien projet et tout recommencé dans Xcode. En janvier 2026, la version 6.0 est sortie, écrite en Swift, 14 Mo au lieu de 890 Mo. Quelques mois plus tard, j'ai dû refaire justement la pièce maîtresse, la grille de vignettes. Mais reprenons depuis le début.
Pourquoi Electron, au départ ?
Je viens du développement web. J'avais déjà fait des apps et des jeux iOS en Objective-C avec SpriteKit, mais une app de bureau complète en Objective-C, c'était trop gros pour moi. Electron le rendait possible : construire une app Mac en HTML, CSS et JavaScript, avec ce que je savais déjà faire. Et puis il y avait l'argument que tout le monde connaît : plus tard, on pourra aussi la sortir sur Windows et Linux.
Je ne l'ai jamais fait. Ça n'aurait même pas été possible, le code était devenu bien trop spécifique au Mac avec le temps. J'ai donc supporté tous ces ennuis pour un avantage dont je ne me suis jamais servi.
Ce qui m'a usé avec Electron
Pas un gros problème unique. Plutôt plein de petits, qui se sont accumulés au fil des années :
- •La taille. 890 Mo pour une visionneuse photo. Chaque téléchargement et chaque mise à jour embarquait un navigateur complet.
- •Elle n'a jamais eu l'air d'une vraie app Mac. Peu importe le travail que j'y mettais.
- •Les modules Node avec du code natif. Il faut les recompiler pour chaque version d'Electron. Un module fonctionnait avec la nouvelle version d'Electron, un autre pas encore, ou jamais. À la fin, je ne pouvais plus du tout mettre Electron à jour : la dernière version de Phiewer tournait encore sur Electron 24, d'avril 2023. Quand j'ai tout recommencé à l'été 2025, Electron en était déjà à la version 37. En plus, certains paquets voulaient chacun une autre version de Node.
- •Electron a changé sous mes pieds. Ma fenêtre accédait directement au processus principal via le module
remote. Il a été déprécié dans Electron 12 et supprimé dans Electron 14, pour des raisons de sécurité et de performances. C'est compréhensible, mais pour moi cela voulait dire réécrire tous les appels dans dix fichiers pour passer au paquet de remplacement@electron/remote. Et la mise à jour vers Electron 12, je l'ai annulée le jour même, parce que la navigation dans les vidéos saccadait. - •La signature pour l'App Store. Deux fichiers d'entitlements, un pour l'app et un pour tout ce qu'elle contient : helpers, frameworks, bibliothèques. Plus un script qui signe tout dans le bon ordre. À la fin, mon script appelait
codesign33 fois, une fois par bibliothèque et par programme : ffmpeg, HandBrake, dcraw, sharp, libvips, etc. Si j'ajoutais un module et oubliais de le mettre dans le script, l'envoi revenait avec une bibliothèque non signée. J'ai même retiré un module parce qu'il ne provoquait que des erreurs de signature à l'envoi. - •Tout en double. La version gratuite lite était une deuxième app à part. Chaque build, chaque signature, chaque envoi, deux fois. Et quand une nouvelle fonction PRO, que lite n'avait même pas, recevait un correctif, les numéros de version et de build des deux apps se décalaient.
- •Les fichiers RAW. Pour ça, j'utilisais dcraw, un outil en ligne de commande déjà à peine maintenu à l'époque (la dernière version date de 2018, les nouveaux appareils sont pris en charge par LibRaw depuis). Pour chaque image RAW : créer un dossier de cache, y copier l'original, lancer dcraw, renommer le résultat et charger le TIFF dans la fenêtre. Tout ça en appels séparés en ligne de commande, même
mkdir,cpetmv. Les images HEIC passaient par sips. Les vignettes étaient générées à l'avance avec le module sharp et rangées dans mon propre cache. Vous imaginez combien de tickets sont arrivés parce qu'un format RAW particulier s'affichait mal.
Voilà à quoi ça ressemblait dans le code, légèrement raccourci :
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 ...
});
});
});
}); La commande de build de mon ancien package.json montre le mieux ce que c'était devenu : 43 dépendances, de jQuery à sharp en passant par HandBrake. dcraw et ExifTool copiés à la main dans node_modules, sharp recompilé pour chaque architecture, quelques modules rafistolés avec patch-package, et pour finir mon propre script de signature :
"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" Mais ce qui m'a coûté le plus de nerfs, c'est le sandbox. Pendant le développement, l'app tourne sans sandbox, dans l'App Store avec. Des choses qui marchaient sans problème en développement échouaient simplement en silence dans l'app finale, la lecture d'un dossier par exemple. Donc : ajouter des sorties de débogage dans la version finale, compiler, signer, tester, et chercher sur Stack Overflow ce que veut dire EPERM et pourquoi ça n'arrive que dans l'app empaquetée. Un exemple : si on ouvre un seul fichier dans le sandbox, l'app peut lire exactement ce fichier, rien d'autre dans le dossier. Or une visionneuse photo a besoin de tout le dossier pour les vignettes et la navigation. Phiewer interceptait donc l'erreur EPERM et affichait un second dialogue pour autoriser l'accès au dossier. Même histoire avec sips, l'outil d'images de macOS en ligne de commande : je voulais m'en servir pour faire générer les vignettes par le système. En développement ça marchait, dans l'app finale non, et cette fois sans le moindre message d'erreur.
Et puis il y avait les security-scoped bookmarks. C'est ce qui permet à une app du Mac App Store de se souvenir des dossiers auxquels elle a accès, même après un redémarrage. Pour une visionneuse qui travaille sur des dossiers, rien ne fonctionne sans eux. Je ne pouvais les tester que dans la version App Store signée, ils n'existent pas en développement. Electron avait une fonction pour ça, j'avais tout branché, et ça aurait dû marcher. Ça ne marchait pas. Au bout d'un moment, j'ai ouvert un ticket, et oui, c'était un bug d'Electron que personne n'avait encore remarqué. Ensuite, il fallait attendre le correctif. Le corriger moi-même, sans IA à l'époque, c'était trop compliqué pour moi. Je détestais ça.
Puis sont arrivées les puces Apple
Au début, Phiewer ne tournait que sur les Mac Intel. Les premiers Mac avec puce Apple sont sortis fin 2020. J'ai eu un build ARM séparé un an plus tard, fin 2021, et mon premier binaire universel seulement en janvier 2024. Les outils étaient tout simplement mauvais au début, et j'avais maintenant les problèmes de dépendances en double : sharp et ffmpeg, par exemple, devaient être compilés et signés séparément pour chaque architecture.
Le plus agaçant : je savais, grâce au développement iOS, que ça pouvait être autrement. Là-bas, on clique sur « Archive » dans Xcode, et c'est tout.
Tout supprimer et recommencer
À un moment, j'en ai eu assez de rafistoler. En juillet 2025, j'ai supprimé tous les fichiers, créé un nouveau projet dans Xcode et repris de zéro, sur une page blanche, le rêve de tout développeur. Cette fois en Swift et SwiftUI. Je n'ai rien gardé de l'ancien code. Seulement ce que je savais de ce que l'app doit faire et de l'allure qu'elle doit avoir.
Il ne s'agissait pas seulement de la taille. Je voulais enfin développer comme Apple le prévoit :
- •890 Mo sont devenus 14 Mo. Plus de navigateur dans les bagages, juste l'app.
- •De vrais contrôles Apple. La barre latérale est une vraie barre latérale de fenêtre, pas une couche JavaScript et CSS qu'on fait glisser. Tout a l'apparence prévue par Apple pour la version de macOS en cours.
- •Le mode sombre sans couleurs maison. Plus de définitions de couleurs à moi, le système s'en occupe.
- •Le RAW sans ligne de commande. macOS sait décoder le RAW lui-même, et LibRaw est intégrée directement comme bibliothèque au lieu d'être appelée via
spawn. Les vignettes viennent du système au lieu de mon propre cache. - •Plus jamais de modules Node qui ne correspondent pas à la version d'Electron. Et plus de VS Code avec HTML, CSS et JavaScript, mais Xcode. Archive, envoi, terminé.
- •Une app au lieu de deux. Il y a toujours eu PRO (achat unique) et lite (gratuite). Aujourd'hui, c'est la même app avec deux targets. Dans lite, tout est là et visible, mais les fonctions PRO sont désactivées et marquées d'un badge PRO.
Il a fallu environ six mois jusqu'à la sortie. En janvier 2026, Phiewer 6.0 est sorti, gratuit pour tous ceux qui l'avaient déjà acheté.
Je n'ai rien mesuré sur l'ancienne version. Mais j'ai l'impression que l'app démarre deux fois plus vite, et les vignettes sont là tout de suite. Les retours ont été formidables, et les ventes sont nettement meilleures depuis. La nouvelle version lite, qui montre ce que PRO sait faire, et un prix un peu plus élevé y ont sûrement contribué aussi.
Et puis AppKit, finalement
Développer comme Apple le prévoit n'est pas toujours idéal non plus. SwiftUI a encore ses limites avec de très grandes listes. Apple le sait aussi : à la WWDC 2025, Apple a annoncé que les listes de plus de 100 000 éléments se chargent désormais six fois plus vite sur Mac. Et pour certaines choses, même les apps 100 % SwiftUI reviennent encore aujourd'hui à AppKit. Alors on prend AppKit, et côté stabilité, AppKit, c'est un char d'assaut.
Chez moi, c'était la grille de vignettes. Elle était construite de façon très classique : un LazyVGrid dans une ScrollView, entouré d'un ScrollViewReader, pour que la grille suive quand on parcourt le dossier avec les flèches.
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) }
}
}
}
} Ça tournait bien aussi avec de gros dossiers. Du moins, c'est ce que je croyais. Jusqu'au jour où, dans un dossier de 14 000 photos, j'ai appuyé sur la flèche gauche depuis la première image et sauté à la fin de la liste. Le saut lui-même était rapide. Mais ensuite, chaque appui sur une touche mettait environ deux secondes avant que quelque chose bouge à l'écran. Et le plus étrange : de retour au début de la liste, tout redevenait normal.
Mon hypothèse (si vous connaissez mieux les rouages internes, corrigez-moi) : LazyVGrid ne construit que les vues visibles. Mais il ne sait pas non plus où se trouve un élément qu'il n'a pas encore disposé. Pour faire défiler jusqu'à l'élément 13 500, il doit calculer la mise en page de tout ce qui précède. scrollTo devient donc plus coûteux à mesure que la cible est loin. Pas idéal pour une visionneuse photo qui ne fait que défiler toute la journée.
NSCollectionView avec un flow layout connaît la taille de chaque élément à l'avance. L'emplacement de l'élément n est un simple calcul. scrollToItems(at:scrollPosition:) coûte donc toujours pareil, que la cible soit l'élément 10 ou l'élément 100 000.
Je ne voulais pas réécrire les cellules elles-mêmes, elles savaient déjà tout faire : vignettes, sélection, menu contextuel, renommage direct dans la vignette, glisser-déposer. J'ai donc seulement remplacé le conteneur par AppKit. Les cellules restent en SwiftUI, hébergées dans une NSHostingView à l'intérieur de chaque 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) Depuis, le même saut dans le même dossier ne provoque plus aucun délai, et même des dossiers de plusieurs centaines de milliers d'images défilent sans à-coups. Ce que ça change pour les utilisateurs, c'est dans l'article Boost de performances dans Phiewer.
Ce qui était encore cassé ensuite
Le changement en lui-même a été rapide. Remettre tout le reste d'aplomb a pris jusqu'en septembre. Six choses dont SwiftUI s'occupait pour moi sans que je m'en rende compte :
- •Les nouveaux tags ne s'affichaient pas.
FileItemne compare que l'URL pourEquatable. Après un changement de tag, SwiftUI pensait donc que la cellule n'avait pas changé. La solution : une identité propre composée de l'URL, des tags et des commentaires (code ci-dessous). - •
reloadData()remonte tout en haut. À chaque fichier ajouté ou supprimé. Si la nouvelle liste n'a aucun ID en commun avec l'ancienne, c'était un changement de dossier, et remonter en haut est correct. Sinon, je mémorise avant l'élément visible le plus haut et de combien il est coupé, puis je rétablis cette position après. - •
withAnimationdéborde jusque dans AppKit. Un vieuxwithAnimationautour d'un interrupteur faisait qu'AppKit animait la nouvelle mise en page et la correction du défilement. La grille tremblait. - •Les vignettes clignotaient en gris. Une cellule reliée à nouveau repart avec un
@Statevide. Maintenant, elle consulte aussi directement le cache. - •En cliquant, la grille glissait sous la souris, parce qu'elle se centrait sur la sélection. Les clics ne font plus défiler que le strict nécessaire, les flèches centrent toujours. Petit piège : pour le haut ou le bas, il faut
.nearestHorizontalEdge. - •Les layouts personnalisés étaient plus simples que prévu. La mosaïque « Exposé » calcule toutes les positions une seule fois dans
prepare(). Le tableau d'affichage n'a besoin d'aucun layout propre, car la rotation et le décalage sont dans la cellule SwiftUI.
private struct FileCellIdentity: Hashable {
let url: URL
let tags: [String]
let comments: String
}
cell.host(FilesGridCellWrapper(item: fileItem, ...).id(FileCellIdentity(fileItem)))
Ce que je ferais autrement aujourd'hui
Développer tout de suite pour la plateforme sur laquelle l'app tourne vraiment. Je me suis battu des années avec Electron pour garder Windows et Linux en option, et je n'ai jamais eu besoin ni de l'un ni de l'autre.
SwiftUI n'est pas lent, mais il ne convient pas partout. Les cellules sont toujours en SwiftUI, et c'est très bien. Mais pour des listes de dizaines de milliers d'éléments où l'on saute sans arrêt d'un endroit à l'autre, le conteneur doit savoir à l'avance où se trouve quoi. Sur Mac, ça s'appelle NSCollectionView.
Les utilisateurs sentent la différence. Personne ne m'a jamais écrit « pas d'app Electron, s'il vous plaît ». Ils l'ont remarqué quand même.
Si quelqu'un a réussi à rendre LazyVGrid rapide pour des listes aussi grandes : je suis curieux de savoir comment.
À propos de Phiewer
Phiewer est une visionneuse de photos et de vidéos pour macOS. La version gratuite (lite) contient la visionneuse complète. Phiewer PRO s'achète une fois, sans abonnement.
