core/gtk: open urls using an apprt action instead of doing it directly

Partial implementation of #5256

This implements the core changes necessary to open urls using an apprt
action rather than doing it directly from the core.

Implements the open_url action in the GTK and GLFW apprts.

Note that this should not be merged until a macOS-savvy developer can add
an implementation of the open_url action for the macOS apprt.
This commit is contained in:
Jeffrey C. Ollie
2025-02-25 11:59:46 -06:00
committed by Mitchell Hashimoto
parent 185a4ea10b
commit 9583ea1b7a
5 changed files with 209 additions and 44 deletions

View File

@ -662,6 +662,19 @@ typedef struct {
bool soft; bool soft;
} ghostty_action_reload_config_s; } ghostty_action_reload_config_s;
// apprt.action.OpenUrlKind
typedef enum {
GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,
} ghostty_action_open_url_kind_e;
// apprt.action.OpenUrl.C
typedef struct {
ghostty_action_open_url_kind_e kind;
const char* url;
uintptr_t len;
} ghostty_action_open_url_s;
// apprt.Action.Key // apprt.Action.Key
typedef enum { typedef enum {
GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_QUIT,
@ -711,7 +724,8 @@ typedef enum {
GHOSTTY_ACTION_RING_BELL, GHOSTTY_ACTION_RING_BELL,
GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_UNDO,
GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_REDO,
GHOSTTY_ACTION_CHECK_FOR_UPDATES GHOSTTY_ACTION_CHECK_FOR_UPDATES,
GHOSTTY_ACTION_OPEN_URL,
} ghostty_action_tag_e; } ghostty_action_tag_e;
typedef union { typedef union {
@ -739,6 +753,7 @@ typedef union {
ghostty_action_color_change_s color_change; ghostty_action_color_change_s color_change;
ghostty_action_reload_config_s reload_config; ghostty_action_reload_config_s reload_config;
ghostty_action_config_change_s config_change; ghostty_action_config_change_s config_change;
ghostty_action_open_url_s open_url;
} ghostty_action_u; } ghostty_action_u;
typedef struct { typedef struct {

View File

@ -3724,7 +3724,11 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
.trim = false, .trim = false,
}); });
defer self.alloc.free(str); defer self.alloc.free(str);
try internal_os.open(self.alloc, .unknown, str); _ = try self.rt_app.performAction(
.{ .surface = self },
.open_url,
.{ .kind = .unknown, .url = str },
);
}, },
._open_osc8 => { ._open_osc8 => {
@ -3732,7 +3736,11 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
log.warn("failed to get URI for OSC8 hyperlink", .{}); log.warn("failed to get URI for OSC8 hyperlink", .{});
return false; return false;
}; };
try internal_os.open(self.alloc, .unknown, uri); _ = try self.rt_app.performAction(
.{ .surface = self },
.open_url,
.{ .kind = .unknown, .url = uri },
);
}, },
} }
@ -4957,7 +4965,13 @@ fn writeScreenFile(
defer self.alloc.free(pathZ); defer self.alloc.free(pathZ);
try self.rt_surface.setClipboardString(pathZ, .standard, false); try self.rt_surface.setClipboardString(pathZ, .standard, false);
}, },
.open => try internal_os.open(self.alloc, .text, path), .open => {
_ = try self.rt_app.performAction(
.{ .surface = self },
.open_url,
.{ .kind = .text, .url = path },
);
},
.paste => self.io.queueMessage(try termio.Message.writeReq( .paste => self.io.queueMessage(try termio.Message.writeReq(
self.alloc, self.alloc,
path, path,

View File

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert; const assert = std.debug.assert;
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig"); const configpkg = @import("../config.zig");
@ -267,6 +268,11 @@ pub const Action = union(Key) {
check_for_updates, check_for_updates,
/// Open a URL using the native OS mechanisms. On macOS this might be `open`
/// or on Linux this might be `xdg-open`. The exact mechanism is up to the
/// apprt.
open_url: OpenUrl,
/// Sync with: ghostty_action_tag_e /// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) { pub const Key = enum(c_int) {
quit, quit,
@ -317,6 +323,7 @@ pub const Action = union(Key) {
undo, undo,
redo, redo,
check_for_updates, check_for_updates,
open_url,
}; };
/// Sync with: ghostty_action_u /// Sync with: ghostty_action_u
@ -357,7 +364,13 @@ pub const Action = union(Key) {
// For ABI compatibility, we expect that this is our union size. // For ABI compatibility, we expect that this is our union size.
// At the time of writing, we don't promise ABI compatibility // At the time of writing, we don't promise ABI compatibility
// so we can change this but I want to be aware of it. // so we can change this but I want to be aware of it.
assert(@sizeOf(CValue) == 16); assert(@sizeOf(CValue) == switch (builtin.target.os.tag) {
.windows => switch (builtin.target.cpu.arch) {
.x86 => 16,
else => 24,
},
else => 24,
});
} }
/// Returns the value type for the given key. /// Returns the value type for the given key.
@ -614,3 +627,37 @@ pub const ConfigChange = struct {
}; };
} }
}; };
/// The type of the data at the URL to open. This is used as a hint to
/// potentially open the URL in a different way.
/// Sync with: ghostty_action_open_url_kind_s
pub const OpenUrlKind = enum(c_int) {
text,
unknown,
};
/// Open a URL
pub const OpenUrl = struct {
/// The type of data that the URL refers to.
kind: OpenUrlKind,
/// The URL.
url: []const u8,
// Sync with: ghostty_action_open_url_s
pub const C = extern struct {
/// The type of data that the URL refers to.
kind: OpenUrlKind,
/// The URL (not zero terminated).
url: [*]const u8,
/// The number of bytes in the URL.
len: usize,
};
pub fn cval(self: OpenUrl) C {
return .{
.kind = self.kind,
.url = self.url.ptr,
.len = self.url.len,
};
}
};

View File

@ -519,6 +519,7 @@ pub fn performAction(
.secure_input => self.setSecureInput(target, value), .secure_input => self.setSecureInput(target, value),
.ring_bell => try self.ringBell(target), .ring_bell => try self.ringBell(target),
.toggle_command_palette => try self.toggleCommandPalette(target), .toggle_command_palette => try self.toggleCommandPalette(target),
.open_url => self.openUrl(value),
// Unimplemented // Unimplemented
.close_all_windows, .close_all_windows,
@ -1757,3 +1758,13 @@ fn initActions(self: *App) void {
action_map.addAction(action.as(gio.Action)); action_map.addAction(action.as(gio.Action));
} }
} }
// TODO: use https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html
pub fn openUrl(
app: *App,
value: apprt.action.OpenUrl,
) void {
internal_os.open(app.core_app.alloc, value.kind, value.url) catch |err| {
log.warn("unable to open url: {}", .{err});
};
}

View File

@ -2,14 +2,10 @@ const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const log = std.log.scoped(.@"os-open"); const apprt = @import("../apprt.zig");
const CircBuf = @import("../datastruct/circ_buf.zig").CircBuf;
/// The type of the data at the URL to open. This is used as a hint const log = std.log.scoped(.@"os-open");
/// to potentially open the URL in a different way.
pub const Type = enum {
text,
unknown,
};
/// Open a URL in the default handling application. /// Open a URL in the default handling application.
/// ///
@ -18,9 +14,39 @@ pub const Type = enum {
/// log output and may allocate from another thread. /// log output and may allocate from another thread.
pub fn open( pub fn open(
alloc: Allocator, alloc: Allocator,
typ: Type, kind: apprt.action.OpenUrlKind,
url: []const u8, url: []const u8,
) !void { ) !void {
// Make a copy of the URL so that we can use it in the thread without
// worrying about it getting freed by other threads.
const copy = try alloc.dupe(u8, url);
errdefer alloc.free(copy);
// Run in a thread so that it never blocks the main thread, no matter how
// long it takes to execute.
const thread = try std.Thread.spawn(.{}, _openThread, .{ alloc, kind, copy });
// Don't worry about the thread any more.
thread.detach();
}
fn _openThread(
alloc: Allocator,
kind: apprt.action.OpenUrlKind,
url: []const u8,
) void {
_openThreadError(alloc, kind, url) catch |err| {
log.warn("error while opening url: {}", .{err});
};
}
fn _openThreadError(
alloc: Allocator,
kind: apprt.action.OpenUrlKind,
url: []const u8,
) !void {
defer alloc.free(url);
var exe: std.process.Child = switch (builtin.os.tag) { var exe: std.process.Child = switch (builtin.os.tag) {
.linux, .freebsd => .init( .linux, .freebsd => .init(
&.{ "xdg-open", url }, &.{ "xdg-open", url },
@ -33,7 +59,7 @@ pub fn open(
), ),
.macos => .init( .macos => .init(
switch (typ) { switch (kind) {
.text => &.{ "open", "-t", url }, .text => &.{ "open", "-t", url },
.unknown => &.{ "open", url }, .unknown => &.{ "open", url },
}, },
@ -44,43 +70,95 @@ pub fn open(
else => @compileError("unsupported OS"), else => @compileError("unsupported OS"),
}; };
// Pipe stdout/stderr so we can collect output from the command. // Ignore stdin & stdout, collect the output from stderr.
// This must be set before spawning the process. // This must be set before spawning the process.
exe.stdout_behavior = .Pipe; exe.stdin_behavior = .Ignore;
exe.stdout_behavior = .Ignore;
exe.stderr_behavior = .Pipe; exe.stderr_behavior = .Pipe;
// Spawn the process on our same thread so we can detect failure exe.spawn() catch |err| {
// quickly. switch (err) {
try exe.spawn(); error.FileNotFound => {
log.warn("Unable to find {s}. Please install {s} and ensure that it is available on the PATH.", .{
exe.argv[0],
exe.argv[0],
});
},
else => |e| return e,
}
return;
};
// Create a thread that handles collecting output and reaping const stderr = exe.stderr orelse {
// the process. This is done in a separate thread because SOME log.warn("Unable to access the stderr of the spawned program!", .{});
// open implementations block and some do not. It's easier to just return;
// spawn a thread to handle this so that we never block. };
const thread = try std.Thread.spawn(.{}, openThread, .{ alloc, exe });
thread.detach(); var cb = try CircBuf(u8, 0).init(alloc, 50 * 1024);
defer cb.deinit(alloc);
// Read any error output and store it in a circular buffer so that we
// get that _last_ 50K of output.
while (true) {
var buf: [1024]u8 = undefined;
const len = try stderr.read(&buf);
if (len == 0) break;
try cb.appendSlice(buf[0..len]);
} }
fn openThread(alloc: Allocator, exe_: std.process.Child) !void { // If we have any stderr output we log it. This makes it easier for users to
// 50 KiB is the default value used by std.process.Child.run and should // debug why some open commands may not work as expected.
// be enough to get the output we care about. if (cb.len() > 0) log: {
const output_max_size = 50 * 1024; {
var it = cb.iterator(.forward);
var stdout: std.ArrayListUnmanaged(u8) = .{}; while (it.next()) |char| {
var stderr: std.ArrayListUnmanaged(u8) = .{}; if (std.mem.indexOfScalar(u8, &std.ascii.whitespace, char.*)) |_| continue;
defer { break;
stdout.deinit(alloc); }
stderr.deinit(alloc); // it's all whitespace, don't log
break :log;
}
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
var it = cb.iterator(.forward);
while (it.next()) |char| {
if (char.* == '\n') {
log.err("{s} stderr: {s}", .{ exe.argv[0], buf.items });
buf.clearRetainingCapacity();
}
try buf.append(char.*);
}
if (buf.items.len > 0)
log.err("{s} stderr: {s}", .{buf.items});
} }
// Copy the exe so it is non-const. This is necessary because wait() const rc = exe.wait() catch |err| {
// requires a mutable reference and we can't have one as a thread switch (err) {
// param. error.FileNotFound => {
var exe = exe_; log.warn("Unable to find {s}. Please install {s} and ensure that it is available on the PATH.", .{
try exe.collectOutput(alloc, &stdout, &stderr, output_max_size); exe.argv[0],
_ = try exe.wait(); exe.argv[0],
});
// If we have any stderr output we log it. This makes it easier for },
// users to debug why some open commands may not work as expected. else => |e| return e,
if (stderr.items.len > 0) log.warn("wait stderr={s}", .{stderr.items}); }
return;
};
switch (rc) {
.Exited => |code| {
if (code != 0) {
log.warn("{s} exited with error code {d}", .{ exe.argv[0], code });
}
},
.Signal => |signal| {
log.warn("{s} was terminaled with signal {}", .{ exe.argv[0], signal });
},
.Stopped => |signal| {
log.warn("{s} was stopped with signal {}", .{ exe.argv[0], signal });
},
.Unknown => |code| {
log.warn("{s} had an unknown error {}", .{ exe.argv[0], code });
},
}
} }