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:
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
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 QAction
s erstellt werden, denen jeweils eine Tastenkombination und eine Funktion zugeordnet sind. Danach wird das Menü erstellt, und die QActions
s 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()