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 37 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 59 60 61 62 63 private var scanGeneration: UInt64 = 06465 func scanFolder(path: String) {66 scanGeneration &+= 167 let requestGeneration = scanGeneration68 isLoading = true69 errorMessage = nil7071 72 73 74 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 98 99 100 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 113 114 115 nonisolated private static func performScan(request: ScanRequest) -> ScanOutcome {116 let folderURL = URL(fileURLWithPath: request.path)117 let fileManager = FileManager.default118119 120 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 147 148 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}184185186187class 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}