gtk-ng: deprecate detection of launch source (#8511)

Detecting the launch source frequently failed because various launchers
fail to sanitize the environment variables that Ghostty used to detect
the launch source. For example, if your desktop environment was launched
by `systemd`, but your desktop environment did not sanitize the
`INVOCATION_ID` or the `JOURNAL_STREAM` environment variables, Ghostty
would assume that it had been launched by `systemd` and behave as such.

This led to complaints about Ghostty not creating new windows when users
expected that it would.

To remedy this, Ghostty no longer does any detection of the launch
source. If your launch source is something other than the CLI, it must
be explicitly speciflied on the CLI. All of Ghostty's default desktop
and service files do this. Users or packagers that create custom desktop
or service files will need to take this into account.

On GTK, the `desktop` setting for `gtk-single-instance` is replaced with
`detect`. `detect` behaves as `gtk-single-instance=true` if one of the
following conditions is true:

1. If no CLI arguments have been set.
2. If `--launched-from` has been set to `desktop`, `dbus`, or `systemd`.

Otherwise `detect` behaves as `gtk-single-instance=false`.
pull/8534/head
Mitchell Hashimoto 2025-09-05 08:59:32 -07:00 committed by GitHub
commit 0492cd16fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 135 additions and 92 deletions

View File

@ -4,7 +4,7 @@ Name=@NAME@
Type=Application Type=Application
Comment=A terminal emulator Comment=A terminal emulator
TryExec=@GHOSTTY@ TryExec=@GHOSTTY@
Exec=@GHOSTTY@ --launched-from=desktop Exec=@GHOSTTY@ --gtk-single-instance=true
Icon=com.mitchellh.ghostty Icon=com.mitchellh.ghostty
Categories=System;TerminalEmulator; Categories=System;TerminalEmulator;
Keywords=terminal;tty;pty; Keywords=terminal;tty;pty;
@ -23,4 +23,4 @@ X-KDE-Shortcuts=Ctrl+Alt+T
[Desktop Action new-window] [Desktop Action new-window]
Name=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] [D-BUS Service]
Name=@APPID@ 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] [D-BUS Service]
Name=@APPID@ Name=@APPID@
SystemdService=app-@APPID@.service 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 Type=notify-reload
ReloadSignal=SIGUSR2 ReloadSignal=SIGUSR2
BusName=@APPID@ BusName=@APPID@
ExecStart=@GHOSTTY@ --launched-from=systemd ExecStart=@GHOSTTY@ --gtk-single-instance=true --initial-window=false
[Install] [Install]
WantedBy=graphical-session.target WantedBy=graphical-session.target

View File

@ -909,10 +909,7 @@ pub const Surface = struct {
// our translation settings for Ghostty. If we aren't from // our translation settings for Ghostty. If we aren't from
// the desktop then we didn't set our LANGUAGE var so we // the desktop then we didn't set our LANGUAGE var so we
// don't need to remove it. // don't need to remove it.
switch (self.app.config.@"launched-from".?) { if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE");
.desktop => env.remove("LANGUAGE"),
.dbus, .systemd, .cli => {},
}
} }
return env; return env;

View File

@ -223,10 +223,8 @@ pub const Application = extern struct {
const single_instance = switch (config.@"gtk-single-instance") { const single_instance = switch (config.@"gtk-single-instance") {
.true => true, .true => true,
.false => false, .false => false,
.desktop => switch (config.@"launched-from".?) { // This should have been resolved to true/false during config loading.
.desktop, .systemd, .dbus => true, .detect => unreachable,
.cli => false,
},
}; };
// Setup the flags for our application. // Setup the flags for our application.
@ -418,9 +416,7 @@ pub const Application = extern struct {
// This just calls the `activate` signal but its part of the normal startup // 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 // routine so we just call it, but only if the config allows it (this allows
// for launching Ghostty in the "background" without immediately opening // for launching Ghostty in the "background" without immediately opening
// a window). An initial window will not be immediately created if we were // a window).
// launched by D-Bus activation or systemd. D-Bus activation will send it's
// own `activate` or `new-window` signal later.
// //
// https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302
const priv = self.private(); const priv = self.private();
@ -428,15 +424,11 @@ pub const Application = extern struct {
// We need to scope any config access because once we run our // We need to scope any config access because once we run our
// event loop, this can change out from underneath us. // event loop, this can change out from underneath us.
const config = priv.config.get(); const config = priv.config.get();
if (config.@"initial-window") switch (config.@"launched-from".?) { if (config.@"initial-window") self.as(gio.Application).activate();
.desktop, .cli => self.as(gio.Application).activate(),
.dbus, .systemd => {},
};
} }
// If we are NOT the primary instance, then we never want to run. // 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 // This means that another instance of the GTK app is running.
// our "activate" call above will open a window.
if (self.as(gio.Application).getIsRemote() != 0) { if (self.as(gio.Application).getIsRemote() != 0) {
log.debug( log.debug(
"application is remote, exiting run loop after activation", "application is remote, exiting run loop after activation",

View File

@ -260,6 +260,7 @@ fn formatInvalidValue(
} }
fn formatValues(comptime T: type, key: []const u8, writer: anytype) std.mem.Allocator.Error!void { fn formatValues(comptime T: type, key: []const u8, writer: anytype) std.mem.Allocator.Error!void {
@setEvalBranchQuota(2000);
const typeinfo = @typeInfo(T); const typeinfo = @typeInfo(T);
inline for (typeinfo.@"struct".fields) |f| { inline for (typeinfo.@"struct".fields) |f| {
if (std.mem.eql(u8, key, f.name)) { if (std.mem.eql(u8, key, f.name)) {

View File

@ -73,6 +73,10 @@ pub const compatibility = std.StaticStringMap(
// Ghostty 1.2 merged `bold-is-bright` into the new `bold-color` // Ghostty 1.2 merged `bold-is-bright` into the new `bold-color`
// by setting the value to "bright". // by setting the value to "bright".
.{ "bold-is-bright", compatBoldIsBright }, .{ "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. /// The font families to use.
@ -2975,14 +2979,29 @@ else
/// ///
/// If `false`, each new ghostty process will launch a separate application. /// If `false`, each new ghostty process will launch a separate application.
/// ///
/// The default value is `desktop` which will default to `true` if Ghostty /// If `detect`, Ghostty will assume true (single instance) unless one of
/// detects that it was launched from the `.desktop` file such as an app /// the following scenarios is found:
/// launcher (like Gnome Shell) or by D-Bus activation. If Ghostty is launched ///
/// from the command line, it will default to `false`. /// 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.
///
/// 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.
///
/// 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`.
/// ///
/// Note that debug builds of Ghostty have a separate single-instance ID /// Note that debug builds of Ghostty have a separate single-instance ID
/// so you can test single instance without conflicting with release builds. /// so you can test single instance without conflicting with release builds.
@"gtk-single-instance": GtkSingleInstance = .desktop, @"gtk-single-instance": GtkSingleInstance = .default,
/// When enabled, the full GTK titlebar is displayed instead of your window /// When enabled, the full GTK titlebar is displayed instead of your window
/// manager's simple titlebar. The behavior of this option will vary with your /// manager's simple titlebar. The behavior of this option will vary with your
@ -3104,25 +3123,6 @@ term: []const u8 = "xterm-ghostty",
/// running. Defaults to an empty string if not set. /// running. Defaults to an empty string if not set.
@"enquiry-response": []const u8 = "", @"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.
///
/// This is set using the standard `no-[value]`, `[value]` syntax separated
/// by commas. Example: "no-desktop,systemd". Specific details about the
/// available values are documented on LaunchProperties 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 = null,
/// Configures the low-level API to use for async IO, eventing, etc. /// Configures the low-level API to use for async IO, eventing, etc.
/// ///
/// Most users should leave this set to `auto`. This will automatically detect /// Most users should leave this set to `auto`. This will automatically detect
@ -3487,7 +3487,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
switch (builtin.os.tag) { switch (builtin.os.tag) {
.windows => {}, .windows => {},
// Fast-path if we are Linux and have no args. // Fast-path if we are Linux/BSD and have no args.
.linux, .freebsd => if (std.os.argv.len <= 1) return, .linux, .freebsd => if (std.os.argv.len <= 1) return,
// Everything else we have to at least try because it may // Everything else we have to at least try because it may
@ -3917,10 +3917,9 @@ pub fn finalize(self: *Config) !void {
const alloc = self._arena.?.allocator(); const alloc = self._arena.?.allocator();
// Ensure our launch source is properly set. // Used for a variety of defaults. See the function docs as well the
if (self.@"launched-from" == null) { // specific variable use sites for more details.
self.@"launched-from" = .detect(); const probable_cli = probableCliEnvironment();
}
// If we have a font-family set and don't set the others, default // 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 // the others to the font family. This way, if someone does
@ -3946,12 +3945,14 @@ pub fn finalize(self: *Config) !void {
} }
// The default for the working directory depends on the system. // The default for the working directory depends on the system.
const wd = self.@"working-directory" orelse switch (self.@"launched-from".?) { const wd = self.@"working-directory" orelse if (probable_cli)
// If we have no working directory set, our default depends on // From the CLI, we want to inherit where we were launched from.
// whether we were launched from the desktop or elsewhere. "inherit"
.desktop => "home", else
.cli, .dbus, .systemd => "inherit", // 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 // 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 // to look up defaults which is kind of expensive. We only do this
@ -3971,12 +3972,9 @@ pub fn finalize(self: *Config) !void {
if (internal_os.isFlatpak()) break :shell_env; if (internal_os.isFlatpak()) break :shell_env;
// If we were launched from the desktop, our SHELL env var // If we were launched from the desktop, our SHELL env var
// will represent our SHELL at login time. We want to use the // will represent our SHELL at login time. We only want to
// latest shell from /etc/passwd or directory services. // read from SHELL if we're in a probable CLI environment.
switch (self.@"launched-from".?) { if (!probable_cli) break :shell_env;
.desktop, .dbus, .systemd => break :shell_env,
.cli => {},
}
if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| {
log.info("default shell source=env value={s}", .{value}); log.info("default shell source=env value={s}", .{value});
@ -4029,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 // 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. // does the same. In the future we should change to a tagged union.
if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null;
@ -4156,6 +4171,23 @@ fn compatGtkTabsLocation(
return false; 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( fn compatCursorInvertFgBg(
self: *Config, self: *Config,
alloc: Allocator, alloc: Allocator,
@ -4493,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. /// This is used to "replay" the configuration. See loadTheme for details.
const Replay = struct { const Replay = struct {
const Step = union(enum) { const Step = union(enum) {
@ -7125,9 +7183,11 @@ pub const MacShortcuts = enum {
/// See gtk-single-instance /// See gtk-single-instance
pub const GtkSingleInstance = enum { pub const GtkSingleInstance = enum {
desktop,
false, false,
true, true,
detect,
pub const default: GtkSingleInstance = .detect;
}; };
/// See gtk-tabs-location /// See gtk-tabs-location
@ -8019,34 +8079,6 @@ pub const Duration = struct {
} }
}; };
pub const LaunchSource = enum {
/// Ghostty was launched via the CLI. This is the default if
/// no other source is detected.
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.
desktop,
/// Ghostty was started via dbus activation.
dbus,
/// Ghostty was started via systemd activation.
systemd,
pub fn detect() LaunchSource {
return if (internal_os.launchedFromDesktop())
.desktop
else if (internal_os.launchedByDbusActivation())
.dbus
else if (internal_os.launchedBySystemd())
.systemd
else
.cli;
}
};
pub const WindowPadding = struct { pub const WindowPadding = struct {
const Self = @This(); const Self = @This();
@ -8711,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" { test "compatibility: removed cursor-invert-fg-bg" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;