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