Merge pull request #1102 from mitchellh/macos-login

macOS: use login command, "command" now accepts arguments directly
This commit is contained in:
Mitchell Hashimoto
2023-12-15 10:13:52 -08:00
committed by GitHub
3 changed files with 128 additions and 109 deletions

View File

@ -336,28 +336,15 @@ palette: Palette = .{},
/// - SHELL environment variable /// - SHELL environment variable
/// - passwd entry (user information) /// - passwd entry (user information)
/// ///
/// The command is the path to only the binary to run. This cannot /// This can contain additional arguments to run the command with.
/// also contain arguments, because Ghostty does not perform any /// If additional arguments are provided, the command will be executed
/// shell string parsing. To provide additional arguments, use the /// using "/bin/sh -c". Ghostty does not do any shell command parsing.
/// "command-arg" configuration (repeated for multiple arguments).
/// ///
/// If you're using the `ghostty` CLI there is also a shortcut /// If you're using the `ghostty` CLI there is also a shortcut
/// to run a command with argumens directly: you can use the `-e` /// to run a command with argumens directly: you can use the `-e`
/// flag. For example: `ghostty -e fish --with --custom --args`. /// flag. For example: `ghostty -e fish --with --custom --args`.
/// This is just shorthand for specifying "command" and
/// "command-arg" in the configuration.
command: ?[]const u8 = null, command: ?[]const u8 = null,
/// A single argument to pass to the command. This can be repeated to
/// pass multiple arguments. This slightly clunky configuration style is
/// so that Ghostty doesn't have to perform any sort of shell parsing
/// to find argument boundaries.
///
/// This cannot be used to override argv[0]. argv[0] will always be
/// set by Ghostty to be the command (possibly with a hyphen-prefix to
/// indicate that it is a login shell, depending on the OS).
@"command-arg": RepeatableString = .{},
/// Match a regular expression against the terminal text and associate /// Match a regular expression against the terminal text and associate
/// clicking it with an action. This can be used to match URLs, file paths, /// clicking it with an action. This can be used to match URLs, file paths,
/// etc. Actions can be opening using the system opener (i.e. "open" or /// etc. Actions can be opening using the system opener (i.e. "open" or
@ -1652,10 +1639,17 @@ pub fn parseManuallyHook(self: *Config, alloc: Allocator, arg: []const u8, iter:
// If it isn't "-e" then we just continue parsing normally. // If it isn't "-e" then we just continue parsing normally.
if (!std.mem.eql(u8, arg, "-e")) return true; if (!std.mem.eql(u8, arg, "-e")) return true;
// The first value is the command to run. // Build up the command. We don't clean this up because we take
if (iter.next()) |command| { // ownership in our allocator.
self.command = try alloc.dupe(u8, command); var command = std.ArrayList(u8).init(alloc);
} else { errdefer command.deinit();
while (iter.next()) |param| {
try command.appendSlice(param);
try command.append(' ');
}
if (command.items.len == 0) {
try self._errors.add(alloc, .{ try self._errors.add(alloc, .{
.message = try std.fmt.allocPrintZ( .message = try std.fmt.allocPrintZ(
alloc, alloc,
@ -1667,11 +1661,7 @@ pub fn parseManuallyHook(self: *Config, alloc: Allocator, arg: []const u8, iter:
return false; return false;
} }
// All further arguments are parameters self.command = command.items[0 .. command.items.len - 1];
self.@"command-arg".list.clearRetainingCapacity();
while (iter.next()) |param| {
try self.@"command-arg".parseCLI(alloc, param);
}
// Do not continue, we consumed everything. // Do not continue, we consumed everything.
return false; return false;
@ -1856,25 +1846,7 @@ test "parse e: command and args" {
var it: TestIterator = .{ .data = &.{ "echo", "foo", "bar baz" } }; var it: TestIterator = .{ .data = &.{ "echo", "foo", "bar baz" } };
try testing.expect(!try cfg.parseManuallyHook(alloc, "-e", &it)); try testing.expect(!try cfg.parseManuallyHook(alloc, "-e", &it));
try testing.expectEqualStrings("echo", cfg.command.?); try testing.expectEqualStrings("echo foo bar baz", cfg.command.?);
try testing.expectEqual(@as(usize, 2), cfg.@"command-arg".list.items.len);
try testing.expectEqualStrings("foo", cfg.@"command-arg".list.items[0]);
try testing.expectEqualStrings("bar baz", cfg.@"command-arg".list.items[1]);
}
test "parse e: command replaces args" {
const testing = std.testing;
var cfg = try Config.default(testing.allocator);
defer cfg.deinit();
const alloc = cfg._arena.?.allocator();
try cfg.@"command-arg".parseCLI(alloc, "foo");
try testing.expectEqual(@as(usize, 1), cfg.@"command-arg".list.items.len);
var it: TestIterator = .{ .data = &.{"echo"} };
try testing.expect(!try cfg.parseManuallyHook(alloc, "-e", &it));
try testing.expectEqualStrings("echo", cfg.command.?);
try testing.expectEqual(@as(usize, 0), cfg.@"command-arg".list.items.len);
} }
test "clone default" { test "clone default" {

View File

@ -26,6 +26,7 @@ const c = if (builtin.os.tag != .windows) @cImport({
pub const Entry = struct { pub const Entry = struct {
shell: ?[]const u8 = null, shell: ?[]const u8 = null,
home: ?[]const u8 = null, home: ?[]const u8 = null,
name: ?[]const u8 = null,
}; };
/// Get the passwd entry for the currently executing user. /// Get the passwd entry for the currently executing user.
@ -134,6 +135,13 @@ pub fn get(alloc: Allocator) !Entry {
result.home = dir; result.home = dir;
} }
if (pw.pw_name) |ptr| {
const source = std.mem.sliceTo(ptr, 0);
const name = try alloc.alloc(u8, source.len);
@memcpy(name, source);
result.name = name;
}
return result; return result;
} }

View File

@ -709,7 +709,6 @@ const Subprocess = struct {
arena: std.heap.ArenaAllocator, arena: std.heap.ArenaAllocator,
cwd: ?[]const u8, cwd: ?[]const u8,
env: EnvMap, env: EnvMap,
path: []const u8,
args: [][]const u8, args: [][]const u8,
grid_size: renderer.GridSize, grid_size: renderer.GridSize,
screen_size: renderer.ScreenSize, screen_size: renderer.ScreenSize,
@ -731,43 +730,6 @@ const Subprocess = struct {
.windows => "cmd.exe", .windows => "cmd.exe",
else => "sh", else => "sh",
}; };
const path = try Command.expandPath(
alloc,
opts.full_config.command orelse default_path,
) orelse path: {
// If we had a command specified, try to at least fall
// back to a default value like "sh" so that Ghostty
// launches.
if (opts.full_config.command) |command| {
log.warn("unable to find command, fallbacking back to default command={s}", .{command});
if (try Command.expandPath(
alloc,
default_path,
)) |path| break :path path;
}
log.warn("unable to find default command to launch, exiting", .{});
return error.CommandNotFound;
};
// On macOS, we launch the program as a login shell. This is a Mac-specific
// behavior (see other terminals). Terminals in general should NOT be
// spawning login shells because well... we're not "logging in." The solution
// is to put dotfiles in "rc" variants rather than "_login" variants. But,
// history!
const argv0_override: ?[]const u8 = if (comptime builtin.target.isDarwin()) argv0: {
// Get rid of the path
const argv0 = if (std.mem.lastIndexOf(u8, path, "/")) |idx|
path[idx + 1 ..]
else
path;
// Copy it with a hyphen so its a login shell
const argv0_buf = try alloc.alloc(u8, argv0.len + 1);
argv0_buf[0] = '-';
@memcpy(argv0_buf[1..], argv0);
break :argv0 argv0_buf;
} else null;
// Set our env vars. For Flatpak builds running in Flatpak we don't // Set our env vars. For Flatpak builds running in Flatpak we don't
// inherit our environment because the login shell on the host side // inherit our environment because the login shell on the host side
@ -838,23 +800,102 @@ const Subprocess = struct {
// Build our args list // Build our args list
const args = args: { const args = args: {
const cap = 1 + opts.full_config.@"command-arg".list.items.len; const cap = 6; // the most we'll use on macOS
var args = try std.ArrayList([]const u8).initCapacity(alloc, cap); var args = try std.ArrayList([]const u8).initCapacity(alloc, cap);
defer args.deinit(); defer args.deinit();
if (!internal_os.isFlatpak()) { // If we're on macOS, we have to use `login(1)` to get all of
try args.append(argv0_override orelse path); // the proper environment variables set, a login shell, and proper
} else { // hushlogin behavior.
// We run our shell wrapped in a /bin/sh login shell because if (comptime builtin.target.isDarwin()) darwin: {
// some systems do not properly initialize the env vars unless const passwd = internal_os.passwd.get(alloc) catch |err| {
// we start this way (NixOS!) log.warn("failed to read passwd, not using a login shell err={}", .{err});
try args.append("/bin/sh"); break :darwin;
try args.append("-l"); };
const username = passwd.name orelse {
log.warn("failed to get username, not using a login shell", .{});
break :darwin;
};
const hush = if (passwd.home) |home| hush: {
var dir = std.fs.openDirAbsolute(home, .{}) catch |err| {
log.warn(
"failed to open home dir, not checking for hushlogin err={}",
.{err},
);
break :hush false;
};
defer dir.close();
break :hush if (dir.access(".hushlogin", .{})) true else |_| false;
} else false;
const cmd = try std.fmt.allocPrint(
alloc,
"exec -l {s}",
.{opts.full_config.command orelse default_path},
);
// The reason for executing login this way is unclear. This
// comment will attempt to explain but prepare for a truly
// unhinged reality.
//
// The first major issue is that on macOS, a lot of users
// put shell configurations in ~/.bash_profile instead of
// ~/.bashrc (or equivalent for another shell). This file is only
// loaded for a login shell so macOS users expect all their terminals
// to be login shells. No other platform behaves this way and its
// totally braindead but somehow the entire dev community on
// macOS has cargo culted their way to this reality so we have to
// do it...
//
// To get a login shell, you COULD just prepend argv0 with a `-`
// but that doesn't fully work because `getlogin()` C API will
// return the wrong value, SHELL won't be set, and various
// other login behaviors that macOS users expect.
//
// The proper way is to use `login(1)`. But login(1) forces
// the working directory to change to the home directory,
// which we may not want. If we specify "-l" then we can avoid
// this behavior but now the shell isn't a login shell.
//
// There is another issue: `login(1)` only checks for ".hushlogin"
// in the working directory. This means that if we specify "-l"
// then we won't get hushlogin honored if its in the home
// directory (which is standard). To get around this, we
// check for hushlogin ourselves and if present specify the
// "-q" flag to login(1).
//
// So to get all the behaviors we want, we specify "-l" but
// execute "zsh" (which is built-in to macOS). We then use
// the zsh builtin "exec" to replace the process with a login
// shell ("-l" on exec) with the command we really want.
//
// To figure out a lot of this logic I read the login.c
// source code in the OSS distribution Apple provides for
// macOS.
//
// Awesome.
try args.append("/usr/bin/login");
if (hush) try args.append("-q");
try args.append("-flp");
try args.append(username);
try args.append("/bin/zsh");
try args.append("-c"); try args.append("-c");
try args.append(path); try args.append(cmd);
break :args try args.toOwnedSlice();
} }
try args.appendSlice(opts.full_config.@"command-arg".list.items); // We run our shell wrapped in `/bin/sh` so that we don't have
// to parse the commadnd line ourselves if it has arguments.
// Additionally, some environments (NixOS, I found) use /bin/sh
// to setup some environment variables that are important to
// have set.
try args.append("/bin/sh");
if (internal_os.isFlatpak()) try args.append("-l");
try args.append("-c");
try args.append(opts.full_config.command orelse default_path);
break :args try args.toOwnedSlice(); break :args try args.toOwnedSlice();
}; };
@ -865,9 +906,6 @@ const Subprocess = struct {
else else
null; null;
// The execution path
const final_path = if (internal_os.isFlatpak()) args[0] else path;
// Setup our shell integration, if we can. // Setup our shell integration, if we can.
const shell_integrated: ?shell_integration.Shell = shell: { const shell_integrated: ?shell_integration.Shell = shell: {
const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") {
@ -877,10 +915,19 @@ const Subprocess = struct {
.zsh => .zsh, .zsh => .zsh,
}; };
// We have to get the path to the executing shell. The command
// can be a full shell string with arguments so we look for a space
// and take the first part.
const path = if (opts.full_config.command) |cmd| path: {
const idx = std.mem.indexOfScalar(u8, cmd, ' ') orelse cmd.len;
break :path cmd[0..idx];
} else default_path;
const dir = opts.resources_dir orelse break :shell null; const dir = opts.resources_dir orelse break :shell null;
break :shell try shell_integration.setup( break :shell try shell_integration.setup(
dir, dir,
final_path, path,
&env, &env,
force, force,
opts.full_config.@"shell-integration-features", opts.full_config.@"shell-integration-features",
@ -902,7 +949,6 @@ const Subprocess = struct {
.arena = arena, .arena = arena,
.env = env, .env = env,
.cwd = cwd, .cwd = cwd,
.path = final_path,
.args = args, .args = args,
.grid_size = opts.grid_size, .grid_size = opts.grid_size,
.screen_size = padded_size, .screen_size = padded_size,
@ -938,10 +984,7 @@ const Subprocess = struct {
self.pty = null; self.pty = null;
} }
log.debug("starting command path={s} args={s}", .{ log.debug("starting command command={s}", .{self.args});
self.path,
self.args,
});
// In flatpak, we use the HostCommand to execute our shell. // In flatpak, we use the HostCommand to execute our shell.
if (internal_os.isFlatpak()) flatpak: { if (internal_os.isFlatpak()) flatpak: {
@ -950,10 +993,6 @@ const Subprocess = struct {
break :flatpak; break :flatpak;
} }
// For flatpak our path and argv[0] must match because that is
// used for execution by the dbus API.
assert(std.mem.eql(u8, self.path, self.args[0]));
// Flatpak command must have a stable pointer. // Flatpak command must have a stable pointer.
self.flatpak_command = .{ self.flatpak_command = .{
.argv = self.args, .argv = self.args,
@ -967,7 +1006,7 @@ const Subprocess = struct {
errdefer killCommandFlatpak(cmd); errdefer killCommandFlatpak(cmd);
log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ log.info("started subcommand on host via flatpak API path={s} pid={?}", .{
self.path, self.args[0],
pid, pid,
}); });
@ -996,7 +1035,7 @@ const Subprocess = struct {
// Build our subcommand // Build our subcommand
var cmd: Command = .{ var cmd: Command = .{
.path = self.path, .path = self.args[0],
.args = self.args, .args = self.args,
.env = &self.env, .env = &self.env,
.cwd = cwd, .cwd = cwd,
@ -1017,7 +1056,7 @@ const Subprocess = struct {
errdefer killCommand(&cmd) catch |err| { errdefer killCommand(&cmd) catch |err| {
log.warn("error killing command during cleanup err={}", .{err}); log.warn("error killing command during cleanup err={}", .{err});
}; };
log.info("started subcommand path={s} pid={?}", .{ self.path, cmd.pid }); log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid });
self.command = cmd; self.command = cmd;
return switch (builtin.os.tag) { return switch (builtin.os.tag) {