from Guide to Hacking on Jun 11, 2023
How a Python-only desktop app works
There's a new breed of cross-platform, browser-like wrappers that are both easy to build and feature-ful, supporting a number of native features such as offline support, deeplinking, notifications, and more.
Native desktop applications support a number of features — offline support, deeplinking, notifications, and more — but are not easy to build; they often require platform-specific development tools and languages, that aren't generalizable across platforms. On the other hand, web applications are easier to build but don't support these exclusive features.
Previously, this effectively meant that you traded developer time for native features. However, with a new set of browser-based cross-platform desktop applications, this calculus has changed.
One of the first mainstream projects to this effect was Electron, which enabled easy deployment of NodeJS-powered applications. In short, the application hosts a local web server and opens a browser.
A slew of other projects, such as neutralino or tauri, may instead open a webview, which you can consider a minimal browser to render webpages. As a result of this, we can create desktop applications with the ease of web applications.
These web-based desktop applications make native application features accessible, a select few of which we'll cover:
- offline: Be able to run the desktop application without an internet connection.
- deeplink: Support what is formally called a "custom URL protocol". In other words, support links that directly connect to a specific page or action in your desktop application. See How a Python-only deeplink works.
Let's now build one of these desktop applications.
This tutorial will cover a Python-only desktop application. In particular, our application uses pywebview, which creates a minimal desktop application by launching both a web server and a webview. Create and navigate to a directory for your project.
mkdir ~/Desktop/desktopapp cd ~/Desktop/desktopapp
Next, create a virtual environment in this directory. For this tutorial, don't use Anaconda or a Python from Anaconda. If you do, you'll encounter a launch error later when we attempt to build the application, which we'll bring up again when that happens.
python -m venv env source env/bin/activate
Then, install the pywebview package.
pip install pywebview==4.2.2
For your hello world desktop application, create a webview that simply loads an external webpage, to start. Write this to a file called
import webview webview.create_window('Hello world', 'https://alvinwan.com/blog') webview.start()
So far, this application simply instantiates a webview. Optionally, check this against our reference implementation. Let's try this now. Run the following to launch your desktop application.
You should now see the following "hello world" desktop application.
You can close the application either by clicking on the window's "close" button or by killing the Python process using
CTRL+K in the terminal.
Next, we'll combine this with a local web server. For simplicity, we'll use the common web framework Flask; if you'd like to learn more, see How a web application works for how it works and basic usage. Install the Flask package.
pip install Flask==2.3.2
app.py, combine the boilerplate pywebview code with boilerplate Flask code.
from flask import Flask import webview app = Flask(__name__) @app.route("/") def hello_world(): return "<p>Hello, World!</p>" if __name__ == '__main__': webview.create_window('Flask example', app) webview.start()
Optionally, check this against our reference implementation. Run your desktop application again using the following.
You should now see a "Hello world!" webpage in your desktop application.
This is now a web-based desktop application that can be run offline, without an internet connection.
Next, note that this desktop application is not easily share-able; namely, the end user needs to have Python and the Python dependencies installed. That isn't a particularly easy-to-setup program, so let's now package this into a standalone program that doesn't rely on external binaries such as Python.
Start by installing another library called
py2app. This particular utility will convert our Python script into a standalone, runnable desktop application.
pip install py2app
Start by creating a new
setup.py file. This will contain some configuration options for how we build a share-able program.
from setuptools import setup setup( app=['app.py'], # your main application setup_requires=['flask', 'py2app', 'pywebview'], # add other dependencies here )
Let's now build our desktop application. Optionally, check with our reference implementation. To build the desktop application, use the
python setup.py py2app -A
-A flag behaves like
-e flag, meaning that the installation is linked to the original source code. No need to rebuild the application, after making source code changes[^3]. This is just for now, during development.
Next, open the application in the
This should now launch your desktop application just like before.
With that said, this is not yet a standalone desktop application. To accomplish this, let's rebuild the desktop application but without the
-A alias flag this time.
python setup.py py2app
This build process will take slightly longer, but once complete, you'll have another desktop application available at
dist/app.app. This time, the application is a standalone program that you can share with anyone.
Let's open this application one more time.
You should see the same preview as before, completing your very first hello world, standalone desktop application. You can now share this
app.app with anyone, and even without the right Python version or Python dependencies, they should be able to run your application out of the box.
To understand how our browser-based desktop application works, we'll peel back the layers of abstraction one by one until we end up at code that compiles to a native desktop application.
- pywebview is a light wrapper around several different "engines". Engines are simply implementations of webviews that you can use as a browser to render content. On Mac, compatible engines include GTK, Qt, and the OS-default webkit.
- The pywebview Qt engine is written using qtpy, which abstracts away two popular Python bindings for the Qt library — both PyQt and PySide. In particular, pywebview uses qtpy's PyQt backend. As a result, to customize pywebview's Qt engine, we'd refer to PyQt's documentation.
- PyQt in turn is a set of Python bindings for Qt, an open-source C++ library that provides tools for creating GUIs across multiple platforms.
In summary, pywebview uses a qtpy engine, which wraps PyQt, which provides Python bindings for Qt. The entire stack is furthermore very mature tooling with a decade or more of development.
- pywebview, our highest layer of abstraction, started in 2014
- qtpy, the "engine" providing our webview, started in 2015
- PyQt, the underlying Python bindings, started in 1998
- Qt, the core GUI implementation, started in 1995
Above, we focus on Qt due to its clear preference over the years, per a Google Trend chart comparing Qt, GTK, and Objective-C. We repeat the same check with Python bindings, finding PyQt the winner among pyobjc, pyqt, pygtk.
Let's now tackle more advanced customizations of pywebview, using our knowledge of this above stack.
Let's say we want to change the title bar for our application anytime it isn't in focus. Unfortunately, pywebview doesn't appear to provide this functionality, as pywebview only provides 9 window-based events. The closest one,
on_shown only triggers when the application launches for the first time.
However, the full list of possible events that the underlying PyQt supports is much more expansive, with 140 total events. Some notable but missing events include timezone changes, clipboard content changes, and most importantly for us — focus changes. In light of this, let's implement a Python-only desktop application using PyQt.
Create a new directory for this version of the project.
mkdir -p ~/Desktop/desktopapp/pyqt cd ~/Desktop/desktopapp/pyqt
In the same virtual environment as before, install additional Qt libraries. We install the GUI library PyQt, as well as a thin abstraction layer qtpy.
pip install qtpy==2.3.1 pyqt6==6.5.1
Then, create a minimal PyQt application in
app.py. In particular, we'll use the thin
from qtpy.QtWidgets import QApplication, QMainWindow from qtpy.QtCore import QEvent class MyApp(QApplication): def __init__(self, argv): super().__init__(argv) self.window = QMainWindow() self.window.show() def event(self, event: QEvent): if event.type() == QEvent.Type.ApplicationActivate: self.window.setWindowTitle("In focus") if event.type() == QEvent.Type.ApplicationDeactivate: self.window.setWindowTitle("Not in focus") return super().event(event) if __name__ == '__main__': app = MyApp() app.exec()
Optionally, check against our reference implementation. Run your desktop app as you would any Python script.
This should launch a small window with the title "In focus" when the window is focused and "Not in focus" when not.
We've now successfully implemented a desktop application that can respond to focus events, using PyQt directly.
However, as we discussed above, pywebview makes development easier by allowing us to build a GUI with web technologies. Ideally, then, we'd like to use pywebview instead of PyQt directly.
Ideally, we would register the same event handler with pywebview directly. Unfortunately, pywebview currently doesn't support extensions to its event handling ability. To hack around this, we can change pywebview's engine to PyQt and manually add an event handler to the underlying PyQt application.
Let's now integrate this back into pywebview, by manually modifying the underlying PyQt engine. Navigate back to your pywebview application.
Pywebview relies on the PyQt web engine, which isn't included in the PyQt libraries we've installed so far. Install the web engine separately.
pip install PyQt6-WebEngine==6.5.0
Insert some new imports for the new GUI library PyQt that we adopted in the last step.
import sys import webview from qtpy.QtWidgets import QApplication from qtpy.QtCore import QEvent import webview.platforms.qt # needed for us to define a custom applicationclass MyApp(QApplication): def event(self, event: QEvent): if event.type() == QEvent.Type.ApplicationActivate:
After your imports at the top of the file, define a custom application1 with a custom event handler.
from qtpy.QtWidgets import QApplication from qtpy.QtCore import QEvent import webview.platforms.qt # needed for us to define a custom applicationclass MyApp(QApplication): def event(self, event: QEvent): if event.type() == QEvent.Type.ApplicationActivate: window.set_title("In focus") if event.type() == QEvent.Type.ApplicationDeactivate: window.set_title("Not in focus") return super(QApplication, self).event(event)if __name__ == '__main__': app = MyApp(sys.argv) window = webview.create_window('Hello world', 'https://alvinwan.com/blog/')
Then, instantiate your application. This way, pywebview will use your custom application.
Additionally, modify your webview start call to change the GUI engine to Qt.
if event.type() == QEvent.Type.ApplicationDeactivate: window.set_title("Not in focus") return super(QApplication, self).event(event)if __name__ == '__main__': app = MyApp(sys.argv) window = webview.create_window('Hello world', 'https://alvinwan.com/blog/') webview.start(gui='qt')
Check against our reference implementation. Now, rerun your pywebview application.
You should now see the same effect: While the application window is focused, the title bar will read "In focus" and "Not in focus" otherwise.
You now have successfully enhanced your browser-based desktop application with some advanced customization.
More broadly, you've implemented a fairly important and useful extension to your browser-based desktop application: Namely, your application can now handle a wide variety of events. We've only explored one here — focus changes — but you can now handle any native system event via PyQt. You can learn more about different types of events from the official qt documentation on PyQt6's Events and Filters.
In this tutorial, we built a minimal Python-only desktop application; the application is share-able, works offline and can even support an expanded set of events, making the app a very native-like experience.
Despite all this, we never left the comfort of Python. In fact, we used familiar web frameworks and technologies, instead of learning proprietary, platform-specific GUI libraries and languages.
With this launching point for a Python-only desktop application, you should be well-prepared to build a native desktop experience for any task.
You can find the source code for this tutorial, as well as the finished code, on my "Guide to Hacking" repository.
Notice we specifically patched the application-level object. Individual webviews and webpages within the application also have event handlers but won't be notified of application-level events like file opens. ↩
Want more tips? Drop your email, and I'll keep you in the loop.