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:
| Mechanism | Pros | Cons |
|---|---|---|
| D-Bus | Standard on most desktops | Heavy dependency, complex API |
| Unix named pipes (FIFO) | Simple, POSIX | No connection semantics; manual EOF/reopen handling |
| Unix domain socket | Connection-oriented, per-client isolation via accept(), zero-copy on same host | Requires 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.sockIf $XDG_RUNTIME_DIR isn't set, we fall back to:
/tmp/milthm_daemon_$UID.sockThe UID suffix avoids collisions on multi-user systems.
Implementation
Socket server (game side)
The game creates the socket at startup:
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.
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:
[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/:
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
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| HTry it
Deep link support will be shipped in Milthm 5.2. Once available, you can test it with:
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
