Das private Foto-Archiv auf dem Rechner wächst stetig, ohne jemals wieder Beachtung zu finden. Mike Schilli rückt dem undurchdringlichen Wildwuchs mit dem Electron-Framework und einer selbst erdachten Applikation zum Ausmustern schlechter Aufnahmen zu Leibe.
Vor zwei Monaten förderte ich per Gesichtserkennung mit künstlicher Intelligenz so manches Schmankerl unter meinen alten Urlaubsfotos zutage [2]. Das machte mir klar, wie wenig ich doch über meine eigenen Archivfotos weiß. Grund dafür ist, dass die Aufnahmen nach jedem Urlaub schnell vom Handy in einen Ordner auf dem PC wandern. Dort aber verschimmeln sie, denn ihnen haftet kein Schlagwort an, um sie über eine Suche ans Licht zu holen.
Spreu vom Weizen trennen
Als ersten Schritt überlegte ich, zumindest die schlechten Aufnahmen vor dem Archivieren auszumustern. Das lässt sich nur schlecht von der Kommandozeile aus erledigen, denn zur Entscheidungsfindung “Töpfchen oder Kröpfchen” muss ich das Bild ansehen.
Nun gibt es zwar eine Reihe von Programmen zum Bearbeiten von Fotosammlungen, allerdings habe ich noch keines nach meinem Geschmack gefunden. Ich suche eine schlanke Applikation, die rasend schnell Bilder liest und beim Löschen natürlich nicht nachfragt – alles andere wäre eines Fachmanns unwürdig. Wie schwierig wäre es wohl, so etwas selbst zu schreiben?
Kein Exotenwissen
Native GUIs programmieren, das erfordert exotisches Spezialwissen (man denke an die Unterschiede zwischen Gnome, Mac OS und Windows). Laufen die GUIs hingegen dank Githubs Electron-Framework als Webapplikation in einem gleich mitgelieferten Browser, sollte das zu schaffen sein.
Daneben paketiert Electron die grafischen Programme ohne viel Zusatzaufwand für verschiedene Zielplattformen. Kein Vergleich zur Portierung eines nativen GUI auf andere Plattformen, was einem Neustart des Projekts gleich käme.
Dass der von Electron vorgegebene Rahmen selbst kommerziell erfolgreichen Applikationen genügt, beweisen Github mit dem Atom-Editor sowie Slack – genau, die 5-Milliarden-Dollar-Firma, die ihren Chat-Client für den Desktop mit Electron entwickelt und ausliefert.
Historisch belegt
Electron entstand, als Hauptentwickler Cheng Zhao die Rendering-Engine des Chromium-Browsers mit einem Node.js-Prozess verbandelte. Dieser verknüpft über das umfangreiche Tool-Angebot der Node.js-Community die restriktive Sandbox des Browsers mit den Angeboten des lokalen Betriebs- und Dateisystems. Dabei laufen Hauptprozess und Renderer stets separat, müssen sich aber irgendwie einen Javascript-Kontext teilen. Das ist nötig, damit zum Beispiel der Hauptprozess in »main.js« Elemente auf der angezeigten Seite im Browser dynamisch verändern kann (Abbildung 1).

Abbildung 1: Electron teilt sich in den Hauptprozess und den Renderer im Chromium-Browser-Code auf. Die beiden Prozesse sind stets separat.
Diese Verknüpfung zweier Prozesse mit eigenen Event-Loops löst Electron durch IPC (Inter Process Communication). Die Module, die den verbindenden Kitt zwischen den beiden Seiten bereitstellen, heißen »ipcMain« und »ipcRenderer«. Damit der Hauptprozess dann etwa ein Foto im Browser darstellt, das er soeben von der Festplatte eingelesen hat, muss er es erst an den Renderer-Prozess schicken, der daraufhin seine Ansicht der dargestellten Browserseite auffrischt.
Los geht’s
Zur Inbetriebnahme des Electron-Frameworks liefen vor einigen Monaten im Linux-Magazin bereits zwei Artikel ([3], [4]). Mein Text erwähnt die Vorbereitung daher nur kurz und stürzt sich dann direkt auf das Bearbeiten der Foto-Ordner. Folgende Kommandos installieren das Framework auf Ubuntu:
sudo apt-get install npm nodejs-legacy
Das Paket »nodejs-legacy« installiert lediglich einige Symlinks, die viele ältere Node-Module während ihrer Buildphase und beim Ausführen brauchen. In einem frischen Verzeichnis legt dann
npm init npm install electron --save-dev
ein neues Projekt an, das nicht nur Electron lokal installiert, sondern auch für neugierige Nachmacher dessen Abhängigkeiten in seiner Dependency-Liste führt. Der Befehl »npm init« fragt interaktiv einige Projektparameter ab, etwa den Namen der Applikation, deren Version oder den Namen des Autors (Abbildung 2). Die Option »–save-dev« der »npm install«-Anweisung oben hängt den Namen des Pakets in die »devDependencies«-Liste in »package.json« an. Zum Vergleich würde »–save« das Paket als Runtime-Abhängigkeit führen.

Abbildung 2: »npm init« legt als Grundgerüst für die I-Nuke-Applikation die Javascript-Datei »package.js« an.
Wer außerdem im »scripts«-Abschnitt den Eintrag
"start": "electron ."
einfügt, kann die Applikation später mittels »npm start« aufrufen. Dabei holt sich Electron das in der Datei unter »main« aufgelistete Startskript »main.js« in Listing 1 und übergibt es dem Node.js-Interpreter zur Ausführung.
Listing 1
main.js
01 const {app,globalShortcut,BrowserWindow} =
02 require('electron');
03 const path = require('path');
04 const url = require('url');
05
06 let win;
07
08 function createWindow(){
09 win = new BrowserWindow({
10 width:800, height:600});
11
12 win.loadURL(url.format({
13 pathname:
14 path.join(__dirname, 'index.html'),
15 protocol: 'file:', slashes: true
16 }));
17
18 win.webContents.openDevTools();
19
20 win.on('closed', () => {
21 win = null;
22 });
23 }
24
25 app.on('ready', () => {
26 createWindow();
27 globalShortcut.register('l', () => {
28 win.webContents.send('nextImage');
29 });
30 globalShortcut.register('h', () => {
31 win.webContents.send('prevImage');
32 });
33 globalShortcut.register('d', () => {
34 win.webContents.send('deleteImage');
35 });
36 win.webContents.send('prevImage');
37 });
38
39 app.on('will-quit', () => {
40 ['h','l','d'].forEach(function(key){
41 globalShortcut.unregister(key);
42 });
43 });
44
45 app.on('window-all-closed', () => {
46 app.quit();
47 });
Mut zum Anderssein
Bekanntlich programmiert sich Node.js mit seinem asynchronen funktionalen Ansatz ganz anders als “normale” Sprachen wie Python oder Perl. Statt Aufrufe sequenziell abzuarbeiten, legt Node.js-Code dem Aufruf einer Funktion oft einen Callback bei. Den springt die Funktion am Ende an.
Damit baut der GUI-Code einen Automaten auf, zwischen dessen Zuständen der Code Event-gesteuert hin und her springt. Dabei muss er stets auf der Hut vor neuen Events wie Mausklicks des Users sein, auf die er zeitnah reagieren muss. Das würde nicht klappen, wäre der Code gerade blockiert, weil er eine große Datei von der Platte liest.
So führt der Code in Listing 1 zunächst gar nichts aus, sondern wartet, bis die Node-Umgebung das Event »ready« meldet. Tritt der Fall ein, springt er den Callback ab Zeile 25 an und reicht zuerst mit »createWindow()« dem Renderer-Prozess eine Webseite zum Anzeigen. Dies geschieht ab Zeile 8 und mit einem Objekt der Klasse »BrowserWindow«, dessen »loadURL()«-Methode den Pfad zur Datei »index.html« in Listing 2 erhält.
Zugleich hält sich Listing 1 über die globale Variable »win« eine Referenz auf das Browserfenster vor. Die kann es bei späteren Callbacks, wie sie das »closed«-Event erzeugt, zurücksetzen und damit die Speicherfreigabe vor Programmschluss einleiten.
Während der Debug-Phase einer neuen Applikation ist es ausgesprochen nützlich, mittels »openDevTools()« (Zeile 18) Chromiums Debug-Fenster im Hauptfenster des Browsers aufzumachen und entweder Warnungen auf der Console mitzulesen oder das HTML der dynamisch aufgefrischten Webseite zu analysieren (Abbildung 3).

Abbildung 3: Bei offenem Debug-Fenster kann der Entwickler das dargestellte HTML analysieren oder Meldungen auf der Konsole verfolgen.
Kurz und bündig
Tastatureingaben abzufangen ist ebenfalls Aufgabe des Hauptprozesses in »main.js«. Die »register«-Aufrufe in den Zeilen 27, 30 und 33 sorgen dafür, dass der User mit [L]+ zum nächsten und mit [H]+ zum vorherigen Bild wechseln (genau wie man in Vim nach links oder rechts fährt) und mit [D] das angezeigte Bild löschen kann.
Listing 2
index.html
01 <html>
02 <head> </head>
03
04 <body>
05 <h1>iNuke My Photos</h1>
06
07 <script>
08 require('./renderer.js');
09 </script>
10
11 <img id="image"></img>
12
13 </body>
14 </html>
Die Kommandos wirken unter anderem auf die angezeigte Webseite. Also schickt der Hauptprozess »main.js« sie mittels IPC und »win.webContents.send()« als Events an den Renderer-Prozess in Listing 3. Den ruft übrigens ganz zu Anfang der Hauptprozess in Listing 1 auf. Er lädt in den Zeilen 12 bis 16 die Datei »index.html« (Listing 2), die wiederum in Zeile 8 über »require(./renderer.js)« das Javascript des Renderers in Listing 3 ausführt.
Listing 3
renderer.js
01 loadImage = require('blueimp-load-image');
02 fs = require( 'fs' );
03 ipc = require('electron').ipcRenderer;
04
05 images = [];
06 images_idx = -1;
07
08 function displayImage(file) {
09 loaded = loadImage(file, function(img) {
10 scaled_img = loadImage.scale(
11 img, {maxWidth: 600});
12 scaled_img.id = "image";
13 node = window.document.getElementById(
14 'image');
15 node.replaceWith(scaled_img);
16 } );
17 }
18
19 function scroll(direction){
20 images_idx += direction;
21 if(images_idx > images.length-1){
22 images_idx = images.length-1;
23 }else if(images_idx<0) {
24 images_idx = 0;
25 }
26 displayImage( images[ images_idx ] );
27 }
28
29 function deleteImage() {
30 fs.unlink(images[ images_idx ]);
31 images.splice(images_idx, 1);
32 if(images.length == 0) {
33 console.log("That's it. Good-bye!");
34 require('electron').remote.app.quit();
35 }
36 scroll(-1);
37 }
38
39 dir = "images"; // change to process.cwd()
40 fs.readdir(dir, function(err, files) {
41 if( err ) {
42 console.error("readdir:", err);
43 require('electron').remote.app.quit();
44 }
45 files.forEach(function(file, index) {
46 images.push( dir + "/" + file );
47 });
48 scroll(0);
49 } );
50
51 ipc.on('nextImage', () => { scroll(1); });
52 ipc.on('prevImage', () => { scroll(-1); });
53 ipc.on('deleteImage', deleteImage);
Es gibt kein Zurück
Listing 2 definiert das Image-Tag des aktuell angezeigten Fotos in Zeile 11 mit der ID »image«. So findet es der Renderer-Prozess später leicht und ersetzt es durch ein neues Foto, falls der User dies per Tastatureingabe befiehlt.
Abbildung 4 zeigt die fertige Applikation. In ihr löscht der Anwender mit [D]+ ein Bild, fährt mit [L]+ zum nächsten oder mit [H] zum vorherigen, falls er sich doch eines anderen besinnt. Ein Undelete gibt es allerdings nicht, das wäre nicht im Sinne der Unix-Philosophie.
Die Applikation fährt herunter, indem der User entweder das Fenster schließt oder – wie unter Gnome üblich – [Ctrl]+ und [W] drückt. Diese Ereignisse fangen die Event-Handler »will-quit« beziehungsweise »window-all-closed« ab. Sie lösen die Tasten-Bindings auf und rufen die »quit()«-Methode der Applikation auf, die alle belegten Resourcen freigibt.
Der Renderer-Prozess in Listing 3 greift sich zunächst die Image-Library »blueimp«. Das setzt voraus, dass der Anwender diese zuvor über
npm install blueimp-load-image --save
aus dem Node.js-Repository »npmjs.com« geholt hat. Den Neuzugang vermerkt der Paketmanager Npm in der Liste der Abhängigkeiten der lokalen »package.json«-Datei. So stellt er sicher, dass neugierige Nachmacher das Projekt nur klonen und »npm install« aufrufen müssen, um die Module zu holen und zu installieren, von denen das Projekt abhängt.
Die zum Programmstart mittels »readdir()« aus dem Verzeichnis »images« (Zeile 39) eingelesenen Fotodateien hält das Programm in der globalen Variablen »images« vor. Die Variable »images_idx« (Zeile 6) merkt sich den Index des gerade angezeigten Bildes.
Signale entgegennehmen
Die Funktion »scroll()« ab Zeile 19 nimmt als Parameter die Richtung (»1« für vorwärts, »-1« für zurück) entgegen, in die der User fortschreiten möchte. Sie stellt die globalen Variablen dann auf das neu anzuzeigende Foto ein und sorgt im Bauch der Funktion noch dafür, dass sich niemand außerhalb der Grenzen des Array bewegt.
Die Zeilen 51 und 52 nehmen hierzu die Signale des Hauptprozesses aus Listing 1 entgegen. Die löst der Code aus, wenn der User die entsprechende Taste drückt. Zeile 53 reagiert auf den Druck der Taste [D] im Hauptprozess und springt bei Aktivierung in die Funktion »deleteImage()« ab Zeile 29. Die putzt mittels der Node.js-Methode »unlink()« aus der Klasse »fs« die Datei gnadenlos von der Platte und stellt das nächste Foto zur Schau.
Fotos einlesen
Ein Foto von der Platte lesen und es dann im Browserfenster anzeigen, das ist die Aufgabe der Funktion »displayImage()« ab Zeile 8. Sie nutzt die Funktion »loadImage()« aus der vom Npmjs-Repository geladenen Library »blueimp«, um das Foto einzulesen. Die Methode »scale()« skaliert es dann auf maximal 600 Pixel Breite. Anschließend sucht es im HTML des Browsers nach einem Image-Tag mit der ID »image« und ersetzt das vorgefundene durch das neue Bild (Zeile 15). Zuvor hat Listing 3 die ID des neuen Bildes ebenfalls auf »image« gesetzt, damit der Auffrischprozess das Tag nächstes Mal auch wieder findet.
Nächste Schritte
Statt die Applikation mit »npm start« hochzufahren, verpackt das Zusatzpaket »electron-builder« sie in ein Binary. Das enthält alles Nötige, inklusive Chromium-Browser und Bibliotheken, von denen das Projekt abhängt. Der Builder verlangt allerdings eine neuere Node.js-Version als die von Ubuntu 16.04 gelieferte. Wer mag, installiert sie aus dem offiziellen Node.js-Repository nach.
Das Resultat lässt sich problemlos als Datei von Host zu Host kopieren und läuft auf ähnlichen Architekturen tadellos. Wer Bilder nicht im Verzeichnis »images«, sondern im aktuellen Verzeichnis sucht, sollte den String in Zeile 39 von Listing 3 auf »process.cwd()« setzen, nachdem er das zuständige Modul mittels »require(‘process’)« an Bord geholt hat.
Plattform wechseln
Wer es auf eine andere Plattform, zum Beispiel den Mac, übertragen möchte, ruft dort im gleichen Verzeichnis einfach »npm install« und »npm start« auf und wird sich verwundert die Augen reiben: Das Ganze funktioniert dort tadellos mit den dort üblichen GUI-Konventionen. Ein Bündel der Freude, ein Wonneproppen.
Wer nun Blut geleckt hat und tiefer in die Materie “GUI-Programmierung mit Electron” einsteigen möchte, für den bieten das Buch “Cross-Platform Desktop Applications” [5] sowie die Ausführungen von Jessica Lord [6] einen guten Einstiegspunkt.
Als Erweiterung der »iNuke«-Applikation böte sich in einer Ausbaustufe das Einfügen von Tags an. Die guten Fotos eines Hawaii-Urlaubs bekämen dann etwa das Tag »hawaii 2018«, Porträtfotos jeweils den Namen der abgebildeten Person und so weiter. Durch diesen Schritt könnte sie ein Suchprogramm später einfach wieder hervorkramen.
Ein paar Knöpferl mit Tagnamen auf der rechten Seite des GUI, die ein Tag in den Exif-Bereich des aktuellen Fotos einfügen, dazu ein »New Tag«-Button zum Erzeugen neuer Tags – wie schwer könnte das sein? Schaun wir mal. (uba)
Online PLUS
Im Screencast demonstriert Michael Schilli das Beispiel: https://www.linux-magazin.de/videos/
Infos
-
Listings zu diesem Artikel: https://www.linux-magazin.de/static/listings/magazin/2018/08/snapshot/
-
Michael Schilli, “Den kenn ich doch”: Linux-Magazin 06/18, S. 80, https://www.linux-magazin.de/ausgaben/2018/08/snapshot-3/
-
Andreas Möller, “Code im Beschleuniger”: Linux-Magazin 02/18, S. 70: https://www.linux-magazin.de/ausgaben/2018/02/electron
-
Andreas Möller, “Schirmherrschaft”: Linux-Magazin 03/18, S. 82: https://www.linux-magazin.de/ausgaben/2018/03/electron
-
Paul B. Jensen, “Cross-Platform Desktop Applications using Electron and NW.js”: Manning, 2017
-
Jessica Lord “Essential Electron”: http://jlord.us/essential-electron/#stay-in-touch







