Adds ability to change sideloaded app icons

This commit is contained in:
Riley Testut
2020-10-01 14:09:45 -07:00
parent 12f33c355a
commit 546db3fa23
7 changed files with 187 additions and 26 deletions

View File

@@ -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")

View File

@@ -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
}
}

View File

@@ -114,4 +114,6 @@ class InstallAppOperationContext: AppOperationContext
private var installedAppContext: NSManagedObjectContext?
var beginInstallationHandler: ((InstalledApp) -> Void)?
var alternateIconURL: URL?
}

View File

@@ -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)

View File

@@ -35,6 +35,7 @@
<attribute name="bundleIdentifier" attributeType="String"/>
<attribute name="certificateSerialNumber" optional="YES" attributeType="String"/>
<attribute name="expirationDate" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hasAlternateIcon" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="installedDate" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="isRefreshing" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -164,7 +165,7 @@
<element name="Account" positionX="-36" positionY="90" width="128" height="135"/>
<element name="AppID" positionX="-27" positionY="153" width="128" height="133"/>
<element name="AppPermission" positionX="-45" positionY="90" width="128" height="90"/>
<element name="InstalledApp" positionX="-63" positionY="0" width="128" height="253"/>
<element name="InstalledApp" positionX="-63" positionY="0" width="128" height="268"/>
<element name="InstalledExtension" positionX="-45" positionY="135" width="128" height="163"/>
<element name="NewsItem" positionX="-45" positionY="126" width="128" height="238"/>
<element name="PatreonAccount" positionX="-45" positionY="117" width="128" height="105"/>

View File

@@ -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<UIImage?, Error>) -> 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)
}
}