Supports adding trusted sources from SourcesViewController

Previously, only the beta version of AltStore could add sources. Now, the public version supports adding explicitly “trusted” sources, while the beta version can continue to add any source.
This commit is contained in:
Riley Testut
2022-04-14 16:24:11 -07:00
parent f1f6852ab4
commit 1f7c089c70
7 changed files with 443 additions and 60 deletions

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17503.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="wKh-xq-NuP"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="wKh-xq-NuP">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17502"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Named colors" minToolsVersion="9.0"/> <capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
@@ -314,8 +314,8 @@
</textView> </textView>
</subviews> </subviews>
<constraints> <constraints>
<constraint firstItem="Pyt-8D-BZA" firstAttribute="top" secondItem="D1G-nK-G0Z" secondAttribute="top" constant="20" id="Lm9-lx-kJ8"/> <constraint firstItem="Pyt-8D-BZA" firstAttribute="top" secondItem="D1G-nK-G0Z" secondAttribute="top" priority="999" constant="20" id="Lm9-lx-kJ8"/>
<constraint firstAttribute="bottom" secondItem="Pyt-8D-BZA" secondAttribute="bottom" constant="44" id="TSS-Au-gYx"/> <constraint firstAttribute="bottom" secondItem="Pyt-8D-BZA" secondAttribute="bottom" priority="999" constant="44" id="TSS-Au-gYx"/>
<constraint firstItem="Pyt-8D-BZA" firstAttribute="leading" secondItem="D1G-nK-G0Z" secondAttribute="leading" constant="20" id="UlS-ct-L9Y"/> <constraint firstItem="Pyt-8D-BZA" firstAttribute="leading" secondItem="D1G-nK-G0Z" secondAttribute="leading" constant="20" id="UlS-ct-L9Y"/>
<constraint firstAttribute="trailing" secondItem="Pyt-8D-BZA" secondAttribute="trailing" constant="20" id="Wq4-Ql-wvN"/> <constraint firstAttribute="trailing" secondItem="Pyt-8D-BZA" secondAttribute="trailing" constant="20" id="Wq4-Ql-wvN"/>
</constraints> </constraints>
@@ -944,15 +944,15 @@ World</string>
<scene sceneID="0S1-zn-9KZ"> <scene sceneID="0S1-zn-9KZ">
<objects> <objects>
<collectionViewController title="Sources" id="cHC-TX-KzQ" customClass="SourcesViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController"> <collectionViewController title="Sources" id="cHC-TX-KzQ" customClass="SourcesViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" dataMode="prototypes" id="S36-hD-vu2"> <collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" dataMode="prototypes" id="S36-hD-vu2">
<rect key="frame" x="0.0" y="0.0" width="375" height="647"/> <rect key="frame" x="0.0" y="0.0" width="375" height="647"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="X20-b5-XEP"> <collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="X20-b5-XEP">
<size key="itemSize" width="375" height="80"/> <size key="itemSize" width="375" height="80"/>
<size key="headerReferenceSize" width="50" height="200"/> <size key="headerReferenceSize" width="50" height="200"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/> <size key="footerReferenceSize" width="50" height="50"/>
<inset key="sectionInset" minX="0.0" minY="10" maxX="0.0" maxY="20"/> <inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout> </collectionViewFlowLayout>
<cells> <cells>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XcN-o4-9qm" customClass="BannerCollectionViewCell" customModule="AltStore" customModuleProvider="target"> <collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XcN-o4-9qm" customClass="BannerCollectionViewCell" customModule="AltStore" customModuleProvider="target">
@@ -999,7 +999,46 @@ World</string>
<constraint firstAttribute="leadingMargin" secondItem="TZv-TM-uJj" secondAttribute="leading" id="aS5-6Y-rMd"/> <constraint firstAttribute="leadingMargin" secondItem="TZv-TM-uJj" secondAttribute="leading" id="aS5-6Y-rMd"/>
</constraints> </constraints>
<connections> <connections>
<outlet property="bottomLayoutConstraint" destination="Aml-PC-dko" id="I1s-ae-C8A"/>
<outlet property="leadingLayoutConstraint" destination="aS5-6Y-rMd" id="An8-KN-xfb"/>
<outlet property="textLabel" destination="TZv-TM-uJj" id="kWV-Wv-5gz"/> <outlet property="textLabel" destination="TZv-TM-uJj" id="kWV-Wv-5gz"/>
<outlet property="topLayoutConstraint" destination="2zE-UV-24S" id="mjq-yH-v8J"/>
<outlet property="trailingLayoutConstraint" destination="V0U-al-5eb" id="z8b-2G-SgY"/>
</connections>
</collectionReusableView>
<collectionReusableView key="sectionFooterView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Footer" id="X5B-Kp-w1p" customClass="SourcesFooterView">
<rect key="frame" x="0.0" y="280" width="375" height="50"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="j0O-xE-gyd">
<rect key="frame" x="8" y="0.0" width="359" height="50"/>
<subviews>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" animating="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="PNx-uR-y2F">
<rect key="frame" x="0.0" y="0.0" width="359" height="3"/>
</activityIndicatorView>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="800" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" editable="NO" text="Test" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="66c-H8-KJx">
<rect key="frame" x="0.0" y="18" width="359" height="32"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="j0O-xE-gyd" secondAttribute="bottom" id="BQ5-11-BzK"/>
<constraint firstItem="j0O-xE-gyd" firstAttribute="top" secondItem="X5B-Kp-w1p" secondAttribute="top" id="KZg-fd-8Cp" propertyAccessControl="none"/>
<constraint firstItem="j0O-xE-gyd" firstAttribute="leading" secondItem="X5B-Kp-w1p" secondAttribute="leadingMargin" id="R2x-Io-bXD"/>
<constraint firstAttribute="trailingMargin" secondItem="j0O-xE-gyd" secondAttribute="trailing" id="aBK-Bq-P9O"/>
</constraints>
<connections>
<outlet property="activityIndicatorView" destination="PNx-uR-y2F" id="7Le-VW-GYK"/>
<outlet property="bottomLayoutConstraint" destination="BQ5-11-BzK" id="iJR-4o-u9l"/>
<outlet property="leadingLayoutConstraint" destination="R2x-Io-bXD" id="plZ-Yj-zTc"/>
<outlet property="textView" destination="66c-H8-KJx" id="kwc-OH-U6i"/>
<outlet property="topLayoutConstraint" destination="KZg-fd-8Cp" id="zNM-UU-feF"/>
<outlet property="trailingLayoutConstraint" destination="aBK-Bq-P9O" id="L2r-VL-ruT"/>
</connections> </connections>
</collectionReusableView> </collectionReusableView>
<connections> <connections>

View File

@@ -11,4 +11,9 @@ import UIKit
class TextCollectionReusableView: UICollectionReusableView class TextCollectionReusableView: UICollectionReusableView
{ {
@IBOutlet var textLabel: UILabel! @IBOutlet var textLabel: UILabel!
@IBOutlet var topLayoutConstraint: NSLayoutConstraint!
@IBOutlet var bottomLayoutConstraint: NSLayoutConstraint!
@IBOutlet var leadingLayoutConstraint: NSLayoutConstraint!
@IBOutlet var trailingLayoutConstraint: NSLayoutConstraint!
} }

View File

@@ -312,9 +312,12 @@ extension AppManager
extension AppManager extension AppManager
{ {
func fetchSource(sourceURL: URL, completionHandler: @escaping (Result<Source, Error>) -> Void) func fetchSource(sourceURL: URL,
managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(),
dependencies: [Foundation.Operation] = [],
completionHandler: @escaping (Result<Source, Error>) -> Void)
{ {
let fetchSourceOperation = FetchSourceOperation(sourceURL: sourceURL) let fetchSourceOperation = FetchSourceOperation(sourceURL: sourceURL, managedObjectContext: managedObjectContext)
fetchSourceOperation.resultHandler = { (result) in fetchSourceOperation.resultHandler = { (result) in
switch result switch result
{ {
@@ -326,6 +329,11 @@ extension AppManager
} }
} }
for dependency in dependencies
{
fetchSourceOperation.addDependency(dependency)
}
self.run([fetchSourceOperation], context: nil) self.run([fetchSourceOperation], context: nil)
} }

View File

@@ -30,6 +30,22 @@ struct SourceError: LocalizedError
} }
} }
@objc(SourcesFooterView)
private class SourcesFooterView: TextCollectionReusableView
{
@IBOutlet var activityIndicatorView: UIActivityIndicatorView!
@IBOutlet var textView: UITextView!
}
extension SourcesViewController
{
private enum Section: Int, CaseIterable
{
case added
case trusted
}
}
class SourcesViewController: UICollectionViewController class SourcesViewController: UICollectionViewController
{ {
var deepLinkSourceURL: URL? { var deepLinkSourceURL: URL? {
@@ -40,6 +56,12 @@ class SourcesViewController: UICollectionViewController
} }
private lazy var dataSource = self.makeDataSource() private lazy var dataSource = self.makeDataSource()
private lazy var addedSourcesDataSource = self.makeAddedSourcesDataSource()
private lazy var trustedSourcesDataSource = self.makeTrustedSourcesDataSource()
private var fetchTrustedSourcesOperation: FetchTrustedSourcesOperation?
private var fetchTrustedSourcesResult: Result<Void, Error>?
private var _fetchTrustedSourcesContext: NSManagedObjectContext?
override func viewDidLoad() override func viewDidLoad()
{ {
@@ -61,6 +83,11 @@ class SourcesViewController: UICollectionViewController
{ {
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
} }
if self.fetchTrustedSourcesOperation == nil
{
self.fetchTrustedSources()
}
} }
override func viewDidAppear(_ animated: Bool) override func viewDidAppear(_ animated: Bool)
@@ -70,22 +97,15 @@ class SourcesViewController: UICollectionViewController
if let sourceURL = self.deepLinkSourceURL if let sourceURL = self.deepLinkSourceURL
{ {
self.addSource(url: sourceURL) self.addSource(url: sourceURL)
self.deepLinkSourceURL = nil
} }
} }
} }
private extension SourcesViewController private extension SourcesViewController
{ {
func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource<Source> func makeDataSource() -> RSTCompositeCollectionViewDataSource<Source>
{ {
let fetchRequest = Source.fetchRequest() as NSFetchRequest<Source> let dataSource = RSTCompositeCollectionViewDataSource<Source>(dataSources: [self.addedSourcesDataSource, self.trustedSourcesDataSource])
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Source.name, ascending: true),
NSSortDescriptor(keyPath: \Source.sourceURL, ascending: true),
NSSortDescriptor(keyPath: \Source.identifier, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
let dataSource = RSTFetchedResultsCollectionViewDataSource<Source>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.proxy = self dataSource.proxy = self
dataSource.cellConfigurationHandler = { (cell, source, indexPath) in dataSource.cellConfigurationHandler = { (cell, source, indexPath) in
let tintColor = UIColor.altPrimary let tintColor = UIColor.altPrimary
@@ -97,9 +117,44 @@ private extension SourcesViewController
cell.bannerView.iconImageView.isHidden = true cell.bannerView.iconImageView.isHidden = true
cell.bannerView.buttonLabel.isHidden = true cell.bannerView.buttonLabel.isHidden = true
cell.bannerView.button.isHidden = true
cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.isIndicatingActivity = false
switch Section.allCases[indexPath.section]
{
case .added:
cell.bannerView.button.isHidden = true
case .trusted:
// Quicker way to determine whether a source is already added than by reading from disk.
if (self.addedSourcesDataSource.fetchedResultsController.fetchedObjects ?? []).contains(where: { $0.identifier == source.identifier })
{
// Source exists in .added section, so hide the button.
cell.bannerView.button.isHidden = true
if #available(iOS 13.0, *)
{
let configuation = UIImage.SymbolConfiguration(pointSize: 24)
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(systemName: "checkmark.circle", withConfiguration: configuation)?.withTintColor(.altPrimary)
let attributedText = NSAttributedString(attachment: imageAttachment)
cell.bannerView.buttonLabel.attributedText = attributedText
cell.bannerView.buttonLabel.textAlignment = .center
cell.bannerView.buttonLabel.isHidden = false
}
}
else
{
// Source does not exist in .added section, so show the button.
cell.bannerView.button.isHidden = false
cell.bannerView.buttonLabel.attributedText = nil
}
cell.bannerView.button.setTitle(NSLocalizedString("ADD", comment: ""), for: .normal)
cell.bannerView.button.addTarget(self, action: #selector(SourcesViewController.addTrustedSource(_:)), for: .primaryActionTriggered)
}
cell.bannerView.titleLabel.text = source.name cell.bannerView.titleLabel.text = source.name
cell.bannerView.subtitleLabel.text = source.sourceURL.absoluteString cell.bannerView.subtitleLabel.text = source.sourceURL.absoluteString
cell.bannerView.subtitleLabel.numberOfLines = 2 cell.bannerView.subtitleLabel.numberOfLines = 2
@@ -116,6 +171,24 @@ private extension SourcesViewController
return dataSource return dataSource
} }
func makeAddedSourcesDataSource() -> RSTFetchedResultsCollectionViewDataSource<Source>
{
let fetchRequest = Source.fetchRequest() as NSFetchRequest<Source>
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Source.name, ascending: true),
NSSortDescriptor(keyPath: \Source.sourceURL, ascending: true),
NSSortDescriptor(keyPath: \Source.identifier, ascending: true)]
let dataSource = RSTFetchedResultsCollectionViewDataSource<Source>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
return dataSource
}
func makeTrustedSourcesDataSource() -> RSTArrayCollectionViewDataSource<Source>
{
let dataSource = RSTArrayCollectionViewDataSource<Source>(items: [])
return dataSource
}
} }
private extension SourcesViewController private extension SourcesViewController
@@ -135,31 +208,52 @@ private extension SourcesViewController
guard let httpsSourceURL = URL(string: "https://" + text) else { return } guard let httpsSourceURL = URL(string: "https://" + text) else { return }
sourceURL = httpsSourceURL sourceURL = httpsSourceURL
} }
self.addSource(url: sourceURL)
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
self.addSource(url: sourceURL) { _ in
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
}
}) })
self.present(alertController, animated: true, completion: nil) self.present(alertController, animated: true, completion: nil)
} }
func addSource(url: URL) func addSource(url: URL, isTrusted: Bool = false, completionHandler: ((Result<Void, Error>) -> Void)? = nil)
{ {
guard self.view.window != nil else { return } guard self.view.window != nil else { return }
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true if url == self.deepLinkSourceURL
{
// Only handle deep link once.
self.deepLinkSourceURL = nil
}
func finish(error: Error?) func finish(_ result: Result<Void, Error>)
{ {
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error switch result
{ {
self.present(error) case .success: break
case .failure(OperationError.cancelled): break
case .failure(let error): self.present(error)
} }
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false self.collectionView.reloadSections([Section.trusted.rawValue])
completionHandler?(result)
} }
} }
AppManager.shared.fetchSource(sourceURL: url) { (result) in var dependencies: [Foundation.Operation] = []
if let fetchTrustedSourcesOperation = self.fetchTrustedSourcesOperation
{
// Must fetch trusted sources first to determine whether this is a trusted source.
// We assume fetchTrustedSources() has already been called before this method.
dependencies = [fetchTrustedSourcesOperation]
}
AppManager.shared.fetchSource(sourceURL: url, dependencies: dependencies) { (result) in
do do
{ {
let source = try result.get() let source = try result.get()
@@ -167,25 +261,28 @@ private extension SourcesViewController
let managedObjectContext = source.managedObjectContext let managedObjectContext = source.managedObjectContext
#if !BETA #if !BETA
guard Source.allowedIdentifiers.contains(source.identifier) else { throw SourceError(code: .unsupported, source: source) } guard let trustedSourceIDs = UserDefaults.shared.trustedSourceIDs, trustedSourceIDs.contains(source.identifier) else { throw SourceError(code: .unsupported, source: source) }
#endif #endif
// Hide warning when adding a featured trusted source.
let message = isTrusted ? nil : NSLocalizedString("Make sure to only add sources that you trust.", comment: "")
DispatchQueue.main.async { DispatchQueue.main.async {
let alertController = UIAlertController(title: String(format: NSLocalizedString("Would you like to add the source “%@”?", comment: ""), sourceName), let alertController = UIAlertController(title: String(format: NSLocalizedString("Would you like to add the source “%@”?", comment: ""), sourceName),
message: NSLocalizedString("Sources control what apps appear in AltStore. Make sure to only add sources that you trust.", comment: ""), preferredStyle: .alert) message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
finish(error: nil) finish(.failure(OperationError.cancelled))
}) })
alertController.addAction(UIAlertAction(title: UIAlertAction.ok.title, style: UIAlertAction.ok.style) { _ in alertController.addAction(UIAlertAction(title: NSLocalizedString("Add Source", comment: ""), style: UIAlertAction.ok.style) { _ in
managedObjectContext?.perform { managedObjectContext?.perform {
do do
{ {
try managedObjectContext?.save() try managedObjectContext?.save()
finish(error: nil) finish(.success(()))
} }
catch catch
{ {
finish(error: error) finish(.failure(error))
} }
} }
}) })
@@ -194,7 +291,7 @@ private extension SourcesViewController
} }
catch catch
{ {
finish(error: error) finish(.failure(error))
} }
} }
} }
@@ -217,6 +314,130 @@ private extension SourcesViewController
alertController.addAction(.ok) alertController.addAction(.ok)
self.present(alertController, animated: true, completion: nil) self.present(alertController, animated: true, completion: nil)
} }
func fetchTrustedSources()
{
func finish(_ result: Result<[Source], Error>)
{
self.fetchTrustedSourcesResult = result.map { _ in () }
DispatchQueue.main.async {
do
{
let sources = try result.get()
print("Fetched trusted sources:", sources.map { $0.identifier })
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
self.trustedSourcesDataSource.setItems(sources, with: [sectionUpdate])
}
catch
{
print("Error fetching trusted sources:", error)
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
self.trustedSourcesDataSource.setItems([], with: [sectionUpdate])
}
}
}
self.fetchTrustedSourcesOperation = AppManager.shared.fetchTrustedSources { result in
switch result
{
case .failure(let error): finish(.failure(error))
case .success(let trustedSources):
// Cache trusted source IDs.
UserDefaults.shared.trustedSourceIDs = trustedSources.map { $0.identifier }
// 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._fetchTrustedSourcesContext = 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): fetchError = error
case .success(let source): sourcesByURL[source.sourceURL] = source
}
dispatchGroup.leave()
}
}
}
dispatchGroup.notify(queue: .main) {
if let error = fetchError
{
finish(.failure(error))
}
else
{
let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] }
finish(.success(sources))
}
}
}
}
}
@IBAction func addTrustedSource(_ sender: PillButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
let completedProgress = Progress(totalUnitCount: 1)
completedProgress.completedUnitCount = 1
sender.progress = completedProgress
let source = self.dataSource.item(at: indexPath)
self.addSource(url: source.sourceURL, isTrusted: true) { _ in
//FIXME: Handle cell reuse.
sender.progress = nil
}
}
func remove(_ source: Source)
{
let alertController = UIAlertController(title: String(format: NSLocalizedString("Are you sure you want to remove the source “%@”?", comment: ""), source.name),
message: NSLocalizedString("Any apps you've installed from this source will remain, but they'll no longer receive any app updates.", comment: ""), preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: nil))
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove Source", comment: ""), style: .destructive) { _ in
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let source = context.object(with: source.objectID) as! Source
context.delete(source)
do
{
try context.save()
DispatchQueue.main.async {
self.collectionView.reloadSections([Section.trusted.rawValue])
}
}
catch
{
DispatchQueue.main.async {
self.present(error)
}
}
}
})
self.present(alertController, animated: true, completion: nil)
}
} }
extension SourcesViewController extension SourcesViewController
@@ -251,11 +472,112 @@ extension SourcesViewController: UICollectionViewDelegateFlowLayout
return size return size
} }
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
{
guard Section(rawValue: section) == .trusted else { return .zero }
let indexPath = IndexPath(row: 0, section: section)
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionFooter, at: indexPath)
// Use this view to calculate the optimal size based on the collection view's width
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
withHorizontalFittingPriority: .required, // Width is fixed
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
return size
}
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
{ {
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView let reuseIdentifier = (kind == UICollectionView.elementKindSectionHeader) ? "Header" : "Footer"
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: reuseIdentifier, for: indexPath) as! TextCollectionReusableView
headerView.layoutMargins.left = self.view.layoutMargins.left headerView.layoutMargins.left = self.view.layoutMargins.left
headerView.layoutMargins.right = self.view.layoutMargins.right headerView.layoutMargins.right = self.view.layoutMargins.right
let almostRequiredPriority = UILayoutPriority(UILayoutPriority.required.rawValue - 1) // Can't be required or else we can't satisfy constraints when hidden (size = 0).
headerView.leadingLayoutConstraint?.priority = almostRequiredPriority
headerView.trailingLayoutConstraint?.priority = almostRequiredPriority
headerView.topLayoutConstraint?.priority = almostRequiredPriority
headerView.bottomLayoutConstraint?.priority = almostRequiredPriority
switch kind
{
case UICollectionView.elementKindSectionHeader:
switch Section.allCases[indexPath.section]
{
case .added:
headerView.textLabel.text = NSLocalizedString("Sources control what apps are available to download through AltStore.", comment: "")
headerView.textLabel.font = UIFont.preferredFont(forTextStyle: .callout)
headerView.textLabel.textAlignment = .natural
headerView.topLayoutConstraint.constant = 14
headerView.bottomLayoutConstraint.constant = 30
case .trusted:
switch self.fetchTrustedSourcesResult
{
case .failure: headerView.textLabel.text = NSLocalizedString("Error Loading Trusted Sources", comment: "")
case .success, .none: headerView.textLabel.text = NSLocalizedString("Trusted Sources", comment: "")
}
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .callout).withSymbolicTraits(.traitBold)!
headerView.textLabel.font = UIFont(descriptor: descriptor, size: 0)
headerView.textLabel.textAlignment = .center
headerView.topLayoutConstraint.constant = 54
headerView.bottomLayoutConstraint.constant = 15
}
case UICollectionView.elementKindSectionFooter:
let footerView = headerView as! SourcesFooterView
let font = UIFont.preferredFont(forTextStyle: .subheadline)
switch self.fetchTrustedSourcesResult
{
case .failure(let error):
footerView.textView.font = font
footerView.textView.text = error.localizedDescription
footerView.activityIndicatorView.stopAnimating()
footerView.topLayoutConstraint.constant = 0
footerView.textView.textAlignment = .center
case .success, .none:
footerView.textView.delegate = self
let attributedText = NSMutableAttributedString(
string: NSLocalizedString("AltStore has reviewed these sources to make sure they meet our safety standards.\n\nSupport for untrusted sources is currently in beta, but you can help test them out by", comment: ""),
attributes: [.font: font, .foregroundColor: UIColor.gray]
)
attributedText.mutableString.append(" ")
let boldedFont = UIFont(descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold)!, size: font.pointSize)
let openPatreonURL = URL(string: "https://altstore.io/patreon")!
let joinPatreonText = NSAttributedString(
string: NSLocalizedString("joining our Patreon.", comment: ""),
attributes: [.font: boldedFont, .link: openPatreonURL, .underlineColor: UIColor.clear]
)
attributedText.append(joinPatreonText)
footerView.textView.attributedText = attributedText
footerView.textView.textAlignment = .natural
if self.fetchTrustedSourcesResult != nil
{
footerView.activityIndicatorView.stopAnimating()
footerView.topLayoutConstraint.constant = 20
}
else
{
footerView.activityIndicatorView.startAnimating()
footerView.topLayoutConstraint.constant = 0
}
}
default: break
}
return headerView return headerView
} }
} }
@@ -274,20 +596,11 @@ extension SourcesViewController
} }
let deleteAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { (action) in let deleteAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { (action) in
self.remove(source)
}
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in let addAction = UIAction(title: String(format: NSLocalizedString("Add “%@”", comment: ""), source.name), image: UIImage(systemName: "plus")) { (action) in
let source = context.object(with: source.objectID) as! Source self.addSource(url: source.sourceURL, isTrusted: true)
context.delete(source)
do
{
try context.save()
}
catch
{
print("Failed to save source context.", error)
}
}
} }
var actions: [UIAction] = [] var actions: [UIAction] = []
@@ -297,11 +610,23 @@ extension SourcesViewController
actions.append(viewErrorAction) actions.append(viewErrorAction)
} }
if source.identifier != Source.altStoreIdentifier switch Section.allCases[indexPath.section]
{ {
actions.append(deleteAction) case .added:
if source.identifier != Source.altStoreIdentifier
{
actions.append(deleteAction)
}
case .trusted:
if let cell = collectionView.cellForItem(at: indexPath) as? BannerCollectionViewCell, !cell.bannerView.button.isHidden
{
actions.append(addAction)
}
} }
guard !actions.isEmpty else { return nil }
let menu = UIMenu(title: "", children: actions) let menu = UIMenu(title: "", children: actions)
return menu return menu
} }
@@ -325,3 +650,11 @@ extension SourcesViewController
return self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration) return self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration)
} }
} }
extension SourcesViewController: UITextViewDelegate
{
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool
{
return true
}
}

View File

@@ -37,6 +37,8 @@ public extension UserDefaults
@NSManaged var patchedApps: [String]? @NSManaged var patchedApps: [String]?
@NSManaged var trustedSourceIDs: [String]?
var activeAppsLimit: Int? { var activeAppsLimit: Int? {
get { get {
return self._activeAppsLimit?.intValue return self._activeAppsLimit?.intValue

View File

@@ -33,10 +33,6 @@ public extension Source
#endif #endif
#endif #endif
static let exampleIdentifier = "io.altstore.example"
static let allowedIdentifiers: Set<String> = [exampleIdentifier]
} }
@objc(Source) @objc(Source)