diff --git a/README.md b/README.md index a3c3a9055..2667b7dc5 100644 --- a/README.md +++ b/README.md @@ -459,16 +459,17 @@ crash report was generated. > [!NOTE] > -> A future version of Ghostty will make the crash reports more easily -> viewable through the CLI and GUI. For now, you must manually check the -> directory. +> Use the `ghostty +crash-report` CLI command to get a list of available crash +> reports. A future version of Ghostty will make the contents of the crash +> reports more easily viewable through the CLI and GUI. -Crash reports end in the `.ghosttycrash` extension. The crash reports are -in [Sentry envelope format](https://develop.sentry.dev/sdk/envelopes/). You -can upload these to your own Sentry account to view their contents, but the -format is also publicly documented so any other available tools can also -be used. A future version of Ghostty will show you the contents of the -crash report directly in the terminal. +Crash reports end in the `.ghosttycrash` extension. The crash reports are in +[Sentry envelope format](https://develop.sentry.dev/sdk/envelopes/). You can +upload these to your own Sentry account to view their contents, but the format +is also publicly documented so any other available tools can also be used. +The `ghostty +crash-report` CLI command can be used to list any crash reports. +A future version of Ghostty will show you the contents of the crash report +directly in the terminal. If Ghostty crashed, you can help the project by sending the crash report to a maintainer. We do not recommend uploading crash reports directly diff --git a/src/cli/action.zig b/src/cli/action.zig index 8113f991a..950577158 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -11,6 +11,7 @@ const list_colors = @import("list_colors.zig"); const list_actions = @import("list_actions.zig"); const show_config = @import("show_config.zig"); const validate_config = @import("validate_config.zig"); +const crash_report = @import("crash_report.zig"); /// Special commands that can be invoked via CLI flags. These are all /// invoked by using `+` as a CLI flag. The only exception is @@ -43,6 +44,9 @@ pub const Action = enum { // Validate passed config file @"validate-config", + // List, (eventually) view, and (eventually) send crash reports. + @"crash-report", + pub const Error = error{ /// Multiple actions were detected. You can specify at most one /// action on the CLI otherwise the behavior desired is ambiguous. @@ -134,6 +138,7 @@ pub const Action = enum { .@"list-actions" => try list_actions.run(alloc), .@"show-config" => try show_config.run(alloc), .@"validate-config" => try validate_config.run(alloc), + .@"crash-report" => try crash_report.run(alloc), }; } diff --git a/src/cli/crash_report.zig b/src/cli/crash_report.zig new file mode 100644 index 000000000..0ec6a8ce0 --- /dev/null +++ b/src/cli/crash_report.zig @@ -0,0 +1,80 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const args = @import("args.zig"); +const Action = @import("action.zig").Action; +const Config = @import("../config.zig").Config; +const crash = @import("../crash/main.zig"); + +pub const Options = struct { + pub fn deinit(self: Options) void { + _ = self; + } + + /// Enables "-h" and "--help" to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `crash-report` command is used to inspect and send crash reports. +/// +/// When executed without any arguments, this will list existing crash reports. +/// +/// This command currently only supports listing crash reports. Viewing +/// and sending crash reports is unimplemented and will be added in the future. +pub fn run(alloc_gpa: Allocator) !u8 { + // Use an arena for the whole command to avoid manual memory management. + var arena = std.heap.ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try std.process.argsWithAllocator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } + + const crash_dir = try crash.defaultDir(alloc); + var reports = std.ArrayList(crash.Report).init(alloc); + + var it = try crash_dir.iterator(); + while (try it.next()) |report| try reports.append(.{ + .name = try alloc.dupe(u8, report.name), + .mtime = report.mtime, + }); + + const stdout = std.io.getStdOut(); + + // If we have no reports, then we're done. If we have a tty then we + // print a message, otherwise we do nothing. + if (reports.items.len == 0) { + if (std.posix.isatty(stdout.handle)) { + try stdout.writeAll("No crash reports! 👻"); + } + return 0; + } + + std.mem.sort(crash.Report, reports.items, {}, lt); + + const writer = stdout.writer(); + for (reports.items) |report| { + var buf: [128]u8 = undefined; + const now = std.time.nanoTimestamp(); + const diff = now - report.mtime; + const since = if (diff <= 0) "now" else s: { + const d = Config.Duration{ .duration = @intCast(diff) }; + break :s try std.fmt.bufPrint(&buf, "{s} ago", .{d.round(std.time.ns_per_s)}); + }; + try writer.print("{s} ({s})\n", .{ report.name, since }); + } + + return 0; +} + +fn lt(_: void, lhs: crash.Report, rhs: crash.Report) bool { + return lhs.mtime > rhs.mtime; +} diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index ca28a2765..b12694625 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -8,6 +8,7 @@ const configpkg = @import("../config.zig"); const Config = configpkg.Config; const vaxis = @import("vaxis"); const input = @import("../input.zig"); +const tui = @import("tui.zig"); const Binding = input.Binding; pub const Options = struct { @@ -61,12 +62,8 @@ pub fn run(alloc: Allocator) !u8 { const stdout = std.io.getStdOut(); - const can_pretty_print = switch (builtin.os.tag) { - .ios, .tvos, .watchos => false, - else => true, - }; // Despite being under the posix namespace, this also works on Windows as of zig 0.13.0 - if (can_pretty_print and !opts.plain and std.posix.isatty(stdout.handle)) { + if (tui.can_pretty_print and !opts.plain and std.posix.isatty(stdout.handle)) { return prettyPrint(alloc, config.keybind); } else { try config.keybind.formatEntryDocs( diff --git a/src/cli/tui.zig b/src/cli/tui.zig new file mode 100644 index 000000000..28d593ed4 --- /dev/null +++ b/src/cli/tui.zig @@ -0,0 +1,6 @@ +const builtin = @import("builtin"); + +pub const can_pretty_print = switch (builtin.os.tag) { + .ios, .tvos, .watchos => false, + else => true, +}; diff --git a/src/config/Config.zig b/src/config/Config.zig index 1f9a78f31..d853f0481 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4126,6 +4126,10 @@ pub const Duration = struct { return self.duration == other.duration; } + pub fn round(self: Duration, to: u64) Duration { + return .{ .duration = self.duration / to * to }; + } + pub fn parseCLI(input: ?[]const u8) !Duration { var remaining = input orelse return error.ValueRequired; diff --git a/src/crash/dir.zig b/src/crash/dir.zig new file mode 100644 index 000000000..e5a8f8a0c --- /dev/null +++ b/src/crash/dir.zig @@ -0,0 +1,66 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const internal_os = @import("../os/main.zig"); + +/// Returns a Dir for the default directory. The Dir.path field must be +/// freed with the given allocator. +pub fn defaultDir(alloc: Allocator) !Dir { + const crash_dir = try internal_os.xdg.state(alloc, .{ .subdir = "ghostty/crash" }); + errdefer alloc.free(crash_dir); + return .{ .path = crash_dir }; +} + +pub const Dir = struct { + /// The directory where crash reports are stored. This memory is owned + /// by the caller. + path: []const u8, + + /// Returns an iterator over the crash reports in this directory. This + /// iterator must be freed with `ReportIterator.deinit`. The iterator + /// may have no reports. + pub fn iterator(self: *const Dir) !ReportIterator { + var dir = std.fs.openDirAbsolute( + self.path, + .{ .iterate = true }, + ) catch return .{}; + errdefer dir.close(); + + return .{ + .dir = dir, + .it = dir.iterate(), + }; + } +}; + +pub const ReportIterator = struct { + dir: ?std.fs.Dir = null, + it: std.fs.Dir.Iterator = undefined, + + pub fn deinit(self: *ReportIterator) void { + if (self.dir) |dir| dir.close(); + } + + pub fn next(self: *ReportIterator) !?Report { + // If we have no dir then we failed to open the directory. + const dir = self.dir orelse return null; + + // Get the next file entry, if any. + const entry = entry: while (true) { + const entry = try self.it.next() orelse return null; + if (entry.kind != .file) continue; + break :entry entry; + }; + + const stat = try dir.statFile(entry.name); + return .{ + .name = entry.name, + .mtime = stat.mtime, + }; + } +}; + +pub const Report = struct { + name: []const u8, + mtime: i128, +}; diff --git a/src/crash/main.zig b/src/crash/main.zig index 1e6044c4b..1ac971851 100644 --- a/src/crash/main.zig +++ b/src/crash/main.zig @@ -2,10 +2,15 @@ //! whether that's setting up the system to catch crashes (Sentry client), //! introspecting crash reports, writing crash reports to disk, etc. +const dir = @import("dir.zig"); const sentry_envelope = @import("sentry_envelope.zig"); pub const sentry = @import("sentry.zig"); pub const Envelope = sentry_envelope.Envelope; +pub const defaultDir = dir.defaultDir; +pub const Dir = dir.Dir; +pub const ReportIterator = dir.ReportIterator; +pub const Report = dir.Report; // The main init/deinit functions for global state. pub const init = sentry.init; diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index 1a4be6841..afff83f50 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -253,12 +253,12 @@ pub const Transport = struct { // Get our XDG state directory where we'll store the crash reports. // This directory must exist for writing to work. - const crash_dir = try internal_os.xdg.state(alloc, .{ .subdir = "ghostty/crash" }); - try std.fs.cwd().makePath(crash_dir); + const dir = try crash.defaultDir(alloc); + try std.fs.cwd().makePath(dir.path); // Build our final path and write to it. const path = try std.fs.path.join(alloc, &.{ - crash_dir, + dir.path, try std.fmt.allocPrint(alloc, "{s}.ghosttycrash", .{uuid.string()}), }); const file = try std.fs.cwd().createFile(path, .{});