Files
SideStore/AltStore/Operations/BackgroundRefreshAppsOperation.swift
June Park 1713fccfc4 merge AltStore 1.6.3, add dynamic anisette lists, merge SideJITServer integration
* Change error from Swift.Error to NSError

* Adds ResultOperation.localizedFailure

* Finish Riley's monster commit

3b38d725d7
May the Gods have mercy on my soul.

* Fix format strings I broke

* Include "Enable JIT" errors in Error Log

* Fix minimuxer status checking

* [skip ci] Update the no wifi message to include VPN

* Opens Error Log when tapping ToastView

* Fixes Error Log context menu covering cell content

* Fixes Error Log context menu appearing while scrolling

* Fixes incorrect Search FAQ URL

* Fix Error Log showing UIAlertController on iOS 14+

* Fix Error Log not showing UIAlertController on iOS <=13

* Fix wrong color in AuthenticationViewController

* Fix typo

* Fixes logging non-AltServerErrors as AltServerError.underlyingError

* Limits quitting other AltStore/SideStore processes to database migrations

* Skips logging cancelled errors

* Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion

We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors.

However, we kept the underlying Core Data property name the same to avoid extra migration.

* Conforms OperatingSystemVersion to Comparable

* Parses AppVersion.minOSVersion/maxOSVersion from source JSON

* Supports non-NSManagedObjects for @Managed properties

This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before.

* Supports optional @Managed properties

* Conforms AppVersion to AppProtocol

* Verifies min/max OS version before downloading app + asks user to download older app version if necessary

* Improves error message when file does not exist at AppVersion.downloadURL

* Removes unnecessary StoreApp convenience properties

* Removes unnecessary StoreApp convenience properties as well as fix other issues

* Remove Settings bundle, add SwiftUI view instead

Fix refresh all shortcut intent

* Update AuthenticationOperation.swift

Signed-off-by: June Park <rjp2030@outlook.com>

* Fix build issues given by develop

* Add availability check to fix CI build(?)

* If it's gonna be that way...

---------

Signed-off-by: June Park <rjp2030@outlook.com>
Co-authored-by: nythepegasus <nythepegasus84@gmail.com>
Co-authored-by: Riley Testut <riley@rileytestut.com>
Co-authored-by: ny <me@nythepegas.us>
2024-08-05 21:43:52 -04:00

278 lines
11 KiB
Swift

//
// 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<InstalledApp, Error>]>
{
let installedApps: [InstalledApp]
private let managedObjectContext: NSManagedObjectContext
var presentsFinishedNotification: Bool = false
private let refreshIdentifier: String = UUID().uuidString
private var runningApplications: Set<String> = []
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<InstalledApp, Error>], 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 {
print("Apps to refresh:", self.installedApps.map(\.bundleIdentifier))
self.startListeningForRunningApps()
// Wait for 3 seconds (2 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() + 2.0) {
self.managedObjectContext.perform {
let filteredApps = self.installedApps.filter { !self.runningApplications.contains($0.bundleIdentifier) }
print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier })
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))
}
}
}
}
}
}
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<InstalledApp, Error>], Error>, delay: TimeInterval = 5)
{
func scheduleFinishedRefreshingNotification()
{
self.cancelFinishedRefreshingNotification()
let content = UNMutableNotificationContent()
var shouldPresentAlert = true
do
{
let results = try result.get()
shouldPresentAlert = false
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
{
print("Failed to refresh apps in background.", error)
content.title = NSLocalizedString("Failed to Refresh Apps", comment: "")
content.body = error.localizedDescription
}
if shouldPresentAlert
{
let trigger = 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 { print("Failed to save refresh attempt.", error) }
}
}
func cancelFinishedRefreshingNotification()
{
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [self.refreshIdentifier])
}
}