diff --git a/src/config/Config.zig b/src/config/Config.zig index 49eb55df7..00b856ceb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -336,28 +336,15 @@ palette: Palette = .{}, /// - SHELL environment variable /// - passwd entry (user information) /// -/// The command is the path to only the binary to run. This cannot -/// also contain arguments, because Ghostty does not perform any -/// shell string parsing. To provide additional arguments, use the -/// "command-arg" configuration (repeated for multiple arguments). +/// This can contain additional arguments to run the command with. +/// If additional arguments are provided, the command will be executed +/// using "/bin/sh -c". Ghostty does not do any shell command parsing. /// /// If you're using the `ghostty` CLI there is also a shortcut /// to run a command with argumens directly: you can use the `-e` /// 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, -/// 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 /// 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 @@ -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 (!std.mem.eql(u8, arg, "-e")) return true; - // The first value is the command to run. - if (iter.next()) |command| { - self.command = try alloc.dupe(u8, command); - } else { + // Build up the command. We don't clean this up because we take + // ownership in our allocator. + var command = std.ArrayList(u8).init(alloc); + errdefer command.deinit(); + + while (iter.next()) |param| { + try command.appendSlice(param); + try command.append(' '); + } + + if (command.items.len == 0) { try self._errors.add(alloc, .{ .message = try std.fmt.allocPrintZ( alloc, @@ -1667,11 +1661,7 @@ pub fn parseManuallyHook(self: *Config, alloc: Allocator, arg: []const u8, iter: return false; } - // All further arguments are parameters - self.@"command-arg".list.clearRetainingCapacity(); - while (iter.next()) |param| { - try self.@"command-arg".parseCLI(alloc, param); - } + self.command = command.items[0 .. command.items.len - 1]; // Do not continue, we consumed everything. return false; @@ -1856,25 +1846,7 @@ test "parse e: command and args" { var it: TestIterator = .{ .data = &.{ "echo", "foo", "bar baz" } }; try testing.expect(!try cfg.parseManuallyHook(alloc, "-e", &it)); - try testing.expectEqualStrings("echo", 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); + try testing.expectEqualStrings("echo foo bar baz", cfg.command.?); } test "clone default" { diff --git a/src/os/passwd.zig b/src/os/passwd.zig index 8aaf5ed42..f93d42f1b 100644 --- a/src/os/passwd.zig +++ b/src/os/passwd.zig @@ -26,6 +26,7 @@ const c = if (builtin.os.tag != .windows) @cImport({ pub const Entry = struct { shell: ?[]const u8 = null, home: ?[]const u8 = null, + name: ?[]const u8 = null, }; /// Get the passwd entry for the currently executing user. @@ -134,6 +135,13 @@ pub fn get(alloc: Allocator) !Entry { 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; } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index dc6b7d6d3..f6a4e3585 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -709,7 +709,6 @@ const Subprocess = struct { arena: std.heap.ArenaAllocator, cwd: ?[]const u8, env: EnvMap, - path: []const u8, args: [][]const u8, grid_size: renderer.GridSize, screen_size: renderer.ScreenSize, @@ -731,43 +730,6 @@ const Subprocess = struct { .windows => "cmd.exe", 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 // inherit our environment because the login shell on the host side @@ -838,23 +800,102 @@ const Subprocess = struct { // Build our args list 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); defer args.deinit(); - if (!internal_os.isFlatpak()) { - try args.append(argv0_override orelse path); - } else { - // We run our shell wrapped in a /bin/sh login shell because - // some systems do not properly initialize the env vars unless - // we start this way (NixOS!) - try args.append("/bin/sh"); - try args.append("-l"); + // If we're on macOS, we have to use `login(1)` to get all of + // the proper environment variables set, a login shell, and proper + // hushlogin behavior. + if (comptime builtin.target.isDarwin()) darwin: { + const passwd = internal_os.passwd.get(alloc) catch |err| { + log.warn("failed to read passwd, not using a login shell err={}", .{err}); + break :darwin; + }; + + 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(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(); }; @@ -865,9 +906,6 @@ const Subprocess = struct { else null; - // The execution path - const final_path = if (internal_os.isFlatpak()) args[0] else path; - // Setup our shell integration, if we can. const shell_integrated: ?shell_integration.Shell = shell: { const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { @@ -877,10 +915,19 @@ const Subprocess = struct { .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; + break :shell try shell_integration.setup( dir, - final_path, + path, &env, force, opts.full_config.@"shell-integration-features", @@ -902,7 +949,6 @@ const Subprocess = struct { .arena = arena, .env = env, .cwd = cwd, - .path = final_path, .args = args, .grid_size = opts.grid_size, .screen_size = padded_size, @@ -938,10 +984,7 @@ const Subprocess = struct { self.pty = null; } - log.debug("starting command path={s} args={s}", .{ - self.path, - self.args, - }); + log.debug("starting command command={s}", .{self.args}); // In flatpak, we use the HostCommand to execute our shell. if (internal_os.isFlatpak()) flatpak: { @@ -950,10 +993,6 @@ const Subprocess = struct { 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. self.flatpak_command = .{ .argv = self.args, @@ -967,7 +1006,7 @@ const Subprocess = struct { errdefer killCommandFlatpak(cmd); log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ - self.path, + self.args[0], pid, }); @@ -996,7 +1035,7 @@ const Subprocess = struct { // Build our subcommand var cmd: Command = .{ - .path = self.path, + .path = self.args[0], .args = self.args, .env = &self.env, .cwd = cwd, @@ -1017,7 +1056,7 @@ const Subprocess = struct { errdefer killCommand(&cmd) catch |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; return switch (builtin.os.tag) {