// // BackgroundRefreshAppsOperation.swift // AltStore // // Created by Riley Testut on 7/6/20. // Copyright © 2020 Riley Testut. All rights reserved. // import UIKit import CoreData import AltStoreCore import EmotionalDamage import minimuxer typealias RefreshError = RefreshErrorCode.Error enum RefreshErrorCode: Int, ALTErrorEnum, CaseIterable { case noInstalledApps var errorFailureReason: String { switch self { case .noInstalledApps: return NSLocalizedString("No active apps require refreshing.", comment: "") } } } private extension CFNotificationName { static let requestAppState = CFNotificationName("com.altstore.RequestAppState" as CFString) static let appIsRunning = CFNotificationName("com.altstore.AppState.Running" as CFString) static func requestAppState(for appID: String) -> CFNotificationName { let name = String(CFNotificationName.requestAppState.rawValue) + "." + appID return CFNotificationName(name as CFString) } static func appIsRunning(for appID: String) -> CFNotificationName { let name = String(CFNotificationName.appIsRunning.rawValue) + "." + appID return CFNotificationName(name as CFString) } } private let ReceivedApplicationState: @convention(c) (CFNotificationCenter?, UnsafeMutableRawPointer?, CFNotificationName?, UnsafeRawPointer?, CFDictionary?) -> Void = { (center, observer, name, object, userInfo) in guard let name = name, let observer = observer else { return } let operation = unsafeBitCast(observer, to: BackgroundRefreshAppsOperation.self) operation.receivedApplicationState(notification: name) } @objc(BackgroundRefreshAppsOperation) final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result]> { let installedApps: [InstalledApp] private let managedObjectContext: NSManagedObjectContext var presentsFinishedNotification: Bool = true var ignoresServerNotFoundError: Bool = true private let refreshIdentifier: String = UUID().uuidString private var runningApplications: Set = [] init(installedApps: [InstalledApp]) { self.installedApps = installedApps self.managedObjectContext = installedApps.compactMap({ $0.managedObjectContext }).first ?? DatabaseManager.shared.persistentContainer.newBackgroundContext() super.init() } override func finish(_ result: Result<[String: Result], Error>) { super.finish(result) self.scheduleFinishedRefreshingNotification(for: result, delay: 0) self.managedObjectContext.perform { self.stopListeningForRunningApps() } DispatchQueue.main.async { if UIApplication.shared.applicationState == .background { } } } override func main() { super.main() guard !self.installedApps.isEmpty else { self.finish(.failure(RefreshError(.noInstalledApps))) return } start_em_proxy(bind_addr: Consts.Proxy.serverURL) target_minimuxer_address() let documentsDirectory = FileManager.default.documentsDirectory.absoluteString do { try minimuxer.start(try String(contentsOf: FileManager.default.documentsDirectory.appendingPathComponent("\(pairingFileName)")), documentsDirectory) } catch { self.finish(.failure(error)) } if #available(iOS 17, *) { // TODO: iOS 17 and above have a new JIT implementation that is completely broken in SideStore :( } else { start_auto_mounter(documentsDirectory) } self.managedObjectContext.perform { Logger.sideload.notice("Refreshing apps in background: \(self.installedApps.map(\.bundleIdentifier), privacy: .public)") self.startListeningForRunningApps() // Wait for 2 seconds (1 now, 1 later in FindServerOperation) to: // a) give us time to discover AltServers // b) give other processes a chance to respond to requestAppState notification DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.managedObjectContext.perform { let filteredApps = self.installedApps.filter { !self.runningApplications.contains($0.bundleIdentifier) } if !self.runningApplications.isEmpty { Logger.sideload.notice("Skipping refreshing running apps: \(self.runningApplications, privacy: .public)") } let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil) group.beginInstallationHandler = { (installedApp) in guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return } // We're starting to install AltStore, which means the app is about to quit. // So, we schedule a "refresh successful" local notification to be displayed after a delay, // but if the app is still running, we cancel the notification. // Then, we schedule another notification and repeat the process. // Also since AltServer has already received the app, it can finish installing even if we're no longer running in background. if let error = group.context.error { self.scheduleFinishedRefreshingNotification(for: .failure(error)) } else { var results = group.results results[installedApp.bundleIdentifier] = .success(installedApp) self.scheduleFinishedRefreshingNotification(for: .success(results)) } } group.completionHandler = { (results) in self.finish(.success(results)) } self.progress.addChild(group.progress, withPendingUnitCount: 1) } } } } } private extension BackgroundRefreshAppsOperation { func startListeningForRunningApps() { let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() let observer = Unmanaged.passUnretained(self).toOpaque() for installedApp in self.installedApps { let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier) CFNotificationCenterAddObserver(notificationCenter, observer, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately) let requestAppStateNotification = CFNotificationName.requestAppState(for: installedApp.bundleIdentifier) CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true) } } func stopListeningForRunningApps() { let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() let observer = Unmanaged.passUnretained(self).toOpaque() for installedApp in self.installedApps { let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier) CFNotificationCenterRemoveObserver(notificationCenter, observer, appIsRunningNotification, nil) } } func receivedApplicationState(notification: CFNotificationName) { let baseName = String(CFNotificationName.appIsRunning.rawValue) let appID = String(notification.rawValue).replacingOccurrences(of: baseName + ".", with: "") self.runningApplications.insert(appID) } func scheduleFinishedRefreshingNotification(for result: Result<[String: Result], Error>, delay: TimeInterval = 5) { func scheduleFinishedRefreshingNotification() { self.cancelFinishedRefreshingNotification() let content = UNMutableNotificationContent() var shouldPresentAlert = true do { let results = try result.get() shouldPresentAlert = !results.isEmpty for (_, result) in results { guard case let .failure(error) = result else { continue } throw error } content.title = NSLocalizedString("Refreshed Apps", comment: "") content.body = NSLocalizedString("All apps have been refreshed.", comment: "") } catch ~OperationError.Code.noWiFi, ~RefreshErrorCode.noInstalledApps { shouldPresentAlert = false } catch ~OperationError.Code.serverNotFound where self.ignoresServerNotFoundError { shouldPresentAlert = false } catch { print("Failed to refresh apps in background.", error) Logger.sideload.error("Failed to refresh apps in background. \(error.localizedDescription, privacy: .public)") content.title = NSLocalizedString("Failed to Refresh Apps", comment: "") content.body = error.localizedDescription shouldPresentAlert = true } if shouldPresentAlert { // Using nil if delay == 0 fixes race condition where multiple notifications can appear (or none). let trigger = delay == 0 ? nil : UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false) let request = UNNotificationRequest(identifier: self.refreshIdentifier, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) if delay > 0 { DispatchQueue.global().asyncAfter(deadline: .now() + delay) { UNUserNotificationCenter.current().getPendingNotificationRequests() { (requests) in // If app is still running at this point, we schedule another notification with same identifier. // This prevents the currently scheduled notification from displaying, and starts another countdown timer. // First though, make sure there _is_ still a pending request, otherwise it's been cancelled // and we should stop polling. guard requests.contains(where: { $0.identifier == self.refreshIdentifier }) else { return } scheduleFinishedRefreshingNotification() } } } } } if self.presentsFinishedNotification { scheduleFinishedRefreshingNotification() } // Perform synchronously to ensure app doesn't quit before we've finishing saving to disk. let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() context.performAndWait { _ = RefreshAttempt(identifier: self.refreshIdentifier, result: result, context: context) do { try context.save() } catch { Logger.sideload.error("Failed to save refresh attempt. \(error.localizedDescription, privacy: .public)") } } } func cancelFinishedRefreshingNotification() { UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [self.refreshIdentifier]) } }