Skip to content

Making milthm:// Protocol Work on Linux: An Implementation of Deep Link Handler

Author: Canadew (chun-awa)

Abstract

This post describes our implementation of milthm:// protocol handling on Linux, covering freedesktop.org desktop integration, inter-process communication via Unix domain sockets, and the associated lifecycle management.

Introduction

The Milthm Deep Link spec defines a milthm:// URI scheme for triggering in-game actions from browsers and external apps. Windows handles this through the registry, macOS through Info.plist and Launch Services. Linux doesn't have an equivalent, so milthm:// links just don't work there. We're fixing that in Milthm 5.2.

The problem

Linux has no centralized URI scheme registry. The closest equivalent is the freedesktop.org convention: the Desktop Entry Specification and xdg-mime(1) together define a way to associate x-scheme-handler/* MIME types with .desktop files. Mainstream desktop environments (e.g. GNOME and KDE Plasma) all respect this convention.

That handles registration. The harder part is IPC. When a user opens a milthm:// link, the desktop environment launches a handler binary with the URL as a command-line argument. That handler needs to forward the URL to the game process that is already running. On Windows we do this with named pipes. On macOS, NSDistributedNotificationCenter. Neither exists on Linux.

Choosing an IPC mechanism

We evaluated three candidates:

MechanismProsCons
D-BusStandard on most desktopsHeavy dependency, complex API
Unix named pipes (FIFO)Simple, POSIXNo connection semantics; manual EOF/reopen handling
Unix domain socketConnection-oriented, per-client isolation via accept(), zero-copy on same hostRequires socket file lifecycle management

We went with Unix domain sockets (AF_UNIX, SOCK_STREAM). The main reason is that accept() gives you clean per-client isolation, and shutdown() can unblock the listener thread for graceful exit. FIFOs can't do either. Domain sockets also map nicely to our existing IPCClient/IPCServer abstractions, which were originally built around Windows named pipes.

Where to put the socket

Per the XDG Base Directory Specification, $XDG_RUNTIME_DIR (typically /run/user/$UID/) is where user-specific runtime files like sockets and pipes should go.

$XDG_RUNTIME_DIR/milthm_daemon.sock

If $XDG_RUNTIME_DIR isn't set, we fall back to:

/tmp/milthm_daemon_$UID.sock

The UID suffix avoids collisions on multi-user systems.

Implementation

Socket server (game side)

The game creates the socket at startup:

cpp
listenFd_ = ::socket(AF_UNIX, SOCK_STREAM, 0);

// Remove stale socket file if it exists
::unlink(socketPath_.c_str());

struct sockaddr_un addr{};
addr.sun_family = AF_UNIX;
std::strncpy(addr.sun_path, socketPath_.c_str(), sizeof(addr.sun_path) - 1);

::bind(listenFd_, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
::listen(listenFd_, 5);

A listener thread loops on accept(). When a client connects, it hands a UnixSocketServerConnection to the registered callback — same interface as the Windows named pipe server, just different syscalls underneath.

Shutdown needs more care than on Windows. You call shutdown(listenFd_, SHUT_RDWR) to unblock accept() so the listener thread can exit cleanly, then unlink() the socket file. Skip the unlink and the next launch fails to bind because the file's still sitting there.

Socket client (launcher side)

The launcher's job is straightforward: connect, send, disconnect.

cpp
fd_ = ::socket(AF_UNIX, SOCK_STREAM, 0);

struct sockaddr_un addr{};
addr.sun_family = AF_UNIX;
std::strncpy(addr.sun_path, socketPath_.c_str(), sizeof(addr.sun_path) - 1);

::connect(fd_, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));

If connect() fails with ENOENT or ECONNREFUSED, the game isn't running. Same fallback as on Windows: search parent directories for the game binary and launch it with the deep link payload as a command-line argument.

Registering the protocol handler

The protocol handler is registered by installing a .desktop file:

ini
[Desktop Entry]
Type=Application
Name=Milize Launcher
Exec=/path/to/SteamLibrary/steamapps/common/Milthm/milthm_Data/Plugins/MilizeLauncher ipc scheme %u
NoDisplay=true
MimeType=x-scheme-handler/milthm;

%u is a freedesktop field code replaced by the desktop environment with the opened URL. NoDisplay=true hides the entry from application menus.

After writing the file to $XDG_DATA_HOME/applications/:

bash
xdg-mime default milize-launcher.desktop x-scheme-handler/milthm
update-desktop-database ~/.local/share/applications/

xdg-open milthm://index now invokes the launcher.

What happens end to end

mermaid
flowchart TD
    A["User clicks milthm://index"]
    B["xdg-open dispatches to MilizeLauncher"]
    C["MilizeLauncher connects to Unix socket"]
    D["Success: sends message, exits"]
    E["Game is not running"]
    F["Search parent directories for 'milthm' binary"]
    G["Found: fork/exec with --mil-deep-link"]
    H["Not found: log error, exit"]

    A --> B
    B --> C
    C -->|Success| D
    C -->|ENOENT / ECONNREFUSED| E
    E --> F
    F -->|Found| G
    F -->|Not found| H

Try it

Deep link support will be shipped in Milthm 5.2. Once available, you can test it with:

bash
xdg-open "milthm://index"

The socket shows up at $XDG_RUNTIME_DIR/milthm_daemon.sock while the game runs and gets cleaned up on exit.

A short demo

Demo