LocalWallpapersManager.swift

5.6 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  func scanFolder(path: String) {59    guard !isLoading else { return }60    isLoading = true61    errorMessage = nil6263    Task.detached(priority: .userInitiated) { [weak self] in64      guard let self = self else { return }6566      let folderURL = URL(fileURLWithPath: path)67      let fileManager = FileManager.default6869      // Create the save folder on first use so a fresh install shows an70      // empty gallery instead of a scan error.71      try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)7273      do {74        let contents = try fileManager.contentsOfDirectory(75          at: folderURL,76          includingPropertiesForKeys: [77            .contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey,78          ],79          options: [.skipsHiddenFiles, .skipsPackageDescendants]80        )8182        var wallpapers: [LocalWallpaper] = []83        for url in contents {84          let ext = url.pathExtension.lowercased()85          guard self.imageExtensions.contains(ext) else { continue }86          if let wallpaper = Self.wallpaper(at: url) { wallpapers.append(wallpaper) }87        }8889        wallpapers.sort { $0.modifiedAt > $1.modifiedAt }9091        let finalWallpapers = wallpapers92        await MainActor.run {93          self.wallpapers = finalWallpapers94          self.isLoading = false95        }96      } catch {97        await MainActor.run {98          self.errorMessage = "Failed to scan folder: \(error.localizedDescription)"99          self.isLoading = false100        }101      }102    }103  }104105  /// Reads pixel dimensions without decoding the full image. Returns nil when106  /// the file can't be read as an image, which is the signal to skip it.107  /// Nonisolated so scans run entirely off the main actor.108  nonisolated private static func imageResolution(url: URL) -> String? {109    guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),110      let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],111      let width = properties[kCGImagePropertyPixelWidth as String] as? NSNumber,112      let height = properties[kCGImagePropertyPixelHeight as String] as? NSNumber,113      width.intValue > 0, height.intValue > 0114    else {115      return nil116    }117    return "\(width.intValue)x\(height.intValue)"118  }119120  nonisolated static func wallpaper(at url: URL) -> LocalWallpaper? {121    guard122      let resourceValues = try? url.resourceValues(forKeys: [123        .fileSizeKey, .creationDateKey, .contentModificationDateKey,124      ]),125      let resolution = imageResolution(url: url)126    else { return nil }127    return LocalWallpaper(128      id: url.path, url: url, filename: url.lastPathComponent, resolution: resolution,129      fileSize: Int64(resourceValues.fileSize ?? 0),130      createdAt: resourceValues.creationDate ?? Date(),131      modifiedAt: resourceValues.contentModificationDate ?? Date())132  }133134  func deleteWallpaper(_ wallpaper: LocalWallpaper) {135    do {136      try FileManager.default.removeItem(at: wallpaper.url)137      wallpapers.removeAll { $0.id == wallpaper.id }138    } catch {139      errorMessage = "Failed to delete: \(error.localizedDescription)"140    }141  }142}143144// MARK: - Folder Monitor145146class FolderMonitor {147  private let folderPath: String148  private let callback: () -> Void149  private var fileDescriptor: Int32 = -1150  private var source: DispatchSourceFileSystemObject?151  private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility)152153  init(folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void) {154    self.folderPath = folderPath155    self.fileDescriptor = fileDescriptor156    self.callback = callback157  }158159  func start() {160    fileDescriptor = open(folderPath, O_EVTONLY)161    guard fileDescriptor >= 0 else { return }162163    source = DispatchSource.makeFileSystemObjectSource(164      fileDescriptor: fileDescriptor,165      eventMask: [.write, .rename, .delete, .link],166      queue: queue167    )168169    source?.setEventHandler { [weak self] in170      DispatchQueue.main.async {171        self?.callback()172      }173    }174175    source?.setCancelHandler { [weak self] in self?.closeFileDescriptor() }176177    source?.resume()178  }179180  func stop() {181    source?.cancel()182    source = nil183  }184185  func closeFileDescriptor() {186    guard fileDescriptor >= 0 else { return }187    close(fileDescriptor)188    fileDescriptor = -1189  }190191  deinit {192    stop()193  }194}