From 3b38d725d7e8e45fb2c0cb465a3968828616c209 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 24 Jan 2023 13:56:41 -0600 Subject: [PATCH] [Shared] Refactors error handling based on ALTLocalizedError protocol (#1115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Shared] Revises ALTLocalizedError protocol * Refactors errors to conform to revised ALTLocalizedError protocol * [Missing Commit] Remaining changes for ALTLocalizedError * [AltServer] Refactors errors to conform to revised ALTLocalizedError protocol * [Missing Commit] Declares ALTLocalizedTitleErrorKey + ALTLocalizedDescriptionKey * Updates Objective-C errors to match revised ALTLocalizedError * [Missing Commit] Unnecessary ALTLocalizedDescription logic * [Shared] Refactors NSError.withLocalizedFailure to properly support ALTLocalizedError * [Shared] Supports adding localized titles to errors via NSError.withLocalizedTitle() * Revises ErrorResponse logic to support arbitrary errors and user info values * [Missed Commit] Renames CodableServerError to CodableError * Merges ConnectionError into OperationError * [Missed Commit] Doesn’t check ALTWrappedError’s userInfo for localizedDescription * [Missed] Fixes incorrect errorDomain for ALTErrorEnums * [Missed] Removes nonexistent ALTWrappedError.h * Includes source file and line number in OperationError.unknown failureReason * Adds localizedTitle to AppManager operation errors * Fixes adding localizedTitle + localizedFailure to ALTWrappedError * Updates ToastView to use error’s localizedTitle as title * [Shared] Adds NSError.formattedDetailedDescription(with:) Returns formatted NSAttributedString containing all user info values intended for displaying to the user. * [Shared] Updates Error.localizedErrorCode to say “code” instead of “error” * Conforms ALTLocalizedError to CustomStringConvertible * Adds “View More Details” option to Error Log context menu to view detailed error description * [Shared] Fixes NSError.formattedDetailedDescription appearing black in dark mode * [AltServer] Updates error alert to match revised error logic Uses error’s localizedTitle as alert title. * [AltServer] Adds “View More Details” button to error alert to view detailed error info * [AltServer] Renames InstallError to OperationError and conforms to ALTErrorEnum * [Shared] Removes CodableError support for Date user info values Not currently used, and we don’t want to accidentally parse a non-Date as a Date in the meantime. * [Shared] Includes dynamic UserInfoValueProvider values in NSError.formattedDetailedDescription() * [Shared] Includes source file + line in NSError.formattedDetailedDescription() Automatically captures source file + line when throwing ALTErrorEnums. * [Shared] Captures source file + line for unknown errors * Removes sourceFunction from OperationError * Adds localizedTitle to AuthenticationViewController errors * [Shared] Moves nested ALTWrappedError logic to ALTWrappedError initializer * [AltServer] Removes now-redundant localized failure from JIT errors All JIT errors now have a localizedTitle which effectively says the same thing. * Makes OperationError.Code start at 1000 “Connection errors” subsection starts at 1200. * [Shared] Updates Error domains to revised [Source].[ErrorType] format * Updates ALTWrappedError.localizedDescription to prioritize using wrapped NSLocalizedDescription as failure reason * Makes ALTAppleAPIError codes start at 3000 * [AltServer] Adds relevant localizedFailures to ALTDeviceManager.installApplication() errors * Revises OperationError failureReasons and recovery suggestions All failure reasons now read correctly when preceded by a failure reason and “because”. * Revises ALTServerError error messages All failure reasons now read correctly when preceded by a failure reason and “because”. * Most failure reasons now read correctly when preceded by a failure reason and “because”. * ALTServerErrorUnderlyingError forwards all user info provider calls to underlying error. * Revises error messages for ALTAppleAPIErrorIncorrectCredentials * [Missed] Removes NSError+AltStore.swift from AltStore target * [Shared] Updates AltServerErrorDomain to revised [Source].[ErrorType] format * [Shared] Removes “code” from Error.localizedErrorCode * [Shared] Makes ALTServerError codes (appear to) start at 2000 We can’t change the actual error codes without breaking backwards compatibility, so instead we just add 2000 whenever we display ALTServerError codes to the user. * Moves VerificationError.errorFailure to VerifyAppOperation * Supports custom failure reason for OperationError.unknown * [Shared] Changes AltServerErrorDomain to “AltServer.ServerError” * [Shared] Converts ALTWrappedError to Objective-C class NSError subclasses must be written in ObjC for Swift.Error <-> NSError bridging to work correctly. # Conflicts: # AltStore.xcodeproj/project.pbxproj * Fixes decoding CodableError nested user info values --- AltServer/AltServer-Bridging-Header.h | 1 + AltServer/AppDelegate.swift | 130 ++++----- AltServer/Base.lproj/Main.storyboard | 58 +++- AltServer/Connections/ALTDebugConnection.mm | 17 +- AltServer/DeveloperDiskManager.swift | 17 +- .../ALTDeviceManager+Installation.swift | 64 ++-- AltServer/Devices/ALTDeviceManager.mm | 6 +- AltServer/ErrorDetailsViewController.swift | 48 +++ AltServer/Plugin/PluginManager.swift | 77 ++++- AltStore.xcodeproj/project.pbxproj | 54 +++- .../AuthenticationViewController.swift | 2 +- AltStore/Components/ToastView.swift | 45 +-- AltStore/Intents/IntentHandler.swift | 2 +- AltStore/Managing Apps/AppManager.swift | 60 +++- AltStore/Managing Apps/AppManagerErrors.swift | 68 ++--- .../Operations/AuthenticationOperation.swift | 21 +- .../BackgroundRefreshAppsOperation.swift | 11 +- AltStore/Operations/BackupAppOperation.swift | 2 +- .../Operations/DownloadAppOperation.swift | 34 +-- .../FetchProvisioningProfilesOperation.swift | 6 +- AltStore/Operations/FindServerOperation.swift | 2 +- AltStore/Operations/OperationError.swift | 160 +++++++--- .../Patch App/PatchAppOperation.swift | 38 ++- .../Patch App/PatchViewController.swift | 2 +- AltStore/Operations/RefreshAppOperation.swift | 4 +- AltStore/Operations/VerifyAppOperation.swift | 104 ++++--- AltStore/Server/Server.swift | 16 - AltStore/Server/ServerManager.swift | 4 +- .../ErrorDetailsViewController.swift | 53 ++++ .../Error Log/ErrorLogViewController.swift | 34 ++- AltStore/Settings/Settings.storyboard | 65 +++++ AltStore/Sources/SourcesViewController.swift | 11 +- AltStoreCore/AltStoreCore.h | 1 + AltStoreCore/Patreon/PatreonAPI.swift | 45 +-- Dependencies/AltSign | 2 +- Shared/Categories/NSError+ALTServerError.m | 139 +++++++-- Shared/Errors/ALTLocalizedError.swift | 182 ++++++++++++ Shared/Errors/ALTWrappedError.h | 25 ++ Shared/Errors/ALTWrappedError.m | 73 +++++ .../ALTServerError+Conveniences.swift | 8 +- Shared/Extensions/NSError+AltStore.swift | 276 +++++++++++++----- Shared/Server Protocol/CodableError.swift | 249 ++++++++++++++++ .../Server Protocol/CodableServerError.swift | 126 -------- Shared/Server Protocol/ServerProtocol.swift | 9 +- 44 files changed, 1707 insertions(+), 644 deletions(-) create mode 100644 AltServer/ErrorDetailsViewController.swift create mode 100644 AltStore/Settings/Error Log/ErrorDetailsViewController.swift create mode 100644 Shared/Errors/ALTLocalizedError.swift create mode 100644 Shared/Errors/ALTWrappedError.h create mode 100644 Shared/Errors/ALTWrappedError.m create mode 100644 Shared/Server Protocol/CodableError.swift delete mode 100644 Shared/Server Protocol/CodableServerError.swift diff --git a/AltServer/AltServer-Bridging-Header.h b/AltServer/AltServer-Bridging-Header.h index c42bab66..2145ae89 100644 --- a/AltServer/AltServer-Bridging-Header.h +++ b/AltServer/AltServer-Bridging-Header.h @@ -11,5 +11,6 @@ #import "ALTConstants.h" #import "ALTConnection.h" #import "AltXPCProtocol.h" +#import "ALTWrappedError.h" #import "NSError+ALTServerError.h" #import "CFNotificationName+AltStore.h" diff --git a/AltServer/AppDelegate.swift b/AltServer/AppDelegate.swift index cde13ae3..952cea94 100644 --- a/AltServer/AppDelegate.swift +++ b/AltServer/AppDelegate.swift @@ -56,6 +56,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var isAltPluginUpdateAvailable = false + private var popoverController: NSPopover? + private var popoverError: NSError? + private var errorAlert: NSAlert? + func applicationDidFinishLaunching(_ aNotification: Notification) { UserDefaults.standard.registerDefaults() @@ -159,8 +163,9 @@ private extension AppDelegate DispatchQueue.main.async { switch result { - case .failure(let error): - self.showErrorAlert(error: error, localizedFailure: String(format: NSLocalizedString("JIT compilation could not be enabled for %@.", comment: ""), app.name)) + case .failure(let error as NSError): + let localizedTitle = String(format: NSLocalizedString("JIT could not be enabled for %@.", comment: ""), app.name) + self.showErrorAlert(error: error.withLocalizedTitle(localizedTitle)) case .success: let alert = NSAlert() @@ -250,13 +255,13 @@ private extension AppDelegate let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) - case .failure(InstallError.cancelled), .failure(ALTAppleAPIError.requiresTwoFactorAuthentication): + case .failure(~OperationErrorCode.cancelled), .failure(ALTAppleAPIError.requiresTwoFactorAuthentication): // Ignore break case .failure(let error): DispatchQueue.main.async { - self.showErrorAlert(error: error, localizedFailure: String(format: NSLocalizedString("Could not install app to %@.", comment: ""), device.name)) + self.showErrorAlert(error: error) } } } @@ -278,7 +283,10 @@ private extension AppDelegate self.pluginManager.isUpdateAvailable { result in switch result { - case .failure(let error): finish(.failure(error)) + case .failure(let error): + let error = (error as NSError).withLocalizedTitle(NSLocalizedString("Could not check for Mail plug-in updates.", comment: "")) + finish(.failure(error)) + case .success(let isUpdateAvailable): self.isAltPluginUpdateAvailable = isUpdateAvailable @@ -302,85 +310,61 @@ private extension AppDelegate } } - func showErrorAlert(error: Error, localizedFailure: String) + func showErrorAlert(error: Error) { + self.popoverError = error as NSError + let nsError = error as NSError - let alert = NSAlert() - alert.alertStyle = .critical - alert.messageText = localizedFailure - - var messageComponents = [String]() - - let separator: String - switch error - { - case ALTServerError.maximumFreeAppLimitReached: separator = "\n\n" - default: separator = " " - } - - if let errorFailure = nsError.localizedFailure - { - if let debugDescription = nsError.localizedDebugDescription - { - alert.messageText = errorFailure - messageComponents.append(debugDescription) - } - else if let failureReason = nsError.localizedFailureReason - { - if nsError.localizedDescription.starts(with: errorFailure) - { - alert.messageText = errorFailure - messageComponents.append(failureReason) - } - else - { - alert.messageText = errorFailure - messageComponents.append(nsError.localizedDescription) - } - } - else - { - // No failure reason given. - - if nsError.localizedDescription.starts(with: errorFailure) - { - // No need to duplicate errorFailure in both title and message. - alert.messageText = localizedFailure - messageComponents.append(nsError.localizedDescription) - } - else - { - alert.messageText = errorFailure - messageComponents.append(nsError.localizedDescription) - } - } - } - else - { - alert.messageText = localizedFailure - - if let debugDescription = nsError.localizedDebugDescription - { - messageComponents.append(debugDescription) - } - else - { - messageComponents.append(nsError.localizedDescription) - } - } - + var messageComponents = [error.localizedDescription] if let recoverySuggestion = nsError.localizedRecoverySuggestion { messageComponents.append(recoverySuggestion) } - let informativeText = messageComponents.joined(separator: separator) - alert.informativeText = informativeText + let title = nsError.localizedTitle ?? NSLocalizedString("Operation Failed", comment: "") + let message = messageComponents.joined(separator: "\n\n") + + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: NSLocalizedString("OK", comment: "")) + alert.addButton(withTitle: NSLocalizedString("View More Details", comment: "")) + + if let viewMoreButton = alert.buttons.last + { + viewMoreButton.target = self + viewMoreButton.action = #selector(AppDelegate.showDetailedErrorDescription) + + self.errorAlert = alert + } NSRunningApplication.current.activate(options: .activateIgnoringOtherApps) - + alert.runModal() + + self.popoverController = nil + self.errorAlert = nil + self.popoverError = nil + } + + @objc func showDetailedErrorDescription() + { + guard let errorAlert, let contentView = errorAlert.window.contentView else { return } + + let errorDetailsViewController = NSStoryboard(name: "Main", bundle: .main).instantiateController(withIdentifier: "errorDetailsViewController") as! ErrorDetailsViewController + errorDetailsViewController.error = self.popoverError + + let fittingSize = errorDetailsViewController.view.fittingSize + errorDetailsViewController.view.frame.size = fittingSize + + let popoverController = NSPopover() + popoverController.contentViewController = errorDetailsViewController + popoverController.contentSize = fittingSize + popoverController.behavior = .transient + popoverController.show(relativeTo: contentView.bounds, of: contentView, preferredEdge: .maxX) + self.popoverController = popoverController } @objc func toggleLaunchAtLogin(_ item: NSMenuItem) diff --git a/AltServer/Base.lproj/Main.storyboard b/AltServer/Base.lproj/Main.storyboard index d01f7ec6..3c867eac 100644 --- a/AltServer/Base.lproj/Main.storyboard +++ b/AltServer/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -384,5 +384,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltServer/Connections/ALTDebugConnection.mm b/AltServer/Connections/ALTDebugConnection.mm index e7e2598a..a32052b5 100644 --- a/AltServer/Connections/ALTDebugConnection.mm +++ b/AltServer/Connections/ALTDebugConnection.mm @@ -108,9 +108,6 @@ char *bin2hex(const unsigned char *bin, size_t length) - (void)_enableUnsignedCodeExecutionForProcessWithName:(nullable NSString *)processName pid:(int32_t)pid completionHandler:(void (^)(BOOL success, NSError *_Nullable error))completionHandler { dispatch_async(self.connectionQueue, ^{ - NSString *name = processName ?: NSLocalizedString(@"this app", @""); - NSString *localizedFailure = [NSString stringWithFormat:NSLocalizedString(@"JIT could not be enabled for %@.", comment: @""), name]; - NSString *attachCommand = nil; if (processName) @@ -133,7 +130,6 @@ char *bin2hex(const unsigned char *bin, size_t length) NSMutableDictionary *userInfo = [error.userInfo mutableCopy]; userInfo[ALTAppNameErrorKey] = processName; userInfo[ALTDeviceNameErrorKey] = self.device.name; - userInfo[NSLocalizedFailureErrorKey] = localizedFailure; NSError *returnError = [NSError errorWithDomain:error.domain code:error.code userInfo:userInfo]; return completionHandler(NO, returnError); @@ -145,7 +141,6 @@ char *bin2hex(const unsigned char *bin, size_t length) NSMutableDictionary *userInfo = [error.userInfo mutableCopy]; userInfo[ALTAppNameErrorKey] = processName; userInfo[ALTDeviceNameErrorKey] = self.device.name; - userInfo[NSLocalizedFailureErrorKey] = localizedFailure; NSError *returnError = [NSError errorWithDomain:error.domain code:error.code userInfo:userInfo]; return completionHandler(NO, returnError); @@ -249,7 +244,11 @@ char *bin2hex(const unsigned char *bin, size_t length) { if (error) { - *error = [NSError errorWithDomain:AltServerConnectionErrorDomain code:ALTServerConnectionErrorUnknown userInfo:@{NSLocalizedFailureReasonErrorKey: response}]; + *error = [NSError errorWithDomain:AltServerConnectionErrorDomain code:ALTServerConnectionErrorUnknown userInfo:@{ + NSLocalizedFailureReasonErrorKey: response, + ALTSourceFileErrorKey: @(__FILE__).lastPathComponent, + ALTSourceLineErrorKey: @(__LINE__) + }]; } return NO; @@ -292,7 +291,11 @@ char *bin2hex(const unsigned char *bin, size_t length) break; default: - *error = [NSError errorWithDomain:AltServerConnectionErrorDomain code:ALTServerConnectionErrorUnknown userInfo:@{NSLocalizedFailureReasonErrorKey: response}]; + *error = [NSError errorWithDomain:AltServerConnectionErrorDomain code:ALTServerConnectionErrorUnknown userInfo:@{ + NSLocalizedFailureReasonErrorKey: response, + ALTSourceFileErrorKey: @(__FILE__).lastPathComponent, + ALTSourceLineErrorKey: @(__LINE__) + }]; break; } } diff --git a/AltServer/DeveloperDiskManager.swift b/AltServer/DeveloperDiskManager.swift index 3830c912..83448c6f 100644 --- a/AltServer/DeveloperDiskManager.swift +++ b/AltServer/DeveloperDiskManager.swift @@ -10,13 +10,14 @@ import Foundation import AltSign -enum DeveloperDiskError: LocalizedError +typealias DeveloperDiskError = DeveloperDiskErrorCode.Error +enum DeveloperDiskErrorCode: Int, ALTErrorEnum { case unknownDownloadURL case unsupportedOperatingSystem case downloadedDiskNotFound - var errorDescription: String? { + var errorFailureReason: String { switch self { case .unknownDownloadURL: return NSLocalizedString("The URL to download the Developer disk image could not be determined.", comment: "") @@ -88,14 +89,14 @@ class DeveloperDiskManager { do { - guard let osName = device.type.osName else { throw DeveloperDiskError.unsupportedOperatingSystem } + guard let osName = device.type.osName else { throw DeveloperDiskError(.unsupportedOperatingSystem) } let osKeyPath: KeyPath switch device.type { case .iphone, .ipad: osKeyPath = \FetchURLsResponse.Disks.iOS case .appletv: osKeyPath = \FetchURLsResponse.Disks.tvOS - default: throw DeveloperDiskError.unsupportedOperatingSystem + default: throw DeveloperDiskError(.unsupportedOperatingSystem) } var osVersion = device.osVersion @@ -146,7 +147,7 @@ class DeveloperDiskManager do { let developerDiskURLs = try result.get() - guard let diskURL = developerDiskURLs[keyPath: osKeyPath]?[osVersion.stringValue] else { throw DeveloperDiskError.unknownDownloadURL } + guard let diskURL = developerDiskURLs[keyPath: osKeyPath]?[osVersion.stringValue] else { throw DeveloperDiskError(.unknownDownloadURL) } switch diskURL { @@ -201,7 +202,7 @@ private extension DeveloperDiskManager { guard let data = data else { throw error! } - let response = try JSONDecoder().decode(FetchURLsResponse.self, from: data) + let response = try Foundation.JSONDecoder().decode(FetchURLsResponse.self, from: data) completionHandler(.success(response.disks)) } catch @@ -244,7 +245,7 @@ private extension DeveloperDiskManager } } - guard let diskFileURL = tempDiskFileURL, let signatureFileURL = tempSignatureFileURL else { throw DeveloperDiskError.downloadedDiskNotFound } + guard let diskFileURL = tempDiskFileURL, let signatureFileURL = tempSignatureFileURL else { throw DeveloperDiskError(.downloadedDiskNotFound) } completionHandler(.success((diskFileURL, signatureFileURL))) } @@ -318,7 +319,7 @@ private extension DeveloperDiskManager } guard let diskFileURL = diskFileURL, let signatureFileURL = signatureFileURL else { - return completionHandler(.failure(downloadError ?? DeveloperDiskError.downloadedDiskNotFound)) + return completionHandler(.failure(downloadError ?? DeveloperDiskError(.downloadedDiskNotFound))) } completionHandler(.success((diskFileURL, signatureFileURL))) diff --git a/AltServer/Devices/ALTDeviceManager+Installation.swift b/AltServer/Devices/ALTDeviceManager+Installation.swift index 74fbe3ab..5d1ee0ae 100644 --- a/AltServer/Devices/ALTDeviceManager+Installation.swift +++ b/AltServer/Devices/ALTDeviceManager+Installation.swift @@ -14,34 +14,21 @@ private let appGroupsSemaphore = DispatchSemaphore(value: 1) private let developerDiskManager = DeveloperDiskManager() -enum InstallError: Int, LocalizedError, _ObjectiveCBridgeableError +typealias OperationError = OperationErrorCode.Error +enum OperationErrorCode: Int, ALTErrorEnum { case cancelled case noTeam case missingPrivateKey case missingCertificate - var errorDescription: String? { + var errorFailureReason: String { switch self { case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "") - case .noTeam: return "You are not a member of any developer teams." - case .missingPrivateKey: return "The developer certificate's private key could not be found." - case .missingCertificate: return "The developer certificate could not be found." - } - } - - init?(_bridgedNSError error: NSError) - { - guard error.domain == InstallError.cancelled._domain else { return nil } - - if let installError = InstallError(rawValue: error.code) - { - self = installError - } - else - { - return nil + case .noTeam: return NSLocalizedString("You are not a member of any developer teams.", comment: "") + case .missingPrivateKey: return NSLocalizedString("The developer certificate's private key could not be found.", comment: "") + case .missingCertificate: return NSLocalizedString("The developer certificate could not be found.", comment: "") } } } @@ -54,16 +41,18 @@ extension ALTDeviceManager var appName = (url.isFileURL) ? url.deletingPathExtension().lastPathComponent : NSLocalizedString("AltStore", comment: "") - func finish(_ result: Result, title: String = "") + func finish(_ result: Result, failure: String? = nil) { DispatchQueue.main.async { switch result { case .success(let app): completion(.success(app)) case .failure(var error as NSError): - if error.localizedFailure == nil + error = error.withLocalizedTitle(String(format: NSLocalizedString("%@ could not be installed onto %@.", comment: ""), appName, altDevice.name)) + + if let failure, error.localizedFailure == nil { - error = error.withLocalizedFailure(String(format: NSLocalizedString("Could not install %@ to %@.", comment: ""), appName, altDevice.name)) + error = error.withLocalizedFailure(failure) } completion(.failure(error)) @@ -144,24 +133,25 @@ extension ALTDeviceManager let profiles = try result.get() self.install(application, to: device, team: team, certificate: certificate, profiles: profiles) { (result) in - finish(result.map { application }, title: "Failed to Install AltStore") + finish(result.map { application }) } } catch { - finish(.failure(error), title: "Failed to Fetch Provisioning Profiles") + finish(.failure(error), failure: NSLocalizedString("AltServer could not fetch new provisioning profiles.", comment: "")) } } } catch { - finish(.failure(error), title: "Failed to Refresh Anisette Data") + finish(.failure(error)) } } } catch { - finish(.failure(error), title: "Failed to Download AltStore") + let failure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), appName) + finish(.failure(error), failure: failure) } } } @@ -169,31 +159,31 @@ extension ALTDeviceManager } catch { - finish(.failure(error), title: "Failed to Fetch Certificate") + finish(.failure(error), failure: NSLocalizedString("A valid signing certificate could not be created.", comment: "")) } } } catch { - finish(.failure(error), title: "Failed to Register Device") + finish(.failure(error), failure: NSLocalizedString("Your device could not be registered with your development team.", comment: "")) } } } catch { - finish(.failure(error), title: "Failed to Fetch Team") + finish(.failure(error)) } } } catch { - finish(.failure(error), title: "Failed to Authenticate") + finish(.failure(error), failure: NSLocalizedString("AltServer could not sign in with your Apple ID.", comment: "")) } } } catch { - finish(.failure(error), title: "Failed to Fetch Anisette Data") + finish(.failure(error)) } } } @@ -306,7 +296,7 @@ private extension ALTDeviceManager } else { - completionHandler(.failure(error ?? ALTAppleAPIError(.unknown))) + completionHandler(.failure(error ?? ALTAppleAPIError.unknown())) } } } @@ -332,7 +322,7 @@ private extension ALTDeviceManager } else { - throw InstallError.noTeam + throw OperationError(.noTeam) } } catch @@ -384,7 +374,7 @@ private extension ALTDeviceManager } } - guard !isCancelled else { return completionHandler(.failure(InstallError.cancelled)) } + guard !isCancelled else { return completionHandler(.failure(OperationError(.cancelled))) } } func addCertificate() @@ -393,7 +383,7 @@ private extension ALTDeviceManager do { let certificate = try Result(certificate, error).get() - guard let privateKey = certificate.privateKey else { throw InstallError.missingPrivateKey } + guard let privateKey = certificate.privateKey else { throw OperationError(.missingPrivateKey) } ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in do @@ -401,7 +391,7 @@ private extension ALTDeviceManager let certificates = try Result(certificates, error).get() guard let certificate = certificates.first(where: { $0.serialNumber == certificate.serialNumber }) else { - throw InstallError.missingCertificate + throw OperationError(.missingCertificate) } certificate.privateKey = privateKey @@ -454,7 +444,7 @@ private extension ALTDeviceManager } } - guard !isCancelled else { return completionHandler(.failure(InstallError.cancelled)) } + guard !isCancelled else { return completionHandler(.failure(OperationError(.cancelled))) } } ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { (success, error) in diff --git a/AltServer/Devices/ALTDeviceManager.mm b/AltServer/Devices/ALTDeviceManager.mm index a7d6aff7..eca5d55f 100644 --- a/AltServer/Devices/ALTDeviceManager.mm +++ b/AltServer/Devices/ALTDeviceManager.mm @@ -1217,7 +1217,11 @@ NSNotificationName const ALTDeviceManagerDeviceDidDisconnectNotification = @"ALT // ALTServerErrorUnderlyingError uses its underlying error's failure reason as its error description (if one exists), // so assign our recovery suggestion to NSLocalizedFailureReasonErrorKey to make sure it's always displayed on client. - NSError *underlyingError = [NSError errorWithDomain:AltServerConnectionErrorDomain code:ALTServerConnectionErrorUnknown userInfo:@{NSLocalizedFailureReasonErrorKey: recoverySuggestion}]; + NSError *underlyingError = [NSError errorWithDomain:AltServerConnectionErrorDomain code:ALTServerConnectionErrorUnknown userInfo:@{ + NSLocalizedFailureReasonErrorKey: recoverySuggestion, + ALTSourceFileErrorKey: @(__FILE__).lastPathComponent, + ALTSourceLineErrorKey: @(__LINE__) + }]; returnError = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorUnderlyingError userInfo:@{NSUnderlyingErrorKey: underlyingError}]; } diff --git a/AltServer/ErrorDetailsViewController.swift b/AltServer/ErrorDetailsViewController.swift new file mode 100644 index 00000000..6f9fd1c7 --- /dev/null +++ b/AltServer/ErrorDetailsViewController.swift @@ -0,0 +1,48 @@ +// +// ErrorDetailsViewController.swift +// AltServer +// +// Created by Riley Testut on 10/4/22. +// Copyright © 2022 Riley Testut. All rights reserved. +// + +import AppKit + +class ErrorDetailsViewController: NSViewController +{ + var error: NSError? { + didSet { + self.update() + } + } + + @IBOutlet private var errorCodeLabel: NSTextField! + @IBOutlet private var detailedDescriptionLabel: NSTextField! + + override func viewDidLoad() + { + super.viewDidLoad() + + self.detailedDescriptionLabel.preferredMaxLayoutWidth = 800 + } +} + +private extension ErrorDetailsViewController +{ + func update() + { + if !self.isViewLoaded + { + self.loadView() + } + + guard let error = self.error else { return } + + self.errorCodeLabel.stringValue = error.localizedErrorCode + + let font = self.detailedDescriptionLabel.font ?? NSFont.systemFont(ofSize: 12) + let detailedDescription = error.formattedDetailedDescription(with: font) + self.detailedDescriptionLabel.attributedStringValue = detailedDescription + } +} + diff --git a/AltServer/Plugin/PluginManager.swift b/AltServer/Plugin/PluginManager.swift index fb2e9c09..3b2f2202 100644 --- a/AltServer/Plugin/PluginManager.swift +++ b/AltServer/Plugin/PluginManager.swift @@ -15,24 +15,71 @@ import STPrivilegedTask private let pluginDirectoryURL = URL(fileURLWithPath: "/Library/Mail/Bundles", isDirectory: true) private let pluginURL = pluginDirectoryURL.appendingPathComponent("AltPlugin.mailbundle") -enum PluginError: LocalizedError +extension PluginError { - case cancelled - case unknown - case notFound - case mismatchedHash(hash: String, expectedHash: String) - case taskError(String) - case taskErrorCode(Int) + enum Code: Int, ALTErrorCode + { + typealias Error = PluginError + + case cancelled + case unknown + case notFound + case mismatchedHash + case taskError + case taskErrorCode + } - var errorDescription: String? { - switch self + static let cancelled = PluginError(code: .cancelled) + static let notFound = PluginError(code: .notFound) + + static func unknown(file: String = #fileID, line: UInt = #line) -> PluginError { PluginError(code: .unknown, sourceFile: file, sourceLine: line) } + static func mismatchedHash(hash: String, expectedHash: String) -> PluginError { PluginError(code: .mismatchedHash, hash: hash, expectedHash: expectedHash) } + static func taskError(output: String) -> PluginError { PluginError(code: .taskError, taskErrorOutput: output) } + static func taskErrorCode(_ code: Int) -> PluginError { PluginError(code: .taskErrorCode, taskErrorCode: code) } +} + +struct PluginError: ALTLocalizedError +{ + let code: Code + + var errorTitle: String? + var errorFailure: String? + var sourceFile: String? + var sourceLine: UInt? + + var hash: String? + var expectedHash: String? + var taskErrorOutput: String? + var taskErrorCode: Int? + + var errorFailureReason: String { + switch self.code { case .cancelled: return NSLocalizedString("Mail plug-in installation was cancelled.", comment: "") case .unknown: return NSLocalizedString("Failed to install Mail plug-in.", comment: "") case .notFound: return NSLocalizedString("The Mail plug-in does not exist at the requested URL.", comment: "") - case .mismatchedHash(let hash, let expectedHash): return String(format: NSLocalizedString("The hash of the downloaded Mail plug-in does not match the expected hash.\n\nHash:\n%@\n\nExpected Hash:\n%@", comment: ""), hash, expectedHash) - case .taskError(let output): return output - case .taskErrorCode(let errorCode): return String(format: NSLocalizedString("There was an error installing the Mail plug-in. (Error Code: %@)", comment: ""), NSNumber(value: errorCode)) + case .mismatchedHash: + let baseMessage = NSLocalizedString("The hash of the downloaded Mail plug-in does not match the expected hash.", comment: "") + guard let hash = self.hash, let expectedHash = self.expectedHash else { return baseMessage } + + let additionalInfo = String(format: NSLocalizedString("Hash:\n%@\n\nExpected Hash:\n%@", comment: ""), hash, expectedHash) + return baseMessage + "\n\n" + additionalInfo + + case .taskError: + if let output = self.taskErrorOutput + { + return output + } + + // Use .taskErrorCode base message as fallback. + fallthrough + + case .taskErrorCode: + let baseMessage = NSLocalizedString("There was an error installing the Mail plug-in.", comment: "") + guard let errorCode = self.taskErrorCode else { return baseMessage } + + let additionalInfo = String(format: NSLocalizedString("(Error Code: %@)", comment: ""), NSNumber(value: errorCode)) + return baseMessage + " " + additionalInfo } } } @@ -160,7 +207,7 @@ extension PluginManager let unzippedPluginURL = temporaryDirectoryURL.appendingPathComponent(pluginURL.lastPathComponent) try self.runAndKeepAuthorization("cp", arguments: ["-R", unzippedPluginURL.path, pluginDirectoryURL.path], authorization: authorization) - guard self.isMailPluginInstalled else { throw PluginError.unknown } + guard self.isMailPluginInstalled else { throw PluginError.unknown() } // Enable Mail plug-in preferences. try self.run("defaults", arguments: ["write", "/Library/Preferences/com.apple.mail", "EnableBundles", "-bool", "YES"], authorization: authorization) @@ -360,13 +407,13 @@ private extension PluginManager if let outputString = String(data: outputData, encoding: .utf8), !outputString.isEmpty { - throw PluginError.taskError(outputString) + throw PluginError.taskError(output: outputString) } throw PluginError.taskErrorCode(Int(task.terminationStatus)) } - guard let authorization = task.authorization else { throw PluginError.unknown } + guard let authorization = task.authorization else { throw PluginError.unknown() } return authorization } } diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index fbd57881..5d4ab600 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -127,7 +127,6 @@ BF58048A246A28F9008AE704 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BF580488246A28F9008AE704 /* LaunchScreen.storyboard */; }; BF580496246A3CB5008AE704 /* UIColor+AltBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF580495246A3CB5008AE704 /* UIColor+AltBackup.swift */; }; BF580498246A3D19008AE704 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF580497246A3D19008AE704 /* UIKit.framework */; }; - BF58049B246A432D008AE704 /* NSError+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C336124197D700034FD24 /* NSError+AltStore.swift */; }; BF5C5FCF237DF69100EDD0C6 /* ALTPluginService.m in Sources */ = {isa = PBXBuildFile; fileRef = BF5C5FCE237DF69100EDD0C6 /* ALTPluginService.m */; }; BF663C4F2433ED8200DAA738 /* FileManager+DirectorySize.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF663C4E2433ED8200DAA738 /* FileManager+DirectorySize.swift */; }; BF66EE822501AE50007EE018 /* AltStoreCore.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE802501AE50007EE018 /* AltStoreCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -174,7 +173,6 @@ BF66EEE92501AED0007EE018 /* JSONDecoder+Properties.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EEE52501AED0007EE018 /* JSONDecoder+Properties.swift */; }; BF66EEEA2501AED0007EE018 /* UIColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EEE62501AED0007EE018 /* UIColor+Hex.swift */; }; BF66EEEB2501AED0007EE018 /* UIApplication+AppExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EEE72501AED0007EE018 /* UIApplication+AppExtension.swift */; }; - BF6C336224197D700034FD24 /* NSError+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C336124197D700034FD24 /* NSError+AltStore.swift */; }; BF6C8FAC242935ED00125131 /* NSAttributedString+Markdown.m in Sources */ = {isa = PBXBuildFile; fileRef = BF6C8FAA242935ED00125131 /* NSAttributedString+Markdown.m */; }; BF6C8FAE2429597900125131 /* BannerCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C8FAD2429597900125131 /* BannerCollectionViewCell.swift */; }; BF6C8FB02429599900125131 /* TextCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C8FAF2429599900125131 /* TextCollectionReusableView.swift */; }; @@ -214,7 +212,7 @@ BFA8172B23C5633D001B5953 /* FetchAnisetteDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFA8172A23C5633D001B5953 /* FetchAnisetteDataOperation.swift */; }; BFAD678E25E0649500D4C4D1 /* ALTDebugConnection.mm in Sources */ = {isa = PBXBuildFile; fileRef = BFAD678D25E0649500D4C4D1 /* ALTDebugConnection.mm */; }; BFAD67A325E0854500D4C4D1 /* DeveloperDiskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFAD67A225E0854500D4C4D1 /* DeveloperDiskManager.swift */; }; - BFAECC522501B0A400528F27 /* CodableServerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD44605241188C300EAB90A /* CodableServerError.swift */; }; + BFAECC522501B0A400528F27 /* CodableError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD44605241188C300EAB90A /* CodableError.swift */; }; BFAECC532501B0A400528F27 /* ServerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1E3128229F474900370A3C /* ServerProtocol.swift */; }; BFAECC542501B0A400528F27 /* NSError+ALTServerError.m in Sources */ = {isa = PBXBuildFile; fileRef = BF1E314922A060F400370A3C /* NSError+ALTServerError.m */; }; BFAECC552501B0A400528F27 /* Connection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF18BFF624858BDE00DD5981 /* Connection.swift */; }; @@ -309,7 +307,7 @@ BFE6325A22A83BEB00F30809 /* Authentication.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BFE6325922A83BEB00F30809 /* Authentication.storyboard */; }; BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE6326B22A86FF300F30809 /* AuthenticationOperation.swift */; }; BFE972E3260A8B2700D0BDAC /* NSError+libimobiledevice.mm in Sources */ = {isa = PBXBuildFile; fileRef = BFE972E2260A8B2700D0BDAC /* NSError+libimobiledevice.mm */; }; - BFECAC7F24FD950B0077C41F /* CodableServerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD44605241188C300EAB90A /* CodableServerError.swift */; }; + BFECAC7F24FD950B0077C41F /* CodableError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD44605241188C300EAB90A /* CodableError.swift */; }; BFECAC8024FD950B0077C41F /* ConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF18BFF22485828200DD5981 /* ConnectionManager.swift */; }; BFECAC8124FD950B0077C41F /* ALTServerError+Conveniences.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF767CB2489AB5C0097E58C /* ALTServerError+Conveniences.swift */; }; BFECAC8224FD950B0077C41F /* ServerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1E3128229F474900370A3C /* ServerProtocol.swift */; }; @@ -318,7 +316,7 @@ BFECAC8524FD950B0077C41F /* Connection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF18BFF624858BDE00DD5981 /* Connection.swift */; }; BFECAC8624FD950B0077C41F /* Result+Conveniences.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBAC8852295C90300587369 /* Result+Conveniences.swift */; }; BFECAC8724FD950B0077C41F /* Bundle+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1E314122A05D4C00370A3C /* Bundle+AltStore.swift */; }; - BFECAC8824FD950E0077C41F /* CodableServerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD44605241188C300EAB90A /* CodableServerError.swift */; }; + BFECAC8824FD950E0077C41F /* CodableError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD44605241188C300EAB90A /* CodableError.swift */; }; BFECAC8924FD950E0077C41F /* ConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF18BFF22485828200DD5981 /* ConnectionManager.swift */; }; BFECAC8A24FD950E0077C41F /* ALTServerError+Conveniences.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF767CB2489AB5C0097E58C /* ALTServerError+Conveniences.swift */; }; BFECAC8B24FD950E0077C41F /* ServerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1E3128229F474900370A3C /* ServerProtocol.swift */; }; @@ -348,10 +346,14 @@ BFF7C90F257844C900E55F36 /* AltXPC.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = BFF7C904257844C900E55F36 /* AltXPC.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BFF7C920257844FA00E55F36 /* ALTPluginService.m in Sources */ = {isa = PBXBuildFile; fileRef = BF5C5FCE237DF69100EDD0C6 /* ALTPluginService.m */; }; BFF7C9342578492100E55F36 /* ALTAnisetteData.m in Sources */ = {isa = PBXBuildFile; fileRef = BFB49AA823834CF900D542D9 /* ALTAnisetteData.m */; }; + D51AD27E29356B7B00967AAA /* ALTWrappedError.h in Headers */ = {isa = PBXBuildFile; fileRef = D51AD27C29356B7B00967AAA /* ALTWrappedError.h */; settings = {ATTRIBUTES = (Public, ); }; }; + D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; }; + D51AD28029356B8000967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; }; D52C08EE28AEC37A006C4AE5 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */; }; D533E8B72727841800A9B5DD /* libAppleArchive.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D533E8B62727841800A9B5DD /* libAppleArchive.tbd */; settings = {ATTRIBUTES = (Weak, ); }; }; D533E8BC2727BBEE00A9B5DD /* libfragmentzip.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D533E8BB2727BBEE00A9B5DD /* libfragmentzip.a */; }; D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D533E8BD2727BBF800A9B5DD /* libcurl.a */; }; + D540E93828EE1BDE000F1B0F /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */; }; D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; }; D55E163728776CB700A627A1 /* ComplicationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55E163528776CB000A627A1 /* ComplicationView.swift */; }; D57DF638271E32F000677701 /* PatchApp.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D57DF637271E32F000677701 /* PatchApp.storyboard */; }; @@ -366,8 +368,12 @@ D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */; }; D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */; }; D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */; }; + D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; }; + D5DB145B28F9DC5C00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; }; D5E1E7C128077DE90016FC96 /* FetchTrustedSourcesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */; }; + D5E3FB9828FDFAD90034B72C /* NSError+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C336124197D700034FD24 /* NSError+AltStore.swift */; }; D5F2F6A92720B7C20081CCF5 /* PatchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F2F6A82720B7C20081CCF5 /* PatchViewController.swift */; }; + D5F5AF7D28ECEA990067C736 /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */; }; D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */; }; D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */; }; /* End PBXBuildFile section */ @@ -740,7 +746,7 @@ BFD2478B2284C4C300981D42 /* AppIconImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconImageView.swift; sourceTree = ""; }; BFD2478E2284C8F900981D42 /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = ""; }; BFD2479E2284FBD000981D42 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = ""; }; - BFD44605241188C300EAB90A /* CodableServerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableServerError.swift; sourceTree = ""; }; + BFD44605241188C300EAB90A /* CodableError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableError.swift; sourceTree = ""; }; BFD52BD222A06EFB000B7ED1 /* ALTConstants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTConstants.h; sourceTree = ""; }; BFD52BD322A0800A000B7ED1 /* ServerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.swift; sourceTree = ""; }; BFD52BE522A1A9CA000B7ED1 /* ptrarray.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = ptrarray.c; path = Dependencies/libplist/src/ptrarray.c; sourceTree = SOURCE_ROOT; }; @@ -818,12 +824,15 @@ BFF7EC4C25081E9300BDE521 /* AltStore 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 8.xcdatamodel"; sourceTree = ""; }; BFFCFA45248835530077BFCE /* AltDaemon.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AltDaemon.entitlements; sourceTree = ""; }; C9EEAA842DA87A88A870053B /* Pods_AltStore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AltStore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D51AD27C29356B7B00967AAA /* ALTWrappedError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTWrappedError.h; sourceTree = ""; }; + D51AD27D29356B7B00967AAA /* ALTWrappedError.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTWrappedError.m; sourceTree = ""; }; D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = ""; }; D533E8B62727841800A9B5DD /* libAppleArchive.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libAppleArchive.tbd; path = usr/lib/libAppleArchive.tbd; sourceTree = SDKROOT; }; D533E8B82727B61400A9B5DD /* fragmentzip.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = fragmentzip.h; sourceTree = ""; }; D533E8BB2727BBEE00A9B5DD /* libfragmentzip.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libfragmentzip.a; path = Dependencies/fragmentzip/libfragmentzip.a; sourceTree = SOURCE_ROOT; }; D533E8BD2727BBF800A9B5DD /* libcurl.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libcurl.a; path = Dependencies/libcurl/libcurl.a; sourceTree = SOURCE_ROOT; }; + D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = ""; }; D55E163528776CB000A627A1 /* ComplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationView.swift; sourceTree = ""; }; D57DF637271E32F000677701 /* PatchApp.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = PatchApp.storyboard; sourceTree = ""; }; @@ -839,8 +848,10 @@ D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore9ToAltStore10.xcmappingmodel; sourceTree = ""; }; D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotProcessor.swift; sourceTree = ""; }; D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePatronsOperation.swift; sourceTree = ""; }; + D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTLocalizedError.swift; sourceTree = ""; }; D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTrustedSourcesOperation.swift; sourceTree = ""; }; D5F2F6A82720B7C20081CCF5 /* PatchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchViewController.swift; sourceTree = ""; }; + D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = ""; }; D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = ""; }; EA79A60285C6AF5848AA16E9 /* Pods-AltStore.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AltStore.debug.xcconfig"; path = "Target Support Files/Pods-AltStore/Pods-AltStore.debug.xcconfig"; sourceTree = ""; }; @@ -981,7 +992,7 @@ isa = PBXGroup; children = ( BF1E3128229F474900370A3C /* ServerProtocol.swift */, - BFD44605241188C300EAB90A /* CodableServerError.swift */, + BFD44605241188C300EAB90A /* CodableError.swift */, ); path = "Server Protocol"; sourceTree = ""; @@ -994,6 +1005,7 @@ BF18BFFF2485A75F00DD5981 /* Server Protocol */, BFF767CF2489AC240097E58C /* Connections */, BFF7C92D2578464D00E55F36 /* XPC */, + D5DB145728F9DC0300A8F606 /* Errors */, BFF767C32489A6800097E58C /* Extensions */, BFF767C42489A6980097E58C /* Categories */, ); @@ -1030,6 +1042,7 @@ BFAD67A225E0854500D4C4D1 /* DeveloperDiskManager.swift */, BFF0394A25F0551600BE607D /* MenuController.swift */, BF904DE9265DAE9A00E86C2A /* InstalledApp.swift */, + D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */, BFC15ADB27BC3AD100ED2FB4 /* Plugin */, BF703195229F36FF006E110F /* Devices */, BFD52BDC22A0A659000B7ED1 /* Connections */, @@ -1804,10 +1817,21 @@ children = ( D57FE84328C7DB7100216002 /* ErrorLogViewController.swift */, D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */, + D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */, ); path = "Error Log"; sourceTree = ""; }; + D5DB145728F9DC0300A8F606 /* Errors */ = { + isa = PBXGroup; + children = ( + D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */, + D51AD27C29356B7B00967AAA /* ALTWrappedError.h */, + D51AD27D29356B7B00967AAA /* ALTWrappedError.m */, + ); + path = Errors; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1867,6 +1891,7 @@ BFAECC5D2501B0BF00528F27 /* ALTConnection.h in Headers */, BF66EE942501AEBC007EE018 /* ALTAppPermission.h in Headers */, BFAECC602501B0BF00528F27 /* NSError+ALTServerError.h in Headers */, + D51AD27E29356B7B00967AAA /* ALTWrappedError.h in Headers */, BFAECC5E2501B0BF00528F27 /* CFNotificationName+AltStore.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2366,7 +2391,7 @@ BF1FE358251A9FB000C3CE09 /* NSXPCConnection+MachServices.swift in Sources */, BFECAC8F24FD950E0077C41F /* Result+Conveniences.swift in Sources */, BF8CAE472489E772004D6CCE /* DaemonRequestHandler.swift in Sources */, - BFECAC8824FD950E0077C41F /* CodableServerError.swift in Sources */, + BFECAC8824FD950E0077C41F /* CodableError.swift in Sources */, BFC712C32512D5F100AB5EBE /* XPCConnection.swift in Sources */, BFC712C52512D5F100AB5EBE /* XPCConnectionHandler.swift in Sources */, BFECAC8A24FD950E0077C41F /* ALTServerError+Conveniences.swift in Sources */, @@ -2398,7 +2423,8 @@ BFC712BB2512B9CF00AB5EBE /* PluginManager.swift in Sources */, BFECAC8224FD950B0077C41F /* ServerProtocol.swift in Sources */, BFECAC8124FD950B0077C41F /* ALTServerError+Conveniences.swift in Sources */, - BFECAC7F24FD950B0077C41F /* CodableServerError.swift in Sources */, + BFECAC7F24FD950B0077C41F /* CodableError.swift in Sources */, + D51AD28029356B8000967AAA /* ALTWrappedError.m in Sources */, BFECAC8624FD950B0077C41F /* Result+Conveniences.swift in Sources */, BF265D1925F843A000080DC9 /* NSError+AltStore.swift in Sources */, BF904DEA265DAE9A00E86C2A /* InstalledApp.swift in Sources */, @@ -2414,8 +2440,10 @@ BF0241AA22F29CCD00129732 /* UserDefaults+AltServer.swift in Sources */, BFECAC9424FD98BA0077C41F /* NSError+ALTServerError.m in Sources */, BFAD67A325E0854500D4C4D1 /* DeveloperDiskManager.swift in Sources */, + D5F5AF7D28ECEA990067C736 /* ErrorDetailsViewController.swift in Sources */, BFECAC9324FD98BA0077C41F /* CFNotificationName+AltStore.m in Sources */, BFE48975238007CE003239E0 /* AnisetteDataManager.swift in Sources */, + D5DB145B28F9DC5C00A8F606 /* ALTLocalizedError.swift in Sources */, BFE972E3260A8B2700D0BDAC /* NSError+libimobiledevice.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2488,7 +2516,6 @@ BF580496246A3CB5008AE704 /* UIColor+AltBackup.swift in Sources */, BF580482246A28F7008AE704 /* ViewController.swift in Sources */, BF44EEF0246B08BA002A52F2 /* BackupController.swift in Sources */, - BF58049B246A432D008AE704 /* NSError+AltStore.swift in Sources */, BF58047E246A28F7008AE704 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2513,7 +2540,7 @@ BF66EECD2501AECA007EE018 /* StoreAppPolicy.swift in Sources */, BF66EEE82501AED0007EE018 /* UserDefaults+AltStore.swift in Sources */, BF340E9A250AD39500A192CB /* ViewApp.intentdefinition in Sources */, - BFAECC522501B0A400528F27 /* CodableServerError.swift in Sources */, + BFAECC522501B0A400528F27 /* CodableError.swift in Sources */, BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */, BF66EEDF2501AECA007EE018 /* PatreonAccount.swift in Sources */, BFAECC532501B0A400528F27 /* ServerProtocol.swift in Sources */, @@ -2535,6 +2562,7 @@ D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */, BFBF331B2526762200B7B8C9 /* AltStore8ToAltStore9.xcmappingmodel in Sources */, D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */, + D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */, BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */, BF66EE962501AEBC007EE018 /* ALTPatreonBenefitType.m in Sources */, BFAECC5A2501B0A400528F27 /* NetworkConnection.swift in Sources */, @@ -2552,12 +2580,14 @@ D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */, BFAECC562501B0A400528F27 /* ALTServerError+Conveniences.swift in Sources */, BFAECC592501B0A400528F27 /* Result+Conveniences.swift in Sources */, + D5E3FB9828FDFAD90034B72C /* NSError+AltStore.swift in Sources */, BFAECC542501B0A400528F27 /* NSError+ALTServerError.m in Sources */, BF66EEE12501AECA007EE018 /* DatabaseManager.swift in Sources */, D52C08EE28AEC37A006C4AE5 /* AppVersion.swift in Sources */, BF66EEEA2501AED0007EE018 /* UIColor+Hex.swift in Sources */, BF66EECC2501AECA007EE018 /* Source.swift in Sources */, BF66EED72501AECA007EE018 /* InstalledApp.swift in Sources */, + D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */, BF66EECE2501AECA007EE018 /* InstalledAppPolicy.swift in Sources */, BF1FE359251A9FB000C3CE09 /* NSXPCConnection+MachServices.swift in Sources */, BF66EEA62501AEC5007EE018 /* PatreonAPI.swift in Sources */, @@ -2592,6 +2622,7 @@ BF3D649D22E7AC1B00E9056B /* PermissionPopoverViewController.swift in Sources */, D57F2C9426E01BC700B9FA39 /* UIDevice+Vibration.swift in Sources */, BFD2478F2284C8F900981D42 /* Button.swift in Sources */, + D540E93828EE1BDE000F1B0F /* ErrorDetailsViewController.swift in Sources */, BF56D2AC23DF8E170006506D /* FetchAppIDsOperation.swift in Sources */, BFC1F38D22AEE3A4003AC21A /* DownloadAppOperation.swift in Sources */, BFE6073A231ADF82002B0E8E /* SettingsViewController.swift in Sources */, @@ -2613,7 +2644,6 @@ BF8F69C422E662D300049BA1 /* AppViewController.swift in Sources */, BFF0B68E23219520007A79E1 /* PatreonViewController.swift in Sources */, BFF00D302501BD7D00746320 /* Intents.intentdefinition in Sources */, - BF6C336224197D700034FD24 /* NSError+AltStore.swift in Sources */, D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */, BFD2476E2284B9A500981D42 /* AppDelegate.swift in Sources */, BF41B806233423AE00C593A3 /* TabBarController.swift in Sources */, diff --git a/AltStore/Authentication/AuthenticationViewController.swift b/AltStore/Authentication/AuthenticationViewController.swift index c910021b..c6136cce 100644 --- a/AltStore/Authentication/AuthenticationViewController.swift +++ b/AltStore/Authentication/AuthenticationViewController.swift @@ -108,7 +108,7 @@ private extension AuthenticationViewController case .failure(let error as NSError): DispatchQueue.main.async { - let error = error.withLocalizedFailure(NSLocalizedString("Failed to Log In", comment: "")) + let error = error.withLocalizedTitle(NSLocalizedString("Failed to Log In", comment: "")) let toastView = ToastView(error: error) toastView.textLabel.textColor = .altPink diff --git a/AltStore/Components/ToastView.swift b/AltStore/Components/ToastView.swift index b58c5dfa..f2ea222c 100644 --- a/AltStore/Components/ToastView.swift +++ b/AltStore/Components/ToastView.swift @@ -51,45 +51,32 @@ class ToastView: RSTToastView var error = error as NSError var underlyingError = error.underlyingError - var preferredDuration: TimeInterval? - if let unwrappedUnderlyingError = underlyingError, error.domain == AltServerErrorDomain && error.code == ALTServerError.Code.underlyingError.rawValue { - // Treat underlyingError as the primary error. + // Treat underlyingError as the primary error, but keep localized title + failure. + + let nsError = (error as NSError) + error = (unwrappedUnderlyingError as NSError) + + if let localizedTitle = nsError.localizedTitle + { + error = error.withLocalizedTitle(localizedTitle) + } + + if let localizedFailure = nsError.localizedFailure + { + error = error.withLocalizedFailure(localizedFailure) + } - error = unwrappedUnderlyingError as NSError underlyingError = nil - - preferredDuration = .longToastViewDuration } - let text: String - let detailText: String? - - if let failure = error.localizedFailure - { - text = failure - detailText = error.localizedFailureReason ?? error.localizedRecoverySuggestion ?? underlyingError?.localizedDescription ?? error.localizedDescription - } - else if let reason = error.localizedFailureReason - { - text = reason - detailText = error.localizedRecoverySuggestion ?? underlyingError?.localizedDescription - } - else - { - text = error.localizedDescription - detailText = underlyingError?.localizedDescription ?? error.localizedRecoverySuggestion - } + let text = error.localizedTitle ?? NSLocalizedString("Operation Failed", comment: "") + let detailText = error.localizedDescription self.init(text: text, detailText: detailText) - - if let preferredDuration = preferredDuration - { - self.preferredDuration = preferredDuration - } } required init(coder aDecoder: NSCoder) { diff --git a/AltStore/Intents/IntentHandler.swift b/AltStore/Intents/IntentHandler.swift index 4a11e640..0aec1c00 100644 --- a/AltStore/Intents/IntentHandler.swift +++ b/AltStore/Intents/IntentHandler.swift @@ -127,7 +127,7 @@ private extension IntentHandler self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil)) } - catch RefreshError.noInstalledApps + catch ~RefreshErrorCode.noInstalledApps { self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil)) } diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 6b9448f6..a56e95a7 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -379,7 +379,7 @@ extension AppManager case .success(let source): fetchedSources.insert(source) case .failure(let error): let source = managedObjectContext.object(with: source.objectID) as! Source - source.error = (error as NSError).sanitizedForCoreData() + source.error = (error as NSError).sanitizedForSerialization() errors[source] = error } @@ -466,7 +466,7 @@ extension AppManager group.completionHandler = { (results) in do { - guard let result = results.values.first else { throw context.error ?? OperationError.unknown } + guard let result = results.values.first else { throw context.error ?? OperationError.unknown() } completionHandler(result) } catch @@ -485,7 +485,7 @@ extension AppManager func update(_ app: InstalledApp, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result) -> Void) -> Progress { guard let storeApp = app.storeApp else { - completionHandler(.failure(OperationError.appNotFound)) + completionHandler(.failure(OperationError.appNotFound(name: app.name))) return Progress.discreteProgress(totalUnitCount: 1) } @@ -493,7 +493,7 @@ extension AppManager group.completionHandler = { (results) in do { - guard let result = results.values.first else { throw OperationError.unknown } + guard let result = results.values.first else { throw OperationError.unknown() } completionHandler(result) } catch @@ -529,7 +529,7 @@ extension AppManager group.completionHandler = { (results) in do { - guard let result = results.values.first else { throw OperationError.unknown } + guard let result = results.values.first else { throw OperationError.unknown() } let installedApp = try result.get() assert(installedApp.managedObjectContext != nil) @@ -571,7 +571,7 @@ extension AppManager group.completionHandler = { (results) in do { - guard let result = results.values.first else { throw OperationError.unknown } + guard let result = results.values.first else { throw OperationError.unknown() } let installedApp = try result.get() assert(installedApp.managedObjectContext != nil) @@ -597,7 +597,7 @@ extension AppManager group.completionHandler = { (results) in do { - guard let result = results.values.first else { throw OperationError.unknown } + guard let result = results.values.first else { throw OperationError.unknown() } let installedApp = try result.get() assert(installedApp.managedObjectContext != nil) @@ -622,7 +622,7 @@ extension AppManager group.completionHandler = { (results) in do { - guard let result = results.values.first else { throw OperationError.unknown } + guard let result = results.values.first else { throw OperationError.unknown() } let installedApp = try result.get() assert(installedApp.managedObjectContext != nil) @@ -1270,7 +1270,7 @@ private extension AppManager case .success(let installedApp): completionHandler(.success(installedApp)) - case .failure(ALTServerError.unknownRequest), .failure(OperationError.appNotFound): + case .failure(ALTServerError.unknownRequest), .failure(~OperationError.Code.appNotFound): // Fall back to installation if AltServer doesn't support newer provisioning profile requests, // OR if the cached app could not be found and we may need to redownload it. app.managedObjectContext?.performAndWait { // Must performAndWait to ensure we add operations before we return. @@ -1544,7 +1544,7 @@ private extension AppManager } guard let application = ALTApplication(fileURL: app.fileURL) else { - completionHandler(.failure(OperationError.appNotFound)) + completionHandler(.failure(OperationError.appNotFound(name: app.name))) return progress } @@ -1556,7 +1556,7 @@ private extension AppManager let temporaryDirectoryURL = context.temporaryDirectory.appendingPathComponent("AltBackup-" + UUID().uuidString) try FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) - guard let altbackupFileURL = Bundle.main.url(forResource: "AltBackup", withExtension: "ipa") else { throw OperationError.appNotFound } + guard let altbackupFileURL = Bundle.main.url(forResource: "AltBackup", withExtension: "ipa") else { throw OperationError.appNotFound(name: "AltBackup") } let unzippedAppBundleURL = try FileManager.default.unzipAppBundle(at: altbackupFileURL, toDirectory: temporaryDirectoryURL) guard let unzippedAppBundle = Bundle(url: unzippedAppBundleURL) else { throw OperationError.invalidApp } @@ -1663,7 +1663,7 @@ private extension AppManager else { // Not preferred server, so ignore these specific errors and throw serverNotFound instead. - return ConnectionError.serverNotFound + return OperationError(.serverNotFound) } default: return error @@ -1716,8 +1716,40 @@ private extension AppManager do { try installedApp.managedObjectContext?.save() } catch { print("Error saving installed app.", error) } } - catch + catch let nsError as NSError { + var appName: String! + if let app = operation.app as? (NSManagedObject & AppProtocol) + { + if let context = app.managedObjectContext + { + context.performAndWait { + appName = app.name + } + } + else + { + appName = NSLocalizedString("App", comment: "") + } + } + else + { + appName = operation.app.name + } + + let localizedTitle: String + switch operation + { + case .install: localizedTitle = String(format: NSLocalizedString("Failed to Install %@", comment: ""), appName) + case .refresh: localizedTitle = String(format: NSLocalizedString("Failed to Refresh %@", comment: ""), appName) + case .update: localizedTitle = String(format: NSLocalizedString("Failed to Update %@", comment: ""), appName) + case .activate: localizedTitle = String(format: NSLocalizedString("Failed to Activate %@", comment: ""), appName) + case .deactivate: localizedTitle = String(format: NSLocalizedString("Failed to Deactivate %@", comment: ""), appName) + case .backup: localizedTitle = String(format: NSLocalizedString("Failed to Back Up %@", comment: ""), appName) + case .restore: localizedTitle = String(format: NSLocalizedString("Failed to Restore %@ Backup", comment: ""), appName) + } + + let error = nsError.withLocalizedTitle(localizedTitle) group.set(.failure(error), forAppWithBundleIdentifier: operation.bundleIdentifier) self.log(error, for: operation) @@ -1748,7 +1780,7 @@ private extension AppManager func log(_ error: Error, for operation: AppOperation) { // Sanitize NSError on same thread before performing background task. - let sanitizedError = (error as NSError).sanitizedForCoreData() + let sanitizedError = (error as NSError).sanitizedForSerialization() let loggedErrorOperation: LoggedError.Operation = { switch operation diff --git a/AltStore/Managing Apps/AppManagerErrors.swift b/AltStore/Managing Apps/AppManagerErrors.swift index 60fa34b9..3745b0ca 100644 --- a/AltStore/Managing Apps/AppManagerErrors.swift +++ b/AltStore/Managing Apps/AppManagerErrors.swift @@ -22,43 +22,35 @@ extension AppManager var managedObjectContext: NSManagedObjectContext? + var localizedTitle: String? { + var localizedTitle: String? + self.managedObjectContext?.performAndWait { + if self.sources?.count == 1 + { + localizedTitle = NSLocalizedString("Failed to Refresh Store", comment: "") + } + else if self.errors.count == 1 + { + guard let source = self.errors.keys.first else { return } + localizedTitle = String(format: NSLocalizedString("Failed to Refresh Source “%@”", comment: ""), source.name) + } + else + { + localizedTitle = String(format: NSLocalizedString("Failed to Refresh %@ Sources", comment: ""), NSNumber(value: self.errors.count)) + } + } + + return localizedTitle + } + var errorDescription: String? { if let error = self.primaryError { return error.localizedDescription } - else + else if let error = self.errors.values.first, self.errors.count == 1 { - var localizedDescription: String? - - self.managedObjectContext?.performAndWait { - if self.sources?.count == 1 - { - localizedDescription = NSLocalizedString("Could not refresh store.", comment: "") - } - else if self.errors.count == 1 - { - guard let source = self.errors.keys.first else { return } - localizedDescription = String(format: NSLocalizedString("Could not refresh source “%@”.", comment: ""), source.name) - } - else - { - localizedDescription = String(format: NSLocalizedString("Could not refresh %@ sources.", comment: ""), NSNumber(value: self.errors.count)) - } - } - - return localizedDescription - } - } - - var recoverySuggestion: String? { - if let error = self.primaryError as NSError? - { - return error.localizedRecoverySuggestion - } - else if self.errors.count == 1 - { - return nil + return error.localizedDescription } else { @@ -67,8 +59,18 @@ extension AppManager } var errorUserInfo: [String : Any] { - guard let error = self.errors.values.first, self.errors.count == 1 else { return [:] } - return [NSUnderlyingErrorKey: error] + let errors = Array(self.errors.values) + + var userInfo = [String: Any]() + userInfo[ALTLocalizedTitleErrorKey] = self.localizedTitle + userInfo[NSUnderlyingErrorKey] = self.primaryError + + if #available(iOS 14.5, *), !errors.isEmpty + { + userInfo[NSMultipleUnderlyingErrorsKey] = errors + } + + return userInfo } init(_ error: Error) diff --git a/AltStore/Operations/AuthenticationOperation.swift b/AltStore/Operations/AuthenticationOperation.swift index 1aaf81bd..3dbdf35f 100644 --- a/AltStore/Operations/AuthenticationOperation.swift +++ b/AltStore/Operations/AuthenticationOperation.swift @@ -13,7 +13,8 @@ import Network import AltStoreCore import AltSign -enum AuthenticationError: LocalizedError +typealias AuthenticationError = AuthenticationErrorCode.Error +enum AuthenticationErrorCode: Int, ALTErrorEnum, CaseIterable { case noTeam case noCertificate @@ -21,10 +22,10 @@ enum AuthenticationError: LocalizedError case missingPrivateKey case missingCertificate - var errorDescription: String? { + var errorFailureReason: String { switch self { - case .noTeam: return NSLocalizedString("Developer team could not be found.", comment: "") - case .noCertificate: return NSLocalizedString("Developer certificate could not be found.", comment: "") + case .noTeam: return NSLocalizedString("Your Apple ID has no developer teams.", comment: "") + case .noCertificate: return NSLocalizedString("The developer certificate could not be found.", comment: "") case .missingPrivateKey: return NSLocalizedString("The certificate's private key could not be found.", comment: "") case .missingCertificate: return NSLocalizedString("The certificate could not be found.", comment: "") } @@ -210,7 +211,7 @@ class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppl guard let account = Account.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Account.identifier), altTeam.account.identifier), in: context), let team = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), altTeam.identifier), in: context) - else { throw AuthenticationError.noTeam } + else { throw AuthenticationError(.noTeam) } // Account account.isActiveAccount = true @@ -429,7 +430,7 @@ private extension AuthenticationOperation } else { - completionHandler(.failure(error ?? OperationError.unknown)) + completionHandler(.failure(error ?? OperationError.unknown())) } } } @@ -456,7 +457,7 @@ private extension AuthenticationOperation } else { - return completionHandler(.failure(AuthenticationError.noTeam)) + return completionHandler(.failure(AuthenticationError(.noTeam))) } } @@ -488,7 +489,7 @@ private extension AuthenticationOperation do { let certificate = try Result(certificate, error).get() - guard let privateKey = certificate.privateKey else { throw AuthenticationError.missingPrivateKey } + guard let privateKey = certificate.privateKey else { throw AuthenticationError(.missingPrivateKey) } ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in do @@ -496,7 +497,7 @@ private extension AuthenticationOperation let certificates = try Result(certificates, error).get() guard let certificate = certificates.first(where: { $0.serialNumber == certificate.serialNumber }) else { - throw AuthenticationError.missingCertificate + throw AuthenticationError(.missingCertificate) } certificate.privateKey = privateKey @@ -517,7 +518,7 @@ private extension AuthenticationOperation func replaceCertificate(from certificates: [ALTCertificate]) { - guard let certificate = certificates.first(where: { $0.machineName?.starts(with: "AltStore") == true }) ?? certificates.first else { return completionHandler(.failure(AuthenticationError.noCertificate)) } + guard let certificate = certificates.first(where: { $0.machineName?.starts(with: "AltStore") == true }) ?? certificates.first else { return completionHandler(.failure(AuthenticationError(.noCertificate))) } ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { (success, error) in if let error = error, !success diff --git a/AltStore/Operations/BackgroundRefreshAppsOperation.swift b/AltStore/Operations/BackgroundRefreshAppsOperation.swift index ee8eb0de..25e51bb6 100644 --- a/AltStore/Operations/BackgroundRefreshAppsOperation.swift +++ b/AltStore/Operations/BackgroundRefreshAppsOperation.swift @@ -11,11 +11,12 @@ import CoreData import AltStoreCore -enum RefreshError: LocalizedError +typealias RefreshError = RefreshErrorCode.Error +enum RefreshErrorCode: Int, ALTErrorEnum, CaseIterable { case noInstalledApps - var errorDescription: String? { + var errorFailureReason: String { switch self { case .noInstalledApps: return NSLocalizedString("No active apps require refreshing.", comment: "") @@ -91,7 +92,7 @@ class BackgroundRefreshAppsOperation: ResultOperation<[String: Result let appName = installedApp.name self.appName = appName - guard let altstoreApp = InstalledApp.fetchAltStore(in: context) else { throw OperationError.appNotFound } + guard let altstoreApp = InstalledApp.fetchAltStore(in: context) else { throw OperationError.appNotFound(name: appName) } let altstoreOpenURL = altstoreApp.openAppURL var returnURLComponents = URLComponents(url: altstoreOpenURL, resolvingAgainstBaseURL: false) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index c386bb35..9866442b 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -12,29 +12,13 @@ import Roxas import AltStoreCore import AltSign -private extension DownloadAppOperation -{ - struct DependencyError: ALTLocalizedError - { - let dependency: Dependency - let error: Error - - var failure: String? { - return String(format: NSLocalizedString("Could not download “%@”.", comment: ""), self.dependency.preferredFilename) - } - - var underlyingError: Error? { - return self.error - } - } -} - @objc(DownloadAppOperation) class DownloadAppOperation: ResultOperation { let app: AppProtocol let context: AppOperationContext + private let appName: String private let bundleIdentifier: String private var sourceURL: URL? private let destinationURL: URL @@ -47,6 +31,7 @@ class DownloadAppOperation: ResultOperation self.app = app self.context = context + self.appName = app.name self.bundleIdentifier = app.bundleIdentifier self.sourceURL = app.url self.destinationURL = destinationURL @@ -69,7 +54,7 @@ class DownloadAppOperation: ResultOperation print("Downloading App:", self.bundleIdentifier) - guard let sourceURL = self.sourceURL else { return self.finish(.failure(OperationError.appNotFound)) } + guard let sourceURL = self.sourceURL else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) } self.downloadApp(from: sourceURL) { result in do @@ -138,7 +123,7 @@ private extension DownloadAppOperation let fileURL = try result.get() var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound } + guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) } try FileManager.default.createDirectory(at: self.temporaryDirectory, withIntermediateDirectories: true, attributes: nil) @@ -252,7 +237,7 @@ private extension DownloadAppOperation let altstorePlist = try PropertyListDecoder().decode(AltStorePlist.self, from: data) var dependencyURLs = Set() - var dependencyError: DependencyError? + var dependencyError: Error? let dispatchGroup = DispatchGroup() let progress = Progress(totalUnitCount: Int64(altstorePlist.dependencies.count), parent: self.progress, pendingUnitCount: 1) @@ -285,7 +270,7 @@ private extension DownloadAppOperation } catch let error as DecodingError { - let nsError = (error as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not download dependencies for %@.", comment: ""), application.name)) + let nsError = (error as NSError).withLocalizedFailure(String(format: NSLocalizedString("The dependencies for %@ could not be determined.", comment: ""), application.name)) completionHandler(.failure(nsError)) } catch @@ -294,7 +279,7 @@ private extension DownloadAppOperation } } - func download(_ dependency: Dependency, for application: ALTApplication, progress: Progress, completionHandler: @escaping (Result) -> Void) + func download(_ dependency: Dependency, for application: ALTApplication, progress: Progress, completionHandler: @escaping (Result) -> Void) { let downloadTask = self.session.downloadTask(with: dependency.downloadURL) { (fileURL, response, error) in do @@ -315,9 +300,10 @@ private extension DownloadAppOperation completionHandler(.success(destinationURL)) } - catch + catch let error as NSError { - completionHandler(.failure(DependencyError(dependency: dependency, error: error))) + let localizedFailure = String(format: NSLocalizedString("The dependency “%@” could not be downloaded.", comment: ""), dependency.preferredFilename) + completionHandler(.failure(error.withLocalizedFailure(localizedFailure))) } } progress.addChild(downloadTask.progress, withPendingUnitCount: 1) diff --git a/AltStore/Operations/FetchProvisioningProfilesOperation.swift b/AltStore/Operations/FetchProvisioningProfilesOperation.swift index d4f9bd8e..c3c015f5 100644 --- a/AltStore/Operations/FetchProvisioningProfilesOperation.swift +++ b/AltStore/Operations/FetchProvisioningProfilesOperation.swift @@ -45,7 +45,7 @@ class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioni let session = self.context.session else { return self.finish(.failure(OperationError.invalidParameters)) } - guard let app = self.context.app else { return self.finish(.failure(OperationError.appNotFound)) } + guard let app = self.context.app else { return self.finish(.failure(OperationError.appNotFound(name: nil))) } self.progress.totalUnitCount = Int64(1 + app.appExtensions.count) @@ -260,7 +260,7 @@ extension FetchProvisioningProfilesOperation { if let expirationDate = sortedExpirationDates.first { - throw OperationError.maximumAppIDLimitReached(application: application, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, nextExpirationDate: expirationDate) + throw OperationError.maximumAppIDLimitReached(appName: application.name, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, expirationDate: expirationDate) } else { @@ -281,7 +281,7 @@ extension FetchProvisioningProfilesOperation { if let expirationDate = sortedExpirationDates.first { - throw OperationError.maximumAppIDLimitReached(application: application, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, nextExpirationDate: expirationDate) + throw OperationError.maximumAppIDLimitReached(appName: application.name, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, expirationDate: expirationDate) } else { diff --git a/AltStore/Operations/FindServerOperation.swift b/AltStore/Operations/FindServerOperation.swift index 35d8727d..78e2ac4e 100644 --- a/AltStore/Operations/FindServerOperation.swift +++ b/AltStore/Operations/FindServerOperation.swift @@ -85,7 +85,7 @@ class FindServerOperation: ResultOperation else { // No servers. - self.finish(.failure(ConnectionError.serverNotFound)) + self.finish(.failure(OperationError.serverNotFound)) } } } diff --git a/AltStore/Operations/OperationError.swift b/AltStore/Operations/OperationError.swift index ded928f5..b7c60b98 100644 --- a/AltStore/Operations/OperationError.swift +++ b/AltStore/Operations/OperationError.swift @@ -8,55 +8,135 @@ import Foundation import AltSign +import AltStoreCore -enum OperationError: LocalizedError +extension OperationError { - static let domain = OperationError.unknown._domain + enum Code: Int, ALTErrorCode, CaseIterable + { + typealias Error = OperationError + + /* General */ + case unknown = 1000 + case unknownResult + case cancelled + case timedOut + case notAuthenticated + case appNotFound + case unknownUDID + case invalidApp + case invalidParameters + case maximumAppIDLimitReached + case noSources + case openAppFailed + case missingAppGroup + + /* Connection */ + case serverNotFound = 1200 + case connectionFailed + case connectionDropped + } - case unknown - case unknownResult - case cancelled - case timedOut + static let unknownResult: OperationError = .init(code: .unknownResult) + static let cancelled: OperationError = .init(code: .cancelled) + static let timedOut: OperationError = .init(code: .timedOut) + static let notAuthenticated: OperationError = .init(code: .notAuthenticated) + static let unknownUDID: OperationError = .init(code: .unknownUDID) + static let invalidApp: OperationError = .init(code: .invalidApp) + static let invalidParameters: OperationError = .init(code: .invalidParameters) + static let noSources: OperationError = .init(code: .noSources) + static let missingAppGroup: OperationError = .init(code: .missingAppGroup) - case notAuthenticated - case appNotFound + static let serverNotFound: OperationError = .init(code: .serverNotFound) + static let connectionFailed: OperationError = .init(code: .connectionFailed) + static let connectionDropped: OperationError = .init(code: .connectionDropped) - case unknownUDID + static func unknown(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError { + OperationError(code: .unknown, failureReason: failureReason, sourceFile: file, sourceLine: line) + } - case invalidApp - case invalidParameters + static func appNotFound(name: String?) -> OperationError { OperationError(code: .appNotFound, appName: name) } + static func openAppFailed(name: String) -> OperationError { OperationError(code: .openAppFailed, appName: name) } - case maximumAppIDLimitReached(application: ALTApplication, requiredAppIDs: Int, availableAppIDs: Int, nextExpirationDate: Date) + static func maximumAppIDLimitReached(appName: String, requiredAppIDs: Int, availableAppIDs: Int, expirationDate: Date) -> OperationError { + OperationError(code: .maximumAppIDLimitReached, appName: appName, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, expirationDate: expirationDate) + } +} + +struct OperationError: ALTLocalizedError +{ + let code: Code - case noSources + var errorTitle: String? + var errorFailure: String? - case openAppFailed(name: String) - case missingAppGroup + var appName: String? + var requiredAppIDs: Int? + var availableAppIDs: Int? + var expirationDate: Date? - var failureReason: String? { - switch self { - case .unknown: return NSLocalizedString("An unknown error occured.", comment: "") + var sourceFile: String? + var sourceLine: UInt? + + private init(code: Code, failureReason: String? = nil, appName: String? = nil, requiredAppIDs: Int? = nil, availableAppIDs: Int? = nil, expirationDate: Date? = nil, + sourceFile: String? = nil, sourceLine: UInt? = nil) + { + self.code = code + self._failureReason = failureReason + + self.appName = appName + self.requiredAppIDs = requiredAppIDs + self.availableAppIDs = availableAppIDs + self.expirationDate = expirationDate + self.sourceFile = sourceFile + self.sourceLine = sourceLine + } + + var errorFailureReason: String { + switch self.code + { + case .unknown: + var failureReason = self._failureReason ?? NSLocalizedString("An unknown error occured.", comment: "") + guard let sourceFile, let sourceLine else { return failureReason } + + failureReason += " (\(sourceFile) line \(sourceLine))" + return failureReason + case .unknownResult: return NSLocalizedString("The operation returned an unknown result.", comment: "") case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "") case .timedOut: return NSLocalizedString("The operation timed out.", comment: "") case .notAuthenticated: return NSLocalizedString("You are not signed in.", comment: "") - case .appNotFound: return NSLocalizedString("App not found.", comment: "") - case .unknownUDID: return NSLocalizedString("Unknown device UDID.", comment: "") - case .invalidApp: return NSLocalizedString("The app is invalid.", comment: "") + case .unknownUDID: return NSLocalizedString("AltStore could not determine this device's UDID.", comment: "") + case .invalidApp: return NSLocalizedString("The app is in an invalid format.", comment: "") case .invalidParameters: return NSLocalizedString("Invalid parameters.", comment: "") + case .maximumAppIDLimitReached: return NSLocalizedString("You cannot register more than 10 App IDs within a 7 day period.", comment: "") case .noSources: return NSLocalizedString("There are no AltStore sources.", comment: "") - case .openAppFailed(let name): return String(format: NSLocalizedString("AltStore was denied permission to launch %@.", comment: ""), name) - case .missingAppGroup: return NSLocalizedString("AltStore's shared app group could not be found.", comment: "") - case .maximumAppIDLimitReached: return NSLocalizedString("Cannot register more than 10 App IDs.", comment: "") + case .missingAppGroup: return NSLocalizedString("AltStore's shared app group could not be accessed.", comment: "") + + case .appNotFound: + let appName = self.appName ?? NSLocalizedString("The app", comment: "") + return String(format: NSLocalizedString("%@ could not be found.", comment: ""), appName) + + case .openAppFailed: + let appName = self.appName ?? NSLocalizedString("the app", comment: "") + return String(format: NSLocalizedString("AltStore was denied permission to launch %@.", comment: ""), appName) + + case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "") + case .connectionFailed: return NSLocalizedString("A connection to AltServer could not be established.", comment: "") + case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "") } } + private var _failureReason: String? var recoverySuggestion: String? { - switch self + switch self.code { - case .maximumAppIDLimitReached(let application, let requiredAppIDs, let availableAppIDs, let date): + case .serverNotFound: return NSLocalizedString("Make sure you're on the same WiFi network as a computer running AltServer, or try connecting this device to your computer via USB.", comment: "") + case .maximumAppIDLimitReached: let baseMessage = NSLocalizedString("Delete sideloaded apps to free up App ID slots.", comment: "") - let message: String + guard let appName = self.appName, let requiredAppIDs = self.requiredAppIDs, let availableAppIDs = self.availableAppIDs, let date = self.expirationDate else { return baseMessage } + + var message: String = "" if requiredAppIDs > 1 { @@ -69,23 +149,25 @@ enum OperationError: LocalizedError default: availableText = String(format: NSLocalizedString("only %@ are available", comment: ""), NSNumber(value: availableAppIDs)) } - let prefixMessage = String(format: NSLocalizedString("%@ requires %@ App IDs, but %@.", comment: ""), application.name, NSNumber(value: requiredAppIDs), availableText) - message = prefixMessage + " " + baseMessage + let prefixMessage = String(format: NSLocalizedString("%@ requires %@ App IDs, but %@.", comment: ""), appName, NSNumber(value: requiredAppIDs), availableText) + message = prefixMessage + " " + baseMessage + "\n\n" } else { - let dateComponents = Calendar.current.dateComponents([.day, .hour, .minute], from: Date(), to: date) - - let dateComponentsFormatter = DateComponentsFormatter() - dateComponentsFormatter.maximumUnitCount = 1 - dateComponentsFormatter.unitsStyle = .full - - let remainingTime = dateComponentsFormatter.string(from: dateComponents)! - - let remainingTimeMessage = String(format: NSLocalizedString("You can register another App ID in %@.", comment: ""), remainingTime) - message = baseMessage + " " + remainingTimeMessage + message = baseMessage + " " } + let dateComponents = Calendar.current.dateComponents([.day, .hour, .minute], from: Date(), to: date) + + let dateComponentsFormatter = DateComponentsFormatter() + dateComponentsFormatter.maximumUnitCount = 1 + dateComponentsFormatter.unitsStyle = .full + + let remainingTime = dateComponentsFormatter.string(from: dateComponents)! + + let remainingTimeMessage = String(format: NSLocalizedString("You can register another App ID in %@.", comment: ""), remainingTime) + message += remainingTimeMessage + return message default: return nil diff --git a/AltStore/Operations/Patch App/PatchAppOperation.swift b/AltStore/Operations/Patch App/PatchAppOperation.swift index af724e1e..c19723e4 100644 --- a/AltStore/Operations/Patch App/PatchAppOperation.swift +++ b/AltStore/Operations/Patch App/PatchAppOperation.swift @@ -25,21 +25,41 @@ protocol PatchAppContext var error: Error? { get } } -enum PatchAppError: LocalizedError +extension PatchAppError { - case unsupportedOperatingSystemVersion(OperatingSystemVersion) + enum Code: Int, ALTErrorCode, CaseIterable + { + typealias Error = PatchAppError + + case unsupportedOperatingSystemVersion + } - var errorDescription: String? { - switch self + static func unsupportedOperatingSystemVersion(_ osVersion: OperatingSystemVersion) -> PatchAppError { PatchAppError(code: .unsupportedOperatingSystemVersion, osVersion: osVersion) } +} + +struct PatchAppError: ALTLocalizedError +{ + let code: Code + var errorFailure: String? + var errorTitle: String? + + var osVersion: OperatingSystemVersion? + + var errorFailureReason: String { + switch self.code { - case .unsupportedOperatingSystemVersion(let osVersion): - var osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion)" - if osVersion.patchVersion != 0 + case .unsupportedOperatingSystemVersion: + let osVersionString: String + if let osVersion = self.osVersion?.stringValue { - osVersionString += ".\(osVersion.patchVersion)" + osVersionString = NSLocalizedString("iOS", comment: "") + " " + osVersion + } + else + { + osVersionString = NSLocalizedString("your device's iOS version", comment: "") } - let errorDescription = String(format: NSLocalizedString("The OTA download URL for iOS %@ could not be determined.", comment: ""), osVersionString) + let errorDescription = String(format: NSLocalizedString("The OTA download URL for %@ could not be determined.", comment: ""), osVersionString) return errorDescription } } diff --git a/AltStore/Operations/Patch App/PatchViewController.swift b/AltStore/Operations/Patch App/PatchViewController.swift index 3b7b0cfb..1c41f2ec 100644 --- a/AltStore/Operations/Patch App/PatchViewController.swift +++ b/AltStore/Operations/Patch App/PatchViewController.swift @@ -439,7 +439,7 @@ private extension PatchViewController do { - guard let (bundleIdentifier, result) = results.first else { throw refreshGroup?.context.error ?? OperationError.unknown } + guard let (bundleIdentifier, result) = results.first else { throw refreshGroup?.context.error ?? OperationError.unknown() } _ = try result.get() if var patchedApps = UserDefaults.standard.patchedApps, let index = patchedApps.firstIndex(of: bundleIdentifier) diff --git a/AltStore/Operations/RefreshAppOperation.swift b/AltStore/Operations/RefreshAppOperation.swift index 354f02f9..00306097 100644 --- a/AltStore/Operations/RefreshAppOperation.swift +++ b/AltStore/Operations/RefreshAppOperation.swift @@ -41,7 +41,7 @@ class RefreshAppOperation: ResultOperation guard let server = self.context.server, let profiles = self.context.provisioningProfiles else { throw OperationError.invalidParameters } - guard let app = self.context.app else { throw OperationError.appNotFound } + guard let app = self.context.app else { throw OperationError.appNotFound(name: nil) } guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { throw OperationError.unknownUDID } ServerManager.shared.connect(to: server) { (result) in @@ -84,7 +84,7 @@ class RefreshAppOperation: ResultOperation self.managedObjectContext.perform { let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), app.bundleIdentifier) guard let installedApp = InstalledApp.first(satisfying: predicate, in: self.managedObjectContext) else { - return self.finish(.failure(OperationError.appNotFound)) + return self.finish(.failure(OperationError.appNotFound(name: app.name))) } self.progress.completedUnitCount += 1 diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index 1daa92b0..59ccd73b 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -8,48 +8,74 @@ import Foundation +import AltStoreCore import AltSign import Roxas -enum VerificationError: ALTLocalizedError +extension VerificationError { - case privateEntitlements(ALTApplication, entitlements: [String: Any]) - case mismatchedBundleIdentifiers(ALTApplication, sourceBundleID: String) - case iOSVersionNotSupported(ALTApplication) - - var app: ALTApplication { - switch self - { - case .privateEntitlements(let app, _): return app - case .mismatchedBundleIdentifiers(let app, _): return app - case .iOSVersionNotSupported(let app): return app - } + enum Code: Int, ALTErrorCode, CaseIterable + { + typealias Error = VerificationError + + case privateEntitlements + case mismatchedBundleIdentifiers + case iOSVersionNotSupported } - var failure: String? { - return String(format: NSLocalizedString("“%@” could not be installed.", comment: ""), app.name) - } + static func privateEntitlements(_ entitlements: [String: Any], app: ALTApplication) -> VerificationError { VerificationError(code: .privateEntitlements, app: app, entitlements: entitlements) } + static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError { VerificationError(code: .mismatchedBundleIdentifiers, app: app, sourceBundleID: sourceBundleID) } + static func iOSVersionNotSupported(app: ALTApplication) -> VerificationError { VerificationError(code: .iOSVersionNotSupported, app: app) } +} + +struct VerificationError: ALTLocalizedError +{ + let code: Code - var failureReason: String? { - switch self + var errorTitle: String? + var errorFailure: String? + + var app: ALTApplication? + var entitlements: [String: Any]? + var sourceBundleID: String? + + + var errorFailureReason: String { + switch self.code { - case .privateEntitlements(let app, _): - return String(format: NSLocalizedString("“%@” requires private permissions.", comment: ""), app.name) + case .privateEntitlements: + let appName = (self.app?.name as String?).map { String(format: NSLocalizedString("“%@”", comment: ""), $0) } ?? NSLocalizedString("The app", comment: "") + return String(format: NSLocalizedString("%@ requires private permissions.", comment: ""), appName) - case .mismatchedBundleIdentifiers(let app, let sourceBundleID): - return String(format: NSLocalizedString("The bundle ID “%@” does not match the one specified by the source (“%@”).", comment: ""), app.bundleIdentifier, sourceBundleID) - - case .iOSVersionNotSupported(let app): - let name = app.name - - var version = "iOS \(app.minimumiOSVersion.majorVersion).\(app.minimumiOSVersion.minorVersion)" - if app.minimumiOSVersion.patchVersion > 0 + case .mismatchedBundleIdentifiers: + if let app = self.app, let bundleID = self.sourceBundleID { - version += ".\(app.minimumiOSVersion.patchVersion)" + return String(format: NSLocalizedString("The bundle ID “%@” does not match the one specified by the source (“%@”).", comment: ""), app.bundleIdentifier, bundleID) + } + else + { + return NSLocalizedString("The bundle ID does not match the one specified by the source.", comment: "") } - let localizedDescription = String(format: NSLocalizedString("%@ requires %@.", comment: ""), name, version) - return localizedDescription + case .iOSVersionNotSupported: + if let app = self.app + { + var version = "iOS \(app.minimumiOSVersion.majorVersion).\(app.minimumiOSVersion.minorVersion)" + if app.minimumiOSVersion.patchVersion > 0 + { + version += ".\(app.minimumiOSVersion.patchVersion)" + } + + let failureReason = String(format: NSLocalizedString("%@ requires %@.", comment: ""), app.name, version) + return failureReason + } + else + { + let version = ProcessInfo.processInfo.operatingSystemVersion.stringValue + + let failureReason = String(format: NSLocalizedString("This app does not support iOS %@.", comment: ""), version) + return failureReason + } } } } @@ -78,14 +104,17 @@ class VerifyAppOperation: ResultOperation throw error } + let appName = self.context.app?.name ?? NSLocalizedString("The app", comment: "") + self.localizedFailure = String(format: NSLocalizedString("%@ could not be installed.", comment: ""), appName) + guard let app = self.context.app else { throw OperationError.invalidParameters } guard app.bundleIdentifier == self.context.bundleIdentifier else { - throw VerificationError.mismatchedBundleIdentifiers(app, sourceBundleID: self.context.bundleIdentifier) + throw VerificationError.mismatchedBundleIdentifiers(sourceBundleID: self.context.bundleIdentifier, app: app) } guard ProcessInfo.processInfo.isOperatingSystemAtLeast(app.minimumiOSVersion) else { - throw VerificationError.iOSVersionNotSupported(app) + throw VerificationError.iOSVersionNotSupported(app: app) } if #available(iOS 13.5, *) @@ -116,7 +145,7 @@ class VerifyAppOperation: ResultOperation let entitlements = try PropertyListSerialization.propertyList(from: entitlementsPlist.data(using: .utf8)!, options: [], format: nil) as! [String: Any] app.hasPrivateEntitlements = true - let error = VerificationError.privateEntitlements(app, entitlements: entitlements) + let error = VerificationError.privateEntitlements(entitlements, app: app) self.process(error) { (result) in self.finish(result.mapError { $0 as Error }) } @@ -145,15 +174,16 @@ private extension VerifyAppOperation guard let presentingViewController = self.context.presentingViewController else { return completion(.failure(error)) } DispatchQueue.main.async { - switch error + switch error.code { - case .privateEntitlements(_, let entitlements): + case .privateEntitlements: + guard let entitlements = error.entitlements else { return completion(.failure(error)) } let permissions = entitlements.keys.sorted().joined(separator: "\n") let message = String(format: NSLocalizedString(""" You must allow access to these private permissions before continuing: - + %@ - + Private permissions allow apps to do more than normally allowed by iOS, including potentially accessing sensitive private data. Make sure to only install apps from sources you trust. """, comment: ""), permissions) diff --git a/AltStore/Server/Server.swift b/AltStore/Server/Server.swift index 4314c1df..f2a55633 100644 --- a/AltStore/Server/Server.swift +++ b/AltStore/Server/Server.swift @@ -8,22 +8,6 @@ import Network -enum ConnectionError: LocalizedError -{ - case serverNotFound - case connectionFailed - case connectionDropped - - var failureReason: String? { - switch self - { - case .serverNotFound: return NSLocalizedString("Could not find AltServer.", comment: "") - case .connectionFailed: return NSLocalizedString("Could not connect to AltServer.", comment: "") - case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "") - } - } -} - extension Server { enum ConnectionType diff --git a/AltStore/Server/ServerManager.swift b/AltStore/Server/ServerManager.swift index abc88c43..04eef659 100644 --- a/AltStore/Server/ServerManager.swift +++ b/AltStore/Server/ServerManager.swift @@ -171,7 +171,7 @@ private extension ServerManager { case .failed(let error): print("Failed to connect to service \(server.service?.name ?? "").", error) - completion(.failure(ConnectionError.connectionFailed)) + completion(.failure(OperationError.connectionFailed)) case .cancelled: completion(.failure(OperationError.cancelled)) @@ -192,7 +192,7 @@ private extension ServerManager func connectToLocalServer(_ server: Server, completion: @escaping (Result) -> Void) { - guard let machServiceName = server.machServiceName else { return completion(.failure(ConnectionError.connectionFailed)) } + guard let machServiceName = server.machServiceName else { return completion(.failure(OperationError.connectionFailed)) } let xpcConnection = NSXPCConnection.makeConnection(machServiceName: machServiceName) diff --git a/AltStore/Settings/Error Log/ErrorDetailsViewController.swift b/AltStore/Settings/Error Log/ErrorDetailsViewController.swift new file mode 100644 index 00000000..9c805aef --- /dev/null +++ b/AltStore/Settings/Error Log/ErrorDetailsViewController.swift @@ -0,0 +1,53 @@ +// +// ErrorDetailsViewController.swift +// AltStore +// +// Created by Riley Testut on 10/5/22. +// Copyright © 2022 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore + +class ErrorDetailsViewController: UIViewController +{ + var loggedError: LoggedError? + + @IBOutlet private var textView: UITextView! + + override func viewDidLoad() + { + super.viewDidLoad() + + if let error = self.loggedError?.error + { + self.title = error.localizedErrorCode + + let font = self.textView.font ?? UIFont.preferredFont(forTextStyle: .body) + let detailedDescription = error.formattedDetailedDescription(with: font) + self.textView.attributedText = detailedDescription + } + else + { + self.title = NSLocalizedString("Error Details", comment: "") + } + + self.navigationController?.navigationBar.tintColor = .altPrimary + + if #available(iOS 15, *), let sheetController = self.navigationController?.sheetPresentationController + { + sheetController.detents = [.medium(), .large()] + sheetController.selectedDetentIdentifier = .medium + sheetController.prefersGrabberVisible = true + } + } + + override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + + self.textView.textContainerInset.left = self.view.layoutMargins.left + self.textView.textContainerInset.right = self.view.layoutMargins.right + } +} diff --git a/AltStore/Settings/Error Log/ErrorLogViewController.swift b/AltStore/Settings/Error Log/ErrorLogViewController.swift index c0f7fa27..ed7acf70 100644 --- a/AltStore/Settings/Error Log/ErrorLogViewController.swift +++ b/AltStore/Settings/Error Log/ErrorLogViewController.swift @@ -37,6 +37,20 @@ class ErrorLogViewController: UITableViewController self.tableView.dataSource = self.dataSource self.tableView.prefetchDataSource = self.dataSource } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) + { + guard let loggedError = sender as? LoggedError, segue.identifier == "showErrorDetails" else { return } + + let navigationController = segue.destination as! UINavigationController + + let errorDetailsViewController = navigationController.viewControllers.first as! ErrorDetailsViewController + errorDetailsViewController.loggedError = loggedError + } + + @IBAction private func unwindFromErrorDetails(_ segue: UIStoryboardSegue) + { + } } private extension ErrorLogViewController @@ -58,13 +72,7 @@ private extension ErrorLogViewController let cell = cell as! ErrorLogTableViewCell cell.dateLabel.text = self.timeFormatter.string(from: loggedError.date) cell.errorFailureLabel.text = loggedError.localizedFailure ?? NSLocalizedString("Operation Failed", comment: "") - - switch loggedError.domain - { - case AltServerErrorDomain: cell.errorCodeLabel?.text = String(format: NSLocalizedString("AltServer Error %@", comment: ""), NSNumber(value: loggedError.code)) - case OperationError.domain: cell.errorCodeLabel?.text = String(format: NSLocalizedString("AltStore Error %@", comment: ""), NSNumber(value: loggedError.code)) - default: cell.errorCodeLabel?.text = loggedError.error.localizedErrorCode - } + cell.errorCodeLabel.text = loggedError.error.localizedErrorCode let nsError = loggedError.error as NSError let errorDescription = [nsError.localizedDescription, nsError.localizedRecoverySuggestion].compactMap { $0 }.joined(separator: "\n\n") @@ -91,7 +99,10 @@ private extension ErrorLogViewController }, UIAction(title: NSLocalizedString("Search FAQ", comment: ""), image: UIImage(systemName: "magnifyingglass")) { [weak self] _ in self?.searchFAQ(for: loggedError) - } + }, + UIAction(title: NSLocalizedString("View More Details", comment: ""), image: UIImage(systemName: "ellipsis.circle")) { [weak self] _ in + self?.viewMoreDetails(for: loggedError) + }, ]) cell.menuButton.menu = menu @@ -224,13 +235,18 @@ private extension ErrorLogViewController let baseURL = URL(string: "https://faq.altstore.io/getting-started/troubleshooting-guide")! var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! - let query = [loggedError.domain, "\(loggedError.code)"].joined(separator: "+") + let query = [loggedError.domain, "\(loggedError.error.displayCode)"].joined(separator: "+") components.queryItems = [URLQueryItem(name: "q", value: query)] let safariViewController = SFSafariViewController(url: components.url ?? baseURL) safariViewController.preferredControlTintColor = .altPrimary self.present(safariViewController, animated: true) } + + func viewMoreDetails(for loggedError: LoggedError) + { + self.performSegue(withIdentifier: "showErrorDetails", sender: loggedError) + } } extension ErrorLogViewController diff --git a/AltStore/Settings/Settings.storyboard b/AltStore/Settings/Settings.storyboard index 7a6f51bb..7e54e313 100644 --- a/AltStore/Settings/Settings.storyboard +++ b/AltStore/Settings/Settings.storyboard @@ -991,11 +991,73 @@ Settings by i cons from the Noun Project + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1009,5 +1071,8 @@ Settings by i cons from the Noun Project + + + diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift index 38bcefd1..34fb8745 100644 --- a/AltStore/Sources/SourcesViewController.swift +++ b/AltStore/Sources/SourcesViewController.swift @@ -12,17 +12,22 @@ import CoreData import AltStoreCore import Roxas -struct SourceError: LocalizedError +struct SourceError: ALTLocalizedError { - enum Code + enum Code: Int, ALTErrorCode { + typealias Error = SourceError + case unsupported } var code: Code + var errorTitle: String? + var errorFailure: String? + @Managed var source: Source - var errorDescription: String? { + var errorFailureReason: String { switch self.code { case .unsupported: return String(format: NSLocalizedString("The source “%@” is not supported by this version of AltStore.", comment: ""), self.$source.name) diff --git a/AltStoreCore/AltStoreCore.h b/AltStoreCore/AltStoreCore.h index 73d95a9c..02ddf3b1 100644 --- a/AltStoreCore/AltStoreCore.h +++ b/AltStoreCore/AltStoreCore.h @@ -23,5 +23,6 @@ FOUNDATION_EXPORT const unsigned char AltStoreCoreVersionString[]; // Shared #import #import +#import #import #import diff --git a/AltStoreCore/Patreon/PatreonAPI.swift b/AltStoreCore/Patreon/PatreonAPI.swift index 92db9608..31da8653 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -15,24 +15,25 @@ private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswux private let campaignID = "2863968" -extension PatreonAPI +typealias PatreonAPIError = PatreonAPIErrorCode.Error +enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable { - enum Error: LocalizedError - { - case unknown - case notAuthenticated - case invalidAccessToken - - var errorDescription: String? { - switch self - { - case .unknown: return NSLocalizedString("An unknown error occurred.", comment: "") - case .notAuthenticated: return NSLocalizedString("No connected Patreon account.", comment: "") - case .invalidAccessToken: return NSLocalizedString("Invalid access token.", comment: "") - } + case unknown + case notAuthenticated + case invalidAccessToken + + var errorFailureReason: String { + switch self + { + case .unknown: return NSLocalizedString("An unknown error occurred.", comment: "") + case .notAuthenticated: return NSLocalizedString("No connected Patreon account.", comment: "") + case .invalidAccessToken: return NSLocalizedString("Invalid access token.", comment: "") } } - +} + +extension PatreonAPI +{ enum AuthorizationType { case none @@ -110,7 +111,7 @@ public extension PatreonAPI let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }), let code = codeQueryItem.value - else { throw Error.unknown } + else { throw PatreonAPIError(.unknown) } self.fetchAccessToken(oauthCode: code) { (result) in switch result @@ -151,9 +152,9 @@ public extension PatreonAPI self.send(request, authorizationType: .user) { (result: Result) in switch result { - case .failure(Error.notAuthenticated): + case .failure(~PatreonAPIErrorCode.notAuthenticated): self.signOut() { (result) in - completion(.failure(Error.notAuthenticated)) + completion(.failure(PatreonAPIError(.notAuthenticated))) } case .failure(let error): completion(.failure(error)) @@ -357,11 +358,11 @@ private extension PatreonAPI { case .none: break case .creator: - guard let creatorAccessToken = Keychain.shared.patreonCreatorAccessToken else { return completion(.failure(Error.invalidAccessToken)) } + guard let creatorAccessToken = Keychain.shared.patreonCreatorAccessToken else { return completion(.failure(PatreonAPIError(.invalidAccessToken))) } request.setValue("Bearer " + creatorAccessToken, forHTTPHeaderField: "Authorization") case .user: - guard let accessToken = Keychain.shared.patreonAccessToken else { return completion(.failure(Error.notAuthenticated)) } + guard let accessToken = Keychain.shared.patreonAccessToken else { return completion(.failure(PatreonAPIError(.notAuthenticated))) } request.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization") } @@ -374,8 +375,8 @@ private extension PatreonAPI { switch authorizationType { - case .creator: completion(.failure(Error.invalidAccessToken)) - case .none: completion(.failure(Error.notAuthenticated)) + case .creator: completion(.failure(PatreonAPIError(.invalidAccessToken))) + case .none: completion(.failure(PatreonAPIError(.notAuthenticated))) case .user: self.refreshAccessToken() { (result) in switch result diff --git a/Dependencies/AltSign b/Dependencies/AltSign index 3d6fbeac..88aac399 160000 --- a/Dependencies/AltSign +++ b/Dependencies/AltSign @@ -1 +1 @@ -Subproject commit 3d6fbeac25cf3cc3302ede5cee2888733eded399 +Subproject commit 88aac399cff2001c5f00b0306a4d7d1507777a29 diff --git a/Shared/Categories/NSError+ALTServerError.m b/Shared/Categories/NSError+ALTServerError.m index 534bca30..205a5dc7 100644 --- a/Shared/Categories/NSError+ALTServerError.m +++ b/Shared/Categories/NSError+ALTServerError.m @@ -8,9 +8,15 @@ #import "NSError+ALTServerError.h" -NSErrorDomain const AltServerErrorDomain = @"com.rileytestut.AltServer"; -NSErrorDomain const AltServerInstallationErrorDomain = @"com.rileytestut.AltServer.Installation"; -NSErrorDomain const AltServerConnectionErrorDomain = @"com.rileytestut.AltServer.Connection"; +#if TARGET_OS_OSX +#import "AltServer-Swift.h" +#else +#import +#endif + +NSErrorDomain const AltServerErrorDomain = @"AltServer.ServerError"; +NSErrorDomain const AltServerInstallationErrorDomain = @"Apple.InstallationError"; +NSErrorDomain const AltServerConnectionErrorDomain = @"AltServer.ConnectionError"; NSErrorUserInfoKey const ALTUnderlyingErrorDomainErrorKey = @"underlyingErrorDomain"; NSErrorUserInfoKey const ALTUnderlyingErrorCodeErrorKey = @"underlyingErrorCode"; @@ -24,8 +30,16 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste + (void)load { - [NSError setUserInfoValueProviderForDomain:AltServerErrorDomain provider:^id _Nullable(NSError * _Nonnull error, NSErrorUserInfoKey _Nonnull userInfoKey) { - if ([userInfoKey isEqualToString:NSLocalizedFailureReasonErrorKey]) + [NSError alt_setUserInfoValueProviderForDomain:AltServerErrorDomain provider:^id _Nullable(NSError * _Nonnull error, NSErrorUserInfoKey _Nonnull userInfoKey) { + if ([userInfoKey isEqualToString:NSLocalizedDescriptionKey]) + { + return [error altserver_localizedDescription]; + } + else if ([userInfoKey isEqualToString:NSLocalizedFailureErrorKey]) + { + return [error altserver_localizedFailure]; + } + else if ([userInfoKey isEqualToString:NSLocalizedFailureReasonErrorKey]) { return [error altserver_localizedFailureReason]; } @@ -41,10 +55,10 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste return nil; }]; - [NSError setUserInfoValueProviderForDomain:AltServerConnectionErrorDomain provider:^id _Nullable(NSError * _Nonnull error, NSErrorUserInfoKey _Nonnull userInfoKey) { - if ([userInfoKey isEqualToString:NSLocalizedDescriptionKey]) + [NSError alt_setUserInfoValueProviderForDomain:AltServerConnectionErrorDomain provider:^id _Nullable(NSError * _Nonnull error, NSErrorUserInfoKey _Nonnull userInfoKey) { + if ([userInfoKey isEqualToString:NSLocalizedFailureReasonErrorKey]) { - return [error altserver_connection_localizedDescription]; + return [error altserver_connection_localizedFailureReason]; } else if ([userInfoKey isEqualToString:NSLocalizedRecoverySuggestionErrorKey]) { @@ -55,6 +69,53 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste }]; } +- (nullable NSString *)altserver_localizedDescription +{ + switch ((ALTServerError)self.code) + { + case ALTServerErrorUnderlyingError: + { + // We're wrapping another error, so return the wrapped error's localized description. + NSError *underlyingError = self.userInfo[NSUnderlyingErrorKey]; + return underlyingError.localizedDescription; + } + + default: + return nil; + } +} + +- (nullable NSString *)altserver_localizedFailure +{ + switch ((ALTServerError)self.code) + { + case ALTServerErrorUnderlyingError: + { + NSError *underlyingError = self.userInfo[NSUnderlyingErrorKey]; + return underlyingError.alt_localizedFailure; + } + + case ALTServerErrorConnectionFailed: + { + NSError *underlyingError = self.userInfo[NSUnderlyingErrorKey]; + if (underlyingError.localizedFailureReason != nil) + { + // Only return localized failure if there is an underlying error with failure reason. +#if TARGET_OS_OSX + return NSLocalizedString(@"There was an error connecting to the device.", @""); +#else + return NSLocalizedString(@"AltServer could not establish a connection to AltStore.", @""); +#endif + } + + return nil; + } + + default: + return nil; + } +} + - (nullable NSString *)altserver_localizedFailureReason { switch ((ALTServerError)self.code) @@ -73,6 +134,7 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste return [NSString stringWithFormat:NSLocalizedString(@"Error code: %@", @""), underlyingErrorCode]; } + // Return nil because this is a "pass-through" error, so if underlyingError doesn't have failure reason, this doesn't either. return nil; } @@ -80,20 +142,30 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste return NSLocalizedString(@"An unknown error occured.", @""); case ALTServerErrorConnectionFailed: + { + NSError *underlyingError = self.userInfo[NSUnderlyingErrorKey]; + if (underlyingError.localizedFailureReason != nil) + { + return underlyingError.localizedFailureReason; + } + + // Return fallback failure reason if there isn't an underlying error with failure reason. + #if TARGET_OS_OSX return NSLocalizedString(@"There was an error connecting to the device.", @""); #else - return NSLocalizedString(@"Could not connect to AltServer.", @""); + return NSLocalizedString(@"AltServer could not establish a connection to AltStore.", @""); #endif + } case ALTServerErrorLostConnection: - return NSLocalizedString(@"Lost connection to AltServer.", @""); + return NSLocalizedString(@"The connection to AltServer was lost.", @""); case ALTServerErrorDeviceNotFound: return NSLocalizedString(@"AltServer could not find this device.", @""); case ALTServerErrorDeviceWriteFailed: - return NSLocalizedString(@"Failed to write app data to device.", @""); + return NSLocalizedString(@"AltServer could not write data to this device.", @""); case ALTServerErrorInvalidRequest: return NSLocalizedString(@"AltServer received an invalid request.", @""); @@ -102,13 +174,21 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste return NSLocalizedString(@"AltServer sent an invalid response.", @""); case ALTServerErrorInvalidApp: - return NSLocalizedString(@"The app is invalid.", @""); + return NSLocalizedString(@"The app is in an invalid format.", @""); case ALTServerErrorInstallationFailed: - return NSLocalizedString(@"An error occured while installing the app.", @""); + { + NSError *underlyingError = self.userInfo[NSUnderlyingErrorKey]; + if (underlyingError != nil) + { + return underlyingError.localizedFailureReason ?: underlyingError.localizedDescription; + } + + return NSLocalizedString(@"An error occurred while installing the app.", @""); + } case ALTServerErrorMaximumFreeAppLimitReached: - return NSLocalizedString(@"Cannot activate more than 3 apps with a non-developer Apple ID.", @""); + return NSLocalizedString(@"You cannot activate more than 3 apps with a non-developer Apple ID.", @""); case ALTServerErrorUnsupportediOSVersion: return NSLocalizedString(@"Your device must be running iOS 12.2 or later to install AltStore.", @""); @@ -117,7 +197,7 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste return NSLocalizedString(@"AltServer does not support this request.", @""); case ALTServerErrorUnknownResponse: - return NSLocalizedString(@"Received an unknown response from AltServer.", @""); + return NSLocalizedString(@"AltStore received an unknown response from AltServer.", @""); case ALTServerErrorInvalidAnisetteData: return NSLocalizedString(@"The provided anisette data is invalid.", @""); @@ -141,7 +221,7 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste case ALTServerErrorIncompatibleDeveloperDisk: { NSString *osVersion = [self altserver_osVersion] ?: NSLocalizedString(@"this device's OS version", @""); - NSString *failureReason = [NSString stringWithFormat:NSLocalizedString(@"The disk is incompatible with %@.", @""), osVersion]; + NSString *failureReason = [NSString stringWithFormat:NSLocalizedString(@"The disk is incompatible with %@.", @""), osVersion]; // "Developer" disk is included in localizedFailure return failureReason; } } @@ -153,7 +233,24 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste { switch ((ALTServerError)self.code) { + case ALTServerErrorUnderlyingError: + { + NSError *underlyingError = self.userInfo[NSUnderlyingErrorKey]; + return underlyingError.localizedRecoverySuggestion; + } + case ALTServerErrorConnectionFailed: + { + NSError *underlyingError = self.userInfo[NSUnderlyingErrorKey]; + if (underlyingError.localizedRecoverySuggestion != nil) + { + return underlyingError.localizedRecoverySuggestion; + } + + // If there is no underlying error, fall through to ALTServerErrorDeviceNotFound. + // return nil; + } + case ALTServerErrorDeviceNotFound: return NSLocalizedString(@"Make sure you have trusted this device with your computer and WiFi sync is enabled.", @""); @@ -182,6 +279,12 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste { switch ((ALTServerError)self.code) { + case ALTServerErrorUnderlyingError: + { + NSError *underlyingError = self.userInfo[NSUnderlyingErrorKey]; + return underlyingError.alt_localizedDebugDescription; + } + case ALTServerErrorIncompatibleDeveloperDisk: { NSString *path = self.userInfo[NSFilePathErrorKey]; @@ -191,7 +294,7 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste } NSString *osVersion = [self altserver_osVersion] ?: NSLocalizedString(@"this device's OS version", @""); - NSString *debugDescription = [NSString stringWithFormat:NSLocalizedString(@"The Developer disk located at\n\n%@\n\nis incompatible with %@.", @""), path, osVersion]; + NSString *debugDescription = [NSString stringWithFormat:NSLocalizedString(@"The Developer disk located at %@ is incompatible with %@.", @""), path, osVersion]; return debugDescription; } @@ -232,7 +335,7 @@ NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSyste #pragma mark - AltServerConnectionErrorDomain - -- (nullable NSString *)altserver_connection_localizedDescription +- (nullable NSString *)altserver_connection_localizedFailureReason { switch ((ALTServerConnectionError)self.code) { diff --git a/Shared/Errors/ALTLocalizedError.swift b/Shared/Errors/ALTLocalizedError.swift new file mode 100644 index 00000000..1a50541d --- /dev/null +++ b/Shared/Errors/ALTLocalizedError.swift @@ -0,0 +1,182 @@ +// +// ALTLocalizedError.swift +// AltStore +// +// Created by Riley Testut on 10/14/22. +// Copyright © 2022 Riley Testut. All rights reserved. +// + +import Foundation +import AltSign + +public let ALTLocalizedTitleErrorKey = "ALTLocalizedTitle" +public let ALTLocalizedDescriptionKey = "ALTLocalizedDescription" + +public protocol ALTLocalizedError: LocalizedError, CustomNSError, CustomStringConvertible +{ + associatedtype Code: ALTErrorCode + + var code: Code { get } + var errorFailureReason: String { get } + + var errorTitle: String? { get set } + var errorFailure: String? { get set } + + var sourceFile: String? { get set } + var sourceLine: UInt? { get set } +} + +public extension ALTLocalizedError +{ + var sourceFile: String? { + get { nil } + set {} + } + + var sourceLine: UInt? { + get { nil } + set {} + } +} + +public protocol ALTErrorCode: RawRepresentable where RawValue == Int +{ + associatedtype Error: ALTLocalizedError where Error.Code == Self + + static var errorDomain: String { get } // Optional +} + +public protocol ALTErrorEnum: ALTErrorCode +{ + associatedtype Error = DefaultLocalizedError + + var errorFailureReason: String { get } +} + +/// LocalizedError & CustomNSError & CustomStringConvertible +public extension ALTLocalizedError +{ + var errorCode: Int { self.code.rawValue } + + var errorDescription: String? { + guard (self as NSError).localizedFailure == nil else { + // Error has localizedFailure, so return nil to construct localizedDescription from it + localizedFailureReason. + return nil + } + + // Otherwise, return failureReason for localizedDescription to avoid system prepending "Operation Failed" message. + return self.failureReason + } + + var failureReason: String? { + return self.errorFailureReason + } + + var errorUserInfo: [String : Any] { + let userInfo: [String: Any?] = [ + NSLocalizedFailureErrorKey: self.errorFailure, + ALTLocalizedTitleErrorKey: self.errorTitle, + ALTSourceFileErrorKey: self.sourceFile, + ALTSourceLineErrorKey: self.sourceLine, + ] + + return userInfo.compactMapValues { $0 } + } + + var description: String { + let description = "\(self.localizedErrorCode) “\(self.localizedDescription)”" + return description + } +} + +/// Default Implementations +public extension ALTLocalizedError where Code: ALTErrorEnum +{ + static var errorDomain: String { + return Code.errorDomain + } + + // ALTErrorEnum Codes provide their failure reason directly. + var errorFailureReason: String { + return self.code.errorFailureReason + } +} + +/// Default Implementations +public extension ALTErrorCode +{ + static var errorDomain: String { + let typeName = String(reflecting: Self.self) // "\(Self.self)" doesn't include module name, but String(reflecting:) does. + let errorDomain = typeName.replacingOccurrences(of: "ErrorCode", with: "Error") + return errorDomain + } +} + +public extension ALTLocalizedError +{ + // Allows us to initialize errors with localizedTitle + localizedFailure + // while still using the error's custom initializer at callsite. + init(_ error: Self, localizedTitle: String? = nil, localizedFailure: String? = nil) + { + self = error + + if let localizedTitle + { + self.errorTitle = localizedTitle + } + + if let localizedFailure + { + self.errorFailure = localizedFailure + } + } +} + +public struct DefaultLocalizedError: ALTLocalizedError +{ + public let code: Code + + public var errorTitle: String? + public var errorFailure: String? + public var sourceFile: String? + public var sourceLine: UInt? + + public init(_ code: Code, localizedTitle: String? = nil, localizedFailure: String? = nil, sourceFile: String? = #fileID, sourceLine: UInt? = #line) + { + self.code = code + self.errorTitle = localizedTitle + self.errorFailure = localizedFailure + self.sourceFile = sourceFile + self.sourceLine = sourceLine + } +} + +/// Custom Operators +/// These allow us to pattern match ALTErrorCodes against arbitrary errors via ~ prefix. +prefix operator ~ +public prefix func ~(expression: Code) -> NSError +{ + let nsError = NSError(domain: Code.errorDomain, code: expression.rawValue) + return nsError +} + +public func ~=(pattern: any Swift.Error, value: any Swift.Error) -> Bool +{ + let isMatch = pattern._domain == value._domain && pattern._code == value._code + return isMatch +} + +// These operators *should* allow us to match ALTErrorCodes against arbitrary errors, +// but they don't work as of iOS 16.1 and Swift 5.7. +// +//public func ~=(pattern: Error, value: Swift.Error) -> Bool +//{ +// let isMatch = pattern._domain == value._domain && pattern._code == value._code +// return isMatch +//} +// +//public func ~=(pattern: Code, value: Swift.Error) -> Bool +//{ +// let isMatch = Code.errorDomain == value._domain && pattern.rawValue == value._code +// return isMatch +//} diff --git a/Shared/Errors/ALTWrappedError.h b/Shared/Errors/ALTWrappedError.h new file mode 100644 index 00000000..cc44ed37 --- /dev/null +++ b/Shared/Errors/ALTWrappedError.h @@ -0,0 +1,25 @@ +// +// ALTWrappedError.h +// AltStoreCore +// +// Created by Riley Testut on 11/28/22. +// Copyright © 2022 Riley Testut. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +// Overrides localizedDescription to check userInfoValueProvider for failure reason +// instead of default behavior which just returns NSLocalizedFailureErrorKey if present. +// +// Must be written in Objective-C for Swift.Error <-> NSError bridging to work correctly. +@interface ALTWrappedError : NSError + +@property (copy, nonatomic) NSError *wrappedError; + +- (instancetype)initWithError:(NSError *)error userInfo:(NSDictionary *)userInfo; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Shared/Errors/ALTWrappedError.m b/Shared/Errors/ALTWrappedError.m new file mode 100644 index 00000000..5b58ec9c --- /dev/null +++ b/Shared/Errors/ALTWrappedError.m @@ -0,0 +1,73 @@ +// +// ALTWrappedError.m +// AltStoreCore +// +// Created by Riley Testut on 11/28/22. +// Copyright © 2022 Riley Testut. All rights reserved. +// + +#import "ALTWrappedError.h" + +@implementation ALTWrappedError + ++ (BOOL)supportsSecureCoding +{ + // Required in order to serialize errors for legacy AltServer communication. + return YES; +} + +- (instancetype)initWithError:(NSError *)error userInfo:(NSDictionary *)userInfo +{ + self = [super initWithDomain:error.domain code:error.code userInfo:userInfo]; + if (self) + { + if ([error isKindOfClass:[ALTWrappedError class]]) + { + _wrappedError = [(ALTWrappedError *)error wrappedError]; + } + else + { + _wrappedError = [error copy]; + } + } + + return self; +} + +- (NSString *)localizedDescription +{ + NSString *localizedFailure = self.userInfo[NSLocalizedFailureErrorKey]; + if (localizedFailure != nil) + { + NSString *wrappedLocalizedDescription = self.wrappedError.userInfo[NSLocalizedDescriptionKey]; + NSString *localizedFailureReason = wrappedLocalizedDescription ?: self.wrappedError.localizedFailureReason ?: self.wrappedError.localizedDescription; + + NSString *localizedDescription = [NSString stringWithFormat:@"%@ %@", localizedFailure, localizedFailureReason]; + return localizedDescription; + } + + // localizedFailure is nil, so return wrappedError's localizedDescription. + return self.wrappedError.localizedDescription; +} + +- (NSString *)localizedFailureReason +{ + return self.wrappedError.localizedFailureReason; +} + +- (NSString *)localizedRecoverySuggestion +{ + return self.wrappedError.localizedRecoverySuggestion; +} + +- (NSString *)debugDescription +{ + return self.wrappedError.debugDescription; +} + +- (NSString *)helpAnchor +{ + return self.wrappedError.helpAnchor; +} + +@end diff --git a/Shared/Extensions/ALTServerError+Conveniences.swift b/Shared/Extensions/ALTServerError+Conveniences.swift index 8ca47903..2078b25e 100644 --- a/Shared/Extensions/ALTServerError+Conveniences.swift +++ b/Shared/Extensions/ALTServerError+Conveniences.swift @@ -21,12 +21,10 @@ public extension ALTServerError case is DecodingError: self = ALTServerError(.invalidRequest, underlyingError: error) case is EncodingError: self = ALTServerError(.invalidResponse, underlyingError: error) case let error as NSError: + // Assign error as underlying error, even if there already is one, + // because it'll still be accessible via error.underlyingError.underlyingError. var userInfo = error.userInfo - if !userInfo.keys.contains(NSUnderlyingErrorKey) - { - // Assign underlying error (if there isn't already one). - userInfo[NSUnderlyingErrorKey] = error - } + userInfo[NSUnderlyingErrorKey] = error self = ALTServerError(.underlyingError, userInfo: userInfo) } diff --git a/Shared/Extensions/NSError+AltStore.swift b/Shared/Extensions/NSError+AltStore.swift index 55288d49..d0ae9562 100644 --- a/Shared/Extensions/NSError+AltStore.swift +++ b/Shared/Extensions/NSError+AltStore.swift @@ -8,7 +8,17 @@ import Foundation -extension NSError +#if canImport(UIKit) +import UIKit +public typealias ALTFont = UIFont +#elseif canImport(AppKit) +import AppKit +public typealias ALTFont = NSFont +#endif + +import AltSign + +public extension NSError { @objc(alt_localizedFailure) var localizedFailure: String? { @@ -22,50 +32,56 @@ extension NSError return debugDescription } + @objc(alt_localizedTitle) + var localizedTitle: String? { + let localizedTitle = self.userInfo[ALTLocalizedTitleErrorKey] as? String + return localizedTitle + } + @objc(alt_errorWithLocalizedFailure:) func withLocalizedFailure(_ failure: String) -> NSError { - var userInfo = self.userInfo - userInfo[NSLocalizedFailureErrorKey] = failure - - if let failureReason = self.localizedFailureReason + switch self { - userInfo[NSLocalizedFailureReasonErrorKey] = failureReason + case var error as any ALTLocalizedError: + error.errorFailure = failure + return error as NSError + + default: + var userInfo = self.userInfo + userInfo[NSLocalizedFailureErrorKey] = failure + + let error = ALTWrappedError(error: self, userInfo: userInfo) + return error } - else if self.localizedFailure == nil && self.localizedFailureReason == nil && self.localizedDescription.contains(self.localizedErrorCode) - { - // Default localizedDescription, so replace with just the localized error code portion. - userInfo[NSLocalizedFailureReasonErrorKey] = "(\(self.localizedErrorCode).)" - } - else - { - userInfo[NSLocalizedFailureReasonErrorKey] = self.localizedDescription - } - - if let localizedDescription = NSError.userInfoValueProvider(forDomain: self.domain)?(self, NSLocalizedDescriptionKey) as? String - { - userInfo[NSLocalizedDescriptionKey] = localizedDescription - } - - // Don't accidentally remove localizedDescription from dictionary - // userInfo[NSLocalizedDescriptionKey] = NSError.userInfoValueProvider(forDomain: self.domain)?(self, NSLocalizedDescriptionKey) as? String - - if let recoverySuggestion = self.localizedRecoverySuggestion - { - userInfo[NSLocalizedRecoverySuggestionErrorKey] = recoverySuggestion - } - - let error = NSError(domain: self.domain, code: self.code, userInfo: userInfo) - return error } - func sanitizedForCoreData() -> NSError + @objc(alt_errorWithLocalizedTitle:) + func withLocalizedTitle(_ title: String) -> NSError + { + switch self + { + case var error as any ALTLocalizedError: + error.errorTitle = title + return error as NSError + + default: + var userInfo = self.userInfo + userInfo[ALTLocalizedTitleErrorKey] = title + + let error = ALTWrappedError(error: self, userInfo: userInfo) + return error + } + } + + func sanitizedForSerialization() -> NSError { var userInfo = self.userInfo - userInfo[NSLocalizedFailureErrorKey] = self.localizedFailure userInfo[NSLocalizedDescriptionKey] = self.localizedDescription + userInfo[NSLocalizedFailureErrorKey] = self.localizedFailure userInfo[NSLocalizedFailureReasonErrorKey] = self.localizedFailureReason userInfo[NSLocalizedRecoverySuggestionErrorKey] = self.localizedRecoverySuggestion + userInfo[NSDebugDescriptionErrorKey] = self.localizedDebugDescription // Remove userInfo values that don't conform to NSSecureEncoding. userInfo = userInfo.filter { (key, value) in @@ -75,22 +91,160 @@ extension NSError // Sanitize underlying errors. if let underlyingError = userInfo[NSUnderlyingErrorKey] as? Error { - let sanitizedError = (underlyingError as NSError).sanitizedForCoreData() + let sanitizedError = (underlyingError as NSError).sanitizedForSerialization() userInfo[NSUnderlyingErrorKey] = sanitizedError } if #available(iOS 14.5, macOS 11.3, *), let underlyingErrors = userInfo[NSMultipleUnderlyingErrorsKey] as? [Error] { - let sanitizedErrors = underlyingErrors.map { ($0 as NSError).sanitizedForCoreData() } + let sanitizedErrors = underlyingErrors.map { ($0 as NSError).sanitizedForSerialization() } userInfo[NSMultipleUnderlyingErrorsKey] = sanitizedErrors } let error = NSError(domain: self.domain, code: self.code, userInfo: userInfo) return error } + + func formattedDetailedDescription(with font: ALTFont) -> NSAttributedString + { + #if canImport(UIKit) + let boldFontDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor + let boldFont = ALTFont(descriptor: boldFontDescriptor, size: font.pointSize) + #else + let boldFontDescriptor = font.fontDescriptor.withSymbolicTraits(.bold) + let boldFont = ALTFont(descriptor: boldFontDescriptor, size: font.pointSize) ?? font + #endif + + var preferredKeyOrder = [ + NSDebugDescriptionErrorKey, + NSLocalizedDescriptionKey, + NSLocalizedFailureErrorKey, + NSLocalizedFailureReasonErrorKey, + NSLocalizedRecoverySuggestionErrorKey, + ALTLocalizedTitleErrorKey, + ALTSourceFileErrorKey, + ALTSourceLineErrorKey, + NSUnderlyingErrorKey + ] + + if #available(iOS 14.5, macOS 11.3, *) + { + preferredKeyOrder.append(NSMultipleUnderlyingErrorsKey) + } + + var userInfo = self.userInfo + userInfo[NSDebugDescriptionErrorKey] = self.localizedDebugDescription + userInfo[NSLocalizedDescriptionKey] = self.localizedDescription + userInfo[NSLocalizedFailureErrorKey] = self.localizedFailure + userInfo[NSLocalizedFailureReasonErrorKey] = self.localizedFailureReason + userInfo[NSLocalizedRecoverySuggestionErrorKey] = self.localizedRecoverySuggestion + + let sortedUserInfo = userInfo.sorted { (a, b) in + let indexA = preferredKeyOrder.firstIndex(of: a.key) + let indexB = preferredKeyOrder.firstIndex(of: b.key) + + switch (indexA, indexB) + { + case (let indexA?, let indexB?): return indexA < indexB + case (_?, nil): return true // indexA exists, indexB is nil, so A should come first. + case (nil, _?): return false // indexA is nil, indexB exists, so B should come first. + case (nil, nil): return a.key < b.key // both indexes are nil, so sort alphabetically. + } + } + + let detailedDescription = NSMutableAttributedString() + + for (key, value) in sortedUserInfo + { + let keyName: String + switch key + { + case NSDebugDescriptionErrorKey: keyName = NSLocalizedString("Debug Description", comment: "") + case NSLocalizedDescriptionKey: keyName = NSLocalizedString("Error Description", comment: "") + case NSLocalizedFailureErrorKey: keyName = NSLocalizedString("Failure", comment: "") + case NSLocalizedFailureReasonErrorKey: keyName = NSLocalizedString("Failure Reason", comment: "") + case NSLocalizedRecoverySuggestionErrorKey: keyName = NSLocalizedString("Recovery Suggestion", comment: "") + case ALTLocalizedTitleErrorKey: keyName = NSLocalizedString("Title", comment: "") + case ALTSourceFileErrorKey: keyName = NSLocalizedString("Source File", comment: "") + case ALTSourceLineErrorKey: keyName = NSLocalizedString("Source Line", comment: "") + case NSUnderlyingErrorKey: keyName = NSLocalizedString("Underlying Error", comment: "") + default: + if #available(iOS 14.5, macOS 11.3, *), key == NSMultipleUnderlyingErrorsKey + { + keyName = NSLocalizedString("Underlying Errors", comment: "") + } + else + { + keyName = key + } + } + + let attributedKey = NSAttributedString(string: keyName, attributes: [.font: boldFont]) + let attributedValue = NSAttributedString(string: String(describing: value), attributes: [.font: font]) + + let attributedString = NSMutableAttributedString(attributedString: attributedKey) + attributedString.mutableString.append("\n") + attributedString.append(attributedValue) + + if !detailedDescription.string.isEmpty + { + detailedDescription.mutableString.append("\n\n") + } + + detailedDescription.append(attributedString) + } + + // Support dark mode + #if canImport(UIKit) + if #available(iOS 13, *) + { + detailedDescription.addAttribute(.foregroundColor, value: UIColor.label, range: NSMakeRange(0, detailedDescription.length)) + } + #else + detailedDescription.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSMakeRange(0, detailedDescription.length)) + #endif + + return detailedDescription + } } -extension Error +public extension NSError +{ + typealias UserInfoProvider = (Error, String) -> Any? + + @objc + class func alt_setUserInfoValueProvider(forDomain domain: String, provider: UserInfoProvider?) + { + NSError.setUserInfoValueProvider(forDomain: domain) { (error, key) in + let nsError = error as NSError + + switch key + { + case NSLocalizedDescriptionKey: + if nsError.localizedFailure != nil + { + // Error has localizedFailure, so return nil to construct localizedDescription from it + localizedFailureReason. + return nil + } + else if let localizedDescription = provider?(error, NSLocalizedDescriptionKey) as? String + { + // Only call provider() if there is no localizedFailure. + return localizedDescription + } + + // Otherwise, return failureReason for localizedDescription to avoid system prepending "Operation Failed" message. + // Do NOT return provider(NSLocalizedFailureReason), which might be unexpectedly nil if unrecognized error code. + return nsError.localizedFailureReason + + default: + let value = provider?(error, key) + return value + } + } + } +} + +public extension Error { var underlyingError: Error? { let underlyingError = (self as NSError).userInfo[NSUnderlyingErrorKey] as? Error @@ -98,46 +252,22 @@ extension Error } var localizedErrorCode: String { - let localizedErrorCode = String(format: NSLocalizedString("%@ error %@", comment: ""), (self as NSError).domain, (self as NSError).code as NSNumber) + let nsError = self as NSError + let localizedErrorCode = String(format: NSLocalizedString("%@ %@", comment: ""), nsError.domain, self.displayCode as NSNumber) return localizedErrorCode } -} - -protocol ALTLocalizedError: LocalizedError, CustomNSError -{ - var failure: String? { get } - var underlyingError: Error? { get } -} - -extension ALTLocalizedError -{ - var errorUserInfo: [String : Any] { - let userInfo = ([ - NSLocalizedDescriptionKey: self.errorDescription, - NSLocalizedFailureReasonErrorKey: self.failureReason, - NSLocalizedFailureErrorKey: self.failure, - NSUnderlyingErrorKey: self.underlyingError - ] as [String: Any?]).compactMapValues { $0 } - return userInfo - } - - var underlyingError: Error? { - // Error's default implementation calls errorUserInfo, - // but ALTLocalizedError.errorUserInfo calls underlyingError. - // Return nil to prevent infinite recursion. - return nil - } - - var errorDescription: String? { - guard let errorFailure = self.failure else { return (self.underlyingError as NSError?)?.localizedDescription } - guard let failureReason = self.failureReason else { return errorFailure } + var displayCode: Int { + guard let serverError = self as? ALTServerError else { + // Not ALTServerError, so display regular code. + return (self as NSError).code + } - let errorDescription = errorFailure + " " + failureReason - return errorDescription + // We want ALTServerError codes to start at 2000, + // but we can't change them without breaking AltServer compatibility. + // Instead, we just add 2000 when displaying code to user + // to make it appear as if codes start at 2000 normally. + let code = 2000 + serverError.code.rawValue + return code } - - var failureReason: String? { (self.underlyingError as NSError?)?.localizedDescription } - var recoverySuggestion: String? { (self.underlyingError as NSError?)?.localizedRecoverySuggestion } - var helpAnchor: String? { (self.underlyingError as NSError?)?.helpAnchor } } diff --git a/Shared/Server Protocol/CodableError.swift b/Shared/Server Protocol/CodableError.swift new file mode 100644 index 00000000..4cca686c --- /dev/null +++ b/Shared/Server Protocol/CodableError.swift @@ -0,0 +1,249 @@ +// +// CodableError.swift +// AltKit +// +// Created by Riley Testut on 3/5/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import Foundation + +// Can only automatically conform ALTServerError.Code to Codable, not ALTServerError itself +extension ALTServerError.Code: Codable {} + +private extension ErrorUserInfoKey +{ + static let altLocalizedDescription: String = "ALTLocalizedDescription" + static let altLocalizedFailureReason: String = "ALTLocalizedFailureReason" + static let altLocalizedRecoverySuggestion: String = "ALTLocalizedRecoverySuggestion" + static let altDebugDescription: String = "ALTDebugDescription" +} + +extension CodableError +{ + enum UserInfoValue: Codable + { + case unknown + case string(String) + case number(Int) + case error(NSError) + case codableError(CodableError) + indirect case array([UserInfoValue]) + indirect case dictionary([String: UserInfoValue]) + + var value: Any? { + switch self + { + case .unknown: return nil + case .string(let string): return string + case .number(let number): return number + case .error(let error): return error + case .codableError(let error): return error.error + case .array(let array): return array.compactMap { $0.value } // .compactMap instead of .map to ensure nil values are removed. + case .dictionary(let dictionary): return dictionary.compactMapValues { $0.value } // .compactMapValues instead of .mapValues to ensure nil values are removed. + } + } + + var codableValue: Codable? { + switch self + { + case .unknown, .string, .number: return self.value as? Codable + case .codableError(let error): return error + case .error(let nsError): + // Ignore error because we don't want to fail completely if error contains invalid user info value. + let sanitizedError = nsError.sanitizedForSerialization() + let data = try? NSKeyedArchiver.archivedData(withRootObject: sanitizedError, requiringSecureCoding: true) + return data + + case .array(let array): return array + case .dictionary(let dictionary): return dictionary + } + } + + init(_ rawValue: Any?) + { + switch rawValue + { + case let string as String: self = .string(string) + case let number as Int: self = .number(number) + case let error as NSError: self = .codableError(CodableError(error: error)) + case let array as [Any]: self = .array(array.compactMap(UserInfoValue.init)) + case let dictionary as [String: Any]: self = .dictionary(dictionary.compactMapValues(UserInfoValue.init)) + default: self = .unknown + } + } + + init(from decoder: Decoder) throws + { + let container = try decoder.singleValueContainer() + + if + let data = try? container.decode(Data.self), + let error = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSError.self, from: data) + { + self = .error(error) + } + else if let codableError = try? container.decode(CodableError.self) + { + self = .codableError(codableError) + } + else if let string = try? container.decode(String.self) + { + self = .string(string) + } + else if let number = try? container.decode(Int.self) + { + self = .number(number) + } + else if let array = try? container.decode([UserInfoValue].self) + { + self = .array(array) + } + else if let dictionary = try? container.decode([String: UserInfoValue].self) + { + self = .dictionary(dictionary) + } + else + { + self = .unknown + } + } + + func encode(to encoder: Encoder) throws + { + var container = encoder.singleValueContainer() + + if let value = self.codableValue + { + try container.encode(value) + } + else + { + try container.encodeNil() + } + } + } +} + +struct CodableError: Codable +{ + var error: Error { + return self.rawError ?? NSError(domain: self.errorDomain, code: self.errorCode, userInfo: self.userInfo ?? [:]) + } + private var rawError: Error? + + private var errorDomain: String + private var errorCode: Int + private var userInfo: [String: Any]? + + private enum CodingKeys: String, CodingKey + { + case errorDomain + case errorCode + case legacyUserInfo = "userInfo" + case errorUserInfo + } + + init(error: Error) + { + self.rawError = error + + let nsError = error as NSError + self.errorDomain = nsError.domain + self.errorCode = nsError.code + + if !nsError.userInfo.isEmpty + { + self.userInfo = nsError.userInfo + } + } + + init(from decoder: Decoder) throws + { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Assume ALTServerError.errorDomain if no explicit domain provided. + self.errorDomain = try container.decodeIfPresent(String.self, forKey: .errorDomain) ?? ALTServerError.errorDomain + self.errorCode = try container.decode(Int.self, forKey: .errorCode) + + if let rawUserInfo = try container.decodeIfPresent([String: UserInfoValue].self, forKey: .errorUserInfo) + { + // Attempt decoding from .errorUserInfo first, because it will gracefully handle unknown user info values. + + // Copy ALTLocalized... values to NSLocalized... if provider is nil or if error is unrecognized. + // This ensures we preserve error messages if receiving an unknown error. + var userInfo = rawUserInfo.compactMapValues { $0.value } + + // Recognized == the provider returns value for NSLocalizedFailureReasonErrorKey, or error is ALTServerError.underlyingError. + let provider = NSError.userInfoValueProvider(forDomain: self.errorDomain) + let isRecognizedError = ( + provider?(self.error, NSLocalizedFailureReasonErrorKey) != nil || + (self.error._domain == ALTServerError.errorDomain && self.error._code == ALTServerError.underlyingError.rawValue) + ) + + if !isRecognizedError + { + // Error not recognized, so copy over NSLocalizedDescriptionKey and NSLocalizedFailureReasonErrorKey. + userInfo[NSLocalizedDescriptionKey] = userInfo[ErrorUserInfoKey.altLocalizedDescription] + userInfo[NSLocalizedFailureReasonErrorKey] = userInfo[ErrorUserInfoKey.altLocalizedFailureReason] + } + + // Copy over NSLocalizedRecoverySuggestionErrorKey and NSDebugDescriptionErrorKey if provider returns nil. + if provider?(self.error, NSLocalizedRecoverySuggestionErrorKey) == nil + { + userInfo[NSLocalizedRecoverySuggestionErrorKey] = userInfo[ErrorUserInfoKey.altLocalizedRecoverySuggestion] + } + + if provider?(self.error, NSDebugDescriptionErrorKey) == nil + { + userInfo[NSDebugDescriptionErrorKey] = userInfo[ErrorUserInfoKey.altDebugDescription] + } + + userInfo[ErrorUserInfoKey.altLocalizedDescription] = nil + userInfo[ErrorUserInfoKey.altLocalizedFailureReason] = nil + userInfo[ErrorUserInfoKey.altLocalizedRecoverySuggestion] = nil + userInfo[ErrorUserInfoKey.altDebugDescription] = nil + + self.userInfo = userInfo + } + else if let rawUserInfo = try container.decodeIfPresent([String: UserInfoValue].self, forKey: .legacyUserInfo) + { + // Fall back to decoding .legacyUserInfo, which only supports String and NSError values. + let userInfo = rawUserInfo.compactMapValues { $0.value } + self.userInfo = userInfo + } + } + + func encode(to encoder: Encoder) throws + { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.errorDomain, forKey: .errorDomain) + try container.encode(self.errorCode, forKey: .errorCode) + + let rawLegacyUserInfo = self.userInfo?.compactMapValues { (value) -> UserInfoValue? in + // .legacyUserInfo only supports String and NSError values. + switch value + { + case let string as String: return .string(string) + case let error as NSError: return .error(error) // Must use .error, not .codableError for backwards compatibility. + default: return nil + } + } + try container.encodeIfPresent(rawLegacyUserInfo, forKey: .legacyUserInfo) + + let nsError = self.error as NSError + + var userInfo = self.userInfo ?? [:] + userInfo[ErrorUserInfoKey.altLocalizedDescription] = nsError.localizedDescription + userInfo[ErrorUserInfoKey.altLocalizedFailureReason] = nsError.localizedFailureReason + userInfo[ErrorUserInfoKey.altLocalizedRecoverySuggestion] = nsError.localizedRecoverySuggestion + userInfo[ErrorUserInfoKey.altDebugDescription] = nsError.localizedDebugDescription + + // No need to use alternate key. This is a no-op if userInfo already contains localizedFailure, + // but it caches the UserInfoProvider value if one exists. + userInfo[NSLocalizedFailureErrorKey] = nsError.localizedFailure + + let rawUserInfo = userInfo.compactMapValues { UserInfoValue($0) } + try container.encodeIfPresent(rawUserInfo, forKey: .errorUserInfo) + } +} diff --git a/Shared/Server Protocol/CodableServerError.swift b/Shared/Server Protocol/CodableServerError.swift deleted file mode 100644 index a9980dcb..00000000 --- a/Shared/Server Protocol/CodableServerError.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// CodableServerError.swift -// AltKit -// -// Created by Riley Testut on 3/5/20. -// Copyright © 2020 Riley Testut. All rights reserved. -// - -import Foundation - -// Can only automatically conform ALTServerError.Code to Codable, not ALTServerError itself -extension ALTServerError.Code: Codable {} - -extension CodableServerError -{ - enum UserInfoValue: Codable - { - case string(String) - case error(NSError) - - public init(from decoder: Decoder) throws - { - let container = try decoder.singleValueContainer() - - if - let data = try? container.decode(Data.self), - let error = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSError.self, from: data) - { - self = .error(error) - } - else if let string = try? container.decode(String.self) - { - self = .string(string) - } - else - { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "UserInfoValue value cannot be decoded") - } - } - - func encode(to encoder: Encoder) throws - { - var container = encoder.singleValueContainer() - - switch self - { - case .string(let string): try container.encode(string) - case .error(let error): - guard let data = try? NSKeyedArchiver.archivedData(withRootObject: error, requiringSecureCoding: true) else { - let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "UserInfoValue value \(self) cannot be encoded") - throw EncodingError.invalidValue(self, context) - } - - try container.encode(data) - } - } - } -} - -struct CodableServerError: Codable -{ - var error: ALTServerError { - return ALTServerError(self.errorCode, userInfo: self.userInfo ?? [:]) - } - - private var errorCode: ALTServerError.Code - private var userInfo: [String: Any]? - - private enum CodingKeys: String, CodingKey - { - case errorCode - case userInfo - } - - init(error: ALTServerError) - { - self.errorCode = error.code - - var userInfo = error.userInfo - if let localizedRecoverySuggestion = (error as NSError).localizedRecoverySuggestion - { - userInfo[NSLocalizedRecoverySuggestionErrorKey] = localizedRecoverySuggestion - } - - if !userInfo.isEmpty - { - self.userInfo = userInfo - } - } - - init(from decoder: Decoder) throws - { - let container = try decoder.container(keyedBy: CodingKeys.self) - - let errorCode = try container.decode(Int.self, forKey: .errorCode) - self.errorCode = ALTServerError.Code(rawValue: errorCode) ?? .unknown - - let rawUserInfo = try container.decodeIfPresent([String: UserInfoValue].self, forKey: .userInfo) - - let userInfo = rawUserInfo?.mapValues { (value) -> Any in - switch value - { - case .string(let string): return string - case .error(let error): return error - } - } - self.userInfo = userInfo - } - - func encode(to encoder: Encoder) throws - { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.error.code.rawValue, forKey: .errorCode) - - let rawUserInfo = self.userInfo?.compactMapValues { (value) -> UserInfoValue? in - switch value - { - case let string as String: return .string(string) - case let error as NSError: return .error(error) - default: return nil - } - } - try container.encodeIfPresent(rawUserInfo, forKey: .userInfo) - } -} - diff --git a/Shared/Server Protocol/ServerProtocol.swift b/Shared/Server Protocol/ServerProtocol.swift index 2b0041c9..8b543801 100644 --- a/Shared/Server Protocol/ServerProtocol.swift +++ b/Shared/Server Protocol/ServerProtocol.swift @@ -197,20 +197,21 @@ public enum ServerResponse: Decodable // from easily changing response format for a request in the future. public struct ErrorResponse: ServerMessageProtocol { - public var version = 2 + public var version = 3 public var identifier = "ErrorResponse" public var error: ALTServerError { - return self.serverError?.error ?? ALTServerError(self.errorCode) + // Must be ALTServerError + return self.serverError.map { ALTServerError($0.error) } ?? ALTServerError(self.errorCode) } - private var serverError: CodableServerError? + private var serverError: CodableError? // Legacy (v1) private var errorCode: ALTServerError.Code public init(error: ALTServerError) { - self.serverError = CodableServerError(error: error) + self.serverError = CodableError(error: error) self.errorCode = error.code } }