Merge branch 'patreon'

This commit is contained in:
Riley Testut
2023-12-01 17:20:24 -06:00
52 changed files with 2286 additions and 824 deletions

View File

@@ -87,8 +87,6 @@ class AppViewController: UIViewController
self.bannerView.iconImageView.tintColor = self.app.tintColor
self.bannerView.button.tintColor = self.app.tintColor
self.bannerView.tintColor = self.app.tintColor
self.bannerView.configure(for: self.app)
self.bannerView.accessibilityTraits.remove(.button)
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
@@ -362,41 +360,26 @@ private extension AppViewController
{
func update()
{
var buttonAction: AppBannerView.AppAction?
if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
{
// Explicitly set button action to .update if there is an update available, even if it's not supported.
buttonAction = .update
}
for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
{
button.tintColor = self.app.tintColor
button.isIndicatingActivity = false
if let installedApp = self.app.installedApp
{
if let latestVersion = self.app.latestSupportedVersion, !installedApp.matches(latestVersion)
{
button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal)
}
else
{
button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
}
}
else
{
button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
}
let progress = AppManager.shared.installationProgress(for: self.app)
button.progress = progress
}
if let versionDate = self.app.latestSupportedVersion?.date, versionDate > Date()
{
self.bannerView.button.countdownDate = versionDate
self.navigationBarDownloadButton.countdownDate = versionDate
}
else
{
self.bannerView.button.countdownDate = nil
self.navigationBarDownloadButton.countdownDate = nil
}
self.bannerView.configure(for: self.app, action: buttonAction)
let title = self.bannerView.button.title(for: .normal)
self.navigationBarDownloadButton.setTitle(title, for: .normal)
self.navigationBarDownloadButton.progress = self.bannerView.button.progress
self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate
let barButtonItem = self.navigationItem.rightBarButtonItem
self.navigationItem.rightBarButtonItem = nil
@@ -523,9 +506,9 @@ extension AppViewController
{
if let installedApp = self.app.installedApp
{
if let latestVersion = self.app.latestSupportedVersion, !installedApp.matches(latestVersion)
if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
{
self.updateApp(installedApp)
self.updateApp(installedApp, to: latestVersion)
}
else
{
@@ -576,7 +559,7 @@ extension AppViewController
UIApplication.shared.open(installedApp.openAppURL)
}
func updateApp(_ installedApp: InstalledApp)
func updateApp(_ installedApp: InstalledApp, to version: AppVersion)
{
let previousProgress = AppManager.shared.installationProgress(for: installedApp)
guard previousProgress == nil else {
@@ -585,7 +568,7 @@ extension AppViewController
return
}
_ = AppManager.shared.update(installedApp, presentingViewController: self) { (result) in
AppManager.shared.update(installedApp, to: version, presentingViewController: self) { (result) in
DispatchQueue.main.async {
switch result
{

View File

@@ -92,7 +92,6 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
super.viewWillAppear(animated)
self.fetchSource()
self.updateDataSource()
self.update()
}
@@ -109,7 +108,8 @@ private extension BrowseViewController
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
let predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
let predicate = StoreApp.visibleAppsPredicate
if let source = self.source
{
let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), source)
@@ -136,40 +136,8 @@ private extension BrowseViewController
cell.bannerView.button.activityIndicatorView.style = .medium
cell.bannerView.button.activityIndicatorView.color = .white
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
// Otherwise, cell reuse can mess up some cached values.
cell.bannerView.button.isIndicatingActivity = false
let tintColor = app.tintColor ?? .altPrimary
cell.tintColor = tintColor
if app.installedApp == nil
{
let buttonTitle = NSLocalizedString("Free", comment: "")
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
cell.bannerView.button.accessibilityValue = buttonTitle
let progress = AppManager.shared.installationProgress(for: app)
cell.bannerView.button.progress = progress
if let versionDate = app.latestSupportedVersion?.date, versionDate > Date()
{
cell.bannerView.button.countdownDate = versionDate
}
else
{
cell.bannerView.button.countdownDate = nil
}
}
else
{
cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name)
cell.bannerView.button.accessibilityValue = nil
cell.bannerView.button.progress = nil
cell.bannerView.button.countdownDate = nil
}
}
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
let iconURL = storeApp.iconURL
@@ -202,18 +170,6 @@ private extension BrowseViewController
return dataSource
}
func updateDataSource()
{
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
{
self.dataSource.predicate = nil
}
else
{
self.dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta))
}
}
func fetchSource()
{
self.loadingState = .loading
@@ -317,7 +273,7 @@ private extension BrowseViewController
let app = self.dataSource.item(at: indexPath)
if let installedApp = app.installedApp
if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
{
self.open(installedApp)
}
@@ -335,7 +291,21 @@ private extension BrowseViewController
return
}
_ = AppManager.shared.install(app, presentingViewController: self) { (result) in
if let installedApp = app.installedApp, installedApp.isUpdateAvailable
{
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
}
else
{
AppManager.shared.install(app, presentingViewController: self, completionHandler: finish(_:))
}
UIView.performWithoutAnimation {
self.collectionView.reloadItems(at: [indexPath])
}
func finish(_ result: Result<InstalledApp, Error>)
{
DispatchQueue.main.async {
switch result
{
@@ -344,15 +314,22 @@ private extension BrowseViewController
let toastView = ToastView(error: error)
toastView.opensErrorLog = true
toastView.show(in: self)
case .success: print("Installed app:", app.bundleIdentifier)
}
self.collectionView.reloadItems(at: [indexPath])
UIView.performWithoutAnimation {
if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app)
{
self.collectionView.reloadItems(at: [indexPath])
}
else
{
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
}
}
self.collectionView.reloadItems(at: [indexPath])
}
func open(_ installedApp: InstalledApp)

View File

@@ -18,6 +18,14 @@ extension AppBannerView
case app
case source
}
enum AppAction
{
case install
case open
case update
case custom(String)
}
}
class AppBannerView: RSTNibView
@@ -111,7 +119,7 @@ class AppBannerView: RSTNibView
extension AppBannerView
{
func configure(for app: AppProtocol)
func configure(for app: AppProtocol, action: AppAction? = nil)
{
struct AppValues
{
@@ -150,6 +158,136 @@ extension AppBannerView
self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
self.accessibilityLabel = values.name
}
if let storeApp = app.storeApp, storeApp.isPledgeRequired
{
// Always show button label for Patreon apps.
self.buttonLabel.isHidden = false
self.buttonLabel.text = storeApp.isPledged ? NSLocalizedString("Pledged", comment: "") : NSLocalizedString("Join Patreon", comment: "")
}
else
{
self.buttonLabel.isHidden = true
}
let buttonAction: AppAction
if let action
{
buttonAction = action
}
else if let storeApp = app.storeApp
{
if let installedApp = storeApp.installedApp
{
// App is installed
if installedApp.isUpdateAvailable && (!storeApp.isPledgeRequired || storeApp.isPledged)
{
buttonAction = .update
}
else
{
buttonAction = .open
}
}
else
{
// App is not installed
buttonAction = .install
}
}
else
{
// App is not from a source, fall back to .open
buttonAction = .open
}
switch buttonAction
{
case .open:
let buttonTitle = NSLocalizedString("Open", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), values.name)
self.button.accessibilityValue = buttonTitle
self.button.countdownDate = nil
case .update:
let buttonTitle = NSLocalizedString("Update", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), values.name)
self.button.accessibilityValue = buttonTitle
self.button.countdownDate = nil
case .custom(let buttonTitle):
self.button.setTitle(buttonTitle, for: .normal)
self.button.accessibilityLabel = buttonTitle
self.button.accessibilityValue = buttonTitle
self.button.countdownDate = nil
case .install:
if let storeApp = app.storeApp, storeApp.isPledgeRequired
{
// Pledge required
if storeApp.isPledged
{
let buttonTitle = NSLocalizedString("Install", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Install %@", comment: ""), app.name)
self.button.accessibilityValue = buttonTitle
}
else if let amount = storeApp.pledgeAmount, let currencyCode = storeApp.pledgeCurrency, #available(iOS 15, *)
{
let price = amount.formatted(.currency(code: currencyCode).presentation(.narrow).precision(.fractionLength(0...2)))
let buttonTitle = String(format: NSLocalizedString("%@/mo", comment: ""), price)
self.button.setTitle(buttonTitle, for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Pledge %@ a month", comment: ""), price)
self.button.accessibilityValue = String(format: NSLocalizedString("%@ a month", comment: ""), price)
}
else
{
let buttonTitle = NSLocalizedString("Pledge", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = buttonTitle
self.button.accessibilityValue = buttonTitle
}
}
else
{
// Free app
let buttonTitle = NSLocalizedString("Free", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
self.button.accessibilityValue = buttonTitle
}
if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date()
{
self.button.countdownDate = versionDate
}
else
{
self.button.countdownDate = nil
}
}
// Ensure PillButton is correct size before assigning progress.
self.layoutIfNeeded()
if let progress = AppManager.shared.installationProgress(for: app), progress.fractionCompleted < 1.0
{
self.button.progress = progress
}
else
{
self.button.progress = nil
}
}
func configure(for source: Source)

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@@ -78,13 +78,13 @@
</subviews>
</stackView>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
<rect key="frame" x="0.0" y="21.5" width="184" height="16"/>
<rect key="frame" x="0.0" y="21.5" width="62" height="16"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC">
<rect key="frame" x="0.0" y="0.0" width="184" height="16"/>
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
<rect key="frame" x="0.0" y="0.0" width="184" height="16"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="750" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -103,36 +103,33 @@
</visualEffectView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3ox-Q2-Rnd" userLabel="Button Stack View">
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" placeholderIntrinsicWidth="77" placeholderIntrinsicHeight="31" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="282" y="28.5" width="77" height="31"/>
<subviews>
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
<rect key="frame" x="0.0" y="0.0" width="77" height="0.0"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" placeholderIntrinsicWidth="77" placeholderIntrinsicHeight="31" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="77" height="31"/>
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="tVx-3G-dcu" secondAttribute="height" priority="999" id="Vbk-VH-5eU"/>
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="FREE"/>
</button>
</subviews>
</stackView>
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="tVx-3G-dcu" secondAttribute="height" priority="999" id="Vbk-VH-5eU"/>
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="FREE"/>
</button>
</subviews>
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
</stackView>
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
<rect key="frame" x="307" y="12.5" width="27" height="12"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
<constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/>
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
<constraint firstItem="Yd9-jw-faD" firstAttribute="centerX" secondItem="tVx-3G-dcu" secondAttribute="centerX" id="acx-pf-8hH"/>
<constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/>
<constraint firstItem="tVx-3G-dcu" firstAttribute="top" secondItem="Yd9-jw-faD" secondAttribute="bottom" constant="4" id="hTD-wh-KV8"/>
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/>
<constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/>
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" priority="999" id="nJo-To-LmX"/>

View File

@@ -289,6 +289,10 @@ extension AppCardCollectionViewCell
{
self.screenshots = storeApp.preferredScreenshots()
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
// Otherwise, cell reuse can mess up some cached values.
self.bannerView.button.isIndicatingActivity = false
self.bannerView.tintColor = storeApp.tintColor
self.bannerView.configure(for: storeApp)

View File

@@ -0,0 +1,14 @@
//
// UTType+AltStore.swift
// AltStore
//
// Created by Riley Testut on 11/3/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UniformTypeIdentifiers
extension UTType
{
static let ipa = UTType(importedAs: "com.apple.itunes.ipa")
}

View File

@@ -181,6 +181,8 @@
<dict>
<key>public.filename-extension</key>
<string>ipa</string>
<key>public.mime-type</key>
<string>application/x-ios-app</string>
</dict>
</dict>
</array>

View File

@@ -557,9 +557,9 @@ extension AppManager
}
@discardableResult
func update(_ installedApp: InstalledApp, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
func update(_ installedApp: InstalledApp, to version: AppVersion? = nil, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
{
guard let appVersion = installedApp.storeApp?.latestSupportedVersion else {
guard let appVersion = version ?? installedApp.storeApp?.latestSupportedVersion else {
completionHandler(.failure(OperationError.appNotFound(name: installedApp.name)))
return Progress.discreteProgress(totalUnitCount: 1)
}
@@ -1246,7 +1246,7 @@ private extension AppManager
let patchAppURL = URL(string: patchAppLink)
else { throw OperationError.invalidApp }
let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL)
let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL, storeApp: nil)
DispatchQueue.main.async {
let storyboard = UIStoryboard(name: "PatchApp", bundle: nil)
@@ -1358,8 +1358,22 @@ private extension AppManager
progress.addChild(installOperation.progress, withPendingUnitCount: 30)
installOperation.addDependency(sendAppOperation)
let operations = [downloadOperation, verifyOperation, deactivateAppsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation]
var operations = [downloadOperation, verifyOperation, deactivateAppsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation]
group.add(operations)
if let storeApp = downloadingApp.storeApp, storeApp.isPledgeRequired
{
// Patreon apps may require authenticating with WebViewController,
// so make sure to run DownloadAppOperation serially.
self.run([downloadOperation], context: group.context, requiresSerialQueue: true)
if let index = operations.firstIndex(of: downloadOperation)
{
// Remove downloadOperation from operations to prevent running it twice.
operations.remove(at: index)
}
}
self.run(operations, context: group.context)
return progress

View File

@@ -112,11 +112,13 @@ class MyAppsViewController: UICollectionViewController, PeekPopPreviewing
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
}
override func viewWillAppear(_ animated: Bool)
override func viewIsAppearing(_ animated: Bool)
{
super.viewWillAppear(animated)
super.viewIsAppearing(animated)
// Ensure the button for each app reflects correct Patreon status.
self.collectionView.reloadData()
self.updateDataSource()
self.update()
self.fetchAppIDs()
@@ -227,7 +229,8 @@ private extension MyAppsViewController
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.configure(for: app)
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.configure(for: app, action: .update)
let versionDate = Date().relativeDateString(since: latestSupportedVersion.date)
cell.bannerView.subtitleLabel.text = versionDate
@@ -245,7 +248,6 @@ private extension MyAppsViewController
cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestSupportedVersion.localizedVersion, versionDate)
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), installedApp.name)
@@ -260,9 +262,6 @@ private extension MyAppsViewController
cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
let progress = AppManager.shared.installationProgress(for: app)
cell.bannerView.button.progress = progress
cell.setNeedsLayout()
}
dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in
@@ -330,17 +329,6 @@ private extension MyAppsViewController
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.33, y: 0.33)
}
cell.bannerView.configure(for: installedApp)
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.buttonLabel.isHidden = false
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
let currentDate = Date()
let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate)
@@ -355,9 +343,30 @@ private extension MyAppsViewController
numberOfDaysText = String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
}
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.configure(for: installedApp, action: .custom(numberOfDaysText.uppercased()))
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.buttonLabel.isHidden = false
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), installedApp.name)
if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged
{
cell.bannerView.button.isEnabled = false
cell.bannerView.button.alpha = 0.5
}
else
{
cell.bannerView.button.isEnabled = true
cell.bannerView.button.alpha = 1.0
}
cell.bannerView.accessibilityLabel? += ". " + String(format: NSLocalizedString("Expires in %@", comment: ""), numberOfDaysText)
// Make sure refresh button is correct size.
@@ -430,15 +439,25 @@ private extension MyAppsViewController
cell.deactivateBadge?.alpha = 0.0
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.5, y: 0.5)
cell.bannerView.configure(for: installedApp)
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.configure(for: installedApp, action: .custom(NSLocalizedString("ACTIVATE", comment: "")))
cell.bannerView.button.tintColor = tintColor
cell.bannerView.button.setTitle(NSLocalizedString("ACTIVATE", comment: ""), for: .normal)
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), installedApp.name)
if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged
{
cell.bannerView.button.isEnabled = false
cell.bannerView.button.alpha = 0.5
}
else
{
cell.bannerView.button.isEnabled = true
cell.bannerView.button.alpha = 1.0
}
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
@@ -475,33 +494,6 @@ private extension MyAppsViewController
return dataSource
}
func updateDataSource()
{
do
{
if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil
{
try self.updatesDataSource.fetchedResultsController.performFetch()
}
}
catch
{
print("[ALTLog] Failed to fetch updates:", error)
}
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
{
self.dataSource.predicate = nil
}
else
{
self.dataSource.predicate = NSPredicate(format: "%K == nil OR %K == NO OR %K == %@",
#keyPath(InstalledApp.storeApp),
#keyPath(InstalledApp.storeApp.isBeta),
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
}
}
}
private extension MyAppsViewController
@@ -710,10 +702,29 @@ private extension MyAppsViewController
@IBAction func refreshAllApps(_ sender: UIBarButtonItem)
{
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
guard !installedApps.isEmpty else {
let error: Error
if let altstoreApp = InstalledApp.fetchAltStore(in: DatabaseManager.shared.viewContext),
let storeApp = altstoreApp.storeApp, storeApp.isPledgeRequired && !storeApp.isPledged
{
// Assume the reason there are no apps is because we are no longer pledged to AltStore beta.
error = OperationError(.pledgeInactive(appName: altstoreApp.name))
}
else
{
// Otherwise, fall back to generic noInstalledApps.
error = RefreshError(.noInstalledApps)
}
let toastView = ToastView(error: error)
toastView.show(in: self)
return
}
self.isRefreshingAllApps = true
self.collectionView.collectionViewLayout.invalidateLayout()
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
self.refresh(installedApps) { (result) in
DispatchQueue.main.async {
@@ -1704,7 +1715,7 @@ extension MyAppsViewController
extension MyAppsViewController
{
private func actions(for installedApp: InstalledApp) -> [UIMenuElement]
private func contextMenu(for installedApp: InstalledApp) -> UIMenu
{
var actions = [UIMenuElement]()
@@ -1762,103 +1773,140 @@ extension MyAppsViewController
let changeIconMenu = UIMenu(title: NSLocalizedString("Change Icon", comment: ""), image: UIImage(systemName: "photo"), children: changeIconActions)
guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else {
#if BETA
return [refreshAction, changeIconMenu]
#else
return [refreshAction]
#endif
}
if installedApp.isActive
if installedApp.bundleIdentifier == StoreApp.altstoreAppID
{
actions.append(openMenu)
actions.append(refreshAction)
#if BETA
actions = [refreshAction, changeIconMenu]
#else
actions = [refreshAction]
#endif
}
else
{
actions.append(activateAction)
}
if installedApp.isActive
{
actions.append(jitAction)
}
#if BETA
actions.append(changeIconMenu)
#endif
if installedApp.isActive
{
actions.append(backupAction)
}
else if let _ = UTTypeCopyDeclaration(installedApp.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?, !UserDefaults.standard.isLegacyDeactivationSupported
{
// Allow backing up inactive apps if they are still installed,
// but on an iOS version that no longer supports legacy deactivation.
// This handles edge case where you can't install more apps until you
// delete some, but can't activate inactive apps again to back them up first.
actions.append(backupAction)
}
if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp)
{
var backupExists = false
var outError: NSError? = nil
self.coordinator.coordinate(readingItemAt: backupDirectoryURL, options: [.withoutChanges], error: &outError) { (backupDirectoryURL) in
#if DEBUG
backupExists = true
#else
backupExists = FileManager.default.fileExists(atPath: backupDirectoryURL.path)
#endif
if installedApp.isActive
{
actions.append(openMenu)
actions.append(refreshAction)
}
else
{
actions.append(activateAction)
}
if backupExists
if installedApp.isActive
{
actions.append(exportBackupAction)
actions.append(jitAction)
}
#if BETA
actions.append(changeIconMenu)
#endif
if installedApp.isActive
{
actions.append(backupAction)
}
else if let _ = UTTypeCopyDeclaration(installedApp.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?, !UserDefaults.standard.isLegacyDeactivationSupported
{
// Allow backing up inactive apps if they are still installed,
// but on an iOS version that no longer supports legacy deactivation.
// This handles edge case where you can't install more apps until you
// delete some, but can't activate inactive apps again to back them up first.
actions.append(backupAction)
}
if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp)
{
var backupExists = false
var outError: NSError? = nil
if installedApp.isActive
self.coordinator.coordinate(readingItemAt: backupDirectoryURL, options: [.withoutChanges], error: &outError) { (backupDirectoryURL) in
#if DEBUG
backupExists = true
#else
backupExists = FileManager.default.fileExists(atPath: backupDirectoryURL.path)
#endif
}
if backupExists
{
actions.append(restoreBackupAction)
actions.append(exportBackupAction)
if installedApp.isActive
{
actions.append(restoreBackupAction)
}
}
else if let error = outError
{
print("Unable to check if backup exists:", error)
}
}
else if let error = outError
if installedApp.isActive
{
print("Unable to check if backup exists:", error)
actions.append(deactivateAction)
}
#if DEBUG
if installedApp.bundleIdentifier != StoreApp.altstoreAppID
{
actions.append(removeAction)
}
#else
if (UserDefaults.standard.legacySideloadedApps ?? []).contains(installedApp.bundleIdentifier)
{
// Legacy sideloaded app, so can't detect if it's deleted.
actions.append(removeAction)
}
else if !UserDefaults.standard.isLegacyDeactivationSupported && !installedApp.isActive
{
// Inactive apps are actually deleted, so we need another way
// for user to remove them from AltStore.
actions.append(removeAction)
}
#endif
}
var title: String?
if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged
{
let error = OperationError.pledgeInactive(appName: installedApp.name)
title = error.localizedDescription
let allowedActions: Set<UIMenuElement> = [
openMenu,
deactivateAction,
removeAction,
backupAction,
exportBackupAction
]
for action in actions where !allowedActions.contains(action)
{
// Disable options for Patreon apps that we are no longer pledged to.
if let action = action as? UIAction
{
action.attributes = .disabled
}
else if let menu = action as? UIMenu
{
for case let action as UIAction in menu.children
{
action.attributes = .disabled
}
}
}
}
if installedApp.isActive
{
actions.append(deactivateAction)
}
#if DEBUG
if installedApp.bundleIdentifier != StoreApp.altstoreAppID
{
actions.append(removeAction)
}
#else
if (UserDefaults.standard.legacySideloadedApps ?? []).contains(installedApp.bundleIdentifier)
{
// Legacy sideloaded app, so can't detect if it's deleted.
actions.append(removeAction)
}
else if !UserDefaults.standard.isLegacyDeactivationSupported && !installedApp.isActive
{
// Inactive apps are actually deleted, so we need another way
// for user to remove them from AltStore.
actions.append(removeAction)
}
#endif
return actions
let menu = UIMenu(title: title ?? "", children: actions)
return menu
}
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration?
@@ -1871,9 +1919,7 @@ extension MyAppsViewController
let installedApp = self.dataSource.item(at: indexPath)
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in
let actions = self.actions(for: installedApp)
let menu = UIMenu(title: "", children: actions)
let menu = self.contextMenu(for: installedApp)
return menu
}
}

View File

@@ -42,8 +42,7 @@ extension UpdateCollectionViewCell
self.contentView.preservesSuperviewLayoutMargins = true
self.bannerView.backgroundEffectView.isHidden = true
self.bannerView.button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal)
self.blurView.layer.cornerRadius = 20
self.blurView.layer.masksToBounds = true

View File

@@ -341,7 +341,7 @@ private extension NewsViewController
let app = self.dataSource.item(at: indexPath)
guard let storeApp = app.storeApp else { return }
if let installedApp = app.storeApp?.installedApp
if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
{
self.open(installedApp)
}
@@ -359,7 +359,21 @@ private extension NewsViewController
return
}
_ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in
if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
{
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
}
else
{
AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:))
}
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
func finish(_ result: Result<InstalledApp, Error>)
{
DispatchQueue.main.async {
switch result
{
@@ -377,10 +391,6 @@ private extension NewsViewController
}
}
}
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
func open(_ installedApp: InstalledApp)
@@ -426,42 +436,13 @@ extension NewsViewController
footerView.layoutMargins.left = self.view.layoutMargins.left
footerView.layoutMargins.right = self.view.layoutMargins.right
footerView.bannerView.button.isIndicatingActivity = false
footerView.bannerView.configure(for: storeApp)
footerView.bannerView.tintColor = storeApp.tintColor
footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered)
footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:)))
footerView.bannerView.button.isIndicatingActivity = false
if storeApp.installedApp == nil
{
let buttonTitle = NSLocalizedString("Free", comment: "")
footerView.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
footerView.bannerView.button.accessibilityValue = buttonTitle
let progress = AppManager.shared.installationProgress(for: storeApp)
footerView.bannerView.button.progress = progress
if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date()
{
footerView.bannerView.button.countdownDate = versionDate
}
else
{
footerView.bannerView.button.countdownDate = nil
}
}
else
{
footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), storeApp.name)
footerView.bannerView.button.accessibilityValue = nil
footerView.bannerView.button.progress = nil
footerView.bannerView.button.countdownDate = nil
}
Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView)
return footerView

View File

@@ -7,15 +7,19 @@
//
import Foundation
import Roxas
import WebKit
import UniformTypeIdentifiers
import AltStoreCore
import AltSign
import Roxas
@objc(DownloadAppOperation)
class DownloadAppOperation: ResultOperation<ALTApplication>
{
let app: AppProtocol
@Managed
private(set) var app: AppProtocol
let context: InstallAppOperationContext
private let appName: String
@@ -25,6 +29,8 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
private let session = URLSession(configuration: .default)
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
private var downloadPatreonAppContinuation: CheckedContinuation<URL, Error>?
init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext)
{
self.app = app
@@ -55,22 +61,36 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
// Set _after_ checking self.context.error to prevent overwriting localized failure for previous errors.
self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName)
guard let storeApp = self.app as? StoreApp else {
// Only StoreApp allows falling back to previous versions.
// AppVersion can only install itself, and ALTApplication doesn't have previous versions.
return self.download(self.app)
}
// Verify storeApp
storeApp.managedObjectContext?.perform {
self.$app.perform { app in
do
{
let latestVersion = try self.verify(storeApp)
self.download(latestVersion)
var appVersion: AppVersion?
if let version = app as? AppVersion
{
appVersion = version
}
else if let storeApp = app as? StoreApp
{
guard let latestVersion = storeApp.latestAvailableVersion else {
let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName)
throw OperationError.unknown(failureReason: failureReason)
}
// Attempt to download latest _available_ version, and fall back to older versions if necessary.
appVersion = latestVersion
}
if let appVersion
{
try self.verify(appVersion)
}
self.download(appVersion ?? app)
}
catch let error as VerificationError where error.code == .iOSVersionNotSupported
{
guard let presentingViewController = self.context.presentingViewController, let latestSupportedVersion = storeApp.latestSupportedVersion
guard let presentingViewController = self.context.presentingViewController, let storeApp = app.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion
else { return self.finish(.failure(error)) }
if let installedApp = storeApp.installedApp
@@ -81,7 +101,7 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
let title = NSLocalizedString("Unsupported iOS Version", comment: "")
let message = error.localizedDescription + "\n\n" + NSLocalizedString("Would you like to download the last version compatible with this device instead?", comment: "")
let localizedVersion = latestSupportedVersion.localizedVersion
DispatchQueue.main.async {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
@@ -117,23 +137,16 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
private extension DownloadAppOperation
{
func verify(_ storeApp: StoreApp) throws -> AppVersion
func verify(_ version: AppVersion) throws
{
guard let version = storeApp.latestAvailableVersion else {
let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName)
throw OperationError.unknown(failureReason: failureReason)
}
if let minOSVersion = version.minOSVersion, !ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion)
{
throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: minOSVersion)
throw VerificationError.iOSVersionNotSupported(app: version, requiredOSVersion: minOSVersion)
}
else if let maxOSVersion = version.maxOSVersion, ProcessInfo.processInfo.operatingSystemVersion > maxOSVersion
{
throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: maxOSVersion)
throw VerificationError.iOSVersionNotSupported(app: version, requiredOSVersion: maxOSVersion)
}
return version
}
func download(@Managed _ app: AppProtocol)
@@ -194,11 +207,43 @@ private extension DownloadAppOperation
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
{
func finishOperation(_ result: Result<URL, Error>)
{
Task<Void, Never>.detached(priority: .userInitiated) {
do
{
let fileURL = try result.get()
let fileURL: URL
if sourceURL.isFileURL
{
fileURL = sourceURL
self.progress.completedUnitCount += 3
}
else if let (isPledged, isPledgeRequired) = await self.context.$appVersion.perform({ $0?.app.map { ($0.isPledged, $0.isPledgeRequired) } }), isPledgeRequired && !isPledged
{
// Not pledged, so just show Patreon page.
guard let presentingViewController = self.context.presentingViewController,
let patreonURL = await self.context.$appVersion.perform({ $0?.app?.source?.patreonURL })
else { throw OperationError.pledgeRequired(appName: self.appName) }
// Intercept downloads just in case they are in fact pledged.
fileURL = try await self.downloadFromPatreon(patreonURL, presentingViewController: presentingViewController)
}
else if let host = sourceURL.host, host.lowercased().hasSuffix("patreon.com") && sourceURL.path.lowercased() == "/file"
{
// Patreon app
fileURL = try await self.downloadPatreonApp(from: sourceURL)
}
else
{
// Regular app
fileURL = try await self.downloadFile(from: sourceURL)
}
defer {
if !sourceURL.isFileURL
{
try? FileManager.default.removeItem(at: fileURL)
}
}
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) }
@@ -235,31 +280,26 @@ private extension DownloadAppOperation
completionHandler(.failure(error))
}
}
if sourceURL.isFileURL
{
finishOperation(.success(sourceURL))
self.progress.completedUnitCount += 3
}
else
{
let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in
}
func downloadFile(from downloadURL: URL) async throws -> URL
{
try await withCheckedThrowingContinuation { continuation in
let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in
do
{
if let response = response as? HTTPURLResponse
{
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: sourceURL]) }
guard response.statusCode != 403 else { throw URLError(.noPermissionsToReadFile) }
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: downloadURL]) }
}
let (fileURL, _) = try Result((fileURL, response), error).get()
finishOperation(.success(fileURL))
try? FileManager.default.removeItem(at: fileURL)
continuation.resume(returning: fileURL)
}
catch
{
finishOperation(.failure(error))
continuation.resume(throwing: error)
}
}
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
@@ -267,6 +307,162 @@ private extension DownloadAppOperation
downloadTask.resume()
}
}
func downloadPatreonApp(from patreonURL: URL) async throws -> URL
{
guard !UserDefaults.shared.skipPatreonDownloads else {
// Skip all hacks, take user straight to Patreon post.
return try await downloadFromPatreonPost()
}
do
{
// User is pledged to this app, attempt to download.
let fileURL = try await self.downloadFile(from: patreonURL)
return fileURL
}
catch URLError.noPermissionsToReadFile
{
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
// Attempt to sign-in again in case our Patreon session has expired.
try await withCheckedThrowingContinuation { continuation in
PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in
do
{
let account = try result.get()
try account.managedObjectContext?.save()
continuation.resume()
}
catch
{
continuation.resume(throwing: error)
}
}
}
do
{
// Success, so try to download once more now that we're definitely authenticated.
let fileURL = try await self.downloadFile(from: patreonURL)
return fileURL
}
catch URLError.noPermissionsToReadFile
{
// We know authentication succeeded, so failure must mean user isn't patron/on the correct tier,
// or that our hacky workaround for downloading Patreon attachments has failed.
// Either way, taking them directly to the post serves as a decent fallback.
return try await downloadFromPatreonPost()
}
}
func downloadFromPatreonPost() async throws -> URL
{
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
let downloadURL: URL
if let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false),
let postItem = components.queryItems?.first(where: { $0.name == "h" }),
let postID = postItem.value,
let patreonPostURL = URL(string: "https://www.patreon.com/posts/" + postID)
{
downloadURL = patreonPostURL
}
else
{
downloadURL = patreonURL
}
return try await self.downloadFromPatreon(downloadURL, presentingViewController: presentingViewController)
}
}
@MainActor
func downloadFromPatreon(_ patreonURL: URL, presentingViewController: UIViewController) async throws -> URL
{
let webViewController = WebViewController(url: patreonURL)
webViewController.delegate = self
webViewController.webView.navigationDelegate = self
let navigationController = UINavigationController(rootViewController: webViewController)
presentingViewController.present(navigationController, animated: true)
let downloadURL: URL
do
{
defer {
navigationController.dismiss(animated: true)
}
downloadURL = try await withCheckedThrowingContinuation { continuation in
self.downloadPatreonAppContinuation = continuation
}
}
let fileURL = try await self.downloadFile(from: downloadURL)
return fileURL
}
}
extension DownloadAppOperation: WebViewControllerDelegate
{
func webViewControllerDidFinish(_ webViewController: WebViewController)
{
guard let continuation = self.downloadPatreonAppContinuation else { return }
self.downloadPatreonAppContinuation = nil
continuation.resume(throwing: CancellationError())
}
}
extension DownloadAppOperation: WKNavigationDelegate
{
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy
{
guard #available(iOS 14.5, *), navigationAction.shouldPerformDownload else { return .allow }
guard let continuation = self.downloadPatreonAppContinuation else { return .allow }
self.downloadPatreonAppContinuation = nil
if let downloadURL = navigationAction.request.url
{
continuation.resume(returning: downloadURL)
}
else
{
continuation.resume(throwing: URLError(.badURL))
}
return .cancel
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy
{
// Called for Patreon attachments
guard !navigationResponse.canShowMIMEType else { return .allow }
guard let continuation = self.downloadPatreonAppContinuation else { return .allow }
self.downloadPatreonAppContinuation = nil
guard let response = navigationResponse.response as? HTTPURLResponse, let responseURL = response.url,
let mimeType = response.mimeType, let type = UTType(mimeType: mimeType),
type.conforms(to: .ipa) || type.conforms(to: .zip) || type.conforms(to: .application)
else {
continuation.resume(throwing: OperationError.invalidApp)
return .cancel
}
continuation.resume(returning: responseURL)
return .cancel
}
}
private extension DownloadAppOperation

View File

@@ -36,6 +36,10 @@ extension OperationError
case serverNotFound = 1200
case connectionFailed = 1201
case connectionDropped = 1202
/* Pledges */
case pledgeRequired = 1401
case pledgeInactive = 1402
}
static var cancelled: CancellationError { CancellationError() }
@@ -67,6 +71,14 @@ extension OperationError
static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line)
}
static func pledgeRequired(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .pledgeRequired, appName: appName, sourceFile: file, sourceLine: line)
}
static func pledgeInactive(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .pledgeInactive, appName: appName, sourceFile: file, sourceLine: line)
}
}
struct OperationError: ALTLocalizedError
@@ -132,6 +144,14 @@ struct OperationError: ALTLocalizedError
case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "")
case .connectionFailed: return NSLocalizedString("A connection to AltServer could not be established.", comment: "")
case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "")
case .pledgeRequired:
let appName = self.appName ?? NSLocalizedString("This app", comment: "")
return String(format: NSLocalizedString("%@ requires an active pledge in order to be installed.", comment: ""), appName)
case .pledgeInactive:
let appName = self.appName ?? NSLocalizedString("this app", comment: "")
return String(format: NSLocalizedString("Your pledge is no longer active. Please renew it to continue using %@ normally.", comment: ""), appName)
}
}
private var _failureReason: String?

View File

@@ -153,7 +153,13 @@ class FetchSourceOperation: ResultOperation<Source>
let identifier = source.identifier
if identifier == Source.altStoreIdentifier, let skipPatreonDownloads = source.userInfo?[.skipPatreonDownloads]
{
UserDefaults.shared.skipPatreonDownloads = (skipPatreonDownloads == "true")
}
try self.verify(source, response: response)
try self.verifyPledges(for: source, in: childContext)
try childContext.save()
@@ -223,6 +229,63 @@ private extension FetchSourceOperation
}
}
func verifyPledges(for source: Source, in context: NSManagedObjectContext) throws
{
guard let patreonURL = source.patreonURL, let patreonAccount = DatabaseManager.shared.patreonAccount(in: context) else { return }
let normalizedPatreonURL = try patreonURL.normalized()
guard let pledge = patreonAccount.pledges.first(where: { pledge in
do
{
let normalizedCampaignURL = try pledge.campaignURL.normalized()
return normalizedCampaignURL == normalizedPatreonURL
}
catch
{
Logger.main.error("Failed to normalize Patreon URL \(pledge.campaignURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
return false
}
}) else { return }
// User is pledged to this source's Patreon, so check which apps they're pledged to.
// We only assign `isPledged = true` because false is already the default,
// and only one check needs to be true for isPledged to be true.
for app in source.apps where app.isPledgeRequired
{
if let requiredAppPledge = app.pledgeAmount
{
if pledge.amount >= requiredAppPledge
{
app.isPledged = true
continue
}
}
if let tierIDs = app._tierIDs
{
let tier = pledge.tiers.first { tierIDs.contains($0.identifier) }
if tier != nil
{
app.isPledged = true
continue
}
}
if let rewardID = app._rewardID
{
let reward = pledge.rewards.first { $0.identifier == rewardID }
if reward != nil
{
app.isPledged = true
continue
}
}
}
}
func verifySourceNotBlocked(_ source: Source, response: URLResponse?) throws
{
guard let blockedSources = UserDefaults.shared.blockedSources else { return }

View File

@@ -110,7 +110,7 @@ class PatchAppOperation: ResultOperation<Void>
.flatMap { self.patch(resignedApp, withBinaryAt: $0) }
.tryMap { try FileManager.default.zipAppBundle(at: $0) }
.tryMap { (fileURL) in
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL)
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil)
let destinationURL = InstalledApp.refreshedIPAURL(for: app)
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)

View File

@@ -44,7 +44,7 @@ class SendAppOperation: ResultOperation<ServerConnection>
Logger.sideload.notice("Sending app \(self.context.bundleIdentifier, privacy: .public) to AltServer \(server.localizedName ?? "nil", privacy: .public)...")
// self.context.resignedApp.fileURL points to the app bundle, but we want the .ipa.
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL)
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil)
let fileURL = InstalledApp.refreshedIPAURL(for: app)
// Connect to server.

View File

@@ -138,7 +138,7 @@ private extension PatreonViewController
headerView.accountButton.addTarget(self, action: #selector(PatreonViewController.signOut(_:)), for: .primaryActionTriggered)
headerView.accountButton.setTitle(String(format: NSLocalizedString("Unlink %@", comment: ""), account.name), for: .normal)
if account.isPatron
if account.isAltStorePatron
{
headerView.supportButton.setTitle(isPatronSupportButtonTitle, for: .normal)
@@ -191,19 +191,35 @@ private extension PatreonViewController
@IBAction func authenticate(_ sender: UIBarButtonItem)
{
PatreonAPI.shared.authenticate { (result) in
PatreonAPI.shared.authenticate(presentingViewController: self) { (result) in
do
{
let account = try result.get()
try account.managedObjectContext?.save()
DispatchQueue.main.async {
self.update()
// Update sources to show any Patreon-only apps.
AppManager.shared.fetchSources { result in
do
{
let (_, context) = try result.get()
try context.save()
}
catch
{
Logger.main.error("Failed to update sources after authenticating Patreon account. \(error.localizedDescription, privacy: .public)")
}
DispatchQueue.main.async {
self.update()
}
}
}
catch ASWebAuthenticationSessionError.canceledLogin
catch is CancellationError
{
// Ignore
// Clear in-app browser cache in case they are signed into wrong account.
Task<Void, Never>.detached {
await PatreonAPI.shared.deleteAuthCookies()
}
}
catch
{

View File

@@ -215,7 +215,7 @@ private extension SourceDetailContentViewController
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<StoreApp, UIImage>(items: limitedFeaturedApps)
dataSource.cellIdentifierHandler = { _ in "AppCell" }
dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta)) // Never show beta apps (at least until we support betas for other sources).
dataSource.predicate = StoreApp.visibleAppsPredicate
dataSource.cellConfigurationHandler = { [weak self] (cell, storeApp, indexPath) in
let cell = cell as! AppBannerCollectionViewCell
cell.tintColor = storeApp.tintColor
@@ -225,43 +225,13 @@ private extension SourceDetailContentViewController
cell.contentView.layoutMargins = .zero
cell.contentView.backgroundColor = .altBackground
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.configure(for: storeApp)
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.buttonLabel.isHidden = true
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.tintColor = storeApp.tintColor
cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.performAppAction(_:)), for: .primaryActionTriggered)
let buttonTitle = NSLocalizedString("Free", comment: "")
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
cell.bannerView.button.accessibilityValue = buttonTitle
cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.addSourceThenDownloadApp(_:)), for: .primaryActionTriggered)
let progress = AppManager.shared.installationProgress(for: storeApp)
cell.bannerView.button.progress = progress
if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date()
{
cell.bannerView.button.countdownDate = versionDate
}
else
{
cell.bannerView.button.countdownDate = nil
}
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
if let progress = AppManager.shared.installationProgress(for: storeApp), progress.fractionCompleted < 1.0
{
cell.bannerView.button.progress = progress
}
else
{
cell.bannerView.button.progress = nil
}
cell.bannerView.iconImageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
return RSTAsyncBlockOperation { (operation) in
@@ -404,64 +374,93 @@ extension SourceDetailContentViewController
private extension SourceDetailContentViewController
{
@objc func addSourceThenDownloadApp(_ sender: UIButton)
@objc func performAppAction(_ sender: PillButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
sender.isIndicatingActivity = true
let storeApp = self.dataSource.item(at: indexPath) as! StoreApp
Task<Void, Never> {
if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
{
self.open(installedApp)
}
else
{
sender.isIndicatingActivity = true
Task<Void, Never> {
await self.addSourceThenDownloadApp(storeApp)
sender.isIndicatingActivity = false
}
}
}
func addSourceThenDownloadApp(_ storeApp: StoreApp) async
{
do
{
let isAdded = try await self.source.isAdded
if !isAdded
{
let message = String(format: NSLocalizedString("You must add this source before you can install apps from it.\n\n“%@” will begin downloading once it has been added.", comment: ""), storeApp.name)
try await AppManager.shared.add(self.source, message: message, presentingViewController: self)
}
do
{
let isAdded = try await self.source.isAdded
if !isAdded
{
let message = String(format: NSLocalizedString("You must add this source before you can install apps from it.\n\n“%@” will begin downloading once it has been added.", comment: ""), storeApp.name)
try await AppManager.shared.add(self.source, message: message, presentingViewController: self)
}
do
{
try await self.downloadApp(storeApp)
}
catch OperationError.cancelled {}
catch
{
let toastView = ToastView(error: error)
toastView.opensErrorLog = true
toastView.show(in: self)
}
try await self.downloadApp(storeApp)
}
catch is CancellationError {}
catch
{
await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription)
let toastView = ToastView(error: error)
toastView.opensErrorLog = true
toastView.show(in: self)
}
sender.isIndicatingActivity = false
self.collectionView.reloadSections([Section.featuredApps.rawValue])
}
catch is CancellationError {}
catch
{
await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription)
}
self.collectionView.reloadSections([Section.featuredApps.rawValue])
}
@MainActor
func downloadApp(_ storeApp: StoreApp) async throws
{
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
AppManager.shared.install(storeApp, presentingViewController: self) { result in
continuation.resume(with: result.map { _ in })
if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
{
AppManager.shared.update(installedApp, presentingViewController: self) { result in
continuation.resume(with: result.map { _ in () })
}
}
else
{
AppManager.shared.install(storeApp, presentingViewController: self) { result in
continuation.resume(with: result.map { _ in () })
}
}
guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else {
self.collectionView.reloadSections([Section.featuredApps.rawValue])
return
UIView.performWithoutAnimation {
guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else {
self.collectionView.reloadSections([Section.featuredApps.rawValue])
return
}
let indexPath = IndexPath(item: index, section: Section.featuredApps.rawValue)
self.collectionView.reloadItems(at: [indexPath])
}
let indexPath = IndexPath(item: index, section: Section.featuredApps.rawValue)
self.collectionView.reloadItems(at: [indexPath])
}
}
func open(_ installedApp: InstalledApp)
{
UIApplication.shared.open(installedApp.openAppURL)
}
}
extension SourceDetailContentViewController: ScrollableContentViewController

View File

@@ -204,15 +204,7 @@ private extension SourcesViewController
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
let numberOfApps: Int
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
{
numberOfApps = source.apps.count
}
else
{
numberOfApps = source.apps.filter { !$0.isBeta }.count
}
let numberOfApps = source.apps.filter { StoreApp.visibleAppsPredicate.evaluate(with: $0) }.count
if let error = source.error
{