Skip to content
Open
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
16 changes: 16 additions & 0 deletions Pine/ContentView+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,22 @@ extension ContentView {
}
}

// MARK: - Crash Reporting Opt-In

extension ContentView {
/// Shows the crash reporting opt-in dialog on first launch.
/// Sets `hasShownPrompt` immediately to prevent duplicate dialogs across multiple windows.
func showCrashReportingOptInIfNeeded() {
guard CrashReportingSettings.needsPrompt else { return }
// Mark as shown BEFORE the async delay to prevent race with other windows
CrashReportingSettings.hasShownPrompt = true
// Slight delay to avoid showing during initial window setup
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
showCrashReportingOptIn = true
}
}
}

// MARK: - Line / offset helpers

extension ContentView {
Expand Down
9 changes: 9 additions & 0 deletions Pine/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct ContentView: View {
@State var isQuickOpenPresented = false
@State var isSymbolNavigatorPresented = false
@State var showGoToLine = false
@State var showCrashReportingOptIn = false
@AppStorage("minimapVisible") var isMinimapVisible = true
@AppStorage(BlameConstants.storageKey) var isBlameVisible = true
@AppStorage("wordWrapEnabled") var isWordWrapEnabled = true
Expand Down Expand Up @@ -114,6 +115,7 @@ struct ContentView: View {
syncSidebarSelection()
applySearchQueryFromEnvironment()
refreshBlame()
showCrashReportingOptInIfNeeded()
}
.sheet(isPresented: $showRecoveryDialog) {
RecoveryDialogView(
Expand Down Expand Up @@ -223,6 +225,13 @@ struct ContentView: View {
tabManager.pendingGoToLine = nil
goToLineOffset = GoToRequest(offset: Self.cursorOffset(forLine: line, in: tab.content))
}
.sheet(isPresented: $showCrashReportingOptIn) {
CrashReportingOptInView(isPresented: $showCrashReportingOptIn) { enabled in
if enabled {
CrashReportingManager.shared.startIfEnabled()
}
}
}
}

/// Branch subtitle as a plain String to avoid generating a localization key.
Expand Down
104 changes: 104 additions & 0 deletions Pine/CrashReport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//
// CrashReport.swift
// Pine
//
// Model for crash diagnostic data collected via MetricKit.
//

import Foundation

/// A structured crash report collected from MetricKit diagnostics.
struct CrashReport: Codable, Equatable, Sendable {
/// Unique identifier for deduplication.
let id: UUID

/// Timestamp when the crash occurred.
let timestamp: Date

/// App version at the time of crash (CFBundleShortVersionString).
let appVersion: String

/// Build number at the time of crash (CFBundleVersion).
let buildNumber: String

/// macOS version string (e.g. "26.0").
let osVersion: String

/// Signal that caused the crash (e.g. SIGSEGV, SIGABRT).
let signal: String?

/// Exception type if available.
let exceptionType: String?

/// Termination reason if available.
let terminationReason: String?

/// Call stack frames as human-readable strings.
let callStackFrames: [String]

/// Number of open editor tabs at crash time (privacy-safe metric).
let openTabCount: Int?

/// Creates a CrashReport with current app/OS metadata.
init(
id: UUID = UUID(),
timestamp: Date = Date(),
signal: String? = nil,
exceptionType: String? = nil,
terminationReason: String? = nil,
callStackFrames: [String] = [],
openTabCount: Int? = nil
) {
self.id = id
self.timestamp = timestamp
self.appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
self.buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
self.osVersion = ProcessInfo.processInfo.operatingSystemVersionString
self.signal = signal
self.exceptionType = exceptionType
self.terminationReason = terminationReason
self.callStackFrames = callStackFrames
self.openTabCount = openTabCount
}

/// Internal initializer for testing with explicit app/OS values.
init(
id: UUID,
timestamp: Date,
appVersion: String,
buildNumber: String,
osVersion: String,
signal: String?,
exceptionType: String?,
terminationReason: String?,
callStackFrames: [String],
openTabCount: Int?
) {
self.id = id
self.timestamp = timestamp
self.appVersion = appVersion
self.buildNumber = buildNumber
self.osVersion = osVersion
self.signal = signal
self.exceptionType = exceptionType
self.terminationReason = terminationReason
self.callStackFrames = callStackFrames
self.openTabCount = openTabCount
}
}

// MARK: - Call Stack Parsing

extension CrashReport {
/// Parses a raw call stack string into individual frame strings.
/// Handles both MetricKit JSON format and standard crash log format.
///
/// Each frame typically looks like:
/// `0 Pine 0x00000001000a1234 someFunction + 42`
static func parseCallStack(_ rawCallStack: String) -> [String] {
let lines = rawCallStack.components(separatedBy: .newlines)
return lines
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
}
187 changes: 187 additions & 0 deletions Pine/CrashReportStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//
// CrashReportStore.swift
// Pine
//
// Persists crash reports to disk for display on next launch.
//

import AppKit
import Foundation
import os

/// Persists crash reports as JSON files in the Application Support directory.
/// Reports are stored individually for atomic read/write and easy cleanup.
///
/// Thread-safe: all file I/O is serialized on an internal serial queue.
/// Safe to call from MetricKit callbacks (arbitrary threads) and from the main thread.
final class CrashReportStore {
/// Shared singleton instance.
static let shared = CrashReportStore()

/// Directory where crash reports are stored.
let storageDirectory: URL

private let logger = Logger(subsystem: "com.pine.editor", category: "CrashReportStore")

/// Serial queue that serializes all disk I/O for thread safety.
private let queue = DispatchQueue(label: "com.pine.crash-report-store")

/// Maximum number of reports to keep on disk.
static let maxReports = 50

/// File extension for crash report files.
static let fileExtension = "crashreport"

init(storageDirectory: URL? = nil) {
if let dir = storageDirectory {
self.storageDirectory = dir
} else if let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask
).first {
self.storageDirectory = appSupport.appendingPathComponent("Pine/CrashReports")
} else {
self.storageDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("Pine/CrashReports")
}
try? FileManager.default.createDirectory(at: self.storageDirectory, withIntermediateDirectories: true)
}

/// Saves a crash report to disk.
func save(_ report: CrashReport) {
queue.sync {
_save(report)
}
}

/// Loads all stored crash reports, sorted by timestamp (newest first).
func loadAll() -> [CrashReport] {
queue.sync {
_loadAll()
}
}

/// Removes a specific crash report by ID.
func remove(id: UUID) {
queue.sync {
_remove(id: id)
}
}

/// Removes all stored crash reports.
func removeAll() {
queue.sync {
_removeAll()
}
}

/// Returns the number of stored reports (directory listing only, no JSON decoding).
var count: Int {
queue.sync {
_count()
}
}

/// Whether the store has no reports (directory listing only, no JSON decoding).
var isEmpty: Bool {
queue.sync {
_count() == 0
}
}

/// Reveals the crash reports directory in Finder.
func revealInFinder() {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: storageDirectory.path)
}

/// Copies all crash reports as JSON to the clipboard.
/// Returns the number of reports copied.
@discardableResult
func copyAllToClipboard() -> Int {
let reports = loadAll()
guard !reports.isEmpty else { return 0 }

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601

guard let data = try? encoder.encode(reports),
let jsonString = String(data: data, encoding: .utf8) else {
return 0
}

let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(jsonString, forType: .string)
return reports.count
}

// MARK: - Private (must be called on self.queue)

private func _save(_ report: CrashReport) {
let fileName = "\(report.id.uuidString).\(Self.fileExtension)"
let fileURL = storageDirectory.appendingPathComponent(fileName)

do {
let data = try JSONEncoder().encode(report)
try data.write(to: fileURL, options: .atomic)
_pruneOldReports()
} catch {
logger.error("Failed to save crash report: \(error.localizedDescription)")
}
}

private func _loadAll() -> [CrashReport] {
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil) else {
return []
}

let reports: [CrashReport] = files
.filter { $0.pathExtension == Self.fileExtension }
.compactMap { url in
guard let data = try? Data(contentsOf: url),
let report = try? JSONDecoder().decode(CrashReport.self, from: data) else {
return nil
}
return report
}
.sorted { $0.timestamp > $1.timestamp }

return reports
}

private func _remove(id: UUID) {
let fileName = "\(id.uuidString).\(Self.fileExtension)"
let fileURL = storageDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}

private func _removeAll() {
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil) else {
return
}
for file in files where file.pathExtension == Self.fileExtension {
try? fm.removeItem(at: file)
}
}

private func _count() -> Int {
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil) else {
return 0
}
return files.filter { $0.pathExtension == Self.fileExtension }.count
}

/// Prunes old reports if count exceeds the maximum.
private func _pruneOldReports() {
let reports = _loadAll()
guard reports.count > Self.maxReports else { return }

let toRemove = reports.suffix(from: Self.maxReports)
for report in toRemove {
_remove(id: report.id)
}
}
}
Loading
Loading