@capgo/cordova-updater 8.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +82 -0
- package/dist/esm/definitions.d.ts +2684 -0
- package/dist/esm/definitions.js +109 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/exec.d.ts +4 -0
- package/dist/esm/exec.js +25 -0
- package/dist/esm/exec.js.map +1 -0
- package/dist/esm/history.d.ts +1 -0
- package/dist/esm/history.js +283 -0
- package/dist/esm/history.js.map +1 -0
- package/dist/esm/index.d.ts +57 -0
- package/dist/esm/index.js +68 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +57 -0
- package/dist/esm/plugin.js +15 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/plugin.cjs.js +217 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +222 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +96 -0
- package/plugin.xml +207 -0
- package/src/android/CordovaUpdaterPlugin.java +5662 -0
- package/src/android/UpdaterPathHandler.java +99 -0
- package/src/android/app/capgo/cordova/updater/AndroidAppExitReporter.java +92 -0
- package/src/android/app/capgo/cordova/updater/AppLifecycleObserver.java +115 -0
- package/src/android/app/capgo/cordova/updater/BundleInfo.java +246 -0
- package/src/android/app/capgo/cordova/updater/BundleStatus.java +45 -0
- package/src/android/app/capgo/cordova/updater/Callback.java +13 -0
- package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +3404 -0
- package/src/android/app/capgo/cordova/updater/CordovaUpdaterConfig.java +56 -0
- package/src/android/app/capgo/cordova/updater/CryptoCipher.java +462 -0
- package/src/android/app/capgo/cordova/updater/DataManager.java +44 -0
- package/src/android/app/capgo/cordova/updater/DelayCondition.java +56 -0
- package/src/android/app/capgo/cordova/updater/DelayUntilNext.java +14 -0
- package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +307 -0
- package/src/android/app/capgo/cordova/updater/DeviceIdHelper.java +230 -0
- package/src/android/app/capgo/cordova/updater/DownloadService.java +1219 -0
- package/src/android/app/capgo/cordova/updater/DownloadWorkerManager.java +260 -0
- package/src/android/app/capgo/cordova/updater/InternalUtils.java +54 -0
- package/src/android/app/capgo/cordova/updater/Logger.java +345 -0
- package/src/android/app/capgo/cordova/updater/ShakeDetector.java +72 -0
- package/src/android/app/capgo/cordova/updater/ShakeMenu.java +39 -0
- package/src/android/app/capgo/cordova/updater/ThreeFingerPinchDetector.java +323 -0
- package/src/android/app/capgo/cordova/updater/compat/JSArray.java +9 -0
- package/src/android/app/capgo/cordova/updater/compat/JSObject.java +83 -0
- package/src/android/app/capgo/cordova/updater/compat/PluginCall.java +165 -0
- package/src/android/capgo-cordova-updater.gradle +19 -0
- package/src/android/capgo-updater.xml +3 -0
- package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +3039 -0
- package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +177 -0
- package/src/android/test/app/capgo/cordova/updater/NativeContractTest.java +105 -0
- package/src/ios/AES.swift +216 -0
- package/src/ios/AppHealthTracker.swift +82 -0
- package/src/ios/BundleInfo.swift +185 -0
- package/src/ios/BundleStatus.swift +127 -0
- package/src/ios/CapgoRawRsa.swift +36 -0
- package/src/ios/CapgoUpdater.swift +3544 -0
- package/src/ios/CordovaCompat.swift +11 -0
- package/src/ios/CordovaPluginCall.swift +190 -0
- package/src/ios/CordovaUpdaterCommands.swift +270 -0
- package/src/ios/CordovaUpdaterPlugin.swift +4706 -0
- package/src/ios/CryptoCipher.swift +326 -0
- package/src/ios/DelayCondition.swift +74 -0
- package/src/ios/DelayUntilNext.swift +30 -0
- package/src/ios/DelayUpdateUtils.swift +257 -0
- package/src/ios/DeviceIdHelper.swift +120 -0
- package/src/ios/Info.plist +28 -0
- package/src/ios/InternalUtils.swift +471 -0
- package/src/ios/Logger.swift +283 -0
- package/src/ios/RSA.swift +96 -0
- package/src/ios/ShakeMenu.swift +52 -0
- package/src/ios/UpdaterSchemeHandler.swift +81 -0
- package/src/ios/UserDefaultsExtension.swift +46 -0
- package/src/ios/WebViewStatsReporter.swift +303 -0
|
@@ -0,0 +1,3544 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import Foundation
|
|
8
|
+
import ZIPFoundation
|
|
9
|
+
import Alamofire
|
|
10
|
+
import Compression
|
|
11
|
+
import UIKit
|
|
12
|
+
|
|
13
|
+
@objc public class CapgoUpdater: NSObject {
|
|
14
|
+
private var logger: Logger!
|
|
15
|
+
|
|
16
|
+
private let versionCode: String = Bundle.main.versionCode ?? ""
|
|
17
|
+
private let versionOs = UIDevice.current.systemVersion
|
|
18
|
+
private let libraryDir: URL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
|
|
19
|
+
private let DEFAULT_FOLDER: String = ""
|
|
20
|
+
private let bundleDirectory: String = "NoCloud/ionic_built_snapshots"
|
|
21
|
+
private let INFO_SUFFIX: String = "_info"
|
|
22
|
+
private let FALLBACK_VERSION: String = "pastVersion"
|
|
23
|
+
private let NEXT_VERSION: String = "nextVersion"
|
|
24
|
+
private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
|
|
25
|
+
private let PENDING_DELETE_IDS: String = "pendingDeleteIds"
|
|
26
|
+
private var unzipPercent = 0
|
|
27
|
+
private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
|
|
28
|
+
/// HTTP + decode share one pool. Cap by CPU: 8 on 4 cores, 16 on 8 cores, 64 max.
|
|
29
|
+
static let manifestMaxConcurrentFiles = clampedManifestConcurrency(processorCount: ProcessInfo.processInfo.processorCount)
|
|
30
|
+
|
|
31
|
+
static func clampedManifestConcurrency(processorCount: Int) -> Int {
|
|
32
|
+
min(64, max(8, max(1, processorCount) * 2))
|
|
33
|
+
}
|
|
34
|
+
private static let emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
35
|
+
private let deletePaceSeconds: TimeInterval = 0.075
|
|
36
|
+
private let deleteLock = NSLock()
|
|
37
|
+
|
|
38
|
+
// Add this line to declare cacheFolder
|
|
39
|
+
private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
|
|
40
|
+
|
|
41
|
+
public let CAP_SERVER_PATH: String = "serverBasePath"
|
|
42
|
+
public var versionBuild: String = ""
|
|
43
|
+
public var customId: String = ""
|
|
44
|
+
public var pluginVersion: String = ""
|
|
45
|
+
public var timeout: Double = 20
|
|
46
|
+
public var statsUrl: String = ""
|
|
47
|
+
/// Optional gate run before any download touches disk (e.g. wait for launch cleanup).
|
|
48
|
+
public var beforeDownload: (() throws -> Void)?
|
|
49
|
+
public var channelUrl: String = ""
|
|
50
|
+
public var defaultChannel: String = ""
|
|
51
|
+
public var appId: String = ""
|
|
52
|
+
public var deviceID = ""
|
|
53
|
+
public var previewSession = false
|
|
54
|
+
public var publicKey: String = ""
|
|
55
|
+
|
|
56
|
+
// Cached key ID calculated once from publicKey
|
|
57
|
+
private var cachedKeyId: String?
|
|
58
|
+
|
|
59
|
+
// Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
|
|
60
|
+
// Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
|
|
61
|
+
private static let rateLimitStateLock = NSLock()
|
|
62
|
+
private static var rateLimitBlockedUntilMs: Double = 0
|
|
63
|
+
private static var rateLimitBlockedError: String = "too_many_requests"
|
|
64
|
+
private static var rateLimitBlockedMessage: String = "Too many requests"
|
|
65
|
+
|
|
66
|
+
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop.
|
|
67
|
+
// Released again when the send fails, so a later 429 can retry it.
|
|
68
|
+
private static var rateLimitStatisticSent = false
|
|
69
|
+
|
|
70
|
+
// Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
|
|
71
|
+
private static let maxRateLimitWindowMs: Double = 24 * 60 * 60 * 1000
|
|
72
|
+
|
|
73
|
+
// Stats batching - queue events and send max once per second
|
|
74
|
+
private var statsQueue: [QueuedStatsEvent] = []
|
|
75
|
+
private var statsInFlight: [QueuedStatsEvent] = []
|
|
76
|
+
private let statsQueueLock = NSLock()
|
|
77
|
+
private let statsPersistLock = NSLock()
|
|
78
|
+
private let statsStateLock = NSLock()
|
|
79
|
+
private var statsFlushTimer: Timer?
|
|
80
|
+
private var _statsStopped = false
|
|
81
|
+
private var statsStopped: Bool {
|
|
82
|
+
get {
|
|
83
|
+
statsStateLock.lock()
|
|
84
|
+
defer { statsStateLock.unlock() }
|
|
85
|
+
return _statsStopped
|
|
86
|
+
}
|
|
87
|
+
set {
|
|
88
|
+
statsStateLock.lock()
|
|
89
|
+
defer { statsStateLock.unlock() }
|
|
90
|
+
_statsStopped = newValue
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
private static let statsFlushInterval: TimeInterval = 1.0
|
|
94
|
+
private static let maxPendingStats = 200
|
|
95
|
+
private let pendingStatsFileName = "capgo_pending_stats.json"
|
|
96
|
+
|
|
97
|
+
private struct QueuedStatsEvent {
|
|
98
|
+
let event: StatsEvent
|
|
99
|
+
let onSent: (() -> Void)?
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private static func sanitizeHeaderValue(_ value: String) -> String {
|
|
103
|
+
if value.isEmpty {
|
|
104
|
+
return "unknown"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let filteredScalars = value.unicodeScalars.filter { scalar in
|
|
108
|
+
let cp = scalar.value
|
|
109
|
+
let isVisibleAscii = (0x20...0x7E).contains(cp)
|
|
110
|
+
let isIso88591 = (0xA0...0xFF).contains(cp)
|
|
111
|
+
return isVisibleAscii || isIso88591
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let sanitized = String(String.UnicodeScalarView(filteredScalars)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
115
|
+
return sanitized.isEmpty ? "unknown" : sanitized
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
static func buildUserAgent(appId: String, pluginVersion: String, versionOs: String) -> String {
|
|
119
|
+
let safePluginVersion = sanitizeHeaderValue(pluginVersion)
|
|
120
|
+
let safeAppId = sanitizeHeaderValue(appId)
|
|
121
|
+
let safeVersionOs = sanitizeHeaderValue(versionOs)
|
|
122
|
+
return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(safeVersionOs)"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private var userAgent: String {
|
|
126
|
+
CapgoUpdater.buildUserAgent(appId: appId, pluginVersion: pluginVersion, versionOs: versionOs)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
struct RequestResult {
|
|
130
|
+
let data: Data?
|
|
131
|
+
let response: HTTPURLResponse?
|
|
132
|
+
let error: Error?
|
|
133
|
+
let timedOut: Bool
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private struct DownloadRequestResult {
|
|
137
|
+
let fileURL: URL?
|
|
138
|
+
let response: HTTPURLResponse?
|
|
139
|
+
let error: Error?
|
|
140
|
+
let timedOut: Bool
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
enum SecurePathError: Error {
|
|
144
|
+
case emptyPath
|
|
145
|
+
case windowsPath
|
|
146
|
+
case absolutePath
|
|
147
|
+
case pathTraversal
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
|
|
151
|
+
if relativePath.isEmpty {
|
|
152
|
+
throw SecurePathError.emptyPath
|
|
153
|
+
}
|
|
154
|
+
if relativePath.contains("\\") || relativePath.contains("\0") {
|
|
155
|
+
throw SecurePathError.windowsPath
|
|
156
|
+
}
|
|
157
|
+
if (relativePath as NSString).isAbsolutePath {
|
|
158
|
+
throw SecurePathError.absolutePath
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let canonicalBase = baseDirectory.standardizedFileURL
|
|
162
|
+
let canonicalBasePath = canonicalBase.path
|
|
163
|
+
let normalizedBasePath = canonicalBasePath.hasSuffix("/") ? canonicalBasePath : "\(canonicalBasePath)/"
|
|
164
|
+
let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
|
|
165
|
+
let canonicalTargetPath = canonicalTarget.path
|
|
166
|
+
|
|
167
|
+
if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
|
|
168
|
+
throw SecurePathError.pathTraversal
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return canonicalTarget
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
|
|
175
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
176
|
+
let targetFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
177
|
+
return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func isTimedOutError(_ error: Error?) -> Bool {
|
|
181
|
+
guard let nsError = error as NSError? else {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private lazy var alamofireSession: Session = {
|
|
189
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
190
|
+
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
191
|
+
configuration.httpCookieStorage = nil
|
|
192
|
+
configuration.httpShouldSetCookies = false
|
|
193
|
+
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
194
|
+
configuration.urlCache = nil
|
|
195
|
+
configuration.httpMaximumConnectionsPerHost = Self.manifestMaxConcurrentFiles
|
|
196
|
+
return Session(configuration: configuration)
|
|
197
|
+
}()
|
|
198
|
+
private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
|
|
199
|
+
|
|
200
|
+
public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
|
|
201
|
+
public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
|
|
202
|
+
let emit = {
|
|
203
|
+
self.notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
|
|
204
|
+
}
|
|
205
|
+
if Thread.isMainThread {
|
|
206
|
+
emit()
|
|
207
|
+
} else {
|
|
208
|
+
DispatchQueue.main.async {
|
|
209
|
+
emit()
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
public var notifyDownload: (String, Int) -> Void = { _, _ in }
|
|
214
|
+
public var notifyListeners: (String, [String: Any]) -> Void = { _, _ in }
|
|
215
|
+
|
|
216
|
+
public func setLogger(_ logger: Logger) {
|
|
217
|
+
self.logger = logger
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func createRequest(url: URL, method: String, parameters: [String: Any]? = nil, expectsJSONResponse: Bool = false) -> URLRequest? {
|
|
221
|
+
var request = URLRequest(url: url)
|
|
222
|
+
request.httpMethod = method
|
|
223
|
+
request.timeoutInterval = self.timeout
|
|
224
|
+
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
|
225
|
+
if expectsJSONResponse || parameters != nil {
|
|
226
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
guard let parameters else {
|
|
230
|
+
return request
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
guard JSONSerialization.isValidJSONObject(parameters) else {
|
|
234
|
+
logger.error("Invalid JSON body for \(method) \(url.absoluteString)")
|
|
235
|
+
return nil
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
do {
|
|
239
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
|
|
240
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
241
|
+
return request
|
|
242
|
+
} catch {
|
|
243
|
+
logger.error("Error encoding request body for \(method) \(url.absoluteString)")
|
|
244
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
245
|
+
return nil
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func performRequest(_ request: URLRequest, label: String) -> RequestResult {
|
|
250
|
+
let waitTimeout = max(self.timeout + 5, 10)
|
|
251
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
252
|
+
var responseData: Data?
|
|
253
|
+
var httpResponse: HTTPURLResponse?
|
|
254
|
+
var requestError: Error?
|
|
255
|
+
let dataRequest = self.alamofireSession.request(request).responseData(queue: self.networkResponseQueue) { response in
|
|
256
|
+
responseData = response.data
|
|
257
|
+
httpResponse = response.response
|
|
258
|
+
requestError = response.error
|
|
259
|
+
semaphore.signal()
|
|
260
|
+
}
|
|
261
|
+
dataRequest.resume()
|
|
262
|
+
|
|
263
|
+
if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
|
|
264
|
+
dataRequest.cancel()
|
|
265
|
+
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
266
|
+
return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: false)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
|
|
273
|
+
let waitTimeout = max(self.timeout + 5, 10)
|
|
274
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
275
|
+
var tempFileURL: URL?
|
|
276
|
+
var httpResponse: HTTPURLResponse?
|
|
277
|
+
var requestError: Error?
|
|
278
|
+
let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
279
|
+
let destination: DownloadRequest.Destination = { _, _ in
|
|
280
|
+
(temporaryDownloadURL, [.removePreviousFile, .createIntermediateDirectories])
|
|
281
|
+
}
|
|
282
|
+
let downloadRequest = self.alamofireSession.download(request, to: destination).response(queue: self.networkResponseQueue) { response in
|
|
283
|
+
tempFileURL = response.fileURL
|
|
284
|
+
httpResponse = response.response
|
|
285
|
+
requestError = response.error
|
|
286
|
+
semaphore.signal()
|
|
287
|
+
}
|
|
288
|
+
downloadRequest.resume()
|
|
289
|
+
|
|
290
|
+
if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
|
|
291
|
+
downloadRequest.cancel()
|
|
292
|
+
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
293
|
+
return DownloadRequestResult(
|
|
294
|
+
fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
|
|
295
|
+
response: httpResponse,
|
|
296
|
+
error: requestError,
|
|
297
|
+
timedOut: true
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if isTimedOutError(requestError) {
|
|
302
|
+
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return DownloadRequestResult(
|
|
306
|
+
fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
|
|
307
|
+
response: httpResponse,
|
|
308
|
+
error: requestError,
|
|
309
|
+
timedOut: isTimedOutError(requestError)
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private func existingDownloadFileURL(_ fileURL: URL?, fallback: URL) -> URL? {
|
|
314
|
+
let fileManager = FileManager.default
|
|
315
|
+
if let fileURL, fileManager.fileExists(atPath: fileURL.path) {
|
|
316
|
+
return fileURL
|
|
317
|
+
}
|
|
318
|
+
return fileManager.fileExists(atPath: fallback.path) ? fallback : nil
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
static func shouldAppendHttpBody(statusCode: Int, existingBytes: Int64) -> Bool {
|
|
322
|
+
existingBytes > 0 && statusCode == 206
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
static func safePartialToken(_ fileName: String) -> String {
|
|
326
|
+
CryptoCipher.shortPathKey(fileName)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
static func manifestPartialURL(cacheFolder: URL, hash: String, fileName: String) -> URL {
|
|
330
|
+
let token = safePartialToken(fileName)
|
|
331
|
+
if isSafeCacheHash(hash) && hash.count == 64 {
|
|
332
|
+
return cacheFolder.appendingPathComponent("partial_\(hash)_\(token).tmp")
|
|
333
|
+
}
|
|
334
|
+
let digest = CryptoCipher.shortPathKey("\(hash)|\(fileName)")
|
|
335
|
+
return cacheFolder.appendingPathComponent("partial_\(digest)_\(token).tmp")
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private func cleanupOldManifestPartials() {
|
|
339
|
+
let cutoff = Date().addingTimeInterval(-3600)
|
|
340
|
+
guard let files = try? FileManager.default.contentsOfDirectory(
|
|
341
|
+
at: cacheFolder,
|
|
342
|
+
includingPropertiesForKeys: [.contentModificationDateKey],
|
|
343
|
+
options: [.skipsHiddenFiles]
|
|
344
|
+
) else {
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
for url in files {
|
|
348
|
+
let name = url.lastPathComponent
|
|
349
|
+
guard name.hasPrefix("partial_") && name.hasSuffix(".tmp") else {
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
|
353
|
+
if modified < cutoff {
|
|
354
|
+
try? FileManager.default.removeItem(at: url)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
|
|
360
|
+
let fileManager = FileManager.default
|
|
361
|
+
if Self.shouldAppendHttpBody(statusCode: response?.statusCode ?? 0, existingBytes: existingBytes) ||
|
|
362
|
+
(existingBytes > 0 && response == nil) {
|
|
363
|
+
let fileHandle = try FileHandle(forWritingTo: tempPath)
|
|
364
|
+
defer {
|
|
365
|
+
try? fileHandle.close()
|
|
366
|
+
}
|
|
367
|
+
fileHandle.seek(toFileOffset: UInt64(existingBytes))
|
|
368
|
+
let input = try FileHandle(forReadingFrom: downloadedFileURL)
|
|
369
|
+
defer {
|
|
370
|
+
try? input.close()
|
|
371
|
+
}
|
|
372
|
+
let chunkSize = CryptoCipher.copyBufferBytes()
|
|
373
|
+
while true {
|
|
374
|
+
let done: Bool = try autoreleasepool {
|
|
375
|
+
let chunk = try input.read(upToCount: chunkSize) ?? Data()
|
|
376
|
+
if chunk.isEmpty {
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
fileHandle.write(chunk)
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
382
|
+
if done {
|
|
383
|
+
break
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
try? fileManager.removeItem(at: downloadedFileURL)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if fileManager.fileExists(atPath: tempPath.path) {
|
|
391
|
+
try fileManager.removeItem(at: tempPath)
|
|
392
|
+
}
|
|
393
|
+
try fileManager.moveItem(at: downloadedFileURL, to: tempPath)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private func persistPartialDownload(_ downloadResult: DownloadRequestResult, id: String, tempPath: URL, existingBytes: Int64) {
|
|
397
|
+
guard let downloadedFileURL = downloadResult.fileURL else {
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
guard FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
do {
|
|
408
|
+
try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: existingBytes, response: downloadResult.response)
|
|
409
|
+
logger.info("Stored partial download for retry")
|
|
410
|
+
} catch {
|
|
411
|
+
logger.error("Failed to store partial download")
|
|
412
|
+
logger.debug("Path: \(downloadedFileURL.path), Error: \(error)")
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
deinit {
|
|
417
|
+
shutdown()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
public func shutdown() {
|
|
421
|
+
statsStopped = true
|
|
422
|
+
statsFlushTimer?.invalidate()
|
|
423
|
+
statsFlushTimer = nil
|
|
424
|
+
persistStatsQueue(force: true)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
|
|
428
|
+
return (percent * (max - min)) / 100 + min
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private func randomString(length: Int) -> String {
|
|
432
|
+
let letters: String = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
433
|
+
return String((0..<length).map { _ in letters.randomElement()! })
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
public func setPublicKey(_ publicKey: String) {
|
|
437
|
+
// Empty string means no encryption - proceed normally
|
|
438
|
+
if publicKey.isEmpty {
|
|
439
|
+
self.publicKey = ""
|
|
440
|
+
self.cachedKeyId = nil
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Non-empty: must be a valid RSA key or crash
|
|
445
|
+
guard RSAPublicKey.load(rsaPublicKey: publicKey) != nil else {
|
|
446
|
+
fatalError("Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.")
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
self.publicKey = publicKey
|
|
450
|
+
self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
public func getKeyId() -> String? {
|
|
454
|
+
return self.cachedKeyId
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private var isDevEnvironment: Bool {
|
|
458
|
+
#if DEBUG
|
|
459
|
+
return true
|
|
460
|
+
#else
|
|
461
|
+
return false
|
|
462
|
+
#endif
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private func isProd() -> Bool {
|
|
466
|
+
return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private func installSource() -> String? {
|
|
470
|
+
if isEmulator() || self.isDevEnvironment || self.hasEmbeddedMobileProvision() {
|
|
471
|
+
return nil
|
|
472
|
+
}
|
|
473
|
+
guard let receiptURL = Bundle.main.appStoreReceiptURL else {
|
|
474
|
+
return nil
|
|
475
|
+
}
|
|
476
|
+
if receiptURL.lastPathComponent == "sandboxReceipt" {
|
|
477
|
+
return "testflight"
|
|
478
|
+
}
|
|
479
|
+
guard FileManager.default.fileExists(atPath: receiptURL.path) else {
|
|
480
|
+
return nil
|
|
481
|
+
}
|
|
482
|
+
return "app_store"
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Checks if there is sufficient disk space for a download.
|
|
487
|
+
* Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
|
|
488
|
+
* - Parameter estimatedSize: The estimated size of the download in bytes. Defaults to 50MB.
|
|
489
|
+
*/
|
|
490
|
+
private func checkDiskSpace(estimatedSize: Int64 = 50 * 1024 * 1024) throws {
|
|
491
|
+
let fileManager = FileManager.default
|
|
492
|
+
guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
do {
|
|
497
|
+
let attributes = try fileManager.attributesOfFileSystem(forPath: documentDirectory.path)
|
|
498
|
+
guard let freeSpace = attributes[.systemFreeSize] as? Int64 else {
|
|
499
|
+
logger.warn("Could not determine free disk space, proceeding with download")
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let requiredSpace = estimatedSize * 2 // 2x safety margin like Android
|
|
504
|
+
|
|
505
|
+
if freeSpace < requiredSpace {
|
|
506
|
+
logger.error("Insufficient disk space. Available: \(freeSpace), Required: \(requiredSpace)")
|
|
507
|
+
self.sendStats(action: "insufficient_disk_space")
|
|
508
|
+
throw CustomError.insufficientDiskSpace
|
|
509
|
+
}
|
|
510
|
+
} catch let error as CustomError {
|
|
511
|
+
throw error
|
|
512
|
+
} catch {
|
|
513
|
+
logger.warn("Error checking disk space: \(error.localizedDescription)")
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private struct RemoteBlockResult {
|
|
518
|
+
let blocked: Bool
|
|
519
|
+
let error: String
|
|
520
|
+
let message: String
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
|
|
525
|
+
* All 429s use the same temporary client block — no sticky latch until restart.
|
|
526
|
+
*/
|
|
527
|
+
private func checkAndHandleRateLimitResponse(
|
|
528
|
+
statusCode: Int?,
|
|
529
|
+
data: Data? = nil,
|
|
530
|
+
response: HTTPURLResponse? = nil
|
|
531
|
+
) -> RemoteBlockResult {
|
|
532
|
+
guard statusCode == 429 else {
|
|
533
|
+
return RemoteBlockResult(blocked: false, error: "", message: "")
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let parsed = parseRemoteError(from: data)
|
|
537
|
+
let errorCode = parsed.error.isEmpty ? "too_many_requests" : parsed.error
|
|
538
|
+
let message = parsed.message.isEmpty ? "Too many requests" : parsed.message
|
|
539
|
+
|
|
540
|
+
let retryUntilMs = resolveRateLimitBlockedUntilMs(data: data, response: response)
|
|
541
|
+
CapgoUpdater.recordRateLimitBlock(untilMs: retryUntilMs, error: errorCode, message: message)
|
|
542
|
+
|
|
543
|
+
// Claim last, and only when there is somewhere to send it, so a 429 burst with no
|
|
544
|
+
// stats URL does not claim and release the latch once per response.
|
|
545
|
+
if errorCode == "too_many_requests" && !previewSession && !statsUrl.isEmpty && CapgoUpdater.claimRateLimitStatistic() {
|
|
546
|
+
DispatchQueue.global(qos: .utility).async {
|
|
547
|
+
self.sendRateLimitStatistic()
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let nowMs = Date().timeIntervalSince1970 * 1000
|
|
552
|
+
let retryAfter = CapgoUpdater.retryAfterSecondsForLog(untilMs: retryUntilMs, nowMs: nowMs)
|
|
553
|
+
logger.warn("Received 429 (\(errorCode)). Honouring Retry-After: \(retryAfter)s.")
|
|
554
|
+
return RemoteBlockResult(blocked: true, error: errorCode, message: message)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/// Stores the block deadline and its metadata together, keeping the longest deadline
|
|
558
|
+
/// so a concurrent 429 with a shorter window cannot cut the block short.
|
|
559
|
+
private static func recordRateLimitBlock(untilMs: Double, error: String, message: String) {
|
|
560
|
+
rateLimitStateLock.lock()
|
|
561
|
+
defer { rateLimitStateLock.unlock() }
|
|
562
|
+
if untilMs > rateLimitBlockedUntilMs {
|
|
563
|
+
rateLimitBlockedUntilMs = untilMs
|
|
564
|
+
rateLimitBlockedError = error
|
|
565
|
+
rateLimitBlockedMessage = message
|
|
566
|
+
} else if rateLimitBlockedUntilMs <= 0 {
|
|
567
|
+
rateLimitBlockedError = error
|
|
568
|
+
rateLimitBlockedMessage = message
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/// Seconds left in the block, clamped and finite so the Int conversion can never trap.
|
|
573
|
+
private static func retryAfterSecondsForLog(untilMs: Double, nowMs: Double) -> Int {
|
|
574
|
+
let seconds = ((untilMs - nowMs) / 1000).rounded(.up)
|
|
575
|
+
guard seconds.isFinite, seconds > 0 else {
|
|
576
|
+
return 0
|
|
577
|
+
}
|
|
578
|
+
return Int(min(seconds, maxRateLimitWindowMs / 1000))
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/// Returns true for the first 429 only, so the rate-limit statistic is sent once.
|
|
582
|
+
private static func claimRateLimitStatistic() -> Bool {
|
|
583
|
+
rateLimitStateLock.lock()
|
|
584
|
+
defer { rateLimitStateLock.unlock() }
|
|
585
|
+
if rateLimitStatisticSent {
|
|
586
|
+
return false
|
|
587
|
+
}
|
|
588
|
+
rateLimitStatisticSent = true
|
|
589
|
+
return true
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/// Gives the claim back when the statistic never made it out, so a later 429 can retry it.
|
|
593
|
+
private static func releaseRateLimitStatisticClaim() {
|
|
594
|
+
rateLimitStateLock.lock()
|
|
595
|
+
defer { rateLimitStateLock.unlock() }
|
|
596
|
+
rateLimitStatisticSent = false
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private func parseRemoteError(from data: Data?) -> (error: String, message: String) {
|
|
600
|
+
guard let data = data,
|
|
601
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
602
|
+
return ("", "")
|
|
603
|
+
}
|
|
604
|
+
let error = json["error"] as? String ?? ""
|
|
605
|
+
let message = json["message"] as? String ?? ""
|
|
606
|
+
return (error, message)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private func resolveRateLimitBlockedUntilMs(data: Data?, response: HTTPURLResponse?) -> Double {
|
|
610
|
+
let nowMs = Date().timeIntervalSince1970 * 1000
|
|
611
|
+
let candidate = rawRateLimitDeadlineMs(data: data, response: response, nowMs: nowMs)
|
|
612
|
+
// NaN and past deadlines mean "no client-side block"; anything further out is capped.
|
|
613
|
+
guard candidate > nowMs else {
|
|
614
|
+
return 0
|
|
615
|
+
}
|
|
616
|
+
return min(candidate, nowMs + CapgoUpdater.maxRateLimitWindowMs)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private func rawRateLimitDeadlineMs(data: Data?, response: HTTPURLResponse?, nowMs: Double) -> Double {
|
|
620
|
+
if let header = response?.value(forHTTPHeaderField: "Retry-After")?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
621
|
+
let seconds = Double(header), seconds >= 0 {
|
|
622
|
+
return nowMs + seconds * 1000
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if let data = data,
|
|
626
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
627
|
+
let moreInfo = json["moreInfo"] as? [String: Any]
|
|
628
|
+
if let retryAfter = (moreInfo?["retryAfterSeconds"] as? NSNumber)?.doubleValue
|
|
629
|
+
?? (json["retryAfterSeconds"] as? NSNumber)?.doubleValue,
|
|
630
|
+
retryAfter >= 0 {
|
|
631
|
+
return nowMs + retryAfter * 1000
|
|
632
|
+
}
|
|
633
|
+
if let resetAt = (moreInfo?["rateLimitResetAt"] as? NSNumber)?.doubleValue
|
|
634
|
+
?? (json["rateLimitResetAt"] as? NSNumber)?.doubleValue {
|
|
635
|
+
return resetAt
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// No retry hint — do not hold a client-side block; allow immediate retry to the worker
|
|
640
|
+
return 0
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private func isRemoteBlocked() -> Bool {
|
|
644
|
+
CapgoUpdater.rateLimitStateLock.lock()
|
|
645
|
+
defer { CapgoUpdater.rateLimitStateLock.unlock() }
|
|
646
|
+
if CapgoUpdater.rateLimitBlockedUntilMs <= 0 {
|
|
647
|
+
return false
|
|
648
|
+
}
|
|
649
|
+
if Date().timeIntervalSince1970 * 1000 >= CapgoUpdater.rateLimitBlockedUntilMs {
|
|
650
|
+
CapgoUpdater.rateLimitBlockedUntilMs = 0
|
|
651
|
+
return false
|
|
652
|
+
}
|
|
653
|
+
return true
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private func remoteBlockedClientError() -> (error: String, message: String) {
|
|
657
|
+
CapgoUpdater.rateLimitStateLock.lock()
|
|
658
|
+
defer { CapgoUpdater.rateLimitStateLock.unlock() }
|
|
659
|
+
return (CapgoUpdater.rateLimitBlockedError, CapgoUpdater.rateLimitBlockedMessage)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Send a synchronous statistic about rate limiting
|
|
664
|
+
* Note: This method uses a semaphore to block until the request completes.
|
|
665
|
+
* It MUST be called from a background queue to avoid blocking the main thread.
|
|
666
|
+
*/
|
|
667
|
+
private func sendRateLimitStatistic() {
|
|
668
|
+
guard !statsUrl.isEmpty else {
|
|
669
|
+
// The URL was cleared after the claim was taken; nothing went out, so hand it back.
|
|
670
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let current = getCurrentBundle()
|
|
675
|
+
var parameters = createInfoObject()
|
|
676
|
+
parameters.action = "rate_limit_reached"
|
|
677
|
+
parameters.version_name = current.getVersionName()
|
|
678
|
+
parameters.old_version_name = ""
|
|
679
|
+
|
|
680
|
+
// Send synchronously using semaphore (safe because we're on a background queue)
|
|
681
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
682
|
+
self.alamofireSession.request(
|
|
683
|
+
self.statsUrl,
|
|
684
|
+
method: .post,
|
|
685
|
+
parameters: parameters.toParameters(),
|
|
686
|
+
encoding: JSONEncoding.default,
|
|
687
|
+
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
688
|
+
).responseData { response in
|
|
689
|
+
let statusCode = response.response?.statusCode
|
|
690
|
+
switch response.result {
|
|
691
|
+
case .success where (200...299).contains(statusCode ?? 0):
|
|
692
|
+
self.logger.info("Rate limit statistic sent")
|
|
693
|
+
case .success:
|
|
694
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
695
|
+
self.logger.error("Error sending rate limit statistic")
|
|
696
|
+
self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
|
|
697
|
+
case let .failure(error):
|
|
698
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
699
|
+
self.logger.error("Error sending rate limit statistic")
|
|
700
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
701
|
+
}
|
|
702
|
+
semaphore.signal()
|
|
703
|
+
}
|
|
704
|
+
semaphore.wait()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// MARK: Private
|
|
708
|
+
private func hasEmbeddedMobileProvision() -> Bool {
|
|
709
|
+
guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
|
|
710
|
+
return true
|
|
711
|
+
}
|
|
712
|
+
return false
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private func isAppStoreReceiptSandbox() -> Bool {
|
|
716
|
+
|
|
717
|
+
if isEmulator() {
|
|
718
|
+
return false
|
|
719
|
+
} else {
|
|
720
|
+
guard let url: URL = Bundle.main.appStoreReceiptURL else {
|
|
721
|
+
return false
|
|
722
|
+
}
|
|
723
|
+
guard url.lastPathComponent == "sandboxReceipt" else {
|
|
724
|
+
return false
|
|
725
|
+
}
|
|
726
|
+
return true
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private func isEmulator() -> Bool {
|
|
731
|
+
#if targetEnvironment(simulator)
|
|
732
|
+
return true
|
|
733
|
+
#else
|
|
734
|
+
return false
|
|
735
|
+
#endif
|
|
736
|
+
}
|
|
737
|
+
// Persistent path /var/mobile/Containers/Data/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/Library/NoCloud/ionic_built_snapshots/FOLDER
|
|
738
|
+
// Hot Reload path /var/mobile/Containers/Data/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/Documents/FOLDER
|
|
739
|
+
// Normal /private/var/containers/Bundle/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/App.app/public
|
|
740
|
+
|
|
741
|
+
private func prepareFolder(source: URL) throws {
|
|
742
|
+
if !FileManager.default.fileExists(atPath: source.path) {
|
|
743
|
+
do {
|
|
744
|
+
try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
|
|
745
|
+
} catch {
|
|
746
|
+
logger.error("Cannot create directory")
|
|
747
|
+
logger.debug("Directory path: \(source.path)")
|
|
748
|
+
throw CustomError.cannotCreateDirectory
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private func deleteFolder(source: URL) throws {
|
|
754
|
+
do {
|
|
755
|
+
try FileManager.default.removeItem(atPath: source.path)
|
|
756
|
+
} catch {
|
|
757
|
+
logger.error("File not removed")
|
|
758
|
+
logger.debug("Path: \(source.path)")
|
|
759
|
+
throw CustomError.cannotDeleteDirectory
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private func unflatFolder(source: URL, dest: URL) throws -> Bool {
|
|
764
|
+
let index: URL = source.appendingPathComponent("index.html")
|
|
765
|
+
do {
|
|
766
|
+
let files: [String] = try FileManager.default.contentsOfDirectory(atPath: source.path)
|
|
767
|
+
if files.count == 1 && source.appendingPathComponent(files[0]).isDirectory && !FileManager.default.fileExists(atPath: index.path) {
|
|
768
|
+
try FileManager.default.moveItem(at: source.appendingPathComponent(files[0]), to: dest)
|
|
769
|
+
return true
|
|
770
|
+
} else {
|
|
771
|
+
try FileManager.default.moveItem(at: source, to: dest)
|
|
772
|
+
return false
|
|
773
|
+
}
|
|
774
|
+
} catch {
|
|
775
|
+
logger.error("File not moved")
|
|
776
|
+
logger.debug("Source: \(source.path), Dest: \(dest.path)")
|
|
777
|
+
throw CustomError.cannotUnflat
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private func resolveZipEntry(path: String, destUnZip: URL) throws -> URL {
|
|
782
|
+
do {
|
|
783
|
+
return try Self.resolvePathInsideDirectory(baseDirectory: destUnZip, relativePath: path)
|
|
784
|
+
} catch SecurePathError.windowsPath {
|
|
785
|
+
logger.error("Unzip failed: Windows path not supported")
|
|
786
|
+
logger.debug("Invalid path: \(path)")
|
|
787
|
+
self.sendStats(action: "windows_path_fail")
|
|
788
|
+
throw CustomError.cannotUnzip
|
|
789
|
+
} catch {
|
|
790
|
+
self.sendStats(action: "canonical_path_fail")
|
|
791
|
+
throw CustomError.cannotUnzip
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
|
|
796
|
+
let fileManager = FileManager.default
|
|
797
|
+
|
|
798
|
+
switch entry.type {
|
|
799
|
+
case .directory:
|
|
800
|
+
try fileManager.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
|
|
801
|
+
case .file:
|
|
802
|
+
let parentDir = destPath.deletingLastPathComponent()
|
|
803
|
+
try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
804
|
+
|
|
805
|
+
if fileManager.fileExists(atPath: destPath.path) {
|
|
806
|
+
try fileManager.removeItem(at: destPath)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
guard fileManager.createFile(atPath: destPath.path, contents: nil) else {
|
|
810
|
+
throw CustomError.cannotUnzip
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
let fileHandle = try FileHandle(forWritingTo: destPath)
|
|
814
|
+
defer {
|
|
815
|
+
fileHandle.closeFile()
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
_ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
|
|
819
|
+
if !data.isEmpty {
|
|
820
|
+
fileHandle.write(data)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
case .symlink:
|
|
824
|
+
var linkData = Data()
|
|
825
|
+
_ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
|
|
826
|
+
linkData.append(data)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
guard let linkPath = String(data: linkData, encoding: .utf8) else {
|
|
830
|
+
throw CustomError.cannotUnzip
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
let parentDir = destPath.deletingLastPathComponent()
|
|
834
|
+
try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
835
|
+
|
|
836
|
+
let isAbsolutePath = (linkPath as NSString).isAbsolutePath
|
|
837
|
+
let linkURL = URL(fileURLWithPath: linkPath, relativeTo: isAbsolutePath ? nil : parentDir)
|
|
838
|
+
let canonicalPath = linkURL.standardizedFileURL.path
|
|
839
|
+
let canonicalDir = parentDir.standardizedFileURL.path
|
|
840
|
+
let normalizedDir = canonicalDir.hasSuffix("/") ? canonicalDir : "\(canonicalDir)/"
|
|
841
|
+
|
|
842
|
+
if canonicalPath != canonicalDir && !canonicalPath.hasPrefix(normalizedDir) {
|
|
843
|
+
throw CustomError.cannotUnzip
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if fileManager.fileExists(atPath: destPath.path) {
|
|
847
|
+
try fileManager.removeItem(at: destPath)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
try fileManager.createSymbolicLink(atPath: destPath.path, withDestinationPath: linkPath)
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
|
|
855
|
+
try prepareFolder(source: base)
|
|
856
|
+
let destPersist: URL = base.appendingPathComponent(id)
|
|
857
|
+
let destUnZip: URL = libraryDir.appendingPathComponent(TEMP_UNZIP_PREFIX + randomString(length: 10))
|
|
858
|
+
|
|
859
|
+
self.unzipPercent = 0
|
|
860
|
+
self.notifyDownload(id: id, percent: 75)
|
|
861
|
+
|
|
862
|
+
// Open the archive
|
|
863
|
+
let archive: Archive
|
|
864
|
+
do {
|
|
865
|
+
archive = try Archive(url: sourceZip, accessMode: .read)
|
|
866
|
+
} catch {
|
|
867
|
+
self.sendStats(action: "unzip_fail")
|
|
868
|
+
throw CustomError.cannotUnzip
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Create destination directory
|
|
872
|
+
try FileManager.default.createDirectory(at: destUnZip, withIntermediateDirectories: true, attributes: nil)
|
|
873
|
+
|
|
874
|
+
// Count total entries for progress
|
|
875
|
+
let totalEntries = archive.reduce(0) { count, _ in count + 1 }
|
|
876
|
+
var processedEntries = 0
|
|
877
|
+
|
|
878
|
+
do {
|
|
879
|
+
for entry in archive {
|
|
880
|
+
let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)
|
|
881
|
+
|
|
882
|
+
if entry.type == .directory {
|
|
883
|
+
try FileManager.default.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
|
|
884
|
+
processedEntries += 1
|
|
885
|
+
if notify && totalEntries > 0 {
|
|
886
|
+
let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
|
|
887
|
+
if newPercent != self.unzipPercent {
|
|
888
|
+
self.unzipPercent = newPercent
|
|
889
|
+
self.notifyDownload(id: id, percent: newPercent)
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
continue
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Create parent directories if needed
|
|
896
|
+
let parentDir = destPath.deletingLastPathComponent()
|
|
897
|
+
if !FileManager.default.fileExists(atPath: parentDir.path) {
|
|
898
|
+
try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
try self.extractZipEntry(archive, entry: entry, to: destPath, bufferSize: bufferSize)
|
|
902
|
+
|
|
903
|
+
// Update progress
|
|
904
|
+
processedEntries += 1
|
|
905
|
+
if notify && totalEntries > 0 {
|
|
906
|
+
let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
|
|
907
|
+
if newPercent != self.unzipPercent {
|
|
908
|
+
self.unzipPercent = newPercent
|
|
909
|
+
self.notifyDownload(id: id, percent: newPercent)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
} catch {
|
|
914
|
+
self.sendStats(action: "unzip_fail")
|
|
915
|
+
try? FileManager.default.removeItem(at: destUnZip)
|
|
916
|
+
throw error
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if try unflatFolder(source: destUnZip, dest: destPersist) {
|
|
920
|
+
try deleteFolder(source: destUnZip)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Cleanup: remove the downloaded/decrypted zip after successful extraction
|
|
924
|
+
do {
|
|
925
|
+
if FileManager.default.fileExists(atPath: sourceZip.path) {
|
|
926
|
+
try FileManager.default.removeItem(at: sourceZip)
|
|
927
|
+
}
|
|
928
|
+
} catch {
|
|
929
|
+
logger.error("Could not delete source zip")
|
|
930
|
+
logger.debug("Path: \(sourceZip.path), Error: \(error)")
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private func populateDeltaCacheAsync(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
|
|
935
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
936
|
+
self?.populateDeltaCache(for: id, manifest: manifest, sessionKey: sessionKey)
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
struct ManifestLookupEntry {
|
|
941
|
+
let hash: String
|
|
942
|
+
/// The manifest's own file name, `.br` suffix included when present. The
|
|
943
|
+
/// built-in bundle stores files under this exact name (see
|
|
944
|
+
/// isManifestEntryAvailableLocally), unlike the extracted/cached copy which
|
|
945
|
+
/// is always named without the suffix.
|
|
946
|
+
let originalFileName: String
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/// Keys are stripped of the `.br` suffix so they match the extracted file names on
|
|
950
|
+
/// disk, not the manifest's (possibly brotli-compressed) original file names.
|
|
951
|
+
func manifestHashLookup(manifest: [ManifestEntry]?, sessionKey: String) -> [String: ManifestLookupEntry] {
|
|
952
|
+
guard let manifest else {
|
|
953
|
+
return [:]
|
|
954
|
+
}
|
|
955
|
+
var lookup: [String: ManifestLookupEntry] = [:]
|
|
956
|
+
for entry in manifest {
|
|
957
|
+
guard let fileName = entry.file_name,
|
|
958
|
+
let hash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
|
|
959
|
+
continue
|
|
960
|
+
}
|
|
961
|
+
let destFileName = fileName.hasSuffix(".br") ? String(fileName.dropLast(3)) : fileName
|
|
962
|
+
lookup[destFileName] = ManifestLookupEntry(hash: hash, originalFileName: fileName)
|
|
963
|
+
}
|
|
964
|
+
return lookup
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/// `manifest` must only contain entries the caller already checksum-verified
|
|
968
|
+
/// (as `downloadManifest` does) — the hashes are trusted as-is, not re-checked.
|
|
969
|
+
func populateDeltaCache(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
|
|
970
|
+
let bundleDir = self.getBundleDirectory(id: id)
|
|
971
|
+
let fileManager = FileManager.default
|
|
972
|
+
|
|
973
|
+
guard fileManager.fileExists(atPath: bundleDir.path) else {
|
|
974
|
+
logger.debug("Skip delta cache population: bundle dir missing")
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
do {
|
|
979
|
+
try fileManager.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
|
|
980
|
+
} catch {
|
|
981
|
+
logger.debug("Skip delta cache population: failed to create cache dir")
|
|
982
|
+
return
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
guard let enumerator = fileManager.enumerator(at: bundleDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else {
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
let knownEntries = manifestHashLookup(manifest: manifest, sessionKey: sessionKey)
|
|
990
|
+
let builtinFolder = self.builtinFolderURL()
|
|
991
|
+
|
|
992
|
+
for case let fileURL as URL in enumerator {
|
|
993
|
+
let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey])
|
|
994
|
+
if resourceValues?.isDirectory == true {
|
|
995
|
+
continue
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
let relativePath = String(fileURL.path.dropFirst(bundleDir.path.count + 1))
|
|
999
|
+
let knownEntry = knownEntries[relativePath]
|
|
1000
|
+
|
|
1001
|
+
let checksum = knownEntry?.hash ?? CryptoCipher.calcChecksum(filePath: fileURL)
|
|
1002
|
+
if checksum.isEmpty {
|
|
1003
|
+
continue
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
|
|
1007
|
+
// so there's no need to also duplicate this file into the delta cache
|
|
1008
|
+
let builtinRelativePath = knownEntry?.originalFileName ?? relativePath
|
|
1009
|
+
let builtinFilePath = builtinFolder.appendingPathComponent(builtinRelativePath)
|
|
1010
|
+
let isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
|
|
1011
|
+
verifyChecksum(file: builtinFilePath, expectedHash: checksum)
|
|
1012
|
+
if isBuiltinOrigin {
|
|
1013
|
+
continue
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
let cacheFile = cacheFolder.appendingPathComponent("\(checksum)_\(fileURL.lastPathComponent)")
|
|
1017
|
+
if fileManager.fileExists(atPath: cacheFile.path) {
|
|
1018
|
+
continue
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
do {
|
|
1022
|
+
try copyItemAtomically(from: fileURL, to: cacheFile)
|
|
1023
|
+
} catch {
|
|
1024
|
+
logger.debug("Delta cache copy failed: \(fileURL.path)")
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
|
|
1030
|
+
return InfoObject(
|
|
1031
|
+
platform: "ios",
|
|
1032
|
+
device_id: self.deviceID,
|
|
1033
|
+
app_id: appIdOverride ?? self.appId,
|
|
1034
|
+
custom_id: self.customId,
|
|
1035
|
+
version_build: self.versionBuild,
|
|
1036
|
+
version_code: self.versionCode,
|
|
1037
|
+
version_os: self.versionOs,
|
|
1038
|
+
version_name: self.getCurrentBundle().getVersionName(),
|
|
1039
|
+
plugin_version: self.pluginVersion,
|
|
1040
|
+
is_emulator: self.isEmulator(),
|
|
1041
|
+
is_prod: self.isProd(),
|
|
1042
|
+
installSource: self.installSource(),
|
|
1043
|
+
action: nil,
|
|
1044
|
+
channel: nil,
|
|
1045
|
+
defaultChannel: self.defaultChannel,
|
|
1046
|
+
key_id: self.cachedKeyId
|
|
1047
|
+
)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
|
|
1051
|
+
let latest: AppVersion = AppVersion()
|
|
1052
|
+
if isRemoteBlocked() {
|
|
1053
|
+
let blocked = remoteBlockedClientError()
|
|
1054
|
+
logger.debug("Skipping getLatest due to remote block (\(blocked.error)).")
|
|
1055
|
+
latest.message = blocked.message
|
|
1056
|
+
latest.error = blocked.error
|
|
1057
|
+
latest.kind = "failed"
|
|
1058
|
+
return latest
|
|
1059
|
+
}
|
|
1060
|
+
func applyLatestResponse(_ value: AppVersionDec?) {
|
|
1061
|
+
if let url = value?.url {
|
|
1062
|
+
latest.url = url
|
|
1063
|
+
}
|
|
1064
|
+
if let checksum = value?.checksum {
|
|
1065
|
+
latest.checksum = checksum
|
|
1066
|
+
}
|
|
1067
|
+
if let version = value?.version {
|
|
1068
|
+
latest.version = version
|
|
1069
|
+
}
|
|
1070
|
+
if let major = value?.major {
|
|
1071
|
+
latest.major = major
|
|
1072
|
+
}
|
|
1073
|
+
if let breaking = value?.breaking {
|
|
1074
|
+
latest.breaking = breaking
|
|
1075
|
+
}
|
|
1076
|
+
if let error = value?.error {
|
|
1077
|
+
latest.error = error
|
|
1078
|
+
}
|
|
1079
|
+
if let kind = value?.kind {
|
|
1080
|
+
latest.kind = kind
|
|
1081
|
+
}
|
|
1082
|
+
if let message = value?.message {
|
|
1083
|
+
latest.message = message
|
|
1084
|
+
}
|
|
1085
|
+
if let sessionKey = value?.session_key {
|
|
1086
|
+
latest.sessionKey = sessionKey
|
|
1087
|
+
}
|
|
1088
|
+
if let data = value?.data {
|
|
1089
|
+
latest.data = data
|
|
1090
|
+
}
|
|
1091
|
+
if let manifest = value?.manifest {
|
|
1092
|
+
latest.manifest = manifest
|
|
1093
|
+
}
|
|
1094
|
+
if let link = value?.link {
|
|
1095
|
+
latest.link = link
|
|
1096
|
+
}
|
|
1097
|
+
if let comment = value?.comment {
|
|
1098
|
+
latest.comment = comment
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
|
|
1103
|
+
if let channel = channel {
|
|
1104
|
+
parameters.defaultChannel = channel
|
|
1105
|
+
}
|
|
1106
|
+
guard let request = createRequest(url: url, method: "POST", parameters: parameters.toParameters()) else {
|
|
1107
|
+
latest.message = "Error getting Latest"
|
|
1108
|
+
latest.error = "request_error"
|
|
1109
|
+
latest.kind = "failed"
|
|
1110
|
+
return latest
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
let result = performRequest(request, label: "getLatest")
|
|
1114
|
+
latest.statusCode = result.response?.statusCode ?? 0
|
|
1115
|
+
|
|
1116
|
+
if result.timedOut {
|
|
1117
|
+
latest.message = "Error getting Latest"
|
|
1118
|
+
latest.error = "timeout_error"
|
|
1119
|
+
latest.kind = "failed"
|
|
1120
|
+
return latest
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if let error = result.error {
|
|
1124
|
+
self.logger.error("Error getting latest version")
|
|
1125
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
1126
|
+
latest.message = "Error getting Latest"
|
|
1127
|
+
latest.error = "response_error"
|
|
1128
|
+
latest.kind = "failed"
|
|
1129
|
+
return latest
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
guard let data = result.data else {
|
|
1133
|
+
self.logger.error("Missing latest version response data")
|
|
1134
|
+
latest.message = "Error getting Latest"
|
|
1135
|
+
latest.error = "response_error"
|
|
1136
|
+
latest.kind = "failed"
|
|
1137
|
+
return latest
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
1141
|
+
statusCode: latest.statusCode,
|
|
1142
|
+
data: data,
|
|
1143
|
+
response: result.response
|
|
1144
|
+
)
|
|
1145
|
+
if rateLimit.blocked {
|
|
1146
|
+
latest.message = rateLimit.message
|
|
1147
|
+
latest.error = rateLimit.error
|
|
1148
|
+
latest.kind = "failed"
|
|
1149
|
+
return latest
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
guard let responseValue = try? JSONDecoder().decode(AppVersionDec.self, from: data) else {
|
|
1153
|
+
self.logger.error("Error decoding latest version")
|
|
1154
|
+
latest.message = "Error getting Latest"
|
|
1155
|
+
latest.error = "decode_error"
|
|
1156
|
+
latest.kind = "failed"
|
|
1157
|
+
return latest
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
applyLatestResponse(responseValue)
|
|
1161
|
+
|
|
1162
|
+
if latest.statusCode < 200 || latest.statusCode >= 300 {
|
|
1163
|
+
if latest.message == nil || latest.message?.isEmpty == true {
|
|
1164
|
+
latest.message = responseValue.message ?? "Server error: \(latest.statusCode)"
|
|
1165
|
+
}
|
|
1166
|
+
if latest.error == nil || latest.error?.isEmpty == true {
|
|
1167
|
+
latest.error = responseValue.error ?? "response_error"
|
|
1168
|
+
}
|
|
1169
|
+
if latest.kind == nil || latest.kind?.isEmpty == true {
|
|
1170
|
+
latest.kind = responseValue.kind ?? "failed"
|
|
1171
|
+
}
|
|
1172
|
+
return latest
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return latest
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
private func setCurrentBundle(bundle: String) {
|
|
1179
|
+
UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
|
|
1180
|
+
UserDefaults.standard.synchronize()
|
|
1181
|
+
logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
static func shouldResetForForeignBundle(bundlePath: String?, isBuiltin: Bool, hasStoredBundleInfo: Bool) -> Bool {
|
|
1185
|
+
guard let bundlePath, !bundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
1186
|
+
return false
|
|
1187
|
+
}
|
|
1188
|
+
return !isBuiltin && !hasStoredBundleInfo
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private func hasStoredBundleInfo(id: String) -> Bool {
|
|
1192
|
+
guard !id.isEmpty,
|
|
1193
|
+
id != BundleInfo.ID_BUILTIN,
|
|
1194
|
+
id != BundleInfo.VERSION_UNKNOWN else {
|
|
1195
|
+
return false
|
|
1196
|
+
}
|
|
1197
|
+
return UserDefaults.standard.object(forKey: "\(id)\(self.INFO_SUFFIX)") != nil
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Per-download temp file paths to prevent collisions when multiple downloads run concurrently
|
|
1201
|
+
private func tempDataPath(for id: String) -> URL {
|
|
1202
|
+
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package_\(id).tmp")
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
private func updateInfoPath(for id: String) -> URL {
|
|
1206
|
+
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update_\(id).dat")
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private var tempData = Data()
|
|
1210
|
+
|
|
1211
|
+
private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
|
|
1212
|
+
let actualHash = CryptoCipher.calcChecksum(filePath: file)
|
|
1213
|
+
return actualHash == expectedHash
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/// Overridable so tests can point it at a writable directory instead of the
|
|
1217
|
+
/// real (read-only) app bundle.
|
|
1218
|
+
func builtinFolderURL() -> URL {
|
|
1219
|
+
Bundle.main.bundleURL.appendingPathComponent("public")
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
|
|
1223
|
+
guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
|
|
1224
|
+
return nil
|
|
1225
|
+
}
|
|
1226
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
1227
|
+
do {
|
|
1228
|
+
fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
1229
|
+
} catch {
|
|
1230
|
+
logger.error("Checksum decryption failed while checking missing manifest files")
|
|
1231
|
+
logger.debug("File: \(entry.file_name ?? "unknown"), Error: \(error.localizedDescription)")
|
|
1232
|
+
return nil
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return fileHash
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
private func isManifestEntryAvailableLocally(entry: ManifestEntry, sessionKey: String) -> Bool {
|
|
1239
|
+
guard let fileName = entry.file_name,
|
|
1240
|
+
let fileHash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
|
|
1241
|
+
return false
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
let builtinFolder = self.builtinFolderURL()
|
|
1245
|
+
let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
|
|
1246
|
+
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
1247
|
+
return true
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
1251
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
1252
|
+
let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
|
|
1253
|
+
if Self.isSafeCacheHash(fileHash) {
|
|
1254
|
+
let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
|
|
1255
|
+
// Cache files are named `{hash}_{filename}` and were checksum-verified
|
|
1256
|
+
// when written. Re-hashing every hit re-reads the whole bundle and
|
|
1257
|
+
// OOMs/janks low-RAM devices during getMissing / delta apply.
|
|
1258
|
+
if isReusableCacheFile(cacheFilePath, expectedHash: fileHash) {
|
|
1259
|
+
return true
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if isBrotli {
|
|
1263
|
+
let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
|
|
1264
|
+
if isReusableCacheFile(legacyCacheFilePath, expectedHash: fileHash) {
|
|
1265
|
+
return true
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
return false
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/// SHA-256 hash-named cache files were verified when written. Existence is
|
|
1274
|
+
/// enough for non-empty files; empty files are reused only for the empty SHA-256.
|
|
1275
|
+
/// CRC32 (8 hex) is too collision-prone to trust without a re-read.
|
|
1276
|
+
private func isReusableCacheFile(_ url: URL, expectedHash: String) -> Bool {
|
|
1277
|
+
guard Self.isSafeCacheHash(expectedHash), expectedHash.count == 64 else {
|
|
1278
|
+
return false
|
|
1279
|
+
}
|
|
1280
|
+
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
|
|
1281
|
+
if size > 0 {
|
|
1282
|
+
return true
|
|
1283
|
+
}
|
|
1284
|
+
return size == 0 && expectedHash.lowercased() == Self.emptySha256
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
static func isSafeCacheHash(_ hash: String) -> Bool {
|
|
1288
|
+
let count = hash.count
|
|
1289
|
+
guard count == 64 || count == 8 else {
|
|
1290
|
+
return false
|
|
1291
|
+
}
|
|
1292
|
+
return hash.unicodeScalars.allSatisfy { scalar in
|
|
1293
|
+
(0x30...0x39).contains(scalar.value) ||
|
|
1294
|
+
(0x41...0x46).contains(scalar.value) ||
|
|
1295
|
+
(0x61...0x66).contains(scalar.value)
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
|
|
1300
|
+
return manifest.filter { entry in
|
|
1301
|
+
!isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
public func missingBundleFilesResult(manifest: [ManifestEntry], sessionKey: String) -> [String: Any] {
|
|
1306
|
+
let missing = getMissingBundleFiles(manifest: manifest, sessionKey: sessionKey)
|
|
1307
|
+
return [
|
|
1308
|
+
"missing": missing.map { $0.toDict() },
|
|
1309
|
+
"total": manifest.count,
|
|
1310
|
+
"missingCount": missing.count,
|
|
1311
|
+
"reusableCount": manifest.count - missing.count
|
|
1312
|
+
]
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
private func manifestSizeUrl(from updateUrl: URL) -> URL {
|
|
1316
|
+
var components = URLComponents(url: updateUrl, resolvingAgainstBaseURL: false)
|
|
1317
|
+
let path = components?.path ?? updateUrl.path
|
|
1318
|
+
let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
1319
|
+
components?.path = trimmedPath == ""
|
|
1320
|
+
? "/manifest_size"
|
|
1321
|
+
: "/\(trimmedPath)/manifest_size"
|
|
1322
|
+
components?.query = nil
|
|
1323
|
+
return components?.url ?? updateUrl.appendingPathComponent("manifest_size")
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
private func unavailableBundleSizeResult(manifest: [ManifestEntry], error: String) -> [String: Any] {
|
|
1327
|
+
return [
|
|
1328
|
+
"totalSize": 0,
|
|
1329
|
+
"knownFiles": 0,
|
|
1330
|
+
"unknownFiles": manifest.count,
|
|
1331
|
+
"files": manifest.map {
|
|
1332
|
+
var dict = $0.toDict()
|
|
1333
|
+
dict["error"] = error
|
|
1334
|
+
return dict
|
|
1335
|
+
}
|
|
1336
|
+
]
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
public func getBundleDownloadSize(updateUrl: URL, version: String?, manifest: [ManifestEntry]) -> [String: Any] {
|
|
1340
|
+
if manifest.isEmpty {
|
|
1341
|
+
return [
|
|
1342
|
+
"totalSize": 0,
|
|
1343
|
+
"knownFiles": 0,
|
|
1344
|
+
"unknownFiles": 0,
|
|
1345
|
+
"files": []
|
|
1346
|
+
]
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
var parameters = self.createInfoObject().toParameters()
|
|
1350
|
+
parameters["version"] = version ?? ""
|
|
1351
|
+
parameters["manifest"] = manifest.map { $0.toDict() }
|
|
1352
|
+
|
|
1353
|
+
guard let request = createRequest(url: manifestSizeUrl(from: updateUrl), method: "POST", parameters: parameters) else {
|
|
1354
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "request_error")
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
let result = performRequest(request, label: "getBundleDownloadSize")
|
|
1358
|
+
if result.timedOut {
|
|
1359
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "timeout_error")
|
|
1360
|
+
}
|
|
1361
|
+
if let error = result.error {
|
|
1362
|
+
logger.error("Error getting bundle download size")
|
|
1363
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
1364
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
|
|
1365
|
+
}
|
|
1366
|
+
guard let data = result.data,
|
|
1367
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
1368
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "parse_error")
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
1372
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
1373
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return json
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
private func runBeforeDownload() throws {
|
|
1380
|
+
try beforeDownload?()
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
1384
|
+
try self.runBeforeDownload()
|
|
1385
|
+
let id = self.randomString(length: 10)
|
|
1386
|
+
logger.info("downloadManifest start \(id)")
|
|
1387
|
+
let destFolder = self.getBundleDirectory(id: id)
|
|
1388
|
+
let builtinFolder = self.builtinFolderURL()
|
|
1389
|
+
|
|
1390
|
+
// Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
|
|
1391
|
+
let estimatedSize = Int64(max(manifest.count * 100 * 1024, 50 * 1024 * 1024))
|
|
1392
|
+
try checkDiskSpace(estimatedSize: estimatedSize)
|
|
1393
|
+
|
|
1394
|
+
try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
|
|
1395
|
+
cleanupOldManifestPartials()
|
|
1396
|
+
try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
|
|
1397
|
+
|
|
1398
|
+
// Create and save BundleInfo before starting the download process
|
|
1399
|
+
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
|
|
1400
|
+
self.saveBundleInfo(id: id, bundle: bundleInfo)
|
|
1401
|
+
|
|
1402
|
+
// Send stats for manifest download start
|
|
1403
|
+
self.sendStats(action: "download_manifest_start", versionName: version)
|
|
1404
|
+
|
|
1405
|
+
// Notify the start of the download process
|
|
1406
|
+
self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
|
|
1407
|
+
|
|
1408
|
+
let totalFiles = manifest.count
|
|
1409
|
+
|
|
1410
|
+
// Thread-safe counters for concurrent operations
|
|
1411
|
+
let completedFiles = AtomicCounter()
|
|
1412
|
+
let hasError = AtomicBool(initialValue: false)
|
|
1413
|
+
var downloadError: Error?
|
|
1414
|
+
let errorLock = NSLock()
|
|
1415
|
+
|
|
1416
|
+
// Create operations for each file
|
|
1417
|
+
var operations: [Operation] = []
|
|
1418
|
+
|
|
1419
|
+
for entry in manifest {
|
|
1420
|
+
guard let fileName = entry.file_name,
|
|
1421
|
+
let downloadUrl = entry.download_url else {
|
|
1422
|
+
let error = NSError(
|
|
1423
|
+
domain: "ManifestEntryError",
|
|
1424
|
+
code: 1,
|
|
1425
|
+
userInfo: [
|
|
1426
|
+
NSLocalizedDescriptionKey: "Manifest entry is missing file_name or download_url"
|
|
1427
|
+
]
|
|
1428
|
+
)
|
|
1429
|
+
errorLock.lock()
|
|
1430
|
+
if downloadError == nil {
|
|
1431
|
+
downloadError = error
|
|
1432
|
+
}
|
|
1433
|
+
errorLock.unlock()
|
|
1434
|
+
hasError.value = true
|
|
1435
|
+
logger.error("Manifest entry is missing file_name or download_url")
|
|
1436
|
+
continue
|
|
1437
|
+
}
|
|
1438
|
+
guard let entryFileHash = entry.file_hash, !entryFileHash.isEmpty else {
|
|
1439
|
+
logger.error("Missing file_hash for manifest entry: \(entry.file_name ?? "unknown")")
|
|
1440
|
+
let error = NSError(
|
|
1441
|
+
domain: "ManifestEntryError",
|
|
1442
|
+
code: 2,
|
|
1443
|
+
userInfo: [
|
|
1444
|
+
NSLocalizedDescriptionKey: "Manifest entry is missing file_hash for \(entry.file_name ?? "unknown")"
|
|
1445
|
+
]
|
|
1446
|
+
)
|
|
1447
|
+
errorLock.lock()
|
|
1448
|
+
if downloadError == nil {
|
|
1449
|
+
downloadError = error
|
|
1450
|
+
}
|
|
1451
|
+
errorLock.unlock()
|
|
1452
|
+
hasError.value = true
|
|
1453
|
+
continue
|
|
1454
|
+
}
|
|
1455
|
+
var fileHash = entryFileHash
|
|
1456
|
+
|
|
1457
|
+
// Decrypt checksum if needed (done before creating operation)
|
|
1458
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
1459
|
+
do {
|
|
1460
|
+
fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
1461
|
+
} catch {
|
|
1462
|
+
errorLock.lock()
|
|
1463
|
+
downloadError = error
|
|
1464
|
+
errorLock.unlock()
|
|
1465
|
+
hasError.value = true
|
|
1466
|
+
logger.error("Checksum decryption failed")
|
|
1467
|
+
logger.debug("Bundle: \(id), File: \(fileName), Error: \(error)")
|
|
1468
|
+
continue
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
let finalFileHash = fileHash
|
|
1473
|
+
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
1474
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
1475
|
+
let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
|
|
1476
|
+
let cacheFilePath: URL? = Self.isSafeCacheHash(finalFileHash)
|
|
1477
|
+
? cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
|
|
1478
|
+
: nil
|
|
1479
|
+
let legacyCacheFilePath: URL? = isBrotli && cacheFilePath != nil
|
|
1480
|
+
? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)")
|
|
1481
|
+
: nil
|
|
1482
|
+
|
|
1483
|
+
let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
1484
|
+
let destFilePath: URL
|
|
1485
|
+
let builtinFilePath: URL
|
|
1486
|
+
do {
|
|
1487
|
+
destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
|
|
1488
|
+
builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
|
|
1489
|
+
} catch {
|
|
1490
|
+
logger.error("Invalid manifest file path: \(fileName)")
|
|
1491
|
+
self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
|
|
1492
|
+
errorLock.lock()
|
|
1493
|
+
if downloadError == nil {
|
|
1494
|
+
downloadError = error
|
|
1495
|
+
}
|
|
1496
|
+
errorLock.unlock()
|
|
1497
|
+
hasError.value = true
|
|
1498
|
+
continue
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Create parent directories synchronously (before operations start)
|
|
1502
|
+
try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
1503
|
+
|
|
1504
|
+
let operation = BlockOperation { [weak self] in
|
|
1505
|
+
guard let self = self else { return }
|
|
1506
|
+
guard !hasError.value else { return } // Skip if error already occurred
|
|
1507
|
+
|
|
1508
|
+
do {
|
|
1509
|
+
// Try builtin first
|
|
1510
|
+
if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
|
|
1511
|
+
try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
|
|
1512
|
+
self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
1513
|
+
}
|
|
1514
|
+
// Try cache
|
|
1515
|
+
else if
|
|
1516
|
+
(cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
|
|
1517
|
+
(legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
|
|
1518
|
+
self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
|
|
1519
|
+
}
|
|
1520
|
+
// Download
|
|
1521
|
+
else {
|
|
1522
|
+
try self.downloadManifestFile(
|
|
1523
|
+
downloadUrl: downloadUrl,
|
|
1524
|
+
destFilePath: destFilePath,
|
|
1525
|
+
cacheFilePath: cacheFilePath,
|
|
1526
|
+
fileHash: finalFileHash,
|
|
1527
|
+
fileName: fileName,
|
|
1528
|
+
destFileName: destFileName,
|
|
1529
|
+
isBrotli: isBrotli,
|
|
1530
|
+
sessionKey: sessionKey,
|
|
1531
|
+
version: version,
|
|
1532
|
+
bundleId: id
|
|
1533
|
+
)
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
let completed = completedFiles.increment()
|
|
1537
|
+
let percent = self.calcTotalPercent(percent: Int((Double(completed) / Double(totalFiles)) * 100), min: 10, max: 70)
|
|
1538
|
+
self.notifyDownload(id: id, percent: percent)
|
|
1539
|
+
|
|
1540
|
+
} catch {
|
|
1541
|
+
errorLock.lock()
|
|
1542
|
+
if downloadError == nil {
|
|
1543
|
+
downloadError = error
|
|
1544
|
+
}
|
|
1545
|
+
errorLock.unlock()
|
|
1546
|
+
hasError.value = true
|
|
1547
|
+
self.logger.error("Manifest file download failed: \(fileName)")
|
|
1548
|
+
self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
operations.append(operation)
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Execute all operations concurrently and wait for completion
|
|
1556
|
+
manifestDownloadQueue.addOperations(operations, waitUntilFinished: true)
|
|
1557
|
+
|
|
1558
|
+
if hasError.value {
|
|
1559
|
+
let resolvedError = downloadError ?? NSError(
|
|
1560
|
+
domain: "ManifestDownloadError",
|
|
1561
|
+
code: 1,
|
|
1562
|
+
userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
|
|
1563
|
+
)
|
|
1564
|
+
// Update bundle status to ERROR if download failed
|
|
1565
|
+
let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
|
|
1566
|
+
self.saveBundleInfo(id: id, bundle: errorBundle)
|
|
1567
|
+
throw resolvedError
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Update bundle status to PENDING after successful download
|
|
1571
|
+
let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.storedValue)
|
|
1572
|
+
self.saveBundleInfo(id: id, bundle: updatedBundle)
|
|
1573
|
+
|
|
1574
|
+
// Send stats for manifest download complete
|
|
1575
|
+
self.sendStats(action: "download_manifest_complete", versionName: version)
|
|
1576
|
+
|
|
1577
|
+
self.notifyDownload(id: id, percent: 100, bundle: updatedBundle)
|
|
1578
|
+
logger.info("downloadManifest done \(id)")
|
|
1579
|
+
return updatedBundle
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/// Downloads a single manifest file synchronously
|
|
1583
|
+
/// Used by downloadManifest for concurrent file downloads
|
|
1584
|
+
private func downloadManifestFile(
|
|
1585
|
+
downloadUrl: String,
|
|
1586
|
+
destFilePath: URL,
|
|
1587
|
+
cacheFilePath: URL?,
|
|
1588
|
+
fileHash: String,
|
|
1589
|
+
fileName: String,
|
|
1590
|
+
destFileName: String,
|
|
1591
|
+
isBrotli: Bool,
|
|
1592
|
+
sessionKey: String,
|
|
1593
|
+
version: String,
|
|
1594
|
+
bundleId: String
|
|
1595
|
+
) throws {
|
|
1596
|
+
guard let url = URL(string: downloadUrl) else {
|
|
1597
|
+
throw NSError(
|
|
1598
|
+
domain: "ManifestDownloadError",
|
|
1599
|
+
code: 1,
|
|
1600
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid manifest download URL for file \(fileName): \(downloadUrl)"]
|
|
1601
|
+
)
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
guard var request = createRequest(url: url, method: "GET") else {
|
|
1605
|
+
throw NSError(
|
|
1606
|
+
domain: "ManifestDownloadError",
|
|
1607
|
+
code: 2,
|
|
1608
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid manifest request for file \(fileName): \(downloadUrl)"]
|
|
1609
|
+
)
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
|
|
1613
|
+
let partialURL = Self.manifestPartialURL(cacheFolder: cacheFolder, hash: fileHash, fileName: fileName)
|
|
1614
|
+
let existingBytes: Int64
|
|
1615
|
+
if FileManager.default.fileExists(atPath: partialURL.path) {
|
|
1616
|
+
existingBytes = Int64((try FileManager.default.attributesOfItem(atPath: partialURL.path)[.size] as? NSNumber)?.int64Value ?? 0)
|
|
1617
|
+
} else {
|
|
1618
|
+
existingBytes = 0
|
|
1619
|
+
}
|
|
1620
|
+
if existingBytes > 0 {
|
|
1621
|
+
request.setValue("bytes=\(existingBytes)-", forHTTPHeaderField: "Range")
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
let result = performDownloadRequest(request, label: "downloadManifestFile \(fileName)")
|
|
1625
|
+
defer {
|
|
1626
|
+
if let fileURL = result.fileURL {
|
|
1627
|
+
try? FileManager.default.removeItem(at: fileURL)
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if result.timedOut {
|
|
1632
|
+
persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
|
|
1633
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1634
|
+
throw NSError(
|
|
1635
|
+
domain: NSURLErrorDomain,
|
|
1636
|
+
code: NSURLErrorTimedOut,
|
|
1637
|
+
userInfo: [NSLocalizedDescriptionKey: "Timed out downloading manifest file \(fileName) at url \(downloadUrl)"]
|
|
1638
|
+
)
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if let error = result.error {
|
|
1642
|
+
persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
|
|
1643
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1644
|
+
self.logger.error("Manifest file download network error")
|
|
1645
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
|
|
1646
|
+
throw error
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
let statusCode = result.response?.statusCode ?? 200
|
|
1650
|
+
if statusCode == 416 && existingBytes > 0 {
|
|
1651
|
+
logger.debug("Range not satisfiable, using existing partial \(partialURL.lastPathComponent)")
|
|
1652
|
+
} else if statusCode < 200 || statusCode >= 300 {
|
|
1653
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1654
|
+
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
|
|
1655
|
+
} else {
|
|
1656
|
+
guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
|
|
1657
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1658
|
+
throw NSError(
|
|
1659
|
+
domain: "ManifestDownloadError",
|
|
1660
|
+
code: 3,
|
|
1661
|
+
userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
|
|
1662
|
+
)
|
|
1663
|
+
}
|
|
1664
|
+
try storeDownloadedFile(downloadedFileURL, at: partialURL, existingBytes: existingBytes, response: result.response)
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
guard FileManager.default.fileExists(atPath: partialURL.path) else {
|
|
1668
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1669
|
+
throw NSError(
|
|
1670
|
+
domain: "ManifestDownloadError",
|
|
1671
|
+
code: 3,
|
|
1672
|
+
userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
|
|
1673
|
+
)
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
var workURL: URL?
|
|
1677
|
+
defer {
|
|
1678
|
+
if let workURL {
|
|
1679
|
+
try? FileManager.default.removeItem(at: workURL)
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
do {
|
|
1684
|
+
var source = partialURL
|
|
1685
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
1686
|
+
let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
|
|
1687
|
+
try FileManager.default.copyItem(at: partialURL, to: work)
|
|
1688
|
+
workURL = work
|
|
1689
|
+
do {
|
|
1690
|
+
try CryptoCipher.decryptFile(filePath: work, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
1691
|
+
} catch {
|
|
1692
|
+
try? FileManager.default.removeItem(at: partialURL)
|
|
1693
|
+
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
1694
|
+
throw error
|
|
1695
|
+
}
|
|
1696
|
+
source = work
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
let calculatedChecksum: String
|
|
1700
|
+
if isBrotli {
|
|
1701
|
+
do {
|
|
1702
|
+
calculatedChecksum = try decompressBrotli(from: source, to: destFilePath, fileName: fileName)
|
|
1703
|
+
} catch {
|
|
1704
|
+
try? FileManager.default.removeItem(at: partialURL)
|
|
1705
|
+
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
|
|
1706
|
+
throw error
|
|
1707
|
+
}
|
|
1708
|
+
} else {
|
|
1709
|
+
let handle = try FileHandle(forReadingFrom: source)
|
|
1710
|
+
defer {
|
|
1711
|
+
try? handle.close()
|
|
1712
|
+
}
|
|
1713
|
+
let length = (try FileManager.default.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
|
|
1714
|
+
calculatedChecksum = try streamCopy(from: handle, count: length, to: destFilePath)
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
|
|
1718
|
+
CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
|
|
1719
|
+
if calculatedChecksum != fileHash {
|
|
1720
|
+
try? FileManager.default.removeItem(at: destFilePath)
|
|
1721
|
+
try? FileManager.default.removeItem(at: partialURL)
|
|
1722
|
+
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
1723
|
+
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
if let cacheFilePath {
|
|
1727
|
+
try copyItemAtomically(from: destFilePath, to: cacheFilePath)
|
|
1728
|
+
}
|
|
1729
|
+
try? FileManager.default.removeItem(at: partialURL)
|
|
1730
|
+
|
|
1731
|
+
self.logger.info("Manifest file downloaded and cached")
|
|
1732
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
|
|
1733
|
+
} catch {
|
|
1734
|
+
self.logger.error("Manifest file download failed")
|
|
1735
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
|
|
1736
|
+
throw error
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/// Copy a file to the destination, replacing any existing file.
|
|
1741
|
+
private func copyItemReplacing(from source: URL, to destination: URL) throws {
|
|
1742
|
+
let fileManager = FileManager.default
|
|
1743
|
+
try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
1744
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
1745
|
+
try fileManager.removeItem(at: destination)
|
|
1746
|
+
}
|
|
1747
|
+
try fileManager.copyItem(at: source, to: destination)
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
func copyMatchingBuiltinFilesForTests(files: [(source: URL, dest: URL, hash: String)]) throws {
|
|
1751
|
+
let queue = OperationQueue()
|
|
1752
|
+
queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
|
|
1753
|
+
let lock = NSLock()
|
|
1754
|
+
var firstError: Error?
|
|
1755
|
+
for file in files {
|
|
1756
|
+
queue.addOperation { [weak self] in
|
|
1757
|
+
guard let self else { return }
|
|
1758
|
+
do {
|
|
1759
|
+
guard self.verifyChecksum(file: file.source, expectedHash: file.hash) else {
|
|
1760
|
+
throw NSError(
|
|
1761
|
+
domain: "CapgoInstallPerf",
|
|
1762
|
+
code: 1,
|
|
1763
|
+
userInfo: [NSLocalizedDescriptionKey: "builtin checksum mismatch"]
|
|
1764
|
+
)
|
|
1765
|
+
}
|
|
1766
|
+
try self.copyItemReplacing(from: file.source, to: file.dest)
|
|
1767
|
+
} catch {
|
|
1768
|
+
lock.lock()
|
|
1769
|
+
if firstError == nil {
|
|
1770
|
+
firstError = error
|
|
1771
|
+
}
|
|
1772
|
+
lock.unlock()
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
queue.waitUntilAllOperationsAreFinished()
|
|
1777
|
+
if let firstError {
|
|
1778
|
+
throw firstError
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/// Copy via a unique temp name then rename, so a crash cannot leave a
|
|
1783
|
+
/// non-empty partial file that `isReusableCacheFile` would trust.
|
|
1784
|
+
private func copyItemAtomically(from source: URL, to destination: URL) throws {
|
|
1785
|
+
let fileManager = FileManager.default
|
|
1786
|
+
try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
1787
|
+
let tempURL = destination.deletingLastPathComponent().appendingPathComponent("\(destination.lastPathComponent).\(UUID().uuidString).tmp")
|
|
1788
|
+
defer {
|
|
1789
|
+
try? fileManager.removeItem(at: tempURL)
|
|
1790
|
+
}
|
|
1791
|
+
try fileManager.copyItem(at: source, to: tempURL)
|
|
1792
|
+
try replaceItemAtomically(at: destination, withItemAt: tempURL)
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/// One-step replace when dest exists, move when it does not. Avoids the
|
|
1796
|
+
/// fileExists/removeItem race that can fail a verified install.
|
|
1797
|
+
private func replaceItemAtomically(at destination: URL, withItemAt tempURL: URL) throws {
|
|
1798
|
+
let fileManager = FileManager.default
|
|
1799
|
+
do {
|
|
1800
|
+
_ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
|
|
1801
|
+
} catch {
|
|
1802
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
1803
|
+
throw error
|
|
1804
|
+
}
|
|
1805
|
+
try fileManager.moveItem(at: tempURL, to: destination)
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
|
|
1810
|
+
/// This handles the race condition where OS can delete cache files between exists() check and copy
|
|
1811
|
+
private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
|
|
1812
|
+
// First quick check - if file doesn't exist or was truncated, don't bother
|
|
1813
|
+
guard isReusableCacheFile(source, expectedHash: expectedHash) else {
|
|
1814
|
+
return false
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// Hash is in the cache file name and was verified when written.
|
|
1818
|
+
// Re-hashing here would re-read every reused file on low-RAM devices.
|
|
1819
|
+
do {
|
|
1820
|
+
try copyItemAtomically(from: source, to: destination)
|
|
1821
|
+
return true
|
|
1822
|
+
} catch {
|
|
1823
|
+
// File was deleted between check and copy, or other IO error - caller should download instead
|
|
1824
|
+
logger.debug("Cache copy failed (likely OS eviction): \(error.localizedDescription)")
|
|
1825
|
+
return false
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/// Stream Brotli from disk to disk. Peek only the 3-byte header and last byte
|
|
1830
|
+
/// for the empty/wrapper special cases; never load the whole file.
|
|
1831
|
+
func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws -> String {
|
|
1832
|
+
let fileManager = FileManager.default
|
|
1833
|
+
try fileManager.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
1834
|
+
let length = (try fileManager.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
|
|
1835
|
+
if length == 0 {
|
|
1836
|
+
try Data().write(to: dest, options: .atomic)
|
|
1837
|
+
return CryptoCipher.RunningChecksum().hex()
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
let handle = try FileHandle(forReadingFrom: source)
|
|
1841
|
+
defer {
|
|
1842
|
+
try? handle.close()
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
let head = try handle.read(upToCount: 3) ?? Data()
|
|
1846
|
+
var last: UInt8 = 0
|
|
1847
|
+
if length >= 1 {
|
|
1848
|
+
try handle.seek(toOffset: length - 1)
|
|
1849
|
+
last = try handle.read(upToCount: 1)?.first ?? 0
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
if length == 3 && head.count == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06 {
|
|
1853
|
+
try Data().write(to: dest, options: .atomic)
|
|
1854
|
+
return CryptoCipher.RunningChecksum().hex()
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
if length > 3 && head.count == 3 && last == 0x03 {
|
|
1858
|
+
let isEmptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06
|
|
1859
|
+
let isQualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == 0x80
|
|
1860
|
+
if isEmptyWrapper || isQualityZeroWrapper {
|
|
1861
|
+
try handle.seek(toOffset: 3)
|
|
1862
|
+
return try streamCopy(from: handle, count: length - 4, to: dest)
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
try handle.seek(toOffset: 0)
|
|
1867
|
+
return try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws -> String {
|
|
1871
|
+
let fileManager = FileManager.default
|
|
1872
|
+
let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
|
|
1873
|
+
fileManager.createFile(atPath: tempURL.path, contents: nil)
|
|
1874
|
+
let output = try FileHandle(forWritingTo: tempURL)
|
|
1875
|
+
defer {
|
|
1876
|
+
try? output.close()
|
|
1877
|
+
try? fileManager.removeItem(at: tempURL)
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
let hasher = CryptoCipher.RunningChecksum()
|
|
1881
|
+
var remaining = count
|
|
1882
|
+
let chunkSize = CryptoCipher.ioBufferBytes()
|
|
1883
|
+
while remaining > 0 {
|
|
1884
|
+
let readCount: Int = try autoreleasepool {
|
|
1885
|
+
let toRead = Int(min(UInt64(chunkSize), remaining))
|
|
1886
|
+
let chunk = try handle.read(upToCount: toRead) ?? Data()
|
|
1887
|
+
if !chunk.isEmpty {
|
|
1888
|
+
hasher.update(chunk)
|
|
1889
|
+
try output.write(contentsOf: chunk)
|
|
1890
|
+
}
|
|
1891
|
+
return chunk.count
|
|
1892
|
+
}
|
|
1893
|
+
if readCount == 0 {
|
|
1894
|
+
break
|
|
1895
|
+
}
|
|
1896
|
+
remaining -= UInt64(readCount)
|
|
1897
|
+
}
|
|
1898
|
+
try output.close()
|
|
1899
|
+
try replaceItemAtomically(at: dest, withItemAt: tempURL)
|
|
1900
|
+
return hasher.hex()
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws -> String {
|
|
1904
|
+
let fileManager = FileManager.default
|
|
1905
|
+
let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
|
|
1906
|
+
fileManager.createFile(atPath: tempURL.path, contents: nil)
|
|
1907
|
+
let output = try FileHandle(forWritingTo: tempURL)
|
|
1908
|
+
defer {
|
|
1909
|
+
try? output.close()
|
|
1910
|
+
try? fileManager.removeItem(at: tempURL)
|
|
1911
|
+
}
|
|
1912
|
+
let hasher = CryptoCipher.RunningChecksum()
|
|
1913
|
+
|
|
1914
|
+
let chunkSize = max(CryptoCipher.ioBufferBytes(), 65536)
|
|
1915
|
+
var inputBuffer = [UInt8](repeating: 0, count: chunkSize)
|
|
1916
|
+
var outputBuffer = [UInt8](repeating: 0, count: chunkSize)
|
|
1917
|
+
|
|
1918
|
+
let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
|
|
1919
|
+
var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
|
|
1920
|
+
guard status != COMPRESSION_STATUS_ERROR else {
|
|
1921
|
+
logger.error("Failed to initialize Brotli stream")
|
|
1922
|
+
logger.debug("File: \(fileName), Status: \(status)")
|
|
1923
|
+
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize Brotli stream for \(fileName)"])
|
|
1924
|
+
}
|
|
1925
|
+
defer {
|
|
1926
|
+
compression_stream_destroy(streamPointer)
|
|
1927
|
+
streamPointer.deallocate()
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
try inputBuffer.withUnsafeMutableBufferPointer { inBuf in
|
|
1931
|
+
try outputBuffer.withUnsafeMutableBufferPointer { outBuf in
|
|
1932
|
+
guard let inBase = inBuf.baseAddress, let outBase = outBuf.baseAddress else {
|
|
1933
|
+
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to get buffer address for \(fileName)"])
|
|
1934
|
+
}
|
|
1935
|
+
streamPointer.pointee.src_size = 0
|
|
1936
|
+
streamPointer.pointee.dst_ptr = outBase
|
|
1937
|
+
streamPointer.pointee.dst_size = chunkSize
|
|
1938
|
+
|
|
1939
|
+
var flags: Int32 = 0
|
|
1940
|
+
var inputExhausted = false
|
|
1941
|
+
while true {
|
|
1942
|
+
if streamPointer.pointee.src_size == 0 && !inputExhausted {
|
|
1943
|
+
let chunk = try handle.read(upToCount: chunkSize) ?? Data()
|
|
1944
|
+
if chunk.isEmpty {
|
|
1945
|
+
inputExhausted = true
|
|
1946
|
+
flags = Int32(bitPattern: COMPRESSION_STREAM_FINALIZE.rawValue)
|
|
1947
|
+
} else {
|
|
1948
|
+
chunk.copyBytes(to: inBase, count: chunk.count)
|
|
1949
|
+
streamPointer.pointee.src_ptr = UnsafePointer(inBase)
|
|
1950
|
+
streamPointer.pointee.src_size = chunk.count
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
status = compression_stream_process(streamPointer, flags)
|
|
1955
|
+
let have = chunkSize - streamPointer.pointee.dst_size
|
|
1956
|
+
if have > 0 {
|
|
1957
|
+
let decoded = Data(bytes: outBase, count: have)
|
|
1958
|
+
hasher.update(decoded)
|
|
1959
|
+
try output.write(contentsOf: decoded)
|
|
1960
|
+
}
|
|
1961
|
+
streamPointer.pointee.dst_ptr = outBase
|
|
1962
|
+
streamPointer.pointee.dst_size = chunkSize
|
|
1963
|
+
|
|
1964
|
+
if status == COMPRESSION_STATUS_END {
|
|
1965
|
+
break
|
|
1966
|
+
}
|
|
1967
|
+
if status == COMPRESSION_STATUS_ERROR {
|
|
1968
|
+
logger.error("Brotli process failed")
|
|
1969
|
+
logger.debug("File: \(fileName), Status: \(status)")
|
|
1970
|
+
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
|
|
1971
|
+
}
|
|
1972
|
+
if inputExhausted && streamPointer.pointee.src_size == 0 && have == 0 {
|
|
1973
|
+
logger.error("Brotli decompression stalled")
|
|
1974
|
+
logger.debug("File: \(fileName)")
|
|
1975
|
+
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
try output.close()
|
|
1982
|
+
try replaceItemAtomically(at: dest, withItemAt: tempURL)
|
|
1983
|
+
return hasher.hex()
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
1987
|
+
try self.runBeforeDownload()
|
|
1988
|
+
let id: String = self.randomString(length: 10)
|
|
1989
|
+
// Each download uses its own temp files keyed by bundle ID to prevent collisions
|
|
1990
|
+
if version != getLocalUpdateVersion(for: id) {
|
|
1991
|
+
cleanDownloadData(for: id)
|
|
1992
|
+
}
|
|
1993
|
+
ensureResumableFilesExist(for: id)
|
|
1994
|
+
saveDownloadInfo(version, for: id)
|
|
1995
|
+
|
|
1996
|
+
// Check disk space before starting download (matches Android behavior)
|
|
1997
|
+
try checkDiskSpace()
|
|
1998
|
+
|
|
1999
|
+
var checksum = ""
|
|
2000
|
+
var lastSentProgress = 0
|
|
2001
|
+
let totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
|
|
2002
|
+
let tempPath = tempDataPath(for: id)
|
|
2003
|
+
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
|
|
2004
|
+
self.saveBundleInfo(id: id, bundle: bundleInfo)
|
|
2005
|
+
|
|
2006
|
+
// Send stats for zip download start
|
|
2007
|
+
self.sendStats(action: "download_zip_start", versionName: version)
|
|
2008
|
+
|
|
2009
|
+
// Opening connection for streaming the bytes
|
|
2010
|
+
if totalReceivedBytes == 0 {
|
|
2011
|
+
self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
|
|
2012
|
+
}
|
|
2013
|
+
var mainError: NSError?
|
|
2014
|
+
|
|
2015
|
+
guard var request = createRequest(url: url, method: "GET") else {
|
|
2016
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
2017
|
+
throw NSError(
|
|
2018
|
+
domain: "DownloadError",
|
|
2019
|
+
code: 2,
|
|
2020
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid download request for \(url.absoluteString)"]
|
|
2021
|
+
)
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
if totalReceivedBytes > 0 {
|
|
2025
|
+
request.setValue("bytes=\(totalReceivedBytes)-", forHTTPHeaderField: "Range")
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
let downloadResult = performDownloadRequest(request, label: "download \(version)")
|
|
2029
|
+
|
|
2030
|
+
if downloadResult.timedOut {
|
|
2031
|
+
persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
|
|
2032
|
+
mainError = NSError(
|
|
2033
|
+
domain: NSURLErrorDomain,
|
|
2034
|
+
code: NSURLErrorTimedOut,
|
|
2035
|
+
userInfo: [NSLocalizedDescriptionKey: "Timed out downloading bundle from \(url.absoluteString)"]
|
|
2036
|
+
)
|
|
2037
|
+
} else if let error = downloadResult.error {
|
|
2038
|
+
logger.error("Download failed")
|
|
2039
|
+
persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
|
|
2040
|
+
mainError = error as NSError
|
|
2041
|
+
} else if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
|
|
2042
|
+
logger.error("Download failed")
|
|
2043
|
+
mainError = NSError(
|
|
2044
|
+
domain: "DownloadError",
|
|
2045
|
+
code: statusCode,
|
|
2046
|
+
userInfo: [NSLocalizedDescriptionKey: "Download request failed with status code \(statusCode)"]
|
|
2047
|
+
)
|
|
2048
|
+
} else if let downloadedFileURL = downloadResult.fileURL {
|
|
2049
|
+
do {
|
|
2050
|
+
try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: totalReceivedBytes, response: downloadResult.response)
|
|
2051
|
+
|
|
2052
|
+
if lastSentProgress < 70 {
|
|
2053
|
+
self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
|
|
2054
|
+
lastSentProgress = 70
|
|
2055
|
+
}
|
|
2056
|
+
self.logger.info("Download complete")
|
|
2057
|
+
} catch let error as NSError {
|
|
2058
|
+
mainError = error
|
|
2059
|
+
} catch {
|
|
2060
|
+
mainError = error as NSError
|
|
2061
|
+
}
|
|
2062
|
+
} else {
|
|
2063
|
+
mainError = NSError(
|
|
2064
|
+
domain: "DownloadError",
|
|
2065
|
+
code: 1,
|
|
2066
|
+
userInfo: [NSLocalizedDescriptionKey: "Downloaded file is missing at \(tempPath.path)"]
|
|
2067
|
+
)
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
if mainError != nil {
|
|
2071
|
+
logger.error("Failed to download bundle")
|
|
2072
|
+
logger.debug("Error: \(String(describing: mainError))")
|
|
2073
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
2074
|
+
throw mainError!
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
|
|
2078
|
+
do {
|
|
2079
|
+
try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
2080
|
+
try FileManager.default.moveItem(at: tempPath, to: finalPath)
|
|
2081
|
+
} catch {
|
|
2082
|
+
logger.error("Failed to decrypt file")
|
|
2083
|
+
logger.debug("Error: \(error)")
|
|
2084
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
2085
|
+
cleanDownloadData(for: id)
|
|
2086
|
+
throw error
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
do {
|
|
2090
|
+
checksum = CryptoCipher.calcChecksum(filePath: finalPath)
|
|
2091
|
+
CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
|
|
2092
|
+
logger.info("Downloading: 80% (unzipping)")
|
|
2093
|
+
try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
|
|
2094
|
+
self.populateDeltaCacheAsync(for: id)
|
|
2095
|
+
|
|
2096
|
+
} catch {
|
|
2097
|
+
logger.error("Failed to unzip file")
|
|
2098
|
+
logger.debug("Error: \(error)")
|
|
2099
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
2100
|
+
// Best-effort cleanup of the decrypted zip file when unzip fails
|
|
2101
|
+
do {
|
|
2102
|
+
if FileManager.default.fileExists(atPath: finalPath.path) {
|
|
2103
|
+
try FileManager.default.removeItem(at: finalPath)
|
|
2104
|
+
}
|
|
2105
|
+
} catch {
|
|
2106
|
+
logger.error("Could not delete failed zip")
|
|
2107
|
+
logger.debug("Path: \(finalPath.path), Error: \(error)")
|
|
2108
|
+
}
|
|
2109
|
+
cleanDownloadData(for: id)
|
|
2110
|
+
throw error
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
self.notifyDownload(id: id, percent: 90)
|
|
2114
|
+
logger.info("Downloading: 90% (wrapping up)")
|
|
2115
|
+
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
|
|
2116
|
+
self.saveBundleInfo(id: id, bundle: info)
|
|
2117
|
+
self.cleanDownloadData(for: id)
|
|
2118
|
+
|
|
2119
|
+
// Send stats for zip download complete
|
|
2120
|
+
self.sendStats(action: "download_zip_complete", versionName: version)
|
|
2121
|
+
|
|
2122
|
+
self.notifyDownload(id: id, percent: 100, bundle: info)
|
|
2123
|
+
logger.info("Downloading: 100% (complete)")
|
|
2124
|
+
return info
|
|
2125
|
+
}
|
|
2126
|
+
private func ensureResumableFilesExist(for id: String) {
|
|
2127
|
+
let fileManager = FileManager.default
|
|
2128
|
+
let tempPath = tempDataPath(for: id)
|
|
2129
|
+
let infoPath = updateInfoPath(for: id)
|
|
2130
|
+
if !fileManager.fileExists(atPath: tempPath.path) {
|
|
2131
|
+
if !fileManager.createFile(atPath: tempPath.path, contents: Data()) {
|
|
2132
|
+
logger.error("Cannot ensure temp data file exists")
|
|
2133
|
+
logger.debug("Path: \(tempPath.path)")
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
if !fileManager.fileExists(atPath: infoPath.path) {
|
|
2138
|
+
if !fileManager.createFile(atPath: infoPath.path, contents: Data()) {
|
|
2139
|
+
logger.error("Cannot ensure update info file exists")
|
|
2140
|
+
logger.debug("Path: \(infoPath.path)")
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
private func cleanDownloadData(for id: String) {
|
|
2146
|
+
let fileManager = FileManager.default
|
|
2147
|
+
let tempPath = tempDataPath(for: id)
|
|
2148
|
+
let infoPath = updateInfoPath(for: id)
|
|
2149
|
+
// Deleting package_<id>.tmp
|
|
2150
|
+
if fileManager.fileExists(atPath: tempPath.path) {
|
|
2151
|
+
do {
|
|
2152
|
+
try fileManager.removeItem(at: tempPath)
|
|
2153
|
+
} catch {
|
|
2154
|
+
logger.error("Could not delete temp data file")
|
|
2155
|
+
logger.debug("Path: \(tempPath), Error: \(error)")
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
// Deleting update_<id>.dat
|
|
2159
|
+
if fileManager.fileExists(atPath: infoPath.path) {
|
|
2160
|
+
do {
|
|
2161
|
+
try fileManager.removeItem(at: infoPath)
|
|
2162
|
+
} catch {
|
|
2163
|
+
logger.error("Could not delete update info file")
|
|
2164
|
+
logger.debug("Path: \(infoPath), Error: \(error)")
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
private func savePartialData(startingAt byteOffset: UInt64, for id: String) {
|
|
2170
|
+
let fileManager = FileManager.default
|
|
2171
|
+
let tempPath = tempDataPath(for: id)
|
|
2172
|
+
do {
|
|
2173
|
+
// Check if package_<id>.tmp exist
|
|
2174
|
+
if !fileManager.fileExists(atPath: tempPath.path) {
|
|
2175
|
+
try self.tempData.write(to: tempPath, options: .atomicWrite)
|
|
2176
|
+
} else {
|
|
2177
|
+
// If yes, it start writing on it
|
|
2178
|
+
let fileHandle = try FileHandle(forWritingTo: tempPath)
|
|
2179
|
+
fileHandle.seek(toFileOffset: byteOffset) // Moving at the specified position to start writing
|
|
2180
|
+
fileHandle.write(self.tempData)
|
|
2181
|
+
fileHandle.closeFile()
|
|
2182
|
+
}
|
|
2183
|
+
} catch {
|
|
2184
|
+
logger.error("Failed to write partial data")
|
|
2185
|
+
logger.debug("Byte offset: \(byteOffset), Error: \(error)")
|
|
2186
|
+
}
|
|
2187
|
+
self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
private func saveDownloadInfo(_ version: String, for id: String) {
|
|
2191
|
+
let infoPath = updateInfoPath(for: id)
|
|
2192
|
+
do {
|
|
2193
|
+
try "\(version)".write(to: infoPath, atomically: true, encoding: .utf8)
|
|
2194
|
+
} catch {
|
|
2195
|
+
logger.error("Failed to save download progress")
|
|
2196
|
+
logger.debug("Error: \(error)")
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
private func getLocalUpdateVersion(for id: String) -> String { // Return the version that was tried to be downloaded on last download attempt
|
|
2201
|
+
let infoPath = updateInfoPath(for: id)
|
|
2202
|
+
if !FileManager.default.fileExists(atPath: infoPath.path) {
|
|
2203
|
+
return "nil"
|
|
2204
|
+
}
|
|
2205
|
+
guard let versionString = try? String(contentsOf: infoPath),
|
|
2206
|
+
let version = Optional(versionString) else {
|
|
2207
|
+
return "nil"
|
|
2208
|
+
}
|
|
2209
|
+
return version
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
private func loadDownloadProgress(for id: String) -> Int64 {
|
|
2213
|
+
let fileManager = FileManager.default
|
|
2214
|
+
let tempPath = tempDataPath(for: id)
|
|
2215
|
+
do {
|
|
2216
|
+
let attributes = try fileManager.attributesOfItem(atPath: tempPath.path)
|
|
2217
|
+
if let fileSize = attributes[.size] as? NSNumber {
|
|
2218
|
+
return fileSize.int64Value
|
|
2219
|
+
}
|
|
2220
|
+
} catch {
|
|
2221
|
+
logger.error("Could not retrieve download progress size")
|
|
2222
|
+
logger.debug("Error: \(error)")
|
|
2223
|
+
}
|
|
2224
|
+
return 0
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
public func list(raw: Bool = false) -> [BundleInfo] {
|
|
2228
|
+
if !raw {
|
|
2229
|
+
// UserDefaults.standard.dictionaryRepresentation().values
|
|
2230
|
+
let dest: URL = libraryDir.appendingPathComponent(bundleDirectory)
|
|
2231
|
+
do {
|
|
2232
|
+
let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
|
|
2233
|
+
var res: [BundleInfo] = []
|
|
2234
|
+
logger.info("list File : \(dest.path)")
|
|
2235
|
+
if dest.exist {
|
|
2236
|
+
for id: String in files {
|
|
2237
|
+
res.append(self.getBundleInfo(id: id))
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
return res
|
|
2241
|
+
} catch {
|
|
2242
|
+
logger.info("No version available \(dest.path)")
|
|
2243
|
+
return []
|
|
2244
|
+
}
|
|
2245
|
+
} else {
|
|
2246
|
+
guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
|
|
2247
|
+
logger.error("Invalid regex ?????")
|
|
2248
|
+
return []
|
|
2249
|
+
}
|
|
2250
|
+
return UserDefaults.standard.dictionaryRepresentation().keys.filter {
|
|
2251
|
+
let range = NSRange($0.startIndex..., in: $0)
|
|
2252
|
+
let matches = regex.matches(in: $0, range: range)
|
|
2253
|
+
return !matches.isEmpty
|
|
2254
|
+
}.map {
|
|
2255
|
+
$0.components(separatedBy: "_")[0]
|
|
2256
|
+
}.map {
|
|
2257
|
+
self.getBundleInfo(id: $0)
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
public func delete(id: String, removeInfo: Bool) -> Bool {
|
|
2264
|
+
self.deleteLock.lock()
|
|
2265
|
+
defer { self.deleteLock.unlock() }
|
|
2266
|
+
|
|
2267
|
+
let deleted: BundleInfo = self.getBundleInfo(id: id)
|
|
2268
|
+
if deleted.isBuiltin() || self.getCurrentBundleId() == id {
|
|
2269
|
+
logger.info("Cannot delete current or builtin bundle")
|
|
2270
|
+
logger.debug("Bundle ID: \(id)")
|
|
2271
|
+
return false
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
if let previewFallback = self.getPreviewFallbackBundle(),
|
|
2275
|
+
!previewFallback.isDeleted(),
|
|
2276
|
+
!previewFallback.isErrorStatus(),
|
|
2277
|
+
!previewFallback.isDeleting(),
|
|
2278
|
+
previewFallback.getId() == id {
|
|
2279
|
+
logger.info("Cannot delete the preview fallback bundle")
|
|
2280
|
+
logger.debug("Bundle ID: \(id)")
|
|
2281
|
+
return false
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Check if this is the next bundle and prevent deletion if it is
|
|
2285
|
+
if let next = self.getNextBundle(),
|
|
2286
|
+
!next.isDeleted() &&
|
|
2287
|
+
!next.isErrorStatus() &&
|
|
2288
|
+
!next.isDeleting() &&
|
|
2289
|
+
next.getId() == id {
|
|
2290
|
+
logger.info("Cannot delete the next bundle")
|
|
2291
|
+
logger.debug("Bundle ID: \(id)")
|
|
2292
|
+
return false
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
|
|
2296
|
+
let hadRegistry = self.hasStoredBundleInfo(id: id)
|
|
2297
|
+
let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
|
|
2298
|
+
if !hadRegistry && !hadFolder {
|
|
2299
|
+
logger.error("Cannot delete unknown bundle")
|
|
2300
|
+
logger.debug("Bundle ID: \(id)")
|
|
2301
|
+
self.dequeuePendingDelete(id: id)
|
|
2302
|
+
return false
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// Persist DELETING before touching disk so kill/OOM can resume on next launch.
|
|
2306
|
+
if !deleted.isDeleting() {
|
|
2307
|
+
if !self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETING.storedValue)) {
|
|
2308
|
+
logger.error("Failed to persist DELETING marker, aborting disk delete")
|
|
2309
|
+
logger.debug("Bundle ID: \(id)")
|
|
2310
|
+
return false
|
|
2311
|
+
}
|
|
2312
|
+
UserDefaults.standard.synchronize()
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
if FileManager.default.fileExists(atPath: destPersist.path) {
|
|
2316
|
+
do {
|
|
2317
|
+
try FileManager.default.removeItem(atPath: destPersist.path)
|
|
2318
|
+
} catch {
|
|
2319
|
+
logger.error("Bundle folder not removed, will retry later")
|
|
2320
|
+
logger.debug("Path: \(destPersist.path), Error: \(error.localizedDescription)")
|
|
2321
|
+
return false
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// Only drop registry after the folder is confirmed gone.
|
|
2326
|
+
if FileManager.default.fileExists(atPath: destPersist.path) {
|
|
2327
|
+
logger.error("Bundle folder still present after delete, will retry later")
|
|
2328
|
+
logger.debug("Bundle ID: \(id)")
|
|
2329
|
+
return false
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
let finalized: Bool
|
|
2333
|
+
if removeInfo {
|
|
2334
|
+
finalized = self.saveBundleInfo(id: id, bundle: nil)
|
|
2335
|
+
} else {
|
|
2336
|
+
finalized = self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
|
|
2337
|
+
}
|
|
2338
|
+
guard finalized else {
|
|
2339
|
+
logger.error("Failed to finalize delete registry update, will retry later")
|
|
2340
|
+
logger.debug("Bundle ID: \(id)")
|
|
2341
|
+
return false
|
|
2342
|
+
}
|
|
2343
|
+
UserDefaults.standard.synchronize()
|
|
2344
|
+
self.dequeuePendingDelete(id: id)
|
|
2345
|
+
logger.info("Bundle deleted and confirmed gone")
|
|
2346
|
+
logger.debug("Version: \(deleted.getVersionName())")
|
|
2347
|
+
self.sendStats(action: "delete", versionName: deleted.getVersionName())
|
|
2348
|
+
return true
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
public func delete(id: String) -> Bool {
|
|
2352
|
+
return self.delete(id: id, removeInfo: true)
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
/// Resume incomplete deletes one-by-one. Safe across app kill / OOM because
|
|
2356
|
+
/// delete() marks DELETING before disk work and only clears registry after confirm.
|
|
2357
|
+
public func drainPendingDeletes() {
|
|
2358
|
+
var pendingIds = Set(self.list(raw: true).filter { $0.isDeleting() }.map { $0.getId() }.filter { !$0.isEmpty })
|
|
2359
|
+
pendingIds.formUnion(self.getPendingDeleteIds())
|
|
2360
|
+
for id in pendingIds {
|
|
2361
|
+
if Thread.current.isCancelled {
|
|
2362
|
+
logger.warn("drainPendingDeletes was cancelled")
|
|
2363
|
+
return
|
|
2364
|
+
}
|
|
2365
|
+
logger.info("Resuming pending delete for bundle: \(id)")
|
|
2366
|
+
if self.delete(id: id, removeInfo: true) {
|
|
2367
|
+
self.dequeuePendingDelete(id: id)
|
|
2368
|
+
}
|
|
2369
|
+
Thread.sleep(forTimeInterval: self.deletePaceSeconds)
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
private func getPendingDeleteIds() -> Set<String> {
|
|
2374
|
+
guard let raw = UserDefaults.standard.string(forKey: self.PENDING_DELETE_IDS), !raw.isEmpty else {
|
|
2375
|
+
return []
|
|
2376
|
+
}
|
|
2377
|
+
return Set(raw.split(separator: ",").map { String($0) }.filter { !$0.isEmpty })
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
private func enqueuePendingDelete(id: String) {
|
|
2381
|
+
guard !id.isEmpty else { return }
|
|
2382
|
+
var ids = self.getPendingDeleteIds()
|
|
2383
|
+
guard ids.insert(id).inserted else { return }
|
|
2384
|
+
UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
|
|
2385
|
+
UserDefaults.standard.synchronize()
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
private func dequeuePendingDelete(id: String) {
|
|
2389
|
+
guard !id.isEmpty else { return }
|
|
2390
|
+
var ids = self.getPendingDeleteIds()
|
|
2391
|
+
guard ids.remove(id) != nil else { return }
|
|
2392
|
+
if ids.isEmpty {
|
|
2393
|
+
UserDefaults.standard.removeObject(forKey: self.PENDING_DELETE_IDS)
|
|
2394
|
+
} else {
|
|
2395
|
+
UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
|
|
2396
|
+
}
|
|
2397
|
+
UserDefaults.standard.synchronize()
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
public func cleanupDeltaCache() {
|
|
2401
|
+
cleanupDeltaCache(threadToCheck: nil)
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
public func cleanupDeltaCache(threadToCheck: Thread?) {
|
|
2405
|
+
// Check if thread was cancelled
|
|
2406
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
2407
|
+
logger.warn("cleanupDeltaCache was cancelled before starting")
|
|
2408
|
+
return
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
let fileManager = FileManager.default
|
|
2412
|
+
guard fileManager.fileExists(atPath: cacheFolder.path) else {
|
|
2413
|
+
return
|
|
2414
|
+
}
|
|
2415
|
+
do {
|
|
2416
|
+
try fileManager.removeItem(at: cacheFolder)
|
|
2417
|
+
logger.info("Cleaned up delta cache folder")
|
|
2418
|
+
} catch {
|
|
2419
|
+
logger.error("Failed to cleanup delta cache")
|
|
2420
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
public func cleanupDownloadDirectories(allowedIds: Set<String>) {
|
|
2425
|
+
cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
|
|
2429
|
+
let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
|
|
2430
|
+
let fileManager = FileManager.default
|
|
2431
|
+
|
|
2432
|
+
guard fileManager.fileExists(atPath: bundleRoot.path) else {
|
|
2433
|
+
return
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
do {
|
|
2437
|
+
let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
2438
|
+
|
|
2439
|
+
for url in contents {
|
|
2440
|
+
// Check if thread was cancelled
|
|
2441
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
2442
|
+
logger.warn("cleanupDownloadDirectories was cancelled")
|
|
2443
|
+
return
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
2447
|
+
if resourceValues.isDirectory != true {
|
|
2448
|
+
continue
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
let id = url.lastPathComponent
|
|
2452
|
+
|
|
2453
|
+
if allowedIds.contains(id) {
|
|
2454
|
+
continue
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
do {
|
|
2458
|
+
try fileManager.removeItem(at: url)
|
|
2459
|
+
if fileManager.fileExists(atPath: url.path) {
|
|
2460
|
+
logger.error("Orphan bundle directory still present after delete")
|
|
2461
|
+
logger.debug("Bundle ID: \(id)")
|
|
2462
|
+
continue
|
|
2463
|
+
}
|
|
2464
|
+
self.removeBundleInfo(id: id)
|
|
2465
|
+
logger.info("Deleted orphan bundle directory")
|
|
2466
|
+
logger.debug("Bundle ID: \(id)")
|
|
2467
|
+
} catch {
|
|
2468
|
+
logger.error("Failed to delete orphan bundle directory")
|
|
2469
|
+
logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
} catch {
|
|
2473
|
+
logger.error("Failed to enumerate bundle directory for cleanup")
|
|
2474
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
public func allowedBundleIdsForCleanup() -> Set<String> {
|
|
2479
|
+
var allowedIds = Set(self.list(raw: true).compactMap { info -> String? in
|
|
2480
|
+
let id = info.getId()
|
|
2481
|
+
// DELETED tombstones must not protect leftover folders.
|
|
2482
|
+
// DELETING stays protected so drainPendingDeletes owns the removal.
|
|
2483
|
+
if id.isEmpty || info.isDeleted() {
|
|
2484
|
+
return nil
|
|
2485
|
+
}
|
|
2486
|
+
return id
|
|
2487
|
+
})
|
|
2488
|
+
let currentId = self.getCurrentBundleId()
|
|
2489
|
+
if !currentId.isEmpty {
|
|
2490
|
+
allowedIds.insert(currentId)
|
|
2491
|
+
}
|
|
2492
|
+
let fallback = self.getFallbackBundle()
|
|
2493
|
+
let fallbackId = fallback.getId()
|
|
2494
|
+
if !fallbackId.isEmpty && !fallback.isDeleting() {
|
|
2495
|
+
allowedIds.insert(fallbackId)
|
|
2496
|
+
}
|
|
2497
|
+
if let next = self.getNextBundle() {
|
|
2498
|
+
let nextId = next.getId()
|
|
2499
|
+
if !nextId.isEmpty && !next.isDeleting() {
|
|
2500
|
+
allowedIds.insert(nextId)
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
if let previewFallback = self.getPreviewFallbackBundle() {
|
|
2504
|
+
let previewId = previewFallback.getId()
|
|
2505
|
+
if !previewId.isEmpty && !previewFallback.isDeleting() {
|
|
2506
|
+
allowedIds.insert(previewId)
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
return allowedIds
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
|
|
2513
|
+
let fileManager = FileManager.default
|
|
2514
|
+
|
|
2515
|
+
do {
|
|
2516
|
+
let contents = try fileManager.contentsOfDirectory(at: libraryDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
2517
|
+
|
|
2518
|
+
for url in contents {
|
|
2519
|
+
// Check if thread was cancelled
|
|
2520
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
2521
|
+
logger.warn("cleanupOrphanedTempFolders was cancelled")
|
|
2522
|
+
return
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
2526
|
+
if resourceValues.isDirectory != true {
|
|
2527
|
+
continue
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
let folderName = url.lastPathComponent
|
|
2531
|
+
|
|
2532
|
+
// Only delete folders with the temp unzip prefix
|
|
2533
|
+
if !folderName.hasPrefix(TEMP_UNZIP_PREFIX) {
|
|
2534
|
+
continue
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
do {
|
|
2538
|
+
try fileManager.removeItem(at: url)
|
|
2539
|
+
logger.info("Deleted orphaned temp unzip folder")
|
|
2540
|
+
logger.debug("Folder: \(folderName)")
|
|
2541
|
+
} catch {
|
|
2542
|
+
logger.error("Failed to delete orphaned temp folder")
|
|
2543
|
+
logger.debug("Folder: \(folderName), Error: \(error.localizedDescription)")
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
} catch {
|
|
2547
|
+
logger.error("Failed to enumerate library directory for temp folder cleanup")
|
|
2548
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
2552
|
+
logger.warn("cleanupOrphanedTempFolders was cancelled")
|
|
2553
|
+
return
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// Also cleanup old download temp files (package_*.tmp and update_*.dat)
|
|
2557
|
+
cleanupOldDownloadTempFiles(threadToCheck: threadToCheck)
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
private func cleanupOldDownloadTempFiles(threadToCheck: Thread? = nil) {
|
|
2561
|
+
let fileManager = FileManager.default
|
|
2562
|
+
guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
2563
|
+
return
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
do {
|
|
2567
|
+
let contents = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
|
|
2568
|
+
let oneHourAgo = Date().addingTimeInterval(-3600)
|
|
2569
|
+
|
|
2570
|
+
for url in contents {
|
|
2571
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
2572
|
+
logger.warn("cleanupOldDownloadTempFiles was cancelled")
|
|
2573
|
+
return
|
|
2574
|
+
}
|
|
2575
|
+
let fileName = url.lastPathComponent
|
|
2576
|
+
// Only cleanup package_*.tmp and update_*.dat files
|
|
2577
|
+
let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
|
|
2578
|
+
(fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
|
|
2579
|
+
if !isDownloadTemp {
|
|
2580
|
+
continue
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Only delete files older than 1 hour
|
|
2584
|
+
if let modDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate,
|
|
2585
|
+
modDate < oneHourAgo {
|
|
2586
|
+
do {
|
|
2587
|
+
try fileManager.removeItem(at: url)
|
|
2588
|
+
logger.debug("Deleted old download temp file: \(fileName)")
|
|
2589
|
+
} catch {
|
|
2590
|
+
logger.debug("Failed to delete old download temp file: \(fileName), Error: \(error.localizedDescription)")
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
} catch {
|
|
2595
|
+
logger.debug("Failed to enumerate documents directory for temp file cleanup: \(error.localizedDescription)")
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
public func getBundleDirectory(id: String) -> URL {
|
|
2600
|
+
return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
struct ResetState {
|
|
2604
|
+
let currentBundlePath: String
|
|
2605
|
+
let fallbackBundleId: String
|
|
2606
|
+
let nextBundleId: String?
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
func captureResetState() -> ResetState {
|
|
2610
|
+
ResetState(
|
|
2611
|
+
currentBundlePath: UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? self.DEFAULT_FOLDER,
|
|
2612
|
+
fallbackBundleId: UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN,
|
|
2613
|
+
nextBundleId: UserDefaults.standard.string(forKey: self.NEXT_VERSION)
|
|
2614
|
+
)
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
func restoreResetState(_ state: ResetState) {
|
|
2618
|
+
let currentBundlePath = state.currentBundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2619
|
+
? self.DEFAULT_FOLDER
|
|
2620
|
+
: state.currentBundlePath
|
|
2621
|
+
let fallbackBundleId = state.fallbackBundleId.isEmpty ? BundleInfo.ID_BUILTIN : state.fallbackBundleId
|
|
2622
|
+
|
|
2623
|
+
self.setCurrentBundle(bundle: currentBundlePath)
|
|
2624
|
+
UserDefaults.standard.set(fallbackBundleId, forKey: self.FALLBACK_VERSION)
|
|
2625
|
+
if let nextBundleId = state.nextBundleId, !nextBundleId.isEmpty {
|
|
2626
|
+
UserDefaults.standard.set(nextBundleId, forKey: self.NEXT_VERSION)
|
|
2627
|
+
} else {
|
|
2628
|
+
UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
|
|
2629
|
+
}
|
|
2630
|
+
UserDefaults.standard.synchronize()
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
func prepareResetStateForTransition() {
|
|
2634
|
+
self.setCurrentBundle(bundle: "")
|
|
2635
|
+
self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
|
|
2636
|
+
_ = self.setNextBundle(next: Optional<String>.none)
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
func finalizeResetTransition(previousBundleName: String, isInternal: Bool) {
|
|
2640
|
+
if !isInternal {
|
|
2641
|
+
self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: previousBundleName)
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
func canSet(bundle: BundleInfo) -> Bool {
|
|
2646
|
+
bundle.isBuiltin() || self.bundleExists(id: bundle.getId())
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
public func set(bundle: BundleInfo) -> Bool {
|
|
2650
|
+
return self.set(id: bundle.getId())
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
private func bundleExists(id: String) -> Bool {
|
|
2654
|
+
let destPersist: URL = self.getBundleDirectory(id: id)
|
|
2655
|
+
let indexPersist: URL = destPersist.appendingPathComponent("index.html")
|
|
2656
|
+
let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
|
|
2657
|
+
if
|
|
2658
|
+
destPersist.exist &&
|
|
2659
|
+
destPersist.isDirectory &&
|
|
2660
|
+
!indexPersist.isDirectory &&
|
|
2661
|
+
indexPersist.exist &&
|
|
2662
|
+
!bundleIndo.isDeleted() &&
|
|
2663
|
+
!bundleIndo.isDeleting() {
|
|
2664
|
+
return true
|
|
2665
|
+
}
|
|
2666
|
+
return false
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
public func set(id: String) -> Bool {
|
|
2670
|
+
let newBundle: BundleInfo = self.getBundleInfo(id: id)
|
|
2671
|
+
if newBundle.isBuiltin() {
|
|
2672
|
+
self.reset()
|
|
2673
|
+
return true
|
|
2674
|
+
}
|
|
2675
|
+
if bundleExists(id: id) {
|
|
2676
|
+
let currentBundleName = self.getCurrentBundle().getVersionName()
|
|
2677
|
+
self.setCurrentBundle(bundle: self.getBundleDirectory(id: id).path)
|
|
2678
|
+
self.setBundleStatus(id: id, status: BundleStatus.PENDING)
|
|
2679
|
+
self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
|
|
2680
|
+
return true
|
|
2681
|
+
}
|
|
2682
|
+
self.setBundleStatus(id: id, status: BundleStatus.ERROR)
|
|
2683
|
+
self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
|
|
2684
|
+
return false
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
func stagePendingReload(bundle: BundleInfo) -> Bool {
|
|
2688
|
+
guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
|
|
2689
|
+
return false
|
|
2690
|
+
}
|
|
2691
|
+
self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
|
|
2692
|
+
return true
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
func stagePreviewFallbackReload(bundle: BundleInfo) -> Bool {
|
|
2696
|
+
guard !bundle.isErrorStatus() else {
|
|
2697
|
+
return false
|
|
2698
|
+
}
|
|
2699
|
+
if bundle.isBuiltin() {
|
|
2700
|
+
self.setCurrentBundle(bundle: self.DEFAULT_FOLDER)
|
|
2701
|
+
return true
|
|
2702
|
+
}
|
|
2703
|
+
guard bundleExists(id: bundle.getId()) else {
|
|
2704
|
+
return false
|
|
2705
|
+
}
|
|
2706
|
+
self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
|
|
2707
|
+
return true
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
|
|
2711
|
+
guard !bundle.isBuiltin() else {
|
|
2712
|
+
return
|
|
2713
|
+
}
|
|
2714
|
+
self.sendStats(action: "set", versionName: bundle.getVersionName(), oldVersionName: previousBundleName)
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
public func autoReset() {
|
|
2718
|
+
let currentBundle: BundleInfo = self.getCurrentBundle()
|
|
2719
|
+
if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
|
|
2720
|
+
logger.info("Folder at bundle path does not exist. Triggering reset.")
|
|
2721
|
+
self.reset()
|
|
2722
|
+
return
|
|
2723
|
+
}
|
|
2724
|
+
let bundlePath = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH)
|
|
2725
|
+
if Self.shouldResetForForeignBundle(
|
|
2726
|
+
bundlePath: bundlePath,
|
|
2727
|
+
isBuiltin: currentBundle.isBuiltin(),
|
|
2728
|
+
hasStoredBundleInfo: self.hasStoredBundleInfo(id: currentBundle.getId())
|
|
2729
|
+
) {
|
|
2730
|
+
logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.")
|
|
2731
|
+
self.reset()
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
public func reset() {
|
|
2736
|
+
self.reset(isInternal: false)
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
public func reset(isInternal: Bool) {
|
|
2740
|
+
logger.info("reset: \(isInternal)")
|
|
2741
|
+
let currentBundleName = self.getCurrentBundle().getVersionName()
|
|
2742
|
+
self.prepareResetStateForTransition()
|
|
2743
|
+
self.finalizeResetTransition(previousBundleName: currentBundleName, isInternal: isInternal)
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
|
|
2747
|
+
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
|
|
2748
|
+
let fallback: BundleInfo = self.getFallbackBundle()
|
|
2749
|
+
let previewFallback = self.getPreviewFallbackBundle()
|
|
2750
|
+
let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
|
|
2751
|
+
logger.info("Fallback bundle is: \(fallback.toString())")
|
|
2752
|
+
logger.info("Version successfully loaded: \(bundle.toString())")
|
|
2753
|
+
let previousFallbackId = fallback.getId()
|
|
2754
|
+
let nextBundle = self.getNextBundle()
|
|
2755
|
+
let previousIsNext = nextBundle?.getId() == previousFallbackId &&
|
|
2756
|
+
!(nextBundle?.isDeleted() ?? true) &&
|
|
2757
|
+
!(nextBundle?.isErrorStatus() ?? true) &&
|
|
2758
|
+
!(nextBundle?.isDeleting() ?? true)
|
|
2759
|
+
let shouldDeletePrevious = autoDeletePrevious &&
|
|
2760
|
+
!fallback.isBuiltin() &&
|
|
2761
|
+
previousFallbackId != bundle.getId() &&
|
|
2762
|
+
!fallbackIsPreviewFallback &&
|
|
2763
|
+
!previousIsNext
|
|
2764
|
+
if shouldDeletePrevious {
|
|
2765
|
+
// Mark durable intent before fallback switch so a kill mid-flight still retries.
|
|
2766
|
+
if !self.saveBundleInfo(id: previousFallbackId, bundle: fallback.setStatus(status: BundleStatus.DELETING.storedValue)) {
|
|
2767
|
+
self.logger.error("Failed to persist DELETING for previous bundle; queueing durable retry")
|
|
2768
|
+
self.logger.debug("Bundle ID: \(previousFallbackId)")
|
|
2769
|
+
self.enqueuePendingDelete(id: previousFallbackId)
|
|
2770
|
+
}
|
|
2771
|
+
UserDefaults.standard.synchronize()
|
|
2772
|
+
}
|
|
2773
|
+
self.setFallbackBundle(fallback: bundle)
|
|
2774
|
+
if shouldDeletePrevious {
|
|
2775
|
+
DispatchQueue.global(qos: .utility).async {
|
|
2776
|
+
let res = self.delete(id: previousFallbackId)
|
|
2777
|
+
if res {
|
|
2778
|
+
self.logger.info("Deleted previous bundle")
|
|
2779
|
+
self.logger.debug("Bundle ID: \(previousFallbackId)")
|
|
2780
|
+
} else {
|
|
2781
|
+
self.logger.info("Previous bundle delete incomplete, will retry")
|
|
2782
|
+
self.logger.debug("Bundle ID: \(previousFallbackId)")
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
public func setError(bundle: BundleInfo) {
|
|
2789
|
+
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
|
|
2793
|
+
let setChannel: SetChannel = SetChannel()
|
|
2794
|
+
|
|
2795
|
+
// Clear persisted defaultChannel and revert to config value
|
|
2796
|
+
UserDefaults.standard.removeObject(forKey: defaultChannelKey)
|
|
2797
|
+
UserDefaults.standard.synchronize()
|
|
2798
|
+
self.defaultChannel = configDefaultChannel
|
|
2799
|
+
self.logger.info("Persisted defaultChannel cleared, reverted to config value: \(configDefaultChannel)")
|
|
2800
|
+
|
|
2801
|
+
setChannel.status = "ok"
|
|
2802
|
+
setChannel.message = "Channel override removed"
|
|
2803
|
+
return setChannel
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
func setChannel(
|
|
2807
|
+
channel: String,
|
|
2808
|
+
defaultChannelKey: String,
|
|
2809
|
+
allowSetDefaultChannel: Bool,
|
|
2810
|
+
configDefaultChannel: String = ""
|
|
2811
|
+
) -> SetChannel {
|
|
2812
|
+
let setChannel: SetChannel = SetChannel()
|
|
2813
|
+
|
|
2814
|
+
// Check if setting defaultChannel is allowed
|
|
2815
|
+
if !allowSetDefaultChannel {
|
|
2816
|
+
logger.error("setChannel is disabled by allowSetDefaultChannel config")
|
|
2817
|
+
setChannel.message = "setChannel is disabled by configuration"
|
|
2818
|
+
setChannel.error = "disabled_by_config"
|
|
2819
|
+
return setChannel
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
if isRemoteBlocked() {
|
|
2823
|
+
let blocked = remoteBlockedClientError()
|
|
2824
|
+
logger.debug("Skipping setChannel due to remote block (\(blocked.error)).")
|
|
2825
|
+
setChannel.message = blocked.message
|
|
2826
|
+
setChannel.error = blocked.error
|
|
2827
|
+
return setChannel
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
if (self.channelUrl ).isEmpty {
|
|
2831
|
+
logger.error("Channel URL is not set")
|
|
2832
|
+
setChannel.message = "Channel URL is not set"
|
|
2833
|
+
setChannel.error = "missing_config"
|
|
2834
|
+
return setChannel
|
|
2835
|
+
}
|
|
2836
|
+
guard let channelURL = URL(string: self.channelUrl) else {
|
|
2837
|
+
logger.error("Invalid channel URL")
|
|
2838
|
+
setChannel.message = "Channel URL is invalid"
|
|
2839
|
+
setChannel.error = "invalid_config"
|
|
2840
|
+
return setChannel
|
|
2841
|
+
}
|
|
2842
|
+
var parameters: InfoObject = self.createInfoObject()
|
|
2843
|
+
parameters.channel = channel
|
|
2844
|
+
guard let request = createRequest(url: channelURL, method: "POST", parameters: parameters.toParameters()) else {
|
|
2845
|
+
setChannel.error = "Request failed: invalid request"
|
|
2846
|
+
return setChannel
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
let result = performRequest(request, label: "setChannel")
|
|
2850
|
+
|
|
2851
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
2852
|
+
statusCode: result.response?.statusCode,
|
|
2853
|
+
data: result.data,
|
|
2854
|
+
response: result.response
|
|
2855
|
+
)
|
|
2856
|
+
if rateLimit.blocked {
|
|
2857
|
+
setChannel.message = rateLimit.message
|
|
2858
|
+
setChannel.error = rateLimit.error
|
|
2859
|
+
return setChannel
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
if result.timedOut {
|
|
2863
|
+
setChannel.error = "Request timed out"
|
|
2864
|
+
return setChannel
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
if let error = result.error {
|
|
2868
|
+
self.logger.error("Error setting channel")
|
|
2869
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
2870
|
+
setChannel.error = "Request failed: \(error.localizedDescription)"
|
|
2871
|
+
return setChannel
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
guard let data = result.data else {
|
|
2875
|
+
setChannel.error = "Request failed: empty response"
|
|
2876
|
+
return setChannel
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
guard let responseValue = try? JSONDecoder().decode(SetChannelDec.self, from: data) else {
|
|
2880
|
+
setChannel.error = "decode_error"
|
|
2881
|
+
return setChannel
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
2885
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
2886
|
+
setChannel.message = responseValue.message ?? "Server error: \(statusCode)"
|
|
2887
|
+
setChannel.error = responseValue.error ?? "response_error"
|
|
2888
|
+
return setChannel
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
if let error = responseValue.error {
|
|
2892
|
+
setChannel.error = error
|
|
2893
|
+
} else if responseValue.unset == true {
|
|
2894
|
+
UserDefaults.standard.removeObject(forKey: defaultChannelKey)
|
|
2895
|
+
UserDefaults.standard.synchronize()
|
|
2896
|
+
self.defaultChannel = configDefaultChannel
|
|
2897
|
+
self.logger.info("Public channel requested, channel override removed")
|
|
2898
|
+
|
|
2899
|
+
setChannel.status = responseValue.status ?? "ok"
|
|
2900
|
+
setChannel.message = responseValue.message
|
|
2901
|
+
?? "Public channel requested, channel override removed. Device will use public channel automatically."
|
|
2902
|
+
} else {
|
|
2903
|
+
self.defaultChannel = channel
|
|
2904
|
+
UserDefaults.standard.set(channel, forKey: defaultChannelKey)
|
|
2905
|
+
UserDefaults.standard.synchronize()
|
|
2906
|
+
self.logger.info("defaultChannel persisted locally: \(channel)")
|
|
2907
|
+
|
|
2908
|
+
setChannel.status = responseValue.status ?? ""
|
|
2909
|
+
setChannel.message = responseValue.message ?? ""
|
|
2910
|
+
}
|
|
2911
|
+
return setChannel
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
|
|
2915
|
+
let getChannel: GetChannel = GetChannel()
|
|
2916
|
+
// Check if rate limit was exceeded
|
|
2917
|
+
if isRemoteBlocked() {
|
|
2918
|
+
let blocked = remoteBlockedClientError()
|
|
2919
|
+
logger.debug("Skipping getChannel due to remote block (\(blocked.error)).")
|
|
2920
|
+
getChannel.message = blocked.message
|
|
2921
|
+
getChannel.error = blocked.error
|
|
2922
|
+
return getChannel
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
if (self.channelUrl ).isEmpty {
|
|
2926
|
+
logger.error("Channel URL is not set")
|
|
2927
|
+
getChannel.message = "Channel URL is not set"
|
|
2928
|
+
getChannel.error = "missing_config"
|
|
2929
|
+
return getChannel
|
|
2930
|
+
}
|
|
2931
|
+
guard let channelURL = URL(string: self.channelUrl) else {
|
|
2932
|
+
logger.error("Invalid channel URL")
|
|
2933
|
+
getChannel.message = "Channel URL is invalid"
|
|
2934
|
+
getChannel.error = "invalid_config"
|
|
2935
|
+
return getChannel
|
|
2936
|
+
}
|
|
2937
|
+
let parameters: InfoObject = self.createInfoObject()
|
|
2938
|
+
guard let request = createRequest(url: channelURL, method: "PUT", parameters: parameters.toParameters()) else {
|
|
2939
|
+
getChannel.error = "Request failed: invalid request"
|
|
2940
|
+
return getChannel
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
let result = performRequest(request, label: "getChannel")
|
|
2944
|
+
|
|
2945
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
2946
|
+
statusCode: result.response?.statusCode,
|
|
2947
|
+
data: result.data,
|
|
2948
|
+
response: result.response
|
|
2949
|
+
)
|
|
2950
|
+
if rateLimit.blocked {
|
|
2951
|
+
getChannel.message = rateLimit.message
|
|
2952
|
+
getChannel.error = rateLimit.error
|
|
2953
|
+
return getChannel
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
if result.timedOut {
|
|
2957
|
+
getChannel.error = "Request timed out"
|
|
2958
|
+
return getChannel
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
if let error = result.error {
|
|
2962
|
+
if let data = result.data, let bodyString = String(data: data, encoding: .utf8) {
|
|
2963
|
+
if bodyString.contains("channel_not_found") && result.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
|
|
2964
|
+
getChannel.channel = self.defaultChannel
|
|
2965
|
+
getChannel.status = "default"
|
|
2966
|
+
return getChannel
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
self.logger.error("Error getting channel")
|
|
2971
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
2972
|
+
getChannel.error = "Request failed: \(error.localizedDescription)"
|
|
2973
|
+
return getChannel
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
guard let data = result.data else {
|
|
2977
|
+
getChannel.error = "Request failed: empty response"
|
|
2978
|
+
return getChannel
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
guard let responseValue = try? JSONDecoder().decode(GetChannelDec.self, from: data) else {
|
|
2982
|
+
getChannel.error = "decode_error"
|
|
2983
|
+
return getChannel
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
2987
|
+
if let error = responseValue.error {
|
|
2988
|
+
if error == "channel_not_found", statusCode == 400, !self.defaultChannel.isEmpty {
|
|
2989
|
+
getChannel.channel = self.defaultChannel
|
|
2990
|
+
getChannel.status = "default"
|
|
2991
|
+
return getChannel
|
|
2992
|
+
}
|
|
2993
|
+
getChannel.error = error
|
|
2994
|
+
getChannel.message = responseValue.message ?? ""
|
|
2995
|
+
return getChannel
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
2999
|
+
getChannel.message = responseValue.message ?? "Server error: \(statusCode)"
|
|
3000
|
+
getChannel.error = "response_error"
|
|
3001
|
+
} else {
|
|
3002
|
+
getChannel.status = responseValue.status ?? ""
|
|
3003
|
+
getChannel.message = responseValue.message ?? ""
|
|
3004
|
+
getChannel.channel = responseValue.channel ?? ""
|
|
3005
|
+
getChannel.allowSet = responseValue.allowSet ?? true
|
|
3006
|
+
persistDefaultChannelFromResponse(channel: responseValue.channel, defaultChannelKey: defaultChannelKey)
|
|
3007
|
+
}
|
|
3008
|
+
return getChannel
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
func persistDefaultChannelFromResponse(channel: String?, defaultChannelKey: String?) {
|
|
3012
|
+
guard let channelName = channel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
3013
|
+
!channelName.isEmpty,
|
|
3014
|
+
channelName != BundleInfo.ID_BUILTIN else {
|
|
3015
|
+
return
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
self.defaultChannel = channelName
|
|
3019
|
+
if let defaultChannelKey, !defaultChannelKey.isEmpty {
|
|
3020
|
+
UserDefaults.standard.set(channelName, forKey: defaultChannelKey)
|
|
3021
|
+
UserDefaults.standard.synchronize()
|
|
3022
|
+
}
|
|
3023
|
+
logger.info("defaultChannel synchronized from getChannel(): \(channelName)")
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
func listChannels() -> ListChannels {
|
|
3027
|
+
let listChannels: ListChannels = ListChannels()
|
|
3028
|
+
|
|
3029
|
+
// Check if rate limit was exceeded
|
|
3030
|
+
if isRemoteBlocked() {
|
|
3031
|
+
let blocked = remoteBlockedClientError()
|
|
3032
|
+
logger.debug("Skipping listChannels due to remote block (\(blocked.error)).")
|
|
3033
|
+
listChannels.error = blocked.error
|
|
3034
|
+
return listChannels
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
if (self.channelUrl).isEmpty {
|
|
3038
|
+
logger.error("Channel URL is not set")
|
|
3039
|
+
listChannels.error = "Channel URL is not set"
|
|
3040
|
+
return listChannels
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
// Create info object and convert to query parameters
|
|
3044
|
+
let infoObject = self.createInfoObject()
|
|
3045
|
+
var urlComponents = URLComponents(string: self.channelUrl)
|
|
3046
|
+
var queryItems: [URLQueryItem] = urlComponents?.queryItems ?? []
|
|
3047
|
+
|
|
3048
|
+
for (key, value) in infoObject.toParameters() {
|
|
3049
|
+
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
urlComponents?.queryItems = queryItems
|
|
3053
|
+
|
|
3054
|
+
guard let url = urlComponents?.url else {
|
|
3055
|
+
logger.error("Invalid channel URL")
|
|
3056
|
+
listChannels.error = "Invalid channel URL"
|
|
3057
|
+
return listChannels
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
guard let request = createRequest(url: url, method: "GET", expectsJSONResponse: true) else {
|
|
3061
|
+
listChannels.error = "Invalid channel URL"
|
|
3062
|
+
return listChannels
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
let result = performRequest(request, label: "listChannels")
|
|
3066
|
+
|
|
3067
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
3068
|
+
statusCode: result.response?.statusCode,
|
|
3069
|
+
data: result.data,
|
|
3070
|
+
response: result.response
|
|
3071
|
+
)
|
|
3072
|
+
if rateLimit.blocked {
|
|
3073
|
+
listChannels.error = rateLimit.error
|
|
3074
|
+
return listChannels
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
if result.timedOut {
|
|
3078
|
+
listChannels.error = "Request timed out"
|
|
3079
|
+
return listChannels
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
if let error = result.error {
|
|
3083
|
+
self.logger.error("Error listing channels")
|
|
3084
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
3085
|
+
listChannels.error = "Request failed: \(error.localizedDescription)"
|
|
3086
|
+
return listChannels
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
guard let data = result.data else {
|
|
3090
|
+
listChannels.error = "Request failed: empty response"
|
|
3091
|
+
return listChannels
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
guard let responseValue = try? JSONDecoder().decode(ListChannelsDec.self, from: data) else {
|
|
3095
|
+
listChannels.error = "decode_error"
|
|
3096
|
+
return listChannels
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
3100
|
+
if let error = responseValue.error {
|
|
3101
|
+
listChannels.error = error
|
|
3102
|
+
return listChannels
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
3106
|
+
listChannels.error = "response_error"
|
|
3107
|
+
return listChannels
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
if let channels = responseValue.channels {
|
|
3111
|
+
listChannels.channels = channels.map { channel in
|
|
3112
|
+
var channelDict: [String: Any] = [:]
|
|
3113
|
+
channelDict["id"] = channel.id
|
|
3114
|
+
channelDict["name"] = channel.name ?? ""
|
|
3115
|
+
channelDict["public"] = channel.public ?? false
|
|
3116
|
+
channelDict["allow_self_set"] = channel.allow_self_set ?? false
|
|
3117
|
+
return channelDict
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
return listChannels
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
private let operationQueue = OperationQueue()
|
|
3125
|
+
|
|
3126
|
+
private let manifestDownloadQueue: OperationQueue = {
|
|
3127
|
+
let queue = OperationQueue()
|
|
3128
|
+
queue.name = "com.capgo.manifestDownload"
|
|
3129
|
+
queue.qualityOfService = .userInitiated
|
|
3130
|
+
queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
|
|
3131
|
+
return queue
|
|
3132
|
+
}()
|
|
3133
|
+
|
|
3134
|
+
func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
|
|
3135
|
+
sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: nil, onSent: nil)
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String]) {
|
|
3139
|
+
sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata, onSent: nil)
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String], onSent: @escaping () -> Void) {
|
|
3143
|
+
sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata, onSent: onSent)
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
private func sendStatsWithMetadata(
|
|
3147
|
+
action: String,
|
|
3148
|
+
versionName: String?,
|
|
3149
|
+
oldVersionName: String?,
|
|
3150
|
+
metadata: [String: String]?,
|
|
3151
|
+
onSent: (() -> Void)?
|
|
3152
|
+
) {
|
|
3153
|
+
if statsStopped {
|
|
3154
|
+
return
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
if previewSession {
|
|
3158
|
+
logger.debug("Skipping sendStats during preview session.")
|
|
3159
|
+
return
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
guard !statsUrl.isEmpty else {
|
|
3163
|
+
return
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
let resolvedVersionName = versionName ?? getCurrentBundle().getVersionName()
|
|
3167
|
+
let info = createInfoObject()
|
|
3168
|
+
|
|
3169
|
+
let event = StatsEvent(
|
|
3170
|
+
platform: info.platform,
|
|
3171
|
+
device_id: info.device_id,
|
|
3172
|
+
app_id: info.app_id,
|
|
3173
|
+
custom_id: info.custom_id,
|
|
3174
|
+
version_build: info.version_build,
|
|
3175
|
+
version_code: info.version_code,
|
|
3176
|
+
version_os: info.version_os,
|
|
3177
|
+
version_name: resolvedVersionName,
|
|
3178
|
+
old_version_name: oldVersionName ?? "",
|
|
3179
|
+
plugin_version: info.plugin_version,
|
|
3180
|
+
is_emulator: info.is_emulator,
|
|
3181
|
+
is_prod: info.is_prod,
|
|
3182
|
+
installSource: info.installSource,
|
|
3183
|
+
action: action,
|
|
3184
|
+
channel: info.channel,
|
|
3185
|
+
defaultChannel: info.defaultChannel,
|
|
3186
|
+
key_id: info.key_id,
|
|
3187
|
+
metadata: metadata,
|
|
3188
|
+
timestamp: Int64(Date().timeIntervalSince1970 * 1000)
|
|
3189
|
+
)
|
|
3190
|
+
|
|
3191
|
+
statsQueueLock.lock()
|
|
3192
|
+
if statsStopped {
|
|
3193
|
+
statsQueueLock.unlock()
|
|
3194
|
+
return
|
|
3195
|
+
}
|
|
3196
|
+
if statsQueue.count >= CapgoUpdater.maxPendingStats {
|
|
3197
|
+
statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats + 1)
|
|
3198
|
+
}
|
|
3199
|
+
statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
|
|
3200
|
+
statsQueueLock.unlock()
|
|
3201
|
+
|
|
3202
|
+
ensureStatsTimerStarted()
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
func restorePendingStats() {
|
|
3206
|
+
let fileURL = pendingStatsFileURL()
|
|
3207
|
+
guard FileManager.default.fileExists(atPath: fileURL.path),
|
|
3208
|
+
let data = try? Data(contentsOf: fileURL),
|
|
3209
|
+
let events = try? JSONDecoder().decode([StatsEvent].self, from: data) else {
|
|
3210
|
+
return
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
statsQueueLock.lock()
|
|
3214
|
+
for event in events {
|
|
3215
|
+
if statsQueue.count >= CapgoUpdater.maxPendingStats {
|
|
3216
|
+
break
|
|
3217
|
+
}
|
|
3218
|
+
statsQueue.append(QueuedStatsEvent(event: event, onSent: nil))
|
|
3219
|
+
}
|
|
3220
|
+
let restoredCount = statsQueue.count
|
|
3221
|
+
statsQueueLock.unlock()
|
|
3222
|
+
|
|
3223
|
+
if restoredCount > 0 {
|
|
3224
|
+
logger.info("Restored \(restoredCount) pending stats events")
|
|
3225
|
+
ensureStatsTimerStarted()
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
func persistPendingStats() {
|
|
3230
|
+
persistStatsQueue()
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
private func pendingStatsFileURL() -> URL {
|
|
3234
|
+
libraryDir.appendingPathComponent(pendingStatsFileName)
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
private func persistStatsQueue(force: Bool = false) {
|
|
3238
|
+
statsPersistLock.lock()
|
|
3239
|
+
defer { statsPersistLock.unlock() }
|
|
3240
|
+
if statsStopped && !force {
|
|
3241
|
+
return
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
statsQueueLock.lock()
|
|
3245
|
+
var events = statsInFlight.map(\.event) + statsQueue.map(\.event)
|
|
3246
|
+
statsQueueLock.unlock()
|
|
3247
|
+
if events.count > CapgoUpdater.maxPendingStats {
|
|
3248
|
+
events = Array(events.suffix(CapgoUpdater.maxPendingStats))
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
let fileURL = pendingStatsFileURL()
|
|
3252
|
+
if events.isEmpty {
|
|
3253
|
+
try? FileManager.default.removeItem(at: fileURL)
|
|
3254
|
+
return
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
do {
|
|
3258
|
+
let data = try JSONEncoder().encode(events)
|
|
3259
|
+
try data.write(to: fileURL, options: .atomic)
|
|
3260
|
+
var resourceURL = fileURL
|
|
3261
|
+
var values = URLResourceValues()
|
|
3262
|
+
values.isExcludedFromBackup = true
|
|
3263
|
+
try resourceURL.setResourceValues(values)
|
|
3264
|
+
} catch {
|
|
3265
|
+
logger.error("Failed to persist stats queue")
|
|
3266
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
private func ensureStatsTimerStarted() {
|
|
3271
|
+
if statsStopped {
|
|
3272
|
+
return
|
|
3273
|
+
}
|
|
3274
|
+
DispatchQueue.main.async { [weak self] in
|
|
3275
|
+
guard let self = self, !self.statsStopped else { return }
|
|
3276
|
+
if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
|
|
3277
|
+
// Use closure-based timer to avoid strong reference cycle
|
|
3278
|
+
self.statsFlushTimer = Timer.scheduledTimer(
|
|
3279
|
+
withTimeInterval: CapgoUpdater.statsFlushInterval,
|
|
3280
|
+
repeats: true
|
|
3281
|
+
) { [weak self] _ in
|
|
3282
|
+
self?.flushStatsQueue()
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
private func flushStatsQueue() {
|
|
3289
|
+
if statsStopped {
|
|
3290
|
+
return
|
|
3291
|
+
}
|
|
3292
|
+
// While Retry-After is active, keep stats queued and skip the network call.
|
|
3293
|
+
if isRemoteBlocked() {
|
|
3294
|
+
logger.debug("Deferring stats flush until Retry-After expires.")
|
|
3295
|
+
return
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
statsQueueLock.lock()
|
|
3299
|
+
guard statsInFlight.isEmpty, !statsQueue.isEmpty else {
|
|
3300
|
+
statsQueueLock.unlock()
|
|
3301
|
+
return
|
|
3302
|
+
}
|
|
3303
|
+
let queuedEvents = statsQueue
|
|
3304
|
+
statsQueue.removeAll()
|
|
3305
|
+
statsInFlight = queuedEvents
|
|
3306
|
+
statsQueueLock.unlock()
|
|
3307
|
+
persistStatsQueue()
|
|
3308
|
+
|
|
3309
|
+
let eventsToSend = queuedEvents.map(\.event)
|
|
3310
|
+
|
|
3311
|
+
operationQueue.maxConcurrentOperationCount = 1
|
|
3312
|
+
|
|
3313
|
+
let operation = BlockOperation {
|
|
3314
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
3315
|
+
self.alamofireSession.request(
|
|
3316
|
+
self.statsUrl,
|
|
3317
|
+
method: .post,
|
|
3318
|
+
parameters: eventsToSend,
|
|
3319
|
+
encoder: JSONParameterEncoder.default,
|
|
3320
|
+
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
3321
|
+
).responseData { response in
|
|
3322
|
+
if self.abandonStoppedStatsFlush() {
|
|
3323
|
+
semaphore.signal()
|
|
3324
|
+
return
|
|
3325
|
+
}
|
|
3326
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
|
|
3327
|
+
self.requeueStatsEvents(queuedEvents)
|
|
3328
|
+
semaphore.signal()
|
|
3329
|
+
return
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
|
|
3333
|
+
if CapgoUpdater.isTransientStatsFailure(statusCode) {
|
|
3334
|
+
self.requeueStatsEvents(queuedEvents)
|
|
3335
|
+
self.logger.error("Error sending stats batch")
|
|
3336
|
+
self.logger.debug("Retrying later, response code: \(statusCode)")
|
|
3337
|
+
} else {
|
|
3338
|
+
self.clearStatsInFlight()
|
|
3339
|
+
self.logger.error("Dropping stats batch after permanent error")
|
|
3340
|
+
self.logger.debug("Response code: \(statusCode)")
|
|
3341
|
+
}
|
|
3342
|
+
semaphore.signal()
|
|
3343
|
+
return
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
switch response.result {
|
|
3347
|
+
case .success:
|
|
3348
|
+
self.clearStatsInFlight()
|
|
3349
|
+
self.logger.info("Stats batch sent successfully")
|
|
3350
|
+
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
3351
|
+
self.runStatsCallbacks(queuedEvents)
|
|
3352
|
+
case let .failure(error):
|
|
3353
|
+
self.requeueStatsEvents(queuedEvents)
|
|
3354
|
+
self.logger.error("Error sending stats batch")
|
|
3355
|
+
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
3356
|
+
}
|
|
3357
|
+
semaphore.signal()
|
|
3358
|
+
}
|
|
3359
|
+
semaphore.wait()
|
|
3360
|
+
if !self.statsStopped {
|
|
3361
|
+
self.persistStatsQueue()
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
operationQueue.addOperation(operation)
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
private func abandonStoppedStatsFlush() -> Bool {
|
|
3368
|
+
statsStopped
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/// Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
|
|
3372
|
+
private static func isTransientStatsFailure(_ statusCode: Int) -> Bool {
|
|
3373
|
+
return statusCode == 429 || statusCode == 408 || statusCode >= 500
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
private func runStatsCallbacks(_ sentEvents: [QueuedStatsEvent]) {
|
|
3377
|
+
for sentEvent in sentEvents {
|
|
3378
|
+
sentEvent.onSent?()
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
private func requeueStatsEvents(_ events: [QueuedStatsEvent]) {
|
|
3383
|
+
guard !statsStopped, !events.isEmpty else { return }
|
|
3384
|
+
statsQueueLock.lock()
|
|
3385
|
+
statsInFlight.removeAll()
|
|
3386
|
+
statsQueue.insert(contentsOf: events, at: 0)
|
|
3387
|
+
if statsQueue.count > CapgoUpdater.maxPendingStats {
|
|
3388
|
+
statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats)
|
|
3389
|
+
}
|
|
3390
|
+
statsQueueLock.unlock()
|
|
3391
|
+
persistStatsQueue()
|
|
3392
|
+
ensureStatsTimerStarted()
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
private func clearStatsInFlight() {
|
|
3396
|
+
statsQueueLock.lock()
|
|
3397
|
+
statsInFlight.removeAll()
|
|
3398
|
+
statsQueueLock.unlock()
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
public func getBundleInfo(id: String?) -> BundleInfo {
|
|
3402
|
+
var trueId = BundleInfo.VERSION_UNKNOWN
|
|
3403
|
+
if id != nil {
|
|
3404
|
+
trueId = id!
|
|
3405
|
+
}
|
|
3406
|
+
let result: BundleInfo
|
|
3407
|
+
if BundleInfo.ID_BUILTIN == trueId {
|
|
3408
|
+
result = BundleInfo(id: trueId, version: self.versionBuild, status: BundleStatus.SUCCESS, checksum: "")
|
|
3409
|
+
} else if BundleInfo.VERSION_UNKNOWN == trueId {
|
|
3410
|
+
result = BundleInfo(id: trueId, version: "", status: BundleStatus.ERROR, checksum: "")
|
|
3411
|
+
} else {
|
|
3412
|
+
do {
|
|
3413
|
+
result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
|
|
3414
|
+
} catch {
|
|
3415
|
+
logger.error("Failed to parse bundle info")
|
|
3416
|
+
logger.debug("Bundle ID: \(trueId), Error: \(error.localizedDescription)")
|
|
3417
|
+
result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
return result
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
public func getBundleInfoByVersionName(version: String) -> BundleInfo? {
|
|
3424
|
+
let installed: [BundleInfo] = self.list()
|
|
3425
|
+
for i in installed {
|
|
3426
|
+
if i.getVersionName() == version {
|
|
3427
|
+
return i
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
return nil
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
private func removeBundleInfo(id: String) {
|
|
3434
|
+
self.saveBundleInfo(id: id, bundle: nil)
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
@discardableResult
|
|
3438
|
+
public func saveBundleInfo(id: String, bundle: BundleInfo?) -> Bool {
|
|
3439
|
+
if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
|
|
3440
|
+
logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
|
|
3441
|
+
return false
|
|
3442
|
+
}
|
|
3443
|
+
if bundle == nil {
|
|
3444
|
+
logger.info("Removing info for bundle [\(id)]")
|
|
3445
|
+
UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
|
|
3446
|
+
return true
|
|
3447
|
+
}
|
|
3448
|
+
let update = bundle!.setId(id: id)
|
|
3449
|
+
logger.info("Storing info for bundle [\(id)] \(update.toString())")
|
|
3450
|
+
do {
|
|
3451
|
+
try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
|
|
3452
|
+
return true
|
|
3453
|
+
} catch {
|
|
3454
|
+
logger.error("Failed to save bundle info")
|
|
3455
|
+
logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
|
|
3456
|
+
return false
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
private func setBundleStatus(id: String, status: BundleStatus) {
|
|
3461
|
+
logger.info("Setting status for bundle [\(id)] to \(status)")
|
|
3462
|
+
let info = self.getBundleInfo(id: id)
|
|
3463
|
+
self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.storedValue))
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
public func getCurrentBundle() -> BundleInfo {
|
|
3467
|
+
return self.getBundleInfo(id: self.getCurrentBundleId())
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
public func getCurrentBundleId() -> String {
|
|
3471
|
+
guard let bundlePath: String = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) else {
|
|
3472
|
+
return BundleInfo.ID_BUILTIN
|
|
3473
|
+
}
|
|
3474
|
+
if (bundlePath).isEmpty {
|
|
3475
|
+
return BundleInfo.ID_BUILTIN
|
|
3476
|
+
}
|
|
3477
|
+
let bundleID: String = bundlePath.components(separatedBy: "/").last ?? bundlePath
|
|
3478
|
+
return bundleID
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
public func isUsingBuiltin() -> Bool {
|
|
3482
|
+
return (UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? "") == self.DEFAULT_FOLDER
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
public func getFallbackBundle() -> BundleInfo {
|
|
3486
|
+
let id: String = UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN
|
|
3487
|
+
return self.getBundleInfo(id: id)
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
private func setFallbackBundle(fallback: BundleInfo?) {
|
|
3491
|
+
UserDefaults.standard.set(fallback == nil ? BundleInfo.ID_BUILTIN : fallback!.getId(), forKey: self.FALLBACK_VERSION)
|
|
3492
|
+
UserDefaults.standard.synchronize()
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
public func getNextBundle() -> BundleInfo? {
|
|
3496
|
+
let id: String? = UserDefaults.standard.string(forKey: self.NEXT_VERSION)
|
|
3497
|
+
return self.getBundleInfo(id: id)
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
public func getPreviewFallbackBundle() -> BundleInfo? {
|
|
3501
|
+
guard let id = UserDefaults.standard.string(forKey: self.PREVIEW_FALLBACK_VERSION) else {
|
|
3502
|
+
return nil
|
|
3503
|
+
}
|
|
3504
|
+
let bundle = self.getBundleInfo(id: id)
|
|
3505
|
+
if !bundle.isBuiltin() && !self.bundleExists(id: id) {
|
|
3506
|
+
_ = self.setPreviewFallbackBundle(fallback: nil)
|
|
3507
|
+
return nil
|
|
3508
|
+
}
|
|
3509
|
+
return bundle
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
public func setPreviewFallbackBundle(fallback: String?) -> Bool {
|
|
3513
|
+
guard let fallbackId = fallback else {
|
|
3514
|
+
UserDefaults.standard.removeObject(forKey: self.PREVIEW_FALLBACK_VERSION)
|
|
3515
|
+
UserDefaults.standard.synchronize()
|
|
3516
|
+
return true
|
|
3517
|
+
}
|
|
3518
|
+
let newBundle: BundleInfo = self.getBundleInfo(id: fallbackId)
|
|
3519
|
+
if !newBundle.isBuiltin() && !self.bundleExists(id: fallbackId) {
|
|
3520
|
+
return false
|
|
3521
|
+
}
|
|
3522
|
+
UserDefaults.standard.set(fallbackId, forKey: self.PREVIEW_FALLBACK_VERSION)
|
|
3523
|
+
UserDefaults.standard.synchronize()
|
|
3524
|
+
return true
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
public func setNextBundle(next: String?) -> Bool {
|
|
3528
|
+
guard let nextId: String = next else {
|
|
3529
|
+
UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
|
|
3530
|
+
UserDefaults.standard.synchronize()
|
|
3531
|
+
return false
|
|
3532
|
+
}
|
|
3533
|
+
let newBundle: BundleInfo = self.getBundleInfo(id: nextId)
|
|
3534
|
+
if !newBundle.isBuiltin() && !self.bundleExists(id: nextId) {
|
|
3535
|
+
return false
|
|
3536
|
+
}
|
|
3537
|
+
UserDefaults.standard.set(nextId, forKey: self.NEXT_VERSION)
|
|
3538
|
+
UserDefaults.standard.synchronize()
|
|
3539
|
+
self.setBundleStatus(id: nextId, status: BundleStatus.PENDING)
|
|
3540
|
+
self.sendStats(action: "set_next", versionName: newBundle.getVersionName(), oldVersionName: self.getCurrentBundle().getVersionName())
|
|
3541
|
+
self.notifyListeners("setNext", ["bundle": newBundle.toJSON()])
|
|
3542
|
+
return true
|
|
3543
|
+
}
|
|
3544
|
+
}
|