diff --git a/Pine/ContentView+Helpers.swift b/Pine/ContentView+Helpers.swift index d75dc56..f59d4ae 100644 --- a/Pine/ContentView+Helpers.swift +++ b/Pine/ContentView+Helpers.swift @@ -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 { diff --git a/Pine/ContentView.swift b/Pine/ContentView.swift index f5d10d0..a769692 100644 --- a/Pine/ContentView.swift +++ b/Pine/ContentView.swift @@ -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 @@ -114,6 +115,7 @@ struct ContentView: View { syncSidebarSelection() applySearchQueryFromEnvironment() refreshBlame() + showCrashReportingOptInIfNeeded() } .sheet(isPresented: $showRecoveryDialog) { RecoveryDialogView( @@ -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. diff --git a/Pine/CrashReport.swift b/Pine/CrashReport.swift new file mode 100644 index 0000000..6fc07e1 --- /dev/null +++ b/Pine/CrashReport.swift @@ -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 } + } +} diff --git a/Pine/CrashReportStore.swift b/Pine/CrashReportStore.swift new file mode 100644 index 0000000..753f0c8 --- /dev/null +++ b/Pine/CrashReportStore.swift @@ -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) + } + } +} diff --git a/Pine/CrashReportingManager.swift b/Pine/CrashReportingManager.swift new file mode 100644 index 0000000..f83cfff --- /dev/null +++ b/Pine/CrashReportingManager.swift @@ -0,0 +1,313 @@ +// +// CrashReportingManager.swift +// Pine +// +// Central coordinator for crash reporting using MetricKit as primary +// and POSIX signal handlers as a minimal fallback. +// + +import Foundation +import MetricKit +import os + +// MARK: - Global async-signal-safe storage for crash marker path + +/// Thread-safe atomic storage for the crash marker C string pointer. +/// Uses `os_unfair_lock` to protect reads/writes from concurrent access +/// between `installSignalHandlers()`, `stop()`, and the signal handler. +/// The signal handler reads via `loadCrashMarkerCString()` which uses +/// `os_unfair_lock_trylock` (async-signal-safe) to avoid deadlock. +private struct CrashMarkerStorage { + nonisolated(unsafe) static var lock = os_unfair_lock() + nonisolated(unsafe) static var cString: UnsafeMutablePointer? + + /// Stores a new C string path. Frees the previous one if set. + /// Called from main thread only (installSignalHandlers / stop). + static func store(_ newValue: UnsafeMutablePointer?) { + os_unfair_lock_lock(&lock) + let previous = cString + cString = newValue + os_unfair_lock_unlock(&lock) + if let previous { + free(previous) + } + } + + /// Loads the current C string pointer. + /// Uses trylock so it is safe to call from a signal handler (async-signal-safe). + /// Returns nil if the lock is contended (extremely unlikely during a crash). + static func load() -> UnsafeMutablePointer? { + guard os_unfair_lock_trylock(&lock) else { return nil } + let value = cString + os_unfair_lock_unlock(&lock) + return value + } +} + +/// Coordinates crash reporting using MetricKit (MXCrashDiagnostic) as the +/// primary source and POSIX signal handlers as a minimal async-signal-safe fallback. +/// +/// MetricKit delivers crash diagnostics on next launch via `MXMetricManagerSubscriber`. +/// The signal handler writes a minimal marker file using only POSIX APIs (no Swift/Foundation). +final class CrashReportingManager: NSObject, MXMetricManagerSubscriber { + /// Shared singleton. + static let shared = CrashReportingManager() + + private let logger = Logger(subsystem: "com.pine.editor", category: "CrashReporting") + private let store: CrashReportStore + + /// Path to the signal handler's crash marker file. + /// Written by the C-level signal handler using only async-signal-safe POSIX calls. + static var crashMarkerPath: String { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first else { + return NSTemporaryDirectory() + "Pine_crash_marker" + } + return appSupport.appendingPathComponent("Pine/crash_marker").path + } + + init(store: CrashReportStore = .shared) { + self.store = store + super.init() + } + + /// Starts crash reporting if the user has opted in. + /// Call this from `applicationDidFinishLaunching`. + func startIfEnabled() { + guard CrashReportingSettings.isEnabled else { + logger.info("Crash reporting is disabled by user preference") + return + } + + // Subscribe to MetricKit diagnostics + MXMetricManager.shared.add(self) + + // Install signal handler fallback + installSignalHandlers() + + // Check for crash marker from previous signal-based crash + checkForCrashMarker() + + logger.info("Crash reporting started (MetricKit + signal handler fallback)") + } + + /// Extracts call stack frame strings from MetricKit JSON representation. + /// Parses the `callStackTree` → `callStacks` → `callStackRootFrames` hierarchy, + /// flattening nested frames into human-readable strings. + static func extractCallStackFrames(from jsonData: Data) -> [String] { + guard let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let callStackTree = json["callStackTree"] as? [String: Any], + let callStacks = callStackTree["callStacks"] as? [[String: Any]] else { + return [] + } + + var frames: [String] = [] + for stack in callStacks { + guard let rootFrames = stack["callStackRootFrames"] as? [[String: Any]] else { continue } + Self.flattenFrames(rootFrames, into: &frames) + } + return frames + } + + /// Recursively flattens nested call stack frames into a flat list of strings. + private static func flattenFrames(_ frameList: [[String: Any]], into result: inout [String]) { + for frame in frameList { + let address = frame["address"] as? UInt64 ?? 0 + let binaryName = frame["binaryName"] as? String ?? "?" + let offsetIntoBinaryTextSegment = frame["offsetIntoBinaryTextSegment"] as? UInt64 ?? 0 + result.append(String( + format: "%@ 0x%llx +%llu", + binaryName, address, offsetIntoBinaryTextSegment + )) + if let subFrames = frame["subFrames"] as? [[String: Any]] { + flattenFrames(subFrames, into: &result) + } + } + } + + /// Stops crash reporting: unsubscribes from MetricKit and restores default signal handlers. + /// Frees the pre-computed crash marker C string to avoid memory leaks on toggle cycles. + func stop() { + MXMetricManager.shared.remove(self) + + // Restore default signal handlers + let signals: [Int32] = [SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGTRAP] + for sig in signals { + signal(sig, SIG_DFL) + } + + // Free the strdup'd path to prevent memory leak on repeated start/stop cycles + CrashMarkerStorage.store(nil) + + logger.info("Crash reporting stopped") + } + + // MARK: - MXMetricManagerSubscriber + + /// Called by MetricKit when crash diagnostics are available (typically next launch). + func didReceive(_ payloads: [MXDiagnosticPayload]) { + guard CrashReportingSettings.isEnabled else { return } + + for payload in payloads { + if let crashDiagnostics = payload.crashDiagnostics { + for diagnostic in crashDiagnostics { + processCrashDiagnostic(diagnostic) + } + } + } + } + + /// Processes a single MetricKit crash diagnostic into a CrashReport. + private func processCrashDiagnostic(_ diagnostic: MXCrashDiagnostic) { + let callStack: [String] = Self.extractCallStackFrames(from: diagnostic.jsonRepresentation()) + + let report = CrashReport( + signal: diagnostic.signal?.description, + exceptionType: diagnostic.exceptionType?.description, + terminationReason: diagnostic.terminationReason, + callStackFrames: callStack + ) + + store.save(report) + logger.info("Saved MetricKit crash report: \(report.id)") + } + + // MARK: - Signal Handler Fallback + + /// Installs POSIX signal handlers for common crash signals. + /// Pre-computes the crash marker path as a C string so the handler + /// never touches Swift/Foundation/malloc. + private func installSignalHandlers() { + // Pre-compute the path as a C string BEFORE installing handlers. + // This is the ONLY place we allocate — the handler only reads this pointer. + // CrashMarkerStorage.store() frees any previous allocation to prevent leaks. + let path = Self.crashMarkerPath + let cString = strdup(path) + if cString == nil { + logger.warning("strdup returned nil (OOM) — signal handler fallback will be disabled") + } + CrashMarkerStorage.store(cString) + + let signals: [Int32] = [SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGTRAP] + + for sig in signals { + signal(sig) { caughtSignal in + signalHandler(caughtSignal) + } + } + } + + /// Checks for a crash marker file left by the signal handler. + /// If found, creates a minimal CrashReport from it. + private func checkForCrashMarker() { + let path = Self.crashMarkerPath + let fm = FileManager.default + + guard fm.fileExists(atPath: path) else { return } + + // Read the signal number from the marker file + let signalName: String? + if let data = fm.contents(atPath: path), + let content = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !content.isEmpty { + signalName = content + } else { + signalName = "unknown" + } + + let report = CrashReport( + signal: signalName, + exceptionType: "Signal crash (fallback handler)", + callStackFrames: ["[Call stack not available — captured by signal handler fallback]"] + ) + + store.save(report) + logger.info("Saved signal-handler crash report: \(report.id)") + + // Clean up the marker file + try? fm.removeItem(atPath: path) + } +} + +// MARK: - Async-signal-safe crash handler (C-level, no Swift/Foundation) + +/// Strictly async-signal-safe signal handler. +/// Uses ONLY POSIX APIs: open(), write(), close(), _exit(), signal(), raise(). +/// No Foundation, no malloc, no ObjC messaging. Minimal Swift (guard let on raw pointer is safe). +/// +/// The crash marker path is pre-computed in `installSignalHandlers()` and stored +/// in `CrashMarkerStorage`. This function reads via trylock (async-signal-safe). +private func signalHandler(_ sig: Int32) { + // Load pre-computed C string path via atomic trylock — no allocation, no Swift runtime. + // If trylock fails (lock contended) or path is nil, skip writing and just re-raise. + // Write marker file only if we successfully obtained the path. + writeMarkerIfPossible(sig) + + // Re-raise with default handler to get proper exit code + Darwin.signal(sig, SIG_DFL) + raise(sig) +} + +/// Writes the signal number to the crash marker file. +/// Separated from signalHandler to allow structured control flow without force-unwrap. +/// Still strictly async-signal-safe: only POSIX calls, no Swift runtime, no malloc. +private func writeMarkerIfPossible(_ sig: Int32) { + guard let cPath = CrashMarkerStorage.load() else { return } + + // Open file for writing (create if needed, truncate) + let fd = open(cPath, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + if fd < 0 { return } + + // Write the signal number as ASCII digits using a stack buffer (no malloc). + // POSIX signals are small numbers (1-31), so 2 digits + newline suffice. + var buf: (UInt8, UInt8, UInt8, UInt8) = (0, 0, 0, 0) + var sigNum = sig + var len: Int = 0 + + if sigNum == 0 { + buf.0 = 0x30 // '0' + len = 1 + } else { + // Signals are 1-31, so at most 2 digits — reverse into buf + var tmp: (UInt8, UInt8) = (0, 0) + var tmpLen: Int = 0 + while sigNum > 0, tmpLen < 2 { + if tmpLen == 0 { + tmp.0 = UInt8(0x30 + sigNum % 10) + } else { + tmp.1 = UInt8(0x30 + sigNum % 10) + } + tmpLen += 1 + sigNum /= 10 + } + if tmpLen == 1 { + buf.0 = tmp.0 + } else { + buf.0 = tmp.1 + buf.1 = tmp.0 + } + len = tmpLen + } + + // Append newline + if len == 0 { + buf.0 = 0x0A + } else if len == 1 { + buf.1 = 0x0A + } else { + buf.2 = 0x0A + } + len += 1 + + // Write each byte individually using POSIX write() — async-signal-safe. + // Avoids Swift closures (withUnsafePointer) inside signal handler. + var byte0 = buf.0 + if len >= 1 { _ = write(fd, &byte0, 1) } + var byte1 = buf.1 + if len >= 2 { _ = write(fd, &byte1, 1) } + var byte2 = buf.2 + if len >= 3 { _ = write(fd, &byte2, 1) } + + close(fd) +} diff --git a/Pine/CrashReportingOptInView.swift b/Pine/CrashReportingOptInView.swift new file mode 100644 index 0000000..7003723 --- /dev/null +++ b/Pine/CrashReportingOptInView.swift @@ -0,0 +1,56 @@ +// +// CrashReportingOptInView.swift +// Pine +// +// First-launch opt-in dialog for crash reporting. +// + +import SwiftUI + +/// Opt-in dialog shown on first launch asking the user to enable crash reporting. +/// Explains what data is collected and provides clear Enable/Disable choices. +struct CrashReportingOptInView: View { + @Binding var isPresented: Bool + var onChoice: (Bool) -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: MenuIcons.crashReporting) + .font(.system(size: 40)) + .foregroundStyle(.secondary) + + Text(Strings.crashReportingOptInTitle) + .font(.headline) + + Text(Strings.crashReportingOptInMessage) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text(Strings.crashReportingOptInPrivacy) + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 12) { + Button(Strings.crashReportingOptInDisable) { + CrashReportingSettings.recordChoice(enabled: false) + onChoice(false) + isPresented = false + } + .keyboardShortcut(.cancelAction) + + Button(Strings.crashReportingOptInEnable) { + CrashReportingSettings.recordChoice(enabled: true) + onChoice(true) + isPresented = false + } + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 400) + } +} diff --git a/Pine/CrashReportingSettings.swift b/Pine/CrashReportingSettings.swift new file mode 100644 index 0000000..4b5696e --- /dev/null +++ b/Pine/CrashReportingSettings.swift @@ -0,0 +1,41 @@ +// +// CrashReportingSettings.swift +// Pine +// +// Manages user preference for opt-in crash reporting. +// + +import Foundation + +/// Manages the opt-in crash reporting preference. +/// Persisted via UserDefaults with a clear opt-in flow. +enum CrashReportingSettings { + /// UserDefaults key for the crash reporting enabled state. + static let enabledKey = "crashReporting.enabled" + + /// UserDefaults key tracking whether the opt-in dialog has been shown. + static let promptShownKey = "crashReporting.promptShown" + + /// Whether crash reporting is currently enabled. + static var isEnabled: Bool { + get { UserDefaults.standard.bool(forKey: enabledKey) } + set { UserDefaults.standard.set(newValue, forKey: enabledKey) } + } + + /// Whether the opt-in prompt has already been shown to the user. + static var hasShownPrompt: Bool { + get { UserDefaults.standard.bool(forKey: promptShownKey) } + set { UserDefaults.standard.set(newValue, forKey: promptShownKey) } + } + + /// Returns true if we need to show the opt-in dialog (first launch). + static var needsPrompt: Bool { + !hasShownPrompt + } + + /// Records that the user made a choice and marks the prompt as shown. + static func recordChoice(enabled: Bool) { + isEnabled = enabled + hasShownPrompt = true + } +} diff --git a/Pine/Localizable.xcstrings b/Pine/Localizable.xcstrings index 4de42b2..5780167 100644 --- a/Pine/Localizable.xcstrings +++ b/Pine/Localizable.xcstrings @@ -1443,6 +1443,486 @@ } } }, + "crashReporting.optIn.disable" : { + "comment" : "Button to decline crash reporting in the opt-in dialog.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht aktivieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Don't Enable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No activar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ne pas activer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "有効にしない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화하지 않기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não ativar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не включать" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不启用" + } + } + } + }, + "crashReporting.optIn.enable" : { + "comment" : "Button to accept crash reporting in the opt-in dialog.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "有効にする" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用" + } + } + } + }, + "crashReporting.optIn.message" : { + "comment" : "Explanatory message in the crash reporting opt-in dialog. Data is stored locally only — not sent anywhere.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pine kann anonyme Absturzberichte lokal sammeln, um die App-Stabilität zu verbessern. Nur technische Daten werden erfasst — keine Dateiinhalte oder Pfade." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pine can collect anonymous crash reports locally to help improve app stability. Only technical data is stored — no file contents or paths." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pine puede recopilar informes de error anónimos localmente para mejorar la estabilidad. Solo se almacenan datos técnicos, sin contenido ni rutas de archivos." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pine peut collecter localement des rapports de plantage anonymes pour améliorer la stabilité. Seules des données techniques sont stockées — aucun contenu ni chemin de fichier." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pineはアプリの安定性向上のため匿名のクラッシュレポートをローカルに収集できます。技術データのみが保存され、ファイルの内容やパスは含まれません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pine은 앱 안정성 향상을 위해 익명 충돌 보고서를 로컬에 수집할 수 있습니다. 기술 데이터만 저장되며 파일 내용이나 경로는 포함되지 않습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pine pode coletar relatórios de falhas anônimos localmente para melhorar a estabilidade. Apenas dados técnicos são armazenados — sem conteúdo ou caminhos de arquivos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pine может локально собирать анонимные отчёты об ошибках для улучшения стабильности. Сохраняются только технические данные — без содержимого файлов и путей." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pine 可以在本地收集匿名崩溃报告以帮助改进应用稳定性。仅存储技术数据,不包含文件内容或路径。" + } + } + } + }, + "crashReporting.optIn.privacy" : { + "comment" : "Privacy note in the crash reporting opt-in dialog.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lokal gespeichert: Stack-Trace, OS-Version, App-Version, Anzahl offener Tabs. Sie können Berichte über das Menü Datei exportieren oder diese Einstellung jederzeit ändern." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stored locally: stack trace, OS version, app version, open tab count. You can export reports from the File menu or change this setting anytime." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almacenado localmente: traza de pila, versión del SO, versión de la app, cantidad de pestañas abiertas. Puedes exportar informes desde el menú Archivo o cambiar esta opción en cualquier momento." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stocké localement : trace de pile, version du SE, version de l'app, nombre d'onglets ouverts. Vous pouvez exporter les rapports depuis le menu Fichier ou modifier ce choix à tout moment." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ローカルに保存:スタックトレース、OS バージョン、アプリバージョン、開いているタブ数。「ファイル」メニューからレポートをエクスポートしたり、設定をいつでも変更できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로컬 저장 항목: 스택 추적, OS 버전, 앱 버전, 열린 탭 수. 파일 메뉴에서 보고서를 내보내거나 언제든지 설정을 변경할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Armazenado localmente: rastreamento de pilha, versão do SO, versão do app, quantidade de abas abertas. Você pode exportar relatórios pelo menu Arquivo ou alterar esta configuração a qualquer momento." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хранится локально: стек вызовов, версия ОС, версия приложения, количество открытых вкладок. Вы можете экспортировать отчёты из меню «Файл» или изменить настройку в любое время." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本地存储内容:堆栈跟踪、操作系统版本、应用版本、打开的标签页数量。您可以从「文件」菜单导出报告或随时更改此设置。" + } + } + } + }, + "crashReporting.optIn.title" : { + "comment" : "Title of the crash reporting opt-in dialog shown on first launch.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absturzberichte verbessern Pine" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crash Reports Help Improve Pine" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los informes de error mejoran Pine" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les rapports de plantage aident à améliorer Pine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クラッシュレポートで Pine を改善" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "충돌 보고서로 Pine 개선에 기여" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relatórios de falhas ajudam a melhorar o Pine" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчёты о сбоях помогают улучшить Pine" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "崩溃报告有助于改进 Pine" + } + } + } + }, + "menu.crashReporting" : { + "comment" : "Menu item to toggle crash reporting on or off.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absturzberichte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crash Reporting" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informes de error" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapports de plantage" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クラッシュレポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "충돌 보고" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relatórios de falhas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчёты о сбоях" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "崩溃报告" + } + } + } + }, + "menu.crashReports.copy" : { + "comment" : "Menu item to copy all crash reports to clipboard.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absturzberichte kopieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy Crash Reports" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar informes de error" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copier les rapports de plantage" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クラッシュレポートをコピー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "충돌 보고서 복사" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar relatórios de falhas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать отчёты о сбоях" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拷贝崩溃报告" + } + } + } + }, + "menu.crashReports.reveal" : { + "comment" : "Menu item to reveal crash reports directory in Finder.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absturzberichte im Finder anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reveal Crash Reports in Finder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar informes de error en Finder" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les rapports de plantage dans le Finder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finderでクラッシュレポートを表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finder에서 충돌 보고서 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar relatórios de falhas no Finder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать отчёты о сбоях в Finder" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在访达中显示崩溃报告" + } + } + } + }, "dialog.ok" : { "comment" : "OK button in dialogs.", "extractionState" : "manual", diff --git a/Pine/MenuIcons.swift b/Pine/MenuIcons.swift index 6e10719..e3a7d51 100644 --- a/Pine/MenuIcons.swift +++ b/Pine/MenuIcons.swift @@ -54,6 +54,11 @@ nonisolated enum MenuIcons { // MARK: - Validation static let toggleValidation = "checkmark.shield" + // MARK: - Crash Reporting + static let crashReporting = "ant.circle" + static let crashReportsReveal = "folder" + static let crashReportsCopy = "doc.on.doc" + // MARK: - Context menu static let newFile = "doc.badge.plus" static let newFolder = "folder.badge.plus" diff --git a/Pine/PineApp.swift b/Pine/PineApp.swift index 155bc09..0fbb879 100644 --- a/Pine/PineApp.swift +++ b/Pine/PineApp.swift @@ -13,6 +13,7 @@ struct PineApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @FocusedValue(\.projectManager) private var focusedProject: ProjectManager? @AppStorage(TabManager.autoSaveKey) private var autoSaveEnabled = false + @AppStorage(CrashReportingSettings.enabledKey) private var crashReportingEnabled = false private var registry: ProjectRegistry { appDelegate.registry } @@ -449,6 +450,31 @@ struct PineApp: App { Toggle(isOn: $autoSaveEnabled) { Label(Strings.menuAutoSave, systemImage: MenuIcons.autoSave) } + + Toggle(isOn: $crashReportingEnabled) { + Label(Strings.menuCrashReporting, systemImage: MenuIcons.crashReporting) + } + .onChange(of: crashReportingEnabled) { _, newValue in + CrashReportingSettings.hasShownPrompt = true + if newValue { + CrashReportingManager.shared.startIfEnabled() + } else { + CrashReportingManager.shared.stop() + } + } + + Button { + CrashReportStore.shared.revealInFinder() + } label: { + Label(Strings.menuCrashReportsReveal, systemImage: MenuIcons.crashReportsReveal) + } + + Button { + CrashReportStore.shared.copyAllToClipboard() + } label: { + Label(Strings.menuCrashReportsCopy, systemImage: MenuIcons.crashReportsCopy) + } + .disabled(CrashReportStore.shared.isEmpty) } // Cmd+W is intercepted by AppDelegate's local event monitor // to close the active tab. The close button goes through @@ -949,6 +975,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { // Clean up stale recovery files older than 7 days across all projects RecoveryManager.cleanupAllStaleEntries(olderThan: 7) + // Start crash reporting if previously opted in + CrashReportingManager.shared.startIfEnabled() + // Intercept Cmd+W before the system "Close" menu item. // For project windows: close active tab (or close window if no tabs). // For other windows: pass through to default behavior. diff --git a/Pine/Strings.swift b/Pine/Strings.swift index 2b2477e..8ea7ad3 100644 --- a/Pine/Strings.swift +++ b/Pine/Strings.swift @@ -425,6 +425,21 @@ enum Strings { String(localized: "toast.filesReloaded.more \(count) \(names) \(remaining)") } + // MARK: - Crash Reporting + + static let menuCrashReporting: LocalizedStringKey = "menu.crashReporting" + static let menuCrashReportsReveal: LocalizedStringKey = "menu.crashReports.reveal" + static let menuCrashReportsCopy: LocalizedStringKey = "menu.crashReports.copy" + static let crashReportingOptInTitle: LocalizedStringKey = "crashReporting.optIn.title" + static let crashReportingOptInMessage: LocalizedStringKey = "crashReporting.optIn.message" + static let crashReportingOptInPrivacy: LocalizedStringKey = "crashReporting.optIn.privacy" + static var crashReportingOptInEnable: String { + String(localized: "crashReporting.optIn.enable") + } + static var crashReportingOptInDisable: String { + String(localized: "crashReporting.optIn.disable") + } + // MARK: - Progress Indicators static var progressLoadingProject: String { diff --git a/PineTests/CrashReportingTests.swift b/PineTests/CrashReportingTests.swift new file mode 100644 index 0000000..3f53324 --- /dev/null +++ b/PineTests/CrashReportingTests.swift @@ -0,0 +1,840 @@ +// +// CrashReportingTests.swift +// PineTests +// + +import Testing +import AppKit +import Foundation +@testable import Pine + +// MARK: - CrashReportingSettings Tests + +struct CrashReportingSettingsTests { + + // Use a unique suite name to avoid collisions with the app's real defaults + private func withCleanDefaults(_ body: () -> Void) { + let enabledKey = CrashReportingSettings.enabledKey + let promptKey = CrashReportingSettings.promptShownKey + let savedEnabled = UserDefaults.standard.object(forKey: enabledKey) + let savedPrompt = UserDefaults.standard.object(forKey: promptKey) + + // Clear + UserDefaults.standard.removeObject(forKey: enabledKey) + UserDefaults.standard.removeObject(forKey: promptKey) + + body() + + // Restore + if let val = savedEnabled { + UserDefaults.standard.set(val, forKey: enabledKey) + } else { + UserDefaults.standard.removeObject(forKey: enabledKey) + } + if let val = savedPrompt { + UserDefaults.standard.set(val, forKey: promptKey) + } else { + UserDefaults.standard.removeObject(forKey: promptKey) + } + } + + @Test func defaultState_isDisabled() { + withCleanDefaults { + #expect(!CrashReportingSettings.isEnabled) + } + } + + @Test func defaultState_promptNotShown() { + withCleanDefaults { + #expect(!CrashReportingSettings.hasShownPrompt) + } + } + + @Test func needsPrompt_trueWhenNotShown() { + withCleanDefaults { + #expect(CrashReportingSettings.needsPrompt) + } + } + + @Test func recordChoice_enabled_setsValues() { + withCleanDefaults { + CrashReportingSettings.recordChoice(enabled: true) + #expect(CrashReportingSettings.isEnabled) + #expect(CrashReportingSettings.hasShownPrompt) + #expect(!CrashReportingSettings.needsPrompt) + } + } + + @Test func recordChoice_disabled_setsValues() { + withCleanDefaults { + CrashReportingSettings.recordChoice(enabled: false) + #expect(!CrashReportingSettings.isEnabled) + #expect(CrashReportingSettings.hasShownPrompt) + #expect(!CrashReportingSettings.needsPrompt) + } + } + + @Test func isEnabled_canBeToggled() { + withCleanDefaults { + CrashReportingSettings.isEnabled = true + #expect(CrashReportingSettings.isEnabled) + CrashReportingSettings.isEnabled = false + #expect(!CrashReportingSettings.isEnabled) + } + } + + @Test func keys_haveExpectedValues() { + #expect(CrashReportingSettings.enabledKey == "crashReporting.enabled") + #expect(CrashReportingSettings.promptShownKey == "crashReporting.promptShown") + } +} + +// MARK: - CrashReport Model Tests + +struct CrashReportModelTests { + + @Test func init_setsDefaults() { + let report = CrashReport() + #expect(report.signal == nil) + #expect(report.exceptionType == nil) + #expect(report.terminationReason == nil) + #expect(report.callStackFrames.isEmpty) + #expect(report.openTabCount == nil) + } + + @Test func init_withValues() { + let report = CrashReport( + signal: "SIGSEGV", + exceptionType: "EXC_BAD_ACCESS", + terminationReason: "Namespace SIGNAL, Code 11", + callStackFrames: ["frame0", "frame1"], + openTabCount: 5 + ) + #expect(report.signal == "SIGSEGV") + #expect(report.exceptionType == "EXC_BAD_ACCESS") + #expect(report.terminationReason == "Namespace SIGNAL, Code 11") + #expect(report.callStackFrames.count == 2) + #expect(report.openTabCount == 5) + } + + @Test func init_capturesAppVersion() { + let report = CrashReport() + // In test target, Bundle.main may not have these keys, but should not crash + #expect(report.appVersion is String) + #expect(report.buildNumber is String) + #expect(!report.osVersion.isEmpty) + } + + @Test func testInit_explicitValues() { + let id = UUID() + let date = Date(timeIntervalSince1970: 1_000_000) + let report = CrashReport( + id: id, + timestamp: date, + appVersion: "1.0.0", + buildNumber: "42", + osVersion: "26.0", + signal: "SIGABRT", + exceptionType: nil, + terminationReason: nil, + callStackFrames: ["a", "b", "c"], + openTabCount: 3 + ) + #expect(report.id == id) + #expect(report.timestamp == date) + #expect(report.appVersion == "1.0.0") + #expect(report.buildNumber == "42") + #expect(report.osVersion == "26.0") + #expect(report.signal == "SIGABRT") + #expect(report.callStackFrames == ["a", "b", "c"]) + #expect(report.openTabCount == 3) + } + + @Test func codable_roundTrip() throws { + let original = CrashReport( + id: UUID(), + timestamp: Date(), + appVersion: "2.0.0", + buildNumber: "100", + osVersion: "26.1", + signal: "SIGSEGV", + exceptionType: "EXC_BAD_ACCESS", + terminationReason: "some reason", + callStackFrames: ["frame0", "frame1"], + openTabCount: 10 + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(CrashReport.self, from: data) + #expect(original == decoded) + } + + @Test func equatable_sameValues_areEqual() { + let id = UUID() + let date = Date() + let a = CrashReport(id: id, timestamp: date, appVersion: "1", buildNumber: "1", + osVersion: "26", signal: nil, exceptionType: nil, + terminationReason: nil, callStackFrames: [], openTabCount: nil) + let b = CrashReport(id: id, timestamp: date, appVersion: "1", buildNumber: "1", + osVersion: "26", signal: nil, exceptionType: nil, + terminationReason: nil, callStackFrames: [], openTabCount: nil) + #expect(a == b) + } + + @Test func equatable_differentIDs_areNotEqual() { + let date = Date() + let a = CrashReport(id: UUID(), timestamp: date, appVersion: "1", buildNumber: "1", + osVersion: "26", signal: nil, exceptionType: nil, + terminationReason: nil, callStackFrames: [], openTabCount: nil) + let b = CrashReport(id: UUID(), timestamp: date, appVersion: "1", buildNumber: "1", + osVersion: "26", signal: nil, exceptionType: nil, + terminationReason: nil, callStackFrames: [], openTabCount: nil) + #expect(a != b) + } +} + +// MARK: - ParseCallStack Tests + +struct ParseCallStackTests { + + @Test func parseCallStack_basicFrames() { + let raw = """ + 0 Pine 0x00000001000a1234 someFunction + 42 + 1 Pine 0x00000001000a5678 anotherFunction + 10 + """ + let frames = CrashReport.parseCallStack(raw) + #expect(frames.count == 2) + #expect(frames[0].contains("someFunction")) + #expect(frames[1].contains("anotherFunction")) + } + + @Test func parseCallStack_emptyString() { + let frames = CrashReport.parseCallStack("") + #expect(frames.isEmpty) + } + + @Test func parseCallStack_singleFrame() { + let raw = "0 Pine 0x1234 main + 0" + let frames = CrashReport.parseCallStack(raw) + #expect(frames.count == 1) + #expect(frames[0].contains("main")) + } + + @Test func parseCallStack_skipsEmptyLines() { + let raw = """ + frame1 + + frame2 + + """ + let frames = CrashReport.parseCallStack(raw) + #expect(frames.count == 2) + #expect(frames[0] == "frame1") + #expect(frames[1] == "frame2") + } + + @Test func parseCallStack_trimsWhitespace() { + let raw = " frame with spaces " + let frames = CrashReport.parseCallStack(raw) + #expect(frames.count == 1) + #expect(frames[0] == "frame with spaces") + } + + @Test func parseCallStack_multipleNewlineFormats() { + let raw = "frame1\nframe2\nframe3" + let frames = CrashReport.parseCallStack(raw) + #expect(frames.count == 3) + } + + @Test func parseCallStack_onlyWhitespaceLines() { + let raw = " \n \n " + let frames = CrashReport.parseCallStack(raw) + #expect(frames.isEmpty) + } +} + +// MARK: - CrashReportStore Tests + +struct CrashReportStoreTests { + + private func makeTempStore() -> CrashReportStore { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("PineTests-CrashStore-\(UUID().uuidString)") + return CrashReportStore(storageDirectory: tmpDir) + } + + private func cleanup(_ store: CrashReportStore) { + try? FileManager.default.removeItem(at: store.storageDirectory) + } + + @Test func save_andLoadAll() { + let store = makeTempStore() + defer { cleanup(store) } + + let report = CrashReport(signal: "SIGABRT") + store.save(report) + + let loaded = store.loadAll() + #expect(loaded.count == 1) + #expect(loaded[0].id == report.id) + #expect(loaded[0].signal == "SIGABRT") + } + + @Test func count_returnsCorrectValue() { + let store = makeTempStore() + defer { cleanup(store) } + + #expect(store.loadAll().isEmpty) + store.save(CrashReport(signal: "SIGSEGV")) + #expect(store.count == 1) + store.save(CrashReport(signal: "SIGBUS")) + #expect(store.count == 2) + } + + @Test func isEmpty_reflectsStoreState() { + let store = makeTempStore() + defer { cleanup(store) } + + #expect(store.isEmpty) + store.save(CrashReport(signal: "SIGSEGV")) + #expect(!store.isEmpty) + store.removeAll() + #expect(store.isEmpty) + } + + @Test func remove_byID() { + let store = makeTempStore() + defer { cleanup(store) } + + let report1 = CrashReport(signal: "A") + let report2 = CrashReport(signal: "B") + store.save(report1) + store.save(report2) + + store.remove(id: report1.id) + + let remaining = store.loadAll() + #expect(remaining.count == 1) + #expect(remaining[0].id == report2.id) + } + + @Test func removeAll_clearsStore() { + let store = makeTempStore() + defer { cleanup(store) } + + store.save(CrashReport(signal: "A")) + store.save(CrashReport(signal: "B")) + store.save(CrashReport(signal: "C")) + + store.removeAll() + #expect(store.loadAll().isEmpty) + } + + @Test func loadAll_sortedByTimestamp_newestFirst() { + let store = makeTempStore() + defer { cleanup(store) } + + let old = CrashReport( + id: UUID(), timestamp: Date(timeIntervalSince1970: 1_000_000), + appVersion: "1", buildNumber: "1", osVersion: "26", + signal: "old", exceptionType: nil, terminationReason: nil, + callStackFrames: [], openTabCount: nil + ) + let recent = CrashReport( + id: UUID(), timestamp: Date(timeIntervalSince1970: 2_000_000), + appVersion: "1", buildNumber: "1", osVersion: "26", + signal: "recent", exceptionType: nil, terminationReason: nil, + callStackFrames: [], openTabCount: nil + ) + store.save(old) + store.save(recent) + + let loaded = store.loadAll() + #expect(loaded.count == 2) + #expect(loaded[0].signal == "recent") + #expect(loaded[1].signal == "old") + } + + @Test func emptyStore_loadsEmpty() { + let store = makeTempStore() + defer { cleanup(store) } + + #expect(store.loadAll().isEmpty) + } + + @Test func maxReports_constant() { + #expect(CrashReportStore.maxReports == 50) + } + + @Test func fileExtension_constant() { + #expect(CrashReportStore.fileExtension == "crashreport") + } + + @Test func remove_nonexistentID_doesNotCrash() { + let store = makeTempStore() + defer { cleanup(store) } + + store.remove(id: UUID()) // Should not throw or crash + #expect(store.loadAll().isEmpty) + } + + @Test func pruning_removesOldReportsOverLimit() { + let store = makeTempStore() + defer { cleanup(store) } + + // Save more than maxReports + for i in 0..<(CrashReportStore.maxReports + 5) { + let report = CrashReport( + id: UUID(), + timestamp: Date(timeIntervalSinceNow: Double(i)), + appVersion: "1", buildNumber: "1", osVersion: "26", + signal: "SIG\(i)", exceptionType: nil, terminationReason: nil, + callStackFrames: [], openTabCount: nil + ) + store.save(report) + } + + #expect(store.count <= CrashReportStore.maxReports) + } +} + +// MARK: - extractCallStackFrames Tests + +struct ExtractCallStackFramesTests { + + @Test func emptyData_returnsEmpty() { + let frames = CrashReportingManager.extractCallStackFrames(from: Data()) + #expect(frames.isEmpty) + } + + @Test func invalidJSON_returnsEmpty() { + let data = Data("not json".utf8) + let frames = CrashReportingManager.extractCallStackFrames(from: data) + #expect(frames.isEmpty) + } + + @Test func missingCallStackTree_returnsEmpty() throws { + let json: [String: Any] = ["other": "data"] + let data = try JSONSerialization.data(withJSONObject: json) + let frames = CrashReportingManager.extractCallStackFrames(from: data) + #expect(frames.isEmpty) + } + + @Test func missingCallStacks_returnsEmpty() throws { + let json: [String: Any] = ["callStackTree": ["other": "data"]] + let data = try JSONSerialization.data(withJSONObject: json) + let frames = CrashReportingManager.extractCallStackFrames(from: data) + #expect(frames.isEmpty) + } + + @Test func singleFrame_parsesCorrectly() throws { + let json: [String: Any] = [ + "callStackTree": [ + "callStacks": [ + [ + "callStackRootFrames": [ + [ + "binaryName": "Pine", + "address": 4_294_967_296, + "offsetIntoBinaryTextSegment": 1234 + ] + ] + ] + ] + ] + ] + let data = try JSONSerialization.data(withJSONObject: json) + let frames = CrashReportingManager.extractCallStackFrames(from: data) + #expect(frames.count == 1) + #expect(frames[0].contains("Pine")) + } + + @Test func nestedSubFrames_flattensAll() throws { + let json: [String: Any] = [ + "callStackTree": [ + "callStacks": [ + [ + "callStackRootFrames": [ + [ + "binaryName": "Pine", + "address": 100, + "offsetIntoBinaryTextSegment": 10, + "subFrames": [ + [ + "binaryName": "libsystem", + "address": 200, + "offsetIntoBinaryTextSegment": 20 + ] + ] + ] + ] + ] + ] + ] + ] + let data = try JSONSerialization.data(withJSONObject: json) + let frames = CrashReportingManager.extractCallStackFrames(from: data) + #expect(frames.count == 2) + #expect(frames[0].contains("Pine")) + #expect(frames[1].contains("libsystem")) + } + + @Test func multipleCallStacks_parsesAll() throws { + let json: [String: Any] = [ + "callStackTree": [ + "callStacks": [ + [ + "callStackRootFrames": [ + ["binaryName": "Pine", "address": 100, "offsetIntoBinaryTextSegment": 10] + ] + ], + [ + "callStackRootFrames": [ + ["binaryName": "AppKit", "address": 200, "offsetIntoBinaryTextSegment": 20] + ] + ] + ] + ] + ] + let data = try JSONSerialization.data(withJSONObject: json) + let frames = CrashReportingManager.extractCallStackFrames(from: data) + #expect(frames.count == 2) + #expect(frames[0].contains("Pine")) + #expect(frames[1].contains("AppKit")) + } + + @Test func missingBinaryName_usesFallback() throws { + let json: [String: Any] = [ + "callStackTree": [ + "callStacks": [ + [ + "callStackRootFrames": [ + ["address": 100, "offsetIntoBinaryTextSegment": 10] + ] + ] + ] + ] + ] + let data = try JSONSerialization.data(withJSONObject: json) + let frames = CrashReportingManager.extractCallStackFrames(from: data) + #expect(frames.count == 1) + #expect(frames[0].contains("?")) + } + + @Test func emptyCallStacks_returnsEmpty() throws { + let json: [String: Any] = [ + "callStackTree": [ + "callStacks": [] as [[String: Any]] + ] + ] + let data = try JSONSerialization.data(withJSONObject: json) + let frames = CrashReportingManager.extractCallStackFrames(from: data) + #expect(frames.isEmpty) + } +} + +// MARK: - CrashReportStore Thread Safety Tests + +struct CrashReportStoreThreadSafetyTests { + + private func makeTempStore() -> CrashReportStore { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("PineTests-CrashStore-Thread-\(UUID().uuidString)") + return CrashReportStore(storageDirectory: tmpDir) + } + + private func cleanup(_ store: CrashReportStore) { + try? FileManager.default.removeItem(at: store.storageDirectory) + } + + @Test func concurrentSaves_doNotCrash() { + let store = makeTempStore() + defer { cleanup(store) } + + let group = DispatchGroup() + let concurrentQueue = DispatchQueue(label: "test.concurrent", attributes: .concurrent) + + for i in 0..<20 { + group.enter() + concurrentQueue.async { + let report = CrashReport(signal: "SIG\(i)") + store.save(report) + group.leave() + } + } + + group.wait() + #expect(store.count == 20) + } + + @Test func concurrentSaveAndLoad_doNotCrash() { + let store = makeTempStore() + defer { cleanup(store) } + + let group = DispatchGroup() + let concurrentQueue = DispatchQueue(label: "test.concurrent.rw", attributes: .concurrent) + + // Pre-populate with some reports + for i in 0..<5 { + store.save(CrashReport(signal: "PRE\(i)")) + } + + // Concurrent reads and writes + for i in 0..<10 { + group.enter() + concurrentQueue.async { + store.save(CrashReport(signal: "WRITE\(i)")) + group.leave() + } + + group.enter() + concurrentQueue.async { + _ = store.loadAll() + group.leave() + } + + group.enter() + concurrentQueue.async { + _ = store.count + group.leave() + } + } + + group.wait() + #expect(store.count == 15) // 5 pre + 10 writes + } + + @Test func concurrentRemoveAll_doNotCrash() { + let store = makeTempStore() + defer { cleanup(store) } + + for i in 0..<10 { + store.save(CrashReport(signal: "SIG\(i)")) + } + + let group = DispatchGroup() + let concurrentQueue = DispatchQueue(label: "test.concurrent.remove", attributes: .concurrent) + + // Concurrent removeAll and loadAll should not crash + for _ in 0..<5 { + group.enter() + concurrentQueue.async { + store.removeAll() + group.leave() + } + + group.enter() + concurrentQueue.async { + _ = store.loadAll() + group.leave() + } + } + + group.wait() + // After removeAll, store should be empty + #expect(store.loadAll().isEmpty) + } +} + +// MARK: - CrashReportStore Export Tests + +struct CrashReportStoreExportTests { + + private func makeTempStore() -> CrashReportStore { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("PineTests-CrashStore-Export-\(UUID().uuidString)") + return CrashReportStore(storageDirectory: tmpDir) + } + + private func cleanup(_ store: CrashReportStore) { + try? FileManager.default.removeItem(at: store.storageDirectory) + } + + @Test func copyAllToClipboard_emptyStore_returnsZero() { + let store = makeTempStore() + defer { cleanup(store) } + + let copiedCount = store.copyAllToClipboard() + #expect(copiedCount == 0) + } + + @Test func copyAllToClipboard_withReports_returnsCopiedCount() { + let store = makeTempStore() + defer { cleanup(store) } + + store.save(CrashReport(signal: "SIGSEGV")) + store.save(CrashReport(signal: "SIGABRT")) + + let copiedCount = store.copyAllToClipboard() + #expect(copiedCount == 2) + } + + @Test func copyAllToClipboard_writesValidJSON() { + let store = makeTempStore() + defer { cleanup(store) } + + let report = CrashReport(signal: "SIGSEGV", exceptionType: "EXC_BAD_ACCESS") + store.save(report) + + store.copyAllToClipboard() + + let pasteboard = NSPasteboard.general + guard let json = pasteboard.string(forType: .string) else { + #expect(Bool(false), "Clipboard should contain a string") + return + } + + // Verify it's valid JSON (uses iso8601 date encoding) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let data = json.data(using: .utf8), + let decoded = try? decoder.decode([CrashReport].self, from: data) else { + #expect(Bool(false), "Clipboard content should be valid JSON decodable to [CrashReport]") + return + } + + #expect(decoded.count == 1) + #expect(decoded[0].signal == "SIGSEGV") + #expect(decoded[0].exceptionType == "EXC_BAD_ACCESS") + } + + @Test func storageDirectory_isAccessible() { + let store = makeTempStore() + defer { cleanup(store) } + + // The storage directory should exist after init + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: store.storageDirectory.path, isDirectory: &isDir) + #expect(exists) + #expect(isDir.boolValue) + } +} + +// MARK: - CrashReportingSettings Opt-In Race Tests + +struct CrashReportingSettingsRaceTests { + + private func withCleanDefaults(_ body: () -> Void) { + let enabledKey = CrashReportingSettings.enabledKey + let promptKey = CrashReportingSettings.promptShownKey + let savedEnabled = UserDefaults.standard.object(forKey: enabledKey) + let savedPrompt = UserDefaults.standard.object(forKey: promptKey) + + UserDefaults.standard.removeObject(forKey: enabledKey) + UserDefaults.standard.removeObject(forKey: promptKey) + + body() + + if let val = savedEnabled { + UserDefaults.standard.set(val, forKey: enabledKey) + } else { + UserDefaults.standard.removeObject(forKey: enabledKey) + } + if let val = savedPrompt { + UserDefaults.standard.set(val, forKey: promptKey) + } else { + UserDefaults.standard.removeObject(forKey: promptKey) + } + } + + @Test func hasShownPrompt_setBeforeDisplay_preventsRace() { + withCleanDefaults { + // Simulate what showCrashReportingOptInIfNeeded now does: + // set hasShownPrompt BEFORE the async delay + #expect(CrashReportingSettings.needsPrompt) + + // First "window" marks prompt as shown + CrashReportingSettings.hasShownPrompt = true + + // Second "window" should see prompt already shown + #expect(!CrashReportingSettings.needsPrompt) + } + } + + @Test func recordChoice_afterHasShownPrompt_worksCorrectly() { + withCleanDefaults { + // Mark as shown (as the fix does) + CrashReportingSettings.hasShownPrompt = true + #expect(!CrashReportingSettings.needsPrompt) + #expect(!CrashReportingSettings.isEnabled) // Not yet enabled + + // User clicks Enable + CrashReportingSettings.recordChoice(enabled: true) + #expect(CrashReportingSettings.isEnabled) + #expect(CrashReportingSettings.hasShownPrompt) // Still true + } + } + + @Test func recordChoice_afterHasShownPrompt_disable() { + withCleanDefaults { + CrashReportingSettings.hasShownPrompt = true + CrashReportingSettings.recordChoice(enabled: false) + #expect(!CrashReportingSettings.isEnabled) + #expect(CrashReportingSettings.hasShownPrompt) + } + } +} + +// MARK: - CrashReportingManager Stop Tests + +struct CrashReportingManagerStopTests { + + @Test func stop_canBeCalledMultipleTimes() { + let store = CrashReportStore( + storageDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent("PineTests-Manager-\(UUID().uuidString)") + ) + defer { try? FileManager.default.removeItem(at: store.storageDirectory) } + + let manager = CrashReportingManager(store: store) + // Multiple stop() calls should not crash + manager.stop() + manager.stop() + manager.stop() + } + + @Test func crashMarkerPath_isNotEmpty() { + let path = CrashReportingManager.crashMarkerPath + #expect(!path.isEmpty) + #expect(path.contains("Pine")) + } +} + +// MARK: - Strings Constants Tests + +struct CrashReportingStringsTests { + + @Test func optInEnable_string_isNotEmpty() { + let value = Strings.crashReportingOptInEnable + #expect(!value.isEmpty) + } + + @Test func optInDisable_string_isNotEmpty() { + let value = Strings.crashReportingOptInDisable + #expect(!value.isEmpty) + } +} + +// MARK: - MenuIcons Tests + +struct CrashReportingMenuIconTests { + @Test func crashReporting_iconExists() { + #expect( + NSImage(systemSymbolName: MenuIcons.crashReporting, accessibilityDescription: nil) != nil, + "SF Symbol '\(MenuIcons.crashReporting)' does not exist" + ) + } + + @Test func crashReportsReveal_iconExists() { + #expect( + NSImage(systemSymbolName: MenuIcons.crashReportsReveal, accessibilityDescription: nil) != nil, + "SF Symbol '\(MenuIcons.crashReportsReveal)' does not exist" + ) + } + + @Test func crashReportsCopy_iconExists() { + #expect( + NSImage(systemSymbolName: MenuIcons.crashReportsCopy, accessibilityDescription: nil) != nil, + "SF Symbol '\(MenuIcons.crashReportsCopy)' does not exist" + ) + } +}