From b03e7737d4b2171f7862d7f7b5df33857d7e6722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Wed, 26 Feb 2025 18:36:02 +0100 Subject: [PATCH 1/6] Add basic log file / data fetch helper functions --- README.md | 16 +++ Sources/ErrorKit/OSLog/ErrorKit+OSLog.swift | 144 ++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 Sources/ErrorKit/OSLog/ErrorKit+OSLog.swift diff --git a/README.md b/README.md index 6b0f967..2bfaa70 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ ErrorKit makes error handling in Swift more intuitive. It reduces boilerplate co - [Typed Throws for System Functions](#typed-throws-for-system-functions) - [Error Nesting with Catching](#error-nesting-with-catching) - [Error Chain Debugging](#error-chain-debugging) +- [Attach Log Files](#attach-log-files) +- [Live Error Analytics](#live-error-analytics) ## The Problem with Swift's Error Protocol @@ -544,3 +546,17 @@ This precise grouping allows you to: ### Summary ErrorKit's debugging tools transform error handling from a black box into a transparent system. By combining `errorChainDescription` for debugging with `groupingID` for analytics, you get deep insight into error flows while maintaining the ability to track and prioritize issues effectively. This is particularly powerful when combined with ErrorKit's `Catching` protocol, creating a comprehensive system for error handling, debugging, and monitoring. + + +## Attach Log File + +ErrorKit makes it super easy to attach a log file with relevant console output data to user bug reports. + +TODO: continue here + + +## Life Error Analytics + +ErrorKit comes with hooks that make it easy to connect the reporting of errors to analytics service so you can find out which errors your users are confronted with most, without them having to contact you! This is great to proactively track issues in your app, track how they're evolving after you make a bug fix release or generally to make decisions on what to fix first! + +TODO: continue here diff --git a/Sources/ErrorKit/OSLog/ErrorKit+OSLog.swift b/Sources/ErrorKit/OSLog/ErrorKit+OSLog.swift new file mode 100644 index 0000000..0757cad --- /dev/null +++ b/Sources/ErrorKit/OSLog/ErrorKit+OSLog.swift @@ -0,0 +1,144 @@ +import Foundation +import OSLog + +import Foundation +import OSLog + +extension ErrorKit { + /// Returns log data from the unified logging system for a specified time period and minimum level. + /// + /// This function collects logs from your app and generates a string representation that can + /// be attached to support emails or saved for diagnostic purposes. It provides the log data + /// directly rather than creating a file, giving you flexibility in how you use the data. + /// + /// - Parameters: + /// - duration: How far back in time to collect logs. + /// For example, `.minutes(5)` collects logs from the last 5 minutes. + /// - minLevel: The minimum log level to include (default: .notice). + /// Higher levels include less but more important information: + /// - `.debug`: All logs (very verbose) + /// - `.info`: Informational logs and above + /// - `.notice`: Notable events (default) + /// - `.error`: Only errors and faults + /// - `.fault`: Only critical errors + /// + /// - Returns: Data object containing the log content as UTF-8 encoded text + /// - Throws: Errors if log store access fails + /// + /// ## Example: Attach Logs to Support Email + /// ```swift + /// func sendSupportEmail() { + /// do { + /// // Get logs from the last 5 minutes + /// let logData = try ErrorKit.loggedData( + /// ofLast: .seconds(5), + /// minLevel: .notice + /// ) + /// + /// // Create and present mail composer + /// if MFMailComposeViewController.canSendMail() { + /// let mail = MFMailComposeViewController() + /// mail.setToRecipients(["support@yourapp.com"]) + /// mail.setSubject("Support Request") + /// mail.setMessageBody("Please describe your issue here:", isHTML: false) + /// + /// // Attach the log data + /// mail.addAttachmentData( + /// logData, + /// mimeType: "text/plain", + /// fileName: "app_logs.txt" + /// ) + /// + /// present(mail, animated: true) + /// } + /// } catch { + /// // Handle log export error + /// showAlert(message: "Could not attach logs: \(error.localizedDescription)") + /// } + /// } + /// ``` + /// + /// - See Also: ``exportLogFile(ofLast:minLevel:)`` for when you need a URL with the log content written to a text file + public static func loggedData(ofLast duration: Duration, minLevel: OSLogEntryLog.Level = .notice) throws -> Data { + let logStore = try OSLogStore(scope: .currentProcessIdentifier) + + let fromDate = Date.now.advanced(by: -duration.timeInterval) + let fromDatePosition = logStore.position(date: fromDate) + + let levelPredicate = NSPredicate(format: "level >= %d", minLevel.rawValue) + + let entries = try logStore.getEntries(with: [.reverse], at: fromDatePosition, matching: levelPredicate) + let logMessages = entries.map(\.composedMessage).joined(separator: "\n") + return Data(logMessages.utf8) + } + + /// Exports logs from the unified logging system to a file for a specified time period and minimum level. + /// + /// This convenience function builds on ``loggedData(ofLast:minLevel:)`` by writing the log data + /// to a temporary file. This is useful when working with APIs that require a file URL rather than Data. + /// + /// - Parameters: + /// - duration: How far back in time to collect logs + /// - minLevel: The minimum log level to include (default: .notice) + /// + /// - Returns: URL to the temporary file containing the exported logs + /// - Throws: Errors if log store access fails or if writing to the file fails + /// + /// - See Also: ``loggedData(ofLast:minLevel:)`` for when you need the log content as Data directly + public static func exportLogFile(ofLast duration: Duration, minLevel: OSLogEntryLog.Level = .notice) throws -> URL { + let logData = try loggedData(ofLast: duration, minLevel: minLevel) + + let fileName = "logs_\(Date.now.formatted(.iso8601)).txt" + let fileURL = FileManager.default.temporaryDirectory.appending(path: fileName) + + try logData.write(to: fileURL) + return fileURL + } +} + +extension Duration { + /// Returns the duration as a `TimeInterval`. + /// + /// This can be useful for interfacing with APIs that require `TimeInterval` (which is measured in seconds), allowing you to convert a `Duration` directly to the needed format. + /// + /// Example: + /// ```swift + /// let duration = Duration.hours(2) + /// let timeInterval = duration.timeInterval // Converts to TimeInterval for compatibility + /// ``` + /// + /// - Returns: The duration as a `TimeInterval`, which represents the duration in seconds. + public var timeInterval: TimeInterval { + TimeInterval(self.components.seconds) + (TimeInterval(self.components.attoseconds) / 1_000_000_000_000_000_000) + } + + /// Constructs a `Duration` given a number of minutes represented as a `BinaryInteger`. + /// + /// This is helpful for precise time measurements, such as cooking timers, short breaks, or meeting durations. + /// + /// Example: + /// ```swift + /// let fifteenMinutesDuration = Duration.minutes(15) // Creates a Duration of 15 minutes + /// ``` + /// + /// - Parameter minutes: The number of minutes. + /// - Returns: A `Duration` representing the given number of minutes. + public static func minutes(_ minutes: T) -> Duration { + self.seconds(minutes * 60) + } + + /// Constructs a `Duration` given a number of hours represented as a `BinaryInteger`. + /// + /// Can be used to schedule events or tasks that are several hours long. + /// + /// Example: + /// ```swift + /// let eightHoursDuration = Duration.hours(8) // Creates a Duration of 8 hours + /// ``` + /// + /// - Parameter hours: The number of hours. + /// - Returns: A `Duration` representing the given number of hours. + public static func hours(_ hours: T) -> Duration { + self.minutes(hours * 60) + } +} From 85d5b0bb7842db9990866ac1030ca7562422e0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Wed, 26 Feb 2025 22:15:33 +0100 Subject: [PATCH 2/6] Add MailView for easy sending support email from SwiftUI with attachment --- .../{OSLog => Logging}/ErrorKit+OSLog.swift | 0 Sources/ErrorKit/Logging/MailView.swift | 150 +++++++++++ .../ErrorKit/Resources/Localizable.xcstrings | 250 ++++++------------ 3 files changed, 233 insertions(+), 167 deletions(-) rename Sources/ErrorKit/{OSLog => Logging}/ErrorKit+OSLog.swift (100%) create mode 100644 Sources/ErrorKit/Logging/MailView.swift diff --git a/Sources/ErrorKit/OSLog/ErrorKit+OSLog.swift b/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift similarity index 100% rename from Sources/ErrorKit/OSLog/ErrorKit+OSLog.swift rename to Sources/ErrorKit/Logging/ErrorKit+OSLog.swift diff --git a/Sources/ErrorKit/Logging/MailView.swift b/Sources/ErrorKit/Logging/MailView.swift new file mode 100644 index 0000000..4fb2585 --- /dev/null +++ b/Sources/ErrorKit/Logging/MailView.swift @@ -0,0 +1,150 @@ +import SwiftUI +import MessageUI + +/// A SwiftUI component that wraps UIKit's MFMailComposeViewController to provide email +/// composition functionality in SwiftUI applications. +/// +/// # Example +/// +/// ```swift +/// struct ContentView: View { +/// @State private var showingMail = false +/// @State private var mailResult: Result? = nil +/// +/// var body: some View { +/// Button("Contact Support") { +/// if MailView.canSendMail() { +/// showingMail = true +/// } +/// } +/// .sheet(isPresented: $showingMail) { +/// MailView( +/// isShowing: $showingMail, +/// result: $mailResult, +/// recipients: ["support@example.com"], +/// subject: "App Feedback", +/// messageBody: "I'm enjoying the app, but found an issue:", +/// attachments: [ +/// MailView.Attachment( +/// data: UIImage(named: "screenshot")!.pngData()!, +/// mimeType: "image/png", +/// filename: "screenshot.png" +/// ) +/// ] +/// ) +/// } +/// } +/// } +/// ``` +public struct MailView: UIViewControllerRepresentable { + /// Represents an email attachment with data, mime type, and filename + public struct Attachment { + let data: Data + let mimeType: String + let filename: String + + /// Creates a new email attachment + /// - Parameters: + /// - data: The content of the attachment as Data + /// - mimeType: The MIME type of the attachment (e.g., "image/jpeg", "application/pdf") + /// - filename: The filename for the attachment when received by the recipient + public init(data: Data, mimeType: String, filename: String) { + self.data = data + self.mimeType = mimeType + self.filename = filename + } + } + + /// Checks if the device is capable of sending emails + /// - Returns: Boolean indicating whether email composition is available + public static func canSendMail() -> Bool { + MFMailComposeViewController.canSendMail() + } + + @Binding private var isShowing: Bool + + private var recipients: [String]? + private var subject: String? + private var messageBody: String? + private var isHTML: Bool + private var attachments: [Attachment]? + + /// Creates a new mail view with the specified parameters + /// - Parameters: + /// - isShowing: Binding to control the presentation state + /// - result: Binding to capture the result of the mail composition + /// - recipients: Optional array of recipient email addresses + /// - subject: Optional subject line + /// - messageBody: Optional body text + /// - isHTML: Whether the message body contains HTML (defaults to false) + /// - attachments: Optional array of attachments + public init( + isShowing: Binding, + result: Binding?>, + recipients: [String]? = nil, + subject: String? = nil, + messageBody: String? = nil, + isHTML: Bool = false, + attachments: [Attachment]? = nil + ) { + self._isShowing = isShowing + self.recipients = recipients + self.subject = subject + self.messageBody = messageBody + self.isHTML = isHTML + self.attachments = attachments + } + + public func makeUIViewController(context: Context) -> MFMailComposeViewController { + let composer = MFMailComposeViewController() + composer.mailComposeDelegate = context.coordinator + + if let recipients = recipients { + composer.setToRecipients(recipients) + } + + if let subject = subject { + composer.setSubject(subject) + } + + if let messageBody = messageBody { + composer.setMessageBody(messageBody, isHTML: isHTML) + } + + if let attachments = attachments { + for attachment in attachments { + composer.addAttachmentData( + attachment.data, + mimeType: attachment.mimeType, + fileName: attachment.filename + ) + } + } + + return composer + } + + public func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {} + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public class Coordinator: NSObject, @preconcurrency MFMailComposeViewControllerDelegate { + var parent: MailView + + init(_ parent: MailView) { + self.parent = parent + } + + @MainActor + public func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: Error? + ) { + parent.isShowing = false + controller.dismiss(animated: true) + } + } +} diff --git a/Sources/ErrorKit/Resources/Localizable.xcstrings b/Sources/ErrorKit/Resources/Localizable.xcstrings index 973b650..a8aea51 100644 --- a/Sources/ErrorKit/Resources/Localizable.xcstrings +++ b/Sources/ErrorKit/Resources/Localizable.xcstrings @@ -249,8 +249,7 @@ "value" : "無法建立與數據庫的連接。請檢查你的網絡設置並重試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.DatabaseError.operationFailed" : { "localizations" : { @@ -500,8 +499,7 @@ "value" : "無法完成 %@ 的數據庫操作。請重試該操作。" } } - }, - "proofread" : true + } }, "BuiltInErrors.DatabaseError.recordNotFound" : { "localizations" : { @@ -751,11 +749,9 @@ "value" : "在數據庫中找不到 %1$@ 記錄。請核實細節並重試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.DatabaseError.recordNotFoundWithID" : { - "extractionState" : "extracted_with_value", "localizations" : { "ar" : { "stringUnit" : { @@ -1003,8 +999,7 @@ "value" : "ID 為 %2$@ 的 %1$@ 記錄在資料庫中未找到。請核實詳細資訊後再試一次。" } } - }, - "proofread" : true + } }, "BuiltInErrors.FileError.fileNotFound" : { "localizations" : { @@ -1254,8 +1249,7 @@ "value" : "無法找到 文件 %@。請檢查文件路徑並重試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.FileError.readError" : { "localizations" : { @@ -1505,8 +1499,7 @@ "value" : "嘗試讀取 文件 %@ 時發生錯誤。請檢查文件權限並重試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.FileError.writeError" : { "localizations" : { @@ -1756,8 +1749,7 @@ "value" : "無法寫入 文件 %@。請確保你擁有必要的權限並重試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.NetworkError.badRequest" : { "localizations" : { @@ -2007,8 +1999,7 @@ "value" : "請求出現問題 (代碼: %1$lld)。%2$@。請檢查並重試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.NetworkError.decodingFailure" : { "localizations" : { @@ -2258,8 +2249,7 @@ "value" : "無法處理伺服器的回應。請重試,如問題持續存在,請聯繫支援。" } } - }, - "proofread" : true + } }, "BuiltInErrors.NetworkError.noInternet" : { "localizations" : { @@ -2509,8 +2499,7 @@ "value" : "無法連接到互聯網。請檢查網絡設置,然後重試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.NetworkError.serverError" : { "localizations" : { @@ -2760,8 +2749,7 @@ "value" : "伺服器發生錯誤 (代碼: %lld)。" } } - }, - "proofread" : true + } }, "BuiltInErrors.NetworkError.timeout" : { "localizations" : { @@ -3011,8 +2999,7 @@ "value" : "網絡請求花了太長時間未能完成。請檢查你的連線,然後重試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.OperationError.canceled" : { "localizations" : { @@ -3262,8 +3249,7 @@ "value" : "該操作因你的要求被取消。你可以在需要時重試該操作。" } } - }, - "proofread" : true + } }, "BuiltInErrors.OperationError.dependencyFailed" : { "localizations" : { @@ -3513,8 +3499,7 @@ "value" : "無法啟動該操作,因為所需的組件無法初始化: %@。請重新啟動應用程式或聯繫支援。" } } - }, - "proofread" : true + } }, "BuiltInErrors.ParsingError.invalidInput" : { "localizations" : { @@ -3764,8 +3749,7 @@ "value" : "提供的輸入無法正確處理:%@。請檢查輸入並確保它符合預期格式。" } } - }, - "proofread" : true + } }, "BuiltInErrors.ParsingError.missingField" : { "localizations" : { @@ -4015,8 +3999,7 @@ "value" : "所需的信息不完整。%@ 欄位缺失,必須提供以繼續。" } } - }, - "proofread" : true + } }, "BuiltInErrors.PermissionError.denied" : { "localizations" : { @@ -4266,8 +4249,7 @@ "value" : "訪問 %@ 被拒絕。要使用此功能,請在設備設定中啟用權限。" } } - }, - "proofread" : true + } }, "BuiltInErrors.PermissionError.notDetermined" : { "localizations" : { @@ -4517,8 +4499,7 @@ "value" : "%@ 的權限尚未確認。請檢查並在設備設定中授予訪問權。" } } - }, - "proofread" : true + } }, "BuiltInErrors.PermissionError.restricted" : { "localizations" : { @@ -4768,8 +4749,7 @@ "value" : "訪問 %@ 目前受到限制。這可能是由於系統設置或家長控制。" } } - }, - "proofread" : true + } }, "BuiltInErrors.StateError.alreadyFinalized" : { "localizations" : { @@ -5019,8 +4999,7 @@ "value" : "該項目已經完成,不可修改。如需更改,請創建新版本。" } } - }, - "proofread" : true + } }, "BuiltInErrors.StateError.invalidState" : { "localizations" : { @@ -5270,8 +5249,7 @@ "value" : "當前狀態不允許此操作:%@。請確保滿足所有要求後再試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.StateError.preconditionFailed" : { "localizations" : { @@ -5521,8 +5499,7 @@ "value" : "未滿足必要條件:%@。請在繼續之前完成所有先決條件。" } } - }, - "proofread" : true + } }, "BuiltInErrors.ValidationError.inputTooLong" : { "localizations" : { @@ -5772,8 +5749,7 @@ "value" : "請縮短您的輸入,再試一次。" } } - }, - "proofread" : true + } }, "BuiltInErrors.ValidationError.invalidInput" : { "localizations" : { @@ -6023,8 +5999,7 @@ "value" : "輸入的%@值格式不正確。請檢查要求,然後再次嘗試。" } } - }, - "proofread" : true + } }, "BuiltInErrors.ValidationError.missingField" : { "localizations" : { @@ -6274,8 +6249,7 @@ "value" : "請提供%@的值。此信息是必需的才能繼續。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CocoaError.default" : { "localizations" : { @@ -6525,8 +6499,7 @@ "value" : "發生文件系統錯誤:%@" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CocoaError.fileNoSuchFile" : { "localizations" : { @@ -6776,8 +6749,7 @@ "value" : "找不到此文件。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CocoaError.fileReadNoPermission" : { "localizations" : { @@ -7027,8 +6999,7 @@ "value" : "您沒有權限閱讀此文件。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CocoaError.fileWriteOutOfSpace" : { "localizations" : { @@ -7278,8 +7249,7 @@ "value" : "磁碟空間不足,無法完成操作。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CoreData.NSManagedObjectValidationError" : { "localizations" : { @@ -7529,8 +7499,7 @@ "value" : "發生對象驗證錯誤。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CoreData.NSPersistentStoreIncompatibleVersionHashError" : { "localizations" : { @@ -7780,8 +7749,7 @@ "value" : "資料存儲與當前模型版本不兼容。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CoreData.NSPersistentStoreOpenError" : { "localizations" : { @@ -8031,8 +7999,7 @@ "value" : "無法打開持久存儲。請檢查您的存儲或權限。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CoreData.NSPersistentStoreSaveError" : { "localizations" : { @@ -8282,8 +8249,7 @@ "value" : "保存數據失敗。請再試一次。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CoreData.NSValidationMissingMandatoryPropertyError" : { "localizations" : { @@ -8533,8 +8499,7 @@ "value" : "缺少必要屬性。請填寫所有必填字段。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CoreData.NSValidationMultipleErrorsError" : { "localizations" : { @@ -8784,8 +8749,7 @@ "value" : "保存時發生多個驗證錯誤。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.CoreData.NSValidationRelationshipLacksMinimumCountError" : { "localizations" : { @@ -9035,8 +8999,7 @@ "value" : "關係缺少所需的相關對象。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.MKError.default" : { "localizations" : { @@ -9286,8 +9249,7 @@ "value" : "發生地圖錯誤:%@。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.MKError.directionsNotFound" : { "localizations" : { @@ -9537,8 +9499,7 @@ "value" : "找不到指定路線的方向。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.MKError.loadingThrottled" : { "localizations" : { @@ -9788,8 +9749,7 @@ "value" : "地圖載入受到限制。請稍候片刻再試。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.MKError.placemarkNotFound" : { "localizations" : { @@ -10039,8 +9999,7 @@ "value" : "無法找到所請求的地標。請檢查地點詳情。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.MKError.serverFailure" : { "localizations" : { @@ -10290,8 +10249,7 @@ "value" : "MapKit 伺服器返回了一個錯誤。請稍後再試。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.MKError.unknown" : { "localizations" : { @@ -10541,8 +10499,7 @@ "value" : "MapKit 中發生了一個未知錯誤。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.POSIXError.default" : { "localizations" : { @@ -10792,8 +10749,7 @@ "value" : "系統發生錯誤:%@" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.POSIXError.EACCES" : { "localizations" : { @@ -11043,8 +10999,7 @@ "value" : "權限被拒絕。請檢查你的文件權限。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.POSIXError.EBADF" : { "localizations" : { @@ -11294,8 +11249,7 @@ "value" : "無效的文件描述符。文件可能已關閉或無效。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.POSIXError.ENOSPC" : { "localizations" : { @@ -11545,8 +11499,7 @@ "value" : "設備上沒有剩餘空間。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.URLError.cannotFindHost" : { "localizations" : { @@ -11796,8 +11749,7 @@ "value" : "無法找到伺服器。請檢查 URL 或你的網絡。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.URLError.default" : { "localizations" : { @@ -12047,8 +11999,7 @@ "value" : "發生了網絡錯誤:%@" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.URLError.networkConnectionLost" : { "localizations" : { @@ -12298,8 +12249,7 @@ "value" : "網絡連接已丟失。請重試。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.URLError.notConnectedToInternet" : { "localizations" : { @@ -12549,8 +12499,7 @@ "value" : "你未連接到互聯網。請檢查你的連接。" } } - }, - "proofread" : true + } }, "EnhancedDescriptions.URLError.timedOut" : { "localizations" : { @@ -12800,8 +12749,7 @@ "value" : "請求超時。請稍後再試。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.corruptFile" : { "localizations" : { @@ -13051,8 +12999,7 @@ "value" : "該文件已損壞或以不可讀的格式存在。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.fileExists" : { "localizations" : { @@ -13302,8 +13249,7 @@ "value" : "該文件或目錄已存在。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.fileLocked" : { "localizations" : { @@ -13553,8 +13499,7 @@ "value" : "該文件已鎖定,無法修改。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.fileNotFound" : { "localizations" : { @@ -13804,8 +13749,7 @@ "value" : "未找到指定的文件或目錄。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.fileTooLarge" : { "localizations" : { @@ -14055,8 +13999,7 @@ "value" : "該文件過大,無法處理。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.invalidFileName" : { "localizations" : { @@ -14306,8 +14249,7 @@ "value" : "該文件名無效,無法使用。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.noReadPermission" : { "localizations" : { @@ -14557,8 +14499,7 @@ "value" : "你沒有權限閱讀此文件或目錄。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.noWritePermission" : { "localizations" : { @@ -14808,8 +14749,7 @@ "value" : "你沒有權限寫入此文件或目錄。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.outOfSpace" : { "localizations" : { @@ -15059,8 +14999,7 @@ "value" : "磁碟空間不足,無法完成操作。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.readError" : { "localizations" : { @@ -15310,8 +15249,7 @@ "value" : "讀取文件時發生未知錯誤。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.unsupportedEncoding" : { "localizations" : { @@ -15561,8 +15499,7 @@ "value" : "文件的字符编码不受支持。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.volumeReadOnly" : { "localizations" : { @@ -15812,8 +15749,7 @@ "value" : "存储卷是唯讀的,无法修改。" } } - }, - "proofread" : true + } }, "TypedOverloads.FileManager.writeError" : { "localizations" : { @@ -16063,8 +15999,7 @@ "value" : "写入文件时发生未知错误。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.badRequest" : { "localizations" : { @@ -16314,8 +16249,7 @@ "value" : "请求格式不正确(400)。请检查并重试。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.badURL" : { "localizations" : { @@ -16565,8 +16499,7 @@ "value" : "网址格式不正确。请检查并重试,或报告错误。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.cancelled" : { "localizations" : { @@ -16816,8 +16749,7 @@ "value" : "请求已被取消。如果这不是预期的,请重试。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.cannotFindHost" : { "localizations" : { @@ -17067,8 +16999,7 @@ "value" : "无法找到主机。请检查您的互联网连接并重试。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.conflict" : { "localizations" : { @@ -17318,8 +17249,7 @@ "value" : "请求存在冲突(409)。请检查并重试。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.forbidden" : { "localizations" : { @@ -17569,8 +17499,7 @@ "value" : "您没有权限访问此资源(403)。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.methodNotAllowed" : { "localizations" : { @@ -17820,8 +17749,7 @@ "value" : "此资源不允许使用该HTTP方法(405)。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.noNetwork" : { "localizations" : { @@ -18071,8 +17999,7 @@ "value" : "未找到网络连接。请检查你的互联网。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.notAcceptable" : { "localizations" : { @@ -18322,8 +18249,7 @@ "value" : "请求的资源无法产生可接受的响应(406)。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.notFound" : { "localizations" : { @@ -18573,8 +18499,7 @@ "value" : "未能找到请求的资源(404)。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.paymentRequired" : { "localizations" : { @@ -18824,8 +18749,7 @@ "value" : "访问此资源需要付款(402)。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.requestTimeout" : { "localizations" : { @@ -19075,8 +18999,7 @@ "value" : "请求已超时(408)。请再试一次。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.serverError" : { "localizations" : { @@ -19326,8 +19249,7 @@ "value" : "服务器遇到错误(500)。请稍后再试。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.sslError" : { "localizations" : { @@ -19577,8 +19499,7 @@ "value" : "发生了 SSL 错误。请检查服务器的证书。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.timeout" : { "localizations" : { @@ -19828,8 +19749,7 @@ "value" : "請求逾時。請再試一次。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.tooManyRequests" : { "localizations" : { @@ -20079,8 +19999,7 @@ "value" : "發送了太多請求。請稍等並再試一次 (429)。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.unauthorized" : { "localizations" : { @@ -20330,8 +20249,7 @@ "value" : "您未獲授權訪問此資源 (401)。" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.unknown" : { "localizations" : { @@ -20581,8 +20499,7 @@ "value" : "從伺服器收到未知狀態碼:%lld" } } - }, - "proofread" : true + } }, "TypedOverloads.URLSession.unsupportedMediaType" : { "localizations" : { @@ -20832,8 +20749,7 @@ "value" : "该请求实体具有不受支持的媒体类型 (415)。" } } - }, - "proofread" : true + } } }, "version" : "1.0" From 215af2dbf69795f70c34e4161e23f427770f0daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Fri, 7 Mar 2025 10:50:55 +0100 Subject: [PATCH 3/6] Add mail sending modifier & logging for platforms with MessageUI --- Sources/ErrorKit/Logging/ErrorKit+OSLog.swift | 60 ++ Sources/ErrorKit/Logging/MailAttachment.swift | 19 + .../Logging/MailComposerModifier.swift | 119 +++ .../ErrorKit/Logging/MailComposerView.swift | 73 ++ Sources/ErrorKit/Logging/MailView.swift | 150 ---- .../ErrorKit/Resources/Localizable.xcstrings | 754 ++++++++++++++++++ 6 files changed, 1025 insertions(+), 150 deletions(-) create mode 100644 Sources/ErrorKit/Logging/MailAttachment.swift create mode 100644 Sources/ErrorKit/Logging/MailComposerModifier.swift create mode 100644 Sources/ErrorKit/Logging/MailComposerView.swift delete mode 100644 Sources/ErrorKit/Logging/MailView.swift diff --git a/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift b/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift index 0757cad..16d2089 100644 --- a/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift +++ b/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift @@ -94,6 +94,66 @@ extension ErrorKit { try logData.write(to: fileURL) return fileURL } + + /// Creates a mail attachment containing log data from the unified logging system. + /// + /// This convenience function builds on the logging functionality to create a ready-to-use + /// mail attachment for including logs in support emails or bug reports. + /// + /// - Parameters: + /// - duration: How far back in time to collect logs. + /// For example, `.minutes(5)` collects logs from the last 5 minutes. + /// - minLevel: The minimum log level to include (default: .notice). + /// Higher levels include less but more important information: + /// - `.debug`: All logs (very verbose) + /// - `.info`: Informational logs and above + /// - `.notice`: Notable events (default) + /// - `.error`: Only errors and faults + /// - `.fault`: Only critical errors + /// - filename: Optional custom filename for the log attachment (default: "app_logs_[timestamp].txt") + /// + /// - Returns: A `MailAttachment` ready to be used with the mail composer + /// - Throws: Errors if log store access fails + /// + /// ## Example: Attach Logs to Support Email + /// ```swift + /// Button("Report Problem") { + /// do { + /// // Get logs from the last hour as a mail attachment + /// let logAttachment = try ErrorKit.logAttachment( + /// ofLast: .minutes(60), + /// minLevel: .notice + /// ) + /// + /// showMailComposer = true + /// } catch { + /// errorMessage = "Could not prepare logs: \(error.localizedDescription)" + /// showError = true + /// } + /// } + /// .mailComposer( + /// isPresented: $showMailComposer, + /// recipients: ["support@yourapp.com"], + /// subject: "Bug Report", + /// messageBody: "I encountered the following issue:", + /// attachments: [logAttachment] + /// ) + /// ``` + public static func logAttachment( + ofLast duration: Duration, + minLevel: OSLogEntryLog.Level = .notice, + filename: String? = nil + ) throws -> MailAttachment { + let logData = try loggedData(ofLast: duration, minLevel: minLevel) + + let attachmentFilename = filename ?? "app_logs_\(Date.now.formatted(.iso8601)).txt" + + return MailAttachment( + data: logData, + mimeType: "text/plain", + filename: attachmentFilename + ) + } } extension Duration { diff --git a/Sources/ErrorKit/Logging/MailAttachment.swift b/Sources/ErrorKit/Logging/MailAttachment.swift new file mode 100644 index 0000000..615cb82 --- /dev/null +++ b/Sources/ErrorKit/Logging/MailAttachment.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Represents an email attachment with data, mime type, and filename +public struct MailAttachment { + let data: Data + let mimeType: String + let filename: String + + /// Creates a new email attachment + /// - Parameters: + /// - data: The content of the attachment as Data + /// - mimeType: The MIME type of the attachment (e.g., "image/jpeg", "application/pdf") + /// - filename: The filename for the attachment when received by the recipient + public init(data: Data, mimeType: String, filename: String) { + self.data = data + self.mimeType = mimeType + self.filename = filename + } +} diff --git a/Sources/ErrorKit/Logging/MailComposerModifier.swift b/Sources/ErrorKit/Logging/MailComposerModifier.swift new file mode 100644 index 0000000..aafe829 --- /dev/null +++ b/Sources/ErrorKit/Logging/MailComposerModifier.swift @@ -0,0 +1,119 @@ +#if canImport(MessageUI) +import SwiftUI +import MessageUI + +/// A view modifier that presents a mail composer for sending emails with attachments. +/// This modifier is particularly useful for implementing feedback or bug reporting features. +struct MailComposerModifier: ViewModifier { + @Environment(\.dismiss) private var dismiss + + @Binding var isPresented: Bool + + var recipient: String + var subject: String? + var messageBody: String? + var attachments: [MailAttachment]? + + func body(content: Content) -> some View { + content + .sheet(isPresented: self.$isPresented) { + if MailComposerView.canSendMail() { + MailComposerView( + isPresented: self.$isPresented, + recipients: [self.recipient], + subject: self.subject, + messageBody: self.messageBody, + attachments: self.attachments + ) + } else { + VStack(spacing: 20) { + Text( + String( + localized: "Logging.MailComposer.notAvailableTitle", + defaultValue: "Mail Not Available", + bundle: .module + ) + ) + .font(.headline) + + Text( + String( + localized: "Logging.MailComposer.notAvailableMessage", + defaultValue: "Your device is not configured to send emails. Please set up the Mail app or use another method to contact support at: \(self.recipient)", + bundle: .module, + comment: "%@ is typically replaced by the email address of the support contact, e.g. 'support@example.com' – so this would read like '... contact support at: support@example.com'" + ) + ) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button( + String( + localized: "Logging.MailComposer.dismissButton", + defaultValue: "Dismiss", + bundle: .module + ) + ) { + self.dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + } + } + } +} + +/// Extension that adds the mailComposer modifier to any SwiftUI view. +extension View { + /// Presents a mail composer when a binding to a Boolean value becomes `true`. + /// + /// Use this modifier to present an email composition interface with optional + /// recipients, subject, message body, and attachments (such as log files). + /// + /// # Example + /// ```swift + /// struct ContentView: View { + /// @State private var showMailComposer = false + /// + /// var body: some View { + /// Button("Report Problem") { + /// showMailComposer = true + /// } + /// .mailComposer( + /// isPresented: $showMailComposer, + /// recipient: "support@yourapp.com", + /// subject: "App Feedback", + /// messageBody: "I encountered an issue while using the app:", + /// attachments: [try? ErrorKit.logAttachment(ofLast: .minutes(10))].compactMap { $0 } + /// ) + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the mail composer. + /// - recipient: The email address to include in the "To" field. + /// - subject: The subject line of the email. + /// - messageBody: The content of the email message. + /// - attachments: An array of attachments to include with the email. + /// - Returns: A view that presents a mail composer when `isPresented` is `true`. + public func mailComposer( + isPresented: Binding, + recipient: String, + subject: String? = nil, + messageBody: String? = nil, + attachments: [MailAttachment]? = nil + ) -> some View { + self.modifier( + MailComposerModifier( + isPresented: isPresented, + recipient: recipient, + subject: subject, + messageBody: messageBody, + attachments: attachments + ) + ) + } +} +#endif diff --git a/Sources/ErrorKit/Logging/MailComposerView.swift b/Sources/ErrorKit/Logging/MailComposerView.swift new file mode 100644 index 0000000..e796f36 --- /dev/null +++ b/Sources/ErrorKit/Logging/MailComposerView.swift @@ -0,0 +1,73 @@ +#if canImport(MessageUI) +import SwiftUI +import MessageUI + +/// A SwiftUI component that wraps UIKit's MFMailComposeViewController to provide email composition functionality in SwiftUI applications. +struct MailComposerView: UIViewControllerRepresentable { + /// Checks if the device is capable of sending emails + /// - Returns: Boolean indicating whether email composition is available + static func canSendMail() -> Bool { + MFMailComposeViewController.canSendMail() + } + + @Binding var isPresented: Bool + + var recipients: [String]? + var subject: String? + var messageBody: String? + var attachments: [MailAttachment]? + + func makeUIViewController(context: Context) -> MFMailComposeViewController { + let composer = MFMailComposeViewController() + composer.mailComposeDelegate = context.coordinator + + if let recipients { + composer.setToRecipients(recipients) + } + + if let subject { + composer.setSubject(subject) + } + + if let messageBody { + composer.setMessageBody(messageBody, isHTML: false) + } + + if let attachments { + for attachment in attachments { + composer.addAttachmentData( + attachment.data, + mimeType: attachment.mimeType, + fileName: attachment.filename + ) + } + } + + return composer + } + + func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, @preconcurrency MFMailComposeViewControllerDelegate { + var parent: MailComposerView + + init(_ parent: MailComposerView) { + self.parent = parent + } + + @MainActor + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: Error? + ) { + self.parent.isPresented = false + controller.dismiss(animated: true) + } + } +} +#endif diff --git a/Sources/ErrorKit/Logging/MailView.swift b/Sources/ErrorKit/Logging/MailView.swift deleted file mode 100644 index 4fb2585..0000000 --- a/Sources/ErrorKit/Logging/MailView.swift +++ /dev/null @@ -1,150 +0,0 @@ -import SwiftUI -import MessageUI - -/// A SwiftUI component that wraps UIKit's MFMailComposeViewController to provide email -/// composition functionality in SwiftUI applications. -/// -/// # Example -/// -/// ```swift -/// struct ContentView: View { -/// @State private var showingMail = false -/// @State private var mailResult: Result? = nil -/// -/// var body: some View { -/// Button("Contact Support") { -/// if MailView.canSendMail() { -/// showingMail = true -/// } -/// } -/// .sheet(isPresented: $showingMail) { -/// MailView( -/// isShowing: $showingMail, -/// result: $mailResult, -/// recipients: ["support@example.com"], -/// subject: "App Feedback", -/// messageBody: "I'm enjoying the app, but found an issue:", -/// attachments: [ -/// MailView.Attachment( -/// data: UIImage(named: "screenshot")!.pngData()!, -/// mimeType: "image/png", -/// filename: "screenshot.png" -/// ) -/// ] -/// ) -/// } -/// } -/// } -/// ``` -public struct MailView: UIViewControllerRepresentable { - /// Represents an email attachment with data, mime type, and filename - public struct Attachment { - let data: Data - let mimeType: String - let filename: String - - /// Creates a new email attachment - /// - Parameters: - /// - data: The content of the attachment as Data - /// - mimeType: The MIME type of the attachment (e.g., "image/jpeg", "application/pdf") - /// - filename: The filename for the attachment when received by the recipient - public init(data: Data, mimeType: String, filename: String) { - self.data = data - self.mimeType = mimeType - self.filename = filename - } - } - - /// Checks if the device is capable of sending emails - /// - Returns: Boolean indicating whether email composition is available - public static func canSendMail() -> Bool { - MFMailComposeViewController.canSendMail() - } - - @Binding private var isShowing: Bool - - private var recipients: [String]? - private var subject: String? - private var messageBody: String? - private var isHTML: Bool - private var attachments: [Attachment]? - - /// Creates a new mail view with the specified parameters - /// - Parameters: - /// - isShowing: Binding to control the presentation state - /// - result: Binding to capture the result of the mail composition - /// - recipients: Optional array of recipient email addresses - /// - subject: Optional subject line - /// - messageBody: Optional body text - /// - isHTML: Whether the message body contains HTML (defaults to false) - /// - attachments: Optional array of attachments - public init( - isShowing: Binding, - result: Binding?>, - recipients: [String]? = nil, - subject: String? = nil, - messageBody: String? = nil, - isHTML: Bool = false, - attachments: [Attachment]? = nil - ) { - self._isShowing = isShowing - self.recipients = recipients - self.subject = subject - self.messageBody = messageBody - self.isHTML = isHTML - self.attachments = attachments - } - - public func makeUIViewController(context: Context) -> MFMailComposeViewController { - let composer = MFMailComposeViewController() - composer.mailComposeDelegate = context.coordinator - - if let recipients = recipients { - composer.setToRecipients(recipients) - } - - if let subject = subject { - composer.setSubject(subject) - } - - if let messageBody = messageBody { - composer.setMessageBody(messageBody, isHTML: isHTML) - } - - if let attachments = attachments { - for attachment in attachments { - composer.addAttachmentData( - attachment.data, - mimeType: attachment.mimeType, - fileName: attachment.filename - ) - } - } - - return composer - } - - public func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {} - - public func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - public class Coordinator: NSObject, @preconcurrency MFMailComposeViewControllerDelegate { - var parent: MailView - - init(_ parent: MailView) { - self.parent = parent - } - - @MainActor - public func mailComposeController( - _ controller: MFMailComposeViewController, - didFinishWith result: MFMailComposeResult, - error: Error? - ) { - parent.isShowing = false - controller.dismiss(animated: true) - } - } -} diff --git a/Sources/ErrorKit/Resources/Localizable.xcstrings b/Sources/ErrorKit/Resources/Localizable.xcstrings index a8aea51..c4535ae 100644 --- a/Sources/ErrorKit/Resources/Localizable.xcstrings +++ b/Sources/ErrorKit/Resources/Localizable.xcstrings @@ -12751,6 +12751,760 @@ } } }, + "Logging.MailComposer.dismissButton" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إغلاق" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Затвори" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descartar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zakládání" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afvis" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Απόρριψη" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss" + } + }, + "en-AU" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descartar" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descartar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hylkää" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disposer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בעיה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "छोड़ें" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zatvori" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eltávolítás" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutup" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + }, + "kk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жабу" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "닫기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutup" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anuluj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispensar" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descartar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Închide" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zavrieť" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปิด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрити" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ qua" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉" + } + } + } + }, + "Logging.MailComposer.notAvailableMessage" : { + "comment" : "%@ is typically replaced by the email address of the support contact, e.g. 'support@example.com' – so this would read like '... contact support at: support@example.com'", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جهازك غير مكون لإرسال الرسائل الإلكترونية. يرجى إعداد تطبيق البريد أو استخدام طريقة أخرى للتواصل مع الدعم على: %@" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вашето устройство не е конфигурирано да изпраща имейли. Моля, настройте приложението Mail или използвайте друг метод, за да се свържете с поддръжката на: %@" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "El teu dispositiu no està configurat per enviar correus electrònics. Si us plau, configura l'aplicació Mail o utilitza un altre mètode per contactar amb el suport a: %@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vaše zařízení není nakonfigurováno pro odesílání emailů. Prosím, nastavte aplikaci Mail nebo použijte jinou metodu k tomu, abyste kontaktovali podporu na: %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din enhed er ikke konfigureret til at sende e-mails. Venligst opsæt Mail-appen eller brug en anden metode til at kontakte support på: %@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Gerät ist nicht für den Versand von E-Mails konfiguriert. Bitte richte die Mail-App ein oder nutze eine andere Methode, um den Support zu kontaktieren unter: %@" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Η συσκευή σας δεν είναι ρυθμισμένη για να αποστέλλει email. Παρακαλώ ρυθμίστε την εφαρμογή Mail ή χρησιμοποιήστε άλλη μέθοδο για να επικοινωνήσετε με την υποστήριξη στο: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your device is not configured to send emails. Please set up the Mail app or use another method to contact support at: %@" + } + }, + "en-AU" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your device is not configured to send emails. Please set up the Mail app or use another method to contact support at: %@" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your device is not configured to send emails. Please set up the Mail app or use another method to contact support at: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu dispositivo no está configurado para enviar correos electrónicos. Por favor, configura la app de Mail o utiliza otro método para contactar con el soporte en: %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu dispositivo no está configurado para enviar correos electrónicos. Por favor, configura la aplicación de correo o usa otro método para contactar a soporte en: %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laiteasi ei ole konfiguroitu lähettämään sähköposteja. Aseta Mail-sovellus tai käytä muuta tapaa ottaaksesi yhteyttä tukeen: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre appareil n'est pas configuré pour envoyer des e-mails. Veuillez configurer l'application Mail ou utiliser une autre méthode pour contacter le support à l'adresse : %@." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre appareil n'est pas configuré pour envoyer des courriels. Veuillez configurer l'application Mail ou utiliser une autre méthode pour contacter le support à : %@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המכשיר שלך לא מוגדר לשלוח מיילים. בבקשה הגדר את אפליקציית המייל או השתמש בדרך אחרת ליצור קשר עם התמיכה ב: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आपका डिवाइस ईमेल भेजने के लिए कॉन्फ़िगर नहीं है। कृपया मेल ऐप सेट करें या सहायता से संपर्क करने के लिए किसी अन्य विधि का उपयोग करें: %@" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vaš uređaj nije konfiguriran za slanje e-pošte. Molimo postavite aplikaciju Mail ili upotrijebite drugi način za kontaktiranje podrške na: %@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A készüléked nincs beállítva e-mailek küldésére. Kérlek, állítsd be a Mail alkalmazást, vagy használj más módszert az ügyfélszolgálat elérhetőségéhez: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perangkat Anda tidak dikonfigurasi untuk mengirim email. Silakan atur aplikasi Mail atau gunakan metode lain untuk menghubungi dukungan di: %@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il tuo dispositivo non è configurato per inviare email. Configura l'app Mail o utilizza un altro metodo per contattare il supporto a: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お使いのデバイスはメールを送信するように設定されていません。メールアプリを設定するか、他の方法でサポートに連絡してください: %@" + } + }, + "kk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сіздің құрылғыңыз электронды пошта жіберуге конфигурацияланбаған. Пошта қолданбасын орнатыңыз немесе техникалық қолдау көрсету үшін басқа әдісті пайдаланыңыз: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 장치는 이메일을 보내도록 설정되어 있지 않습니다. 메일 앱을 설정하시거나 다음 방법을 사용하여 지원 팀에 연락해 주십시오: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peranti anda tidak dikonfigurasi untuk menghantar emel. Sila tetapkan aplikasi Mail atau gunakan kaedah lain untuk menghubungi sokongan di: %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enheten din er ikke konfigurert for å sende e-poster. Vennligst sett opp Mail-appen eller bruk en annen metode for å kontakte support på: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je apparaat is niet geconfigureerd om e-mails te verzenden. Stel de Mail-app in of gebruik een andere methode om ondersteuning te contacteren op: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoje urządzenie nie jest skonfigurowane do wysyłania e-maili. Ustaw aplikację Mail lub skorzystaj z innej metody, aby skontaktować się z pomocą pod adresem: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seu dispositivo não está configurado para enviar e-mails. Por favor, configure o aplicativo de e-mail ou use outro método para entrar em contato com o suporte em: %@" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O seu dispositivo não está configurado para enviar emails. Por favor, configure a aplicação Mail ou utilize outro método para contactar o suporte em: %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispozitivul tău nu este configurat pentru a trimite e-mailuri. Te rugăm să configurezi aplicația Mail sau să folosești o altă metodă pentru a contacta suportul la: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "На твоём устройстве не настроена отправка писем. Пожалуйста, настрой приложение Mail или используй другой способ, чтобы связаться со службой поддержки по адресу: %@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tvoje zariadenie nie je nastavené na posielanie e-mailov. Prosím, nastav aplikáciu Mail alebo použij iný spôsob, ako kontaktovať podporu na: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din enhet är inte konfigurerad för att skicka mejl. Vänligen ställ in Mail-appen eller använd en annan metod för att kontakta support på: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อุปกรณ์ของคุณไม่สามารถส่งอีเมลได้ กรุณาตั้งค่าแอป Mail หรือลองใช้วิธีอื่นในการติดต่อสนับสนุนที่: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cihazınız e-posta göndermek üzere ayarlanmamış. Lütfen Mail uygulamasını ayarlayın veya destekle iletişim kurmak için başka bir yöntem kullanın: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш пристрій не налаштований для відправки електронних листів. Будь ласка, налаштуйте програму Пошта або скористайтеся іншим способом, щоб зв’язатися з підтримкою за адресою: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thiết bị của bạn chưa được cấu hình để gửi email. Vui lòng thiết lập ứng dụng Mail hoặc sử dụng phương thức khác để liên hệ với bộ phận hỗ trợ tại: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您的设备未配置为发送电子邮件。请设置邮件应用或使用其他方法联系支持:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "您的設備未設定為發送電子郵件。請設置郵件應用程式或使用其他方法聯繫支持:%@." + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的設備尚未配置以發送電子郵件。請設置郵件應用程式或使用其他方法聯絡支援:%@" + } + } + } + }, + "Logging.MailComposer.notAvailableTitle" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "البريد غير متوفر" + } + }, + "bg" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имейлът не е наличен" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correu No Disponible" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Email není k dispozici" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail ikke tilgængelig" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Nicht Verfügbar" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Η Mail δεν είναι διαθέσιμη" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Not Available" + } + }, + "en-AU" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Not Available" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Not Available" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correo no disponible" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correo No Disponible" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posti ei saatavilla" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail non disponible" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Courrier non disponible" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אימייל לא זמין" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेल उपलब्ध नहीं है" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-pošta nije dostupna" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Mail nem elérhető" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Tidak Tersedia" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Non Disponibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メールが使用できません" + } + }, + "kk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пошта қолжетімсіз" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메일 사용 불가능" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emel Tidak Tersedia" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-post ikke tilgjengelig" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Niet Beschikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail niedostępny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-mail Não Disponível" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Não Disponível" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail indisponibil" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Почта недоступна" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail nie je k dispozícii" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-post inte tillgänglig" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่สามารถใช้งาน Mail ได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Mevcut Değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пошта недоступна" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail Không Khả Dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "邮件不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "郵件不可用" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "郵件不可用" + } + } + } + }, "TypedOverloads.FileManager.corruptFile" : { "localizations" : { "ar" : { From a3766db25518b6540962731220fbc0be02289b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Sat, 8 Mar 2025 12:24:42 +0100 Subject: [PATCH 4/6] Get full Linux (& potentially other platform) compatibility --- Package.resolved | 24 ++ Package.swift | 11 + .../BuiltInErrors/DatabaseError.swift | 28 +- .../ErrorKit/BuiltInErrors/FileError.swift | 21 +- .../ErrorKit/BuiltInErrors/NetworkError.swift | 41 ++- .../BuiltInErrors/OperationError.swift | 14 +- .../ErrorKit/BuiltInErrors/ParsingError.swift | 14 +- .../BuiltInErrors/PermissionError.swift | 21 +- .../ErrorKit/BuiltInErrors/StateError.swift | 21 +- .../BuiltInErrors/ValidationError.swift | 21 +- .../ErrorKit+CoreData.swift | 49 ++-- .../ErrorKit+Foundation.swift | 91 +++--- .../ErrorKit+MapKit.swift | 42 ++- Sources/ErrorKit/ErrorKit.swift | 11 +- .../ErrorKit/Helpers/String+ErrorKit.swift | 19 ++ Sources/ErrorKit/Logging/ErrorKit+OSLog.swift | 2 + .../Logging/MailComposerModifier.swift | 6 +- .../ErrorKit/Logging/MailComposerView.swift | 6 +- .../ErrorKit/Resources/Localizable.xcstrings | 273 +++++++++++++++++- .../TypedOverloads/FileManager+ErrorKit.swift | 91 +++--- .../TypedOverloads/URLSession+ErrorKit.swift | 136 ++++----- Tests/ErrorKitTests/ErrorKitTests.swift | 6 + 22 files changed, 605 insertions(+), 343 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/ErrorKit/Helpers/String+ErrorKit.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0ccfe05 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "18d64f2cd87195dfdf874f23785fb72b623e5d64dba4fcb971873c4679c8924b", + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "ae33e5941bb88d88538d0a6b19ca0b01e6c76dcf", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "45305d32cfb830faebcaa9a7aea66ad342637518", + "version" : "3.11.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 96e2857..c50708f 100644 --- a/Package.swift +++ b/Package.swift @@ -6,9 +6,20 @@ let package = Package( defaultLocalization: "en", platforms: [.macOS(.v13), .iOS(.v16), .tvOS(.v16), .watchOS(.v9), .macCatalyst(.v16)], products: [.library(name: "ErrorKit", targets: ["ErrorKit"])], + dependencies: [ + // CryptoKit is not available on Linux, so we need Swift Crypto + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.11.0"), + ], targets: [ .target( name: "ErrorKit", + dependencies: [ + .product( + name: "Crypto", + package: "swift-crypto", + condition: .when(platforms: [.android, .linux, .openbsd, .wasi, .windows]) + ), + ], resources: [.process("Resources/Localizable.xcstrings")] ), .testTarget(name: "ErrorKitTests", dependencies: ["ErrorKit"]), diff --git a/Sources/ErrorKit/BuiltInErrors/DatabaseError.swift b/Sources/ErrorKit/BuiltInErrors/DatabaseError.swift index fd6341b..af1aa8c 100644 --- a/Sources/ErrorKit/BuiltInErrors/DatabaseError.swift +++ b/Sources/ErrorKit/BuiltInErrors/DatabaseError.swift @@ -136,29 +136,25 @@ public enum DatabaseError: Throwable, Catching { public var userFriendlyMessage: String { switch self { case .connectionFailed: - return String( - localized: "BuiltInErrors.DatabaseError.connectionFailed", - defaultValue: "Unable to establish a connection to the database. Check your network settings and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.DatabaseError.connectionFailed", + defaultValue: "Unable to establish a connection to the database. Check your network settings and try again." ) case .operationFailed(let context): - return String( - localized: "BuiltInErrors.DatabaseError.operationFailed", - defaultValue: "The database operation for \(context) could not be completed. Please retry the action.", - bundle: .module + return String.localized( + key: "BuiltInErrors.DatabaseError.operationFailed", + defaultValue: "The database operation for \(context) could not be completed. Please retry the action." ) case .recordNotFound(let entity, let identifier): if let identifier { - return String( - localized: "BuiltInErrors.DatabaseError.recordNotFoundWithID", - defaultValue: "The \(entity) record with ID \(identifier) was not found in the database. Verify the details and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.DatabaseError.recordNotFoundWithID", + defaultValue: "The \(entity) record with ID \(identifier) was not found in the database. Verify the details and try again." ) } else { - return String( - localized: "BuiltInErrors.DatabaseError.recordNotFound", - defaultValue: "The \(entity) record was not found in the database. Verify the details and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.DatabaseError.recordNotFound", + defaultValue: "The \(entity) record was not found in the database. Verify the details and try again." ) } case .generic(let userFriendlyMessage): diff --git a/Sources/ErrorKit/BuiltInErrors/FileError.swift b/Sources/ErrorKit/BuiltInErrors/FileError.swift index 35c4b90..162228c 100644 --- a/Sources/ErrorKit/BuiltInErrors/FileError.swift +++ b/Sources/ErrorKit/BuiltInErrors/FileError.swift @@ -128,22 +128,19 @@ public enum FileError: Throwable, Catching { public var userFriendlyMessage: String { switch self { case .fileNotFound(let fileName): - return String( - localized: "BuiltInErrors.FileError.fileNotFound", - defaultValue: "The file \(fileName) could not be located. Please verify the file path and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.FileError.fileNotFound", + defaultValue: "The file \(fileName) could not be located. Please verify the file path and try again." ) case .readFailed(let fileName): - return String( - localized: "BuiltInErrors.FileError.readError", - defaultValue: "An error occurred while attempting to read the file \(fileName). Please check file permissions and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.FileError.readError", + defaultValue: "An error occurred while attempting to read the file \(fileName). Please check file permissions and try again." ) case .writeFailed(let fileName): - return String( - localized: "BuiltInErrors.FileError.writeError", - defaultValue: "Unable to write to the file \(fileName). Ensure you have the necessary permissions and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.FileError.writeError", + defaultValue: "Unable to write to the file \(fileName). Ensure you have the necessary permissions and try again." ) case .generic(let userFriendlyMessage): return userFriendlyMessage diff --git a/Sources/ErrorKit/BuiltInErrors/NetworkError.swift b/Sources/ErrorKit/BuiltInErrors/NetworkError.swift index 2caf238..0a67c02 100644 --- a/Sources/ErrorKit/BuiltInErrors/NetworkError.swift +++ b/Sources/ErrorKit/BuiltInErrors/NetworkError.swift @@ -178,39 +178,38 @@ public enum NetworkError: Throwable, Catching { public var userFriendlyMessage: String { switch self { case .noInternet: - return String( - localized: "BuiltInErrors.NetworkError.noInternet", - defaultValue: "Unable to connect to the internet. Please check your network settings and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.NetworkError.noInternet", + defaultValue: "Unable to connect to the internet. Please check your network settings and try again." ) case .timeout: - return String( - localized: "BuiltInErrors.NetworkError.timeout", - defaultValue: "The network request took too long to complete. Please check your connection and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.NetworkError.timeout", + defaultValue: "The network request took too long to complete. Please check your connection and try again." ) case .badRequest(let code, let message): - return String( - localized: "BuiltInErrors.NetworkError.badRequest", - defaultValue: "There was an issue with the request (Code: \(code)). \(message). Please review and retry.", - bundle: .module + return String.localized( + key: "BuiltInErrors.NetworkError.badRequest", + defaultValue: "There was an issue with the request (Code: \(code)). \(message). Please review and retry." ) case .serverError(let code, let message): - let defaultMessage = String( - localized: "BuiltInErrors.NetworkError.serverError", - defaultValue: "The server encountered an error (Code: \(code)). ", - bundle: .module + let defaultMessage = String.localized( + key: "BuiltInErrors.NetworkError.serverError", + defaultValue: "The server encountered an error (Code: \(code)). " ) + if let message = message { return defaultMessage + message } else { - return defaultMessage + "Please try again later." + return defaultMessage + String.localized( + key: "Common.Message.tryAgainLater", + defaultValue: "Please try again later." + ) } case .decodingFailure: - return String( - localized: "BuiltInErrors.NetworkError.decodingFailure", - defaultValue: "Unable to process the server's response. Please try again or contact support if the issue persists.", - bundle: .module + return String.localized( + key: "BuiltInErrors.NetworkError.decodingFailure", + defaultValue: "Unable to process the server's response. Please try again or contact support if the issue persists." ) case .generic(let userFriendlyMessage): return userFriendlyMessage diff --git a/Sources/ErrorKit/BuiltInErrors/OperationError.swift b/Sources/ErrorKit/BuiltInErrors/OperationError.swift index 890d354..daa30a2 100644 --- a/Sources/ErrorKit/BuiltInErrors/OperationError.swift +++ b/Sources/ErrorKit/BuiltInErrors/OperationError.swift @@ -108,16 +108,14 @@ public enum OperationError: Throwable, Catching { public var userFriendlyMessage: String { switch self { case .dependencyFailed(let dependency): - return String( - localized: "BuiltInErrors.OperationError.dependencyFailed", - defaultValue: "The operation could not be started because a required component failed to initialize: \(dependency). Please restart the application or contact support.", - bundle: .module + return String.localized( + key: "BuiltInErrors.OperationError.dependencyFailed", + defaultValue: "The operation could not be started because a required component failed to initialize: \(dependency). Please restart the application or contact support." ) case .canceled: - return String( - localized: "BuiltInErrors.OperationError.canceled", - defaultValue: "The operation was canceled at your request. You can retry the action if needed.", - bundle: .module + return String.localized( + key: "BuiltInErrors.OperationError.canceled", + defaultValue: "The operation was canceled at your request. You can retry the action if needed." ) case .generic(let userFriendlyMessage): return userFriendlyMessage diff --git a/Sources/ErrorKit/BuiltInErrors/ParsingError.swift b/Sources/ErrorKit/BuiltInErrors/ParsingError.swift index e97c7bc..53436b5 100644 --- a/Sources/ErrorKit/BuiltInErrors/ParsingError.swift +++ b/Sources/ErrorKit/BuiltInErrors/ParsingError.swift @@ -108,16 +108,14 @@ public enum ParsingError: Throwable, Catching { public var userFriendlyMessage: String { switch self { case .invalidInput(let input): - return String( - localized: "BuiltInErrors.ParsingError.invalidInput", - defaultValue: "The provided input could not be processed correctly: \(input). Please review the input and ensure it matches the expected format.", - bundle: .module + return String.localized( + key: "BuiltInErrors.ParsingError.invalidInput", + defaultValue: "The provided input could not be processed correctly: \(input). Please review the input and ensure it matches the expected format." ) case .missingField(let field): - return String( - localized: "BuiltInErrors.ParsingError.missingField", - defaultValue: "The required information is incomplete. The \(field) field is missing and must be provided to continue.", - bundle: .module + return String.localized( + key: "BuiltInErrors.ParsingError.missingField", + defaultValue: "The required information is incomplete. The \(field) field is missing and must be provided to continue." ) case .generic(let userFriendlyMessage): return userFriendlyMessage diff --git a/Sources/ErrorKit/BuiltInErrors/PermissionError.swift b/Sources/ErrorKit/BuiltInErrors/PermissionError.swift index 980bfa9..bb9319f 100644 --- a/Sources/ErrorKit/BuiltInErrors/PermissionError.swift +++ b/Sources/ErrorKit/BuiltInErrors/PermissionError.swift @@ -129,22 +129,19 @@ public enum PermissionError: Throwable, Catching { public var userFriendlyMessage: String { switch self { case .denied(let permission): - return String( - localized: "BuiltInErrors.PermissionError.denied", - defaultValue: "Access to \(permission) was declined. To use this feature, please enable the permission in your device Settings.", - bundle: .module + return String.localized( + key: "BuiltInErrors.PermissionError.denied", + defaultValue: "Access to \(permission) was declined. To use this feature, please enable the permission in your device Settings." ) case .restricted(let permission): - return String( - localized: "BuiltInErrors.PermissionError.restricted", - defaultValue: "Access to \(permission) is currently restricted. This may be due to system settings or parental controls.", - bundle: .module + return String.localized( + key: "BuiltInErrors.PermissionError.restricted", + defaultValue: "Access to \(permission) is currently restricted. This may be due to system settings or parental controls." ) case .notDetermined(let permission): - return String( - localized: "BuiltInErrors.PermissionError.notDetermined", - defaultValue: "Permission for \(permission) has not been confirmed. Please review and grant access in your device Settings.", - bundle: .module + return String.localized( + key: "BuiltInErrors.PermissionError.notDetermined", + defaultValue: "Permission for \(permission) has not been confirmed. Please review and grant access in your device Settings." ) case .generic(let userFriendlyMessage): return userFriendlyMessage diff --git a/Sources/ErrorKit/BuiltInErrors/StateError.swift b/Sources/ErrorKit/BuiltInErrors/StateError.swift index 410ac30..a69a04b 100644 --- a/Sources/ErrorKit/BuiltInErrors/StateError.swift +++ b/Sources/ErrorKit/BuiltInErrors/StateError.swift @@ -122,22 +122,19 @@ public enum StateError: Throwable, Catching { public var userFriendlyMessage: String { switch self { case .invalidState(let description): - return String( - localized: "BuiltInErrors.StateError.invalidState", - defaultValue: "The current state prevents this action: \(description). Please ensure all requirements are met and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.StateError.invalidState", + defaultValue: "The current state prevents this action: \(description). Please ensure all requirements are met and try again." ) case .alreadyFinalized: - return String( - localized: "BuiltInErrors.StateError.alreadyFinalized", - defaultValue: "This item has already been finalized and cannot be modified. Please create a new version if changes are needed.", - bundle: .module + return String.localized( + key: "BuiltInErrors.StateError.alreadyFinalized", + defaultValue: "This item has already been finalized and cannot be modified. Please create a new version if changes are needed." ) case .preconditionFailed(let description): - return String( - localized: "BuiltInErrors.StateError.preconditionFailed", - defaultValue: "A required condition was not met: \(description). Please complete all prerequisites before proceeding.", - bundle: .module + return String.localized( + key: "BuiltInErrors.StateError.preconditionFailed", + defaultValue: "A required condition was not met: \(description). Please complete all prerequisites before proceeding." ) case .generic(let userFriendlyMessage): return userFriendlyMessage diff --git a/Sources/ErrorKit/BuiltInErrors/ValidationError.swift b/Sources/ErrorKit/BuiltInErrors/ValidationError.swift index c4ef2bc..bd8e89f 100644 --- a/Sources/ErrorKit/BuiltInErrors/ValidationError.swift +++ b/Sources/ErrorKit/BuiltInErrors/ValidationError.swift @@ -136,22 +136,19 @@ public enum ValidationError: Throwable, Catching { public var userFriendlyMessage: String { switch self { case .invalidInput(let field): - return String( - localized: "BuiltInErrors.ValidationError.invalidInput", - defaultValue: "The value entered for \(field) is not in the correct format. Please review the requirements and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.ValidationError.invalidInput", + defaultValue: "The value entered for \(field) is not in the correct format. Please review the requirements and try again." ) case .missingField(let field): - return String( - localized: "BuiltInErrors.ValidationError.missingField", - defaultValue: "Please provide a value for \(field). This information is required to proceed.", - bundle: .module + return String.localized( + key: "BuiltInErrors.ValidationError.missingField", + defaultValue: "Please provide a value for \(field). This information is required to proceed." ) case .inputTooLong(let field, let maxLength): - return String( - localized: "BuiltInErrors.ValidationError.inputTooLong", - defaultValue: "The \(field) field cannot be longer than \(maxLength) characters. Please shorten your input and try again.", - bundle: .module + return String.localized( + key: "BuiltInErrors.ValidationError.inputTooLong", + defaultValue: "The \(field) field cannot be longer than \(maxLength) characters. Please shorten your input and try again." ) case .generic(let userFriendlyMessage): return userFriendlyMessage diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift index b0691cc..111d913 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift @@ -11,46 +11,39 @@ extension ErrorKit { switch nsError.code { case NSPersistentStoreSaveError: - return String( - localized: "EnhancedDescriptions.CoreData.NSPersistentStoreSaveError", - defaultValue: "Failed to save the data. Please try again.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CoreData.NSPersistentStoreSaveError", + defaultValue: "Failed to save the data. Please try again." ) case NSValidationMultipleErrorsError: - return String( - localized: "EnhancedDescriptions.CoreData.NSValidationMultipleErrorsError", - defaultValue: "Multiple validation errors occurred while saving.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CoreData.NSValidationMultipleErrorsError", + defaultValue: "Multiple validation errors occurred while saving." ) case NSValidationMissingMandatoryPropertyError: - return String( - localized: "EnhancedDescriptions.CoreData.NSValidationMissingMandatoryPropertyError", - defaultValue: "A mandatory property is missing. Please fill all required fields.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CoreData.NSValidationMissingMandatoryPropertyError", + defaultValue: "A mandatory property is missing. Please fill all required fields." ) case NSValidationRelationshipLacksMinimumCountError: - return String( - localized: "EnhancedDescriptions.CoreData.NSValidationRelationshipLacksMinimumCountError", - defaultValue: "A relationship is missing required related objects.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CoreData.NSValidationRelationshipLacksMinimumCountError", + defaultValue: "A relationship is missing required related objects." ) case NSPersistentStoreIncompatibleVersionHashError: - return String( - localized: "EnhancedDescriptions.CoreData.NSPersistentStoreIncompatibleVersionHashError", - defaultValue: "The data store is incompatible with the current model version.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CoreData.NSPersistentStoreIncompatibleVersionHashError", + defaultValue: "The data store is incompatible with the current model version." ) case NSPersistentStoreOpenError: - return String( - localized: "EnhancedDescriptions.CoreData.NSPersistentStoreOpenError", - defaultValue: "Unable to open the persistent store. Please check your storage or permissions.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CoreData.NSPersistentStoreOpenError", + defaultValue: "Unable to open the persistent store. Please check your storage or permissions." ) case NSManagedObjectValidationError: - return String( - localized: "EnhancedDescriptions.CoreData.NSManagedObjectValidationError", - defaultValue: "An object validation error occurred.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CoreData.NSManagedObjectValidationError", + defaultValue: "An object validation error occurred." ) default: return nil diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift index 10bc893..28692a0 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift @@ -11,34 +11,29 @@ extension ErrorKit { case let urlError as URLError: switch urlError.code { case .notConnectedToInternet: - return String( - localized: "EnhancedDescriptions.URLError.notConnectedToInternet", - defaultValue: "You are not connected to the Internet. Please check your connection.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.URLError.notConnectedToInternet", + defaultValue: "You are not connected to the Internet. Please check your connection." ) case .timedOut: - return String( - localized: "EnhancedDescriptions.URLError.timedOut", - defaultValue: "The request timed out. Please try again later.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.URLError.timedOut", + defaultValue: "The request timed out. Please try again later." ) case .cannotFindHost: - return String( - localized: "EnhancedDescriptions.URLError.cannotFindHost", - defaultValue: "Unable to find the server. Please check the URL or your network.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.URLError.cannotFindHost", + defaultValue: "Unable to find the server. Please check the URL or your network." ) case .networkConnectionLost: - return String( - localized: "EnhancedDescriptions.URLError.networkConnectionLost", - defaultValue: "The network connection was lost. Please try again.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.URLError.networkConnectionLost", + defaultValue: "The network connection was lost. Please try again." ) default: - return String( - localized: "EnhancedDescriptions.URLError.default", - defaultValue: "A network error occurred: \(urlError.localizedDescription)", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.URLError.default", + defaultValue: "A network error occurred: \(urlError.localizedDescription)" ) } @@ -46,28 +41,24 @@ extension ErrorKit { case let cocoaError as CocoaError: switch cocoaError.code { case .fileNoSuchFile: - return String( - localized: "EnhancedDescriptions.CocoaError.fileNoSuchFile", - defaultValue: "The file could not be found.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CocoaError.fileNoSuchFile", + defaultValue: "The file could not be found." ) case .fileReadNoPermission: - return String( - localized: "EnhancedDescriptions.CocoaError.fileReadNoPermission", - defaultValue: "You do not have permission to read this file.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CocoaError.fileReadNoPermission", + defaultValue: "You do not have permission to read this file." ) case .fileWriteOutOfSpace: - return String( - localized: "EnhancedDescriptions.CocoaError.fileWriteOutOfSpace", - defaultValue: "There is not enough disk space to complete the operation.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CocoaError.fileWriteOutOfSpace", + defaultValue: "There is not enough disk space to complete the operation." ) default: - return String( - localized: "EnhancedDescriptions.CocoaError.default", - defaultValue: "A file system error occurred: \(cocoaError.localizedDescription)", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.CocoaError.default", + defaultValue: "A file system error occurred: \(cocoaError.localizedDescription)" ) } @@ -75,28 +66,24 @@ extension ErrorKit { case let posixError as POSIXError: switch posixError.code { case .ENOSPC: - return String( - localized: "EnhancedDescriptions.POSIXError.ENOSPC", - defaultValue: "There is no space left on the device.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.POSIXError.ENOSPC", + defaultValue: "There is no space left on the device." ) case .EACCES: - return String( - localized: "EnhancedDescriptions.POSIXError.EACCES", - defaultValue: "Permission denied. Please check your file permissions.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.POSIXError.EACCES", + defaultValue: "Permission denied. Please check your file permissions." ) case .EBADF: - return String( - localized: "EnhancedDescriptions.POSIXError.EBADF", - defaultValue: "Bad file descriptor. The file may be closed or invalid.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.POSIXError.EBADF", + defaultValue: "Bad file descriptor. The file may be closed or invalid." ) default: - return String( - localized: "EnhancedDescriptions.POSIXError.default", - defaultValue: "A system error occurred: \(posixError.localizedDescription)", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.POSIXError.default", + defaultValue: "A system error occurred: \(posixError.localizedDescription)" ) } diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift index 9439c7f..be067d1 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift @@ -8,40 +8,34 @@ extension ErrorKit { if let mkError = error as? MKError { switch mkError.code { case .unknown: - return String( - localized: "EnhancedDescriptions.MKError.unknown", - defaultValue: "An unknown error occurred in MapKit.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.MKError.unknown", + defaultValue: "An unknown error occurred in MapKit." ) case .serverFailure: - return String( - localized: "EnhancedDescriptions.MKError.serverFailure", - defaultValue: "The MapKit server returned an error. Please try again later.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.MKError.serverFailure", + defaultValue: "The MapKit server returned an error. Please try again later." ) case .loadingThrottled: - return String( - localized: "EnhancedDescriptions.MKError.loadingThrottled", - defaultValue: "Map loading is being throttled. Please wait a moment and try again.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.MKError.loadingThrottled", + defaultValue: "Map loading is being throttled. Please wait a moment and try again." ) case .placemarkNotFound: - return String( - localized: "EnhancedDescriptions.MKError.placemarkNotFound", - defaultValue: "The requested placemark could not be found. Please check the location details.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.MKError.placemarkNotFound", + defaultValue: "The requested placemark could not be found. Please check the location details." ) case .directionsNotFound: - return String( - localized: "EnhancedDescriptions.MKError.directionsNotFound", - defaultValue: "No directions could be found for the specified route.", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.MKError.directionsNotFound", + defaultValue: "No directions could be found for the specified route." ) default: - return String( - localized: "EnhancedDescriptions.MKError.default", - defaultValue: "A MapKit error occurred: \(mkError.localizedDescription)", - bundle: .module + return String.localized( + key: "EnhancedDescriptions.MKError.default", + defaultValue: "A MapKit error occurred: \(mkError.localizedDescription)" ) } } diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 99906c2..3f9b717 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(CryptoKit) import CryptoKit +#else +import Crypto +#endif public enum ErrorKit { /// Provides enhanced, user-friendly, localized error descriptions for a wide range of system errors. @@ -208,8 +212,13 @@ public enum ErrorKit { // Split at first occurrence of "(" or ":" to remove specific parameters and user-friendly messages let descriptionWithoutDetails = errorChainDescription.components(separatedBy: CharacterSet(charactersIn: "(:")).first! - let digest = SHA256.hash(data: Data(descriptionWithoutDetails.utf8)) + #if canImport(CryptoKit) + let digest = CryptoKit.SHA256.hash(data: Data(descriptionWithoutDetails.utf8)) + let fullHash = Data(digest).compactMap { String(format: "%02x", $0) }.joined() + #else + let digest = Crypto.SHA256.hash(data: Data(descriptionWithoutDetails.utf8)) let fullHash = digest.compactMap { String(format: "%02x", $0) }.joined() + #endif // Return first 6 characters for a shorter but still practically unique identifier return String(fullHash.prefix(6)) diff --git a/Sources/ErrorKit/Helpers/String+ErrorKit.swift b/Sources/ErrorKit/Helpers/String+ErrorKit.swift new file mode 100644 index 0000000..f0591b6 --- /dev/null +++ b/Sources/ErrorKit/Helpers/String+ErrorKit.swift @@ -0,0 +1,19 @@ +import Foundation + +extension String { + #if canImport(CryptoKit) + // On Apple platforms, use the modern localization API with Bundle.module + static func localized(key: StaticString, defaultValue: String.LocalizationValue) -> String { + return String( + localized: key, + defaultValue: defaultValue, + bundle: Bundle.module + ) + } + #else + // On non-Apple platforms, just return the default value (the English translation) + static func localized(key: StaticString, defaultValue: String) -> String { + return defaultValue + } + #endif +} diff --git a/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift b/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift index 16d2089..7bb4c08 100644 --- a/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift +++ b/Sources/ErrorKit/Logging/ErrorKit+OSLog.swift @@ -1,3 +1,4 @@ +#if canImport(OSLog) import Foundation import OSLog @@ -202,3 +203,4 @@ extension Duration { self.minutes(hours * 60) } } +#endif diff --git a/Sources/ErrorKit/Logging/MailComposerModifier.swift b/Sources/ErrorKit/Logging/MailComposerModifier.swift index aafe829..455d395 100644 --- a/Sources/ErrorKit/Logging/MailComposerModifier.swift +++ b/Sources/ErrorKit/Logging/MailComposerModifier.swift @@ -12,7 +12,7 @@ struct MailComposerModifier: ViewModifier { var recipient: String var subject: String? var messageBody: String? - var attachments: [MailAttachment]? + var attachments: [MailAttachment?] func body(content: Content) -> some View { content @@ -85,7 +85,7 @@ extension View { /// recipient: "support@yourapp.com", /// subject: "App Feedback", /// messageBody: "I encountered an issue while using the app:", - /// attachments: [try? ErrorKit.logAttachment(ofLast: .minutes(10))].compactMap { $0 } + /// attachments: [try? ErrorKit.logAttachment(ofLast: .minutes(10))] /// ) /// } /// } @@ -103,7 +103,7 @@ extension View { recipient: String, subject: String? = nil, messageBody: String? = nil, - attachments: [MailAttachment]? = nil + attachments: [MailAttachment?] = [] ) -> some View { self.modifier( MailComposerModifier( diff --git a/Sources/ErrorKit/Logging/MailComposerView.swift b/Sources/ErrorKit/Logging/MailComposerView.swift index e796f36..1217d4f 100644 --- a/Sources/ErrorKit/Logging/MailComposerView.swift +++ b/Sources/ErrorKit/Logging/MailComposerView.swift @@ -15,7 +15,7 @@ struct MailComposerView: UIViewControllerRepresentable { var recipients: [String]? var subject: String? var messageBody: String? - var attachments: [MailAttachment]? + var attachments: [MailAttachment?] func makeUIViewController(context: Context) -> MFMailComposeViewController { let composer = MFMailComposeViewController() @@ -33,8 +33,8 @@ struct MailComposerView: UIViewControllerRepresentable { composer.setMessageBody(messageBody, isHTML: false) } - if let attachments { - for attachment in attachments { + for attachment in attachments { + if let attachment { composer.addAttachmentData( attachment.data, mimeType: attachment.mimeType, diff --git a/Sources/ErrorKit/Resources/Localizable.xcstrings b/Sources/ErrorKit/Resources/Localizable.xcstrings index c4535ae..c9522f7 100644 --- a/Sources/ErrorKit/Resources/Localizable.xcstrings +++ b/Sources/ErrorKit/Resources/Localizable.xcstrings @@ -1,6 +1,54 @@ { "sourceLanguage" : "en", "strings" : { + "A file system error occurred: %@" : { + + }, + "A mandatory property is missing. Please fill all required fields." : { + + }, + "A MapKit error occurred: %@" : { + + }, + "A network error occurred: %@" : { + + }, + "A relationship is missing required related objects." : { + + }, + "A required condition was not met: %@. Please complete all prerequisites before proceeding." : { + + }, + "A system error occurred: %@" : { + + }, + "Access to %@ is currently restricted. This may be due to system settings or parental controls." : { + + }, + "Access to %@ was declined. To use this feature, please enable the permission in your device Settings." : { + + }, + "An error occurred while attempting to read the file %@. Please check file permissions and try again." : { + + }, + "An object validation error occurred." : { + + }, + "An unknown error occurred in MapKit." : { + + }, + "An unknown error occurred while reading the file." : { + + }, + "An unknown error occurred while writing the file." : { + + }, + "An unknown status code was received from the server: %lld" : { + + }, + "Bad file descriptor. The file may be closed or invalid." : { + + }, "BuiltInErrors.DatabaseError.connectionFailed" : { "localizations" : { "ar" : { @@ -6250,6 +6298,9 @@ } } } + }, + "Cannot find host. Please check your internet connection and try again." : { + }, "EnhancedDescriptions.CocoaError.default" : { "localizations" : { @@ -12750,9 +12801,11 @@ } } } + }, + "Failed to save the data. Please try again." : { + }, "Logging.MailComposer.dismissButton" : { - "extractionState" : "extracted_with_value", "localizations" : { "ar" : { "stringUnit" : { @@ -13004,7 +13057,6 @@ }, "Logging.MailComposer.notAvailableMessage" : { "comment" : "%@ is typically replaced by the email address of the support contact, e.g. 'support@example.com' – so this would read like '... contact support at: support@example.com'", - "extractionState" : "extracted_with_value", "localizations" : { "ar" : { "stringUnit" : { @@ -13255,7 +13307,6 @@ } }, "Logging.MailComposer.notAvailableTitle" : { - "extractionState" : "extracted_with_value", "localizations" : { "ar" : { "stringUnit" : { @@ -13504,6 +13555,186 @@ } } } + }, + "Map loading is being throttled. Please wait a moment and try again." : { + + }, + "Multiple validation errors occurred while saving." : { + + }, + "No directions could be found for the specified route." : { + + }, + "No network connection found. Please check your internet." : { + + }, + "Payment is required to access this resource (402)." : { + + }, + "Permission denied. Please check your file permissions." : { + + }, + "Permission for %@ has not been confirmed. Please review and grant access in your device Settings." : { + + }, + "Please provide a value for %@. This information is required to proceed." : { + + }, + "Please try again later." : { + + }, + "The %@ field cannot be longer than %lld characters. Please shorten your input and try again." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The %1$@ field cannot be longer than %2$lld characters. Please shorten your input and try again." + } + } + } + }, + "The %@ record was not found in the database. Verify the details and try again." : { + + }, + "The %@ record with ID %@ was not found in the database. Verify the details and try again." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The %1$@ record with ID %2$@ was not found in the database. Verify the details and try again." + } + } + } + }, + "The current state prevents this action: %@. Please ensure all requirements are met and try again." : { + + }, + "The data store is incompatible with the current model version." : { + + }, + "The database operation for %@ could not be completed. Please retry the action." : { + + }, + "The file %@ could not be located. Please verify the file path and try again." : { + + }, + "The file could not be found." : { + + }, + "The file is corrupted or in an unreadable format." : { + + }, + "The file is locked and cannot be modified." : { + + }, + "The file is too large to be processed." : { + + }, + "The file name is invalid and cannot be used." : { + + }, + "The file or directory already exists." : { + + }, + "The file's character encoding is not supported." : { + + }, + "The HTTP method is not allowed for this resource (405)." : { + + }, + "The MapKit server returned an error. Please try again later." : { + + }, + "The network connection was lost. Please try again." : { + + }, + "The network request took too long to complete. Please check your connection and try again." : { + + }, + "The operation could not be started because a required component failed to initialize: %@. Please restart the application or contact support." : { + + }, + "The operation was canceled at your request. You can retry the action if needed." : { + + }, + "The provided input could not be processed correctly: %@. Please review the input and ensure it matches the expected format." : { + + }, + "The request entity has an unsupported media type (415)." : { + + }, + "The request timed out (408). Please try again." : { + + }, + "The request timed out. Please try again later." : { + + }, + "The request timed out. Please try again." : { + + }, + "The request was cancelled. Please try again if this wasn't intended." : { + + }, + "The request was malformed (400). Please review and try again." : { + + }, + "The requested placemark could not be found. Please check the location details." : { + + }, + "The requested resource cannot produce an acceptable response (406)." : { + + }, + "The requested resource could not be found (404)." : { + + }, + "The required information is incomplete. The %@ field is missing and must be provided to continue." : { + + }, + "The server encountered an error (500). Please try again later." : { + + }, + "The server encountered an error (Code: %lld). " : { + + }, + "The specified file or directory could not be found." : { + + }, + "The storage volume is read-only and cannot be modified." : { + + }, + "The URL is malformed. Please check it and try again or report a bug." : { + + }, + "The value entered for %@ is not in the correct format. Please review the requirements and try again." : { + + }, + "There is no space left on the device." : { + + }, + "There is not enough disk space to complete the operation." : { + + }, + "There was a conflict with the request (409). Please review and try again." : { + + }, + "There was an issue with the request (Code: %lld). %@. Please review and retry." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There was an issue with the request (Code: %1$lld). %2$@. Please review and retry." + } + } + } + }, + "There was an SSL error. Please check the server's certificate." : { + + }, + "This item has already been finalized and cannot be modified. Please create a new version if changes are needed." : { + + }, + "Too many requests have been sent. Please wait and try again (429)." : { + }, "TypedOverloads.FileManager.corruptFile" : { "localizations" : { @@ -21504,6 +21735,42 @@ } } } + }, + "Unable to connect to the internet. Please check your network settings and try again." : { + + }, + "Unable to establish a connection to the database. Check your network settings and try again." : { + + }, + "Unable to find the server. Please check the URL or your network." : { + + }, + "Unable to open the persistent store. Please check your storage or permissions." : { + + }, + "Unable to process the server's response. Please try again or contact support if the issue persists." : { + + }, + "Unable to write to the file %@. Ensure you have the necessary permissions and try again." : { + + }, + "You are not authorized to access this resource (401)." : { + + }, + "You are not connected to the Internet. Please check your connection." : { + + }, + "You do not have permission to access this resource (403)." : { + + }, + "You do not have permission to read this file or directory." : { + + }, + "You do not have permission to read this file." : { + + }, + "You do not have permission to write to this file or directory." : { + } }, "version" : "1.0" diff --git a/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift b/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift index 9a4a931..cccd3e0 100644 --- a/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift +++ b/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift @@ -64,82 +64,69 @@ public enum FileManagerError: Throwable { public var userFriendlyMessage: String { switch self { case .fileNotFound: - String( - localized: "TypedOverloads.FileManager.fileNotFound", - defaultValue: "The specified file or directory could not be found.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.fileNotFound", + defaultValue: "The specified file or directory could not be found." ) case .noReadPermission: - String( - localized: "TypedOverloads.FileManager.noReadPermission", - defaultValue: "You do not have permission to read this file or directory.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.noReadPermission", + defaultValue: "You do not have permission to read this file or directory." ) case .noWritePermission: - String( - localized: "TypedOverloads.FileManager.noWritePermission", - defaultValue: "You do not have permission to write to this file or directory.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.noWritePermission", + defaultValue: "You do not have permission to write to this file or directory." ) case .outOfSpace: - String( - localized: "TypedOverloads.FileManager.outOfSpace", - defaultValue: "There is not enough disk space to complete the operation.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.outOfSpace", + defaultValue: "There is not enough disk space to complete the operation." ) case .invalidFileName: - String( - localized: "TypedOverloads.FileManager.invalidFileName", - defaultValue: "The file name is invalid and cannot be used.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.invalidFileName", + defaultValue: "The file name is invalid and cannot be used." ) case .corruptFile: - String( - localized: "TypedOverloads.FileManager.corruptFile", - defaultValue: "The file is corrupted or in an unreadable format.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.corruptFile", + defaultValue: "The file is corrupted or in an unreadable format." ) case .fileLocked: - String( - localized: "TypedOverloads.FileManager.fileLocked", - defaultValue: "The file is locked and cannot be modified.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.fileLocked", + defaultValue: "The file is locked and cannot be modified." ) case .readError: - String( - localized: "TypedOverloads.FileManager.readError", - defaultValue: "An unknown error occurred while reading the file.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.readError", + defaultValue: "An unknown error occurred while reading the file." ) case .writeError: - String( - localized: "TypedOverloads.FileManager.writeError", - defaultValue: "An unknown error occurred while writing the file.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.writeError", + defaultValue: "An unknown error occurred while writing the file." ) case .unsupportedEncoding: - String( - localized: "TypedOverloads.FileManager.unsupportedEncoding", - defaultValue: "The file's character encoding is not supported.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.unsupportedEncoding", + defaultValue: "The file's character encoding is not supported." ) case .fileTooLarge: - String( - localized: "TypedOverloads.FileManager.fileTooLarge", - defaultValue: "The file is too large to be processed.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.fileTooLarge", + defaultValue: "The file is too large to be processed." ) case .volumeReadOnly: - String( - localized: "TypedOverloads.FileManager.volumeReadOnly", - defaultValue: "The storage volume is read-only and cannot be modified.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.volumeReadOnly", + defaultValue: "The storage volume is read-only and cannot be modified." ) case .fileExists: - String( - localized: "TypedOverloads.FileManager.fileExists", - defaultValue: "The file or directory already exists.", - bundle: .module + String.localized( + key: "TypedOverloads.FileManager.fileExists", + defaultValue: "The file or directory already exists." ) case .other(let error): ErrorKit.userFriendlyMessage(for: error) diff --git a/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift b/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift index 8836ba0..171984b 100644 --- a/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift +++ b/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// An enumeration that represents various errors that can occur when performing network requests with `URLSession`. public enum URLSessionError: Throwable { @@ -68,120 +71,101 @@ public enum URLSessionError: Throwable { public var userFriendlyMessage: String { switch self { case .timeout: - return String( - localized: "TypedOverloads.URLSession.timeout", - defaultValue: "The request timed out. Please try again.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.timeout", + defaultValue: "The request timed out. Please try again." ) case .noNetwork: - return String( - localized: "TypedOverloads.URLSession.noNetwork", - defaultValue: "No network connection found. Please check your internet.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.noNetwork", + defaultValue: "No network connection found. Please check your internet." ) case .cannotFindHost: - return String( - localized: "TypedOverloads.URLSession.cannotFindHost", - defaultValue: "Cannot find host. Please check your internet connection and try again.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.cannotFindHost", + defaultValue: "Cannot find host. Please check your internet connection and try again." ) case .badURL: - return String( - localized: "TypedOverloads.URLSession.badURL", - defaultValue: "The URL is malformed. Please check it and try again or report a bug.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.badURL", + defaultValue: "The URL is malformed. Please check it and try again or report a bug." ) case .cancelled: - return String( - localized: "TypedOverloads.URLSession.cancelled", - defaultValue: "The request was cancelled. Please try again if this wasn't intended.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.cancelled", + defaultValue: "The request was cancelled. Please try again if this wasn't intended." ) case .sslError: - return String( - localized: "TypedOverloads.URLSession.sslError", - defaultValue: "There was an SSL error. Please check the server's certificate.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.sslError", + defaultValue: "There was an SSL error. Please check the server's certificate." ) case .networkError(let error): return ErrorKit.userFriendlyMessage(for: error) case .unauthorized: - return String( - localized: "TypedOverloads.URLSession.unauthorized", - defaultValue: "You are not authorized to access this resource (401).", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.unauthorized", + defaultValue: "You are not authorized to access this resource (401)." ) case .paymentRequired: - return String( - localized: "TypedOverloads.URLSession.paymentRequired", - defaultValue: "Payment is required to access this resource (402).", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.paymentRequired", + defaultValue: "Payment is required to access this resource (402)." ) case .forbidden: - return String( - localized: "TypedOverloads.URLSession.forbidden", - defaultValue: "You do not have permission to access this resource (403).", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.forbidden", + defaultValue: "You do not have permission to access this resource (403)." ) case .notFound: - return String( - localized: "TypedOverloads.URLSession.notFound", - defaultValue: "The requested resource could not be found (404).", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.notFound", + defaultValue: "The requested resource could not be found (404)." ) case .methodNotAllowed: - return String( - localized: "TypedOverloads.URLSession.methodNotAllowed", - defaultValue: "The HTTP method is not allowed for this resource (405).", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.methodNotAllowed", + defaultValue: "The HTTP method is not allowed for this resource (405)." ) case .notAcceptable: - return String( - localized: "TypedOverloads.URLSession.notAcceptable", - defaultValue: "The requested resource cannot produce an acceptable response (406).", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.notAcceptable", + defaultValue: "The requested resource cannot produce an acceptable response (406)." ) case .requestTimeout: - return String( - localized: "TypedOverloads.URLSession.requestTimeout", - defaultValue: "The request timed out (408). Please try again.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.requestTimeout", + defaultValue: "The request timed out (408). Please try again." ) case .conflict: - return String( - localized: "TypedOverloads.URLSession.conflict", - defaultValue: "There was a conflict with the request (409). Please review and try again.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.conflict", + defaultValue: "There was a conflict with the request (409). Please review and try again." ) case .unsupportedMediaType: - return String( - localized: "TypedOverloads.URLSession.unsupportedMediaType", - defaultValue: "The request entity has an unsupported media type (415).", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.unsupportedMediaType", + defaultValue: "The request entity has an unsupported media type (415)." ) case .tooManyRequests: - return String( - localized: "TypedOverloads.URLSession.tooManyRequests", - defaultValue: "Too many requests have been sent. Please wait and try again (429).", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.tooManyRequests", + defaultValue: "Too many requests have been sent. Please wait and try again (429)." ) case .badRequest: - return String( - localized: "TypedOverloads.URLSession.badRequest", - defaultValue: "The request was malformed (400). Please review and try again.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.badRequest", + defaultValue: "The request was malformed (400). Please review and try again." ) case .serverError: - return String( - localized: "TypedOverloads.URLSession.serverError", - defaultValue: "The server encountered an error (500). Please try again later.", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.serverError", + defaultValue: "The server encountered an error (500). Please try again later." ) case .unknownStatusCode(let statusCode): - return String( - localized: "TypedOverloads.URLSession.unknown", - defaultValue: "An unknown status code was received from the server: \(statusCode)", - bundle: .module + return String.localized( + key: "TypedOverloads.URLSession.unknown", + defaultValue: "An unknown status code was received from the server: \(statusCode)" ) case .other(let error): return ErrorKit.userFriendlyMessage(for: error) diff --git a/Tests/ErrorKitTests/ErrorKitTests.swift b/Tests/ErrorKitTests/ErrorKitTests.swift index 59d03c3..e45e360 100644 --- a/Tests/ErrorKitTests/ErrorKitTests.swift +++ b/Tests/ErrorKitTests/ErrorKitTests.swift @@ -20,11 +20,13 @@ enum ErrorKitTests { #expect(ErrorKit.userFriendlyMessage(for: SomeLocalizedError()) == "Something failed. It failed because it wanted to. Try again later.") } + #if canImport(CryptoKit) @Test static func nsError() { let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) #expect(ErrorKit.userFriendlyMessage(for: nsError) == "[SOME: 1245] Something failed.") } + #endif @Test static func throwable() async throws { @@ -51,6 +53,7 @@ enum ErrorKitTests { ) } + #if canImport(CryptoKit) @Test static func nsError() { let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) @@ -61,6 +64,7 @@ enum ErrorKitTests { """ #expect(generatedErrorChainDescription == expectedErrorChainDescription) } + #endif @Test static func throwableStruct() { @@ -163,6 +167,7 @@ enum ErrorKitTests { #expect(generatedErrorChainDescription == expectedErrorChainDescription) } + #if canImport(CryptoKit) @Test static func deeplyNestedThrowablesWithNSErrorLeaf() { let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) @@ -184,6 +189,7 @@ enum ErrorKitTests { """ #expect(generatedErrorChainDescription == expectedErrorChainDescription) } + #endif } // TODO: add more tests for more specific errors such as CoreData, MapKit – and also nested errors! From 064baa79dfc6195b07bb8ca017d147155383946a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Sat, 8 Mar 2025 12:56:58 +0100 Subject: [PATCH 5/6] Document log collection & sending via email in README --- README.md | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2bfaa70..2d99472 100644 --- a/README.md +++ b/README.md @@ -548,15 +548,120 @@ This precise grouping allows you to: ErrorKit's debugging tools transform error handling from a black box into a transparent system. By combining `errorChainDescription` for debugging with `groupingID` for analytics, you get deep insight into error flows while maintaining the ability to track and prioritize issues effectively. This is particularly powerful when combined with ErrorKit's `Catching` protocol, creating a comprehensive system for error handling, debugging, and monitoring. -## Attach Log File +## User Feedback with Error Logs -ErrorKit makes it super easy to attach a log file with relevant console output data to user bug reports. +When users encounter issues in your app, getting enough context to diagnose the problem can be challenging. Users rarely know what information you need, and reproducing issues without logs is often impossible. 😕 -TODO: continue here +ErrorKit makes it simple to add diagnostic log collection to your app, providing crucial context for bug reports and support requests. +### The Power of System Logs -## Life Error Analytics +ErrorKit leverages Apple's unified logging system (`OSLog`/`Logger`) to collect valuable diagnostic information. If you're not already using structured logging, here's a quick primer: -ErrorKit comes with hooks that make it easy to connect the reporting of errors to analytics service so you can find out which errors your users are confronted with most, without them having to contact you! This is great to proactively track issues in your app, track how they're evolving after you make a bug fix release or generally to make decisions on what to fix first! +```swift +import OSLog + +// Log at appropriate levels +Logger().debug("Detailed connection info: \(details)") // Development debugging +Logger().info("User tapped on \(button)") // General information +Logger().notice("Successfully loaded user profile") // Important events +Logger().error("Failed to parse server response") // Errors that should be fixed +Logger().fault("Database corruption detected") // Critical system failures +``` + +ErrorKit can collect these logs based on level, giving you control over how much detail to include in reports. 3rd-party frameworks that also use Apple's unified logging system will be included so you get a full picture of what happened in your app, not just what you logged yourself. + +### Creating a Feedback Button with Automatic Log Collection + +The easiest way to implement a support system is using the `.mailComposer` SwiftUI modifier combined with `logAttachment`: + +```swift +struct ContentView: View { + @State private var showMailComposer = false + + var body: some View { + Form { + // Your app content here + + Button("Report a Problem") { + showMailComposer = true + } + .mailComposer( + isPresented: $showMailComposer, + recipient: "support@yourapp.com", + subject: " Bug Report", + messageBody: """ + Please describe what happened: + + + + ---------------------------------- + [Please do not remove the information below] + + App version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") + Build: \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown") + Device: \(UIDevice.current.model) + iOS: \(UIDevice.current.systemVersion) + """, + attachments: [ + try? ErrorKit.logAttachment(ofLast: .minutes(30), minLevel: .notice) + ] + ) + } + } +} +``` + +This creates a simple "Report a Problem" button that: +1. Opens a pre-filled email composer +2. Includes useful device and app information +3. Automatically attaches recent system logs +4. Provides space for the user to describe the issue + +The above is just an example, feel free to adjust it to your needs and include any additional info needed. + +### Alternative Methods for More Control + +If you need more control over log handling, ErrorKit offers two additional approaches: + +#### 1. Getting Log Data Directly + +For sending logs to your own backend or processing them in-app: + +```swift +let logData = try ErrorKit.loggedData( + ofLast: .minutes(10), + minLevel: .notice +) + +// Use the data with your custom reporting system +analyticsService.sendLogs(data: logData) +``` + +#### 2. Exporting to a Temporary File + +For sharing logs via other mechanisms: + +```swift +let logFileURL = try ErrorKit.exportLogFile( + ofLast: .hours(1), + minLevel: .error +) + +// Share the log file +let activityVC = UIActivityViewController( + activityItems: [logFileURL], + applicationActivities: nil +) +present(activityVC, animated: true) +``` + +### Benefits of Automatic Log Collection + +- **Better bug reports**: Get the context you need without asking users for technical details +- **Faster issue resolution**: See exactly what happened leading up to the problem +- **Lower support burden**: Reduce back-and-forth communications with users +- **User satisfaction**: Demonstrate that you take their problems seriously +- **Developer sanity**: Stop trying to reproduce issues with insufficient information -TODO: continue here +By implementing a feedback button with automatic log collection, you transform the error reporting experience for both users and developers. Users can report issues with a single tap, and you get the diagnostic information you need to fix problems quickly. From 680ff48310e0c83bc8dd14281aba89a0cbce4a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Fri, 28 Mar 2025 18:21:17 +0100 Subject: [PATCH 6/6] Fix table of contents link to new section --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d99472..250fd88 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ ErrorKit makes error handling in Swift more intuitive. It reduces boilerplate co - [Typed Throws for System Functions](#typed-throws-for-system-functions) - [Error Nesting with Catching](#error-nesting-with-catching) - [Error Chain Debugging](#error-chain-debugging) -- [Attach Log Files](#attach-log-files) -- [Live Error Analytics](#live-error-analytics) +- [User Feedback with Error Logs](#user-feedback-with-error-logs) ## The Problem with Swift's Error Protocol