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) } } func startMonitoring(folderPath: String) { folderMonitor?.stop() folderMonitor = FolderMonitor(folderPath: folderPath) { [weak self] in Task { @MainActor in self?.scanFolder(path: folderPath) } } folderMonitor?.start() scanFolder(path: folderPath) } func stopMonitoring() { folderMonitor?.stop() folderMonitor = nil } func scanFolder(path: String) { guard !isLoading else { return } isLoading = true errorMessage = nil Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } let folderURL = URL(fileURLWithPath: 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 self.imageExtensions.contains(ext) else { continue } if let wallpaper = Self.wallpaper(at: url) { wallpapers.append(wallpaper) } } wallpapers.sort { $0.modifiedAt > $1.modifiedAt } let finalWallpapers = wallpapers await MainActor.run { self.wallpapers = finalWallpapers self.isLoading = false } } catch { await MainActor.run { self.errorMessage = "Failed to scan folder: \(error.localizedDescription)" self.isLoading = false } } } } /// 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) init(folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void) { self.folderPath = folderPath self.fileDescriptor = fileDescriptor self.callback = callback } func start() { fileDescriptor = open(folderPath, O_EVTONLY) guard fileDescriptor >= 0 else { return } 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() } func stop() { source?.cancel() source = nil } func closeFileDescriptor() { guard fileDescriptor >= 0 else { return } close(fileDescriptor) fileDescriptor = -1 } deinit { stop() } }