diff --git a/include/ghostty.h b/include/ghostty.h index fc2c915cb..0c5a63448 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -385,6 +385,11 @@ typedef struct { bool rectangle; } ghostty_selection_s; +typedef struct { + const char* key; + const char* value; +} ghostty_env_var_s; + typedef struct { void* nsview; } ghostty_platform_macos_s; @@ -406,6 +411,8 @@ typedef struct { float font_size; const char* working_directory; const char* command; + ghostty_env_var_s* env_vars; + size_t env_var_count; } ghostty_surface_config_s; typedef struct { @@ -807,7 +814,8 @@ void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); ghostty_surface_config_s ghostty_surface_config_new(); -ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*); +ghostty_surface_t ghostty_surface_new(ghostty_app_t, + const ghostty_surface_config_s*); void ghostty_surface_free(ghostty_surface_t); void* ghostty_surface_userdata(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6b0cfd6f8..a64e6038e 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; }; A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; }; + A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -153,6 +154,7 @@ A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = ""; }; A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = ""; }; + A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; @@ -506,6 +508,7 @@ A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, + A51194122E05D003007258CC /* Optional+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, @@ -786,6 +789,7 @@ CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, + A51194132E05D006007258CC /* Optional+Extension.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 371e4ff41..2f0623b79 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -418,18 +418,36 @@ extension Ghostty { /// Explicit command to set var command: String? = nil + + /// Environment variables to set for the terminal + var environmentVariables: [String: String] = [:] init() {} init(from config: ghostty_surface_config_s) { self.fontSize = config.font_size - self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8) - self.command = String.init(cString: config.command, encoding: .utf8) + if let workingDirectory = config.working_directory { + self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8) + } + if let command = config.command { + self.command = String.init(cString: command, encoding: .utf8) + } + + // Convert the C env vars to Swift dictionary + if config.env_var_count > 0, let envVars = config.env_vars { + for i in 0.. ghostty_surface_config_s { + /// Provides a C-compatible ghostty configuration within a closure. The configuration + /// and all its string pointers are only valid within the closure. + func withCValue(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T { var config = ghostty_surface_config_new() config.userdata = Unmanaged.passUnretained(view).toOpaque() #if os(macOS) @@ -438,7 +456,6 @@ extension Ghostty { nsview: Unmanaged.passUnretained(view).toOpaque() )) config.scale_factor = NSScreen.main!.backingScaleFactor - #elseif os(iOS) config.platform_tag = GHOSTTY_PLATFORM_IOS config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( @@ -453,15 +470,42 @@ extension Ghostty { #error("unsupported target") #endif - if let fontSize = fontSize { config.font_size = fontSize } - if let workingDirectory = workingDirectory { - config.working_directory = (workingDirectory as NSString).utf8String - } - if let command = command { - config.command = (command as NSString).utf8String - } + // Zero is our default value that means to inherit the font size. + config.font_size = fontSize ?? 0 - return config + // Use withCString to ensure strings remain valid for the duration of the closure + return try workingDirectory.withCString { cWorkingDir in + config.working_directory = cWorkingDir + + return try command.withCString { cCommand in + config.command = cCommand + + // Convert dictionary to arrays for easier processing + let keys = Array(environmentVariables.keys) + let values = Array(environmentVariables.values) + + // Create C strings for all keys and values + return try keys.withCStrings { keyCStrings in + return try values.withCStrings { valueCStrings in + // Create array of ghostty_env_var_s + var envVars = Array() + envVars.reserveCapacity(environmentVariables.count) + for i in 0..(_ body: ([UnsafePointer?]) throws -> T) rethrows -> T { + // Handle empty array + if isEmpty { + return try body([]) + } + + // Recursive helper to process strings + func helper(index: Int, accumulated: [UnsafePointer?], body: ([UnsafePointer?]) throws -> T) rethrows -> T { + if index == count { + return try body(accumulated) + } else { + return try self[index].withCString { cStr in + var newAccumulated = accumulated + newAccumulated.append(cStr) + return try helper(index: index + 1, accumulated: newAccumulated, body: body) + } + } + } + + return try helper(index: 0, accumulated: [], body: body) + } +} diff --git a/macos/Sources/Helpers/Extensions/Optional+Extension.swift b/macos/Sources/Helpers/Extensions/Optional+Extension.swift new file mode 100644 index 000000000..a844c0fe9 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Optional+Extension.swift @@ -0,0 +1,10 @@ +extension Optional where Wrapped == String { + /// Executes a closure with a C string pointer, handling nil gracefully. + func withCString(_ body: (UnsafePointer?) throws -> T) rethrows -> T { + if let string = self { + return try string.withCString(body) + } else { + return try body(nil) + } + } +} diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 01e287d16..02f143985 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -376,6 +376,14 @@ pub const PlatformTag = enum(c_int) { ios = 2, }; +pub const EnvVar = extern struct { + /// The name of the environment variable. + key: [*:0]const u8, + + /// The value of the environment variable. + value: [*:0]const u8, +}; + pub const Surface = struct { app: *App, platform: Platform, @@ -407,7 +415,7 @@ pub const Surface = struct { font_size: f32 = 0, /// The working directory to load into. - working_directory: [*:0]const u8 = "", + working_directory: ?[*:0]const u8 = null, /// The command to run in the new surface. If this is set then /// the "wait-after-command" option is also automatically set to true, @@ -417,7 +425,11 @@ pub const Surface = struct { /// despite Ghostty allowing directly executed commands via config. /// This is a legacy thing and we should probably change it in the /// future once we have a concrete use case. - command: [*:0]const u8 = "", + command: ?[*:0]const u8 = null, + + /// Extra environment variables to set for the surface. + env_vars: ?[*]EnvVar = null, + env_var_count: usize = 0, }; pub fn init(self: *Surface, app: *App, opts: Options) !void { @@ -443,41 +455,59 @@ pub const Surface = struct { defer config.deinit(); // If we have a working directory from the options then we set it. - const wd = std.mem.sliceTo(opts.working_directory, 0); - if (wd.len > 0) wd: { - var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { - log.warn( - "error opening requested working directory dir={s} err={}", - .{ wd, err }, - ); - break :wd; - }; - defer dir.close(); + if (opts.working_directory) |c_wd| { + const wd = std.mem.sliceTo(c_wd, 0); + if (wd.len > 0) wd: { + var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { + log.warn( + "error opening requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; + defer dir.close(); - const stat = dir.stat() catch |err| { - log.warn( - "failed to stat requested working directory dir={s} err={}", - .{ wd, err }, - ); - break :wd; - }; + const stat = dir.stat() catch |err| { + log.warn( + "failed to stat requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; - if (stat.kind != .directory) { - log.warn( - "requested working directory is not a directory dir={s}", - .{wd}, - ); - break :wd; + if (stat.kind != .directory) { + log.warn( + "requested working directory is not a directory dir={s}", + .{wd}, + ); + break :wd; + } + + config.@"working-directory" = wd; } - - config.@"working-directory" = wd; } // If we have a command from the options then we set it. - const cmd = std.mem.sliceTo(opts.command, 0); - if (cmd.len > 0) { - config.command = .{ .shell = cmd }; - config.@"wait-after-command" = true; + if (opts.command) |c_command| { + const cmd = std.mem.sliceTo(c_command, 0); + if (cmd.len > 0) { + config.command = .{ .shell = cmd }; + config.@"wait-after-command" = true; + } + } + + // Apply any environment variables that were requested. + if (opts.env_var_count > 0) { + const alloc = config.arenaAlloc(); + for (opts.env_vars.?[0..opts.env_var_count]) |env_var| { + const key = std.mem.sliceTo(env_var.key, 0); + const value = std.mem.sliceTo(env_var.value, 0); + try config.env.map.put( + alloc, + try alloc.dupeZ(u8, key), + try alloc.dupeZ(u8, value), + ); + } } // Initialize our surface right away. We're given a view that is diff --git a/src/config/Config.zig b/src/config/Config.zig index 2df66ba45..e9370d9b3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3004,6 +3004,11 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { } } +/// Get the arena allocator associated with the configuration. +pub fn arenaAlloc(self: *Config) Allocator { + return self._arena.?.allocator(); +} + /// Change the state of conditionals and reload the configuration /// based on the new state. This returns a new configuration based /// on the new state. The caller must free the old configuration if they