Something went wrong. Try again.
Open a URI in the user's chosen application from Zig: the desktop portal on Linux, ShellExecuteExW on Windows, Launch Services on macOS.
Something went wrong. Try again.
14 kB · 327 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Opening a URI through the `org.freedesktop.portal.OpenURI` desktop portal://! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html//!//! The portal answers a call at once with the object path of a Request, and//! the outcome arrives later as that Request's `Response` signal, once the//! user has picked a handler if they had to be asked. The path is predictable//! from the connection's unique name and the `handle_token` option, so the//! match rule for `Response` is installed before the call is made and a//! signal that beats the reply cannot be missed.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;const linux = std.os.linux;
const mdbus = @import("mdbus");pub const Connection = mdbus.Connection;const Message = Connection.Message;
const root = @import("root.zig");const Error = root.Error;const Options = root.Options;const Outcome = root.Outcome;const uri_check = @import("uri.zig");
const portal = Connection.Method{ .destination = "org.freedesktop.portal.Desktop", .path = "/org/freedesktop/portal/desktop", .interface = "org.freedesktop.portal.OpenURI", .member = undefined,};
pub fn open( io: Io, gpa: Allocator, environ: std.process.Environ, uri: []const u8, is_file: bool, options: Options,) Error!Outcome { // mdbus reports every way of failing to reach a session bus -- none named // in the environment, none answering where it should be -- as one error, // and with no bus there is no portal. var connection = Connection.connectSession(io, gpa, environ) catch return error.PortalUnavailable; defer connection.deinit(); return openWithConnection(&connection, uri, is_file, options);}
pub fn openWithConnection( connection: *Connection, uri: []const u8, is_file: bool, options: Options,) Error!Outcome { // The descriptor is opened before the bus is touched, so that a missing // file is reported as one rather than as whatever the portal makes of it. // O_PATH opens a directory as readily as a file, and needs no permission // to read it: the portal checks what the handler will be allowed to do. var fd: ?linux.fd_t = null; defer if (fd) |f| { _ = linux.close(f); }; if (is_file) { var path_buffer: [std.fs.max_path_bytes + 1]u8 = undefined; const path = try uri_check.filePath(uri, &path_buffer); fd = try openPath(path); }
const deadline = Deadline.start(connection.io, options.timeout_ms);
var token_bytes: [8]u8 = undefined; connection.io.random(&token_bytes); var token_buffer: [32]u8 = undefined; const token = std.fmt.bufPrint(&token_buffer, "openuri_{s}", .{std.fmt.bytesToHex(token_bytes, .lower)}) catch unreachable;
var path_buffer: [256]u8 = undefined; const request_path = requestPath(&path_buffer, connection.uniqueName(), token) catch return error.Unexpected;
var match_buffer: [512]u8 = undefined; const match = matchRule(&match_buffer, request_path) catch return error.Unexpected; connection.addMatch(match) catch |err| return mapTransportError(err); defer removeMatch(connection, match);
var body: Connection.Encoder = .init(connection.allocator); defer body.deinit(); encodeCall(&body, uri, fd != null, token, options) catch |err| return switch (err) { error.OutOfMemory => error.OutOfMemory, else => error.InvalidUri, };
var method = portal; method.member = if (fd != null) "OpenFile" else "OpenURI"; const serial = connection.sendMethod( method, if (fd != null) "sha{sv}" else "ssa{sv}", body.bytes(), if (fd) |f| &.{f} else &.{}, ) catch |err| return mapTransportError(err);
var reply = connection.waitForReply(serial, deadline.remainingMs(connection.io) orelse return error.Timeout) catch |err| return mapTransportError(err); defer reply.deinit(); if (reply.messageType() == .error_reply) return mapErrorName(reply.header.error_name orelse "");
var decoder = reply.bodyDecoder(); const handle = decoder.objectPath() catch return error.Unexpected;
// A portal older than `handle_token` (before 0.9) picks a path of its // own, and its Response needs a match rule of its own too. var handle_match_buffer: [512]u8 = undefined; var handle_match: ?[]const u8 = null; defer if (handle_match) |rule| removeMatch(connection, rule); if (!std.mem.eql(u8, handle, request_path)) { const rule = matchRule(&handle_match_buffer, handle) catch return error.Unexpected; connection.addMatch(rule) catch |err| return mapTransportError(err); handle_match = rule; }
var response = connection.waitForMessage(handle, isResponse, deadline.remainingMs(connection.io) orelse return error.Timeout) catch |err| return mapTransportError(err); defer response.deinit(); return outcomeOf(&response);}
/// When the portal has to have answered by.const Deadline = struct { at_ns: ?i96,
fn start(io: Io, timeout_ms: ?u32) Deadline { const ms = timeout_ms orelse return .{ .at_ns = null }; return .{ .at_ns = nowNs(io) + @as(i96, ms) * std.time.ns_per_ms }; }
/// The milliseconds left, or null once there are none. fn remainingMs(self: Deadline, io: Io) ?u32 { const at = self.at_ns orelse return std.math.maxInt(u32); const remaining = at - nowNs(io); if (remaining <= 0) return null; return @intCast(@min(std.math.maxInt(u32), @divTrunc(remaining + std.time.ns_per_ms - 1, std.time.ns_per_ms))); }
fn nowNs(io: Io) i96 { return Io.Clock.Timestamp.now(io, .awake).raw.nanoseconds; }};
fn openPath(path: [:0]const u8) Error!linux.fd_t { const rc = linux.open(path.ptr, .{ .PATH = true, .CLOEXEC = true }, 0); return switch (linux.errno(rc)) { .SUCCESS => @intCast(rc), .NOENT, .NOTDIR => error.FileNotFound, .ACCES, .PERM => error.AccessDenied, .NAMETOOLONG, .LOOP => error.InvalidUri, .NOMEM => error.OutOfMemory, else => error.Unexpected, };}
/// The path the portal gives the Request for `token`: the caller's unique/// name without its `:`, with each `.` made `_`, then the token.fn requestPath(buffer: []u8, unique_name: []const u8, token: []const u8) ![]const u8 { var writer: Io.Writer = .fixed(buffer); try writer.writeAll("/org/freedesktop/portal/desktop/request/"); const name = if (std.mem.startsWith(u8, unique_name, ":")) unique_name[1..] else unique_name; for (name) |c| try writer.writeByte(if (c == '.') '_' else c); try writer.print("/{s}", .{token}); return writer.buffered();}
fn matchRule(buffer: []u8, path: []const u8) ![]const u8 { return std.fmt.bufPrint( buffer, "type='signal',sender='org.freedesktop.portal.Desktop',interface='org.freedesktop.portal.Request',member='Response',path='{s}'", .{path}, );}
/// Best effort: a rule that cannot be removed only costs the bus a little/// routing, and the connection's own lifetime bounds it anyway.fn removeMatch(connection: *Connection, rule: []const u8) void { var body: Connection.Encoder = .init(connection.allocator); defer body.deinit(); body.string(rule) catch return; var reply = connection.call(.{ .destination = "org.freedesktop.DBus", .path = "/org/freedesktop/DBus", .interface = "org.freedesktop.DBus", .member = "RemoveMatch", }, "s", body.bytes(), &.{}, 1000) catch return; reply.deinit();}
/// The arguments of `OpenURI` (`ssa{sv}`) or, with a descriptor at index 0,/// of `OpenFile` (`sha{sv}`).fn encodeCall( body: *Connection.Encoder, uri: []const u8, is_file: bool, token: []const u8, options: Options,) !void { var parent_buffer: [256]u8 = undefined; const parent: []const u8 = if (options.parent_window) |window| switch (window) { .x11 => |xid| try std.fmt.bufPrint(&parent_buffer, "x11:{x}", .{xid}), .wayland => |handle| try std.fmt.bufPrint(&parent_buffer, "wayland:{s}", .{handle}), } else ""; try body.string(parent); if (is_file) try body.unixFd(0) else try body.string(uri);
const array = try body.beginArray('{'); try stringOption(body, "handle_token", token); if (options.activation_token) |activation| try stringOption(body, "activation_token", activation); if (options.ask) try boolOption(body, "ask", true); if (is_file and options.writable) try boolOption(body, "writable", true); try body.endArray(array);}
fn stringOption(body: *Connection.Encoder, key: []const u8, value: []const u8) !void { try body.dictEntryAlignment(); try body.string(key); try body.variantSignature("s"); try body.string(value);}
fn boolOption(body: *Connection.Encoder, key: []const u8, value: bool) !void { try body.dictEntryAlignment(); try body.string(key); try body.variantSignature("b"); try body.boolean(value);}
fn isResponse(handle: []const u8, message: *const Message) bool { if (message.messageType() != .signal) return false; return std.mem.eql(u8, message.header.path orelse return false, handle) and std.mem.eql(u8, message.header.interface orelse return false, "org.freedesktop.portal.Request") and std.mem.eql(u8, message.header.member orelse return false, "Response");}
/// `Response` carries a code -- 0 success, 1 cancelled by the user, 2 any/// other end -- and a vardict that OpenURI leaves empty.fn outcomeOf(response: *const Message) Error!Outcome { var decoder = response.bodyDecoder(); return switch (decoder.uint32() catch return error.Unexpected) { 0 => .opened, 1 => .cancelled, else => error.NoHandler, };}
fn mapErrorName(name: []const u8) Error { const unavailable = [_][]const u8{ "org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.NameHasNoOwner", "org.freedesktop.DBus.Error.UnknownMethod", "org.freedesktop.DBus.Error.UnknownInterface", "org.freedesktop.DBus.Error.UnknownObject", }; for (unavailable) |candidate| if (std.mem.eql(u8, name, candidate)) return error.PortalUnavailable; // Bus activation of the portal failed: it is installed but cannot start. if (std.mem.startsWith(u8, name, "org.freedesktop.DBus.Error.Spawn.")) return error.PortalUnavailable; if (std.mem.eql(u8, name, "org.freedesktop.portal.Error.NotAllowed")) return error.AccessDenied; if (std.mem.eql(u8, name, "org.freedesktop.portal.Error.InvalidArgument")) return error.InvalidUri; if (std.mem.eql(u8, name, "org.freedesktop.portal.Error.NotFound")) return error.FileNotFound; if (std.mem.eql(u8, name, "org.freedesktop.DBus.Error.NoReply") or std.mem.eql(u8, name, "org.freedesktop.DBus.Error.Timeout")) return error.Timeout; return error.Unexpected;}
fn mapTransportError(err: anyerror) Error { return switch (err) { error.OutOfMemory => error.OutOfMemory, error.Timeout => error.Timeout, error.UnixFdUnsupported => error.PortalUnavailable, else => error.Unexpected, };}
const testing = std.testing;
test "the request path is predicted from the unique name" { var buffer: [128]u8 = undefined; try testing.expectEqualStrings( "/org/freedesktop/portal/desktop/request/1_42/openuri_00ff", try requestPath(&buffer, ":1.42", "openuri_00ff"), );}
test "OpenURI arguments carry the options asked for" { var body: Connection.Encoder = .init(testing.allocator); defer body.deinit(); try encodeCall(&body, "https://example.com/", false, "openuri_t", .{ .parent_window = .{ .x11 = 0x3a00007 }, .activation_token = "tok", .ask = true, .writable = true, });
var decoder: mdbus.wire.Decoder = .{ .data = body.bytes(), .endian = .little, .base_offset = 0 }; try testing.expectEqualStrings("x11:3a00007", try decoder.string()); try testing.expectEqualStrings("https://example.com/", try decoder.string()); const end = try decoder.beginArray('{'); var keys: [3][]const u8 = undefined; var count: usize = 0; while (!try decoder.arrayFinished(end)) : (count += 1) { try decoder.structAlignment(); keys[count] = try decoder.string(); const signature = try decoder.variantSignature(); if (std.mem.eql(u8, signature, "s")) _ = try decoder.string() else _ = try decoder.boolean(); } try decoder.endArray(end); // `writable` is meaningless to OpenURI and is not sent. try testing.expectEqual(3, count); try testing.expectEqualStrings("handle_token", keys[0]); try testing.expectEqualStrings("activation_token", keys[1]); try testing.expectEqualStrings("ask", keys[2]);}
test "portal errors map onto the library's" { try testing.expectEqual(error.PortalUnavailable, mapErrorName("org.freedesktop.DBus.Error.ServiceUnknown")); try testing.expectEqual(error.PortalUnavailable, mapErrorName("org.freedesktop.DBus.Error.Spawn.ChildExited")); try testing.expectEqual(error.AccessDenied, mapErrorName("org.freedesktop.portal.Error.NotAllowed")); try testing.expectEqual(error.Unexpected, mapErrorName("org.example.Whatever"));}