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 43 44 45 46 47 func startMonitoring(folderPath: String) {48 folderMonitor?.stop()49 50 51 52 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 73 74 75 76 77 private var scanGeneration: UInt64 = 07879 func scanFolder(path: String) {80 scanGeneration &+= 181 let requestGeneration = scanGeneration82 isLoading = true83 84 85 86 87 8889 90 91 92 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 109 110 111 case .failure(let message):112 self.errorMessage = message113 }114 self.isLoading = false115 }116 }117118 119 120 121 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 134 135 136 nonisolated private static func performScan(request: ScanRequest) -> ScanOutcome {137 let folderURL = URL(fileURLWithPath: request.path)138 let fileManager = FileManager.default139140 141 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 168 169 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}205206207208class 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 217 218 219 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 235 236 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}