Open a URI in the user's chosen application from Zig, through the desktop portal on Linux, ShellExecuteExW on Windows and Launch Services on macOS.
  • Zig 98.3%
  • Nix 1.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 115152e497
All checks were successful
test / test (push) Successful in 7m4s
test / docs (push) Successful in 6m16s
Send the fake portal's Response to the caller alone
mdbus 624d9a3 can address a signal to one peer, which is how the real
portal sends Request::Response. The fake portal now does the same, so
the test for a portal that ignores handle_token no longer waits for the
client's match rule before answering.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXfXdgZgdHNKx7ZjYvdtci
2026-10-14 18:41:18 -05:00
.forgejo/workflows Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
docs/adr Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
LICENSES Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
src Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
tests Send the fake portal's Response to the caller alone 2026-10-14 18:41:18 -05:00
tools Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
.gitignore Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
build.zig Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
build.zig.zon Send the fake portal's Response to the caller alone 2026-10-14 18:41:18 -05:00
CONTRIBUTING.md Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
flake.lock Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
flake.nix Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
GLOSSARY.md Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
README.md Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00
REUSE.toml Open URIs in the user's chosen handler 2026-10-14 17:07:30 -05:00

zig-openuri

Open a URI in the user's chosen application from Zig: a web page in their browser, a mailto: link in their mail client, a local file in whatever opens files of that type. It does what clicking a link in a document does.

const openuri = @import("openuri");

switch (try openuri.open(io, gpa, environ, "https://ziglang.org/", .{})) {
    .opened => {},
    .cancelled => {}, // the user dismissed the choice of application
}

The API reference is generated from the doc comments and published at https://jeff.jcollie.page/zig-openuri/.

How each platform opens a URI

Platform Mechanism
Linux The org.freedesktop.portal.OpenURI desktop portal over the session bus, through mdbus. file: URIs go to the portal's OpenFile, which receives the file as an open descriptor.
Windows ShellExecuteExW with the open verb.
macOS LSOpenCFURLRef from Launch Services.

The portal is what makes this work from inside a Flatpak or Snap sandbox. It also lets the desktop ask the user which application to use, so on Linux the outcome can be that they cancelled. When there is no portal, open returns error.PortalUnavailable. It does not fall back to running xdg-open (why). A program that wants that fallback can catch the error and run xdg-open itself.

On Linux, open connects to the session bus for the length of the call. A program that already holds an mdbus connection can pass it to openWithConnection. Only the portal's own reply and signal are taken from the connection, and everything else stays queued for the caller.

Options

Option Platforms Meaning
schemes all The only schemes that may be opened, or null for any.
parent_window Linux The caller's X11 window ID, or xdg-foreign handle on Wayland, so that a dialog from the portal appears over it.
activation_token Linux Lets the opened application's window take focus. A program started from a launcher finds one in XDG_ACTIVATION_TOKEN.
ask Linux Always ask which application to use.
writable Linux Open a file: URI for an application that edits it.
timeout_ms Linux How long to wait for the portal (default two minutes, null for no limit).

Opening URIs you did not write

A URI from a chat message, a web page or text in a terminal is untrusted. Handing it to the operating system's opener can run a program. On Windows, C:\x\evil.exe is a valid argument to ShellExecuteExW, and a file: URI naming a program runs it on Windows and macOS. So zig-openuri:

  • always refuses single-letter schemes, which are Windows drive letters rather than schemes;
  • refuses URIs containing spaces, control characters, or invalid UTF-8;
  • takes an allowlist in Options.schemes, so that a program can say, for example, that it opens only http, https and mailto.

A program opening untrusted URIs should always pass an allowlist. The reasons are recorded in ADR 0001.

The openuri command

$ openuri [--ask] [--timeout=SECONDS] [--scheme=NAME]... URI

It exits 0 when the URI was opened, 1 when the choice of application was cancelled, and 2 on any error. On Linux it passes on the activation token from XDG_ACTIVATION_TOKEN.

Using it

Requires Zig 0.17.

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-openuri.git
const openuri = b.dependency("openuri", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("openuri", openuri.module("openuri"));

The module links what each platform needs: shell32 on Windows, and CoreFoundation and CoreServices on macOS. Linking for macOS therefore needs Apple's SDK, either by building on a Mac or by passing --sysroot. On Windows, some protocol handlers need COM, so a program should initialize COM on the calling thread itself if it opens arbitrary schemes.

Building

$ nix develop
$ zig build              # the openuri command, in zig-out/bin
$ zig build test         # unit tests, and the portal tests on Linux
$ zig build check        # compile for Linux, Windows and macOS on x86_64 and aarch64
$ zig build docs-serve   # read the API documentation at http://localhost:8000/

On Linux the portal tests start a private dbus-daemon with a fake portal on it. They skip themselves when dbus-daemon is not on PATH, which the devshell provides.

Where this lives

The canonical repository is on my Forgejo instance, and Tangled carries a mirror:

$ git clone https://git.jcollie.dev/jeff/zig-openuri.git
$ git clone https://tangled.org/jcollie.dev/zig-openuri

Issues and pull requests

Please report issues and send pull requests on the Tangled mirror: https://tangled.org/jcollie.dev/zig-openuri. See CONTRIBUTING.md for how to work on the code.

License

MIT. The project follows the REUSE specification.

References cited