Compare commits
7 Commits
d18422af00
...
a6be43da53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6be43da53 | ||
|
|
c807154873 | ||
|
|
1103a7f0b4 | ||
|
|
8a63725113 | ||
|
|
4a73efe680 | ||
|
|
be4d62fb1e | ||
|
|
7683447eea |
@@ -1,115 +0,0 @@
|
|||||||
//
|
|
||||||
// AnalyticsManager.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 3/31/20.
|
|
||||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
|
|
||||||
import AppCenter
|
|
||||||
import AppCenterAnalytics
|
|
||||||
import AppCenterCrashes
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
|
||||||
#elseif RELEASE
|
|
||||||
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
|
||||||
#else
|
|
||||||
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
extension AnalyticsManager
|
|
||||||
{
|
|
||||||
enum EventProperty: String
|
|
||||||
{
|
|
||||||
case name
|
|
||||||
case bundleIdentifier
|
|
||||||
case developerName
|
|
||||||
case version
|
|
||||||
case buildVersion
|
|
||||||
case size
|
|
||||||
case tintColor
|
|
||||||
case sourceIdentifier
|
|
||||||
case sourceURL
|
|
||||||
case patreonURL
|
|
||||||
case pledgeAmount
|
|
||||||
case pledgeCurrency
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Event
|
|
||||||
{
|
|
||||||
case installedApp(InstalledApp)
|
|
||||||
case updatedApp(InstalledApp)
|
|
||||||
case refreshedApp(InstalledApp)
|
|
||||||
|
|
||||||
var name: String {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .installedApp: return "installed_app"
|
|
||||||
case .updatedApp: return "updated_app"
|
|
||||||
case .refreshedApp: return "refreshed_app"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var properties: [EventProperty: String] {
|
|
||||||
let properties: [EventProperty: String?]
|
|
||||||
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .installedApp(let app), .updatedApp(let app), .refreshedApp(let app):
|
|
||||||
let appBundleURL = InstalledApp.fileURL(for: app)
|
|
||||||
let appBundleSize = FileManager.default.directorySize(at: appBundleURL)
|
|
||||||
|
|
||||||
properties = [
|
|
||||||
.name: app.name,
|
|
||||||
.bundleIdentifier: app.bundleIdentifier,
|
|
||||||
.developerName: app.storeApp?.developerName,
|
|
||||||
.version: app.version,
|
|
||||||
.buildVersion: app.buildVersion,
|
|
||||||
.size: appBundleSize?.description,
|
|
||||||
.tintColor: app.storeApp?.tintColor?.hexString,
|
|
||||||
.sourceIdentifier: app.storeApp?.sourceIdentifier,
|
|
||||||
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString,
|
|
||||||
.patreonURL: app.storeApp?.source?.patreonURL?.absoluteString,
|
|
||||||
.pledgeAmount: app.storeApp?.pledgeAmount?.description,
|
|
||||||
.pledgeCurrency: app.storeApp?.pledgeCurrency
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return properties.compactMapValues { $0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class AnalyticsManager
|
|
||||||
{
|
|
||||||
static let shared = AnalyticsManager()
|
|
||||||
|
|
||||||
private init()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AnalyticsManager
|
|
||||||
{
|
|
||||||
func start()
|
|
||||||
{
|
|
||||||
AppCenter.start(withAppSecret: appCenterAppSecret, services: [
|
|
||||||
Analytics.self,
|
|
||||||
Crashes.self
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackEvent(_ event: Event)
|
|
||||||
{
|
|
||||||
let properties = event.properties.reduce(into: [:]) { (properties, item) in
|
|
||||||
properties[item.key.rawValue] = item.value
|
|
||||||
}
|
|
||||||
|
|
||||||
Analytics.trackEvent(event.name, withProperties: properties)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,8 +93,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnalyticsManager.shared.start()
|
self.setTintColor()
|
||||||
|
|
||||||
self.setTintColor()
|
self.setTintColor()
|
||||||
self.prepareImageCache()
|
self.prepareImageCache()
|
||||||
|
|
||||||
@@ -148,8 +147,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
if UserDefaults.standard.enableEMPforWireguard {
|
if UserDefaults.standard.enableEMPforWireguard {
|
||||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
PatreonAPI.shared.refreshPatreonAccount()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
||||||
|
|||||||
@@ -199,8 +199,6 @@ extension LaunchViewController {
|
|||||||
didFinishLaunching = true
|
didFinishLaunching = true
|
||||||
|
|
||||||
AppManager.shared.update()
|
AppManager.shared.update()
|
||||||
AppManager.shared.updatePatronsIfNeeded()
|
|
||||||
PatreonAPI.shared.refreshPatreonAccount()
|
|
||||||
AppManager.shared.updateAllSources { result in
|
AppManager.shared.updateAllSources { result in
|
||||||
guard case .failure(let error) = result else { return }
|
guard case .failure(let error) = result else { return }
|
||||||
Logger.main.error("Failed to update sources on launch. \(error.localizedDescription, privacy: .public)")
|
Logger.main.error("Failed to update sources on launch. \(error.localizedDescription, privacy: .public)")
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import Roxas
|
|||||||
extension AppManager
|
extension AppManager
|
||||||
{
|
{
|
||||||
static let didFetchSourceNotification = Notification.Name("io.sidestore.AppManager.didFetchSource")
|
static let didFetchSourceNotification = Notification.Name("io.sidestore.AppManager.didFetchSource")
|
||||||
static let didUpdatePatronsNotification = Notification.Name("io.sidestore.AppManager.didUpdatePatrons")
|
|
||||||
static let didAddSourceNotification = Notification.Name("io.sidestore.AppManager.didAddSource")
|
static let didAddSourceNotification = Notification.Name("io.sidestore.AppManager.didAddSource")
|
||||||
static let didRemoveSourceNotification = Notification.Name("io.sidestore.AppManager.didRemoveSource")
|
static let didRemoveSourceNotification = Notification.Name("io.sidestore.AppManager.didRemoveSource")
|
||||||
static let willInstallAppFromNewSourceNotification = Notification.Name("io.sidestore.AppManager.willInstallAppFromNewSource")
|
static let willInstallAppFromNewSourceNotification = Notification.Name("io.sidestore.AppManager.willInstallAppFromNewSource")
|
||||||
@@ -590,34 +589,6 @@ extension AppManager
|
|||||||
return updateKnownSourcesOperation
|
return updateKnownSourcesOperation
|
||||||
}
|
}
|
||||||
|
|
||||||
func updatePatronsIfNeeded()
|
|
||||||
{
|
|
||||||
// guard self.operationQueue.operations.allSatisfy({ !($0 is UpdatePatronsOperation) }) else {
|
|
||||||
// // There's already an UpdatePatronsOperation running.
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// self.updatePatronsResult = nil
|
|
||||||
//
|
|
||||||
// let updatePatronsOperation = UpdatePatronsOperation()
|
|
||||||
// updatePatronsOperation.resultHandler = { (result) in
|
|
||||||
// do
|
|
||||||
// {
|
|
||||||
// try result.get()
|
|
||||||
// self.updatePatronsResult = .success(())
|
|
||||||
// }
|
|
||||||
// catch
|
|
||||||
// {
|
|
||||||
// print("Error updating Friend Zone Patrons:", error)
|
|
||||||
// self.updatePatronsResult = .failure(error)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// NotificationCenter.default.post(name: AppManager.didUpdatePatronsNotification, object: self)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// self.run([updatePatronsOperation], context: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateAllSources(completion: @escaping (Result<Void, Error>) -> Void)
|
func updateAllSources(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
{
|
{
|
||||||
self.updateSourcesResult = nil
|
self.updateSourcesResult = nil
|
||||||
@@ -1265,17 +1236,6 @@ private extension AppManager
|
|||||||
assert(context.authenticatedContext === group.context)
|
assert(context.authenticatedContext === group.context)
|
||||||
|
|
||||||
context.beginInstallationHandler = { (installedApp) in
|
context.beginInstallationHandler = { (installedApp) in
|
||||||
switch appOperation
|
|
||||||
{
|
|
||||||
case .update where installedApp.bundleIdentifier == StoreApp.altstoreAppID:
|
|
||||||
// AltStore will quit before installation finishes,
|
|
||||||
// so assume if we get this far the update will finish successfully.
|
|
||||||
let event = AnalyticsManager.Event.updatedApp(installedApp)
|
|
||||||
AnalyticsManager.shared.trackEvent(event)
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
|
|
||||||
group.beginInstallationHandler?(installedApp)
|
group.beginInstallationHandler?(installedApp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1296,20 +1256,6 @@ private extension AppManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var verifyPledgeOperation: VerifyAppPledgeOperation?
|
|
||||||
if let storeApp = app.storeApp
|
|
||||||
{
|
|
||||||
verifyPledgeOperation = VerifyAppPledgeOperation(storeApp: storeApp, presentingViewController: context.presentingViewController)
|
|
||||||
verifyPledgeOperation?.resultHandler = { result in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error):
|
|
||||||
context.error = error
|
|
||||||
case .success: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Download */
|
/* Download */
|
||||||
let downloadedAppURL = context.temporaryDirectory.appendingPathComponent("Cached.app")
|
let downloadedAppURL = context.temporaryDirectory.appendingPathComponent("Cached.app")
|
||||||
let downloadOperation = DownloadAppOperation(app: downloadingApp, destinationURL: downloadedAppURL, context: context)
|
let downloadOperation = DownloadAppOperation(app: downloadingApp, destinationURL: downloadedAppURL, context: context)
|
||||||
@@ -1332,12 +1278,6 @@ private extension AppManager
|
|||||||
}
|
}
|
||||||
progress.addChild(downloadOperation.progress, withPendingUnitCount: 25)
|
progress.addChild(downloadOperation.progress, withPendingUnitCount: 25)
|
||||||
|
|
||||||
if let verifyPledgeOperation
|
|
||||||
{
|
|
||||||
downloadOperation.addDependency(verifyPledgeOperation)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Verify App */
|
/* Verify App */
|
||||||
let permissionsMode = UserDefaults.shared.permissionCheckingDisabled ? .none : permissionReviewMode
|
let permissionsMode = UserDefaults.shared.permissionCheckingDisabled ? .none : permissionReviewMode
|
||||||
let verifyOperation = VerifyAppOperation(permissionsMode: permissionsMode, context: context, customBundleId: app.bundleIdentifier)
|
let verifyOperation = VerifyAppOperation(permissionsMode: permissionsMode, context: context, customBundleId: app.bundleIdentifier)
|
||||||
@@ -1605,7 +1545,6 @@ private extension AppManager
|
|||||||
|
|
||||||
// Operations picked for request
|
// Operations picked for request
|
||||||
var operations = [
|
var operations = [
|
||||||
verifyPledgeOperation,
|
|
||||||
downloadOperation,
|
downloadOperation,
|
||||||
verifyOperation,
|
verifyOperation,
|
||||||
removeAppExtensionsOperation,
|
removeAppExtensionsOperation,
|
||||||
@@ -2153,27 +2092,6 @@ private extension AppManager
|
|||||||
self.scheduleExpirationWarningLocalNotification(for: installedApp)
|
self.scheduleExpirationWarningLocalNotification(for: installedApp)
|
||||||
}
|
}
|
||||||
|
|
||||||
let event: AnalyticsManager.Event?
|
|
||||||
|
|
||||||
switch operation
|
|
||||||
{
|
|
||||||
case .install: event = .installedApp(installedApp)
|
|
||||||
case .refresh: event = .refreshedApp(installedApp)
|
|
||||||
case .update where installedApp.bundleIdentifier == StoreApp.altstoreAppID:
|
|
||||||
// AltStore quits before update finishes, so we've preemptively logged this update event.
|
|
||||||
// In case AltStore doesn't quit, such as when update has a different bundle identifier,
|
|
||||||
// make sure we don't log this update event a second time.
|
|
||||||
event = nil
|
|
||||||
|
|
||||||
case .update: event = .updatedApp(installedApp)
|
|
||||||
case .activate, .deactivate, .backup, .restore: event = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let event = event
|
|
||||||
{
|
|
||||||
AnalyticsManager.shared.trackEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask widgets to be refreshed
|
// Ask widgets to be refreshed
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
@@ -2258,7 +2176,7 @@ private extension AppManager
|
|||||||
switch operation
|
switch operation
|
||||||
{
|
{
|
||||||
case _ where requiresSerialQueue: fallthrough
|
case _ where requiresSerialQueue: fallthrough
|
||||||
case is InstallAppOperation, is RefreshAppOperation, is BackupAppOperation, is VerifyAppPledgeOperation:
|
case is InstallAppOperation, is RefreshAppOperation, is BackupAppOperation:
|
||||||
if let installAltStoreOperation = operation as? InstallAppOperation, installAltStoreOperation.context.bundleIdentifier == StoreApp.altstoreAppID
|
if let installAltStoreOperation = operation as? InstallAppOperation, installAltStoreOperation.context.bundleIdentifier == StoreApp.altstoreAppID
|
||||||
{
|
{
|
||||||
// Add dependencies on previous serial operations in `context` to ensure re-installing AltStore goes last.
|
// Add dependencies on previous serial operations in `context` to ensure re-installing AltStore goes last.
|
||||||
|
|||||||
@@ -544,11 +544,6 @@ private extension MyAppsViewController
|
|||||||
{
|
{
|
||||||
print("[ALTLog] Failed to fetch updates:", error)
|
print("[ALTLog] Failed to fetch updates:", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isAltStorePatron, PatreonAPI.shared.isAuthenticated
|
|
||||||
{
|
|
||||||
self.dataSource.predicate = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,12 +224,6 @@ private extension DownloadAppOperation
|
|||||||
fileURL = sourceURL
|
fileURL = sourceURL
|
||||||
self.progress.completedUnitCount += 3
|
self.progress.completedUnitCount += 3
|
||||||
}
|
}
|
||||||
else if let host = sourceURL.host, host.lowercased().hasSuffix("patreon.com") && sourceURL.path.lowercased() == "/file"
|
|
||||||
{
|
|
||||||
// Patreon app
|
|
||||||
fileURL = try await downloadPatreonApp(from: sourceURL)
|
|
||||||
self.printWithTid("downloadPatreonApp: completed at \(fileURL.path)")
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Regular app
|
// Regular app
|
||||||
@@ -323,107 +317,6 @@ private extension DownloadAppOperation
|
|||||||
self.printWithTid("download started: \(downloadURL)")
|
self.printWithTid("download started: \(downloadURL)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 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 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 downloadFile(from: downloadURL)
|
|
||||||
return fileURL
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ final class FetchSourceOperation: ResultOperation<Source>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try self.verify(source, response: response)
|
try self.verify(source, response: response)
|
||||||
try self.verifyPledges(for: source, in: childContext)
|
|
||||||
|
|
||||||
try childContext.save()
|
try childContext.save()
|
||||||
|
|
||||||
@@ -264,63 +263,6 @@ 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
|
func verifySourceNotBlocked(_ source: Source, response: URLResponse?) throws
|
||||||
{
|
{
|
||||||
guard let blockedSources = UserDefaults.shared.blockedSources else { return }
|
guard let blockedSources = UserDefaults.shared.blockedSources else { return }
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
//
|
|
||||||
// VerifyAppPledgeOperation.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 12/6/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
|
|
||||||
class VerifyAppPledgeOperation: ResultOperation<Void>
|
|
||||||
{
|
|
||||||
@AsyncManaged
|
|
||||||
private(set) var storeApp: StoreApp
|
|
||||||
|
|
||||||
private let presentingViewController: UIViewController?
|
|
||||||
private var openPatreonPageContinuation: CheckedContinuation<Void, Never>?
|
|
||||||
|
|
||||||
private var cancellable: AnyCancellable?
|
|
||||||
|
|
||||||
init(storeApp: StoreApp, presentingViewController: UIViewController?)
|
|
||||||
{
|
|
||||||
self.storeApp = storeApp
|
|
||||||
self.presentingViewController = presentingViewController
|
|
||||||
}
|
|
||||||
|
|
||||||
override func main()
|
|
||||||
{
|
|
||||||
super.main()
|
|
||||||
|
|
||||||
// _Don't_ rethrow earlier errors, or else user will only be taken to Patreon post if connected to same Wi-Fi as AltServer.
|
|
||||||
// if let error = self.context.error
|
|
||||||
// {
|
|
||||||
// self.finish(.failure(error))
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
Task<Void, Never>.detached(priority: .medium) {
|
|
||||||
do
|
|
||||||
{
|
|
||||||
guard await self.$storeApp.isPledgeRequired else { return self.finish(.success(())) }
|
|
||||||
|
|
||||||
if let presentingViewController = self.presentingViewController
|
|
||||||
{
|
|
||||||
// Ask user to connect Patreon account if they are signed-in to Patreon inside WebViewController, but haven't yet signed in through AltStore settings.
|
|
||||||
// This is most likely because the user joined a Patreon campaign directly through WebViewController before connecting Patreon account in settings.
|
|
||||||
try await self.connectPatreonAccountIfNeeded(presentingViewController: presentingViewController)
|
|
||||||
}
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try await self.verifyPledge()
|
|
||||||
}
|
|
||||||
catch let error as OperationError where error.code == .pledgeRequired || error.code == .pledgeInactive
|
|
||||||
{
|
|
||||||
guard
|
|
||||||
let presentingViewController = self.presentingViewController,
|
|
||||||
let source = await self.$storeApp.source,
|
|
||||||
let patreonURL = await self.$storeApp.perform({ _ in source.patreonURL })
|
|
||||||
else { throw error }
|
|
||||||
|
|
||||||
let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false)
|
|
||||||
let lastPathComponent = components?.path.components(separatedBy: "/").last
|
|
||||||
|
|
||||||
let username = lastPathComponent ?? patreonURL.lastPathComponent
|
|
||||||
|
|
||||||
let checkoutURL: URL
|
|
||||||
if await self.$storeApp.prefersCustomPledge, let customPledgeURL = URL(string: "https://www.patreon.com/checkout/" + username + "?rid=0&custom=1")
|
|
||||||
{
|
|
||||||
checkoutURL = customPledgeURL
|
|
||||||
|
|
||||||
let action = await UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default)
|
|
||||||
try await presentingViewController.presentConfirmationAlert(title: NSLocalizedString("Custom Pledge", comment: ""),
|
|
||||||
message: NSLocalizedString("This app supports custom pledges. Pledge any amount on Patreon to receive access.", comment: ""),
|
|
||||||
primaryAction: action)
|
|
||||||
}
|
|
||||||
else if !username.isEmpty, let url = URL(string: "https://www.patreon.com/join/" + username)
|
|
||||||
{
|
|
||||||
// Prefer /join URL over campaign homepage.
|
|
||||||
// URL format from https://support.patreon.com/hc/en-us/articles/360044376211-Managing-members-with-custom-pledges
|
|
||||||
checkoutURL = url
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
checkoutURL = patreonURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct user to Patreon page if they're not already pledged.
|
|
||||||
await self.openPatreonPage(checkoutURL, presentingViewController: presentingViewController)
|
|
||||||
|
|
||||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
if let patreonAccount = await context.performAsync({ DatabaseManager.shared.patreonAccount(in: context) })
|
|
||||||
{
|
|
||||||
// Patreon account is connected, so we'll update it via API to see if pledges changed.
|
|
||||||
// If so, we'll re-fetch the source to update pledge statuses.
|
|
||||||
try await self.updatePledges(for: source, account: patreonAccount)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Patreon account is not connected, so prompt user to connect it.
|
|
||||||
try await self.connectPatreonAccountIfNeeded(presentingViewController: presentingViewController)
|
|
||||||
}
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try await self.verifyPledge()
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore error, but cancel remainder of operation.
|
|
||||||
throw CancellationError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.finish(.success(()))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
self.finish(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension VerifyAppPledgeOperation
|
|
||||||
{
|
|
||||||
func verifyPledge() async throws
|
|
||||||
{
|
|
||||||
let (appName, isPledged) = await self.$storeApp.perform { ($0.name, $0.isPledged) }
|
|
||||||
|
|
||||||
if !PatreonAPI.shared.isAuthenticated || !isPledged
|
|
||||||
{
|
|
||||||
let isInstalled = await self.$storeApp.installedApp != nil
|
|
||||||
if isInstalled
|
|
||||||
{
|
|
||||||
// Assume if there is an InstalledApp, the user had previously pledged to this app.
|
|
||||||
throw OperationError.pledgeInactive(appName: appName)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw OperationError.pledgeRequired(appName: appName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectPatreonAccountIfNeeded(presentingViewController: UIViewController) async throws
|
|
||||||
{
|
|
||||||
guard !PatreonAPI.shared.isAuthenticated, let authCookie = PatreonAPI.shared.authCookies.first(where: { $0.name.lowercased() == "session_id" }) else { return }
|
|
||||||
|
|
||||||
Logger.sideload.debug("Patreon Auth cookie: \(authCookie.name)=\(authCookie.value)")
|
|
||||||
|
|
||||||
let message = NSLocalizedString("You're signed into Patreon but haven't connected your account with SideStore.\n\nPlease connect your account to download Patreon-exclusive apps.", comment: "")
|
|
||||||
let action = await UIAlertAction(title: NSLocalizedString("Connect Patreon Account", comment: ""), style: .default)
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
_ = try await presentingViewController.presentConfirmationAlert(title: NSLocalizedString("Patreon Account Detected", comment: ""),
|
|
||||||
message: message, actions: [action])
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore and continue
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let source = await self.$storeApp.source
|
|
||||||
{
|
|
||||||
// Fetch source to update pledge status now that account is connected.
|
|
||||||
try await self.update(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePledges(@AsyncManaged for source: Source, @AsyncManaged account: PatreonAccount) async throws
|
|
||||||
{
|
|
||||||
guard PatreonAPI.shared.isAuthenticated else { return }
|
|
||||||
|
|
||||||
let previousPledgeIDs = Set(await $account.perform { $0.pledges.map(\.identifier) })
|
|
||||||
|
|
||||||
let updatedPledgeIDs = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
PatreonAPI.shared.fetchAccount { (result: Result<PatreonAccount, Swift.Error>) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let account = try result.get()
|
|
||||||
let pledgeIDs = Set(account.pledges.map(\.identifier))
|
|
||||||
|
|
||||||
try account.managedObjectContext?.save()
|
|
||||||
|
|
||||||
continuation.resume(returning: pledgeIDs)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
Logger.sideload.error("Failed to update Patreon account. \(error.localizedDescription, privacy: .public)")
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if updatedPledgeIDs != previousPledgeIDs
|
|
||||||
{
|
|
||||||
// Active pledges changed, so fetch source to update pledge status.
|
|
||||||
try await self.update(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(@AsyncManaged _ source: Source) async throws
|
|
||||||
{
|
|
||||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
_ = try await AppManager.shared.fetchSource(sourceURL: $source.sourceURL, managedObjectContext: context)
|
|
||||||
|
|
||||||
try await context.performAsync {
|
|
||||||
try context.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func openPatreonPage(_ patreonURL: URL, presentingViewController: UIViewController) async
|
|
||||||
{
|
|
||||||
let webViewController = WebViewController(url: patreonURL)
|
|
||||||
webViewController.delegate = self
|
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
|
||||||
presentingViewController.present(navigationController, animated: true)
|
|
||||||
|
|
||||||
// Automatically dismiss if user completes checkout flow.
|
|
||||||
self.cancellable = webViewController.webView.publisher(for: \.url, options: [.new])
|
|
||||||
.compactMap { $0 }
|
|
||||||
.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
|
|
||||||
.compactMap { components in
|
|
||||||
let lastPathComponent = components.path.components(separatedBy: "/").last
|
|
||||||
return lastPathComponent?.lowercased()
|
|
||||||
}
|
|
||||||
.filter { $0 == "membership" }
|
|
||||||
.receive(on: RunLoop.main)
|
|
||||||
.sink { [weak self] url in
|
|
||||||
guard let continuation = self?.openPatreonPageContinuation else { return }
|
|
||||||
self?.openPatreonPageContinuation = nil
|
|
||||||
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
self.openPatreonPageContinuation = continuation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache auth cookies just in case user signed in.
|
|
||||||
await PatreonAPI.shared.saveAuthCookies()
|
|
||||||
|
|
||||||
navigationController.dismiss(animated: true)
|
|
||||||
|
|
||||||
self.cancellable = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VerifyAppPledgeOperation: WebViewControllerDelegate
|
|
||||||
{
|
|
||||||
func webViewControllerDidFinish(_ webViewController: WebViewController)
|
|
||||||
{
|
|
||||||
guard let continuation = self.openPatreonPageContinuation else { return }
|
|
||||||
self.openPatreonPageContinuation = nil
|
|
||||||
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Original</string>
|
<string>Original</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>iconName</key>
|
||||||
<string>AppIcon</string>
|
<string>AppIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
@@ -18,66 +20,88 @@
|
|||||||
<string>Blue</string>
|
<string>Blue</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Blue</string>
|
<string>Blue</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>BlueIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Dark</string>
|
<string>Dark</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Dark</string>
|
<string>Dark</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>DarkIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Honeydew</string>
|
<string>Honeydew</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Honeydew</string>
|
<string>Honeydew</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>HoneydewIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Pride</string>
|
<string>Pride</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Pride</string>
|
<string>Pride</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>PrideIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Sandy</string>
|
<string>Sandy</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Sandy</string>
|
<string>Sandy</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>SandyIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Sky</string>
|
<string>Sky</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Sky</string>
|
<string>Sky</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>SkyIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Snow</string>
|
<string>Snow</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Snow</string>
|
<string>Snow</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>SnowIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Starburst</string>
|
<string>Starburst</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Starburst</string>
|
<string>Starburst</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>StarburstIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Storm</string>
|
<string>Storm</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Storm</string>
|
<string>Storm</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>StormIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Vista</string>
|
<string>Vista</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Vista</string>
|
<string>Vista</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>VistaIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>name</key>
|
<key>name</key>
|
||||||
<string>Winter</string>
|
<string>Winter</string>
|
||||||
<key>imageName</key>
|
<key>imageName</key>
|
||||||
<string>Winter</string>
|
<string>Winter</string>
|
||||||
|
<key>iconName</key>
|
||||||
|
<string>WinterIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
BIN
AltStore/Resources/Icons.xcassets/Classic/App.imageset/App.png
vendored
Normal file
|
After Width: | Height: | Size: 50 KiB |
12
AltStore/Resources/Icons.xcassets/Classic/App.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "App.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
AltStore/Resources/Icons.xcassets/Classic/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Blue.imageset/Blue.png
vendored
Normal file
|
After Width: | Height: | Size: 38 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Blue.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Blue.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 313 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Dark.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Dark.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Dark.imageset/Dark.png
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Honeydew.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Honeydew.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Honeydew.imageset/Honeydew.png
vendored
Normal file
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 373 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Pride.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Pride.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Pride.imageset/Pride.png
vendored
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 719 KiB After Width: | Height: | Size: 719 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Sandy.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Sandy.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Sandy.imageset/Sandy.png
vendored
Normal file
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 352 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Sky.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Sky.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Sky.imageset/Sky.png
vendored
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Snow.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Snow.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Snow.imageset/Snow.png
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 453 KiB After Width: | Height: | Size: 453 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Starburst.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Starburst.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Starburst.imageset/Starburst.png
vendored
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 181 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Storm.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Storm.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Storm.imageset/Storm.png
vendored
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 464 KiB After Width: | Height: | Size: 464 KiB |
12
AltStore/Resources/Icons.xcassets/Modern/Vista.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Vista.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Vista.imageset/Vista.png
vendored
Normal file
|
After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
12
AltStore/Resources/Icons.xcassets/Modern/Winter.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Winter.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
AltStore/Resources/Icons.xcassets/Modern/Winter.imageset/Winter.png
vendored
Normal file
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
@@ -43,8 +43,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate
|
|||||||
if UserDefaults.standard.enableEMPforWireguard {
|
if UserDefaults.standard.enableEMPforWireguard {
|
||||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
PatreonAPI.shared.refreshPatreonAccount()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidEnterBackground(_ scene: UIScene)
|
func sceneDidEnterBackground(_ scene: UIScene)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="24506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24504"/>
|
||||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="f7H-EV-7Sx">
|
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="f7H-EV-7Sx">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="343" height="55"/>
|
<rect key="frame" x="0.0" y="0.0" width="343" height="55"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="SideStore" translatesAutoresizingMaskIntoConstraints="NO" id="pn6-Ic-MJm">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="SideStore" translatesAutoresizingMaskIntoConstraints="NO" id="pn6-Ic-MJm" userLabel="Icon View">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="55" height="55"/>
|
<rect key="frame" x="0.0" y="0.0" width="55" height="55"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="width" constant="55" id="7LH-UB-M1b"/>
|
<constraint firstAttribute="width" constant="55" id="7LH-UB-M1b"/>
|
||||||
@@ -31,36 +31,19 @@
|
|||||||
</constraints>
|
</constraints>
|
||||||
</imageView>
|
</imageView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="si2-MA-3RH">
|
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="si2-MA-3RH">
|
||||||
<rect key="frame" x="65" y="0.0" width="213" height="55"/>
|
<rect key="frame" x="65" y="0.0" width="278" height="55"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="hkS-oz-wiT">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="hkS-oz-wiT">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="100.5" height="55"/>
|
<rect key="frame" x="0.0" y="0.0" width="278" height="55"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="SideTeam" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="DTd-Yu-HXr">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="SideTeam" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="DTd-Yu-HXr" userLabel="Team name">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="100.5" height="21.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="278" height="21.5"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="18"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="18"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nPA-DN-RTG" userLabel=" ">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nPA-DN-RTG" userLabel=" ">
|
||||||
<rect key="frame" x="0.0" y="23.5" width="100.5" height="31.5"/>
|
<rect key="frame" x="0.0" y="23.5" width="278" height="31.5"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
|
||||||
<color key="textColor" white="1" alpha="0.65000000000000002" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="TFB-qo-cbh">
|
|
||||||
<rect key="frame" x="127" y="0.0" width="86" height="55"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Zpb-k3-y7l">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="86" height="50"/>
|
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="18"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="VOu-b8-uEL">
|
|
||||||
<rect key="frame" x="0.0" y="52" width="86" height="3"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||||
<color key="textColor" white="1" alpha="0.65000000000000002" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.65000000000000002" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
@@ -69,14 +52,6 @@
|
|||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Shane" translatesAutoresizingMaskIntoConstraints="NO" id="F6g-4g-Gr2">
|
|
||||||
<rect key="frame" x="288" y="0.0" width="55" height="55"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" constant="55" id="CaK-rR-Zjy"/>
|
|
||||||
<constraint firstAttribute="width" secondItem="F6g-4g-Gr2" secondAttribute="height" id="cCw-He-Yyc"/>
|
|
||||||
<constraint firstAttribute="width" secondItem="F6g-4g-Gr2" secondAttribute="height" id="geK-Xu-ybL"/>
|
|
||||||
</constraints>
|
|
||||||
</imageView>
|
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="55" id="Uiy-9X-WSO"/>
|
<constraint firstAttribute="height" constant="55" id="Uiy-9X-WSO"/>
|
||||||
@@ -149,7 +124,6 @@ Following us on social media allows us to give quick updates and spread the word
|
|||||||
<outlet property="instagramButton" destination="VdY-7Q-amF" id="5kj-9x-k4F"/>
|
<outlet property="instagramButton" destination="VdY-7Q-amF" id="5kj-9x-k4F"/>
|
||||||
<outlet property="rileyImageView" destination="pn6-Ic-MJm" id="60i-Q0-ojz"/>
|
<outlet property="rileyImageView" destination="pn6-Ic-MJm" id="60i-Q0-ojz"/>
|
||||||
<outlet property="rileyLabel" destination="DTd-Yu-HXr" id="O0y-JB-gWp"/>
|
<outlet property="rileyLabel" destination="DTd-Yu-HXr" id="O0y-JB-gWp"/>
|
||||||
<outlet property="shaneLabel" destination="Zpb-k3-y7l" id="aQN-6B-s5T"/>
|
|
||||||
<outlet property="supportButton" destination="yEi-L6-kQ8" id="Dzo-vd-SnD"/>
|
<outlet property="supportButton" destination="yEi-L6-kQ8" id="Dzo-vd-SnD"/>
|
||||||
<outlet property="textView" destination="FeG-e5-LJl" id="K0M-lF-I6u"/>
|
<outlet property="textView" destination="FeG-e5-LJl" id="K0M-lF-I6u"/>
|
||||||
<outlet property="twitterButton" destination="hov-Ce-LaM" id="gib-Lt-qtY"/>
|
<outlet property="twitterButton" destination="hov-Ce-LaM" id="gib-Lt-qtY"/>
|
||||||
@@ -158,8 +132,7 @@ Following us on social media allows us to give quick updates and spread the word
|
|||||||
</collectionReusableView>
|
</collectionReusableView>
|
||||||
</objects>
|
</objects>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="Shane" width="128" height="128"/>
|
<image name="SideStore" width="512" height="512"/>
|
||||||
<image name="SideStore" width="1024" height="1024"/>
|
|
||||||
<namedColor name="SettingsHighlighted">
|
<namedColor name="SettingsHighlighted">
|
||||||
<color red="0.38823529411764707" green="0.011764705882352941" blue="0.58823529411764708" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.38823529411764707" green="0.011764705882352941" blue="0.58823529411764708" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
|
|||||||
@@ -20,15 +20,17 @@ extension UIApplication
|
|||||||
|
|
||||||
private final class AltIcon: Decodable
|
private final class AltIcon: Decodable
|
||||||
{
|
{
|
||||||
static let defaultIconName: String = "AppIcon"
|
static let defaultName: String = "Original"
|
||||||
|
|
||||||
var name: String
|
var name: String
|
||||||
var imageName: String
|
var imageName: String
|
||||||
|
var iconName: String
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey
|
private enum CodingKeys: String, CodingKey
|
||||||
{
|
{
|
||||||
case name
|
case name
|
||||||
case imageName
|
case imageName
|
||||||
|
case iconName
|
||||||
}
|
}
|
||||||
|
|
||||||
required init(from decoder: Decoder) throws
|
required init(from decoder: Decoder) throws
|
||||||
@@ -36,6 +38,7 @@ private final class AltIcon: Decodable
|
|||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.name = try container.decode(String.self, forKey: .name)
|
self.name = try container.decode(String.self, forKey: .name)
|
||||||
self.imageName = try container.decode(String.self, forKey: .imageName)
|
self.imageName = try container.decode(String.self, forKey: .imageName)
|
||||||
|
self.iconName = try container.decode(String.self, forKey: .iconName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,20 +149,14 @@ private extension AltAppIconsViewController
|
|||||||
config.textProperties.font = font
|
config.textProperties.font = font
|
||||||
config.textProperties.color = .label
|
config.textProperties.color = .label
|
||||||
|
|
||||||
// we have to do this hardcodded name hack for .appiconset
|
let image = UIImage(named: icon.imageName)
|
||||||
// else one can supply the artifacts via .imageset
|
|
||||||
let image: UIImage? =
|
|
||||||
UIImage(named: "\(icon.imageName)76x76@2x~ipad") ??
|
|
||||||
UIImage(named: "\(icon.imageName)60x60@2x") ??
|
|
||||||
UIImage(named: icon.imageName)
|
|
||||||
|
|
||||||
config.image = image
|
config.image = image
|
||||||
config.imageProperties.maximumSize = CGSize(width: imageWidth, height: imageWidth)
|
config.imageProperties.maximumSize = CGSize(width: imageWidth, height: imageWidth)
|
||||||
config.imageProperties.cornerRadius = imageWidth / 5.0 // Copied from AppIconImageView
|
config.imageProperties.cornerRadius = imageWidth / 5.0 // Copied from AppIconImageView
|
||||||
|
|
||||||
cell.contentConfiguration = config
|
cell.contentConfiguration = config
|
||||||
|
|
||||||
if UIApplication.shared.alternateIconName == icon.imageName || (UIApplication.shared.alternateIconName == nil && icon.imageName == AltIcon.defaultIconName)
|
if UIApplication.shared.alternateIconName == icon.iconName || (UIApplication.shared.alternateIconName == nil && icon.name == AltIcon.defaultName)
|
||||||
{
|
{
|
||||||
cell.accessories = [.checkmark(options: .init(tintColor: .white))]
|
cell.accessories = [.checkmark(options: .init(tintColor: .white))]
|
||||||
}
|
}
|
||||||
@@ -167,7 +164,7 @@ private extension AltAppIconsViewController
|
|||||||
{
|
{
|
||||||
cell.accessories = []
|
cell.accessories = []
|
||||||
}
|
}
|
||||||
|
|
||||||
var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell()
|
var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell()
|
||||||
backgroundConfiguration.backgroundColorTransformer = UIConfigurationColorTransformer { [weak cell] c in
|
backgroundConfiguration.backgroundColorTransformer = UIConfigurationColorTransformer { [weak cell] c in
|
||||||
if let state = cell?.configurationState, state.isHighlighted
|
if let state = cell?.configurationState, state.isHighlighted
|
||||||
@@ -199,14 +196,14 @@ extension AltAppIconsViewController
|
|||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||||
{
|
{
|
||||||
let icon = self.dataSource.item(at: indexPath)
|
let icon = self.dataSource.item(at: indexPath)
|
||||||
guard UIApplication.shared.alternateIconName != icon.imageName else { return }
|
guard UIApplication.shared.alternateIconName != icon.iconName else { return }
|
||||||
|
|
||||||
// Deselect previous icon + select new icon
|
// Deselect previous icon + select new icon
|
||||||
collectionView.reloadData()
|
collectionView.reloadData()
|
||||||
|
|
||||||
// If assigning primary icon, pass "nil" as alternate icon name.
|
// If assigning primary icon, pass "nil" as alternate icon name.
|
||||||
let imageName = (icon.imageName == "AppIcon") ? nil : icon.imageName
|
let iconName = (icon.name == AltIcon.defaultName) ? nil : icon.iconName
|
||||||
UIApplication.shared.setAlternateIconName(imageName) { error in
|
UIApplication.shared.setAlternateIconName(iconName) { error in
|
||||||
if let error
|
if let error
|
||||||
{
|
{
|
||||||
let alertController = UIAlertController(title: NSLocalizedString("Unable to Change App Icon", comment: ""),
|
let alertController = UIAlertController(title: NSLocalizedString("Unable to Change App Icon", comment: ""),
|
||||||
|
|||||||
@@ -42,63 +42,56 @@ struct OperationsLoggingControlView: View {
|
|||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
CustomToggle("2. VerifyAppPledge", isOn: Binding(
|
CustomToggle("2. DownloadApp", isOn: Binding(
|
||||||
get: { self.viewModel.getFromDatabase(for: VerifyAppPledgeOperation.self) },
|
|
||||||
set: { value in
|
|
||||||
self.viewModel.updateDatabase(for: VerifyAppPledgeOperation.self, value: value)
|
|
||||||
}
|
|
||||||
))
|
|
||||||
|
|
||||||
CustomToggle("3. DownloadApp", isOn: Binding(
|
|
||||||
get: { self.viewModel.getFromDatabase(for: DownloadAppOperation.self) },
|
get: { self.viewModel.getFromDatabase(for: DownloadAppOperation.self) },
|
||||||
set: { value in
|
set: { value in
|
||||||
self.viewModel.updateDatabase(for: DownloadAppOperation.self, value: value)
|
self.viewModel.updateDatabase(for: DownloadAppOperation.self, value: value)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
CustomToggle("4. VerifyApp", isOn: Binding(
|
CustomToggle("3. VerifyApp", isOn: Binding(
|
||||||
get: { self.viewModel.getFromDatabase(for: VerifyAppOperation.self) },
|
get: { self.viewModel.getFromDatabase(for: VerifyAppOperation.self) },
|
||||||
set: { value in
|
set: { value in
|
||||||
self.viewModel.updateDatabase(for: VerifyAppOperation.self, value: value)
|
self.viewModel.updateDatabase(for: VerifyAppOperation.self, value: value)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
CustomToggle("5. RemoveAppExtensions", isOn: Binding(
|
CustomToggle("4. RemoveAppExtensions", isOn: Binding(
|
||||||
get: { self.viewModel.getFromDatabase(for: RemoveAppExtensionsOperation.self) },
|
get: { self.viewModel.getFromDatabase(for: RemoveAppExtensionsOperation.self) },
|
||||||
set: { value in
|
set: { value in
|
||||||
self.viewModel.updateDatabase(for: RemoveAppExtensionsOperation.self, value: value)
|
self.viewModel.updateDatabase(for: RemoveAppExtensionsOperation.self, value: value)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
CustomToggle("6. FetchAnisetteData", isOn: Binding(
|
CustomToggle("5. FetchAnisetteData", isOn: Binding(
|
||||||
get: { self.viewModel.getFromDatabase(for: FetchAnisetteDataOperation.self) },
|
get: { self.viewModel.getFromDatabase(for: FetchAnisetteDataOperation.self) },
|
||||||
set: { value in
|
set: { value in
|
||||||
self.viewModel.updateDatabase(for: FetchAnisetteDataOperation.self, value: value)
|
self.viewModel.updateDatabase(for: FetchAnisetteDataOperation.self, value: value)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
CustomToggle("7. FetchProvisioningProfiles(I)", isOn: Binding(
|
CustomToggle("6. FetchProvisioningProfiles(I)", isOn: Binding(
|
||||||
get: { self.viewModel.getFromDatabase(for: FetchProvisioningProfilesInstallOperation.self) },
|
get: { self.viewModel.getFromDatabase(for: FetchProvisioningProfilesInstallOperation.self) },
|
||||||
set: { value in
|
set: { value in
|
||||||
self.viewModel.updateDatabase(for: FetchProvisioningProfilesInstallOperation.self, value: value)
|
self.viewModel.updateDatabase(for: FetchProvisioningProfilesInstallOperation.self, value: value)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
CustomToggle("8. ResignApp", isOn: Binding(
|
CustomToggle("7. ResignApp", isOn: Binding(
|
||||||
get: { self.viewModel.getFromDatabase(for: ResignAppOperation.self) },
|
get: { self.viewModel.getFromDatabase(for: ResignAppOperation.self) },
|
||||||
set: { value in
|
set: { value in
|
||||||
self.viewModel.updateDatabase(for: ResignAppOperation.self, value: value)
|
self.viewModel.updateDatabase(for: ResignAppOperation.self, value: value)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
CustomToggle("9. SendApp", isOn: Binding(
|
CustomToggle("8. SendApp", isOn: Binding(
|
||||||
get: { self.viewModel.getFromDatabase(for: SendAppOperation.self) },
|
get: { self.viewModel.getFromDatabase(for: SendAppOperation.self) },
|
||||||
set: { value in
|
set: { value in
|
||||||
self.viewModel.updateDatabase(for: SendAppOperation.self, value: value)
|
self.viewModel.updateDatabase(for: SendAppOperation.self, value: value)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
CustomToggle("10. InstallApp", isOn: Binding(
|
CustomToggle("9. InstallApp", isOn: Binding(
|
||||||
get: { self.viewModel.getFromDatabase(for: InstallAppOperation.self) },
|
get: { self.viewModel.getFromDatabase(for: InstallAppOperation.self) },
|
||||||
set: { value in
|
set: { value in
|
||||||
self.viewModel.updateDatabase(for: InstallAppOperation.self, value: value)
|
self.viewModel.updateDatabase(for: InstallAppOperation.self, value: value)
|
||||||
@@ -203,16 +196,6 @@ struct OperationsLoggingControlView: View {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomSection(header: Text("Patrons Operations"))
|
|
||||||
{
|
|
||||||
CustomToggle("1. UpdatePatrons", isOn: Binding(
|
|
||||||
get: { self.viewModel.getFromDatabase(for: UpdatePatronsOperation.self) },
|
|
||||||
set: { value in
|
|
||||||
self.viewModel.updateDatabase(for: UpdatePatronsOperation.self, value: value)
|
|
||||||
}
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
CustomSection(header: Text("Cache Operations"))
|
CustomSection(header: Text("Cache Operations"))
|
||||||
{
|
{
|
||||||
CustomToggle("1. ClearAppCache", isOn: Binding(
|
CustomToggle("1. ClearAppCache", isOn: Binding(
|
||||||
|
|||||||
@@ -13,60 +13,18 @@ final class PatronCollectionViewCell: UICollectionViewCell
|
|||||||
@IBOutlet var textLabel: UILabel!
|
@IBOutlet var textLabel: UILabel!
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PatronsHeaderView: UICollectionReusableView
|
|
||||||
{
|
|
||||||
let textLabel = UILabel()
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.textLabel.font = UIFont.boldSystemFont(ofSize: 17)
|
|
||||||
self.textLabel.textColor = .white
|
|
||||||
self.addSubview(self.textLabel, pinningEdgesWith: UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20))
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class PatronsFooterView: UICollectionReusableView
|
|
||||||
{
|
|
||||||
let button = UIButton(type: .system)
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.button.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.button.activityIndicatorView.style = .medium
|
|
||||||
self.button.activityIndicatorView.color = .white
|
|
||||||
self.button.titleLabel?.textColor = .white
|
|
||||||
self.addSubview(self.button)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([self.button.centerXAnchor.constraint(equalTo: self.centerXAnchor),
|
|
||||||
self.button.centerYAnchor.constraint(equalTo: self.centerYAnchor)])
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class AboutPatreonHeaderView: UICollectionReusableView
|
final class AboutPatreonHeaderView: UICollectionReusableView
|
||||||
{
|
{
|
||||||
@IBOutlet var supportButton: UIButton!
|
@IBOutlet var supportButton: UIButton!
|
||||||
@IBOutlet var twitterButton: UIButton!
|
@IBOutlet var twitterButton: UIButton!
|
||||||
@IBOutlet var instagramButton: UIButton!
|
@IBOutlet var instagramButton: UIButton!
|
||||||
@IBOutlet var accountButton: UIButton!
|
|
||||||
@IBOutlet var textView: UITextView!
|
@IBOutlet var textView: UITextView!
|
||||||
|
|
||||||
@IBOutlet private var rileyLabel: UILabel!
|
@IBOutlet private var rileyLabel: UILabel!
|
||||||
@IBOutlet private var shaneLabel: UILabel!
|
@IBOutlet private var shaneLabel: UILabel!
|
||||||
|
|
||||||
@IBOutlet private var rileyImageView: UIImageView!
|
@IBOutlet private var rileyImageView: UIImageView!
|
||||||
@IBOutlet private var shaneImageView: UIImageView!
|
|
||||||
|
|
||||||
override func awakeFromNib()
|
override func awakeFromNib()
|
||||||
{
|
{
|
||||||
@@ -76,13 +34,13 @@ final class AboutPatreonHeaderView: UICollectionReusableView
|
|||||||
self.textView.layer.cornerRadius = 20
|
self.textView.layer.cornerRadius = 20
|
||||||
self.textView.textContainer.lineFragmentPadding = 0
|
self.textView.textContainer.lineFragmentPadding = 0
|
||||||
|
|
||||||
for imageView in [self.rileyImageView, self.shaneImageView].compactMap({$0})
|
for imageView in [self.rileyImageView].compactMap({$0})
|
||||||
{
|
{
|
||||||
imageView.clipsToBounds = true
|
imageView.clipsToBounds = true
|
||||||
imageView.layer.cornerRadius = imageView.bounds.midY
|
imageView.layer.cornerRadius = imageView.bounds.midY
|
||||||
}
|
}
|
||||||
|
|
||||||
for button in [self.supportButton, self.accountButton, self.twitterButton, self.instagramButton].compactMap({$0})
|
for button in [self.supportButton, self.twitterButton, self.instagramButton].compactMap({$0})
|
||||||
{
|
{
|
||||||
button.clipsToBounds = true
|
button.clipsToBounds = true
|
||||||
button.layer.cornerRadius = 16
|
button.layer.cornerRadius = 16
|
||||||
|
|||||||
@@ -13,26 +13,10 @@ import AuthenticationServices
|
|||||||
import AltStoreCore
|
import AltStoreCore
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
extension PatreonViewController
|
|
||||||
{
|
|
||||||
private enum Section: Int, CaseIterable
|
|
||||||
{
|
|
||||||
case about
|
|
||||||
case patrons
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class PatreonViewController: UICollectionViewController
|
final class PatreonViewController: UICollectionViewController
|
||||||
{
|
{
|
||||||
// private lazy var dataSource = self.makeDataSource()
|
|
||||||
private lazy var patronsDataSource = self.makePatronsDataSource()
|
|
||||||
|
|
||||||
private var prototypeAboutHeader: AboutPatreonHeaderView!
|
private var prototypeAboutHeader: AboutPatreonHeaderView!
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
||||||
return .lightContent
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
override func viewDidLoad()
|
||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
@@ -40,24 +24,14 @@ final class PatreonViewController: UICollectionViewController
|
|||||||
let aboutHeaderNib = UINib(nibName: "AboutPatreonHeaderView", bundle: nil)
|
let aboutHeaderNib = UINib(nibName: "AboutPatreonHeaderView", bundle: nil)
|
||||||
self.prototypeAboutHeader = aboutHeaderNib.instantiate(withOwner: nil, options: nil)[0] as? AboutPatreonHeaderView
|
self.prototypeAboutHeader = aboutHeaderNib.instantiate(withOwner: nil, options: nil)[0] as? AboutPatreonHeaderView
|
||||||
|
|
||||||
// self.collectionView.dataSource = self.dataSource
|
|
||||||
|
|
||||||
self.collectionView.register(aboutHeaderNib, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "AboutHeader")
|
self.collectionView.register(aboutHeaderNib, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "AboutHeader")
|
||||||
self.collectionView.register(PatronsHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "PatronsHeader")
|
self.collectionView.reloadData()
|
||||||
self.collectionView.register(PatronsFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "PatronsFooter")
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(PatreonViewController.didUpdatePatrons(_:)), name: AppManager.didUpdatePatronsNotification, object: nil)
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
override func viewWillAppear(_ animated: Bool)
|
||||||
{
|
{
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
self.collectionView.reloadData()
|
||||||
//self.fetchPatrons()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews()
|
override func viewDidLayoutSubviews()
|
||||||
@@ -76,103 +50,17 @@ final class PatreonViewController: UICollectionViewController
|
|||||||
|
|
||||||
private extension PatreonViewController
|
private extension PatreonViewController
|
||||||
{
|
{
|
||||||
func makeDataSource() -> RSTCompositeCollectionViewDataSource<ManagedPatron>
|
|
||||||
{
|
|
||||||
let aboutDataSource = RSTDynamicCollectionViewDataSource<ManagedPatron>()
|
|
||||||
aboutDataSource.numberOfSectionsHandler = { 1 }
|
|
||||||
aboutDataSource.numberOfItemsHandler = { _ in 0 }
|
|
||||||
|
|
||||||
let dataSource = RSTCompositeCollectionViewDataSource<ManagedPatron>(dataSources: [aboutDataSource, self.patronsDataSource])
|
|
||||||
dataSource.proxy = self
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makePatronsDataSource() -> RSTFetchedResultsCollectionViewDataSource<ManagedPatron>
|
|
||||||
{
|
|
||||||
let fetchRequest: NSFetchRequest<ManagedPatron> = ManagedPatron.fetchRequest()
|
|
||||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(ManagedPatron.name), ascending: true, selector: #selector(NSString.caseInsensitiveCompare(_:)))]
|
|
||||||
|
|
||||||
let patronsDataSource = RSTFetchedResultsCollectionViewDataSource<ManagedPatron>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
|
||||||
patronsDataSource.cellConfigurationHandler = { (cell, patron, indexPath) in
|
|
||||||
let cell = cell as! PatronCollectionViewCell
|
|
||||||
cell.textLabel.text = patron.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return patronsDataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
self.collectionView.reloadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepare(_ headerView: AboutPatreonHeaderView)
|
func prepare(_ headerView: AboutPatreonHeaderView)
|
||||||
{
|
{
|
||||||
headerView.layoutMargins = self.view.layoutMargins
|
headerView.layoutMargins = self.view.layoutMargins
|
||||||
|
|
||||||
headerView.supportButton.addTarget(self, action: #selector(PatreonViewController.openPatreonURL(_:)), for: .primaryActionTriggered)
|
headerView.supportButton.addTarget(self, action: #selector(PatreonViewController.openPatreonURL(_:)), for: .primaryActionTriggered)
|
||||||
headerView.twitterButton.addTarget(self, action: #selector(PatreonViewController.openTwitterURL(_:)), for: .primaryActionTriggered)
|
headerView.twitterButton.addTarget(self, action: #selector(PatreonViewController.openTwitterURL(_:)), for: .primaryActionTriggered)
|
||||||
headerView.instagramButton.addTarget(self, action: #selector(PatreonViewController.openInstagramURL(_:)), for: .primaryActionTriggered)
|
headerView.instagramButton.addTarget(self, action: #selector(PatreonViewController.openInstagramURL(_:)), for: .primaryActionTriggered)
|
||||||
|
|
||||||
let defaultSupportButtonTitle = NSLocalizedString("Become a patron", comment: "")
|
|
||||||
let isPatronSupportButtonTitle = NSLocalizedString("View Patreon", comment: "")
|
|
||||||
|
|
||||||
let defaultText = NSLocalizedString("""
|
|
||||||
Hello, thank you for using SideStore!
|
|
||||||
|
|
||||||
If you would subscribe to the patreon that would support us and make sure we can continue developing SideStore for you.
|
|
||||||
|
|
||||||
-SideTeam
|
|
||||||
""", comment: "")
|
|
||||||
|
|
||||||
let isPatronText = NSLocalizedString("""
|
|
||||||
Hey ,
|
|
||||||
|
|
||||||
You’re the best. Your account was linked successfully, so you now have access to any beta versions of our apps. You can find them all in the Browse tab.
|
|
||||||
|
|
||||||
Thanks for all of your support. Enjoy!
|
|
||||||
- SideTeam
|
|
||||||
""", comment: "")
|
|
||||||
|
|
||||||
if let account = DatabaseManager.shared.patreonAccount(), PatreonAPI.shared.isAuthenticated
|
|
||||||
{
|
|
||||||
headerView.accountButton.addTarget(self, action: #selector(PatreonViewController.signOut(_:)), for: .primaryActionTriggered)
|
|
||||||
headerView.accountButton.setTitle(String(format: NSLocalizedString("Unlink %@", comment: ""), account.name), for: .normal)
|
|
||||||
|
|
||||||
if account.isAltStorePatron
|
|
||||||
{
|
|
||||||
headerView.supportButton.setTitle(isPatronSupportButtonTitle, for: .normal)
|
|
||||||
|
|
||||||
let font = UIFont.systemFont(ofSize: 16)
|
|
||||||
|
|
||||||
let attributedText = NSMutableAttributedString(string: isPatronText, attributes: [.font: font,
|
|
||||||
.foregroundColor: UIColor.white])
|
|
||||||
|
|
||||||
let boldedName = NSAttributedString(string: account.firstName ?? account.name,
|
|
||||||
attributes: [.font: UIFont.boldSystemFont(ofSize: font.pointSize),
|
|
||||||
.foregroundColor: UIColor.white])
|
|
||||||
attributedText.insert(boldedName, at: 4)
|
|
||||||
|
|
||||||
headerView.textView.attributedText = attributedText
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
headerView.supportButton.setTitle(defaultSupportButtonTitle, for: .normal)
|
|
||||||
headerView.textView.text = defaultText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension PatreonViewController
|
private extension PatreonViewController
|
||||||
{
|
{
|
||||||
@objc func fetchPatrons()
|
|
||||||
{
|
|
||||||
AppManager.shared.updatePatronsIfNeeded()
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func openPatreonURL(_ sender: UIButton)
|
@objc func openPatreonURL(_ sender: UIButton)
|
||||||
{
|
{
|
||||||
let patreonURL = URL(string: "https://www.patreon.com/SideStoreIO")!
|
let patreonURL = URL(string: "https://www.patreon.com/SideStoreIO")!
|
||||||
@@ -199,148 +87,15 @@ private extension PatreonViewController
|
|||||||
safariViewController.preferredControlTintColor = self.view.tintColor
|
safariViewController.preferredControlTintColor = self.view.tintColor
|
||||||
self.present(safariViewController, animated: true, completion: nil)
|
self.present(safariViewController, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func authenticate(_ sender: UIBarButtonItem)
|
|
||||||
{
|
|
||||||
PatreonAPI.shared.authenticate(presentingViewController: self) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let account = try result.get()
|
|
||||||
try account.managedObjectContext?.save()
|
|
||||||
|
|
||||||
// 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 is CancellationError
|
|
||||||
{
|
|
||||||
// Clear in-app browser cache in case they are signed into wrong account.
|
|
||||||
Task<Void, Never>.detached {
|
|
||||||
await PatreonAPI.shared.deleteAuthCookies()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let toastView = ToastView(error: error)
|
|
||||||
toastView.show(in: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func signOut(_ sender: UIBarButtonItem)
|
|
||||||
{
|
|
||||||
func signOut()
|
|
||||||
{
|
|
||||||
PatreonAPI.shared.signOut { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try result.get()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let toastView = ToastView(error: error)
|
|
||||||
toastView.show(in: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#if MARKETPLACE
|
|
||||||
let message = NSLocalizedString("You will no longer be able to install or update any apps that require pledges.", comment: "")
|
|
||||||
#else
|
|
||||||
let message = NSLocalizedString("You will no longer be able to install or refresh any apps that require pledges.", comment: "")
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to unlink your Patreon account?", comment: ""), message: message, preferredStyle: .actionSheet)
|
|
||||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Unlink Patreon Account", comment: ""), style: .destructive) { _ in signOut() })
|
|
||||||
alertController.addAction(.cancel)
|
|
||||||
self.present(alertController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func didUpdatePatrons(_ notification: Notification)
|
|
||||||
{
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
// Wait short delay before reloading or else footer won't properly update if it's already visible 🤷♂️
|
|
||||||
self.collectionView.reloadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PatreonViewController
|
extension PatreonViewController
|
||||||
{
|
{
|
||||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
||||||
{
|
{
|
||||||
let section = Section.allCases[indexPath.section]
|
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "AboutHeader", for: indexPath) as! AboutPatreonHeaderView
|
||||||
switch section
|
self.prepare(headerView)
|
||||||
{
|
return headerView
|
||||||
case .about:
|
|
||||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "AboutHeader", for: indexPath) as! AboutPatreonHeaderView
|
|
||||||
self.prepare(headerView)
|
|
||||||
return headerView
|
|
||||||
|
|
||||||
case .patrons:
|
|
||||||
if kind == UICollectionView.elementKindSectionHeader
|
|
||||||
{
|
|
||||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "PatronsHeader", for: indexPath) as! PatronsHeaderView
|
|
||||||
headerView.textLabel.text = NSLocalizedString("Special thanks to...", comment: "")
|
|
||||||
return headerView
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "PatronsFooter", for: indexPath) as! PatronsFooterView
|
|
||||||
footerView.button.isIndicatingActivity = false
|
|
||||||
footerView.button.isHidden = false
|
|
||||||
//footerView.button.addTarget(self, action: #selector(PatreonViewController.fetchPatrons), for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
switch AppManager.shared.updatePatronsResult
|
|
||||||
{
|
|
||||||
case .none: footerView.button.isIndicatingActivity = true
|
|
||||||
case .success?: footerView.button.isHidden = true
|
|
||||||
case .failure?:
|
|
||||||
// In simulator debug builds only enable debug mode flag
|
|
||||||
#if DEBUG && targetEnvironment(simulator)
|
|
||||||
let debug = true
|
|
||||||
#else
|
|
||||||
let debug = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if self.patronsDataSource.itemCount == 0 || debug
|
|
||||||
{
|
|
||||||
// Only show error message if there aren't any cached Patrons (or if this is a debug build).
|
|
||||||
|
|
||||||
footerView.button.isHidden = false
|
|
||||||
footerView.button.setTitle(NSLocalizedString("Error Loading Patrons", comment: ""), for: .normal)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
footerView.button.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return footerView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,31 +103,11 @@ extension PatreonViewController: UICollectionViewDelegateFlowLayout
|
|||||||
{
|
{
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
|
||||||
{
|
{
|
||||||
let section = Section.allCases[section]
|
let widthConstraint = self.prototypeAboutHeader.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||||
switch section
|
NSLayoutConstraint.activate([widthConstraint])
|
||||||
{
|
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
|
||||||
case .about:
|
|
||||||
let widthConstraint = self.prototypeAboutHeader.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
self.prepare(self.prototypeAboutHeader)
|
||||||
NSLayoutConstraint.activate([widthConstraint])
|
return self.prototypeAboutHeader.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||||
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
|
|
||||||
|
|
||||||
self.prepare(self.prototypeAboutHeader)
|
|
||||||
|
|
||||||
let size = self.prototypeAboutHeader.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
||||||
return size
|
|
||||||
|
|
||||||
case .patrons:
|
|
||||||
return CGSize(width: 0, height: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
|
|
||||||
{
|
|
||||||
let section = Section.allCases[section]
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .about: return .zero
|
|
||||||
case .patrons: return CGSize(width: 320, height: 44)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<color key="separatorColor" white="1" alpha="0.25" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="separatorColor" white="1" alpha="0.25" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<stackView key="tableFooterView" opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalCentering" alignment="center" spacing="15" id="48g-cT-stR">
|
<stackView key="tableFooterView" opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalCentering" alignment="center" spacing="15" id="48g-cT-stR">
|
||||||
<rect key="frame" x="0.0" y="2431.3333301544189" width="402" height="125"/>
|
<rect key="frame" x="0.0" y="2442.6666641235352" width="402" height="125"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="900" text="Follow SideStore for updates" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="XFa-MY-7cV">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="900" text="Follow SideStore for updates" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="XFa-MY-7cV">
|
||||||
@@ -500,13 +500,13 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="View Error Log" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vH6-7i-tCE">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="View Error Log" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vH6-7i-tCE">
|
||||||
<rect key="frame" x="30" y="15.333333333333334" width="119" height="20.333333333333329"/>
|
<rect key="frame" x="30" y="15.333333333333334" width="119" height="20.333333333333329"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="AeT-qF-bwB">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="AeT-qF-bwB">
|
||||||
<rect key="frame" x="356.33333333333331" y="14.333333333333334" width="15.666666666666686" height="22.333333333333329"/>
|
<rect key="frame" x="356.33333333333331" y="14.333333333333334" width="15.666666666666686" height="22.333333333333329"/>
|
||||||
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
@@ -537,7 +537,7 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Clear Cache…" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="j4e-Mz-DlL">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Clear Cache…" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="j4e-Mz-DlL">
|
||||||
<rect key="frame" x="29.999999999999993" y="15.333333333333334" width="114.33333333333331" height="20.333333333333329"/>
|
<rect key="frame" x="29.999999999999993" y="15.333333333333334" width="114.33333333333331" height="20.333333333333329"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
@@ -569,22 +569,22 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Developers" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hRA-OK-Vjw">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Developers" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hRA-OK-Vjw">
|
||||||
<rect key="frame" x="30" y="15.333333333333334" width="86" height="20.333333333333329"/>
|
<rect key="frame" x="30" y="15.333333333333334" width="86" height="20.333333333333329"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="lx9-35-OSk">
|
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="lx9-35-OSk">
|
||||||
<rect key="frame" x="217" y="15.333333333333334" width="155" height="20.333333333333329"/>
|
<rect key="frame" x="217" y="15.333333333333336" width="155" height="20.333333333333329"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="SideStore Team" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JAA-iZ-VGb">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="SideStore Team" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JAA-iZ-VGb">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="125.33333333333333" height="20.333333333333332"/>
|
<rect key="frame" x="0.0" y="0.0" width="125.33333333333333" height="20.333333333333332"/>
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Mmj-3V-fTb">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Mmj-3V-fTb">
|
||||||
<rect key="frame" x="139.33333333333331" y="-1" width="15.666666666666657" height="22.333333333333332"/>
|
<rect key="frame" x="139.33333333333331" y="-1" width="15.666666666666657" height="22.333333333333332"/>
|
||||||
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
@@ -614,23 +614,23 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="UI Designer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oqY-wY-1Vf">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="UI Designer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oqY-wY-1Vf">
|
||||||
<rect key="frame" x="30" y="15.333333333333334" width="89" height="20.333333333333329"/>
|
<rect key="frame" x="30" y="15.333333333333334" width="89" height="20.333333333333329"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="gUq-6Q-t5X">
|
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="gUq-6Q-t5X">
|
||||||
<rect key="frame" x="227.33333333333337" y="15.333333333333334" width="144.66666666666663" height="20.333333333333329"/>
|
<rect key="frame" x="227.33333333333337" y="15.333333333333336" width="144.66666666666663" height="20.333333333333329"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Fabian (thdev)" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ylE-VL-7Fq">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Fabian (thdev)" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ylE-VL-7Fq">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="115" height="20.333333333333332"/>
|
<rect key="frame" x="0.0" y="0.0" width="115" height="20.333333333333332"/>
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="e3L-vR-Jae">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="e3L-vR-Jae">
|
||||||
<rect key="frame" x="128.99999999999997" y="-1" width="15.666666666666657" height="22.333333333333332"/>
|
<rect key="frame" x="129" y="-1" width="15.666666666666657" height="22.333333333333332"/>
|
||||||
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
@@ -659,23 +659,23 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Asset Designer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fGU-Fp-XgM">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Asset Designer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fGU-Fp-XgM">
|
||||||
<rect key="frame" x="29.999999999999993" y="15.333333333333334" width="115.33333333333331" height="20.333333333333329"/>
|
<rect key="frame" x="29.999999999999993" y="15.333333333333334" width="115.33333333333331" height="20.333333333333329"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="R8B-DW-7mY">
|
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="R8B-DW-7mY">
|
||||||
<rect key="frame" x="235.33333333333337" y="15.333333333333334" width="136.66666666666663" height="20.333333333333329"/>
|
<rect key="frame" x="235.33333333333337" y="15.333333333333336" width="136.66666666666663" height="20.333333333333329"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Chris (LitRitt)" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hId-3P-41T">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Chris (LitRitt)" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hId-3P-41T">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="107" height="20.333333333333332"/>
|
<rect key="frame" x="0.0" y="0.0" width="107" height="20.333333333333332"/>
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="baq-cE-fMY">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="baq-cE-fMY">
|
||||||
<rect key="frame" x="120.99999999999999" y="-1" width="15.666666666666671" height="22.333333333333332"/>
|
<rect key="frame" x="120.99999999999997" y="-1" width="15.666666666666657" height="22.333333333333332"/>
|
||||||
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
@@ -704,13 +704,13 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Licenses" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="D6b-cd-pVK">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Licenses" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="D6b-cd-pVK">
|
||||||
<rect key="frame" x="30" y="15.333333333333334" width="67.333333333333329" height="20.333333333333329"/>
|
<rect key="frame" x="30" y="15.333333333333334" width="67.333333333333329" height="20.333333333333329"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="s79-GQ-khr">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="s79-GQ-khr">
|
||||||
<rect key="frame" x="356.33333333333331" y="14.333333333333334" width="15.666666666666686" height="22.333333333333329"/>
|
<rect key="frame" x="356.33333333333331" y="14.333333333333334" width="15.666666666666686" height="22.333333333333329"/>
|
||||||
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
<imageReference key="image" image="chevron.right" catalog="system" symbolScale="large"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
@@ -1062,7 +1062,7 @@
|
|||||||
<tableViewSection id="ZhW-yK-wdJ">
|
<tableViewSection id="ZhW-yK-wdJ">
|
||||||
<cells>
|
<cells>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="qjD-UK-myl" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="qjD-UK-myl" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="1714.6666641235352" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="1701.6666622161865" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="qjD-UK-myl" id="bcu-KT-Xee">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="qjD-UK-myl" id="bcu-KT-Xee">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1090,7 +1090,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="dNh-fp-vBs" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="dNh-fp-vBs" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="1765.6666641235352" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="1752.6666622161865" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="dNh-fp-vBs" id="Meb-tV-6br">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="dNh-fp-vBs" id="Meb-tV-6br">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1118,7 +1118,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="Y6h-Bo-yec" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="Y6h-Bo-yec" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="1816.6666641235352" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="1803.6666622161865" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Y6h-Bo-yec" id="4Jf-I6-v7z">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Y6h-Bo-yec" id="4Jf-I6-v7z">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1146,7 +1146,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="dLk-d6-X4T" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="dLk-d6-X4T" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="1867.6666641235352" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="1854.6666622161865" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="dLk-d6-X4T" id="Okl-3m-rde">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="dLk-d6-X4T" id="Okl-3m-rde">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1178,7 +1178,7 @@
|
|||||||
<tableViewSection headerTitle="" id="lLQ-K0-XSb">
|
<tableViewSection headerTitle="" id="lLQ-K0-XSb">
|
||||||
<cells>
|
<cells>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="daQ-mk-yqC" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="daQ-mk-yqC" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="1954.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="1941.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="daQ-mk-yqC" id="ZkW-ZR-twy">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="daQ-mk-yqC" id="ZkW-ZR-twy">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1213,7 +1213,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="hRP-jU-2hd" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="hRP-jU-2hd" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2005.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="1992.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="hRP-jU-2hd" id="JhE-O4-pRg">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="hRP-jU-2hd" id="JhE-O4-pRg">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1248,7 +1248,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="JoN-Aj-XtZ" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="JoN-Aj-XtZ" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2056.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="2043.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="JoN-Aj-XtZ" id="v8Q-VQ-Q1h">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="JoN-Aj-XtZ" id="v8Q-VQ-Q1h">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1283,7 +1283,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="QOO-bO-4M5" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="QOO-bO-4M5" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2107.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="2094.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="QOO-bO-4M5" id="VTT-z5-C89">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="QOO-bO-4M5" id="VTT-z5-C89">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1311,7 +1311,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="ToB-H7-2lR" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="ToB-H7-2lR" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2158.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="2145.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ToB-H7-2lR" id="Acf-xV-Isn">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ToB-H7-2lR" id="Acf-xV-Isn">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1339,7 +1339,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="xtI-eU-LFb" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="xtI-eU-LFb" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2209.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="2196.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="xtI-eU-LFb" id="bc9-41-6mE">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="xtI-eU-LFb" id="bc9-41-6mE">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1373,7 +1373,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="pvu-IV-Poa" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="pvu-IV-Poa" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2260.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="2247.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="pvu-IV-Poa" id="zck-an-8cK">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="pvu-IV-Poa" id="zck-an-8cK">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1408,7 +1408,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="9By-QW-Jw9" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="9By-QW-Jw9" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2311.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="2298.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="9By-QW-Jw9" id="Dzq-gE-zyT">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="9By-QW-Jw9" id="Dzq-gE-zyT">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1443,7 +1443,7 @@
|
|||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="LzP-Qb-bmC" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="51" id="LzP-Qb-bmC" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2362.3333301544189" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="2349.3333282470703" width="402" height="51"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="LzP-Qb-bmC" id="3rE-h0-8kb">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="LzP-Qb-bmC" id="3rE-h0-8kb">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="402" height="51"/>
|
||||||
@@ -1768,33 +1768,7 @@ Settings by i cons from the Noun Project</string>
|
|||||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||||
<inset key="sectionInset" minX="20" minY="8" maxX="20" maxY="0.0"/>
|
<inset key="sectionInset" minX="20" minY="8" maxX="20" maxY="0.0"/>
|
||||||
</collectionViewFlowLayout>
|
</collectionViewFlowLayout>
|
||||||
<cells>
|
<cells/>
|
||||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="T6v-Rq-ntX" customClass="PatronCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="20" y="8" width="157" height="20"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="157" height="20"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Caroline Moore" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.25" translatesAutoresizingMaskIntoConstraints="NO" id="ahr-fF-k3e">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="157" height="20"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
|
||||||
<color key="textColor" white="1" alpha="0.75" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
</view>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="ahr-fF-k3e" secondAttribute="trailing" id="9aF-2y-sZf"/>
|
|
||||||
<constraint firstItem="ahr-fF-k3e" firstAttribute="top" secondItem="T6v-Rq-ntX" secondAttribute="top" id="M89-x2-VnS"/>
|
|
||||||
<constraint firstItem="ahr-fF-k3e" firstAttribute="leading" secondItem="T6v-Rq-ntX" secondAttribute="leading" id="THC-sX-gVq"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="ahr-fF-k3e" secondAttribute="bottom" id="loA-GD-3td"/>
|
|
||||||
</constraints>
|
|
||||||
<connections>
|
|
||||||
<outlet property="textLabel" destination="ahr-fF-k3e" id="xql-Ch-bfh"/>
|
|
||||||
</connections>
|
|
||||||
</collectionViewCell>
|
|
||||||
</cells>
|
|
||||||
<connections>
|
<connections>
|
||||||
<outlet property="dataSource" destination="dp8-8j-vt9" id="ONG-kb-M7N"/>
|
<outlet property="dataSource" destination="dp8-8j-vt9" id="ONG-kb-M7N"/>
|
||||||
<outlet property="delegate" destination="dp8-8j-vt9" id="790-Kr-6l7"/>
|
<outlet property="delegate" destination="dp8-8j-vt9" id="790-Kr-6l7"/>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ extension SettingsViewController
|
|||||||
case refreshSideJITServer
|
case refreshSideJITServer
|
||||||
case resetPairingFile
|
case resetPairingFile
|
||||||
case anisetteServers
|
case anisetteServers
|
||||||
|
case enableEMPForWiregaurd
|
||||||
case customizeAppId
|
case customizeAppId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1367,7 +1368,7 @@ extension SettingsViewController
|
|||||||
let anisetteServersController = UIHostingController(rootView: anisetteServersView)
|
let anisetteServersController = UIHostingController(rootView: anisetteServersView)
|
||||||
|
|
||||||
self.prepare(for: UIStoryboardSegue(identifier: "anisetteServers", source: self, destination: anisetteServersController), sender: nil)
|
self.prepare(for: UIStoryboardSegue(identifier: "anisetteServers", source: self, destination: anisetteServersController), sender: nil)
|
||||||
case .refreshAttempts, .customizeAppId: break
|
case .refreshAttempts, .enableEMPForWiregaurd, .customizeAppId: break
|
||||||
}
|
}
|
||||||
case .signing:
|
case .signing:
|
||||||
let row = SigningSettingsRow.allCases[indexPath.row]
|
let row = SigningSettingsRow.allCases[indexPath.row]
|
||||||
|
|||||||
@@ -348,16 +348,6 @@ public extension DatabaseManager
|
|||||||
let activeTeam = Team.first(satisfying: predicate, in: context)
|
let activeTeam = Team.first(satisfying: predicate, in: context)
|
||||||
return activeTeam
|
return activeTeam
|
||||||
}
|
}
|
||||||
|
|
||||||
func patreonAccount(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> PatreonAccount?
|
|
||||||
{
|
|
||||||
guard let patreonAccountID = Keychain.shared.patreonAccountID else { return nil }
|
|
||||||
|
|
||||||
let predicate = NSPredicate(format: "%K == %@", #keyPath(PatreonAccount.identifier), patreonAccountID)
|
|
||||||
|
|
||||||
let patreonAccount = PatreonAccount.first(satisfying: predicate, in: context, requestProperties: [\.relationshipKeyPathsForPrefetching: [#keyPath(PatreonAccount._pledges)]])
|
|
||||||
return patreonAccount
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension DatabaseManager
|
private extension DatabaseManager
|
||||||
|
|||||||
@@ -306,35 +306,6 @@ extension MergePolicy{
|
|||||||
conflictedObject.featuredSortID = featuredSortID
|
conflictedObject.featuredSortID = featuredSortID
|
||||||
}
|
}
|
||||||
|
|
||||||
case let databasePledge as Pledge:
|
|
||||||
guard let contextPledge = conflict.conflictingObjects.first as? Pledge else { break }
|
|
||||||
|
|
||||||
// Tiers
|
|
||||||
let contextTierIDs = Set(contextPledge._tiers.lazy.compactMap { $0 as? PledgeTier }.map { $0.identifier })
|
|
||||||
for case let databaseTier as PledgeTier in databasePledge._tiers where !contextTierIDs.contains(databaseTier.identifier)
|
|
||||||
{
|
|
||||||
// Tier ID does NOT exist in context, so delete existing databaseTier.
|
|
||||||
databaseTier.managedObjectContext?.delete(databaseTier)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rewards
|
|
||||||
let contextRewardIDs = Set(contextPledge._rewards.lazy.compactMap { $0 as? PledgeReward }.map { $0.identifier })
|
|
||||||
for case let databaseReward as PledgeReward in databasePledge._rewards where !contextRewardIDs.contains(databaseReward.identifier)
|
|
||||||
{
|
|
||||||
// Reward ID does NOT exist in context, so delete existing databaseReward.
|
|
||||||
databaseReward.managedObjectContext?.delete(databaseReward)
|
|
||||||
}
|
|
||||||
|
|
||||||
case let databaseAccount as PatreonAccount:
|
|
||||||
guard let contextAccount = conflict.conflictingObjects.first as? PatreonAccount else { break }
|
|
||||||
|
|
||||||
let contextPledgeIDs = Set(contextAccount._pledges.lazy.compactMap { $0 as? Pledge }.map { $0.identifier })
|
|
||||||
for case let databasePledge as Pledge in databaseAccount._pledges where !contextPledgeIDs.contains(databasePledge.identifier)
|
|
||||||
{
|
|
||||||
// Pledge ID does NOT exist in context, so delete existing databasePledge.
|
|
||||||
databasePledge.managedObjectContext?.delete(databasePledge)
|
|
||||||
}
|
|
||||||
|
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
//
|
|
||||||
// ManagedPatron.swift
|
|
||||||
// AltStoreCore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 4/18/22.
|
|
||||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(ManagedPatron)
|
|
||||||
public class ManagedPatron: BaseEntity
|
|
||||||
{
|
|
||||||
@NSManaged public var name: String
|
|
||||||
@NSManaged public var identifier: String
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
public init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
// Only cache Patrons with non-nil names.
|
|
||||||
guard let name = patron.name else { return nil }
|
|
||||||
|
|
||||||
super.init(entity: ManagedPatron.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.name = name
|
|
||||||
self.identifier = patron.identifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension ManagedPatron
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<ManagedPatron>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<ManagedPatron>(entityName: "Patron")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
//
|
|
||||||
// PatreonAccount.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/20/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(PatreonAccount)
|
|
||||||
public class PatreonAccount: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
@NSManaged public var identifier: String
|
|
||||||
|
|
||||||
@NSManaged public var name: String
|
|
||||||
@NSManaged public var firstName: String?
|
|
||||||
|
|
||||||
// Use `isPatron` for backwards compatibility.
|
|
||||||
@NSManaged @objc(isPatron) public var isAltStorePatron: Bool
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@nonobjc public var pledges: Set<Pledge> { _pledges as! Set<Pledge> }
|
|
||||||
@NSManaged @objc(pledges) internal var _pledges: NSSet
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(account: PatreonAPI.UserAccount, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
super.init(entity: PatreonAccount.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.identifier = account.identifier
|
|
||||||
self.name = account.name
|
|
||||||
self.firstName = account.firstName
|
|
||||||
|
|
||||||
// if let patronResponse = response.included?.first
|
|
||||||
// {
|
|
||||||
// let patron = Patron(response: patronResponse)
|
|
||||||
// self.isPatron = (patron.status == .active)
|
|
||||||
// }
|
|
||||||
// else
|
|
||||||
// {
|
|
||||||
// self.isPatron = false
|
|
||||||
// }
|
|
||||||
// self.isPatron = true
|
|
||||||
self.isAltStorePatron = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension PatreonAccount
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PatreonAccount>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<PatreonAccount>(entityName: "PatreonAccount")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
//
|
|
||||||
// Pledge.swift
|
|
||||||
// AltStoreCore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/24/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(Pledge)
|
|
||||||
public class Pledge: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged public private(set) var identifier: String
|
|
||||||
@NSManaged public private(set) var campaignURL: URL
|
|
||||||
|
|
||||||
@nonobjc public var amount: Decimal { _amount as Decimal }
|
|
||||||
@NSManaged @objc(amount) private var _amount: NSDecimalNumber
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged public private(set) var account: PatreonAccount?
|
|
||||||
|
|
||||||
@nonobjc public var tiers: Set<PledgeTier> { _tiers as! Set<PledgeTier> }
|
|
||||||
@NSManaged @objc(tiers) internal var _tiers: NSSet
|
|
||||||
|
|
||||||
@nonobjc public var rewards: Set<PledgeReward> { _rewards as! Set<PledgeReward> }
|
|
||||||
@NSManaged @objc(rewards) internal var _rewards: NSSet
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
guard let amount = patron.pledgeAmount, let campaignURL = patron.campaign?.url else { return nil }
|
|
||||||
|
|
||||||
super.init(entity: Pledge.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.identifier = patron.identifier
|
|
||||||
self._amount = amount as NSDecimalNumber
|
|
||||||
self.campaignURL = campaignURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Pledge
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Pledge>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<Pledge>(entityName: "Pledge")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
//
|
|
||||||
// PledgeReward.swift
|
|
||||||
// AltStoreCore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/24/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(PledgeReward)
|
|
||||||
public class PledgeReward: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged public private(set) var name: String
|
|
||||||
@NSManaged public private(set) var identifier: String
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged public private(set) var pledge: Pledge?
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(benefit: PatreonAPI.Benefit, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
super.init(entity: PledgeReward.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.name = benefit.name
|
|
||||||
self.identifier = benefit.identifier.rawValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension PledgeReward
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PledgeReward>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<PledgeReward>(entityName: "PledgeReward")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
//
|
|
||||||
// PledgeTier.swift
|
|
||||||
// AltStoreCore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/24/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(PledgeTier)
|
|
||||||
public class PledgeTier: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged public private(set) var name: String?
|
|
||||||
@NSManaged public private(set) var identifier: String
|
|
||||||
|
|
||||||
@nonobjc public var amount: Decimal { _amount as Decimal } // In USD
|
|
||||||
@NSManaged @objc(amount) private var _amount: NSDecimalNumber
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged public private(set) var pledge: Pledge?
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(tier: PatreonAPI.Tier, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
super.init(entity: PledgeTier.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.name = tier.name
|
|
||||||
self.identifier = tier.identifier
|
|
||||||
self._amount = tier.amount as NSDecimalNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension PledgeTier
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PledgeTier>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<PledgeTier>(entityName: "PledgeTier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
//
|
|
||||||
// Benefit.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/21/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
typealias BenefitResponse = DataResponse<BenefitAttributes, AnyRelationships>
|
|
||||||
|
|
||||||
struct BenefitAttributes: Decodable
|
|
||||||
{
|
|
||||||
var title: String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
public struct Benefit: Hashable
|
|
||||||
{
|
|
||||||
public var name: String
|
|
||||||
public var identifier: ALTPatreonBenefitID
|
|
||||||
|
|
||||||
internal init(response: BenefitResponse)
|
|
||||||
{
|
|
||||||
self.name = response.attributes.title
|
|
||||||
self.identifier = ALTPatreonBenefitID(response.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
//
|
|
||||||
// Campaign.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/21/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
typealias CampaignResponse = DataResponse<CampaignAttributes, AnyRelationships>
|
|
||||||
|
|
||||||
struct CampaignAttributes: Decodable
|
|
||||||
{
|
|
||||||
var url: URL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
public struct Campaign
|
|
||||||
{
|
|
||||||
public var identifier: String
|
|
||||||
public var url: URL
|
|
||||||
|
|
||||||
internal init(response: PatreonAPI.CampaignResponse)
|
|
||||||
{
|
|
||||||
self.identifier = response.id
|
|
||||||
self.url = response.attributes.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
//
|
|
||||||
// PatreonAPI+Responses.swift
|
|
||||||
// AltStoreCore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 11/3/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
protocol ResponseData: Decodable
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allows us to use Arrays with Response<> despite them not conforming to `ItemResponse`
|
|
||||||
extension Array: ResponseData where Element: ItemResponse
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol ItemResponse: ResponseData
|
|
||||||
{
|
|
||||||
var id: String { get }
|
|
||||||
var type: String { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
struct Response<Data: ResponseData>: Decodable
|
|
||||||
{
|
|
||||||
var data: Data
|
|
||||||
|
|
||||||
var included: IncludedResponses?
|
|
||||||
var links: [String: URL]?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AnyItemResponse: ItemResponse
|
|
||||||
{
|
|
||||||
var id: String
|
|
||||||
var type: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DataResponse<Attributes: Decodable, Relationships: Decodable>: ItemResponse
|
|
||||||
{
|
|
||||||
var id: String
|
|
||||||
var type: String
|
|
||||||
|
|
||||||
var attributes: Attributes
|
|
||||||
var relationships: Relationships?
|
|
||||||
}
|
|
||||||
|
|
||||||
// `Never` only conforms to Decodable from iOS 17 onwards,
|
|
||||||
// so use our own "Empty" type for DataResponses without relationships.
|
|
||||||
struct AnyRelationships: Decodable
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IncludedResponses: Decodable
|
|
||||||
{
|
|
||||||
var items: [IncludedItem]
|
|
||||||
|
|
||||||
var campaigns: [String: CampaignResponse]
|
|
||||||
var patrons: [String: PatronResponse]
|
|
||||||
var tiers: [String: TierResponse]
|
|
||||||
var benefits: [String: BenefitResponse]
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws
|
|
||||||
{
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
self.items = try container.decode([IncludedItem].self)
|
|
||||||
|
|
||||||
var campaignsByID = [String: PatreonAPI.CampaignResponse]()
|
|
||||||
var patronsByID = [String: PatreonAPI.PatronResponse]()
|
|
||||||
var tiersByID = [String: PatreonAPI.TierResponse]()
|
|
||||||
var benefitsByID = [String: PatreonAPI.BenefitResponse]()
|
|
||||||
|
|
||||||
for response in self.items
|
|
||||||
{
|
|
||||||
switch response
|
|
||||||
{
|
|
||||||
case .campaign(let response): campaignsByID[response.id] = response
|
|
||||||
case .patron(let response): patronsByID[response.id] = response
|
|
||||||
case .tier(let response): tiersByID[response.id] = response
|
|
||||||
case .benefit(let response): benefitsByID[response.id] = response
|
|
||||||
case .unknown: break // Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.campaigns = campaignsByID
|
|
||||||
self.patrons = patronsByID
|
|
||||||
self.tiers = tiersByID
|
|
||||||
self.benefits = benefitsByID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IncludedItem: ItemResponse
|
|
||||||
{
|
|
||||||
case tier(TierResponse)
|
|
||||||
case benefit(BenefitResponse)
|
|
||||||
case patron(PatronResponse)
|
|
||||||
case campaign(CampaignResponse)
|
|
||||||
case unknown(AnyItemResponse)
|
|
||||||
|
|
||||||
var id: String {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .tier(let response): return response.id
|
|
||||||
case .benefit(let response): return response.id
|
|
||||||
case .patron(let response): return response.id
|
|
||||||
case .campaign(let response): return response.id
|
|
||||||
case .unknown(let response): return response.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var type: String {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .tier(let response): return response.type
|
|
||||||
case .benefit(let response): return response.type
|
|
||||||
case .patron(let response): return response.type
|
|
||||||
case .campaign(let response): return response.type
|
|
||||||
case .unknown(let response): return response.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey
|
|
||||||
{
|
|
||||||
case type
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws
|
|
||||||
{
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
let type = try container.decode(String.self, forKey: .type)
|
|
||||||
switch type
|
|
||||||
{
|
|
||||||
case "tier":
|
|
||||||
let response = try TierResponse(from: decoder)
|
|
||||||
self = .tier(response)
|
|
||||||
|
|
||||||
case "benefit":
|
|
||||||
let response = try BenefitResponse(from: decoder)
|
|
||||||
self = .benefit(response)
|
|
||||||
|
|
||||||
case "member":
|
|
||||||
let response = try PatronResponse(from: decoder)
|
|
||||||
self = .patron(response)
|
|
||||||
|
|
||||||
case "campaign":
|
|
||||||
let response = try CampaignResponse(from: decoder)
|
|
||||||
self = .campaign(response)
|
|
||||||
|
|
||||||
default:
|
|
||||||
Logger.main.error("Unrecognized PatreonAPI response type: \(type, privacy: .public).")
|
|
||||||
|
|
||||||
let response = try AnyItemResponse(from: decoder)
|
|
||||||
self = .unknown(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
//
|
|
||||||
// PatreonAPI.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/20/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import AuthenticationServices
|
|
||||||
import CoreData
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
private let clientID = "my4hpHHG4iVRme6QALnQGlhSBQiKdB_AinrVgPpIpiC-xiHstTYiLKO5vfariFo1"
|
|
||||||
private let clientSecret = "Zow0ggt9YgwIyd4DVLoO9Z02KuuIXW44xhx4lfL27x2u-_u4FE4rYR48bEKREPS5"
|
|
||||||
|
|
||||||
private let campaignID = "12794837"
|
|
||||||
|
|
||||||
typealias PatreonAPIError = PatreonAPIErrorCode.Error
|
|
||||||
enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable
|
|
||||||
{
|
|
||||||
case unknown
|
|
||||||
case notAuthenticated
|
|
||||||
case invalidAccessToken
|
|
||||||
|
|
||||||
var errorFailureReason: String {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .unknown: return NSLocalizedString("An unknown error occurred.", comment: "")
|
|
||||||
case .notAuthenticated: return NSLocalizedString("No connected Patreon account.", comment: "")
|
|
||||||
case .invalidAccessToken: return NSLocalizedString("Invalid access token.", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
static let altstoreCampaignID = "2863968"
|
|
||||||
|
|
||||||
typealias FetchAccountResponse = Response<UserAccountResponse>
|
|
||||||
typealias FriendZonePatronsResponse = Response<[PatronResponse]>
|
|
||||||
|
|
||||||
enum AuthorizationType
|
|
||||||
{
|
|
||||||
case none
|
|
||||||
case user
|
|
||||||
case creator
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PatreonAPI: NSObject
|
|
||||||
{
|
|
||||||
public static let shared = PatreonAPI()
|
|
||||||
|
|
||||||
public var isAuthenticated: Bool {
|
|
||||||
return Keychain.shared.patreonAccessToken != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private var authenticationSession: ASWebAuthenticationSession?
|
|
||||||
|
|
||||||
private let session = URLSession(configuration: .ephemeral)
|
|
||||||
private let baseURL = URL(string: "https://www.patreon.com/")!
|
|
||||||
|
|
||||||
private var authHandlers = [(Result<PatreonAccount, Swift.Error>) -> Void]()
|
|
||||||
private var authContinuation: CheckedContinuation<URL, Error>?
|
|
||||||
private weak var webViewController: WebViewController?
|
|
||||||
|
|
||||||
private override init()
|
|
||||||
{
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension PatreonAPI
|
|
||||||
{
|
|
||||||
func authenticate(presentingViewController: UIViewController, completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
Task<Void, Never>.detached { @MainActor in
|
|
||||||
guard self.authHandlers.isEmpty else {
|
|
||||||
self.authHandlers.append(completion)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.authHandlers.append(completion)
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
var components = URLComponents(string: "/oauth2/authorize")!
|
|
||||||
components.queryItems = [URLQueryItem(name: "response_type", value: "code"),
|
|
||||||
URLQueryItem(name: "client_id", value: clientID),
|
|
||||||
URLQueryItem(name: "redirect_uri", value: "https://rileytestut.com/patreon/altstore"),
|
|
||||||
URLQueryItem(name: "scope", value: "identity identity[email] identity.memberships campaigns.posts")]
|
|
||||||
|
|
||||||
let requestURL = components.url(relativeTo: self.baseURL)
|
|
||||||
|
|
||||||
let configuration = WKWebViewConfiguration()
|
|
||||||
configuration.setURLSchemeHandler(self, forURLScheme: "altstore")
|
|
||||||
configuration.websiteDataStore = .default()
|
|
||||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
|
||||||
configuration.applicationNameForUserAgent = "Version/17.1.2 Mobile/15E148 Safari/604.1" // Required for "Sign-in With Google" to work in WKWebView
|
|
||||||
|
|
||||||
let webViewController = WebViewController(url: requestURL, configuration: configuration)
|
|
||||||
webViewController.delegate = self
|
|
||||||
webViewController.webView.uiDelegate = self
|
|
||||||
self.webViewController = webViewController
|
|
||||||
|
|
||||||
let callbackURL = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
self.authContinuation = continuation
|
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
|
||||||
presentingViewController.present(navigationController, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard
|
|
||||||
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
|
||||||
let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }),
|
|
||||||
let code = codeQueryItem.value
|
|
||||||
else { throw PatreonAPIError(.unknown) }
|
|
||||||
|
|
||||||
let (accessToken, refreshToken) = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
self.fetchAccessToken(oauthCode: code) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Keychain.shared.patreonAccessToken = accessToken
|
|
||||||
Keychain.shared.patreonRefreshToken = refreshToken
|
|
||||||
|
|
||||||
let patreonAccount = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
self.fetchAccount { result in
|
|
||||||
let result = result.map { AsyncManaged(wrappedValue: $0) }
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.saveAuthCookies()
|
|
||||||
|
|
||||||
await patreonAccount.perform { patreonAccount in
|
|
||||||
for callback in self.authHandlers
|
|
||||||
{
|
|
||||||
callback(.success(patreonAccount))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
for callback in self.authHandlers
|
|
||||||
{
|
|
||||||
callback(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.authHandlers = []
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
self.webViewController?.dismiss(animated: true)
|
|
||||||
self.webViewController = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchAccount(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
var components = URLComponents(string: "/api/oauth2/v2/identity")!
|
|
||||||
components.queryItems = [URLQueryItem(name: "include", value: "memberships.campaign.tiers,memberships.currently_entitled_tiers.benefits"),
|
|
||||||
URLQueryItem(name: "fields[user]", value: "first_name,full_name"),
|
|
||||||
URLQueryItem(name: "fields[tier]", value: "title,amount_cents"),
|
|
||||||
URLQueryItem(name: "fields[benefit]", value: "title"),
|
|
||||||
URLQueryItem(name: "fields[campaign]", value: "url"),
|
|
||||||
URLQueryItem(name: "fields[member]", value: "full_name,patron_status,currently_entitled_amount_cents")]
|
|
||||||
|
|
||||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
|
||||||
let request = URLRequest(url: requestURL)
|
|
||||||
|
|
||||||
self.send(request, authorizationType: .user) { (result: Result<FetchAccountResponse, Swift.Error>) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(~PatreonAPIErrorCode.notAuthenticated):
|
|
||||||
self.signOut() { (result) in
|
|
||||||
completion(.failure(PatreonAPIError(.notAuthenticated)))
|
|
||||||
}
|
|
||||||
|
|
||||||
case .failure(let error as DecodingError):
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let nsError = error as NSError
|
|
||||||
guard let codingPath = nsError.userInfo[ALTNSCodingPathKey] as? [CodingKey] else { throw error }
|
|
||||||
|
|
||||||
let rawComponents = codingPath.map { $0.intValue?.description ?? $0.stringValue }
|
|
||||||
let pathDescription = rawComponents.joined(separator: " > ")
|
|
||||||
|
|
||||||
let localizedDescription = nsError.localizedDebugDescription ?? nsError.localizedDescription
|
|
||||||
let debugDescription = localizedDescription + " Path: " + pathDescription
|
|
||||||
|
|
||||||
var userInfo = nsError.userInfo
|
|
||||||
userInfo[NSDebugDescriptionErrorKey] = debugDescription
|
|
||||||
throw NSError(domain: nsError.domain, code: nsError.code, userInfo: userInfo)
|
|
||||||
}
|
|
||||||
catch let error as NSError
|
|
||||||
{
|
|
||||||
Logger.main.error("Failed to fetch Patreon account. \(error.localizedDebugDescription ?? error.localizedDescription, privacy: .public)")
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
case .failure(let error as NSError):
|
|
||||||
Logger.main.error("Failed to fetch Patreon account. \(error.localizedDebugDescription ?? error.localizedDescription, privacy: .public)")
|
|
||||||
completion(.failure(error))
|
|
||||||
|
|
||||||
case .success(let response):
|
|
||||||
let account = PatreonAPI.UserAccount(response: response.data, including: response.included)
|
|
||||||
|
|
||||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
||||||
let account = PatreonAccount(account: account, context: context)
|
|
||||||
Keychain.shared.patreonAccountID = account.identifier
|
|
||||||
completion(.success(account))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchPatrons(completion: @escaping (Result<[Patron], Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(PatreonAPI.altstoreCampaignID)/members")!
|
|
||||||
components.queryItems = [URLQueryItem(name: "include", value: "currently_entitled_tiers,currently_entitled_tiers.benefits"),
|
|
||||||
URLQueryItem(name: "fields[tier]", value: "title,amount_cents"),
|
|
||||||
URLQueryItem(name: "fields[benefit]", value: "title"),
|
|
||||||
URLQueryItem(name: "fields[member]", value: "full_name,patron_status,currently_entitled_amount_cents"),
|
|
||||||
URLQueryItem(name: "page[size]", value: "1000")]
|
|
||||||
|
|
||||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
|
||||||
|
|
||||||
var allPatrons = [Patron]()
|
|
||||||
|
|
||||||
func fetchPatrons(url: URL)
|
|
||||||
{
|
|
||||||
let request = URLRequest(url: url)
|
|
||||||
|
|
||||||
self.send(request, authorizationType: .creator) { (result: Result<FriendZonePatronsResponse, Swift.Error>) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): completion(.failure(error))
|
|
||||||
case .success(let patronsResponse):
|
|
||||||
let patrons = patronsResponse.data.map { (response) -> Patron in
|
|
||||||
let patron = Patron(response: response, including: patronsResponse.included)
|
|
||||||
return patron
|
|
||||||
}.filter { $0.benefits.contains(where: { $0.identifier == .credits }) }
|
|
||||||
|
|
||||||
allPatrons.append(contentsOf: patrons)
|
|
||||||
|
|
||||||
if let nextURL = patronsResponse.links?["next"]
|
|
||||||
{
|
|
||||||
fetchPatrons(url: nextURL)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completion(.success(allPatrons))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchPatrons(url: requestURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func signOut(completion: @escaping (Result<Void, Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let accounts = PatreonAccount.all(in: context, requestProperties: [\.returnsObjectsAsFaults: true])
|
|
||||||
accounts.forEach(context.delete(_:))
|
|
||||||
|
|
||||||
let pledgeRequiredApps = StoreApp.all(satisfying: NSPredicate(format: "%K == YES", #keyPath(StoreApp.isPledgeRequired)), in: context)
|
|
||||||
pledgeRequiredApps.forEach { $0.isPledged = false }
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
|
|
||||||
Keychain.shared.patreonAccessToken = nil
|
|
||||||
Keychain.shared.patreonRefreshToken = nil
|
|
||||||
Keychain.shared.patreonAccountID = nil
|
|
||||||
|
|
||||||
Task<Void, Never>.detached {
|
|
||||||
await self.deleteAuthCookies()
|
|
||||||
completion(.success(()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshPatreonAccount()
|
|
||||||
{
|
|
||||||
guard PatreonAPI.shared.isAuthenticated else { return }
|
|
||||||
|
|
||||||
PatreonAPI.shared.fetchAccount { (result: Result<PatreonAccount, Swift.Error>) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let account = try result.get()
|
|
||||||
|
|
||||||
if let context = account.managedObjectContext, !account.isAltStorePatron
|
|
||||||
{
|
|
||||||
// Deactivate all beta apps now that we're no longer a patron.
|
|
||||||
//self.deactivateBetaApps(in: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
try account.managedObjectContext?.save()
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to fetch Patreon account.", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
public var authCookies: [HTTPCookie] {
|
|
||||||
let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://www.patreon.com")!) ?? []
|
|
||||||
return cookies
|
|
||||||
}
|
|
||||||
|
|
||||||
public func saveAuthCookies() async
|
|
||||||
{
|
|
||||||
let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor
|
|
||||||
|
|
||||||
let cookies = await cookieStore.allCookies()
|
|
||||||
for cookie in cookies where cookie.domain.lowercased().hasSuffix("patreon.com")
|
|
||||||
{
|
|
||||||
Logger.main.debug("Saving Patreon cookie \(cookie.name, privacy: .public): \(cookie.value, privacy: .private(mask: .hash)) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))")
|
|
||||||
HTTPCookieStorage.shared.setCookie(cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func deleteAuthCookies() async
|
|
||||||
{
|
|
||||||
Logger.main.info("Clearing Patreon cookie cache...")
|
|
||||||
|
|
||||||
let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor
|
|
||||||
|
|
||||||
for cookie in self.authCookies
|
|
||||||
{
|
|
||||||
Logger.main.debug("Deleting Patreon cookie \(cookie.name, privacy: .public) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))")
|
|
||||||
|
|
||||||
await cookieStore.deleteCookie(cookie)
|
|
||||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.main.info("Cleared Patreon cookie cache!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI: WebViewControllerDelegate
|
|
||||||
{
|
|
||||||
public func webViewControllerDidFinish(_ webViewController: WebViewController)
|
|
||||||
{
|
|
||||||
guard let authContinuation else { return }
|
|
||||||
self.authContinuation = nil
|
|
||||||
|
|
||||||
authContinuation.resume(throwing: CancellationError())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension PatreonAPI
|
|
||||||
{
|
|
||||||
func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
let encodedRedirectURI = ("https://rileytestut.com/patreon/altstore" as NSString).addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
|
|
||||||
let encodedOauthCode = (oauthCode as NSString).addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
|
|
||||||
|
|
||||||
let body = "code=\(encodedOauthCode)&grant_type=authorization_code&client_id=\(clientID)&client_secret=\(clientSecret)&redirect_uri=\(encodedRedirectURI)"
|
|
||||||
|
|
||||||
let requestURL = URL(string: "/api/oauth2/token", relativeTo: self.baseURL)!
|
|
||||||
|
|
||||||
var request = URLRequest(url: requestURL)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.httpBody = body.data(using: .utf8)
|
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
struct Response: Decodable
|
|
||||||
{
|
|
||||||
var access_token: String
|
|
||||||
var refresh_token: String
|
|
||||||
}
|
|
||||||
|
|
||||||
self.send(request, authorizationType: .none) { (result: Result<Response, Swift.Error>) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): completion(.failure(error))
|
|
||||||
case .success(let response): completion(.success((response.access_token, response.refresh_token)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshAccessToken(completion: @escaping (Result<Void, Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
guard let refreshToken = Keychain.shared.patreonRefreshToken else { return }
|
|
||||||
|
|
||||||
var components = URLComponents(string: "/api/oauth2/token")!
|
|
||||||
components.queryItems = [URLQueryItem(name: "grant_type", value: "refresh_token"),
|
|
||||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
|
||||||
URLQueryItem(name: "client_id", value: clientID),
|
|
||||||
URLQueryItem(name: "client_secret", value: clientSecret)]
|
|
||||||
|
|
||||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
|
||||||
|
|
||||||
var request = URLRequest(url: requestURL)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
|
|
||||||
struct Response: Decodable
|
|
||||||
{
|
|
||||||
var access_token: String
|
|
||||||
var refresh_token: String
|
|
||||||
}
|
|
||||||
|
|
||||||
self.send(request, authorizationType: .none) { (result: Result<Response, Swift.Error>) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): completion(.failure(error))
|
|
||||||
case .success(let response):
|
|
||||||
Keychain.shared.patreonAccessToken = response.access_token
|
|
||||||
Keychain.shared.patreonRefreshToken = response.refresh_token
|
|
||||||
|
|
||||||
completion(.success(()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func send<ResponseType: Decodable>(_ request: URLRequest, authorizationType: AuthorizationType, completion: @escaping (Result<ResponseType, Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
var request = request
|
|
||||||
|
|
||||||
switch authorizationType
|
|
||||||
{
|
|
||||||
case .none: break
|
|
||||||
case .creator:
|
|
||||||
guard let creatorAccessToken = Keychain.shared.patreonCreatorAccessToken else { return completion(.failure(PatreonAPIError(.invalidAccessToken))) }
|
|
||||||
request.setValue("Bearer " + creatorAccessToken, forHTTPHeaderField: "Authorization")
|
|
||||||
|
|
||||||
case .user:
|
|
||||||
guard let accessToken = Keychain.shared.patreonAccessToken else { return completion(.failure(PatreonAPIError(.notAuthenticated))) }
|
|
||||||
request.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
|
|
||||||
}
|
|
||||||
|
|
||||||
let task = self.session.dataTask(with: request) { (data, response, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let data = try Result(data, error).get()
|
|
||||||
|
|
||||||
if let response = response as? HTTPURLResponse, response.statusCode == 401
|
|
||||||
{
|
|
||||||
switch authorizationType
|
|
||||||
{
|
|
||||||
case .creator: completion(.failure(PatreonAPIError(.invalidAccessToken)))
|
|
||||||
case .none: completion(.failure(PatreonAPIError(.notAuthenticated)))
|
|
||||||
case .user:
|
|
||||||
self.refreshAccessToken() { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): completion(.failure(error))
|
|
||||||
case .success: self.send(request, authorizationType: authorizationType, completion: completion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = try JSONDecoder().decode(ResponseType.self, from: data)
|
|
||||||
completion(.success(response))
|
|
||||||
}
|
|
||||||
catch let error
|
|
||||||
{
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI: WKURLSchemeHandler
|
|
||||||
{
|
|
||||||
public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask)
|
|
||||||
{
|
|
||||||
guard let authContinuation else { return }
|
|
||||||
self.authContinuation = nil
|
|
||||||
|
|
||||||
if let callbackURL = urlSchemeTask.request.url
|
|
||||||
{
|
|
||||||
authContinuation.resume(returning: callbackURL)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
authContinuation.resume(throwing: URLError(.badURL))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask)
|
|
||||||
{
|
|
||||||
Logger.main.debug("WKWebView stopped handling url scheme.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI: WKUIDelegate
|
|
||||||
{
|
|
||||||
public func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView?
|
|
||||||
{
|
|
||||||
// Signing in with Google requires us to use separate windows/"tabs"
|
|
||||||
|
|
||||||
Logger.main.debug("Intercepting new window request: \(navigationAction.request)")
|
|
||||||
|
|
||||||
let webViewController = WebViewController(request: navigationAction.request, configuration: configuration)
|
|
||||||
webViewController.delegate = self
|
|
||||||
webViewController.webView.uiDelegate = self
|
|
||||||
self.webViewController?.navigationController?.pushViewController(webViewController, animated: true)
|
|
||||||
|
|
||||||
return webViewController.webView
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webViewDidClose(_ webView: WKWebView)
|
|
||||||
{
|
|
||||||
self.webViewController?.navigationController?.popToRootViewController(animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
//
|
|
||||||
// Patron.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/21/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
typealias PatronResponse = DataResponse<PatronAttributes, PatronRelationships>
|
|
||||||
|
|
||||||
struct PatronAttributes: Decodable
|
|
||||||
{
|
|
||||||
var full_name: String?
|
|
||||||
var patron_status: String?
|
|
||||||
var currently_entitled_amount_cents: Int32 // In campaign's currency
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PatronRelationships: Decodable
|
|
||||||
{
|
|
||||||
var campaign: Response<AnyItemResponse>?
|
|
||||||
var currently_entitled_tiers: Response<[AnyItemResponse]>?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
public enum Status: String, Decodable
|
|
||||||
{
|
|
||||||
case active = "active_patron"
|
|
||||||
case declined = "declined_patron"
|
|
||||||
case former = "former_patron"
|
|
||||||
case unknown = "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Roughly equivalent to AltStoreCore.Pledge
|
|
||||||
public class Patron
|
|
||||||
{
|
|
||||||
public var name: String?
|
|
||||||
public var identifier: String
|
|
||||||
public var pledgeAmount: Decimal?
|
|
||||||
public var status: Status
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
public var campaign: Campaign?
|
|
||||||
public var tiers: Set<Tier> = []
|
|
||||||
public var benefits: Set<Benefit> = []
|
|
||||||
|
|
||||||
internal init(response: PatronResponse, including included: IncludedResponses?)
|
|
||||||
{
|
|
||||||
self.name = response.attributes.full_name
|
|
||||||
self.identifier = response.id
|
|
||||||
self.pledgeAmount = Decimal(response.attributes.currently_entitled_amount_cents) / 100
|
|
||||||
|
|
||||||
if let status = response.attributes.patron_status
|
|
||||||
{
|
|
||||||
self.status = Status(rawValue: status) ?? .unknown
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.status = .unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let included, let relationships = response.relationships else { return }
|
|
||||||
|
|
||||||
if let campaignID = relationships.campaign?.data.id, let response = included.campaigns[campaignID]
|
|
||||||
{
|
|
||||||
let campaign = Campaign(response: response)
|
|
||||||
self.campaign = campaign
|
|
||||||
}
|
|
||||||
|
|
||||||
let tiers = (relationships.currently_entitled_tiers?.data ?? []).compactMap { included.tiers[$0.id] }.map { Tier(response: $0, including: included) }
|
|
||||||
self.tiers = Set(tiers)
|
|
||||||
|
|
||||||
let benefits = tiers.flatMap { $0.benefits }
|
|
||||||
self.benefits = Set(benefits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
//
|
|
||||||
// Tier.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/21/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
typealias TierResponse = DataResponse<TierAttributes, TierRelationships>
|
|
||||||
|
|
||||||
struct TierAttributes: Decodable
|
|
||||||
{
|
|
||||||
var title: String?
|
|
||||||
var amount_cents: Int32 // In USD
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TierRelationships: Decodable
|
|
||||||
{
|
|
||||||
var benefits: Response<[AnyItemResponse]>?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
public struct Tier: Hashable
|
|
||||||
{
|
|
||||||
public var name: String?
|
|
||||||
public var identifier: String
|
|
||||||
public var amount: Decimal
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
public var benefits: [Benefit] = []
|
|
||||||
|
|
||||||
internal init(response: TierResponse, including included: IncludedResponses?)
|
|
||||||
{
|
|
||||||
self.name = response.attributes.title
|
|
||||||
self.identifier = response.id
|
|
||||||
|
|
||||||
let amount = Decimal(response.attributes.amount_cents) / 100
|
|
||||||
self.amount = amount
|
|
||||||
|
|
||||||
guard let included, let benefitIDs = response.relationships?.benefits?.data.map(\.id) else { return }
|
|
||||||
|
|
||||||
let benefits = benefitIDs.compactMap { included.benefits[$0] }.map(Benefit.init(response:))
|
|
||||||
self.benefits = benefits
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
//
|
|
||||||
// Account.swift
|
|
||||||
// AltStoreCore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 11/3/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
typealias UserAccountResponse = DataResponse<UserAccountAttributes, AnyRelationships>
|
|
||||||
|
|
||||||
struct UserAccountAttributes: Decodable
|
|
||||||
{
|
|
||||||
var first_name: String?
|
|
||||||
var full_name: String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
public struct UserAccount
|
|
||||||
{
|
|
||||||
var name: String
|
|
||||||
var firstName: String?
|
|
||||||
var identifier: String
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
var pledges: [Patron]?
|
|
||||||
|
|
||||||
init(response: UserAccountResponse, including included: IncludedResponses?)
|
|
||||||
{
|
|
||||||
self.identifier = response.id
|
|
||||||
self.name = response.attributes.full_name
|
|
||||||
self.firstName = response.attributes.first_name
|
|
||||||
|
|
||||||
guard let included else { return }
|
|
||||||
|
|
||||||
let patrons = included.patrons.values.compactMap { response -> Patron? in
|
|
||||||
let patron = Patron(response: response, including: included)
|
|
||||||
return patron
|
|
||||||
}
|
|
||||||
|
|
||||||
self.pledges = patrons
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
//
|
|
||||||
// AltTests+Sources.swift
|
|
||||||
// AltTests
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/10/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
@testable import AltStoreCore
|
|
||||||
|
|
||||||
extension AltTests
|
|
||||||
{
|
|
||||||
func testSourceID() throws
|
|
||||||
{
|
|
||||||
let url = Source.altStoreSourceURL
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url)
|
|
||||||
XCTAssertEqual(sourceID, "apps.altstore.io")
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17, *)
|
|
||||||
func testSourceIDWithPercentEncoding() throws
|
|
||||||
{
|
|
||||||
let url = URL(string: "apple.com/MY invalid•path/")!
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url)
|
|
||||||
XCTAssertEqual(sourceID, "apple.com/my invalid•path")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithDifferentSchemes() throws
|
|
||||||
{
|
|
||||||
let url1 = URL(string: "http://rileytestut.com")!
|
|
||||||
let url2 = URL(string: "https://rileytestut.com")!
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID, "rileytestut.com")
|
|
||||||
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID, sourceID2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithNonDefaultPort() throws
|
|
||||||
{
|
|
||||||
let url = URL(string: "http://localhost:8008/apps.json")!
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url)
|
|
||||||
XCTAssertEqual(sourceID, "localhost:8008/apps.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithFragmentsAndQueries() throws
|
|
||||||
{
|
|
||||||
var components = URLComponents(string: "https://disney.com/altstore/apps")!
|
|
||||||
components.fragment = "get started"
|
|
||||||
|
|
||||||
components.queryItems = [URLQueryItem(name: "id", value: "1234")]
|
|
||||||
let url1 = components.url!
|
|
||||||
|
|
||||||
components.queryItems = [URLQueryItem(name: "id", value: "5678")]
|
|
||||||
let url2 = components.url!
|
|
||||||
|
|
||||||
XCTAssertNotEqual(url1, url2)
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID, "disney.com/altstore/apps")
|
|
||||||
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID, sourceID2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithDuplicateSlashes() throws
|
|
||||||
{
|
|
||||||
let url1 = URL(string: "http://rileytestut.co.nz//secret/altstore//apps.json")!
|
|
||||||
let url2 = URL(string: "http://rileytestut.co.nz/secret/altstore/apps.json//")!
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID, "rileytestut.co.nz/secret/altstore/apps.json")
|
|
||||||
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID, sourceID2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithMixedCase() throws
|
|
||||||
{
|
|
||||||
let href = "https://rileyTESTUT.co.nz/test/PATH/ApPs.json"
|
|
||||||
|
|
||||||
let url1 = URL(string: href)!
|
|
||||||
let url2 = URL(string: href.lowercased())!
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID, "rileytestut.co.nz/test/path/apps.json")
|
|
||||||
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID, sourceID2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithTrailingSlash() throws
|
|
||||||
{
|
|
||||||
let url1 = URL(string: "http://apps.altstore.io/")!
|
|
||||||
let url2 = URL(string: "http://apps.altstore.io")!
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID, "apps.altstore.io")
|
|
||||||
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID, sourceID2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithLeadingWWW() throws
|
|
||||||
{
|
|
||||||
let url1 = URL(string: "http://www.GBA4iOSApp.com")!
|
|
||||||
let url2 = URL(string: "http://gba4iosapp.com")!
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID, "gba4iosapp.com")
|
|
||||||
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID, sourceID2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithAllRules() throws
|
|
||||||
{
|
|
||||||
let url1 = URL(string: "fTp://WWW.apps.APPLE.com:4004//altstore apps/source.JSON?user=test@altstore.io#welcome//")!
|
|
||||||
let url2 = URL(string: "ftp://apps.apple.com:4004/altstore apps/source.json?user=anothertest@altstore.io#welcome")!
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID, "apps.apple.com:4004/altstore apps/source.json")
|
|
||||||
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID, sourceID2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithEmoji() throws
|
|
||||||
{
|
|
||||||
let url1 = URL(string: "http://xn--g5h5981o.com")! // 🤷♂️.com
|
|
||||||
let sourceID1 = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID1, "🤷♂.com")
|
|
||||||
|
|
||||||
let url2 = URL(string: "http://www.xn--7r8h.io")! // www.💜.io
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID2, "💜.io")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSourceIDWithRelativeURL() throws
|
|
||||||
{
|
|
||||||
let baseURL = URL(string: "https://rileytestut.com")!
|
|
||||||
let path = "altstore/apps.json"
|
|
||||||
|
|
||||||
let url1 = URL(string: path, relativeTo: baseURL)!
|
|
||||||
let url2 = baseURL.appendingPathComponent(path)
|
|
||||||
|
|
||||||
let sourceID = try Source.sourceID(from: url1)
|
|
||||||
XCTAssertEqual(sourceID, "rileytestut.com/altstore/apps.json")
|
|
||||||
|
|
||||||
let sourceID2 = try Source.sourceID(from: url2)
|
|
||||||
XCTAssertEqual(sourceID, sourceID2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
//
|
|
||||||
// TestErrors.swift
|
|
||||||
// AltTests
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/17/22.
|
|
||||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
@testable import AltStore
|
|
||||||
@testable import AltStoreCore
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
typealias TestError = TestErrorCode.Error
|
|
||||||
enum TestErrorCode: Int, ALTErrorEnum, CaseIterable
|
|
||||||
{
|
|
||||||
static var errorDomain: String {
|
|
||||||
return "TestErrorDomain"
|
|
||||||
}
|
|
||||||
|
|
||||||
case computerOnFire
|
|
||||||
case alienInvasion
|
|
||||||
|
|
||||||
var errorFailureReason: String {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .computerOnFire: return "Your computer is on fire."
|
|
||||||
case .alienInvasion: return "There is an ongoing alien invasion."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DefaultLocalizedError<TestErrorCode>
|
|
||||||
{
|
|
||||||
static var allErrors: [TestError] {
|
|
||||||
return Code.allCases.map { TestError($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var recoverySuggestion: String? {
|
|
||||||
switch self.code
|
|
||||||
{
|
|
||||||
case .computerOnFire: return "Try using a fire extinguisher!"
|
|
||||||
case .alienInvasion: return nil // Nothing you can do to stop the aliens.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let allTestErrors = TestErrorCode.allCases.map { TestError($0) }
|
|
||||||
|
|
||||||
extension ALTLocalizedError where Self.Code: ALTErrorEnum & CaseIterable
|
|
||||||
{
|
|
||||||
static var testErrors: [DefaultLocalizedError<Code>] {
|
|
||||||
return Code.allCases.map { DefaultLocalizedError<Code>($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AuthenticationError
|
|
||||||
{
|
|
||||||
static var testErrors: [AuthenticationError] {
|
|
||||||
return AuthenticationError.Code.allCases.map { code -> AuthenticationError in
|
|
||||||
return AuthenticationError(code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension VerificationError
|
|
||||||
{
|
|
||||||
static var testErrors: [VerificationError] {
|
|
||||||
let app = ALTApplication(fileURL: Bundle.main.bundleURL)!
|
|
||||||
|
|
||||||
return VerificationError.Code.allCases.compactMap { code -> VerificationError? in
|
|
||||||
switch code
|
|
||||||
{
|
|
||||||
case .mismatchedBundleIdentifiers: return VerificationError.mismatchedBundleIdentifiers(sourceBundleID: "com.rileytestut.App", app: app)
|
|
||||||
case .iOSVersionNotSupported: return VerificationError.iOSVersionNotSupported(app: app, requiredOSVersion: OperatingSystemVersion(majorVersion: 21, minorVersion: 1, patchVersion: 0))
|
|
||||||
case .mismatchedHash: return VerificationError.mismatchedHash("12345", expectedHash: "67890", app: app)
|
|
||||||
case .mismatchedVersion: return VerificationError.mismatchedVersion("1.0", expectedVersion: "1.1", app: app)
|
|
||||||
case .mismatchedBuildVersion: return VerificationError.mismatchedBuildVersion("1", expectedVersion: "28", app: app)
|
|
||||||
case .undeclaredPermissions: return VerificationError.undeclaredPermissions([ALTEntitlement.appGroups, ALTAppPrivacyPermission.bluetooth], app: app)
|
|
||||||
case .addedPermissions: return nil //VerificationError.addedPermissions([ALTAppPrivacyPermission.appleMusic, ALTEntitlement.interAppAudio], appVersion: app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatchAppError
|
|
||||||
{
|
|
||||||
static var testErrors: [PatchAppError] {
|
|
||||||
PatchAppError.Code.allCases.map { code -> PatchAppError in
|
|
||||||
switch code
|
|
||||||
{
|
|
||||||
case .unsupportedOperatingSystemVersion: return PatchAppError(.unsupportedOperatingSystemVersion(.init(majorVersion: 15, minorVersion: 5, patchVersion: 1)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AltTests
|
|
||||||
{
|
|
||||||
static var allLocalErrors: [any ALTLocalizedError] {
|
|
||||||
let errors = [
|
|
||||||
OperationError.testErrors as [any ALTLocalizedError],
|
|
||||||
AuthenticationError.testErrors as [any ALTLocalizedError],
|
|
||||||
VerificationError.testErrors as [any ALTLocalizedError],
|
|
||||||
PatreonAPIError.testErrors as [any ALTLocalizedError],
|
|
||||||
RefreshError.testErrors as [any ALTLocalizedError],
|
|
||||||
PatchAppError.testErrors as [any ALTLocalizedError]
|
|
||||||
].flatMap { $0 }
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
static var allRemoteErrors: [any Error] {
|
|
||||||
let errors: [any Error] = ALTServerError.testErrors + ALTAppleAPIError.testErrors
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
static var allRealErrors: [any Error] {
|
|
||||||
return self.allLocalErrors + self.allRemoteErrors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ALTServerError
|
|
||||||
{
|
|
||||||
static var testErrors: [ALTServerError] {
|
|
||||||
[
|
|
||||||
// ALTServerError(.underlyingError), // Doesn't occur in practice? But does mess up tests
|
|
||||||
|
|
||||||
ALTServerError(.underlyingError, userInfo: [NSUnderlyingErrorKey: ALTServerError(.pluginNotFound)]),
|
|
||||||
ALTServerError(ALTServerError(.pluginNotFound)),
|
|
||||||
ALTServerError(TestError(.computerOnFire)),
|
|
||||||
ALTServerError(CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: "~/Desktop/TestFile"])),
|
|
||||||
|
|
||||||
ALTServerError(.unknown),
|
|
||||||
|
|
||||||
ALTServerError(.connectionFailed),
|
|
||||||
ALTServerError(ALTServerConnectionError(.timedOut, userInfo: [ALTUnderlyingErrorDomainErrorKey: "Mobile Image Mounter",
|
|
||||||
ALTUnderlyingErrorCodeErrorKey: -27,
|
|
||||||
ALTDeviceNameErrorKey: "Riley's iPhone"])),
|
|
||||||
|
|
||||||
ALTServerError(.lostConnection),
|
|
||||||
ALTServerError(.deviceNotFound),
|
|
||||||
ALTServerError(.deviceWriteFailed),
|
|
||||||
ALTServerError(.invalidRequest),
|
|
||||||
ALTServerError(.invalidResponse),
|
|
||||||
ALTServerError(.invalidApp),
|
|
||||||
ALTServerError(.installationFailed),
|
|
||||||
ALTServerError(.maximumFreeAppLimitReached),
|
|
||||||
ALTServerError(.unsupportediOSVersion),
|
|
||||||
ALTServerError(.unknownRequest),
|
|
||||||
ALTServerError(.unknownResponse),
|
|
||||||
ALTServerError(.invalidAnisetteData),
|
|
||||||
ALTServerError(.pluginNotFound),
|
|
||||||
ALTServerError(.profileNotFound),
|
|
||||||
ALTServerError(.appDeletionFailed),
|
|
||||||
ALTServerError(.requestedAppNotRunning, userInfo: [ALTAppNameErrorKey: "Delta", ALTDeviceNameErrorKey: "Riley's iPhone"]),
|
|
||||||
ALTServerError(.incompatibleDeveloperDisk, userInfo: [ALTOperatingSystemNameErrorKey: "iOS",
|
|
||||||
ALTOperatingSystemVersionErrorKey: "13.0",
|
|
||||||
NSFilePathErrorKey: URL(fileURLWithPath: "~/Library/Application Support/com.rileytestut.AltServer/DeveloperDiskImages/iOS/13.0/DeveloperDiskImage.dmg").path]),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ALTAppleAPIError.Code: CaseIterable
|
|
||||||
{
|
|
||||||
public static var allCases: [Self] {
|
|
||||||
return [.unknown, .invalidParameters,
|
|
||||||
.incorrectCredentials, .appSpecificPasswordRequired,
|
|
||||||
.noTeams,
|
|
||||||
.invalidDeviceID, .deviceAlreadyRegistered,
|
|
||||||
.invalidCertificateRequest, .certificateDoesNotExist,
|
|
||||||
.invalidAppIDName, .invalidBundleIdentifier, .bundleIdentifierUnavailable, .appIDDoesNotExist, .maximumAppIDLimitReached,
|
|
||||||
.invalidAppGroup, .appGroupDoesNotExist,
|
|
||||||
.invalidProvisioningProfileIdentifier, .provisioningProfileDoesNotExist,
|
|
||||||
.requiresTwoFactorAuthentication, .incorrectVerificationCode, .authenticationHandshakeFailed,
|
|
||||||
.invalidAnisetteData]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ALTAppleAPIError
|
|
||||||
{
|
|
||||||
static var testErrors: [Self] {
|
|
||||||
Code.allCases.map { code -> ALTAppleAPIError in
|
|
||||||
return ALTAppleAPIError(code)
|
|
||||||
//
|
|
||||||
// switch code
|
|
||||||
// {
|
|
||||||
// case .unknown: return ALTAppleAPIError(.unknown)
|
|
||||||
// case .invalidParameters:
|
|
||||||
// case .incorrectCredentials:
|
|
||||||
// case .appSpecificPasswordRequired:
|
|
||||||
// case .noTeams:
|
|
||||||
// case .invalidDeviceID:
|
|
||||||
// case .deviceAlreadyRegistered:
|
|
||||||
// case .invalidCertificateRequest:
|
|
||||||
// case .certificateDoesNotExist:
|
|
||||||
// case .invalidAppIDName:
|
|
||||||
// case .invalidBundleIdentifier:
|
|
||||||
// case .bundleIdentifierUnavailable:
|
|
||||||
// case .appIDDoesNotExist:
|
|
||||||
// case .maximumAppIDLimitReached:
|
|
||||||
// case .invalidAppGroup:
|
|
||||||
// case .appGroupDoesNotExist:
|
|
||||||
// case .invalidProvisioningProfileIdentifier:
|
|
||||||
// case .provisioningProfileDoesNotExist:
|
|
||||||
// case .requiresTwoFactorAuthentication:
|
|
||||||
// case .incorrectVerificationCode:
|
|
||||||
// case .authenticationHandshakeFailed:
|
|
||||||
// case .invalidAnisetteData:
|
|
||||||
// @unknown default:
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension OperationError
|
|
||||||
{
|
|
||||||
static var testErrors: [OperationError] {
|
|
||||||
OperationError.Code.allCases.map { code -> OperationError in
|
|
||||||
switch code
|
|
||||||
{
|
|
||||||
case .unknown: return .unknown()
|
|
||||||
case .unknownResult: return .unknownResult
|
|
||||||
case .timedOut: return .timedOut
|
|
||||||
case .notAuthenticated: return .notAuthenticated
|
|
||||||
case .appNotFound: return .appNotFound(name: "Delta")
|
|
||||||
case .unknownUDID: return .unknownUDID
|
|
||||||
case .invalidApp: return .invalidApp
|
|
||||||
case .invalidParameters: return .invalidParameters
|
|
||||||
case .maximumAppIDLimitReached: return .maximumAppIDLimitReached(appName: "Delta", requiredAppIDs: 2, availableAppIDs: 1, expirationDate: Date())
|
|
||||||
case .noSources: return .noSources
|
|
||||||
case .openAppFailed: return .openAppFailed(name: "Delta")
|
|
||||||
case .missingAppGroup: return .missingAppGroup
|
|
||||||
case .serverNotFound: return .serverNotFound
|
|
||||||
case .connectionFailed: return .connectionFailed
|
|
||||||
case .connectionDropped: return .connectionDropped
|
|
||||||
case .forbidden: return .forbidden()
|
|
||||||
case .pledgeRequired: return .pledgeRequired(appName: "Delta")
|
|
||||||
case .pledgeInactive: return .pledgeInactive(appName: "Delta")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||