diff --git a/AltKit/ServerProtocol.swift b/AltKit/ServerProtocol.swift index 42604e8f..2eb97c59 100644 --- a/AltKit/ServerProtocol.swift +++ b/AltKit/ServerProtocol.swift @@ -27,7 +27,8 @@ public struct ServerRequest: Codable public struct ServerResponse: Codable { - public var success: Bool + public var progress: Double + public var error: ALTServerError? { get { guard let code = self.errorCode else { return nil } @@ -37,12 +38,11 @@ public struct ServerResponse: Codable self.errorCode = newValue?.code } } - private var errorCode: ALTServerError.Code? - public init(success: Bool, error: ALTServerError?) + public init(progress: Double, error: ALTServerError?) { - self.success = success + self.progress = progress self.error = error } } diff --git a/AltServer/Connections/ConnectionManager.swift b/AltServer/Connections/ConnectionManager.swift index abda68b2..98eab3e5 100644 --- a/AltServer/Connections/ConnectionManager.swift +++ b/AltServer/Connections/ConnectionManager.swift @@ -184,8 +184,7 @@ private extension ConnectionManager print("Processed request from \(connection.endpoint).") } - let success = (error == nil) - let response = ServerResponse(success: success, error: error) + let response = ServerResponse(progress: 1.0, error: error) self.send(response, to: connection) { (result) in print("Sent response to \(connection.endpoint) with result:", result) @@ -209,12 +208,15 @@ private extension ConnectionManager { case .failure(let error): finish(error: error) case .success(let request, let fileURL): - print("Installing to device..") + print("Installing to device \(request.udid)...") - ALTDeviceManager.shared.installApp(at: fileURL, toDeviceWithUDID: request.udid) { (success, error) in - print("Installed app with result:", result) - let error = error.map { $0 as? ALTServerError ?? ALTServerError(.unknown) } - finish(error: error) + 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) + } } } } @@ -303,6 +305,46 @@ private extension ConnectionManager } } } + + func installApp(at fileURL: URL, toDeviceWithUDID udid: String, connection: NWConnection, completionHandler: @escaping (Result) -> Void) + { + let serialQueue = DispatchQueue(label: "com.altstore.ConnectionManager.installQueue", qos: .default) + var isSending = false + + var observation: NSKeyValueObservation? + + let progress = ALTDeviceManager.shared.installApp(at: fileURL, toDeviceWithUDID: udid) { (success, error) in + print("Installed app with result:", error == nil ? "Success" : error!.localizedDescription) + + if let error = error.map({ $0 as? ALTServerError ?? ALTServerError(.unknown) }) + { + completionHandler(.failure(error)) + } + else + { + completionHandler(.success(())) + } + + observation?.invalidate() + observation = nil + } + + observation = progress.observe(\.fractionCompleted, changeHandler: { (progress, change) in + serialQueue.async { + guard !isSending else { return } + isSending = true + + print("Progress:", progress.fractionCompleted) + let response = ServerResponse(progress: progress.fractionCompleted, error: nil) + + self.send(response, to: connection) { (result) in + serialQueue.async { + isSending = false + } + } + } + }) + } func send(_ response: ServerResponse, to connection: NWConnection, completionHandler: @escaping (Result) -> Void) { diff --git a/AltServer/Devices/ALTDeviceManager.mm b/AltServer/Devices/ALTDeviceManager.mm index 49d398ca..a4693bd0 100644 --- a/AltServer/Devices/ALTDeviceManager.mm +++ b/AltServer/Devices/ALTDeviceManager.mm @@ -25,6 +25,7 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError"; @property (nonatomic, readonly) NSMutableDictionary *installationCompletionHandlers; @property (nonatomic, readonly) NSMutableDictionary *installationProgress; @property (nonatomic, readonly) NSMutableDictionary *installationClients; +@property (nonatomic, readonly) dispatch_queue_t installationQueue; @end @@ -49,6 +50,8 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError"; _installationCompletionHandlers = [NSMutableDictionary dictionary]; _installationProgress = [NSMutableDictionary dictionary]; _installationClients = [NSMutableDictionary dictionary]; + + _installationQueue = dispatch_queue_create("com.rileytestut.AltServer.InstallationQueue", DISPATCH_QUEUE_SERIAL); } return self; @@ -56,222 +59,239 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError"; - (NSProgress *)installAppAtURL:(NSURL *)fileURL toDeviceWithUDID:(NSString *)udid completionHandler:(void (^)(BOOL, NSError * _Nullable))completionHandler { - NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:100]; + NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:4]; - NSUUID *UUID = [NSUUID UUID]; - __block char *uuidString = (char *)malloc(UUID.UUIDString.length + 1); - strncpy(uuidString, (const char *)UUID.UUIDString.UTF8String, UUID.UUIDString.length); - uuidString[UUID.UUIDString.length] = '\0'; - - idevice_t device = NULL; - lockdownd_client_t client = NULL; - instproxy_client_t ipc = NULL; - np_client_t np = NULL; - afc_client_t afc = NULL; - lockdownd_service_descriptor_t service = NULL; - - void (^finish)(NSError *error) = ^(NSError *error) { - np_client_free(np); - instproxy_client_free(ipc); - afc_client_free(afc); - lockdownd_client_free(client); - idevice_free(device); - lockdownd_service_descriptor_free(service); + dispatch_async(self.installationQueue, ^{ + NSUUID *UUID = [NSUUID UUID]; + __block char *uuidString = (char *)malloc(UUID.UUIDString.length + 1); + strncpy(uuidString, (const char *)UUID.UUIDString.UTF8String, UUID.UUIDString.length); + uuidString[UUID.UUIDString.length] = '\0'; - free(uuidString); - uuidString = NULL; + idevice_t device = NULL; + lockdownd_client_t client = NULL; + instproxy_client_t ipc = NULL; + np_client_t np = NULL; + afc_client_t afc = NULL; + lockdownd_service_descriptor_t service = NULL; - if (error != nil) + void (^finish)(NSError *error) = ^(NSError *error) { + np_client_free(np); + instproxy_client_free(ipc); + afc_client_free(afc); + lockdownd_client_free(client); + idevice_free(device); + lockdownd_service_descriptor_free(service); + + free(uuidString); + uuidString = NULL; + + if (error != nil) + { + completionHandler(NO, error); + } + else + { + completionHandler(YES, nil); + } + }; + + NSURL *appBundleURL = nil; + NSURL *temporaryDirectoryURL = nil; + + if ([fileURL.pathExtension.lowercaseString isEqualToString:@"app"]) { - completionHandler(NO, error); + appBundleURL = fileURL; + temporaryDirectoryURL = nil; + } + else if ([fileURL.pathExtension.lowercaseString isEqualToString:@"ipa"]) + { + NSLog(@"Unzipping .ipa..."); + + temporaryDirectoryURL = [NSFileManager.defaultManager.temporaryDirectory URLByAppendingPathComponent:[[NSUUID UUID] UUIDString] isDirectory:YES]; + + NSError *error = nil; + if (![[NSFileManager defaultManager] createDirectoryAtURL:temporaryDirectoryURL withIntermediateDirectories:YES attributes:nil error:&error]) + { + return finish(error); + } + + appBundleURL = [[NSFileManager defaultManager] unzipAppBundleAtURL:fileURL toDirectory:temporaryDirectoryURL error:&error]; + if (appBundleURL == nil) + { + return finish(error); + } } else { - completionHandler(YES, nil); + return finish([NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadCorruptFileError userInfo:@{NSURLErrorKey: fileURL}]); } - }; - - NSURL *appBundleURL = nil; - NSURL *temporaryDirectoryURL = nil; - - if ([fileURL.pathExtension.lowercaseString isEqualToString:@"app"]) - { - appBundleURL = fileURL; - temporaryDirectoryURL = nil; - } - else if ([fileURL.pathExtension.lowercaseString isEqualToString:@"ipa"]) - { - NSLog(@"Unzipping .ipa..."); - temporaryDirectoryURL = [NSFileManager.defaultManager.temporaryDirectory URLByAppendingPathComponent:[[NSUUID UUID] UUIDString] isDirectory:YES]; - - NSError *error = nil; - if (![[NSFileManager defaultManager] createDirectoryAtURL:temporaryDirectoryURL withIntermediateDirectories:YES attributes:nil error:&error]) + /* Find Device */ + if (idevice_new(&device, udid.UTF8String) != IDEVICE_E_SUCCESS) { - finish(error); - return progress; + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorDeviceNotFound userInfo:nil]); } - appBundleURL = [[NSFileManager defaultManager] unzipAppBundleAtURL:fileURL toDirectory:temporaryDirectoryURL error:&error]; - if (appBundleURL == nil) + /* Connect to Device */ + if (lockdownd_client_new_with_handshake(device, &client, "altserver") != LOCKDOWN_E_SUCCESS) { - finish(error); - return progress; + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); } - } - else - { - finish([NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadCorruptFileError userInfo:@{NSURLErrorKey: fileURL}]); - return progress; - } - - /* Find Device */ - if (idevice_new(&device, udid.UTF8String) != IDEVICE_E_SUCCESS) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorDeviceNotFound userInfo:nil]); - return progress; - } - - /* Connect to Device */ - if (lockdownd_client_new_with_handshake(device, &client, "altserver") != LOCKDOWN_E_SUCCESS) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); - return progress; - } - - /* Connect to Notification Proxy */ - if ((lockdownd_start_service(client, "com.apple.mobile.notification_proxy", &service) != LOCKDOWN_E_SUCCESS) || service == NULL) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); - return progress; - } - - if (np_client_new(device, service, &np) != NP_E_SUCCESS) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); - return progress; - } - - np_set_notify_callback(np, ALTDeviceManagerDidFinishAppInstallation, uuidString); - - const char *notifications[2] = { NP_APP_INSTALLED, NULL }; - np_observe_notifications(np, notifications); - - if (service) - { + + /* Connect to Notification Proxy */ + if ((lockdownd_start_service(client, "com.apple.mobile.notification_proxy", &service) != LOCKDOWN_E_SUCCESS) || service == NULL) + { + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); + } + + if (np_client_new(device, service, &np) != NP_E_SUCCESS) + { + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); + } + + np_set_notify_callback(np, ALTDeviceManagerDidFinishAppInstallation, uuidString); + + const char *notifications[2] = { NP_APP_INSTALLED, NULL }; + np_observe_notifications(np, notifications); + + if (service) + { + lockdownd_service_descriptor_free(service); + service = NULL; + } + + /* Connect to Installation Proxy */ + if ((lockdownd_start_service(client, "com.apple.mobile.installation_proxy", &service) != LOCKDOWN_E_SUCCESS) || service == NULL) + { + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); + } + + if (instproxy_client_new(device, service, &ipc) != INSTPROXY_E_SUCCESS) + { + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); + } + + if (service) + { + lockdownd_service_descriptor_free(service); + service = NULL; + } + lockdownd_service_descriptor_free(service); service = NULL; - } - - /* Connect to Installation Proxy */ - if ((lockdownd_start_service(client, "com.apple.mobile.installation_proxy", &service) != LOCKDOWN_E_SUCCESS) || service == NULL) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); - return progress; - } - - if (instproxy_client_new(device, service, &ipc) != INSTPROXY_E_SUCCESS) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); - return progress; - } - - if (service) - { - lockdownd_service_descriptor_free(service); - service = NULL; - } - - lockdownd_service_descriptor_free(service); - service = NULL; - - /* Connect to AFC service */ - if ((lockdownd_start_service(client, "com.apple.afc", &service) != LOCKDOWN_E_SUCCESS) || service == NULL) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); - return progress; - } - - lockdownd_client_free(client); - client = NULL; - - if (afc_client_new(device, service, &afc) != AFC_E_SUCCESS) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); - return progress; - } - - NSURL *stagingURL = [NSURL fileURLWithPath:@"PublicStaging" isDirectory:YES]; - - /* Prepare for installation */ - char **files = NULL; - if (afc_get_file_info(afc, stagingURL.relativePath.fileSystemRepresentation, &files) != AFC_E_SUCCESS) - { - if (afc_make_directory(afc, stagingURL.relativePath.fileSystemRepresentation) != AFC_E_SUCCESS) - { - finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorDeviceWriteFailed userInfo:nil]); - return progress; - } - } - - if (files) - { - int i = 0; - while (files[i]) + /* Connect to AFC service */ + if ((lockdownd_start_service(client, "com.apple.afc", &service) != LOCKDOWN_E_SUCCESS) || service == NULL) { - free(files[i]); - i++; + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); } - free(files); - } - - NSLog(@"Writing to device..."); - - plist_t options = instproxy_client_options_new(); - instproxy_client_options_add(options, "PackageType", "Developer", NULL); - - NSURL *destinationURL = [stagingURL URLByAppendingPathComponent:appBundleURL.lastPathComponent]; - - NSError *writeError = nil; - if (![self writeDirectory:appBundleURL toDestinationURL:destinationURL client:afc error:&writeError]) - { - finish(writeError); - return progress; - } - - NSLog(@"Finished writing to device."); - - NSValue *value = [NSValue valueWithPointer:(const void *)np]; - - self.installationClients[UUID] = value; - self.installationProgress[UUID] = progress; - self.installationCompletionHandlers[UUID] = ^{ - finish(nil); + lockdownd_client_free(client); + client = NULL; - if (temporaryDirectoryURL != nil) + if (afc_client_new(device, service, &afc) != AFC_E_SUCCESS) { - NSError *error = nil; - if (![[NSFileManager defaultManager] removeItemAtURL:temporaryDirectoryURL error:&error]) + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]); + } + + NSURL *stagingURL = [NSURL fileURLWithPath:@"PublicStaging" isDirectory:YES]; + + /* Prepare for installation */ + char **files = NULL; + if (afc_get_file_info(afc, stagingURL.relativePath.fileSystemRepresentation, &files) != AFC_E_SUCCESS) + { + if (afc_make_directory(afc, stagingURL.relativePath.fileSystemRepresentation) != AFC_E_SUCCESS) { - NSLog(@"Error removing temporary directory. %@", error); + return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorDeviceWriteFailed userInfo:nil]); } } - }; - - NSLog(@"Installing to device %@...", udid); - - instproxy_install(ipc, destinationURL.relativePath.fileSystemRepresentation, options, ALTDeviceManagerUpdateStatus, uuidString); - instproxy_client_options_free(options); + + if (files) + { + int i = 0; + + while (files[i]) + { + free(files[i]); + i++; + } + + free(files); + } + + NSLog(@"Writing to device..."); + + plist_t options = instproxy_client_options_new(); + instproxy_client_options_add(options, "PackageType", "Developer", NULL); + + NSURL *destinationURL = [stagingURL URLByAppendingPathComponent:appBundleURL.lastPathComponent]; + + // Writing files to device should be worth 3/4 of total work. + [progress becomeCurrentWithPendingUnitCount:3]; + + NSError *writeError = nil; + if (![self writeDirectory:appBundleURL toDestinationURL:destinationURL client:afc progress:nil error:&writeError]) + { + return finish(writeError); + } + + NSLog(@"Finished writing to device."); + + NSProgress *installationProgress = [NSProgress progressWithTotalUnitCount:100 parent:progress pendingUnitCount:1]; + + NSValue *value = [NSValue valueWithPointer:(const void *)np]; + + self.installationClients[UUID] = value; + self.installationProgress[UUID] = installationProgress; + self.installationCompletionHandlers[UUID] = ^{ + finish(nil); + + if (temporaryDirectoryURL != nil) + { + NSError *error = nil; + if (![[NSFileManager defaultManager] removeItemAtURL:temporaryDirectoryURL error:&error]) + { + NSLog(@"Error removing temporary directory. %@", error); + } + } + }; + + NSLog(@"Installing to device %@...", udid); + + instproxy_install(ipc, destinationURL.relativePath.fileSystemRepresentation, options, ALTDeviceManagerUpdateStatus, uuidString); + instproxy_client_options_free(options); + }); return progress; } -- (BOOL)writeDirectory:(NSURL *)directoryURL toDestinationURL:(NSURL *)destinationURL client:(afc_client_t)afc error:(NSError **)error +- (BOOL)writeDirectory:(NSURL *)directoryURL toDestinationURL:(NSURL *)destinationURL client:(afc_client_t)afc progress:(NSProgress *)progress error:(NSError **)error { afc_make_directory(afc, destinationURL.relativePath.fileSystemRepresentation); + if (progress == nil) + { + NSDirectoryEnumerator *countEnumerator = [[NSFileManager defaultManager] enumeratorAtURL:directoryURL + includingPropertiesForKeys:@[] + options:0 + errorHandler:^BOOL(NSURL * _Nonnull url, NSError * _Nonnull error) { + if (error) { + NSLog(@"[Error] %@ (%@)", error, url); + return NO; + } + + return YES; + }]; + + NSInteger totalCount = 0; + for (NSURL *__unused fileURL in countEnumerator) + { + totalCount++; + } + + progress = [NSProgress progressWithTotalUnitCount:totalCount]; + } + NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtURL:directoryURL includingPropertiesForKeys:@[NSURLIsDirectoryKey] options:NSDirectoryEnumerationSkipsSubdirectoryDescendants @@ -295,7 +315,7 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError"; if ([isDirectory boolValue]) { NSURL *destinationDirectoryURL = [destinationURL URLByAppendingPathComponent:fileURL.lastPathComponent isDirectory:YES]; - if (![self writeDirectory:fileURL toDestinationURL:destinationDirectoryURL client:afc error:error]) + if (![self writeDirectory:fileURL toDestinationURL:destinationDirectoryURL client:afc progress:progress error:error]) { return NO; } @@ -308,6 +328,8 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError"; return NO; } } + + progress.completedUnitCount += 1; } return YES; @@ -315,12 +337,19 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError"; - (BOOL)writeFile:(NSURL *)fileURL toDestinationURL:(NSURL *)destinationURL client:(afc_client_t)afc error:(NSError **)error { - NSData *data = [NSData dataWithContentsOfURL:fileURL options:0 error:error]; - if (data == nil) + NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:fileURL.path]; + if (fileHandle == nil) { + if (error) + { + *error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileNoSuchFileError userInfo:@{NSURLErrorKey: fileURL}]; + } + return NO; } + NSData *data = [fileHandle readDataToEndOfFile]; + uint64_t af = 0; if ((afc_file_open(afc, destinationURL.relativePath.fileSystemRepresentation, AFC_FOPEN_WRONLY, &af) != AFC_E_SUCCESS) || af == 0) { @@ -334,11 +363,12 @@ NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError"; BOOL success = YES; uint32_t bytesWritten = 0; - + while (bytesWritten < data.length) { uint32_t count = 0; - if (afc_file_write(afc, af, (const char *)data.bytes, (uint32_t)data.length, &bytesWritten) != AFC_E_SUCCESS) + + if (afc_file_write(afc, af, (const char *)data.bytes + bytesWritten, (uint32_t)data.length - bytesWritten, &count) != AFC_E_SUCCESS) { if (error) { diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index f45e1776..6189beab 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -98,6 +98,7 @@ BFBBE2DD22931B20002097FA /* AltStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = BFBBE2DB22931B20002097FA /* AltStore.xcdatamodeld */; }; BFBBE2DF22931F73002097FA /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBBE2DE22931F73002097FA /* App.swift */; }; BFBBE2E122931F81002097FA /* InstalledApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBBE2E022931F81002097FA /* InstalledApp.swift */; }; + BFC1F38D22AEE3A4003AC21A /* DownloadAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1F38C22AEE3A4003AC21A /* DownloadAppOperation.swift */; }; BFD2476E2284B9A500981D42 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD2476D2284B9A500981D42 /* AppDelegate.swift */; }; BFD247702284B9A500981D42 /* AppsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD2476F2284B9A500981D42 /* AppsViewController.swift */; }; BFD247722284B9A500981D42 /* MyAppsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD247712284B9A500981D42 /* MyAppsViewController.swift */; }; @@ -112,7 +113,6 @@ BFD2479C2284E19A00981D42 /* AppDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD2479B2284E19A00981D42 /* AppDetailViewController.swift */; }; BFD2479F2284FBD000981D42 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD2479E2284FBD000981D42 /* UIColor+AltStore.swift */; }; BFD52BD422A0800A000B7ED1 /* ServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD52BD322A0800A000B7ED1 /* ServerManager.swift */; }; - BFD52BD622A08A85000B7ED1 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD52BD522A08A85000B7ED1 /* Server.swift */; }; BFD52C0122A1A9CB000B7ED1 /* ptrarray.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52BE522A1A9CA000B7ED1 /* ptrarray.c */; }; BFD52C0222A1A9CB000B7ED1 /* base64.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52BE622A1A9CA000B7ED1 /* base64.c */; }; BFD52C0322A1A9CB000B7ED1 /* hashtable.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52BE722A1A9CA000B7ED1 /* hashtable.c */; }; @@ -146,6 +146,10 @@ BFD52C2222A1A9EC000B7ED1 /* cnary.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52C1F22A1A9EC000B7ED1 /* cnary.c */; }; BFDB69FD22A9A7B7007EA6D6 /* AccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB69FC22A9A7B7007EA6D6 /* AccountViewController.swift */; }; BFDB6A0522A9AFB2007EA6D6 /* Fetchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0422A9AFB2007EA6D6 /* Fetchable.swift */; }; + 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 */; }; 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 */; }; @@ -315,6 +319,7 @@ BFBBE2DC22931B20002097FA /* AltStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AltStore.xcdatamodel; sourceTree = ""; }; BFBBE2DE22931F73002097FA /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; BFBBE2E022931F81002097FA /* InstalledApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledApp.swift; sourceTree = ""; }; + BFC1F38C22AEE3A4003AC21A /* DownloadAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAppOperation.swift; sourceTree = ""; }; BFD2476A2284B9A500981D42 /* AltStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AltStore.app; sourceTree = BUILT_PRODUCTS_DIR; }; BFD2476D2284B9A500981D42 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; BFD2476F2284B9A500981D42 /* AppsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsViewController.swift; sourceTree = ""; }; @@ -331,7 +336,6 @@ BFD2479E2284FBD000981D42 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = ""; }; BFD52BD222A06EFB000B7ED1 /* AltKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AltKit.h; sourceTree = ""; }; BFD52BD322A0800A000B7ED1 /* ServerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.swift; sourceTree = ""; }; - BFD52BD522A08A85000B7ED1 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; BFD52BE522A1A9CA000B7ED1 /* ptrarray.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = ptrarray.c; path = Dependencies/libplist/src/ptrarray.c; sourceTree = SOURCE_ROOT; }; BFD52BE622A1A9CA000B7ED1 /* base64.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = base64.c; path = Dependencies/libplist/src/base64.c; sourceTree = SOURCE_ROOT; }; BFD52BE722A1A9CA000B7ED1 /* hashtable.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = hashtable.c; path = Dependencies/libplist/src/hashtable.c; sourceTree = SOURCE_ROOT; }; @@ -365,6 +369,10 @@ BFD52C1F22A1A9EC000B7ED1 /* cnary.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = cnary.c; path = Dependencies/libplist/libcnary/cnary.c; sourceTree = SOURCE_ROOT; }; BFDB69FC22A9A7B7007EA6D6 /* AccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountViewController.swift; sourceTree = ""; }; BFDB6A0422A9AFB2007EA6D6 /* Fetchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fetchable.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -641,7 +649,6 @@ isa = PBXGroup; children = ( BFD52BD322A0800A000B7ED1 /* ServerManager.swift */, - BFD52BD522A08A85000B7ED1 /* Server.swift */, ); path = Server; sourceTree = ""; @@ -682,6 +689,7 @@ BFDB69FB22A9A7A6007EA6D6 /* Account */, BFC51D7922972F1F00388324 /* Server */, BFD247982284D7FC00981D42 /* Model */, + BFDB6A0922AAEDA1007EA6D6 /* Operations */, BFD2478D2284C4C700981D42 /* Components */, BFDB6A0622A9B114007EA6D6 /* Protocols */, BFD2479D2284FBBD00981D42 /* Extensions */, @@ -793,6 +801,19 @@ path = Protocols; sourceTree = ""; }; + BFDB6A0922AAEDA1007EA6D6 /* Operations */ = { + isa = PBXGroup; + children = ( + BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */, + BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */, + BFE6326B22A86FF300F30809 /* AuthenticationOperation.swift */, + BFC1F38C22AEE3A4003AC21A /* DownloadAppOperation.swift */, + BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */, + BFDB6A0E22AB2776007EA6D6 /* InstallAppOperation.swift */, + ); + path = Operations; + sourceTree = ""; + }; BFE6325822A83BA800F30809 /* Authentication */ = { isa = PBXGroup; children = ( @@ -800,7 +821,6 @@ BFE6325B22A83C0100F30809 /* AuthenticationViewController.swift */, BFE6325D22A8497000F30809 /* SelectTeamViewController.swift */, BFE6326922A85DAF00F30809 /* ReplaceCertificateViewController.swift */, - BFE6326B22A86FF300F30809 /* AuthenticationOperation.swift */, ); path = Authentication; sourceTree = ""; @@ -1148,12 +1168,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + BFDB6A0F22AB2776007EA6D6 /* InstallAppOperation.swift in Sources */, + BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */, BFE6325E22A8497000F30809 /* SelectTeamViewController.swift in Sources */, BFD2478F2284C8F900981D42 /* Button.swift in Sources */, BFDB69FD22A9A7B7007EA6D6 /* AccountViewController.swift in Sources */, BFE6326A22A85DAF00F30809 /* ReplaceCertificateViewController.swift in Sources */, BFD247722284B9A500981D42 /* MyAppsViewController.swift in Sources */, BFB11692229322E400BB457C /* DatabaseManager.swift in Sources */, + BFC1F38D22AEE3A4003AC21A /* DownloadAppOperation.swift in Sources */, BFBBE2E122931F81002097FA /* InstalledApp.swift in Sources */, BFBBE2DF22931F73002097FA /* App.swift in Sources */, BF43002E22A714AF0051E2BC /* Keychain.swift in Sources */, @@ -1163,14 +1186,15 @@ BFD2479C2284E19A00981D42 /* AppDetailViewController.swift in Sources */, BFD2476E2284B9A500981D42 /* AppDelegate.swift in Sources */, BFD247702284B9A500981D42 /* AppsViewController.swift in Sources */, + BFDB6A0B22AAEDB7007EA6D6 /* Operation.swift in Sources */, BFB116A022933DEB00BB457C /* UpdatesViewController.swift in Sources */, BFBBE2DD22931B20002097FA /* AltStore.xcdatamodeld in Sources */, BFE6325C22A83C0100F30809 /* AuthenticationViewController.swift in Sources */, BFB1169B2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift in Sources */, BFD247932284D4B700981D42 /* AppTableViewCell.swift in Sources */, BFD52BD422A0800A000B7ED1 /* ServerManager.swift in Sources */, + BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */, BF0C4EBD22A1BD8B009A2DD7 /* AppManager.swift in Sources */, - BFD52BD622A08A85000B7ED1 /* Server.swift in Sources */, BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */, BFDB6A0522A9AFB2007EA6D6 /* Fetchable.swift in Sources */, BFD2479F2284FBD000981D42 /* UIColor+AltStore.swift in Sources */, diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index a4a2fcc9..4a03c0be 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -62,7 +62,7 @@ extension AppDelegate private func prepareForBackgroundFetch() { // Fetch every 6 hours. - UIApplication.shared.setMinimumBackgroundFetchInterval(60 * 60 * 6) + UIApplication.shared.setMinimumBackgroundFetchInterval(60 * 60) UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in } @@ -72,8 +72,10 @@ extension AppDelegate { ServerManager.shared.startDiscovering() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - AppManager.shared.refreshAllApps(presentingViewController: nil) { (result) in + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + let installedApps = InstalledApp.all(in: DatabaseManager.shared.viewContext) + + _ = AppManager.shared.refresh(installedApps, presentingViewController: nil) { (result) in ServerManager.shared.stopDiscovering() let content = UNMutableNotificationContent() @@ -87,9 +89,7 @@ extension AppDelegate guard case let .failure(error) = result else { continue } throw error } - - print(results) - + content.title = "Refreshed Apps!" content.body = "Successfully refreshed all apps." diff --git a/AltStore/Apps/AppDetailViewController.swift b/AltStore/Apps/AppDetailViewController.swift index 5596979d..b043ee2b 100644 --- a/AltStore/Apps/AppDetailViewController.swift +++ b/AltStore/Apps/AppDetailViewController.swift @@ -124,7 +124,11 @@ private extension AppDetailViewController sender.isIndicatingActivity = true - AppManager.shared.install(self.app, presentingViewController: self) { (result) in + 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() @@ -138,7 +142,7 @@ private extension AppDetailViewController toastView.show(in: self.navigationController!.view, duration: 2) } } - catch AppManager.AppError.authentication(AuthenticationOperation.Error.cancelled) + catch OperationError.cancelled { // Ignore } @@ -150,12 +154,28 @@ private extension AppDetailViewController toastView.show(in: self.navigationController!.view, duration: 2) } } - + DispatchQueue.main.async { + UIView.animate(withDuration: 0.4, animations: { + progressView.alpha = 0.0 + }) { _ in + progressView.removeFromSuperview() + } + sender.isIndicatingActivity = false self.update() } } + + progressView.observedProgress = progress + + if let navigationBar = self.navigationController?.navigationBar + { + navigationBar.addSubview(progressView) + + NSLayoutConstraint.activate([progressView.widthAnchor.constraint(equalTo: navigationBar.widthAnchor), + progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)]) + } } } diff --git a/AltStore/Apps/AppManager.swift b/AltStore/Apps/AppManager.swift index 6a8cb4bf..cd2d4cd8 100644 --- a/AltStore/Apps/AppManager.swift +++ b/AltStore/Apps/AppManager.swift @@ -14,52 +14,10 @@ import AltKit import Roxas -extension AppManager -{ - enum AppError: LocalizedError - { - case unknown - case missingUDID - case noServersFound - case missingPrivateKey - case missingCertificate - case notAuthenticated - - case multipleCertificates - case multipleTeams - - case download(URLError) - case authentication(Error) - case fetchingSigningResources(Error) - case prepare(Error) - case install(Error) - - var errorDescription: String? { - switch self - { - case .unknown: return "An unknown error occured." - case .missingUDID: return "The UDID for this device is unknown." - case .noServersFound: return "An active AltServer could not be found." - case .missingPrivateKey: return "A valid private key must be provided." - case .missingCertificate: return "A valid certificate must be provided." - case .notAuthenticated: return "You must be logged in with your Apple ID to install apps." - case .multipleCertificates: return "You must select a certificate to use to install apps." - case .multipleTeams: return "You must select a team to use to install apps." - case .download(let error): return error.localizedDescription - case .authentication(let error): return error.localizedDescription - case .fetchingSigningResources(let error): return error.localizedDescription - case .prepare(let error): return error.localizedDescription - case .install(let error): return error.localizedDescription - } - } - } -} - class AppManager { static let shared = AppManager() - private let session = URLSession(configuration: .default) private let operationQueue = OperationQueue() private init() @@ -107,7 +65,7 @@ extension AppManager #endif } - func authenticate(presentingViewController: UIViewController?, completionHandler: @escaping (Result<(ALTTeam, ALTCertificate), Error>) -> Void) + func authenticate(presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) { let authenticationOperation = AuthenticationOperation(presentingViewController: presentingViewController) authenticationOperation.resultHandler = { (result) in @@ -119,91 +77,60 @@ extension AppManager extension AppManager { - func install(_ app: App, presentingViewController: UIViewController, completionHandler: @escaping (Result) -> Void) + func install(_ app: App, presentingViewController: UIViewController, completionHandler: @escaping (Result) -> Void) -> Progress { - let ipaURL = InstalledApp.ipaURL(for: app) + let progress = Progress.discreteProgress(totalUnitCount: 100) - let backgroundTaskID = RSTBeginBackgroundTask("com.rileytestut.AltStore.InstallApp") - - func finish(_ result: Result) - { - completionHandler(result) - - RSTEndBackgroundTask(backgroundTaskID) - } - - // Download app - self.downloadApp(from: app.downloadURL) { (result) in - let result = result.flatMap { (fileURL) -> Result in - // Copy downloaded app to proper location - let result = Result { try FileManager.default.copyItem(at: fileURL, to: ipaURL, shouldReplace: true) } - return result.mapError { _ in URLError(.cannotWriteToFile) } - } - + // Authenticate + let authenticationOperation = AuthenticationOperation(presentingViewController: presentingViewController) + authenticationOperation.resultHandler = { (result) in switch result { - case .failure(let error): finish(.failure(.download(error))) - case .success: - // Authenticate - self.authenticate(presentingViewController: presentingViewController) { (result) in - switch result - { - case .failure(let error): finish(.failure(.authentication(error))) - case .success(let team, let certificate): - - // Fetch provisioning profile - self.prepareProvisioningProfile(for: app, team: team) { (result) in - switch result - { - case .failure(let error): finish(.failure(.fetchingSigningResources(error))) - case .success(let profile): - - // Prepare app - DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in - let app = context.object(with: app.objectID) as! App - - let installedApp = InstalledApp(app: app, - bundleIdentifier: profile.appID.bundleIdentifier, - expirationDate: profile.expirationDate, - context: context) - - let signer = ALTSigner(team: team, certificate: certificate) - self.prepare(installedApp, provisioningProfile: profile, signer: signer) { (result) in - switch result - { - case .failure(let error): finish(.failure(.prepare(error))) - case .success(let resignedURL): - - // Send app to server - context.perform { - self.sendAppToServer(fileURL: resignedURL, identifier: installedApp.bundleIdentifier) { (result) in - switch result - { - case .failure(let error): finish(.failure(.install(error))) - case .success: - context.perform { - finish(.success(installedApp)) - } - } - } - } - } + case .failure(let error): completionHandler(.failure(error)) + case .success(let signer): + + // Download + app.managedObjectContext?.perform { + let downloadAppOperation = DownloadAppOperation(app: app) + downloadAppOperation.resultHandler = { (result) in + switch result + { + case .failure(let error): completionHandler(.failure(error)) + case .success(let installedApp): + let context = installedApp.managedObjectContext + + // Refresh/Install + let (resignProgress, installProgress) = self.refresh(installedApp, signer: signer, presentingViewController: presentingViewController) { (result) in + switch result + { + case .failure(let error): completionHandler(.failure(error)) + case .success: + context?.perform { + completionHandler(.success(installedApp)) } } } + progress.addChild(resignProgress, withPendingUnitCount: 10) + progress.addChild(installProgress, withPendingUnitCount: 45) } } + progress.addChild(downloadAppOperation.progress, withPendingUnitCount: 40) + self.operationQueue.addOperation(downloadAppOperation) } } } + progress.addChild(authenticationOperation.progress, withPendingUnitCount: 5) + self.operationQueue.addOperation(authenticationOperation) + + return progress } - func refresh(_ app: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) + func refresh(_ app: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) -> Progress { - self.refresh([app], presentingViewController: presentingViewController) { (result) in + return self.refresh([app], presentingViewController: presentingViewController) { (result) in do { - guard let (_, result) = try result.get().first else { throw AppError.unknown } + guard let (_, result) = try result.get().first else { throw OperationError.unknown } completionHandler(result) } catch @@ -213,303 +140,101 @@ extension AppManager } } - func refreshAllApps(presentingViewController: UIViewController?, completionHandler: @escaping (Result<[String: Result], AppError>) -> Void) + @discardableResult func refresh(_ installedApps: [InstalledApp], presentingViewController: UIViewController?, completionHandler: @escaping (Result<[String: Result], Error>) -> Void) -> Progress { - DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in - do - { - let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest - fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.app)] - - let installedApps = try context.fetch(fetchRequest) - self.refresh(installedApps, presentingViewController: presentingViewController) { (result) in - context.perform { // keep context alive - completionHandler(result) - } - } - } - catch - { - completionHandler(.failure(.prepare(error))) - } - } - } - - private func refresh(_ installedApps: T, presentingViewController: UIViewController?, completionHandler: @escaping (Result<[String: Result], AppError>) -> Void) where T.Element == InstalledApp - { - let backgroundTaskID = RSTBeginBackgroundTask("com.rileytestut.AltStore.RefreshApps") + let progress = Progress.discreteProgress(totalUnitCount: Int64(installedApps.count)) - func finish(_ result: Result<[String: Result], AppError>) - { - completionHandler(result) - - RSTEndBackgroundTask(backgroundTaskID) + guard let context = installedApps.first?.managedObjectContext else { + completionHandler(.success([:])) + return progress } - guard !ServerManager.shared.discoveredServers.isEmpty else { return finish(.failure(.noServersFound)) } - // Authenticate - self.authenticate(presentingViewController: presentingViewController) { (result) in + let authenticationOperation = AuthenticationOperation(presentingViewController: presentingViewController) + authenticationOperation.resultHandler = { (result) in switch result { - case .failure(let error): finish(.failure(.authentication(error))) - case .success(let team, let certificate): + case .failure(let error): completionHandler(.failure(error)) + case .success(let signer): - // Sign - let signer = ALTSigner(team: team, certificate: certificate) - - let dispatchGroup = DispatchGroup() - var results = [String: Result]() - - let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() - - for app in installedApps - { - dispatchGroup.enter() + // Refresh + context.perform { + let dispatchGroup = DispatchGroup() + var results = [String: Result]() - app.managedObjectContext?.perform { - let bundleIdentifier = app.bundleIdentifier + for installedApp in installedApps + { + let bundleIdentifier = installedApp.bundleIdentifier print("Refreshing App:", bundleIdentifier) - self.refresh(app, signer: signer, context: context) { (result) in + dispatchGroup.enter() + + let (resignProgress, installProgress) = self.refresh(installedApp, signer: signer, presentingViewController: presentingViewController) { (result) in print("Refreshed App: \(bundleIdentifier).", result) results[bundleIdentifier] = result dispatchGroup.leave() } + + let refreshProgress = Progress(totalUnitCount: 100) + refreshProgress.addChild(resignProgress, withPendingUnitCount: 20) + refreshProgress.addChild(installProgress, withPendingUnitCount: 80) + + progress.addChild(refreshProgress, withPendingUnitCount: 1) } - } - - dispatchGroup.notify(queue: .global()) { - context.perform { - finish(.success(results)) - } - } - } - } - } -} - -private extension AppManager -{ - func downloadApp(from url: URL, completionHandler: @escaping (Result) -> Void) - { - let downloadTask = self.session.downloadTask(with: url) { (fileURL, response, error) in - do - { - let (fileURL, _) = try Result((fileURL, response), error).get() - completionHandler(.success(fileURL)) - } - catch let error as URLError - { - completionHandler(.failure(error)) - } - catch - { - completionHandler(.failure(URLError(.unknown))) - } - } - - downloadTask.resume() - } - - func prepareProvisioningProfile(for app: App, team: ALTTeam, completionHandler: @escaping (Result) -> Void) - { - guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { return completionHandler(.failure(AppError.missingUDID)) } - - let device = ALTDevice(name: UIDevice.current.name, identifier: udid) - self.register(device, team: team) { (result) in - do - { - _ = try result.get() - - app.managedObjectContext?.perform { - self.register(app, with: team) { (result) in - do - { - let appID = try result.get() - self.fetchProvisioningProfile(for: appID, team: team) { (result) in - do - { - let provisioningProfile = try result.get() - completionHandler(.success(provisioningProfile)) - } - catch - { - completionHandler(.failure(error)) - } - } - } - catch - { - completionHandler(.failure(error)) - } - } - } - } - catch - { - completionHandler(.failure(error)) - } - } - } - - func prepare(_ installedApp: InstalledApp, provisioningProfile: ALTProvisioningProfile, signer: ALTSigner, completionHandler: @escaping (Result) -> Void) - { - do - { - let refreshedAppDirectory = installedApp.directoryURL.appendingPathComponent("Refreshed", isDirectory: true) - - 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 = try FileManager.default.unzipAppBundle(at: installedApp.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) } - - var allURLSchemes = infoDictionary[Bundle.Info.urlTypes] as? [[String: Any]] ?? [] - - let altstoreURLScheme = ["CFBundleTypeRole": "Editor", - "CFBundleURLName": installedApp.bundleIdentifier, - "CFBundleURLSchemes": [installedApp.openAppURL.scheme!]] as [String : Any] - allURLSchemes.append(altstoreURLScheme) - - infoDictionary[Bundle.Info.urlTypes] = allURLSchemes - - try (infoDictionary as NSDictionary).write(to: bundle.infoPlistURL) - - signer.signApp(at: appBundleURL, provisioningProfile: provisioningProfile) { (success, error) in - do - { - try Result(success, error).get() - let resignedURL = try FileManager.default.zipAppBundle(at: appBundleURL) - completionHandler(.success(resignedURL)) - } - catch - { - completionHandler(.failure(error)) + dispatchGroup.notify(queue: .global()) { + context.perform { + completionHandler(.success(results)) + } + } } } } - catch - { - completionHandler(.failure(error)) - } - } - - func sendAppToServer(fileURL: URL, identifier: String, completionHandler: @escaping (Result) -> Void) - { - guard let server = ServerManager.shared.discoveredServers.first else { return completionHandler(.failure(AppError.noServersFound)) } - server.installApp(at: fileURL, identifier: identifier) { (result) in - let result = result.mapError { $0 as Error } - completionHandler(result) - } + self.operationQueue.addOperation(authenticationOperation) + + return progress } } private extension AppManager { - func register(_ device: ALTDevice, team: ALTTeam, completionHandler: @escaping (Result) -> Void) + func refresh(_ installedApp: InstalledApp, signer: ALTSigner, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) -> (Progress, Progress) { - ALTAppleAPI.shared.fetchDevices(for: team) { (devices, error) in - do - { - let devices = try Result(devices, error).get() - - if let device = devices.first(where: { $0.identifier == device.identifier }) - { - completionHandler(.success(device)) - } - else - { - ALTAppleAPI.shared.registerDevice(name: device.name, identifier: device.identifier, team: team) { (device, error) in - completionHandler(Result(device, error)) - } - } - } - catch - { - completionHandler(.failure(error)) - } - } - } - - func register(_ app: App, with team: ALTTeam, completionHandler: @escaping (Result) -> Void) - { - let appName = app.name - let bundleID = "com.\(team.identifier).\(app.identifier)" + let context = installedApp.managedObjectContext - ALTAppleAPI.shared.fetchAppIDs(for: team) { (appIDs, error) in - do - { - let appIDs = try Result(appIDs, error).get() - - if let appID = appIDs.first(where: { $0.bundleIdentifier == bundleID }) - { - completionHandler(.success(appID)) - } - else - { - ALTAppleAPI.shared.addAppID(withName: appName, bundleIdentifier: bundleID, team: team) { (appID, error) in - completionHandler(Result(appID, error)) - } - } - } - catch + 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 } } - } - - func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, completionHandler: @escaping (Result) -> Void) - { - ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team) { (profile, error) in - completionHandler(Result(profile, error)) - } - } - - func refresh(_ installedApp: InstalledApp, signer: ALTSigner, context: NSManagedObjectContext, completionHandler: @escaping (Result) -> Void) - { - self.prepareProvisioningProfile(for: installedApp.app, team: signer.team) { (result) in + + // Install + installAppOperation.addDependency(resignAppOperation) + installAppOperation.resultHandler = { (result) in switch result { case .failure(let error): completionHandler(.failure(error)) - case .success(let profile): - - installedApp.managedObjectContext?.perform { - self.prepare(installedApp, provisioningProfile: profile, signer: signer) { (result) in - switch result - { - case .failure(let error): completionHandler(.failure(error)) - case .success(let resignedURL): - - // Send app to server - installedApp.managedObjectContext?.perform { - self.sendAppToServer(fileURL: resignedURL, identifier: installedApp.bundleIdentifier) { (result) in - context.perform { - switch result - { - case .success: - let installedApp = context.object(with: installedApp.objectID) as! InstalledApp - installedApp.expirationDate = profile.expirationDate - completionHandler(.success(installedApp)) - - case .failure(let error): - completionHandler(.failure(error)) - } - } - } - } - } - } + case .success: + context?.perform { + completionHandler(.success(installedApp)) } } } + + self.operationQueue.addOperations([resignAppOperation, installAppOperation], waitUntilFinished: false) + + return (resignAppOperation.progress, installAppOperation.progress) } } diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 59de1fd3..eb67f297 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -76,12 +76,19 @@ - + + + + + + + + @@ -629,7 +636,7 @@ - + diff --git a/AltStore/Model/InstalledApp.swift b/AltStore/Model/InstalledApp.swift index 7185d58b..5ef25314 100644 --- a/AltStore/Model/InstalledApp.swift +++ b/AltStore/Model/InstalledApp.swift @@ -10,7 +10,7 @@ import Foundation import CoreData @objc(InstalledApp) -class InstalledApp: NSManagedObject +class InstalledApp: NSManagedObject, Fetchable { /* Properties */ @NSManaged var bundleIdentifier: String @@ -75,6 +75,13 @@ extension InstalledApp return ipaURL } + class func refreshedIPAURL(for app: App) -> URL + { + let ipaURL = self.directoryURL(for: app).appendingPathComponent("Refreshed.ipa") + return ipaURL + } + + class func directoryURL(for app: App) -> URL { let directoryURL = InstalledApp.appsDirectoryURL.appendingPathComponent(app.identifier) @@ -92,4 +99,8 @@ extension InstalledApp var ipaURL: URL { return InstalledApp.ipaURL(for: self.app) } + + var refreshedIPAURL: URL { + return InstalledApp.refreshedIPAURL(for: self.app) + } } diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 020ef743..16f58941 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -9,6 +9,8 @@ import UIKit import Roxas +import AltSign + class MyAppsViewController: UITableViewController { private var refreshErrors = [String: Error]() @@ -22,12 +24,23 @@ class MyAppsViewController: UITableViewController return dateFormatter }() + @IBOutlet private var progressView: UIProgressView! + override func viewDidLoad() { super.viewDidLoad() self.tableView.dataSource = self.dataSource + if let navigationBar = self.navigationController?.navigationBar + { + self.progressView.translatesAutoresizingMaskIntoConstraints = false + navigationBar.addSubview(self.progressView) + + NSLayoutConstraint.activate([self.progressView.widthAnchor.constraint(equalTo: navigationBar.widthAnchor), + self.progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)]) + } + self.update() } @@ -106,7 +119,9 @@ private extension MyAppsViewController { sender.isIndicatingActivity = true - AppManager.shared.refreshAllApps(presentingViewController: self) { (result) in + let installedApps = InstalledApp.all(in: DatabaseManager.shared.viewContext) + + let progress = AppManager.shared.refresh(installedApps, presentingViewController: self) { (result) in DispatchQueue.main.async { switch result { @@ -146,10 +161,49 @@ private extension MyAppsViewController self.refreshErrors = failures } + 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.progressView.observedProgress = progress } } @@ -183,7 +237,7 @@ extension MyAppsViewController toastView.activityIndicatorView.startAnimating() toastView.show(in: self.navigationController?.view ?? self.view) - AppManager.shared.refresh(installedApp, presentingViewController: self) { (result) in + let progress = AppManager.shared.refresh(installedApp, presentingViewController: self) { (result) in do { let app = try result.get() @@ -203,11 +257,16 @@ extension MyAppsViewController 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) - - self.update() } } + + DispatchQueue.main.async { + self.progressView.observedProgress = nil + self.progressView.progress = 0.0 + } } + + self.progressView.observedProgress = progress } return [deleteAction, refreshAction] diff --git a/AltStore/Authentication/AuthenticationOperation.swift b/AltStore/Operations/AuthenticationOperation.swift similarity index 66% rename from AltStore/Authentication/AuthenticationOperation.swift rename to AltStore/Operations/AuthenticationOperation.swift index 12b5ac43..bcb0b622 100644 --- a/AltStore/Authentication/AuthenticationOperation.swift +++ b/AltStore/Operations/AuthenticationOperation.swift @@ -11,36 +11,27 @@ import Roxas import AltSign -extension AuthenticationOperation +enum AuthenticationError: LocalizedError { - enum Error: LocalizedError - { - case cancelled - - case notAuthenticated - case noTeam - case noCertificate - - case missingPrivateKey - case missingCertificate - - var errorDescription: String? { - switch self { - case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "") - case .notAuthenticated: return NSLocalizedString("You are not signed in.", comment: "") - case .noTeam: return NSLocalizedString("Developer team could not be found.", comment: "") - case .noCertificate: return NSLocalizedString("Developer certificate could not be found.", comment: "") - case .missingPrivateKey: return NSLocalizedString("The certificate's private key could not be found.", comment: "") - case .missingCertificate: return NSLocalizedString("The certificate could not be found.", comment: "") - } + case noTeam + case noCertificate + + case missingPrivateKey + case missingCertificate + + var errorDescription: String? { + switch self { + case .noTeam: return NSLocalizedString("Developer team could not be found.", comment: "") + case .noCertificate: return NSLocalizedString("Developer certificate could not be found.", comment: "") + case .missingPrivateKey: return NSLocalizedString("The certificate's private key could not be found.", comment: "") + case .missingCertificate: return NSLocalizedString("The certificate could not be found.", comment: "") } } } -class AuthenticationOperation: RSTOperation +@objc(AuthenticationOperation) +class AuthenticationOperation: ResultOperation { - var resultHandler: ((Result<(ALTTeam, ALTCertificate), Swift.Error>) -> Void)? - private weak var presentingViewController: UIViewController? private lazy var navigationController = UINavigationController() @@ -48,106 +39,51 @@ class AuthenticationOperation: RSTOperation private var appleIDPassword: String? - override var isAsynchronous: Bool { - return true - } - init(presentingViewController: UIViewController?) { self.presentingViewController = presentingViewController super.init() + + self.progress.totalUnitCount = 3 } override func main() { super.main() - let backgroundTaskID = RSTBeginBackgroundTask("com.rileytestut.AltStore.Authenticate") - - func finish(_ result: Result<(ALTTeam, ALTCertificate), Swift.Error>) - { - print("Finished authenticating with result:", result) - - DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in - do - { - let (altTeam, altCertificate) = try result.get() - let altAccount = altTeam.account - - // Account - let account = Account(altAccount, context: context) - account.isActiveAccount = true - - let otherAccountsFetchRequest = Account.fetchRequest() as NSFetchRequest - otherAccountsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Account.identifier), account.identifier) - - let otherAccounts = try context.fetch(otherAccountsFetchRequest) - for account in otherAccounts - { - account.isActiveAccount = false - } - - // Team - let team = Team(altTeam, account: account, context: context) - team.isActiveTeam = true - - let otherTeamsFetchRequest = Team.fetchRequest() as NSFetchRequest - otherTeamsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Team.identifier), team.identifier) - - let otherTeams = try context.fetch(otherTeamsFetchRequest) - for team in otherTeams - { - team.isActiveTeam = false - } - - // Save - try context.save() - - // Update keychain - Keychain.shared.appleIDEmailAddress = altAccount.appleID // "account" may have nil appleID since we just saved. - Keychain.shared.appleIDPassword = self.appleIDPassword - - Keychain.shared.signingCertificateIdentifier = altCertificate.identifier - Keychain.shared.signingCertificatePrivateKey = altCertificate.privateKey - - self.resultHandler?(.success((altTeam, altCertificate))) - } - catch - { - self.resultHandler?(.failure(error)) - } - - self.finish() - - DispatchQueue.main.async { - self.navigationController.dismiss(animated: true, completion: nil) - } - - RSTEndBackgroundTask(backgroundTaskID) - } - } - // Sign In self.signIn { (result) in + guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) } + switch result { - case .failure(let error): finish(.failure(error)) + case .failure(let error): self.finish(.failure(error)) case .success(let account): + self.progress.completedUnitCount += 1 // Fetch Team self.fetchTeam(for: account) { (result) in + guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) } + switch result { - case .failure(let error): finish(.failure(error)) + case .failure(let error): self.finish(.failure(error)) case .success(let team): + self.progress.completedUnitCount += 1 // Fetch Certificate self.fetchCertificate(for: team) { (result) in + guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) } + switch result { - case .failure(let error): finish(.failure(error)) - case .success(let certificate): finish(.success((team, certificate))) + case .failure(let error): self.finish(.failure(error)) + case .success(let certificate): + self.progress.completedUnitCount += 1 + + let signer = ALTSigner(team: team, certificate: certificate) + self.finish(.success(signer)) } } } @@ -155,6 +91,68 @@ class AuthenticationOperation: RSTOperation } } } + + override func finish(_ result: Result) + { + guard !self.isFinished else { return } + + print("Finished authenticating with result:", result) + + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + context.performAndWait { + do + { + let signer = try result.get() + let altAccount = signer.team.account + + // Account + let account = Account(altAccount, context: context) + account.isActiveAccount = true + + let otherAccountsFetchRequest = Account.fetchRequest() as NSFetchRequest + otherAccountsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Account.identifier), account.identifier) + + let otherAccounts = try context.fetch(otherAccountsFetchRequest) + for account in otherAccounts + { + account.isActiveAccount = false + } + + // Team + let team = Team(signer.team, account: account, context: context) + team.isActiveTeam = true + + let otherTeamsFetchRequest = Team.fetchRequest() as NSFetchRequest + otherTeamsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Team.identifier), team.identifier) + + let otherTeams = try context.fetch(otherTeamsFetchRequest) + for team in otherTeams + { + team.isActiveTeam = false + } + + // Save + try context.save() + + // Update keychain + Keychain.shared.appleIDEmailAddress = altAccount.appleID // "account" may have nil appleID since we just saved. + Keychain.shared.appleIDPassword = self.appleIDPassword + + Keychain.shared.signingCertificateIdentifier = signer.certificate.identifier + Keychain.shared.signingCertificatePrivateKey = signer.certificate.privateKey + + super.finish(.success(signer)) + } + catch + { + super.finish(.failure(error)) + } + + DispatchQueue.main.async { + self.navigationController.dismiss(animated: true, completion: nil) + } + } + } } private extension AuthenticationOperation @@ -199,13 +197,13 @@ private extension AuthenticationOperation } else { - completionHandler(.failure(Error.cancelled)) + completionHandler(.failure(OperationError.cancelled)) } } if !self.present(authenticationViewController) { - completionHandler(.failure(Error.notAuthenticated)) + completionHandler(.failure(OperationError.notAuthenticated)) } } } @@ -254,13 +252,13 @@ private extension AuthenticationOperation } else { - completionHandler(.failure(Error.cancelled)) + completionHandler(.failure(OperationError.cancelled)) } } if !self.present(selectTeamViewController) { - completionHandler(.failure(Error.noTeam)) + completionHandler(.failure(AuthenticationError.noTeam)) } } } @@ -294,7 +292,7 @@ private extension AuthenticationOperation do { let certificate = try Result(certificate, error).get() - guard let privateKey = certificate.privateKey else { throw Error.missingPrivateKey } + guard let privateKey = certificate.privateKey else { throw AuthenticationError.missingPrivateKey } ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in do @@ -302,7 +300,7 @@ private extension AuthenticationOperation let certificates = try Result(certificates, error).get() guard let certificate = certificates.first(where: { $0.identifier == certificate.identifier }) else { - throw Error.missingCertificate + throw AuthenticationError.missingCertificate } certificate.privateKey = privateKey @@ -334,13 +332,13 @@ private extension AuthenticationOperation } else { - completionHandler(.failure(Error.cancelled)) + completionHandler(.failure(OperationError.cancelled)) } } if !self.present(replaceCertificateViewController) { - completionHandler(.failure(Error.noCertificate)) + completionHandler(.failure(AuthenticationError.noCertificate)) } } } diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift new file mode 100644 index 00000000..6f17f60f --- /dev/null +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -0,0 +1,65 @@ +// +// DownloadAppOperation.swift +// AltStore +// +// Created by Riley Testut on 6/10/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation +import Roxas + +import AltSign + +@objc(DownloadAppOperation) +class DownloadAppOperation: ResultOperation +{ + let app: App + private let downloadURL: URL + private let ipaURL: URL + + private let session = URLSession(configuration: .default) + + init(app: App) + { + self.app = app + self.downloadURL = app.downloadURL + self.ipaURL = InstalledApp.ipaURL(for: app) + + super.init() + + self.progress.totalUnitCount = 1 + } + + override func main() + { + super.main() + + 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) + + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + let app = context.object(with: self.app.objectID) as! App + + let installedApp = InstalledApp(app: app, + bundleIdentifier: app.identifier, + expirationDate: Date(), + context: context) + + self.finish(.success(installedApp)) + } + } + catch let error + { + self.finish(.failure(error)) + } + } + + self.progress.addChild(downloadTask.progress, withPendingUnitCount: 1) + downloadTask.resume() + } +} diff --git a/AltStore/Operations/InstallAppOperation.swift b/AltStore/Operations/InstallAppOperation.swift new file mode 100644 index 00000000..1ddf97cd --- /dev/null +++ b/AltStore/Operations/InstallAppOperation.swift @@ -0,0 +1,286 @@ +// +// InstallAppOperation.swift +// AltStore +// +// Created by Riley Testut on 6/7/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +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: "") + } + } +} + +@objc(InstallAppOperation) +class InstallAppOperation: ResultOperation +{ + var fileURL: URL? + + private let dispatchQueue = DispatchQueue(label: "com.altstore.InstallAppOperation") + + private var connection: NWConnection? + + override init() + { + super.init() + + self.progress.totalUnitCount = 4 + } + + override func main() + { + super.main() + + guard let fileURL = self.fileURL else { return self.finish(.failure(OperationError.appNotFound)) } + + // Connect to server. + self.connect { (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) { (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) + } + } + } + } + } + + override func finish(_ result: Result) + { + 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 + { + case .failed(let error): + print("Failed to connect to service \(server.service.name).", error) + completionHandler(.failure(InstallationError.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, 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 + { + completionHandler(.failure(InstallationError.connectionDropped)) + return false + } + else + { + return true + } + } + + // 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 + } + } + 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 new file mode 100644 index 00000000..7e05c16f --- /dev/null +++ b/AltStore/Operations/Operation.swift @@ -0,0 +1,84 @@ +// +// Operation.swift +// AltStore +// +// Created by Riley Testut on 6/7/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation +import Roxas + +class ResultOperation: Operation +{ + var resultHandler: ((Result) -> Void)? + + @available(*, unavailable) + override func finish() + { + super.finish() + } + + func finish(_ result: Result) + { + guard !self.isFinished else { return } + + super.finish() + + self.resultHandler?(result) + } +} + +class Operation: RSTOperation, ProgressReporting +{ + let progress = Progress.discreteProgress(totalUnitCount: 1) + + private var backgroundTaskID: UIBackgroundTaskIdentifier? + + override var isAsynchronous: Bool { + return true + } + + override init() + { + super.init() + + self.progress.cancellationHandler = { [weak self] in self?.cancel() } + } + + override func cancel() + { + super.cancel() + + if !self.progress.isCancelled + { + self.progress.cancel() + } + } + + override func main() + { + super.main() + + let name = "com.altstore." + NSStringFromClass(type(of: self)) + self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + guard let backgroundTask = self?.backgroundTaskID else { return } + + self?.cancel() + + UIApplication.shared.endBackgroundTask(backgroundTask) + self?.backgroundTaskID = .invalid + } + } + + override func finish() + { + super.finish() + + if let backgroundTaskID = self.backgroundTaskID + { + UIApplication.shared.endBackgroundTask(backgroundTaskID) + self.backgroundTaskID = .invalid + } + } +} diff --git a/AltStore/Operations/OperationError.swift b/AltStore/Operations/OperationError.swift new file mode 100644 index 00000000..e9a54c92 --- /dev/null +++ b/AltStore/Operations/OperationError.swift @@ -0,0 +1,32 @@ +// +// OperationError.swift +// AltStore +// +// Created by Riley Testut on 6/7/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation + +enum OperationError: LocalizedError +{ + case unknown + case unknownResult + case cancelled + + case notAuthenticated + case appNotFound + + case unknownUDID + + var errorDescription: 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 .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: "") + } + } +} diff --git a/AltStore/Operations/ResignAppOperation.swift b/AltStore/Operations/ResignAppOperation.swift new file mode 100644 index 00000000..013ecead --- /dev/null +++ b/AltStore/Operations/ResignAppOperation.swift @@ -0,0 +1,244 @@ +// +// ResignAppOperation.swift +// AltStore +// +// Created by Riley Testut on 6/7/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation +import Roxas + +import AltSign + +@objc(ResignAppOperation) +class ResignAppOperation: ResultOperation +{ + let installedApp: InstalledApp + private var context: NSManagedObjectContext? + + var signer: ALTSigner? + + init(installedApp: InstalledApp) + { + self.installedApp = installedApp + self.context = installedApp.managedObjectContext + + super.init() + + self.progress.totalUnitCount = 3 + } + + override func main() + { + 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)) } + + context.perform { + // 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 + guard let appID = self.process(result) else { return } + + // Fetch Provisioning Profile + self.fetchProvisioningProfile(for: appID, team: signer.team) { (result) in + guard let profile = self.process(result) else { return } + + // Prepare app bundle + context.perform { + let prepareAppProgress = Progress.discreteProgress(totalUnitCount: 2) + self.progress.addChild(prepareAppProgress, withPendingUnitCount: 3) + + let prepareAppBundleProgress = self.prepareAppBundle(for: self.installedApp) { (result) in + guard let appBundleURL = self.process(result) else { return } + + // 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 { + do + { + try FileManager.default.copyItem(at: resignedURL, to: self.installedApp.refreshedIPAURL, shouldReplace: true) + + let refreshedDirectory = resignedURL.deletingLastPathComponent() + try? FileManager.default.removeItem(at: refreshedDirectory) + + self.finish(.success(self.installedApp.refreshedIPAURL)) + } + catch + { + self.finish(.failure(error)) + } + } + } + prepareAppProgress.addChild(resignProgress, withPendingUnitCount: 1) + } + prepareAppProgress.addChild(prepareAppBundleProgress, withPendingUnitCount: 1) + } + } + } + } + } + } + } + + func process(_ result: Result) -> T? + { + switch result + { + case .failure(let error): + self.finish(.failure(error)) + return nil + + case .success(let value): + guard !self.isCancelled else { + self.finish(.failure(OperationError.cancelled)) + return nil + } + + return value + } + } +} + +private extension ResignAppOperation +{ + func registerCurrentDevice(for team: ALTTeam, completionHandler: @escaping (Result) -> Void) + { + guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { + return completionHandler(.failure(OperationError.unknownUDID)) + } + + ALTAppleAPI.shared.fetchDevices(for: team) { (devices, error) in + do + { + let devices = try Result(devices, error).get() + + if let device = devices.first(where: { $0.identifier == udid }) + { + completionHandler(.success(device)) + } + else + { + ALTAppleAPI.shared.registerDevice(name: UIDevice.current.name, identifier: udid, team: team) { (device, error) in + completionHandler(Result(device, error)) + } + } + } + catch + { + completionHandler(.failure(error)) + } + } + } + + func register(_ app: App, team: ALTTeam, completionHandler: @escaping (Result) -> Void) + { + let appName = app.name + let bundleID = "com.\(team.identifier).\(app.identifier)" + + ALTAppleAPI.shared.fetchAppIDs(for: team) { (appIDs, error) in + do + { + let appIDs = try Result(appIDs, error).get() + + if let appID = appIDs.first(where: { $0.bundleIdentifier == bundleID }) + { + completionHandler(.success(appID)) + } + else + { + ALTAppleAPI.shared.addAppID(withName: appName, bundleIdentifier: bundleID, team: team) { (appID, error) in + completionHandler(Result(appID, error)) + } + } + } + catch + { + completionHandler(.failure(error)) + } + } + } + + func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, completionHandler: @escaping (Result) -> Void) + { + ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team) { (profile, error) in + completionHandler(Result(profile, error)) + } + } + + func prepareAppBundle(for installedApp: InstalledApp, completionHandler: @escaping (Result) -> Void) -> Progress + { + 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 + + 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) + + // 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) } + + var allURLSchemes = infoDictionary[Bundle.Info.urlTypes] as? [[String: Any]] ?? [] + + let altstoreURLScheme = ["CFBundleTypeRole": "Editor", + "CFBundleURLName": bundleIdentifier, + "CFBundleURLSchemes": [openURL.scheme!]] as [String : Any] + allURLSchemes.append(altstoreURLScheme) + + infoDictionary[Bundle.Info.urlTypes] = allURLSchemes + + try (infoDictionary as NSDictionary).write(to: bundle.infoPlistURL) + + completionHandler(.success(appBundleURL)) + } + catch + { + completionHandler(.failure(error)) + } + } + + return progress + } + + func resignAppBundle(at fileURL: URL, signer: ALTSigner, profile: ALTProvisioningProfile, completionHandler: @escaping (Result) -> Void) -> Progress + { + let progress = signer.signApp(at: fileURL, provisioningProfile: profile) { (success, error) in + do + { + try Result(success, error).get() + + let ipaURL = try FileManager.default.zipAppBundle(at: fileURL) + completionHandler(.success(ipaURL)) + } + catch + { + completionHandler(.failure(error)) + } + } + + return progress + } +} diff --git a/AltStore/Server/Server.swift b/AltStore/Server/Server.swift deleted file mode 100644 index b344163e..00000000 --- a/AltStore/Server/Server.swift +++ /dev/null @@ -1,264 +0,0 @@ -// -// Server.swift -// AltStore -// -// Created by Riley Testut on 5/30/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 InstallError: LocalizedError -{ - case unknown - case cancelled - case invalidApp - case noUDID - case server(ALTServerError) - - var errorDescription: String? { - switch self - { - case .server(let error): return error.localizedDescription - default: return nil - } - } -} - -struct Server: Equatable -{ - var service: NetService - - private let dispatchQueue = DispatchQueue(label: "com.rileytestut.AltStore.server", qos: .utility) - - func installApp(at fileURL: URL, identifier: String, completionHandler: @escaping (Result) -> Void) - { - var isFinished = false - - var serverConnection: NWConnection? - - func finish(error: InstallError?) - { - // Prevent duplicate callbacks if connection is lost. - guard !isFinished else { return } - isFinished = true - - if let connection = serverConnection - { - connection.cancel() - } - - if let error = error - { - print("Failed to install \(identifier).", error) - completionHandler(.failure(error)) - } - else - { - print("Installed \(identifier)!") - completionHandler(.success(())) - } - } - - self.connect { (result) in - switch result - { - case .failure(let error): finish(error: error) - case .success(let connection): - serverConnection = connection - - self.sendApp(at: fileURL, via: connection) { (result) in - switch result - { - case .failure(let error): finish(error: error) - case .success: - - self.receiveResponse(from: connection) { (result) in - switch result - { - case .success: finish(error: nil) - case .failure(let error): finish(error: .server(error)) - } - } - } - } - } - } - } -} - -private extension Server -{ - func connect(completionHandler: @escaping (Result) -> Void) - { - let connection = NWConnection(to: .service(name: self.service.name, type: self.service.type, domain: self.service.domain, interface: nil), using: .tcp) - - connection.stateUpdateHandler = { [weak service, unowned connection] (state) in - switch state - { - case .ready: completionHandler(.success(connection)) - case .cancelled: completionHandler(.failure(.cancelled)) - - case .failed(let error): - print("Failed to connect to service \(service?.name ?? "").", error) - completionHandler(.failure(.server(.init(.connectionFailed)))) - - 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 InstallError.invalidApp } - guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { throw InstallError.noUDID } - - let request = ServerRequest(udid: udid, contentSize: appData.count) - let requestData = try JSONEncoder().encode(request) - - let requestSize = Int32(requestData.count) - let requestSizeData = withUnsafeBytes(of: requestSize) { Data($0) } - - // Send request data size. - print("Sending request data size \(requestSize)") - connection.send(content: requestSizeData, completion: .contentProcessed { (error) in - if error != nil - { - completionHandler(.failure(.server(.init(.lostConnection)))) - } - else - { - // Send request. - print("Sending request \(request)") - connection.send(content: requestData, completion: .contentProcessed { (error) in - if error != nil - { - completionHandler(.failure(.server(.init(.lostConnection)))) - } - else - { - // Send app data. - print("Sending app data (Size: \(appData.count))") - connection.send(content: appData, completion: .contentProcessed { (error) in - if error != nil - { - completionHandler(.failure(.server(.init(.lostConnection)))) - } - else - { - completionHandler(.success(())) - } - }) - } - }) - } - }) - } - catch is EncodingError - { - completionHandler(.failure(.server(.init(.invalidRequest)))) - } - catch let error as InstallError - { - completionHandler(.failure(error)) - } - catch - { - assertionFailure("Unknown error type. \(error)") - completionHandler(.failure(.unknown)) - } - } - - func receiveResponse(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(ServerResponse.self, from: data) - - if let error = response.error - { - completionHandler(.failure(error)) - } - else - { - completionHandler(.success(())) - } - } - catch - { - completionHandler(.failure(ALTServerError(error))) - } - } - } - catch - { - completionHandler(.failure(ALTServerError(error))) - } - } - } - - 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 a1cbb47b..7073748b 100644 --- a/AltStore/Server/ServerManager.swift +++ b/AltStore/Server/ServerManager.swift @@ -11,6 +11,11 @@ import Network import AltKit +struct Server: Equatable +{ + var service: NetService +} + class ServerManager: NSObject { static let shared = ServerManager() diff --git a/Dependencies/AltSign b/Dependencies/AltSign index 726e515a..c99aa73a 160000 --- a/Dependencies/AltSign +++ b/Dependencies/AltSign @@ -1 +1 @@ -Subproject commit 726e515ad9ee56a96c05aae25fb8d7a0da5867ed +Subproject commit c99aa73a89fb16e27823476032a5b2e7e16118f0