diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b4a7e83..b2dfb23 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -54,7 +54,7 @@ body: id: evidence attributes: label: Evidence - description: The relevant part of your configuration, script output, error text or logs. Drag screenshots or recordings in here. + description: The relevant part of your configuration, script output, error text or logs. If StableBar quit unexpectedly, paste the report from Settings → Status → Crashes → Copy report. Drag screenshots or recordings in here. - type: textarea id: related diff --git a/Sources/StableBar/BarStore.swift b/Sources/StableBar/BarStore.swift index 32d9f8e..d8d571f 100644 --- a/Sources/StableBar/BarStore.swift +++ b/Sources/StableBar/BarStore.swift @@ -15,6 +15,7 @@ final class BarStore: NSObject { let windowClearance = WindowClearance() let menuBarDelay = MenuBarDelay() let appMenu = AppMenu() + var crashReports = CrashReports() let popup: WidgetPopup let innerPopup = WidgetPopup() var update: AppUpdate? @@ -28,6 +29,7 @@ final class BarStore: NSObject { let registry: DataSourceRegistry var onConfigurationChange: (() -> Void)? var onCustomize: (() -> Void)? + var onShowCrashes: (() -> Void)? var onCustomizeWidget: ((String) -> Void)? var onCustomizePackages: (() -> Void)? /// A `stablebar://add-source` link waiting for Settings › Packages to open its form. diff --git a/Sources/StableBar/BarView.swift b/Sources/StableBar/BarView.swift index 90a68bb..9a88942 100644 --- a/Sources/StableBar/BarView.swift +++ b/Sources/StableBar/BarView.swift @@ -147,10 +147,10 @@ struct BarView: View { Text("StableBar") if store.barEditing.isAvailable { Button(store.barEditing.menuTitle, action: store.barEditing.toggle) } Button("Try Themes…") { store.onTryThemes?(display.id) } - if let error = store.configurationError ?? store.workspaceStore.error ?? store.configurationWarning { - Divider() - Text(error) - } + let error = store.configurationError ?? store.workspaceStore.error ?? store.configurationWarning + if error != nil || store.crashReports.note != nil { Divider() } + if let error { Text(error) } + if let note = store.crashReports.note { Button(note) { store.onShowCrashes?() } } Divider() Button("Settings…") { store.onCustomize?() } Button("Quit StableBar") { NSApp.terminate(nil) } diff --git a/Sources/StableBar/ConfigurationEditor.swift b/Sources/StableBar/ConfigurationEditor.swift index c52ccfd..5216675 100644 --- a/Sources/StableBar/ConfigurationEditor.swift +++ b/Sources/StableBar/ConfigurationEditor.swift @@ -32,7 +32,8 @@ final class ConfigurationEditor { var barScreen = ScreenKind.laptop var themeChangesEveryBar = true var selectedPackageID: String? - var diagnosticWidgetID: String? + /// What Status shows; nil is the overview of every widget. + var diagnosticSelection: DiagnosticsSelection? = .overview var selectedProfileName: String? var search = "" var searchTarget: String? @@ -92,7 +93,14 @@ final class ConfigurationEditor { func troubleshoot(_ id: String) { finishEditing() searchTarget = nil - diagnosticWidgetID = id + diagnosticSelection = .widget(id) + section = .diagnostics + } + + func showCrashes() { + finishEditing() + searchTarget = nil + diagnosticSelection = .crashes section = .diagnostics } diff --git a/Sources/StableBar/CrashReport.swift b/Sources/StableBar/CrashReport.swift new file mode 100644 index 0000000..2670d8c --- /dev/null +++ b/Sources/StableBar/CrashReport.swift @@ -0,0 +1,171 @@ +import Foundation + +/// A crash of StableBar, read from the report macOS writes to ~/Library/Logs/DiagnosticReports after every crash. +/// The report stays on the Mac; `markdown` and `issueURL` are what the user can choose to copy or post. +struct CrashReport: Identifiable, Equatable, Sendable { + struct Frame: Equatable, Sendable { + var image: String + var symbol: String? + /// The offset into `symbol` when there is one, otherwise into `image`. + var offset: Int + /// `File.swift:12`, when the build kept line information. + var source: String? + + var text: String { text(imageWidth: 0) } + + func text(imageWidth: Int, symbolLimit: Int = .max) -> String { + guard let symbol else { return "\(image) + 0x\(String(offset, radix: 16))" } + return "\(image.padding(toLength: max(imageWidth, image.count), withPad: " ", startingAt: 0)) " + + "\(CrashReport.shortened(symbol, to: symbolLimit)) + \(offset)" + + (source.map { " (\($0))" } ?? "") + } + } + + var id: String + var file: URL + var date: Date + var version: String? + var build: String? + var system: String + var model: String? + var exception: String + var subtype: String? + var termination: String? + var message: String? + var thread: String + var frames: [Frame] + + /// Reads a crash report (`bug_type` 309) of StableBar itself. Other reports, and files macOS is still writing, give nil. + init?(data: Data, file: URL) { + guard let newline = data.firstIndex(of: UInt8(ascii: "\n")), + let header = try? JSONSerialization.jsonObject(with: data[.. String? { (value as? String).flatMap { $0.isEmpty ? nil : $0 } } + version = text(header["app_version"]) ?? text(bundle?["CFBundleShortVersionString"]) + build = text(header["build_version"]) ?? text(bundle?["CFBundleVersion"]) + system = header["os_version"] as? String + ?? [os?["train"] as? String, (os?["build"] as? String).map { "(\($0))" }].compactMap { $0 }.joined(separator: " ") + model = body["modelCode"] as? String + self.exception = [exception?["type"] as? String ?? "Unknown exception", (exception?["signal"] as? String).map { "(\($0))" }] + .compactMap { $0 }.joined(separator: " ") + subtype = exception?["subtype"] as? String + termination = (body["termination"] as? [String: Any])?["indicator"] as? String + let messages = (body["asi"] as? [String: Any] ?? [:]).sorted { $0.key < $1.key }.flatMap { $0.value as? [String] ?? [] } + message = messages.isEmpty ? nil : Self.redacted(messages.joined(separator: "\n")) + let details = [thread["name"] as? String, thread["queue"] as? String].compactMap { $0 } + self.thread = "Thread \(index)" + (details.isEmpty ? "" : " (\(details.joined(separator: ", ")))") + frames = (thread["frames"] as? [[String: Any]] ?? []).map { frame in + let image = (frame["imageIndex"] as? Int).flatMap { images.indices.contains($0) ? images[$0] : nil } ?? "???" + let symbol = frame["symbol"] as? String + let file = frame["sourceFile"] as? String, line = frame["sourceLine"] as? Int + return Frame(image: image, symbol: symbol, + offset: (symbol == nil ? frame["imageOffset"] : frame["symbolLocation"]) as? Int ?? 0, + source: file.map { "\($0)\(line.map { ":\($0)" } ?? "")" }) + } + } + + /// Where it crashed: the first frame in StableBar's own code, or the top frame when none is. + var location: String? { + let frame = frames.first { $0.image == "StableBar" } ?? frames.first + return frame.map { $0.symbol ?? $0.text } + } + + /// One line for lists: when, what, where, which build and which file. + var summary: String { + "\(Self.timestamp(date)): \(exception)\(location.map { " in \($0)" } ?? ""), StableBar \(release), \(file.lastPathComponent)" + } + + var markdown: String { markdown() } + + /// The summary to paste into an issue, with at most `frameLimit` frames, `messageLimit` characters of message and + /// `symbolLimit` characters of each frame's symbol. + func markdown(frameLimit: Int = 60, messageLimit: Int = 2000, symbolLimit: Int = .max) -> String { + let shown = frames.prefix(frameLimit) + let width = shown.filter { $0.symbol != nil }.map(\.image.count).max() ?? 0 + let numberWidth = String(max(shown.count - 1, 0)).count + var stack = shown.enumerated().map { index, frame in + String(index).padding(toLength: numberWidth, withPad: " ", startingAt: 0) + " " + frame.text(imageWidth: width, symbolLimit: symbolLimit) + } + if frames.count > shown.count { stack.append("… \(frames.count - shown.count) more frames in the report") } + var lines = ["StableBar quit unexpectedly on \(Self.timestamp(date)).", ""] + lines.append("- StableBar: \(release)") + lines.append("- macOS: \(system)") + if let model { lines.append("- Mac: \(model)") } + lines.append("- Exception: \([exception, subtype].compactMap { $0 }.joined(separator: ", "))") + if let termination { lines.append("- Termination: \(termination)") } + lines.append("- Report: \(Self.redacted(file.path))") + if let message { + lines += ["", "Message:", "", "```", Self.shortened(message, to: messageLimit), "```"] + } + lines += ["", "Crashed thread: \(thread)", "", "```"] + stack + ["```"] + return lines.joined(separator: "\n") + } + + /// GitHub's bug report form with this crash filled in. Nothing is sent until the user submits the form. + /// Long Swift symbols are shortened, then frames and message are cut until the address fits in `limit` characters. + func issueURL(limit: Int = 8000) -> URL { + let title = "StableBar quit unexpectedly: \(exception.split(separator: " ").first.map(String.init) ?? exception)" + + (location.map { " in \(Self.shortened($0, to: 160))" } ?? "") + let happened = "StableBar quit unexpectedly on \(Self.timestamp(date)).\n\nWhat I was doing when it happened: " + let environment = "StableBar: \(release)\nmacOS: \(system)\nDisplays (how many, which have a notch):\n" + + "AeroSpace or yabai version, if you use one:" + func url(frames: Int, message: Int) -> URL { + let fields = [("template", "bug_report.yml"), ("title", title), ("what-happens", happened), + ("environment", environment), ("evidence", markdown(frameLimit: frames, messageLimit: message, symbolLimit: 300))] + let query = fields.map { "\($0)=\($1.addingPercentEncoding(withAllowedCharacters: Self.unreserved)!)" }.joined(separator: "&") + return URL(string: "https://github.com/Seb-GRAF/StableBar/issues/new?\(query)")! + } + var frames = 60, message = 2000 + var result = url(frames: frames, message: message) + while result.absoluteString.count > limit, frames > 0 || message > 200 { + if message > 200 { message /= 2 } else { frames = frames * 2 / 3 } + result = url(frames: frames, message: message) + } + return result + } + + private static func shortened(_ text: String, to limit: Int) -> String { + text.count > limit ? String(text.prefix(limit)) + "…" : text + } + + private var release: String { + [version ?? "unknown version", build.map { "(\($0))" }].compactMap { $0 }.joined(separator: " ") + } + + private static let unreserved = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~") + + private static func timestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ" + return formatter.string(from: date) + } + + /// Reads `2026-10-26 14:03:12.1234 +0200`, whatever the number of fractional digits. + private static func date(_ value: Any?) -> Date? { + guard let text = value as? String else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" + return formatter.date(from: text.replacingOccurrences(of: #"\.\d+"#, with: "", options: .regularExpression)) + } + + /// Home folders, the user's or a build machine's, become `~`. + static func redacted(_ text: String) -> String { + text.replacingOccurrences(of: #"/Users/[^/\s]+"#, with: "~", options: .regularExpression) + } +} diff --git a/Sources/StableBar/CrashReports.swift b/Sources/StableBar/CrashReports.swift new file mode 100644 index 0000000..61d3534 --- /dev/null +++ b/Sources/StableBar/CrashReports.swift @@ -0,0 +1,82 @@ +import Foundation +import Observation + +/// StableBar's crashes from the last 30 days, read from the reports macOS keeps. StableBar stores only which crashes +/// the user has seen or dismissed; the reports stay where macOS wrote them, and macOS removes old ones itself. +@MainActor @Observable +final class CrashReports { + nonisolated static let window: TimeInterval = 30 * 24 * 60 * 60 + nonisolated static let folders: [URL] = { + let logs = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Logs/DiagnosticReports") + return [logs, logs.appending(path: "Retired")] + }() + + /// Newest first, without the dismissed ones. + private(set) var reports: [CrashReport] = [] + /// The crashes the user hasn't looked at in Status yet. + var unseen: [CrashReport] { reports.filter { $0.date > seenThrough } } + /// The line the bar's menus show while a crash is unseen. + var note: String? { unseen.isEmpty ? nil : "StableBar Quit Unexpectedly…" } + + private var found: [CrashReport] = [] + private var seenThrough: Date + private var dismissed: Set + @ObservationIgnored private let directories: [URL] + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let now: @Sendable () -> Date + + init(directories: [URL] = CrashReports.folders, defaults: UserDefaults = .standard, + now: @escaping @Sendable () -> Date = { Date() }) { + self.directories = directories + self.defaults = defaults + self.now = now + seenThrough = defaults.object(forKey: "crashReportsSeenThrough") as? Date ?? .distantPast + dismissed = Set(defaults.stringArray(forKey: "dismissedCrashReports") ?? []) + } + + func refresh() async { + let directories = directories, since = now().addingTimeInterval(-Self.window) + found = await Task.detached(priority: .utility) { Self.read(directories, since: since) }.value + let kept = dismissed.intersection(found.map(\.id)) + if kept != dismissed { setDismissed(kept) } + update() + } + + func markSeen() { + guard let newest = reports.first?.date, newest > seenThrough else { return } + seenThrough = newest + defaults.set(newest, forKey: "crashReportsSeenThrough") + } + + /// Hides a crash from the list. The macOS report stays. + func dismiss(_ report: CrashReport) { + setDismissed(dismissed.union([report.id])) + update() + } + + private func setDismissed(_ ids: Set) { + dismissed = ids + defaults.set(ids.sorted(), forKey: "dismissedCrashReports") + } + + private func update() { + reports = found.filter { !dismissed.contains($0.id) } + } + + /// StableBar's crash reports in `directories` since `since`, newest first. A report in both the folder and its + /// `Retired` folder counts once. + nonisolated static func read(_ directories: [URL], since: Date) -> [CrashReport] { + let files = directories.flatMap { directory in + (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey], + options: .skipsHiddenFiles)) ?? [] + } + var ids = Set() + return files + .filter { $0.lastPathComponent.hasPrefix("StableBar-") && $0.pathExtension == "ips" } + .filter { ((try? $0.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? since) >= since } + .compactMap { file in (try? Data(contentsOf: file)).flatMap { CrashReport(data: $0, file: file) } } + .filter { $0.date >= since } + .sorted { $0.date > $1.date } + .filter { ids.insert($0.id).inserted } + } +} diff --git a/Sources/StableBar/CrashesView.swift b/Sources/StableBar/CrashesView.swift new file mode 100644 index 0000000..c2a5be3 --- /dev/null +++ b/Sources/StableBar/CrashesView.swift @@ -0,0 +1,79 @@ +import AppKit +import SwiftUI + +/// Settings › Status › Crashes: StableBar's crashes from the last 30 days, from the reports macOS keeps on this Mac. +struct CrashesView: View { + let crashes: CrashReports + @State private var newIDs: Set = [] + @State private var shownID: String? + @State private var copiedID: String? + + var body: some View { + Form { + Section { + if crashes.reports.isEmpty { + Label("No crashes in the last 30 days", systemImage: "checkmark.circle.fill").foregroundStyle(SettingsPalette.success) + } + } header: { + SettingsSectionTitle("Crashes") + } footer: { + Text("macOS saves a report each time StableBar quits unexpectedly. StableBar lists the last 30 days of them and sends nothing. Report on GitHub opens the bug report form with the crash filled in, for you to check before submitting.") + .font(.caption).foregroundStyle(.secondary) + } + ForEach(crashes.reports) { report in + Section { + LabeledContent("Exception", value: [report.exception, report.subtype].compactMap { $0 }.joined(separator: ", ")) + if let location = report.location { + LabeledContent("Where") { + Text(location).font(.system(size: 11.5, design: .monospaced)).textSelection(.enabled).multilineTextAlignment(.trailing) + } + } + if let message = report.message { + Text(message).font(.system(size: 11.5, design: .monospaced)).textSelection(.enabled) + } + HStack { + Button("Copy report") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report.markdown, forType: .string) + copiedID = report.id + } + Button("Report on GitHub…") { NSWorkspace.shared.open(report.issueURL()) } + Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([report.file]) } + Spacer() + Button("Dismiss") { crashes.dismiss(report) } + } + if copiedID == report.id { + Text("Report copied. Paste it into a GitHub issue.").font(.callout).foregroundStyle(.secondary) + } + DisclosureGroup("Report", isExpanded: Binding(get: { shownID == report.id }, set: { shownID = $0 ? report.id : nil })) { + ScrollView([.vertical, .horizontal]) { + Text(report.markdown).font(.system(size: 11.5, design: .monospaced)).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .frame(height: 240) + .background(SettingsPalette.field, in: RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(SettingsPalette.groupBorder)) + } + } header: { + HStack(spacing: 6) { + SettingsSectionTitle("\(report.date.formatted(date: .abbreviated, time: .shortened)) · StableBar \(report.version ?? "unknown version")") + if newIDs.contains(report.id) { + Text("New").font(.system(size: 10, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Color.red, in: Capsule()) + } + } + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .task { + await crashes.refresh() + newIDs = Set(crashes.unseen.map(\.id)) + crashes.markSeen() + } + } +} diff --git a/Sources/StableBar/CustomizationWindow.swift b/Sources/StableBar/CustomizationWindow.swift index 0c35b92..9081065 100644 --- a/Sources/StableBar/CustomizationWindow.swift +++ b/Sources/StableBar/CustomizationWindow.swift @@ -39,6 +39,11 @@ final class CustomizationWindow: NSObject, NSWindowDelegate { window!.makeKeyAndOrderFront(nil) } + func showCrashes(store: BarStore) { + show(store: store) + editor?.showCrashes() + } + func windowWillClose(_ notification: Notification) { editor?.endProfileEditing() editor?.finishEditing() diff --git a/Sources/StableBar/DiagnosticsView.swift b/Sources/StableBar/DiagnosticsView.swift index 4c979bc..90a01d6 100644 --- a/Sources/StableBar/DiagnosticsView.swift +++ b/Sources/StableBar/DiagnosticsView.swift @@ -57,9 +57,13 @@ enum WidgetStatus: Equatable { } } +/// What Status shows: the overview of every widget, StableBar's recent crashes or one widget. +enum DiagnosticsSelection: Hashable { + case overview, crashes, widget(String) +} + struct DiagnosticsView: View { - let editor: ConfigurationEditor - @State private var selectedID: String? + @Bindable var editor: ConfigurationEditor @State private var event = "manual" @State private var notice: String? @State private var error: String? @@ -72,11 +76,13 @@ struct DiagnosticsView: View { init(editor: ConfigurationEditor) { self.editor = editor - _selectedID = State(initialValue: editor.diagnosticWidgetID ?? "") _needingAttention = State(initialValue: Set((editor.store.configuration?.widgets ?? []).filter { editor.store.widgetStatus(for: $0).needsAttention }.map(\.id))) } private var store: BarStore { editor.store } + private var selectedID: String? { + if case let .widget(id) = editor.diagnosticSelection { id } else { nil } + } private var widgets: [WidgetConfiguration] { store.configuration?.widgets ?? [] } private var names: [String: String] { WidgetDisplayName.names(for: widgets, packageNames: store.widgetPackageNames, providers: store.configuration?.providers ?? [:]) @@ -102,7 +108,9 @@ struct DiagnosticsView: View { .padding() Divider() } - if let widget = widgets.first(where: { $0.id == selectedID }) { + if editor.diagnosticSelection == .crashes { + CrashesView(crashes: store.crashReports) + } else if let widget = widgets.first(where: { $0.id == selectedID }) { details(widget) } else { overview @@ -110,15 +118,14 @@ struct DiagnosticsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .onChange(of: editor.diagnosticWidgetID) { selectedID = editor.diagnosticWidgetID ?? "" } - .onChange(of: selectedID) { clearSelection() } + .onChange(of: editor.diagnosticSelection) { clearSelection() } .onChange(of: widgets.map(\.id)) { - if !widgets.contains(where: { $0.id == selectedID }) { selectedID = "" } + if let selectedID, !widgets.contains(where: { $0.id == selectedID }) { editor.diagnosticSelection = .overview } } .onDisappear { clearSelection() } .task(id: editor.searchTarget) { if editor.searchTarget == SettingsSearchResult.status.id { - selectedID = "" + editor.diagnosticSelection = .overview editor.searchTarget = nil } } @@ -126,8 +133,19 @@ struct DiagnosticsView: View { private var sidebar: some View { let names = names - return List(selection: $selectedID) { - Label("All widgets", systemImage: "list.bullet").foregroundStyle(.primary).tag("") + let crashes = store.crashReports + return List(selection: $editor.diagnosticSelection) { + Label("All widgets", systemImage: "list.bullet").foregroundStyle(.primary).tag(DiagnosticsSelection.overview) + Label { + VStack(alignment: .leading, spacing: 1) { + Text("Crashes").foregroundStyle(.primary) + Text(crashes.reports.isEmpty ? "None in 30 days" : "\(crashes.reports.count) in 30 days") + .font(.caption).foregroundStyle(crashes.unseen.isEmpty ? .secondary : Color.red) + } + } icon: { + Image(systemName: "exclamationmark.octagon") + } + .tag(DiagnosticsSelection.crashes) ForEach(widgets.sorted { needingAttention.contains($0.id) && !needingAttention.contains($1.id) }) { widget in let status = store.widgetStatus(for: widget) Label { @@ -138,7 +156,7 @@ struct DiagnosticsView: View { } icon: { Circle().fill(status.color).frame(width: 8, height: 8) } - .tag(widget.id) + .tag(DiagnosticsSelection.widget(widget.id)) } } .listStyle(.sidebar) @@ -153,6 +171,12 @@ struct DiagnosticsView: View { LabeledContent("Widgets", value: String(widgets.count)) LabeledContent("Need attention", value: String(attention.count)) LabeledContent("Active profiles", value: store.activeProfiles.isEmpty ? "Base configuration" : store.activeProfiles.joined(separator: ", ")) + if !store.crashReports.reports.isEmpty { + LabeledContent("Crashes in the last 30 days") { + Text(String(store.crashReports.reports.count)) + Button("Show") { editor.diagnosticSelection = .crashes } + } + } } header: { SettingsSectionTitle("Status") } footer: { @@ -163,7 +187,7 @@ struct DiagnosticsView: View { Section { ForEach(attention) { widget in let status = store.widgetStatus(for: widget) - Button { selectedID = widget.id } label: { + Button { editor.diagnosticSelection = .widget(widget.id) } label: { HStack(alignment: .firstTextBaseline, spacing: 8) { Circle().fill(status.color).frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 2) { @@ -351,7 +375,7 @@ struct DiagnosticsView: View { private func captureSnapshot() { snapshotTask = Task { do { - let value = try store.snapshot(id: selectedID == "" ? nil : selectedID) + let value = try store.snapshot(id: selectedID) let text = try await Task.detached(priority: .utility) { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] diff --git a/Sources/StableBar/SettingsSearch.swift b/Sources/StableBar/SettingsSearch.swift index d61f4ae..f37e99b 100644 --- a/Sources/StableBar/SettingsSearch.swift +++ b/Sources/StableBar/SettingsSearch.swift @@ -44,7 +44,7 @@ enum SettingsSearchResult: String, CaseIterable, Identifiable { case .library: ("Installed packages", .packages, "Packages › Installed", "package version update uninstall settings credentials local folder built-in") case .store: ("Get widgets and themes from the Store", .packages, "Packages › Store", "store install download") case .packageSources: ("Package sources", .packages, "Packages › Sources", "private team company source repository index token gitlab github") - case .status: ("Status and troubleshooting", .diagnostics, "Status", "diagnostics error failed refresh snapshot event") + case .status: ("Status and troubleshooting", .diagnostics, "Status", "diagnostics error failed refresh snapshot event crash quit unexpectedly report") } } diff --git a/Sources/StableBar/StableBarApp.swift b/Sources/StableBar/StableBarApp.swift index 210b053..e55c2a3 100644 --- a/Sources/StableBar/StableBarApp.swift +++ b/Sources/StableBar/StableBarApp.swift @@ -230,6 +230,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { statusItem.menu = menu store.onConfigurationChange = { [weak self] in self?.updatePanels() } store.onCustomize = { [weak self] in self?.showCustomization() } + store.onShowCrashes = { [weak self] in self?.showCrashes() } store.onCustomizePackages = { [weak self] in guard let self else { return } customization.show(store: store, section: .packages) @@ -271,6 +272,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { systemContext = SystemContext { [store] in store.refreshContext() } systemContext!.start() if !UserDefaults.standard.bool(forKey: "setupCompleted") { showSetup() } + await store.crashReports.refresh() } NotificationCenter.default.addObserver(self, selector: #selector(screensChanged), name: NSApplication.didChangeScreenParametersNotification, object: nil) @@ -405,10 +407,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if let update = store.update { menu.addItem(withTitle: "Check for Updates…", action: #selector(AppUpdate.checkForUpdates), keyEquivalent: "").target = update } - if let error = store.configurationError ?? store.workspaceStore.error ?? store.configurationWarning { - menu.addItem(.separator()) - menu.addItem(withTitle: error, action: nil, keyEquivalent: "") - } + let error = store.configurationError ?? store.workspaceStore.error ?? store.configurationWarning + let note = store.crashReports.note + if error != nil || note != nil { menu.addItem(.separator()) } + if let error { menu.addItem(withTitle: error, action: nil, keyEquivalent: "") } + if let note { addItem(note, action: #selector(showCrashes), to: menu) } + Task { await store.crashReports.refresh() } menu.addItem(.separator()) addItem("Settings…", action: #selector(showCustomization), to: menu) addItem("Quit StableBar", action: #selector(quit), to: menu) @@ -420,6 +424,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } @objc private func showCustomization() { customization.show(store: store) } + @objc private func showCrashes() { customization.showCrashes(store: store) } @objc private func editBar() { store.barEditing.toggle() } @objc private func tryThemes() { let screens = NSScreen.screens diff --git a/Sources/StableBar/Triage.swift b/Sources/StableBar/Triage.swift index e40c6c7..bb1a8a8 100644 --- a/Sources/StableBar/Triage.swift +++ b/Sources/StableBar/Triage.swift @@ -39,6 +39,7 @@ struct TriageFacts { var validation: String var barRunning: Bool var sourceCache: URL + var crashes: [String] @MainActor static func gather(configuration: URL, sourceCache: URL, path: String) -> TriageFacts { let version = AppVersion() @@ -67,7 +68,8 @@ struct TriageFacts { configuration: configuration, validation: Triage.validation(of: configuration), barRunning: RuntimeControl.isRunning(url: configuration), - sourceCache: sourceCache) + sourceCache: sourceCache, + crashes: CrashReports.read(CrashReports.folders, since: Date().addingTimeInterval(-CrashReports.window)).map(\.summary)) } private static func sysctl(_ name: String) -> String { @@ -278,6 +280,7 @@ enum Triage { - StableBar command: \(facts.executable) - Crash reports: ~/Library/Logs/DiagnosticReports/StableBar*.ips + - StableBar crashes in the last 30 days:\(list(facts.crashes)) - System log: `log show --last 1h --style compact --predicate 'process == "StableBar"'` - Preferences: `defaults read org.stablebar.app` - Package secrets: Keychain items with service "StableBar" (NEVER read these) diff --git a/Tests/StableBarTests/CrashReportTests.swift b/Tests/StableBarTests/CrashReportTests.swift new file mode 100644 index 0000000..8e33049 --- /dev/null +++ b/Tests/StableBarTests/CrashReportTests.swift @@ -0,0 +1,242 @@ +import AppKit +import Testing +@testable import StableBar + +/// A crash report in the format macOS writes to ~/Library/Logs/DiagnosticReports: a JSON header line, then a JSON body. +private func crashReport(app: String = "StableBar", bugType: String = "309", incident: String = "4F1C2A9E", version: String = "0.9.0", + captured: String = "2026-10-26 14:03:12.1234 +0200", frames: [[String: Any]]? = nil, + message: String? = nil) -> Data { + let header: [String: Any] = [ + "app_name": app, "name": app, "bug_type": bugType, "incident_id": incident, "timestamp": "2026-10-26 14:03:13.00 +0200", + "app_version": version, "build_version": version.isEmpty ? "" : "421", "os_version": "macOS 26.6.2 (25G83)", "bundleID": "org.stablebar.app", + "share_with_app_devs": 0 + ] + let body: [String: Any] = [ + "captureTime": captured, "incident": incident, "modelCode": "Mac16,1", + "procPath": "/Users/someone/Applications/StableBar.app/Contents/MacOS/StableBar", + "exception": ["type": "EXC_BAD_ACCESS", "signal": "SIGSEGV", "subtype": "KERN_INVALID_ADDRESS at 0x0000000000000010"], + "termination": ["namespace": "SIGNAL", "indicator": "Segmentation fault: 11", "code": 11], + "asi": ["libswiftCore.dylib": [message ?? "/Users/someone/StableBar/Sources/StableBar/WidgetPopup.swift:50: Fatal error: Unexpectedly found nil"]], + "faultingThread": 1, + "threads": [ + ["id": 1, "queue": "com.apple.main-thread", "frames": [["imageOffset": 8, "symbol": "main", "symbolLocation": 4, "imageIndex": 0]]], + ["id": 2, "triggered": true, "name": "Worker", "queue": "com.apple.root.default-qos", "frames": frames ?? [ + ["imageOffset": 4096, "symbol": "_assertionFailure(_:_:file:line:flags:)", "symbolLocation": 276, "imageIndex": 1], + ["imageOffset": 6192548, "symbol": "WidgetPopup.toggle(_:widget:defaults:store:anchor:)", "symbolLocation": 1308, "imageIndex": 0, + "sourceFile": "WidgetPopup.swift", "sourceLine": 50], + ["imageOffset": 16, "imageIndex": 2] + ]] + ], + "usedImages": [ + ["name": "StableBar", "path": "/Users/USER/*/StableBar.app/Contents/MacOS/StableBar"], + ["name": "libswiftCore.dylib", "path": "/usr/lib/swift/libswiftCore.dylib"], + ["name": "libfoo.dylib", "path": "/usr/lib/libfoo.dylib"] + ] + ] + let json = { (value: [String: Any]) in try! JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) } + return json(header) + Data("\n".utf8) + json(body) +} + +private let reportFile = URL(filePath: "/Users/someone/Library/Logs/DiagnosticReports/StableBar-2026-10-26-140312.ips") + +@Test func crashReportReadsTheCrashFromAnIPSFile() throws { + let report = try #require(CrashReport(data: crashReport(), file: reportFile)) + #expect(report.id == "4F1C2A9E") + #expect(report.file == reportFile) + #expect(report.date == ISO8601DateFormatter().date(from: "2026-10-26T12:03:12Z")) + #expect(report.version == "0.9.0") + #expect(report.build == "421") + #expect(report.system == "macOS 26.6.2 (25G83)") + #expect(report.model == "Mac16,1") + #expect(report.exception == "EXC_BAD_ACCESS (SIGSEGV)") + #expect(report.subtype == "KERN_INVALID_ADDRESS at 0x0000000000000010") + #expect(report.termination == "Segmentation fault: 11") + #expect(report.message == "~/StableBar/Sources/StableBar/WidgetPopup.swift:50: Fatal error: Unexpectedly found nil") + #expect(report.thread == "Thread 1 (Worker, com.apple.root.default-qos)") + #expect(report.frames.map(\.text) == [ + "libswiftCore.dylib _assertionFailure(_:_:file:line:flags:) + 276", + "StableBar WidgetPopup.toggle(_:widget:defaults:store:anchor:) + 1308 (WidgetPopup.swift:50)", + "libfoo.dylib + 0x10" + ]) + #expect(report.location == "WidgetPopup.toggle(_:widget:defaults:store:anchor:)") + #expect(report.summary.hasPrefix("2026-10-26 ")) + #expect(report.summary.hasSuffix(": EXC_BAD_ACCESS (SIGSEGV) in WidgetPopup.toggle(_:widget:defaults:store:anchor:), StableBar 0.9.0 (421), StableBar-2026-10-26-140312.ips")) +} + +@Test func crashReportIgnoresOtherReportsAndPartialFiles() { + #expect(CrashReport(data: crashReport(bugType: "288"), file: reportFile) == nil) + #expect(CrashReport(data: crashReport(app: "StableBarPackageTests"), file: reportFile) == nil) + let complete = crashReport() + #expect(CrashReport(data: complete.prefix(complete.count / 2), file: reportFile) == nil) + #expect(CrashReport(data: Data("not a report".utf8), file: reportFile) == nil) +} + +@Test func crashReportMarkdownAndIssueURLFitGitHub() throws { + let report = try #require(CrashReport(data: crashReport(), file: reportFile)) + let markdown = report.markdown + for line in ["StableBar quit unexpectedly on 2026-10-26", "- StableBar: 0.9.0 (421)", "- macOS: macOS 26.6.2 (25G83)", + "- Mac: Mac16,1", "- Exception: EXC_BAD_ACCESS (SIGSEGV), KERN_INVALID_ADDRESS at 0x0000000000000010", + "- Termination: Segmentation fault: 11", + "- Report: ~/Library/Logs/DiagnosticReports/StableBar-2026-10-26-140312.ips", + "Crashed thread: Thread 1 (Worker, com.apple.root.default-qos)", + "0 libswiftCore.dylib _assertionFailure(_:_:file:line:flags:) + 276", + "1 StableBar WidgetPopup.toggle(_:widget:defaults:store:anchor:) + 1308 (WidgetPopup.swift:50)", + "2 libfoo.dylib + 0x10"] { + #expect(markdown.contains(line), "Missing \(line)") + } + #expect(!markdown.contains("/Users/")) + + let url = report.issueURL() + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.host == "github.com") + #expect(components.path == "/Seb-GRAF/StableBar/issues/new") + let fields = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + #expect(fields["template"] == "bug_report.yml") + #expect(fields["title"] == "StableBar quit unexpectedly: EXC_BAD_ACCESS in WidgetPopup.toggle(_:widget:defaults:store:anchor:)") + #expect(fields["what-happens"]?.hasPrefix("StableBar quit unexpectedly on 2026-10-26") == true) + #expect(fields["environment"]?.hasPrefix("StableBar: 0.9.0 (421)\nmacOS: macOS 26.6.2 (25G83)\n") == true) + #expect(fields["evidence"] == markdown) + #expect(!url.absoluteString.contains("+"), "A literal + reads as a space in a query") +} + +@Test func crashReportIssueURLStaysShortForDeepStacksAndLongMessages() throws { + let frames = (0..<2000).map { ["imageOffset": $0, "symbol": "recurse(\($0))", "symbolLocation": 12, "imageIndex": 0] as [String: Any] } + let report = try #require(CrashReport(data: crashReport(frames: frames, message: String(repeating: "x", count: 20_000)), file: reportFile)) + let url = report.issueURL() + #expect(url.absoluteString.count <= 8000) + let evidence = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "evidence" }?.value + #expect(evidence?.contains("- Exception: EXC_BAD_ACCESS (SIGSEGV)") == true) + #expect(evidence?.contains("StableBar recurse(0) + 12") == true) + #expect(evidence?.contains("more frames in the report") == true) + #expect(report.markdown.contains("\n0 StableBar recurse(0) + 12\n")) + #expect(report.markdown.contains("\n59 StableBar recurse(59) + 12\n… 1940 more frames in the report\n")) +} + +@Test func crashReportIssueURLStaysShortForHugeSwiftSymbols() throws { + let symbol = "specialized " + String(repeating: "Generic>.", count: 120) + "body.getter" + let report = try #require(CrashReport(data: crashReport(frames: [ + ["imageOffset": 1, "symbol": symbol, "symbolLocation": 8, "imageIndex": 0], + ["imageOffset": 2, "symbol": symbol, "symbolLocation": 9, "imageIndex": 0] + ]), file: reportFile)) + #expect(symbol.count > 2700) + let url = report.issueURL() + #expect(url.absoluteString.count <= 8000) + let fields = Dictionary(uniqueKeysWithValues: (URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + #expect(try #require(fields["title"]).count <= 256) + #expect(fields["title"]?.hasPrefix("StableBar quit unexpectedly: EXC_BAD_ACCESS in specialized Generic>.") == true) + #expect(fields["evidence"]?.contains("- Exception: EXC_BAD_ACCESS (SIGSEGV)") == true) + #expect(report.markdown.contains(symbol), "The copied report keeps whole symbols") +} + +@Test func crashReportOfABuildWithoutAVersionSaysSo() throws { + let report = try #require(CrashReport(data: crashReport(version: ""), file: reportFile)) + #expect(report.version == nil) + #expect(report.build == nil) + #expect(report.markdown.contains("- StableBar: unknown version\n")) +} + +private let october27 = ISO8601DateFormatter().date(from: "2026-10-27T00:00:00Z")! + +private func reportFolders() throws -> (folders: [URL], write: (String, Data, Bool) throws -> Void, remove: () -> Void) { + let logs = FileManager.default.temporaryDirectory.appending(path: "crash-\(UUID().uuidString)") + let retired = logs.appending(path: "Retired") + try FileManager.default.createDirectory(at: retired, withIntermediateDirectories: true) + return ([logs, retired], { name, data, isRetired in try data.write(to: (isRetired ? retired : logs).appending(path: name)) }, + { try? FileManager.default.removeItem(at: logs) }) +} + +@Test @MainActor func crashReportsListStableBarCrashesFromTheLastThirtyDays() async throws { + let (folders, write, remove) = try reportFolders() + defer { remove() } + try write("StableBar-2026-10-26-140312.ips", crashReport(incident: "A"), false) + try write("StableBar-2026-10-26-140312.ips", crashReport(incident: "A"), true) + try write("StableBar-2026-10-20-090000.ips", crashReport(incident: "B", captured: "2026-10-20 09:00:00.00 +0200"), true) + try write("StableBar-2026-09-01-090000.ips", crashReport(incident: "C", captured: "2026-09-01 09:00:00.00 +0200"), false) + try write("StableBarPackageTests-2026-10-26-140312.ips", crashReport(app: "StableBarPackageTests", incident: "D"), false) + try write("StableBar-2026-10-25-100000.ips", crashReport(bugType: "288", incident: "E"), false) + try write("StableBar-2026-10-25-110000.txt", crashReport(incident: "F"), false) + let defaults = UserDefaults(suiteName: "crash-\(UUID().uuidString)")! + let crashes = CrashReports(directories: folders, defaults: defaults, now: { october27 }) + #expect(crashes.reports.isEmpty) + await crashes.refresh() + #expect(crashes.reports.map(\.id) == ["A", "B"]) +} + +@Test @MainActor func crashReportsRememberSeenAndDismissedCrashes() async throws { + let (folders, write, remove) = try reportFolders() + defer { remove() } + let suite = "crash-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + try write("StableBar-2026-10-20-090000.ips", crashReport(incident: "B", captured: "2026-10-20 09:00:00.00 +0200"), false) + try write("StableBar-2026-10-26-140312.ips", crashReport(incident: "A"), false) + + let first = CrashReports(directories: folders, defaults: defaults, now: { october27 }) + #expect(first.note == nil) + await first.refresh() + #expect(first.unseen.map(\.id) == ["A", "B"]) + #expect(first.note == "StableBar Quit Unexpectedly…") + first.markSeen() + #expect(first.unseen.isEmpty) + #expect(first.note == nil) + + let second = CrashReports(directories: folders, defaults: defaults, now: { october27 }) + await second.refresh() + #expect(second.unseen.isEmpty) + try write("StableBar-2026-10-26-200000.ips", crashReport(incident: "N", captured: "2026-10-26 20:00:00.00 +0200"), false) + await second.refresh() + #expect(second.unseen.map(\.id) == ["N"]) + second.dismiss(try #require(second.reports.first)) + #expect(second.reports.map(\.id) == ["A", "B"]) + #expect(second.unseen.isEmpty) + + let third = CrashReports(directories: folders, defaults: defaults, now: { october27 }) + await third.refresh() + #expect(third.reports.map(\.id) == ["A", "B"]) + try FileManager.default.removeItem(at: folders[0].appending(path: "StableBar-2026-10-26-200000.ips")) + await third.refresh() + #expect(defaults.stringArray(forKey: "dismissedCrashReports") == []) +} + +@Test @MainActor func showingCrashesOpensStatusOnThem() throws { + let fixture = try ThemeGalleryFixture(#"{"widgets":[{"id":"crashes","type":"clock","position":"right"}]}"#) + defer { fixture.close() } + let store = try fixture.store() + defer { store.stop() } + let editor = ConfigurationEditor(store: store) + editor.troubleshoot("crashes") + #expect(editor.section == .diagnostics) + #expect(editor.diagnosticSelection == .widget("crashes")) + editor.section = .general + editor.showCrashes() + #expect(editor.section == .diagnostics) + #expect(editor.diagnosticSelection == .crashes) + #expect(SettingsSearchResult.allCases.filter { $0.matches("crash") } == [.status]) + #expect(SettingsSearchResult.status.matches("quit unexpectedly")) +} + +@Test @MainActor func statusMenuMentionsANewCrashUntilItIsSeen() async throws { + let (folders, write, remove) = try reportFolders() + defer { remove() } + let suite = "crash-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let fixture = try ThemeGalleryFixture() + defer { fixture.close() } + let store = BarStore(configurationURL: fixture.url) + defer { store.stop() } + store.crashReports = CrashReports(directories: folders, defaults: defaults, now: { october27 }) + let menu = NSMenu() + let delegate = AppDelegate(store: store) + delegate.menuWillOpen(menu) + #expect(!menu.items.contains { $0.title == "StableBar Quit Unexpectedly…" }) + try write("StableBar-2026-10-26-140312.ips", crashReport(incident: "A"), false) + await store.crashReports.refresh() + delegate.menuWillOpen(menu) + let item = try #require(menu.items.first { $0.title == "StableBar Quit Unexpectedly…" }) + #expect(item.target === delegate) + #expect(item.action != nil) + store.crashReports.markSeen() + delegate.menuWillOpen(menu) + #expect(!menu.items.contains { $0.title == "StableBar Quit Unexpectedly…" }) +} diff --git a/Tests/StableBarTests/TriageTests.swift b/Tests/StableBarTests/TriageTests.swift index f097323..a64512b 100644 --- a/Tests/StableBarTests/TriageTests.swift +++ b/Tests/StableBarTests/TriageTests.swift @@ -119,6 +119,9 @@ import Testing @Test func triageContextNamesTheFactsTheAgentStartsFrom() { let context = Triage.context(.example) + var quiet = TriageFacts.example + quiet.crashes = [] + #expect(Triage.context(quiet).contains("- StableBar crashes in the last 30 days: none\n")) for line in [ "- StableBar: 0.6.0 (Stable, build 320)", "- Update track: Beta", @@ -135,6 +138,8 @@ import Testing "- Packages: /Users/someone/.config/stablebar/packages", "- StableBar command: /Applications/StableBar.app/Contents/MacOS/StableBar", "- Source cache (clone the repository here): /Users/someone/Library/Caches/StableBar/triage/source", + "- StableBar crashes in the last 30 days:", + " - 2026-10-26 14:03:12 +02:00: EXC_BAD_ACCESS (SIGSEGV) in WidgetPopup.toggle(_:widget:defaults:store:anchor:), StableBar 0.9.0 (421), StableBar-2026-10-26-140312.ips", #"- Package secrets: Keychain items with service "StableBar" (NEVER read these)"# ] { #expect(context.contains(line + "\n"), "\(line)") @@ -183,5 +188,6 @@ extension TriageFacts { configuration: URL(filePath: "/Users/someone/.config/stablebar/config.json"), validation: "valid, 3 widgets in 2 files", barRunning: true, - sourceCache: URL(filePath: "/Users/someone/Library/Caches/StableBar/triage/source")) + sourceCache: URL(filePath: "/Users/someone/Library/Caches/StableBar/triage/source"), + crashes: ["2026-10-26 14:03:12 +02:00: EXC_BAD_ACCESS (SIGSEGV) in WidgetPopup.toggle(_:widget:defaults:store:anchor:), StableBar 0.9.0 (421), StableBar-2026-10-26-140312.ips"]) } diff --git a/docs/command-line.md b/docs/command-line.md index e61ddee..ca03201 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -54,4 +54,4 @@ StableBar triage --agent claude --model opus It runs Claude Code (`claude`) or Codex (`codex`), whichever is on your `PATH`, and asks which when both are; `--agent` picks one, and `--model` passes a model to it. The agent asks what's going on, looks at your configuration, running bar, displays and crash reports, reads StableBar's source at your version, and searches the issues on GitHub. Then it offers to fix your setup, comment on an existing issue or file a new one. It changes your configuration or posts on GitHub only after you say yes. With `--config PATH`, it looks at that configuration. -StableBar first writes what the agent starts from into `~/Library/Caches/StableBar/triage/`: the facts it found about this Mac and the [playbook](../.github/triage/PLAYBOOK.md) the agent follows. The agent switches to the playbook on `main` when it's newer. When neither agent is installed, StableBar prints where these files are, so you can paste the prompt into another agent. +StableBar first writes what the agent starts from into `~/Library/Caches/StableBar/triage/`: the facts it found about this Mac, including StableBar's crashes in the last 30 days, and the [playbook](../.github/triage/PLAYBOOK.md) the agent follows. The agent switches to the playbook on `main` when it's newer. When neither agent is installed, StableBar prints where these files are, so you can paste the prompt into another agent. diff --git a/docs/settings.md b/docs/settings.md index b879b95..c37229d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -41,4 +41,6 @@ Opening Status does not resume a hidden provider. **Resume for testing** keeps a **Capture output and configuration** creates a snapshot only when requested. It includes the composed configuration, source paths, resolved styles and presentation layers, widget/provider output, graph samples and diagnostics. JSON formatting runs off the main thread. The snapshot is labelled with its capture time and stays unchanged until **Refresh snapshot**; **Copy snapshot** copies that captured text. Snapshot refresh reads current data and does not itself restart a source. +**Crashes**, in the Status sidebar, lists the times StableBar quit unexpectedly in the last 30 days, read from the crash reports macOS saves in `~/Library/Logs/DiagnosticReports`. Each crash shows the StableBar version, the exception, where in StableBar it happened and any error message. **Copy report** copies a summary to paste into an issue, with your home folder written as `~`. **Report on GitHub…** opens the bug report form in your browser with that summary filled in; nothing is sent until you submit the form. **Show in Finder** reveals the macOS report, and **Dismiss** hides a crash from the list without deleting the report. StableBar sends nothing on its own and stores only which crashes you have seen or dismissed. After a new crash, the bar's right-click menu and its menu-bar icon show **StableBar Quit Unexpectedly…** until you open Crashes. + **Settings → General → Updates** shows a released app's channel (Stable or Beta), full version and build number. Local app builds show Development and their source revision; builds without version metadata show Development with the version unavailable. It also checks for [updates](getting-started.md#updates), chooses the update track and turns automatic checks and installs on or off.