from Guide to Hacking on Jun 18, 2023

How a Python-only deeplink works

Setting up deeplinks in a desktop application is generally no easy task, especially for these newer classes of browser-based apps. Some popular frameworks such as Electron have a commonly-used extension for deeplinks, but others may not. How do they work? And how can you implement one yourself, for Python?

Deeplinks, also called custom URL protocols, allow anyone to link to specific locations within an application. For example, by clicking on a "Launch" button in the browser, you can launch a specific workspace in your Slack desktop app or a specific project in your Figma desktop app.

In this post, we'll cover how deeplinks work. To simplify implementation, we'll use Python instead of Swift and Xcode to implement deep linking. For our demos in this post, we'll specifically implement this on a Mac, but the concepts well discuss generalize to other operating systems and languages as well.

Start with the usual project initialization steps. Create and navigate to a directory for your project.

mkdir -p ~/Desktop/deeplink/webview
cd ~/Desktop/deeplink

Next, create a virtual environment in this directory.

python3 -m venv env
source env/bin/activate

Navigate to the webview subdirectory, where we'll start our pywebview-based application.

cd ~/Desktop/deeplink/webview

Here, we'll initialize a "Hello World" desktop application, just like we did in How a Python-only desktop app works. Install a few prerequisite libraries.

pip install pywebview py2app

Create an app.py.

deeplink/webview/app.py

import webview

if __name__ == '__main__':
    window = webview.create_window('Hello world', 'https://alvinwan.com/blog')
    webview.start()

Let's now specify the deeplink for our application. On a Mac, deeplinks require only three properties:

  1. The custom URL protocol (a.k.a., deeplink prefix) for your application must be specified in your application's Info.plist.
  2. The application must be located in the /Applications folder on your computer1.
  3. The application must have been opened at least once, normally, without deeplinks.

Let's handle the first requirement, by specifying a custom URL protocol in py2app's build configuration. Modify your setup.py script to match the following.

deeplink/webview/setup.py

from setuptools import setup

OPTIONS = {
    'plist': {
        'CFBundleURLTypes': [  # for custom URL schemes (i.e., deeplinks)
            {
                'CFBundleURLName': 'MyApplication',  # arbitrary
                'CFBundleURLSchemes': ['myapp'],  # deeplink will be myapp://
            },
        ],
    },
}

setup(
    app=['app.py'],  # your main application
    options={'py2app': OPTIONS},
    setup_requires=['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 module. We'll add back the -A flag for now, so that building runs much faster.

python setup.py py2app -A

Next, move the built application to the ~/Applications folder.

mv ./dist/app.app /Applications

Open the application once first, without the deeplink.

open /Applications/app.app

Now, you can test your deeplink by opening myapp://whatever_you_like_here. You can open this in your browser, or by typing the following in your terminal on Mac.

open myapp://whatever_you_like_here

Either way, your desktop application should now pop open, with the following preview.

icon

Right now, although the deeplink does open our application, our application doesn't have access to the contents of our deeplink. Let's now modify our desktop application so that it can read and use the deeplink itself.

Start by modifying the configuration in setup.py, so that deeplinks are passed to the application as though they were arguments.

deeplink/webview/setup.py

from setuptools import setup
OPTIONS = { 'argv_emulation': True, # pass sys.argv to the app
'plist': { 'CFBundleURLTypes': [ # for custom URL schemes (i.e., deeplinks) { 'CFBundleURLName': 'MyApplication', # arbitrary 'CFBundleURLSchemes': ['myapp'], # deeplink will be myapp://

Then, from your app.py, display the deeplink in the window title, by reading and displaying file arguments.

deeplink/webview/app.py

import webview
import sys

if __name__ == '__main__':
    window = webview.create_window(str(sys.argv[1:]), 'https://alvinwan.com/blog')
    webview.start()

Optionally check with our reference implementation. Repeat the same process to build your application.

python setup.py py2app -A
rm -rf /Applications/app.app
mv dist/app.app /Applications

Open the deeplink.

open myapp://whatever_you_want_here

This should open in your desktop application, with the deeplink now displayed in your window title.

icon

You can now use this deeplink to open a specific page or perform a particular action. With that said, since the deeplink is passed as command line arguments, there's no clear way to access a deeplink that's opened after the application launches. To support this, we'll need a slightly deeper understanding of how deep links work.

Deeplinks are just one of several possible events3 sent to the application. The particular event we care about is called a "File Open" event, because at its core, this event is what enables deeplinking.

In particular, the "File Open" event can be sent to our application for one of two reasons — to open a resource with a custom protocol or to open a file with a custom file extension:

To deeplink, you would register your application with your operating system to handle a custom protocol like myapp:// or a custom file extension like .myapp. Then, once such a resource is accessed, your application will receive a "File Open" event with either a URI or a path.

Above, these details are abstracted away from us by py2app. However, to handle deeplinks even after the application has launched, we now need to handle this incoming "File Open" event.

As we discussed in How a Python-only desktop app works, the library pywebview that we're using is powered by several engines under the hood. One of those engines is pyqt, which we'll use directly, for a simple starting demo. pyqt supports event handlers by default.

Create and navigate to a subdirectory for this version of your project.

mkdir -p ~/Desktop/deeplink/qt
cd ~/Desktop/deeplink/qt

Install the GUI library PyQt.

pip install qtpy==2.3.1 pyqt6==6.5.1

Copy your last setup.py file, except drop the argv_emulation flag. We will now rely on pyqt instead of our packaging tool py2app to handle system events. This way, our pyqt event handler will receive the "File open" event.

deeplink/qt/setup.py

from setuptools import setup

OPTIONS = {  # notice argv_emulation has been removed
    'plist': {
        'CFBundleURLTypes': [  # for custom URL schemes (i.e., deeplinks)
            {
                'CFBundleURLName': 'MyApplication',  # arbitrary
                'CFBundleURLSchemes': ['myapp'],  # deeplink will be myapp://
            },
        ],
    },
}

setup(
    app=['app.py'],
    options={'py2app': OPTIONS},
    setup_requires=['py2app', 'qtpy', 'pyqt6'],  # add pyqt to dependencies
)

Then, create a new file app.py for our initial test. This is a brand new desktop application written in raw pyqt.

deeplink/qt/app.py

from qtpy.QtWidgets import QApplication
from qtpy.QtCore import QEvent


class MyApp(QApplication): # subclass application
    def event(self, event: QEvent): # custom event handler
        if event.type() == QEvent.Type.FileOpen: # handle "File Open" event
            print(event.url())  # log the deeplink to console
        return super().event(event)


if __name__ == "__main__":
    app = MyApp([])
    app.exec()

Check with our reference implementation. Run through the familiar steps to build and move the application.

python setup.py py2app -A
rm -rf /Applications/app.app
mv dist/app.app /Applications

This time, open the application's packaged binary directly, so that we can see the outputs printed to console.

/Applications/app.app/Contents/MacOS/app

Note that our application doesn't have a window; it's just a minimal GUI-less "application" that is listening for deeplink opens. Then, open a second terminal window. In this new session, access the deeplink.

open myapp://whatever_you_want_here

In your first terminal window, you should see the following printed.

output

PyQt5.QtCore.QUrl('myapp://whatever_you_want_here')

Success! You can now handle the deeplink in the above event handler to perform whatever action you wish. However, this implementation uses pyqt; we'd still like to retain the benefit of a browser-based desktop application. To achieve that, we'll now add this event handler to pywebview.

We'll now implement the same manual event handling from How a Python-only desktop app works. This will enable us to override PyQt's default event handling while still benefiting from pywebview.

Navigate back to your pywebview-based project in webview/.

cd ~/Desktop/deeplink/webview

Install the PyQt web engine for pywebview to use.

pip install PyQt6-WebEngine==6.5.0

Finally, update your setup.py script to include the new GUI packages.

deeplink/webview/setup.py

} setup( app=['app.py'], options={'py2app': OPTIONS},
setup_requires=['py2app', 'pywebview', 'qtpy', 'pyqt6', 'PyQt6-WebEngine'], # add pyqt to dependencies
)

In app.py, update your imports to include PyQt and friends.

deeplink/webview/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.FileOpen:

Still in app.py, Create a new application that contains a custom event handler for the "File Open" event.

deeplink/webview/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.FileOpen: print(event.url()) return super(QApplication, self).event(event)
if __name__ == '__main__': app = MyApp(sys.argv) webview.create_window('Hello world', 'https://alvinwan.com/blog')

Instantiate your custom application, and change the GUI engine to Qt.

deeplink/webview/app.py

if event.type() == QEvent.Type.FileOpen: print(event.url()) return super(QApplication, self).event(event)
if __name__ == '__main__': app = MyApp(sys.argv) webview.create_window('Hello world', 'https://alvinwan.com/blog') webview.start(gui='qt')

Check against our reference implementation. We'll now test this in the same way we did above: Build, move, and open the application.

python setup.py py2app -A
rm -rf /Applications/app.app
mv dist/app.app /Applications
/Applications/app.app/Content/MacOS/app

In a second terminal window, open the deeplink.

open myapp://whatever_you_want_here

In your first terminal window, you should see the following printed.

output

PyQt6.QtCore.QUrl('myapp://whatever_you_want_here')

Success again! You now have proper deeplink support for your browser-based desktop application; your application can now handle deeplinks both at launch and after launch.

Conclusion

In this post, we discussed how deeplinking works and implemented a minimal, Python-only implementation: We started with a simple demo that only supported deeplinks to launch the application, then built up to a usable form of deeplinks that work even after the application has launched. Your takeaways are two-fold:

  1. First, there are three requirements for a deeplink to take effect.

    1. The custom URL protocol (a.k.a., deeplink prefix) for your application must be specified in your application's Info.plist.
    2. The application must be located in the /Applications folder on your computer.
    3. The application must have been opened at least once, normally, without deeplinks.
  2. Deeplinks work by registering your application as an event handler for a custom protocol. This terminology is useful to know, when looking for the right documentation and tooling.

That's a wrap on Python-only desktop applications with deeplinking support. 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. Technically, the application doesn't need to be in the /Applications folder. According to SO, the application simply needs to be moved. 

  2. There's a small caveat here. If you modify the configuration for the desktop application, then you'll need to rebuild the application. For example, if you want to rename the deeplink, you'd need to rebuild. 

  3. An event is more formally an interprocess message. In this case, another application sends this message, and the operating system determines that your application should receive this message. You can learn more from official Apple documentation for Apple events