macos: support env vars for surface config, clean up surface config

This commit is contained in:
Mitchell Hashimoto
2025-06-20 10:09:01 -07:00
parent e6c24fbf0a
commit f8bc9b547c
9 changed files with 180 additions and 50 deletions

View File

@ -385,6 +385,11 @@ typedef struct {
bool rectangle; bool rectangle;
} ghostty_selection_s; } ghostty_selection_s;
typedef struct {
const char* key;
const char* value;
} ghostty_env_var_s;
typedef struct { typedef struct {
void* nsview; void* nsview;
} ghostty_platform_macos_s; } ghostty_platform_macos_s;
@ -406,6 +411,8 @@ typedef struct {
float font_size; float font_size;
const char* working_directory; const char* working_directory;
const char* command; const char* command;
ghostty_env_var_s* env_vars;
size_t env_var_count;
} ghostty_surface_config_s; } ghostty_surface_config_s;
typedef struct { 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_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_free(ghostty_surface_t);
void* ghostty_surface_userdata(ghostty_surface_t); void* ghostty_surface_userdata(ghostty_surface_t);
ghostty_app_t ghostty_surface_app(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t);

View File

@ -15,6 +15,7 @@
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; };
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; }; A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; };
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.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 */; }; 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 */; }; 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 */; }; 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 = "<group>"; }; A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; }; A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; };
A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = "<group>"; }; A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = "<group>"; };
A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = "<group>"; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; }; A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; };
A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; }; A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; };
@ -506,6 +508,7 @@
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, A586366E2DF25D8300E04A10 /* Duration+Extension.swift */,
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
A51194122E05D003007258CC /* Optional+Extension.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
@ -786,6 +789,7 @@
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
A51194132E05D006007258CC /* Optional+Extension.swift in Sources */,
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */,

View File

@ -418,18 +418,36 @@ extension Ghostty {
/// Explicit command to set /// Explicit command to set
var command: String? = nil var command: String? = nil
/// Environment variables to set for the terminal
var environmentVariables: [String: String] = [:]
init() {} init() {}
init(from config: ghostty_surface_config_s) { init(from config: ghostty_surface_config_s) {
self.fontSize = config.font_size self.fontSize = config.font_size
self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8) if let workingDirectory = config.working_directory {
self.command = String.init(cString: config.command, encoding: .utf8) 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..<config.env_var_count {
let envVar = envVars[i]
if let key = String(cString: envVar.key, encoding: .utf8),
let value = String(cString: envVar.value, encoding: .utf8) {
self.environmentVariables[key] = value
}
}
}
} }
/// Returns the ghostty configuration for this surface configuration struct. The memory /// Provides a C-compatible ghostty configuration within a closure. The configuration
/// in the returned struct is only valid as long as this struct is retained. /// and all its string pointers are only valid within the closure.
func ghosttyConfig(view: SurfaceView) -> ghostty_surface_config_s { func withCValue<T>(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T {
var config = ghostty_surface_config_new() var config = ghostty_surface_config_new()
config.userdata = Unmanaged.passUnretained(view).toOpaque() config.userdata = Unmanaged.passUnretained(view).toOpaque()
#if os(macOS) #if os(macOS)
@ -438,7 +456,6 @@ extension Ghostty {
nsview: Unmanaged.passUnretained(view).toOpaque() nsview: Unmanaged.passUnretained(view).toOpaque()
)) ))
config.scale_factor = NSScreen.main!.backingScaleFactor config.scale_factor = NSScreen.main!.backingScaleFactor
#elseif os(iOS) #elseif os(iOS)
config.platform_tag = GHOSTTY_PLATFORM_IOS config.platform_tag = GHOSTTY_PLATFORM_IOS
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
@ -453,15 +470,42 @@ extension Ghostty {
#error("unsupported target") #error("unsupported target")
#endif #endif
if let fontSize = fontSize { config.font_size = fontSize } // Zero is our default value that means to inherit the font size.
if let workingDirectory = workingDirectory { config.font_size = fontSize ?? 0
config.working_directory = (workingDirectory as NSString).utf8String
}
if let command = command {
config.command = (command as NSString).utf8String
}
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<ghostty_env_var_s>()
envVars.reserveCapacity(environmentVariables.count)
for i in 0..<environmentVariables.count {
envVars.append(ghostty_env_var_s(
key: keyCStrings[i],
value: valueCStrings[i]
))
}
return try envVars.withUnsafeMutableBufferPointer { buffer in
config.env_vars = buffer.baseAddress
config.env_var_count = environmentVariables.count
return try body(&config)
}
}
}
}
}
} }
} }

View File

@ -290,8 +290,10 @@ extension Ghostty {
// Setup our surface. This will also initialize all the terminal IO. // Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration() let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { ghostty_surface_new(app, &surface_cfg_c)
}
guard let surface = surface else {
self.error = Ghostty.Error.apiFailed self.error = Ghostty.Error.apiFailed
return return
} }

View File

@ -57,8 +57,10 @@ extension Ghostty {
// Setup our surface. This will also initialize all the terminal IO. // Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration() let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { ghostty_surface_new(app, &surface_cfg_c)
}
guard let surface = surface else {
// TODO // TODO
return return
} }

View File

@ -21,3 +21,28 @@ extension Array {
return i + 1 return i + 1
} }
} }
extension Array where Element == String {
/// Executes a closure with an array of C string pointers.
func withCStrings<T>(_ body: ([UnsafePointer<Int8>?]) throws -> T) rethrows -> T {
// Handle empty array
if isEmpty {
return try body([])
}
// Recursive helper to process strings
func helper(index: Int, accumulated: [UnsafePointer<Int8>?], body: ([UnsafePointer<Int8>?]) 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)
}
}

View File

@ -0,0 +1,10 @@
extension Optional where Wrapped == String {
/// Executes a closure with a C string pointer, handling nil gracefully.
func withCString<T>(_ body: (UnsafePointer<Int8>?) throws -> T) rethrows -> T {
if let string = self {
return try string.withCString(body)
} else {
return try body(nil)
}
}
}

View File

@ -376,6 +376,14 @@ pub const PlatformTag = enum(c_int) {
ios = 2, 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 { pub const Surface = struct {
app: *App, app: *App,
platform: Platform, platform: Platform,
@ -407,7 +415,7 @@ pub const Surface = struct {
font_size: f32 = 0, font_size: f32 = 0,
/// The working directory to load into. /// 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 command to run in the new surface. If this is set then
/// the "wait-after-command" option is also automatically set to true, /// 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. /// despite Ghostty allowing directly executed commands via config.
/// This is a legacy thing and we should probably change it in the /// This is a legacy thing and we should probably change it in the
/// future once we have a concrete use case. /// 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 { pub fn init(self: *Surface, app: *App, opts: Options) !void {
@ -443,41 +455,59 @@ pub const Surface = struct {
defer config.deinit(); defer config.deinit();
// If we have a working directory from the options then we set it. // If we have a working directory from the options then we set it.
const wd = std.mem.sliceTo(opts.working_directory, 0); if (opts.working_directory) |c_wd| {
if (wd.len > 0) wd: { const wd = std.mem.sliceTo(c_wd, 0);
var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { if (wd.len > 0) wd: {
log.warn( var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| {
"error opening requested working directory dir={s} err={}", log.warn(
.{ wd, err }, "error opening requested working directory dir={s} err={}",
); .{ wd, err },
break :wd; );
}; break :wd;
defer dir.close(); };
defer dir.close();
const stat = dir.stat() catch |err| { const stat = dir.stat() catch |err| {
log.warn( log.warn(
"failed to stat requested working directory dir={s} err={}", "failed to stat requested working directory dir={s} err={}",
.{ wd, err }, .{ wd, err },
); );
break :wd; break :wd;
}; };
if (stat.kind != .directory) { if (stat.kind != .directory) {
log.warn( log.warn(
"requested working directory is not a directory dir={s}", "requested working directory is not a directory dir={s}",
.{wd}, .{wd},
); );
break :wd; break :wd;
}
config.@"working-directory" = wd;
} }
config.@"working-directory" = wd;
} }
// If we have a command from the options then we set it. // If we have a command from the options then we set it.
const cmd = std.mem.sliceTo(opts.command, 0); if (opts.command) |c_command| {
if (cmd.len > 0) { const cmd = std.mem.sliceTo(c_command, 0);
config.command = .{ .shell = cmd }; if (cmd.len > 0) {
config.@"wait-after-command" = true; 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 // Initialize our surface right away. We're given a view that is

View File

@ -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 /// Change the state of conditionals and reload the configuration
/// based on the new state. This returns a new configuration based /// based on the new state. This returns a new configuration based
/// on the new state. The caller must free the old configuration if they /// on the new state. The caller must free the old configuration if they