mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-08 22:33:26 +01:00
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:
@@ -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>
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +117,43 @@ 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
|
||||
@@ -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,11 +291,11 @@ private extension SourcesViewController
|
||||
}
|
||||
catch
|
||||
{
|
||||
finish(error: error)
|
||||
finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func present(_ error: Error)
|
||||
{
|
||||
if let transitionCoordinator = self.transitionCoordinator
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
self.remove(source)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ public extension UserDefaults
|
||||
|
||||
@NSManaged var patchedApps: [String]?
|
||||
|
||||
@NSManaged var trustedSourceIDs: [String]?
|
||||
|
||||
var activeAppsLimit: Int? {
|
||||
get {
|
||||
return self._activeAppsLimit?.intValue
|
||||
|
||||
@@ -33,10 +33,6 @@ public extension Source
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
static let exampleIdentifier = "io.altstore.example"
|
||||
|
||||
static let allowedIdentifiers: Set<String> = [exampleIdentifier]
|
||||
}
|
||||
|
||||
@objc(Source)
|
||||
|
||||
2
Dependencies/Roxas
vendored
2
Dependencies/Roxas
vendored
Submodule Dependencies/Roxas updated: 636bab81a2...fb33e158ae
Reference in New Issue
Block a user