From 82cacb1b5152b5cbe059143313e69e2f8cdb2f20 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 4 Apr 2023 16:55:55 -0500 Subject: [PATCH] Supports adding/removing source from SourceDetailViewController --- AltStore/Managing Apps/AppManager.swift | 55 ++++++ AltStore/Operations/OperationError.swift | 14 ++ .../SourceDetailContentViewController.swift | 63 +++++++ .../Sources/SourceDetailViewController.swift | 162 +++++++++++++++++- 4 files changed, 293 insertions(+), 1 deletion(-) diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 35168ec2..48a9ad91 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -344,6 +344,61 @@ extension AppManager extension AppManager { + func fetchSource(sourceURL: URL, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) async throws -> Source + { + try await withCheckedThrowingContinuation { continuation in + self.fetchSource(sourceURL: sourceURL, managedObjectContext: managedObjectContext) { result in + continuation.resume(with: result) + } + } + } + + func add(@AsyncManaged _ source: Source, message: String? = nil, presentingViewController: UIViewController) async throws + { + let (sourceName, sourceURL) = await $source.get { ($0.name, $0.sourceURL) } + + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + async let fetchedSource = try await self.fetchSource(sourceURL: sourceURL, managedObjectContext: context) // Fetch source async while showing alert. + + let title = String(format: NSLocalizedString("Would you like to add the source “%@”?", comment: ""), sourceName) + let message = message ?? NSLocalizedString("Make sure to only add sources that you trust.", comment: "") + let action = await UIAlertAction(title: NSLocalizedString("Add Source", comment: ""), style: .default) + try await presentingViewController.presentConfirmationAlert(title: title, message: message, primaryAction: action) + + // Wait for fetch to finish before saving context. + _ = try await fetchedSource + + try await context.performAsync { + try context.save() + } + } + + func remove(@AsyncManaged _ source: Source, presentingViewController: UIViewController) async throws + { + let (sourceName, sourceID) = await $source.get { ($0.name, $0.identifier) } + guard sourceID != Source.altStoreIdentifier else { + throw OperationError.forbidden(failureReason: NSLocalizedString("The default AltStore source cannot be removed.", comment: "")) + } + + let title = String(format: NSLocalizedString("Are you sure you want to remove the source “%@”?", comment: ""), sourceName) + let message = NSLocalizedString("Any apps you've installed from this source will remain, but they'll no longer receive any app updates.", comment: "") + let action = await UIAlertAction(title: NSLocalizedString("Remove Source", comment: ""), style: .destructive) + try await presentingViewController.presentConfirmationAlert(title: title, message: message, primaryAction: action) + + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + try await context.performAsync { + let predicate = NSPredicate(format: "%K == %@", #keyPath(Source.identifier), sourceID) + guard let source = Source.first(satisfying: predicate, in: context) else { return } // Doesn't exist == success. + + context.delete(source) + try context.save() + } + } +} + +extension AppManager +{ + @available(*, renamed: "fetchSource(sourceURL:managedObjectContext:)") func fetchSource(sourceURL: URL, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(), dependencies: [Foundation.Operation] = [], diff --git a/AltStore/Operations/OperationError.swift b/AltStore/Operations/OperationError.swift index 901cdfbe..c1591afb 100644 --- a/AltStore/Operations/OperationError.swift +++ b/AltStore/Operations/OperationError.swift @@ -45,6 +45,12 @@ extension OperationError case anisetteV3Error//(message: String) case cacheClearError//(errors: [String]) + case forbidden + + /* Connection */ + case serverNotFound = 1200 + case connectionFailed + case connectionDropped } static let unknownResult: OperationError = .init(code: .unknownResult) @@ -114,6 +120,9 @@ extension OperationError static func invalidParameters(_ message: String? = nil) -> OperationError { OperationError(code: .invalidParameters, failureReason: message) + + static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError { + OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line) } } @@ -166,6 +175,11 @@ struct OperationError: ALTLocalizedError { case .maximumAppIDLimitReached: return NSLocalizedString("Cannot register more than 10 App IDs within a 7 day period.", comment: "") case .noSources: return NSLocalizedString("There are no SideStore sources.", comment: "") case .missingAppGroup: return NSLocalizedString("SideStore's shared app group could not be accessed.", comment: "") + case .invalidParameters: return NSLocalizedString("Invalid parameters.", comment: "") + case .forbidden: + guard let failureReason = self._failureReason else { return NSLocalizedString("The operation is forbidden.", comment: "") } + return failureReason + case .appNotFound: let appName = self.appName ?? NSLocalizedString("The app", comment: "") return String(format: NSLocalizedString("%@ could not be found.", comment: ""), appName) diff --git a/AltStore/Sources/SourceDetailContentViewController.swift b/AltStore/Sources/SourceDetailContentViewController.swift index 3fea070c..9957d501 100644 --- a/AltStore/Sources/SourceDetailContentViewController.swift +++ b/AltStore/Sources/SourceDetailContentViewController.swift @@ -234,6 +234,7 @@ private extension SourceDetailContentViewController cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal) cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name) cell.bannerView.button.accessibilityValue = buttonTitle + cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.addSourceThenDownloadApp(_:)), for: .primaryActionTriggered) let progress = AppManager.shared.installationProgress(for: storeApp) cell.bannerView.button.progress = progress @@ -397,6 +398,68 @@ extension SourceDetailContentViewController } } +private extension SourceDetailContentViewController +{ + @objc func addSourceThenDownloadApp(_ sender: UIButton) + { + let point = self.collectionView.convert(sender.center, from: sender.superview) + guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } + + sender.isIndicatingActivity = true + + let storeApp = self.dataSource.item(at: indexPath) as! StoreApp + + Task { + do + { + let isAdded = try await self.source.isAdded + if !isAdded + { + let message = String(format: NSLocalizedString("You must add this source before you can install apps from it.\n\n“%@” will begin downloading once it has been added.", comment: ""), storeApp.name) + try await AppManager.shared.add(self.source, message: message, presentingViewController: self) + } + + do + { + try await self.downloadApp(storeApp) + } + catch OperationError.cancelled {} + catch + { + let toastView = ToastView(error: error) + toastView.opensErrorLog = true + toastView.show(in: self) + } + } + catch is CancellationError {} + catch + { + await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription) + } + + sender.isIndicatingActivity = false + self.collectionView.reloadSections([Section.featuredApps.rawValue]) + } + } + + func downloadApp(_ storeApp: StoreApp) async throws + { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + AppManager.shared.install(storeApp, presentingViewController: self) { result in + continuation.resume(with: result.map { _ in }) + } + + guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else { + self.collectionView.reloadSections([Section.featuredApps.rawValue]) + return + } + + let indexPath = IndexPath(item: index, section: Section.featuredApps.rawValue) + self.collectionView.reloadItems(at: [indexPath]) + } + } +} + extension SourceDetailContentViewController: ScrollableContentViewController { var scrollView: UIScrollView { self.collectionView } diff --git a/AltStore/Sources/SourceDetailViewController.swift b/AltStore/Sources/SourceDetailViewController.swift index 3493de6c..7621487a 100644 --- a/AltStore/Sources/SourceDetailViewController.swift +++ b/AltStore/Sources/SourceDetailViewController.swift @@ -8,23 +8,90 @@ import UIKit import SafariServices +import Combine import AltStoreCore import Roxas import Nuke +extension SourceDetailViewController +{ + private class ViewModel: ObservableObject + { + let source: Source + + @Published + var isSourceAdded: Bool? = nil + + @Published + var isAddingSource: Bool = false + + init(source: Source) + { + self.source = source + + Task { + do + { + self.isSourceAdded = try await self.source.isAdded + } + catch + { + print("[ALTLog] Failed to check if source is added.", error) + } + } + } + } +} + class SourceDetailViewController: HeaderContentViewController { @Managed private(set) var source: Source + private let viewModel: ViewModel + private var addButton: VibrantButton! private var previousBounds: CGRect? + private var cancellable: AnyCancellable? init?(source: Source, coder: NSCoder) { + let isolatedContext: NSManagedObjectContext + + if source.managedObjectContext == DatabaseManager.shared.viewContext + { + do + { + // Source is persisted to disk, so we can create a new view context + // that's pinned to current query generation to ensure information + // doesn't disappear out from under us if we remove (delete) the source. + let context = DatabaseManager.shared.persistentContainer.newViewContext(withParent: nil) + try context.setQueryGenerationFrom(.current) + isolatedContext = context + } + catch + { + print("[ATLog] Failed to set query generation for context.", error) + isolatedContext = DatabaseManager.shared.persistentContainer.newViewContext(withParent: source.managedObjectContext) + } + } + else + { + // Source is not persisted to disk, so create child view context with source's managedObjectContext as parent. + // This also maintains a strong reference to source.managedObjectContext, which may be necessary. + isolatedContext = DatabaseManager.shared.persistentContainer.newViewContext(withParent: source.managedObjectContext) + } + + // Ignore changes from other contexts so we can delete source without UI automatically updating. + isolatedContext.automaticallyMergesChangesFromParent = false + + let source = isolatedContext.object(with: source.objectID) as! Source self.source = source + + self.viewModel = ViewModel(source: source) + super.init(coder: coder) self.title = source.name @@ -41,15 +108,18 @@ class SourceDetailViewController: HeaderContentViewController { /* @MainActor in */ // Already on MainActor, even though this function wasn't called from async context. + var isSourceAdded: Bool? + var errorTitle = NSLocalizedString("Unable to Add Source", comment: "") + + do + { + let isAdded = try await self.source.isAdded + if isAdded + { + errorTitle = NSLocalizedString("Unable to Remove Source", comment: "") + try await AppManager.shared.remove(self.source, presentingViewController: self) + } + else + { + try await AppManager.shared.add(self.source, presentingViewController: self) + } + + isSourceAdded = try await self.source.isAdded + } + catch is CancellationError {} + catch + { + await self.presentAlert(title: errorTitle, message: error.localizedDescription) + } + + self.viewModel.isAddingSource = false + + if let isSourceAdded + { + self.viewModel.isSourceAdded = isSourceAdded + } + } + } + @objc private func showWebsite() { guard let websiteURL = self.source.websiteURL else { return } @@ -119,3 +266,16 @@ class SourceDetailViewController: HeaderContentViewController