malte70.blog()

PyQt5-Wrapper für Webanwendungen

Webanwendungen sind ungemein praktisch, da sie per se plattformunabhängig sind. Mit PyQt5 lässt sich mit ein wenig Aufwand eine Wrapper-Anwendung implementieren, die eine solche Anwendung in einem nativen Fenster anzeigt. Es lassen sich sogar relativ einfach Menüeinträge bauen, die JavaScript in der Webanwendung ausführen, z.B. um einen Klick auf einen Button auszuführen.

Im folgenden stelle ich einen grundlegenden Wrapper für meine DNS-Tools, sowie einen erweiterten mit Menü und About-Dialog für das Spiel 2048; zuvor aber erst einmal ein paar PyQt5-Grundlagen.

PyQt5-Basics

Da das Qt-Framework auf C++ setzt, ist es streng objektorientiert, und PyQt5 ist ein sehr schlanker Wrapper, der lediglich die C++-Klassen in Python bereitstellt. Zentrales Element einer Qt-Anwendung ist eine QApplication-Instanz. Diese kennt z.B. den Namen der Anwendung, der beim erweiterten Wrapper Teil des Pfades für persistenten Speicher wird.

Der Zweck eines QMainWindow liegt auf der Hand: Es ist das Haupt-Fenster der Anwendung, und in unserem Fall auch das einzige. Ihm wird als zentrales Widget ein QtWebEngine-WebView zugewiesen.

QtWebEngine basiert auf einem von Google gelösten Chromium, und hat mittlerweile QtWebKit ersetzt. QtWebEngine bietet die volle Unterstützung moderner Web-Standards, die auch Chrome/Chromium bieten.

Minimaler Webapp-Wrapper

Zuerst ein minimaler Wrapper, der nur ein Fenster mit QtWebEngine-WebView anzeigt, und der Vollständigkeit halber die Kommandozeilenargumente --help und --version unterstützt. Das ganze sieht am Ende so aus:

DNS Tools Wrapper unter MATE/Debian

Unter Debian müssen dafür zwei Pakete installiert werden, da die WebEngine nicht Teil des PyQt5-Pakets ist:

sudo apt install python-pyqt5 python-pyqt5.qtwebengine

Imports und App-Info-dataclass

Zu aller erst müssen wir einige Module einbinden. Wie unten zu sehen, ist PyQt5 auf zahlreiche Module aufgeteilt, von denen wir hier vier Stück importieren müssen. Die AppInfo-Klasse nutzt den dataclass-Dekorator aus der Python-Standard-Bibliothek; neben dem Namen und der Version wird hier vor allem die URL der Webanwendung angegeben:

import sys
import dataclasses

import PyQt5.QtCore
import PyQt5.QtGui
import PyQt5.QtWidgets
import PyQt5.QtWebEngineWidgets


@dataclasses.dataclass
class AppInfo:
	Name = "DNS Tools"
	Version = "0.20231109"
	Description = "Qt WebEngine Wrapper for malte70's DNS tools"
	WebViewURL = "https://app.malte70.de/dns-tools/"

Hauptfenster mit WebView: QMainWindow + QWebEngineView

Für das Anwendungsfenster wird eine Unterklasse von QMainWindow erstellt. Es wird der Fenstertitel sowie die Abmessungen festgelegt; und eine QWebEngineView mit der URL aus der AppInfo-Klasse als zentrales Widget erstellt.

Um die URL aus der dataclass an die WebView zu übergeben, muss eine Instanz der Hilfsklasse QUrl erstellt werden:

class MainWindow(PyQt5.QtWidgets.QMainWindow):
	def __init__(self):
		super().__init__()
		self.title = AppInfo.Name
		self.left = 10
		self.top = 10
		self.width = 800
		self.height = 900
		self.initUI()

	def initUI(self):
		# Fenstereigenschaften festlegen
		self.setWindowTitle(self.title)
		self.setGeometry(self.left, self.top, self.width, self.height)

		# WebView
		self.webview = PyQt5.QtWebEngineWidgets.QWebEngineView()
		self.webview.setUrl(
			PyQt5.QtCore.QUrl(AppInfo.WebViewURL)
		)
		self.setCentralWidget(self.webview)

main()-Funktion und Kommandozeilenargumente

In der main()-Funktion muss zuerst ein QApplication-Objekt erstellt werden, dass sich z.B. um die Verarbeitung von Events kümmert. Danach wird eine Instanz unseres MainWindow erstellt und angezeigt, und die QApplication ausgeführt:

def main():
	app = PyQt5.QtWidgets.QApplication(sys.argv)
	app.setApplicationName(AppInfo.Name)
	app.setApplicationVersion(AppInfo.Version)

	main_window = MainWindow()
	main_window.show()

	app.exec()

Um die Kommandozeilenargumente --version und --help zu unterstützen, werden die Funktionen version() und usage() definiert. usage() kann optional ein Output-Stream übergeben werden, um die Ausgabe auf STDERR zu leiten.

def version():
	print(AppInfo.Name + " " + AppInfo.Version)
	print("\t" + AppInfo.Description.replace("\n", "\n\t"))
	print("\n\tWebApp URL: " + AppInfo.WebViewURL)
	print()


def usage(out=sys.stdout):
	print(f"Usage: {sys.argv[0]} [--version|--help]", file=out)

Am Ende des Skripts wird auf die beiden Optionen geprüft, und falls keine Option übergeben wurde wird main() ausgeführt:

if __name__ == "__main__":
	if "--version" in sys.argv:
		version()
		sys.exit(0)
	elif "--help" in sys.argv:
		usage()
		sys.exit(0)
	elif len(sys.argv) > 1:
		print("Unknown option(s): " + sys.argv[1:], file=sys.stderr)
		sys.exit(2)

	main()

Erweiterter Wrapper für 2048: Q2048App

Nun zu einem deutlich erweiterten Skript: Q2048App, ein Wrapper für 2048 von Gabriele Cirulli. Für die Highscore-Funktion muss persistenter Speicher für die WebView konfiguriert werden; zusätzlich auch ein paar weitere Features:

  • Persistenz von Webseitendaten (bei 2048: LocalStorage)
  • Anwendungs-Icon
  • Menüs
    • About-Dialog
    • Menüpunkt für ein neues Spiel (simuliert einen Klick auf einen Button in der Webseite)
    • Vollbildmodus
  • Benutzerdefinierter User-Agent

Der erweiterte Webapp-Wrapper Q2048App

QtGui und erweiterte AppInfo-Klasse

Um den User-Agent mit Systeminfos zu versehen, verwende ich meine OSDetect-Bibliothek. Diese wird zu aller erst Benutzerweit installiert:

pip install --user --break-system-packages OSDetect

Zusätzlich zu den beim DNS-Tools-Wrapper bereits verwendeten Modulen müssen OSDetect und für das Anwendungs-Icon PyQt5.QtGui importiert werden:

import sys
import os
import dataclasses
from OSDetect import info as os_info
import PyQt5.QtCore
import PyQt5.QtGui
import PyQt5.QtWidgets
import PyQt5.QtWebEngine
import PyQt5.QtWebEngineWidgets

Die AppInfo-Klasse wird um einige Angaben erweitert, die z.B. im About-Dialog angezeigt werden. Außerdem kommt die Funktion get_user_agent() hinzu, die einen typischen User-Agent-String zusammen baut:

@dataclasses.dataclass
class AppInfo:
	"""Contains information about the application"""

	Name        = "Q2048App"
	Version     = "0.1.0"
	Description = "Gabriele Cirulli's 2048, wrapped in a minimal Qt desktop application"
	WebViewURL  = "https://app.malte70.de/2048/"
	WebsiteURL  = "https://github.com/malte70/Q2048App"
	Icon        = "Q2048App.png"
	Vendor      = "rolltreppe3"
	VendorDomain = "rolltreppe3.de"

	def get_user_agent():
		"""Generate our HTTP User-Agent.

		Returns:
			str: User-Agent string
		"""
		user_agent  = "Mozilla/5.0 ("
		if not os_info.getOS() in ("Windows", "Darwin") and os_info.hasGUI():
			user_agent += "X11; "

		user_agent += os_info.getOS() + " " + os_info.getMachine() + ") "
		user_agent += "PyQt5.QtWebEngine/"+PyQt5.QtCore.QT_VERSION_STR
		user_agent += " Python/" + os_info.getPythonVersion()
		user_agent += " " + AppInfo.Name + "/" + AppInfo.Version

		return user_agent

Der Anfang der Fenster-Implementierung ist nicht anders als im minimalen Wrapper:

class Q2048MainWindow(PyQt5.QtWidgets.QMainWindow):
	"""Our main window; contains the application logic"""

	def __init__(self, app):
		super().__init__()
		self.app    = app
		self.title  = AppInfo.Name
		self.left   = 10
		self.top    = 10
		self.width  = 640
		self.height = 1000
		
		self.initUI()
		
	def initUI(self):
		"""Initialize user interface."""
		self.setWindowTitle(self.title)
		self.setGeometry(self.left, self.top, self.width, self.height)

Jetzt wird das Icon für das Fenster gesetzt. Wie auch bei URLs muss bei Qt eine Hilfklasse, QIcon, verwendet werden:

		self.setWindowIcon(
			PyQt5.QtGui.QIcon(
				os.path.join(os.path.dirname(__file__), AppInfo.Icon)
			)
		)

Es folgt ein einfaches Menü mit Standard-Tastaturkürzeln. Dafür müssen zuerst einige QActions erstellt werden, denen jeweils eine Tastenkombination und eine Funktion zugeordnet sind. Danach wird das Menü erstellt, und die QActionss an dieses gebunden.

		# Game-Menü -> Neues Spiel
		menu_game_new = PyQt5.QtWidgets.QAction("&New", self)
		menu_game_new.setShortcut("Ctrl+N")
		menu_game_new.triggered.connect(self.new_game)
		
		# Game-Menü -> Vollbild
		menu_game_fullscreen = PyQt5.QtWidgets.QAction("&Fullscreen", self)
		menu_game_fullscreen.setShortcut("F11")
		menu_game_fullscreen.triggered.connect(self.full_screen)
		
		# Game-Menü -> Anwendung beenden
		menu_game_quit = PyQt5.QtWidgets.QAction("&Quit", self)
		menu_game_quit.setShortcut("Ctrl+Q")
		menu_game_quit.triggered.connect(self.app.quit)
		
		# Help-Menü -> About-Dialog
		menu_help_about = PyQt5.QtWidgets.QAction("&About", self)
		menu_help_about.setShortcut("F1")
		menu_help_about.triggered.connect(self.about)
		
		# Menüzeile und Game- & Help-Menüs erzeugen
		menu_bar = self.menuBar()
		menu_game = menu_bar.addMenu("&Game")
		menu_game.addAction(menu_game_new)
		menu_game.addAction(menu_game_fullscreen)
		menu_game.addSeparator()
		menu_game.addAction(menu_game_quit)
		menu_help = menu_bar.addMenu("&Help")
		menu_help.addAction(menu_help_about)

Jetzt wird wieder ein QWebEngineView erzeugt, allerdings dieses Mal mit persistentem Speicher und unserem User-Agent. Beides wird nicht direkt für die WebView festgelegt, sondern für ein QWebEngineProfile, das wiederum der WebEnginePage des eigentlichen WebEngineView zugeordnet ist:

		# WebEngine View mit persistentem Speicher
		self.web_engine_view    = PyQt5.QtWebEngineWidgets.QWebEngineView()
		self.web_engine_page    = self.web_engine_view.page()
		self.web_engine_profile = self.web_engine_page.profile()
		self.web_engine_profile.setPersistentStoragePath(
			PyQt5.QtCore.QStandardPaths.writableLocation(
				PyQt5.QtCore.QStandardPaths.CacheLocation
			)
		)

		# User-Agent
		self.web_engine_profile.setHttpUserAgent(AppInfo.get_user_agent())

		# Web-App URL laden und Widget in Fenster einbetten
		self.web_engine_view.load(PyQt5.QtCore.QUrl(AppInfo.WebViewURL))
		self.setCentralWidget(self.web_engine_view)

Um ein neues Spiel zu starten, muss ein Button im WebView geklickt werden. Dies geschiet durch eine Zeile JavaScript, die in der QWebEnginePage ausgeführt wird:

	def new_game(self):
		self.web_engine_page.runJavaScript(
			"document.getElementsByClassName('restart-button')[0].click()",
			PyQt5.QtWebEngineWidgets.QWebEngineScript.ApplicationWorld
		)

Die Vollbild-Option setzt entweder den Vollbildmodus, oder deaktiviert diesen falls er bereits aktiv ist:

	def full_screen(self):
		if self.isFullScreen():
			self.showNormal()
		else:
			self.showFullScreen()

Für den About-Dialog wird eine einfache QMessageBox mit OK-Button angezeigt. Qt5 erlaubt hier sogar ein paar Formattierungen mit HTML-Tags:

	def about(self):
		"""Show some info about the application."""
		about_text = "<b>{0} {1}</b><br>\n".format(AppInfo.Name, AppInfo.Version)
		about_text+= "<p>{0}</p>\n".format(AppInfo.Description)
		about_text+= "<a href=\"{0}\">{1}</a>".format(
			AppInfo.WebsiteURL,
			AppInfo.WebsiteURL.split(":")[1][2:]
		)
		message_box = PyQt5.QtWidgets.QMessageBox(
			PyQt5.QtWidgets.QMessageBox.Information,
			"About "+AppInfo.Name,
			about_text,
			PyQt5.QtWidgets.QMessageBox.Ok,
			self
		)
		message_box.exec()

Zusätzlich zum Anwendungsnamen/-version wird für die QApplication auch eine Organisation gesetzt; je nach Plattform werden diese Angaben für den Pfad des persistenten WebView-Speichers genutzt:

def main():
	"""Create QApplication and show the main window."""
	app = PyQt5.QtWidgets.QApplication(sys.argv)
	app.setApplicationName(AppInfo.Name)
	app.setApplicationVersion(AppInfo.Version)
	app.setOrganizationName(AppInfo.Vendor)
	app.setOrganizationDomain(AppInfo.VendorDomain)

	main_window = Q2048MainWindow(app)
	main_window.showMaximized()
	
	try:
		app.exec()
	except KeyboardInterrupt:
		app.quit()
		sys.exit(0)
	
if __name__ == "__main__":
	main()