-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBackgroundRefreshAppsOperation.swift
More file actions
332 lines (277 loc) · 14.7 KB
/
Copy pathBackgroundRefreshAppsOperation.swift
File metadata and controls
332 lines (277 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
//
// BackgroundRefreshAppsOperation.swift
// AltStore
//
// Created by Riley Testut on 7/6/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
import CoreData
import AltStoreCore
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 }
// Use takeUnretainedValue() — ownership is managed by observerRetain stored on the operation.
let operation = Unmanaged<BackgroundRefreshAppsOperation>.fromOpaque(observer).takeUnretainedValue()
operation.receivedApplicationState(notification: name)
}
@objc(BackgroundRefreshAppsOperation)
final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<InstalledApp, Error>]>, @unchecked Sendable
{
let installedApps: [InstalledApp]
private let managedObjectContext: NSManagedObjectContext
var presentsFinishedNotification: Bool = true
var ignoresServerNotFoundError: Bool = true
private let refreshIdentifier: String = UUID().uuidString
// Protects runningApplications from concurrent access by the CFNotification callback
// (Darwin notify center thread) and the operation's own work queue.
private let runningAppsQueue = DispatchQueue(label: "com.ministore.BackgroundRefreshAppsOperation.runningApps")
private var _runningApplications: Set<String> = []
// Retains self while Darwin CFNotification observers are registered so the C
// callback never fires on freed memory. Released in stopListeningForRunningApps().
private var observerRetain: Unmanaged<BackgroundRefreshAppsOperation>?
private var runningApplications: Set<String> {
get { runningAppsQueue.sync { _runningApplications } }
}
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() is internally idempotent (guards on isFinished), but the side
// effects below are NOT: scheduleFinishedRefreshingNotification persists a
// RefreshAttempt row and arms a local notification. Guard before super flips
// isFinished so a second finish call can't duplicate them. No current path
// double-finishes, but this keeps the contract robust against future edits.
guard !self.isFinished else { return }
super.finish(result)
self.scheduleFinishedRefreshingNotification(for: result, delay: 0)
self.managedObjectContext.perform {
self.stopListeningForRunningApps()
}
}
override func main()
{
super.main()
guard !self.installedApps.isEmpty else {
self.finish(.failure(RefreshError(.noInstalledApps)))
return
}
if UserDefaults.standard.enableEMPforWireguard {
startEMProxy(bind_addr: AppConstants.Proxy.serverURL)
}
targetMinimuxerAddress()
let documentsDirectory = FileManager.default.documentsDirectory.absoluteString
do {
// enable minimuxer console logging only if enabled in settings
let isMinimuxerConsoleLoggingEnabled = UserDefaults.standard.isMinimuxerConsoleLoggingEnabled
try minimuxerStartWithLogger(
try String(contentsOf: FileManager.default.documentsDirectory.appendingPathComponent("\(pairingFileName)")),
documentsDirectory,
isMinimuxerConsoleLoggingEnabled
)
} catch {
self.finish(.failure(error))
return
}
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) {
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
self.managedObjectContext.perform {
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
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)")
}
// Apply smart-refresh threshold: skip apps with more than threshold days remaining.
let thresholdDays = UserDefaults.standard.smartRefreshThresholdDays
let appsNeedingRefresh: [InstalledApp]
if thresholdDays > 0 {
let threshold = Calendar.current.date(byAdding: .day, value: thresholdDays, to: Date()) ?? Date()
appsNeedingRefresh = filteredApps.filter { $0.expirationDate <= threshold }
if appsNeedingRefresh.count < filteredApps.count {
Logger.sideload.notice("Smart refresh: skipping \(filteredApps.count - appsNeedingRefresh.count, privacy: .public) app(s) with > \(thresholdDays, privacy: .public) days remaining")
}
} else {
appsNeedingRefresh = filteredApps
}
guard !appsNeedingRefresh.isEmpty else {
self.finish(.failure(RefreshError(.noInstalledApps)))
return
}
let group = AppManager.shared.refresh(appsNeedingRefresh, presentingViewController: nil)
group.beginInstallationHandler = { [weak self] (installedApp) in
guard let self else { return }
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 = { [weak self] (results) in
self?.finish(.success(results))
}
self.progress.addChild(group.progress, withPendingUnitCount: 1)
}
}
}
}
}
private extension BackgroundRefreshAppsOperation
{
func startListeningForRunningApps()
{
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
// passRetained keeps self alive for the entire time Darwin holds the observer
// pointer. Released in stopListeningForRunningApps().
let retained = Unmanaged.passRetained(self)
self.observerRetain = retained
let observer = retained.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()
{
guard let retained = self.observerRetain else { return }
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
let observer = retained.toOpaque()
for installedApp in self.installedApps
{
let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier)
CFNotificationCenterRemoveObserver(notificationCenter, observer, appIsRunningNotification, nil)
}
// All observers removed — release the retained reference.
retained.release()
self.observerRetain = nil
}
func receivedApplicationState(notification: CFNotificationName)
{
let baseName = String(CFNotificationName.appIsRunning.rawValue)
let appID = String(notification.rawValue).replacingOccurrences(of: baseName + ".", with: "")
runningAppsQueue.async {
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 = !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
{
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])
}
}