Terug naar de blog

Van Electron naar Swift

Acht jaar Electron, dan alles weggegooid en in Swift opnieuw begonnen. Van 890 MB naar 14 MB. En uiteindelijk moest het fotoraster toch nog naar AppKit.

Phiewer vandaag: 14 MB, native. Het raster draait op NSCollectionView, de tegels zijn SwiftUI.

Phiewer vandaag: 14 MB, native. Het raster draait op NSCollectionView, de tegels zijn SwiftUI.

Phiewer staat sinds 2017 in de Mac App Store. Een fotoviewer die rechtstreeks met je mappen werkt, zonder bibliotheek en zonder importeren. Tot eind 2025 was het een Electron-app.

In juli 2025 heb ik het oude project verwijderd en in Xcode helemaal opnieuw begonnen. In januari 2026 kwam versie 6.0 uit, geschreven in Swift, 14 MB in plaats van 890 MB. Een paar maanden later moest ik uitgerekend het belangrijkste onderdeel, het raster met de miniaturen, nog een keer ombouwen. Maar laten we bij het begin beginnen.

Waarom eigenlijk Electron?

Ik kom uit de webontwikkeling. Ik had al iOS-apps en games gemaakt met Objective-C en SpriteKit, maar een complete desktopapp in Objective-C was mij een maatje te groot. Met Electron kon het wel: een Mac-app bouwen met HTML, CSS en JavaScript, dus met wat ik al kende. En dan was er nog het argument dat iedereen kent: later kun je de app ook voor Windows en Linux uitbrengen.

Heb ik nooit gedaan. Het had ook niet gekund, de code was in de loop der jaren veel te Mac-specifiek geworden. Al die ellende heb ik dus geslikt voor een voordeel dat ik nooit heb gebruikt.

Wat me aan Electron opbrak

Niet één groot probleem. Eerder heel veel kleine, en die stapelden zich door de jaren heen op:

  • •De grootte. 890 MB voor een fotoviewer. Bij elke download en elke update kwam een complete browser mee.
  • •Het voelde nooit als een echte Mac-app. Hoeveel werk ik er ook in stak.
  • •Node-modules met native code. Die moeten voor elke Electron-versie opnieuw gecompileerd worden. Het ene module werkte met de nieuwe Electron-versie, het andere nog niet, of nooit. Uiteindelijk kon ik Electron helemaal niet meer updaten: de laatste versie van Phiewer draaide nog op Electron 24 uit april 2023. Toen ik in de zomer van 2025 opnieuw begon, zat Electron al op versie 37. Daar kwamen nog pakketten bij die elk een andere Node-versie wilden.
  • •Electron veranderde onder mijn handen. Mijn venster praatte via de remote-module rechtstreeks met het hoofdproces. Die werd in Electron 12 als verouderd gemarkeerd en in Electron 14 verwijderd, om redenen van veiligheid en prestaties. Begrijpelijk, maar voor mij betekende het: alle aanroepen in tien bestanden ombouwen naar het vervangende pakket @electron/remote. En de update naar Electron 12 heb ik dezelfde dag nog teruggedraaid, omdat door video's bladeren ging haperen.
  • •Ondertekenen voor de App Store. Twee entitlements-bestanden, één voor de app en één voor alles wat erin zit: helpers, frameworks, bibliotheken. Plus een script dat alles in de juiste volgorde ondertekent. Uiteindelijk riep mijn script 33 keer codesign aan, voor elke bibliotheek en elk programma apart: ffmpeg, HandBrake, dcraw, sharp, libvips enzovoort. Voegde ik een nieuwe module toe en vergat ik die in het script te zetten, dan kwam de upload terug met een niet-ondertekende bibliotheek. Eén module heb ik zelfs weer verwijderd, omdat die bij het uploaden alleen maar ondertekeningsfouten gaf.
  • •Alles dubbel. De gratis lite-versie was een aparte, tweede app. Elke build, elke handtekening, elke upload twee keer. En als een nieuwe PRO-functie, die lite niet eens had, een fix kreeg, liepen de versie- en buildnummers van de twee apps uit elkaar.
  • •RAW-bestanden. Daarvoor gebruikte ik dcraw, een commandlinetool die toen al nauwelijks onderhouden werd (de laatste versie is van 2018, nieuwe camera's worden sindsdien door LibRaw ondersteund). Voor elke RAW-foto betekende dat: een cachemap aanmaken, het origineel erin kopiëren, dcraw erop loslaten, het resultaat hernoemen en de TIFF dan in het venster laden. Allemaal als losse aanroepen op de commandline, zelfs mkdir, cp en mv. HEIC-foto's gingen via sips. De miniaturen maakte ik vooraf met de module sharp en zette ik in een eigen cache. Je kunt je voorstellen hoeveel issues er binnenkwamen omdat een of ander speciaal RAW-formaat niet goed werd weergegeven.

Zo zag dat er in de code uit, iets ingekort:

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 ...
      });
    });
  });
});

Het buildcommando uit mijn oude package.json laat het best zien wat het was geworden: 43 afhankelijkheden, van jQuery via sharp tot HandBrake. dcraw en ExifTool met de hand in node_modules gekopieerd, sharp voor elke architectuur apart opnieuw gebouwd, een paar modules opgelapt met patch-package en tot slot een eigen ondertekeningsscript:

"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"

Maar wat me de meeste zenuwen heeft gekost, was de sandbox. Tijdens het ontwikkelen draait de app zonder sandbox, in de App Store met. Dingen die tijdens het ontwikkelen probleemloos werkten, mislukten in de uiteindelijke app gewoon stil, bijvoorbeeld het lezen van een map. Dus: debug-uitvoer in de uiteindelijke app inbouwen, bouwen, ondertekenen, testen en op Stack Overflow zoeken wat EPERM betekent en waarom het alleen in de gebouwde app opduikt. Een voorbeeld: open je in de sandbox één bestand, dan mag de app precies dat bestand lezen en niets anders uit de map. Maar een fotoviewer heeft voor miniaturen en bladeren de hele map nodig. Dus ving Phiewer de EPERM-fout op en liet een tweede venster zien waarin je de map nog een keer moest vrijgeven. Hetzelfde met sips, de beeldtool van macOS op de commandline: daarmee wilde ik het systeem de miniaturen laten maken. Tijdens het ontwikkelen werkte het, in de uiteindelijke app niet, en dit keer zonder enige foutmelding.

En dan waren er de security-scoped bookmarks. Daarmee onthoudt een app uit de Mac App Store tot welke mappen hij toegang heeft, ook na een herstart. Voor een fotoviewer die met mappen werkt, werkt zonder die bookmarks helemaal niets. Testen kon ik ze alleen in de ondertekende App Store-versie, tijdens het ontwikkelen bestaan ze niet. Electron had er een functie voor, ik had alles ingebouwd en het had moeten werken. Deed het niet. Op een gegeven moment heb ik een issue ingediend, en ja, het was een bug in Electron die gewoon nog niemand had opgemerkt. Daarna was het wachten tot het opgelost was. Zelf patchen was me toen, zonder AI, te ingewikkeld. Ik haatte het.

Toen kwamen de Apple-chips

In het begin draaide Phiewer alleen op Intel-Macs. De eerste Macs met Apple-chip kwamen eind 2020. Een eigen ARM-build had ik een jaar later, eind 2021, en de eerste universal binary pas in januari 2024. De tools waren in het begin gewoon slecht, en de problemen met afhankelijkheden van hierboven had ik nu dubbel: sharp en ffmpeg bijvoorbeeld moest ik voor elke architectuur apart bouwen en ondertekenen.

Het irritante eraan: van iOS-ontwikkeling wist ik dat het anders kon. Daar klik je in Xcode op ‘Archive’ en klaar.

Alles weggooien en opnieuw beginnen

Op een gegeven moment had ik geen zin meer om verder te lappen. In juli 2025 heb ik alle bestanden verwijderd, in Xcode een nieuw project aangemaakt en vanaf nul begonnen, met een schone lei, de droom van elke ontwikkelaar. Dit keer met Swift en SwiftUI. Van de oude code heb ik niets overgenomen. Alleen de kennis van wat de app moet kunnen en hoe hij eruit moet zien.

Het ging niet alleen om de grootte. Ik wilde eindelijk programmeren zoals Apple het bedoeld heeft:

  • •Van 890 MB naar 14 MB. Geen browser meer in de bagage, alleen nog de app.
  • •Echte bedieningselementen van Apple. De zijbalk is een echte venster-zijbalk en geen laag van JavaScript en CSS die in- en uitklapt. Alles ziet eruit zoals Apple het voor de betreffende macOS-versie heeft bedacht.
  • •Donkere modus zonder eigen kleuren. Geen eigen kleurdefinities meer, het systeem regelt dat.
  • •RAW zonder commandline. macOS kan zelf RAW lezen, en LibRaw is direct als bibliotheek ingebouwd in plaats van via spawn. De miniaturen komen van het systeem in plaats van uit een eigen cache.
  • •Nooit meer Node-modules die niet bij de Electron-versie passen. En geen VS Code met HTML, CSS en JavaScript meer, maar Xcode. Archive, uploaden, klaar.
  • •Eén app in plaats van twee. PRO (eenmalige aankoop) en lite (gratis) waren er altijd al. Nu is het dezelfde app met twee targets. In lite is alles aanwezig en zichtbaar, maar de PRO-functies zijn uitgeschakeld en hebben een PRO-badge.

Tot de release duurde het ongeveer een half jaar. In januari 2026 kwam Phiewer 6.0, gratis voor iedereen die hem al gekocht had.

Bij de oude versie heb ik niets gemeten. Maar het voelt alsof de app twee keer zo snel start, en de miniaturen zijn er meteen. De reacties waren geweldig, en de verkoop is sindsdien duidelijk beter. Daar hebben vast ook de nieuwe lite-versie, die laat zien wat PRO kan, en een iets hogere prijs aan bijgedragen.

En toen toch nog AppKit

Programmeren zoals Apple het bedoeld heeft, is trouwens ook niet altijd ideaal. SwiftUI heeft bij heel grote lijsten nog zijn grenzen. Dat weet Apple ook: op WWDC 2025 kondigde Apple aan dat lijsten met meer dan 100.000 items op de Mac nu zes keer sneller laden. En voor sommige dingen grijpen zelfs pure SwiftUI-apps tot op de dag van vandaag terug op AppKit. Dan neem je dus AppKit, en qua stabiliteit is AppKit een tank.

Bij mij was het het raster met de miniaturen. Dat was heel klassiek gebouwd: een LazyVGrid in een ScrollView, met daaromheen een ScrollViewReader, zodat het raster meescrolt als je met de pijltjestoetsen door de map gaat.

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) }
            }
        }
    }
}

Het liep ook met grote mappen soepel. Dacht ik. Tot ik in een map met 14.000 foto's bij de eerste foto op de pijl naar links drukte en zo naar het einde van de lijst sprong. De sprong zelf ging snel. Maar daarna duurde elke toetsaanslag ongeveer twee seconden voordat er iets op het scherm bewoog. En het vreemde: terug aan het begin van de lijst was alles weer normaal.

Mijn vermoeden (wie de interne werking beter kent, mag me gerust verbeteren): LazyVGrid bouwt alleen de views die op dat moment zichtbaar zijn. Maar het weet ook niet waar een item ligt dat het nog niet heeft geplaatst. Om naar item 13.500 te scrollen, moet het de layout van alles daarvoor uitrekenen. scrollTo wordt dus duurder naarmate het doel verder naar beneden ligt. Niet handig voor een fotoviewer die de hele dag niets anders doet dan scrollen.

NSCollectionView met een flow-layout kent de grootte van elk item vooraf. Waar item n ligt, is een simpele rekensom. Daarom kost scrollToItems(at:scrollPosition:) altijd evenveel, of het doel nu item 10 is of item 100.000.

De cellen zelf wilde ik niet opnieuw schrijven, die konden al alles: miniaturen, selectie, contextmenu, direct hernoemen in de tegel, slepen en neerzetten. Dus heb ik alleen de container vervangen door AppKit. De cellen zijn nog steeds SwiftUI en zitten in een NSHostingView in elk 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)

Sindsdien heeft dezelfde sprong in dezelfde map geen vertraging meer, en ook mappen met honderdduizenden foto's scrollen soepel. Hoe dat voor gebruikers aanvoelt, staat in het artikel Snelheidsboost in Phiewer.

Wat er daarna nog kapot was

De ombouw zelf ging snel. Tot alles eromheen weer klopte, was het september. Zes dingen die SwiftUI eerder voor me regelde zonder dat ik het merkte:

  • •Nieuwe tags werden niet getoond. FileItem vergelijkt voor Equatable alleen de URL. Na een tagwijziging dacht SwiftUI daardoor dat de cel niet veranderd was. De oplossing: een eigen identiteit van URL, tags en opmerkingen (code hieronder).
  • •reloadData() springt naar boven. Bij elk nieuw of verwijderd bestand. Heeft de nieuwe lijst geen enkele ID gemeen met de oude, dan was het een mapwissel en is naar boven springen juist. Anders onthoud ik vooraf het bovenste zichtbare item en hoe ver het is afgesneden, en zet ik dat daarna terug.
  • •withAnimation werkt door tot in AppKit. Een oude withAnimation rond een schakelaar zorgde ervoor dat AppKit de nieuwe layout en de scrollcorrectie animeerde. Het raster wiebelde.
  • •Miniaturen flitsten grijs op. Een cel die opnieuw gekoppeld wordt, begint met een lege @State. Nu kijkt hij daarnaast direct in de cache.
  • •Bij het klikken gleed het raster onder de muis weg, omdat het op de selectie centreerde. Klikken scrolt nu alleen zo ver als nodig, de pijltjestoetsen centreren nog steeds. Kleine valkuil: voor boven of onder heb je .nearestHorizontalEdge nodig.
  • •Eigen layouts waren makkelijker dan gedacht. Het mozaïek ‘Exposé’ berekent alle posities één keer in prepare(). Het prikbord heeft helemaal geen eigen layout nodig, omdat draaiing en verschuiving in de SwiftUI-cel zitten.
private struct FileCellIdentity: Hashable {
    let url: URL
    let tags: [String]
    let comments: String
}

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

Wat ik nu anders zou doen

Meteen bouwen voor het platform waarop de app echt draait. Ik heb jarenlang met Electron geworsteld om Windows en Linux open te houden, en heb ze geen van beide ooit nodig gehad.

SwiftUI is niet traag, maar het past niet overal. De cellen zijn nog steeds SwiftUI, en dat is goed. Maar bij lijsten met tienduizenden items waarin je voortdurend ergens naartoe springt, moet de container vooraf weten waar wat ligt. Op de Mac heet dat NSCollectionView.

Gebruikers merken het verschil. Niemand heeft me ooit geschreven ‘alsjeblieft geen Electron-app’. Ze merkten het toch.

Mocht iemand LazyVGrid voor zulke grote lijsten toch snel hebben gekregen: ik ben benieuwd hoe.

Over Phiewer

Phiewer is een foto- en videoviewer voor macOS. De gratis versie (lite) bevat de volledige fotoviewer. Phiewer PRO is een eenmalige aankoop, zonder abonnement.