From 5a2f32704cb1db93dba11b7779518edfd605ae89 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 16 May 2023 15:39:38 -0500 Subject: [PATCH] Updates SourceError.blocked recovery suggestion to list installed/blocked apps If source is already added, the error message will list all installed apps from the source. If adding source for first time, the error message will mention exactly which apps have been blocked from the source (if provided). --- AltStore.xcodeproj/project.pbxproj | 4 + AltStore/Managing Apps/AppManager.swift | 6 +- AltStore/Operations/Errors/SourceError.swift | 85 ++++++++++++++++--- .../Operations/FetchSourceOperation.swift | 59 +++++++++---- .../UpdateKnownSourcesOperation.swift | 26 ++---- AltStore/Sources/SourcesViewController.swift | 2 +- AltStore/Types/KnownSource.swift | 69 +++++++++++++++ .../Extensions/UserDefaults+AltStore.swift | 37 -------- 8 files changed, 202 insertions(+), 86 deletions(-) create mode 100644 AltStore/Types/KnownSource.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 83276594..5aa17f24 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -369,6 +369,7 @@ D586D39B28EF58B0000E101F /* AltTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D586D39A28EF58B0000E101F /* AltTests.swift */; }; D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58916FD28C7C55C00E39C8B /* LoggedError.swift */; }; D5893F802A1419E800E767CD /* NSManagedObjectContext+Conveniences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5893F7E2A14183200E767CD /* NSManagedObjectContext+Conveniences.swift */; }; + D5893F822A141E4900E767CD /* KnownSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5893F812A141E4900E767CD /* KnownSource.swift */; }; D58D5F2E26DFE68E00E55E38 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = D58D5F2D26DFE68E00E55E38 /* LaunchAtLogin */; }; D59162AB29BA60A9005CBF47 /* SourceHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */; }; D59162AD29BA616A005CBF47 /* SourceHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */; }; @@ -931,6 +932,7 @@ D586D39A28EF58B0000E101F /* AltTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltTests.swift; sourceTree = ""; }; D58916FD28C7C55C00E39C8B /* LoggedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedError.swift; sourceTree = ""; }; D5893F7E2A14183200E767CD /* NSManagedObjectContext+Conveniences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Conveniences.swift"; sourceTree = ""; }; + D5893F812A141E4900E767CD /* KnownSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnownSource.swift; sourceTree = ""; }; D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceHeaderView.swift; sourceTree = ""; }; D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SourceHeaderView.xib; sourceTree = ""; }; D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBarAppearance+TintColor.swift"; sourceTree = ""; }; @@ -1224,6 +1226,7 @@ BF41B807233433C100C593A3 /* LoadingState.swift */, D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */, D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */, + D5893F812A141E4900E767CD /* KnownSource.swift */, ); path = Types; sourceTree = ""; @@ -2722,6 +2725,7 @@ D540E93828EE1BDE000F1B0F /* ErrorDetailsViewController.swift in Sources */, D513F6162A12CE4E0061EAA1 /* SourceError.swift in Sources */, BF56D2AC23DF8E170006506D /* FetchAppIDsOperation.swift in Sources */, + D5893F822A141E4900E767CD /* KnownSource.swift in Sources */, BFC1F38D22AEE3A4003AC21A /* DownloadAppOperation.swift in Sources */, BFE6073A231ADF82002B0E8E /* SettingsViewController.swift in Sources */, D57F2C9126E0070200B9FA39 /* EnableJITOperation.swift in Sources */, diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index a5ca074e..b303bc45 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -369,8 +369,8 @@ extension AppManager // sure there isn't already a source with this identifier. let sourceExists = try await fetchedSource.isAdded - // This is just a sanity check, so pass nil for previousSourceName to keep code simple. - guard !sourceExists else { throw SourceError.duplicate(source, previousSourceName: nil) } + // This is just a sanity check, so pass nil for existingSource to keep code simple. + guard !sourceExists else { throw SourceError.duplicate(source, existingSource: nil) } try await context.performAsync { try context.save() @@ -498,7 +498,7 @@ extension AppManager } @discardableResult - func updateKnownSources(completionHandler: @escaping (Result<([UpdateKnownSourcesOperation.Source], [UpdateKnownSourcesOperation.Source]), Error>) -> Void) -> UpdateKnownSourcesOperation + func updateKnownSources(completionHandler: @escaping (Result<([KnownSource], [KnownSource]), Error>) -> Void) -> UpdateKnownSourcesOperation { let updateKnownSourcesOperation = UpdateKnownSourcesOperation() updateKnownSourcesOperation.resultHandler = completionHandler diff --git a/AltStore/Operations/Errors/SourceError.swift b/AltStore/Operations/Errors/SourceError.swift index b75c4960..6a5450bc 100644 --- a/AltStore/Operations/Errors/SourceError.swift +++ b/AltStore/Operations/Errors/SourceError.swift @@ -29,9 +29,9 @@ extension SourceError static func duplicateBundleID(_ bundleID: String, source: Source) -> SourceError { SourceError(code: .duplicateBundleID, source: source, bundleID: bundleID) } static func duplicateVersion(_ version: String, for app: StoreApp, source: Source) -> SourceError { SourceError(code: .duplicateVersion, source: source, app: app, version: version) } - static func blocked(_ source: Source) -> SourceError { SourceError(code: .blocked, source: source) } + static func blocked(_ source: Source, bundleIDs: [String]?, existingSource: Source?) -> SourceError { SourceError(code: .blocked, source: source, existingSource: existingSource, bundleIDs: bundleIDs) } static func changedID(_ identifier: String, previousID: String, source: Source) -> SourceError { SourceError(code: .changedID, source: source, sourceID: identifier, previousSourceID: previousID) } - static func duplicate(_ source: Source, previousSourceName: String?) -> SourceError { SourceError(code: .duplicate, source: source, previousSourceName: previousSourceName) } + static func duplicate(_ source: Source, existingSource: Source?) -> SourceError { SourceError(code: .duplicate, source: source, existingSource: existingSource) } static func missingPermissionUsageDescription(for permission: any ALTAppPermission, app: StoreApp, source: Source) -> SourceError { SourceError(code: .missingPermissionUsageDescription, source: source, app: app, permission: permission) @@ -45,12 +45,13 @@ struct SourceError: ALTLocalizedError var errorFailure: String? @Managed var source: Source + @Managed var app: StoreApp? - var bundleID: String? + @Managed var existingSource: Source? var version: String? - - @UserInfoValue var previousSourceName: String? - + var bundleID: String? + var bundleIDs: [String]? + // Store in userInfo so they can be viewed from Error Log. @UserInfoValue var sourceID: String? @UserInfoValue var previousSourceID: String? @@ -97,9 +98,9 @@ struct SourceError: ALTLocalizedError case .duplicate: let baseMessage = String(format: NSLocalizedString("A source with the identifier '%@' already exists", comment: ""), self.$source.identifier) - guard let previousSourceName else { return baseMessage + "." } + guard let existingSourceName = self.$existingSource.name else { return baseMessage + "." } - let failureReason = baseMessage + " (“\(previousSourceName)”)." + let failureReason = baseMessage + " (“\(existingSourceName)”)." return failureReason case .missingPermissionUsageDescription: @@ -117,13 +118,75 @@ struct SourceError: ALTLocalizedError var recoverySuggestion: String? { switch self.code { - case .blocked: return NSLocalizedString("For your protection, please remove the source and uninstall all apps downloaded from it.", comment: "") + case .blocked: + if self.existingSource != nil + { + // Source already added, so tell them to remove it + any installed apps. + let baseMessage = NSLocalizedString("For your protection, please remove the source and uninstall", comment: "") + + if let blockedAppNames = self.blockedAppNames + { + let recoverySuggestion = baseMessage + " " + NSLocalizedString("the following apps:", comment: "") + "\n\n" + blockedAppNames.joined(separator: "\n") + return recoverySuggestion + } + else + { + let recoverySuggestion = baseMessage + " " + NSLocalizedString("all apps downloaded from it.", comment: "") + return recoverySuggestion + } + } + else + { + // Source is not already added, so no need to tell users to remove it. + // Instead, we just list all affected apps (if provided). + guard let blockedAppNames else { return nil } + + let recoverySuggestion = NSLocalizedString("The following apps have been flagged:", comment: "") + "\n\n" + blockedAppNames.joined(separator: "\n") + return recoverySuggestion + } + case .changedID: return NSLocalizedString("A source cannot change its identifier once added. This source can no longer be updated.", comment: "") case .duplicate: - let failureReason = NSLocalizedString("Please remove the existing source in order to add this one.", comment: "") - return failureReason + let recoverySuggestion = NSLocalizedString("Please remove the existing source in order to add this one.", comment: "") + return recoverySuggestion default: return nil } } } + +private extension SourceError +{ + var blockedAppNames: [String]? { + let blockedAppNames: [String]? + + if let existingSource + { + // Blocked apps = all installed apps from this source. + blockedAppNames = self.$existingSource.perform { _ in + let storeApps = existingSource.apps.lazy.filter { $0.installedApp != nil } + guard !storeApps.isEmpty else { return nil } + + let appNames = storeApps.map { "\($0.name) (\($0.bundleIdentifier))" } + return Array(appNames) + } + } + else if let bundleIDs + { + // Blocked apps = explicitly listed bundleIDs in blocked source JSON entry. + blockedAppNames = self.$source.perform { source in + bundleIDs.compactMap { (bundleID) in + guard let storeApp = source._apps.lazy.compactMap({ $0 as? StoreApp }).first(where: { $0.bundleIdentifier == bundleID }) else { return nil } + return "\(storeApp.name) (\(storeApp.bundleIdentifier))" + } + } + } + else + { + blockedAppNames = nil + } + + let sortedNames = blockedAppNames?.sorted { $0.localizedCompare($1) == .orderedAscending } + return sortedNames + } +} diff --git a/AltStore/Operations/FetchSourceOperation.swift b/AltStore/Operations/FetchSourceOperation.swift index 98c5bf71..ef54b472 100644 --- a/AltStore/Operations/FetchSourceOperation.swift +++ b/AltStore/Operations/FetchSourceOperation.swift @@ -58,6 +58,28 @@ final class FetchSourceOperation: ResultOperation { super.main() + if let source = self.source + { + // Check if source is blocked before fetching it. + + do + { + try self.managedObjectContext.performAndWait { + // Source must be from self.managedObjectContext + let source = self.managedObjectContext.object(with: source.objectID) as! Source + try self.verifySourceNotBlocked(source, response: nil) + } + } + catch + { + self.managedObjectContext.perform { + self.finish(.failure(error)) + } + + return + } + } + let dataTask = self.session.dataTask(with: self.sourceURL) { (data, response, error) in let childContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(withParent: self.managedObjectContext) @@ -129,21 +151,7 @@ private extension FetchSourceOperation { func verify(_ source: Source, response: URLResponse) throws { - if let blockedSourceIDs = UserDefaults.shared.blockedSourceIDs - { - guard !blockedSourceIDs.contains(source.identifier) else { throw SourceError.blocked(source) } - } - - if let blockedSourceURLs = UserDefaults.shared.blockedSourceURLs - { - guard !blockedSourceURLs.contains(source.sourceURL) else { throw SourceError.blocked(source) } - - if let responseURL = response.url - { - // responseURL may differ from sourceURL (e.g. due to redirects), so double-check it's also not blocked. - guard !blockedSourceURLs.contains(responseURL) else { throw SourceError.blocked(source) } - } - } + try self.verifySourceNotBlocked(source, response: response) var bundleIDs = Set() for app in source.apps @@ -175,4 +183,25 @@ private extension FetchSourceOperation guard source.identifier == previousSourceID else { throw SourceError.changedID(source.identifier, previousID: previousSourceID, source: source) } } } + + func verifySourceNotBlocked(_ source: Source, response: URLResponse?) throws + { + guard let blockedSources = UserDefaults.shared.blockedSources else { return } + + for blockedSource in blockedSources + { + guard + source.identifier != blockedSource.identifier, + source.sourceURL.absoluteString.lowercased() != blockedSource.sourceURL?.absoluteString.lowercased() + else { throw SourceError.blocked(source, bundleIDs: blockedSource.bundleIDs, existingSource: self.source) } + + if let responseURL = response?.url + { + // responseURL may differ from source.sourceURL (e.g. due to redirects), so double-check it's also not blocked. + guard responseURL.absoluteString.lowercased() != blockedSource.sourceURL?.absoluteString.lowercased() else { + throw SourceError.blocked(source, bundleIDs: blockedSource.bundleIDs, existingSource: self.source) + } + } + } + } } diff --git a/AltStore/Operations/UpdateKnownSourcesOperation.swift b/AltStore/Operations/UpdateKnownSourcesOperation.swift index b039e1b8..62f98e1e 100644 --- a/AltStore/Operations/UpdateKnownSourcesOperation.swift +++ b/AltStore/Operations/UpdateKnownSourcesOperation.swift @@ -19,22 +19,16 @@ private extension URL extension UpdateKnownSourcesOperation { - struct Source: Decodable - { - var identifier: String - var sourceURL: URL? - } - private struct Response: Decodable { var version: Int - var trusted: [Source] - var blocked: [Source]? + var trusted: [KnownSource]? + var blocked: [KnownSource]? } } -class UpdateKnownSourcesOperation: ResultOperation<([UpdateKnownSourcesOperation.Source], [UpdateKnownSourcesOperation.Source])> +class UpdateKnownSourcesOperation: ResultOperation<([KnownSource], [KnownSource])> { override func main() { @@ -54,17 +48,11 @@ class UpdateKnownSourcesOperation: ResultOperation<([UpdateKnownSourcesOperation guard let data = data else { throw error! } let response = try Foundation.JSONDecoder().decode(Response.self, from: data) - let sources = (trusted: response.trusted, blocked: response.blocked ?? []) + let sources = (trusted: response.trusted ?? [], blocked: response.blocked ?? []) - // Cache trusted sources - let trustedSourceIDs = Set(sources.trusted.map { $0.identifier }) - UserDefaults.shared.trustedSourceIDs = trustedSourceIDs - - // Cache blocked sources - let blockedSourceIDs = Set(sources.blocked.map { $0.identifier }) - let blockedSourceURLs = Set(sources.blocked.compactMap { $0.sourceURL }) - UserDefaults.shared.blockedSourceIDs = blockedSourceIDs - UserDefaults.shared.blockedSourceURLs = blockedSourceURLs + // Cache sources + UserDefaults.shared.trustedSources = sources.trusted + UserDefaults.shared.blockedSources = sources.blocked self.finish(.success(sources)) } diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift index 9f4205de..f42d9ee6 100644 --- a/AltStore/Sources/SourcesViewController.swift +++ b/AltStore/Sources/SourcesViewController.swift @@ -293,7 +293,7 @@ private extension SourcesViewController let predicate = NSPredicate(format: "%K == %@", #keyPath(Source.identifier), $source.identifier) if let existingSource = Source.first(satisfying: predicate, in: backgroundContext) { - throw SourceError.duplicate(source, previousSourceName: existingSource.name) + throw SourceError.duplicate(source, existingSource: existingSource) } DispatchQueue.main.async { diff --git a/AltStore/Types/KnownSource.swift b/AltStore/Types/KnownSource.swift new file mode 100644 index 00000000..dd961843 --- /dev/null +++ b/AltStore/Types/KnownSource.swift @@ -0,0 +1,69 @@ +// +// KnownSource.swift +// AltStore +// +// Created by Riley Testut on 5/16/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import Foundation + +struct KnownSource: Decodable +{ + var identifier: String + var sourceURL: URL? + var bundleIDs: [String]? +} + +private extension KnownSource +{ + var dictionaryRepresentation: [String: Any] { + let dictionary: [String: Any?] = [ + KnownSource.CodingKeys.identifier.stringValue: identifier, + KnownSource.CodingKeys.sourceURL.stringValue: self.sourceURL?.absoluteString, + KnownSource.CodingKeys.bundleIDs.stringValue: self.bundleIDs + ] + + return dictionary.compactMapValues { $0 } + } + + init?(dictionary: [String: Any]) + { + guard let identifier = dictionary[CodingKeys.identifier.stringValue] as? String else { return nil } + self.identifier = identifier + + if let sourceURLString = dictionary[CodingKeys.sourceURL.stringValue] as? String + { + self.sourceURL = URL(string: sourceURLString) + } + + let bundleIDs = dictionary[CodingKeys.bundleIDs.stringValue] as? [String] + self.bundleIDs = bundleIDs + } +} + +extension UserDefaults +{ + // Cache trusted sources just in case we need to check whether source is trusted or not. + @nonobjc var trustedSources: [KnownSource]? { + get { + guard let sources = _trustedSources?.compactMap({ KnownSource(dictionary: $0) }) else { return nil } + return sources + } + set { + _trustedSources = newValue?.map { $0.dictionaryRepresentation } + } + } + @NSManaged @objc(trustedSources) private var _trustedSources: [[String: Any]]? + + @nonobjc var blockedSources: [KnownSource]? { + get { + guard let sources = _blockedSources?.compactMap({ KnownSource(dictionary: $0) }) else { return nil } + return sources + } + set { + _blockedSources = newValue?.map { $0.dictionaryRepresentation } + } + } + @NSManaged @objc(blockedSources) private var _blockedSources: [[String: Any]]? +} diff --git a/AltStoreCore/Extensions/UserDefaults+AltStore.swift b/AltStoreCore/Extensions/UserDefaults+AltStore.swift index 39acab52..2093e5a5 100644 --- a/AltStoreCore/Extensions/UserDefaults+AltStore.swift +++ b/AltStoreCore/Extensions/UserDefaults+AltStore.swift @@ -118,40 +118,3 @@ public extension UserDefaults } } } - -public extension UserDefaults -{ - // Cache trustedSourceIDs just in case we need to check whether source is trusted or not. - @nonobjc var trustedSourceIDs: Set? { - get { - guard let sourceIDs = _trustedSourceIDs else { return nil } - return Set(sourceIDs) - } - set { - _trustedSourceIDs = newValue?.map { $0 } - } - } - @NSManaged @objc(trustedSourceIDs) private var _trustedSourceIDs: [String]? - - @nonobjc var blockedSourceIDs: Set? { - get { - guard let sourceIDs = _blockedSourceIDs else { return nil } - return Set(sourceIDs) - } - set { - _blockedSourceIDs = newValue?.map { $0 } - } - } - @NSManaged @objc(blockedSourceIDs) private var _blockedSourceIDs: [String]? - - @nonobjc var blockedSourceURLs: Set? { - get { - guard let sourceURLs = _blockedSourceURLs?.compactMap({ URL(string: $0) }) else { return nil } - return Set(sourceURLs) - } - set { - _blockedSourceURLs = newValue?.map { $0.absoluteString } - } - } - @NSManaged @objc(blockedSourceURLs) private var _blockedSourceURLs: [String]? -}