LocalWallpapersManager.swift

7.3 KB · raw

1import Combine2import CoreGraphics3import Foundation4import ImageIO56struct LocalWallpaper: Identifiable, Equatable {7  let id: String8  let url: URL9  let filename: String10  let resolution: String11  let fileSize: Int6412  let createdAt: Date13  let modifiedAt: Date1415  var thumbnailURL: URL? {16    return url17  }1819  static func == (lhs: LocalWallpaper, rhs: LocalWallpaper) -> Bool {20    return lhs.id == rhs.id21  }22}2324@MainActor25class LocalWallpapersManager: ObservableObject {26  static let shared = LocalWallpapersManager()2728  @Published var wallpapers: [LocalWallpaper] = []29  @Published var isLoading: Bool = false30  @Published var errorMessage: String?3132  private var folderMonitor: FolderMonitor?33  private let imageExtensions: Set<String> = ["jpg", "jpeg", "png", "webp", "bmp", "gif"]3435  init() {36    // Initial scan if a folder is configured37    if let folderPath = UserDefaults.standard.string(forKey: "saveFolderPath") {38      startMonitoring(folderPath: folderPath)39    }40  }4142  func startMonitoring(folderPath: String) {43    folderMonitor?.stop()44    folderMonitor = FolderMonitor(folderPath: folderPath) { [weak self] in45      Task { @MainActor in46        self?.scanFolder(path: folderPath)47      }48    }49    folderMonitor?.start()50    scanFolder(path: folderPath)51  }5253  func stopMonitoring() {54    folderMonitor?.stop()55    folderMonitor = nil56  }5758  /// Bumped on every `scanFolder` invocation. A scan only commits its59  /// results when it still matches this counter at completion, which means60  /// newer requests take priority over older in-flight scans and filesystem61  /// changes after the scan started are observed by the next request rather62  /// than silently dropped.63  private var scanGeneration: UInt64 = 06465  func scanFolder(path: String) {66    scanGeneration &+= 167    let requestGeneration = scanGeneration68    isLoading = true69    errorMessage = nil7071    // Snapshot all immutable configuration the detached task needs into a72    // Sendable value. This keeps `Task.detached` from capturing `self` (and73    // its non-Sendable mutable state) by reference, which the Swift 674    // concurrency checker would otherwise flag.75    let snapshot = ScanRequest(76      path: path,77      generation: requestGeneration,78      imageExtensions: imageExtensions)7980    Task.detached(priority: .userInitiated) { [weak self] in81      let result = LocalWallpapersManager.performScan(request: snapshot)82      await MainActor.run {83        guard let self = self else { return }84        guard self.scanGeneration == snapshot.generation else { return }85        switch result {86        case .success(let wallpapers):87          self.wallpapers = wallpapers88          self.errorMessage = nil89        case .failure(let message):90          self.errorMessage = message91        }92        self.isLoading = false93      }94    }95  }9697  /// Pure, Sendable value carried across the actor boundary into the98  /// detached scan task. Capturing everything the scan needs up front means99  /// the closure body never touches mutable manager state from a non-isolated100  /// context.101  private struct ScanRequest: Sendable {102    let path: String103    let generation: UInt64104    let imageExtensions: Set<String>105  }106107  private enum ScanOutcome: Sendable {108    case success([LocalWallpaper])109    case failure(String)110  }111112  /// Scans the folder and returns the wallpapers sorted newest-first. Runs113  /// entirely off the main actor; all inputs are passed via the Sendable114  /// `ScanRequest` snapshot.115  nonisolated private static func performScan(request: ScanRequest) -> ScanOutcome {116    let folderURL = URL(fileURLWithPath: request.path)117    let fileManager = FileManager.default118119    // Create the save folder on first use so a fresh install shows an120    // empty gallery instead of a scan error.121    try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)122123    do {124      let contents = try fileManager.contentsOfDirectory(125        at: folderURL,126        includingPropertiesForKeys: [127          .contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey,128        ],129        options: [.skipsHiddenFiles, .skipsPackageDescendants]130      )131132      var wallpapers: [LocalWallpaper] = []133      for url in contents {134        let ext = url.pathExtension.lowercased()135        guard request.imageExtensions.contains(ext) else { continue }136        if let wallpaper = wallpaper(at: url) { wallpapers.append(wallpaper) }137      }138139      wallpapers.sort { $0.modifiedAt > $1.modifiedAt }140      return .success(wallpapers)141    } catch {142      return .failure("Failed to scan folder: \(error.localizedDescription)")143    }144  }145146  /// Reads pixel dimensions without decoding the full image. Returns nil when147  /// the file can't be read as an image, which is the signal to skip it.148  /// Nonisolated so scans run entirely off the main actor.149  nonisolated private static func imageResolution(url: URL) -> String? {150    guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),151      let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],152      let width = properties[kCGImagePropertyPixelWidth as String] as? NSNumber,153      let height = properties[kCGImagePropertyPixelHeight as String] as? NSNumber,154      width.intValue > 0, height.intValue > 0155    else {156      return nil157    }158    return "\(width.intValue)x\(height.intValue)"159  }160161  nonisolated static func wallpaper(at url: URL) -> LocalWallpaper? {162    guard163      let resourceValues = try? url.resourceValues(forKeys: [164        .fileSizeKey, .creationDateKey, .contentModificationDateKey,165      ]),166      let resolution = imageResolution(url: url)167    else { return nil }168    return LocalWallpaper(169      id: url.path, url: url, filename: url.lastPathComponent, resolution: resolution,170      fileSize: Int64(resourceValues.fileSize ?? 0),171      createdAt: resourceValues.creationDate ?? Date(),172      modifiedAt: resourceValues.contentModificationDate ?? Date())173  }174175  func deleteWallpaper(_ wallpaper: LocalWallpaper) {176    do {177      try FileManager.default.removeItem(at: wallpaper.url)178      wallpapers.removeAll { $0.id == wallpaper.id }179    } catch {180      errorMessage = "Failed to delete: \(error.localizedDescription)"181    }182  }183}184185// MARK: - Folder Monitor186187class FolderMonitor {188  private let folderPath: String189  private let callback: () -> Void190  private var fileDescriptor: Int32 = -1191  private var source: DispatchSourceFileSystemObject?192  private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility)193194  init(folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void) {195    self.folderPath = folderPath196    self.fileDescriptor = fileDescriptor197    self.callback = callback198  }199200  func start() {201    fileDescriptor = open(folderPath, O_EVTONLY)202    guard fileDescriptor >= 0 else { return }203204    source = DispatchSource.makeFileSystemObjectSource(205      fileDescriptor: fileDescriptor,206      eventMask: [.write, .rename, .delete, .link],207      queue: queue208    )209210    source?.setEventHandler { [weak self] in211      DispatchQueue.main.async {212        self?.callback()213      }214    }215216    source?.setCancelHandler { [weak self] in self?.closeFileDescriptor() }217218    source?.resume()219  }220221  func stop() {222    source?.cancel()223    source = nil224  }225226  func closeFileDescriptor() {227    guard fileDescriptor >= 0 else { return }228    close(fileDescriptor)229    fileDescriptor = -1230  }231232  deinit {233    stop()234  }235}