LocalWallpapersManager.swift

9.4 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  /// Begin watching `folderPath` for filesystem changes. If monitoring could43  /// not be installed (missing folder, permission denied, descriptor open44  /// failure), surfaces the failure through `errorMessage` so the UI is not45  /// silently out of date. The first scan still runs so the user sees the46  /// current state even when live updates are unavailable.47  func startMonitoring(folderPath: String) {48    folderMonitor?.stop()49    // Reset prior state: the previous monitor (if any) is gone, the50    // previous scan result no longer applies. Both completion paths51    // (monitor failure here, scan failure inside scanFolder) repopulate52    // this field.53    errorMessage = nil54    let monitor = FolderMonitor(folderPath: folderPath) { [weak self] in55      Task { @MainActor in56        self?.scanFolder(path: folderPath)57      }58    }59    folderMonitor = monitor60    monitor.start()61    if let message = monitor.openFailureMessage {62      errorMessage = message63    }64    scanFolder(path: folderPath)65  }6667  func stopMonitoring() {68    folderMonitor?.stop()69    folderMonitor = nil70  }7172  /// Bumped on every `scanFolder` invocation. A scan only commits its73  /// results when it still matches this counter at completion, which means74  /// newer requests take priority over older in-flight scans and filesystem75  /// changes after the scan started are observed by the next request rather76  /// than silently dropped.77  private var scanGeneration: UInt64 = 07879  func scanFolder(path: String) {80    scanGeneration &+= 181    let requestGeneration = scanGeneration82    isLoading = true83    // Note: errorMessage is *not* cleared here so an unrelated failure84    // (e.g. FolderMonitor could not install its watch) survives the scan85    // attempt and stays visible to the UI. The success/failure cases below86    // own the message: success clears it, failure overwrites it with the87    // scan-specific cause.8889    // Snapshot all immutable configuration the detached task needs into a90    // Sendable value. This keeps `Task.detached` from capturing `self` (and91    // its non-Sendable mutable state) by reference, which the Swift 692    // concurrency checker would otherwise flag.93    let snapshot = ScanRequest(94      path: path,95      generation: requestGeneration,96      imageExtensions: imageExtensions)9798    let scanTask = Task.detached(priority: .userInitiated) {99      LocalWallpapersManager.performScan(request: snapshot)100    }101    Task { @MainActor [weak self] in102      guard let self = self else { return }103      let result = await scanTask.value104      guard self.scanGeneration == snapshot.generation else { return }105      switch result {106      case .success(let wallpapers):107        self.wallpapers = wallpapers108      // errorMessage intentionally not cleared: a previously surfaced109      // monitor open failure must stay visible even when the scan itself110      // completes successfully.111      case .failure(let message):112        self.errorMessage = message113      }114      self.isLoading = false115    }116  }117118  /// Pure, Sendable value carried across the actor boundary into the119  /// detached scan task. Capturing everything the scan needs up front means120  /// the closure body never touches mutable manager state from a non-isolated121  /// context.122  private struct ScanRequest: Sendable {123    let path: String124    let generation: UInt64125    let imageExtensions: Set<String>126  }127128  private enum ScanOutcome: Sendable {129    case success([LocalWallpaper])130    case failure(String)131  }132133  /// Scans the folder and returns the wallpapers sorted newest-first. Runs134  /// entirely off the main actor; all inputs are passed via the Sendable135  /// `ScanRequest` snapshot.136  nonisolated private static func performScan(request: ScanRequest) -> ScanOutcome {137    let folderURL = URL(fileURLWithPath: request.path)138    let fileManager = FileManager.default139140    // Create the save folder on first use so a fresh install shows an141    // empty gallery instead of a scan error.142    try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)143144    do {145      let contents = try fileManager.contentsOfDirectory(146        at: folderURL,147        includingPropertiesForKeys: [148          .contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey,149        ],150        options: [.skipsHiddenFiles, .skipsPackageDescendants]151      )152153      var wallpapers: [LocalWallpaper] = []154      for url in contents {155        let ext = url.pathExtension.lowercased()156        guard request.imageExtensions.contains(ext) else { continue }157        if let wallpaper = wallpaper(at: url) { wallpapers.append(wallpaper) }158      }159160      wallpapers.sort { $0.modifiedAt > $1.modifiedAt }161      return .success(wallpapers)162    } catch {163      return .failure("Failed to scan folder: \(error.localizedDescription)")164    }165  }166167  /// Reads pixel dimensions without decoding the full image. Returns nil when168  /// the file can't be read as an image, which is the signal to skip it.169  /// Nonisolated so scans run entirely off the main actor.170  nonisolated private static func imageResolution(url: URL) -> String? {171    guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),172      let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],173      let width = properties[kCGImagePropertyPixelWidth as String] as? NSNumber,174      let height = properties[kCGImagePropertyPixelHeight as String] as? NSNumber,175      width.intValue > 0, height.intValue > 0176    else {177      return nil178    }179    return "\(width.intValue)x\(height.intValue)"180  }181182  nonisolated static func wallpaper(at url: URL) -> LocalWallpaper? {183    guard184      let resourceValues = try? url.resourceValues(forKeys: [185        .fileSizeKey, .creationDateKey, .contentModificationDateKey,186      ]),187      let resolution = imageResolution(url: url)188    else { return nil }189    return LocalWallpaper(190      id: url.path, url: url, filename: url.lastPathComponent, resolution: resolution,191      fileSize: Int64(resourceValues.fileSize ?? 0),192      createdAt: resourceValues.creationDate ?? Date(),193      modifiedAt: resourceValues.contentModificationDate ?? Date())194  }195196  func deleteWallpaper(_ wallpaper: LocalWallpaper) {197    do {198      try FileManager.default.removeItem(at: wallpaper.url)199      wallpapers.removeAll { $0.id == wallpaper.id }200    } catch {201      errorMessage = "Failed to delete: \(error.localizedDescription)"202    }203  }204}205206// MARK: - Folder Monitor207208class FolderMonitor {209  private let folderPath: String210  private let callback: () -> Void211  private var fileDescriptor: Int32 = -1212  private var source: DispatchSourceFileSystemObject?213  private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility)214  private let openPath: (String, Int32) -> Int32215216  /// Human-readable description of why `start()` could not install a live217  /// monitor; `nil` when monitoring is healthy (or has not yet been218  /// attempted). Surfaced through the manager's `errorMessage` so the UI219  /// shows the cause instead of silently dropping the watch.220  private(set) var openFailureMessage: String?221222  init(223    folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void,224    openPath: @escaping (String, Int32) -> Int32 = { path, flags in225      Darwin.open(path, flags)226    }227  ) {228    self.folderPath = folderPath229    self.fileDescriptor = fileDescriptor230    self.callback = callback231    self.openPath = openPath232  }233234  /// Returns `true` when the monitor is now actively watching the folder.235  /// `false` means `open()` rejected the path; callers should surface the236  /// stored `openFailureMessage` instead of pretending monitoring succeeded.237  @discardableResult238  func start() -> Bool {239    fileDescriptor = openPath(folderPath, O_EVTONLY)240    guard fileDescriptor >= 0 else {241      openFailureMessage =242        "Could not watch \(folderPath): \(String(cString: strerror(errno)))"243      return false244    }245    openFailureMessage = nil246247    source = DispatchSource.makeFileSystemObjectSource(248      fileDescriptor: fileDescriptor,249      eventMask: [.write, .rename, .delete, .link],250      queue: queue251    )252253    source?.setEventHandler { [weak self] in254      DispatchQueue.main.async {255        self?.callback()256      }257    }258259    source?.setCancelHandler { [weak self] in self?.closeFileDescriptor() }260261    source?.resume()262    return true263  }264265  func stop() {266    source?.cancel()267    source = nil268  }269270  func closeFileDescriptor() {271    guard fileDescriptor >= 0 else { return }272    close(fileDescriptor)273    fileDescriptor = -1274  }275276  deinit {277    stop()278  }279}