// // LNFileDownloader.swift // Lanu // // Created by OneeChan on 2025/12/5. // import Foundation private class LNFileDownloadTask { let sourceUrl: String let destinationPath: URL var isRunning = false var progressHandler: [((Float) -> Void)] = [] var completionHandler: [((Result) -> Void)] = [] var totalBytesWritten: Int64 = 0 var totalBytesExpectedToWrite: Int64 = 0 init(url: String, destinationPath: URL) { self.sourceUrl = url self.destinationPath = destinationPath } } class LNFileDownloader: NSObject { static let shared = LNFileDownloader() private let maxConcurrentCount: Int = 3 private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.httpMaximumConnectionsPerHost = maxConcurrentCount return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue()) }() private let lock = NSLock() private var taskList: [LNFileDownloadTask] = [] private var runningTaskCount: Int = 0 enum LNFileDownloadError: Error { case invalidURL case fileMoveFailed case beCancelled case networkError(Error) var errorDesc: String? { switch self { case .invalidURL: return "无效的下载链接" case .fileMoveFailed: return "文件保存失败" case .beCancelled: return "被取消" case .networkError: return "网络错误" } } } func startDownload( from urlString: String, destinationPath: URL? = nil, progressHandler: ((Float) -> Void)? = nil, completionHandler: ((Result) -> Void)? = nil ) { guard let url = URL(string: urlString) else { completionHandler?(.failure(LNFileDownloadError.invalidURL)) return } DispatchQueue.global().async { [weak self] in guard let self else { return } lock.lock() defer { lock.unlock() } let task = taskList.first { $0.sourceUrl == urlString } if let task { if let progressHandler { task.progressHandler.append(progressHandler) } if let completionHandler { task.completionHandler.append(completionHandler) } return } let targetPath = destinationPath ?? defaultDestinationPath(for: url) let newTask = LNFileDownloadTask(url: urlString, destinationPath: targetPath) if let progressHandler { newTask.progressHandler.append(progressHandler) } if let completionHandler { newTask.completionHandler.append(completionHandler) } taskList.append(newTask) if runningTaskCount >= maxConcurrentCount { return } let downloadTask = session.downloadTask(with: url) downloadTask.taskDescription = urlString runningTaskCount += 1 newTask.isRunning = true downloadTask.resume() } } func cancelDownload(urlString: String) { DispatchQueue.global().async { [weak self] in guard let self else { return } lock.lock() defer { lock.unlock() } guard let task = taskList.first(where: { $0.sourceUrl == urlString }) else { return } session.getAllTasks(completionHandler: { tasks in tasks.first { $0.taskDescription == urlString }?.cancel() }) removeTask(urlString: urlString) let handlers = task.completionHandler runOnMain { handlers.forEach { $0(.failure(LNFileDownloadError.beCancelled)) } } } } /// 取消所有下载任务 func cancelAllDownloads() { DispatchQueue.global().async { [weak self] in guard let self else { return } lock.lock() defer { lock.unlock() } session.getAllTasks(completionHandler: { tasks in tasks.forEach { $0.cancel() } }) taskList.removeAll() runningTaskCount = 0 } } } extension LNFileDownloader { /// 生成默认保存路径(Documents目录+原文件名) private func defaultDestinationPath(for url: URL) -> URL { let fileName = url.lastPathComponent return URL.documentsDir.appendingPathComponent(fileName) } private func removeTask(urlString: String) { DispatchQueue.global().async { [weak self] in guard let self else { return } lock.lock() defer { lock.unlock() } taskList.removeAll(where: { $0.sourceUrl == urlString }) runningTaskCount = max(0, self.runningTaskCount - 1) if let nextTask = taskList.first(where: { $0.isRunning == false }) { let downloadTask = session.downloadTask(with: URL(string: nextTask.sourceUrl)!) downloadTask.taskDescription = urlString runningTaskCount += 1 nextTask.isRunning = true downloadTask.resume() } } } } extension LNFileDownloader: URLSessionDownloadDelegate { func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { lock.lock() defer { lock.unlock() } guard let urlString = downloadTask.taskDescription, let taskModel = taskList.first(where: { $0.sourceUrl == urlString }) else { return } taskModel.totalBytesWritten = totalBytesWritten taskModel.totalBytesExpectedToWrite = totalBytesExpectedToWrite let progress = totalBytesExpectedToWrite > 0 ? Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) : 0.0 let handlers = taskModel.progressHandler runOnMain { handlers.forEach { $0(progress) } } } func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { lock.lock() defer { lock.unlock() } guard FileManager.default.fileExists(atPath: location.path) else { return } guard let urlString = downloadTask.taskDescription, let taskModel = taskList.first(where: { $0.sourceUrl == urlString }) else { return } // 2. 移动临时文件到目标路径 do { let destinationPath = taskModel.destinationPath // 检查目标文件是否存在,存在则删除(避免覆盖失败) if FileManager.default.fileExists(atPath: destinationPath.path) { try FileManager.default.removeItem(at: destinationPath) } // 移动文件 let destinationDir = destinationPath.deletingLastPathComponent() if !FileManager.default.fileExists(atPath: destinationDir.path) { try FileManager.default.createDirectory( at: destinationDir, withIntermediateDirectories: true, attributes: nil ) } try FileManager.default.moveItem(at: location, to: destinationPath) // 回调成功结果 let handlers = taskModel.completionHandler runOnMain { handlers.forEach { $0(.success(destinationPath)) } } } catch { // 回调文件移动失败 let handlers = taskModel.completionHandler runOnMain { handlers.forEach { $0(.failure(LNFileDownloadError.fileMoveFailed)) } } } removeTask(urlString: urlString) } } extension LNFileDownloader: URLSessionTaskDelegate { /// 任务完成(含错误处理) func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { lock.lock() defer { lock.unlock() } guard let urlString = task.taskDescription, let taskModel = taskList.first(where: { $0.sourceUrl == urlString }) else { return } // 处理错误(排除用户取消的情况) if let error = error { let nsError = error as NSError // 取消下载在取消操作进行了回调,这里不处理 if nsError.code == NSURLErrorCancelled { removeTask(urlString: urlString) return } // 其他错误:回调网络错误 let handlers = taskModel.completionHandler runOnMain { handlers.forEach { $0(.failure(LNFileDownloadError.networkError(error))) } } } // 移除任务(无论成功与否,任务完成后都要移除) removeTask(urlString: urlString) } }