diff --git a/AltStore/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index 44c26f42..8854c56d 100644 --- a/AltStore/App Detail/AppViewController.swift +++ b/AltStore/App Detail/AppViewController.swift @@ -87,8 +87,6 @@ final class AppViewController: UIViewController self.bannerView.iconImageView.tintColor = self.app.tintColor self.bannerView.button.tintColor = self.app.tintColor self.bannerView.tintColor = self.app.tintColor - - self.bannerView.configure(for: self.app) self.bannerView.accessibilityTraits.remove(.button) self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered) @@ -352,37 +350,14 @@ private extension AppViewController { button.tintColor = self.app.tintColor button.isIndicatingActivity = false - - if let installedApp = self.app.installedApp - { - if let latestVersion = self.app.latestSupportedVersion, !installedApp.matches(latestVersion) - { - button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal) - } - else - { - button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal) - } - } - else - { - button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal) - } - - let progress = AppManager.shared.installationProgress(for: self.app) - button.progress = progress } - if let versionDate = self.app.latestSupportedVersion?.date, versionDate > Date() - { - self.bannerView.button.countdownDate = versionDate - self.navigationBarDownloadButton.countdownDate = versionDate - } - else - { - self.bannerView.button.countdownDate = nil - self.navigationBarDownloadButton.countdownDate = nil - } + self.bannerView.configure(for: self.app) + + let title = self.bannerView.button.title(for: .normal) + self.navigationBarDownloadButton.setTitle(title, for: .normal) + self.navigationBarDownloadButton.progress = self.bannerView.button.progress + self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate let barButtonItem = self.navigationItem.rightBarButtonItem self.navigationItem.rightBarButtonItem = nil diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index 26d08355..7949abd2 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -137,40 +137,8 @@ private extension BrowseViewController cell.bannerView.button.activityIndicatorView.style = .medium cell.bannerView.button.activityIndicatorView.color = .white - // Explicitly set to false to ensure we're starting from a non-activity indicating state. - // Otherwise, cell reuse can mess up some cached values. - cell.bannerView.button.isIndicatingActivity = false - let tintColor = app.tintColor ?? .altPrimary cell.tintColor = tintColor - - if app.installedApp == nil - { - let buttonTitle = NSLocalizedString("Free", comment: "") - cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal) - cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name) - cell.bannerView.button.accessibilityValue = buttonTitle - - let progress = AppManager.shared.installationProgress(for: app) - cell.bannerView.button.progress = progress - - if let versionDate = app.latestSupportedVersion?.date, versionDate > Date() - { - cell.bannerView.button.countdownDate = versionDate - } - else - { - cell.bannerView.button.countdownDate = nil - } - } - else - { - cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal) - cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name) - cell.bannerView.button.accessibilityValue = nil - cell.bannerView.button.progress = nil - cell.bannerView.button.countdownDate = nil - } } dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in let iconURL = storeApp.iconURL @@ -311,7 +279,7 @@ private extension BrowseViewController let app = self.dataSource.item(at: indexPath) - if let installedApp = app.installedApp + if let installedApp = app.installedApp, !installedApp.isUpdateAvailable { self.open(installedApp) } @@ -335,7 +303,21 @@ private extension BrowseViewController return } - _ = AppManager.shared.install(app, presentingViewController: self) { (result) in + if let installedApp = app.installedApp, installedApp.isUpdateAvailable + { + AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:)) + } + else + { + AppManager.shared.install(app, presentingViewController: self, completionHandler: finish(_:)) + } + + UIView.performWithoutAnimation { + self.collectionView.reloadItems(at: [indexPath]) + } + + func finish(_ result: Result) + { DispatchQueue.main.async { switch result { @@ -343,15 +325,22 @@ private extension BrowseViewController case .failure(let error): let toastView = ToastView(error: error, opensLog: true) toastView.show(in: self) - + case .success: print("Installed app:", app.bundleIdentifier) } - self.collectionView.reloadItems(at: [indexPath]) + UIView.performWithoutAnimation { + if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app) + { + self.collectionView.reloadItems(at: [indexPath]) + } + else + { + self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) + } + } } } - - self.collectionView.reloadItems(at: [indexPath]) } func open(_ installedApp: InstalledApp) diff --git a/AltStore/Components/AppBannerView.swift b/AltStore/Components/AppBannerView.swift index 9396b6e8..ed316768 100644 --- a/AltStore/Components/AppBannerView.swift +++ b/AltStore/Components/AppBannerView.swift @@ -18,6 +18,14 @@ extension AppBannerView case app case source } + + enum AppAction + { + case install + case open + case update + case custom(String) + } } class AppBannerView: RSTNibView @@ -111,7 +119,7 @@ class AppBannerView: RSTNibView extension AppBannerView { - func configure(for app: AppProtocol) + func configure(for app: AppProtocol, action: AppAction? = nil) { struct AppValues { @@ -150,6 +158,94 @@ extension AppBannerView self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "") self.accessibilityLabel = values.name } + + self.buttonLabel.isHidden = true + + let buttonAction: AppAction + + if let action + { + buttonAction = action + } + else if let storeApp = app.storeApp + { + if let installedApp = storeApp.installedApp + { + // App is installed + + if installedApp.isUpdateAvailable + { + buttonAction = .update + } + else + { + buttonAction = .open + } + } + else + { + // App is not installed + buttonAction = .install + } + } + else + { + // App is not from a source, fall back to .open + buttonAction = .open + } + + switch buttonAction + { + case .open: + let buttonTitle = NSLocalizedString("Open", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), values.name) + self.button.accessibilityValue = buttonTitle + + self.button.countdownDate = nil + + case .update: + let buttonTitle = NSLocalizedString("Update", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), values.name) + self.button.accessibilityValue = buttonTitle + + self.button.countdownDate = nil + + case .custom(let buttonTitle): + self.button.setTitle(buttonTitle, for: .normal) + self.button.accessibilityLabel = buttonTitle + self.button.accessibilityValue = buttonTitle + + self.button.countdownDate = nil + + case .install: + let buttonTitle = NSLocalizedString("Free", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name) + self.button.accessibilityValue = buttonTitle + + if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date() + { + self.button.countdownDate = versionDate + } + else + { + self.button.countdownDate = nil + } + } + + // Ensure PillButton is correct size before assigning progress. + self.layoutIfNeeded() + + if let progress = AppManager.shared.installationProgress(for: app), progress.fractionCompleted < 1.0 + { + self.button.progress = progress + } + else + { + self.button.progress = nil + } } func configure(for source: Source) diff --git a/AltStore/Components/AppCardCollectionViewCell.swift b/AltStore/Components/AppCardCollectionViewCell.swift index 26fb00ba..25fa3046 100644 --- a/AltStore/Components/AppCardCollectionViewCell.swift +++ b/AltStore/Components/AppCardCollectionViewCell.swift @@ -289,6 +289,10 @@ extension AppCardCollectionViewCell { self.screenshots = storeApp.preferredScreenshots() + // Explicitly set to false to ensure we're starting from a non-activity indicating state. + // Otherwise, cell reuse can mess up some cached values. + self.bannerView.button.isIndicatingActivity = false + self.bannerView.tintColor = storeApp.tintColor self.bannerView.configure(for: storeApp) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 8ec466af..8328d46a 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -237,7 +237,8 @@ private extension MyAppsViewController cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.isIndicatingActivity = true - cell.bannerView.configure(for: app) + cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.configure(for: app, action: .update) let versionDate = Date().relativeDateString(since: latestSupportedVersion.date) cell.bannerView.subtitleLabel.text = versionDate @@ -255,7 +256,6 @@ private extension MyAppsViewController cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestSupportedVersion.localizedVersion, versionDate) - cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered) cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), installedApp.name) @@ -270,9 +270,6 @@ private extension MyAppsViewController cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered) - let progress = AppManager.shared.installationProgress(for: app) - cell.bannerView.button.progress = progress - cell.setNeedsLayout() } dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in @@ -340,17 +337,6 @@ private extension MyAppsViewController cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.33, y: 0.33) } - cell.bannerView.configure(for: installedApp) - - cell.bannerView.iconImageView.isIndicatingActivity = true - - cell.bannerView.buttonLabel.isHidden = false - cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "") - - cell.bannerView.button.isIndicatingActivity = false - cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered) - cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered) - let currentDate = Date() let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate) @@ -368,6 +354,17 @@ private extension MyAppsViewController cell.bannerView.button.setTitle(formatter.string(from: currentDate, to: installedApp.expirationDate)?.uppercased(), for: .normal) + cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.configure(for: installedApp, action: .custom(numberOfDaysText.uppercased())) + + cell.bannerView.iconImageView.isIndicatingActivity = true + + cell.bannerView.buttonLabel.isHidden = false + cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "") + + cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered) + cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered) + cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), installedApp.name) // formatter.includesTimeRemainingPhrase = true @@ -457,11 +454,10 @@ private extension MyAppsViewController cell.deactivateBadge?.alpha = 0.0 cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.5, y: 0.5) - cell.bannerView.configure(for: installedApp) - cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.configure(for: installedApp, action: .custom(NSLocalizedString("ACTIVATE", comment: ""))) + cell.bannerView.button.tintColor = tintColor - cell.bannerView.button.setTitle(NSLocalizedString("ACTIVATE", comment: ""), for: .normal) cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered) cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered) cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), installedApp.name) diff --git a/AltStore/My Apps/UpdateCollectionViewCell.swift b/AltStore/My Apps/UpdateCollectionViewCell.swift index b9c92791..72ae798a 100644 --- a/AltStore/My Apps/UpdateCollectionViewCell.swift +++ b/AltStore/My Apps/UpdateCollectionViewCell.swift @@ -42,8 +42,7 @@ extension UpdateCollectionViewCell self.contentView.preservesSuperviewLayoutMargins = true self.bannerView.backgroundEffectView.isHidden = true - self.bannerView.button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal) - + self.blurView.layer.cornerRadius = 20 self.blurView.layer.masksToBounds = true diff --git a/AltStore/News/NewsViewController.swift b/AltStore/News/NewsViewController.swift index d32caa7f..6ad135ab 100644 --- a/AltStore/News/NewsViewController.swift +++ b/AltStore/News/NewsViewController.swift @@ -341,7 +341,7 @@ private extension NewsViewController let app = self.dataSource.item(at: indexPath) guard let storeApp = app.storeApp else { return } - if let installedApp = app.storeApp?.installedApp + if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable { self.open(installedApp) } @@ -359,7 +359,21 @@ private extension NewsViewController return } - _ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in + if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable + { + AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:)) + } + else + { + AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:)) + } + + UIView.performWithoutAnimation { + self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) + } + + func finish(_ result: Result) + { DispatchQueue.main.async { switch result { @@ -375,10 +389,6 @@ private extension NewsViewController } } } - - UIView.performWithoutAnimation { - self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) - } } func open(_ installedApp: InstalledApp) @@ -424,42 +434,13 @@ extension NewsViewController footerView.layoutMargins.left = self.view.layoutMargins.left footerView.layoutMargins.right = self.view.layoutMargins.right + footerView.bannerView.button.isIndicatingActivity = false footerView.bannerView.configure(for: storeApp) footerView.bannerView.tintColor = storeApp.tintColor footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered) footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:))) - footerView.bannerView.button.isIndicatingActivity = false - - if storeApp.installedApp == nil - { - let buttonTitle = NSLocalizedString("Free", comment: "") - footerView.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal) - footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name) - footerView.bannerView.button.accessibilityValue = buttonTitle - - let progress = AppManager.shared.installationProgress(for: storeApp) - footerView.bannerView.button.progress = progress - - if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date() - { - footerView.bannerView.button.countdownDate = versionDate - } - else - { - footerView.bannerView.button.countdownDate = nil - } - } - else - { - footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal) - footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), storeApp.name) - footerView.bannerView.button.accessibilityValue = nil - footerView.bannerView.button.progress = nil - footerView.bannerView.button.countdownDate = nil - } - Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView) return footerView diff --git a/AltStore/Sources/SourceDetailContentViewController.swift b/AltStore/Sources/SourceDetailContentViewController.swift index e2cd8eb0..d1ef6e56 100644 --- a/AltStore/Sources/SourceDetailContentViewController.swift +++ b/AltStore/Sources/SourceDetailContentViewController.swift @@ -225,43 +225,13 @@ private extension SourceDetailContentViewController cell.contentView.layoutMargins = .zero cell.contentView.backgroundColor = .altBackground + cell.bannerView.button.isIndicatingActivity = false cell.bannerView.configure(for: storeApp) - cell.bannerView.iconImageView.isIndicatingActivity = true - cell.bannerView.buttonLabel.isHidden = true - - cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.tintColor = storeApp.tintColor + cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.performAppAction(_:)), for: .primaryActionTriggered) - let buttonTitle = NSLocalizedString("Free", comment: "") - 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 - - if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date() - { - cell.bannerView.button.countdownDate = versionDate - } - else - { - cell.bannerView.button.countdownDate = nil - } - - // Make sure refresh button is correct size. - cell.layoutIfNeeded() - - if let progress = AppManager.shared.installationProgress(for: storeApp), progress.fractionCompleted < 1.0 - { - cell.bannerView.button.progress = progress - } - else - { - cell.bannerView.button.progress = nil - } + cell.bannerView.iconImageView.isIndicatingActivity = true } dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in return RSTAsyncBlockOperation { (operation) in @@ -404,64 +374,93 @@ extension SourceDetailContentViewController private extension SourceDetailContentViewController { - @objc func addSourceThenDownloadApp(_ sender: UIButton) + @objc func performAppAction(_ sender: PillButton) { 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 { + if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable + { + self.open(installedApp) + } + else + { + sender.isIndicatingActivity = true + + Task { + await self.addSourceThenDownloadApp(storeApp) + sender.isIndicatingActivity = false + } + } + } + + func addSourceThenDownloadApp(_ 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 { - 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) - } + try await self.downloadApp(storeApp) } 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) } - - sender.isIndicatingActivity = false - self.collectionView.reloadSections([Section.featuredApps.rawValue]) } + catch is CancellationError {} + catch + { + await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription) + } + + self.collectionView.reloadSections([Section.featuredApps.rawValue]) } + @MainActor 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 }) + 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 () }) + } } - guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else { - self.collectionView.reloadSections([Section.featuredApps.rawValue]) - return + UIView.performWithoutAnimation { + 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]) } - - let indexPath = IndexPath(item: index, section: Section.featuredApps.rawValue) - self.collectionView.reloadItems(at: [indexPath]) } } + + func open(_ installedApp: InstalledApp) + { + UIApplication.shared.open(installedApp.openAppURL) + } } extension SourceDetailContentViewController: ScrollableContentViewController diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index 79f49698..c38e62f1 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -336,6 +336,13 @@ public extension InstalledApp let openAppURL = URL(string: "altstore-" + app.bundleIdentifier + "://")! return openAppURL } + + var isUpdateAvailable: Bool { + guard let storeApp = self.storeApp, let latestVersion = storeApp.latestSupportedVersion else { return false } + + let isUpdateAvailable = !self.matches(latestVersion) + return isUpdateAvailable + } } public extension InstalledApp