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 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 99 100 101 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 114 115 116 nonisolated private static func performScan(request: ScanRequest) -> ScanOutcome {117 let folderURL = URL(fileURLWithPath: request.path)118 let fileManager = FileManager.default119120 121 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 148 149 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}185186187188class 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}