Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/StableBar/BarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions Sources/StableBar/BarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
12 changes: 10 additions & 2 deletions Sources/StableBar/ConfigurationEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
}

Expand Down
171 changes: 171 additions & 0 deletions Sources/StableBar/CrashReport.swift
Original file line number Diff line number Diff line change
@@ -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[..<newline]) as? [String: Any],
header["bug_type"] as? String == "309", (header["app_name"] ?? header["name"]) as? String == "StableBar",
let body = try? JSONSerialization.jsonObject(with: data[data.index(after: newline)...]) as? [String: Any]
else { return nil }
let bundle = body["bundleInfo"] as? [String: Any]
let os = body["osVersion"] as? [String: Any]
let exception = body["exception"] as? [String: Any]
let images = (body["usedImages"] as? [[String: Any]] ?? []).map { $0["name"] as? String ?? "???" }
let threads = body["threads"] as? [[String: Any]] ?? []
let index = body["faultingThread"] as? Int ?? threads.firstIndex { $0["triggered"] as? Bool == true } ?? 0
let thread = threads.indices.contains(index) ? threads[index] : [:]

id = header["incident_id"] as? String ?? body["incident"] as? String ?? file.lastPathComponent
self.file = file
guard let date = Self.date(body["captureTime"]) ?? Self.date(header["timestamp"]) else { return nil }
self.date = date
func text(_ value: Any?) -> 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)
}
}
82 changes: 82 additions & 0 deletions Sources/StableBar/CrashReports.swift
Original file line number Diff line number Diff line change
@@ -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<String>
@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<String>) {
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<String>()
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 }
}
}
Loading
Loading