import Combine import CoreGraphics import Foundation import ImageIO struct LocalWallpaper: Identifiable, Equatable { let id: String let url: URL let filename: String let resolution: String let fileSize: Int64 let createdAt: Date let modifiedAt: Date var thumbnailURL: URL? { return url } static func == (lhs: LocalWallpaper, rhs: LocalWallpaper) -> Bool { return lhs.id == rhs.id } } @MainActor class LocalWallpapersManager: ObservableObject { static let shared = LocalWallpapersManager() @Published var wallpapers: [LocalWallpaper] = [] @Published var isLoading: Bool = false @Published var errorMessage: String? private var folderMonitor: FolderMonitor? private let imageExtensions: Set = ["jpg", "jpeg", "png", "webp", "bmp", "gif"] init() { // Initial scan if a folder is configured if let folderPath = UserDefaults.standard.string(forKey: "saveFolderPath") { startMonitoring(folderPath: folderPath) } } /// Begin watching `folderPath` for filesystem changes. If monitoring could /// not be installed (missing folder, permission denied, descriptor open /// failure), surfaces the failure through `errorMessage` so the UI is not /// silently out of date. The first scan still runs so the user sees the /// current state even when live updates are unavailable. func startMonitoring(folderPath: String) { folderMonitor?.stop() // Reset prior state: the previous monitor (if any) is gone, the // previous scan result no longer applies. Both completion paths // (monitor failure here, scan failure inside scanFolder) repopulate // this field. errorMessage = nil let monitor = FolderMonitor(folderPath: folderPath) { [weak self] in Task { @MainActor in self?.scanFolder(path: folderPath) } } folderMonitor = monitor monitor.start() if let message = monitor.openFailureMessage { errorMessage = message } scanFolder(path: folderPath) } func stopMonitoring() { folderMonitor?.stop() folderMonitor = nil } /// Bumped on every `scanFolder` invocation. A scan only commits its /// results when it still matches this counter at completion, which means /// newer requests take priority over older in-flight scans and filesystem /// changes after the scan started are observed by the next request rather /// than silently dropped. private var scanGeneration: UInt64 = 0 func scanFolder(path: String) { scanGeneration &+= 1 let requestGeneration = scanGeneration isLoading = true // Note: errorMessage is *not* cleared here so an unrelated failure // (e.g. FolderMonitor could not install its watch) survives the scan // attempt and stays visible to the UI. The success/failure cases below // own the message: success clears it, failure overwrites it with the // scan-specific cause. // Snapshot all immutable configuration the detached task needs into a // Sendable value. This keeps `Task.detached` from capturing `self` (and // its non-Sendable mutable state) by reference, which the Swift 6 // concurrency checker would otherwise flag. let snapshot = ScanRequest( path: path, generation: requestGeneration, imageExtensions: imageExtensions) let scanTask = Task.detached(priority: .userInitiated) { LocalWallpapersManager.performScan(request: snapshot) } Task { @MainActor [weak self] in guard let self = self else { return } let result = await scanTask.value guard self.scanGeneration == snapshot.generation else { return } switch result { case .success(let wallpapers): self.wallpapers = wallpapers // errorMessage intentionally not cleared: a previously surfaced // monitor open failure must stay visible even when the scan itself // completes successfully. case .failure(let message): self.errorMessage = message } self.isLoading = false } } /// Pure, Sendable value carried across the actor boundary into the /// detached scan task. Capturing everything the scan needs up front means /// the closure body never touches mutable manager state from a non-isolated /// context. private struct ScanRequest: Sendable { let path: String let generation: UInt64 let imageExtensions: Set } private enum ScanOutcome: Sendable { case success([LocalWallpaper]) case failure(String) } /// Scans the folder and returns the wallpapers sorted newest-first. Runs /// entirely off the main actor; all inputs are passed via the Sendable /// `ScanRequest` snapshot. nonisolated private static func performScan(request: ScanRequest) -> ScanOutcome { let folderURL = URL(fileURLWithPath: request.path) let fileManager = FileManager.default // Create the save folder on first use so a fresh install shows an // empty gallery instead of a scan error. try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true) do { let contents = try fileManager.contentsOfDirectory( at: folderURL, includingPropertiesForKeys: [ .contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey, ], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) var wallpapers: [LocalWallpaper] = [] for url in contents { let ext = url.pathExtension.lowercased() guard request.imageExtensions.contains(ext) else { continue } if let wallpaper = wallpaper(at: url) { wallpapers.append(wallpaper) } } wallpapers.sort { $0.modifiedAt > $1.modifiedAt } return .success(wallpapers) } catch { return .failure("Failed to scan folder: \(error.localizedDescription)") } } /// Reads pixel dimensions without decoding the full image. Returns nil when /// the file can't be read as an image, which is the signal to skip it. /// Nonisolated so scans run entirely off the main actor. nonisolated private static func imageResolution(url: URL) -> String? { guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil), let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any], let width = properties[kCGImagePropertyPixelWidth as String] as? NSNumber, let height = properties[kCGImagePropertyPixelHeight as String] as? NSNumber, width.intValue > 0, height.intValue > 0 else { return nil } return "\(width.intValue)x\(height.intValue)" } nonisolated static func wallpaper(at url: URL) -> LocalWallpaper? { guard let resourceValues = try? url.resourceValues(forKeys: [ .fileSizeKey, .creationDateKey, .contentModificationDateKey, ]), let resolution = imageResolution(url: url) else { return nil } return LocalWallpaper( id: url.path, url: url, filename: url.lastPathComponent, resolution: resolution, fileSize: Int64(resourceValues.fileSize ?? 0), createdAt: resourceValues.creationDate ?? Date(), modifiedAt: resourceValues.contentModificationDate ?? Date()) } func deleteWallpaper(_ wallpaper: LocalWallpaper) { do { try FileManager.default.removeItem(at: wallpaper.url) wallpapers.removeAll { $0.id == wallpaper.id } } catch { errorMessage = "Failed to delete: \(error.localizedDescription)" } } } // MARK: - Folder Monitor class FolderMonitor { private let folderPath: String private let callback: () -> Void private var fileDescriptor: Int32 = -1 private var source: DispatchSourceFileSystemObject? private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility) private let openPath: (String, Int32) -> Int32 /// Human-readable description of why `start()` could not install a live /// monitor; `nil` when monitoring is healthy (or has not yet been /// attempted). Surfaced through the manager's `errorMessage` so the UI /// shows the cause instead of silently dropping the watch. private(set) var openFailureMessage: String? init( folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void, openPath: @escaping (String, Int32) -> Int32 = { path, flags in Darwin.open(path, flags) } ) { self.folderPath = folderPath self.fileDescriptor = fileDescriptor self.callback = callback self.openPath = openPath } /// Returns `true` when the monitor is now actively watching the folder. /// `false` means `open()` rejected the path; callers should surface the /// stored `openFailureMessage` instead of pretending monitoring succeeded. @discardableResult func start() -> Bool { fileDescriptor = openPath(folderPath, O_EVTONLY) guard fileDescriptor >= 0 else { openFailureMessage = "Could not watch \(folderPath): \(String(cString: strerror(errno)))" return false } openFailureMessage = nil source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fileDescriptor, eventMask: [.write, .rename, .delete, .link], queue: queue ) source?.setEventHandler { [weak self] in DispatchQueue.main.async { self?.callback() } } source?.setCancelHandler { [weak self] in self?.closeFileDescriptor() } source?.resume() return true } func stop() { source?.cancel() source = nil } func closeFileDescriptor() { guard fileDescriptor >= 0 else { return } close(fileDescriptor) fileDescriptor = -1 } deinit { stop() } }