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"?>
<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"/>
<dependencies>
<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="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
@@ -314,8 +314,8 @@
</textView>
</subviews>
<constraints>
<constraint firstItem="Pyt-8D-BZA" firstAttribute="top" secondItem="D1G-nK-G0Z" secondAttribute="top" constant="20" id="Lm9-lx-kJ8"/>
<constraint firstAttribute="bottom" secondItem="Pyt-8D-BZA" secondAttribute="bottom" constant="44" id="TSS-Au-gYx"/>
<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" 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 firstAttribute="trailing" secondItem="Pyt-8D-BZA" secondAttribute="trailing" constant="20" id="Wq4-Ql-wvN"/>
</constraints>
@@ -944,15 +944,15 @@ World</string>
<scene sceneID="0S1-zn-9KZ">
<objects>
<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"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="X20-b5-XEP">
<size key="itemSize" width="375" height="80"/>
<size key="headerReferenceSize" width="50" height="200"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="10" maxX="0.0" maxY="20"/>
<size key="footerReferenceSize" width="50" height="50"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<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"/>
</constraints>
<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="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>
</collectionReusableView>
<connections>

View File

@@ -11,4 +11,9 @@ import UIKit
class TextCollectionReusableView: UICollectionReusableView
{
@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
{
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
switch result
{
@@ -326,6 +329,11 @@ extension AppManager
}
}
for dependency in dependencies
{
fetchSourceOperation.addDependency(dependency)
}
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
{
var deepLinkSourceURL: URL? {
@@ -40,6 +56,12 @@ class SourcesViewController: UICollectionViewController
}
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()
{
@@ -61,6 +83,11 @@ class SourcesViewController: UICollectionViewController
{
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
}
if self.fetchTrustedSourcesOperation == nil
{
self.fetchTrustedSources()
}
}
override func viewDidAppear(_ animated: Bool)
@@ -70,22 +97,15 @@ class SourcesViewController: UICollectionViewController
if let sourceURL = self.deepLinkSourceURL
{
self.addSource(url: sourceURL)
self.deepLinkSourceURL = nil
}
}
}
private extension SourcesViewController
{
func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource<Source>
func makeDataSource() -> RSTCompositeCollectionViewDataSource<Source>
{
let fetchRequest = Source.fetchRequest() as NSFetchRequest<Source>
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)
let dataSource = RSTCompositeCollectionViewDataSource<Source>(dataSources: [self.addedSourcesDataSource, self.trustedSourcesDataSource])
dataSource.proxy = self
dataSource.cellConfigurationHandler = { (cell, source, indexPath) in
let tintColor = UIColor.altPrimary
@@ -97,9 +117,44 @@ private extension SourcesViewController
cell.bannerView.iconImageView.isHidden = true
cell.bannerView.buttonLabel.isHidden = true
cell.bannerView.button.isHidden = true
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.subtitleLabel.text = source.sourceURL.absoluteString
cell.bannerView.subtitleLabel.numberOfLines = 2
@@ -116,6 +171,24 @@ private extension SourcesViewController
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
@@ -135,31 +208,52 @@ private extension SourcesViewController
guard let httpsSourceURL = URL(string: "https://" + text) else { return }
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)
}
func addSource(url: URL)
func addSource(url: URL, isTrusted: Bool = false, completionHandler: ((Result<Void, Error>) -> Void)? = nil)
{
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 {
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
{
let source = try result.get()
@@ -167,25 +261,28 @@ private extension SourcesViewController
let managedObjectContext = source.managedObjectContext
#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
// 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 {
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
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 {
do
{
try managedObjectContext?.save()
finish(error: nil)
finish(.success(()))
}
catch
{
finish(error: error)
finish(.failure(error))
}
}
})
@@ -194,7 +291,7 @@ private extension SourcesViewController
}
catch
{
finish(error: error)
finish(.failure(error))
}
}
}
@@ -217,6 +314,130 @@ private extension SourcesViewController
alertController.addAction(.ok)
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
@@ -251,11 +472,112 @@ extension SourcesViewController: UICollectionViewDelegateFlowLayout
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
{
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.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
}
}
@@ -274,20 +596,11 @@ extension SourcesViewController
}
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 source = context.object(with: source.objectID) as! Source
context.delete(source)
do
{
try context.save()
}
catch
{
print("Failed to save source context.", error)
}
}
let addAction = UIAction(title: String(format: NSLocalizedString("Add “%@”", comment: ""), source.name), image: UIImage(systemName: "plus")) { (action) in
self.addSource(url: source.sourceURL, isTrusted: true)
}
var actions: [UIAction] = []
@@ -297,11 +610,23 @@ extension SourcesViewController
actions.append(viewErrorAction)
}
switch Section.allCases[indexPath.section]
{
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)
return menu
}
@@ -325,3 +650,11 @@ extension SourcesViewController
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 trustedSourceIDs: [String]?
var activeAppsLimit: Int? {
get {
return self._activeAppsLimit?.intValue

View File

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