diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 383c2bcb..b0d3c9c5 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ BF26A0E12370C5D400F53F9F /* ALTSourceUserInfoKey.m in Sources */ = {isa = PBXBuildFile; fileRef = BF26A0E02370C5D400F53F9F /* ALTSourceUserInfoKey.m */; }; BF29012F2318F6B100D88A45 /* AppBannerView.xib in Resources */ = {isa = PBXBuildFile; fileRef = BF29012E2318F6B100D88A45 /* AppBannerView.xib */; }; BF2901312318F7A800D88A45 /* AppBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF2901302318F7A800D88A45 /* AppBannerView.swift */; }; + BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3432FA246B894F0052F4A1 /* BackupAppOperation.swift */; }; BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3BEFBE2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift */; }; BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3BEFC024086A1E00DE7D55 /* RefreshAppOperation.swift */; }; BF3D648822E79A3700E9056B /* AppPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3D648722E79A3700E9056B /* AppPermission.swift */; }; @@ -354,6 +355,7 @@ BF26A0E02370C5D400F53F9F /* ALTSourceUserInfoKey.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTSourceUserInfoKey.m; sourceTree = ""; }; BF29012E2318F6B100D88A45 /* AppBannerView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AppBannerView.xib; sourceTree = ""; }; BF2901302318F7A800D88A45 /* AppBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBannerView.swift; sourceTree = ""; }; + BF3432FA246B894F0052F4A1 /* BackupAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupAppOperation.swift; sourceTree = ""; }; BF3BEFBE2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchProvisioningProfilesOperation.swift; sourceTree = ""; }; BF3BEFC024086A1E00DE7D55 /* RefreshAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAppOperation.swift; sourceTree = ""; }; BF3D648722E79A3700E9056B /* AppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermission.swift; sourceTree = ""; }; @@ -1290,6 +1292,7 @@ BF56D2AB23DF8E170006506D /* FetchAppIDsOperation.swift */, BFC57A642416C72400EB891E /* DeactivateAppOperation.swift */, BFCCB519245E3401001853EA /* VerifyAppOperation.swift */, + BF3432FA246B894F0052F4A1 /* BackupAppOperation.swift */, ); path = Operations; sourceTree = ""; @@ -1912,6 +1915,7 @@ BF3D648D22E79AC800E9056B /* ALTAppPermission.m in Sources */, BFD5D6F2230DD974007955AB /* Benefit.swift in Sources */, BFF0B6942321CB85007A79E1 /* AuthenticationViewController.swift in Sources */, + BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */, BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */, BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */, BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */, diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index 65106524..f31273b7 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -55,7 +55,10 @@ extension AppDelegate static let openPatreonSettingsDeepLinkNotification = Notification.Name("com.rileytestut.AltStore.OpenPatreonSettingsDeepLinkNotification") static let importAppDeepLinkNotification = Notification.Name("com.rileytestut.AltStore.ImportAppDeepLinkNotification") + static let appBackupDidFinish = Notification.Name("com.rileytestut.AltStore.AppBackupDidFinish") + static let importAppDeepLinkURLKey = "fileURL" + static let appBackupResultKey = "result" } @UIApplicationMain @@ -134,13 +137,43 @@ private extension AppDelegate else { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } - guard let host = components.host, host.lowercased() == "patreon" else { return false } + guard let host = components.host?.lowercased() else { return false } - DispatchQueue.main.async { - NotificationCenter.default.post(name: AppDelegate.openPatreonSettingsDeepLinkNotification, object: nil) + switch host + { + case "patreon": + DispatchQueue.main.async { + NotificationCenter.default.post(name: AppDelegate.openPatreonSettingsDeepLinkNotification, object: nil) + } + + return true + + case "appbackupresponse": + let result: Result + + switch url.path.lowercased() + { + case "/success": result = .success(()) + case "/failure": + let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name] = $1.value } ?? [:] + guard + let errorDomain = queryItems["errorDomain"], + let errorCodeString = queryItems["errorCode"], let errorCode = Int(errorCodeString), + let errorDescription = queryItems["errorDescription"] + else { return false } + + let error = NSError(domain: errorDomain, code: errorCode, userInfo: [NSLocalizedDescriptionKey: errorDescription]) + result = .failure(error) + + default: return false + } + + NotificationCenter.default.post(name: AppDelegate.appBackupDidFinish, object: nil, userInfo: [AppDelegate.appBackupResultKey: result]) + + return true + + default: return false } - - return true } } } diff --git a/AltStore/Operations/BackupAppOperation.swift b/AltStore/Operations/BackupAppOperation.swift new file mode 100644 index 00000000..000e44db --- /dev/null +++ b/AltStore/Operations/BackupAppOperation.swift @@ -0,0 +1,183 @@ +// +// BackupAppOperation.swift +// AltStore +// +// Created by Riley Testut on 5/12/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import Foundation + +import AltKit +import AltSign + +extension BackupAppOperation +{ + enum Action: String + { + case backup + case restore + } +} + +@objc(BackupAppOperation) +class BackupAppOperation: ResultOperation +{ + let action: Action + let context: InstallAppOperationContext + + private var appName: String? + private var timeoutTimer: Timer? + + init(action: Action, context: InstallAppOperationContext) + { + self.action = action + self.context = context + + super.init() + } + + override func main() + { + super.main() + + do + { + if let error = self.context.error + { + throw error + } + + guard let installedApp = self.context.installedApp, let context = installedApp.managedObjectContext else { throw OperationError.invalidParameters } + context.perform { + do + { + let appName = installedApp.name + self.appName = appName + + guard let altstoreApp = InstalledApp.fetchAltStore(in: context) else { throw OperationError.appNotFound } + let altstoreOpenURL = altstoreApp.openAppURL + + var returnURLComponents = URLComponents(url: altstoreOpenURL, resolvingAgainstBaseURL: false) + returnURLComponents?.host = "appBackupResponse" + guard let returnURL = returnURLComponents?.url else { throw OperationError.openAppFailed(name: appName) } + + var openURLComponents = URLComponents() + openURLComponents.scheme = installedApp.openAppURL.scheme + openURLComponents.host = self.action.rawValue + openURLComponents.queryItems = [URLQueryItem(name: "returnURL", value: returnURL.absoluteString)] + + guard let openURL = openURLComponents.url else { throw OperationError.openAppFailed(name: appName) } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + let currentTime = CFAbsoluteTimeGetCurrent() + + UIApplication.shared.open(openURL, options: [:]) { (success) in + let elapsedTime = CFAbsoluteTimeGetCurrent() - currentTime + + if success + { + self.registerObservers() + } + else if elapsedTime < 0.5 + { + // Failed too quickly for human to respond to alert, possibly still finalizing installation. + // Try again in a couple seconds. + + print("Failed too quickly, retrying after a few seconds...") + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + UIApplication.shared.open(openURL, options: [:]) { (success) in + if success + { + self.registerObservers() + } + else + { + self.finish(.failure(OperationError.openAppFailed(name: appName))) + } + } + } + } + else + { + self.finish(.failure(OperationError.openAppFailed(name: appName))) + } + } + } + } + catch + { + self.finish(.failure(error)) + } + } + } + catch + { + self.finish(.failure(error)) + } + } + + override func finish(_ result: Result) + { + let result = result.mapError { (error) -> Error in + let appName = self.appName ?? self.context.bundleIdentifier + + switch (error, self.action) + { + case (let error as NSError, _) where (self.context.error as NSError?) == error: fallthrough + case (OperationError.cancelled, _): + return error + + case (let error as NSError, .backup): + let localizedFailure = String(format: NSLocalizedString("Could not back up “%@”.", comment: ""), appName) + return error.withLocalizedFailure(localizedFailure) + + case (let error as NSError, .restore): + let localizedFailure = String(format: NSLocalizedString("Could not restore “%@”.", comment: ""), appName) + return error.withLocalizedFailure(localizedFailure) + } + } + + switch result + { + case .success: self.progress.completedUnitCount += 1 + case .failure: break + } + + super.finish(result) + } +} + +private extension BackupAppOperation +{ + func registerObservers() + { + var applicationWillReturnObserver: NSObjectProtocol! + applicationWillReturnObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] (notification) in + guard let self = self, !self.isFinished else { return } + + self.timeoutTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] (timer) in + // Final delay to ensure we don't prematurely return failure + // in case timer expired while we were in background, but + // are now returning to app with success response. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + guard let self = self, !self.isFinished else { return } + self.finish(.failure(OperationError.timedOut)) + } + } + + NotificationCenter.default.removeObserver(applicationWillReturnObserver!) + } + + var backupResponseObserver: NSObjectProtocol! + backupResponseObserver = NotificationCenter.default.addObserver(forName: AppDelegate.appBackupDidFinish, object: nil, queue: nil) { [weak self] (notification) in + self?.timeoutTimer?.invalidate() + + let result = notification.userInfo?[AppDelegate.appBackupResultKey] as? Result ?? .failure(OperationError.unknownResult) + self?.finish(result) + + NotificationCenter.default.removeObserver(backupResponseObserver!) + } + } +} diff --git a/AltStore/Operations/OperationContexts.swift b/AltStore/Operations/OperationContexts.swift index 1b666785..97bc0dfb 100644 --- a/AltStore/Operations/OperationContexts.swift +++ b/AltStore/Operations/OperationContexts.swift @@ -105,6 +105,12 @@ class InstallAppOperationContext: AppOperationContext var resignedApp: ALTApplication? var installationConnection: ServerConnection? + var installedApp: InstalledApp? { + didSet { + self.installedAppContext = self.installedApp?.managedObjectContext + } + } + private var installedAppContext: NSManagedObjectContext? var beginInstallationHandler: ((InstalledApp) -> Void)? } diff --git a/AltStore/Operations/OperationError.swift b/AltStore/Operations/OperationError.swift index f509a46b..82ed2a51 100644 --- a/AltStore/Operations/OperationError.swift +++ b/AltStore/Operations/OperationError.swift @@ -14,6 +14,7 @@ enum OperationError: LocalizedError case unknown case unknownResult case cancelled + case timedOut case notAuthenticated case appNotFound @@ -28,17 +29,23 @@ enum OperationError: LocalizedError case noSources + case openAppFailed(name: String) + case missingAppGroup + var failureReason: String? { switch self { case .unknown: return NSLocalizedString("An unknown error occured.", comment: "") case .unknownResult: return NSLocalizedString("The operation returned an unknown result.", comment: "") case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "") + case .timedOut: return NSLocalizedString("The operation timed out.", comment: "") case .notAuthenticated: return NSLocalizedString("You are not signed in.", comment: "") case .appNotFound: return NSLocalizedString("App not found.", comment: "") case .unknownUDID: return NSLocalizedString("Unknown device UDID.", comment: "") case .invalidApp: return NSLocalizedString("The app is invalid.", comment: "") case .invalidParameters: return NSLocalizedString("Invalid parameters.", comment: "") case .noSources: return NSLocalizedString("There are no AltStore sources.", comment: "") + case .openAppFailed(let name): return String(format: NSLocalizedString("AltStore was denied permission to launch %@.", comment: ""), name) + case .missingAppGroup: return NSLocalizedString("AltStore's shared app group could not be found.", comment: "") case .iOSVersionNotSupported(let app): let name = app.name