diff --git a/include/ghostty.h b/include/ghostty.h index 2dc1bffef..10ad4d75e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -558,6 +558,19 @@ typedef struct { bool soft; } 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 typedef enum { GHOSTTY_ACTION_QUIT, @@ -601,6 +614,7 @@ typedef enum { GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, + GHOSTTY_ACTION_OPEN_URL, } ghostty_action_tag_e; typedef union { @@ -627,6 +641,7 @@ typedef union { ghostty_action_color_change_s color_change; ghostty_action_reload_config_s reload_config; ghostty_action_config_change_s config_change; + ghostty_action_open_url_s open_url; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 89031a1b5..ee3fcd326 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3288,7 +3288,11 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { .trim = false, }); 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 => { @@ -3296,7 +3300,11 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - try internal_os.open(self.alloc, .unknown, uri); + _ = try self.rt_app.performAction( + .{ .surface = self }, + .open_url, + .{ .kind = .unknown, .url = uri }, + ); }, } @@ -4452,7 +4460,13 @@ fn writeScreenFile( const path = try tmp_dir.dir.realpath(filename, &path_buf); switch (write_action) { - .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( self.alloc, path, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 30cb2fa5e..04554a38e 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -244,6 +244,11 @@ pub const Action = union(Key) { /// Closes the currently focused window. close_window, + /// 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 pub const Key = enum(c_int) { quit, @@ -287,6 +292,7 @@ pub const Action = union(Key) { reload_config, config_change, close_window, + open_url, }; /// Sync with: ghostty_action_u @@ -327,7 +333,7 @@ pub const Action = union(Key) { // For ABI compatibility, we expect that this is our union size. // At the time of writing, we don't promise ABI compatibility // so we can change this but I want to be aware of it. - assert(@sizeOf(CValue) == 16); + assert(@sizeOf(CValue) == 24); } /// Returns the value type for the given key. @@ -578,3 +584,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, + }; + } +}; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 998f88022..71236adfc 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -215,6 +215,8 @@ pub const App = struct { .reload_config => try self.reloadConfig(target, value), + .open_url => self.openUrl(value), + // Unimplemented .new_split, .goto_split, @@ -439,6 +441,17 @@ pub const App = struct { return .unknown; } + /// Open a URL. On Linux, use the new `openUrlLinux` otherwise fall back + /// to `open`. + fn openUrl(self: *App, value: apprt.action.OpenUrl) void { + switch (builtin.os.tag) { + .linux => internal_os.openUrlLinux(self.app.alloc, value.url), + else => internal_os.open(self.app.alloc, value.kind, value.url) catch |err| { + log.warn("unable to open url: {}", .{err}); + }, + } + } + /// Mac-specific settings. This is only enabled when the target is /// Mac and the artifact is a standalone exe. We don't target libs because /// the embedded API doesn't do windowing. diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ddee49459..38d96290c 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -484,6 +484,7 @@ pub fn performAction( .prompt_title => try self.promptTitle(target), .toggle_quick_terminal => return try self.toggleQuickTerminal(), .secure_input => self.setSecureInput(target, value), + .open_url => self.openUrl(value), // Unimplemented .close_all_windows, @@ -1662,3 +1663,10 @@ test "isValidAppId" { try testing.expect(!isValidAppId("")); try testing.expect(!isValidAppId("foo" ** 86)); } + +pub fn openUrl( + app: *App, + value: apprt.action.OpenUrl, +) void { + internal_os.openUrlLinux(app.core_app.alloc, value.url); +} diff --git a/src/os/main.zig b/src/os/main.zig index 36833f427..c79110410 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -48,6 +48,7 @@ pub const expandHome = homedir.expandHome; pub const ensureLocale = locale.ensureLocale; pub const clickInterval = mouse.clickInterval; pub const open = openpkg.open; +pub const openUrlLinux = openpkg.openUrlLinux; pub const OpenType = openpkg.Type; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; diff --git a/src/os/open.zig b/src/os/open.zig index f7eadd06e..6cf8a551a 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -2,12 +2,10 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -/// 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. -pub const Type = enum { - text, - unknown, -}; +const apprt = @import("../apprt.zig"); +const CircBuf = @import("../datastruct/circ_buf.zig").CircBuf; + +const log = std.log.scoped(.os); /// Open a URL in the default handling application. /// @@ -15,7 +13,7 @@ pub const Type = enum { /// Output on stdout is ignored. pub fn open( alloc: Allocator, - typ: Type, + kind: apprt.action.OpenUrlKind, url: []const u8, ) !void { const cmd: OpenCommand = switch (builtin.os.tag) { @@ -31,7 +29,7 @@ pub fn open( .macos => .{ .child = std.process.Child.init( - switch (typ) { + switch (kind) { .text => &.{ "open", "-t", url }, .unknown => &.{ "open", url }, }, @@ -77,3 +75,125 @@ const OpenCommand = struct { child: std.process.Child, wait: bool = false, }; + +/// Use `xdg-open` to open a URL using the default application. +/// +/// Any output on stderr is logged as a warning in the application logs. Output +/// on stdout is ignored. +pub fn openUrlLinux( + alloc: Allocator, + url: []const u8, +) void { + openUrlLinuxError(alloc, url) catch |err| { + log.warn("unable to open url: {}", .{err}); + }; +} + +fn openUrlLinuxError( + alloc: Allocator, + url: []const u8, +) !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 `xdg-open` 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(.{}, _openUrlLinux, .{ alloc, copy }); + + // Don't worry about the thread any more. + thread.detach(); +} + +fn _openUrlLinux(alloc: Allocator, url: []const u8) void { + _openUrlLinuxError(alloc, url) catch |err| { + log.warn("error while opening url: {}", .{err}); + }; +} + +fn _openUrlLinuxError(alloc: Allocator, url: []const u8) !void { + defer alloc.free(url); + + var exe = std.process.Child.init( + &.{ "xdg-open", url }, + alloc, + ); + + // We're only interested in stderr + exe.stdin_behavior = .Ignore; + exe.stdout_behavior = .Ignore; + exe.stderr_behavior = .Pipe; + + exe.spawn() catch |err| { + switch (err) { + error.FileNotFound => { + log.err("Unable to find xdg-open. Please install xdg-open and ensure that it is available on the PATH.", .{}); + }, + else => |e| return e, + } + return; + }; + + const stderr = exe.stderr orelse { + log.warn("Unable to access the stderr of the spawned program!", .{}); + return; + }; + + 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]); + } + + // 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. + if (cb.len() > 0) log: { + { + var it = cb.iterator(.forward); + while (it.next()) |char| { + if (std.mem.indexOfScalar(u8, &std.ascii.whitespace, char.*)) |_| continue; + break; + } + // 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("xdg-open stderr: {s}", .{buf.items}); + buf.clearRetainingCapacity(); + } + try buf.append(char.*); + } + if (buf.items.len > 0) + log.err("xdg-open stderr: {s}", .{buf.items}); + } + + const rc = try exe.wait(); + + switch (rc) { + .Exited => |code| { + if (code != 0) { + log.warn("xdg-open exited with error code {d}", .{code}); + } + }, + .Signal => |signal| { + log.warn("xdg-open was terminaled with signal {}", .{signal}); + }, + .Stopped => |signal| { + log.warn("xdg-open was stopped with signal {}", .{signal}); + }, + .Unknown => |code| { + log.warn("xdg-open had an unknown error {}", .{code}); + }, + } +}