gtk: Open urls with openuri portal

pull/11754/head
Timon de Groot 2026-03-22 18:17:09 +01:00
parent efb3523591
commit 855a6b01fc
3 changed files with 579 additions and 9 deletions

View File

@ -41,6 +41,7 @@ const Tab = @import("tab.zig").Tab;
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog;
const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts;
const OpenURI = @import("../portal.zig").OpenURI;
const log = std.log.scoped(.gtk_ghostty_application);
@ -215,6 +216,8 @@ pub const Application = extern struct {
/// not exist in Ghostty's environment variable.
saved_language: ?[:0]const u8 = null,
open_uri: OpenURI = undefined,
pub var offset: c_int = 0;
};
@ -398,6 +401,7 @@ pub const Application = extern struct {
.custom_css_providers = .empty,
.global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}),
.saved_language = saved_language,
.open_uri = .init(rt_app),
};
// Signals
@ -433,6 +437,7 @@ pub const Application = extern struct {
const priv: *Private = self.private();
priv.config.unref();
priv.winproto.deinit();
priv.open_uri.deinit();
priv.global_shortcuts.unref();
if (priv.saved_language) |language| alloc.free(language);
if (gdk.Display.getDefault()) |display| {
@ -806,6 +811,11 @@ pub const Application = extern struct {
return &self.private().winproto;
}
/// Returns the open URI portal implementation.
pub fn open_uri(self: *Self) *OpenURI {
return &self.private().open_uri;
}
/// This will get called when there are no more open surfaces.
fn startQuitTimer(self: *Self) void {
const priv = self.private();
@ -1289,6 +1299,11 @@ pub const Application = extern struct {
// Set ourselves as the default application.
gio.Application.setDefault(self.as(gio.Application));
// The D-Bus connection is only valid after GApplication startup.
self.open_uri().setDbusConnection(
self.as(gio.Application).getDbusConnection(),
);
// Setup our event loop
self.startupXev();
@ -1873,6 +1888,17 @@ pub const Application = extern struct {
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
};
pub fn openUrlFallback(self: *Application, kind: apprt.action.OpenUrl.Kind, url: []const u8) void {
// Fallback to the minimal cross-platform way of opening a URL.
// This is always a safe fallback and enables for example Windows
// to open URLs (GTK on Windows via WSL is a thing).
internal_os.open(
self.allocator(),
kind,
url,
) catch |err| log.warn("unable to open url: {}", .{err});
}
};
/// All apprt action handlers
@ -2317,16 +2343,20 @@ const Action = struct {
self: *Application,
value: apprt.action.OpenUrl,
) void {
// TODO: use https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html
if (std.mem.startsWith(u8, value.url, "/")) {
self.openUrlFallback(value.kind, value.url);
return;
}
if (std.mem.startsWith(u8, value.url, "file://")) {
self.openUrlFallback(value.kind, value.url);
return;
}
// Fallback to the minimal cross-platform way of opening a URL.
// This is always a safe fallback and enables for example Windows
// to open URLs (GTK on Windows via WSL is a thing).
internal_os.open(
self.allocator(),
value.kind,
value.url,
) catch |err| log.warn("unable to open url: {}", .{err});
self.open_uri().start(value) catch |err| {
log.err("unable to open uri err={}", .{err});
self.openUrlFallback(value.kind, value.url);
return;
};
}
pub fn pwd(

75
src/apprt/gtk/portal.zig Normal file
View File

@ -0,0 +1,75 @@
const std = @import("std");
const gio = @import("gio");
const Allocator = std.mem.Allocator;
pub const OpenURI = @import("portal/OpenURI.zig");
/// Generate a token suitable for use in requests to the XDG Desktop Portal
pub fn generateToken() usize {
return std.crypto.random.int(usize);
}
/// Get the XDG portal request path for the current Ghostty instance.
///
/// If this sounds like nonsense, see `request` for an explanation as to
/// why we need to do this.
pub fn getRequestPath(alloc: Allocator, dbus: *gio.DBusConnection, token: usize) (Allocator.Error || std.fmt.BufPrintError || error{NoDBusUniqueName})![:0]const u8 {
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html
// for the syntax XDG portals expect.
// Get the unique name from D-Bus and strip the leading `:`
const unique_name = try alloc.dupe(u8, std.mem.span(
dbus.getUniqueName() orelse {
return error.NoDBusUniqueName;
},
)[1..]);
defer alloc.free(unique_name);
return buildRequestPath(alloc, unique_name, token);
}
/// Build the XDG portal request path for given unique name and token.
fn buildRequestPath(alloc: Allocator, unique_name: []u8, token: usize) (Allocator.Error || std.fmt.BufPrintError)![:0]const u8 {
var token_buf: [16]u8 = undefined;
const token_string = try std.fmt.bufPrint(&token_buf, "{x:0>16}", .{token});
// Sanitize the unique name by replacing every `.` with `_`. In effect, this
// will turn a unique name like `1.192` into `1_192`.
std.mem.replaceScalar(u8, unique_name, '.', '_');
const object_path = try std.mem.joinZ(
alloc,
"/",
&.{
"/org/freedesktop/portal/desktop/request",
unique_name,
token_string,
},
);
return object_path;
}
/// Try and parse the token out of a request path.
pub fn parseRequestPath(request_path: []const u8) ?usize {
const index = std.mem.lastIndexOfScalar(u8, request_path, '/') orelse return null;
const token = request_path[index + 1 ..];
return std.fmt.parseUnsigned(usize, token, 16) catch return null;
}
test "buildRequestPath" {
const testing = std.testing;
const path = try buildRequestPath(testing.allocator, "1_42", 0x75af01a79c6fea34);
try testing.expectEqualStrings("/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fea34", path);
}
test "parseRequestPath" {
const testing = std.testing;
try testing.expectEqual(0x75af01a79c6fea34, parseRequestPath("/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fea34").?);
try testing.expectEqual(null, parseRequestPath("/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fGa34"));
try testing.expectEqual(null, parseRequestPath("75af01a79c6fea34"));
}

View File

@ -0,0 +1,465 @@
//! Use DBus to call the XDG Desktop Portal to open an URI.
//! See: https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html#org-freedesktop-portal-openuri-openuri
const OpenURI = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const App = @import("../App.zig");
const portal = @import("../portal.zig");
const apprt = @import("../../../apprt.zig");
const log = std.log.scoped(.openuri);
/// The GTK app that we "belong" to.
app: *App,
/// Connection to the D-Bus session bus that we'll use for all of our messaging.
dbus: ?*gio.DBusConnection = null,
/// Mutex to protect modification of the entries map or the cleanup timer.
mutex: std.Thread.Mutex = .{},
/// Map to store data about any in-flight calls to the portal.
entries: std.AutoArrayHashMapUnmanaged(usize, *Entry) = .empty,
/// Used to manage a timer to clean up any orphan entries in the map.
cleanup_timer: ?c_uint = null,
/// Data about any in-flight calls to the portal.
pub const Entry = struct {
/// When the request started.
start: std.time.Instant,
/// A token used by the portal to identify requests and responses. The
/// actual format of the token does not really matter as long as it can be
/// used as part of a D-Bus object path. `usize` was chosen since it's easy
/// to hash and to generate random tokens.
token: usize,
/// The "kind" of URI. Unused here, but we may need to pass it on to the
/// fallback URL opener if the D-Bus method fails.
kind: apprt.action.OpenUrl.Kind,
/// A copy of the URI that we are opening. We need our own copy since the
/// method calls are asynchronous and the original may have been freed by
/// the time we need it.
uri: [:0]const u8,
/// Used to manage a scription to a D-Bus signal, which is how the XDG
/// Portal reports results of the method call.
subscription: ?c_uint = null,
pub fn deinit(self: *const Entry, alloc: Allocator) void {
alloc.free(self.uri);
}
};
pub const Errors = error{
/// Could not get a D-Bus connection
DBusConnectionRequired,
/// The D-Bus connection did not have a unique name. This _should_ be
/// impossible, but is handled for safety's sake.
NoDBusUniqueName,
/// The system was unable to give us the time.
TimerUnavailable,
};
pub fn init(app: *App) OpenURI {
return .{
.app = app,
};
}
pub fn setDbusConnection(self: *OpenURI, dbus: ?*gio.DBusConnection) void {
self.dbus = dbus;
}
pub fn deinit(self: *OpenURI) void {
const alloc = self.app.app.allocator();
self.mutex.lock();
defer self.mutex.unlock();
self.stopCleanupTimer();
for (self.entries.entries.items(.value)) |entry| {
entry.deinit(alloc);
alloc.destroy(entry);
}
self.entries.deinit(alloc);
}
/// Send the D-Bus method call to the XDG Desktop portal. The result of the
/// method call will be reported asynchronously.
pub fn start(self: *OpenURI, value: apprt.action.OpenUrl) (Allocator.Error || std.fmt.BufPrintError || Errors)!void {
const alloc = self.app.app.allocator();
const dbus = self.dbus orelse return error.DBusConnectionRequired;
const token = portal.generateToken();
self.mutex.lock();
defer self.mutex.unlock();
// Create an entry that is used to track the results of the D-Bus method
// call.
const entry = entry: {
const entry = try alloc.create(Entry);
errdefer alloc.destroy(entry);
entry.* = .{
.start = std.time.Instant.now() catch return error.TimerUnavailable,
.token = token,
.kind = value.kind,
.uri = try alloc.dupeZ(u8, value.url),
};
errdefer entry.deinit(alloc);
try self.entries.putNoClobber(alloc, token, entry);
break :entry entry;
};
errdefer {
_ = self.entries.swapRemove(token);
entry.deinit(alloc);
alloc.destroy(entry);
}
self.startCleanupTimer();
try self.subscribeToResponse(entry, dbus);
errdefer self.unsubscribeFromResponse(entry);
try self.sendRequest(entry, dbus);
}
/// Subscribe to the D-Bus signal that will contain the results of our method
/// call to the portal. This must be called with the mutex locked.
fn subscribeToResponse(
self: *OpenURI,
entry: *Entry,
dbus: *gio.DBusConnection,
) (Allocator.Error || std.fmt.BufPrintError || Errors)!void {
assert(!self.mutex.tryLock());
const alloc = self.app.app.allocator();
if (entry.subscription != null) return;
const request_path = try portal.getRequestPath(alloc, dbus, entry.token);
defer alloc.free(request_path);
entry.subscription = dbus.signalSubscribe(
null,
"org.freedesktop.portal.Request",
"Response",
request_path,
null,
.{},
responseReceived,
self,
null,
);
}
/// Unsubscribe to the D-Bus signal that contains the result of the method call.
/// This will prevent a response from being processed multiple times. This must
/// be called when the mutex is locked.
fn unsubscribeFromResponse(self: *OpenURI, entry: *Entry) void {
assert(!self.mutex.tryLock());
// Unsubscribe from the response signal
if (entry.subscription) |subscription| {
const dbus = self.dbus orelse {
entry.subscription = null;
log.warn("unable to unsubscribe open uri response without dbus connection", .{});
return;
};
dbus.signalUnsubscribe(subscription);
entry.subscription = null;
}
}
/// Send the D-Bus method call to the portal. The mutex must be locked when this
/// is called.
fn sendRequest(
self: *OpenURI,
entry: *Entry,
dbus: *gio.DBusConnection,
) Allocator.Error!void {
assert(!self.mutex.tryLock());
const alloc = self.app.app.allocator();
const payload = payload: {
const builder_type = glib.VariantType.new("(ssa{sv})");
defer builder_type.free();
// Initialize our builder to build up our parameters
var builder: glib.VariantBuilder = undefined;
builder.init(builder_type);
errdefer builder.clear();
// parent window - empty string means we have no window
builder.add("s", "");
// URI to open
builder.add("s", entry.uri.ptr);
// Options
{
const options = glib.VariantType.new("a{sv}");
defer options.free();
builder.open(options);
defer builder.close();
{
const option = glib.VariantType.new("{sv}");
defer option.free();
builder.open(option);
defer builder.close();
builder.add("s", "handle_token");
const token = try std.fmt.allocPrintSentinel(alloc, "{x:0<16}", .{entry.token}, 0);
defer alloc.free(token);
const handle_token = glib.Variant.newString(token.ptr);
builder.add("v", handle_token);
}
{
const option = glib.VariantType.new("{sv}");
defer option.free();
builder.open(option);
defer builder.close();
builder.add("s", "writable");
const writable = glib.Variant.newBoolean(@intFromBool(false));
builder.add("v", writable);
}
{
const option = glib.VariantType.new("{sv}");
defer option.free();
builder.open(option);
defer builder.close();
builder.add("s", "ask");
const ask = glib.Variant.newBoolean(@intFromBool(false));
builder.add("v", ask);
}
}
break :payload builder.end();
};
// We're expecting an object path back from the method call.
const reply_type = glib.VariantType.new("(o)");
defer reply_type.free();
dbus.call(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.OpenURI",
"OpenURI",
payload,
reply_type,
.{},
-1,
null,
requestCallback,
self,
);
}
/// Process the result of the original method call. Receiving this result does
/// not indicate that the that the method call succeeded but it may contain an
/// error message that is useful to log for debugging purposes.
fn requestCallback(
_: ?*gobject.Object,
result: *gio.AsyncResult,
ud: ?*anyopaque,
) callconv(.c) void {
const self: *OpenURI = @ptrCast(@alignCast(ud orelse return));
const dbus = self.dbus orelse {
log.err("Open URI request finished without a D-Bus connection", .{});
return;
};
var err_: ?*glib.Error = null;
defer if (err_) |err| err.free();
const reply_ = dbus.callFinish(result, &err_);
if (err_) |err| {
log.err("Open URI request failed={s} ({})", .{
err.f_message orelse "(unknown)",
err.f_code,
});
return;
}
const reply = reply_ orelse {
log.err("D-Bus method call returned a null value!", .{});
return;
};
defer reply.unref();
const reply_type = glib.VariantType.new("(o)");
defer reply_type.free();
if (reply.isOfType(reply_type) == 0) {
log.warn("Reply from D-Bus method call does not contain an object path!", .{});
return;
}
var object_path_: ?[*:0]const u8 = null;
reply.get("(o)", &object_path_);
const object_path = object_path_ orelse {
log.err("D-Bus method call did not return an object path", .{});
return;
};
const token = portal.parseRequestPath(std.mem.span(object_path)) orelse {
log.warn("Unable to parse token from the object path {s}", .{object_path});
return;
};
// Check to see if the request path returned matches a token that we sent.
self.mutex.lock();
defer self.mutex.unlock();
if (!self.entries.contains(token)) {
log.warn("Token {x:0<16} not found in the map!", .{token});
}
}
/// Handle the response signal from the portal. This should contain the actual
/// results of the method call (success or failure).
fn responseReceived(
_: *gio.DBusConnection,
_: ?[*:0]const u8,
object_path: [*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
params: *glib.Variant,
ud: ?*anyopaque,
) callconv(.c) void {
const self: *OpenURI = @ptrCast(@alignCast(ud orelse {
log.err("OpenURI response received with null userdata", .{});
return;
}));
const alloc = self.app.app.allocator();
const token = portal.parseRequestPath(std.mem.span(object_path)) orelse {
log.warn("invalid object path: {s}", .{std.mem.span(object_path)});
return;
};
self.mutex.lock();
defer self.mutex.unlock();
const entry = (self.entries.fetchSwapRemove(token) orelse {
log.warn("no entry for token {x:0<16}", .{token});
return;
}).value;
defer {
entry.deinit(alloc);
alloc.destroy(entry);
}
self.unsubscribeFromResponse(entry);
var response: u32 = 0;
var results: ?*glib.Variant = null;
params.get("(u@a{sv})", &response, &results);
switch (response) {
0 => {
log.debug("open uri successful", .{});
},
1 => {
log.debug("open uri request was cancelled by the user", .{});
},
2 => {
log.warn("open uri request ended unexpectedly", .{});
self.app.app.openUrlFallback(entry.kind, entry.uri);
},
else => {
log.err("unrecognized response code={}", .{response});
self.app.app.openUrlFallback(entry.kind, entry.uri);
},
}
}
/// Wait this number of seconds and then clean up any orphaned entries.
const cleanup_timeout = 30;
/// If there is an active cleanup timer, cancel it. This must be called with the
/// mutex locked
fn stopCleanupTimer(self: *OpenURI) void {
assert(!self.mutex.tryLock());
if (self.cleanup_timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove cleanup timer source={d}", .{timer});
}
self.cleanup_timer = null;
}
}
/// Start a timer to clean up any entries that have not received a timely
/// response. If there is already a timer it will be stopped and replaced with a
/// new one. This must be called with the mutex locked.
fn startCleanupTimer(self: *OpenURI) void {
assert(!self.mutex.tryLock());
self.stopCleanupTimer();
self.cleanup_timer = glib.timeoutAddSeconds(cleanup_timeout + 1, cleanup, self);
}
/// The cleanup timer is used to free up any entries that may have failed
/// to get a response in a timely manner.
fn cleanup(ud: ?*anyopaque) callconv(.c) c_int {
const self: *OpenURI = @ptrCast(@alignCast(ud orelse {
log.warn("cleanup called with null userdata", .{});
return @intFromBool(glib.SOURCE_REMOVE);
}));
const alloc = self.app.app.allocator();
self.mutex.lock();
defer self.mutex.unlock();
self.cleanup_timer = null;
const now = std.time.Instant.now() catch {
// `now()` should never fail, but if it does, don't crash, just return.
// This might cause a small memory leak in rare circumstances but it
// should get cleaned up the next time a URL is clicked.
return @intFromBool(glib.SOURCE_REMOVE);
};
loop: while (true) {
for (self.entries.entries.items(.value)) |entry| {
if (now.since(entry.start) > cleanup_timeout * std.time.ns_per_s) {
self.unsubscribeFromResponse(entry);
_ = self.entries.swapRemove(entry.token);
entry.deinit(alloc);
alloc.destroy(entry);
continue :loop;
}
}
break :loop;
}
return @intFromBool(glib.SOURCE_REMOVE);
}