Wonneproppen (Linux-Magazin, August 2018)

Die Gesichtserkennung mit Hilfe künstlicher Intelligenz vor zwei Monaten in dieser Reihe stöberte in meinen Urlaubsfotos vergangener Tage so manches Schmankerl zutage ([1]) und hat mir klargemacht, wie wenig ich doch über meine eigenen Archivfotos weiß. Grund dafür ist freilich, dass die Aufnahmen nach jedem Urlaub schnell vom Handy in einen neuen Ordner auf dem PC wandern, aber dort verschimmeln, denn ihnen haftet kein Tag an, anhand dessen ich sie später via Stichwortsuche wieder ans Licht holen könnte.

Als ersten Schritt überlegte ich, zumindest die schlechten Aufnahmen vor dem Archivieren auszumustern, aber das lässt sich auch schlecht von der Kommandozeile aus erledigen, da man zur Entscheidungsfindung "Töpfchen oder Kröpfchen" das Bild ansehen muss. Nun gibt es zwar eine ganze Reihe von Programmen zur Bearbeitung von Fotosammlungen, allerdings habe ich noch keines nach meinem Geschmack gefunden. Ich brauche 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 zu programmieren erfordert exotisches Spezialwissen (man denke an die Unterschiede zwischen Gnome, MacOS und Windows), aber wenn sie mit Githubs "Electron"-Framework integriert in einem gleich mitgelieferten Browser als Web-Applikation laufen, sollte das zu schaffen sein. Außerdem bündelt Electron GUIs ohne viel Zusatzaufwand für verschiedene Zielplattformen. Kein Vergleich zur Portierung einer native GUI auf andere Plattform, was einem Neustart des Projekts gleichkäme. Und dass dieser Rahmen selbst kommerziell erfolgreichen Applikationen genügt, beweisen Github mit dem Atom-Editor, und Slack. Genau, die 5-Milliarden-Dollar-Firma, die ihren Chat-Client für den Desktop mit Electron entwickelt und ausliefert. Wer hätte das gewusst?

Historisch belegt

Electron entstand, als Hauptentwickler Cheng Zhao den Rendering-Engine des Chromium-Browsers mit einem Node.js-Prozess verbandelte, der die restriktive Sandbox des Browsers mit den Angeboten des lokalen Betriebs- und Dateisystems über das umfangreiche Tool-Angebot der Node.js-Community verband. Dabei laufen Hauptprozess und Renderer stets separat, müssen sich aber irgendwie einen JavaScript-Kontext teilen, damit zum Beispiel der Hauptprozess in main.js Elemente auf der angezeigten Seite im Browser dynamisch verändern kann.

Abbildung 1: Electron teilt sich in den Hauptprozess und den Renderer im Chromium-Browser (bitte als Diagramm zeichnen)

Diese Verknüpfung zweier Prozesse mit eigenen Event-Loops löst Elektron durch IPC (Inter-Process-Communication). Die Module, die den Kitt zwischen beiden Seiten bereitstellen heißen ipcMain und ipcRenderer. Damit der Hauptprozess zum Beispiel ein Foto im Browser darstellen kann, das er soeben von der Festplatte gelesen hat, muss er es erst an den Renderer-Prozess schicken, der dann seine Sicht der dargestellten Browserseite auffrischt.

Los geht's

Zur Inbetriebnahme des Electron-Frameworks liefen vor einigen Monaten im Linux-Magazin bereits zwei Artikel ([2], [3]), gehen wir also nur kurz auf die Vorbereitung ein und stürzen uns dann direkt in die Bearbeitung von Fotoordnern. 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 Build-Phase und während der Ausführung brauchen. In einem frischen Verzeichnis legt dann

    npm init
    npm install electron --save-dev

ein neues Projekt an, das nicht nur lokal Electron installiert sondern auch für neugierige Nachmacher dessen Abhängigkeiten in seiner Dependency-Liste führt. npm init fragt interaktiv einige Projektparameter ab, wie den Namen der Applikation, deren Version oder den Namen des Autors (Abbildung 2). Die Option --save-dev der Install-Anweisung für Electron 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 iNuke-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 Nodejs-Interpreter zur Ausführung.

Mut zum Anderssein

Bekanntlich programmiert sich Node.js mit seinem asynchronen funktionialen Ansatz ja ganz anders als "normale" Sprachen wie Python oder Perl. Statt Aufrufe sequenziell abzuarbeiten, legt NodeJS-Code dem Aufruf einer Funktion oft einen Callback bei, den diese am Ende anspringt. So baut der GUI-Code einen Automaten auf, zwischen dessen Zuständen der Code Event-gesteuert herumspringt, immer auf der Hut vor neuen Events wie Mausklicks des Users, auf die es zeitnah zu reagieren gilt, was gar nicht ginge, falls der Code gerade blockiert wäre, weil er gerade eine große Datei von der Platte liest.

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

So führt der Code in Listing 1 zunächst gar nichts aus, sondern wartet bis die Node-Umgebung den Event ready meldet, worauf es den Callback ab Zeile 25 anspringt und zuerst mit createWindow() dem Renderer-Prozess eine Webseite zum Anzeigen reicht. Dies geschieht in createWindow() ab Zeile 8 und mit einem Objekt der Klasse BrowserWindow, dessen loadURL-Methode den Pfad zu index.html in Listing 2 erhält.

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>

Dabei hält sich Listing 1 eine Referenz auf das Browserfenster in der globalen Variablen win vor, damit es sie später in Callbacks wie beim closed-Event wieder zurücksetzen und damit die Speicherfreigabe vor Programmschluss einleiten kann. Während der Debug-Phase einer neuen Applikation ist es ausgesprochen nützlich, mittels openDevTools() wie in 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 Console 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 definieren, dass der User mit 'l' zum nächsten und mit 'h' zum vorherigen Bild wechseln kann (genau wie man in vim nach links bzw. rechts fährt), und mit 'd' das angezeigte Bild löschen kann. 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. Dieser wird übrigens ganz zu Anfang vom Hauptprozess in Listing 1 aufgerufen, der in Zeile 12 die Datei index.html (Listing 2) lädt, das wiederum in Zeile 8 mit require(./renderer.js) das JavaScript des Renderers in Listing 3 ausführt.

Es gibt kein Zurück

Listing 2 definiert das Image-Tag des aktuell angezeigten Fotos in Zeile 11 mit der ID "image", sodass es der Renderer-Prozess später leicht finden und gegebenenfalls durch ein neues Foto ersetzen kann, falls der User entsprechende Wünsche durch Tastatureingaben kund tut. Abbildung 4 zeigt die fertige Applikation, in der der User mit "d" ein Bild löscht, mit "l" zum nächsten Bild fährt oder mit "h" zurück, falls er sich eines anderen besinnt. Ein Undelete gibt es allerdings nicht, das wäre nicht im Sinne der Unix-Philosophie.

Fährt die Applikation herunter, was typischerweise der User einleitet, der entweder das Fenster zuklickt oder wie unter Gnome üblich CTRL-W drückt, fangen dies die Event-Handler will-quit bzw. window-all-closed ab. Sie lösen die Tasten-Bindings auf und rufen die quit-Methode der Applikation auf, die alle belegten Resourcen freigibt.

Abbildung 4: Die GUI der iNuke-App auf Ubuntu Linux.

Der Renderer-Prozess in Listing 3 zieht zunächst die Image-Library blueimp herein, die der User mit

    npm install blueimp-load-image --save

vom Node.js-Repository npmjs.com hereinzieht. Darauf macht npm in der lokalen package.json-Datei eine Notiz davon in der Liste der Abhängigkeiten, und stellt damit sicher, dass neugierige Nachmacher nur das Projekt klonen und npm install aufrufen müssen, und schon sorgt npm dafür, dass auch sie die Module, von denen das Projekt abhängt, ohne viel Federlesens installiert bekommen. Die zum Programmstart aus dem Verzeichnis images mittels readdir in Zeile 39 eingelesenen Fotodateien hält das Programm in den globalen Variablen images vor und images_idx merkt sich den Index des gerade angezeigten Bildes.

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

Hört die Signale

Die Funktion scroll() ab Zeile 19 nimmt als Parameter die Richtung (1: vor,-1: zurück) entgegen, in die der User fortschreiten möchte, stellt die globalen Variablen auf das neu anzuzeigende Foto ein und sorgt im Bauch der Funktion dafür, dass sich niemand außerhalb der Grenzen des Arrays bewegt. Die Zeilen 51 und 52 nehmen hierzu die Signale des Hauptprozesses aus Listing 1 entgegen, die dann ausgelöst werden, 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 mittels der Node.js-Methode unlink() aus der Klasse fs die Datei gnadenlos von der Platte putzt und das nächste Foto zur Anzeige bringt.

Ein Foto von der Platte zu lesen und es im Browserfenster anzuzeigen, 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, dann die Methode scale() um es auf maximal 600 Pixel Breite zu skalieren. Anschließend sucht es im HTML des Browsers nach einem Image-Tag mit der ID "image" und ersetzt es durch das neue Bild, nachdem sie die ID des neuen Bildes ebenfalls auf "image" gesetzt hat, damit der Auffrischprozess das Tag nächstes Mal auch wieder findet.

Nächste Schritte

Statt die Applikation mit npm start hochzufahren, packt sie das Zusatzpacket electron-builder in ein Binary, das alles enthält, inklusive Chromium Browser und den Libraries von denen das Projekt abhängt. Der Builder benötigt allerdings eine neuere Node.js-Version als die Ubuntu 16.04 beiliegende, wer mag, kann sie aus dem offiziellen Node.js-Repo nachinstallieren. Das Erzeugnis lässt sich problemlos als Datei von Host zu Host kopieren und läuft auf ähnlichen Architekturen tadellos. Wer Bilder nicht in einem Verzeichnis "images" sucht sondern im aktuellen Verzeichnis, sollte den String in Zeile 39 von Listing 3 durch process.cwd() ersetzen, nachdem das Modul mittels require('process') hereingezogen wurde.

Wer es auf eine andere Plattform, wie 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, denn das Ganze funktioniert dort tadellos mit den dort üblichen GUI-Konventionen. Ein Bündel der Freude, ein Wonneproppen.

Wer Blut geleckt hat und tiefer in die Materie "GUI-Programmierung mit Electron" einsteigen möchte, für den bietet das Buch "Cross-Platform Desktop Applications" ([5]) und die Ausführungen von Jessica Lord ([6]) eine gute Basis.

Als Erweiterung der iNuke-Applikation böte sich das Einfügen von Tags an. Die guten Fotos eines Hawaii-Urlaubs bekämen dann das Tag "hawaii 2018", Portraitfotos den Namen der abgebildeten Person, und so fort, auf dass ein Suchprogramm sie später wieder hervorholen könnte. Ein paar Knöpferl mit Tagnamen auf der rechten Seite der GUI, die ein Tag in den EXIF-Bereich des aktuellen Fotos einfügen, ein "New Tag"-Button zum Erzeugen neuer Tags, wie schwer könnte das sein? Schau'n mer mal.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2018/08/snapshot/

[2]

Michael Schilli, "Den kenn ich doch": Linux-Magazin 06/2018, S.80, <U>http://www.linux-magazin.de/ausgaben/2018/06/snapshot-3/<U>

[3]

Andreas Möller, "Code im Beschleuniger", Linux-Magazin 02/2018, S.70

[4]

Andreas Möller, "Schirmherrschaft", Linux-Magazin 03/2018, S.82

[5]

"Cross-Platform Desktop Applications using Electron and NW.js", Paul B. Jensen, Manning 2017

[6]

"Essential Electron", Jessica Lord, http://jlord.us/essential-electron/#stay-in-touch

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc