import Foundation import Combine import CoreGraphics 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"] private 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 } do { let resourceValues = try url.resourceValues(forKeys: [ .fileSizeKey, .creationDateKey, .contentModificationDateKey ]) // Skip anything we can't actually decode — a broken or // unsupported file has no readable pixel dimensions, and // the user would rather it never appear in the gallery. guard let resolution = Self.imageResolution(url: url) else { continue } let filename = url.lastPathComponent let fileSize = Int64(resourceValues.fileSize ?? 0) let createdAt = resourceValues.creationDate ?? Date() let modifiedAt = resourceValues.contentModificationDate ?? Date() wallpapers.append(LocalWallpaper( id: url.path, url: url, filename: filename, resolution: resolution, fileSize: fileSize, createdAt: createdAt, modifiedAt: modifiedAt )) } catch { continue } } 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)" } 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 private 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, callback: @escaping () -> Void) { self.folderPath = folderPath 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 guard let self = self else { return } close(self.fileDescriptor) } source?.resume() } func stop() { source?.cancel() source = nil } deinit { stop() } }