Download a 2 GB model in the background on iOS with URLSession, mirrors and SHA-256
A language model is a 1.6 GB file that people will download over a hotel Wi-Fi and then close your app. This is how to make that download finish anyway, and make sure what arrived is the right file.
Shipping a local model inside your app bundle is rarely an option: LFM2 2.6B in 4-bit is 1,563,668,704 bytes, and nobody wants a 1.7 GB install for a feature they might not use. So the model is downloaded on request. A plain URLSession.shared.download(from:) works in the happy path and fails in all the common ones: the person switches apps, the phone locks, the connection drops at 80 %, the CDN returns an HTML error page with status 200 or 404.
This guide builds a download manager that handles those cases with a background URLSession, a list of mirrors and a checksum. It is about 150 lines and has no dependencies.
Describe the file you expect
Start with what makes a file the right file: its exact size and its SHA-256. Get both once on your Mac with ls -l and shasum -a 256.
ls -l LFM2-2.6B-Q4_K_M.gguf # 1563668704
shasum -a 256 LFM2-2.6B-Q4_K_M.gguf # 384bc877b6c37064982f96885bef69e4475919f5969218ed4e3b9399ae0340dfimport Foundation
struct ModelSpec: Sendable {
let id: String
let urls: [URL] // tried in order: your mirror first, the original last
let fileName: String
let bytes: Int64 // for progress when the server sends no Content-Length
let sha256: String
static let lfm2 = ModelSpec(
id: "lfm2-2.6b-q4_k_m",
urls: [
URL(string: "https://cdn.example.com/models/lfm2-2.6b-q4_k_m.gguf")!,
URL(string: "https://huggingface.co/LiquidAI/LFM2-2.6B-GGUF/resolve/main/LFM2-2.6B-Q4_K_M.gguf")!,
],
fileName: "lfm2-2.6b-q4_k_m.gguf",
bytes: 1_563_668_704,
sha256: "384bc877b6c37064982f96885bef69e4475919f5969218ed4e3b9399ae0340df"
)
}Why a checksum, when HTTPS already protects the transfer? Because the failures you will see are not tampering. They are a mirror that serves a stale version, a proxy that truncates large responses, or a file that was replaced upstream. llama.cpp does not handle a cut-off GGUF gracefully; it can crash while loading it. Checking 1.6 GB takes a few seconds once and saves you that crash report.
A background session
A background session hands the transfer to a system daemon. It continues while your app is suspended, and if the app is terminated by the system it is relaunched in the background when the download finishes. Three settings matter:
import Foundation
import CryptoKit
import Observation
@MainActor
@Observable
final class ModelDownloader: NSObject {
enum State: Equatable {
case notDownloaded, downloading(Double), verifying, ready, failed(String)
}
let spec: ModelSpec
private(set) var state: State = .notDownloaded
@ObservationIgnored private var session: URLSession!
@ObservationIgnored private var task: URLSessionDownloadTask?
@ObservationIgnored private var urlIndex = 0
@ObservationIgnored static var completions: [String: () -> Void] = [:]
nonisolated var identifier: String { "model.\(spec.id)" }
nonisolated var file: URL {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Models", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent(spec.fileName)
}
init(_ spec: ModelSpec) {
self.spec = spec
super.init()
let config = URLSessionConfiguration.background(withIdentifier: identifier)
config.isDiscretionary = false // start now, not when iOS finds it convenient
config.sessionSendsLaunchEvents = true // relaunch the app when the transfer finishes
session = URLSession(configuration: config, delegate: self, delegateQueue: .main)
if FileManager.default.fileExists(atPath: file.path) { state = .ready; return }
reattach()
}
}isDiscretionary = false is the one people miss. With true, iOS may wait for Wi-Fi and power before it even starts, which is correct for a prefetch and wrong for a download someone just tapped. The identifier must be stable across launches: it is how your new process finds the transfer the old one started.
Pick up a running download on launch
When the person reopens the app halfway through, the transfer is still running in the system. Creating the session with the same identifier reconnects it; you only need to ask which task is in flight and show its progress.
extension ModelDownloader {
private func reattach() {
session.getAllTasks { [weak self] tasks in
guard let running = tasks.first as? URLSessionDownloadTask else { return }
Task { @MainActor in
guard let self else { return }
self.task = running
self.urlIndex = Int(running.taskDescription ?? "0") ?? 0
self.state = .downloading(self.fraction(running.countOfBytesReceived,
running.countOfBytesExpectedToReceive))
}
}
}
func download() {
guard task == nil, state != .ready else { return }
urlIndex = 0
start()
}
private func start() {
guard urlIndex < spec.urls.count else {
state = .failed("The download didn't work. Check the connection and try again, ideally on Wi-Fi.")
return
}
state = .downloading(0)
task = session.downloadTask(with: spec.urls[urlIndex])
task?.taskDescription = String(urlIndex) // survives relaunch: which mirror is this?
task?.resume()
}
private func next() {
task = nil
urlIndex += 1
start()
}
private func fraction(_ written: Int64, _ expected: Int64) -> Double {
let total = expected > 0 ? expected : spec.bytes
return min(1, Double(written) / Double(max(total, 1)))
}
}taskDescription is the only place to keep state on the task itself, and it survives the app being killed. Storing the mirror index there means that if the first mirror fails after a relaunch, you still know which one to try next.
The delegate: status codes, moving the file, errors
Background sessions do not support completion handlers; everything arrives through the delegate. Two traps live in didFinishDownloadingTo. First, a 404 or 500 also "finishes" and hands you a file, containing an error page. Second, the temporary file is deleted as soon as the method returns, so you have to move it synchronously, before hopping to the main actor.
extension ModelDownloader: URLSessionDownloadDelegate {
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didWriteData _: Int64, totalBytesWritten written: Int64,
totalBytesExpectedToWrite expected: Int64) {
Task { @MainActor in self.state = .downloading(self.fraction(written, expected)) }
}
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
if let http = downloadTask.response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
Task { @MainActor in self.finished(ok: false) }
return
}
var target = file
try? FileManager.default.removeItem(at: target)
let ok = (try? FileManager.default.moveItem(at: location, to: target)) != nil
if ok {
var values = URLResourceValues()
values.isExcludedFromBackup = true // 1.6 GB does not belong in iCloud Backup
try? target.setResourceValues(values)
}
Task { @MainActor in self.finished(ok: ok) }
}
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error, (error as NSError).code != NSURLErrorCancelled else { return }
Task { @MainActor in
if (error as NSError).code == NSURLErrorNotConnectedToInternet {
self.task = nil
self.state = .failed("You're offline. Try again when you're connected.")
} else {
self.next() // timeouts, resets, TLS errors: try the next mirror
}
}
}
}Excluding the file from backup is not optional politeness. App Review has rejected apps for putting large re-downloadable data where it is backed up, and the person's iCloud quota will thank you.
Verify SHA-256 off the main thread
Reading 1.6 GB into a Data to hash it would double your memory footprint at exactly the moment you are about to load a model. Stream it in 8 MB chunks instead, on a background task:
extension ModelDownloader {
fileprivate func finished(ok: Bool) {
task = nil
guard ok else { next(); return }
state = .verifying
let file = self.file, expected = spec.sha256.lowercased()
Task.detached(priority: .utility) {
let good = Self.sha256(of: file) == expected
await MainActor.run {
if good { self.state = .ready }
else {
try? FileManager.default.removeItem(at: file)
self.next() // wrong or broken file from this source: try the next one
}
}
}
}
nonisolated static func sha256(of url: URL) -> String? {
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try? handle.read(upToCount: 8 << 20), !chunk.isEmpty {
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
}A failed checksum moves on to the next URL, exactly like an HTTP error. That is what makes mirrors useful: your own server can be the fast first choice, and if it ever serves a bad file, the original on Hugging Face still gets the person a working model.
Forward background events from the app delegate
When the download finishes while your app is not running, iOS launches it in the background and calls the app delegate. You must keep the completion handler and call it once the session has delivered all its events, or iOS will stop granting you background time.
import SwiftUI
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
guard identifier.hasPrefix("model.") else { completionHandler(); return }
ModelDownloader.completions[identifier] = completionHandler
}
}
extension ModelDownloader {
nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
let id = session.configuration.identifier ?? ""
Task { @MainActor in ModelDownloader.completions.removeValue(forKey: id)?() }
}
}
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
var body: some Scene { WindowGroup { ContentView() } }
}For the relaunched process to receive those delegate calls, a ModelDownloader with the same identifier has to exist. Create it early, in your app's root view or a shared model object, not lazily on the settings screen.
The UI
struct ModelRow: View {
let downloader: ModelDownloader
var body: some View {
switch downloader.state {
case .notDownloaded: Button("Download (1.6 GB)") { downloader.download() }
case .downloading(let p): ProgressView(value: p) { Text("Downloading \(Int(p * 100)) %") }
case .verifying: Text("Checking the file …")
case .ready: Label("Ready", systemImage: "checkmark.circle")
case .failed(let reason): Text(reason)
}
}
}Show the size before the download starts and make the download a choice. Both are good manners and both come up in App Review. Once the file is ready, load it with llama.cpp; if prompts get long, read the n_batch crash guide before your users find it.
Testing it
- Start the download, then stop the app from Xcode. Relaunch: the progress should continue from where it is, not from zero.
- Put an invalid URL first in
urls, then a URL to a small wrong file. Both should fall through to the working mirror. - Turn on Airplane Mode mid-download and check the message, then retry.
- Background relaunch is easiest to observe on a real device with the debugger detached; Xcode keeps the app alive otherwise.