164 lines
6.2 KiB
Swift
164 lines
6.2 KiB
Swift
//
|
|
// ModelDownloadStorage.swift
|
|
// MNNLLMiOS
|
|
//
|
|
// Created by 游薪渝(揽清) on 2025/2/20.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// ModelDownloadStorage - Persistent storage manager for download state tracking
|
|
///
|
|
/// This class provides comprehensive storage management for tracking downloaded model files,
|
|
/// their metadata, and download completion status. It uses UserDefaults for persistence
|
|
/// and maintains file integrity through size and revision validation.
|
|
///
|
|
/// Key Features:
|
|
/// - Persistent download state tracking across app sessions
|
|
/// - File integrity validation using size and revision checks
|
|
/// - Efficient storage using relative path mapping
|
|
/// - Thread-safe operations with immediate persistence
|
|
/// - Automatic cleanup and state management
|
|
///
|
|
/// Architecture:
|
|
/// - Uses UserDefaults as the underlying storage mechanism
|
|
/// - Maintains a dictionary mapping relative paths to FileStatus objects
|
|
/// - Provides atomic operations for state updates
|
|
/// - Supports both individual file and batch operations
|
|
///
|
|
/// Storage Format:
|
|
/// - Files are indexed by relative paths from Documents directory
|
|
/// - Each entry contains size, revision, and timestamp information
|
|
/// - JSON encoding ensures cross-session compatibility
|
|
///
|
|
/// Usage:
|
|
/// ```swift
|
|
/// let storage = ModelDownloadStorage()
|
|
///
|
|
/// // Check if file is already downloaded
|
|
/// if storage.isFileDownloaded(file, at: destinationPath) {
|
|
/// print("File already exists and is up to date")
|
|
/// }
|
|
///
|
|
/// // Save download completion status
|
|
/// storage.saveFileStatus(file, at: destinationPath)
|
|
/// ```
|
|
final class ModelDownloadStorage {
|
|
// MARK: - Properties
|
|
|
|
private let userDefaults: UserDefaults
|
|
private let downloadedFilesKey = "ModelScope.DownloadedFiles"
|
|
|
|
// MARK: - Initialization
|
|
|
|
/// Initializes the storage manager with configurable UserDefaults
|
|
///
|
|
/// - Parameter userDefaults: UserDefaults instance for persistence (defaults to .standard)
|
|
init(userDefaults: UserDefaults = .standard) {
|
|
self.userDefaults = userDefaults
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Checks if a file has been completely downloaded and is up to date
|
|
///
|
|
/// Validates both file existence on disk and metadata consistency including
|
|
/// file size and revision to ensure the downloaded file is current and complete.
|
|
///
|
|
/// - Parameters:
|
|
/// - file: ModelFile object containing expected metadata
|
|
/// - path: Destination path where the file should be located
|
|
/// - Returns: true if file exists and matches expected metadata, false otherwise
|
|
func isFileDownloaded(_ file: ModelFile, at path: String) -> Bool {
|
|
let filePath = (path as NSString).appendingPathComponent(file.name.sanitizedPath)
|
|
let relativePath = getRelativePath(from: filePath)
|
|
|
|
guard FileManager.default.fileExists(atPath: filePath),
|
|
let downloadedFiles = getDownloadedFiles(),
|
|
let fileStatus = downloadedFiles[relativePath]
|
|
else {
|
|
return false
|
|
}
|
|
|
|
return fileStatus.size == file.size && fileStatus.revision == file.revision
|
|
}
|
|
|
|
/// Saves the download completion status for a file
|
|
///
|
|
/// Records file metadata including size, revision, and download timestamp
|
|
/// to persistent storage. This enables resume functionality and prevents
|
|
/// unnecessary re-downloads of unchanged files.
|
|
///
|
|
/// - Parameters:
|
|
/// - file: ModelFile object containing file metadata
|
|
/// - path: Destination path where the file was downloaded
|
|
func saveFileStatus(_ file: ModelFile, at path: String) {
|
|
let filePath = (path as NSString).appendingPathComponent(file.name.sanitizedPath)
|
|
let relativePath = getRelativePath(from: filePath)
|
|
|
|
let fileStatus = FileStatus(
|
|
path: relativePath,
|
|
size: file.size,
|
|
revision: file.revision,
|
|
lastModified: Date()
|
|
)
|
|
|
|
var downloadedFiles = getDownloadedFiles() ?? [:]
|
|
downloadedFiles[relativePath] = fileStatus
|
|
|
|
// Save to UserDefaults and ensure immediate write
|
|
if let encoded = try? JSONEncoder().encode(downloadedFiles) {
|
|
userDefaults.set(encoded, forKey: downloadedFilesKey)
|
|
userDefaults.synchronize()
|
|
}
|
|
}
|
|
|
|
/// Retrieves all downloaded file statuses from persistent storage
|
|
///
|
|
/// - Returns: Dictionary mapping relative file paths to FileStatus objects,
|
|
/// or nil if no download history exists
|
|
func getDownloadedFiles() -> [String: FileStatus]? {
|
|
guard let data = userDefaults.data(forKey: downloadedFilesKey),
|
|
let downloadedFiles = try? JSONDecoder().decode([String: FileStatus].self, from: data)
|
|
else {
|
|
return nil
|
|
}
|
|
return downloadedFiles
|
|
}
|
|
|
|
/// Removes download status for a specific file
|
|
///
|
|
/// Cleans up storage by removing the file's download record, typically
|
|
/// used when files are deleted or need to be re-downloaded.
|
|
///
|
|
/// - Parameter path: Full path to the file whose status should be cleared
|
|
func clearFileStatus(at path: String) {
|
|
let relativePath = getRelativePath(from: path)
|
|
var downloadedFiles = getDownloadedFiles() ?? [:]
|
|
downloadedFiles.removeValue(forKey: relativePath)
|
|
|
|
if let encoded = try? JSONEncoder().encode(downloadedFiles) {
|
|
userDefaults.set(encoded, forKey: downloadedFilesKey)
|
|
userDefaults.synchronize()
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
/// Converts absolute file paths to relative paths from Documents directory
|
|
///
|
|
/// This normalization ensures consistent path handling across different
|
|
/// app installations and device configurations.
|
|
///
|
|
/// - Parameter fullPath: Absolute file path to convert
|
|
/// - Returns: Relative path from Documents directory, or original path if conversion fails
|
|
private func getRelativePath(from fullPath: String) -> String {
|
|
guard let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path else {
|
|
return fullPath
|
|
}
|
|
|
|
// Convert absolute path to relative path from Documents directory
|
|
return fullPath.replacingOccurrences(of: documentsPath + "/", with: "")
|
|
}
|
|
}
|