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    let scanTask = Task.detached(priority: .userInitiated) {81      LocalWallpapersManager.performScan(request: snapshot)82    }83    Task { @MainActor [weak self] in84      guard let self = self else { return }85      let result = await scanTask.value86      guard self.scanGeneration == snapshot.generation else { return }87      switch result {88      case .success(let wallpapers):89        self.wallpapers = wallpapers90        self.errorMessage = nil91      case .failure(let message):92        self.errorMessage = message93      }94      self.isLoading = false95    }96  }9798  /// Pure, Sendable value carried across the actor boundary into the99  /// detached scan task. Capturing everything the scan needs up front means100  /// the closure body never touches mutable manager state from a non-isolated101  /// context.102  private struct ScanRequest: Sendable {103    let path: String104    let generation: UInt64105    let imageExtensions: Set<String>106  }107108  private enum ScanOutcome: Sendable {109    case success([LocalWallpaper])110    case failure(String)111  }112113  /// Scans the folder and returns the wallpapers sorted newest-first. Runs114  /// entirely off the main actor; all inputs are passed via the Sendable115  /// `ScanRequest` snapshot.116  nonisolated private static func performScan(request: ScanRequest) -> ScanOutcome {117    let folderURL = URL(fileURLWithPath: request.path)118    let fileManager = FileManager.default119120    // Create the save folder on first use so a fresh install shows an121    // empty gallery instead of a scan error.122    try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)123124    do {125      let contents = try fileManager.contentsOfDirectory(126        at: folderURL,127        includingPropertiesForKeys: [128          .contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey,129        ],130        options: [.skipsHiddenFiles, .skipsPackageDescendants]131      )132133      var wallpapers: [LocalWallpaper] = []134      for url in contents {135        let ext = url.pathExtension.lowercased()136        guard request.imageExtensions.contains(ext) else { continue }137        if let wallpaper = wallpaper(at: url) { wallpapers.append(wallpaper) }138      }139140      wallpapers.sort { $0.modifiedAt > $1.modifiedAt }141      return .success(wallpapers)142    } catch {143      return .failure("Failed to scan folder: \(error.localizedDescription)")144    }145  }146147  /// Reads pixel dimensions without decoding the full image. Returns nil when148  /// the file can't be read as an image, which is the signal to skip it.149  /// Nonisolated so scans run entirely off the main actor.150  nonisolated private static func imageResolution(url: URL) -> String? {151    guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),152      let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],153      let width = properties[kCGImagePropertyPixelWidth as String] as? NSNumber,154      let height = properties[kCGImagePropertyPixelHeight as String] as? NSNumber,155      width.intValue > 0, height.intValue > 0156    else {157      return nil158    }159    return "\(width.intValue)x\(height.intValue)"160  }161162  nonisolated static func wallpaper(at url: URL) -> LocalWallpaper? {163    guard164      let resourceValues = try? url.resourceValues(forKeys: [165        .fileSizeKey, .creationDateKey, .contentModificationDateKey,166      ]),167      let resolution = imageResolution(url: url)168    else { return nil }169    return LocalWallpaper(170      id: url.path, url: url, filename: url.lastPathComponent, resolution: resolution,171      fileSize: Int64(resourceValues.fileSize ?? 0),172      createdAt: resourceValues.creationDate ?? Date(),173      modifiedAt: resourceValues.contentModificationDate ?? Date())174  }175176  func deleteWallpaper(_ wallpaper: LocalWallpaper) {177    do {178      try FileManager.default.removeItem(at: wallpaper.url)179      wallpapers.removeAll { $0.id == wallpaper.id }180    } catch {181      errorMessage = "Failed to delete: \(error.localizedDescription)"182    }183  }184}185186// MARK: - Folder Monitor187188class FolderMonitor {189  private let folderPath: String190  private let callback: () -> Void191  private var fileDescriptor: Int32 = -1192  private var source: DispatchSourceFileSystemObject?193  private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility)194195  init(folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void) {196    self.folderPath = folderPath197    self.fileDescriptor = fileDescriptor198    self.callback = callback199  }200201  func start() {202    fileDescriptor = open(folderPath, O_EVTONLY)203    guard fileDescriptor >= 0 else { return }204205    source = DispatchSource.makeFileSystemObjectSource(206      fileDescriptor: fileDescriptor,207      eventMask: [.write, .rename, .delete, .link],208      queue: queue209    )210211    source?.setEventHandler { [weak self] in212      DispatchQueue.main.async {213        self?.callback()214      }215    }216217    source?.setCancelHandler { [weak self] in self?.closeFileDescriptor() }218219    source?.resume()220  }221222  func stop() {223    source?.cancel()224    source = nil225  }226227  func closeFileDescriptor() {228    guard fileDescriptor >= 0 else { return }229    close(fileDescriptor)230    fileDescriptor = -1231  }232233  deinit {234    stop()235  }236}