@dvai-bridge/ios-llama-core 4.0.0 → 4.0.2
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 +341 -34
- package/Package.swift +71 -71
- package/ios/Sources/DVAILlamaCore/AudioDecoder.swift +112 -112
- package/ios/Sources/DVAILlamaCore/ContentPartsTranslator.swift +232 -232
- package/ios/Sources/DVAILlamaCore/ImageDecoder.swift +91 -91
- package/ios/Sources/DVAILlamaCore/LlamaCppBridgeProtocol.swift +59 -59
- package/ios/Sources/DVAILlamaCore/LlamaHandlers.swift +422 -422
- package/ios/Sources/DVAILlamaCore/ModelDownloader.swift +445 -445
- package/ios/Sources/DVAILlamaCore/PluginState.swift +158 -158
- package/ios/Sources/DVAILlamaCoreObjC/LlamaCppBridge.mm +649 -649
- package/ios/Sources/DVAILlamaCoreObjC/include/LlamaCppBridge.h +101 -101
- package/ios/Tests/DVAILlamaCoreTests/AudioDecoderTest.swift +46 -46
- package/ios/Tests/DVAILlamaCoreTests/ContentPartsTranslatorTest.swift +361 -361
- package/ios/Tests/DVAILlamaCoreTests/ImageDecoderTest.swift +139 -139
- package/ios/Tests/DVAILlamaCoreTests/LlamaCppBridgeTest.swift +131 -131
- package/ios/Tests/DVAILlamaCoreTests/LlamaHandlersTest.swift +515 -515
- package/ios/Tests/DVAILlamaCoreTests/ModelDownloaderTest.swift +89 -89
- package/ios/Tests/DVAILlamaCoreTests/PluginStateTest.swift +51 -51
- package/package.json +3 -3
- package/README.md +0 -199
|
@@ -1,445 +1,445 @@
|
|
|
1
|
-
// Internal/ModelDownloader.swift
|
|
2
|
-
import Foundation
|
|
3
|
-
import CryptoKit
|
|
4
|
-
|
|
5
|
-
/// Result of `listCachedModels()` — one entry per file in the cache dir.
|
|
6
|
-
public struct CachedModelInfoSwift: Sendable {
|
|
7
|
-
public let filename: String
|
|
8
|
-
public let path: String
|
|
9
|
-
public let bytes: Int64
|
|
10
|
-
public let sha256: String
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/// Resumable, sha256-verified model downloader plus cache management.
|
|
14
|
-
///
|
|
15
|
-
/// Cache directory layout (per spec §9.2):
|
|
16
|
-
/// - default: `<App Support>/<bundle-id>/dvai-models/<filename>`
|
|
17
|
-
/// - test override: any caller-supplied URL (used by unit tests to keep
|
|
18
|
-
/// real App Support clean).
|
|
19
|
-
///
|
|
20
|
-
/// Concurrency: an `actor` so cache-list / cache-delete operations are
|
|
21
|
-
/// serialised. The download path delegates to a private `URLSessionDataDelegate`
|
|
22
|
-
/// (compatible with iOS 14+, unlike the iOS-15 `bytes(for:)` API).
|
|
23
|
-
public actor ModelDownloader {
|
|
24
|
-
public enum DownloadError: LocalizedError {
|
|
25
|
-
case checksumMismatch(expected: String, got: String)
|
|
26
|
-
case httpError(status: Int)
|
|
27
|
-
case missingApplicationSupport
|
|
28
|
-
case sha256Required
|
|
29
|
-
case ioError(String)
|
|
30
|
-
|
|
31
|
-
public var errorDescription: String? {
|
|
32
|
-
switch self {
|
|
33
|
-
case .checksumMismatch(let expected, let got):
|
|
34
|
-
return "ChecksumMismatchError: expected \(expected), got \(got)"
|
|
35
|
-
case .httpError(let status):
|
|
36
|
-
return "HTTP error \(status)"
|
|
37
|
-
case .missingApplicationSupport:
|
|
38
|
-
return "Could not locate Application Support directory"
|
|
39
|
-
case .sha256Required:
|
|
40
|
-
return "sha256 is required"
|
|
41
|
-
case .ioError(let msg):
|
|
42
|
-
return "I/O error: \(msg)"
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
private let cacheDirOverride: URL?
|
|
48
|
-
|
|
49
|
-
public init(cacheDirOverride: URL? = nil) {
|
|
50
|
-
self.cacheDirOverride = cacheDirOverride
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// MARK: - Cache dir
|
|
54
|
-
|
|
55
|
-
/// Resolve and create the cache directory. Returns the directory URL.
|
|
56
|
-
func cacheDirURL() throws -> URL {
|
|
57
|
-
if let override = cacheDirOverride {
|
|
58
|
-
try FileManager.default.createDirectory(at: override, withIntermediateDirectories: true)
|
|
59
|
-
return override
|
|
60
|
-
}
|
|
61
|
-
guard let asd = try? FileManager.default.url(
|
|
62
|
-
for: .applicationSupportDirectory,
|
|
63
|
-
in: .userDomainMask,
|
|
64
|
-
appropriateFor: nil,
|
|
65
|
-
create: true
|
|
66
|
-
) else {
|
|
67
|
-
throw DownloadError.missingApplicationSupport
|
|
68
|
-
}
|
|
69
|
-
let bundleId = Bundle.main.bundleIdentifier ?? "co.deepvoiceai.dvai-bridge"
|
|
70
|
-
let dir = asd.appendingPathComponent(bundleId).appendingPathComponent("dvai-models")
|
|
71
|
-
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
72
|
-
return dir
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
public func cacheDirPath() throws -> String {
|
|
76
|
-
try cacheDirURL().path
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// MARK: - List / delete
|
|
80
|
-
|
|
81
|
-
/// Enumerate files in the cache dir (skipping `.partial` and dotfiles),
|
|
82
|
-
/// sha256 each, return one entry per file.
|
|
83
|
-
public func listCachedModels() throws -> [CachedModelInfoSwift] {
|
|
84
|
-
let dir = try cacheDirURL()
|
|
85
|
-
let fm = FileManager.default
|
|
86
|
-
let names = (try? fm.contentsOfDirectory(atPath: dir.path)) ?? []
|
|
87
|
-
var out: [CachedModelInfoSwift] = []
|
|
88
|
-
for name in names {
|
|
89
|
-
if name.hasPrefix(".") || name.hasSuffix(".partial") { continue }
|
|
90
|
-
let url = dir.appendingPathComponent(name)
|
|
91
|
-
var isDir: ObjCBool = false
|
|
92
|
-
if !fm.fileExists(atPath: url.path, isDirectory: &isDir) || isDir.boolValue { continue }
|
|
93
|
-
let attrs = try fm.attributesOfItem(atPath: url.path)
|
|
94
|
-
let bytes = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
|
95
|
-
let sha = try Self.sha256OfFile(at: url)
|
|
96
|
-
out.append(CachedModelInfoSwift(
|
|
97
|
-
filename: name,
|
|
98
|
-
path: url.path,
|
|
99
|
-
bytes: bytes,
|
|
100
|
-
sha256: sha
|
|
101
|
-
))
|
|
102
|
-
}
|
|
103
|
-
return out
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
public func deleteCachedModel(filename: String) throws {
|
|
107
|
-
let dir = try cacheDirURL()
|
|
108
|
-
let url = dir.appendingPathComponent(filename)
|
|
109
|
-
if FileManager.default.fileExists(atPath: url.path) {
|
|
110
|
-
try FileManager.default.removeItem(at: url)
|
|
111
|
-
}
|
|
112
|
-
let partial = dir.appendingPathComponent("\(filename).partial")
|
|
113
|
-
if FileManager.default.fileExists(atPath: partial.path) {
|
|
114
|
-
try? FileManager.default.removeItem(at: partial)
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// MARK: - Download
|
|
119
|
-
|
|
120
|
-
/// Download `url` into `<cacheDir>/<destFilename>`, resumable + sha256-verified.
|
|
121
|
-
///
|
|
122
|
-
/// - Parameters:
|
|
123
|
-
/// - expectedSha256: lowercase-hex digest the final file MUST match.
|
|
124
|
-
/// - onProgress: bytesDone, optional bytesTotal. Caller already throttles
|
|
125
|
-
/// ~10/sec internally; do not throttle again here.
|
|
126
|
-
/// - Returns: (final-path, cached). `cached: true` means the file was already
|
|
127
|
-
/// present with a matching hash and no network request was made.
|
|
128
|
-
public func downloadModel(
|
|
129
|
-
url: URL,
|
|
130
|
-
expectedSha256: String,
|
|
131
|
-
destFilename: String,
|
|
132
|
-
headers: [String: String],
|
|
133
|
-
onProgress: @Sendable @escaping (Int64, Int64?) -> Void
|
|
134
|
-
) async throws -> (path: String, cached: Bool) {
|
|
135
|
-
guard !expectedSha256.isEmpty else { throw DownloadError.sha256Required }
|
|
136
|
-
let expected = expectedSha256.lowercased()
|
|
137
|
-
|
|
138
|
-
let dir = try cacheDirURL()
|
|
139
|
-
let final = dir.appendingPathComponent(destFilename)
|
|
140
|
-
let partial = dir.appendingPathComponent("\(destFilename).partial")
|
|
141
|
-
let fm = FileManager.default
|
|
142
|
-
|
|
143
|
-
// Step 2: cache hit check.
|
|
144
|
-
if fm.fileExists(atPath: final.path) {
|
|
145
|
-
let existing = try Self.sha256OfFile(at: final)
|
|
146
|
-
if existing == expected {
|
|
147
|
-
Self.applyNoBackupAttribute(final)
|
|
148
|
-
return (final.path, true)
|
|
149
|
-
}
|
|
150
|
-
// Step 3: mismatch → delete and fall through.
|
|
151
|
-
try? fm.removeItem(at: final)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Step 4: stream download (resumable).
|
|
155
|
-
try await StreamingDownload.run(
|
|
156
|
-
url: url,
|
|
157
|
-
partial: partial,
|
|
158
|
-
headers: headers,
|
|
159
|
-
onProgress: onProgress
|
|
160
|
-
).verifyAndFinalize(
|
|
161
|
-
final: final,
|
|
162
|
-
partial: partial,
|
|
163
|
-
expectedSha256: expected
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
// Step 7: iOS no-backup attribute.
|
|
167
|
-
Self.applyNoBackupAttribute(final)
|
|
168
|
-
|
|
169
|
-
return (final.path, false)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// MARK: - Helpers
|
|
173
|
-
|
|
174
|
-
/// Compute SHA-256 of a file lazily by streaming 64 KiB chunks.
|
|
175
|
-
static func sha256OfFile(at url: URL) throws -> String {
|
|
176
|
-
var hasher = SHA256()
|
|
177
|
-
let handle = try FileHandle(forReadingFrom: url)
|
|
178
|
-
defer { try? handle.close() }
|
|
179
|
-
while true {
|
|
180
|
-
let chunk = try handle.read(upToCount: 64 * 1024) ?? Data()
|
|
181
|
-
if chunk.isEmpty { break }
|
|
182
|
-
hasher.update(data: chunk)
|
|
183
|
-
}
|
|
184
|
-
let digest = hasher.finalize()
|
|
185
|
-
return digest.map { String(format: "%02x", $0) }.joined()
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/// Set `URLResourceKey.isExcludedFromBackupKey = true`. Best-effort —
|
|
189
|
-
/// failure here is non-fatal (e.g. unit tests in tmp dirs).
|
|
190
|
-
static func applyNoBackupAttribute(_ url: URL) {
|
|
191
|
-
var mutableURL = url
|
|
192
|
-
var values = URLResourceValues()
|
|
193
|
-
values.isExcludedFromBackup = true
|
|
194
|
-
try? mutableURL.setResourceValues(values)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// MARK: - Streaming download (URLSessionDataDelegate, iOS 14+)
|
|
199
|
-
|
|
200
|
-
/// Result of a streaming download: the running SHA-256 digest as hex.
|
|
201
|
-
struct StreamingDownloadResult {
|
|
202
|
-
let gotHex: String
|
|
203
|
-
|
|
204
|
-
/// Verify hash, atomic-rename, and clean up on mismatch.
|
|
205
|
-
func verifyAndFinalize(final: URL, partial: URL, expectedSha256: String) throws {
|
|
206
|
-
let fm = FileManager.default
|
|
207
|
-
if gotHex != expectedSha256 {
|
|
208
|
-
try? fm.removeItem(at: partial)
|
|
209
|
-
try? fm.removeItem(at: final)
|
|
210
|
-
throw ModelDownloader.DownloadError.checksumMismatch(
|
|
211
|
-
expected: expectedSha256,
|
|
212
|
-
got: gotHex
|
|
213
|
-
)
|
|
214
|
-
}
|
|
215
|
-
if fm.fileExists(atPath: final.path) {
|
|
216
|
-
try? fm.removeItem(at: final)
|
|
217
|
-
}
|
|
218
|
-
try fm.moveItem(at: partial, to: final)
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/// Wraps `URLSessionDataDelegate` with a continuation so the streaming
|
|
223
|
-
/// download can be `await`ed. Handles:
|
|
224
|
-
/// - Replaying existing `.partial` bytes through SHA-256 (resume).
|
|
225
|
-
/// - Range request + 200/206 handling (server-honoured / not-honoured).
|
|
226
|
-
/// - 64 KiB-buffered hashing + appending.
|
|
227
|
-
/// - Progress debounced to ~10/sec.
|
|
228
|
-
final class StreamingDownload: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
|
229
|
-
private var hasher = SHA256()
|
|
230
|
-
private var written: Int64 = 0
|
|
231
|
-
private var totalBytes: Int64?
|
|
232
|
-
private var writeHandle: FileHandle?
|
|
233
|
-
private var lastEmit: Date = .distantPast
|
|
234
|
-
private let debounceInterval: TimeInterval = 0.1
|
|
235
|
-
|
|
236
|
-
private let partial: URL
|
|
237
|
-
private let onProgress: @Sendable (Int64, Int64?) -> Void
|
|
238
|
-
private var continuation: CheckedContinuation<StreamingDownloadResult, Error>?
|
|
239
|
-
private var didFinish = false
|
|
240
|
-
|
|
241
|
-
/// Whether we asked the server for a Range. If true and the server replies
|
|
242
|
-
/// 200 (full body), reset hash + truncate file before consuming data.
|
|
243
|
-
private var requestedRange = false
|
|
244
|
-
private var serverWillSendFullBody = false
|
|
245
|
-
|
|
246
|
-
private init(
|
|
247
|
-
partial: URL,
|
|
248
|
-
onProgress: @Sendable @escaping (Int64, Int64?) -> Void
|
|
249
|
-
) {
|
|
250
|
-
self.partial = partial
|
|
251
|
-
self.onProgress = onProgress
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/// Entry point. Replays existing `.partial`, performs the download,
|
|
255
|
-
/// returns the final hex digest on success.
|
|
256
|
-
static func run(
|
|
257
|
-
url: URL,
|
|
258
|
-
partial: URL,
|
|
259
|
-
headers: [String: String],
|
|
260
|
-
onProgress: @Sendable @escaping (Int64, Int64?) -> Void
|
|
261
|
-
) async throws -> StreamingDownloadResult {
|
|
262
|
-
let runner = StreamingDownload(partial: partial, onProgress: onProgress)
|
|
263
|
-
try runner.replayPartial()
|
|
264
|
-
|
|
265
|
-
// Parity with Android: if `.partial` is larger than the remote resource,
|
|
266
|
-
// a Range request would yield 416 Range Not Satisfiable (opaque to the
|
|
267
|
-
// caller). HEAD-probe the Content-Length and discard an oversized
|
|
268
|
-
// partial before issuing the real GET. Any HEAD failure is non-fatal —
|
|
269
|
-
// we just skip the optimisation and let the GET path proceed.
|
|
270
|
-
if runner.written > 0 {
|
|
271
|
-
if let remoteLength = await Self.probeContentLength(url: url, headers: headers),
|
|
272
|
-
remoteLength >= 0,
|
|
273
|
-
runner.written > remoteLength {
|
|
274
|
-
try? FileManager.default.removeItem(at: partial)
|
|
275
|
-
runner.hasher = SHA256()
|
|
276
|
-
runner.written = 0
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
var request = URLRequest(url: url)
|
|
281
|
-
for (k, v) in headers {
|
|
282
|
-
request.setValue(v, forHTTPHeaderField: k)
|
|
283
|
-
}
|
|
284
|
-
if runner.written > 0 {
|
|
285
|
-
request.setValue("bytes=\(runner.written)-", forHTTPHeaderField: "Range")
|
|
286
|
-
runner.requestedRange = true
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
let session = URLSession(
|
|
290
|
-
configuration: .default,
|
|
291
|
-
delegate: runner,
|
|
292
|
-
delegateQueue: nil // serial OperationQueue created internally
|
|
293
|
-
)
|
|
294
|
-
defer { session.finishTasksAndInvalidate() }
|
|
295
|
-
|
|
296
|
-
return try await withCheckedThrowingContinuation { cont in
|
|
297
|
-
runner.continuation = cont
|
|
298
|
-
let task = session.dataTask(with: request)
|
|
299
|
-
task.resume()
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/// HEAD-probe the URL to learn `Content-Length`. Returns nil on any
|
|
304
|
-
/// failure (network error, non-2xx, missing/invalid header). Headers are
|
|
305
|
-
/// forwarded so authenticated endpoints work.
|
|
306
|
-
private static func probeContentLength(url: URL, headers: [String: String]) async -> Int64? {
|
|
307
|
-
var request = URLRequest(url: url)
|
|
308
|
-
request.httpMethod = "HEAD"
|
|
309
|
-
for (k, v) in headers {
|
|
310
|
-
request.setValue(v, forHTTPHeaderField: k)
|
|
311
|
-
}
|
|
312
|
-
do {
|
|
313
|
-
let (_, response) = try await URLSession.shared.data(for: request)
|
|
314
|
-
guard let http = response as? HTTPURLResponse,
|
|
315
|
-
(200...299).contains(http.statusCode) else {
|
|
316
|
-
return nil
|
|
317
|
-
}
|
|
318
|
-
// expectedContentLength uses Content-Length when present (-1 if unknown).
|
|
319
|
-
let len = http.expectedContentLength
|
|
320
|
-
return len >= 0 ? len : nil
|
|
321
|
-
} catch {
|
|
322
|
-
return nil
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/// Replay any existing .partial bytes through the hash so we can resume.
|
|
327
|
-
private func replayPartial() throws {
|
|
328
|
-
let fm = FileManager.default
|
|
329
|
-
guard fm.fileExists(atPath: partial.path) else { return }
|
|
330
|
-
let attrs = try fm.attributesOfItem(atPath: partial.path)
|
|
331
|
-
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
|
332
|
-
if size <= 0 { return }
|
|
333
|
-
let handle = try FileHandle(forReadingFrom: partial)
|
|
334
|
-
defer { try? handle.close() }
|
|
335
|
-
while true {
|
|
336
|
-
let chunk = try handle.read(upToCount: 64 * 1024) ?? Data()
|
|
337
|
-
if chunk.isEmpty { break }
|
|
338
|
-
hasher.update(data: chunk)
|
|
339
|
-
written += Int64(chunk.count)
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// MARK: - URLSessionDataDelegate
|
|
344
|
-
|
|
345
|
-
func urlSession(
|
|
346
|
-
_ session: URLSession,
|
|
347
|
-
dataTask: URLSessionDataTask,
|
|
348
|
-
didReceive response: URLResponse,
|
|
349
|
-
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
|
350
|
-
) {
|
|
351
|
-
guard let http = response as? HTTPURLResponse else {
|
|
352
|
-
finish(with: .failure(ModelDownloader.DownloadError.httpError(status: -1)))
|
|
353
|
-
completionHandler(.cancel)
|
|
354
|
-
return
|
|
355
|
-
}
|
|
356
|
-
if !(200...206).contains(http.statusCode) {
|
|
357
|
-
finish(with: .failure(ModelDownloader.DownloadError.httpError(status: http.statusCode)))
|
|
358
|
-
completionHandler(.cancel)
|
|
359
|
-
return
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
let fm = FileManager.default
|
|
363
|
-
|
|
364
|
-
// If we asked for a Range and got 200 → server didn't honour it,
|
|
365
|
-
// restart hash + truncate file.
|
|
366
|
-
if requestedRange && http.statusCode == 200 {
|
|
367
|
-
hasher = SHA256()
|
|
368
|
-
written = 0
|
|
369
|
-
serverWillSendFullBody = true
|
|
370
|
-
try? fm.removeItem(at: partial)
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
let contentLength = http.expectedContentLength // -1 if unknown
|
|
374
|
-
if contentLength >= 0 {
|
|
375
|
-
totalBytes = written + contentLength
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Open partial for write at current offset.
|
|
379
|
-
if !fm.fileExists(atPath: partial.path) {
|
|
380
|
-
fm.createFile(atPath: partial.path, contents: nil)
|
|
381
|
-
}
|
|
382
|
-
do {
|
|
383
|
-
let h = try FileHandle(forWritingTo: partial)
|
|
384
|
-
try h.seek(toOffset: UInt64(written))
|
|
385
|
-
writeHandle = h
|
|
386
|
-
} catch {
|
|
387
|
-
finish(with: .failure(error))
|
|
388
|
-
completionHandler(.cancel)
|
|
389
|
-
return
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Initial 0% emit so callers see we've started.
|
|
393
|
-
onProgress(written, totalBytes)
|
|
394
|
-
lastEmit = Date()
|
|
395
|
-
|
|
396
|
-
completionHandler(.allow)
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
func urlSession(
|
|
400
|
-
_ session: URLSession,
|
|
401
|
-
dataTask: URLSessionDataTask,
|
|
402
|
-
didReceive data: Data
|
|
403
|
-
) {
|
|
404
|
-
guard let h = writeHandle else { return }
|
|
405
|
-
do {
|
|
406
|
-
try h.write(contentsOf: data)
|
|
407
|
-
} catch {
|
|
408
|
-
finish(with: .failure(error))
|
|
409
|
-
return
|
|
410
|
-
}
|
|
411
|
-
hasher.update(data: data)
|
|
412
|
-
written += Int64(data.count)
|
|
413
|
-
let now = Date()
|
|
414
|
-
if now.timeIntervalSince(lastEmit) >= debounceInterval {
|
|
415
|
-
onProgress(written, totalBytes)
|
|
416
|
-
lastEmit = now
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
func urlSession(
|
|
421
|
-
_ session: URLSession,
|
|
422
|
-
task: URLSessionTask,
|
|
423
|
-
didCompleteWithError error: Error?
|
|
424
|
-
) {
|
|
425
|
-
try? writeHandle?.close()
|
|
426
|
-
writeHandle = nil
|
|
427
|
-
if let error = error {
|
|
428
|
-
finish(with: .failure(error))
|
|
429
|
-
return
|
|
430
|
-
}
|
|
431
|
-
// Final progress emit.
|
|
432
|
-
onProgress(written, totalBytes)
|
|
433
|
-
let digest = hasher.finalize()
|
|
434
|
-
let gotHex = digest.map { String(format: "%02x", $0) }.joined()
|
|
435
|
-
finish(with: .success(StreamingDownloadResult(gotHex: gotHex)))
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
private func finish(with result: Result<StreamingDownloadResult, Error>) {
|
|
439
|
-
guard !didFinish else { return }
|
|
440
|
-
didFinish = true
|
|
441
|
-
let cont = continuation
|
|
442
|
-
continuation = nil
|
|
443
|
-
cont?.resume(with: result)
|
|
444
|
-
}
|
|
445
|
-
}
|
|
1
|
+
// Internal/ModelDownloader.swift
|
|
2
|
+
import Foundation
|
|
3
|
+
import CryptoKit
|
|
4
|
+
|
|
5
|
+
/// Result of `listCachedModels()` — one entry per file in the cache dir.
|
|
6
|
+
public struct CachedModelInfoSwift: Sendable {
|
|
7
|
+
public let filename: String
|
|
8
|
+
public let path: String
|
|
9
|
+
public let bytes: Int64
|
|
10
|
+
public let sha256: String
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Resumable, sha256-verified model downloader plus cache management.
|
|
14
|
+
///
|
|
15
|
+
/// Cache directory layout (per spec §9.2):
|
|
16
|
+
/// - default: `<App Support>/<bundle-id>/dvai-models/<filename>`
|
|
17
|
+
/// - test override: any caller-supplied URL (used by unit tests to keep
|
|
18
|
+
/// real App Support clean).
|
|
19
|
+
///
|
|
20
|
+
/// Concurrency: an `actor` so cache-list / cache-delete operations are
|
|
21
|
+
/// serialised. The download path delegates to a private `URLSessionDataDelegate`
|
|
22
|
+
/// (compatible with iOS 14+, unlike the iOS-15 `bytes(for:)` API).
|
|
23
|
+
public actor ModelDownloader {
|
|
24
|
+
public enum DownloadError: LocalizedError {
|
|
25
|
+
case checksumMismatch(expected: String, got: String)
|
|
26
|
+
case httpError(status: Int)
|
|
27
|
+
case missingApplicationSupport
|
|
28
|
+
case sha256Required
|
|
29
|
+
case ioError(String)
|
|
30
|
+
|
|
31
|
+
public var errorDescription: String? {
|
|
32
|
+
switch self {
|
|
33
|
+
case .checksumMismatch(let expected, let got):
|
|
34
|
+
return "ChecksumMismatchError: expected \(expected), got \(got)"
|
|
35
|
+
case .httpError(let status):
|
|
36
|
+
return "HTTP error \(status)"
|
|
37
|
+
case .missingApplicationSupport:
|
|
38
|
+
return "Could not locate Application Support directory"
|
|
39
|
+
case .sha256Required:
|
|
40
|
+
return "sha256 is required"
|
|
41
|
+
case .ioError(let msg):
|
|
42
|
+
return "I/O error: \(msg)"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private let cacheDirOverride: URL?
|
|
48
|
+
|
|
49
|
+
public init(cacheDirOverride: URL? = nil) {
|
|
50
|
+
self.cacheDirOverride = cacheDirOverride
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// MARK: - Cache dir
|
|
54
|
+
|
|
55
|
+
/// Resolve and create the cache directory. Returns the directory URL.
|
|
56
|
+
func cacheDirURL() throws -> URL {
|
|
57
|
+
if let override = cacheDirOverride {
|
|
58
|
+
try FileManager.default.createDirectory(at: override, withIntermediateDirectories: true)
|
|
59
|
+
return override
|
|
60
|
+
}
|
|
61
|
+
guard let asd = try? FileManager.default.url(
|
|
62
|
+
for: .applicationSupportDirectory,
|
|
63
|
+
in: .userDomainMask,
|
|
64
|
+
appropriateFor: nil,
|
|
65
|
+
create: true
|
|
66
|
+
) else {
|
|
67
|
+
throw DownloadError.missingApplicationSupport
|
|
68
|
+
}
|
|
69
|
+
let bundleId = Bundle.main.bundleIdentifier ?? "co.deepvoiceai.dvai-bridge"
|
|
70
|
+
let dir = asd.appendingPathComponent(bundleId).appendingPathComponent("dvai-models")
|
|
71
|
+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
72
|
+
return dir
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public func cacheDirPath() throws -> String {
|
|
76
|
+
try cacheDirURL().path
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// MARK: - List / delete
|
|
80
|
+
|
|
81
|
+
/// Enumerate files in the cache dir (skipping `.partial` and dotfiles),
|
|
82
|
+
/// sha256 each, return one entry per file.
|
|
83
|
+
public func listCachedModels() throws -> [CachedModelInfoSwift] {
|
|
84
|
+
let dir = try cacheDirURL()
|
|
85
|
+
let fm = FileManager.default
|
|
86
|
+
let names = (try? fm.contentsOfDirectory(atPath: dir.path)) ?? []
|
|
87
|
+
var out: [CachedModelInfoSwift] = []
|
|
88
|
+
for name in names {
|
|
89
|
+
if name.hasPrefix(".") || name.hasSuffix(".partial") { continue }
|
|
90
|
+
let url = dir.appendingPathComponent(name)
|
|
91
|
+
var isDir: ObjCBool = false
|
|
92
|
+
if !fm.fileExists(atPath: url.path, isDirectory: &isDir) || isDir.boolValue { continue }
|
|
93
|
+
let attrs = try fm.attributesOfItem(atPath: url.path)
|
|
94
|
+
let bytes = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
|
95
|
+
let sha = try Self.sha256OfFile(at: url)
|
|
96
|
+
out.append(CachedModelInfoSwift(
|
|
97
|
+
filename: name,
|
|
98
|
+
path: url.path,
|
|
99
|
+
bytes: bytes,
|
|
100
|
+
sha256: sha
|
|
101
|
+
))
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public func deleteCachedModel(filename: String) throws {
|
|
107
|
+
let dir = try cacheDirURL()
|
|
108
|
+
let url = dir.appendingPathComponent(filename)
|
|
109
|
+
if FileManager.default.fileExists(atPath: url.path) {
|
|
110
|
+
try FileManager.default.removeItem(at: url)
|
|
111
|
+
}
|
|
112
|
+
let partial = dir.appendingPathComponent("\(filename).partial")
|
|
113
|
+
if FileManager.default.fileExists(atPath: partial.path) {
|
|
114
|
+
try? FileManager.default.removeItem(at: partial)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// MARK: - Download
|
|
119
|
+
|
|
120
|
+
/// Download `url` into `<cacheDir>/<destFilename>`, resumable + sha256-verified.
|
|
121
|
+
///
|
|
122
|
+
/// - Parameters:
|
|
123
|
+
/// - expectedSha256: lowercase-hex digest the final file MUST match.
|
|
124
|
+
/// - onProgress: bytesDone, optional bytesTotal. Caller already throttles
|
|
125
|
+
/// ~10/sec internally; do not throttle again here.
|
|
126
|
+
/// - Returns: (final-path, cached). `cached: true` means the file was already
|
|
127
|
+
/// present with a matching hash and no network request was made.
|
|
128
|
+
public func downloadModel(
|
|
129
|
+
url: URL,
|
|
130
|
+
expectedSha256: String,
|
|
131
|
+
destFilename: String,
|
|
132
|
+
headers: [String: String],
|
|
133
|
+
onProgress: @Sendable @escaping (Int64, Int64?) -> Void
|
|
134
|
+
) async throws -> (path: String, cached: Bool) {
|
|
135
|
+
guard !expectedSha256.isEmpty else { throw DownloadError.sha256Required }
|
|
136
|
+
let expected = expectedSha256.lowercased()
|
|
137
|
+
|
|
138
|
+
let dir = try cacheDirURL()
|
|
139
|
+
let final = dir.appendingPathComponent(destFilename)
|
|
140
|
+
let partial = dir.appendingPathComponent("\(destFilename).partial")
|
|
141
|
+
let fm = FileManager.default
|
|
142
|
+
|
|
143
|
+
// Step 2: cache hit check.
|
|
144
|
+
if fm.fileExists(atPath: final.path) {
|
|
145
|
+
let existing = try Self.sha256OfFile(at: final)
|
|
146
|
+
if existing == expected {
|
|
147
|
+
Self.applyNoBackupAttribute(final)
|
|
148
|
+
return (final.path, true)
|
|
149
|
+
}
|
|
150
|
+
// Step 3: mismatch → delete and fall through.
|
|
151
|
+
try? fm.removeItem(at: final)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Step 4: stream download (resumable).
|
|
155
|
+
try await StreamingDownload.run(
|
|
156
|
+
url: url,
|
|
157
|
+
partial: partial,
|
|
158
|
+
headers: headers,
|
|
159
|
+
onProgress: onProgress
|
|
160
|
+
).verifyAndFinalize(
|
|
161
|
+
final: final,
|
|
162
|
+
partial: partial,
|
|
163
|
+
expectedSha256: expected
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
// Step 7: iOS no-backup attribute.
|
|
167
|
+
Self.applyNoBackupAttribute(final)
|
|
168
|
+
|
|
169
|
+
return (final.path, false)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// MARK: - Helpers
|
|
173
|
+
|
|
174
|
+
/// Compute SHA-256 of a file lazily by streaming 64 KiB chunks.
|
|
175
|
+
static func sha256OfFile(at url: URL) throws -> String {
|
|
176
|
+
var hasher = SHA256()
|
|
177
|
+
let handle = try FileHandle(forReadingFrom: url)
|
|
178
|
+
defer { try? handle.close() }
|
|
179
|
+
while true {
|
|
180
|
+
let chunk = try handle.read(upToCount: 64 * 1024) ?? Data()
|
|
181
|
+
if chunk.isEmpty { break }
|
|
182
|
+
hasher.update(data: chunk)
|
|
183
|
+
}
|
|
184
|
+
let digest = hasher.finalize()
|
|
185
|
+
return digest.map { String(format: "%02x", $0) }.joined()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Set `URLResourceKey.isExcludedFromBackupKey = true`. Best-effort —
|
|
189
|
+
/// failure here is non-fatal (e.g. unit tests in tmp dirs).
|
|
190
|
+
static func applyNoBackupAttribute(_ url: URL) {
|
|
191
|
+
var mutableURL = url
|
|
192
|
+
var values = URLResourceValues()
|
|
193
|
+
values.isExcludedFromBackup = true
|
|
194
|
+
try? mutableURL.setResourceValues(values)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// MARK: - Streaming download (URLSessionDataDelegate, iOS 14+)
|
|
199
|
+
|
|
200
|
+
/// Result of a streaming download: the running SHA-256 digest as hex.
|
|
201
|
+
struct StreamingDownloadResult {
|
|
202
|
+
let gotHex: String
|
|
203
|
+
|
|
204
|
+
/// Verify hash, atomic-rename, and clean up on mismatch.
|
|
205
|
+
func verifyAndFinalize(final: URL, partial: URL, expectedSha256: String) throws {
|
|
206
|
+
let fm = FileManager.default
|
|
207
|
+
if gotHex != expectedSha256 {
|
|
208
|
+
try? fm.removeItem(at: partial)
|
|
209
|
+
try? fm.removeItem(at: final)
|
|
210
|
+
throw ModelDownloader.DownloadError.checksumMismatch(
|
|
211
|
+
expected: expectedSha256,
|
|
212
|
+
got: gotHex
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if fm.fileExists(atPath: final.path) {
|
|
216
|
+
try? fm.removeItem(at: final)
|
|
217
|
+
}
|
|
218
|
+
try fm.moveItem(at: partial, to: final)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// Wraps `URLSessionDataDelegate` with a continuation so the streaming
|
|
223
|
+
/// download can be `await`ed. Handles:
|
|
224
|
+
/// - Replaying existing `.partial` bytes through SHA-256 (resume).
|
|
225
|
+
/// - Range request + 200/206 handling (server-honoured / not-honoured).
|
|
226
|
+
/// - 64 KiB-buffered hashing + appending.
|
|
227
|
+
/// - Progress debounced to ~10/sec.
|
|
228
|
+
final class StreamingDownload: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
|
229
|
+
private var hasher = SHA256()
|
|
230
|
+
private var written: Int64 = 0
|
|
231
|
+
private var totalBytes: Int64?
|
|
232
|
+
private var writeHandle: FileHandle?
|
|
233
|
+
private var lastEmit: Date = .distantPast
|
|
234
|
+
private let debounceInterval: TimeInterval = 0.1
|
|
235
|
+
|
|
236
|
+
private let partial: URL
|
|
237
|
+
private let onProgress: @Sendable (Int64, Int64?) -> Void
|
|
238
|
+
private var continuation: CheckedContinuation<StreamingDownloadResult, Error>?
|
|
239
|
+
private var didFinish = false
|
|
240
|
+
|
|
241
|
+
/// Whether we asked the server for a Range. If true and the server replies
|
|
242
|
+
/// 200 (full body), reset hash + truncate file before consuming data.
|
|
243
|
+
private var requestedRange = false
|
|
244
|
+
private var serverWillSendFullBody = false
|
|
245
|
+
|
|
246
|
+
private init(
|
|
247
|
+
partial: URL,
|
|
248
|
+
onProgress: @Sendable @escaping (Int64, Int64?) -> Void
|
|
249
|
+
) {
|
|
250
|
+
self.partial = partial
|
|
251
|
+
self.onProgress = onProgress
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Entry point. Replays existing `.partial`, performs the download,
|
|
255
|
+
/// returns the final hex digest on success.
|
|
256
|
+
static func run(
|
|
257
|
+
url: URL,
|
|
258
|
+
partial: URL,
|
|
259
|
+
headers: [String: String],
|
|
260
|
+
onProgress: @Sendable @escaping (Int64, Int64?) -> Void
|
|
261
|
+
) async throws -> StreamingDownloadResult {
|
|
262
|
+
let runner = StreamingDownload(partial: partial, onProgress: onProgress)
|
|
263
|
+
try runner.replayPartial()
|
|
264
|
+
|
|
265
|
+
// Parity with Android: if `.partial` is larger than the remote resource,
|
|
266
|
+
// a Range request would yield 416 Range Not Satisfiable (opaque to the
|
|
267
|
+
// caller). HEAD-probe the Content-Length and discard an oversized
|
|
268
|
+
// partial before issuing the real GET. Any HEAD failure is non-fatal —
|
|
269
|
+
// we just skip the optimisation and let the GET path proceed.
|
|
270
|
+
if runner.written > 0 {
|
|
271
|
+
if let remoteLength = await Self.probeContentLength(url: url, headers: headers),
|
|
272
|
+
remoteLength >= 0,
|
|
273
|
+
runner.written > remoteLength {
|
|
274
|
+
try? FileManager.default.removeItem(at: partial)
|
|
275
|
+
runner.hasher = SHA256()
|
|
276
|
+
runner.written = 0
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
var request = URLRequest(url: url)
|
|
281
|
+
for (k, v) in headers {
|
|
282
|
+
request.setValue(v, forHTTPHeaderField: k)
|
|
283
|
+
}
|
|
284
|
+
if runner.written > 0 {
|
|
285
|
+
request.setValue("bytes=\(runner.written)-", forHTTPHeaderField: "Range")
|
|
286
|
+
runner.requestedRange = true
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let session = URLSession(
|
|
290
|
+
configuration: .default,
|
|
291
|
+
delegate: runner,
|
|
292
|
+
delegateQueue: nil // serial OperationQueue created internally
|
|
293
|
+
)
|
|
294
|
+
defer { session.finishTasksAndInvalidate() }
|
|
295
|
+
|
|
296
|
+
return try await withCheckedThrowingContinuation { cont in
|
|
297
|
+
runner.continuation = cont
|
|
298
|
+
let task = session.dataTask(with: request)
|
|
299
|
+
task.resume()
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/// HEAD-probe the URL to learn `Content-Length`. Returns nil on any
|
|
304
|
+
/// failure (network error, non-2xx, missing/invalid header). Headers are
|
|
305
|
+
/// forwarded so authenticated endpoints work.
|
|
306
|
+
private static func probeContentLength(url: URL, headers: [String: String]) async -> Int64? {
|
|
307
|
+
var request = URLRequest(url: url)
|
|
308
|
+
request.httpMethod = "HEAD"
|
|
309
|
+
for (k, v) in headers {
|
|
310
|
+
request.setValue(v, forHTTPHeaderField: k)
|
|
311
|
+
}
|
|
312
|
+
do {
|
|
313
|
+
let (_, response) = try await URLSession.shared.data(for: request)
|
|
314
|
+
guard let http = response as? HTTPURLResponse,
|
|
315
|
+
(200...299).contains(http.statusCode) else {
|
|
316
|
+
return nil
|
|
317
|
+
}
|
|
318
|
+
// expectedContentLength uses Content-Length when present (-1 if unknown).
|
|
319
|
+
let len = http.expectedContentLength
|
|
320
|
+
return len >= 0 ? len : nil
|
|
321
|
+
} catch {
|
|
322
|
+
return nil
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/// Replay any existing .partial bytes through the hash so we can resume.
|
|
327
|
+
private func replayPartial() throws {
|
|
328
|
+
let fm = FileManager.default
|
|
329
|
+
guard fm.fileExists(atPath: partial.path) else { return }
|
|
330
|
+
let attrs = try fm.attributesOfItem(atPath: partial.path)
|
|
331
|
+
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
|
332
|
+
if size <= 0 { return }
|
|
333
|
+
let handle = try FileHandle(forReadingFrom: partial)
|
|
334
|
+
defer { try? handle.close() }
|
|
335
|
+
while true {
|
|
336
|
+
let chunk = try handle.read(upToCount: 64 * 1024) ?? Data()
|
|
337
|
+
if chunk.isEmpty { break }
|
|
338
|
+
hasher.update(data: chunk)
|
|
339
|
+
written += Int64(chunk.count)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// MARK: - URLSessionDataDelegate
|
|
344
|
+
|
|
345
|
+
func urlSession(
|
|
346
|
+
_ session: URLSession,
|
|
347
|
+
dataTask: URLSessionDataTask,
|
|
348
|
+
didReceive response: URLResponse,
|
|
349
|
+
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
|
350
|
+
) {
|
|
351
|
+
guard let http = response as? HTTPURLResponse else {
|
|
352
|
+
finish(with: .failure(ModelDownloader.DownloadError.httpError(status: -1)))
|
|
353
|
+
completionHandler(.cancel)
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
if !(200...206).contains(http.statusCode) {
|
|
357
|
+
finish(with: .failure(ModelDownloader.DownloadError.httpError(status: http.statusCode)))
|
|
358
|
+
completionHandler(.cancel)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let fm = FileManager.default
|
|
363
|
+
|
|
364
|
+
// If we asked for a Range and got 200 → server didn't honour it,
|
|
365
|
+
// restart hash + truncate file.
|
|
366
|
+
if requestedRange && http.statusCode == 200 {
|
|
367
|
+
hasher = SHA256()
|
|
368
|
+
written = 0
|
|
369
|
+
serverWillSendFullBody = true
|
|
370
|
+
try? fm.removeItem(at: partial)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let contentLength = http.expectedContentLength // -1 if unknown
|
|
374
|
+
if contentLength >= 0 {
|
|
375
|
+
totalBytes = written + contentLength
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Open partial for write at current offset.
|
|
379
|
+
if !fm.fileExists(atPath: partial.path) {
|
|
380
|
+
fm.createFile(atPath: partial.path, contents: nil)
|
|
381
|
+
}
|
|
382
|
+
do {
|
|
383
|
+
let h = try FileHandle(forWritingTo: partial)
|
|
384
|
+
try h.seek(toOffset: UInt64(written))
|
|
385
|
+
writeHandle = h
|
|
386
|
+
} catch {
|
|
387
|
+
finish(with: .failure(error))
|
|
388
|
+
completionHandler(.cancel)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Initial 0% emit so callers see we've started.
|
|
393
|
+
onProgress(written, totalBytes)
|
|
394
|
+
lastEmit = Date()
|
|
395
|
+
|
|
396
|
+
completionHandler(.allow)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
func urlSession(
|
|
400
|
+
_ session: URLSession,
|
|
401
|
+
dataTask: URLSessionDataTask,
|
|
402
|
+
didReceive data: Data
|
|
403
|
+
) {
|
|
404
|
+
guard let h = writeHandle else { return }
|
|
405
|
+
do {
|
|
406
|
+
try h.write(contentsOf: data)
|
|
407
|
+
} catch {
|
|
408
|
+
finish(with: .failure(error))
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
hasher.update(data: data)
|
|
412
|
+
written += Int64(data.count)
|
|
413
|
+
let now = Date()
|
|
414
|
+
if now.timeIntervalSince(lastEmit) >= debounceInterval {
|
|
415
|
+
onProgress(written, totalBytes)
|
|
416
|
+
lastEmit = now
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
func urlSession(
|
|
421
|
+
_ session: URLSession,
|
|
422
|
+
task: URLSessionTask,
|
|
423
|
+
didCompleteWithError error: Error?
|
|
424
|
+
) {
|
|
425
|
+
try? writeHandle?.close()
|
|
426
|
+
writeHandle = nil
|
|
427
|
+
if let error = error {
|
|
428
|
+
finish(with: .failure(error))
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
// Final progress emit.
|
|
432
|
+
onProgress(written, totalBytes)
|
|
433
|
+
let digest = hasher.finalize()
|
|
434
|
+
let gotHex = digest.map { String(format: "%02x", $0) }.joined()
|
|
435
|
+
finish(with: .success(StreamingDownloadResult(gotHex: gotHex)))
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private func finish(with result: Result<StreamingDownloadResult, Error>) {
|
|
439
|
+
guard !didFinish else { return }
|
|
440
|
+
didFinish = true
|
|
441
|
+
let cont = continuation
|
|
442
|
+
continuation = nil
|
|
443
|
+
cont?.resume(with: result)
|
|
444
|
+
}
|
|
445
|
+
}
|