Von Electron zu Swift
Acht Jahre Electron, dann alles weggeworfen und in Swift neu angefangen. Aus 890 MB wurden 14 MB. Und am Ende musste das Bilder-Raster trotzdem nochmal auf AppKit umziehen.
Phiewer heute: 14 MB, nativ. Das Raster läuft auf NSCollectionView, die Kacheln sind SwiftUI.
Phiewer gibt es seit 2017 im Mac App Store. Ein Bildbetrachter, der direkt mit Ordnern arbeitet, ohne Mediathek und ohne Import. Bis Ende 2025 war das eine Electron-App.
Im Juli 2025 habe ich das alte Projekt gelöscht und in Xcode komplett neu angefangen. Im Januar 2026 kam Version 6.0 raus, in Swift geschrieben, 14 MB statt 890 MB. Ein paar Monate später musste ich dann ausgerechnet das Herzstück, das Raster mit den Vorschaubildern, nochmal umbauen. Aber der Reihe nach.
Warum überhaupt Electron?
Ich komme aus der Webentwicklung. Mit Objective-C und SpriteKit hatte ich zwar schon iOS-Apps und Spiele gebaut, aber eine ganze Desktop-App damit war mir eine Nummer zu groß. Mit Electron ging das: eine Mac-App mit HTML, CSS und JavaScript bauen, also mit dem, was ich eh schon konnte. Und dann war da noch das Argument, das jeder kennt: Später kann man die App ja auch für Windows und Linux rausbringen.
Hab ich nie gemacht. Wäre auch gar nicht gegangen, der Code war mit der Zeit viel zu sehr auf den Mac zugeschnitten. Den ganzen Ärger habe ich mir also für etwas eingehandelt, das ich nie gebraucht habe.
Was mich an Electron genervt hat
Kein einzelnes großes Problem. Eher viele kleine, und die wurden über die Jahre immer mehr:
- •Die Größe. 890 MB für einen Bildbetrachter. Bei jedem Download und jedem Update kam ein kompletter Browser mit.
- •Es hat sich nie wie eine echte Mac-App angefühlt. Egal, wie viel Arbeit ich reingesteckt habe.
- •Node-Module mit nativem Code. Die müssen passend zur jeweiligen Electron-Version neu kompiliert werden. Das eine Modul lief mit der neuen Electron-Version, das andere noch nicht oder nie. Am Ende konnte ich Electron gar nicht mehr updaten: Die letzte Version von Phiewer lief noch auf Electron 24 vom April 2023. Als ich im Sommer 2025 neu angefangen habe, war Electron schon bei Version 37. Dazu kamen Pakete, die jeweils eine andere Node-Version wollten.
- •Electron hat sich unter mir verändert. Mein Fenster hat über das
remote-Modul direkt auf den Hauptprozess zugegriffen. Das wurde mit Electron 12 als veraltet markiert und mit Electron 14 rausgeworfen, aus Sicherheitsgründen und wegen der Performance. Nachvollziehbar, hieß für mich aber: alle Aufrufe in zehn Dateien auf das Ersatzpaket@electron/remoteumbauen. Und das Update auf Electron 12 habe ich noch am selben Tag zurückgenommen, weil damit das Durchblättern von Videos geruckelt hat. - •Signieren für den App Store. Zwei Entitlements-Dateien, eine für die App und eine für alles, was darin steckt: Helfer, Frameworks, Bibliotheken. Dazu ein Skript, das alles der Reihe nach signiert. Am Ende hat mein Skript 33 Mal
codesignaufgerufen, für jede Bibliothek und jedes Programm einzeln: ffmpeg, HandBrake, dcraw, sharp, libvips und so weiter. Kam ein neues Modul dazu und ich hatte vergessen, es ins Skript einzutragen, kam der Upload mit einer unsignierten Bibliothek zurück. Ein Modul habe ich sogar wieder rausgeworfen, weil es beim Upload nur Signaturfehler gab. - •Alles doppelt. Die kostenlose lite-Version war eine eigene, zweite App. Jeder Build, jede Signatur, jeder Upload zweimal. Und wenn ein neues PRO-Feature, das lite gar nicht hatte, einen Fix bekam, liefen die Versions- und Build-Nummern der beiden Apps auseinander.
- •RAW-Dateien. Dafür habe ich dcraw benutzt, ein Kommandozeilenprogramm, das schon damals kaum noch gepflegt wurde (die letzte Version ist von 2018, neue Kameras unterstützt seitdem LibRaw). Für jedes RAW-Bild hieß das: Cache-Ordner anlegen, Original hineinkopieren, dcraw drüberlaufen lassen, Ergebnis umbenennen und das TIFF dann ins Fenster laden. Alles als einzelne Kommandozeilen-Aufrufe, sogar
mkdir,cpundmv. HEIC-Bilder liefen über sips. Die Vorschaubilder habe ich mit dem Modul sharp vorab erzeugt und in einen eigenen Cache gelegt. Wie viele Issues reinkamen, weil irgendein spezielles RAW-Format falsch angezeigt wurde, kann man sich vorstellen.
So sah das im Code aus, leicht gekürzt:
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 ...
});
});
});
}); Wie das am Ende aussah, zeigt am besten der Build-Befehl aus meiner alten package.json: 43 Abhängigkeiten von jQuery über sharp bis HandBrake. dcraw und ExifTool von Hand in node_modules kopiert, sharp für jede Architektur extra neu gebaut, ein paar Module mit patch-package geflickt und zum Schluss ein eigenes Signier-Skript:
"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" Am meisten Nerven gekostet hat aber die Sandbox. Beim Entwickeln läuft die App ohne Sandbox, im App Store mit. Dinge, die beim Entwickeln problemlos liefen, sind in der fertigen App einfach still gescheitert, zum Beispiel das Lesen eines Ordners. Also Debug-Ausgaben in die fertige App einbauen, bauen, signieren, testen und auf Stack Overflow suchen, was EPERM bedeutet und warum es nur in der gebauten App auftaucht. Ein Beispiel: Öffnet man in der Sandbox eine einzelne Datei, darf die App nur genau diese Datei lesen, nicht den Rest des Ordners. Für Vorschaubilder und Blättern braucht ein Bildbetrachter aber den ganzen Ordner. Also hat Phiewer den EPERM-Fehler abgefangen und einen zweiten Dialog gezeigt, in dem man den Ordner noch einmal freigeben musste. Ähnlich mit sips, dem Bildwerkzeug von macOS auf der Kommandozeile: Ich wollte damit die Vorschaubilder vom System erzeugen lassen. Beim Entwickeln lief es, in der fertigen App nicht, diesmal ganz ohne Fehlermeldung.
Und dann die Security-Scoped Bookmarks. Damit merkt sich eine App aus dem Mac App Store, auf welche Ordner sie zugreifen darf, auch nach einem Neustart. Für einen Bildbetrachter, der mit Ordnern arbeitet, geht ohne die gar nichts. Testen konnte ich sie nur in der signierten App-Store-Version, beim Entwickeln gibt es sie nicht. Electron hatte dafür eine Funktion, ich hatte alles eingebaut, und es hätte laufen sollen. Tat es aber nicht. Irgendwann habe ich ein Issue eingereicht, und ja, es war ein Fehler in Electron, der nur noch niemandem aufgefallen war. Dann hieß es warten, bis er behoben ist. Selbst patchen war mir damals, ohne KI, zu kompliziert. Das war der Hass.
Dann kamen die Apple-Chips
Anfangs lief Phiewer nur auf Intel-Macs. Die ersten Macs mit Apple-Chip kamen Ende 2020. Einen eigenen ARM-Build hatte ich ein Jahr später, Ende 2021, und das erste Universal Binary erst im Januar 2024. Die Werkzeuge waren am Anfang einfach schlecht, und die Probleme mit den Abhängigkeiten von oben hatte ich jetzt doppelt: sharp und ffmpeg zum Beispiel musste ich für jede Architektur extra bauen und signieren.
Das Ärgerliche daran: Von der iOS-Entwicklung kannte ich es anders. Da drückt man in Xcode auf „Archive“, und das war's.
Alles löschen und neu anfangen
Irgendwann hatte ich keine Lust mehr, weiter zu flicken. Im Juli 2025 habe ich alle Dateien gelöscht, in Xcode ein neues Projekt angelegt und bei null angefangen, auf der grünen Wiese, ein Traum eines jeden Entwicklers. Diesmal mit Swift und SwiftUI. Vom alten Code habe ich nichts übernommen. Nur das Wissen, was die App können muss und wie sie aussehen soll.
Dabei ging es nicht nur um die Größe. Ich wollte endlich so programmieren, wie Apple es vorsieht:
- •Aus 890 MB wurden 14 MB. Kein Browser mehr im Gepäck, nur noch die App.
- •Echte Bedienelemente von Apple. Die Seitenleiste ist eine richtige Fenster-Seitenleiste und keine Ebene aus JavaScript und CSS zum Ein- und Ausklappen. Alles sieht so aus, wie Apple es für die jeweilige macOS-Version vorsieht.
- •Dunkelmodus ohne eigene Farben. Keine eigenen Farbdefinitionen mehr, das System erledigt das.
- •RAW ohne Kommandozeile. macOS kann RAW selbst, und LibRaw ist direkt als Bibliothek eingebunden statt über
spawn. Die Vorschaubilder kommen vom System statt aus einem eigenen Cache. - •Nie wieder Node-Module, die nicht zur Electron-Version passen. Und kein VS Code mit HTML, CSS und JavaScript mehr, sondern Xcode. Archive, hochladen, fertig.
- •Eine App statt zwei. PRO (Einmalkauf) und lite (kostenlos) gab es schon immer. Heute ist es dieselbe App mit zwei Targets. In lite ist alles da und sichtbar, die PRO-Funktionen sind aber deaktiviert und mit einem PRO-Badge versehen.
Bis zur Veröffentlichung hat es ungefähr ein halbes Jahr gedauert. Im Januar 2026 kam Phiewer 6.0, für alle bisherigen Käufer kostenlos.
Gemessen habe ich bei der alten Version nichts. Gefühlt startet die App aber doppelt so schnell, und die Vorschaubilder sind sofort da. Die Reaktionen waren grandios, und die Verkäufe sind seitdem deutlich besser. Dazu beigetragen haben sicher auch die neue lite-Version, die zeigt, was PRO kann, und ein etwas höherer Preis.
Und dann doch noch AppKit
So zu programmieren, wie Apple es vorsieht, ist allerdings auch nicht immer das Gelbe vom Ei. SwiftUI hat bei sehr großen Listen noch seine Grenzen. Das weiß Apple auch: Zur WWDC 2025 hat Apple angekündigt, dass Listen mit über 100.000 Einträgen auf dem Mac jetzt sechsmal schneller laden. Und für manche Dinge greifen auch reine SwiftUI-Apps bis heute auf AppKit zurück. Dann nimmt man eben AppKit, und das ist, was Stabilität angeht, ein Panzer.
Bei mir war es das Raster mit den Vorschaubildern. Das war ganz klassisch gebaut: ein LazyVGrid in einer ScrollView, außen herum ein ScrollViewReader, damit das Raster mitscrollt, wenn man mit den Pfeiltasten durch den Ordner geht.
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) }
}
}
}
} Lief auch mit großen Ordnern flüssig. Dachte ich. Bis ich in einem Ordner mit 14.000 Fotos beim ersten Bild die Pfeiltaste nach links gedrückt habe und damit ans Ende der Liste gesprungen bin. Der Sprung selbst ging schnell. Aber danach dauerte jeder Tastendruck etwa zwei Sekunden, bis sich auf dem Bildschirm was bewegt hat. Und das Seltsame: Zurück am Anfang der Liste war alles wieder normal.
Meine Vermutung (wer sich mit den Interna besser auskennt, darf mich gern korrigieren): LazyVGrid baut nur die Views, die gerade zu sehen sind. Wo ein Element liegt, das noch nicht angeordnet wurde, weiß es aber auch nicht. Um zu Element 13.500 zu scrollen, muss es das Layout von allem davor durchrechnen. scrollTo wird also teurer, je weiter unten das Ziel liegt. Blöd für einen Bildbetrachter, der den ganzen Tag nichts anderes macht als scrollen.
NSCollectionView mit einem Flow-Layout kennt die Größe jedes Elements vorher. Wo Element n liegt, ist eine einfache Rechnung. scrollToItems(at:scrollPosition:) kostet deshalb immer gleich viel, egal ob das Ziel Element 10 ist oder Element 100.000.
Die Zellen selbst wollte ich nicht neu schreiben, die konnten ja schon alles: Vorschaubilder, Auswahl, Kontextmenü, Umbenennen direkt in der Kachel, Drag & Drop. Also habe ich nur den Container gegen AppKit getauscht. Die Zellen sind weiter SwiftUI und stecken in einer NSHostingView in jedem 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) Seitdem gibt es beim selben Sprung im selben Ordner keine Verzögerung mehr, und auch Ordner mit mehreren hunderttausend Bildern scrollen flüssig. Wie sich das für Nutzer anfühlt, steht im Artikel Performance-Boost in Phiewer.
Was danach noch kaputt war
Der Umbau selbst ging schnell. Bis drumherum wieder alles gepasst hat, war September. Sechs Sachen, um die sich vorher SwiftUI gekümmert hatte, ohne dass ich es gemerkt habe:
- •Neue Tags wurden nicht angezeigt.
FileItemvergleicht beiEquatablenur die URL. Nach einer Tag-Änderung dachte SwiftUI deshalb, die Zelle sei unverändert. Lösung: eine eigene Identität aus URL, Tags und Kommentaren (Code unten). - •
reloadData()springt nach oben. Und zwar bei jeder neuen oder gelöschten Datei. Hat die neue Liste keine einzige ID mit der alten gemeinsam, war es ein Ordnerwechsel, dann ist der Sprung richtig. Sonst merke ich mir vorher das oberste sichtbare Element und wie weit es angeschnitten ist und stelle das danach wieder her. - •
withAnimationwirkt bis in AppKit. Ein alteswithAnimationum einen Schalter hat dafür gesorgt, dass AppKit das neue Layout und die Scroll-Korrektur animiert hat. Das Raster hat gewackelt. - •Vorschaubilder blitzten grau auf. Eine Zelle, die neu gebunden wird, fängt mit leerem
@Statean. Jetzt schaut sie zusätzlich direkt in den Cache. - •Beim Klicken ist das Raster unter der Maus weggerutscht, weil es auf die Auswahl zentriert hat. Klicks scrollen jetzt nur so weit wie nötig, Pfeiltasten zentrieren weiter. Kleine Falle: Für oben oder unten braucht man
.nearestHorizontalEdge. - •Eigene Layouts waren einfacher als gedacht. Das Mosaik „Exposé“ rechnet alle Positionen einmal in
prepare()aus. Die Pinnwand braucht gar kein eigenes Layout, weil Drehung und Versatz in der SwiftUI-Zelle stecken.
private struct FileCellIdentity: Hashable {
let url: URL
let tags: [String]
let comments: String
}
cell.host(FilesGridCellWrapper(item: fileItem, ...).id(FileCellIdentity(fileItem)))
Was ich heute anders machen würde
Gleich für die Plattform bauen, auf der die App wirklich läuft. Ich habe mich jahrelang mit Electron rumgeschlagen, um mir Windows und Linux offenzuhalten, und beides nie gebraucht.
SwiftUI ist nicht langsam, aber es passt nicht überall. Die Zellen sind weiter SwiftUI, und das ist gut so. Aber bei Listen mit zehntausenden Einträgen, in denen man ständig irgendwohin springt, sollte der Container vorher wissen, wo was liegt. Auf dem Mac heißt das NSCollectionView.
Nutzer merken den Unterschied. Mir hat nie jemand geschrieben „bitte keine Electron-App“. Gemerkt haben sie es trotzdem.
Falls jemand LazyVGrid für so große Listen doch flott bekommen hat: Ich bin neugierig, wie.
Über Phiewer
Phiewer ist ein Foto- und Videobetrachter für macOS. Die kostenlose Version (lite) enthält den vollen Bildbetrachter. Phiewer PRO kostet einmalig, ohne Abo.
