From 6d7d06a85edca99c95f5c59d0fe0206bfc6f0aae Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 23 Jan 2024 16:43:05 -0600 Subject: [PATCH] Hides source detail screens after adding/removing source Fixes various issues due to saving/deleting source while viewing source details. --- AltStore/Managing Apps/AppManager.swift | 60 +++++++++++++++++ .../Operations/Errors/OperationError.swift | 18 ++++- .../SourceDetailContentViewController.swift | 67 ++++++++----------- .../Sources/SourceDetailViewController.swift | 36 +++++++++- 4 files changed, 137 insertions(+), 44 deletions(-) diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 5deaf2be..29a2fa44 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -399,6 +399,66 @@ extension AppManager NotificationCenter.default.post(name: AppManager.didRemoveSourceNotification, object: source) } + + @discardableResult + func installAsync(@AsyncManaged _ app: T, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), + completionHandler: @escaping (Result) -> Void) async -> RefreshGroup + { + @AsyncManaged var installingApp: AppProtocol = app + + do + { + // Check if we need to add source first before installing app. + if let source = await $app.perform({ $0.storeApp?.source }), try await !source.isAdded + { + // This app's source is not yet added, so add it first. + guard let presentingViewController else { throw OperationError.sourceNotAdded(source) } + + let (appName, appBundleID, sourceID) = await $app.perform { ($0.name, $0.bundleIdentifier, source.identifier) } + + do + { + let message = String(format: NSLocalizedString("You must add this source before installing apps from it.\n\n“%@” will begin downloading once it has been added.", comment: ""), appName) + try await AppManager.shared.add(source, message: message, presentingViewController: presentingViewController) + } + catch let error as CancellationError + { + throw error + } + catch + { + // This should be an alert, so show directly rather than re-throwing error. + await presentingViewController.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription) + + // Don't rethrow error + // throw error + + throw CancellationError() + } + + // Fetch persisted StoreApp to use for remainder of operation. + installingApp = try await DatabaseManager.shared.viewContext.performAsync { + let fetchRequest = StoreApp.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == %@ AND %K == %@", + #keyPath(StoreApp.bundleIdentifier), appBundleID, + #keyPath(StoreApp.sourceIdentifier), sourceID) + + guard let storeApp = try DatabaseManager.shared.viewContext.fetch(fetchRequest).first else { throw OperationError.appNotFound(name: appName) } + return storeApp + } + } + } + catch + { + completionHandler(.failure(error)) + + let group = RefreshGroup(context: context) + return group + } + + let group = await $installingApp.perform { self.install($0, presentingViewController: presentingViewController, context: context, completionHandler: completionHandler) } + return group + } } extension AppManager diff --git a/AltStore/Operations/Errors/OperationError.swift b/AltStore/Operations/Errors/OperationError.swift index 3f7c25e7..9571419c 100644 --- a/AltStore/Operations/Errors/OperationError.swift +++ b/AltStore/Operations/Errors/OperationError.swift @@ -46,6 +46,7 @@ extension OperationError case cacheClearError//(errors: [String]) case forbidden = 1013 + case sourceNotAdded = 1014 /* Connection */ case serverNotFound = 1200 @@ -130,6 +131,10 @@ extension OperationError OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line) } + static func sourceNotAdded(@Managed _ source: Source, file: String = #fileID, line: UInt = #line) -> OperationError { + OperationError(code: .sourceNotAdded, sourceName: $source.name, sourceFile: file, sourceLine: line) + } + static func pledgeRequired(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError { OperationError(code: .pledgeRequired, appName: appName, sourceFile: file, sourceLine: line) } @@ -146,9 +151,13 @@ struct OperationError: ALTLocalizedError { var errorTitle: String? var errorFailure: String? - + + @UserInfoValue var appName: String? - + + @UserInfoValue + var sourceName: String? + var requiredAppIDs: Int? var availableAppIDs: Int? var expirationDate: Date? @@ -165,6 +174,7 @@ struct OperationError: ALTLocalizedError { self._failureReason = failureReason self.appName = appName + self.sourceName = sourceName self.requiredAppIDs = requiredAppIDs self.availableAppIDs = availableAppIDs self.expirationDate = expirationDate @@ -191,6 +201,10 @@ struct OperationError: ALTLocalizedError { case .forbidden: guard let failureReason = self._failureReason else { return NSLocalizedString("The operation is forbidden.", comment: "") } return failureReason + + case .sourceNotAdded: + let sourceName = self.sourceName.map { String(format: NSLocalizedString("The source “%@”", comment: ""), $0) } ?? NSLocalizedString("The source", comment: "") + return String(format: NSLocalizedString("%@ is not added to AltStore.", comment: ""), sourceName) case .appNotFound: let appName = self.appName ?? NSLocalizedString("The app", comment: "") diff --git a/AltStore/Sources/SourceDetailContentViewController.swift b/AltStore/Sources/SourceDetailContentViewController.swift index 89f2bc81..d8af74c6 100644 --- a/AltStore/Sources/SourceDetailContentViewController.swift +++ b/AltStore/Sources/SourceDetailContentViewController.swift @@ -391,61 +391,50 @@ private extension SourceDetailContentViewController sender.isIndicatingActivity = true Task { - await self.addSourceThenDownloadApp(storeApp) + await self.downloadApp(storeApp) sender.isIndicatingActivity = false } } } - func addSourceThenDownloadApp(_ storeApp: StoreApp) async + @MainActor + func downloadApp(_ storeApp: StoreApp) async { 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 is CancellationError {} - catch - { - let toastView = ToastView(error: error) - toastView.opensErrorLog = true - toastView.show(in: self) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable + { + AppManager.shared.update(installedApp, presentingViewController: self) { result in + continuation.resume(with: result.map { _ in () }) + } + + reload() + } + else + { + Task { @MainActor in + await AppManager.shared.installAsync(storeApp, presentingViewController: self) { result in + continuation.resume(with: result.map { _ in () }) + } + + reload() + } + } } } catch is CancellationError {} catch { - await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription) + let toastView = ToastView(error: error) + toastView.opensErrorLog = true + toastView.show(in: self) } self.collectionView.reloadSections([Section.featuredApps.rawValue]) - } - - @MainActor - func downloadApp(_ storeApp: StoreApp) async throws - { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable - { - AppManager.shared.update(installedApp, presentingViewController: self) { result in - continuation.resume(with: result.map { _ in () }) - } - } - else - { - AppManager.shared.install(storeApp, presentingViewController: self) { result in - continuation.resume(with: result.map { _ in () }) - } - } - + + func reload() + { UIView.performWithoutAnimation { guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else { self.collectionView.reloadSections([Section.featuredApps.rawValue]) diff --git a/AltStore/Sources/SourceDetailViewController.swift b/AltStore/Sources/SourceDetailViewController.swift index 0f6df617..d6dab7f1 100644 --- a/AltStore/Sources/SourceDetailViewController.swift +++ b/AltStore/Sources/SourceDetailViewController.swift @@ -80,7 +80,7 @@ class SourceDetailViewController: HeaderContentViewController() init?(source: Source, coder: NSCoder) { @@ -307,11 +307,41 @@ private extension SourceDetailViewController { func preparePipeline() { - self.cancellable = Publishers - .CombineLatest(self.viewModel.$isSourceAdded, self.viewModel.$isAddingSource) + Publishers.CombineLatest(self.viewModel.$isSourceAdded, self.viewModel.$isAddingSource) .receive(on: RunLoop.main) .sink { [weak self] _ in self?.update() } + .store(in: &self.cancellables) + + // Adding or removing a source while viewing source details is currently broken, + // so for now we just dismiss the view whenever the source is added or removed. + self.viewModel.$isSourceAdded + .compactMap { $0 } + .dropFirst() // Ignore first non-nil value. + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] isAdded in + if isAdded + { + self?.didAddSource() + } + else + { + self?.didRemoveSource() + } + } + .store(in: &self.cancellables) + } + + func didAddSource() + { + guard let presentingViewController = self.navigationController?.presentingViewController else { return } + presentingViewController.dismiss(animated: true) + } + + func didRemoveSource() + { + self.navigationController?.popToRootViewController(animated: true) } }