@onekeyfe/react-native-range-downloader 3.0.39
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 +36 -0
- package/ReactNativeRangeDownloader.podspec +30 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +132 -0
- package/android/gradle.properties +4 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +340 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +233 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloaderPackage.kt +24 -0
- package/ios/ReactNativeRangeDownloader.swift +732 -0
- package/lib/module/ReactNativeRangeDownloader.nitro.js +4 -0
- package/lib/module/ReactNativeRangeDownloader.nitro.js.map +1 -0
- package/lib/module/index.js +15 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +35 -0
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +9 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/nitro.json +17 -0
- package/nitrogen/generated/android/c++/JDownloadChannel.hpp +62 -0
- package/nitrogen/generated/android/c++/JFunc_void_RangeDownloadEvent.hpp +80 -0
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +117 -0
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +69 -0
- package/nitrogen/generated/android/c++/JRangeDownloadEvent.hpp +75 -0
- package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +59 -0
- package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +84 -0
- package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +68 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/DownloadChannel.kt +22 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/Func_void_RangeDownloadEvent.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +79 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadEvent.kt +50 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +21 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +44 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/reactnativerangedownloaderOnLoad.kt +35 -0
- package/nitrogen/generated/android/reactnativerangedownloader+autolinking.cmake +81 -0
- package/nitrogen/generated/android/reactnativerangedownloader+autolinking.gradle +27 -0
- package/nitrogen/generated/android/reactnativerangedownloaderOnLoad.cpp +46 -0
- package/nitrogen/generated/android/reactnativerangedownloaderOnLoad.hpp +25 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader+autolinking.rb +60 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.cpp +65 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +246 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +62 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloaderAutolinking.mm +33 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloaderAutolinking.swift +25 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +123 -0
- package/nitrogen/generated/ios/swift/DownloadChannel.swift +44 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_RangeDownloadEvent.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_RangeDownloadResult.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +47 -0
- package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +60 -0
- package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +197 -0
- package/nitrogen/generated/ios/swift/RangeDownloadEvent.swift +80 -0
- package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +40 -0
- package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +145 -0
- package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +77 -0
- package/nitrogen/generated/shared/c++/DownloadChannel.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +25 -0
- package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +79 -0
- package/nitrogen/generated/shared/c++/RangeDownloadEvent.hpp +93 -0
- package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +76 -0
- package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +102 -0
- package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +86 -0
- package/package.json +169 -0
- package/src/ReactNativeRangeDownloader.nitro.ts +60 -0
- package/src/index.tsx +20 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CommonCrypto
|
|
3
|
+
import NitroModules
|
|
4
|
+
import ReactNativeNativeLogger
|
|
5
|
+
|
|
6
|
+
// MARK: - Nitro HybridObject entry point
|
|
7
|
+
//
|
|
8
|
+
// Thin Nitro shim over `RangeDownloader.shared`. The heavy lifting — concurrent
|
|
9
|
+
// background range download, segment stash/concatenate, resume — is migrated
|
|
10
|
+
// almost verbatim from react-native-bundle-update's ConcurrentBundleDownloader.
|
|
11
|
+
// The only structural changes vs. that origin are:
|
|
12
|
+
// - single global run state → a `runs` table keyed by "channel|taskId", so
|
|
13
|
+
// different channels (apk, chart, bundle) can download concurrently;
|
|
14
|
+
// - one hardcoded background session identifier → one session per channel;
|
|
15
|
+
// - `onProgress` closure → a listener registry broadcasting
|
|
16
|
+
// `RangeDownloadEvent`s (multi-consumer);
|
|
17
|
+
// - `throws FallbackError` → returns `RangeDownloadResult(.fallback, …)`.
|
|
18
|
+
class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec {
|
|
19
|
+
|
|
20
|
+
func download(params: RangeDownloadParams) throws -> Promise<RangeDownloadResult> {
|
|
21
|
+
return Promise.async {
|
|
22
|
+
let (outcome, filePath, fallbackReason) = await RangeDownloader.shared.download(
|
|
23
|
+
channel: params.channel,
|
|
24
|
+
taskId: params.taskId,
|
|
25
|
+
urlString: params.url,
|
|
26
|
+
filePath: params.destFilePath,
|
|
27
|
+
expectedSha256: params.expectedSha256,
|
|
28
|
+
segmentCount: params.segmentCount.map { Int($0) },
|
|
29
|
+
minConcurrentBytes: params.minConcurrentBytes.map { Int64($0) }
|
|
30
|
+
)
|
|
31
|
+
return RangeDownloadResult(
|
|
32
|
+
outcome: outcome,
|
|
33
|
+
filePath: filePath,
|
|
34
|
+
fallbackReason: fallbackReason
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func discardArtifacts(
|
|
40
|
+
channel: DownloadChannel,
|
|
41
|
+
taskId: String,
|
|
42
|
+
destFilePath: String
|
|
43
|
+
) throws -> Promise<Void> {
|
|
44
|
+
return Promise.async {
|
|
45
|
+
RangeDownloader.shared.discardArtifacts(filePath: destFilePath)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func addDownloadListener(
|
|
50
|
+
callback: @escaping (_ event: RangeDownloadEvent) -> Void
|
|
51
|
+
) throws -> Double {
|
|
52
|
+
return Double(RangeDownloader.shared.addListener(callback))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func removeDownloadListener(id: Double) throws {
|
|
56
|
+
RangeDownloader.shared.removeListener(Int(id))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// App Caches directory — an app-owned, writable absolute path resolved at
|
|
60
|
+
// runtime (no hardcoded sandbox path).
|
|
61
|
+
func getDownloadsDir() throws -> String {
|
|
62
|
+
if let caches = FileManager.default.urls(
|
|
63
|
+
for: .cachesDirectory, in: .userDomainMask
|
|
64
|
+
).first {
|
|
65
|
+
return caches.path
|
|
66
|
+
}
|
|
67
|
+
return NSTemporaryDirectory()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MARK: - RangeDownloader (migrated core)
|
|
72
|
+
|
|
73
|
+
/// Concurrent + background multi-range downloader for iOS.
|
|
74
|
+
///
|
|
75
|
+
/// Each channel owns its own background `URLSession` (identifier
|
|
76
|
+
/// "so.onekey.rangedownloader.bg.<channel>"). A background session carries
|
|
77
|
+
/// [segmentCount] download tasks, each requesting a byte Range of the file. A
|
|
78
|
+
/// background session satisfies BOTH requirements at once:
|
|
79
|
+
/// - concurrency: the range tasks run in parallel (foreground = full speed);
|
|
80
|
+
/// - background: `nsurlsessiond` keeps transferring while the app is
|
|
81
|
+
/// suspended and even relaunches the app to deliver completion events.
|
|
82
|
+
/// It needs NO new entitlement.
|
|
83
|
+
///
|
|
84
|
+
/// Because background download tasks deliver a whole file per task at
|
|
85
|
+
/// completion (not an incremental stream), each segment is downloaded to its
|
|
86
|
+
/// own `<file>.segN` and the segments are concatenated in order once all are
|
|
87
|
+
/// present. A segment file is therefore atomic — fully present or absent — so
|
|
88
|
+
/// resume across app suspension/kill is just "which `.segN` already exist".
|
|
89
|
+
///
|
|
90
|
+
/// Unlike the single-run origin, this downloader keeps a `runs` table keyed by
|
|
91
|
+
/// "channel|taskId" so multiple channels can download at once. Each running
|
|
92
|
+
/// task's `taskDescription` encodes "channel|taskId|segIndex" so a delegate
|
|
93
|
+
/// callback can locate both the run and the segment.
|
|
94
|
+
public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
95
|
+
|
|
96
|
+
public static let shared = RangeDownloader()
|
|
97
|
+
|
|
98
|
+
/// Posted by the AppDelegate from
|
|
99
|
+
/// application(_:handleEventsForBackgroundURLSession:completionHandler:).
|
|
100
|
+
/// userInfo: ["identifier": String, "completionHandler": () -> Void].
|
|
101
|
+
/// (The AppDelegate posts this generic name for every background URLSession.)
|
|
102
|
+
static let backgroundEventsNotification =
|
|
103
|
+
Notification.Name("RangeDownloaderBackgroundEvents")
|
|
104
|
+
|
|
105
|
+
/// Our per-channel session identifier prefix. Identifiers not carrying this
|
|
106
|
+
/// prefix (and not the legacy one) are ignored so we never steal another
|
|
107
|
+
/// module's background session events.
|
|
108
|
+
private static let sessionIdentifierPrefix = "so.onekey.rangedownloader.bg."
|
|
109
|
+
/// Legacy bundle-update identifier prefix, recognized during the transition
|
|
110
|
+
/// so events queued by an older build still route here.
|
|
111
|
+
private static let legacySessionIdentifierPrefix = "so.onekey.bundleupdate.concurrent.bg"
|
|
112
|
+
|
|
113
|
+
private static let defaultSegmentCount = 8
|
|
114
|
+
private static let defaultMinConcurrentBytes: Int64 = 2 * 1024 * 1024
|
|
115
|
+
|
|
116
|
+
private let lock = NSLock()
|
|
117
|
+
|
|
118
|
+
/// Active-run state keyed by "channel|taskId". Replaces the origin's single
|
|
119
|
+
/// global run state to support concurrent channels.
|
|
120
|
+
private var runs: [String: RunState] = [:]
|
|
121
|
+
|
|
122
|
+
/// Lazy per-channel background sessions, cached by identifier.
|
|
123
|
+
private var sessions: [String: URLSession] = [:]
|
|
124
|
+
|
|
125
|
+
/// Listener registry. The Nitro layer registers JS callbacks here; events are
|
|
126
|
+
/// broadcast to all of them (replaces the origin's single `onProgress`).
|
|
127
|
+
private var listeners: [Int: (RangeDownloadEvent) -> Void] = [:]
|
|
128
|
+
private var nextListenerId = 1
|
|
129
|
+
|
|
130
|
+
/// Stored by the AppDelegate's handleEventsForBackgroundURLSession (via the
|
|
131
|
+
/// notification), keyed by session identifier, so we can call each back once
|
|
132
|
+
/// all its queued background events have been delivered.
|
|
133
|
+
private var backgroundCompletionHandlers: [String: () -> Void] = [:]
|
|
134
|
+
|
|
135
|
+
// Per-run mutable state.
|
|
136
|
+
private final class RunState {
|
|
137
|
+
let channel: DownloadChannel
|
|
138
|
+
let taskId: String
|
|
139
|
+
let filePath: String
|
|
140
|
+
let segmentCount: Int
|
|
141
|
+
var totalSize: Int64 = 0
|
|
142
|
+
var etag: String?
|
|
143
|
+
var ranges: [(start: Int64, end: Int64)] = []
|
|
144
|
+
var segmentWritten: [Int64] = [] // progress estimate per segment
|
|
145
|
+
var continuation: CheckedContinuation<Void, Error>?
|
|
146
|
+
var prevProgress = -1
|
|
147
|
+
var fellBack = false
|
|
148
|
+
let sessionIdentifier: String
|
|
149
|
+
|
|
150
|
+
init(channel: DownloadChannel, taskId: String, filePath: String,
|
|
151
|
+
segmentCount: Int, sessionIdentifier: String) {
|
|
152
|
+
self.channel = channel
|
|
153
|
+
self.taskId = taskId
|
|
154
|
+
self.filePath = filePath
|
|
155
|
+
self.segmentCount = segmentCount
|
|
156
|
+
self.sessionIdentifier = sessionIdentifier
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func segPath(_ index: Int) -> String { "\(filePath).seg\(index)" }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
override init() {
|
|
163
|
+
super.init()
|
|
164
|
+
NotificationCenter.default.addObserver(
|
|
165
|
+
self,
|
|
166
|
+
selector: #selector(handleBackgroundEventsNotification(_:)),
|
|
167
|
+
name: Self.backgroundEventsNotification,
|
|
168
|
+
object: nil
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// MARK: - Identifier <-> channel
|
|
173
|
+
|
|
174
|
+
private static func sessionIdentifier(for channel: DownloadChannel) -> String {
|
|
175
|
+
return "\(sessionIdentifierPrefix)\(channel.stringValue)"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Reverse-resolves a channel from a session identifier; nil if the
|
|
179
|
+
/// identifier doesn't belong to this module (so we don't preempt it).
|
|
180
|
+
private static func channel(forIdentifier identifier: String) -> DownloadChannel? {
|
|
181
|
+
if identifier.hasPrefix(sessionIdentifierPrefix) {
|
|
182
|
+
let raw = String(identifier.dropFirst(sessionIdentifierPrefix.count))
|
|
183
|
+
return DownloadChannel(fromString: raw)
|
|
184
|
+
}
|
|
185
|
+
// Legacy bundle-update identifier maps to the bundle channel during the
|
|
186
|
+
// transition.
|
|
187
|
+
if identifier == legacySessionIdentifierPrefix
|
|
188
|
+
|| identifier.hasPrefix(legacySessionIdentifierPrefix) {
|
|
189
|
+
return .bundle
|
|
190
|
+
}
|
|
191
|
+
return nil
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@objc private func handleBackgroundEventsNotification(_ note: Notification) {
|
|
195
|
+
guard let identifier = note.userInfo?["identifier"] as? String,
|
|
196
|
+
Self.channel(forIdentifier: identifier) != nil else { return }
|
|
197
|
+
// Re-create the session with this delegate so queued completion events are
|
|
198
|
+
// delivered here on a background relaunch.
|
|
199
|
+
_ = session(forIdentifier: identifier)
|
|
200
|
+
if let handler = note.userInfo?["completionHandler"] as? () -> Void {
|
|
201
|
+
lock.lock()
|
|
202
|
+
backgroundCompletionHandlers[identifier] = handler
|
|
203
|
+
lock.unlock()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// MARK: - Session cache
|
|
208
|
+
|
|
209
|
+
private func session(forChannel channel: DownloadChannel, segmentCount: Int) -> URLSession {
|
|
210
|
+
return session(forIdentifier: Self.sessionIdentifier(for: channel),
|
|
211
|
+
segmentCount: segmentCount)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func session(forIdentifier identifier: String,
|
|
215
|
+
segmentCount: Int = RangeDownloader.defaultSegmentCount) -> URLSession {
|
|
216
|
+
lock.lock()
|
|
217
|
+
if let existing = sessions[identifier] {
|
|
218
|
+
lock.unlock()
|
|
219
|
+
return existing
|
|
220
|
+
}
|
|
221
|
+
lock.unlock()
|
|
222
|
+
let cfg = URLSessionConfiguration.background(withIdentifier: identifier)
|
|
223
|
+
cfg.tlsMinimumSupportedProtocolVersion = .TLSv12
|
|
224
|
+
cfg.isDiscretionary = false
|
|
225
|
+
cfg.sessionSendsLaunchEvents = true
|
|
226
|
+
cfg.httpMaximumConnectionsPerHost = segmentCount
|
|
227
|
+
let created = URLSession(configuration: cfg, delegate: self, delegateQueue: nil)
|
|
228
|
+
lock.lock()
|
|
229
|
+
// Another thread may have raced us; prefer the first-stored session.
|
|
230
|
+
if let existing = sessions[identifier] {
|
|
231
|
+
lock.unlock()
|
|
232
|
+
created.invalidateAndCancel()
|
|
233
|
+
return existing
|
|
234
|
+
}
|
|
235
|
+
sessions[identifier] = created
|
|
236
|
+
lock.unlock()
|
|
237
|
+
return created
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// MARK: - Listeners
|
|
241
|
+
|
|
242
|
+
public func addListener(_ callback: @escaping (RangeDownloadEvent) -> Void) -> Int {
|
|
243
|
+
lock.lock(); defer { lock.unlock() }
|
|
244
|
+
let id = nextListenerId
|
|
245
|
+
nextListenerId += 1
|
|
246
|
+
listeners[id] = callback
|
|
247
|
+
return id
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
public func removeListener(_ id: Int) {
|
|
251
|
+
lock.lock(); defer { lock.unlock() }
|
|
252
|
+
listeners.removeValue(forKey: id)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private func emit(channel: DownloadChannel, taskId: String, type: String,
|
|
256
|
+
progress: Double, message: String) {
|
|
257
|
+
let snapshot: [(RangeDownloadEvent) -> Void] = lock.withLockValue {
|
|
258
|
+
Array(self.listeners.values)
|
|
259
|
+
}
|
|
260
|
+
guard !snapshot.isEmpty else { return }
|
|
261
|
+
let event = RangeDownloadEvent(
|
|
262
|
+
channel: channel, taskId: taskId, type: type, progress: progress, message: message
|
|
263
|
+
)
|
|
264
|
+
for cb in snapshot { cb(event) }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// MARK: - Run table helpers
|
|
268
|
+
|
|
269
|
+
private static func runKey(channel: DownloadChannel, taskId: String) -> String {
|
|
270
|
+
return "\(channel.stringValue)|\(taskId)"
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// taskDescription codec: "channel|taskId|segIndex".
|
|
274
|
+
private static func encodeTaskDescription(channel: DownloadChannel, taskId: String,
|
|
275
|
+
segIndex: Int) -> String {
|
|
276
|
+
return "\(channel.stringValue)|\(taskId)|\(segIndex)"
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private static func decodeTaskDescription(_ desc: String)
|
|
280
|
+
-> (channel: DownloadChannel, taskId: String, segIndex: Int)? {
|
|
281
|
+
// taskId may itself contain '|'? Keep it conservative: split into exactly 3
|
|
282
|
+
// by taking the first and last fields, joining the middle as taskId.
|
|
283
|
+
let parts = desc.components(separatedBy: "|")
|
|
284
|
+
guard parts.count >= 3,
|
|
285
|
+
let channel = DownloadChannel(fromString: parts[0]),
|
|
286
|
+
let segIndex = Int(parts[parts.count - 1]) else { return nil }
|
|
287
|
+
let taskId = parts[1..<(parts.count - 1)].joined(separator: "|")
|
|
288
|
+
return (channel, taskId, segIndex)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private func run(forKey key: String) -> RunState? {
|
|
292
|
+
lock.withLockValue { self.runs[key] }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private func run(for desc: String) -> (run: RunState, segIndex: Int)? {
|
|
296
|
+
guard let decoded = Self.decodeTaskDescription(desc) else { return nil }
|
|
297
|
+
let key = Self.runKey(channel: decoded.channel, taskId: decoded.taskId)
|
|
298
|
+
guard let r = run(forKey: key) else { return nil }
|
|
299
|
+
return (r, decoded.segIndex)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// MARK: - Public entry
|
|
303
|
+
|
|
304
|
+
/// Downloads [urlString] into [filePath] using concurrent background ranges.
|
|
305
|
+
/// Returns `(.completed, filePath, nil)` on success, or `(.fallback, filePath,
|
|
306
|
+
/// reason)` when the caller should use its single-stream path. Transient
|
|
307
|
+
/// network errors are also reported as `.fallback` with the error reason (the
|
|
308
|
+
/// `.segN` files are kept for the next attempt).
|
|
309
|
+
public func download(
|
|
310
|
+
channel: DownloadChannel,
|
|
311
|
+
taskId: String,
|
|
312
|
+
urlString: String,
|
|
313
|
+
filePath: String,
|
|
314
|
+
expectedSha256: String?,
|
|
315
|
+
segmentCount: Int?,
|
|
316
|
+
minConcurrentBytes: Int64?
|
|
317
|
+
) async -> (RangeDownloadOutcome, String, String?) {
|
|
318
|
+
let segCount = max(1, segmentCount ?? Self.defaultSegmentCount)
|
|
319
|
+
let minBytes = minConcurrentBytes ?? Self.defaultMinConcurrentBytes
|
|
320
|
+
let key = Self.runKey(channel: channel, taskId: taskId)
|
|
321
|
+
|
|
322
|
+
guard let url = URL(string: urlString) else {
|
|
323
|
+
return (.fallback, filePath, "invalid url")
|
|
324
|
+
}
|
|
325
|
+
// HTTPS-only: background URLSession + transport hardening.
|
|
326
|
+
guard urlString.hasPrefix("https://") else {
|
|
327
|
+
return (.fallback, filePath, "url must use https")
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let probe: ProbeResult
|
|
331
|
+
do {
|
|
332
|
+
probe = try await self.probe(url: url)
|
|
333
|
+
} catch {
|
|
334
|
+
return (.fallback, filePath, "probe failed: \(error.localizedDescription)")
|
|
335
|
+
}
|
|
336
|
+
guard probe.supportsRange, probe.total >= minBytes else {
|
|
337
|
+
return (.fallback, filePath, "range unsupported or file too small")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let state = RunState(
|
|
341
|
+
channel: channel, taskId: taskId, filePath: filePath,
|
|
342
|
+
segmentCount: segCount,
|
|
343
|
+
sessionIdentifier: Self.sessionIdentifier(for: channel)
|
|
344
|
+
)
|
|
345
|
+
state.totalSize = probe.total
|
|
346
|
+
state.etag = probe.etag
|
|
347
|
+
state.ranges = Self.planRanges(total: probe.total, segments: segCount)
|
|
348
|
+
state.segmentWritten = [Int64](repeating: 0, count: state.ranges.count)
|
|
349
|
+
|
|
350
|
+
lock.lock()
|
|
351
|
+
runs[key] = state
|
|
352
|
+
lock.unlock()
|
|
353
|
+
|
|
354
|
+
let ranges = state.ranges
|
|
355
|
+
|
|
356
|
+
emit(channel: channel, taskId: taskId, type: "start", progress: 0, message: "")
|
|
357
|
+
|
|
358
|
+
do {
|
|
359
|
+
// If every segment is already on disk (resume after suspension/kill), skip
|
|
360
|
+
// straight to concatenation.
|
|
361
|
+
if allSegmentsPresent(state: state, ranges: ranges) {
|
|
362
|
+
try concatenateAndFinish(state: state, ranges: ranges)
|
|
363
|
+
} else {
|
|
364
|
+
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
365
|
+
self.lock.lock()
|
|
366
|
+
state.continuation = cont
|
|
367
|
+
self.lock.unlock()
|
|
368
|
+
reconcileAndStartTasks(state: state, url: url, ranges: ranges)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch let fb as FallbackError {
|
|
372
|
+
clearRun(key: key)
|
|
373
|
+
emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason)
|
|
374
|
+
return (.fallback, filePath, fb.reason)
|
|
375
|
+
} catch {
|
|
376
|
+
// Transient error → ask caller to fall back (segments retained for retry).
|
|
377
|
+
clearRun(key: key)
|
|
378
|
+
let reason = error.localizedDescription
|
|
379
|
+
emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
|
|
380
|
+
return (.fallback, filePath, reason)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
clearRun(key: key)
|
|
384
|
+
|
|
385
|
+
// Optional immediate SHA256 self-check backstop. When omitted, the caller
|
|
386
|
+
// verifies after the fact.
|
|
387
|
+
if let expected = expectedSha256, !expected.isEmpty {
|
|
388
|
+
let actual = Self.calculateSHA256(filePath)
|
|
389
|
+
if actual?.lowercased() != expected.lowercased() {
|
|
390
|
+
try? FileManager.default.removeItem(atPath: filePath)
|
|
391
|
+
let reason = "sha256 mismatch (expected \(expected), got \(actual ?? "nil"))"
|
|
392
|
+
OneKeyLog.error("RangeDownloader", "\(channel.stringValue)/\(taskId): \(reason)")
|
|
393
|
+
emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
|
|
394
|
+
return (.fallback, filePath, reason)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
emit(channel: channel, taskId: taskId, type: "complete", progress: 100, message: "")
|
|
399
|
+
return (.completed, filePath, nil)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private func clearRun(key: String) {
|
|
403
|
+
lock.lock(); runs.removeValue(forKey: key); lock.unlock()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// MARK: - Range planning / probing
|
|
407
|
+
|
|
408
|
+
static func planRanges(total: Int64, segments: Int) -> [(start: Int64, end: Int64)] {
|
|
409
|
+
var out: [(Int64, Int64)] = []
|
|
410
|
+
let chunk = (total + Int64(segments) - 1) / Int64(segments)
|
|
411
|
+
var i = 0
|
|
412
|
+
while i < segments {
|
|
413
|
+
let start = Int64(i) * chunk
|
|
414
|
+
if start >= total { break }
|
|
415
|
+
let end = min(start + chunk - 1, total - 1)
|
|
416
|
+
out.append((start, end))
|
|
417
|
+
i += 1
|
|
418
|
+
}
|
|
419
|
+
return out
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private struct ProbeResult { let total: Int64; let etag: String?; let supportsRange: Bool }
|
|
423
|
+
|
|
424
|
+
struct FallbackError: Error { let reason: String }
|
|
425
|
+
|
|
426
|
+
/// One-byte Range request on a default (foreground) session to learn total
|
|
427
|
+
/// size + ETag + Range support. Background sessions can't do data tasks, so
|
|
428
|
+
/// the probe uses an ephemeral session.
|
|
429
|
+
private func probe(url: URL) async throws -> ProbeResult {
|
|
430
|
+
var req = URLRequest(url: url)
|
|
431
|
+
req.setValue("bytes=0-0", forHTTPHeaderField: "Range")
|
|
432
|
+
let cfg = URLSessionConfiguration.ephemeral
|
|
433
|
+
cfg.tlsMinimumSupportedProtocolVersion = .TLSv12
|
|
434
|
+
let probeSession = URLSession(configuration: cfg)
|
|
435
|
+
defer { probeSession.finishTasksAndInvalidate() }
|
|
436
|
+
|
|
437
|
+
return try await withCheckedThrowingContinuation { cont in
|
|
438
|
+
let task = probeSession.dataTask(with: req) { _, response, error in
|
|
439
|
+
if let error = error { cont.resume(throwing: error); return }
|
|
440
|
+
guard let http = response as? HTTPURLResponse else {
|
|
441
|
+
cont.resume(throwing: FallbackError(reason: "no http response")); return
|
|
442
|
+
}
|
|
443
|
+
let etag = http.value(forHTTPHeaderField: "ETag")
|
|
444
|
+
if http.statusCode == 206,
|
|
445
|
+
let cr = http.value(forHTTPHeaderField: "Content-Range"),
|
|
446
|
+
let total = Self.parseContentRangeTotal(cr) {
|
|
447
|
+
cont.resume(returning: ProbeResult(total: total, etag: etag, supportsRange: true))
|
|
448
|
+
} else {
|
|
449
|
+
// 200 (Range ignored) or anything else → single-stream.
|
|
450
|
+
cont.resume(returning: ProbeResult(total: 0, etag: etag, supportsRange: false))
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
task.resume()
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
static func parseContentRangeTotal(_ header: String) -> Int64? {
|
|
458
|
+
// "bytes 0-0/65226095"
|
|
459
|
+
guard let slash = header.lastIndex(of: "/") else { return nil }
|
|
460
|
+
let tail = header[header.index(after: slash)...]
|
|
461
|
+
return Int64(tail.trimmingCharacters(in: .whitespaces))
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// MARK: - Task reconciliation (handles app relaunch)
|
|
465
|
+
|
|
466
|
+
/// Ensures exactly one in-flight (or completed) artifact per missing segment:
|
|
467
|
+
/// segments already on disk as `.segN` are skipped; segments that already
|
|
468
|
+
/// have a running task in the (recreated) background session for THIS run are
|
|
469
|
+
/// left alone; the rest get a fresh Range download task.
|
|
470
|
+
private func reconcileAndStartTasks(state: RunState, url: URL,
|
|
471
|
+
ranges: [(start: Int64, end: Int64)]) {
|
|
472
|
+
let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
|
|
473
|
+
session.getAllTasks { [weak self] tasks in
|
|
474
|
+
guard let self = self else { return }
|
|
475
|
+
var liveIndexes = Set<Int>()
|
|
476
|
+
for t in tasks {
|
|
477
|
+
guard let desc = t.taskDescription,
|
|
478
|
+
let decoded = Self.decodeTaskDescription(desc) else {
|
|
479
|
+
// Unrecognized task description on our channel session — leave it.
|
|
480
|
+
continue
|
|
481
|
+
}
|
|
482
|
+
// Only adopt tasks belonging to THIS run (same channel|taskId). Stale
|
|
483
|
+
// tasks from a DIFFERENT taskId on the same channel session that point
|
|
484
|
+
// at a different URL are cancelled so their segment indexes can't
|
|
485
|
+
// collide with this run's.
|
|
486
|
+
let belongsToThisRun =
|
|
487
|
+
decoded.channel.stringValue == state.channel.stringValue
|
|
488
|
+
&& decoded.taskId == state.taskId
|
|
489
|
+
if belongsToThisRun {
|
|
490
|
+
if let turl = t.originalRequest?.url,
|
|
491
|
+
turl.absoluteString == url.absoluteString,
|
|
492
|
+
t.state == .running || t.state == .suspended {
|
|
493
|
+
liveIndexes.insert(decoded.segIndex)
|
|
494
|
+
} else {
|
|
495
|
+
// Same run id but different URL (etag/url changed) — drop it.
|
|
496
|
+
t.cancel()
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Tasks for other taskIds on the same channel are left running so
|
|
500
|
+
// concurrent downloads in the same channel are not disturbed.
|
|
501
|
+
}
|
|
502
|
+
for (idx, range) in ranges.enumerated() {
|
|
503
|
+
if FileManager.default.fileExists(atPath: state.segPath(idx)) { continue }
|
|
504
|
+
if liveIndexes.contains(idx) { continue }
|
|
505
|
+
var req = URLRequest(url: url)
|
|
506
|
+
req.setValue("bytes=\(range.start)-\(range.end)", forHTTPHeaderField: "Range")
|
|
507
|
+
if let etag = state.etag { req.setValue(etag, forHTTPHeaderField: "If-Range") }
|
|
508
|
+
let task = session.downloadTask(with: req)
|
|
509
|
+
task.taskDescription = Self.encodeTaskDescription(
|
|
510
|
+
channel: state.channel, taskId: state.taskId, segIndex: idx
|
|
511
|
+
)
|
|
512
|
+
task.resume()
|
|
513
|
+
}
|
|
514
|
+
// It's possible every segment was already present but the early
|
|
515
|
+
// allSegmentsPresent check raced a just-finished task; re-check.
|
|
516
|
+
if self.allSegmentsPresent(state: state, ranges: ranges) {
|
|
517
|
+
self.finishContinuation(state: state, with: nil, ranges: ranges)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private func allSegmentsPresent(state: RunState,
|
|
523
|
+
ranges: [(start: Int64, end: Int64)]) -> Bool {
|
|
524
|
+
for (idx, range) in ranges.enumerated() {
|
|
525
|
+
let expected = range.end - range.start + 1
|
|
526
|
+
let p = state.segPath(idx)
|
|
527
|
+
guard FileManager.default.fileExists(atPath: p),
|
|
528
|
+
let attrs = try? FileManager.default.attributesOfItem(atPath: p),
|
|
529
|
+
let size = attrs[.size] as? Int64, size == expected else {
|
|
530
|
+
return false
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return true
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// MARK: - URLSessionDownloadDelegate
|
|
537
|
+
|
|
538
|
+
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
539
|
+
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
|
|
540
|
+
totalBytesExpectedToWrite: Int64) {
|
|
541
|
+
guard let desc = downloadTask.taskDescription,
|
|
542
|
+
let (state, idx) = run(for: desc) else { return }
|
|
543
|
+
lock.lock()
|
|
544
|
+
if idx < state.segmentWritten.count { state.segmentWritten[idx] = totalBytesWritten }
|
|
545
|
+
let sum = state.segmentWritten.reduce(0, +)
|
|
546
|
+
let total = state.totalSize
|
|
547
|
+
var emit = false
|
|
548
|
+
if total > 0 {
|
|
549
|
+
let p = Int((sum * 100) / total)
|
|
550
|
+
if p != state.prevProgress { state.prevProgress = p; emit = true }
|
|
551
|
+
}
|
|
552
|
+
let progressValue = total > 0 ? Int((sum * 100) / total) : 0
|
|
553
|
+
let channel = state.channel
|
|
554
|
+
let taskId = state.taskId
|
|
555
|
+
lock.unlock()
|
|
556
|
+
if emit {
|
|
557
|
+
self.emit(channel: channel, taskId: taskId, type: "progress",
|
|
558
|
+
progress: Double(progressValue), message: "")
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
563
|
+
didFinishDownloadingTo location: URL) {
|
|
564
|
+
guard let desc = downloadTask.taskDescription,
|
|
565
|
+
let (state, idx) = run(for: desc) else { return }
|
|
566
|
+
// A 200 means the server ignored Range / the ETag changed — we cannot
|
|
567
|
+
// safely assemble. Flag fallback; finalize happens in didCompleteWithError.
|
|
568
|
+
if let http = downloadTask.response as? HTTPURLResponse, http.statusCode == 200 {
|
|
569
|
+
lock.lock(); state.fellBack = true; lock.unlock()
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
// Move the segment into place atomically (temp file is deleted after return).
|
|
573
|
+
let dest = state.segPath(idx)
|
|
574
|
+
do {
|
|
575
|
+
let dir = (dest as NSString).deletingLastPathComponent
|
|
576
|
+
if !FileManager.default.fileExists(atPath: dir) {
|
|
577
|
+
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
578
|
+
}
|
|
579
|
+
if FileManager.default.fileExists(atPath: dest) {
|
|
580
|
+
try FileManager.default.removeItem(atPath: dest)
|
|
581
|
+
}
|
|
582
|
+
try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath: dest))
|
|
583
|
+
} catch {
|
|
584
|
+
OneKeyLog.error("RangeDownloader",
|
|
585
|
+
"\(state.channel.stringValue)/\(state.taskId): failed to stash segment \(idx): \(error)")
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
590
|
+
guard let desc = task.taskDescription,
|
|
591
|
+
let (state, _) = run(for: desc) else { return }
|
|
592
|
+
let ranges = lock.withLockValue { state.ranges }
|
|
593
|
+
if let error = error {
|
|
594
|
+
// Background ignores user-cancels (e.g. our own fallback cancel below) —
|
|
595
|
+
// surface only genuine give-ups.
|
|
596
|
+
let nsErr = error as NSError
|
|
597
|
+
if nsErr.domain == NSURLErrorDomain && nsErr.code == NSURLErrorCancelled {
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
finishContinuation(state: state, with: error, ranges: ranges)
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
if lock.withLockValue({ state.fellBack }) {
|
|
604
|
+
// Abandon the other in-flight segment tasks for THIS run so they don't
|
|
605
|
+
// keep downloading after we've decided to fall back to single-stream.
|
|
606
|
+
let channel = state.channel
|
|
607
|
+
let taskId = state.taskId
|
|
608
|
+
session.getAllTasks { tasks in
|
|
609
|
+
for t in tasks {
|
|
610
|
+
if let d = t.taskDescription,
|
|
611
|
+
let decoded = Self.decodeTaskDescription(d),
|
|
612
|
+
decoded.channel.stringValue == channel.stringValue,
|
|
613
|
+
decoded.taskId == taskId {
|
|
614
|
+
t.cancel()
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
cleanupSegments(state: state, ranges: ranges)
|
|
619
|
+
finishContinuation(state: state,
|
|
620
|
+
with: FallbackError(reason: "server returned 200 to a Range request"),
|
|
621
|
+
ranges: ranges)
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
if allSegmentsPresent(state: state, ranges: ranges) {
|
|
625
|
+
finishContinuation(state: state, with: nil, ranges: ranges)
|
|
626
|
+
}
|
|
627
|
+
// else: other segments still in flight; wait for their completions.
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/// Called on the session delegate queue when all background events for this
|
|
631
|
+
/// session have been delivered after a background relaunch.
|
|
632
|
+
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
|
633
|
+
let identifier = session.configuration.identifier
|
|
634
|
+
DispatchQueue.main.async { [weak self] in
|
|
635
|
+
guard let self = self else { return }
|
|
636
|
+
var handler: (() -> Void)?
|
|
637
|
+
if let id = identifier {
|
|
638
|
+
self.lock.lock()
|
|
639
|
+
handler = self.backgroundCompletionHandlers.removeValue(forKey: id)
|
|
640
|
+
self.lock.unlock()
|
|
641
|
+
}
|
|
642
|
+
handler?()
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// MARK: - Finalize
|
|
647
|
+
|
|
648
|
+
private func finishContinuation(state: RunState, with error: Error?,
|
|
649
|
+
ranges: [(start: Int64, end: Int64)]) {
|
|
650
|
+
let cont: CheckedContinuation<Void, Error>? = lock.withLockValue {
|
|
651
|
+
let c = state.continuation
|
|
652
|
+
state.continuation = nil
|
|
653
|
+
return c
|
|
654
|
+
}
|
|
655
|
+
guard let cont = cont else { return }
|
|
656
|
+
if let error = error { cont.resume(throwing: error); return }
|
|
657
|
+
do {
|
|
658
|
+
try concatenateAndFinish(state: state, ranges: ranges)
|
|
659
|
+
cont.resume(returning: ())
|
|
660
|
+
} catch {
|
|
661
|
+
cont.resume(throwing: error)
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private func concatenateAndFinish(state: RunState,
|
|
666
|
+
ranges: [(start: Int64, end: Int64)]) throws {
|
|
667
|
+
let filePath = state.filePath
|
|
668
|
+
let partial = "\(filePath).partial"
|
|
669
|
+
if FileManager.default.fileExists(atPath: partial) {
|
|
670
|
+
try FileManager.default.removeItem(atPath: partial)
|
|
671
|
+
}
|
|
672
|
+
FileManager.default.createFile(atPath: partial, contents: nil)
|
|
673
|
+
guard let out = FileHandle(forWritingAtPath: partial) else {
|
|
674
|
+
throw NSError(domain: "RangeDownloader", code: -1,
|
|
675
|
+
userInfo: [NSLocalizedDescriptionKey: "cannot open partial for write"])
|
|
676
|
+
}
|
|
677
|
+
defer { try? out.close() }
|
|
678
|
+
for idx in 0..<ranges.count {
|
|
679
|
+
let segData = try Data(contentsOf: URL(fileURLWithPath: state.segPath(idx)))
|
|
680
|
+
try out.write(contentsOf: segData)
|
|
681
|
+
}
|
|
682
|
+
try? out.close()
|
|
683
|
+
if FileManager.default.fileExists(atPath: filePath) {
|
|
684
|
+
try FileManager.default.removeItem(atPath: filePath)
|
|
685
|
+
}
|
|
686
|
+
try FileManager.default.moveItem(atPath: partial, toPath: filePath)
|
|
687
|
+
cleanupSegments(state: state, ranges: ranges)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private func cleanupSegments(state: RunState, ranges: [(start: Int64, end: Int64)]) {
|
|
691
|
+
for idx in 0..<ranges.count {
|
|
692
|
+
try? FileManager.default.removeItem(atPath: state.segPath(idx))
|
|
693
|
+
}
|
|
694
|
+
try? FileManager.default.removeItem(atPath: "\(state.filePath).partial")
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/// Discards all segment artifacts (used by the caller when falling back to
|
|
698
|
+
/// single-stream so the bare slot is clean).
|
|
699
|
+
public func discardArtifacts(filePath: String) {
|
|
700
|
+
for idx in 0..<Self.defaultSegmentCount {
|
|
701
|
+
try? FileManager.default.removeItem(atPath: "\(filePath).seg\(idx)")
|
|
702
|
+
}
|
|
703
|
+
try? FileManager.default.removeItem(atPath: "\(filePath).partial")
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// MARK: - SHA256 (streaming backstop)
|
|
707
|
+
|
|
708
|
+
static func calculateSHA256(_ filePath: String) -> String? {
|
|
709
|
+
let fm = FileManager.default
|
|
710
|
+
guard fm.fileExists(atPath: filePath),
|
|
711
|
+
let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil }
|
|
712
|
+
defer { try? fileHandle.close() }
|
|
713
|
+
var context = CC_SHA256_CTX()
|
|
714
|
+
CC_SHA256_Init(&context)
|
|
715
|
+
while autoreleasepool(invoking: { () -> Bool in
|
|
716
|
+
let data = fileHandle.readData(ofLength: 8192)
|
|
717
|
+
if data.isEmpty { return false }
|
|
718
|
+
data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
|
|
719
|
+
return true
|
|
720
|
+
}) {}
|
|
721
|
+
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
722
|
+
CC_SHA256_Final(&hash, &context)
|
|
723
|
+
return hash.map { String(format: "%02x", $0) }.joined()
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private extension NSLock {
|
|
728
|
+
func withLockValue<T>(_ body: () -> T) -> T {
|
|
729
|
+
lock(); defer { unlock() }
|
|
730
|
+
return body()
|
|
731
|
+
}
|
|
732
|
+
}
|