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).
This commit is contained in:
Riley Testut
2023-05-16 15:39:38 -05:00
committed by Magesh K
parent 177d453491
commit 5a2f32704c
8 changed files with 202 additions and 86 deletions

View File

@@ -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 = "<group>"; };
D58916FD28C7C55C00E39C8B /* LoggedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedError.swift; sourceTree = "<group>"; };
D5893F7E2A14183200E767CD /* NSManagedObjectContext+Conveniences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Conveniences.swift"; sourceTree = "<group>"; };
D5893F812A141E4900E767CD /* KnownSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnownSource.swift; sourceTree = "<group>"; };
D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceHeaderView.swift; sourceTree = "<group>"; };
D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SourceHeaderView.xib; sourceTree = "<group>"; };
D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBarAppearance+TintColor.swift"; sourceTree = "<group>"; };
@@ -1224,6 +1226,7 @@
BF41B807233433C100C593A3 /* LoadingState.swift */,
D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */,
D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */,
D5893F812A141E4900E767CD /* KnownSource.swift */,
);
path = Types;
sourceTree = "<group>";
@@ -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 */,

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -58,6 +58,28 @@ final class FetchSourceOperation: ResultOperation<Source>
{
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<String>()
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)
}
}
}
}
}

View File

@@ -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))
}

View File

@@ -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 {

View File

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

View File

@@ -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<String>? {
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<String>? {
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<URL>? {
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]?
}