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.
This commit is contained in:
Mitchell Hashimoto
2025-09-04 20:57:32 -07:00
committed by Jeffrey C. Ollie
parent d10e474860
commit 587f47a587
7 changed files with 125 additions and 136 deletions

View File

@@ -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

View File

@@ -1,3 +1,3 @@
[D-BUS Service]
Name=@APPID@
Exec=@GHOSTTY@ --launched-from=dbus
Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;