import Foundation import Combine import AppKit enum AppTab { case explore case local case favorites case history case settings } /// Browse mode for the Explore tab. Each maps to a Wallhaven `sorting` /// value; the free-text search box applies on top of the chosen mode. enum FeedMode: String, CaseIterable, Identifiable { case latest case toplist case random var id: String { rawValue } var sorting: WallhavenSorting { switch self { case .latest: return .dateAdded case .toplist: return .toplist case .random: return .random } } var displayName: String { switch self { case .latest: return "Latest" case .toplist: return "Toplist" case .random: return "Random" } } var systemImage: String { switch self { case .latest: return "clock" case .toplist: return "diamond" case .random: return "arrow.triangle.2.circlepath" } } } @MainActor class AppState: ObservableObject { // Navigation @Published var activeTab: AppTab = .explore // Data Sources @Published var wallpapers: [Wallpaper] = [] @Published var favoritesManager = FavoritesManager.shared @Published var historyManager = HistoryManager.shared // Search & Filter @Published var feedMode: FeedMode = .latest @Published var searchText: String = "" @Published var selectedResolution: String = "" @Published var selectedRatio: String = "" @Published var isLoading: Bool = false @Published var errorMessage: String? // Pagination @Published var canLoadMore = true private var currentPage = 1 private var isFetchingMore = false // Settings @Published var saveToFolderEnabled: Bool { didSet { UserDefaults.standard.set(saveToFolderEnabled, forKey: "saveToFolderEnabled") } } @Published var saveFolderPath: String { didSet { UserDefaults.standard.set(saveFolderPath, forKey: "saveFolderPath") } } @Published var launchAtLogin: Bool = AutostartManager.shared.isEnabled { didSet { AutostartManager.shared.isEnabled = launchAtLogin } } // Filters @Published var categories: (general: Bool, anime: Bool, people: Bool) = (true, true, true) @Published var purity: (sfw: Bool, sketchy: Bool) = (true, false) let availableResolutions = ["Any", "1920x1080", "2560x1440", "3840x2160", "5120x2880"] let availableRatios = ["Any", "16x9", "16x10", "21x9", "32x9", "4x3"] private let api = WallhavenAPI() private var cancellables = Set() init() { // Load settings self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled") if let path = UserDefaults.standard.string(forKey: "saveFolderPath") { self.saveFolderPath = path } else { let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first! self.saveFolderPath = pictures.appendingPathComponent("Haven").path } // Debounce search $searchText .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) .sink { [weak self] _ in Task { [weak self] in await self?.fetchWallpapers() } } .store(in: &cancellables) // Observe filters $categories .map { _ in () } .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) .sink { [weak self] _ in Task { await self?.fetchWallpapers() } } .store(in: &cancellables) $purity .map { _ in () } .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) .sink { [weak self] _ in Task { await self?.fetchWallpapers() } } .store(in: &cancellables) $feedMode .dropFirst() .sink { [weak self] _ in Task { [weak self] in await self?.fetchWallpapers(reset: true) } } .store(in: &cancellables) updateLocalWallpapersMonitoring() $saveToFolderEnabled .dropFirst() .sink { [weak self] _ in self?.updateLocalWallpapersMonitoring() } .store(in: &cancellables) $saveFolderPath .dropFirst() .sink { [weak self] _ in self?.updateLocalWallpapersMonitoring() } .store(in: &cancellables) } private func updateLocalWallpapersMonitoring() { if saveToFolderEnabled { LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath) } else { LocalWallpapersManager.shared.stopMonitoring() } } func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async { if reset { if !refreshing { isLoading = true wallpapers = [] } errorMessage = nil currentPage = 1 canLoadMore = true } else { guard !isFetchingMore && canLoadMore else { return } isFetchingMore = true } do { let resolutions = selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution] let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio] // Construct bitstrings let catStr = "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")" // NSFW is intentionally unsupported; the third purity bit is always 0. let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0" let (newWallpapers, meta) = try await api.searchWallpapers( query: searchText, categories: catStr, purity: purityStr, sorting: feedMode.sorting, resolutions: resolutions, ratios: ratios, limit: 24, page: currentPage ) if reset { self.wallpapers = newWallpapers } else { self.wallpapers.append(contentsOf: newWallpapers) } if let meta = meta { self.canLoadMore = meta.current_page < meta.last_page self.currentPage = meta.current_page + 1 } else { self.canLoadMore = !newWallpapers.isEmpty if !newWallpapers.isEmpty { self.currentPage += 1 } } } catch { if reset { self.errorMessage = error.localizedDescription } } if reset { isLoading = false } else { isFetchingMore = false } } func loadMore() async { await fetchWallpapers(reset: false) } func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async { do { let localURL = try await WallpaperManager.shared.download(from: URL(string: wallpaper.path)!) try await MainActor.run { try WallpaperManager.shared.setDesktopImage(from: localURL, screen: screen) } // Add to history historyManager.addToHistory(wallpaper) if saveToFolderEnabled { let destFolder = URL(fileURLWithPath: saveFolderPath) try WallpaperManager.shared.saveToFolder(from: localURL, to: destFolder) } } catch { self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)" } } func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async { do { try await MainActor.run { try WallpaperManager.shared.setDesktopImage(from: wallpaper.url, screen: screen) } let historyWallpaper = Wallpaper( id: wallpaper.id, url: wallpaper.url.path, path: wallpaper.url.path, resolution: wallpaper.resolution, purity: "local", file_size: Int(wallpaper.fileSize), thumbs: Wallpaper.Thumbs( large: wallpaper.url.path, original: wallpaper.url.path, small: wallpaper.url.path ) ) historyManager.addToHistory(historyWallpaper) } catch { self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)" } } func downloadWallpaper(_ wallpaper: Wallpaper) async { do { let localURL = try await WallpaperManager.shared.download(from: URL(string: wallpaper.path)!) // Always save to folder if download requested, or ensure save folder is used let destFolder = URL(fileURLWithPath: saveFolderPath) try WallpaperManager.shared.saveToFolder(from: localURL, to: destFolder) } catch { self.errorMessage = "Failed to download: \(error.localizedDescription)" } } func selectSaveFolder() { // Hold the transient popover open; without this the picker steals focus // and the popover closes before a folder can be chosen. MenuController.shared?.beginModalPresentation() NSApp.activate(ignoringOtherApps: true) let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.canCreateDirectories = true panel.allowsMultipleSelection = false panel.directoryURL = URL(fileURLWithPath: saveFolderPath) panel.prompt = "Choose" panel.begin { response in if response == .OK, let url = panel.url { self.saveFolderPath = url.path } MenuController.shared?.endModalPresentation() } } func quit() { NSApp.terminate(nil) } }