From 39c84e623a87635e6dd56a38df8e762dd6a3c2ef Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Fri, 21 Jun 2019 11:20:03 -0700 Subject: [PATCH] Prioritizes app refresh order Tries to refresh apps that are about to expire first, and then always refreshes AltStore itself last, since refreshing AltStore means that the app will quit. --- AltKit/ServerProtocol.swift | 26 +- AltServer/Connections/ConnectionManager.swift | 162 ++++++---- AltStore.xcodeproj/project.pbxproj | 24 +- AltStore/AppDelegate.swift | 81 ++++- AltStore/Apps/AppDetailViewController.swift | 9 +- AltStore/Apps/AppManager.swift | 287 +++++++++--------- .../AltStore.xcdatamodel/contents | 3 +- AltStore/Model/DatabaseManager.swift | 18 +- AltStore/Model/InstalledApp.swift | 64 +++- AltStore/My Apps/MyAppsViewController.swift | 99 ++---- AltStore/Operations/AppOperationContext.swift | 35 +++ .../Operations/DownloadAppOperation.swift | 91 ++++-- AltStore/Operations/InstallAppOperation.swift | 278 +++-------------- AltStore/Operations/Operation.swift | 6 +- AltStore/Operations/OperationError.swift | 5 + AltStore/Operations/OperationGroup.swift | 63 ++++ AltStore/Operations/ResignAppOperation.swift | 80 +++-- AltStore/Operations/SendAppOperation.swift | 128 ++++++++ AltStore/Server/Server.swift | 170 +++++++++++ AltStore/Server/ServerManager.swift | 5 - AltStore/Updates/UpdatesViewController.swift | 3 +- 21 files changed, 1016 insertions(+), 621 deletions(-) create mode 100644 AltStore/Operations/AppOperationContext.swift create mode 100644 AltStore/Operations/OperationGroup.swift create mode 100644 AltStore/Operations/SendAppOperation.swift create mode 100644 AltStore/Server/Server.swift diff --git a/AltKit/ServerProtocol.swift b/AltKit/ServerProtocol.swift index 2eb97c59..aa3ffc62 100644 --- a/AltKit/ServerProtocol.swift +++ b/AltKit/ServerProtocol.swift @@ -13,8 +13,17 @@ public let ALTServerServiceType = "_altserver._tcp" // Can only automatically conform ALTServerError.Code to Codable, not ALTServerError itself extension ALTServerError.Code: Codable {} -public struct ServerRequest: Codable +protocol ServerMessage: Codable { + var version: Int { get } + var identifier: String { get } +} + +public struct PrepareAppRequest: ServerMessage +{ + public var version = 1 + public var identifier = "PrepareApp" + public var udid: String public var contentSize: Int @@ -25,8 +34,21 @@ public struct ServerRequest: Codable } } -public struct ServerResponse: Codable +public struct BeginInstallationRequest: ServerMessage { + public var version = 1 + public var identifier = "BeginInstallation" + + public init() + { + } +} + +public struct ServerResponse: ServerMessage +{ + public var version = 1 + public var identifier = "ServerResponse" + public var progress: Double public var error: ALTServerError? { diff --git a/AltServer/Connections/ConnectionManager.swift b/AltServer/Connections/ConnectionManager.swift index 98eab3e5..31675c5f 100644 --- a/AltServer/Connections/ConnectionManager.swift +++ b/AltServer/Connections/ConnectionManager.swift @@ -173,25 +173,6 @@ private extension ConnectionManager guard !self.connections.contains(where: { $0 === connection }) else { return } self.connections.append(connection) - func finish(error: ALTServerError?) - { - if let error = error - { - print("Failed to process request from \(connection.endpoint).", error) - } - else - { - print("Processed request from \(connection.endpoint).") - } - - let response = ServerResponse(progress: 1.0, error: error) - - self.send(response, to: connection) { (result) in - print("Sent response to \(connection.endpoint) with result:", result) - - self.disconnect(connection) - } - } connection.stateUpdateHandler = { [weak self] (state) in switch state @@ -201,24 +182,8 @@ private extension ConnectionManager case .ready: print("Connected to client:", connection.endpoint) - self?.receiveRequest(from: connection) { (result) in - print("Received request with result:", result) - - switch result - { - case .failure(let error): finish(error: error) - case .success(let request, let fileURL): - print("Installing to device \(request.udid)...") - - self?.installApp(at: fileURL, toDeviceWithUDID: request.udid, connection: connection) { (result) in - print("Installed to device with result:", result) - switch result - { - case .failure(let error): finish(error: error) - case .success: finish(error: nil) - } - } - } + self?.receiveApp(from: connection) { (result) in + self?.finish(connection: connection, error: result.error) } case .waiting: @@ -238,44 +203,70 @@ private extension ConnectionManager connection.start(queue: self.dispatchQueue) } - func receiveRequest(from connection: NWConnection, completionHandler: @escaping (Result<(ServerRequest, URL), ALTServerError>) -> Void) + func receiveApp(from connection: NWConnection, completionHandler: @escaping (Result) -> Void) { - let size = MemoryLayout.size - - print("Receiving request size") - connection.receive(minimumIncompleteLength: size, maximumLength: size) { (data, _, _, error) in - do + self.receive(PrepareAppRequest.self, from: connection) { (result) in + print("Received request with result:", result) + + switch result { - let data = try self.process(data: data, error: error, from: connection) - - print("Receiving request") - - let expectedBytes = Int(data.withUnsafeBytes { $0.load(as: Int32.self) }) - connection.receive(minimumIncompleteLength: expectedBytes, maximumLength: expectedBytes) { (data, _, _, error) in - do + case .failure(let error): completionHandler(.failure(error)) + case .success(let request): + self.receiveApp(for: request, from: connection) { (result) in + print("Received app with result:", result) + + switch result { - let data = try self.process(data: data, error: error, from: connection) + case .failure(let error): completionHandler(.failure(error)) + case .success(let request, let fileURL): + print("Awaiting begin installation request...") - let request = try JSONDecoder().decode(ServerRequest.self, from: data) - - print("Receiving app data (Size: \(request.contentSize))") - - self.process(request, from: connection, completionHandler: completionHandler) - } - catch - { - completionHandler(.failure(ALTServerError(error))) + self.receive(BeginInstallationRequest.self, from: connection) { (result) in + print("Received begin installation request with result:", result) + + switch result + { + case .failure(let error): completionHandler(.failure(error)) + case .success: + print("Installing to device \(request.udid)...") + + self.installApp(at: fileURL, toDeviceWithUDID: request.udid, connection: connection) { (result) in + print("Installed to device with result:", result) + switch result + { + case .failure(let error): completionHandler(.failure(error)) + case .success: completionHandler(.success(())) + } + } + } + } } } } - catch - { - completionHandler(.failure(ALTServerError(error))) - } } } - func process(_ request: ServerRequest, from connection: NWConnection, completionHandler: @escaping (Result<(ServerRequest, URL), ALTServerError>) -> Void) + func finish(connection: NWConnection, error: ALTServerError?) + { + if let error = error + { + print("Failed to process request from \(connection.endpoint).", error) + } + else + { + print("Processed request from \(connection.endpoint).") + } + + let response = ServerResponse(progress: 1.0, error: error) + + self.send(response, to: connection) { (result) in + print("Sent response to \(connection.endpoint) with result:", result) + + self.disconnect(connection) + } + } + + func receiveApp(for request: PrepareAppRequest, from connection: NWConnection, completionHandler: @escaping (Result<(PrepareAppRequest, URL), ALTServerError>) -> Void) { connection.receive(minimumIncompleteLength: request.contentSize, maximumLength: request.contentSize) { (data, _, _, error) in do @@ -346,10 +337,10 @@ private extension ConnectionManager }) } - func send(_ response: ServerResponse, to connection: NWConnection, completionHandler: @escaping (Result) -> Void) + func send(_ response: T, to connection: NWConnection, completionHandler: @escaping (Result) -> Void) { do - { + { let data = try JSONEncoder().encode(response) let responseSize = withUnsafeBytes(of: Int32(data.count)) { Data($0) } @@ -383,4 +374,41 @@ private extension ConnectionManager completionHandler(.failure(.init(.invalidResponse))) } } + + func receive(_ responseType: T.Type, from connection: NWConnection, completionHandler: @escaping (Result) -> Void) + { + let size = MemoryLayout.size + + print("Receiving request size") + connection.receive(minimumIncompleteLength: size, maximumLength: size) { (data, _, _, error) in + do + { + let data = try self.process(data: data, error: error, from: connection) + + print("Receiving request...") + + let expectedBytes = Int(data.withUnsafeBytes { $0.load(as: Int32.self) }) + connection.receive(minimumIncompleteLength: expectedBytes, maximumLength: expectedBytes) { (data, _, _, error) in + do + { + let data = try self.process(data: data, error: error, from: connection) + + let request = try JSONDecoder().decode(T.self, from: data) + + print("Received installation request:", request) + + completionHandler(.success(request)) + } + catch + { + completionHandler(.failure(ALTServerError(error))) + } + } + } + catch + { + completionHandler(.failure(ALTServerError(error))) + } + } + } } diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 3ae1eb57..8298b064 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -90,6 +90,10 @@ BF4713A622976D1E00784A2F /* openssl.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF4713A422976CFC00784A2F /* openssl.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; BF5AB3A82285FE7500DC914B /* AltSign.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF5AB3A72285FE6C00DC914B /* AltSign.framework */; }; BF5AB3A92285FE7500DC914B /* AltSign.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF5AB3A72285FE6C00DC914B /* AltSign.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BF770E5122BB1CF6002A40FE /* InstallAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF770E5022BB1CF6002A40FE /* InstallAppOperation.swift */; }; + BF770E5422BC044E002A40FE /* AppOperationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF770E5322BC044E002A40FE /* AppOperationContext.swift */; }; + BF770E5622BC3C03002A40FE /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF770E5522BC3C02002A40FE /* Server.swift */; }; + BF770E5822BC3D0F002A40FE /* OperationGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF770E5722BC3D0F002A40FE /* OperationGroup.swift */; }; BF7B9EF322B82B1F0042C873 /* FetchAppsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7B9EF222B82B1F0042C873 /* FetchAppsOperation.swift */; }; BF9B63C6229DD44E002F0A62 /* AltSign.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF9B63C5229DD44D002F0A62 /* AltSign.framework */; }; BFB11692229322E400BB457C /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB11691229322E400BB457C /* DatabaseManager.swift */; }; @@ -150,7 +154,7 @@ BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */; }; BFDB6A0B22AAEDB7007EA6D6 /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */; }; BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */; }; - BFDB6A0F22AB2776007EA6D6 /* InstallAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0E22AB2776007EA6D6 /* InstallAppOperation.swift */; }; + BFDB6A0F22AB2776007EA6D6 /* SendAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0E22AB2776007EA6D6 /* SendAppOperation.swift */; }; BFE6325A22A83BEB00F30809 /* Authentication.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BFE6325922A83BEB00F30809 /* Authentication.storyboard */; }; BFE6325C22A83C0100F30809 /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE6325B22A83C0100F30809 /* AuthenticationViewController.swift */; }; BFE6325E22A8497000F30809 /* SelectTeamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE6325D22A8497000F30809 /* SelectTeamViewController.swift */; }; @@ -311,6 +315,10 @@ BF4588962298DE6E00BD7491 /* libzip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = libzip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BF4713A422976CFC00784A2F /* openssl.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = openssl.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BF5AB3A72285FE6C00DC914B /* AltSign.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = AltSign.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BF770E5022BB1CF6002A40FE /* InstallAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallAppOperation.swift; sourceTree = ""; }; + BF770E5322BC044E002A40FE /* AppOperationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppOperationContext.swift; sourceTree = ""; }; + BF770E5522BC3C02002A40FE /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; + BF770E5722BC3D0F002A40FE /* OperationGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationGroup.swift; sourceTree = ""; }; BF7B9EF222B82B1F0042C873 /* FetchAppsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchAppsOperation.swift; sourceTree = ""; }; BF9B63C5229DD44D002F0A62 /* AltSign.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AltSign.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BFB11691229322E400BB457C /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; }; @@ -374,7 +382,7 @@ BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignAppOperation.swift; sourceTree = ""; }; BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationError.swift; sourceTree = ""; }; - BFDB6A0E22AB2776007EA6D6 /* InstallAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallAppOperation.swift; sourceTree = ""; }; + BFDB6A0E22AB2776007EA6D6 /* SendAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendAppOperation.swift; sourceTree = ""; }; BFE6325922A83BEB00F30809 /* Authentication.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Authentication.storyboard; sourceTree = ""; }; BFE6325B22A83C0100F30809 /* AuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewController.swift; sourceTree = ""; }; BFE6325D22A8497000F30809 /* SelectTeamViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectTeamViewController.swift; sourceTree = ""; }; @@ -651,6 +659,7 @@ isa = PBXGroup; children = ( BFD52BD322A0800A000B7ED1 /* ServerManager.swift */, + BF770E5522BC3C02002A40FE /* Server.swift */, ); path = Server; sourceTree = ""; @@ -808,10 +817,13 @@ children = ( BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */, BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */, + BF770E5722BC3D0F002A40FE /* OperationGroup.swift */, + BF770E5322BC044E002A40FE /* AppOperationContext.swift */, BFE6326B22A86FF300F30809 /* AuthenticationOperation.swift */, BFC1F38C22AEE3A4003AC21A /* DownloadAppOperation.swift */, BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */, - BFDB6A0E22AB2776007EA6D6 /* InstallAppOperation.swift */, + BFDB6A0E22AB2776007EA6D6 /* SendAppOperation.swift */, + BF770E5022BB1CF6002A40FE /* InstallAppOperation.swift */, BF7B9EF222B82B1F0042C873 /* FetchAppsOperation.swift */, ); path = Operations; @@ -1171,7 +1183,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - BFDB6A0F22AB2776007EA6D6 /* InstallAppOperation.swift in Sources */, + BFDB6A0F22AB2776007EA6D6 /* SendAppOperation.swift in Sources */, BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */, BF7B9EF322B82B1F0042C873 /* FetchAppsOperation.swift in Sources */, BFE6325E22A8497000F30809 /* SelectTeamViewController.swift in Sources */, @@ -1184,6 +1196,7 @@ BFBBE2E122931F81002097FA /* InstalledApp.swift in Sources */, BFBBE2DF22931F73002097FA /* App.swift in Sources */, BF43002E22A714AF0051E2BC /* Keychain.swift in Sources */, + BF770E5422BC044E002A40FE /* AppOperationContext.swift in Sources */, BFD2478C2284C4C300981D42 /* AppIconImageView.swift in Sources */, BFE6326822A858F300F30809 /* Account.swift in Sources */, BFE6326622A857C200F30809 /* Team.swift in Sources */, @@ -1196,12 +1209,15 @@ BFE6325C22A83C0100F30809 /* AuthenticationViewController.swift in Sources */, BFB1169B2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift in Sources */, BFD247932284D4B700981D42 /* AppTableViewCell.swift in Sources */, + BF770E5822BC3D0F002A40FE /* OperationGroup.swift in Sources */, BFD52BD422A0800A000B7ED1 /* ServerManager.swift in Sources */, BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */, + BF770E5122BB1CF6002A40FE /* InstallAppOperation.swift in Sources */, BF0C4EBD22A1BD8B009A2DD7 /* AppManager.swift in Sources */, BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */, BFDB6A0522A9AFB2007EA6D6 /* Fetchable.swift in Sources */, BFD2479F2284FBD000981D42 /* UIColor+AltStore.swift in Sources */, + BF770E5622BC3C03002A40FE /* Server.swift in Sources */, BF43003022A71C960051E2BC /* UserDefaults+AltStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index 7201abf2..fdaa427e 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -63,7 +63,7 @@ extension AppDelegate { private func prepareForBackgroundFetch() { - // Fetch every 6 hours. + // "Fetch" every hour, but then refresh only those that need to be refreshed (so we don't drain the battery). UIApplication.shared.setMinimumBackgroundFetchInterval(60 * 60) UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in @@ -72,49 +72,100 @@ extension AppDelegate func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: DatabaseManager.shared.viewContext) + guard !installedApps.isEmpty else { return completionHandler(.noData) } + + print("Apps to refresh:", installedApps.map { $0.app.identifier }) + ServerManager.shared.startDiscovering() + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("Refreshing apps...", comment: "") + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.01, repeats: false) + + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) { (error) in + if let error = error { + print(error) + } + } + + // Wait a few seconds so we have a chance to discover nearby AltServers. DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - let installedApps = InstalledApp.all(in: DatabaseManager.shared.viewContext) - _ = AppManager.shared.refresh(installedApps, presentingViewController: nil) { (result) in + func finish(_ result: Result<[String: Result], Error>) + { ServerManager.shared.stopDiscovering() let content = UNMutableNotificationContent() + var shouldPresentAlert = true do { let results = try result.get() + shouldPresentAlert = !results.isEmpty for (_, result) in results { guard case let .failure(error) = result else { continue } throw error } - - content.title = "Refreshed Apps!" - content.body = "Successfully refreshed all apps." - completionHandler(.newData) + content.title = NSLocalizedString("Refreshed all apps!", comment: "") } catch { print("Failed to refresh apps in background.", error) - content.title = "Failed to Refresh Apps" + content.title = NSLocalizedString("Failed to Refresh Apps", comment: "") content.body = error.localizedDescription - completionHandler(.failed) + shouldPresentAlert = true } - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) - - let request = UNNotificationRequest(identifier: "RefreshedApps", content: content, trigger: trigger) - UNUserNotificationCenter.current().add(request) { (error) in - if let error = error { - print(error) + if shouldPresentAlert + { + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.01, repeats: false) + + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) { (error) in + if let error = error { + print(error) + } } } + + switch result + { + case .failure(ConnectionError.serverNotFound): completionHandler(.newData) + case .failure: completionHandler(.failed) + case .success: completionHandler(.newData) + } + } + + let group = AppManager.shared.refresh(installedApps, presentingViewController: nil) + group.beginInstallationHandler = { (installedApp) in + guard installedApp.app.identifier == App.altstoreAppID else { return } + + // We're starting to install AltStore, which means the app is about to quit. + // So, we say we were successful even though we technically don't know 100% yet. + // Also since AltServer has already received the app, it can finish installing even if we're no longer running in background. + + if let error = group.error + { + finish(.failure(error)) + } + else + { + var results = group.results + results[installedApp.app.identifier] = .success(installedApp) + + finish(.success(results)) + } + } + group.completionHandler = { (result) in + finish(result) } } } diff --git a/AltStore/Apps/AppDetailViewController.swift b/AltStore/Apps/AppDetailViewController.swift index b043ee2b..7b6cebf8 100644 --- a/AltStore/Apps/AppDetailViewController.swift +++ b/AltStore/Apps/AppDetailViewController.swift @@ -127,14 +127,11 @@ private extension AppDetailViewController let progressView = UIProgressView(progressViewStyle: .bar) progressView.translatesAutoresizingMaskIntoConstraints = false progressView.progress = 0.0 - + let progress = AppManager.shared.install(self.app, presentingViewController: self) { (result) in do { - let installedApp = try result.get() - - do { try installedApp.managedObjectContext?.save() } - catch { print("Failed to save context.", error) } + _ = try result.get() DispatchQueue.main.async { let toastView = RSTToastView(text: "Installed \(self.app.name)!", detailText: nil) @@ -154,7 +151,7 @@ private extension AppDetailViewController toastView.show(in: self.navigationController!.view, duration: 2) } } - + DispatchQueue.main.async { UIView.animate(withDuration: 0.4, animations: { progressView.alpha = 0.0 diff --git a/AltStore/Apps/AppManager.swift b/AltStore/Apps/AppManager.swift index 48020fb7..719de131 100644 --- a/AltStore/Apps/AppManager.swift +++ b/AltStore/Apps/AppManager.swift @@ -24,10 +24,11 @@ class AppManager static let shared = AppManager() private let operationQueue = OperationQueue() + private let processingQueue = DispatchQueue(label: "com.altstore.AppManager.processingQueue") private init() { - self.operationQueue.name = "com.rileytestut.AltStore.AppManager" + self.operationQueue.name = "com.altstore.AppManager.operationQueue" } } @@ -104,7 +105,8 @@ extension AppManager { func install(_ app: App, presentingViewController: UIViewController, completionHandler: @escaping (Result) -> Void) -> Progress { - let progress = self.install([app], forceDownload: true, presentingViewController: presentingViewController) { (result) in + let group = self.install([app], forceDownload: true, presentingViewController: presentingViewController) + group.completionHandler = { (result) in do { guard let (_, result) = try result.get().first else { throw OperationError.unknown } @@ -116,170 +118,179 @@ extension AppManager } } - return progress + return group.progress } - func refresh(_ app: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) -> Progress - { - return self.refresh([app], presentingViewController: presentingViewController) { (result) in - do - { - guard let (_, result) = try result.get().first else { throw OperationError.unknown } - completionHandler(result) - } - catch - { - completionHandler(.failure(error)) - } - } - } - - @discardableResult func refresh(_ installedApps: [InstalledApp], presentingViewController: UIViewController?, completionHandler: @escaping (Result<[String: Result], Error>) -> Void) -> Progress + func refresh(_ installedApps: [InstalledApp], presentingViewController: UIViewController?, group: OperationGroup? = nil) -> OperationGroup { let apps = installedApps.compactMap { $0.app } - - let progress = self.install(apps, forceDownload: false, presentingViewController: presentingViewController, completionHandler: completionHandler) - return progress + + let group = self.install(apps, forceDownload: false, presentingViewController: presentingViewController, group: group) + return group } } private extension AppManager { - func install(_ apps: [App], forceDownload: Bool, presentingViewController: UIViewController?, completionHandler: @escaping (Result<[String: Result], Error>) -> Void) -> Progress + func install(_ apps: [App], forceDownload: Bool, presentingViewController: UIViewController?, group: OperationGroup? = nil) -> OperationGroup { - let progress = Progress.discreteProgress(totalUnitCount: Int64(apps.count)) + // Authenticate -> Download (if necessary) -> Resign -> Send -> Install. + let group = group ?? OperationGroup() - guard let context = apps.first?.managedObjectContext else { - completionHandler(.success([:])) - return progress + guard let server = ServerManager.shared.discoveredServers.first else { + DispatchQueue.main.async { + group.completionHandler?(.failure(ConnectionError.serverNotFound)) + } + + return group } - // Authenticate + group.server = server + + var operations = [Operation]() + + + /* Authenticate */ let authenticationOperation = AuthenticationOperation(presentingViewController: presentingViewController) authenticationOperation.resultHandler = { (result) in switch result { - case .failure(let error): completionHandler(.failure(error)) - case .success(let signer): - - // Download - context.perform { - let dispatchGroup = DispatchGroup() - var results = [String: Result]() - - let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext() - - for app in apps - { - let appProgress = Progress(totalUnitCount: 100) - - let appID = app.identifier - print("Installing app:", appID) - - dispatchGroup.enter() - - func finishApp(_ result: Result) - { - switch result - { - case .failure(let error): print("Failed to install app \(appID).", error) - case .success: print("Installed app:", appID) - } - - results[appID] = result - dispatchGroup.leave() - } - - // Ensure app is downloaded. - let downloadAppOperation = DownloadAppOperation(app: app) - downloadAppOperation.useCachedAppIfAvailable = !forceDownload - downloadAppOperation.context = backgroundContext - downloadAppOperation.resultHandler = { (result) in - switch result - { - case .failure(let error): - finishApp(.failure(error)) - - case .success(let installedApp): - - // Refresh - let (resignProgress, installProgress) = self.refresh(installedApp, signer: signer, presentingViewController: presentingViewController) { (result) in - finishApp(result) - } - - if forceDownload - { - appProgress.addChild(resignProgress, withPendingUnitCount: 10) - appProgress.addChild(installProgress, withPendingUnitCount: 50) - } - else - { - appProgress.addChild(resignProgress, withPendingUnitCount: 20) - appProgress.addChild(installProgress, withPendingUnitCount: 80) - } - } - } - - if forceDownload - { - appProgress.addChild(downloadAppOperation.progress, withPendingUnitCount: 40) - } - - progress.addChild(appProgress, withPendingUnitCount: 1) - - self.operationQueue.addOperation(downloadAppOperation) - } - - dispatchGroup.notify(queue: .global()) { - backgroundContext.perform { - completionHandler(.success(results)) - } - } - } + case .failure(let error): group.error = error + case .success(let signer): group.signer = signer } } + operations.append(authenticationOperation) - self.operationQueue.addOperation(authenticationOperation) - return progress + for app in apps + { + let context = AppOperationContext(appIdentifier: app.identifier, group: group) + let progress = Progress.discreteProgress(totalUnitCount: 100) + + + /* Resign */ + let resignAppOperation = ResignAppOperation(context: context) + resignAppOperation.resultHandler = { (result) in + switch result + { + case .failure(let error): context.error = error + case .success(let fileURL): context.resignedFileURL = fileURL + } + } + resignAppOperation.addDependency(authenticationOperation) + progress.addChild(resignAppOperation.progress, withPendingUnitCount: 20) + operations.append(resignAppOperation) + + + /* Download */ + let fileURL = InstalledApp.fileURL(for: app) + if let installedApp = app.installedApp, FileManager.default.fileExists(atPath: fileURL.path), !forceDownload + { + // Already installed, don't need to download. + + // If we don't need to download the app, reduce the total unit count by 40. + progress.totalUnitCount -= 40 + + let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext() + backgroundContext.performAndWait { + let installedApp = backgroundContext.object(with: installedApp.objectID) as! InstalledApp + context.installedApp = installedApp + } + } + else + { + // App is not yet installed (or we're forcing it to download a new version), so download it before resigning it. + + let downloadOperation = DownloadAppOperation(app: app) + downloadOperation.resultHandler = { (result) in + switch result + { + case .failure(let error): context.error = error + case .success(let installedApp): context.installedApp = installedApp + } + } + progress.addChild(downloadOperation.progress, withPendingUnitCount: 40) + resignAppOperation.addDependency(downloadOperation) + operations.append(downloadOperation) + } + + + /* Send */ + let sendAppOperation = SendAppOperation(context: context) + sendAppOperation.resultHandler = { (result) in + switch result + { + case .failure(let error): context.error = error + case .success(let connection): context.connection = connection + } + } + progress.addChild(sendAppOperation.progress, withPendingUnitCount: 10) + sendAppOperation.addDependency(resignAppOperation) + operations.append(sendAppOperation) + + + /* Install */ + let installOperation = InstallAppOperation(context: context) + installOperation.resultHandler = { (result) in + switch result + { + case .failure(let error): context.error = error + case .success: break + } + + self.finishAppOperation(context) + } + progress.addChild(installOperation.progress, withPendingUnitCount: 30) + installOperation.addDependency(sendAppOperation) + operations.append(installOperation) + + group.progress.totalUnitCount += 1 + group.progress.addChild(progress, withPendingUnitCount: 1) + } + + group.addOperations(operations) + + return group } - func refresh(_ installedApp: InstalledApp, signer: ALTSigner, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) -> (Progress, Progress) + @discardableResult func process(_ result: Result, context: AppOperationContext) -> T? { - let context = installedApp.managedObjectContext - - let resignAppOperation = ResignAppOperation(installedApp: installedApp) - let installAppOperation = InstallAppOperation() - - // Resign - resignAppOperation.signer = signer - resignAppOperation.resultHandler = { (result) in - switch result - { - case .failure(let error): - installAppOperation.cancel() - completionHandler(.failure(error)) - - case .success(let resignedURL): - installAppOperation.fileURL = resignedURL - } + do + { + let value = try result.get() + return value } - - // Install - installAppOperation.addDependency(resignAppOperation) - installAppOperation.resultHandler = { (result) in - switch result + catch + { + context.error = error + return nil + } + } + + func finishAppOperation(_ context: AppOperationContext) + { + self.processingQueue.sync { + if let error = context.error { - case .failure(let error): completionHandler(.failure(error)) - case .success: - context?.perform { - completionHandler(.success(installedApp)) + context.group.results[context.appIdentifier] = .failure(error) + } + else if let installedApp = context.installedApp + { + context.group.results[context.appIdentifier] = .success(installedApp) + + // Save after each installation. + installedApp.managedObjectContext?.perform { + do { try installedApp.managedObjectContext?.save() } + catch { print("Error saving installed app.", error) } } } + + print("Finished operation!", context.appIdentifier) + + if context.group.results.count == context.group.progress.totalUnitCount + { + context.group.completionHandler?(.success(context.group.results)) + } } - - self.operationQueue.addOperations([resignAppOperation, installAppOperation], waitUntilFinished: false) - - return (resignAppOperation.progress, installAppOperation.progress) } } diff --git a/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents b/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents index 64a4aa4f..e150807c 100644 --- a/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents +++ b/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents @@ -34,6 +34,7 @@ + @@ -57,7 +58,7 @@ - + \ No newline at end of file diff --git a/AltStore/Model/DatabaseManager.swift b/AltStore/Model/DatabaseManager.swift index 64926fff..017f35c1 100644 --- a/AltStore/Model/DatabaseManager.swift +++ b/AltStore/Model/DatabaseManager.swift @@ -8,6 +8,7 @@ import CoreData +import AltSign import Roxas public class DatabaseManager @@ -120,14 +121,23 @@ private extension DatabaseManager altStoreApp.version = version } - if let installedApp = altStoreApp.installedApp + let installedApp: InstalledApp + + if let app = altStoreApp.installedApp { - installedApp.version = version + installedApp = app } else { - let installedApp = InstalledApp(app: altStoreApp, bundleIdentifier: altStoreApp.identifier, expirationDate: Date(), context: context) - installedApp.version = version + installedApp = InstalledApp(app: altStoreApp, bundleIdentifier: altStoreApp.identifier, context: context) + } + + installedApp.version = version + + if let provisioningProfileURL = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"), let provisioningProfile = ALTProvisioningProfile(url: provisioningProfileURL) + { + installedApp.refreshedDate = provisioningProfile.creationDate + installedApp.expirationDate = provisioningProfile.expirationDate } do diff --git a/AltStore/Model/InstalledApp.swift b/AltStore/Model/InstalledApp.swift index b1b27e74..b69be65d 100644 --- a/AltStore/Model/InstalledApp.swift +++ b/AltStore/Model/InstalledApp.swift @@ -16,6 +16,7 @@ class InstalledApp: NSManagedObject, Fetchable @NSManaged var bundleIdentifier: String @NSManaged var version: String + @NSManaged var refreshedDate: Date @NSManaged var expirationDate: Date /* Relationships */ @@ -26,7 +27,7 @@ class InstalledApp: NSManagedObject, Fetchable super.init(entity: entity, insertInto: context) } - init(app: App, bundleIdentifier: String, expirationDate: Date, context: NSManagedObjectContext) + init(app: App, bundleIdentifier: String, context: NSManagedObjectContext) { super.init(entity: InstalledApp.entity(), insertInto: context) @@ -35,7 +36,9 @@ class InstalledApp: NSManagedObject, Fetchable self.version = app.version self.bundleIdentifier = bundleIdentifier - self.expirationDate = expirationDate + + self.refreshedDate = Date() + self.expirationDate = self.refreshedDate.addingTimeInterval(60 * 60 * 24 * 7) // Rough estimate until we get real values from provisioning profile. } } @@ -45,6 +48,52 @@ extension InstalledApp { return NSFetchRequest(entityName: "InstalledApp") } + + class func fetchAltStore(in context: NSManagedObjectContext) -> InstalledApp? + { + let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.app.identifier), App.altstoreAppID) + + let altStore = InstalledApp.first(satisfying: predicate, in: context) + return altStore + } + + class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp] + { + let predicate = NSPredicate(format: "%K != %@", #keyPath(InstalledApp.app.identifier), App.altstoreAppID) + + var installedApps = InstalledApp.all(satisfying: predicate, + sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)], + in: context) + + if let altStoreApp = InstalledApp.fetchAltStore(in: context) + { + // Refresh AltStore last since it causes app to quit. + installedApps.append(altStoreApp) + } + + return installedApps + } + + class func fetchAppsForBackgroundRefresh(in context: NSManagedObjectContext) -> [InstalledApp] + { + let date = Date().addingTimeInterval(-120) + + let predicate = NSPredicate(format: "(%K < %@) AND (%K != %@)", + #keyPath(InstalledApp.refreshedDate), date as NSDate, + #keyPath(InstalledApp.app.identifier), App.altstoreAppID) + + var installedApps = InstalledApp.all(satisfying: predicate, + sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)], + in: context) + + if let altStoreApp = InstalledApp.fetchAltStore(in: context), altStoreApp.refreshedDate < date + { + // Refresh AltStore last since it causes app to quit. + installedApps.append(altStoreApp) + } + + return installedApps + } } extension InstalledApp @@ -67,10 +116,10 @@ extension InstalledApp return appsDirectoryURL } - class func ipaURL(for app: App) -> URL + class func fileURL(for app: App) -> URL { - let ipaURL = self.directoryURL(for: app).appendingPathComponent("App.ipa") - return ipaURL + let appURL = self.directoryURL(for: app).appendingPathComponent("App.app") + return appURL } class func refreshedIPAURL(for app: App) -> URL @@ -79,7 +128,6 @@ extension InstalledApp return ipaURL } - class func directoryURL(for app: App) -> URL { let directoryURL = InstalledApp.appsDirectoryURL.appendingPathComponent(app.identifier) @@ -94,8 +142,8 @@ extension InstalledApp return InstalledApp.directoryURL(for: self.app) } - var ipaURL: URL { - return InstalledApp.ipaURL(for: self.app) + var fileURL: URL { + return InstalledApp.fileURL(for: self.app) } var refreshedIPAURL: URL { diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index eb2fb079..dce6938f 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -24,6 +24,8 @@ class MyAppsViewController: UITableViewController return dateFormatter }() + private var refreshGroup: OperationGroup? + @IBOutlet private var progressView: UIProgressView! override func viewDidLoad() @@ -119,9 +121,25 @@ private extension MyAppsViewController { sender.isIndicatingActivity = true - let installedApps = InstalledApp.all(in: DatabaseManager.shared.viewContext) + let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext) - let progress = AppManager.shared.refresh(installedApps, presentingViewController: self) { (result) in + self.refresh(installedApps) { (result) in + sender.isIndicatingActivity = false + } + } + + func refresh(_ installedApps: [InstalledApp], completionHandler: @escaping (Result<[String : Result], Error>) -> Void) + { + if self.refreshGroup == nil + { + let toastView = RSTToastView(text: "Refreshing...", detailText: nil) + toastView.tintColor = .altPurple + toastView.activityIndicatorView.startAnimating() + toastView.show(in: self.navigationController?.view ?? self.view) + } + + let group = AppManager.shared.refresh(installedApps, presentingViewController: self, group: self.refreshGroup) + group.completionHandler = { (result) in DispatchQueue.main.async { switch result { @@ -164,46 +182,16 @@ private extension MyAppsViewController self.progressView.observedProgress = nil self.progressView.progress = 0.0 - sender.isIndicatingActivity = false self.update() - } - } - - self.progressView.observedProgress = progress - } - - func refresh(_ installedApp: InstalledApp) - { - let progress = AppManager.shared.refresh(installedApp, presentingViewController: self) { (result) in - do - { - let app = try result.get() - try app.managedObjectContext?.save() - DispatchQueue.main.async { - let toastView = RSTToastView(text: "Refreshed \(installedApp.app.name)!", detailText: nil) - toastView.tintColor = .altPurple - toastView.show(in: self.navigationController?.view ?? self.view, duration: 2) - - self.update() - } - } - catch - { - DispatchQueue.main.async { - let toastView = RSTToastView(text: "Failed to refresh \(installedApp.app.name)", detailText: error.localizedDescription) - toastView.tintColor = .altPurple - toastView.show(in: self.navigationController?.view ?? self.view, duration: 2) - } - } - - DispatchQueue.main.async { - self.progressView.observedProgress = nil - self.progressView.progress = 0.0 + self.refreshGroup = nil + completionHandler(result) } } - self.progressView.observedProgress = progress + self.progressView.observedProgress = group.progress + + self.refreshGroup = group } } @@ -231,42 +219,9 @@ extension MyAppsViewController let refreshAction = UITableViewRowAction(style: .normal, title: "Refresh") { (action, indexPath) in let installedApp = self.dataSource.item(at: indexPath) - - let toastView = RSTToastView(text: "Refreshing...", detailText: nil) - toastView.tintColor = .altPurple - toastView.activityIndicatorView.startAnimating() - toastView.show(in: self.navigationController?.view ?? self.view) - - let progress = AppManager.shared.refresh(installedApp, presentingViewController: self) { (result) in - do - { - let app = try result.get() - try app.managedObjectContext?.save() - - DispatchQueue.main.async { - let toastView = RSTToastView(text: "Refreshed \(installedApp.app.name)!", detailText: nil) - toastView.tintColor = .altPurple - toastView.show(in: self.navigationController?.view ?? self.view, duration: 2) - - self.update() - } - } - catch - { - DispatchQueue.main.async { - let toastView = RSTToastView(text: "Failed to refresh \(installedApp.app.name)", detailText: error.localizedDescription) - toastView.tintColor = .altPurple - toastView.show(in: self.navigationController?.view ?? self.view, duration: 2) - } - } - - DispatchQueue.main.async { - self.progressView.observedProgress = nil - self.progressView.progress = 0.0 - } + self.refresh([installedApp]) { (result) in + print("Refreshed", installedApp.app.identifier) } - - self.progressView.observedProgress = progress } return [deleteAction, refreshAction] diff --git a/AltStore/Operations/AppOperationContext.swift b/AltStore/Operations/AppOperationContext.swift new file mode 100644 index 00000000..1a39c749 --- /dev/null +++ b/AltStore/Operations/AppOperationContext.swift @@ -0,0 +1,35 @@ +// +// Contexts.swift +// AltStore +// +// Created by Riley Testut on 6/20/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation +import CoreData +import Network + +class AppOperationContext +{ + var appIdentifier: String + var group: OperationGroup + + var installedApp: InstalledApp? { + didSet { + self.installedAppContext = self.installedApp?.managedObjectContext + } + } + private var installedAppContext: NSManagedObjectContext? + + var resignedFileURL: URL? + var connection: NWConnection? + + var error: Error? + + init(appIdentifier: String, group: OperationGroup) + { + self.appIdentifier = appIdentifier + self.group = group + } +} diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index a40b2651..6c587723 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -19,16 +19,18 @@ class DownloadAppOperation: ResultOperation var useCachedAppIfAvailable = false lazy var context = DatabaseManager.shared.persistentContainer.newBackgroundContext() - private let downloadURL: URL - private let ipaURL: URL + private let appIdentifier: String + private let sourceURL: URL + private let destinationURL: URL private let session = URLSession(configuration: .default) init(app: App) { self.app = app - self.downloadURL = app.downloadURL - self.ipaURL = InstalledApp.ipaURL(for: app) + self.appIdentifier = app.identifier + self.sourceURL = app.downloadURL + self.destinationURL = InstalledApp.fileURL(for: app) super.init() @@ -39,14 +41,36 @@ class DownloadAppOperation: ResultOperation { super.main() - func finish(error: Error?) + print("Downloading App:", self.appIdentifier) + + func finishOperation(_ result: Result) { - if let error = error - { - self.finish(.failure(error)) - } - else + do { + let fileURL = try result.get() + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound } + + if isDirectory.boolValue + { + // Directory, so assuming this is .app bundle. + guard Bundle(url: fileURL) != nil else { throw OperationError.invalidApp } + + try FileManager.default.copyItem(at: fileURL, to: self.destinationURL, shouldReplace: true) + } + else + { + // File, so assuming this is a .ipa file. + + let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let bundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: temporaryDirectory) + try FileManager.default.copyItem(at: bundleURL, to: self.destinationURL, shouldReplace: true) + } + self.context.perform { let app = self.context.object(with: self.app.objectID) as! App @@ -59,40 +83,41 @@ class DownloadAppOperation: ResultOperation } else { - installedApp = InstalledApp(app: app, - bundleIdentifier: app.identifier, - expirationDate: Date(), - context: self.context) + installedApp = InstalledApp(app: app, bundleIdentifier: app.identifier, context: self.context) } installedApp.version = app.version self.finish(.success(installedApp)) } } + catch + { + self.finish(.failure(error)) + } } - if self.useCachedAppIfAvailable && FileManager.default.fileExists(atPath: self.ipaURL.path) + if self.sourceURL.isFileURL { - finish(error: nil) - return + finishOperation(.success(self.sourceURL)) + + self.progress.completedUnitCount += 1 } - - let downloadTask = self.session.downloadTask(with: self.downloadURL) { (fileURL, response, error) in - do - { - let (fileURL, _) = try Result((fileURL, response), error).get() - - try FileManager.default.copyItem(at: fileURL, to: self.ipaURL, shouldReplace: true) - - finish(error: nil) - } - catch let error - { - finish(error: error) + else + { + let downloadTask = self.session.downloadTask(with: self.sourceURL) { (fileURL, response, error) in + do + { + let (fileURL, _) = try Result((fileURL, response), error).get() + finishOperation(.success(fileURL)) + } + catch + { + finishOperation(.failure(error)) + } } + self.progress.addChild(downloadTask.progress, withPendingUnitCount: 1) + + downloadTask.resume() } - - self.progress.addChild(downloadTask.progress, withPendingUnitCount: 1) - downloadTask.resume() } } diff --git a/AltStore/Operations/InstallAppOperation.swift b/AltStore/Operations/InstallAppOperation.swift index 1ddf97cd..5f416a05 100644 --- a/AltStore/Operations/InstallAppOperation.swift +++ b/AltStore/Operations/InstallAppOperation.swift @@ -2,7 +2,7 @@ // InstallAppOperation.swift // AltStore // -// Created by Riley Testut on 6/7/19. +// Created by Riley Testut on 6/19/19. // Copyright © 2019 Riley Testut. All rights reserved. // @@ -10,277 +10,83 @@ import Foundation import Network import AltKit - -extension ALTServerError -{ - init(_ error: E) - { - switch error - { - case let error as ALTServerError: self = error - case is DecodingError: self = ALTServerError(.invalidResponse) - case is EncodingError: self = ALTServerError(.invalidRequest) - default: - assertionFailure("Caught unknown error type") - self = ALTServerError(.unknown) - } - } -} - -enum InstallationError: LocalizedError -{ - case serverNotFound - case connectionFailed - case connectionDropped - case invalidApp - - var errorDescription: String? { - switch self - { - case .serverNotFound: return NSLocalizedString("Could not find AltServer.", comment: "") - case .connectionFailed: return NSLocalizedString("Could not connect to AltServer.", comment: "") - case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "") - case .invalidApp: return NSLocalizedString("The app is invalid.", comment: "") - } - } -} +import Roxas @objc(InstallAppOperation) class InstallAppOperation: ResultOperation { - var fileURL: URL? + let context: AppOperationContext - private let dispatchQueue = DispatchQueue(label: "com.altstore.InstallAppOperation") - - private var connection: NWConnection? - - override init() + init(context: AppOperationContext) { + self.context = context + super.init() - self.progress.totalUnitCount = 4 + self.progress.totalUnitCount = 100 } override func main() { super.main() - guard let fileURL = self.fileURL else { return self.finish(.failure(OperationError.appNotFound)) } + if let error = self.context.error + { + self.finish(.failure(error)) + return + } - // Connect to server. - self.connect { (result) in + guard + let installedApp = self.context.installedApp, + let connection = self.context.connection, + let server = self.context.group.server + else { return self.finish(.failure(OperationError.invalidParameters)) } + + installedApp.managedObjectContext?.perform { + print("Installing app:", installedApp.app.identifier) + self.context.group.beginInstallationHandler?(installedApp) + } + + let request = BeginInstallationRequest() + server.send(request, via: connection) { (result) in switch result { case .failure(let error): self.finish(.failure(error)) - case .success(let connection): - self.connection = connection + case .success: - // Send app to server. - self.sendApp(at: fileURL, via: connection) { (result) in - switch result - { - case .failure(let error): self.finish(.failure(error)) - case .success: - self.progress.completedUnitCount += 1 - - // Receive response from server. - let progress = self.receiveResponse(from: connection) { (result) in - switch result - { - case .failure(let error): self.finish(.failure(error)) - case .success: self.finish(.success(())) - } - } - - self.progress.addChild(progress, withPendingUnitCount: 3) - } + self.receive(from: connection, server: server) { (result) in + self.finish(result) } } } } - override func finish(_ result: Result) + func receive(from connection: NWConnection, server: Server, completionHandler: @escaping (Result) -> Void) { - super.finish(result) - - if let connection = self.connection - { - connection.cancel() - } - } -} - -private extension InstallAppOperation -{ - func connect(completionHandler: @escaping (Result) -> Void) - { - guard let server = ServerManager.shared.discoveredServers.first else { return completionHandler(.failure(InstallationError.serverNotFound)) } - - let connection = NWConnection(to: .service(name: server.service.name, type: server.service.type, domain: server.service.domain, interface: nil), using: .tcp) - - connection.stateUpdateHandler = { [unowned connection] (state) in - switch state + server.receive(ServerResponse.self, from: connection) { (result) in + do { - case .failed(let error): - print("Failed to connect to service \(server.service.name).", error) - completionHandler(.failure(InstallationError.connectionFailed)) - - case .cancelled: - completionHandler(.failure(OperationError.cancelled)) + let response = try result.get() + print(response) - case .ready: - completionHandler(.success(connection)) - - case .waiting: break - case .setup: break - case .preparing: break - @unknown default: break - } - } - - connection.start(queue: self.dispatchQueue) - } - - func sendApp(at fileURL: URL, via connection: NWConnection, completionHandler: @escaping (Result) -> Void) - { - do - { - guard let appData = try? Data(contentsOf: fileURL) else { throw InstallationError.invalidApp } - guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { throw OperationError.unknownUDID } - - let request = ServerRequest(udid: udid, contentSize: appData.count) - let requestData: Data - - do { - requestData = try JSONEncoder().encode(request) - } - catch { - print("Invalid request.", error) - throw ALTServerError(.invalidRequest) - } - - let requestSize = Int32(requestData.count) - let requestSizeData = withUnsafeBytes(of: requestSize) { Data($0) } - - func process(_ error: Error?) -> Bool - { - if error != nil + if let error = response.error { - completionHandler(.failure(InstallationError.connectionDropped)) - return false + self.finish(.failure(error)) + } + else if response.progress == 1.0 + { + self.finish(.success(())) } else { - return true + self.progress.completedUnitCount = Int64(response.progress * 100) + self.receive(from: connection, server: server, completionHandler: completionHandler) } } - - // Send request data size. - print("Sending request data size \(requestSize)") - connection.send(content: requestSizeData, completion: .contentProcessed { (error) in - guard process(error) else { return } - - // Send request. - print("Sending request \(request)") - connection.send(content: requestData, completion: .contentProcessed { (error) in - guard process(error) else { return } - - // Send app data. - print("Sending app data (Size: \(appData.count))") - connection.send(content: appData, completion: .contentProcessed { (error) in - print("Sent app data!") - - guard process(error) else { return } - completionHandler(.success(())) - }) - }) - }) - } - catch - { - completionHandler(.failure(error)) - } - } - - func receiveResponse(from connection: NWConnection, completionHandler: @escaping (Result) -> Void) -> Progress - { - func receive(from connection: NWConnection, progress: Progress, completionHandler: @escaping (Result) -> Void) - { - let size = MemoryLayout.size - - connection.receive(minimumIncompleteLength: size, maximumLength: size) { (data, _, _, error) in - do - { - let data = try self.process(data: data, error: error, from: connection) - - let expectedBytes = Int(data.withUnsafeBytes { $0.load(as: Int32.self) }) - connection.receive(minimumIncompleteLength: expectedBytes, maximumLength: expectedBytes) { (data, _, _, error) in - do - { - let data = try self.process(data: data, error: error, from: connection) - - let response = try JSONDecoder().decode(ServerResponse.self, from: data) - print(response) - - if let error = response.error - { - completionHandler(.failure(error)) - } - else if response.progress == 1.0 - { - completionHandler(.success(())) - } - else - { - progress.completedUnitCount = Int64(response.progress * 100) - receive(from: connection, progress: progress, completionHandler: completionHandler) - } - } - catch - { - completionHandler(.failure(ALTServerError(error))) - } - } - } - catch - { - completionHandler(.failure(ALTServerError(error))) - } - } - } - - let progress = Progress.discreteProgress(totalUnitCount: 100) - receive(from: connection, progress: progress, completionHandler: completionHandler) - return progress - } - - func process(data: Data?, error: NWError?, from connection: NWConnection) throws -> Data - { - do - { - do - { - guard let data = data else { throw error ?? ALTServerError(.unknown) } - return data - } - catch let error as NWError - { - print("Error receiving data from connection \(connection)", error) - - throw ALTServerError(.lostConnection) - } catch { - throw error + completionHandler(.failure(ALTServerError(error))) } } - catch let error as ALTServerError - { - throw error - } - catch - { - preconditionFailure("A non-ALTServerError should never be thrown from this method.") - } } } diff --git a/AltStore/Operations/Operation.swift b/AltStore/Operations/Operation.swift index 7e05c16f..1f648ef6 100644 --- a/AltStore/Operations/Operation.swift +++ b/AltStore/Operations/Operation.swift @@ -23,9 +23,9 @@ class ResultOperation: Operation { guard !self.isFinished else { return } - super.finish() - self.resultHandler?(result) + + super.finish() } } @@ -73,6 +73,8 @@ class Operation: RSTOperation, ProgressReporting override func finish() { + guard !self.isFinished else { return } + super.finish() if let backgroundTaskID = self.backgroundTaskID diff --git a/AltStore/Operations/OperationError.swift b/AltStore/Operations/OperationError.swift index e9a54c92..7b50fc79 100644 --- a/AltStore/Operations/OperationError.swift +++ b/AltStore/Operations/OperationError.swift @@ -19,6 +19,9 @@ enum OperationError: LocalizedError case unknownUDID + case invalidApp + case invalidParameters + var errorDescription: String? { switch self { case .unknown: return NSLocalizedString("An unknown error occured.", comment: "") @@ -27,6 +30,8 @@ enum OperationError: LocalizedError 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: "") } } } diff --git a/AltStore/Operations/OperationGroup.swift b/AltStore/Operations/OperationGroup.swift new file mode 100644 index 00000000..6a081e14 --- /dev/null +++ b/AltStore/Operations/OperationGroup.swift @@ -0,0 +1,63 @@ +// +// OperationGroup.swift +// AltStore +// +// Created by Riley Testut on 6/20/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation +import CoreData + +import AltSign + +class OperationGroup +{ + let progress = Progress.discreteProgress(totalUnitCount: 0) + + var completionHandler: ((Result<[String: Result], Error>) -> Void)? + var beginInstallationHandler: ((InstalledApp) -> Void)? + + var server: Server? + var signer: ALTSigner? + + var error: Error? + + var results = [String: Result]() + + private let operationQueue = OperationQueue() + private let installOperationQueue = OperationQueue() + + init() + { + // Enforce only one installation at a time. + self.installOperationQueue.maxConcurrentOperationCount = 1 + } + + func cancel() + { + self.operationQueue.cancelAllOperations() + self.installOperationQueue.cancelAllOperations() + } + + func addOperations(_ operations: [Operation]) + { + for operation in operations + { + if let installOperation = operation as? InstallAppOperation + { + if let previousOperation = self.installOperationQueue.operations.last + { + // Ensures they execute in the order they're added, since isReady is still false at this point. + installOperation.addDependency(previousOperation) + } + + self.installOperationQueue.addOperation(installOperation) + } + else + { + self.operationQueue.addOperation(operation) + } + } + } +} diff --git a/AltStore/Operations/ResignAppOperation.swift b/AltStore/Operations/ResignAppOperation.swift index 5ab218a9..d7897637 100644 --- a/AltStore/Operations/ResignAppOperation.swift +++ b/AltStore/Operations/ResignAppOperation.swift @@ -14,15 +14,13 @@ import AltSign @objc(ResignAppOperation) class ResignAppOperation: ResultOperation { - let installedApp: InstalledApp - private var context: NSManagedObjectContext? + let context: AppOperationContext - var signer: ALTSigner? + private let temporaryDirectory: URL = FileManager.default.uniqueTemporaryURL() - init(installedApp: InstalledApp) + init(context: AppOperationContext) { - self.installedApp = installedApp - self.context = installedApp.managedObjectContext + self.context = context super.init() @@ -33,17 +31,38 @@ class ResignAppOperation: ResultOperation { super.main() - guard let context = self.context else { return self.finish(.failure(OperationError.appNotFound)) } - guard let signer = self.signer else { return self.finish(.failure(OperationError.notAuthenticated)) } + do + { + try FileManager.default.createDirectory(at: self.temporaryDirectory, withIntermediateDirectories: true, attributes: nil) + } + catch + { + self.finish(.failure(error)) + return + } - context.perform { + if let error = self.context.error + { + self.finish(.failure(error)) + return + } + + guard + let installedApp = self.context.installedApp, + let appContext = installedApp.managedObjectContext, + let signer = self.context.group.signer + else { return self.finish(.failure(OperationError.invalidParameters)) } + + appContext.perform { + let appIdentifier = installedApp.app.identifier + // Register Device self.registerCurrentDevice(for: signer.team) { (result) in guard let _ = self.process(result) else { return } // Register App - context.perform { - self.register(self.installedApp.app, team: signer.team) { (result) in + appContext.perform { + self.register(installedApp.app, team: signer.team) { (result) in guard let appID = self.process(result) else { return } // Fetch Provisioning Profile @@ -51,27 +70,29 @@ class ResignAppOperation: ResultOperation guard let profile = self.process(result) else { return } // Prepare app bundle - context.perform { + appContext.perform { let prepareAppProgress = Progress.discreteProgress(totalUnitCount: 2) self.progress.addChild(prepareAppProgress, withPendingUnitCount: 3) - let prepareAppBundleProgress = self.prepareAppBundle(for: self.installedApp) { (result) in + let prepareAppBundleProgress = self.prepareAppBundle(for: installedApp) { (result) in guard let appBundleURL = self.process(result) else { return } + print("Resigning App:", appIdentifier) + // Resign app bundle let resignProgress = self.resignAppBundle(at: appBundleURL, signer: signer, profile: profile) { (result) in guard let resignedURL = self.process(result) else { return } // Finish - context.perform { + appContext.perform { do { - try FileManager.default.copyItem(at: resignedURL, to: self.installedApp.refreshedIPAURL, shouldReplace: true) + installedApp.expirationDate = profile.expirationDate + installedApp.refreshedDate = Date() - let refreshedDirectory = resignedURL.deletingLastPathComponent() - try? FileManager.default.removeItem(at: refreshedDirectory) + try FileManager.default.copyItem(at: resignedURL, to: installedApp.refreshedIPAURL, shouldReplace: true) - self.finish(.success(self.installedApp.refreshedIPAURL)) + self.finish(.success(installedApp.refreshedIPAURL)) } catch { @@ -107,6 +128,17 @@ class ResignAppOperation: ResultOperation return value } } + + override func finish(_ result: Result) + { + super.finish(result) + + if FileManager.default.fileExists(atPath: self.temporaryDirectory.path, isDirectory: nil) + { + do { try FileManager.default.removeItem(at: self.temporaryDirectory) } + catch { print("Failed to remove app bundle.", error) } + } + } } private extension ResignAppOperation @@ -179,25 +211,21 @@ private extension ResignAppOperation { let progress = Progress.discreteProgress(totalUnitCount: 1) - let refreshedAppDirectory = installedApp.directoryURL.appendingPathComponent("Refreshed", isDirectory: true) - let ipaURL = installedApp.ipaURL let bundleIdentifier = installedApp.bundleIdentifier let openURL = installedApp.openAppURL let appIdentifier = installedApp.app.identifier + let fileURL = installedApp.fileURL + DispatchQueue.global().async { do { - if FileManager.default.fileExists(atPath: refreshedAppDirectory.path) - { - try FileManager.default.removeItem(at: refreshedAppDirectory) - } - try FileManager.default.createDirectory(at: refreshedAppDirectory, withIntermediateDirectories: true, attributes: nil) + let appBundleURL = self.temporaryDirectory.appendingPathComponent("App.app") + try FileManager.default.copyItem(at: fileURL, to: appBundleURL) // Become current so we can observe progress from unzipAppBundle(). progress.becomeCurrent(withPendingUnitCount: 1) - let appBundleURL = try FileManager.default.unzipAppBundle(at: ipaURL, toDirectory: refreshedAppDirectory) guard let bundle = Bundle(url: appBundleURL) else { throw ALTError(.missingAppBundle) } guard var infoDictionary = NSDictionary(contentsOf: bundle.infoPlistURL) as? [String: Any] else { throw ALTError(.missingInfoPlist) } diff --git a/AltStore/Operations/SendAppOperation.swift b/AltStore/Operations/SendAppOperation.swift new file mode 100644 index 00000000..ad96ece1 --- /dev/null +++ b/AltStore/Operations/SendAppOperation.swift @@ -0,0 +1,128 @@ +// +// SendAppOperation.swift +// AltStore +// +// Created by Riley Testut on 6/7/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation +import Network + +import AltKit + +@objc(SendAppOperation) +class SendAppOperation: ResultOperation +{ + let context: AppOperationContext + + private let dispatchQueue = DispatchQueue(label: "com.altstore.SendAppOperation") + + private var connection: NWConnection? + + init(context: AppOperationContext) + { + self.context = context + + super.init() + + self.progress.totalUnitCount = 1 + } + + override func main() + { + super.main() + + if let error = self.context.error + { + self.finish(.failure(error)) + return + } + + guard let fileURL = self.context.resignedFileURL, let server = self.context.group.server else { return self.finish(.failure(OperationError.invalidParameters)) } + + // Connect to server. + self.connect(to: server) { (result) in + switch result + { + case .failure(let error): self.finish(.failure(error)) + case .success(let connection): + self.connection = connection + + // Send app to server. + self.sendApp(at: fileURL, via: connection, server: server) { (result) in + switch result + { + case .failure(let error): self.finish(.failure(error)) + case .success: + self.progress.completedUnitCount += 1 + self.finish(.success(connection)) + } + } + } + } + } +} + +private extension SendAppOperation +{ + func connect(to server: Server, completionHandler: @escaping (Result) -> Void) + { + let connection = NWConnection(to: .service(name: server.service.name, type: server.service.type, domain: server.service.domain, interface: nil), using: .tcp) + + connection.stateUpdateHandler = { [unowned connection] (state) in + switch state + { + case .failed(let error): + print("Failed to connect to service \(server.service.name).", error) + completionHandler(.failure(ConnectionError.connectionFailed)) + + case .cancelled: + completionHandler(.failure(OperationError.cancelled)) + + case .ready: + completionHandler(.success(connection)) + + case .waiting: break + case .setup: break + case .preparing: break + @unknown default: break + } + } + + connection.start(queue: self.dispatchQueue) + } + + func sendApp(at fileURL: URL, via connection: NWConnection, server: Server, completionHandler: @escaping (Result) -> Void) + { + do + { + guard let appData = try? Data(contentsOf: fileURL) else { throw OperationError.invalidApp } + guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { throw OperationError.unknownUDID } + + let request = PrepareAppRequest(udid: udid, contentSize: appData.count) + + print("Sending request \(request)") + server.send(request, via: connection) { (result) in + switch result + { + case .failure(let error): completionHandler(.failure(error)) + case .success: + + print("Sending app data (\(appData.count) bytes)") + server.send(appData, via: connection, prependSize: false) { (result) in + switch result + { + case .failure(let error): completionHandler(.failure(error)) + case .success: completionHandler(.success(())) + } + } + } + } + } + catch + { + completionHandler(.failure(error)) + } + } +} diff --git a/AltStore/Server/Server.swift b/AltStore/Server/Server.swift new file mode 100644 index 00000000..c10868e9 --- /dev/null +++ b/AltStore/Server/Server.swift @@ -0,0 +1,170 @@ +// +// Server.swift +// AltStore +// +// Created by Riley Testut on 6/20/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Network + +import AltKit + +extension ALTServerError +{ + init(_ error: E) + { + switch error + { + case let error as ALTServerError: self = error + case is DecodingError: self = ALTServerError(.invalidResponse) + case is EncodingError: self = ALTServerError(.invalidRequest) + default: + assertionFailure("Caught unknown error type") + self = ALTServerError(.unknown) + } + } +} + +enum ConnectionError: LocalizedError +{ + case serverNotFound + case connectionFailed + case connectionDropped + + var errorDescription: String? { + switch self + { + case .serverNotFound: return NSLocalizedString("Could not find AltServer.", comment: "") + case .connectionFailed: return NSLocalizedString("Could not connect to AltServer.", comment: "") + case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "") + } + } +} + +struct Server: Equatable +{ + var service: NetService + + func send(_ payload: T, via connection: NWConnection, prependSize: Bool = true, completionHandler: @escaping (Result) -> Void) + { + do + { + let data: Data + + if let payload = payload as? Data + { + data = payload + } + else + { + data = try JSONEncoder().encode(payload) + } + + func process(_ error: Error?) -> Bool + { + if error != nil + { + completionHandler(.failure(ConnectionError.connectionDropped)) + return false + } + else + { + return true + } + } + + if prependSize + { + let requestSize = Int32(data.count) + let requestSizeData = withUnsafeBytes(of: requestSize) { Data($0) } + + connection.send(content: requestSizeData, completion: .contentProcessed { (error) in + guard process(error) else { return } + + connection.send(content: data, completion: .contentProcessed { (error) in + guard process(error) else { return } + completionHandler(.success(())) + }) + }) + + } + else + { + connection.send(content: data, completion: .contentProcessed { (error) in + guard process(error) else { return } + completionHandler(.success(())) + }) + } + } + catch + { + print("Invalid request.", error) + completionHandler(.failure(ALTServerError(.invalidRequest))) + } + } + + func receive(_ type: T.Type, from connection: NWConnection, completionHandler: @escaping (Result) -> Void) + { + let size = MemoryLayout.size + + connection.receive(minimumIncompleteLength: size, maximumLength: size) { (data, _, _, error) in + do + { + let data = try self.process(data: data, error: error, from: connection) + + let expectedBytes = Int(data.withUnsafeBytes { $0.load(as: Int32.self) }) + connection.receive(minimumIncompleteLength: expectedBytes, maximumLength: expectedBytes) { (data, _, _, error) in + do + { + let data = try self.process(data: data, error: error, from: connection) + + let response = try JSONDecoder().decode(T.self, from: data) + completionHandler(.success(response)) + } + catch + { + completionHandler(.failure(ALTServerError(error))) + } + } + } + catch + { + completionHandler(.failure(ALTServerError(error))) + } + } + } +} + +private extension Server +{ + func process(data: Data?, error: NWError?, from connection: NWConnection) throws -> Data + { + do + { + do + { + guard let data = data else { throw error ?? ALTServerError(.unknown) } + return data + } + catch let error as NWError + { + print("Error receiving data from connection \(connection)", error) + + throw ALTServerError(.lostConnection) + } + catch + { + throw error + } + } + catch let error as ALTServerError + { + throw error + } + catch + { + preconditionFailure("A non-ALTServerError should never be thrown from this method.") + } + } +} diff --git a/AltStore/Server/ServerManager.swift b/AltStore/Server/ServerManager.swift index 7073748b..a1cbb47b 100644 --- a/AltStore/Server/ServerManager.swift +++ b/AltStore/Server/ServerManager.swift @@ -11,11 +11,6 @@ import Network import AltKit -struct Server: Equatable -{ - var service: NetService -} - class ServerManager: NSObject { static let shared = ServerManager() diff --git a/AltStore/Updates/UpdatesViewController.swift b/AltStore/Updates/UpdatesViewController.swift index 960f185d..e4007ce4 100644 --- a/AltStore/Updates/UpdatesViewController.swift +++ b/AltStore/Updates/UpdatesViewController.swift @@ -123,8 +123,7 @@ private extension UpdatesViewController let progress = AppManager.shared.install(installedApp.app, presentingViewController: self) { (result) in do { - let app = try result.get() - try app.managedObjectContext?.save() + _ = try result.get() DispatchQueue.main.async { let installedApp = DatabaseManager.shared.persistentContainer.viewContext.object(with: installedApp.objectID) as! InstalledApp