LocalWallpapersManager.swift

6.9 KB · raw

1import Foundation2import Combine3import CoreGraphics4import 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: Date14    15    var thumbnailURL: URL? {16        return url17    }18    19    static func == (lhs: LocalWallpaper, rhs: LocalWallpaper) -> Bool {20        return lhs.id == rhs.id21    }22}2324@MainActor25class LocalWallpapersManager: ObservableObject {26    static let shared = LocalWallpapersManager()27    28    @Published var wallpapers: [LocalWallpaper] = []29    @Published var isLoading: Bool = false30    @Published var errorMessage: String?31    32    private var folderMonitor: FolderMonitor?33    private let imageExtensions: Set<String> = ["jpg", "jpeg", "png", "webp", "bmp", "gif"]34    35    private init() {36        // Initial scan if a folder is configured37        if let folderPath = UserDefaults.standard.string(forKey: "saveFolderPath") {38            startMonitoring(folderPath: folderPath)39        }40    }41    42    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    }52    53    func stopMonitoring() {54        folderMonitor?.stop()55        folderMonitor = nil56    }57    58    func scanFolder(path: String) {59        guard !isLoading else { return }60        isLoading = true61        errorMessage = nil62        63        Task.detached(priority: .userInitiated) { [weak self] in64            guard let self = self else { return }65            66            let folderURL = URL(fileURLWithPath: path)67            let fileManager = FileManager.default6869            // Create the save folder on first use so a fresh install shows an70            // empty gallery instead of a scan error.71            try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)7273            do {74                let contents = try fileManager.contentsOfDirectory(75                    at: folderURL,76                    includingPropertiesForKeys: [.contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey],77                    options: [.skipsHiddenFiles, .skipsPackageDescendants]78                )79                80                var wallpapers: [LocalWallpaper] = []81                for url in contents {82                    let ext = url.pathExtension.lowercased()83                    guard self.imageExtensions.contains(ext) else { continue }84                    85                    do {86                        let resourceValues = try url.resourceValues(forKeys: [87                            .fileSizeKey,88                            .creationDateKey,89                            .contentModificationDateKey90                        ])9192                        // Skip anything we can't actually decode — a broken or93                        // unsupported file has no readable pixel dimensions, and94                        // the user would rather it never appear in the gallery.95                        guard let resolution = Self.imageResolution(url: url) else { continue }9697                        let filename = url.lastPathComponent98                        let fileSize = Int64(resourceValues.fileSize ?? 0)99                        let createdAt = resourceValues.creationDate ?? Date()100                        let modifiedAt = resourceValues.contentModificationDate ?? Date()101102                        wallpapers.append(LocalWallpaper(103                            id: url.path,104                            url: url,105                            filename: filename,106                            resolution: resolution,107                            fileSize: fileSize,108                            createdAt: createdAt,109                            modifiedAt: modifiedAt110                        ))111                    } catch {112                        continue113                    }114                }115                116                wallpapers.sort { $0.modifiedAt > $1.modifiedAt }117                118                let finalWallpapers = wallpapers119                await MainActor.run {120                    self.wallpapers = finalWallpapers121                    self.isLoading = false122                }123            } catch {124                await MainActor.run {125                    self.errorMessage = "Failed to scan folder: \(error.localizedDescription)"126                    self.isLoading = false127                }128            }129        }130    }131    132    /// Reads pixel dimensions without decoding the full image. Returns nil when133    /// the file can't be read as an image, which is the signal to skip it.134    /// Nonisolated so scans run entirely off the main actor.135    nonisolated private static func imageResolution(url: URL) -> String? {136        guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),137              let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],138              let width = properties[kCGImagePropertyPixelWidth as String] as? NSNumber,139              let height = properties[kCGImagePropertyPixelHeight as String] as? NSNumber,140              width.intValue > 0, height.intValue > 0 else {141            return nil142        }143        return "\(width.intValue)x\(height.intValue)"144    }145    146    func deleteWallpaper(_ wallpaper: LocalWallpaper) {147        do {148            try FileManager.default.removeItem(at: wallpaper.url)149            wallpapers.removeAll { $0.id == wallpaper.id }150        } catch {151            errorMessage = "Failed to delete: \(error.localizedDescription)"152        }153    }154}155156// MARK: - Folder Monitor157158private class FolderMonitor {159    private let folderPath: String160    private let callback: () -> Void161    private var fileDescriptor: Int32 = -1162    private var source: DispatchSourceFileSystemObject?163    private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility)164    165    init(folderPath: String, callback: @escaping () -> Void) {166        self.folderPath = folderPath167        self.callback = callback168    }169    170    func start() {171        fileDescriptor = open(folderPath, O_EVTONLY)172        guard fileDescriptor >= 0 else { return }173        174        source = DispatchSource.makeFileSystemObjectSource(175            fileDescriptor: fileDescriptor,176            eventMask: [.write, .rename, .delete, .link],177            queue: queue178        )179        180        source?.setEventHandler { [weak self] in181            DispatchQueue.main.async {182                self?.callback()183            }184        }185        186        source?.setCancelHandler { [weak self] in187            guard let self = self else { return }188            close(self.fileDescriptor)189        }190        191        source?.resume()192    }193    194    func stop() {195        source?.cancel()196        source = nil197    }198    199    deinit {200        stop()201    }202}