From 546db3fa2373973da5f05b9d91809326c7b46291 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 1 Oct 2020 14:09:45 -0700 Subject: [PATCH] Adds ability to change sideloaded app icons --- AltStore/Managing Apps/AppManager.swift | 14 +- AltStore/My Apps/MyAppsViewController.swift | 139 +++++++++++++++--- AltStore/Operations/OperationContexts.swift | 2 + AltStore/Operations/ResignAppOperation.swift | 16 ++ .../AltStore 8.xcdatamodel/contents | 3 +- AltStoreCore/Model/InstalledApp.swift | 37 +++++ Dependencies/Roxas | 2 +- 7 files changed, 187 insertions(+), 26 deletions(-) diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 48ff4ae8..ca241cce 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -766,10 +766,18 @@ private extension AppManager var downloadingApp = app - if let installedApp = app as? InstalledApp, let storeApp = installedApp.storeApp, !FileManager.default.fileExists(atPath: installedApp.fileURL.path) + if let installedApp = app as? InstalledApp { - // Cached app has been deleted, so we need to redownload it. - downloadingApp = storeApp + if let storeApp = installedApp.storeApp, !FileManager.default.fileExists(atPath: installedApp.fileURL.path) + { + // Cached app has been deleted, so we need to redownload it. + downloadingApp = storeApp + } + + if installedApp.hasAlternateIcon + { + context.alternateIconURL = installedApp.alternateIconURL + } } let downloadedAppURL = context.temporaryDirectory.appendingPathComponent("Cached.app") diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index c994cce9..f3a85a4d 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -51,6 +51,8 @@ class MyAppsViewController: UICollectionViewController private var sideloadingProgress: Progress? private var dropDestinationIndexPath: IndexPath? + private var _imagePickerInstalledApp: InstalledApp? + // Cache private var cachedUpdateSizes = [String: CGSize]() @@ -361,16 +363,16 @@ private extension MyAppsViewController } } dataSource.prefetchHandler = { (item, indexPath, completion) in - let fileURL = item.fileURL - - return BlockOperation { - guard let application = ALTApplication(fileURL: fileURL) else { - completion(nil, OperationError.invalidApp) - return + RSTAsyncBlockOperation { (operation) in + item.managedObjectContext?.perform { + item.loadIcon { (result) in + switch result + { + case .failure(let error): completion(nil, error) + case .success(let image): completion(image, nil) + } + } } - - let icon = application.icon - completion(icon, nil) } } dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in @@ -435,16 +437,16 @@ private extension MyAppsViewController } } dataSource.prefetchHandler = { (item, indexPath, completion) in - let fileURL = item.fileURL - - return BlockOperation { - guard let application = ALTApplication(fileURL: fileURL) else { - completion(nil, OperationError.invalidApp) - return + RSTAsyncBlockOperation { (operation) in + item.managedObjectContext?.perform { + item.loadIcon { (result) in + switch result + { + case .failure(let error): completion(nil, error) + case .success(let image): completion(image, nil) + } + } } - - let icon = application.icon - completion(icon, nil) } } dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in @@ -1280,6 +1282,63 @@ private extension MyAppsViewController documentPicker.delegate = self self.present(documentPicker, animated: true, completion: nil) } + + func chooseIcon(for installedApp: InstalledApp) + { + self._imagePickerInstalledApp = installedApp + + let imagePicker = UIImagePickerController() + imagePicker.delegate = self + imagePicker.allowsEditing = true + self.present(imagePicker, animated: true, completion: nil) + } + + func changeIcon(for installedApp: InstalledApp, to image: UIImage?) + { + // Remove previous icon from cache. + self.activeAppsDataSource.prefetchItemCache.removeObject(forKey: installedApp) + self.inactiveAppsDataSource.prefetchItemCache.removeObject(forKey: installedApp) + + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + do + { + let tempApp = context.object(with: installedApp.objectID) as! InstalledApp + tempApp.needsResign = true + tempApp.hasAlternateIcon = (image != nil) + + if let image = image + { + guard let icon = image.resizing(toFill: CGSize(width: 256, height: 256)), + let iconData = icon.pngData() + else { return } + + try iconData.write(to: tempApp.alternateIconURL, options: .atomic) + } + else + { + try FileManager.default.removeItem(at: tempApp.alternateIconURL) + } + + try context.save() + + if tempApp.isActive + { + DispatchQueue.main.async { + self.refresh(installedApp) + } + } + } + catch + { + print("Failed to change app icon.", error) + + DispatchQueue.main.async { + let toastView = ToastView(error: error) + toastView.show(in: self) + } + } + } + } } private extension MyAppsViewController @@ -1460,9 +1519,9 @@ extension MyAppsViewController @available(iOS 13.0, *) extension MyAppsViewController { - private func actions(for installedApp: InstalledApp) -> [UIAction] + private func actions(for installedApp: InstalledApp) -> [UIMenuElement] { - var actions = [UIAction]() + var actions = [UIMenuElement]() let refreshAction = UIAction(title: NSLocalizedString("Refresh", comment: ""), image: UIImage(systemName: "arrow.clockwise")) { (action) in self.refresh(installedApp) @@ -1492,8 +1551,24 @@ extension MyAppsViewController self.restore(installedApp) } + let chooseIconAction = UIAction(title: NSLocalizedString("Photos", comment: ""), image: UIImage(systemName: "photo")) { (action) in + self.chooseIcon(for: installedApp) + } + + let removeIconAction = UIAction(title: NSLocalizedString("Remove Custom Icon", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { (action) in + self.changeIcon(for: installedApp, to: nil) + } + + var changeIconActions = [chooseIconAction] + if installedApp.hasAlternateIcon + { + changeIconActions.append(removeIconAction) + } + + let changeIconMenu = UIMenu(title: NSLocalizedString("Change Icon", comment: ""), image: UIImage(systemName: "photo"), children: changeIconActions) + guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else { - return [refreshAction] + return [refreshAction, changeIconMenu] } if installedApp.isActive @@ -1505,6 +1580,8 @@ extension MyAppsViewController actions.append(activateAction) } + actions.append(changeIconMenu) + if installedApp.isActive { actions.append(backupAction) @@ -2012,3 +2089,23 @@ extension MyAppsViewController: UIViewControllerPreviewingDelegate self.performSegue(withIdentifier: "showUpdate", sender: cell) } } + +extension MyAppsViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate +{ + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) + { + defer { + picker.dismiss(animated: true, completion: nil) + self._imagePickerInstalledApp = nil + } + + guard let image = info[.editedImage] as? UIImage, let installedApp = self._imagePickerInstalledApp else { return } + self.changeIcon(for: installedApp, to: image) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) + { + picker.dismiss(animated: true, completion: nil) + self._imagePickerInstalledApp = nil + } +} diff --git a/AltStore/Operations/OperationContexts.swift b/AltStore/Operations/OperationContexts.swift index 8e7fa67e..d5b8e75f 100644 --- a/AltStore/Operations/OperationContexts.swift +++ b/AltStore/Operations/OperationContexts.swift @@ -114,4 +114,6 @@ class InstallAppOperationContext: AppOperationContext private var installedAppContext: NSManagedObjectContext? var beginInstallationHandler: ((InstalledApp) -> Void)? + + var alternateIconURL: URL? } diff --git a/AltStore/Operations/ResignAppOperation.swift b/AltStore/Operations/ResignAppOperation.swift index d4f02614..64884e0c 100644 --- a/AltStore/Operations/ResignAppOperation.swift +++ b/AltStore/Operations/ResignAppOperation.swift @@ -191,6 +191,22 @@ private extension ResignAppOperation } } + let iconScale = Int(UIScreen.main.scale) + + if let alternateIconURL = self.context.alternateIconURL, + case let data = try Data(contentsOf: alternateIconURL), + let image = UIImage(data: data), + let icon = image.resizing(toFill: CGSize(width: 60 * iconScale, height: 60 * iconScale)), + let iconData = icon.pngData() + { + let iconName = "AltIcon" + let iconURL = appBundleURL.appendingPathComponent(iconName + "@\(iconScale)x.png") + try iconData.write(to: iconURL, options: .atomic) + + let iconDictionary = ["CFBundlePrimaryIcon": ["CFBundleIconFiles": [iconName]]] + additionalValues["CFBundleIcons"] = iconDictionary + } + // Prepare app try prepare(appBundle, additionalInfoDictionaryValues: additionalValues) diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 8.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 8.xcdatamodel/contents index 184adf66..82e82502 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 8.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 8.xcdatamodel/contents @@ -35,6 +35,7 @@ + @@ -164,7 +165,7 @@ - + diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index 6c275c29..54f8e32e 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -41,6 +41,7 @@ public class InstalledApp: NSManagedObject, InstalledAppProtocol @NSManaged public var isActive: Bool @NSManaged public var needsResign: Bool + @NSManaged public var hasAlternateIcon: Bool @NSManaged public var certificateSerialNumber: String? @@ -104,6 +105,32 @@ public class InstalledApp: NSManagedObject, InstalledAppProtocol self.refreshedDate = provisioningProfile.creationDate self.expirationDate = provisioningProfile.expirationDate } + + public func loadIcon(completion: @escaping (Result) -> Void) + { + let hasAlternateIcon = self.hasAlternateIcon + let alternateIconURL = self.alternateIconURL + let fileURL = self.fileURL + + DispatchQueue.global().async { + do + { + if hasAlternateIcon, + case let data = try Data(contentsOf: alternateIconURL), + let icon = UIImage(data: data) + { + return completion(.success(icon)) + } + + let application = ALTApplication(fileURL: fileURL) + completion(.success(application?.icon)) + } + catch + { + completion(.failure(error)) + } + } + } } public extension InstalledApp @@ -269,6 +296,12 @@ public extension InstalledApp return installedBackupAppUTI } + class func alternateIconURL(for app: AppProtocol) -> URL + { + let installedBackupAppUTI = self.directoryURL(for: app).appendingPathComponent("AltIcon.png") + return installedBackupAppUTI + } + var directoryURL: URL { return InstalledApp.directoryURL(for: self) } @@ -288,4 +321,8 @@ public extension InstalledApp var installedBackupAppUTI: String { return InstalledApp.installedBackupAppUTI(forBundleIdentifier: self.resignedBundleIdentifier) } + + var alternateIconURL: URL { + return InstalledApp.alternateIconURL(for: self) + } } diff --git a/Dependencies/Roxas b/Dependencies/Roxas index d5c9a655..84645e43 160000 --- a/Dependencies/Roxas +++ b/Dependencies/Roxas @@ -1 +1 @@ -Subproject commit d5c9a6551dc9a330f18fb00c39e8580bcf0d4f04 +Subproject commit 84645e43182eb2c8fd5904d9e7e379ad2a1b94cb