mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 23:03:27 +01:00
iOS 13.3.1 limits free developer accounts to 3 apps and app extensions. As a workaround, we now allow up to 3 “active” apps (apps with installed provisioning profiles), as well as additional “inactivate” apps which don’t have any profiles installed, causing them to not count towards the total. Inactive apps cannot be opened until they are activated.
657 lines
25 KiB
Swift
657 lines
25 KiB
Swift
//
|
|
// AppManager.swift
|
|
// AltStore
|
|
//
|
|
// Created by Riley Testut on 5/29/19.
|
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
import UserNotifications
|
|
import MobileCoreServices
|
|
|
|
import AltSign
|
|
import AltKit
|
|
|
|
import Roxas
|
|
|
|
extension AppManager
|
|
{
|
|
static let didFetchSourceNotification = Notification.Name("com.altstore.AppManager.didFetchSource")
|
|
|
|
static let expirationWarningNotificationID = "altstore-expiration-warning"
|
|
|
|
static let whitelistedSideloadingBundleIDs: Set<String> = ["science.xnu.undecimus"]
|
|
}
|
|
|
|
class AppManager
|
|
{
|
|
static let shared = AppManager()
|
|
|
|
private let operationQueue = OperationQueue()
|
|
private let serialOperationQueue = OperationQueue()
|
|
|
|
private var installationProgress = [String: Progress]()
|
|
private var refreshProgress = [String: Progress]()
|
|
|
|
private init()
|
|
{
|
|
self.operationQueue.name = "com.altstore.AppManager.operationQueue"
|
|
|
|
self.serialOperationQueue.name = "com.altstore.AppManager.serialOperationQueue"
|
|
self.serialOperationQueue.maxConcurrentOperationCount = 1
|
|
}
|
|
}
|
|
|
|
extension AppManager
|
|
{
|
|
func update()
|
|
{
|
|
#if targetEnvironment(simulator)
|
|
// Apps aren't ever actually installed to simulator, so just do nothing rather than delete them from database.
|
|
return
|
|
#else
|
|
|
|
let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext()
|
|
|
|
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
|
|
fetchRequest.returnsObjectsAsFaults = false
|
|
|
|
var activeAppsCount = 0
|
|
|
|
do
|
|
{
|
|
let installedApps = try context.fetch(fetchRequest)
|
|
|
|
if UserDefaults.standard.legacySideloadedApps == nil
|
|
{
|
|
// First time updating apps since updating AltStore to use custom UTIs,
|
|
// so cache all existing apps temporarily to prevent us from accidentally
|
|
// deleting them due to their custom UTI not existing (yet).
|
|
let apps = installedApps.map { $0.bundleIdentifier }
|
|
UserDefaults.standard.legacySideloadedApps = apps
|
|
}
|
|
|
|
let legacySideloadedApps = Set(UserDefaults.standard.legacySideloadedApps ?? [])
|
|
|
|
for app in installedApps
|
|
{
|
|
guard app.bundleIdentifier != StoreApp.altstoreAppID else {
|
|
self.scheduleExpirationWarningLocalNotification(for: app)
|
|
continue
|
|
}
|
|
|
|
let uti = UTTypeCopyDeclaration(app.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?
|
|
guard uti != nil || legacySideloadedApps.contains(app.bundleIdentifier) else {
|
|
// This UTI is not declared by any apps, which means this app has been deleted by the user.
|
|
// This app is also not a legacy sideloaded app, so we can assume it's fine to delete it.
|
|
context.delete(app)
|
|
continue
|
|
}
|
|
|
|
if app.isActive
|
|
{
|
|
if let activeAppsLimit = UserDefaults.standard.activeAppsLimit, activeAppsCount >= activeAppsLimit - 1
|
|
{
|
|
// We have reached active apps limit (excluding AltStore itself), so mark additional active apps as inactive.
|
|
app.isActive = false
|
|
}
|
|
else
|
|
{
|
|
activeAppsCount += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
try context.save()
|
|
}
|
|
catch
|
|
{
|
|
print("Error while fetching installed apps")
|
|
}
|
|
|
|
#endif
|
|
}
|
|
|
|
@discardableResult
|
|
func findServer(context: OperationContext = OperationContext(), completionHandler: @escaping (Result<Server, Error>) -> Void) -> FindServerOperation
|
|
{
|
|
let findServerOperation = FindServerOperation(context: context)
|
|
findServerOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let server): context.server = server
|
|
}
|
|
}
|
|
|
|
self.run([findServerOperation])
|
|
|
|
return findServerOperation
|
|
}
|
|
|
|
@discardableResult
|
|
func authenticate(presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<(ALTTeam, ALTCertificate, ALTAppleAPISession), Error>) -> Void) -> AuthenticationOperation
|
|
{
|
|
if let operation = context.authenticationOperation
|
|
{
|
|
return operation
|
|
}
|
|
|
|
let findServerOperation = self.findServer(context: context) { _ in }
|
|
|
|
let authenticationOperation = AuthenticationOperation(context: context, presentingViewController: presentingViewController)
|
|
authenticationOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success: break
|
|
}
|
|
|
|
completionHandler(result)
|
|
}
|
|
authenticationOperation.addDependency(findServerOperation)
|
|
|
|
self.run([authenticationOperation])
|
|
|
|
return authenticationOperation
|
|
}
|
|
}
|
|
|
|
extension AppManager
|
|
{
|
|
func fetchSource(completionHandler: @escaping (Result<Source, Error>) -> Void)
|
|
{
|
|
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
guard let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context) else {
|
|
return completionHandler(.failure(OperationError.noSources))
|
|
}
|
|
|
|
let fetchSourceOperation = FetchSourceOperation(sourceURL: source.sourceURL)
|
|
fetchSourceOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error):
|
|
completionHandler(.failure(error))
|
|
|
|
case .success(let source):
|
|
completionHandler(.success(source))
|
|
NotificationCenter.default.post(name: AppManager.didFetchSourceNotification, object: self)
|
|
}
|
|
}
|
|
self.run([fetchSourceOperation])
|
|
}
|
|
}
|
|
|
|
func fetchAppIDs(completionHandler: @escaping (Result<([AppID], NSManagedObjectContext), Error>) -> Void)
|
|
{
|
|
let authenticationOperation = self.authenticate(presentingViewController: nil) { (result) in
|
|
print("Authenticated for fetching App IDs with result:", result)
|
|
}
|
|
|
|
let fetchAppIDsOperation = FetchAppIDsOperation(context: authenticationOperation.context)
|
|
fetchAppIDsOperation.resultHandler = completionHandler
|
|
fetchAppIDsOperation.addDependency(authenticationOperation)
|
|
self.run([fetchAppIDsOperation])
|
|
}
|
|
|
|
@discardableResult
|
|
func install<T: AppProtocol>(_ app: T, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let group = RefreshGroup(context: context)
|
|
group.completionHandler = { (results) in
|
|
do
|
|
{
|
|
guard let result = results.values.first else { throw OperationError.unknown }
|
|
completionHandler(result)
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
|
|
let operation = AppOperation.install(app)
|
|
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
|
|
|
return group.progress
|
|
}
|
|
|
|
@discardableResult
|
|
func refresh(_ installedApps: [InstalledApp], presentingViewController: UIViewController?, group: RefreshGroup? = nil) -> RefreshGroup
|
|
{
|
|
let group = group ?? RefreshGroup()
|
|
|
|
let operations = installedApps.map { AppOperation.refresh($0) }
|
|
return self.perform(operations, presentingViewController: presentingViewController, group: group)
|
|
}
|
|
|
|
func activate(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
|
|
{
|
|
let group = self.refresh([installedApp], presentingViewController: presentingViewController)
|
|
group.completionHandler = { (results) in
|
|
do
|
|
{
|
|
guard let result = results.values.first else { throw OperationError.unknown }
|
|
|
|
let installedApp = try result.get()
|
|
installedApp.managedObjectContext?.perform {
|
|
installedApp.isActive = true
|
|
completionHandler(.success(installedApp))
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func deactivate(_ installedApp: InstalledApp, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
|
|
{
|
|
let context = OperationContext()
|
|
|
|
let findServerOperation = self.findServer(context: context) { _ in }
|
|
|
|
let deactivateAppOperation = DeactivateAppOperation(app: installedApp, context: context)
|
|
deactivateAppOperation.resultHandler = { (result) in
|
|
completionHandler(result)
|
|
}
|
|
deactivateAppOperation.addDependency(findServerOperation)
|
|
|
|
self.run([deactivateAppOperation], requiresSerialQueue: true)
|
|
}
|
|
|
|
func installationProgress(for app: AppProtocol) -> Progress?
|
|
{
|
|
let progress = self.installationProgress[app.bundleIdentifier]
|
|
return progress
|
|
}
|
|
|
|
func refreshProgress(for app: AppProtocol) -> Progress?
|
|
{
|
|
let progress = self.refreshProgress[app.bundleIdentifier]
|
|
return progress
|
|
}
|
|
}
|
|
|
|
private extension AppManager
|
|
{
|
|
enum AppOperation
|
|
{
|
|
case install(AppProtocol)
|
|
case refresh(AppProtocol)
|
|
|
|
var app: AppProtocol {
|
|
switch self
|
|
{
|
|
case .install(let app), .refresh(let app): return app
|
|
}
|
|
}
|
|
|
|
var bundleIdentifier: String {
|
|
var bundleIdentifier: String!
|
|
|
|
if let context = (self.app as? NSManagedObject)?.managedObjectContext
|
|
{
|
|
context.performAndWait { bundleIdentifier = self.app.bundleIdentifier }
|
|
}
|
|
else
|
|
{
|
|
bundleIdentifier = self.app.bundleIdentifier
|
|
}
|
|
|
|
return bundleIdentifier
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func perform(_ operations: [AppOperation], presentingViewController: UIViewController?, group: RefreshGroup) -> RefreshGroup
|
|
{
|
|
let operations = operations.filter { self.progress(for: $0) == nil || self.progress(for: $0)?.isCancelled == true }
|
|
|
|
for operation in operations
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
self.set(progress, for: operation)
|
|
}
|
|
|
|
/* Authenticate (if necessary) */
|
|
var authenticationOperation: AuthenticationOperation?
|
|
if group.context.session == nil
|
|
{
|
|
authenticationOperation = self.authenticate(presentingViewController: presentingViewController, context: group.context) { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): group.context.error = error
|
|
case .success: break
|
|
}
|
|
}
|
|
}
|
|
|
|
func performAppOperations()
|
|
{
|
|
for operation in operations
|
|
{
|
|
let progress = self.progress(for: operation)
|
|
|
|
if let progress = progress
|
|
{
|
|
group.progress.totalUnitCount += 1
|
|
group.progress.addChild(progress, withPendingUnitCount: 1)
|
|
|
|
if group.context.session != nil
|
|
{
|
|
// Finished authenticating, so increase completed unit count.
|
|
progress.completedUnitCount += 20
|
|
}
|
|
}
|
|
|
|
switch operation
|
|
{
|
|
case .refresh(let installedApp as InstalledApp) where installedApp.certificateSerialNumber == group.context.certificate?.serialNumber:
|
|
// Refreshing apps, but using same certificate as last time, so we can just refresh provisioning profiles.
|
|
|
|
let refreshProgress = self._refresh(installedApp, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(refreshProgress, withPendingUnitCount: 80)
|
|
|
|
case .refresh(let app), .install(let app):
|
|
// Either installing for first time, or refreshing with a different signing certificate,
|
|
// so we need to resign the app then install it.
|
|
|
|
let installProgress = self._install(app, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(installProgress, withPendingUnitCount: 80)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let authenticationOperation = authenticationOperation
|
|
{
|
|
let awaitAuthenticationOperation = BlockOperation {
|
|
if let managedObjectContext = operations.lazy.compactMap({ ($0.app as? NSManagedObject)?.managedObjectContext }).first
|
|
{
|
|
managedObjectContext.perform { performAppOperations() }
|
|
}
|
|
else
|
|
{
|
|
performAppOperations()
|
|
}
|
|
}
|
|
awaitAuthenticationOperation.addDependency(authenticationOperation)
|
|
self.run([awaitAuthenticationOperation], requiresSerialQueue: true)
|
|
}
|
|
else
|
|
{
|
|
performAppOperations()
|
|
}
|
|
|
|
return group
|
|
}
|
|
|
|
|
|
private func _install(_ app: AppProtocol, group: RefreshGroup, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
|
|
let context = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
context.beginInstallationHandler = group.beginInstallationHandler
|
|
|
|
/* Download */
|
|
let downloadOperation = DownloadAppOperation(app: app, context: context)
|
|
downloadOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let app): context.app = app
|
|
}
|
|
}
|
|
progress.addChild(downloadOperation.progress, withPendingUnitCount: 25)
|
|
|
|
|
|
/* Refresh Anisette Data */
|
|
let refreshAnisetteDataOperation = FetchAnisetteDataOperation(context: group.context)
|
|
refreshAnisetteDataOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let anisetteData): group.context.session?.anisetteData = anisetteData
|
|
}
|
|
}
|
|
refreshAnisetteDataOperation.addDependency(downloadOperation)
|
|
|
|
|
|
/* Fetch Provisioning Profiles */
|
|
let fetchProvisioningProfilesOperation = FetchProvisioningProfilesOperation(context: context)
|
|
fetchProvisioningProfilesOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let provisioningProfiles): context.provisioningProfiles = provisioningProfiles
|
|
}
|
|
}
|
|
fetchProvisioningProfilesOperation.addDependency(refreshAnisetteDataOperation)
|
|
progress.addChild(fetchProvisioningProfilesOperation.progress, withPendingUnitCount: 5)
|
|
|
|
|
|
/* Resign */
|
|
let resignAppOperation = ResignAppOperation(context: context)
|
|
resignAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let resignedApp): context.resignedApp = resignedApp
|
|
}
|
|
}
|
|
resignAppOperation.addDependency(fetchProvisioningProfilesOperation)
|
|
progress.addChild(resignAppOperation.progress, withPendingUnitCount: 20)
|
|
|
|
|
|
/* Send */
|
|
let sendAppOperation = SendAppOperation(context: context)
|
|
sendAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let installationConnection): context.installationConnection = installationConnection
|
|
}
|
|
}
|
|
sendAppOperation.addDependency(resignAppOperation)
|
|
progress.addChild(sendAppOperation.progress, withPendingUnitCount: 20)
|
|
|
|
|
|
/* Install */
|
|
let installOperation = InstallAppOperation(context: context)
|
|
installOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): completionHandler(.failure(error))
|
|
case .success(let installedApp):
|
|
if let app = app as? StoreApp, let storeApp = installedApp.managedObjectContext?.object(with: app.objectID) as? StoreApp
|
|
{
|
|
installedApp.storeApp = storeApp
|
|
}
|
|
|
|
if let index = UserDefaults.standard.legacySideloadedApps?.firstIndex(of: installedApp.bundleIdentifier)
|
|
{
|
|
// No longer a legacy sideloaded app, so remove it from cached list.
|
|
UserDefaults.standard.legacySideloadedApps?.remove(at: index)
|
|
}
|
|
|
|
completionHandler(.success(installedApp))
|
|
}
|
|
}
|
|
progress.addChild(installOperation.progress, withPendingUnitCount: 30)
|
|
installOperation.addDependency(sendAppOperation)
|
|
|
|
let operations = [downloadOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation]
|
|
group.add(operations)
|
|
self.run(operations)
|
|
|
|
return progress
|
|
}
|
|
|
|
private func _refresh(_ app: InstalledApp, group: RefreshGroup, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
|
|
let context = AppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
context.app = ALTApplication(fileURL: app.url)
|
|
|
|
/* Fetch Provisioning Profiles */
|
|
let fetchProvisioningProfilesOperation = FetchProvisioningProfilesOperation(context: context)
|
|
fetchProvisioningProfilesOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let provisioningProfiles): context.provisioningProfiles = provisioningProfiles
|
|
}
|
|
}
|
|
progress.addChild(fetchProvisioningProfilesOperation.progress, withPendingUnitCount: 60)
|
|
|
|
/* Refresh */
|
|
let refreshAppOperation = RefreshAppOperation(context: context)
|
|
refreshAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .success(let installedApp):
|
|
completionHandler(.success(installedApp))
|
|
|
|
case .failure(ALTServerError.unknownRequest):
|
|
// Fall back to installation if AltServer doesn't support newer provisioning profile requests.
|
|
app.managedObjectContext?.perform {
|
|
let installProgress = self._install(app, group: group) { (result) in
|
|
completionHandler(result)
|
|
}
|
|
progress.addChild(installProgress, withPendingUnitCount: 40)
|
|
}
|
|
|
|
case .failure(let error):
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
progress.addChild(refreshAppOperation.progress, withPendingUnitCount: 40)
|
|
refreshAppOperation.addDependency(fetchProvisioningProfilesOperation)
|
|
|
|
let operations = [fetchProvisioningProfilesOperation, refreshAppOperation]
|
|
group.add(operations)
|
|
self.run(operations)
|
|
|
|
return progress
|
|
}
|
|
|
|
func finish(_ operation: AppOperation, result: Result<InstalledApp, Error>, group: RefreshGroup, progress: Progress?)
|
|
{
|
|
let result = result.mapError { (resultError) -> Error in
|
|
guard let error = resultError as? ALTServerError else { return resultError }
|
|
|
|
switch error.code
|
|
{
|
|
case .deviceNotFound, .lostConnection:
|
|
if let server = group.context.server, server.isPreferred || server.isWiredConnection
|
|
{
|
|
// Preferred server (or wired connection), so report errors normally.
|
|
return error
|
|
}
|
|
else
|
|
{
|
|
// Not preferred server, so ignore these specific errors and throw serverNotFound instead.
|
|
return ConnectionError.serverNotFound
|
|
}
|
|
|
|
default: return error
|
|
}
|
|
}
|
|
|
|
// Must remove before saving installedApp.
|
|
if let currentProgress = self.progress(for: operation), currentProgress == progress
|
|
{
|
|
// Only remove progress if it hasn't been replaced by another one.
|
|
self.set(nil, for: operation)
|
|
}
|
|
|
|
do
|
|
{
|
|
let installedApp = try result.get()
|
|
group.set(.success(installedApp), forAppWithBundleIdentifier: installedApp.bundleIdentifier)
|
|
|
|
if installedApp.bundleIdentifier == StoreApp.altstoreAppID
|
|
{
|
|
self.scheduleExpirationWarningLocalNotification(for: installedApp)
|
|
}
|
|
|
|
do { try installedApp.managedObjectContext?.save() }
|
|
catch { print("Error saving installed app.", error) }
|
|
}
|
|
catch
|
|
{
|
|
group.set(.failure(error), forAppWithBundleIdentifier: operation.bundleIdentifier)
|
|
}
|
|
}
|
|
|
|
func scheduleExpirationWarningLocalNotification(for app: InstalledApp)
|
|
{
|
|
let notificationDate = app.expirationDate.addingTimeInterval(-1 * 60 * 60 * 24) // 24 hours before expiration.
|
|
|
|
let timeIntervalUntilNotification = notificationDate.timeIntervalSinceNow
|
|
guard timeIntervalUntilNotification > 0 else {
|
|
// Crashes if we pass negative value to UNTimeIntervalNotificationTrigger initializer.
|
|
return
|
|
}
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeIntervalUntilNotification, repeats: false)
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = NSLocalizedString("AltStore Expiring Soon", comment: "")
|
|
content.body = NSLocalizedString("AltStore will expire in 24 hours. Open the app and refresh it to prevent it from expiring.", comment: "")
|
|
content.sound = .default
|
|
|
|
let request = UNNotificationRequest(identifier: AppManager.expirationWarningNotificationID, content: content, trigger: trigger)
|
|
UNUserNotificationCenter.current().add(request)
|
|
}
|
|
|
|
func run(_ operations: [Foundation.Operation], requiresSerialQueue: Bool = false)
|
|
{
|
|
for operation in operations
|
|
{
|
|
switch operation
|
|
{
|
|
case _ where requiresSerialQueue: fallthrough
|
|
case is InstallAppOperation, is RefreshAppOperation:
|
|
if let previousOperation = self.serialOperationQueue.operations.last
|
|
{
|
|
// Ensure operations execute in the order they're added, since they may become ready at different points.
|
|
operation.addDependency(previousOperation)
|
|
}
|
|
|
|
self.serialOperationQueue.addOperation(operation)
|
|
|
|
default:
|
|
self.operationQueue.addOperation(operation)
|
|
}
|
|
}
|
|
}
|
|
|
|
func progress(for operation: AppOperation) -> Progress?
|
|
{
|
|
switch operation
|
|
{
|
|
case .install: return self.installationProgress[operation.bundleIdentifier]
|
|
case .refresh: return self.refreshProgress[operation.bundleIdentifier]
|
|
}
|
|
}
|
|
|
|
func set(_ progress: Progress?, for operation: AppOperation)
|
|
{
|
|
switch operation
|
|
{
|
|
case .install: self.installationProgress[operation.bundleIdentifier] = progress
|
|
case .refresh: self.refreshProgress[operation.bundleIdentifier] = progress
|
|
}
|
|
}
|
|
}
|