diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 233463c5..41d03e40 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -332,6 +332,7 @@ D57FE84428C7DB7100216002 /* ErrorLogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57FE84328C7DB7100216002 /* ErrorLogViewController.swift */; }; D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58916FD28C7C55C00E39C8B /* LoggedError.swift */; }; D593F1942717749A006E82DE /* PatchAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D593F1932717749A006E82DE /* PatchAppOperation.swift */; }; + D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; }; D5CA0C4B280E141900469595 /* ManagedPatron.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4A280E141900469595 /* ManagedPatron.swift */; }; D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */; }; D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */; }; @@ -839,6 +840,7 @@ D57FE84328C7DB7100216002 /* ErrorLogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogViewController.swift; sourceTree = ""; }; D58916FD28C7C55C00E39C8B /* LoggedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedError.swift; sourceTree = ""; }; D593F1932717749A006E82DE /* PatchAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchAppOperation.swift; sourceTree = ""; }; + D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = ""; }; D5CA0C4A280E141900469595 /* ManagedPatron.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedPatron.swift; sourceTree = ""; }; D5CA0C4C280E242500469595 /* AltStore 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 10.xcdatamodel"; sourceTree = ""; }; D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore9ToAltStore10.xcmappingmodel; sourceTree = ""; }; @@ -1697,6 +1699,7 @@ D57F2C9026E0070200B9FA39 /* EnableJITOperation.swift */, D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */, D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */, + D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */, BF7B44062725A4B8005288A4 /* Patch App */, ); path = Operations; @@ -2504,6 +2507,7 @@ BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */, BF41B808233433C100C593A3 /* LoadingState.swift in Sources */, BFF0B69A2322D7D0007A79E1 /* UIScreen+CompactHeight.swift in Sources */, + D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */, D5F2F6A92720B7C20081CCF5 /* PatchViewController.swift in Sources */, B39F16132918D7C5002E9404 /* Consts.swift in Sources */, BF8F69C222E659F700049BA1 /* AppContentViewController.swift in Sources */, diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 05e9c8d5..d11aa773 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -307,6 +307,16 @@ extension AppManager presentingViewController.present(alertController, animated: true, completion: nil) } } + + func clearAppCache(completion: @escaping (Result) -> Void) + { + let clearAppCacheOperation = ClearAppCacheOperation() + clearAppCacheOperation.resultHandler = { result in + completion(result) + } + + self.run([clearAppCacheOperation], context: nil) + } } extension AppManager @@ -754,6 +764,12 @@ extension AppManager let progress = self.refreshProgress[app.bundleIdentifier] return progress } + + func isActivelyManagingApp(withBundleID bundleID: String) -> Bool + { + let isActivelyManaging = self.installationProgress.keys.contains(bundleID) || self.refreshProgress.keys.contains(bundleID) + return isActivelyManaging + } } extension AppManager @@ -808,12 +824,6 @@ private extension AppManager } } - func isActivelyManagingApp(withBundleID bundleID: String) -> Bool - { - let isActivelyManaging = self.installationProgress.keys.contains(bundleID) || self.refreshProgress.keys.contains(bundleID) - return isActivelyManaging - } - @discardableResult private func perform(_ operations: [AppOperation], presentingViewController: UIViewController?, group: RefreshGroup) -> RefreshGroup { diff --git a/AltStore/Operations/ClearAppCacheOperation.swift b/AltStore/Operations/ClearAppCacheOperation.swift new file mode 100644 index 00000000..614dc75f --- /dev/null +++ b/AltStore/Operations/ClearAppCacheOperation.swift @@ -0,0 +1,203 @@ +// +// ClearAppCacheOperation.swift +// AltStore +// +// Created by Riley Testut on 9/27/22. +// Copyright © 2022 Riley Testut. All rights reserved. +// + +import Foundation +import AltStoreCore + +struct BatchError: ALTLocalizedError +{ + enum Code: Int, ALTErrorCode + { + typealias Error = BatchError + + case batchError + } + + var code: Code = .batchError + var underlyingErrors: [Error] + + var errorTitle: String? + var errorFailure: String? + + init(errors: [Error]) + { + self.underlyingErrors = errors + } + + var errorFailureReason: String { + guard !self.underlyingErrors.isEmpty else { return NSLocalizedString("An unknown error occured.", comment: "") } + + let errorMessages = self.underlyingErrors.map { $0.localizedDescription } + + let message = errorMessages.joined(separator: "\n\n") + return message + } +} + +@objc(ClearAppCacheOperation) +class ClearAppCacheOperation: ResultOperation +{ + private let coordinator = NSFileCoordinator() + private let coordinatorQueue = OperationQueue() + + override init() + { + self.coordinatorQueue.name = "AltStore - ClearAppCacheOperation Queue" + } + + override func main() + { + super.main() + + var allErrors = [Error]() + + self.clearTemporaryDirectory { result in + switch result + { + case .failure(let batchError as BatchError): allErrors.append(contentsOf: batchError.underlyingErrors) + case .failure(let error): allErrors.append(error) + case .success: break + } + + self.removeUninstalledAppBackupDirectories { result in + switch result + { + case .failure(let batchError as BatchError): allErrors.append(contentsOf: batchError.underlyingErrors) + case .failure(let error): allErrors.append(error) + case .success: break + } + + if allErrors.isEmpty + { + self.finish(.success(())) + } + else + { + let error = BatchError(errors: allErrors) + self.finish(.failure(error)) + } + } + } + } +} + +private extension ClearAppCacheOperation +{ + func clearTemporaryDirectory(completion: @escaping (Result) -> Void) + { + let intent = NSFileAccessIntent.writingIntent(with: FileManager.default.temporaryDirectory, options: [.forDeleting]) + self.coordinator.coordinate(with: [intent], queue: self.coordinatorQueue) { (error) in + do + { + if let error + { + throw error + } + + let fileURLs = try FileManager.default.contentsOfDirectory(at: intent.url, + includingPropertiesForKeys: [], + options: [.skipsSubdirectoryDescendants, .skipsHiddenFiles]) + var errors = [Error]() + + for fileURL in fileURLs + { + do + { + print("[ALTLog] Removing item from temporary directory:", fileURL.lastPathComponent) + try FileManager.default.removeItem(at: fileURL) + } + catch + { + print("[ALTLog] Failed to remove \(fileURL.lastPathComponent) from temporary directory.", error) + errors.append(error) + } + } + + if !errors.isEmpty + { + let error = BatchError(errors: errors) + completion(.failure(error)) + } + else + { + completion(.success(())) + } + } + catch + { + completion(.failure(error)) + } + } + } + + func removeUninstalledAppBackupDirectories(completion: @escaping (Result) -> Void) + { + guard let backupsDirectory = FileManager.default.appBackupsDirectory else { return completion(.failure(OperationError.missingAppGroup)) } + + DatabaseManager.shared.persistentContainer.performBackgroundTask { context in + let installedAppBundleIDs = Set(InstalledApp.all(in: context).map { $0.bundleIdentifier }) + + let intent = NSFileAccessIntent.writingIntent(with: backupsDirectory, options: [.forDeleting]) + self.coordinator.coordinate(with: [intent], queue: self.coordinatorQueue) { (error) in + do + { + if let error + { + throw error + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: intent.url.path, isDirectory: &isDirectory), isDirectory.boolValue else { + completion(.success(())) + return + } + + let fileURLs = try FileManager.default.contentsOfDirectory(at: intent.url, + includingPropertiesForKeys: [.isDirectoryKey, .nameKey], + options: [.skipsSubdirectoryDescendants, .skipsHiddenFiles]) + var errors = [Error]() + + for backupDirectory in fileURLs + { + do + { + let resourceValues = try backupDirectory.resourceValues(forKeys: [.isDirectoryKey, .nameKey]) + guard let isDirectory = resourceValues.isDirectory, let bundleID = resourceValues.name else { continue } + + if isDirectory && !installedAppBundleIDs.contains(bundleID) && !AppManager.shared.isActivelyManagingApp(withBundleID: bundleID) + { + print("[ALTLog] Removing backup directory for uninstalled app:", bundleID) + try FileManager.default.removeItem(at: backupDirectory) + } + } + catch + { + print("[ALTLog] Failed to remove app backup directory:", error) + errors.append(error) + } + } + + if !errors.isEmpty + { + let error = BatchError(errors: errors) + completion(.failure(error)) + } + else + { + completion(.success(())) + } + } + catch + { + print("[ALTLog] Failed to remove app backup directory:", error) + completion(.failure(error)) + } + } + } + } +} diff --git a/AltStore/Settings/SettingsViewController.swift b/AltStore/Settings/SettingsViewController.swift index e43e21f4..6293ef03 100644 --- a/AltStore/Settings/SettingsViewController.swift +++ b/AltStore/Settings/SettingsViewController.swift @@ -48,6 +48,12 @@ extension SettingsViewController case softwareLicenses } + fileprivate enum TechyThingsRow: Int, CaseIterable + { + case errorLog + case clearCache + } + fileprivate enum DebugRow: Int, CaseIterable { case sendFeedback @@ -291,6 +297,34 @@ private extension SettingsViewController self.present(viewController, animated: true, completion: nil) } + func clearCache() + { + let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to clear AltStore's cache?", comment: ""), + message: NSLocalizedString("This will remove all temporary files as well as backups for uninstalled apps.", comment: ""), + preferredStyle: .actionSheet) + alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { [weak self] _ in + self?.tableView.indexPathForSelectedRow.map { self?.tableView.deselectRow(at: $0, animated: true) } + }) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Clear Cache", comment: ""), style: .destructive) { [weak self] _ in + AppManager.shared.clearAppCache { result in + DispatchQueue.main.async { + self?.tableView.indexPathForSelectedRow.map { self?.tableView.deselectRow(at: $0, animated: true) } + + switch result + { + case .success: break + case .failure(let error): + let alertController = UIAlertController(title: NSLocalizedString("Unable to Clear Cache", comment: ""), message: error.localizedDescription, preferredStyle: .alert) + alertController.addAction(.ok) + self?.present(alertController, animated: true) + } + } + } + }) + + self.present(alertController, animated: true) + } + @IBAction func handleDebugModeGesture(_ gestureRecognizer: UISwipeGestureRecognizer) { self.debugGestureCounter += 1 @@ -470,6 +504,14 @@ extension SettingsViewController self.addRefreshAppsShortcut() } + case .techyThings: + let row = TechyThingsRow.allCases[indexPath.row] + switch row + { + case .errorLog: break + case .clearCache: self.clearCache() + } + case .credits: let row = CreditsRow.allCases[indexPath.row] switch row