diff --git a/src/App.zig b/src/App.zig index b2893fdd3..42c4183fe 100644 --- a/src/App.zig +++ b/src/App.zig @@ -16,6 +16,7 @@ const Config = @import("config.zig").Config; const BlockingQueue = @import("./blocking_queue.zig").BlockingQueue; const renderer = @import("renderer.zig"); const font = @import("font/main.zig"); +const internal_os = @import("os/main.zig"); const macos = @import("macos"); const objc = @import("objc"); @@ -40,6 +41,10 @@ mailbox: Mailbox.Queue, /// Set to true once we're quitting. This never goes false again. quit: bool, +/// The app resources directory, equivalent to zig-out/share when we build +/// from source. This is null if we can't detect it. +resources_dir: ?[]const u8 = null, + /// Initialize the main app instance. This creates the main window, sets /// up the renderer state, compiles the shaders, etc. This is the primary /// "startup" logic. @@ -48,11 +53,21 @@ pub fn create( ) !*App { var app = try alloc.create(App); errdefer alloc.destroy(app); + + // Find our resources directory once for the app so every launch + // hereafter can use this cached value. + var resources_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const resources_dir = if (try internal_os.resourcesDir(&resources_buf)) |dir| + try alloc.dupe(u8, dir) + else + null; + app.* = .{ .alloc = alloc, .surfaces = .{}, .mailbox = .{}, .quit = false, + .resources_dir = resources_dir, }; errdefer app.surfaces.deinit(alloc); @@ -64,6 +79,7 @@ pub fn destroy(self: *App) void { for (self.surfaces.items) |surface| surface.deinit(); self.surfaces.deinit(self.alloc); + if (self.resources_dir) |dir| self.alloc.free(dir); self.alloc.destroy(self); } diff --git a/src/Surface.zig b/src/Surface.zig index 2d24647c0..382b35e1b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -182,6 +182,7 @@ pub fn init( alloc: Allocator, config: *const configpkg.Config, app_mailbox: App.Mailbox, + app_resources_dir: ?[]const u8, rt_surface: *apprt.runtime.Surface, ) !void { // Initialize our renderer with our initialized surface. @@ -405,6 +406,7 @@ pub fn init( .screen_size = screen_size, .full_config = config, .config = try termio.Impl.DerivedConfig.init(alloc, config), + .resources_dir = app_resources_dir, .renderer_state = &self.renderer_state, .renderer_wakeup = render_thread.wakeup, .renderer_mailbox = render_thread.mailbox, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 8dab646ac..5c4c7bbc5 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -177,6 +177,7 @@ pub const Surface = struct { app.core_app.alloc, &config, .{ .rt_app = app, .mailbox = &app.core_app.mailbox }, + app.core_app.resources_dir, self, ); errdefer self.core_surface.deinit(); diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 4db75f31f..0cef7251f 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -363,6 +363,7 @@ pub const Surface = struct { app.app.alloc, &config, .{ .rt_app = app, .mailbox = &app.app.mailbox }, + app.app.resources_dir, self, ); errdefer self.core_surface.deinit(); diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 349efde5d..bbdbccd43 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -298,6 +298,9 @@ const Window = struct { /// The background CSS for the window (if any). css_window_background: ?[]u8 = null, + /// The resources directory for the icon (if any). + icon_search_dir: ?[:0]const u8 = null, + pub fn init(self: *Window, app: *App) !void { // Set up our own state self.* = .{ @@ -314,6 +317,31 @@ const Window = struct { c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_default_size(gtk_window, 1000, 600); + // If we don't have the icon then we'll try to add our resources dir + // to the search path and see if we can find it there. + const icon_name = "com.mitchellh.ghostty"; + const icon_theme = c.gtk_icon_theme_get_for_display(c.gtk_widget_get_display(window)); + if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) icon: { + const base = self.app.core_app.resources_dir orelse { + log.info("gtk app missing Ghostty icon and no resources dir detected", .{}); + log.info("gtk app will not have Ghostty icon", .{}); + break :icon; + }; + + // Note that this method for adding the icon search path is + // a fallback mechanism. The recommended mechanism is the + // Freedesktop Icon Theme Specification. We distribute a ".desktop" + // file in zig-out/share that should be installed to the proper + // place. + const dir = try std.fmt.allocPrintZ(app.core_app.alloc, "{s}/icons", .{base}); + self.icon_search_dir = dir; + c.gtk_icon_theme_add_search_path(icon_theme, dir.ptr); + if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) { + log.warn("Ghostty icon for gtk app not found", .{}); + } + } + c.gtk_window_set_icon_name(gtk_window, icon_name); + // Apply background opacity if we have it if (app.config.@"background-opacity" < 1) { var css = try std.fmt.allocPrint( @@ -361,6 +389,7 @@ const Window = struct { pub fn deinit(self: *Window) void { if (self.css_window_background) |ptr| self.app.core_app.alloc.free(ptr); + if (self.icon_search_dir) |ptr| self.app.core_app.alloc.free(ptr); } /// Add a new tab to this window. @@ -765,6 +794,7 @@ pub const Surface = struct { self.app.core_app.alloc, &config, .{ .rt_app = self.app, .mailbox = &self.app.core_app.mailbox }, + self.app.core_app.resources_dir, self, ); errdefer self.core_surface.deinit(); diff --git a/src/os/main.zig b/src/os/main.zig index 3c2afcb31..b80c116e6 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -7,6 +7,7 @@ pub usingnamespace @import("homedir.zig"); pub usingnamespace @import("locale.zig"); pub usingnamespace @import("macos_version.zig"); pub usingnamespace @import("mouse.zig"); +pub usingnamespace @import("resourcesdir.zig"); pub const TempDir = @import("TempDir.zig"); pub const passwd = @import("passwd.zig"); pub const xdg = @import("xdg.zig"); diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig new file mode 100644 index 000000000..1496f53f7 --- /dev/null +++ b/src/os/resourcesdir.zig @@ -0,0 +1,77 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; + +/// Gets the directory to the bundled resources directory, if it +/// exists (not all platforms or packages have it). The output is +/// written to the given buffer if we need to allocate. Note that +/// the output is not ALWAYS written to the buffer and may refer to +/// static memory. +/// +/// This is highly Ghostty-specific and can likely be generalized at +/// some point but we can cross that bridge if we ever need to. +/// +/// This returns error.OutOfMemory is buffer is not big enough. +pub fn resourcesDir(buf: []u8) !?[]const u8 { + // If we have an environment variable set, we always use that. + if (std.os.getenv("GHOSTTY_RESOURCES_DIR")) |dir| { + if (dir.len > 0) { + return dir; + } + } + + // This is the sentinel value we look for in the path to know + // we've found the resources directory. + const sentinel = "terminfo/ghostty.termcap"; + + // Get the path to our running binary + var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + var exe: []const u8 = std.fs.selfExePath(&exe_buf) catch return null; + + // We have an exe path! Climb the tree looking for the terminfo + // bundle as we expect it. + while (std.fs.path.dirname(exe)) |dir| { + exe = dir; + + // On MacOS, we look for the app bundle path. + if (comptime builtin.target.isDarwin()) { + if (try maybeDir(buf, dir, "Contents/Resources", sentinel)) |v| { + return v; + } + } + + // On all platforms, we look for a /usr/share style path. This + // is valid even on Mac since there is nothing that requires + // Ghostty to be in an app bundle. + if (try maybeDir(buf, dir, "share", sentinel)) |v| { + return v; + } + } + + return null; +} + +/// Little helper to check if the "base/sub/suffix" directory exists and +/// if so return true. The "suffix" is just used as a way to verify a directory +/// seems roughly right. +/// +/// "buf" must be large enough to fit base + sub + suffix. This is generally +/// MAX_PATH_BYTES so its not a big deal. +pub fn maybeDir( + buf: []u8, + base: []const u8, + sub: []const u8, + suffix: []const u8, +) !?[]const u8 { + const path = try std.fmt.bufPrint(buf, "{s}/{s}/{s}", .{ base, sub, suffix }); + + if (std.fs.accessAbsolute(path, .{})) { + const len = path.len - suffix.len - 1; + return buf[0..len]; + } else |_| { + // Folder doesn't exist. If a different error happens its okay + // we just ignore it and move on. + } + + return null; +} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 5f6c70aac..3ed481ef2 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -544,26 +544,12 @@ const Subprocess = struct { }; errdefer env.deinit(); - // Get our bundled resources directory, if it exists. We use this - // for terminfo, shell-integration, etc. + // If we have a resources dir then set our env var const resources_key = "GHOSTTY_RESOURCES_DIR"; - const resources_dir = dir: { - if (env.get(resources_key)) |dir| { - if (dir.len > 0) { - log.info("using Ghostty resources dir from env var: {s}", .{dir}); - break :dir dir; - } - } - - if (try resourcesDir(alloc)) |dir| { - log.info("found Ghostty resources dir: {s}", .{dir}); - try env.put(resources_key, dir); - break :dir dir; - } - - log.warn("Ghostty resources dir not found, some features disabled", .{}); - break :dir null; - }; + if (opts.resources_dir) |dir| { + log.info("found Ghostty resources dir: {s}", .{dir}); + try env.put(resources_key, dir); + } // Set our TERM var. This is a bit complicated because we want to use // the ghostty TERM value but we want to only do that if we have @@ -571,7 +557,9 @@ const Subprocess = struct { // // For now, we just look up a bundled dir but in the future we should // also load the terminfo database and look for it. - if (try terminfoDir(alloc, resources_dir)) |dir| { + if (opts.resources_dir) |base| { + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{base}); try env.put("TERM", "xterm-ghostty"); try env.put("COLORTERM", "truecolor"); try env.put("TERMINFO", dir); @@ -641,7 +629,7 @@ const Subprocess = struct { .zsh => .zsh, }; - const dir = resources_dir orelse break :shell null; + const dir = opts.resources_dir orelse break :shell null; break :shell try shell_integration.setup( dir, final_path, @@ -860,73 +848,6 @@ const Subprocess = struct { fn killCommandFlatpak(command: *FlatpakHostCommand) !void { try command.signal(c.SIGHUP, true); } - - /// Gets the directory to the terminfo database, if it can be detected. - /// The memory returned can't be easily freed so the alloc should be - /// an arena or something similar. - fn terminfoDir(alloc: Allocator, base: ?[]const u8) !?[]const u8 { - const dir = base orelse return null; - return try tryDir(alloc, dir, "terminfo", ""); - } - - /// Gets the directory to the bundled resources directory, if it - /// exists (not all platforms or packages have it). - /// - /// The memory returned can't be easily freed so the alloc should be - /// an arena or something similar. - fn resourcesDir(alloc: Allocator) !?[]const u8 { - // This is the sentinel value we look for in the path to know - // we've found the resources directory. - const sentinel = "terminfo/ghostty.termcap"; - - // Get the path to our running binary - var exe_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - var exe: []const u8 = std.fs.selfExePath(&exe_buf) catch return null; - - // We have an exe path! Climb the tree looking for the terminfo - // bundle as we expect it. - while (std.fs.path.dirname(exe)) |dir| { - exe = dir; - - // On MacOS, we look for the app bundle path. - if (comptime builtin.target.isDarwin()) { - if (try tryDir(alloc, dir, "Contents/Resources", sentinel)) |v| { - return v; - } - } - - // On all platforms, we look for a /usr/share style path. This - // is valid even on Mac since there is nothing that requires - // Ghostty to be in an app bundle. - if (try tryDir(alloc, dir, "share", sentinel)) |v| { - return v; - } - } - - return null; - } - - /// Little helper to check if the "base/sub/suffix" directory exists and - /// if so, duplicate the "base/sub" path and return it. - fn tryDir( - alloc: Allocator, - base: []const u8, - sub: []const u8, - suffix: []const u8, - ) !?[]const u8 { - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const path = try std.fmt.bufPrint(&buf, "{s}/{s}/{s}", .{ base, sub, suffix }); - - if (std.fs.accessAbsolute(path, .{})) { - const len = path.len - suffix.len - 1; - return try alloc.dupe(u8, path[0..len]); - } else |_| { - // Folder doesn't exist. If a different error happens its okay - // we just ignore it and move on. - } - - return null; - } }; /// The read thread sits in a loop doing the following pseudo code: diff --git a/src/termio/Options.zig b/src/termio/Options.zig index 5e88b1010..cad5d5665 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -20,6 +20,9 @@ full_config: *const Config, /// The derived configuration for this termio implementation. config: termio.Impl.DerivedConfig, +/// The application resources directory. +resources_dir: ?[]const u8, + /// The render state. The IO implementation can modify anything here. The /// surface thread will setup the initial "terminal" pointer but the IO impl /// is free to change that if that is useful (i.e. doing some sort of dual