mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-19 19:53:25 +01:00
Supports adding/removing source from SourceDetailViewController
This commit is contained in:
@@ -332,6 +332,61 @@ extension AppManager
|
|||||||
|
|
||||||
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,
|
func fetchSource(sourceURL: URL,
|
||||||
managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(),
|
managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(),
|
||||||
dependencies: [Foundation.Operation] = [],
|
dependencies: [Foundation.Operation] = [],
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ extension OperationError
|
|||||||
case noSources
|
case noSources
|
||||||
case openAppFailed
|
case openAppFailed
|
||||||
case missingAppGroup
|
case missingAppGroup
|
||||||
|
case forbidden
|
||||||
|
|
||||||
/* Connection */
|
/* Connection */
|
||||||
case serverNotFound = 1200
|
case serverNotFound = 1200
|
||||||
@@ -61,6 +62,10 @@ extension OperationError
|
|||||||
static func maximumAppIDLimitReached(appName: String, requiredAppIDs: Int, availableAppIDs: Int, expirationDate: Date) -> OperationError {
|
static func maximumAppIDLimitReached(appName: String, requiredAppIDs: Int, availableAppIDs: Int, expirationDate: Date) -> OperationError {
|
||||||
OperationError(code: .maximumAppIDLimitReached, appName: appName, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, expirationDate: expirationDate)
|
OperationError(code: .maximumAppIDLimitReached, appName: appName, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, expirationDate: expirationDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
|
||||||
|
OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OperationError: ALTLocalizedError
|
struct OperationError: ALTLocalizedError
|
||||||
@@ -112,6 +117,9 @@ struct OperationError: ALTLocalizedError
|
|||||||
case .maximumAppIDLimitReached: return NSLocalizedString("You cannot register more than 10 App IDs within a 7 day period.", comment: "")
|
case .maximumAppIDLimitReached: return NSLocalizedString("You cannot register more than 10 App IDs within a 7 day period.", comment: "")
|
||||||
case .noSources: return NSLocalizedString("There are no AltStore sources.", comment: "")
|
case .noSources: return NSLocalizedString("There are no AltStore sources.", comment: "")
|
||||||
case .missingAppGroup: return NSLocalizedString("AltStore's shared app group could not be accessed.", comment: "")
|
case .missingAppGroup: return NSLocalizedString("AltStore's shared app group could not be accessed.", comment: "")
|
||||||
|
case .forbidden:
|
||||||
|
guard let failureReason = self._failureReason else { return NSLocalizedString("The operation is forbidden.", comment: "") }
|
||||||
|
return failureReason
|
||||||
|
|
||||||
case .appNotFound:
|
case .appNotFound:
|
||||||
let appName = self.appName ?? NSLocalizedString("The app", comment: "")
|
let appName = self.appName ?? NSLocalizedString("The app", comment: "")
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ private extension SourceDetailContentViewController
|
|||||||
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
|
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
|
||||||
cell.bannerView.button.accessibilityValue = buttonTitle
|
cell.bannerView.button.accessibilityValue = buttonTitle
|
||||||
|
cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.addSourceThenDownloadApp(_:)), for: .primaryActionTriggered)
|
||||||
|
|
||||||
let progress = AppManager.shared.installationProgress(for: storeApp)
|
let progress = AppManager.shared.installationProgress(for: storeApp)
|
||||||
cell.bannerView.button.progress = progress
|
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<Void, Never> {
|
||||||
|
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<Void, Error>) 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
|
extension SourceDetailContentViewController: ScrollableContentViewController
|
||||||
{
|
{
|
||||||
var scrollView: UIScrollView { self.collectionView }
|
var scrollView: UIScrollView { self.collectionView }
|
||||||
|
|||||||
@@ -8,23 +8,90 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
import Combine
|
||||||
|
|
||||||
import AltStoreCore
|
import AltStoreCore
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
import Nuke
|
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<Void, Never> {
|
||||||
|
do
|
||||||
|
{
|
||||||
|
self.isSourceAdded = try await self.source.isAdded
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
print("[ALTLog] Failed to check if source is added.", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SourceDetailViewController: HeaderContentViewController<SourceHeaderView, SourceDetailContentViewController>
|
class SourceDetailViewController: HeaderContentViewController<SourceHeaderView, SourceDetailContentViewController>
|
||||||
{
|
{
|
||||||
@Managed private(set) var source: Source
|
@Managed private(set) var source: Source
|
||||||
|
|
||||||
|
private let viewModel: ViewModel
|
||||||
|
|
||||||
private var addButton: VibrantButton!
|
private var addButton: VibrantButton!
|
||||||
|
|
||||||
private var previousBounds: CGRect?
|
private var previousBounds: CGRect?
|
||||||
|
private var cancellable: AnyCancellable?
|
||||||
|
|
||||||
init?(source: Source, coder: NSCoder)
|
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.source = source
|
||||||
|
|
||||||
|
self.viewModel = ViewModel(source: source)
|
||||||
|
|
||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
|
|
||||||
self.title = source.name
|
self.title = source.name
|
||||||
@@ -41,15 +108,18 @@ class SourceDetailViewController: HeaderContentViewController<SourceHeaderView,
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
self.addButton = VibrantButton(type: .system)
|
self.addButton = VibrantButton(type: .system)
|
||||||
self.addButton.title = NSLocalizedString("ADD", comment: "")
|
|
||||||
self.addButton.contentInsets = PillButton.contentInsets
|
self.addButton.contentInsets = PillButton.contentInsets
|
||||||
|
self.addButton.addTarget(self, action: #selector(SourceDetailViewController.addSource), for: .primaryActionTriggered)
|
||||||
self.addButton.sizeToFit()
|
self.addButton.sizeToFit()
|
||||||
self.view.addSubview(self.addButton)
|
self.view.addSubview(self.addButton)
|
||||||
|
|
||||||
|
self.navigationBarButton.addTarget(self, action: #selector(SourceDetailViewController.addSource), for: .primaryActionTriggered)
|
||||||
|
|
||||||
Nuke.loadImage(with: self.source.effectiveIconURL, into: self.navigationBarIconView)
|
Nuke.loadImage(with: self.source.effectiveIconURL, into: self.navigationBarIconView)
|
||||||
Nuke.loadImage(with: self.source.effectiveHeaderImageURL, into: self.backgroundImageView)
|
Nuke.loadImage(with: self.source.effectiveHeaderImageURL, into: self.backgroundImageView)
|
||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
|
self.preparePipeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews()
|
override func viewDidLayoutSubviews()
|
||||||
@@ -106,10 +176,87 @@ class SourceDetailViewController: HeaderContentViewController<SourceHeaderView,
|
|||||||
self.addButton.isHidden = true
|
self.addButton.isHidden = true
|
||||||
self.navigationBarButton.isHidden = true
|
self.navigationBarButton.isHidden = true
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Update isIndicatingActivity first to ensure later updates are applied correctly.
|
||||||
|
self.addButton.isIndicatingActivity = self.viewModel.isAddingSource
|
||||||
|
self.navigationBarButton.isIndicatingActivity = self.viewModel.isAddingSource
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
switch self.viewModel.isSourceAdded
|
||||||
|
{
|
||||||
|
case true?:
|
||||||
|
title = NSLocalizedString("REMOVE", comment: "")
|
||||||
|
self.navigationBarButton.tintColor = .refreshRed
|
||||||
|
|
||||||
|
self.addButton.isHidden = false
|
||||||
|
self.navigationBarButton.isHidden = false
|
||||||
|
|
||||||
|
case false?:
|
||||||
|
title = NSLocalizedString("ADD", comment: "")
|
||||||
|
self.navigationBarButton.tintColor = self.source.effectiveTintColor ?? .altPrimary
|
||||||
|
|
||||||
|
self.addButton.isHidden = false
|
||||||
|
self.navigationBarButton.isHidden = false
|
||||||
|
|
||||||
|
case nil:
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
self.addButton.isHidden = true
|
||||||
|
self.navigationBarButton.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.addButton.title != title
|
||||||
|
{
|
||||||
|
self.addButton.title = title
|
||||||
|
self.navigationBarButton.setTitle(title, for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.view.setNeedsLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//MARK: Actions
|
//MARK: Actions
|
||||||
|
|
||||||
|
@objc private func addSource()
|
||||||
|
{
|
||||||
|
self.viewModel.isAddingSource = true
|
||||||
|
|
||||||
|
Task<Void, Never> { /* @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()
|
@objc private func showWebsite()
|
||||||
{
|
{
|
||||||
guard let websiteURL = self.source.websiteURL else { return }
|
guard let websiteURL = self.source.websiteURL else { return }
|
||||||
@@ -119,3 +266,16 @@ class SourceDetailViewController: HeaderContentViewController<SourceHeaderView,
|
|||||||
self.present(safariViewController, animated: true, completion: nil)
|
self.present(safariViewController, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension SourceDetailViewController
|
||||||
|
{
|
||||||
|
func preparePipeline()
|
||||||
|
{
|
||||||
|
self.cancellable = Publishers
|
||||||
|
.CombineLatest(self.viewModel.$isSourceAdded, self.viewModel.$isAddingSource)
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2
Dependencies/Roxas
vendored
2
Dependencies/Roxas
vendored
Submodule Dependencies/Roxas updated: f6eb404e2d...a4f2e9f2eb
Reference in New Issue
Block a user