From 587f47a5870fa9ddc4558452b76ffbdab0a16f38 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Sep 2025 20:57:32 -0700 Subject: [PATCH] apprt/gtk-ng: clean up our single instance, new window interactions This removes `launched-from` entirely and moves our `gtk-single-instance` detection logic to assume true unless we detect CLI instead of assume false unless we detect desktop/dbus/systemd. The "assume true" scenario for single instance is desirable because detecting a CLI instance is much more reliable. Removing `launched-from` fixes an issue where we had a difficult-to-understand relationship between `launched-from`, `gtk-single-instance`, and `initial-window`. Now, only `gtk-single-instance` has some hueristic logic. And `initial-window` ALWAYS sends a GTK activation signal regardless of single instance or not. As a result, we need to be explicit in our systemd, dbus, desktop files about what we want Ghostty to do, but everything works as you'd mostly expect. Now, if you put plain old `ghostty` in your terminal, you get a new Ghostty instance. If you put it anywhere else, you get a GTK single instance activation call (either creates a first instance or opens a new window in the existing instance). Works for launchers and so on. --- dist/linux/app.desktop.in | 4 +- dist/linux/dbus.service.flatpak.in | 2 +- dist/linux/dbus.service.in | 2 +- dist/linux/systemd.service.in | 2 +- src/apprt/embedded.zig | 5 +- src/apprt/gtk/class/application.zig | 12 +- src/config/Config.zig | 234 ++++++++++++++-------------- 7 files changed, 125 insertions(+), 136 deletions(-) diff --git a/dist/linux/app.desktop.in b/dist/linux/app.desktop.in index 32ba00cfd..e05c47b6e 100644 --- a/dist/linux/app.desktop.in +++ b/dist/linux/app.desktop.in @@ -4,7 +4,7 @@ Name=@NAME@ Type=Application Comment=A terminal emulator TryExec=@GHOSTTY@ -Exec=@GHOSTTY@ --launched-from=desktop +Exec=@GHOSTTY@ --gtk-single-instance=true Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; @@ -23,4 +23,4 @@ X-KDE-Shortcuts=Ctrl+Alt+T [Desktop Action new-window] Name=New Window -Exec=@GHOSTTY@ --launched-from=desktop +Exec=@GHOSTTY@ --gtk-single-instance=true diff --git a/dist/linux/dbus.service.flatpak.in b/dist/linux/dbus.service.flatpak.in index 213cda78f..873f8dcf1 100644 --- a/dist/linux/dbus.service.flatpak.in +++ b/dist/linux/dbus.service.flatpak.in @@ -1,3 +1,3 @@ [D-BUS Service] Name=@APPID@ -Exec=@GHOSTTY@ --launched-from=dbus +Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false diff --git a/dist/linux/dbus.service.in b/dist/linux/dbus.service.in index df31a1abd..8758a34a2 100644 --- a/dist/linux/dbus.service.in +++ b/dist/linux/dbus.service.in @@ -1,4 +1,4 @@ [D-BUS Service] Name=@APPID@ SystemdService=app-@APPID@.service -Exec=@GHOSTTY@ --launched-from=dbus +Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in index 76ccdd3f4..17589f00f 100644 --- a/dist/linux/systemd.service.in +++ b/dist/linux/systemd.service.in @@ -8,7 +8,7 @@ Requires=dbus.socket Type=notify-reload ReloadSignal=SIGUSR2 BusName=@APPID@ -ExecStart=@GHOSTTY@ --launched-from=systemd +ExecStart=@GHOSTTY@ --gtk-single-instance=true --initial-window=false [Install] WantedBy=graphical-session.target diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 390292601..08d8291ef 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -909,10 +909,7 @@ pub const Surface = struct { // our translation settings for Ghostty. If we aren't from // the desktop then we didn't set our LANGUAGE var so we // don't need to remove it. - switch (self.app.config.@"launched-from") { - .desktop => env.remove("LANGUAGE"), - .dbus, .systemd, .cli => {}, - } + if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE"); } return env; diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index e9ff3dd81..5f87613cd 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -416,9 +416,7 @@ pub const Application = extern struct { // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening - // a window). An initial window will not be immediately created if we were - // launched by D-Bus activation or systemd. D-Bus activation will send it's - // own `activate` or `new-window` signal later. + // a window). // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 const priv = self.private(); @@ -426,15 +424,11 @@ pub const Application = extern struct { // We need to scope any config access because once we run our // event loop, this can change out from underneath us. const config = priv.config.get(); - if (config.@"initial-window") switch (config.@"launched-from") { - .desktop, .cli => self.as(gio.Application).activate(), - .dbus, .systemd => {}, - }; + if (config.@"initial-window") self.as(gio.Application).activate(); } // If we are NOT the primary instance, then we never want to run. - // This means that another instance of the GTK app is running and - // our "activate" call above will open a window. + // This means that another instance of the GTK app is running. if (self.as(gio.Application).getIsRemote() != 0) { log.debug( "application is remote, exiting run loop after activation", diff --git a/src/config/Config.zig b/src/config/Config.zig index debf5e9d5..221a7cf93 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -73,6 +73,10 @@ pub const compatibility = std.StaticStringMap( // Ghostty 1.2 merged `bold-is-bright` into the new `bold-color` // by setting the value to "bright". .{ "bold-is-bright", compatBoldIsBright }, + + // Ghostty 1.2 removed the "desktop" option and renamed it to "detect". + // The semantics also changed slightly but this is the correct mapping. + .{ "gtk-single-instance", compatGtkSingleInstance }, }); /// The font families to use. @@ -2975,16 +2979,23 @@ else /// /// If `false`, each new ghostty process will launch a separate application. /// -/// If `detect`, Ghostty will act as if it was `true` if one of the following -/// conditions is true: +/// If `detect`, Ghostty will assume true (single instance) unless one of +/// the following scenarios is found: /// -/// 1. If no CLI arguments have been set. -/// 2. If `--launched-from` has been set to `desktop`, `dbus`, or `systemd`. +/// 1. TERM_PROGRAM environment variable is a non-empty value. In this +/// case, we assume Ghostty is being launched from a graphical terminal +/// session and you want a dedicated instance. /// -/// Otherwise, Ghostty will act as if it was `false`. +/// 2. Any CLI arguments exist. In this case, we assume you are passing +/// custom Ghostty configuration. Single instance mode inherits the +/// configuration from when it was launched, so we must disable single +/// instance to load the new configuration. /// -/// The pre-1.2 option `desktop` has been deprecated. If encountered it will be -/// treated as `detect`. +/// If either of these scenarios is producing a false positive, you can +/// set this configuration explicitly to the behavior you want. +/// +/// The pre-1.2 option `desktop` has been deprecated. Please replace +/// this with `detect`. /// /// The default value is `detect`. /// @@ -3112,23 +3123,6 @@ term: []const u8 = "xterm-ghostty", /// running. Defaults to an empty string if not set. @"enquiry-response": []const u8 = "", -/// The mechanism used to launch Ghostty. This should generally not be -/// set by users, see the warning below. -/// -/// WARNING: This is a low-level configuration that is not intended to be -/// modified by users. All the values will be automatically detected as they -/// are needed by Ghostty. This is only here in case our detection logic is -/// incorrect for your environment or for developers who want to test -/// Ghostty's behavior in different, forced environments. -/// -/// Specific details about the available values are documented on LaunchSource -/// in the code. Since this isn't intended to be modified by users, the -/// documentation is lighter than the other configurations and users are -/// expected to refer to the code for details. -/// -/// Available since: 1.2.0 -@"launched-from": LaunchSource = .default, - /// Configures the low-level API to use for async IO, eventing, etc. /// /// Most users should leave this set to `auto`. This will automatically detect @@ -3493,24 +3487,8 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { .windows => {}, - // Fast-path if we are Linux and have no args. - .linux, .freebsd => { - if (std.os.argv.len <= 1) { - if (self.@"gtk-single-instance" == .detect) { - const arena_alloc = self._arena.?.allocator(); - // Add an artificial replay step so that replaying the - // inputs doesn't undo this change. - try self._replay_steps.append( - arena_alloc, - .{ - .arg = "--gtk-single-instance=true", - }, - ); - self.@"gtk-single-instance" = .true; - } - return; - } - }, + // Fast-path if we are Linux/BSD and have no args. + .linux, .freebsd => if (std.os.argv.len <= 1) return, // Everything else we have to at least try because it may // not use std.os.argv. @@ -3606,34 +3584,6 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // directory. var buf: [std.fs.max_path_bytes]u8 = undefined; try self.expandPaths(try std.fs.cwd().realpath(".", &buf)); - - if (self.@"gtk-single-instance" == .detect) { - const arena_alloc = self._arena.?.allocator(); - switch (self.@"launched-from") { - .cli => { - // Add an artificial replay step so that replaying the - // inputs doesn't undo this change. - try self._replay_steps.append( - arena_alloc, - .{ - .arg = "--gtk-single-instance=false", - }, - ); - self.@"gtk-single-instance" = .false; - }, - .desktop, .systemd, .dbus => { - // Add an artificial replay step so that replaying the - // inputs doesn't undo this change. - try self._replay_steps.append( - arena_alloc, - .{ - .arg = "--gtk-single-instance=true", - }, - ); - self.@"gtk-single-instance" = .true; - }, - } - } } /// Load and parse the config files that were added in the "config-file" key. @@ -3967,6 +3917,10 @@ pub fn finalize(self: *Config) !void { const alloc = self._arena.?.allocator(); + // Used for a variety of defaults. See the function docs as well the + // specific variable use sites for more details. + const probable_cli = probableCliEnvironment(); + // If we have a font-family set and don't set the others, default // the others to the font family. This way, if someone does // --font-family=foo, then we try to get the stylized versions of @@ -3991,12 +3945,14 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse switch (self.@"launched-from") { - // If we have no working directory set, our default depends on - // whether we were launched from the desktop or elsewhere. - .desktop => "home", - .cli, .dbus, .systemd => "inherit", - }; + const wd = self.@"working-directory" orelse if (probable_cli) + // From the CLI, we want to inherit where we were launched from. + "inherit" + else + // Otherwise we typically just want the home directory because + // our pwd is probably a runtime state dir or root or something + // (launchers and desktop environments typically do this). + "home"; // If we are missing either a command or home directory, we need // to look up defaults which is kind of expensive. We only do this @@ -4016,12 +3972,9 @@ pub fn finalize(self: *Config) !void { if (internal_os.isFlatpak()) break :shell_env; // If we were launched from the desktop, our SHELL env var - // will represent our SHELL at login time. We want to use the - // latest shell from /etc/passwd or directory services. - switch (self.@"launched-from") { - .desktop, .dbus, .systemd => break :shell_env, - .cli => {}, - } + // will represent our SHELL at login time. We only want to + // read from SHELL if we're in a probable CLI environment. + if (!probable_cli) break :shell_env; if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { log.info("default shell source=env value={s}", .{value}); @@ -4074,6 +4027,23 @@ pub fn finalize(self: *Config) !void { } } + // Apprt-specific defaults + switch (build_config.app_runtime) { + .none => {}, + .gtk => { + switch (self.@"gtk-single-instance") { + .true, .false => {}, + + // For detection, we assume single instance unless we're + // in a CLI environment, then we disable single instance. + .detect => self.@"gtk-single-instance" = if (probable_cli) + .false + else + .true, + } + }, + } + // If we have the special value "inherit" then set it to null which // does the same. In the future we should change to a tagged union. if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; @@ -4201,6 +4171,23 @@ fn compatGtkTabsLocation( return false; } +fn compatGtkSingleInstance( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "gtk-single-instance")); + + if (std.mem.eql(u8, value orelse "", "desktop")) { + self.@"gtk-single-instance" = .detect; + return true; + } + + return false; +} + fn compatCursorInvertFgBg( self: *Config, alloc: Allocator, @@ -4538,6 +4525,32 @@ fn equalField(comptime T: type, old: T, new: T) bool { } } +/// This runs a heuristic to determine if we are likely running +/// Ghostty in a CLI environment. We need this to change some behaviors. +/// We should keep the set of behaviors that depend on this as small +/// as possible because magic sucks, but each place is well documented. +fn probableCliEnvironment() bool { + switch (builtin.os.tag) { + // Windows has its own problems, just ignore it for now since + // its not a real supported target and GTK via WSL2 assuming + // single instance is probably fine. + .windows => return false, + else => {}, + } + + // If we have TERM_PROGRAM set to a non-empty value, we assume + // a graphical terminal environment. + if (std.posix.getenv("TERM_PROGRAM")) |v| { + if (v.len > 0) return true; + } + + // CLI arguments makes things probable + if (std.os.argv.len > 1) return true; + + // Unlikely CLI environment + return false; +} + /// This is used to "replay" the configuration. See loadTheme for details. const Replay = struct { const Step = union(enum) { @@ -7175,18 +7188,6 @@ pub const GtkSingleInstance = enum { detect, pub const default: GtkSingleInstance = .detect; - - pub fn parseCLI(input_: ?[]const u8) error{ ValueRequired, InvalidValue }!GtkSingleInstance { - const input = std.mem.trim( - u8, - input_ orelse return error.ValueRequired, - cli.args.whitespace, - ); - - if (std.mem.eql(u8, input, "desktop")) return .detect; - - return std.meta.stringToEnum(GtkSingleInstance, input) orelse error.InvalidValue; - } }; /// See gtk-tabs-location @@ -8078,30 +8079,6 @@ pub const Duration = struct { } }; -pub const LaunchSource = enum { - /// Ghostty was launched via the CLI. This is the default on non-macOS - /// platforms. - cli, - - /// Ghostty was launched in a desktop environment (not via the CLI). - /// This is used to determine some behaviors such as how to read - /// settings, whether single instance defaults to true, etc. - /// - /// This is the default on macOS. - desktop, - - /// Ghostty was started via dbus activation. - dbus, - - /// Ghostty was started via systemd unit. - systemd, - - pub const default: LaunchSource = switch (builtin.os.tag) { - .macos => .desktop, - else => .cli, - }; -}; - pub const WindowPadding = struct { const Self = @This(); @@ -8766,6 +8743,27 @@ test "theme specifying light/dark sets theme usage in conditional state" { } } +test "compatibility: gtk-single-instance desktop" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--gtk-single-instance=desktop", + } }; + try cfg.loadIter(alloc, &it); + + // We need to test this BEFORE finalize, because finalize will + // convert our detect to a real value. + try testing.expectEqual( + GtkSingleInstance.detect, + cfg.@"gtk-single-instance", + ); + } +} + test "compatibility: removed cursor-invert-fg-bg" { const testing = std.testing; const alloc = testing.allocator;