HTTP server to display desktop notifications

These past few weeks, I've been working on remote server to create a deep learning model. I often find myself waiting while downloading large datasets or training the model. Rather than periodically checking the server to see if my script is done, I figured there must be a way to trigger notifications on my desktop as soon as the task running on the server finishes.

If the script were running on my desktop directly, triggering a notification would be as easy as calling the appropriate python / bash / PowerShell function. But my scripts run on a remote server that I can only access through SSH.

SSH has a feature called remote port forwarding that we can use here. For instance, if we use remote forwarding with port 8000, then a connection on localhost:8000 on the server will be forwarded to a connection to port 8000 on my client desktop. And the connection is secured thanks to SSH.

So, if the server can connect to a port on my local desktop, why not create a simple web API to trigger notifications?

Crafting an HTTP server in python is dead easy using the FastAPI library. To display notifications on the desktop, we need a platform-specific implementation. On windows, we can use the python library win10toast. On Windows' WSL, we can simply call python.exe or powershell.exe to forward the notification to the Windows side. For MacOS and Linux... you are welcome to contribute a solution to the GitHub repo ^^

Code on GitHub.

The notification server

Python code (FastAPI)

The notification server has a simple interface composed of a single endpoint: /json . To create a new notification, send a POST request with json-formatted data. For instance, we can trigger a notification manually in bash using the curl utility as follows:

curl \
  --request POST \
  --header "Content-Type: application/json" \
  --data '{"title":"Hello!","description":"Notification sent with cURL."}' \
  http://127.0.0.1:8000/json

Thanks to FastAPI, the server code is pretty terse:

notification_server.py
import logging, cgi
from fastapi import FastAPI
from pydantic import BaseModel
from notification import win10toast as notifier

app = FastAPI()

class NotificationData(BaseModel):
    title: str
    description: str = ""
    data: str = ""

def new_notification(title, description="", data=""):
    maybe = lambda x: "\n" + x if x else ""
    logging.info(f'Notification: {title}{maybe(description)}{maybe(data)}')
    notifier.notify(title, description)

@app.on_event("startup")
def startup_event():
    notifier.start()

@app.on_event("shutdown")
def shutdown_event():
    notifier.notify('Notification Server Stopped')
    notifier.stop()

@app.post("/json")
async def json_endpoint(*, n: NotificationData):
    new_notification(n.title, n.description, n.data)
    return {"status": "ok"}

This snippet:

@app.post("/json")
async def json_endpoint(*, n: NotificationData):
    #...

defines the POST endpoint /json. The NotificationData argument will be automatically filled by FastAPI from the request's json-formatted body.

Actually, it's pretty straightforward to define a second endpoint to accept data sent from an HTML form, rather than json-formatted data:

notification_server.py (continued)
from fastapi import Form
from fastapi.responses import HTMLResponse

@app.post("/formdata", response_class=HTMLResponse)
async def formdata_endpoint(*, 
        title: str = Form(...), 
        description: str = Form(default=''), 
        data: str = Form(default=''),
    ): 
    new_notification(title, description, data)
    return "<html><body><h1>Notification sent!</h1></body></html>"

Once again, the arguments will be filled automatically by FastAPI from the request's body.

Running the server

Because our server uses the operating system to display a desktop notification, the simplest is to run the server directly using uvicorn.

uvicorn

Uvicorn is an ASGI server able to run FastApi apps at production scale. To use uvicorn, first install it using pip and run the following command in the project directory:

pip install uvicorn
uvicorn notification_server:app

That's it.

docker

Our notification server needs to talk directly to the operating system to display desktop notifications. Running the server in a container isolated from the host OS won't do us any good. But it's very easy to run a FastAPI app in a docker container so I'll quickly show how to do it for future reference.

You can start a FastAPI app in a container using FastAPI's docker image straight from docker Hub by running this command in the project directory:

docker run \
--rm \
--name notification-server \
-p 8000:80 \
-v "$(pwd):/app" \
tiangolo/uvicorn-gunicorn-fastapi:python3.7

Here are the details of what this command does:

docker run 

--rm 
# tells docker to delete this container as soon as it's stopped

--name notification-server 
# name of this container

-p 8889:80 
# forward the local port 8889 to the container's port 80

-v "$(pwd):/app" 
# mount the current directory $(pwd) inside the container as /app

tiangolo/uvicorn-gunicorn-fastapi:python3.7
# FastAPI's base image that we are running

Note: if you want to run the server as a daemon, add the -d flag.

To access this container from other containers, you can create (link to doc) a dedicated network named "notification-network" and connect (linkt to doc) the container "notification-server" that we just created as follows:

docker network create --driver bridge notification-network
docker network connect notification-network notification-server

For containers connected to the same network, the container can then be addressed by its name. For instance: http://notification-server/json.

Does it work?

We can check if the server works by sending POST requests. We can use the terminal with the curl command or use a web-browser.

For curl, here is the command and expected result:

 $ curl \
  --request POST \
  --header "Content-Type: application/json" \
  --data '{"title":"Hello!","description":"Notification sent with cURL."}' \
  http://127.0.0.1:8000/json

 {'status': 'ok'}

In python, we can use the requests library to send a post request with json body:

pip install requests
client.py
#!/usr/bin/env python3
import requests
import logging

host = '127.0.0.1:8000'


def send_notification(title, description="", data=""):
    return requests.post(f'http://{host}/json', json={
        "title": title,
        "description": description,
        "data": data
    })


if __name__ == '__main__':
    send_notification('Hello!', 'Notification sent from the python client.')

Desktop notifications

On windows

To display toast notification on Windows, we can use the python library win10toast. Install with pip:

pip install win10toast

And use as follows:

from win10toast import ToastNotifier

toaster = ToastNotifier()
toaster.show_toast(title, descr, duration=10)

A little care is needed to properly queue the notifications. You can see the full implementation on github.

The notifications will display as toast but do not remain in Windows' notification center. For this reason, I prefer to use the PowerShell module BurntToast. See the next section to learn about it.

On WSL

If python runs in WSL (e.g. Ubuntu on Windows), it cannot access the win10toast library directly. To solve the issue, we can use Windows' python interpreter through powershell.exe. Here's the code:

# use this if you run python in Windows' WSL
def windows_notification(title, descr):
  from win10toast import ToastNotifier
  toaster = ToastNotifier()
  toaster.show_toast(title, descr, duration=10, threaded=False)

def escape(string):
  return string.translate(str.maketrans({
    "'": r"\'",
    "`": r"``",
    '"': r'\`"',
  }))

def desktop_notification(title, descr):
  import inspect
  import subprocess
  source_code = inspect.getsource(windows_notification)
  lines = source_code.splitlines()
  inner_lines = [line.strip() for line in lines[1:]]
  one_liner = ';'.join(inner_lines)
  one_liner = one_liner.replace('title', f"'{escape(title)}'")
  one_liner = one_liner.replace('descr', f"'{escape(descr)}'")
  subprocess.run(['powershell.exe', f'python -c "{one_liner}"'])

Another option, that I like more is to use powershell.exe directly together with the BurntToast module. The python code to call it is:

def notify(title, description=""):
    powershell_special_chars = str.maketrans({'"': '`"', '`': '``'})
    title = title.translate(powershell_special_chars)
    command = f'New-BurntToastNotification -Text "{title}"'

    if description:
        description = description.translate(powershell_special_chars)
        command = f'New-BurntToastNotification -Text ("{title}", "{description}")'

    subprocess.run([
        'powershell.exe',
        command,
    ])  

It's already implemented in the GitHub repo, in the notification/ package.

GitHub

Feel free to fork the code on GitHub.

If you know a way to improve the system or just wanna say something, leave a comment!