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.

Introducing browser-based desktop applications

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:

Let's now build one of these desktop applications.

Hello world desktop application

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 app.py.

desktopapp/app.py

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.

python app.py

You should now see the following "hello world" desktop application.

icon

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.

Offline desktop application

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

Below, in app.py, combine the boilerplate pywebview code with boilerplate Flask code.

desktopapp/app.py

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.

python app.py

You should now see a "Hello world!" webpage in your desktop application.

icon

This is now a web-based desktop application that can be run offline, without an internet connection.

Share-able desktop application

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.

desktopapp/setup.py

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 py2app command.

python setup.py py2app -A

Above, the -A flag behaves like pip install's -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 dist/ folder.

open dist/app.app

This should now launch your desktop application just like before.

icon

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.

open dist/app.app

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.

How does a browser-based native app work?

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.

  1. 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.
  2. 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.
  3. 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.

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.

Advanced features via PyQt

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 qtpy wrapper.

desktopapp/pyqt/app.py

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.

python app.py

This should launch a small window with the title "In focus" when the window is focused and "Not in focus" when not.

icon

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.

Advanced Customization of Pywebview

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.

cd ~/Desktop/desktopapp/

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.

desktopapp/app.py

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 application
class 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.

desktopapp/app.py

from qtpy.QtWidgets import QApplication from qtpy.QtCore import QEvent import webview.platforms.qt # needed for us to define a custom application
class 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.

desktopapp/app.py

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.

python app.py

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.

icon

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.

Conclusion

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.


back to Guide to Hacking



  1. 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.