diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index d33aeef1..71cfe90d 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ BF44CC6C232AEB90004DA9C3 /* LaunchAtLogin.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF44CC6A232AEB74004DA9C3 /* LaunchAtLogin.framework */; }; BF44CC6D232AEB90004DA9C3 /* LaunchAtLogin.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF44CC6A232AEB74004DA9C3 /* LaunchAtLogin.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; BF44EEF0246B08BA002A52F2 /* BackupController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF44EEEF246B08BA002A52F2 /* BackupController.swift */; }; + BF44EEF3246B3A17002A52F2 /* AltBackup.ipa in Resources */ = {isa = PBXBuildFile; fileRef = BF44EEF2246B3A17002A52F2 /* AltBackup.ipa */; }; BF44EEFC246B4550002A52F2 /* RemoveAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF44EEFB246B4550002A52F2 /* RemoveAppOperation.swift */; }; BF458690229872EA00BD7491 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF45868F229872EA00BD7491 /* AppDelegate.swift */; }; BF458694229872EA00BD7491 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BF458693229872EA00BD7491 /* Assets.xcassets */; }; @@ -375,6 +376,7 @@ BF43002F22A71C960051E2BC /* UserDefaults+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+AltStore.swift"; sourceTree = ""; }; BF44CC6A232AEB74004DA9C3 /* LaunchAtLogin.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LaunchAtLogin.framework; path = Carthage/Build/Mac/LaunchAtLogin.framework; sourceTree = ""; }; BF44EEEF246B08BA002A52F2 /* BackupController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupController.swift; sourceTree = ""; }; + BF44EEF2246B3A17002A52F2 /* AltBackup.ipa */ = {isa = PBXFileReference; lastKnownFileType = file; path = AltBackup.ipa; sourceTree = ""; }; BF44EEFB246B4550002A52F2 /* RemoveAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveAppOperation.swift; sourceTree = ""; }; BF45868D229872EA00BD7491 /* AltServer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AltServer.app; sourceTree = BUILT_PRODUCTS_DIR; }; BF45868F229872EA00BD7491 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -1171,6 +1173,7 @@ BFD247962284D7C100981D42 /* Resources */ = { isa = PBXGroup; children = ( + BF44EEF2246B3A17002A52F2 /* AltBackup.ipa */, BFB1169C22932DB100BB457C /* apps.json */, BFD247762284B9A700981D42 /* Assets.xcassets */, BF770E6822BD57DD002A40FE /* Silence.m4a */, @@ -1588,6 +1591,7 @@ BFB4323F22DE852000B7F8BC /* UpdateCollectionViewCell.xib in Resources */, BFE60738231ADF49002B0E8E /* Settings.storyboard in Resources */, BFD2477A2284B9A700981D42 /* LaunchScreen.storyboard in Resources */, + BF44EEF3246B3A17002A52F2 /* AltBackup.ipa in Resources */, BF770E6922BD57DD002A40FE /* Silence.m4a in Resources */, BFD247772284B9A700981D42 /* Assets.xcassets in Resources */, BFF0B6922321A305007A79E1 /* AboutPatreonHeaderView.xib in Resources */, diff --git a/AltStore/Info.plist b/AltStore/Info.plist index 1f9ca88e..90def6a1 100644 --- a/AltStore/Info.plist +++ b/AltStore/Info.plist @@ -53,6 +53,16 @@ altstore + + CFBundleTypeRole + Editor + CFBundleURLName + AltStore Backup + CFBundleURLSchemes + + altstore-com.rileytestut.AltStore + + CFBundleVersion 1 diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index c8688380..e5c89652 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -74,6 +74,15 @@ extension AppManager guard !self.isActivelyManagingApp(withBundleID: app.bundleIdentifier) else { continue } + if !UserDefaults.standard.isLegacyDeactivationSupported + { + // We can't (ab)use provisioning profiles to deactivate apps, + // which means we must delete apps to free up active slots. + // So, only check if active apps are installed to prevent + // false positives when checking inactive apps. + guard app.isActive else { continue } + } + let uti = UTTypeCopyDeclaration(app.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary? if uti == nil && !legacySideloadedApps.contains(app.bundleIdentifier) { @@ -329,19 +338,49 @@ extension AppManager } } - func deactivate(_ installedApp: InstalledApp, completionHandler: @escaping (Result) -> Void) + func deactivate(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) { - let context = OperationContext() - - let findServerOperation = self.findServer(context: context) { _ in } - - let deactivateAppOperation = DeactivateAppOperation(app: installedApp, context: context) - deactivateAppOperation.resultHandler = { (result) in - completionHandler(result) + if UserDefaults.standard.isLegacyDeactivationSupported + { + // Normally we pipe everything down into perform(), + // but the pre-iOS 13.5 deactivation method doesn't require + // authentication, so we keep it separate. + 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], context: context, requiresSerialQueue: true) + } + else + { + let group = RefreshGroup() + group.completionHandler = { (results) in + do + { + guard let result = results.values.first else { throw OperationError.unknown } + + let installedApp = try result.get() + assert(installedApp.managedObjectContext != nil) + + installedApp.managedObjectContext?.perform { + completionHandler(.success(installedApp)) + } + } + catch + { + completionHandler(.failure(error)) + } + } + + let operation = AppOperation.deactivate(installedApp) + self.perform([operation], presentingViewController: presentingViewController, group: group) } - deactivateAppOperation.addDependency(findServerOperation) - - self.run([deactivateAppOperation], context: context, requiresSerialQueue: true) } func remove(_ installedApp: InstalledApp, completionHandler: @escaping (Result) -> Void) @@ -405,12 +444,15 @@ private extension AppManager { case install(AppProtocol) case update(AppProtocol) - case refresh(AppProtocol) + case refresh(InstalledApp) + case deactivate(InstalledApp) var app: AppProtocol { switch self { - case .install(let app), .update(let app), .refresh(let app): return app + case .install(let app), .update(let app), + .refresh(let app as AppProtocol), .deactivate(let app as AppProtocol): + return app } } @@ -485,22 +527,43 @@ private extension AppManager 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, operation: operation, 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), .update(let app): - // Either installing for first time, or refreshing with a different signing certificate, - // so we need to resign the app then install it. - + case .install(let app), .update(let app): let installProgress = self._install(app, operation: operation, group: group) { (result) in self.finish(operation, result: result, group: group, progress: progress) } progress?.addChild(installProgress, withPendingUnitCount: 80) + + case .activate(let app): fallthrough + case .refresh(let app): + // Check if backup app is installed in place of real app. + let uti = UTTypeCopyDeclaration(app.installedBackupAppUTI as CFString)?.takeRetainedValue() as NSDictionary? + + if app.certificateSerialNumber == group.context.certificate?.serialNumber && uti == nil + { + // Refreshing with same certificate as last time, and backup app isn't still installed, + // so we can just refresh provisioning profiles. + + let refreshProgress = self._refresh(app, operation: operation, group: group) { (result) in + self.finish(operation, result: result, group: group, progress: progress) + } + progress?.addChild(refreshProgress, withPendingUnitCount: 80) + } + else + { + // Refreshing using different certificate or backup app is still installed, + // so we need to resign + install. + + let installProgress = self._install(app, operation: operation, group: group) { (result) in + self.finish(operation, result: result, group: group, progress: progress) + } + progress?.addChild(installProgress, withPendingUnitCount: 80) + } + + case .deactivate(let app): + let deactivateProgress = self._deactivate(app, operation: operation, group: group) { (result) in + self.finish(operation, result: result, group: group, progress: progress) + } + progress?.addChild(deactivateProgress, withPendingUnitCount: 80) } } } @@ -528,11 +591,13 @@ private extension AppManager return group } - private func _install(_ app: AppProtocol, operation: AppOperation, group: RefreshGroup, additionalEntitlements: [ALTEntitlement: Any]? = nil, cacheApp: Bool = true, completionHandler: @escaping (Result) -> Void) -> Progress + private func _install(_ app: AppProtocol, operation: AppOperation, group: RefreshGroup, context: InstallAppOperationContext? = nil, additionalEntitlements: [ALTEntitlement: Any]? = nil, cacheApp: Bool = true, completionHandler: @escaping (Result) -> Void) -> Progress { let progress = Progress.discreteProgress(totalUnitCount: 100) - let context = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) + let context = context ?? InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) + assert(context.authenticatedContext === group.context) + context.beginInstallationHandler = { (installedApp) in switch operation { @@ -578,6 +643,7 @@ private extension AppManager } progress.addChild(downloadOperation.progress, withPendingUnitCount: 25) + /* Verify App */ let verifyOperation = VerifyAppOperation(context: context) verifyOperation.resultHandler = { (result) in @@ -589,6 +655,7 @@ private extension AppManager } verifyOperation.addDependency(downloadOperation) + /* Refresh Anisette Data */ let refreshAnisetteDataOperation = FetchAnisetteDataOperation(context: group.context) refreshAnisetteDataOperation.resultHandler = { (result) in @@ -648,6 +715,8 @@ private extension AppManager { case .failure(let error): completionHandler(.failure(error)) case .success(let installedApp): + context.installedApp = installedApp + if let app = app as? StoreApp, let storeApp = installedApp.managedObjectContext?.object(with: app.objectID) as? StoreApp { installedApp.storeApp = storeApp @@ -678,7 +747,7 @@ private extension AppManager 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 @@ -689,7 +758,7 @@ private extension AppManager } } progress.addChild(fetchProvisioningProfilesOperation.progress, withPendingUnitCount: 60) - + /* Refresh */ let refreshAppOperation = RefreshAppOperation(context: context) refreshAppOperation.resultHandler = { (result) in @@ -718,6 +787,140 @@ private extension AppManager let operations = [fetchProvisioningProfilesOperation, refreshAppOperation] group.add(operations) self.run(operations, context: group.context) + + return progress + } + + private func _deactivate(_ app: InstalledApp, operation appOperation: AppOperation, group: RefreshGroup, completionHandler: @escaping (Result) -> Void) -> Progress + { + let progress = Progress.discreteProgress(totalUnitCount: 100) + let context = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) + + let installBackupAppProgress = Progress.discreteProgress(totalUnitCount: 100) + let installBackupAppOperation = RSTAsyncBlockOperation { [weak self] (operation) in + app.managedObjectContext?.perform { + guard let self = self else { return } + + let progress = self._installBackupApp(for: app, operation: appOperation, group: group, context: context) { (result) in + switch result + { + case .success(let installedApp): context.installedApp = installedApp + case .failure(let error): context.error = error + } + + operation.finish() + } + installBackupAppProgress.addChild(progress, withPendingUnitCount: 100) + } + } + progress.addChild(installBackupAppProgress, withPendingUnitCount: 70) + + let backupAppOperation = BackupAppOperation(action: .backup, context: context) + backupAppOperation.resultHandler = { (result) in + switch result + { + case .failure(let error): context.error = error + case .success: break + } + } + backupAppOperation.addDependency(installBackupAppOperation) + progress.addChild(backupAppOperation.progress, withPendingUnitCount: 15) + + let removeAppOperation = RemoveAppOperation(context: context) + removeAppOperation.resultHandler = { (result) in + completionHandler(result) + } + removeAppOperation.addDependency(backupAppOperation) + progress.addChild(removeAppOperation.progress, withPendingUnitCount: 15) + + group.add([installBackupAppOperation, backupAppOperation, removeAppOperation]) + self.run([installBackupAppOperation, backupAppOperation, removeAppOperation], context: group.context) + + return progress + } + + private func _installBackupApp(for app: InstalledApp, operation appOperation: AppOperation, group: RefreshGroup, context: InstallAppOperationContext, completionHandler: @escaping (Result) -> Void) -> Progress + { + let progress = Progress.discreteProgress(totalUnitCount: 100) + + guard let application = ALTApplication(fileURL: app.fileURL) else { + completionHandler(.failure(OperationError.appNotFound)) + return progress + } + + let prepareProgress = Progress.discreteProgress(totalUnitCount: 1) + let prepareOperation = RSTAsyncBlockOperation { (operation) in + app.managedObjectContext?.perform { + do + { + let temporaryDirectoryURL = context.temporaryDirectory.appendingPathComponent("AltBackup-" + UUID().uuidString) + try FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) + + guard let altbackupFileURL = Bundle.main.url(forResource: "AltBackup", withExtension: "ipa") else { throw OperationError.appNotFound } + + let unzippedAppBundleURL = try FileManager.default.unzipAppBundle(at: altbackupFileURL, toDirectory: temporaryDirectoryURL) + guard let unzippedAppBundle = Bundle(url: unzippedAppBundleURL) else { throw OperationError.invalidApp } + + if var infoDictionary = unzippedAppBundle.infoDictionary + { + // Replace name + bundle identifier so AltStore treats it as the same app. + infoDictionary["CFBundleDisplayName"] = app.name + infoDictionary[kCFBundleIdentifierKey as String] = app.bundleIdentifier + + // Add app-specific exported UTI so we can check later if this temporary backup app is still installed or not. + let installedAppUTI = ["UTTypeConformsTo": [], + "UTTypeDescription": "AltStore Backup App", + "UTTypeIconFiles": [], + "UTTypeIdentifier": app.installedBackupAppUTI, + "UTTypeTagSpecification": [:]] as [String : Any] + + var exportedUTIs = infoDictionary[Bundle.Info.exportedUTIs] as? [[String: Any]] ?? [] + exportedUTIs.append(installedAppUTI) + infoDictionary[Bundle.Info.exportedUTIs] = exportedUTIs + + try (infoDictionary as NSDictionary).write(to: unzippedAppBundle.infoPlistURL) + } + + guard let backupApp = ALTApplication(fileURL: unzippedAppBundleURL) else { throw OperationError.invalidApp } + context.app = backupApp + + prepareProgress.completedUnitCount += 1 + } + catch + { + print(error) + } + + operation.finish() + } + } + progress.addChild(prepareProgress, withPendingUnitCount: 20) + + let installProgress = Progress.discreteProgress(totalUnitCount: 100) + let installOperation = RSTAsyncBlockOperation { [weak self] (operation) in + guard let self = self else { return } + + guard let backupApp = context.app else { + context.error = OperationError.invalidApp + operation.finish() + return + } + + var appGroups = application.entitlements[.appGroups] as? [String] ?? [] + appGroups.append(Bundle.baseAltStoreAppGroupID) + + let additionalEntitlements: [ALTEntitlement: Any] = [.appGroups: appGroups] + let progress = self._install(backupApp, operation: appOperation, group: group, context: context, additionalEntitlements: additionalEntitlements, cacheApp: false) { (result) in + completionHandler(result) + operation.finish() + } + installProgress.addChild(progress, withPendingUnitCount: 100) + } + installOperation.addDependency(prepareOperation) + progress.addChild(installProgress, withPendingUnitCount: 80) + + group.add([prepareOperation, installOperation]) + self.run([prepareOperation, installOperation], context: group.context) return progress } @@ -775,6 +978,7 @@ private extension AppManager event = nil case .update: event = .updatedApp(installedApp) + case .deactivate: event = nil } if let event = event @@ -819,17 +1023,8 @@ private extension AppManager switch operation { case _ where requiresSerialQueue: fallthrough - case is InstallAppOperation, is RefreshAppOperation: - if let context = context, let previousOperation = self.serialOperationQueue.operations.last(where: { context.operations.contains($0) }) - { - // Ensure operations execute in the order they're added (in same context), since they may become ready at different points. - operation.addDependency(previousOperation) - } - - self.serialOperationQueue.addOperation(operation) - - default: - self.operationQueue.addOperation(operation) + case is InstallAppOperation, is RefreshAppOperation, is BackupAppOperation: self.serialOperationQueue.addOperation(operation) + default: self.operationQueue.addOperation(operation) } context?.operations.add(operation) @@ -841,7 +1036,7 @@ private extension AppManager switch operation { case .install, .update: return self.installationProgress[operation.bundleIdentifier] - case .refresh: return self.refreshProgress[operation.bundleIdentifier] + case .refresh, .deactivate: return self.refreshProgress[operation.bundleIdentifier] } } @@ -850,7 +1045,7 @@ private extension AppManager switch operation { case .install, .update: self.installationProgress[operation.bundleIdentifier] = progress - case .refresh: self.refreshProgress[operation.bundleIdentifier] = progress + case .refresh, .deactivate: self.refreshProgress[operation.bundleIdentifier] = progress } } } diff --git a/AltStore/Model/InstalledApp.swift b/AltStore/Model/InstalledApp.swift index dc4b2193..36170e0c 100644 --- a/AltStore/Model/InstalledApp.swift +++ b/AltStore/Model/InstalledApp.swift @@ -247,6 +247,12 @@ extension InstalledApp return installedAppUTI } + class func installedBackupAppUTI(forBundleIdentifier bundleIdentifier: String) -> String + { + let installedBackupAppUTI = InstalledApp.installedAppUTI(forBundleIdentifier: bundleIdentifier) + ".backup" + return installedBackupAppUTI + } + var directoryURL: URL { return InstalledApp.directoryURL(for: self) } @@ -262,4 +268,8 @@ extension InstalledApp var installedAppUTI: String { return InstalledApp.installedAppUTI(forBundleIdentifier: self.resignedBundleIdentifier) } + + var installedBackupAppUTI: String { + return InstalledApp.installedBackupAppUTI(forBundleIdentifier: self.resignedBundleIdentifier) + } } diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index d5d6f987..232bb391 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -404,6 +404,15 @@ private extension MyAppsViewController // Ensure no leftover progress from active apps cell reuse. cell.bannerView.button.progress = nil + + if let progress = AppManager.shared.refreshProgress(for: installedApp), progress.fractionCompleted < 1.0 + { + cell.bannerView.button.progress = progress + } + else + { + cell.bannerView.button.progress = nil + } } dataSource.prefetchHandler = { (item, indexPath, completion) in let fileURL = item.fileURL @@ -887,7 +896,7 @@ private extension MyAppsViewController guard installedApp.isActive else { return } installedApp.isActive = false - AppManager.shared.deactivate(installedApp) { (result) in + AppManager.shared.deactivate(installedApp, presentingViewController: self) { (result) in do { let app = try result.get() diff --git a/AltStore/Operations/OperationContexts.swift b/AltStore/Operations/OperationContexts.swift index 97bc0dfb..1cf990c0 100644 --- a/AltStore/Operations/OperationContexts.swift +++ b/AltStore/Operations/OperationContexts.swift @@ -63,7 +63,7 @@ class AuthenticatedOperationContext: OperationContext class AppOperationContext { let bundleIdentifier: String - private let authenticatedContext: AuthenticatedOperationContext + let authenticatedContext: AuthenticatedOperationContext var app: ALTApplication? var provisioningProfiles: [String: ALTProvisioningProfile]? diff --git a/AltStore/Operations/RefreshGroup.swift b/AltStore/Operations/RefreshGroup.swift index 9d487b6b..721fc26e 100644 --- a/AltStore/Operations/RefreshGroup.swift +++ b/AltStore/Operations/RefreshGroup.swift @@ -21,6 +21,10 @@ class RefreshGroup: NSObject private(set) var results = [String: Result]() + // Keep strong references to managed object contexts + // so they don't die out from under us. + private(set) var _contexts = Set() + private var isFinished = false private let dispatchGroup = DispatchGroup() @@ -33,6 +37,8 @@ class RefreshGroup: NSObject super.init() } + /// Used to keep track of which operations belong to this group. + /// This does _not_ add them to any operation queue. func add(_ operations: [Foundation.Operation]) { for operation in operations @@ -57,6 +63,14 @@ class RefreshGroup: NSObject func set(_ result: Result, forAppWithBundleIdentifier bundleIdentifier: String) { self.results[bundleIdentifier] = result + + switch result + { + case .failure: break + case .success(let installedApp): + guard let context = installedApp.managedObjectContext else { break } + self._contexts.insert(context) + } } func cancel() diff --git a/AltStore/Operations/SendAppOperation.swift b/AltStore/Operations/SendAppOperation.swift index 75b35ec8..9d8835dc 100644 --- a/AltStore/Operations/SendAppOperation.swift +++ b/AltStore/Operations/SendAppOperation.swift @@ -89,8 +89,13 @@ private extension SendAppOperation connection.send(appData, prependSize: false) { (result) in switch result { - case .failure(let error): completionHandler(.failure(error)) - case .success: completionHandler(.success(())) + case .failure(let error): + print("Failed to send app data (\(appData.count) bytes)") + completionHandler(.failure(error)) + + case .success: + print("Successfully sent app data (\(appData.count) bytes)") + completionHandler(.success(())) } } } diff --git a/AltStore/Resources/AltBackup.ipa b/AltStore/Resources/AltBackup.ipa new file mode 100644 index 00000000..b08d0da0 Binary files /dev/null and b/AltStore/Resources/AltBackup.ipa differ