From ee18838fc7895ecb51edcf869282b686cb22833c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 28 Jul 2024 20:03:37 -0500 Subject: [PATCH] D-Bus and SystemD activation This updates Ghostty to use D-Bus and SystemD activation to start. In addition to making future Gnome/GTK integration easier, this also implements a "background daemon" mode (Fixing #2010). The configuration variable `quit-after-last-window-closed` is changed to an enum that takes `always`, `never`, and `after-timeout`. Always works like `true` used to and `never` works like `false` used to. If set to `after-timeout` Ghostty will quit a configurable delay after the last surface is closed. This is set by default to five minutes. The timeout only works on Linux for now. I attempted to update the macOS code to support the change to `quit-after-last-window-closed` but I have no way to test the changes so I didn't attempt to implement the background timeout. --- README.md | 18 ++ build.zig | 12 +- dist/linux/debug-systemd/app.desktop | 20 ++ dist/linux/debug-systemd/dbus.service | 4 + dist/linux/debug-systemd/systemd.service | 7 + dist/linux/debug/app.desktop | 19 ++ dist/linux/release-systemd/app.desktop | 20 ++ dist/linux/release-systemd/dbus.service | 4 + dist/linux/release-systemd/systemd.service | 7 + dist/linux/{ => release}/app.desktop | 9 +- macos/Sources/App/macOS/AppDelegate.swift | 2 +- macos/Sources/Ghostty/Ghostty.Config.swift | 11 +- nix/package.nix | 6 + src/App.zig | 2 +- src/apprt/gtk/App.zig | 98 ++++++- src/apprt/gtk/Surface.zig | 1 + src/apprt/gtk/dbus/background.zig | 124 +++++++++ src/config/Config.zig | 282 ++++++++++++++++++++- src/os/dbus.zig | 21 ++ src/os/main.zig | 2 + src/os/systemd.zig | 22 ++ src/termio/Exec.zig | 11 + 22 files changed, 664 insertions(+), 38 deletions(-) create mode 100644 dist/linux/debug-systemd/app.desktop create mode 100644 dist/linux/debug-systemd/dbus.service create mode 100644 dist/linux/debug-systemd/systemd.service create mode 100644 dist/linux/debug/app.desktop create mode 100644 dist/linux/release-systemd/app.desktop create mode 100644 dist/linux/release-systemd/dbus.service create mode 100644 dist/linux/release-systemd/systemd.service rename dist/linux/{ => release}/app.desktop (66%) create mode 100644 src/apprt/gtk/dbus/background.zig create mode 100644 src/os/dbus.zig create mode 100644 src/os/systemd.zig diff --git a/README.md b/README.md index 06b59a4a0..125a5f51c 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,24 @@ on the search path for a lot of software (such as Gnome and KDE) and installing into a prefix with `-p` sets up a directory structure to ensure all features of Ghostty work. +### Linux D-Bus Debugging Tips + +The GTK runtime uses D-Bus behind the scenes for sending signals and other features. Debug and release +builds use different names and paths on the D-Bus bus so that they can run simultaneously. + +| Type of Build | NAME | PATH | +| ------------- | --------------------------- | ---------------------------- | +| Release | com.mitchellh.ghostty | /com/mitchellh/ghostty | +| Debug | com.mitchellh.ghostty-debug | /com/mitchellh/ghostty_debug | + +- Introspect + + gdbus introspect --session --dest ${NAME} --object-path ${PATH} + +- Monitor + + gdbus monitor --session --dest ${NAME} + ### Mac `.app` To build the official, fully featured macOS application, you must diff --git a/build.zig b/build.zig index 25b903977..380d32cc2 100644 --- a/build.zig +++ b/build.zig @@ -147,6 +147,12 @@ pub fn build(b: *std.Build) !void { config.app_runtime == .none and (!emit_bench and !emit_test_exe and !emit_helpgen); + const linux_systemd_desktop = b.option( + bool, + "linux-systemd-desktop", + "Install advanced desktop integration files that use systemd user units and D-Bus activation.", + ) orelse true; + // On NixOS, the built binary from `zig build` needs to patch the rpath // into the built binary for it to be portable across the NixOS system // it was built for. We default this to true if we can detect we're in @@ -465,8 +471,12 @@ pub fn build(b: *std.Build) !void { // Desktop file so that we have an icon and other metadata if (config.flatpak) { b.installFile("dist/linux/app-flatpak.desktop", "share/applications/com.mitchellh.ghostty.desktop"); + } else if (linux_systemd_desktop) { + b.installFile("dist/linux/release-systemd/app.desktop", "share/applications/com.mitchellh.ghostty.desktop"); + b.installFile("dist/linux/release-systemd/dbus.service", "share/dbus-1/services/com.mitchellh.ghostty.service"); + b.installFile("dist/linux/release-systemd/systemd.service", "lib/systemd/user/com.mitchellh.ghostty.service"); } else { - b.installFile("dist/linux/app.desktop", "share/applications/com.mitchellh.ghostty.desktop"); + b.installFile("dist/linux/release/app.desktop", "share/applications/com.mitchellh.ghostty.desktop"); } // Various icons that our application can use, including the icon diff --git a/dist/linux/debug-systemd/app.desktop b/dist/linux/debug-systemd/app.desktop new file mode 100644 index 000000000..14aa91dce --- /dev/null +++ b/dist/linux/debug-systemd/app.desktop @@ -0,0 +1,20 @@ +[Desktop Entry] +Version=1.0 +Name=Ghostty (Debug) +Type=Application +Comment=A terminal emulator +TryExec=ghostty +Exec=ghostty %F +Icon=com.mitchellh.ghostty +Categories=System;TerminalEmulator; +Keywords=terminal;tty;pty; +StartupNotify=true +StartupWMClass=com.mitchellh.ghostty-debug +Terminal=false +Actions=new_window; +X-GNOME-UsesNotifications=true +DBusActivatable=true + +[Desktop Action new_window] +Name=New Window +Exec=ghostty diff --git a/dist/linux/debug-systemd/dbus.service b/dist/linux/debug-systemd/dbus.service new file mode 100644 index 000000000..50bbbaffb --- /dev/null +++ b/dist/linux/debug-systemd/dbus.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=com.mitchellh.ghostty-debug +SystemdService=com.mitchellh.ghostty-debug.service +Exec=ghostty diff --git a/dist/linux/debug-systemd/systemd.service b/dist/linux/debug-systemd/systemd.service new file mode 100644 index 000000000..3f726e7d0 --- /dev/null +++ b/dist/linux/debug-systemd/systemd.service @@ -0,0 +1,7 @@ +[Unit] +Description=Ghostty + +[Service] +Type=dbus +BusName=com.mitchellh.ghostty-debug +ExecStart=ghostty diff --git a/dist/linux/debug/app.desktop b/dist/linux/debug/app.desktop new file mode 100644 index 000000000..28ed84012 --- /dev/null +++ b/dist/linux/debug/app.desktop @@ -0,0 +1,19 @@ +[Desktop Entry] +Version=1.0 +Name=Ghostty (Debug) +Type=Application +Comment=A terminal emulator +TryExec=ghostty +Exec=ghostty %F +Icon=com.mitchellh.ghostty +Categories=System;TerminalEmulator; +Keywords=terminal;tty;pty; +StartupNotify=true +StartupWMClass=com.mitchellh.ghostty-debug +Terminal=false +Actions=new_window; +X-GNOME-UsesNotifications=true + +[Desktop Action new_window] +Name=New Window +Exec=ghostty diff --git a/dist/linux/release-systemd/app.desktop b/dist/linux/release-systemd/app.desktop new file mode 100644 index 000000000..25e8e963e --- /dev/null +++ b/dist/linux/release-systemd/app.desktop @@ -0,0 +1,20 @@ +[Desktop Entry] +Version=1.0 +Name=Ghostty +Type=Application +Comment=A terminal emulator +TryExec=ghostty +Exec=ghostty %F +Icon=com.mitchellh.ghostty +Categories=System;TerminalEmulator; +Keywords=terminal;tty;pty; +StartupNotify=true +StartupWMClass=com.mitchellh.ghostty +Terminal=false +Actions=new_window; +X-GNOME-UsesNotifications=true +DBusActivatable=true + +[Desktop Action new_window] +Name=New Window +Exec=ghostty diff --git a/dist/linux/release-systemd/dbus.service b/dist/linux/release-systemd/dbus.service new file mode 100644 index 000000000..4d508d168 --- /dev/null +++ b/dist/linux/release-systemd/dbus.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=com.mitchellh.ghostty +SystemdService=com.mitchellh.ghostty.service +Exec=ghostty diff --git a/dist/linux/release-systemd/systemd.service b/dist/linux/release-systemd/systemd.service new file mode 100644 index 000000000..dcc354eff --- /dev/null +++ b/dist/linux/release-systemd/systemd.service @@ -0,0 +1,7 @@ +[Unit] +Description=Ghostty + +[Service] +Type=dbus +BusName=com.mitchellh.ghostty +ExecStart=ghostty diff --git a/dist/linux/app.desktop b/dist/linux/release/app.desktop similarity index 66% rename from dist/linux/app.desktop rename to dist/linux/release/app.desktop index 1c9017ff4..361bb7050 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/release/app.desktop @@ -1,16 +1,19 @@ [Desktop Entry] +Version=1.0 Name=Ghostty Type=Application Comment=A terminal emulator -Exec=ghostty +TryExec=ghostty +Exec=ghostty %F Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true +StartupWMClass=com.mitchellh.ghostty Terminal=false -Actions=new-window; +Actions=new_window; X-GNOME-UsesNotifications=true -[Desktop Action new-window] +[Desktop Action new_window] Name=New Window Exec=ghostty diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8b6b064a9..ea6a6691f 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -146,7 +146,7 @@ class AppDelegate: NSObject, } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return ghostty.config.shouldQuitAfterLastWindowClosed + return ghostty.config.shouldQuitAfterLastWindowClosed != "never" } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index ab583e956..7e17ba9cf 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -151,12 +151,13 @@ extension Ghostty { /// details on what each means. We only add documentation if there is a strange conversion /// due to the embedded library and Swift. - var shouldQuitAfterLastWindowClosed: Bool { - guard let config = self.config else { return true } - var v = false; + var shouldQuitAfterLastWindowClosed: String { + guard let config = self.config else { return "never" } + var v: UnsafePointer? = nil; let key = "quit-after-last-window-closed" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) - return v + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "never" } + guard let ptr = v else { return "never" }; + return String(cString: ptr) } var windowColorspace: String { diff --git a/nix/package.nix b/nix/package.nix index 47bf5ac48..b3ebd6f86 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -167,6 +167,11 @@ in mkdir -p "$out/nix-support" + sed -i -e "s@^Exec=.*ghostty@Exec=$out/bin/ghostty@" $out/share/applications/com.mitchellh.ghostty.desktop + sed -i -e "s@^TryExec=.*ghostty@TryExec=$out/bin/ghostty@" $out/share/applications/com.mitchellh.ghostty.desktop + sed -i -e "s@^Exec=.*ghostty@Exec=$out/bin/ghostty@" $out/share/dbus-1/services/com.mitchellh.ghostty.service + sed -i -e "s@^ExecStart=.*ghostty@ExecStart=$out/bin/ghostty@" $out/lib/systemd/user/com.mitchellh.ghostty.service + mkdir -p "$terminfo/share" mv "$terminfo_src" "$terminfo/share/terminfo" ln -sf "$terminfo/share/terminfo" "$terminfo_src" @@ -179,6 +184,7 @@ in ''; postFixup = '' + # explicitly add libX11 to the rpath because it's dynamically loaded patchelf --add-rpath "${lib.makeLibraryPath [libX11]}" "$out/bin/.ghostty-wrapped" ''; diff --git a/src/App.zig b/src/App.zig index 314d0b25b..8d0fc02c8 100644 --- a/src/App.zig +++ b/src/App.zig @@ -309,7 +309,7 @@ pub const Message = union(enum) { redraw_inspector: *apprt.Surface, const NewWindow = struct { - /// The parent surface + /// The parent surface. parent: ?*Surface = null, }; }; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 60fefc6de..fb58c341a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -32,6 +32,7 @@ const c = @import("c.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); const x11 = @import("x11.zig"); + const testing = std.testing; const log = std.log.scoped(.gtk); @@ -108,7 +109,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { const single_instance = switch (config.@"gtk-single-instance") { .true => true, .false => false, - .desktop => internal_os.launchedFromDesktop(), + .detect => internal_os.launchedFromDesktop() or internal_os.launchedByDBusActivation() or internal_os.launchedBySystemd(), }; // Setup the flags for our application. @@ -178,6 +179,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { break :app @ptrCast(adw_app); }; errdefer c.g_object_unref(app); + const gapp = @as(*c.GApplication, @ptrCast(app)); // force the resource path to a known value so that it doesn't depend on @@ -253,10 +255,13 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { break :x11_xkb try x11.Xkb.init(display); }; - // This just calls the "activate" signal but its part of the normal - // startup routine so we just call it: + // This just calls the `activate` signal but its part of the normal startup + // routine so we just call it, but only if we were not launched by D-Bus + // activation or systemd. D-Bus activation will send it's own `activate` + // signal later. // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 - c.g_application_activate(gapp); + if (!internal_os.launchedByDBusActivation() and !internal_os.launchedBySystemd()) + c.g_application_activate(gapp); // Register for dbus events if (c.g_application_get_dbus_connection(gapp)) |dbus_connection| { @@ -277,6 +282,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { const css_provider = c.gtk_css_provider_new(); try loadRuntimeCss(&config, css_provider); + // Run a small no-op function so that we don't get stuck in + // g_main_context_iteration forever if there are no open surfaces. + _ = c.g_timeout_add(500, gtkTimeout, null); + return .{ .core_app = core_app, .app = app, @@ -293,6 +302,12 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { }; } +// This timeout function is run periodically so that we don't get stuck in +// g_main_context_iteration forever if there are no open surfaces. +pub fn gtkTimeout(_: ?*anyopaque) callconv(.C) c.gboolean { + return 1; +} + // Terminate the application. The application will not be restarted after // this so all global state can be cleaned up. pub fn terminate(self: *App) void { @@ -463,7 +478,10 @@ pub fn run(self: *App) !void { self.transient_cgroup_base = path; } else log.debug("cgroup isoation disabled config={}", .{self.config.@"linux-cgroup"}); - // Setup our menu items + // The last instant that one or more surfaces were open + var last_one = try std.time.Instant.now(); + + // If we're not remote, then we also setup our actions and menus. self.initActions(); self.initMenu(); self.initContextMenu(); @@ -478,9 +496,48 @@ pub fn run(self: *App) !void { while (self.running) { _ = c.g_main_context_iteration(self.ctx, 1); - // Tick the terminal app + // Tick the terminal app and see if we should quit. const should_quit = try self.core_app.tick(self); - if (should_quit or self.core_app.surfaces.items.len == 0) self.quit(); + + // If there are one or more surfaces open, update the timer. + if (self.core_app.surfaces.items.len > 0) last_one = try std.time.Instant.now(); + + const q = q: { + // If we've been told by GTK that we should quit, do so regardless + // of any other setting. + if (should_quit) break :q true; + + // If there are no surfaces check to see if we should stay in the + // background or not. + if (self.core_app.surfaces.items.len == 0) { + switch (self.config.@"quit-after-last-window-closed") { + .always => break :q true, + .never => break :q false, + .@"after-timeout" => { + + // If the background timeout is not null, check to see + // if the timeout has elapsed. + if (self.config.@"background-timeout".duration) |duration| { + const now = try std.time.Instant.now(); + + if (now.since(last_one) > duration) + // The timeout has elapsed, quit. + break :q true; + + // Not enough time has elapsed, don't quit. + break :q false; + } + + // `background-timeout` is null, don't quit. + break :q false; + }, + } + } + + break :q false; + }; + + if (q) self.quit(); } } @@ -567,8 +624,7 @@ fn quit(self: *App) void { } /// This immediately destroys all windows, forcing the application to quit. -fn quitNow(self: *App) void { - _ = self; +fn quitNow(_: *App) void { const list = c.gtk_window_list_toplevels(); defer c.g_list_free(list); c.g_list_foreach(list, struct { @@ -598,11 +654,10 @@ fn gtkQuitConfirmation( self.quitNow(); } -/// This is called by the "activate" signal. This is sent on program -/// startup and also when a secondary instance launches and requests -/// a new window. -fn gtkActivate(app: *c.GtkApplication, ud: ?*anyopaque) callconv(.C) void { - _ = app; +/// This is called by the `activate` signal. This is sent on program startup and +/// also when a secondary instance launches and requests a new window. +fn gtkActivate(_: *c.GtkApplication, ud: ?*anyopaque) callconv(.C) void { + log.info("received activate signal", .{}); const core_app: *CoreApp = @ptrCast(@alignCast(ud orelse return)); @@ -726,6 +781,8 @@ fn gtkActionQuit( _: *c.GVariant, ud: ?*anyopaque, ) callconv(.C) void { + log.info("gtk quit action received", .{}); + const self: *App = @ptrCast(@alignCast(ud orelse return)); self.core_app.setQuit() catch |err| { log.warn("error setting quit err={}", .{err}); @@ -733,6 +790,18 @@ fn gtkActionQuit( }; } +fn gtkActionNewWindow( + _: *c.GSimpleAction, + _: *c.GVariant, + ud: ?*anyopaque, +) callconv(.C) void { + log.info("received new window action", .{}); + const self: *App = @ptrCast(@alignCast(ud orelse return)); + _ = self.core_app.mailbox.push(.{ + .new_window = .{}, + }, .{ .forever = {} }); +} + /// This is called to setup the action map that this application supports. /// This should be called only once on startup. fn initActions(self: *App) void { @@ -740,6 +809,7 @@ fn initActions(self: *App) void { .{ "quit", >kActionQuit }, .{ "open_config", >kActionOpenConfig }, .{ "reload_config", >kActionReloadConfig }, + .{ "new_window", >kActionNewWindow }, }; inline for (actions) |entry| { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index ee6c6869e..422f64055 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -545,6 +545,7 @@ fn realize(self: *Surface) !void { // Get our new surface config var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config); defer config.deinit(); + if (!self.parent_surface) { // A hack, see the "parent_surface" field for more information. config.@"working-directory" = self.app.config.@"working-directory"; diff --git a/src/apprt/gtk/dbus/background.zig b/src/apprt/gtk/dbus/background.zig new file mode 100644 index 000000000..5c56173ea --- /dev/null +++ b/src/apprt/gtk/dbus/background.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const c = @import("../c.zig"); +const CoreApp = @import("../../../App.zig"); + +const log = std.log.scoped(.gtk_dbus); + +const UserData = struct { + core_app: *CoreApp, + dbus_connection: *c.DBusConnection, +}; + +pub fn requestBackgroundStart(core_app: *CoreApp, dbus_connection: *c.GDBusConnection) !void { + const name = c.g_dbus_connection_get_unique_name(dbus_connection); + log.info("dbus connection unique name: {s}", .{name}); + + const sender = try core_app.alloc.dupe(u8, std.mem.span(name)[1..]); + defer core_app.alloc.free(sender); + std.mem.replaceScalar(u8, sender, '.', '_'); + + var buf: [128]u8 = undefined; + const handle = try std.fmt.bufPrintZ(&buf, "/org/freedesktop/portal/desktop/request/{s}/ghostty_background_{d}", .{ sender, std.crypto.random.int(u32) }); + log.info("handle: {s}", .{handle}); + + _ = c.g_dbus_connection_signal_subscribe( + dbus_connection, + null, + "org.freedesktop.portal.Request", + "Response", + &buf, + null, + c.G_DBUS_SIGNAL_FLAGS_NONE, + &requestBackgroundResult, + core_app, + null, + ); + + const options = c.g_variant_builder_new(c.G_VARIANT_TYPE("a{sv}")); + defer c.g_variant_builder_unref(options); + + c.g_variant_builder_add(options, "{sv}", "handle_token", c.g_variant_new("s", "background")); + c.g_variant_builder_add(options, "{sv}", "reason", c.g_variant_new("s", "because we're cool")); + c.g_variant_builder_add(options, "{sv}", "autostart", c.g_variant_new("b", @as(u32, 0))); + + const command = c.g_variant_builder_new(c.G_VARIANT_TYPE("as")); + defer c.g_variant_builder_unref(command); + c.g_variant_builder_add(command, "s", "ghostty"); + + c.g_variant_builder_add(options, "{sv}", "commandline", c.g_variant_builder_end(command)); + c.g_variant_builder_add(options, "{sv}", "dbus-activatable", c.g_variant_new("b", @as(u32, 0))); + + const params = c.g_variant_new( + "(s@a{sv})", + "", + c.g_variant_builder_end(options), + ); + _ = c.g_variant_ref_sink(params); + defer c.g_variant_unref(params); + + c.g_dbus_connection_call( + dbus_connection, // connection + "org.freedesktop.portal.Desktop", // bus name + "/org/freedesktop/portal/desktop", // object path + "org.freedesktop.portal.Background", // interface name + "RequestBackground", // method name + params, // parameters + null, + // c.G_VARIANT_TYPE("(o)"), // reply type + c.G_DBUS_CALL_FLAGS_NONE, // flags + -1, // timeout_msec + null, // cancellable + requestBackgroundFinish, + @ptrCast(dbus_connection), + ); +} + +fn requestBackgroundFinish(source_object: ?*c.GObject, res: ?*c.GAsyncResult, user_data: ?*anyopaque) callconv(.C) void { + _ = source_object; + const dbus_connection: *c.GDBusConnection = @ptrCast(@alignCast(user_data orelse unreachable)); + + var err: ?*c.GError = null; + defer if (err) |e| c.g_error_free(e); + + const value = c.g_dbus_connection_call_finish( + dbus_connection, + res, + &err, + ) orelse { + if (err) |e| log.err("unable to request background: {s}", .{e.message}); + return; + }; + defer c.g_variant_unref(value); + + log.err("return type: {s}", .{c.g_variant_get_type_string(value)}); + + if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(o)")) != 1) { + log.err("wrong return type: {s}", .{c.g_variant_get_type_string(value)}); + return; + } + + var path: ?[*c]u8 = null; + c.g_variant_get(value, "(o)", &path); + defer if (path) |p| c.g_free(p); + + if (path) |p| { + log.warn("background path: {s}", .{p}); + } else { + log.warn("error with path", .{}); + } +} + +fn requestBackgroundResult( + _: ?*c.GDBusConnection, + _: [*c]const u8, + _: [*c]const u8, + _: [*c]const u8, + _: [*c]const u8, + parameters: ?*c.GVariant, + user_data: ?*anyopaque, +) callconv(.C) void { + _ = parameters; + _ = user_data; + + log.info("request background result", .{}); +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 81799d167..87ebb2ba0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -880,10 +880,48 @@ keybind: Keybinds = .{}, /// true. If set to false, surfaces will close without any confirmation. @"confirm-close-surface": bool = true, -/// Whether or not to quit after the last window is closed. This defaults to -/// false. Currently only supported on macOS. On Linux, the process always exits -/// after the last window is closed. -@"quit-after-last-window-closed": bool = false, +/// Whether or not to quit after the last surface is closed. +/// * `never` - Ghostty will continue running even if there are no open +/// surfaces. +/// * `always` - Ghostty will quit as soon as there are no open surfaces. +/// * `after-timeout` - Ghostty will continue running after all surfaces are +/// closed until the `background-timeout` expires. +/// This defaults to `never` on macOS and `always` on other systems. +@"quit-after-last-window-closed": QuitOptions = if (builtin.target.isDarwin()) + .never +else switch (builtin.os.tag) { + .linux => .always, + else => .always, +}, + +/// If `quit-after-last-window-closed` is set to `after-timeout`, this controls +/// how long Ghostty will stay running after the last open surface has been +/// closed. If the `background-timeout` is unset Ghostty will remain running +/// indefinitely. The duration should be long enough to allow Ghostty to +/// initialize and open it's first window. The duration is specified as a series +/// of numbers followed by time units. Whitespace is allowed between numbers and +/// units. The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. +/// +/// By default the `background-timeout` is set to five minutes. +/// +/// Only implemented on Linux. +@"background-timeout": Duration = .{ .duration = 5 * std.time.ns_per_min }, /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: @@ -1109,19 +1147,20 @@ keybind: Keybinds = .{}, /// must always be able to move themselves into an isolated cgroup. @"linux-cgroup-hard-fail": bool = false, -/// If true, the Ghostty GTK application will run in single-instance mode: -/// each new `ghostty` process launched will result in a new window if there -/// is already a running process. +/// If `true`, the Ghostty GTK application will run in single-instance mode: +/// each new `ghostty` process launched will result in a new window if there is +/// already a running process. /// -/// 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 -/// detects it was launched from the `.desktop` file such as an app launcher. -/// If Ghostty is launched from the command line, it will default to `false`. +/// The default value is `detect` which will default to `true` if Ghostty +/// detects that it was launched from the `.desktop` file such as an app +/// launcher (like Gnome Shell) or by D-Bus activation. If Ghostty is launched +/// from the command line, it will default to `false`. /// /// Note that debug builds of Ghostty have a separate single-instance ID /// so you can test single instance without conflicting with release builds. -@"gtk-single-instance": GtkSingleInstance = .desktop, +@"gtk-single-instance": GtkSingleInstance = .detect, /// 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 @@ -3700,7 +3739,7 @@ pub const MacTitlebarStyle = enum { /// See gtk-single-instance pub const GtkSingleInstance = enum { - desktop, + detect, false, true, }; @@ -3753,3 +3792,220 @@ pub const LinuxCgroup = enum { always, @"single-instance", }; + +/// See quit-after-last-window-closed +pub const QuitOptions = enum { + never, + always, + @"after-timeout", +}; + +pub const Duration = struct { + duration: ?u64 = null, + + pub fn clone(self: *const @This(), _: Allocator) !@This() { + return .{ .duration = self.duration }; + } + + pub fn equal(self: @This(), other: @This()) bool { + return self.duration == other.duration; + } + + pub fn parseCLI(self: *@This(), _: Allocator, input: ?[]const u8) !void { + var remaining = input orelse { + self.duration = null; + return; + }; + + const units = [_]struct { + name: []const u8, + factor: u64, + }{ + .{ .name = "y", .factor = 365 * std.time.ns_per_day }, + .{ .name = "w", .factor = std.time.ns_per_week }, + .{ .name = "d", .factor = std.time.ns_per_day }, + .{ .name = "h", .factor = std.time.ns_per_hour }, + .{ .name = "m", .factor = std.time.ns_per_min }, + .{ .name = "s", .factor = std.time.ns_per_s }, + .{ .name = "ms", .factor = std.time.ns_per_ms }, + .{ .name = "us", .factor = std.time.ns_per_us }, + .{ .name = "µs", .factor = std.time.ns_per_us }, + .{ .name = "ns", .factor = 1 }, + }; + + var value: ?u64 = null; + + while (remaining.len > 0) { + // Skip over whitespace before the number + while (remaining.len > 0 and std.ascii.isWhitespace(remaining[0])) { + remaining = remaining[1..]; + } + // There was whitespace at the end, that's OK + if (remaining.len == 0) break; + + // Find the longest number + const number = number: { + var prev_number: ?u64 = null; + var prev_remaining: ?[]const u8 = null; + for (1..remaining.len + 1) |index| { + prev_number = std.fmt.parseUnsigned(u64, remaining[0..index], 10) catch { + if (prev_remaining) |prev| remaining = prev; + break :number prev_number; + }; + prev_remaining = remaining[index..]; + } + if (prev_remaining) |prev| remaining = prev; + break :number prev_number; + } orelse return error.InvalidValue; + + // Skip over any whitespace between the number and the unit + while (remaining.len > 0 and std.ascii.isWhitespace(remaining[0])) { + remaining = remaining[1..]; + } + + // A number without a unit is invalid + if (remaining.len == 0) return error.InvalidValue; + + // Find the longest matching unit. Needs to be the longest matching + // to distinguish 'm' from 'ms'. + const factor = factor: { + var prev_factor: ?u64 = null; + var prev_index: ?usize = null; + for (1..remaining.len + 1) |index| { + const next_factor = next: { + for (units) |unit| { + if (std.mem.eql(u8, unit.name, remaining[0..index])) { + break :next unit.factor; + } + } + break :next null; + }; + if (next_factor) |next| { + prev_factor = next; + prev_index = index; + } + } + if (prev_index) |index| { + remaining = remaining[index..]; + } + break :factor prev_factor; + } orelse return error.InvalidValue; + + if (value) |v| + value = v + number * factor + else + value = number * factor; + } + + self.duration = value; + } + + pub fn formatEntry(self: @This(), formatter: anytype) !void { + if (self.duration) |v| { + var buf: [64]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + + var value = v; + + const units = [_]struct { + name: []const u8, + factor: u64, + }{ + .{ .name = "y", .factor = 365 * std.time.ns_per_day }, + .{ .name = "w", .factor = std.time.ns_per_week }, + .{ .name = "d", .factor = std.time.ns_per_day }, + .{ .name = "h", .factor = std.time.ns_per_hour }, + .{ .name = "m", .factor = std.time.ns_per_min }, + .{ .name = "s", .factor = std.time.ns_per_s }, + .{ .name = "ms", .factor = std.time.ns_per_ms }, + .{ .name = "µs", .factor = std.time.ns_per_us }, + .{ .name = "ns", .factor = 1 }, + }; + + var i: usize = 0; + for (units) |unit| { + if (value > unit.factor) { + if (i > 0) writer.writeAll(" ") catch unreachable; + const remainder = value % unit.factor; + const quotient = (value - remainder) / unit.factor; + writer.print("{d}{s}", .{ quotient, unit.name }) catch unreachable; + value = remainder; + i += 1; + } + } + + try formatter.formatEntry([]const u8, fbs.getWritten()); + } else try formatter.formatEntry(void, {}); + } +}; + +test "parse duration" { + var d: Duration = undefined; + + try d.parseCLI(std.testing.allocator, ""); + try std.testing.expectEqual(@as(?u64, null), d.duration); + + try d.parseCLI(std.testing.allocator, "0ns"); + try std.testing.expectEqual(@as(u64, 0), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1ns"); + try std.testing.expectEqual(@as(u64, 1), d.duration.?); + + try d.parseCLI(std.testing.allocator, "100ns"); + try std.testing.expectEqual(@as(u64, 100), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1µs"); + try std.testing.expectEqual(@as(u64, 1000), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1µs1ns"); + try std.testing.expectEqual(@as(u64, 1001), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1µs 1ns"); + try std.testing.expectEqual(@as(u64, 1001), d.duration.?); + + try d.parseCLI(std.testing.allocator, " 1µs1ns"); + try std.testing.expectEqual(@as(u64, 1001), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1µs1ns "); + try std.testing.expectEqual(@as(u64, 1001), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1y"); + try std.testing.expectEqual(@as(u64, 365 * std.time.ns_per_day), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1d"); + try std.testing.expectEqual(@as(u64, std.time.ns_per_day), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1h"); + try std.testing.expectEqual(@as(u64, std.time.ns_per_hour), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1m"); + try std.testing.expectEqual(@as(u64, std.time.ns_per_min), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1s"); + try std.testing.expectEqual(@as(u64, std.time.ns_per_s), d.duration.?); + + try d.parseCLI(std.testing.allocator, "1ms"); + try std.testing.expectEqual(@as(u64, std.time.ns_per_ms), d.duration.?); + + try d.parseCLI(std.testing.allocator, "30s"); + try std.testing.expectEqual(@as(u64, 30 * std.time.ns_per_s), d.duration.?); + + try d.parseCLI(std.testing.allocator, "584y 49w 23h 34m 33s 709ms 551µs 615ns"); + try std.testing.expectEqual(std.math.maxInt(u64), d.duration.?); + + try std.testing.expectError(error.InvalidValue, d.parseCLI(std.testing.allocator, "1")); + try std.testing.expectError(error.InvalidValue, d.parseCLI(std.testing.allocator, "s")); + try std.testing.expectError(error.InvalidValue, d.parseCLI(std.testing.allocator, "1x")); + try std.testing.expectError(error.InvalidValue, d.parseCLI(std.testing.allocator, "1 ")); +} + +test "format duration" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var p: Duration = .{ .duration = std.math.maxInt(u64) }; + try p.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualStrings("a = 584y 49w 23h 34m 33s 709ms 551µs 615ns\n", buf.items); +} diff --git a/src/os/dbus.zig b/src/os/dbus.zig new file mode 100644 index 000000000..e2e0e9509 --- /dev/null +++ b/src/os/dbus.zig @@ -0,0 +1,21 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Returns true if the program was launched by D-Bus activation. +/// +/// On Linux GTK, this returns true if the program was launched using D-Bus +/// activation. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedByDBusActivation() bool { + return switch (builtin.os.tag) { + + // On Linux, D-Bus activation sets `DBUS_STARTER_ADDRESS` and + // `DBUS_STARTER_BUS_TYPE`. If these environment variables are present + // (no matter the value) we were launched by D-Bus activation. + .linux => std.posix.getenv("DBUS_STARTER_ADDRESS") != null and std.posix.getenv("DBUS_STARTER_BUS_TYPE") != null, + + // No other system supports D-Bus so always return false. + else => false, + }; +} diff --git a/src/os/main.zig b/src/os/main.zig index 04d53d752..e0c19d7c6 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -3,6 +3,7 @@ //! also OS-specific features and conventions. pub usingnamespace @import("env.zig"); +pub usingnamespace @import("dbus.zig"); pub usingnamespace @import("desktop.zig"); pub usingnamespace @import("file.zig"); pub usingnamespace @import("flatpak.zig"); @@ -13,6 +14,7 @@ pub usingnamespace @import("mouse.zig"); pub usingnamespace @import("open.zig"); pub usingnamespace @import("pipe.zig"); pub usingnamespace @import("resourcesdir.zig"); +pub usingnamespace @import("systemd.zig"); pub const CFReleaseThread = @import("cf_release_thread.zig"); pub const TempDir = @import("TempDir.zig"); pub const cgroup = @import("cgroup.zig"); diff --git a/src/os/systemd.zig b/src/os/systemd.zig new file mode 100644 index 000000000..e4b9c479c --- /dev/null +++ b/src/os/systemd.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Returns true if the program was launched as a systemd service. +/// +/// On Linux, this returns true if the program was launched as a systemd +/// service. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedBySystemd() bool { + return switch (builtin.os.tag) { + // On Linux, systemd sets the `INVOCATION_ID` (v232+) and the + // `JOURNAL_STREAM` (v231+) enviroment variables. If these environment + // variables are present (no matter the value) we were launched by + // systemd. This can be fooled if Ghostty is launched from another + // terminal that does not clean up these environment variables. + .linux => std.posix.getenv("INVOCATION_ID") != null and std.posix.getenv("JOURNAL_STREAM") != null, + + // No other system supports systemd so always return false. + else => false, + }; +} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 7961ea4a9..326a4c556 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -691,6 +691,17 @@ const Subprocess = struct { env.remove("GSK_RENDERER"); } + // On Linux, remove some environment variables that are set when Ghostty + // is launched from a `.desktop` file or by D-Bus activation. + if (comptime builtin.os.tag == .linux) { + env.remove("GIO_LAUNCHED_DESKTOP_FILE"); + env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + env.remove("DBUS_STARTER_ADDRESS"); + env.remove("DBUS_STARTER_BUS_TYPE"); + env.remove("INVOCATION_ID"); + env.remove("JOURNAL_STREAM"); + } + // Setup our shell integration, if we can. const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { const default_shell_command = cfg.command orelse switch (builtin.os.tag) {