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]? -}