diff --git a/include/ghostty.h b/include/ghostty.h index 73c708c6b..16ca21d89 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -662,6 +662,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, @@ -711,7 +724,8 @@ typedef enum { GHOSTTY_ACTION_RING_BELL, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, - GHOSTTY_ACTION_CHECK_FOR_UPDATES + GHOSTTY_ACTION_CHECK_FOR_UPDATES, + GHOSTTY_ACTION_OPEN_URL, } ghostty_action_tag_e; typedef union { @@ -739,6 +753,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 33cf581af..db272dddc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3724,7 +3724,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 => { @@ -3732,7 +3736,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 }, + ); }, } @@ -4957,7 +4965,13 @@ fn writeScreenFile( defer self.alloc.free(pathZ); 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( self.alloc, path, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index b4c5164c2..6c33a296f 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const assert = std.debug.assert; const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); @@ -267,6 +268,11 @@ pub const Action = union(Key) { 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 pub const Key = enum(c_int) { quit, @@ -317,6 +323,7 @@ pub const Action = union(Key) { undo, redo, check_for_updates, + open_url, }; /// 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. // 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) == 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. @@ -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, + }; + } +}; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c61254fbd..a046291ef 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -519,6 +519,7 @@ pub fn performAction( .secure_input => self.setSecureInput(target, value), .ring_bell => try self.ringBell(target), .toggle_command_palette => try self.toggleCommandPalette(target), + .open_url => self.openUrl(value), // Unimplemented .close_all_windows, @@ -1757,3 +1758,13 @@ fn initActions(self: *App) void { 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}); + }; +} diff --git a/src/os/open.zig b/src/os/open.zig index ce62a7e0b..6841c76ab 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -2,14 +2,10 @@ const std = @import("std"); const builtin = @import("builtin"); 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 -/// to potentially open the URL in a different way. -pub const Type = enum { - text, - unknown, -}; +const log = std.log.scoped(.@"os-open"); /// Open a URL in the default handling application. /// @@ -18,9 +14,39 @@ pub const Type = enum { /// log output and may allocate from another thread. pub fn open( alloc: Allocator, - typ: Type, + kind: apprt.action.OpenUrlKind, 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 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) { .linux, .freebsd => .init( &.{ "xdg-open", url }, @@ -33,7 +59,7 @@ pub fn open( ), .macos => .init( - switch (typ) { + switch (kind) { .text => &.{ "open", "-t", url }, .unknown => &.{ "open", url }, }, @@ -44,43 +70,95 @@ pub fn open( 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. - exe.stdout_behavior = .Pipe; + exe.stdin_behavior = .Ignore; + exe.stdout_behavior = .Ignore; exe.stderr_behavior = .Pipe; - // Spawn the process on our same thread so we can detect failure - // quickly. - try exe.spawn(); + exe.spawn() catch |err| { + switch (err) { + 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 - // the process. This is done in a separate thread because SOME - // open implementations block and some do not. It's easier to just - // spawn a thread to handle this so that we never block. - const thread = try std.Thread.spawn(.{}, openThread, .{ alloc, exe }); - thread.detach(); -} + const stderr = exe.stderr orelse { + log.warn("Unable to access the stderr of the spawned program!", .{}); + return; + }; -fn openThread(alloc: Allocator, exe_: std.process.Child) !void { - // 50 KiB is the default value used by std.process.Child.run and should - // be enough to get the output we care about. - const output_max_size = 50 * 1024; + var cb = try CircBuf(u8, 0).init(alloc, 50 * 1024); + defer cb.deinit(alloc); - var stdout: std.ArrayListUnmanaged(u8) = .{}; - var stderr: std.ArrayListUnmanaged(u8) = .{}; - defer { - stdout.deinit(alloc); - stderr.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]); } - // Copy the exe so it is non-const. This is necessary because wait() - // requires a mutable reference and we can't have one as a thread - // param. - var exe = exe_; - try exe.collectOutput(alloc, &stdout, &stderr, output_max_size); - _ = try exe.wait(); + // 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("{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}); + } - // 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 (stderr.items.len > 0) log.warn("wait stderr={s}", .{stderr.items}); + const rc = exe.wait() catch |err| { + switch (err) { + 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; + }; + + 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 }); + }, + } }