@otakit/capacitor-updater 1.0.0
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 +21 -0
- package/README.md +183 -0
- package/UpdatekitUpdater.podspec +18 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/updatekit/updater/BundleInfo.java +100 -0
- package/android/src/main/java/com/updatekit/updater/BundleStatus.java +28 -0
- package/android/src/main/java/com/updatekit/updater/BundleStore.java +264 -0
- package/android/src/main/java/com/updatekit/updater/DateUtils.java +17 -0
- package/android/src/main/java/com/updatekit/updater/HashUtils.java +32 -0
- package/android/src/main/java/com/updatekit/updater/HostedManifestKeys.java +37 -0
- package/android/src/main/java/com/updatekit/updater/ManifestClient.java +209 -0
- package/android/src/main/java/com/updatekit/updater/ManifestVerifier.java +146 -0
- package/android/src/main/java/com/updatekit/updater/StatsClient.java +80 -0
- package/android/src/main/java/com/updatekit/updater/UpdaterPlugin.java +963 -0
- package/android/src/main/java/com/updatekit/updater/ZipUtils.java +72 -0
- package/dist/esm/definitions.d.ts +229 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +17 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +85 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +25 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +56 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +165 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +168 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +39 -0
- package/ios/Sources/UpdaterPlugin/BundleStatus.swift +9 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +233 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +120 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +33 -0
- package/ios/Sources/UpdaterPlugin/HostedManifestKeys.swift +23 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +161 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +115 -0
- package/ios/Sources/UpdaterPlugin/StatsClient.swift +66 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.m +15 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +913 -0
- package/ios/Sources/UpdaterPlugin/ZipUtils.swift +90 -0
- package/package.json +85 -0
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
import Capacitor
|
|
2
|
+
import CryptoKit
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
@objc(UpdaterPlugin)
|
|
6
|
+
public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
7
|
+
private enum UpdateMode: String {
|
|
8
|
+
case manual
|
|
9
|
+
case nextLaunch = "next-launch"
|
|
10
|
+
case immediate
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public let identifier = "UpdaterPlugin"
|
|
14
|
+
public let jsName = "OtaKit"
|
|
15
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
16
|
+
CAPPluginMethod(name: "check", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "download", returnType: CAPPluginReturnPromise),
|
|
18
|
+
CAPPluginMethod(name: "apply", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "debugGetState", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "debugCheck", returnType: CAPPluginReturnPromise),
|
|
21
|
+
CAPPluginMethod(name: "debugDownload", returnType: CAPPluginReturnPromise),
|
|
22
|
+
CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
|
|
23
|
+
CAPPluginMethod(name: "debugReset", returnType: CAPPluginReturnPromise),
|
|
24
|
+
CAPPluginMethod(name: "debugListBundles", returnType: CAPPluginReturnPromise),
|
|
25
|
+
CAPPluginMethod(name: "debugDeleteBundle", returnType: CAPPluginReturnPromise),
|
|
26
|
+
CAPPluginMethod(name: "debugGetLastFailure", returnType: CAPPluginReturnPromise),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
private let store = BundleStore()
|
|
30
|
+
private var downloader = Downloader()
|
|
31
|
+
private let zipUtils = ZipUtils()
|
|
32
|
+
private let fileManager = FileManager.default
|
|
33
|
+
|
|
34
|
+
private var appReadyTimeoutMs = 10_000
|
|
35
|
+
private var allowInsecureUrls = false
|
|
36
|
+
private var updateMode: UpdateMode = .nextLaunch
|
|
37
|
+
private var updateUrl = UpdaterPlugin.defaultUpdateURL
|
|
38
|
+
private var appId: String?
|
|
39
|
+
private var channel: String?
|
|
40
|
+
private var manifestKeys: [(kid: String, key: Data)] = []
|
|
41
|
+
private var trialTimeoutWorkItem: DispatchWorkItem?
|
|
42
|
+
private static let defaultUpdateURL = "https://www.otakit.app/api/v1"
|
|
43
|
+
private static let apiPathSuffix = "/api/v1"
|
|
44
|
+
|
|
45
|
+
public override func load() {
|
|
46
|
+
let envUpdateUrl = ProcessInfo.processInfo.environment["OTAKIT_SERVER_URL"]
|
|
47
|
+
updateUrl = resolveUpdateUrl(
|
|
48
|
+
configured: getConfig().getString("serverUrl"),
|
|
49
|
+
env: envUpdateUrl
|
|
50
|
+
)
|
|
51
|
+
appId = getConfig().getString("appId")
|
|
52
|
+
channel = trimToNil(getConfig().getString("channel"))
|
|
53
|
+
allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false)
|
|
54
|
+
let updateModeRaw = getConfig().getString("updateMode", UpdateMode.nextLaunch.rawValue)
|
|
55
|
+
updateMode = resolveUpdateMode(configured: updateModeRaw)
|
|
56
|
+
downloader = Downloader(allowInsecureUrls: allowInsecureUrls)
|
|
57
|
+
appReadyTimeoutMs = max(1000, getConfig().getInt("appReadyTimeout", 10_000))
|
|
58
|
+
|
|
59
|
+
let rawKeysValue = getConfig().getArray("manifestKeys")
|
|
60
|
+
if let rawKeys = rawKeysValue as? [[String: String]] {
|
|
61
|
+
manifestKeys = rawKeys.compactMap { entry in
|
|
62
|
+
guard let kid = entry["kid"],
|
|
63
|
+
let keyBase64 = entry["key"],
|
|
64
|
+
let keyData = Data(base64Encoded: keyBase64) else { return nil }
|
|
65
|
+
return (kid: kid, key: keyData)
|
|
66
|
+
}
|
|
67
|
+
if manifestKeys.isEmpty && !rawKeys.isEmpty {
|
|
68
|
+
print("[UpdateKit] ERROR: manifestKeys configured but all entries are invalid. Manifest verification will reject all updates.")
|
|
69
|
+
manifestKeys = [(kid: "_invalid_", key: Data())]
|
|
70
|
+
}
|
|
71
|
+
} else if rawKeysValue != nil {
|
|
72
|
+
print("[UpdateKit] ERROR: manifestKeys has wrong format (expected array of {kid, key}). Manifest verification will reject all updates.")
|
|
73
|
+
manifestKeys = [(kid: "_invalid_", key: Data())]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if manifestKeys.isEmpty && HostedManifestKeys.matchesManagedServer(updateUrl) {
|
|
77
|
+
manifestKeys = HostedManifestKeys.defaults
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var current = store.getCurrentBundle()
|
|
81
|
+
if current.status == .trial {
|
|
82
|
+
rollbackCurrentBundle(reason: "app_restarted_before_notify")
|
|
83
|
+
current = store.getCurrentBundle()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if shouldActivateStagedOnLaunch() {
|
|
87
|
+
current = activateStagedBundleForLaunch()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if !current.isBuiltin, let path = current.path {
|
|
91
|
+
applyServerBasePath(path)
|
|
92
|
+
} else {
|
|
93
|
+
applyServerBasePath(nil)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if current.status == .pending {
|
|
97
|
+
store.markStatus(bundleId: current.id, status: .trial)
|
|
98
|
+
scheduleTrialTimeout(for: current.id)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if isAutomaticUpdateMode() {
|
|
102
|
+
startAutoUpdate()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func startAutoUpdate() {
|
|
107
|
+
switch updateMode {
|
|
108
|
+
case .manual:
|
|
109
|
+
return
|
|
110
|
+
case .nextLaunch:
|
|
111
|
+
Task {
|
|
112
|
+
_ = try? await performCheckAndDownload(channel: nil, emitEvents: true)
|
|
113
|
+
}
|
|
114
|
+
case .immediate:
|
|
115
|
+
Task {
|
|
116
|
+
do {
|
|
117
|
+
let downloaded = try await performCheckAndDownload(channel: nil, emitEvents: true)
|
|
118
|
+
if downloaded != nil {
|
|
119
|
+
activateStagedBundleForReload()
|
|
120
|
+
reloadWebView()
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
print("[UpdateKit] immediate startup update failed: \(error.localizedDescription)")
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func shouldActivateStagedOnLaunch() -> Bool {
|
|
130
|
+
updateMode != .manual
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func isAutomaticUpdateMode() -> Bool {
|
|
134
|
+
updateMode != .manual
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private func resolveUpdateMode(configured: String?) -> UpdateMode {
|
|
138
|
+
let raw = configured?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
|
139
|
+
switch raw {
|
|
140
|
+
case "", UpdateMode.nextLaunch.rawValue:
|
|
141
|
+
return .nextLaunch
|
|
142
|
+
case UpdateMode.manual.rawValue:
|
|
143
|
+
return .manual
|
|
144
|
+
case UpdateMode.immediate.rawValue:
|
|
145
|
+
return .immediate
|
|
146
|
+
default:
|
|
147
|
+
print("[UpdateKit] Unknown updateMode '\(raw)', defaulting to 'next-launch'")
|
|
148
|
+
return .nextLaunch
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@objc func debugGetState(_ call: CAPPluginCall) {
|
|
153
|
+
let current = store.getCurrentBundle()
|
|
154
|
+
let staged: [String: Any]? = {
|
|
155
|
+
guard let stagedId = store.getStagedBundleId() else {
|
|
156
|
+
return nil
|
|
157
|
+
}
|
|
158
|
+
guard let staged = store.getBundle(id: stagedId) else {
|
|
159
|
+
store.setStagedBundleId(nil)
|
|
160
|
+
return nil
|
|
161
|
+
}
|
|
162
|
+
return staged.toDictionary()
|
|
163
|
+
}()
|
|
164
|
+
var payload: [String: Any] = [
|
|
165
|
+
"current": current.toDictionary(),
|
|
166
|
+
"fallback": store.getFallbackBundle().toDictionary(),
|
|
167
|
+
"builtinVersion": store.builtinVersion,
|
|
168
|
+
]
|
|
169
|
+
payload["staged"] = staged ?? NSNull()
|
|
170
|
+
call.resolve(payload)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@objc func debugCheck(_ call: CAPPluginCall) {
|
|
174
|
+
let requestedChannel = call.getString("channel")
|
|
175
|
+
let targetChannel = resolveTargetChannel(requestedChannel)
|
|
176
|
+
Task {
|
|
177
|
+
do {
|
|
178
|
+
let latest = try await fetchLatest(channel: targetChannel)
|
|
179
|
+
if let latest {
|
|
180
|
+
let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
|
|
181
|
+
call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
|
|
182
|
+
} else {
|
|
183
|
+
call.resolve()
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
call.reject("debugCheck failed: \(error.localizedDescription)")
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@objc func check(_ call: CAPPluginCall) {
|
|
192
|
+
let targetChannel = resolveTargetChannel(nil)
|
|
193
|
+
Task {
|
|
194
|
+
do {
|
|
195
|
+
let latest = try await fetchLatest(channel: targetChannel)
|
|
196
|
+
if let latest {
|
|
197
|
+
let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
|
|
198
|
+
call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
|
|
199
|
+
} else {
|
|
200
|
+
call.resolve()
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
call.reject("check failed: \(error.localizedDescription)")
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@objc func debugDownload(_ call: CAPPluginCall) {
|
|
209
|
+
let requestedChannel = call.getString("channel")
|
|
210
|
+
Task {
|
|
211
|
+
do {
|
|
212
|
+
let bundle = try await performCheckAndDownload(
|
|
213
|
+
channel: requestedChannel,
|
|
214
|
+
emitEvents: true
|
|
215
|
+
)
|
|
216
|
+
if let bundle {
|
|
217
|
+
call.resolve(bundle.toDictionary())
|
|
218
|
+
} else {
|
|
219
|
+
call.resolve()
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
call.reject("debugDownload failed: \(error.localizedDescription)")
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@objc func download(_ call: CAPPluginCall) {
|
|
228
|
+
Task {
|
|
229
|
+
do {
|
|
230
|
+
let bundle = try await performCheckAndDownload(channel: nil, emitEvents: true)
|
|
231
|
+
if let bundle {
|
|
232
|
+
call.resolve(bundle.toDictionary())
|
|
233
|
+
} else {
|
|
234
|
+
call.resolve()
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
call.reject("download failed: \(error.localizedDescription)")
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@objc func apply(_ call: CAPPluginCall) {
|
|
243
|
+
guard let stagedId = store.getStagedBundleId() else {
|
|
244
|
+
call.reject("No staged update to apply")
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
guard store.getBundle(id: stagedId) != nil else {
|
|
248
|
+
store.setStagedBundleId(nil)
|
|
249
|
+
call.reject("Staged bundle not found")
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
activateStagedBundleForReload()
|
|
254
|
+
call.resolve()
|
|
255
|
+
reloadWebView()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@objc func notifyAppReady(_ call: CAPPluginCall) {
|
|
259
|
+
let current = store.getCurrentBundle()
|
|
260
|
+
guard !current.isBuiltin else {
|
|
261
|
+
call.resolve()
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if current.status == .trial || current.status == .pending {
|
|
266
|
+
let oldFallback = store.getFallbackBundle()
|
|
267
|
+
|
|
268
|
+
store.markStatus(bundleId: current.id, status: .success)
|
|
269
|
+
store.setFallbackBundleId(current.id)
|
|
270
|
+
|
|
271
|
+
if let confirmed = store.getBundle(id: current.id) {
|
|
272
|
+
notifyListeners("appReady", data: confirmed.toDictionary())
|
|
273
|
+
} else {
|
|
274
|
+
notifyListeners("appReady", data: current.toDictionary())
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
sendStats(
|
|
278
|
+
action: .applied,
|
|
279
|
+
bundleVersion: current.version,
|
|
280
|
+
channel: current.channel,
|
|
281
|
+
releaseId: current.releaseId
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if !oldFallback.isBuiltin,
|
|
285
|
+
oldFallback.id != current.id {
|
|
286
|
+
try? store.deleteBundle(id: oldFallback.id)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
call.resolve()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@objc func debugReset(_ call: CAPPluginCall) {
|
|
294
|
+
cancelTrialTimeout()
|
|
295
|
+
store.setCurrentBundleId(nil)
|
|
296
|
+
store.setStagedBundleId(nil)
|
|
297
|
+
store.setFallbackBundleId(nil)
|
|
298
|
+
store.setFailedBundle(nil)
|
|
299
|
+
applyServerBasePath(nil)
|
|
300
|
+
call.resolve()
|
|
301
|
+
reloadWebView()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@objc func debugListBundles(_ call: CAPPluginCall) {
|
|
305
|
+
let bundles = store.listDownloadedBundles().map { $0.toDictionary() }
|
|
306
|
+
call.resolve(["bundles": bundles])
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@objc func debugDeleteBundle(_ call: CAPPluginCall) {
|
|
310
|
+
guard let bundleId = call.getString("bundleId") else {
|
|
311
|
+
call.reject("Missing bundleId")
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
guard store.bundleExists(id: bundleId) else {
|
|
315
|
+
call.reject("Bundle not found")
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let current = store.getCurrentBundle()
|
|
320
|
+
if current.id == bundleId {
|
|
321
|
+
call.reject("Cannot delete current bundle")
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let fallback = store.getFallbackBundle()
|
|
326
|
+
if fallback.id == bundleId {
|
|
327
|
+
call.reject("Cannot delete fallback bundle")
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if store.getStagedBundleId() == bundleId {
|
|
332
|
+
call.reject("Cannot delete staged bundle")
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
do {
|
|
337
|
+
try store.deleteBundle(id: bundleId)
|
|
338
|
+
call.resolve()
|
|
339
|
+
} catch {
|
|
340
|
+
call.reject("Failed to delete bundle: \(error.localizedDescription)")
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
@objc func debugGetLastFailure(_ call: CAPPluginCall) {
|
|
345
|
+
guard let failed = store.getFailedBundle() else {
|
|
346
|
+
call.resolve()
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
call.resolve(failed.toDictionary())
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private func fetchLatest(channel: String?) async throws -> LatestManifest? {
|
|
353
|
+
guard let appId else {
|
|
354
|
+
throw NSError(domain: "OtaKit", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing appId in plugin config"])
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let current = store.getCurrentBundle()
|
|
358
|
+
return try await ManifestClient.fetchLatest(
|
|
359
|
+
updateUrl: updateUrl,
|
|
360
|
+
appId: appId,
|
|
361
|
+
channel: channel,
|
|
362
|
+
currentVersion: current.version,
|
|
363
|
+
currentReleaseId: current.releaseId,
|
|
364
|
+
nativeBuild: store.nativeBuild,
|
|
365
|
+
platform: "ios",
|
|
366
|
+
allowInsecureUrls: allowInsecureUrls,
|
|
367
|
+
manifestKeys: manifestKeys.map {
|
|
368
|
+
ManifestKey(kid: $0.kid, derData: $0.key)
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private func performCheckAndDownload(
|
|
374
|
+
channel: String?,
|
|
375
|
+
emitEvents: Bool
|
|
376
|
+
) async throws -> BundleInfo? {
|
|
377
|
+
let targetChannel = resolveTargetChannel(channel)
|
|
378
|
+
var latest = try await fetchLatest(channel: targetChannel)
|
|
379
|
+
|
|
380
|
+
guard var manifest = latest else {
|
|
381
|
+
if emitEvents {
|
|
382
|
+
notifyListeners("noUpdateAvailable", data: [:])
|
|
383
|
+
}
|
|
384
|
+
return nil
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let staged = findMatchingStagedBundle(latest: manifest, targetChannel: targetChannel)
|
|
388
|
+
if emitEvents {
|
|
389
|
+
notifyListeners("updateAvailable", data: manifestToDictionary(manifest, downloaded: staged != nil))
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if let staged {
|
|
393
|
+
return staged
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
guard var url = URL(string: manifest.url) else {
|
|
397
|
+
throw NSError(
|
|
398
|
+
domain: "OtaKit",
|
|
399
|
+
code: 1,
|
|
400
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid download URL from manifest"]
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
do {
|
|
405
|
+
return try await downloadAndStage(
|
|
406
|
+
url: url,
|
|
407
|
+
version: manifest.version,
|
|
408
|
+
expectedSha256: manifest.sha256,
|
|
409
|
+
expectedSize: manifest.size,
|
|
410
|
+
channel: targetChannel,
|
|
411
|
+
releaseId: manifest.releaseId
|
|
412
|
+
)
|
|
413
|
+
} catch let error as NSError where isExpiredURLError(error) {
|
|
414
|
+
// Download URL may have expired — re-fetch manifest once and retry
|
|
415
|
+
latest = try await fetchLatest(channel: targetChannel)
|
|
416
|
+
guard let refreshed = latest else {
|
|
417
|
+
throw error
|
|
418
|
+
}
|
|
419
|
+
manifest = refreshed
|
|
420
|
+
guard let retryUrl = URL(string: manifest.url) else {
|
|
421
|
+
throw error
|
|
422
|
+
}
|
|
423
|
+
url = retryUrl
|
|
424
|
+
return try await downloadAndStage(
|
|
425
|
+
url: url,
|
|
426
|
+
version: manifest.version,
|
|
427
|
+
expectedSha256: manifest.sha256,
|
|
428
|
+
expectedSize: manifest.size,
|
|
429
|
+
channel: targetChannel,
|
|
430
|
+
releaseId: manifest.releaseId
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private func isExpiredURLError(_ error: NSError) -> Bool {
|
|
436
|
+
// HTTP 403 or 410 typically indicates an expired presigned URL
|
|
437
|
+
if error.domain == "Downloader" && (error.code == 403 || error.code == 410) {
|
|
438
|
+
return true
|
|
439
|
+
}
|
|
440
|
+
let desc = error.localizedDescription.lowercased()
|
|
441
|
+
return desc.contains("403") || desc.contains("410")
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private func downloadAndStage(
|
|
445
|
+
url: URL,
|
|
446
|
+
version: String,
|
|
447
|
+
expectedSha256: String,
|
|
448
|
+
expectedSize: Int? = nil,
|
|
449
|
+
channel: String? = nil,
|
|
450
|
+
releaseId: String? = nil
|
|
451
|
+
) async throws -> BundleInfo {
|
|
452
|
+
// Check disk space before downloading
|
|
453
|
+
if let size = expectedSize {
|
|
454
|
+
let requiredSpace = Int64(Double(size) * 2.5) // zip + extracted + buffer
|
|
455
|
+
let availableSpace = getFreeDiskSpace()
|
|
456
|
+
if availableSpace < requiredSpace {
|
|
457
|
+
let error = NSError(
|
|
458
|
+
domain: "OtaKit",
|
|
459
|
+
code: 1,
|
|
460
|
+
userInfo: [NSLocalizedDescriptionKey: "Insufficient disk space"]
|
|
461
|
+
)
|
|
462
|
+
sendStats(
|
|
463
|
+
action: .downloadError,
|
|
464
|
+
bundleVersion: version,
|
|
465
|
+
channel: channel,
|
|
466
|
+
releaseId: releaseId,
|
|
467
|
+
errorMessage: "insufficient_disk_space"
|
|
468
|
+
)
|
|
469
|
+
throw error
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
notifyListeners("downloadStarted", data: ["version": version])
|
|
474
|
+
let zipURL = try await downloader.download(from: url)
|
|
475
|
+
|
|
476
|
+
let extractDirectory = fileManager.temporaryDirectory
|
|
477
|
+
.appendingPathComponent("updatekit-extract-\(UUID().uuidString)", isDirectory: true)
|
|
478
|
+
defer {
|
|
479
|
+
try? fileManager.removeItem(at: zipURL)
|
|
480
|
+
try? fileManager.removeItem(at: extractDirectory)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
do {
|
|
484
|
+
let valid = try HashUtils.verify(
|
|
485
|
+
fileURL: zipURL,
|
|
486
|
+
expectedSha256: expectedSha256
|
|
487
|
+
)
|
|
488
|
+
guard valid else {
|
|
489
|
+
throw NSError(
|
|
490
|
+
domain: "OtaKit",
|
|
491
|
+
code: 1,
|
|
492
|
+
userInfo: [NSLocalizedDescriptionKey: "Downloaded bundle hash mismatch"]
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try zipUtils.extractSecurely(zipURL: zipURL, to: extractDirectory)
|
|
497
|
+
let bundleRoot = try resolveBundleRoot(extractedDirectory: extractDirectory)
|
|
498
|
+
|
|
499
|
+
let bundleId = buildBundleId(from: version)
|
|
500
|
+
let destination = store.bundleDirectory(for: bundleId)
|
|
501
|
+
|
|
502
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
503
|
+
try fileManager.removeItem(at: destination)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if bundleRoot.path != destination.path {
|
|
507
|
+
try fileManager.moveItem(at: bundleRoot, to: destination)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let info = BundleInfo(
|
|
511
|
+
id: bundleId,
|
|
512
|
+
version: version,
|
|
513
|
+
status: .pending,
|
|
514
|
+
downloadedAt: Date(),
|
|
515
|
+
sha256: expectedSha256,
|
|
516
|
+
path: destination.path,
|
|
517
|
+
channel: channel,
|
|
518
|
+
releaseId: releaseId
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
let previousStagedId = store.getStagedBundleId()
|
|
522
|
+
try store.saveBundle(info)
|
|
523
|
+
store.setStagedBundleId(bundleId)
|
|
524
|
+
cleanupSupersededStagedBundle(previousStagedId: previousStagedId, replacementId: bundleId)
|
|
525
|
+
|
|
526
|
+
notifyListeners("downloadComplete", data: info.toDictionary())
|
|
527
|
+
sendStats(
|
|
528
|
+
action: .downloaded,
|
|
529
|
+
bundleVersion: version,
|
|
530
|
+
channel: channel,
|
|
531
|
+
releaseId: releaseId
|
|
532
|
+
)
|
|
533
|
+
return info
|
|
534
|
+
} catch {
|
|
535
|
+
notifyListeners("downloadFailed", data: [
|
|
536
|
+
"version": version,
|
|
537
|
+
"error": error.localizedDescription,
|
|
538
|
+
])
|
|
539
|
+
sendStats(
|
|
540
|
+
action: .downloadError,
|
|
541
|
+
bundleVersion: version,
|
|
542
|
+
channel: channel,
|
|
543
|
+
releaseId: releaseId,
|
|
544
|
+
errorMessage: error.localizedDescription
|
|
545
|
+
)
|
|
546
|
+
throw error
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private func resolveBundleRoot(extractedDirectory: URL) throws -> URL {
|
|
551
|
+
let indexAtRoot = extractedDirectory.appendingPathComponent("index.html")
|
|
552
|
+
if fileManager.fileExists(atPath: indexAtRoot.path) {
|
|
553
|
+
return extractedDirectory
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let children = try fileManager.contentsOfDirectory(
|
|
557
|
+
at: extractedDirectory,
|
|
558
|
+
includingPropertiesForKeys: [.isDirectoryKey],
|
|
559
|
+
options: [.skipsHiddenFiles]
|
|
560
|
+
)
|
|
561
|
+
if children.count == 1 {
|
|
562
|
+
let child = children[0]
|
|
563
|
+
let childIndex = child.appendingPathComponent("index.html")
|
|
564
|
+
if fileManager.fileExists(atPath: childIndex.path) {
|
|
565
|
+
return child
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
throw NSError(
|
|
570
|
+
domain: "OtaKit",
|
|
571
|
+
code: 1,
|
|
572
|
+
userInfo: [NSLocalizedDescriptionKey: "Bundle archive does not contain index.html"]
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private func activateStagedBundleForLaunch() -> BundleInfo {
|
|
577
|
+
guard let stagedId = store.getStagedBundleId() else {
|
|
578
|
+
return store.getCurrentBundle()
|
|
579
|
+
}
|
|
580
|
+
guard let staged = store.getBundle(id: stagedId) else {
|
|
581
|
+
store.setStagedBundleId(nil)
|
|
582
|
+
return store.getCurrentBundle()
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
store.setCurrentBundleId(staged.id)
|
|
586
|
+
store.setStagedBundleId(nil)
|
|
587
|
+
return staged
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private func activateStagedBundleForReload() {
|
|
591
|
+
guard let stagedId = store.getStagedBundleId() else {
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
guard var staged = store.getBundle(id: stagedId) else {
|
|
595
|
+
store.setStagedBundleId(nil)
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
store.setCurrentBundleId(staged.id)
|
|
600
|
+
store.setStagedBundleId(nil)
|
|
601
|
+
|
|
602
|
+
if staged.status == .pending {
|
|
603
|
+
store.markStatus(bundleId: staged.id, status: .trial)
|
|
604
|
+
staged = store.getBundle(id: staged.id) ?? staged
|
|
605
|
+
scheduleTrialTimeout(for: staged.id)
|
|
606
|
+
} else if staged.status == .trial {
|
|
607
|
+
scheduleTrialTimeout(for: staged.id)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if let path = staged.path {
|
|
611
|
+
applyServerBasePath(path)
|
|
612
|
+
} else {
|
|
613
|
+
applyServerBasePath(nil)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private func scheduleTrialTimeout(for bundleId: String) {
|
|
618
|
+
cancelTrialTimeout()
|
|
619
|
+
|
|
620
|
+
let workItem = DispatchWorkItem { [weak self] in
|
|
621
|
+
guard let self else {
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
let current = self.store.getCurrentBundle()
|
|
625
|
+
guard current.id == bundleId else {
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
if current.status == .trial {
|
|
629
|
+
self.rollbackCurrentBundle(reason: "notify_timeout")
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
trialTimeoutWorkItem = workItem
|
|
633
|
+
|
|
634
|
+
DispatchQueue.main.asyncAfter(
|
|
635
|
+
deadline: .now() + .milliseconds(appReadyTimeoutMs),
|
|
636
|
+
execute: workItem
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private func cancelTrialTimeout() {
|
|
641
|
+
trialTimeoutWorkItem?.cancel()
|
|
642
|
+
trialTimeoutWorkItem = nil
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private func rollbackCurrentBundle(reason: String) {
|
|
646
|
+
cancelTrialTimeout()
|
|
647
|
+
|
|
648
|
+
let current = store.getCurrentBundle()
|
|
649
|
+
guard !current.isBuiltin else {
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let failed = BundleInfo(
|
|
654
|
+
id: current.id,
|
|
655
|
+
version: current.version,
|
|
656
|
+
status: .error,
|
|
657
|
+
downloadedAt: current.downloadedAt,
|
|
658
|
+
sha256: current.sha256,
|
|
659
|
+
path: current.path,
|
|
660
|
+
channel: current.channel,
|
|
661
|
+
releaseId: current.releaseId
|
|
662
|
+
)
|
|
663
|
+
store.markStatus(bundleId: current.id, status: .error)
|
|
664
|
+
store.setFailedBundle(failed)
|
|
665
|
+
store.setStagedBundleId(nil)
|
|
666
|
+
|
|
667
|
+
sendStats(
|
|
668
|
+
action: .rollback,
|
|
669
|
+
bundleVersion: current.version,
|
|
670
|
+
channel: current.channel,
|
|
671
|
+
releaseId: current.releaseId,
|
|
672
|
+
errorMessage: reason
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
let fallback = store.getFallbackBundle()
|
|
676
|
+
if fallback.isBuiltin {
|
|
677
|
+
store.setCurrentBundleId(nil)
|
|
678
|
+
applyServerBasePath(nil)
|
|
679
|
+
notifyListeners("rollback", data: [
|
|
680
|
+
"from": failed.toDictionary(),
|
|
681
|
+
"to": store.builtinBundle().toDictionary(),
|
|
682
|
+
"reason": reason,
|
|
683
|
+
])
|
|
684
|
+
} else if let fallbackPath = fallback.path {
|
|
685
|
+
store.setCurrentBundleId(fallback.id)
|
|
686
|
+
applyServerBasePath(fallbackPath)
|
|
687
|
+
notifyListeners("rollback", data: [
|
|
688
|
+
"from": failed.toDictionary(),
|
|
689
|
+
"to": fallback.toDictionary(),
|
|
690
|
+
"reason": reason,
|
|
691
|
+
])
|
|
692
|
+
} else {
|
|
693
|
+
store.setCurrentBundleId(nil)
|
|
694
|
+
applyServerBasePath(nil)
|
|
695
|
+
notifyListeners("rollback", data: [
|
|
696
|
+
"from": failed.toDictionary(),
|
|
697
|
+
"to": store.builtinBundle().toDictionary(),
|
|
698
|
+
"reason": reason,
|
|
699
|
+
])
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
try? store.deleteBundle(id: current.id)
|
|
703
|
+
|
|
704
|
+
reloadWebView()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private func cleanupSupersededStagedBundle(
|
|
708
|
+
previousStagedId: String?,
|
|
709
|
+
replacementId: String
|
|
710
|
+
) {
|
|
711
|
+
guard let previousStagedId,
|
|
712
|
+
previousStagedId != replacementId,
|
|
713
|
+
previousStagedId != "builtin" else {
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let current = store.getCurrentBundle()
|
|
718
|
+
if previousStagedId == current.id {
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let fallback = store.getFallbackBundle()
|
|
723
|
+
if previousStagedId == fallback.id {
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
try? store.deleteBundle(id: previousStagedId)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private func applyServerBasePath(_ path: String?) {
|
|
731
|
+
DispatchQueue.main.async { [weak self] in
|
|
732
|
+
if let path, !path.isEmpty {
|
|
733
|
+
self?.bridge?.setServerBasePath(path)
|
|
734
|
+
} else {
|
|
735
|
+
let builtinPath = Bundle.main.resourceURL?
|
|
736
|
+
.appendingPathComponent("public", isDirectory: true).path ?? ""
|
|
737
|
+
self?.bridge?.setServerBasePath(builtinPath)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private func reloadWebView() {
|
|
743
|
+
DispatchQueue.main.async { [weak self] in
|
|
744
|
+
if self?.bridge?.webView == nil {
|
|
745
|
+
print("[OtaKit] WARNING: WebView not available for reload")
|
|
746
|
+
}
|
|
747
|
+
self?.bridge?.webView?.reload()
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private func manifestToDictionary(
|
|
752
|
+
_ latest: LatestManifest,
|
|
753
|
+
downloaded: Bool = false
|
|
754
|
+
) -> [String: Any] {
|
|
755
|
+
var payload: [String: Any] = [
|
|
756
|
+
"version": latest.version,
|
|
757
|
+
"url": latest.url,
|
|
758
|
+
"sha256": latest.sha256,
|
|
759
|
+
"size": latest.size,
|
|
760
|
+
"downloaded": downloaded,
|
|
761
|
+
]
|
|
762
|
+
if let releaseId = latest.releaseId {
|
|
763
|
+
payload["releaseId"] = releaseId
|
|
764
|
+
}
|
|
765
|
+
if let minNativeBuild = latest.minNativeBuild {
|
|
766
|
+
payload["minNativeBuild"] = minNativeBuild
|
|
767
|
+
}
|
|
768
|
+
return payload
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private func findMatchingStagedBundle(
|
|
772
|
+
latest: LatestManifest,
|
|
773
|
+
targetChannel: String?
|
|
774
|
+
) -> BundleInfo? {
|
|
775
|
+
guard let stagedId = store.getStagedBundleId() else {
|
|
776
|
+
return nil
|
|
777
|
+
}
|
|
778
|
+
guard let staged = store.getBundle(id: stagedId) else {
|
|
779
|
+
store.setStagedBundleId(nil)
|
|
780
|
+
return nil
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if trimToNil(staged.channel) != targetChannel {
|
|
784
|
+
return nil
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if let latestReleaseId = latest.releaseId,
|
|
788
|
+
let stagedReleaseId = staged.releaseId,
|
|
789
|
+
latestReleaseId == stagedReleaseId {
|
|
790
|
+
return staged
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if latest.version == staged.version,
|
|
794
|
+
latest.sha256 == staged.sha256 {
|
|
795
|
+
return staged
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return nil
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private func buildBundleId(from version: String) -> String {
|
|
802
|
+
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
803
|
+
let allowed = CharacterSet(
|
|
804
|
+
charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
var normalizedScalars = String.UnicodeScalarView()
|
|
808
|
+
for scalar in trimmed.unicodeScalars {
|
|
809
|
+
if allowed.contains(scalar) {
|
|
810
|
+
normalizedScalars.append(scalar)
|
|
811
|
+
} else {
|
|
812
|
+
normalizedScalars.append("-")
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
var normalized = String(normalizedScalars).replacingOccurrences(
|
|
817
|
+
of: "-{2,}",
|
|
818
|
+
with: "-",
|
|
819
|
+
options: .regularExpression
|
|
820
|
+
)
|
|
821
|
+
normalized = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
|
|
822
|
+
|
|
823
|
+
if normalized.isEmpty {
|
|
824
|
+
normalized = "bundle"
|
|
825
|
+
}
|
|
826
|
+
if normalized.count > 64 {
|
|
827
|
+
normalized = String(normalized.prefix(64))
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
let digest = SHA256.hash(data: Data(trimmed.utf8))
|
|
831
|
+
let suffix = digest.map { String(format: "%02x", $0) }.joined().prefix(12)
|
|
832
|
+
return "\(normalized)-\(suffix)"
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private func sendStats(
|
|
836
|
+
action: StatsAction,
|
|
837
|
+
bundleVersion: String? = nil,
|
|
838
|
+
channel: String? = nil,
|
|
839
|
+
releaseId: String? = nil,
|
|
840
|
+
errorMessage: String? = nil
|
|
841
|
+
) {
|
|
842
|
+
guard let appId else {
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
StatsClient.send(
|
|
846
|
+
updateUrl: updateUrl,
|
|
847
|
+
appId: appId,
|
|
848
|
+
platform: "ios",
|
|
849
|
+
action: action,
|
|
850
|
+
bundleVersion: bundleVersion,
|
|
851
|
+
channel: channel,
|
|
852
|
+
releaseId: releaseId,
|
|
853
|
+
nativeBuild: store.nativeBuild,
|
|
854
|
+
errorMessage: errorMessage
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private func resolveTargetChannel(_ channel: String?) -> String? {
|
|
859
|
+
if let channel = trimToNil(channel) {
|
|
860
|
+
return channel
|
|
861
|
+
}
|
|
862
|
+
return self.channel
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
private func trimToNil(_ value: String?) -> String? {
|
|
866
|
+
guard let value else {
|
|
867
|
+
return nil
|
|
868
|
+
}
|
|
869
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
870
|
+
return trimmed.isEmpty ? nil : trimmed
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
private func resolveUpdateUrl(configured: String?, env: String?) -> String {
|
|
874
|
+
let configuredValue = configured?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
875
|
+
if let configuredValue, !configuredValue.isEmpty {
|
|
876
|
+
return normalizeUpdateUrl(configuredValue)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
let envValue = env?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
880
|
+
if let envValue, !envValue.isEmpty {
|
|
881
|
+
return normalizeUpdateUrl(envValue)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return UpdaterPlugin.defaultUpdateURL
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private func normalizeUpdateUrl(_ raw: String) -> String {
|
|
888
|
+
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
889
|
+
let withoutTrailingSlash = trimmed.replacingOccurrences(
|
|
890
|
+
of: "/+$",
|
|
891
|
+
with: "",
|
|
892
|
+
options: .regularExpression
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
if withoutTrailingSlash.lowercased().hasSuffix(UpdaterPlugin.apiPathSuffix) {
|
|
896
|
+
return withoutTrailingSlash
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return withoutTrailingSlash + UpdaterPlugin.apiPathSuffix
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
private func getFreeDiskSpace() -> Int64 {
|
|
903
|
+
do {
|
|
904
|
+
let attributes = try fileManager.attributesOfFileSystem(
|
|
905
|
+
forPath: NSHomeDirectory()
|
|
906
|
+
)
|
|
907
|
+
if let freeSize = attributes[.systemFreeSize] as? Int64 {
|
|
908
|
+
return freeSize
|
|
909
|
+
}
|
|
910
|
+
} catch {}
|
|
911
|
+
return Int64.max // If we can't determine, allow download
|
|
912
|
+
}
|
|
913
|
+
}
|