diff --git a/.github/workflows/clean-artifacts.yml b/.github/workflows/clean-artifacts.yml new file mode 100644 index 000000000..cb6864b23 --- /dev/null +++ b/.github/workflows/clean-artifacts.yml @@ -0,0 +1,17 @@ +name: Clean Artifacts +on: + schedule: + # Every 6 hours + - cron: '0 */6 * * *' + workflow_dispatch: +jobs: + remove-old-artifacts: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Remove old artifacts + uses: c-hive/gha-remove-artifacts@v1 + with: + age: '1 week' + skip-tags: true + skip-recent: 5 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index eaeb3f1f5..cbf0a1c7f 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -9,6 +9,50 @@ on: name: Release Tip jobs: + flatpak: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + container: + image: bilelmoussaoui/flatpak-github-actions:gnome-43 + options: --privileged + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + fetch-depth: 0 + # Docker is required by the docker/setup-qemu-action which enables emulation + - name: Install deps + run: | + dnf -y install docker + - name: Set up QEMU + id: qemu + uses: docker/setup-qemu-action@v1 + with: + platforms: arm64 + + - uses: flatpak/flatpak-github-actions/flatpak-builder@v4 + with: + bundle: ghostty.flatpak + manifest-path: com.mitchellh.ghostty.yml + branch: tip + cache-key: flatpak-builder-${{ matrix.arch }}-${{ github.sha }}-v1 + arch: ${{ matrix.arch }} + + - name: Rename Bundle + run: mv ghostty.flatpak ghostty-${{ matrix.arch }}.flatpak + - name: Release + uses: softprops/action-gh-release@v1 + with: + name: "Ghostty Tip (\"Nightly\")" + prerelease: true + tag_name: tip + target_commitish: ${{ github.sha }} + files: ghostty-${{ matrix.arch }}.flatpak + build-macos: if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: macos-12 diff --git a/.gitignore b/.gitignore index 2fef0f117..0a3b50879 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .direnv/ +.flatpak-builder/ zig-cache/ zig-out/ /result* diff --git a/.gitmodules b/.gitmodules index 24bed8d5b..793dcf0a0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -40,3 +40,6 @@ [submodule "vendor/libxev"] path = vendor/libxev url = https://github.com/mitchellh/libxev.git +[submodule "vendor/mach-sdk/sdk-linux-x86_64"] + path = vendor/mach-sdk/sdk-linux-x86_64 + url = https://github.com/hexops/sdk-linux-x86_64.git diff --git a/build.zig b/build.zig index 009ef5521..9ddc81aa4 100644 --- a/build.zig +++ b/build.zig @@ -46,6 +46,7 @@ comptime { var tracy: bool = false; var enable_coretext: bool = false; var enable_fontconfig: bool = false; +var flatpak: bool = false; var app_runtime: apprt.Runtime = .none; pub fn build(b: *std.build.Builder) !void { @@ -67,6 +68,12 @@ pub fn build(b: *std.build.Builder) !void { "Enable Tracy integration (default true in Debug on Linux)", ) orelse (optimize == .Debug and target.isLinux()); + flatpak = b.option( + bool, + "flatpak", + "Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.", + ) orelse false; + enable_coretext = b.option( bool, "coretext", @@ -123,6 +130,7 @@ pub fn build(b: *std.build.Builder) !void { }); const exe_options = b.addOptions(); exe_options.addOption(bool, "tracy_enabled", tracy); + exe_options.addOption(bool, "flatpak", flatpak); exe_options.addOption(bool, "coretext", enable_coretext); exe_options.addOption(bool, "fontconfig", enable_fontconfig); exe_options.addOption(apprt.Runtime, "app_runtime", app_runtime); @@ -146,7 +154,11 @@ pub fn build(b: *std.build.Builder) !void { // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html // Desktop file so that we have an icon and other metadata - b.installFile("dist/linux/app.desktop", "share/applications/com.mitchellh.ghostty.desktop"); + if (flatpak) { + b.installFile("dist/linux/app-flatpak.desktop", "share/applications/com.mitchellh.ghostty.desktop"); + } else { + b.installFile("dist/linux/app.desktop", "share/applications/com.mitchellh.ghostty.desktop"); + } // Various icons that our application can use, including the icon // that will be used for the desktop. @@ -159,7 +171,6 @@ pub fn build(b: *std.build.Builder) !void { b.installFile("images/icons/icon_32x32@2x@2x.png", "share/icons/hicolor/32x32@2/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_128x128@2x@2x.png", "share/icons/hicolor/128x128@2/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_256x256@2x@2x.png", "share/icons/hicolor/256x256@2/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_512x512@2x@2x.png", "share/icons/hicolor/512x512@2/com.mitchellh.ghostty.png"); } // App (Mac) @@ -579,6 +590,17 @@ fn addDeps( step.addIncludePath("vendor/glad/include/"); step.addCSourceFile("vendor/glad/src/gl.c", &.{}); + // When we're targeting flatpak we ALWAYS link GTK so we + // get access to glib for dbus. + if (flatpak) { + step.linkSystemLibrary("gtk4"); + switch (step.target.getCpuArch()) { + .aarch64 => step.addLibraryPath("/usr/lib/aarch64-linux-gnu"), + .x86_64 => step.addLibraryPath("/usr/lib/x86_64-linux-gnu"), + else => @panic("unsupported flatpak target"), + } + } + switch (app_runtime) { .none => {}, diff --git a/com.mitchellh.ghostty.yml b/com.mitchellh.ghostty.yml new file mode 100644 index 000000000..7a330d780 --- /dev/null +++ b/com.mitchellh.ghostty.yml @@ -0,0 +1,56 @@ +app-id: com.mitchellh.ghostty +runtime: org.gnome.Platform +runtime-version: '43' +sdk: org.gnome.Sdk +default-branch: tip +command: ghostty +build-options: + append-path: /app/tmp/zig + strip: false + no-debuginfo: true +# Note: we have to use cleanup-commands because flatpak-builder doesn't +# run "cleanup" on its own: https://github.com/flatpak/flatpak-builder/issues/14 +cleanup-commands: + - "rm -rf /app/tmp" +finish-args: + # 3D rendering + - --device=dri + # Windowing + - --share=ipc + - --socket=x11 + - --socket=wayland + # Files (we are a terminal so we need all of them) + - --filesystem=host + # So we can escape the sandbox + - --talk-name=org.freedesktop.Flatpak +modules: + # Note: this should be kept in sync with our flake.nix. Over time this + # should stabilize to being a release version and not a nightly. + - name: zig + buildsystem: simple + build-commands: + - mkdir -p /app/tmp/zig + - cp -r ./* /app/tmp/zig + sources: + - type: archive + url: https://ziglang.org/builds/zig-linux-x86_64-0.11.0-dev.1650+5e7b09ce9.tar.xz + sha256: 8b77a475d3d124f0d4a4c4d4e2756f4a5317838272c08341325f196e8d539747 + only-arches: + - x86_64 + - type: archive + url: https://ziglang.org/builds/zig-linux-aarch64-0.11.0-dev.1650+5e7b09ce9.tar.xz + sha256: 104c2370c6eba25164ede3fefaf133ed650cca951f2e473bd533cfb0660c4e23 + only-arches: + - aarch64 + + - name: ghostty + buildsystem: simple + build-commands: + - MACH_SDK_PATH="$(pwd)/vendor/mach-sdk" zig build -Doptimize=ReleaseSafe -Dcpu=baseline -Dflatpak=true -Dapp-runtime=gtk --prefix /app + sources: + - type: dir + path: . + skip: + - .flatpak-builder + - zig-cache + - zig-out diff --git a/dist/linux/app-flatpak.desktop b/dist/linux/app-flatpak.desktop new file mode 100644 index 000000000..99a005750 --- /dev/null +++ b/dist/linux/app-flatpak.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Ghostty +Type=Application +Comment=A terminal emulator +Exec=/app/bin/ghostty +Icon=com.mitchellh.ghostty +Keywords=terminal;tty;pty; +StartupNotify=true +Terminal=false diff --git a/nix/devshell.nix b/nix/devshell.nix index b2dc082fa..abea909d6 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -1,5 +1,7 @@ { mkShell, lib, stdenv +, debugedit +, flatpak-builder , fpm , gdb , glxinfo @@ -86,6 +88,10 @@ in mkShell rec { wabt wasmtime ] ++ lib.optionals stdenv.isLinux [ + # Flatpak builds + debugedit + flatpak-builder + valgrind wraptest ]; diff --git a/src/Command.zig b/src/Command.zig index ba11090d5..f5607b200 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -24,6 +24,7 @@ const Command = @This(); const std = @import("std"); const builtin = @import("builtin"); const TempDir = @import("TempDir.zig"); +const internal_os = @import("os/main.zig"); const mem = std.mem; const os = std.os; const debug = std.debug; diff --git a/src/Pty.zig b/src/Pty.zig index c61c9feb9..f9d419ec0 100644 --- a/src/Pty.zig +++ b/src/Pty.zig @@ -28,12 +28,13 @@ const TIOCSWINSZ = if (builtin.os.tag == .macos) 2148037735 else c.TIOCSWINSZ; const TIOCGWINSZ = if (builtin.os.tag == .macos) 1074295912 else c.TIOCGWINSZ; /// Redeclare this winsize struct so we can just use a Zig struct. This -/// layout should be correct on all tested platforms. +/// layout should be correct on all tested platforms. The defaults on this +/// are some reasonable screen size but you should probably not use them. const winsize = extern struct { - ws_row: u16, - ws_col: u16, - ws_xpixel: u16, - ws_ypixel: u16, + ws_row: u16 = 100, + ws_col: u16 = 80, + ws_xpixel: u16 = 800, + ws_ypixel: u16 = 600, }; pub extern "c" fn setsid() std.c.pid_t; diff --git a/src/build_config.zig b/src/build_config.zig index 05aa38825..27575c79d 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -22,6 +22,9 @@ pub const app_runtime = std.meta.stringToEnum( /// compiled. pub const devmode_enabled = artifact == .exe and app_runtime == .glfw; +/// We want to integrate with Flatpak APIs. +pub const flatpak = options.flatpak; + pub const Artifact = enum { /// Standalone executable exe, diff --git a/src/config.zig b/src/config.zig index 324bc713c..b0e13e1e8 100644 --- a/src/config.zig +++ b/src/config.zig @@ -344,14 +344,18 @@ pub const Config = struct { if (self.command == null or wd_home) command: { const alloc = self._arena.?.allocator(); - // First look up the command using the SHELL env var. - if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { - log.debug("default shell source=env value={s}", .{value}); - self.command = value; + // We don't do this in flatpak because SHELL in Flatpak is + // always set to /bin/sh + if (!internal_os.isFlatpak()) { + // First look up the command using the SHELL env var. + if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { + log.debug("default shell source=env value={s}", .{value}); + self.command = value; - // If we don't need the working directory, then we can exit now. - if (!wd_home) break :command; - } else |_| {} + // If we don't need the working directory, then we can exit now. + if (!wd_home) break :command; + } else |_| {} + } // We need the passwd entry for the remainder const pw = try passwd.get(alloc); diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig new file mode 100644 index 000000000..bcb8486f5 --- /dev/null +++ b/src/os/flatpak.zig @@ -0,0 +1,392 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); + +const log = std.log.scoped(.flatpak); + +/// Returns true if we're running in a Flatpak environment. +pub fn isFlatpak() bool { + // If we're not on Linux then we'll make this comptime false. + if (comptime builtin.os.tag != .linux) return false; + return if (std.fs.accessAbsolute("/.flatpak-info", .{})) true else |_| false; +} + +/// A struct to help execute commands on the host via the +/// org.freedesktop.Flatpak.Development DBus module. This uses GIO/GLib +/// under the hood. +/// +/// This always spawns its own thread and maintains its own GLib event loop. +/// This makes it easy for the command to behave synchronously similar to +/// std.process.ChildProcess. +/// +/// There are lots of chances for low-hanging improvements here (automatic +/// pipes, /dev/null, etc.) but this was purpose built for my needs so +/// it doesn't have all of those. +/// +/// Requires GIO, GLib to be available and linked. +pub const FlatpakHostCommand = struct { + const fd_t = std.os.fd_t; + const EnvMap = std.process.EnvMap; + const c = @cImport({ + @cInclude("gio/gio.h"); + @cInclude("gio/gunixfdlist.h"); + }); + + /// Argv are the arguments to call on the host with argv[0] being + /// the command to execute. + argv: []const []const u8, + + /// The cwd for the new process. If this is not set then it will use + /// the current cwd of the calling process. + cwd: ?[:0]const u8 = null, + + /// Environment variables for the child process. If this is null, this + /// does not send any environment variables. + env: ?*const EnvMap = null, + + /// File descriptors to send to the child process. It is up to the + /// caller to create the file descriptors and set them up. + stdin: fd_t, + stdout: fd_t, + stderr: fd_t, + + /// State of the process. This is updated by the dedicated thread it + /// runs in and is protected by the given lock and condition variable. + state: State = .{ .init = {} }, + state_mutex: std.Thread.Mutex = .{}, + state_cv: std.Thread.Condition = .{}, + + /// State the process is in. This can't be inspected directly, you + /// must use getters on the struct to get access. + const State = union(enum) { + /// Initial state + init: void, + + /// Error starting. The error message is only available via logs. + /// (This isn't a fundamental limitation, just didn't need the + /// error message yet) + err: void, + + /// Process started with the given pid on the host. + started: struct { + pid: c_int, + subscription: c.guint, + loop: *c.GMainLoop, + }, + + /// Process exited + exited: struct { + pid: c_int, + status: u8, + }, + }; + + /// Errors that are possible from us. + pub const Error = error{ + FlatpakMustBeStarted, + FlatpakSpawnFail, + FlatpakSetupFail, + FlatpakRPCFail, + }; + + /// Spawn the command. This will start the host command. On return, + /// the pid will be available. This must only be called with the + /// state in "init". + /// + /// Precondition: The self pointer MUST be stable. + pub fn spawn(self: *FlatpakHostCommand, alloc: Allocator) !c_int { + const thread = try std.Thread.spawn(.{}, threadMain, .{ self, alloc }); + thread.setName("flatpak-host-command") catch {}; + + // Wait for the process to start or error. + self.state_mutex.lock(); + defer self.state_mutex.unlock(); + while (self.state == .init) self.state_cv.wait(&self.state_mutex); + + return switch (self.state) { + .init => unreachable, + .err => Error.FlatpakSpawnFail, + .started => |v| v.pid, + .exited => |v| v.pid, + }; + } + + /// Wait for the process to end and return the exit status. This + /// can only be called ONCE. Once this returns, the state is reset. + pub fn wait(self: *FlatpakHostCommand) !u8 { + self.state_mutex.lock(); + defer self.state_mutex.unlock(); + + while (true) { + switch (self.state) { + .init => return Error.FlatpakMustBeStarted, + .err => return Error.FlatpakSpawnFail, + .started => {}, + .exited => |v| { + self.state = .{ .init = {} }; + self.state_cv.broadcast(); + return v.status; + }, + } + + self.state_cv.wait(&self.state_mutex); + } + } + + /// Send a signal to the started command. This does nothing if the + /// command is not in the started state. + pub fn signal(self: *FlatpakHostCommand, sig: u8, pg: bool) !void { + const pid = pid: { + self.state_mutex.lock(); + defer self.state_mutex.unlock(); + switch (self.state) { + .started => |v| break :pid v.pid, + else => return, + } + }; + + // Get our bus connection. + var g_err: [*c]c.GError = null; + const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse { + log.warn("signal error getting bus: {s}", .{g_err.*.message}); + return Error.FlatpakSetupFail; + }; + defer c.g_object_unref(bus); + + const reply = c.g_dbus_connection_call_sync( + bus, + "org.freedesktop.Flatpak", + "/org/freedesktop/Flatpak/Development", + "org.freedesktop.Flatpak.Development", + "HostCommandSignal", + c.g_variant_new( + "(uub)", + pid, + sig, + @intCast(c_int, @boolToInt(pg)), + ), + c.G_VARIANT_TYPE("()"), + c.G_DBUS_CALL_FLAGS_NONE, + c.G_MAXINT, + null, + &g_err, + ); + if (g_err != null) { + log.warn("signal send error: {s}", .{g_err.*.message}); + return; + } + defer c.g_variant_unref(reply); + } + + fn threadMain(self: *FlatpakHostCommand, alloc: Allocator) void { + // Create a new thread-local context so that all our sources go + // to this context and we can run our loop correctly. + const ctx = c.g_main_context_new(); + defer c.g_main_context_unref(ctx); + c.g_main_context_push_thread_default(ctx); + defer c.g_main_context_pop_thread_default(ctx); + + // Get our loop for the current thread + const loop = c.g_main_loop_new(ctx, 1).?; + defer c.g_main_loop_unref(loop); + + // Get our bus connection. This has to remain active until we exit + // the thread otherwise our signals won't be called. + var g_err: [*c]c.GError = null; + const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse { + log.warn("spawn error getting bus: {s}", .{g_err.*.message}); + self.updateState(.{ .err = {} }); + return; + }; + defer c.g_object_unref(bus); + + // Spawn the command first. This will setup all our IO. + self.start(alloc, bus, loop) catch |err| { + log.warn("error starting host command: {}", .{err}); + self.updateState(.{ .err = {} }); + return; + }; + + // Run the event loop. It quits in the exit callback. + c.g_main_loop_run(loop); + } + + /// Start the command. This will start the host command and set the + /// pid field on success. This will not wait for completion. + /// + /// Once this is called, the self pointer MUST remain stable. This + /// requirement is due to using GLib under the covers with callbacks. + fn start( + self: *FlatpakHostCommand, + alloc: Allocator, + bus: *c.GDBusConnection, + loop: *c.GMainLoop, + ) !void { + var err: [*c]c.GError = null; + var arena_allocator = std.heap.ArenaAllocator.init(alloc); + defer arena_allocator.deinit(); + const arena = arena_allocator.allocator(); + + // Our list of file descriptors that we need to send to the process. + const fd_list = c.g_unix_fd_list_new(); + defer c.g_object_unref(fd_list); + if (c.g_unix_fd_list_append(fd_list, self.stdin, &err) < 0) { + log.warn("error adding fd: {s}", .{err.*.message}); + return Error.FlatpakSetupFail; + } + if (c.g_unix_fd_list_append(fd_list, self.stdout, &err) < 0) { + log.warn("error adding fd: {s}", .{err.*.message}); + return Error.FlatpakSetupFail; + } + if (c.g_unix_fd_list_append(fd_list, self.stderr, &err) < 0) { + log.warn("error adding fd: {s}", .{err.*.message}); + return Error.FlatpakSetupFail; + } + + // Build our arguments for the file descriptors. + const fd_builder = c.g_variant_builder_new(c.G_VARIANT_TYPE("a{uh}")); + defer c.g_variant_builder_unref(fd_builder); + c.g_variant_builder_add(fd_builder, "{uh}", @as(c_int, 0), self.stdin); + c.g_variant_builder_add(fd_builder, "{uh}", @as(c_int, 1), self.stdout); + c.g_variant_builder_add(fd_builder, "{uh}", @as(c_int, 2), self.stderr); + + // Build our env vars + const env_builder = c.g_variant_builder_new(c.G_VARIANT_TYPE("a{ss}")); + defer c.g_variant_builder_unref(env_builder); + if (self.env) |env| { + var it = env.iterator(); + while (it.next()) |pair| { + const key = try arena.dupeZ(u8, pair.key_ptr.*); + const value = try arena.dupeZ(u8, pair.value_ptr.*); + c.g_variant_builder_add(env_builder, "{ss}", key.ptr, value.ptr); + } + } + + // Build our args + const args_ptr = c.g_ptr_array_new(); + { + errdefer _ = c.g_ptr_array_free(args_ptr, 1); + for (self.argv) |arg| { + const argZ = try arena.dupeZ(u8, arg); + c.g_ptr_array_add(args_ptr, argZ.ptr); + } + } + const args = c.g_ptr_array_free(args_ptr, 0); + defer c.g_free(@ptrCast(?*anyopaque, args)); + + // Get the cwd in case we don't have ours set. A small optimization + // would be to do this only if we need it but this isn't a + // common code path. + const g_cwd = c.g_get_current_dir(); + defer c.g_free(g_cwd); + + // The params for our RPC call + const params = c.g_variant_new( + "(^ay^aay@a{uh}@a{ss}u)", + if (self.cwd) |cwd| cwd.ptr else g_cwd, + args, + c.g_variant_builder_end(fd_builder), + c.g_variant_builder_end(env_builder), + @as(c_int, 0), + ); + _ = c.g_variant_ref_sink(params); // take ownership + defer c.g_variant_unref(params); + + // Subscribe to exit notifications + const subscription_id = c.g_dbus_connection_signal_subscribe( + bus, + "org.freedesktop.Flatpak", + "org.freedesktop.Flatpak.Development", + "HostCommandExited", + "/org/freedesktop/Flatpak/Development", + null, + 0, + onExit, + self, + null, + ); + errdefer c.g_dbus_connection_signal_unsubscribe(bus, subscription_id); + + // Go! + const reply = c.g_dbus_connection_call_with_unix_fd_list_sync( + bus, + "org.freedesktop.Flatpak", + "/org/freedesktop/Flatpak/Development", + "org.freedesktop.Flatpak.Development", + "HostCommand", + params, + c.G_VARIANT_TYPE("(u)"), + c.G_DBUS_CALL_FLAGS_NONE, + c.G_MAXINT, + fd_list, + null, + null, + &err, + ) orelse { + log.warn("Flatpak.HostCommand failed: {s}", .{err.*.message}); + return Error.FlatpakRPCFail; + }; + defer c.g_variant_unref(reply); + + var pid: c_int = 0; + c.g_variant_get(reply, "(u)", &pid); + log.debug("HostCommand started pid={} subscription={}", .{ + pid, + subscription_id, + }); + + self.updateState(.{ + .started = .{ + .pid = pid, + .subscription = subscription_id, + .loop = loop, + }, + }); + } + + /// Helper to update the state and notify waiters via the cv. + fn updateState(self: *FlatpakHostCommand, state: State) void { + self.state_mutex.lock(); + defer self.state_mutex.unlock(); + defer self.state_cv.broadcast(); + self.state = state; + } + + fn onExit( + bus: ?*c.GDBusConnection, + _: [*c]const u8, + _: [*c]const u8, + _: [*c]const u8, + _: [*c]const u8, + params: ?*c.GVariant, + ud: ?*anyopaque, + ) callconv(.C) void { + const self = @ptrCast(*FlatpakHostCommand, @alignCast(@alignOf(FlatpakHostCommand), ud)); + const state = state: { + self.state_mutex.lock(); + defer self.state_mutex.unlock(); + break :state self.state.started; + }; + + var pid: c_int = 0; + var exit_status: c_int = 0; + c.g_variant_get(params.?, "(uu)", &pid, &exit_status); + if (state.pid != pid) return; + + // Update our state + self.updateState(.{ + .exited = .{ + .pid = pid, + .status = std.math.cast(u8, exit_status) orelse 255, + }, + }); + log.debug("HostCommand exited pid={} status={}", .{ pid, exit_status }); + + // We're done now, so we can unsubscribe + c.g_dbus_connection_signal_unsubscribe(bus.?, state.subscription); + + // We are also done with our loop so we can exit. + c.g_main_loop_quit(state.loop); + } +}; diff --git a/src/os/main.zig b/src/os/main.zig index 132764dd7..070e7be1d 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -2,6 +2,7 @@ //! system. pub usingnamespace @import("file.zig"); +pub usingnamespace @import("flatpak.zig"); pub usingnamespace @import("locale.zig"); pub usingnamespace @import("macos_version.zig"); pub usingnamespace @import("mouse.zig"); diff --git a/src/passwd.zig b/src/passwd.zig index 0a97676cb..e492d120a 100644 --- a/src/passwd.zig +++ b/src/passwd.zig @@ -1,7 +1,9 @@ const std = @import("std"); const builtin = @import("builtin"); +const build_config = @import("build_config.zig"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; +const internal_os = @import("os/main.zig"); const log = std.log.scoped(.passwd); @@ -45,6 +47,77 @@ pub fn get(alloc: Allocator) !Entry { var result: Entry = .{}; + // If we're in flatpak then our entry is always empty so we grab it + // by shelling out to the host. note that we do HAVE an entry in the + // sandbox but only the username is correct. + if (internal_os.isFlatpak()) flatpak: { + if (comptime !build_config.flatpak) { + log.warn("flatpak detected, but this build doesn't contain flatpak support", .{}); + break :flatpak; + } + + log.info("flatpak detected, will use host command to get our entry", .{}); + + // Note: we wrap our getent call in a /bin/sh login shell because + // some operating systems (NixOS tested) don't set the PATH for various + // utilities properly until we get a login shell. + const Pty = @import("Pty.zig"); + var pty = try Pty.open(.{}); + defer pty.deinit(); + var cmd: internal_os.FlatpakHostCommand = .{ + .argv = &[_][]const u8{ + "/bin/sh", + "-l", + "-c", + try std.fmt.allocPrint( + alloc, + "getent passwd {s}", + .{std.mem.sliceTo(pw.pw_name, 0)}, + ), + }, + .stdin = pty.slave, + .stdout = pty.slave, + .stderr = pty.slave, + }; + _ = try cmd.spawn(alloc); + _ = try cmd.wait(); + + // Once started, we can close the child side. We do this after + // wait right now but that is fine too. This lets us read the + // parent and detect EOF. + _ = std.os.close(pty.slave); + + // Read all of our output + const output = output: { + var output: std.ArrayListUnmanaged(u8) = .{}; + while (true) { + const n = std.os.read(pty.master, &buf) catch |err| { + switch (err) { + // EIO is triggered at the end since we closed our + // child side. This is just EOF for this. I'm not sure + // if I'm doing this wrong. + error.InputOutput => break, + else => return err, + } + }; + + try output.appendSlice(alloc, buf[0..n]); + + // Max total size is buf.len. We can do better here by trimming + // the front and continuing reading but we choose to just exit. + if (output.items.len > buf.len) break; + } + + break :output try output.toOwnedSlice(alloc); + }; + + // Shell and home are the last two entries + var it = std.mem.splitBackwards(u8, std.mem.trimRight(u8, output, " \r\n"), ":"); + result.shell = it.next() orelse null; + result.home = it.next() orelse null; + return result; + } + if (pw.pw_shell) |ptr| { const source = std.mem.sliceTo(ptr, 0); const sh = try alloc.alloc(u8, source.len); diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 12424272d..099c71cdf 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -136,6 +136,7 @@ pub fn threadMain(self: *Thread) void { } fn threadMain_(self: *Thread) !void { + defer log.debug("renderer thread exited", .{}); tracy.setThreadName("renderer"); // Run our thread start/end callbacks. This is important because some @@ -185,7 +186,7 @@ fn threadMain_(self: *Thread) !void { // Run log.debug("starting renderer thread", .{}); - defer log.debug("exiting renderer thread", .{}); + defer log.debug("starting renderer thread shutdown", .{}); _ = try self.loop.run(.until_done); } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 86dcf71e8..b55167dd6 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -6,6 +6,7 @@ const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const EnvMap = std.process.EnvMap; const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); const Pty = @import("../Pty.zig"); @@ -17,6 +18,7 @@ const tracy = @import("tracy"); const trace = tracy.trace; const apprt = @import("../apprt.zig"); const fastmem = @import("../fastmem.zig"); +const internal_os = @import("../os/main.zig"); const log = std.log.scoped(.io_exec); @@ -90,7 +92,7 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec { } pub fn deinit(self: *Exec) void { - self.subprocess.deinit(self.alloc); + self.subprocess.deinit(); // Clean up our other members self.terminal.deinit(self.alloc); @@ -139,6 +141,7 @@ pub fn threadEnter(self: *Exec, thread: *termio.Thread) !ThreadData { // Store our data so our callbacks can access it self.data = ev_data_ptr; + errdefer self.data = null; // Start our reader thread const read_thread = try std.Thread.spawn( @@ -319,22 +322,33 @@ fn ttyWrite( /// Subprocess manages the lifecycle of the shell subprocess. const Subprocess = struct { + /// If we build with flatpak support then we have to keep track of + /// a potential execution on the host. + const FlatpakHostCommand = if (build_config.flatpak) internal_os.FlatpakHostCommand else void; + + arena: std.heap.ArenaAllocator, cwd: ?[]const u8, - env: std.process.EnvMap, + env: EnvMap, path: []const u8, - argv0_override: ?[]const u8, + args: [][]const u8, grid_size: renderer.GridSize, screen_size: renderer.ScreenSize, pty: ?Pty = null, command: ?Command = null, + flatpak_command: ?FlatpakHostCommand = null, /// Initialize the subprocess. This will NOT start it, this only sets /// up the internal state necessary to start it later. - pub fn init(alloc: Allocator, opts: termio.Options) !Subprocess { + pub fn init(gpa: Allocator, opts: termio.Options) !Subprocess { + // We have a lot of maybe-allocations that all share the same lifetime + // so use an arena so we don't end up in an accounting nightmare. + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + // Determine the path to the binary we're executing const path = (try Command.expandPath(alloc, opts.config.command orelse "sh")) orelse return error.CommandNotFound; - errdefer alloc.free(path); // 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 @@ -354,10 +368,19 @@ const Subprocess = struct { std.mem.copy(u8, argv0_buf[1..], argv0); break :argv0 argv0_buf; } else null; - errdefer if (argv0_override) |buf| alloc.free(buf); - // Set our env vars - var env = try std.process.getEnvMap(alloc); + // Set our env vars. For Flatpak builds running in Flatpak we don't + // inherit our environment because the login shell on the host side + // will get it. + var env = env: { + if (comptime build_config.flatpak) { + if (internal_os.isFlatpak()) { + break :env std.process.EnvMap.init(alloc); + } + } + + break :env try std.process.getEnvMap(alloc); + }; errdefer env.deinit(); try env.put("TERM", "xterm-256color"); try env.put("COLORTERM", "truecolor"); @@ -377,22 +400,41 @@ const Subprocess = struct { } } + // If we're NOT in a flatpak (usually!), then we just exec the + // process directly. If we are in a flatpak, we use flatpak-spawn + // to escape the sandbox. + const args = if (!internal_os.isFlatpak()) &[_][]const u8{ + argv0_override orelse path, + } else args: { + var args = try std.ArrayList([]const u8).initCapacity(alloc, 8); + defer args.deinit(); + + // 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"); + try args.append("-c"); + try args.append(path); + + break :args try args.toOwnedSlice(); + }; + return .{ + .arena = arena, .env = env, .cwd = opts.config.@"working-directory", - .path = path, - .argv0_override = argv0_override, + .path = if (internal_os.isFlatpak()) args[0] else path, + .args = args, .grid_size = opts.grid_size, .screen_size = opts.screen_size, }; } /// Clean up the subprocess. This will stop the subprocess if it is started. - pub fn deinit(self: *Subprocess, alloc: Allocator) void { + pub fn deinit(self: *Subprocess) void { self.stop(); - self.env.deinit(); - alloc.free(self.path); - if (self.argv0_override) |v| alloc.free(v); + self.arena.deinit(); self.* = undefined; } @@ -414,12 +456,51 @@ const Subprocess = struct { self.pty = null; } - const args = &[_][]const u8{self.argv0_override orelse self.path}; + log.debug("starting command path={s} args={s}", .{ + self.path, + self.args, + }); + + // In flatpak, we use the HostCommand to execute our shell. + if (internal_os.isFlatpak()) flatpak: { + if (comptime !build_config.flatpak) { + log.warn("flatpak detected, but flatpak support not built-in", .{}); + 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, + .env = &self.env, + .stdin = pty.slave, + .stdout = pty.slave, + .stderr = pty.slave, + }; + var cmd = &self.flatpak_command.?; + const pid = try cmd.spawn(alloc); + errdefer killCommandFlatpak(cmd); + + log.info("started subcommand on host via flatpak API path={s} pid={?}", .{ + self.path, + pid, + }); + + // Once started, we can close the pty child side. We do this after + // wait right now but that is fine too. This lets us read the + // parent and detect EOF. + _ = std.os.close(pty.slave); + + return pty.master; + } // Build our subcommand var cmd: Command = .{ .path = self.path, - .args = args, + .args = self.args, .env = &self.env, .cwd = self.cwd, .stdin = .{ .handle = pty.slave }, @@ -454,6 +535,17 @@ const Subprocess = struct { self.command = null; } + // Kill our Flatpak command + if (FlatpakHostCommand != void) { + if (self.flatpak_command) |*cmd| { + killCommandFlatpak(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + _ = cmd.wait() catch |err| + log.err("error waiting for command to exit: {}", .{err}); + self.flatpak_command = null; + } + } + // Close our PTY. We do this after killing our command because on // macOS, close will block until all blocking operations read/write // are done with it and our reader thread is probably still alive. @@ -512,6 +604,12 @@ const Subprocess = struct { } } } + + /// Kill the underlying process started via Flatpak host command. + /// This sends a signal via the Flatpak API. + fn killCommandFlatpak(command: *FlatpakHostCommand) !void { + try command.signal(c.SIGHUP, true); + } }; /// The read thread sits in a loop doing the following pseudo code: @@ -535,7 +633,9 @@ const ReadThread = struct { switch (err) { // This means our pty is closed. We're probably // gracefully shutting down. - error.NotOpenForReading => log.info("io reader exiting", .{}), + error.NotOpenForReading, + error.InputOutput, + => log.info("io reader exiting", .{}), else => { log.err("io reader error err={}", .{err}); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 48dd4a35e..aa2a9c568 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -95,6 +95,7 @@ pub fn threadMain(self: *Thread) void { } fn threadMain_(self: *Thread) !void { + defer log.debug("IO thread exited", .{}); tracy.setThreadName("pty io"); // Run our thread start/end callbacks. This allows the implementation @@ -109,7 +110,7 @@ fn threadMain_(self: *Thread) !void { // Run log.debug("starting IO thread", .{}); - defer log.debug("exiting IO thread", .{}); + defer log.debug("starting IO thread shutdown", .{}); try self.loop.run(.until_done); } diff --git a/vendor/mach-sdk/sdk-linux-aarch64 b/vendor/mach-sdk/sdk-linux-aarch64 index 8f6ddaf6c..a279b0a3e 160000 --- a/vendor/mach-sdk/sdk-linux-aarch64 +++ b/vendor/mach-sdk/sdk-linux-aarch64 @@ -1 +1 @@ -Subproject commit 8f6ddaf6cc25df02925ef78448d512c3184abc63 +Subproject commit a279b0a3ef2f103b308defcd7e1a32e20346f70b diff --git a/vendor/mach-sdk/sdk-linux-x86_64 b/vendor/mach-sdk/sdk-linux-x86_64 new file mode 160000 index 000000000..ebd1ce12e --- /dev/null +++ b/vendor/mach-sdk/sdk-linux-x86_64 @@ -0,0 +1 @@ +Subproject commit ebd1ce12e9abc152c7ed43afbcdb4b6e1c95be07