From 98125e93aa848e008bb0b3d0d1be4700150410e2 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 18 Oct 2023 18:56:40 -0500 Subject: [PATCH] Adds AddSourceViewController to add sources by URL or from list of recommended sources --- AltStore.xcodeproj/project.pbxproj | 28 +- AltStore/Components/AppBannerView.swift | 16 +- AltStore/Components/PillButton.swift | 2 + AltStore/Managing Apps/AppManager.swift | 10 +- .../Operations/FetchSourceOperation.swift | 10 + .../Sources/AddSourceViewController.swift | 816 ++++++++++++++++++ .../Components/AddSourceTextFieldCell.swift | 93 ++ .../SourceComponents.swift} | 24 + .../{ => Components}/SourceHeaderView.swift | 0 .../{ => Components}/SourceHeaderView.xib | 0 .../Sources/SourceDetailViewController.swift | 36 +- AltStore/Sources/Sources.storyboard | 79 +- AltStore/Sources/SourcesViewController.swift | 5 + 13 files changed, 1096 insertions(+), 23 deletions(-) create mode 100644 AltStore/Sources/AddSourceViewController.swift create mode 100644 AltStore/Sources/Components/AddSourceTextFieldCell.swift rename AltStore/Sources/{SourceDetailsComponents.swift => Components/SourceComponents.swift} (74%) rename AltStore/Sources/{ => Components}/SourceHeaderView.swift (100%) rename AltStore/Sources/{ => Components}/SourceHeaderView.xib (100%) diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 386a19b0..4cc377ad 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -342,6 +342,7 @@ BFF435D8255CBDAB00DD724F /* ALTApplication+AltStoreApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF435D7255CBDAB00DD724F /* ALTApplication+AltStoreApp.swift */; }; BFF615A82510042B00484D3B /* AltStoreCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF66EE7E2501AE50007EE018 /* AltStoreCore.framework */; }; BFF7C9342578492100E55F36 /* ALTAnisetteData.m in Sources */ = {isa = PBXBuildFile; fileRef = BFB49AA823834CF900D542D9 /* ALTAnisetteData.m */; }; + D50107EC2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50107EB2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift */; }; D513F6162A12CE4E0061EAA1 /* SourceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D571ADCD2A02FA7400B24B63 /* SourceError.swift */; }; D5151BD92A8FF64300C96F28 /* RefreshAllAppsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5151BD82A8FF64300C96F28 /* RefreshAllAppsIntent.swift */; }; D5151BE12A90344300C96F28 /* RefreshAllAppsWidgetIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5151BE02A90344300C96F28 /* RefreshAllAppsWidgetIntent.swift */; }; @@ -362,6 +363,7 @@ D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D533E8BD2727BBF800A9B5DD /* libcurl.a */; }; D537C8592AA94D94009A1E08 /* altjit in Embed AltJIT */ = {isa = PBXBuildFile; fileRef = D5FB7A132AA284BE00EF863D /* altjit */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; D537C85B2AA9507A009A1E08 /* libcorecrypto.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D537C85A2AA95066009A1E08 /* libcorecrypto.tbd */; platformFilters = (macos, ); }; + D5390C3C2AC3A43900D17E62 /* AddSourceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5390C3B2AC3A43900D17E62 /* AddSourceViewController.swift */; }; D53D84022A2158FC00543C3B /* Permissions.plist in Resources */ = {isa = PBXBuildFile; fileRef = D53D84012A2158FC00543C3B /* Permissions.plist */; }; D54058B92A1D6269008CCC58 /* AppPermissionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */; }; D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */; }; @@ -394,7 +396,7 @@ D59162AD29BA616A005CBF47 /* SourceHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */; }; D5927D6629DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */; }; D5927D6929DCE28700D6898E /* AltStore11ToAltStore12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5927D6829DCE28700D6898E /* AltStore11ToAltStore12.xcmappingmodel */; }; - D5935AED29C39DE300C157EF /* SourceDetailsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */; }; + D5935AED29C39DE300C157EF /* SourceComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5935AEC29C39DE300C157EF /* SourceComponents.swift */; }; D5935AEF29C3B23600C157EF /* Sources.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D5935AEE29C3B23600C157EF /* Sources.storyboard */; }; D593F1942717749A006E82DE /* PatchAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D593F1932717749A006E82DE /* PatchAppOperation.swift */; }; D59A6B7B2AA91B8E00F61259 /* PythonCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7A2AA91B8E00F61259 /* PythonCommand.swift */; }; @@ -1007,6 +1009,7 @@ D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = ""; }; C9EEAA842DA87A88A870053B /* Pods_AltStore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AltStore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D50107EB2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSourceTextFieldCell.swift; sourceTree = ""; }; D5151BD82A8FF64300C96F28 /* RefreshAllAppsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAllAppsIntent.swift; sourceTree = ""; }; D5151BE02A90344300C96F28 /* RefreshAllAppsWidgetIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAllAppsWidgetIntent.swift; sourceTree = ""; }; D5151BE52A90391900C96F28 /* View+AltWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AltWidget.swift"; sourceTree = ""; }; @@ -1023,6 +1026,7 @@ D533E8BB2727BBEE00A9B5DD /* libfragmentzip.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libfragmentzip.a; path = Dependencies/fragmentzip/libfragmentzip.a; sourceTree = SOURCE_ROOT; }; D533E8BD2727BBF800A9B5DD /* libcurl.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libcurl.a; path = Dependencies/libcurl/libcurl.a; sourceTree = SOURCE_ROOT; }; D537C85A2AA95066009A1E08 /* libcorecrypto.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libcorecrypto.tbd; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.0.sdk/usr/lib/system/libcorecrypto.tbd; sourceTree = DEVELOPER_DIR; }; + D5390C3B2AC3A43900D17E62 /* AddSourceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSourceViewController.swift; sourceTree = ""; }; D53D84012A2158FC00543C3B /* Permissions.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Permissions.plist; sourceTree = ""; }; D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionProtocol.swift; sourceTree = ""; }; D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = ""; }; @@ -1057,7 +1061,7 @@ D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBarAppearance+TintColor.swift"; sourceTree = ""; }; D5927D6729DCE1FE00D6898E /* AltStore 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 12.xcdatamodel"; sourceTree = ""; }; D5927D6829DCE28700D6898E /* AltStore11ToAltStore12.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore11ToAltStore12.xcmappingmodel; sourceTree = ""; }; - D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDetailsComponents.swift; sourceTree = ""; }; + D5935AEC29C39DE300C157EF /* SourceComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceComponents.swift; sourceTree = ""; }; D5935AEE29C3B23600C157EF /* Sources.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources.storyboard; sourceTree = ""; }; D593F1932717749A006E82DE /* PatchAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchAppOperation.swift; sourceTree = ""; }; D59A6B7A2AA91B8E00F61259 /* PythonCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PythonCommand.swift; sourceTree = ""; }; @@ -1835,9 +1839,8 @@ BFC84A4C2421A19100853474 /* SourcesViewController.swift */, D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */, D5A0537229B91DB400997551 /* SourceDetailContentViewController.swift */, - D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */, - D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */, - D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */, + D5390C3B2AC3A43900D17E62 /* AddSourceViewController.swift */, + D50107ED2ADF2E310069F2A1 /* Components */, ); path = Sources; sourceTree = ""; @@ -2133,6 +2136,17 @@ path = XPC; sourceTree = ""; }; + D50107ED2ADF2E310069F2A1 /* Components */ = { + isa = PBXGroup; + children = ( + D5935AEC29C39DE300C157EF /* SourceComponents.swift */, + D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */, + D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */, + D50107EB2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift */, + ); + path = Components; + sourceTree = ""; + }; D50C29F22A8ECD71009AB488 /* Widgets */ = { isa = PBXGroup; children = ( @@ -3149,6 +3163,7 @@ D5E1E7C128077DE90016FC96 /* UpdateKnownSourcesOperation.swift in Sources */, BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */, D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */, + D5390C3C2AC3A43900D17E62 /* AddSourceViewController.swift in Sources */, D5CD805F29CA755E00E591B0 /* SourceDetailViewController.swift in Sources */, D57968CB29CB99EF00539069 /* VibrantButton.swift in Sources */, BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */, @@ -3161,7 +3176,7 @@ BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */, BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */, BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */, - D5935AED29C39DE300C157EF /* SourceDetailsComponents.swift in Sources */, + D5935AED29C39DE300C157EF /* SourceComponents.swift in Sources */, D5A0537329B91DB400997551 /* SourceDetailContentViewController.swift in Sources */, BF770E5422BC044E002A40FE /* OperationContexts.swift in Sources */, BFD2478C2284C4C300981D42 /* AppIconImageView.swift in Sources */, @@ -3214,6 +3229,7 @@ BFC84A4D2421A19100853474 /* SourcesViewController.swift in Sources */, BFF0B696232242D3007A79E1 /* LicensesViewController.swift in Sources */, D57FE84428C7DB7100216002 /* ErrorLogViewController.swift in Sources */, + D50107EC2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift in Sources */, D5151BD92A8FF64300C96F28 /* RefreshAllAppsIntent.swift in Sources */, BFBE0007250AD0E70080826E /* ViewAppIntentHandler.swift in Sources */, BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */, diff --git a/AltStore/Components/AppBannerView.swift b/AltStore/Components/AppBannerView.swift index 7b0a8d8a..8bf05098 100644 --- a/AltStore/Components/AppBannerView.swift +++ b/AltStore/Components/AppBannerView.swift @@ -154,9 +154,21 @@ extension AppBannerView { self.style = .source - self.titleLabel.text = source.name + let subtitle: String + if let text = source.subtitle + { + subtitle = text + } + else if let scheme = source.sourceURL.scheme + { + subtitle = source.sourceURL.absoluteString.replacingOccurrences(of: scheme + "://", with: "") + } + else + { + subtitle = source.sourceURL.absoluteString + } - let subtitle = source.subtitle ?? source.sourceURL.absoluteString + self.titleLabel.text = source.name self.subtitleLabel.text = subtitle let tintColor = source.effectiveTintColor ?? .altPrimary diff --git a/AltStore/Components/PillButton.swift b/AltStore/Components/PillButton.swift index 0fc467df..180669e5 100644 --- a/AltStore/Components/PillButton.swift +++ b/AltStore/Components/PillButton.swift @@ -66,6 +66,8 @@ class PillButton: UIButton var style: Style = .pill { didSet { + guard self.style != oldValue else { return } + if self.style == .custom { // Reset insets for custom style. diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 9436011c..b3d668c3 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -358,7 +358,7 @@ extension AppManager } } - func add(@AsyncManaged _ source: Source, message: String? = nil, presentingViewController: UIViewController) async throws + func add(@AsyncManaged _ source: Source, message: String? = NSLocalizedString("Make sure to only add sources that you trust.", comment: ""), presentingViewController: UIViewController) async throws { let (sourceName, sourceURL) = await $source.perform { ($0.name, $0.sourceURL) } @@ -366,9 +366,8 @@ extension AppManager 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) + try await presentingViewController.presentConfirmationAlert(title: title, message: message ?? "", primaryAction: action) // Wait for fetch to finish before saving context to make // sure there isn't already a source with this identifier. @@ -412,10 +411,11 @@ extension AppManager extension AppManager { @available(*, renamed: "fetchSource(sourceURL:managedObjectContext:)") + @discardableResult func fetchSource(sourceURL: URL, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(), dependencies: [Foundation.Operation] = [], - completionHandler: @escaping (Result) -> Void) + completionHandler: @escaping (Result) -> Void) -> FetchSourceOperation { let fetchSourceOperation = FetchSourceOperation(sourceURL: sourceURL, managedObjectContext: managedObjectContext) fetchSourceOperation.resultHandler = { (result) in @@ -435,6 +435,8 @@ extension AppManager } self.run([fetchSourceOperation], context: nil) + + return fetchSourceOperation } @available(*, renamed: "fetchSources") diff --git a/AltStore/Operations/FetchSourceOperation.swift b/AltStore/Operations/FetchSourceOperation.swift index 31d595fc..dc9cd1c5 100644 --- a/AltStore/Operations/FetchSourceOperation.swift +++ b/AltStore/Operations/FetchSourceOperation.swift @@ -23,6 +23,7 @@ final class FetchSourceOperation: ResultOperation private var source: Source? private let session: URLSession + private weak var dataTask: URLSessionDataTask? private lazy var dateFormatter: ISO8601DateFormatter = { let dateFormatter = ISO8601DateFormatter() @@ -54,6 +55,13 @@ final class FetchSourceOperation: ResultOperation self.session = URLSession(configuration: configuration) } + override func cancel() + { + super.cancel() + + self.dataTask?.cancel() + } + override func main() { super.main() @@ -144,6 +152,8 @@ final class FetchSourceOperation: ResultOperation self.progress.addChild(dataTask.progress, withPendingUnitCount: 1) dataTask.resume() + + self.dataTask = dataTask } } diff --git a/AltStore/Sources/AddSourceViewController.swift b/AltStore/Sources/AddSourceViewController.swift new file mode 100644 index 00000000..f47b3874 --- /dev/null +++ b/AltStore/Sources/AddSourceViewController.swift @@ -0,0 +1,816 @@ +// +// AddSourceViewController.swift +// AltStore +// +// Created by Riley Testut on 9/26/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit +import Combine + +import AltStoreCore +import Roxas + +import Nuke + +private extension UIAction.Identifier +{ + static let addSource = UIAction.Identifier("io.altstore.AddSource") +} + +private typealias SourcePreviewResult = (sourceURL: URL, result: Result, Error>) + +extension AddSourceViewController +{ + private enum Section: Int + { + case add + case preview + case recommended + } + + private enum ReuseID: String + { + case textFieldCell = "TextFieldCell" + case placeholderFooter = "PlaceholderFooter" + } + + private class ViewModel: ObservableObject + { + /* Pipeline */ + @Published + var sourceAddress: String = "" + + @Published + var sourceURL: URL? + + @Published + var sourcePreviewResult: SourcePreviewResult? + + + /* State */ + @Published + var isLoadingPreview: Bool = false + + @Published + var isShowingPreviewStatus: Bool = false + } +} + +class AddSourceViewController: UICollectionViewController +{ + private lazy var dataSource = self.makeDataSource() + private lazy var addSourceDataSource = self.makeAddSourceDataSource() + private lazy var sourcePreviewDataSource = self.makeSourcePreviewDataSource() + private lazy var recommendedSourcesDataSource = self.makeRecommendedSourcesDataSource() + + private var fetchRecommendedSourcesOperation: UpdateKnownSourcesOperation? + private var fetchRecommendedSourcesResult: Result? + private var _fetchRecommendedSourcesContext: NSManagedObjectContext? + + private let viewModel = ViewModel() + private var cancellables: Set = [] + + override func viewDidLoad() + { + super.viewDidLoad() + + self.navigationController?.isModalInPresentation = true + self.navigationController?.view.tintColor = .altPrimary + + let layout = self.makeLayout() + self.collectionView.collectionViewLayout = layout + + self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) + self.collectionView.register(AddSourceTextFieldCell.self, forCellWithReuseIdentifier: ReuseID.textFieldCell.rawValue) + + self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: UICollectionView.elementKindSectionHeader) + self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: UICollectionView.elementKindSectionFooter) + self.collectionView.register(PlaceholderCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ReuseID.placeholderFooter.rawValue) + + self.collectionView.backgroundColor = .altBackground + self.collectionView.keyboardDismissMode = .onDrag + + self.collectionView.dataSource = self.dataSource + self.collectionView.prefetchDataSource = self.dataSource + + self.startPipeline() + } + + override func viewWillAppear(_ animated: Bool) + { + super.viewWillAppear(animated) + + if self.fetchRecommendedSourcesOperation == nil + { + self.fetchRecommendedSources() + } + } +} + +private extension AddSourceViewController +{ + func makeLayout() -> UICollectionViewCompositionalLayout + { + let layoutConfig = UICollectionViewCompositionalLayoutConfiguration() + layoutConfig.contentInsetsReference = .safeArea + + let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + guard let self, let section = Section(rawValue: sectionIndex) else { return nil } + switch section + { + case .add: + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20)) + let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = 10 + layoutSection.boundarySupplementaryItems = [headerItem] + return layoutSection + + case .preview: + var configuration = UICollectionLayoutListConfiguration(appearance: .grouped) + configuration.showsSeparators = false + configuration.backgroundColor = .clear + + if self.viewModel.sourceURL != nil && self.viewModel.isShowingPreviewStatus + { + switch self.viewModel.sourcePreviewResult + { + case (_, .success)?: configuration.footerMode = .none + case (_, .failure)?: configuration.footerMode = .supplementary + case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary + default: configuration.footerMode = .none + } + } + else + { + configuration.footerMode = .none + } + + let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) + return layoutSection + + case .recommended: + var configuration = UICollectionLayoutListConfiguration(appearance: .grouped) + configuration.showsSeparators = false + configuration.backgroundColor = .clear + + switch self.fetchRecommendedSourcesResult + { + case nil: + configuration.headerMode = .supplementary + configuration.footerMode = .supplementary + + case .failure: configuration.footerMode = .supplementary + case .success: configuration.headerMode = .supplementary + } + + let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) + return layoutSection + } + }, configuration: layoutConfig) + + return layout + } + + func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource + { + let dataSource = RSTCompositeCollectionViewPrefetchingDataSource(dataSources: [self.addSourceDataSource, + self.sourcePreviewDataSource, + self.recommendedSourcesDataSource]) + dataSource.proxy = self + return dataSource + } + + func makeAddSourceDataSource() -> RSTDynamicCollectionViewPrefetchingDataSource + { + let dataSource = RSTDynamicCollectionViewPrefetchingDataSource() + dataSource.numberOfSectionsHandler = { 1 } + dataSource.numberOfItemsHandler = { _ in 1 } + dataSource.cellIdentifierHandler = { _ in ReuseID.textFieldCell.rawValue } + dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in + guard let self else { return } + + let cell = cell as! AddSourceTextFieldCell + cell.contentView.layoutMargins.left = self.view.layoutMargins.left + cell.contentView.layoutMargins.right = self.view.layoutMargins.right + + cell.textField.delegate = self + + cell.setNeedsLayout() + cell.layoutIfNeeded() + + NotificationCenter.default + .publisher(for: UITextField.textDidChangeNotification, object: cell.textField) + .map { ($0.object as? UITextField)?.text ?? "" } + .assign(to: &self.viewModel.$sourceAddress) + + // Results in memory leak + // .assign(to: \.viewModel.sourceAddress, on: self) + } + + return dataSource + } + + func makeSourcePreviewDataSource() -> RSTArrayCollectionViewPrefetchingDataSource + { + let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: []) + dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in + guard let self else { return } + + let cell = cell as! AppBannerCollectionViewCell + self.configure(cell, with: source) + } + dataSource.prefetchHandler = { (source, indexPath, completionHandler) in + guard let imageURL = source.effectiveIconURL else { return nil } + + return RSTAsyncBlockOperation() { (operation) in + ImagePipeline.shared.loadImage(with: imageURL, progress: nil) { result in + guard !operation.isCancelled else { return operation.finish() } + + switch result + { + case .success(let response): completionHandler(response.image, nil) + case .failure(let error): completionHandler(nil, error) + } + } + } + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + let cell = cell as! AppBannerCollectionViewCell + cell.bannerView.iconImageView.isIndicatingActivity = false + cell.bannerView.iconImageView.image = image + + if let error = error + { + print("Error loading image:", error) + } + } + + return dataSource + } + + func makeRecommendedSourcesDataSource() -> RSTArrayCollectionViewPrefetchingDataSource + { + let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: []) + dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in + guard let self else { return } + + let cell = cell as! AppBannerCollectionViewCell + self.configure(cell, with: source) + } + dataSource.prefetchHandler = { (source, indexPath, completionHandler) in + guard let imageURL = source.effectiveIconURL else { return nil } + + return RSTAsyncBlockOperation() { (operation) in + ImagePipeline.shared.loadImage(with: imageURL, progress: nil) { result in + guard !operation.isCancelled else { return operation.finish() } + + switch result + { + case .success(let response): completionHandler(response.image, nil) + case .failure(let error): completionHandler(nil, error) + } + } + } + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + let cell = cell as! AppBannerCollectionViewCell + cell.bannerView.iconImageView.isIndicatingActivity = false + cell.bannerView.iconImageView.image = image + + if let error = error + { + print("Error loading image:", error) + } + } + + return dataSource + } +} + +private extension AddSourceViewController +{ + func startPipeline() + { + /* Pipeline */ + + // Map UITextField text -> URL + self.viewModel.$sourceAddress + .map { [weak self] in self?.sourceURL(from: $0) } + .assign(to: &self.viewModel.$sourceURL) + + let showPreviewStatusPublisher = self.viewModel.$isShowingPreviewStatus + .filter { $0 == true } + + let sourceURLPublisher = self.viewModel.$sourceURL + .removeDuplicates() + .debounce(for: 0.2, scheduler: RunLoop.main) + .receive(on: RunLoop.main) + .map { [weak self] sourceURL in + // Only set sourcePreviewResult to nil if sourceURL actually changes. + self?.viewModel.sourcePreviewResult = nil + return sourceURL + } + + // Map URL -> Source Preview + Publishers.CombineLatest(sourceURLPublisher, showPreviewStatusPublisher.prepend(false)) + .receive(on: RunLoop.main) + .map { $0.0 } + .compactMap { [weak self] (sourceURL: URL?) -> AnyPublisher? in + guard let self else { return nil } + + guard let sourceURL else { + // Unlike above guard, this continues the pipeline with nil value. + return Just(nil).eraseToAnyPublisher() + } + + self.viewModel.isLoadingPreview = true + return self.fetchSourcePreview(sourceURL: sourceURL).eraseToAnyPublisher() + } + .switchToLatest() // Cancels previous publisher + .receive(on: RunLoop.main) + .sink { [weak self] sourcePreviewResult in + self?.viewModel.isLoadingPreview = false + self?.viewModel.sourcePreviewResult = sourcePreviewResult + } + .store(in: &self.cancellables) + + + /* Update UI */ + + Publishers.CombineLatest(self.viewModel.$isLoadingPreview.removeDuplicates(), + self.viewModel.$isShowingPreviewStatus.removeDuplicates()) + .sink { [weak self] _ in + guard let self else { return } + + // @Published fires _before_ property is updated, so wait until next run loop. + DispatchQueue.main.async { + self.collectionView.performBatchUpdates { + let indexPath = IndexPath(item: 0, section: Section.preview.rawValue) + + if let footerView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) as? PlaceholderCollectionReusableView + { + self.configure(footerView, with: self.viewModel.sourcePreviewResult) + } + + let context = UICollectionViewLayoutInvalidationContext() + context.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter, at: [indexPath]) + self.collectionView.collectionViewLayout.invalidateLayout(with: context) + } + } + } + .store(in: &self.cancellables) + + self.viewModel.$sourcePreviewResult + .map { $0?.1 } + .map { result -> Managed? in + switch result + { + case .success(let source): return source + case .failure, nil: return nil + } + } + .removeDuplicates { (sourceA: Managed?, sourceB: Managed?) in + sourceA?.identifier == sourceB?.identifier + } + .receive(on: RunLoop.main) + .sink { [weak self] source in + self?.updateSourcePreview(for: source?.wrappedValue) + } + .store(in: &self.cancellables) + + let addPublisher = NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification) + let removePublisher = NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification) + Publishers.Merge(addPublisher, removePublisher) + .compactMap { notification -> String? in + guard let source = notification.object as? Source, + let context = source.managedObjectContext + else { return nil } + + let sourceID = context.performAndWait { source.identifier } + return sourceID + } + .receive(on: RunLoop.main) + .compactMap { [dataSource = recommendedSourcesDataSource] sourceID -> IndexPath? in + guard let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) else { return nil } + + let indexPath = IndexPath(item: index, section: Section.recommended.rawValue) + return indexPath + } + .sink { [weak self] indexPath in + // Added or removed a recommended source, so make sure to update its state. + self?.collectionView.reloadItems(at: [indexPath]) + } + .store(in: &self.cancellables) + } + + func sourceURL(from address: String) -> URL? + { + guard let sourceURL = URL(string: address) else { return nil } + + // URLs without hosts are OK (e.g. localhost:8000) + // guard sourceURL.host != nil else { return } + + guard let scheme = sourceURL.scheme else { + let sanitizedURL = URL(string: "https://" + address) + return sanitizedURL + } + + guard scheme.lowercased() != "localhost" else { + let sanitizedURL = URL(string: "http://" + address) + return sanitizedURL + } + + return sourceURL + } + + func fetchSourcePreview(sourceURL: URL) -> some Publisher + { + let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext() + + var fetchOperation: FetchSourceOperation? + return Future { promise in + fetchOperation = AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in + promise(result) + } + } + .map { source in + let result = SourcePreviewResult(sourceURL, .success(Managed(wrappedValue: source))) + return result + } + .catch { error in + print("Failed to fetch source for URL \(sourceURL).", error.localizedDescription) + + let result = SourcePreviewResult(sourceURL, .failure(error)) + return Just(result) + } + .handleEvents(receiveCancel: { + fetchOperation?.cancel() + }) + } + + func updateSourcePreview(for source: Source?) + { + let items = [source].compactMap { $0 } + + // Have to provide changes in terms of sourcePreviewDataSource. + let indexPath = IndexPath(row: 0, section: 0) + + if !items.isEmpty && self.sourcePreviewDataSource.items.isEmpty + { + let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: indexPath) + self.sourcePreviewDataSource.setItems(items, with: [change]) + } + else if items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty + { + let change = RSTCellContentChange(type: .delete, currentIndexPath: indexPath, destinationIndexPath: nil) + self.sourcePreviewDataSource.setItems(items, with: [change]) + } + else if !items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty + { + let change = RSTCellContentChange(type: .update, currentIndexPath: indexPath, destinationIndexPath: indexPath) + self.sourcePreviewDataSource.setItems(items, with: [change]) + } + + if source == nil + { + self.collectionView.reloadSections([Section.preview.rawValue]) + } + else + { + self.collectionView.collectionViewLayout.invalidateLayout() + } + } +} + +private extension AddSourceViewController +{ + func configure(_ cell: AppBannerCollectionViewCell, with source: Source) + { + cell.bannerView.style = .source + cell.layoutMargins.top = 5 + cell.layoutMargins.bottom = 5 + cell.layoutMargins.left = self.view.layoutMargins.left + cell.layoutMargins.right = self.view.layoutMargins.right + cell.contentView.backgroundColor = .altBackground + + cell.bannerView.configure(for: source) + cell.bannerView.subtitleLabel.numberOfLines = 2 + + cell.bannerView.iconImageView.image = nil + cell.bannerView.iconImageView.isIndicatingActivity = true + + let config = UIImage.SymbolConfiguration(scale: .medium) + let image = UIImage(systemName: "plus.circle.fill", withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) + cell.bannerView.button.setImage(image, for: .normal) + cell.bannerView.button.setImage(image, for: .highlighted) + cell.bannerView.button.setTitle(nil, for: .normal) + cell.bannerView.button.imageView?.contentMode = .scaleAspectFit + cell.bannerView.button.contentHorizontalAlignment = .fill // Fill entire button with imageView + cell.bannerView.button.contentVerticalAlignment = .fill + cell.bannerView.button.contentEdgeInsets = .zero + cell.bannerView.button.tintColor = .clear + cell.bannerView.button.isHidden = false + + let action = UIAction(identifier: .addSource) { [weak self] _ in + self?.add(source) + } + cell.bannerView.button.addAction(action, for: .primaryActionTriggered) + + Task(priority: .userInitiated) { + do + { + let isAdded = try await source.isAdded + if isAdded + { + cell.bannerView.button.isHidden = true + } + } + catch + { + print("Failed to determine if source is added.", error) + } + } + } + + func configure(_ footerView: PlaceholderCollectionReusableView, with sourcePreviewResult: SourcePreviewResult?) + { + footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = false + + footerView.placeholderView.textLabel.textColor = .secondaryLabel + footerView.placeholderView.textLabel.font = .preferredFont(forTextStyle: .subheadline) + footerView.placeholderView.textLabel.textAlignment = .center + + footerView.placeholderView.detailTextLabel.isHidden = true + + switch sourcePreviewResult + { + case (let sourceURL, .failure(let previewError))? where self.viewModel.sourceURL == sourceURL && !self.viewModel.isLoadingPreview: + // The current URL matches the error being displayed, and we're not loading another preview, so show error. + + footerView.placeholderView.textLabel.text = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription + footerView.placeholderView.textLabel.isHidden = false + + footerView.placeholderView.activityIndicatorView.stopAnimating() + + default: + // The current URL does not match the URL of the source/error being displayed, so show loading indicator. + + footerView.placeholderView.textLabel.text = nil + footerView.placeholderView.textLabel.isHidden = true + + footerView.placeholderView.activityIndicatorView.startAnimating() + } + } + + func fetchRecommendedSources() + { + // Closure instead of local function so we can capture `self` weakly. + let finish: (Result<[Source], Error>) -> Void = { [weak self] result in + self?.fetchRecommendedSourcesResult = result.map { _ in () } + + DispatchQueue.main.async { + do + { + let sources = try result.get() + print("Fetched recommended sources:", sources.map { $0.identifier }) + + let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0) + self?.recommendedSourcesDataSource.setItems(sources, with: [sectionUpdate]) + } + catch + { + print("Error fetching recommended sources:", error) + + let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0) + self?.recommendedSourcesDataSource.setItems([], with: [sectionUpdate]) + } + } + } + + self.fetchRecommendedSourcesOperation = AppManager.shared.updateKnownSources { [weak self] result in + switch result + { + case .failure(let error): finish(.failure(error)) + case .success((let trustedSources, _)): + + // Don't show sources without a sourceURL. + let featuredSourceURLs = trustedSources.compactMap { $0.sourceURL } + + // This context is never saved, but keeps the managed sources alive. + let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext() + self?._fetchRecommendedSourcesContext = context + + let dispatchGroup = DispatchGroup() + + var sourcesByURL = [URL: Source]() + var fetchError: Error? + + for sourceURL in featuredSourceURLs + { + dispatchGroup.enter() + + AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in + // Serialize access to sourcesByURL. + context.performAndWait { + switch result + { + case .failure(let error): + print("Failed to load recommended source \(sourceURL.absoluteString):", error.localizedDescription) + fetchError = error + + case .success(let source): sourcesByURL[source.sourceURL] = source + } + + dispatchGroup.leave() + } + } + } + + dispatchGroup.notify(queue: .main) { + let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] } + + if let error = fetchError, sources.isEmpty + { + finish(.failure(error)) + } + else + { + finish(.success(sources)) + } + } + } + } + } + + func add(@AsyncManaged _ source: Source) + { + Task { + do + { + let isRecommended = await $source.isRecommended + if isRecommended + { + try await AppManager.shared.add(source, message: nil, presentingViewController: self) + } + else + { + // Use default message + try await AppManager.shared.add(source, presentingViewController: self) + } + + self.dismiss() + + } + catch is CancellationError {} + catch + { + let errorTitle = NSLocalizedString("Unable to Add Source", comment: "") + await self.presentAlert(title: errorTitle, message: error.localizedDescription) + } + } + } + + func dismiss() + { + guard + let navigationController = self.navigationController, let presentingViewController = navigationController.presentingViewController + else { return } + + presentingViewController.dismiss(animated: true) + } +} + +private extension AddSourceViewController +{ + @IBSegueAction + func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController? + { + guard let source = sender as? Source else { return nil } + + let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder) + sourceDetailViewController?.addedSourceHandler = { [weak self] _ in + self?.dismiss() + } + return sourceDetailViewController + } +} + +extension AddSourceViewController +{ + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) + { + guard Section(rawValue: indexPath.section) != .add else { return } + + let source = self.dataSource.item(at: indexPath) + self.performSegue(withIdentifier: "showSourceDetails", sender: source) + } +} + +extension AddSourceViewController: UICollectionViewDelegateFlowLayout +{ + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView + { + let section = Section(rawValue: indexPath.section)! + switch (section, kind) + { + case (.add, UICollectionView.elementKindSectionHeader): + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell + + var configuation = UIListContentConfiguration.cell() + configuation.text = NSLocalizedString("Enter a source's URL below, or add one of the recommended sources.", comment: "") + configuation.textProperties.color = .secondaryLabel + + headerView.contentConfiguration = configuation + + return headerView + + case (.preview, UICollectionView.elementKindSectionFooter): + let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView + + self.configure(footerView, with: self.viewModel.sourcePreviewResult) + + return footerView + + case (.recommended, UICollectionView.elementKindSectionHeader): + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell + + var configuation = UIListContentConfiguration.groupedHeader() + configuation.text = NSLocalizedString("Recommended Sources", comment: "") + configuation.textProperties.color = .secondaryLabel + + headerView.contentConfiguration = configuation + + return headerView + + case (.recommended, UICollectionView.elementKindSectionFooter): + let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView + + footerView.placeholderView.stackView.spacing = 15 + footerView.placeholderView.stackView.directionalLayoutMargins.top = 20 + footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = true + + if let result = self.fetchRecommendedSourcesResult, case .failure(let error) = result + { + footerView.placeholderView.textLabel.isHidden = false + footerView.placeholderView.textLabel.font = UIFont.preferredFont(forTextStyle: .headline) + footerView.placeholderView.textLabel.text = NSLocalizedString("Unable to Load Recommended Sources", comment: "") + + footerView.placeholderView.detailTextLabel.isHidden = false + footerView.placeholderView.detailTextLabel.text = error.localizedDescription + + footerView.placeholderView.activityIndicatorView.stopAnimating() + } + else + { + footerView.placeholderView.textLabel.isHidden = true + footerView.placeholderView.detailTextLabel.isHidden = true + + footerView.placeholderView.activityIndicatorView.startAnimating() + } + + return footerView + + default: fatalError() + } + } +} + +extension AddSourceViewController: UITextFieldDelegate +{ + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool + { + self.viewModel.isShowingPreviewStatus = false + return true + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool + { + textField.resignFirstResponder() + return false + } + + func textFieldDidEndEditing(_ textField: UITextField) + { + self.viewModel.isShowingPreviewStatus = true + } +} + +@available(iOS 17.0, *) +#Preview(traits: .portrait) { + DatabaseManager.shared.startForPreview() + + let storyboard = UIStoryboard(name: "Sources", bundle: .main) + + let addSourceNavigationController = storyboard.instantiateViewController(withIdentifier: "addSourceNavigationController") + return addSourceNavigationController +} diff --git a/AltStore/Sources/Components/AddSourceTextFieldCell.swift b/AltStore/Sources/Components/AddSourceTextFieldCell.swift new file mode 100644 index 00000000..8f8ff254 --- /dev/null +++ b/AltStore/Sources/Components/AddSourceTextFieldCell.swift @@ -0,0 +1,93 @@ +// +// AddSourceTextFieldCell.swift +// AltStore +// +// Created by Riley Testut on 10/17/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +class AddSourceTextFieldCell: UICollectionViewCell +{ + let textField: UITextField + + private let backgroundEffectView: UIVisualEffectView + private let imageView: UIImageView + + override init(frame: CGRect) + { + self.textField = UITextField(frame: frame) + self.textField.translatesAutoresizingMaskIntoConstraints = false + self.textField.placeholder = "apps.altstore.io" + self.textField.textContentType = .URL + self.textField.keyboardType = .URL + self.textField.returnKeyType = .done + self.textField.autocapitalizationType = .none + self.textField.autocorrectionType = .no + self.textField.spellCheckingType = .no + self.textField.enablesReturnKeyAutomatically = true + self.textField.tintColor = .altPrimary + self.textField.textColor = UIColor { traits in + if traits.userInterfaceStyle == .dark + { + //TODO: Change once we update UIColor.altPrimary to match 2.0 icon. + return UIColor(resource: .gradientTop) + } + else + { + return UIColor.altPrimary + } + } + + let blurEffect = UIBlurEffect(style: .systemChromeMaterial) + self.backgroundEffectView = UIVisualEffectView(effect: blurEffect) + self.backgroundEffectView.translatesAutoresizingMaskIntoConstraints = false + self.backgroundEffectView.clipsToBounds = true + self.backgroundEffectView.backgroundColor = .altPrimary + + let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .bold) + let image = UIImage(systemName: "link", withConfiguration: config)?.withRenderingMode(.alwaysTemplate) + self.imageView = UIImageView(image: image) + self.imageView.translatesAutoresizingMaskIntoConstraints = false + self.imageView.contentMode = .center + self.imageView.tintColor = .altPrimary + + super.init(frame: frame) + + self.contentView.preservesSuperviewLayoutMargins = true + + self.backgroundEffectView.contentView.addSubview(self.imageView) + self.backgroundEffectView.contentView.addSubview(self.textField) + self.contentView.addSubview(self.backgroundEffectView) + + NSLayoutConstraint.activate([ + self.backgroundEffectView.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor), + self.backgroundEffectView.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor), + self.backgroundEffectView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + self.backgroundEffectView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + + self.imageView.widthAnchor.constraint(equalToConstant: 44), + self.imageView.heightAnchor.constraint(equalToConstant: 44), + self.imageView.centerYAnchor.constraint(equalTo: self.backgroundEffectView.centerYAnchor), + + self.textField.topAnchor.constraint(equalTo: self.backgroundEffectView.topAnchor, constant: 15), + self.textField.bottomAnchor.constraint(equalTo: self.backgroundEffectView.bottomAnchor, constant: -15), + self.textField.trailingAnchor.constraint(equalTo: self.backgroundEffectView.trailingAnchor, constant: -15), + + self.imageView.leadingAnchor.constraint(equalTo: self.backgroundEffectView.leadingAnchor, constant: 15), + self.textField.leadingAnchor.constraint(equalToSystemSpacingAfter: self.imageView.trailingAnchor, multiplier: 1.0), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() + { + super.layoutSubviews() + + self.backgroundEffectView.layer.cornerRadius = self.backgroundEffectView.bounds.midY + } +} diff --git a/AltStore/Sources/SourceDetailsComponents.swift b/AltStore/Sources/Components/SourceComponents.swift similarity index 74% rename from AltStore/Sources/SourceDetailsComponents.swift rename to AltStore/Sources/Components/SourceComponents.swift index 570b3556..e57c840b 100644 --- a/AltStore/Sources/SourceDetailsComponents.swift +++ b/AltStore/Sources/Components/SourceComponents.swift @@ -87,3 +87,27 @@ class TextViewCollectionViewCell: UICollectionViewCell self.textView.textContainerInset.right = self.contentView.layoutMargins.right } } + +class PlaceholderCollectionReusableView: UICollectionReusableView +{ + let placeholderView: RSTPlaceholderView + + override init(frame: CGRect) + { + self.placeholderView = RSTPlaceholderView(frame: .zero) + self.placeholderView.activityIndicatorView.style = .medium + + super.init(frame: frame) + + self.addSubview(self.placeholderView, pinningEdgesWith: .zero) + + NSLayoutConstraint.activate([ + self.placeholderView.stackView.topAnchor.constraint(equalTo: self.placeholderView.topAnchor), + self.placeholderView.stackView.bottomAnchor.constraint(equalTo: self.placeholderView.bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/AltStore/Sources/SourceHeaderView.swift b/AltStore/Sources/Components/SourceHeaderView.swift similarity index 100% rename from AltStore/Sources/SourceHeaderView.swift rename to AltStore/Sources/Components/SourceHeaderView.swift diff --git a/AltStore/Sources/SourceHeaderView.xib b/AltStore/Sources/Components/SourceHeaderView.xib similarity index 100% rename from AltStore/Sources/SourceHeaderView.xib rename to AltStore/Sources/Components/SourceHeaderView.xib diff --git a/AltStore/Sources/SourceDetailViewController.swift b/AltStore/Sources/SourceDetailViewController.swift index a27ab248..111fb893 100644 --- a/AltStore/Sources/SourceDetailViewController.swift +++ b/AltStore/Sources/SourceDetailViewController.swift @@ -31,6 +31,30 @@ extension SourceDetailViewController { self.source = source + let sourceID = source.identifier + + let addedPublisher = NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification, object: nil) + let removedPublisher = NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification, object: nil) + + Publishers.Merge(addedPublisher, removedPublisher) + .filter { notification -> Bool in + guard let source = notification.object as? Source, let context = source.managedObjectContext else { return false } + + let updatedSourceID = context.performAndWait { source.identifier } + return sourceID == updatedSourceID + } + .compactMap { notification in + switch notification.name + { + case AppManager.didAddSourceNotification: return true + case AppManager.didRemoveSourceNotification: return false + default: return nil + } + } + .filter { $0 != nil } + .receive(on: RunLoop.main) + .assign(to: &self.$isSourceAdded) + Task { do { @@ -49,6 +73,8 @@ class SourceDetailViewController: HeaderContentViewController Void)? + private let viewModel: ViewModel private var addButton: VibrantButton! @@ -224,7 +250,6 @@ 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 @@ -238,9 +263,9 @@ class SourceDetailViewController: HeaderContentViewController - + - + @@ -109,6 +109,7 @@ + @@ -174,7 +175,11 @@ - + + + + + @@ -184,7 +189,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift index e9703983..ae8a2807 100644 --- a/AltStore/Sources/SourcesViewController.swift +++ b/AltStore/Sources/SourcesViewController.swift @@ -347,6 +347,11 @@ private extension SourcesViewController let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder) return sourceDetailViewController } + + @IBAction + func unwindFromAddSource(_ segue: UIStoryboardSegue) + { + } } private extension SourcesViewController